Metadata-Version: 2.4
Name: pscan-pythoncan
Version: 0.2.1
Classifier: License :: Other/Proprietary License
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Rust
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Requires-Dist: python-can>=4.0.0
Summary: Moved to python-can-pscan. Native Python-CAN hardware backend for PSCAN USB devices
Keywords: can,can-bus,python-can,pscan,usb,automotive
Author-email: VeVeeS <vevees@probesync.com>
License: Proprietary
Requires-Python: >=3.8
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

> [!IMPORTANT]
> **`pscan-pythoncan` has moved to [`python-can-pscan`](https://pypi.org/project/python-can-pscan/).**
>
> The package was renamed to follow python-can's plugin naming convention
> (distribution `python-can-pscan`, import package `can_pscan`). This
> `pscan-pythoncan` release contains exactly the same code, so existing
> installs keep working, but future releases will be published under the new
> name only after the transition period. Switch now:
>
> ```bash
> pip uninstall pscan-pythoncan
> pip install python-can-pscan
> ```
>
> Uninstall first: both distributions contain the `pscan_pythoncan` package,
> so removing the old one after installing the new one deletes shared files.
> Your code needs no changes. `interface="pscan"` and `import pscan_pythoncan`
> keep working, and `import can_pscan` is the new preferred import name.

# python-can-pscan

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

`python-can-pscan` 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 separate PSCAN DLL required (Windows requires the WinUSB driver). 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.
- **Auto-detection** — connects automatically when exactly one PSCAN adapter is attached.
- **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).
- **Bus state change notifications** — register callbacks, or receive error frames through `can.Notifier`, whenever the controller changes error state.
- **Optional transmission confirmation** — modern firmware reports success, failure,
  arbitration loss or cancellation; older firmware falls back to success-only echoes.
- **Firmware diagnostics** — capability flags, save status, configured/running timing
  and decoded historical controller errors are available through Python.

---

## Installation

```bash
pip install python-can-pscan
```

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

The project follows python-can's plugin naming convention: distribution
`python-can-pscan`, import package `can_pscan`, interface name `pscan`. The
earlier import name `pscan_pythoncan` still works and exposes the same objects,
so classes, exceptions and `isinstance` checks agree across both names:

```python
import can_pscan                      # preferred
import pscan_pythoncan                # legacy, same objects
assert can_pscan.PscanBus is pscan_pythoncan.PscanBus
```

Releases up to 0.1.9 were published as `pscan-pythoncan`. Uninstall that
distribution before installing `python-can-pscan`, since both contain the
`pscan_pythoncan` package.

**Wheels** (one abi3 wheel per platform, CPython 3.8 and newer):

| OS | Architectures |
|---|---|
| macOS | Intel x86_64 (10.12+), Apple Silicon arm64 (11.0+) |
| Windows | x86 (win32), x64 (win_amd64), arm64 (win_arm64) |
| Linux | x86_64, i686, aarch64 (manylinux2014, glibc 2.17+) |

Windows wheels link the MSVC C runtime statically, so no Visual C++
Redistributable is needed; they import only Windows system DLLs and require
Windows 10 or newer plus the WinUSB driver for the adapter.

No source distribution is published, because building needs access to the
private core library.

---

## Quick Start

