Skip to content

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[0.9.1] - 2026-09-30

A packaging release. No application code changed since 0.9.0 — src/ and crates/ are byte-for-byte identical — so the studio behaves exactly as it did. What changes is how it is delivered, and that two release-process faults are closed.

Added

  • macOS ships a disk image, CANopen-Studio-macOS-x64.dmg, holding the .app beside an Applications shortcut: mount, drag, eject. It had been a .tar.gz only because macOS shared Linux's branch in the packaging step, and Archive Utility leaves the bundle loose in ~/Downloads, from where it runs but is never installed. ditto copies the bundle rather than cp -R, which can drop the extended attributes carrying its signature, and hdiutil verify runs before upload.

This does not quiet Gatekeeper: the bundle is signed ad hoc, not with an Apple Developer ID, and is not notarized, so the first launch still needs a right-click and Open. The installation page now says so where a reader meets it.

Fixed

  • A tag naming a version the tree does not carry now fails the build. v0.9.0 was tagged a minute before the commit bumping the version merged, so the workflow built the previous commit: the installer came out named for 0.8.2, with 0.8.2 in its AppVersion and in the bundle. Moving the tag afterwards changed nothing, the build having already run — and because updater.py compares __version__ against the tag of releases/latest, every install of that build was offered an upgrade to itself, permanently. The release workflow now compares the two before building anything, right after uv sync and before the extension build, so the job stops in seconds rather than after PyInstaller has run on three platforms.

Changed

  • CANopen-Studio-macOS-x64-Portable.tar.gz is no longer published, the disk image replacing it.

[0.9.0] - 2026-09-29

The OBD-II tab becomes a scan tool, and the studio stops treating a CAN bus and a diagnostic adapter as two unrelated connections.

This is also the first release since 0.8.0. The 0.8.2 tag was never cut, so everything its section below describes — CAN FD in the core frame type, the CI outage that had every workflow red — reaches a downloadable build here for the first time.

The minor bump rather than a patch is deliberate: Link changes the MCP surface. connect() now accepts ELM327 adapters and obd_connect() may be called with no transport at all. Nothing was removed and transport= still works, so existing callers keep running.

Added

  • ELM327 adapters over Bluetooth Low Energy. Most dongles sold today, and every one sold as iPhone-compatible, expose a GATT service rather than a serial port, so no existing transport could reach them. BleElmTransport finds the adapter by scan, name or address, locates the notify/write characteristic pair (FFF0, HM-10 FFE0, Vgate 18F0, Nordic UART, or an unambiguous custom service) and presents it as an ordinary ElmTransport. bleak runs on a private event loop so the GUI and the MCP server stay synchronous. Optional ble extra; transport="elm327_ble".
  • just obd-ble-check, a first-contact check for a BLE adapter. Plugging an unknown dongle into a car and opening the whole studio mixes two questions — does the link work, and does the vehicle answer — and tells you neither when it fails. Stage 1 sends only AT commands the ELM327 chip answers itself, so it runs with the ignition off and cannot reach the vehicle's bus. Stage 2, behind --vehicle, adds read-only identification and a trouble-code count; clearing codes is not offered. Exit codes tell the link, the chip and the vehicle apart.
  • Freeze frame (mode 02). read_freeze_frames() reports, per ECU, the code that stored the frame and the captured values, decoded with the mode 01 definitions. Supported-PID discovery learns to carry mode 02's frame number, and the echo check refuses a reply for another frame.
  • Emissions readiness (PID 01 and 41). A new readiness module decodes every monitor. Bytes C and D name different monitors on spark and compression ignition, which a flat bit table cannot express. Several ECUs are combined: the lamp is on if any commands it, a monitor is complete only if every ECU implementing it says so, and an ECU with no engine monitors cannot turn a diesel into a petrol engine. all_complete is the strict reading rather than a pass/fail, since inspection regimes differ.
  • Continuous polling and recording. OBD-II has no subscription: a value is only ever the answer to a request, so watching one change means asking again and again. PidPoller polls a PID set on its own thread and measures the rate it achieves over recent cycles rather than since the start, because the first request can carry the adapter's protocol search. A PID that fails is counted and skipped; a lost link ends the run. CsvRecorder writes one row per reading with its own UTC timestamp, so two ECUs answering one PID stay apart and nothing is invented for gaps.
  • More of the generic J1979 profile: 01:61, 62, 63, 65, 8E, 9E and A6, plus the support PIDs 80, A0 and C0, which previously came back as raw bytes. The multi-field PIDs (69, 7A, 87, 9A, 9D) are left raw on purpose: their scalings could not be verified, and a plausible wrong value is worse than an undecoded one.
  • Profiles reloaded without restarting. reload_default_library(), exposed as obd_reload_profiles over MCP and a reload button in the studio. The open session is resolved again from the identity it already holds, without asking the vehicle, and a generic profile that no longer loads leaves the previous copy in use.
  • New MCP tools: obd_read_freeze_frame, obd_read_readiness, obd_sample_pids (a bounded sampling window returning per-PID statistics and the measured rate) and obd_reload_profiles. Recording to a file is not offered to agents.
  • A page mapping CAN, CANopen and OBD-II onto the OSI model, naming the standard governing every layer. The three are routinely taken for alternatives when they share layers 1 and 2 entirely and differ only above them — which is why one adapter, one capture and one decoder stack serve all three.

