Metadata-Version: 2.4
Name: pscan-pythoncan
Version: 0.1.9
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Requires-Dist: python-can>=4.0.0
Summary: Native Python-CAN hardware backend for PSCAN USB devices
Author-email: VeVeeS <vevees@probesync.com>
License: Proprietary
Requires-Python: >=3.8
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# pscan-pythoncan

A native [python-can](https://python-can.readthedocs.io/) hardware interface for **PSCAN USB** CAN adapters.

`pscan-pythoncan` extends `python-can` by registering a new `pscan` interface backend. Once installed, you can use the standard `can.Bus()` API to send and receive CAN frames through your PSCAN hardware — no extra drivers or DLLs required. The heavy lifting (USB I/O, frame parsing, multi-frame buffering, and message filtering) is handled by a compiled native extension for maximum performance.

## Key Features

- **Drop-in python-can backend** — works with `can.Bus()`, `can.Notifier`, `can.Logger`, and all standard python-can tools.
- **Serial number based device selection** — connect to a specific adapter by its unique serial number instead of an ambiguous channel or port name.
- **User-friendly name support** — open devices by a short name stored in the adapter's EEPROM.
- **Auto-detection** — automatically finds and connects to the first available PSCAN adapter.
- **High-performance core** — frame serialization, deserialization, multi-frame USB bulk reads, and software filtering all run natively, not in pure Python.
- **Listen-only and loopback modes** — built-in CAN controller mode configuration.
- **Bus state monitoring** — read error counters and CAN controller state (Error Active / Warning / Passive / Bus-Off).

---

## Installation

```bash
pip install pscan-pythoncan
```

`python-can >= 4.0.0` is installed automatically as a dependency.

**Supported platforms:** Windows (x86, x64, aarch64). Python 3.8 and above.

---

## Quick Start

```python
import can

# Open the first available PSCAN device at 500 kbit/s
bus = can.Bus(interface='pscan', bitrate=500000)

# Send a CAN frame
msg = can.Message(arbitration_id=0x123, data=[0x11, 0x22, 0x33, 0x44], is_extended_id=False)
bus.send(msg)

# Receive a CAN frame (1 second timeout)
recv_msg = bus.recv(timeout=1.0)
if recv_msg is not None:
    print(recv_msg)

# Always shut down when done
bus.shutdown()
```

---

## Opening a Device

PSCAN adapters are identified by their **serial number** (printed on the device label) or by an optional **user-friendly name** stored in EEPROM. This avoids the ambiguity of generic channel numbers or COM ports — you always connect to exactly the hardware you intend.

### By serial number (recommended)

```python
bus = can.Bus(interface='pscan', serial='P1P.IN:XFG:H001', bitrate=500000)
```

### By user-friendly name

If your adapter has been programmed with a short name (e.g. via PSCANStudio), you can open it by that name:

```python
bus = can.Bus(interface='pscan', name='MyBMS', bitrate=500000)
```

### Auto-detect (single device)

When only one PSCAN adapter is connected you can omit `serial` and `name`:

```python
bus = can.Bus(interface='pscan', bitrate=500000)
```

---

## Constructor Parameters

All parameters are passed through the standard `can.Bus()` constructor:

```python
bus = can.Bus(
    interface='pscan',
    channel='PSCAN_USB1',       # Channel label (for python-can compatibility)
    serial='P1P.IN:XFG:H001',  # Device serial number
    name='MyBMS',               # Device user-friendly name (alternative to serial)
    bitrate=500000,             # Bitrate in bits per second
    sample_point=875,           # Sample point in permille (875 = 87.5%)
    listen_only=False,          # Enable listen-only mode (no TX, no ACK)
    loopback=False,             # Enable loopback mode (TX echoed to RX)
    can_filters=None,           # Message filters (see Filtering section)
)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `serial` | `str` | `None` | Serial number of the PSCAN adapter. Used to select a specific device when multiple adapters are connected. |
| `name` | `str` | `None` | User-friendly name stored in the adapter's EEPROM (takes priority over `serial`). |
| `bitrate` | `int` | `500000` | CAN bus bitrate in bits per second. Common values: `125000`, `250000`, `500000`, `1000000`. |
| `sample_point` | `int` | `875` | CAN sample point in permille. `875` means 87.5%. Typical range: `750`–`875`. |
| `listen_only` | `bool` | `False` | When `True`, the adapter will not transmit any frames and will not send ACK bits on the bus. Useful for passive monitoring. |
| `loopback` | `bool` | `False` | When `True`, transmitted frames are echoed back to the receive path. Useful for testing without a second node. |
| `led_mode` | `int` | `1` | Controls the hardware LED visual behavior natively. `0=Off`, `1=On (Default)`, `2=ActiveOnly`. |
| `channel` | `str` | `'PSCAN_USB1'` | Channel identifier string for python-can compatibility. Not used for device selection. |
| `can_filters` | `list` | `None` | List of filter dictionaries. See [Message Filtering](#message-filtering) below. |

Device selection priority: `name` > `serial` > auto-detect first device.

---

## Sending Messages

Use the standard `bus.send()` method:

```python
# Standard CAN frame (11-bit ID)
msg = can.Message(arbitration_id=0x123, data=[0x01, 0x02, 0x03], is_extended_id=False)
bus.send(msg)

# Extended CAN frame (29-bit ID)
msg = can.Message(arbitration_id=0x1ABCDEF0, data=[0xAA, 0xBB], is_extended_id=True)
bus.send(msg)

# Remote Transmit Request (RTR)
msg = can.Message(arbitration_id=0x200, is_remote_frame=True, dlc=8, is_extended_id=False)
bus.send(msg)

# With a custom timeout (seconds)
bus.send(msg, timeout=0.1)  # 100 ms timeout
```

**Notes:**
- Default send timeout is 50 ms when no `timeout` argument is provided.
- Sending in listen-only mode raises `can.CanOperationError`.
- CAN FD frames are not supported and will raise `NotImplementedError`.

---

## Receiving Messages

Use the standard `bus.recv()` method:

```python
# Blocking receive with timeout
msg = bus.recv(timeout=1.0)  # Wait up to 1 second

# Non-blocking receive
msg = bus.recv(timeout=0)

# Blocking forever (use with caution)
msg = bus.recv()  # Blocks until a frame arrives
```

The returned `can.Message` object contains:

| Field | Description |
|---|---|
| `timestamp` | Hardware timestamp in seconds (microsecond resolution from the adapter) |
| `arbitration_id` | CAN ID (11-bit or 29-bit) |
| `is_extended_id` | `True` if 29-bit extended frame |
| `is_remote_frame` | `True` if RTR frame |
| `is_error_frame` | `True` if error frame |
| `dlc` | Data Length Code (0–8) |
| `data` | Frame payload as `bytearray` |
| `channel` | Channel info string (e.g. `"PSCAN: P1P.IN:XFG:H001"`) |

### Event-Driven Receive with Notifier

For background message processing, use `can.Notifier`:

```python
class MyListener(can.Listener):
    def on_message_received(self, msg):
        print(f"RX: 0x{msg.arbitration_id:03X}  [{msg.dlc}]  {msg.data.hex()}")

listener = MyListener()
notifier = can.Notifier(bus, [listener])

# Main thread is free to do other work...
import time
time.sleep(10)

notifier.stop()
bus.shutdown()
```

---

## Message Filtering

Filters are applied in the compiled backend for high performance — frames that don't match are discarded before they ever reach Python.

```python
# Accept only CAN ID 0x123 (standard frames)
bus.set_filters([{"can_id": 0x123, "can_mask": 0x7FF, "extended": False}])

# Accept IDs 0x200–0x2FF (standard frames)
bus.set_filters([{"can_id": 0x200, "can_mask": 0x700, "extended": False}])

# Accept extended frames with ID 0x1ABCDE00–0x1ABCDEFF
bus.set_filters([{"can_id": 0x1ABCDE00, "can_mask": 0x1FFFFF00, "extended": True}])

# Multiple filters (frame passes if it matches ANY filter)
bus.set_filters([
    {"can_id": 0x100, "can_mask": 0x7FF, "extended": False},
    {"can_id": 0x200, "can_mask": 0x7FF, "extended": False},
])

# Clear all filters (accept everything)
bus.set_filters(None)
```

| Filter Key | Type | Description |
|---|---|---|
| `can_id` | `int` | CAN ID to match |
| `can_mask` | `int` | Bitmask. A frame passes if `(frame_id & can_mask) == (can_id & can_mask)`. |
| `extended` | `bool` or omitted | `True` = match only extended (29-bit) frames. `False` = match only standard (11-bit) frames. Omit to match both. |

You can also set filters when constructing the bus:

```python
bus = can.Bus(
    interface='pscan',
    bitrate=500000,
    can_filters=[{"can_id": 0x123, "can_mask": 0x7FF}]
)
```

---

## Bus State & Error Monitoring

### Reading the bus state

```python
state = bus.state  # Returns can.BusState enum

if state == can.BusState.ACTIVE:
    print("Bus is Error Active (normal operation)")
elif state == can.BusState.PASSIVE:
    print("Bus is in Error Warning or Error Passive state")
elif state == can.BusState.ERROR:
    print("Bus-Off or Stopped")
```

### Setting the bus state

```python
bus.state = can.BusState.ACTIVE   # Go bus-on
bus.state = can.BusState.ERROR    # Go bus-off
bus.state = can.BusState.PASSIVE  # Switch to listen-only mode
```

### Flushing the transmit buffer

Discard all pending outgoing messages:

```python
bus.flush_tx_buffer()
```

---

## Shutdown

Always shut down the bus when you are done to release the USB device:

```python
bus.shutdown()
```

The bus can also be used as a context manager:

```python
with can.Bus(interface='pscan', bitrate=500000) as bus:
    bus.send(can.Message(arbitration_id=0x123, data=[1, 2, 3]))
    msg = bus.recv(timeout=1.0)
# Bus is automatically shut down here
```

---

## PSCAN-Specific Functions

The following methods are PSCAN-specific extensions that go beyond the standard `python-can` API.

### Listing connected devices

Enumerate all connected PSCAN adapters and their serial numbers **before** opening a bus:

```python
from pscan_pythoncan import PscanBus

serials = PscanBus.list_pscan_devices()
print(f"Connected PSCAN devices: {serials}")
# Example output: ['P1P.IN:XFG:H001', 'P2P.IN:ABC:D002']
```

This is also available through the standard python-can device discovery:

```python
configs = can.detect_available_configs(interfaces=['pscan'])
print(configs)
# [{'interface': 'pscan', 'channel': 'PSCAN_USB1', 'serial': 'P1P.IN:XFG:H001'}, ...]
```

### Querying device information

Read the hardware version, firmware version, and number of CAN channels from an open adapter:

```python
bus = can.Bus(interface='pscan', bitrate=500000)

hw_version, fw_version, channel_count = bus.get_device_info()
print(f"Hardware : {hw_version}")
print(f"Firmware : {fw_version}")
print(f"Channels : {channel_count}")
```

### Accessing Native Properties Directly

`pscan-pythoncan` exposes advanced parameters natively out of the classic CAN hardware over Python properties. Note that `bus.name` and integers update the physical EEPROM arrays seamlessly!

```python
# Read or Overwrite the internal hardware short-name via USB
print(f"Current Name: {bus.name}")
bus.name = "MyBMS"

# Dynamically change the native physical indicator LED mode
bus.led_mode = 2  # Set to ActiveOnly (blinks on traffic)
bus.led_mode = 1  # Standard On

# Instantly pull real physical adapter bounds
caps = bus.capabilities
print(f"Clock Frequency: {caps['clock_freq']} Hz")
print(f"TX Buffer Elements: {caps['tx_data_buffer_size']}")
```

---

## Logging & Debugging

The library uses Python's standard `logging` module under the logger name `can.pscan`:

```python
import logging
logging.basicConfig(level=logging.DEBUG)
```

---

## Complete Example

```python
import can
import time
from pscan_pythoncan import PscanBus

# --- Device Discovery ---
devices = PscanBus.list_pscan_devices()
print(f"Found {len(devices)} PSCAN adapter(s): {devices}")

if not devices:
    print("No PSCAN devices found.")
    exit(1)

# --- Open bus by serial number ---
with can.Bus(interface='pscan', serial=devices[0], bitrate=500000) as bus:

    # Print adapter info
    hw, fw, ch = bus.get_device_info()
    print(f"Adapter: HW={hw}  FW={fw}  Channels={ch}")

    # Set up a filter to accept only IDs 0x100–0x1FF
    bus.set_filters([{"can_id": 0x100, "can_mask": 0x700, "extended": False}])

    # Send a frame
    tx_msg = can.Message(
        arbitration_id=0x150,
        data=[0xDE, 0xAD, 0xBE, 0xEF],
        is_extended_id=False
    )
    bus.send(tx_msg)
    print(f"TX: {tx_msg}")

    # Receive frames for 5 seconds
    end_time = time.time() + 5
    count = 0
    while time.time() < end_time:
        msg = bus.recv(timeout=0.5)
        if msg is not None:
            count += 1
            print(f"RX: 0x{msg.arbitration_id:03X}  [{msg.dlc}]  {msg.data.hex()}")

    print(f"Received {count} messages in 5 seconds.")

# Bus is automatically shut down by the context manager
```

---

---

## API Reference

### `PscanBus` (extends `can.BusABC`)

#### Constructor

```python
PscanBus(channel, serial, name, bitrate, sample_point, listen_only, loopback, can_filters)
```

#### Standard python-can methods

| Method | Description |
|---|---|
| `send(msg, timeout=None)` | Transmit a `can.Message`. Default timeout: 50 ms. |
| `recv(timeout=None)` | Receive a `can.Message`. Returns `None` on timeout. |
| `set_filters(filters)` | Set message acceptance filters (applied in native code). |
| `shutdown()` | Go bus-off, release the USB device, and clean up. |
| `flush_tx_buffer()` | Discard all pending messages in the transmit queue. |
| `state` (property) | Read or set the bus state (`can.BusState.ACTIVE / PASSIVE / ERROR`). |

#### PSCAN-specific methods

| Method | Description |
|---|---|
| `PscanBus.list_pscan_devices()` | *Static.* Returns `List[str]` of serial numbers for all connected PSCAN adapters. |
| `get_device_info()` | Returns `(hw_version: str, fw_version: str, channel_count: int)` for the open adapter. |
| `name` (property) | Read or set the 15-character physical name tag stored on the device ROM. |
| `led_mode` (property) | Read or set the physical LED mode (`0=Off`, `1=On`, `2=ActiveOnly`). |
| `capabilities` (property) | Read a Python Dictionary containing structural hardware lengths and frequency mapping natively parsed by the backend extension. |

---

## Feature Compatibility

### Supported python-can features

| Feature | Status | Notes |
|---|---|---|
| `can.Bus()` instantiation | ✅ Supported | Via `interface='pscan'` |
| `bus.send()` | ✅ Supported | Standard (11-bit) and Extended (29-bit) CAN frames |
| `bus.recv()` | ✅ Supported | With hardware timestamps (microsecond resolution) |
| Remote Transmit Request (RTR) | ✅ Supported | Send and receive RTR frames |
| Error Frame detection (RX) | ✅ Supported | Error frames are flagged in received messages |
| `bus.set_filters()` | ✅ Supported | High-performance filtering in native code, not pure Python |
| `bus.state` property | ✅ Supported | Read and write; maps to Error Active / Warning / Passive / Bus-Off |
| `bus.flush_tx_buffer()` | ✅ Supported | Clears the hardware transmit queue |
| `bus.shutdown()` | ✅ Supported | Goes bus-off and releases the USB device |
| Context manager (`with`) | ✅ Supported | `with can.Bus(...) as bus:` auto-shuts down on exit |
| Iterator protocol | ✅ Supported | `for msg in bus:` works (inherited from `BusABC`) |
| `can.Notifier` | ✅ Supported | Background receive thread with listener callbacks |
| `can.Logger` / `can.Printer` | ✅ Supported | All standard python-can listeners work |
| `can.detect_available_configs()` | ✅ Supported | Returns list of connected PSCAN adapters with serial numbers |
| Listen-only mode | ✅ Supported | `listen_only=True` — no TX, no ACK on the bus |
| Loopback mode | ✅ Supported | `loopback=True` — TX frames echoed to RX |
| Periodic send (`bus.send_periodic()`) | ✅ Supported | High-precision timing via dedicated OS threads. Bypasses Python's GIL for ~1μs jitter. |
| Multi-threading | ✅ Supported | Safe for use with `can.Notifier`, background workers, and concurrent send/recv across Python threads. |

### Currently unsupported python-can features

The following standard python-can features are **not yet implemented** in this version. Attempting to use them will either raise an exception or fall back to default `BusABC` behavior.

| Feature | Status | Details |
|---|---|---|
| **CAN FD** | ❌ Blocked | The PSCAN-USB hardware currently supports Classical CAN only. `bus` strictly aborts `fd=True`/`bitrates > 1Mbps`/`msg.is_fd` directly returning `NotImplementedError` via explicit Python exceptions saving devs from silent execution. |
| **Multi-channel support** | ❌ Not supported | All operations are hardcoded to CAN channel 0. If your PSCAN adapter has multiple CAN channels, only the first channel is accessible. To use a second channel, open a separate `can.Bus()` instance with a different adapter. |
| **Data bitrate / bitrate switch (BRS)** | ❌ Blocked | CAN FD bitrate switching triggers native safety protocols enforcing Classic architecture bounds. Explicitly throws `NotImplementedError("CAN FD and Bitrate Switching (BRS) are not yet supported")`. |
| **Hardware-level bus statistics** | ❌ Not available | There is no `get_bus_statistics()` method. You can read the bus state and error counters via `bus.state` and the raw `bus._dev.get_bus_state()` call (which returns `state`, `rx_error_count`, `tx_error_count`), but aggregate statistics like bus load, messages-per-second, or frame counts must be implemented in your application using a `can.Listener`. |
| **Hardware-level message filtering** | ⚠️ Software only | Filters set via `bus.set_filters()` are applied in the native extension (software filtering) before frames reach Python. They are not pushed down to the CAN controller hardware. All frames are still received via USB and filtered in software on the host. |

> **Note:** Software filtering runs natively and easily handles 100% bus loads without dropping frames, completely transparent to your Python application.

---

## License

See the project repository for license details.