```python
import can

# Open the only connected 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 on the device. 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 exactly one PSCAN adapter is connected you can omit `serial` and `name`.
With several adapters attached, auto-detect fails as ambiguous; pass `serial`
or `name` instead:

```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)
    tx_confirm=False,           # True: wait for device transmission confirmation
    can_filters=None,           # Message filters (see Filtering section)
    state_notifications=False,  # Emit bus-state error frames to can.Notifier
    state_poll_interval=0.5,    # Seconds between bus-state polls (monitor)
)
```

| 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 (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. |
| `tx_confirm` | `bool` | `False` | Enable managed per-frame delivery confirmation. Firmware capabilities select modern results or legacy success-only echoes. |
| `led_mode` | `int` | `1` | Hardware LED behavior: `0=Off`, `1=On` (default), `2=ActiveOnly`, `3=TxOff`, `4=RxOff`. |
| `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. |
| `state_notifications` | `bool` | `False` | When `True`, start the bus-state monitor at construction and deliver each controller state transition as a synthetic error frame on the receive path (so a `can.Notifier` sees it). Off by default so it never perturbs error-frame counters unless requested. See [Bus State & Error Monitoring](#bus-state--error-monitoring). |
| `state_poll_interval` | `float` | `0.5` | Seconds between hardware bus-state polls for the notification monitor. Clamped to a minimum of `0.05`s. |

Device selection priority: `name` > `serial` > auto-detect (single device only).

Unsupported python-can options fail before the device opens instead of being
ignored: `fd=True`, `data_bitrate`, `bitrate_switch=True`, `timing=` (including
timing built by python-can from `f_clock`/`brp`/`tseg1`/`tseg2`/`sjw` config
keys) and `receive_own_messages=True` raise `NotImplementedError`. Use
`bitrate` and `sample_point` for timing, and `loopback=True` for controller
loopback.

---

## 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:**

- Without confirmation, the default USB submission timeout is 50 ms.
  With `tx_confirm=True`, `send(msg, timeout=...)` waits for success within one
  submission-and-confirmation deadline. `timeout=None` has no confirmation deadline;
  use an explicit timeout when bounded delivery time is required.
- An explicit `timeout=0` gives the USB transport no time budget: it submits
  nothing and raises `can.CanOperationError`, matching the pinned library.
- Sending in listen-only mode raises `can.CanOperationError`.
- A USB submission timeout without confirmation raises
  `can_pscan.SendTimeoutError`, a subclass of both `can.CanOperationError` and
  `can.CanTimeoutError`. The frame may or may not have been submitted.
- CAN FD frames are not supported and will raise `NotImplementedError`.

### Delivery results and cancellation

Ordinary `send()` without confirmation means the frame was submitted over USB;
it does not prove a CAN peer acknowledged it. To inspect an individual result:

```python
from can_pscan import PscanBus, TxTimeoutError

with PscanBus(serial="YOUR_SERIAL", bitrate=500000, tx_confirm=True) as bus:
    print(bus.confirmation_info)
    ticket = bus.submit(msg, timeout=0.050)   # one submission, never retried
    result = ticket.wait(timeout=1.0)        # None means still pending
    if result is None and bus.confirmation_info["cancellation_available"]:
        reply = ticket.cancel(timeout=0.2)  # request only, not proof of cancellation
        result = ticket.wait(timeout=1.0)
    print(result)  # success / failed / arbitration_lost / cancelled / uncertain
```

The library owns confirmation IDs and drains both RX and completion endpoints.
A timeout, dropped ticket or uncertain USB write never permits blind retransmission
or ID reuse. `ticket.usb_error` preserves an uncertain USB error; its eventual
result may still be success. Cancellation may race with successful delivery.

`send_with_confirmation(msg, timeout=1.0)` returns the result, including failed
results. If still pending, it raises `TxTimeoutError`; `exception.ticket` can be
waited on or cancelled. Standard `send()` returns `None` on success and raises a
python-can error for a failed or uncertain outcome. Neither method automatically
retries a frame or treats an abort request as a cancelled result.

Legacy firmware supplies success-only echoes and cannot provide tracked targeted
cancellation. Unsupported optional queries return `None` where documented;
malformed responses and transport failures remain errors. No firmware version
string is used to guess support.

### Firmware settings and diagnostics

```python
from can_pscan import get_library_info

print(get_library_info())             # package, product/API and dependency revision; no USB
print(bus.serial_number)
print(bus.firmware_features)          # known flags plus the complete raw mask
print(bus.compatibility_report())     # qualification, optional features and legacy paths
print(bus.get_bittiming())            # configured Classic CAN timing
print(bus.get_bittiming_snapshot())   # None if unsupported; no controller changes
print(bus.get_diagnostics())          # state/counters and a historical error
print(bus.get_settings_status())     # None on firmware without status support

# Explicitly stop before operations requiring a stopped controller.
bus.stop_all_periodic_tasks()
bus.set_bus_state(False)
bus.set_device_name("bench", wait_for_save=2.0)
bus.set_bitrate(1000000)
bus.wait_settings_saved(timeout=2.0)
bus.reset_timestamps()
bus.set_bus_state(True)
```

`wait_settings_saved()` only polls automatic persistence: it does not request a
save, reboot or stop the bus. Only `saved`/`unchanged` prove durability. The atomic
name-and-wait operation checks that CAN is stopped before writing. Channel IDs
(`get_channel_id`/`set_channel_id`), volatile `set_interface_name` and supported
termination control (`get_termination`/`set_termination`) are also exposed.

Controller diagnostics are separate observations, not an atomic snapshot and not
proof of an individual frame's failure cause. An arbitration-lost TX result is
per-frame evidence; a last recorded ACK error is historical context. Timing
comparison is meaningful only while the peripheral is running.

Frame timestamps remain device-relative microseconds converted to seconds,
not Unix wall-clock time. Opening the bus resets the device clock where the
firmware supports it, so timestamps normally start near zero. Resetting
timestamps deliberately changes the time origin. CAN FD/data-phase timing are still rejected until the main transport
supports them.

### Capture files

Use python-can's existing writers with `can.Notifier`; no extra PSCAN logging
thread or format is required. Choose `can.BLFWriter`, `can.ASCWriter`,
`can.CanutilsLogWriter` (candump) or `can.CSVWriter`:

```python
writer = can.BLFWriter("capture.blf")
notifier = can.Notifier(bus, [writer])
try:
    # Application work; notifier receives in the background.
    run_application()
finally:
    notifier.stop()  # stops the writer and finalizes its file
```

See [python-can file I/O](https://python-can.readthedocs.io/en/stable/file_io.html)
for format limits and timestamp conventions. Experimental speed detection, bus
load TUI and SocketCAN daemons remain standalone CLI tools.
In python-can 4.6.1, the candump writer omits the requested DLC of RTR frames;
use BLF, ASC or CSV when that field must survive a capture round trip.

### PSCAN controls beyond BusABC

`can.Bus(interface="pscan", ...)` returns a `PscanBus`, so the same object also
provides device controls that the portable `BusABC` interface does not define.
The normal `send`, `recv`, filters, notifier and logger APIs remain available.

```python
print(bus.get_identity())          # model, serial, name and all firmware/API versions
print(bus.get_bittiming_limits())  # clock, prescaler, segments and SJW limits
print(bus.get_supported_modes())  # capability flags; raw preserves unknown bits
print(bus.get_modes())
print(bus.get_statistics())       # host counters since open, across CAN restarts

# Explicitly change only the supplied settings, if supported by this device.
bus.stop_all_periodic_tasks()
bus.set_modes(one_shot=True, error_reporting=True)
bus.set_listen_only(True)
bus.set_listen_only(False)

if bus.firmware_features["identify"]:
    bus.identify(duration_ms=1000)
temperatures = bus.get_temperatures()  # None if the capability is absent
```

`set_modes()` accepts `listen_only`, `loopback`, `one_shot`, `triple_sampling`
and `error_reporting` as optional booleans. Omitted flags are preserved,
including confirmation flags. An actual change restarts a running CAN
controller and stops native periodic workers; a no-op does neither. Stop Python
callback periodic tasks before reconfiguring. Pending TX confirmation tickets
must finish before a change; a cancellation request alone does not qualify.
`set_listen_only()` is clearer than the compatibility `state=BusState.PASSIVE`
setter, since the reported error-passive state and listen-only mode differ.

`get_statistics()` contains 64-bit `tx_frames`, `rx_frames`, `tx_data`, `rx_data`,
`overruns`, `bus_warnings` and `bus_off`. TX counts completed USB submissions, not CAN acknowledgements;
RX counts native queue dequeues before adapter filtering. Data counters sum
DLC, including RTR requests. `overruns` counts host queue evictions. These are
individually sampled counters, not an atomic wire-loss measurement. They remain
cumulative across CAN restarts until close. `bus_warnings` and `bus_off` count
controller error frames (`bus_event_semantics` is `"controller_error_frames"`),
before adapter filtering. They only advance while the firmware delivers error
frames, so zero alone does not prove the bus was quiet. Read `get_diagnostics()` for
device-reported state, error counters and bus-off count.

Temperature responses preserve `temp_mask`, `soc_temp`, `device_temp`,
`aux1_temp` and `aux2_temp` as unsigned firmware words; only present sensors are
valid. Firmware documents SOC/device values in Celsius. The adapter does not
guess auxiliary scaling or signed encoding. Advertised command failures remain
errors, including a stalled identify request.

`reset_can()` uses the managed reset path, stops native periodic workers and
preserves whether CAN was running. It rejects pending confirmed tickets and
does not reboot the USB device. Full feature coverage and remaining limits are
listed in [Main-library integration](docs/library-parity.md).

### Periodic messages

`bus.send_periodic(msg, period)` uses a native worker. Supplying
`modifier_callback` uses a Python worker that invokes the callback immediately
before each transmission, allowing rolling counters and checksums. Keep these
callbacks short; stopping a task waits for an active callback to return.
Inspect `task.is_running` and `task.last_error` to detect expiry or a send failure.
Confirmed periodic sends have a 50 ms per-frame deadline and stop on failure or
unknown delivery; they never silently retry. Pending tickets retain their IDs
until a result arrives or the bus is shut down. A native periodic timeout does
not expose an individual ticket; stop the task and shut down/reopen the bus if
its result never arrives. Python callback timeout errors retain `.ticket`.

Direct sends and periodic start/modification use the same Classic CAN
validation: valid arbitration ID, DLC 0–8, matching data length, and no FD or
error-frame transmission. RTR frames have no data and may request DLC 0–8.
Periods must be finite and at least one microsecond; this is input resolution,
not a timing guarantee. Missed ticks are skipped to avoid catch-up bursts.
Actual timing depends on the OS, USB and bus load.

`shutdown()` stops periodic workers and closes the native device even if task
handles are retained or `store_task=False` was used. Tasks cannot restart after
their bus shuts down. Pending and subsequent `recv()` calls raise
`can.CanOperationError` on shutdown; stop a `can.Notifier` before shutting down
its bus when possible.

---

## 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
```

### Reading the full (raw) bus state

`bus.state` collapses the controller's six-way hardware state into python-can's
three-way `BusState`. To read the raw state and error counters instead, use the
PSCAN-specific `get_raw_bus_state()`:

```python
state, rx_err, tx_err = bus.get_raw_bus_state()
# state: 0=ErrorActive 1=ErrorWarning 2=ErrorPassive 3=BusOff 4=Stopped 5=Sleeping
print(f"raw state={state}  rx_err={rx_err}  tx_err={tx_err}")
```

### Bus state change notifications

python-can exposes `bus.state` as a **polled** property — it has no built-in
event for state transitions. `python-can-pscan` adds a background monitor that
watches the controller's error state and notifies you when it changes, in two
complementary ways.

**1. Callback listeners** *(PSCAN extension)*

Register a callback that fires on every transition. A daemon thread polls the
hardware every `state_poll_interval` seconds (default 0.5 s) and calls your
callback as `callback(new_state, raw)`, where `new_state` is a `can.BusState`
and `raw` is the full `(state, rx_error_count, tx_error_count)` tuple:

```python
def on_state_change(new_state, raw):
    state_val, rx_err, tx_err = raw
    print(f"Bus state -> {new_state.name}  (rx_err={rx_err}, tx_err={tx_err})")

bus = can.Bus(interface='pscan', bitrate=500000)
bus.add_state_change_listener(on_state_change)

# ... run your application ...

bus.remove_state_change_listener(on_state_change)  # optional
bus.shutdown()  # also stops the monitor thread
```

The monitor starts automatically on the first `add_state_change_listener()`
call and is stopped by `shutdown()`. Callbacks run on the monitor thread, so
keep them short and non-blocking; an exception raised by one callback is logged
and never affects the other listeners or the monitor.

**2. Error frames through `can.Notifier`** *(opt-in, python-can idiomatic)*

Pass `state_notifications=True` to *also* have each transition delivered as a
synthetic **error frame** (`is_error_frame=True`) on the normal receive path.
This lets an existing `can.Notifier` observe state changes with no extra
wiring — the frame's first three data bytes carry
`(state, rx_error_count, tx_error_count)`. Its timestamp uses the device clock
of hardware frames, extrapolated from the latest received frame or timestamp
reset, so logs keep a single time base:

```python
class StateWatcher(can.Listener):
    def on_message_received(self, msg):
        if msg.is_error_frame:
            state, rx_err, tx_err = msg.data[0], msg.data[1], msg.data[2]
            print(f"Controller state changed: state={state} rx_err={rx_err} tx_err={tx_err}")

bus = can.Bus(interface='pscan', bitrate=500000, state_notifications=True)
notifier = can.Notifier(bus, [StateWatcher()])
# ...
notifier.stop()
bus.shutdown()
```

Error-frame injection is **off by default** so it never perturbs your
error-frame counters unless you ask for it. When `state_notifications=True`
the monitor starts at construction (no listener registration required).

### Flushing the transmit buffer

Discard frames still queued in the adapter for transmission:

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

The firmware only flushes safely while CAN is stopped, so on a running bus this
briefly stops CAN, flushes and restarts it. Periodic tasks are stopped and
frames already queued for receive are discarded. With `tx_confirm=True`, pending
tickets must finish or be cancelled first.

---

## 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 can_pscan 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

`bus.name` and `bus.led_mode` expose device settings. Writing a name does not itself prove persistence; use `set_device_name(..., wait_for_save=...)` while stopped when durability matters.

```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 bytes: {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 can_pscan 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="PSCAN_USB1", serial=None, name=None, bitrate=500000,
         sample_point=875, listen_only=False, loopback=False, led_mode=1,
         can_filters=None, state_notifications=False, state_poll_interval=0.5,
         tx_confirm=False)
```

#### Standard python-can methods

| Method | Description |
|---|---|
| `send(msg, timeout=None)` | Submit in 50 ms by default without confirmation; confirmed mode waits for a result, with no deadline when timeout is None. |
| `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 queued TX frames by briefly restarting CAN, which stops periodic tasks and drops queued RX frames. Pending confirmed tickets must finish or be cancelled first. |
| `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 saved device name (up to 15 ASCII letters, digits, underscores or hyphens). |
| `led_mode` (property) | Read or set the physical LED mode (`0=Off`, `1=On`, `2=ActiveOnly`, `3=TxOff`, `4=RxOff`). |
| `capabilities` (property) | Read a Python Dictionary containing structural hardware lengths and frequency mapping natively parsed by the backend extension. |
| `get_raw_bus_state()` | Returns `(state, rx_error_count, tx_error_count)` without python-can's 3-way `BusState` collapse. `state`: `0=ErrorActive 1=ErrorWarning 2=ErrorPassive 3=BusOff 4=Stopped 5=Sleeping`. |
| `add_state_change_listener(cb)` | Register `cb(new_state, raw)` fired on every bus-state transition (starts the monitor thread). |
| `remove_state_change_listener(cb)` | Unregister a previously added state-change callback. |

---

## 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 state change notifications | ✅ Supported | PSCAN extension: `add_state_change_listener()` callbacks, plus opt-in error frames via `can.Notifier` (`state_notifications=True`). python-can has no built-in state event. |
| `bus.protocol` property | ✅ Supported | Reports `CanProtocol.CAN_20` (Classical CAN) on python-can ≥ 4.3. |
| `bus.flush_tx_buffer()` | ✅ Supported | Clears the adapter TX queue by briefly restarting CAN; see [Flushing the transmit buffer](#flushing-the-transmit-buffer) |
| `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 | Native workers for ordinary tasks; Python workers for per-transmission modifier callbacks. Stop interrupts the period wait. |
| 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 | This transport supports Classic CAN only. FD options/frames raise `NotImplementedError`; constructor bitrates outside 1–1,000,000 bit/s raise `CanInitializationError` before opening USB. |
| **Multi-channel support** | ❌ Not supported | The managed transport requires single-channel devices and rejects other channel counts. Use separate bus instances for separate physical adapters. |
| **Data bitrate / bitrate switch (BRS)** | ❌ Blocked | `data_bitrate` and `bitrate_switch=True` raise `NotImplementedError` before the device opens. |
| **`timing=` (`can.BitTiming`)** | ❌ Not supported | Raises `NotImplementedError`, including timing python-can builds from `f_clock`/`brp`/`tseg` config keys. Use `bitrate` and `sample_point`; `get_bittiming_limits()` and `get_bittiming()` read the hardware timing. |
| **`receive_own_messages=True`** | ❌ Not supported | Raises `NotImplementedError`. `loopback=True` enables controller loopback, but echoed frames are not marked `is_rx=False`. |
| **Bus load statistics** | ⚠️ Partial | `get_statistics()` returns host frame/byte counters and error-frame bus warning/bus-off counts; `get_raw_bus_state()` returns live error counters. Bus load and rates must be computed in your application. |
| **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. |

Receive buffering uses the main library’s bounded 20,000-frame queue. Slow consumers can still overflow it; inspect `bus.dropped_frame_count`. Physical throughput and loss depend on firmware, USB, host scheduling and application processing, and require hardware measurement.

---

## License

Proprietary software (`License: Proprietary` in the package metadata).

## Standalone development

This repository owns the Python package and its PyO3 extension. Version 0.2.1 is
independent of the core library version. Rust 1.99 or newer is required.
`pscan-core` and `pscan-usb` 1.4.0 are pinned to commit
`eb2e45dd6ba47b92a60f8f509feba69520847497` on `Linux-SockectCAN` in the private
[core repository](https://gitlab.com/probesync/host-tools/lib); configure your
normal GitLab credentials before building. No sibling checkout is required.

```sh
python3 -m venv .venv
. .venv/bin/activate  # Windows: .venv\Scripts\Activate.ps1
python -m pip install maturin pytest
cargo test --locked --lib
maturin develop --locked --release
python -m pytest tests/unit
maturin build --locked --release --out dist
```

Default pytest collection only runs hardware-free tests. `examples/` contains
hardware demos (including the former `python-test` scripts). Scripts under
`benchmarks/python/` require a dedicated, explicitly reserved physical CAN rig;
see its README_TESTING.md. They are never run by this repository's CI.

The Linux CI matrix tests python-can 4.0.0 and 4.6.1. Each job runs native tests
with in-memory device/session doubles and strict Clippy, then builds and installs
a wheel and runs unit tests outside the source directory. JUnit results remain
available on failures. Coverage includes managed TX/RX lifecycle, confirmation,
legacy feature handling, shutdown, periodic tasks, notifications, capture formats,
the entry point, and mocked benchmark/example lifecycles.
Private dependency checkout uses a job-local Git configuration and credential
helper; the core project must allow this project's CI_JOB_TOKEN. Tokens are
never embedded in dependency URLs or persisted in Git configuration.

## Releasing

Each release publishes the same compiled wheels under two PyPI names:
`python-can-pscan`, and the legacy `pscan-pythoncan`, whose description opens
with a notice to switch (see `packaging/legacy-notice.md`). The legacy wheels
are repackaged from the new ones by `scripts/make_legacy_wheels.py`; only the
distribution metadata differs.

### Automated (GitLab CI, self-hosted runners)

Release wheels build on two machines registered as GitLab project runners:

| Runner tag | Machine | Jobs |
|---|---|---|
| `pscan-macos` | this Mac, shell executor | macOS x86_64 + arm64 wheels; tests the native one |
| `pscan-docker` | same Mac, Docker Desktop | Linux x86_64, i686, aarch64 wheels; runs each on its own architecture (amd64 and 386 emulated); publishes |
| `pscan_windows` | Windows PC, `probesync/host-tools` group runner (pwsh) | win32, win_amd64, win_arm64 wheels (native MSVC); tests the x64 one |

Merge request tests keep running on GitLab's shared runners.

One-time setup:

1. In GitLab, *Settings → CI/CD → Runners → New project runner*: create the
   two Mac runners, with *Protected* ticked and *Run untagged jobs* off.
   Each shows a `glrt-…` token once. Protected runners only take jobs from
   protected branches and tags, so other branches cannot run code on these
   machines.
2. On the Mac (Docker Desktop running):
   `MACOS_RUNNER_TOKEN=glrt-… DOCKER_RUNNER_TOKEN=glrt-… scripts/runners/setup-macos-runner.sh`
3. Windows builds use the existing `probesync/host-tools` group runner tagged
   `pscan_windows`. It needs rustup, Python, Git for Windows and Visual Studio
   with the x64/x86 and ARM64 MSVC build tools. To register a different
   Windows machine instead, use `scripts/runners/setup-windows-runner.ps1`.
4. Add the PyPI token under *Settings → CI/CD → Variables* as `PYPI_API_TOKEN`,
   **masked** and **protected**, with environment scope `pypi` so only the
   `publish-pypi` job receives it. The first upload creates the
   `python-can-pscan` project, so that token must be account-scoped; replace it
   with a project-scoped token afterwards. `PYPI_LEGACY_API_TOKEN` (optional)
   is used for `pscan-pythoncan` and defaults to `PYPI_API_TOKEN`. Do not put
   the token in a runner's environment, where every job on that runner can
   read it.
5. Protect `v*` tags under *Settings → Repository → Protected tags*, so
   protected variables reach tag pipelines.

To release, bump `version` in `pyproject.toml` and `Cargo.toml`, update
`tests/unit/test_wheel.py`, run `cargo update --offline -p pscan-pythoncan`,
commit, then push a matching tag: `git tag v0.2.1 && git push origin v0.2.1`.

The tag pipeline runs the tests, builds all eight wheels, runs them, and only
then runs `publish-pypi`. That job checks that the tag matches the version,
that every platform wheel is present and that `twine check --strict` passes,
then uploads both names. Re-running it skips files already uploaded. All three
runners must be online; jobs wait for an offline runner. A manually started
pipeline (*Build → Pipelines → Run pipeline*) on a protected branch such as
`main` runs the same builds plus `release-check`, a dry run that uploads
nothing.

### Manual

- `scripts/build_wheels.sh [all|macos|linux|windows|TARGET...]`: on macOS it
  builds all eight wheels (Linux through zig, Windows through maturin's xwin);
  on Linux it builds Linux and Windows. It needs `rustup`, `maturin>=1.5` and
  `zig` (`pip install ziglang`).
- `scripts/build_linux_docker.sh [--out DIR] [--no-test]`: builds the three
  Linux wheels inside Docker on a Mac, then runs each wheel's unit tests in a
  container of its architecture. Dependencies are fetched on the host, so no
  token enters the container.
- `scripts/build_wheels.ps1`: native MSVC build of the three Windows wheels on
  Windows (Visual Studio Build Tools with x64/x86 and ARM64 C++ components).
- `scripts/publish.sh [--repository testpypi] [--allow-partial] [--dry-run] [DIST_DIR]`:
  validates the wheels in `dist/` and uploads both names. It reads tokens only
  from `PYPI_API_TOKEN` and `PYPI_LEGACY_API_TOKEN`. Use `--repository testpypi`
  with TestPyPI tokens to rehearse a release.

PyPI never accepts the same file twice, even after deletion, so a broken
release needs a new version number.

Source history was extracted from `crates/pscan-pythoncan` in
`probesync/host-tools/lib` at d5a772e1b49b41f31f134fd87a4da78d60f8f218.
Hardware scripts and demos were copied from that same source revision.