Changed

  • One link for CAN and ELM327. canopen_studio.link.Link models one physical adapter, opened once, declaring what it can carry (trace, CANopen, OBD-II) and handing out the diagnostic session it supports. The studio had two Connect buttons: "Native CAN" in the second silently depended on the first, an ELM327 session was invisible to everything but the OBD-II tab, and MCP mirrored the split.
  • Studio: one Connect in the top bar, its list now including the ELM327 adapters. The OBD-II tab loses its own link box and says which link it runs on. Tabs needing raw frames say why an ELM327 cannot serve them. A connection chain shows studio → adapter and adapter → vehicle (or bus) as two segments, because a single "Connected" hid the case that matters with an ELM327: the adapter reachable while the vehicle does not answer.
  • MCP: connect() accepts ELM327 adapters, in the GUI and standalone. obd_connect() with no transport runs on the current link, and closing diagnostics on an ELM327 leaves the link open. transport= still works.
  • Diagnostic exchanges are serialised by a lock, so the studio, a poller and an agent can share one session.
  • Released builds bundle bleak and each platform's backend. The release job ran a plain uv sync, and even with bleak installed PyInstaller cannot see the backend, which bleak imports at run time per platform — so a released studio offered "ELM327 — Bluetooth LE" and failed on first use. CoreBluetooth with pyobjc, WinRT, BlueZ with dbus-fast are now collected explicitly.
  • pyo3 0.23 → 0.29.

Fixed

  • An MCP session on the GUI's bus never received a frame. The capture loop now feeds every native session on the link.
  • A vehicle the adapter cannot fully describe is identified anyway. On a v1.5 clone, several ECUs answering the ECU-name request (09 0A) at once overflow the adapter's buffer and it answers BUFFER FULL; identify() let that abort the whole session, although ECU names and calibration IDs only refine a match the VIN and supported PIDs already make. They are optional now — a failure costs the refinement, and a lost link is still raised.
  • The OBD standard (01 1C) came back as raw bytes, in the studio and over MCP: identify() runs before any profile has supplied a PID table, since its result is what selects the profile. It is labelled with the generic J1979 definition when the current table has none.
  • On macOS the app was killed on first Bluetooth access, for want of NSBluetoothAlwaysUsageDescription in its Info.plist. The bundle is declared and signed again ad hoc, since the edit invalidates PyInstaller's signature.
  • The PyO3 bindings were compiled by no Rust job. cargo clippy --workspace never enables the optional python feature, so pyo3_bindings.rs was seen only by the Python jobs, at their build step, minutes later — which is how the pyo3 0.29 bump merged with a green Rust job and a build that could not link. A second clippy step covers them, and the seven lints the blind spot had been hiding are cleared.
  • Mermaid never rendered on the documentation site. pymdownx.superfences was enabled without the custom fence Material needs, so every ```mermaid block became a code listing — including the two architecture diagrams in ROADMAP.md, published as source all along.

[0.8.2] - 2026-09-24

This release exists to publish. v0.8.1 never produced any binaries: its release workflow died at Install dependencies, as did every CI and documentation run since, because the dev group had gained a dependency resolving from a private repository that the runners cannot clone. The fix landed on main but a tag already cut does not replay, so the artifacts for 0.8.1 were never built. This tag carries them, together with everything merged since.

Fixed

  • Every workflow was red, on every push, since simple-apps-deployment was declared in the dev dependency group. It resolves from a private repository, uv sync installs dev by default, and the runners have no credentials for it, so CI, the documentation deploy and the release build all stopped before compiling anything. Moving it to another group would not have helped — uv resolves every dependency group when it locks, whichever one the entry sits in — so it leaves the project metadata entirely, and scripts/bundle_app.py documents uv run --with git+... instead. It is a tool one maintainer runs to publish to a share, not something the studio imports or CI ever touches.

Added

  • CAN FD groundwork in the core frame type (ISO 11898-1). CanFrame carries payloads up to 64 bytes and the three things that distinguish an FD frame from a classic one: is_fd, bitrate_switch and error_state_indicator. The FD DLC encoding comes with it — fd_dlc_code, fd_length_for_code and fd_padded_length — because on FD the wire code is not the length: 64 bytes travel under code 15, and the format can only name 0..=8, 12, 16, 20, 24, 32, 48 and 64. A payload of nine bytes is refused rather than padded silently, since the pad bytes reach the receiver as data and only the caller knows what they should be.
  • python-can interoperability for FD. The MessagePack encoder used to write is_fd: false unconditionally and the decoder dropped the three FD fields, so an FD frame round-tripped through Python came back classic. Both directions now carry them.
  • FD over the compact binary wire format, in 80 bytes rather than 24. A classic frame is still exactly the 24 bytes it was, so a peer built before this reads it unchanged; an FD frame sets a flag bit that such a peer refuses on length rather than decoding truncated. A short FD frame is refused rather than zero-padded, because the missing bytes are payload.
  • CanFrame.is_fd, .bitrate_switch, .error_state_indicator and .dlc_code on the Python extension, and canopen_core.CanFrame(..., is_fd=True, bitrate_switch=True).
  • CAN FD's CRC-17 and CRC-21 (crc17, crc21, crc_fd, fd_crc_width), the payload turning over from one to the other above 16 bytes. Both are checked against the value the CRC catalogues publish for "123456789" — 0x04F03 and 0x0ED841 — because a round trip against our own encoder would pass just as happily with the wrong polynomial. The register seed is an argument rather than a constant: ISO 11898-1:2015 seeds it with a leading one and the 2012 Bosch version with zero, and controllers on either side of that do not interoperate.
  • CAN FD over the SLCAN serial transport, the four letters d, D, b and B — FD standard and extended, without and with bitrate switch. The trap of that grammar is that the length digit is the DLC code rather than the byte count: 9 asks for twelve bytes and F for sixty-four, and no digit means nine. FD has no standard here, the firmwares disagreeing between x/X and a leading 0/1; this follows python-can's slcan driver, which follows the CANable 2.0 firmware — the adapter family the module already names. x is deliberately left alone, since in that dialect it spells a classic extended frame. A classic line whose digit exceeds 8 is still refused rather than reread as FD.
  • CAN FD in the PCAP-NG capture, so Wireshark reads an FD bus. There is no separate link type for it: LINKTYPE_CAN_SOCKETCAN carries both, and a reader tells them apart by the record length — 16 bytes for classic, 72 for struct canfd_frame — together with CANFD_FDF in the flags byte. The two bytes behind those flags are written zero, because that byte was once reserved and a reader only trusts it when they are.

Changed

  • CanFrame is 88 bytes rather than 24, the payload buffer having grown to hold both formats in one type — the way python-can's Message does — instead of splitting every consumer across an enum. What the benchmarks rest on is unchanged: no allocation, no garbage collector, Copy in and out of the ring buffer, and classic frames still cost 24 bytes on the wire.
  • The VCD writer refuses an FD frame instead of drawing it wrong. The waveform is rebuilt with the classic bit rules, and FD switches bit rate mid-frame, stuffs differently and uses CRC-17 or CRC-21 in place of CRC-15, so the picture would match no analyser. It returns an error naming the reason. The bitstream decoder already refused a recessive r1, and still does.

[0.8.1] - 2026-09-22

Changed

  • The notebook opens in the editor with its code hidden (notebooks/canopen-studio.py). The platform can open an application's notebook in the marimo editor, on a copy in the user's own documents; it opened on the source, so the tool sat behind the code that draws it. Every cell now carries hide_code=True — application mode hid the code already, this is about the editor, where marimo keeps the toggle one click away, per cell.
  • scripts/bundle_app.py builds the notebook archive too, with --variant notebook. How that archive was built lived nowhere: it was assembled by hand, which is how it came to carry libraries the platform provides. It now carries canopen and python-can and nothing else — the notebook plots with marimo's own elements, so it needs neither numpy nor matplotlib of its own.

[0.8.0] - 2026-09-22

Changed

  • The deployed archive stops carrying matplotlib (scripts/bundle_app.py). Simple Apps Deployment 1.8.0 provides marimo, numpy, pandas and matplotlib to the applications it runs, and states that in its README as a promise rather than an accident. matplotlib and what it pulls accounted for 35 of the 38 MB this archive weighed, sent over the share once per installation and again at every update; it now weighs about 3 MB. The script also records how the archive is built, which until now lived nowhere: the studio is published as a kind: python application, so it carries canopen, pyserial, python-can, msgpack and pyyaml, and nothing the platform already holds. The floor is platform 1.8.0 — on an older one the archive arrives without matplotlib and dies on its first import. The trade, written down in that README: an application relying on the platform's libraries runs on the interpreter it ships, not on one the user picked with --use-python unless that one has them too.

Fixed

  • just sample N never stopped after N frames: it passed its argument as -c, which can-sniffer reads as --channel, so the count went to the channel string and the capture ran until interrupted. It now passes -n.

Documentation

  • The published site trailed the code by two releases. The 0.5.0 Rust engine, the native front end and canopen-cli appeared nowhere in it, and ROADMAP.md, benchmarks.md and CHANGELOG.md were reachable only from the README on GitHub. Added architecture.md, rust_core.md and cli.md, covering the engine through 0.7.0 — the SLCAN serial transport, the PCAP-NG and VCD exports, the Wireshark extcap — and published the three root documents as site pages through pymdownx.snippets, so they keep one source of truth.
  • Corrected what had drifted: installation.md described a shortcut to can_gui.py and linked release.yml through an absolute file:/// path left over from an old checkout; it now covers the macOS and Linux release assets, the just rust-python step and what happens without it, and the servers extra the agent servers moved into in 0.6.1. quickstart.md showed just sniff 250000, which passes a bitrate where the recipe expects an interface. hardware.md still launched can_gui.py. The MCP tool table in ai_integration.md was missing get_latency_stats, ping_bus and the ten obd_* tools.
  • index.md and README.md list the OBD-II diagnostics, the bridge, the native engine and the capture exports, and the release assets are named by version rather than pinned at v0.2.1.
  • The animated captures added with the Rust port are now shown in the gallery.
  • wireshark.md predates the extcap, which shipped in 0.7.0 with no user-facing page at all: added how to build canopen-extcap, where Wireshark looks for it on each platform, the two interfaces it advertises and why only those two.

[0.7.0] - 2026-09-22

Added

  • SDO download in the Slint studio (crates/canopen-gui/src/main.rs, ui/main.slint): the SDO tab could only read. canopen-core already had build_sdo_write(); what was missing was somewhere to type the value and its encoding. The Download section offers the same five data types as the Python studio. Values are decimal unless prefixed 0x — a deliberate divergence from the Python studio, which reads a bare 10 as sixteen, easy to type by accident and expensive to send to a drive.
  • The virtual simulator answers writes (crates/canopen-core/src/simulator.rs): it only served uploads, so a download went unacknowledged and the button looked dead. It now confirms an expedited download, keeps the value so the object reads back, and aborts with 0x06010002 on the objects it serves from its own live state.
  • sniff --pcap <FILE|PIPE> (crates/canopen-cli/src/main.rs): pcap.rs has written PCAP-NG with LINKTYPE_CAN_SOCKETCAN since the capture work landed, but no command exposed it. A plain path records a file; a named pipe joins a live Wireshark capture.
  • sniff --vcd <FILE> and its four companions: mirrors the Python sniffer's spellings, so a capture reads the same whichever front end wrote it. Both exports can run together — one capture, Wireshark for the protocol and PulseView for the waveform. --vcd-bitrate has no Python counterpart: the Python sniffer takes the rate from the adapter it opened, a UDP bus has none, and sigrok must be told the figure the waveform is drawn at.
  • A Wireshark extcap (crates/canopen-cli/src/extcap.rs, src/bin/canopen-extcap.rs): Wireshark cannot capture CAN anywhere but Linux, SocketCAN being a kernel feature, so its interface list has nothing to offer on macOS and Windows. Dropped into Wireshark's extcap directory, canopen-extcap puts the studio's buses in that list. It advertises the UDP transport and the bundled simulator only — the two canopen-core can really open — rather than listing interfaces that cannot be captured from.
  • The SLCAN serial transport (crates/canopen-core/src/slcan.rs): the core could only reach a bus over UDP. The LAWICEL ASCII codec and a serial transport over it bring in the CANUSB, USBtin and CANable adapters, until now reachable only from the Python studio. serialport is taken with default-features = false: its default pulls libudev, which the Linux CI does not install, so port enumeration falls back to scanning /dev and /sys.

Fixed

  • Debug prints that had slipped into the Slint RX loop.

[0.6.1] - 2026-09-22

Changed

  • The MCP and A2A servers become an optional servers extra (pyproject.toml): fastmcp, a2a-sdk and fastapi[standard] were hard dependencies, so every deployed copy carried cryptography, pydantic-core, beartype, pygments and the google namespace whether or not anything ever spoke to an agent. gui.py already imported both servers inside a try/except ImportError and ran with _SERVERS_AVAILABLE = False when they were missing, so the code was written for this from the start; only the dependency declaration disagreed. They stay in the dev group, so their tests keep running in CI, exactly as cantools does for the optional DBC importer. Measured on Python 3.13 with uv pip install --target: 188 MB on disk and 61.0 MB compressed with them, 99 MB and 33.4 MB without. That is what lets the analyser ship as a deployed Python application rather than a PyInstaller build carrying a Python of its own.

[0.6.0] - 2026-09-21

Added

  • Reconstructed CAN waveforms for sigrok/PulseView (crates/canopen-core/src/bitstream.rs, vcd.rs): rebuilds the bit stream a decoded frame would have produced — SOF, arbitration, bit stuffing, CRC-15, delimiters, ACK slot, EOF — and writes it as VCD, which PulseView reads natively. Exposed to Python as canopen_core.VcdWriter and driven with can-sniffer --vcd <path>, with --vcd-timing (adapter timestamps or frames packed back to back), --vcd-ack and --vcd-tick-ns. Verified against sigrok-cli 0.7.2: 668 frames captured from the simulator decode back with the right identifiers and no CRC warnings. The waveform is a reconstruction, not a measurement, and says so where it cannot be missed: the channel is named CAN_RX_RECONSTRUCTED, the VCD header spells out that the ACK slot is an assumption, that errors and retransmissions are absent, that dropped frames leave no trace, and that the timing carries adapter accuracy. Frames sharing a timestamp are shifted apart so the wire never carries two at once, and the sniffer reports how many had to move.
  • PCAP-NG capture for Wireshark (crates/canopen-core/src/pcap.rs): frames are written with the LINKTYPE_CAN_SOCKETCAN encapsulation Wireshark reads natively, so its CANopen, J1939 and ISO 15765 dissectors apply without a plugin. Exposed to Python as canopen_core.PcapNgWriter and driven from the sniffer with can-sniffer --pcap <path>. The path may be a file or a named pipe (\\.\pipe\canopen-studio, or a FIFO), which gives a live capture: the studio creates the pipe and waits, then Wireshark connects to it (wireshark -i <pipe> -k) — Wireshark connects to pipes, it never creates them. The capture records every frame received, before the display filters, so it never silently omits traffic.
  • Hardware documentation for signal-level analysis (docs/logic_analyzer.md): probing the bus with a CAN transceiver and an FX2 logic analyzer alongside the adapter — bill of materials, DB9 wiring, the termination trap, PulseView settings, and why a lone adapter on a bus never gets its frames acknowledged.
  • Wireshark integration guide (docs/wireshark.md): capture files, live pipes, Decode As for CANopen, and useful display filters.

[0.5.0] - 2026-09-21

Added

  • Rust High-Performance Core Engine (crates/canopen-core) (Phase 1A):
  • Compact stack-allocated CanFrame (24 bytes) supporting zero heap allocations, microsecond timestamps, and dual serialization (MessagePack python-can interop and raw compact binary).
  • High-throughput TraceRingBuffer with snapshotting and filtering by arbitration ID/mask.
  • Sub-microsecond LatencyTracker measuring periodic frame intervals and clock jitter (e.g. 50 Hz SYNC = 20,000 µs nominal) and RTT statistics.
  • Cross-platform UdpCanBus with SO_REUSEADDR, SO_REUSEPORT, and SO_BROADCAST.
  • CANopen Service Data Object (SDO) client & server protocol engine (CiA 301): expedited upload (read), expedited download (write), segmented block transfers, and abort code decoding.
  • Process Data Object (PDO) Signal Mapping & Packing Engine: Bit-level packing and extraction for arbitrary integer and boolean signals with scale factor, offset, and physical engineering units (PdoMapping, SignalDefinition).
  • ISO-TP (ISO 15765-2) Multi-Frame Transport Engine: Single Frame (SF), First Frame (FF), Consecutive Frame (CF) reassembly, and automatic Flow Control (FC) frame emission for diagnostic messages up to 4095 bytes.
  • Native CANopen service decoders (NMT, SYNC, TIME, EMCY, TPDO1..4, RPDO1..4, TSDO, RSDO, Heartbeat) and OBD-II SAE J1979 Mode 01 PID and DTC decoders.
  • CANopen Network Management (NMT) Master & Heartbeat Consumer Engine (CiA 301): Command generation (StartRemoteNode, StopRemoteNode, EnterPreOperational, ResetNode, ResetCommunication), real-time node state tracking, and heartbeat timeout detection (NmtMaster, MonitoredNode).
  • Electronic Data Sheet (EDS) & Object Dictionary Engine (CiA 306): Parser for standard CANopen .eds files ([FileInfo], [DeviceInfo], objects [1000], subindices [1018sub1], data types, access rights, default values), with automatic PdoMapping generation directly from TPDO/RPDO mapping records (0x1A00..0x1A03, 0x1600..0x1603).
  • PyO3 Python Bindings (canopen_core): Exposes the compiled Rust core directly to Python (from canopen_studio import canopen_core), tested via tests/test_rust_core.py (including IsoTpReassembler, fragment_isotp, PdoMapping, NmtMaster, and EdsFile).
  • Phase 1A Native Integration: The Python UdpBus interface now natively instantiates and delegates to the compiled Rust canopen_core.UdpCanBus and canopen_core.CanFrame objects, achieving wire-speed frame parsing directly in the backend and eliminating python-can's MessagePack overhead.
  • Phase 1B Global Parsing: The CanopenLayer class in Python now natively offloads protocol classification (SDO, PDO, NMT, Heartbeat) and bitwise decoding to canopen_core.decode_canopen_message in Rust.
  • Phase 1B Virtual Simulator: The VirtualCanopenSimulator math generation and background timing loops (SYNC, Heartbeat, PDO sine waves) have been entirely ported to a native Rust OS thread, eliminating the Python GIL overhead.
  • Phase 1C ISO-TP Engine: The IsoTpReassembler used for OBD-II vehicle diagnostics is now fully rewritten in Rust. It tracks multiplexed concurrent diagnostic responses directly in native code, drastically speeding up VIN extraction and PID scanning.
  • Rust CLI Tool (crates/canopen-cli):
  • sniff: Real-time decoded CANopen and OBD-II network monitoring over UDP.
  • bench-tx: High-speed packet generator achieving ~300,000 frames/second.
  • latency: Real-time jitter and latency statistics display.
  • bench-ping: RTT benchmark — sends N CAN ping frames (0x7E0) and measures round-trip time from echo replies (0x7E1), reporting min/avg/max/stddev and packet loss. Same protocol as the Python LatencyTracker baseline.
  • echo: Auto-echo responder — listens for 0x7E0 frames and immediately replies with 0x7E1 echoing the identical payload. Run on the remote machine to act as the ping target.
  • Justfile Recipes: rust-build, rust-test, rust-python, rust-bench-tx, rust-bench-ping, rust-echo, and rust-gui.
  • Full SYNC Jitter benchmark matrix — transport × language (broadcast / unicast / multicast, local + Wi-Fi):
  • Local loopback — Rust 737 µs, Python 648 µs (Rust-backed UdpBus).
  • Wi-Fi broadcast — Rust 2,331 µs, Python (Rust-backed) 2,277 µs (vs old Python 23,920 µs with pure-Python simulator).
  • Wi-Fi unicast — Rust 643 µs, Python 609 µs.
  • Wi-Fi multicast (IGMP join_multicast_v4) — Rust 718 µs, Python 682 µs.
  • Key finding: Python ≈ Rust for all transports (same Rust UdpCanBus core); broadcast 3–4× worse due to Wi-Fi DTIM buffering.
  • simulate, --filter-id, --group added to canopen-cli; rust-simulate, rust-bench-latency added to justfile.
  • Python benchmark scripts added: benchmarks/python_sync_jitter.py (Rust-backed UdpBus) and benchmarks/pure_python_sync_jitter.py (stdlib socket + time.sleep, no Rust).
  • Pure-Python loopback baseline: unicast 231 µs, multicast 302 µs, broadcast 312 µs (time.perf_counter receiver-side).
  • Pure-Python cross-Wi-Fi benchmark (receiver-side time.perf_counter, pure stdlib — no Rust):
  • Unicast 192.168.30.31: +224 µs avg jitter, StdDev 830 µs — best single metric (no DTIM, receiver-side timing).
  • Broadcast 192.168.30.255: +438 µs avg jitter, StdDev 3,774 µs, max 143 ms — DTIM bursts visible in extremes.
  • Multicast 239.0.0.1: +608 µs avg jitter, StdDev 6,007 µs — experienced DTIM burst event; AP IGMP snooping inconsistent.
  • Pure Python sim → Python (Rust-backed) receiver cross-Wi-Fi (sender-side timestamps):
  • Unicast: +258 µs avg jitter, StdDev 127 µs — pure Python sim is more precise than Rust CLI sim (+609 µs) because it only sends SYNC (50fps, no HB/TPDO math between sleeps).
  • Multicast: +289 µs avg jitter, StdDev 89 µs — lowest StdDev of all measurements.
  • Broadcast: +786 µs avg jitter, StdDev 3,392 µs — DTIM visible, but less severe than Rust CLI sim broadcast (76fps vs 50fps).
  • Rust CLI sim → Pure Python receiver broadcast cross-Wi-Fi: +2,914 µs avg jitter, StdDev 11,541 µs (receiver-side, DTIM visible).
  • §6.2 table reordered: Pure Python sim rows first, then Rust CLI, then Python GUI; receivers in order Pure Python → Rust CLI → Python (Rust-backed) within each simulator block.
  • .gitignore updated to whitelist benchmarks/ directory.
  • fix(cli): latency now binds to 0.0.0.0 so it can receive frames from remote simulators over the network (was hardcoded to 127.0.0.1).
  • Benchmarks documentation updated with a pedagogical Glossary of Benchmark Types section explaining RTT, SYNC jitter, and TX throughput benchmarks.
  • Rust RTT Ping Benchmark (MAINPADIX NixOS ↔ MacBook, Wi-Fi 802.11, 20 pings each direction):
  • Linux→Mac: min 5.92 ms, avg 25.94 ms, 0% loss (vs Python: min 24.85 ms, avg 76.42 ms — −76% min, −66% avg).
  • Mac→Linux: min 6.15 ms, avg 71.66 ms, 0% loss (vs Python: min 18.15 ms, avg 74.32 ms — −66% min).
  • Gain attributed to elimination of Python GIL overhead, time.perf_counter() scheduling latency, and msgpack serialization round-trips. Remaining high-end outliers are Wi-Fi 802.11 burst jitter (DTIM beacons, power-save cycles), independent of the language runtime.
  • OBD-II vehicle diagnostics (SAE J1979), in two independently testable parts: an adapter backend and an application layer.
  • DiagnosticInterface, a transport-neutral abstraction deliberately separate from the CAN adapter catalog. An ELM327 is not a transparent bridge — it runs its own protocol autodetection and ISO-TP handling and answers in ASCII hexadecimal — so it cannot honour the contract that open_can_bus() consumers (the CANopen layer, the trace, the plotter, the bridge) all depend on.
  • ElmDiagnosticInterface: ELM327 over USB, classic Bluetooth SPP (via a bound rfcomm or COMx port) and TCP. Bluetooth Low Energy is documented as unsupported, because BLE adapters expose a vendor-specific GATT service rather than a serial port.
  • NativeCanDiagnosticInterface: ISO 15765-2 over every adapter already in the catalog — SLCAN, PCAN, Kvaser, Vector, IXXAT, gs_usb, SocketCAN, UDP multicast and the virtual bus — with no ELM327 in the way.
  • Runtime ELM327 capability probing. Counterfeit boards commonly report a version their firmware does not live up to, so the version is read but never trusted: each capability is probed by sending the command and watching for ?, the effective version is capped at the evidence, and the session degrades instead of failing.
  • Robust ELM327 reply parsing: framing on the > prompt, echo removal, SEARCHING... / BUS INIT / power-alert chatter (including when an adapter glues it to the data), and explicit handling of NO DATA, CAN ERROR, UNABLE TO CONNECT, BUFFER FULL, STOPPED and ?. A status word is reported rather than raised, since NO DATA is the ordinary answer to an unsupported PID.
  • SAE J1979 modes 01 through 0A, supported-PID discovery from the vehicle's own bitmasks (0x00, 0x20, 0x40 …) tracked per ECU, ISO 15031-6 trouble codes with the P/C/B/U prefixes, and VIN reading via mode 09 PID 02 with ISO 3779 decoding.
  • Declarative vehicle profiles with inheritance (generic J1979 → make → model and year) and a four-stage resolution cascade: VIN, supported-PID fingerprint, manual choice, then generic J1979. Resolution never fails, because a wrong-but-specific profile would decode a manufacturer PID into a plausible-looking wrong number.
  • The generic j1979_base profile as shipped data, covering mode 01 (0x00–0x60) and mode 09, verified by tests against the standard's own worked values.
  • Importers for Torque Pro custom-PID CSV exports and for DBC databases (via the optional cantools extra, canopen-studio[dbc]). Both skip a definition they cannot express exactly, with a reason, rather than importing one that would decode to a plausible wrong number.
  • Ten obd_* MCP tools on the existing server — one process, one port, one set of guards. Nine read; obd_clear_dtcs can change the vehicle and is gated (see below).
  • An 🩺 OBD-II Diagnostics GUI tab: adapter selection, vehicle identification, supported-parameter discovery and live reading, trouble codes, and a gated clear.
  • Opt-in integration tests against Ircama's ELM327-emulator (just test-emulator), plus an in-repository ELM327 fake that carries the everyday suite with nothing installed.
  • just recipes: test-emulator, emulator, import-torque, profiles.

Security

  • Diagnostic writes are off by default and pass three independent gates: the CANOPEN_STUDIO_DIAG_WRITE environment flag (following the CANOPEN_STUDIO_A2A pattern), a per-profile write_whitelist, and an explicit per-call confirmation that never persists. A refused write transmits nothing.
  • The UDS write services 0x2E, 0x31 and 0x2F have no request path in this release and are refused even with every gate open. The gate exists so that adding one is a deliberate change rather than an accident.
  • Clearing trouble codes (mode 04) is implemented but gated, and the GUI states what it costs: it erases the readiness monitors, which need a full drive cycle to rebuild and without which an emissions test fails.
  • A write requested through MCP passes a fourth gate, CANOPEN_STUDIO_MCP_DIAG_WRITE, on top of the three above. It is deliberately separate from CANOPEN_STUDIO_DIAG_WRITE: enabling writes so that a person can clear codes from the GUI must not, by itself, hand that capability to whatever model is connected to the server. With only one of the two set, the call transmits nothing and the refusal names the missing one.
  • When agent writes are enabled, the capability is stated at the server's console on startup, in the tool's own description, in the result of every trouble-code read, and in obd_status(). Nobody should discover it by watching a model use it.
  • The UDS write services stay unavailable through MCP, because they stay unimplemented everywhere.
  • Decoding formulas from profiles and imported files are interpreted, never executed: parsed to a syntax tree, whitelisted node by node, and evaluated by walking that tree. Nothing is compiled and eval is never called, so a profile cannot reach the filesystem, the network or the interpreter. Profile files are read with yaml.safe_load, and exponents are bounded.

Documentation

  • SECURITY.md, with private disclosure through GitHub Security Advisories, the components whose security surface is real (the local agent servers, the diagnostic write gate, the formula interpreter, file loading, the updater), what is not a vulnerability but a documented property, and the physical-safety notes that matter when a bug can move something.
  • CODE_OF_CONDUCT.md: Contributor Covenant 3.0, with the reporting and enforcement sections filled in. Both live in .github/, where GitHub picks them up.
  • The documentation build now publishes llms.txt and llms-full.txt (via mkdocs-llmstxt), so a language model can read the documentation without scraping the rendered HTML.

Changed

  • pyyaml is now a dependency, for the vehicle profile format. cantools is an optional [dbc] extra rather than a base dependency, since the generic profile and every hand-written one work without it.
  • The MCP server's instructions mention the obd_* tools and state that they read only.

Notes

  • Only the CAN protocols of ISO 15765-4 are parsed. A session that autodetects K-line (ISO 9141-2, ISO 14230-4) or J1850 says which protocol it found and stops, rather than mis-parsing a differently framed reply.
  • ELM327-emulator is intentionally not a development dependency: it is licensed CC-BY-NC-SA-4.0, which is non-commercial and non-OSI, and adding it to the default dev group of a GPL project would impose that on everyone running the suite. It runs as a separate process, so nothing is redistributed, and the tests that need it skip with an instruction when it is absent.

[0.4.0] - 2026-09-19

Security

  • The A2A server no longer starts automatically. It is stateless and accepts text/plain, so a single POST from any web page the user visited reached it without a CORS preflight and could transmit on a connected CAN bus (send, sync, nmt stop were all confirmed to execute). It now requires CANOPEN_STUDIO_A2A=1.
  • Both servers refuse requests carrying an Origin header, which blocks browser-originated requests while leaving command-line clients and MCP agents unaffected. CANOPEN_STUDIO_ALLOWED_ORIGINS allows specific origins when a front-end genuinely needs one.
  • Both servers refuse requests whose Host header does not name the loopback interface, closing the DNS rebinding variant where the page becomes same-origin and sends no Origin.
  • The MCP server can be kept from starting with CANOPEN_STUDIO_MCP=0, for shared machines.
  • Documented the real exposure of each server, why their defaults differ, and what remains uncovered (no authentication: any local process can still reach a running server).

Fixed

  • Worked around fastmcp 4.0.5 silently ignoring host_origin_protection on the SSE transport, which left the protection absent while appearing enabled: the guard middleware is never installed although the setting is accepted, so the MCP server builds and guards its SSE application itself.

[0.3.0] - 2026-09-18 [WITHDRAWN]

Withdrawn on 2026-09-19: this release shipped the A2A exposure described above. Its content is included in 0.4.0.

Added

  • CAN <-> network bridge (can_bridge.py): mirrors the bus currently captured — typically a physical adapter connected to a real device — onto a UDP multicast group, so remote machines observe the live traffic as if they were wired to it. Available from the GUI toolbar (Bridge → Net) and from the MCP/A2A bridge_start / bridge_stop tools, with bridge counters reported by get_status.
  • Optional injection direction (allow_inject) replaying network frames onto the real bus, off by default because it writes to real hardware.
  • Symmetric loop protection in the bridge: frames sent in either direction are remembered for a short window so the multicast reflection is discarded instead of being relayed back; the bridge also refuses to mirror a multicast group onto itself.
  • Configurable UDP multicast hop limit (TTL) via the CANOPEN_UDP_HOP_LIMIT environment variable or the hop_limit argument of open_can_bus() and the MCP/A2A connect tool, allowing the virtual CAN bus to reach machines on other subnets (python-can defaults to 1, confining frames to the local segment).
  • MCP/A2A status now reports the active channel and bitrate alongside the interface, so remote clients can join the same bus.
  • Unit tests for the MCP server tools (tests/test_mcp_server.py), the GUI status snapshot (tests/test_gui_status.py) and the UDP multicast bus configuration.
  • MCP server (can_mcp_server.py) and A2A server (can_a2a_server.py) started automatically by the GUI in daemon threads, exposing the bus to AI agents: connection management, trace and network state reading, raw frame transmission, NMT commands, SYNC generation and SDO reads.
  • Multi-instance support: CANOPEN_STUDIO_INSTANCE names an instance in the window title and status reports, while MCP_PORT and A2A_PORT let several instances run side by side.
  • UDP Multicast interface (CAN over IP) with an attachable simulator, turning any local network into a shared virtual CAN bus.
  • NixOS support: shell.nix, automatic LD_LIBRARY_PATH setup for libstdc++ in just gui, and a venv setup handling tkinter and binary wheels.
  • Static analysis and security scanning in CI (mypy, bandit, safety, pre-commit), and a release pipeline building standalone binaries for macOS and Linux alongside Windows.

Fixed

  • Corrected the claude mcp add command shown in can_mcp_server.py (docstring and standalone startup message): the server name must precede the URL.
  • get_status reported the interface selected in the combobox instead of the one actually connected, so a bus opened from MCP/A2A was misreported (e.g. slcan while running on udp_multicast).
  • Frames sent through MCP/A2A (send_frame, send_nmt, send_sync, sdo_read) were not counted in the total_tx statistic, unlike frames sent from the GUI transmission console.
  • Removed an unused tkinter import in connect_from_mcp flagged by Ruff (F401).
  • The virtual simulator now transmits on a separate sim_bus when running on a network interface: udp_multicast cannot receive its own messages, so simulated frames never reached the capture loop.
  • Added the msgpack dependency required by the UDP Multicast backend, which failed to open without it.
  • VirtualCanopenSimulator.running is initialised in __init__, avoiding an attribute error when stopping a simulator that was never started.
  • Corrected bandit scanning the .venv directory in CI.
  • The in-app git updater located the checkout from its own directory, which broke once the code moved under src/; it now walks up to find the repository root and reports clearly when running outside a checkout.

Changed

  • Breaking (source layout): the Python code moved to a src/canopen_studio/ package. Modules were renamed (can_gui -> canopen_studio.gui, can_interfaces -> canopen_studio.interfaces, can_bridge -> canopen_studio.bridge, can_mcp_server -> canopen_studio.mcp_server, can_a2a_server -> canopen_studio.a2a_server, can_sniffer -> canopen_studio.sniffer, updater -> canopen_studio.updater, canopen_stack -> canopen_studio.stack). Console entry points (canopen-studio, can-sniffer, canopen-mcp, canopen-a2a) are unchanged.
  • The version is declared once in src/canopen_studio/__init__.py; pyproject.toml reads it as dynamic metadata, the modules import it, and the Windows installer receives it from the release workflow.
  • Documented the AI integration (MCP and A2A) on a dedicated page, covering the tool reference, bridge control from an agent, multi-instance ports and the absence of authentication.
  • Applied ruff format to can_gui.py, can_mcp_server.py and can_a2a_server.py, which were failing the CI formatting check.
  • Made the justfile fully cross-platform.
  • Documented UDP Multicast, the virtual simulator and the network impact of multicast versus broadcast.

[0.2.1] - 2026-09-18

Added

  • Automated macOS installation script (install.sh and scripts/install_macos.sh) creating a native .app wrapper bundle on the user's Desktop.
  • Automated Linux installation script (scripts/install_linux.sh) generating a standard .desktop application menu entry.
  • Added Python best practices to AGENTS.md based on cookiecutter-python-package recommendations (pre-commit, mypy, bandit, safety).

[0.2.0] - 2026-09-18

Added

  • Generalized multi-interface hardware support: SLCAN (Lawicel, USBtin, CANable), PEAK PCAN, Kvaser, Vector, IXXAT, gs_usb, SocketCAN, and Virtual Simulator.
  • Extensible protocol decoding architecture with generic CiA 301/402, SEVCON Gen4, De Haardt, J1939, and Raw CAN profiles.
  • Real-time multi-byte payload oscilloscope plotter with simultaneous B0–B7 time-series tracing.
  • Network monitor with automatic node discovery, state tracking, and live gauges.
  • CANopen Transmission Console with Generic NMT Master, SYNC clock generator, and frame templates library.
  • SDO expedited dictionary reader and writer.
  • Built-in Virtual CANopen Simulator for hardware-free educational study and testing.
  • One-click Windows installer script (install.bat and scripts/install_windows.ps1) to automatically configure the environment and create Desktop and Start Menu shortcuts.
  • Standalone PyInstaller build script (scripts/build_exe.py) supporting both directory and single-file portable builds.
  • Native Windows Setup installer script (installer/CANopenStudio.iss) for Inno Setup.
  • Automated GitHub Actions release pipeline (.github/workflows/release.yml) producing downloadable .exe installer and portable .zip packages on tag releases.
  • Custom vector-styled application icons (assets/icon.png and assets/icon.ico) featuring CAN differential signal waveforms and network topology.
  • Package entry points in pyproject.toml (canopen-studio and can-sniffer) for global installation via uv tool or pip.
  • Justfile automation recipes for test, lint, format, check, install, generate-icon, build-exe, build-portable, doc-build, and doc-serve.
  • Comprehensive pytest unit test suite (34 tests) covering CANopen layer protocol state transitions, application decoders (SEVCON, CiA 402, De Haardt, J1939), hardware interface abstraction, virtual simulator loop, CLI argument parsing, and updater routines.
  • Integrated code quality and style linting with Ruff (ruff check, ruff format) configured in pyproject.toml.
  • Continuous Integration workflow (.github/workflows/ci.yml) testing across Python 3.10, 3.11, 3.12, 3.13 on both Ubuntu and Windows runners, with Ruff linting and formatting verification.
  • Automated dependency update configuration with Dependabot (.github/dependabot.yml) for both GitHub Actions and Python dependencies.
  • In-app update mechanism (updater.py) with non-blocking background release checks against GitHub API, update notifications in status bar, interactive "Check for Updates" dialog with release notes, one-click Windows installer download & execution, and Git git pull && uv sync automated updates for developer installations.
  • Comprehensive documentation website built with Material for MkDocs (mkdocs.yml, docs/) with instant search, dark/light themes, hardware setup guides, protocol specifications, and automated deployment to GitHub Pages (.github/workflows/docs.yml).
  • Minimalist README.md with CI/Docs/Release badges, feature highlights, and direct documentation links.
  • Allowlist .gitignore policy and initial GitHub release.