From 02cf10a00a14f62440294e289d7ce2aab26b9dc0 Mon Sep 17 00:00:00 2001 From: AKJ7 Date: Sat, 31 Oct 2026 19:25:09 +0100 Subject: [PATCH] Release v1.6.0 --- CHANGELOG.md | 114 +- README.md | 19 +- can_explorer/core/e2e.py | 110 ++ can_explorer/core/elm327_vpw.py | 371 ++++++ can_explorer/core/ethernet.py | 546 +++++++++ can_explorer/core/gptp.py | 155 +++ can_explorer/core/j1850.py | 425 +++++++ can_explorer/core/obd.py | 186 +++ can_explorer/core/protocols/__init__.py | 4 + can_explorer/core/protocols/j1587.py | 142 +++ can_explorer/core/protocols/nmea2000.py | 228 ++++ can_explorer/core/reverse/console.py | 151 +++ can_explorer/core/reverse/raster.py | 75 ++ can_explorer/core/signals.py | 64 +- can_explorer/core/someip.py | 99 +- can_explorer/core/someip_arxml.py | 185 +++ can_explorer/core/uds.py | 21 +- can_explorer/gui/app_state.py | 27 +- can_explorer/gui/bit_raster.py | 162 +++ can_explorer/gui/connection_manager.py | 47 + can_explorer/gui/ethernet.py | 304 +++++ can_explorer/gui/j1850.py | 189 ++++ can_explorer/gui/mcp_server.py | 116 ++ can_explorer/gui/obd.py | 247 ++++ can_explorer/gui/python_highlighter.py | 191 ++++ .../gui/qt/CanExplorer/AboutDialog.qml | 46 + .../gui/qt/CanExplorer/ConnectionView.qml | 37 +- .../gui/qt/CanExplorer/EthernetView.qml | 290 +++++ can_explorer/gui/qt/CanExplorer/J1850View.qml | 175 +++ .../gui/qt/CanExplorer/McpServerDialog.qml | 126 +++ can_explorer/gui/qt/CanExplorer/ObdView.qml | 209 ++++ .../qt/CanExplorer/ReverseActivityPanel.qml | 100 ++ .../qt/CanExplorer/ReverseConsolePanel.qml | 176 +++ .../gui/qt/CanExplorer/ReverseView.qml | 36 + can_explorer/gui/qt/CanExplorer/Sidebar.qml | 8 + .../gui/qt/CanExplorer/SomeIpView.qml | 8 +- can_explorer/gui/qt/CanExplorer/Theme.qml | 7 + can_explorer/gui/qt/CanExplorer/qmldir | 6 + can_explorer/gui/qt/main_window.qml | 30 +- can_explorer/gui/reverse.py | 39 + can_explorer/gui/reverse_timeline.py | 78 +- can_explorer/gui/reverse_tools.py | 114 ++ can_explorer/gui/someip.py | 10 +- can_explorer/gui/uds_scan.py | 7 + can_explorer/main_application.py | 4 + can_explorer/mcp/__main__.py | 42 +- can_explorer/mcp/http_server.py | 95 ++ can_explorer/mcp/server.py | 297 ++++- can_explorer/mcp/service.py | 726 +++++++++++- .../resources/translations/canexplorer_de.ts | 1006 +++++++++++++---- .../resources/translations/canexplorer_fr.ts | 1006 +++++++++++++---- .../resources/translations/canexplorer_ja.ts | 1006 +++++++++++++---- .../resources/translations/canexplorer_zh.ts | 1006 +++++++++++++---- docs/assets/screenshots/j1850.png | Bin 0 -> 151211 bytes docs/assets/screenshots/mcp.png | Bin 0 -> 179154 bytes docs/assets/screenshots/obd.png | Bin 0 -> 132663 bytes docs/index.md | 16 + docs/javascripts/releases.js | 77 ++ docs/reference/command-line.md | 15 +- docs/reference/interfaces.md | 14 + docs/stylesheets/dracula.css | 20 + docs/user-guide/ethernet.md | 55 + docs/user-guide/j1850.md | 71 ++ docs/user-guide/mcp.md | 257 ++++- docs/user-guide/obd.md | 94 ++ docs/user-guide/protocols.md | 8 +- docs/user-guide/reverse-engineering.md | 56 + docs/user-guide/someip.md | 30 +- examples/simulation/engine_simulation.py | 15 +- mkdocs.yml | 6 + packaging/build.py | 5 +- .../io.github.akj7.CANExplorer.metainfo.xml | 88 ++ pyproject.toml | 10 +- snap/snapcraft.yaml | 23 +- tests/data/automotive.pcap | Bin 0 -> 570 bytes tests/data/someip.arxml | 133 +++ tests/unit/core/test_e2e.py | 60 + tests/unit/core/test_elm327_vpw.py | 223 ++++ tests/unit/core/test_ethernet.py | 208 ++++ tests/unit/core/test_gptp.py | 84 ++ tests/unit/core/test_j1587_decoder.py | 34 + tests/unit/core/test_j1850.py | 91 ++ tests/unit/core/test_nmea2000_decoder.py | 48 + tests/unit/core/test_obd.py | 65 ++ tests/unit/core/test_raster.py | 46 + tests/unit/core/test_reverse_console.py | 52 + tests/unit/core/test_signals.py | 34 + tests/unit/core/test_someip.py | 29 + tests/unit/core/test_someip_arxml.py | 38 + tests/unit/core/test_uds.py | 30 + tests/unit/gui/test_app_state.py | 10 + tests/unit/gui/test_connection_features.py | 67 ++ tests/unit/gui/test_ethernet_gui.py | 61 + tests/unit/gui/test_main_window.py | 70 ++ tests/unit/gui/test_python_highlighter.py | 47 + tests/unit/gui/test_reverse_gui.py | 103 ++ tests/unit/gui/test_someip_gui.py | 34 + tests/unit/gui/test_uds_scan_gui.py | 33 + tests/unit/mcp_interface/test_http_server.py | 64 ++ tests/unit/mcp_interface/test_server.py | 71 +- tests/unit/mcp_interface/test_service.py | 250 +++- tools/docs_screenshots.py | 147 +++ uv.lock | 10 +- 103 files changed, 12860 insertions(+), 1010 deletions(-) create mode 100644 can_explorer/core/e2e.py create mode 100644 can_explorer/core/elm327_vpw.py create mode 100644 can_explorer/core/ethernet.py create mode 100644 can_explorer/core/gptp.py create mode 100644 can_explorer/core/j1850.py create mode 100644 can_explorer/core/obd.py create mode 100644 can_explorer/core/protocols/j1587.py create mode 100644 can_explorer/core/protocols/nmea2000.py create mode 100644 can_explorer/core/reverse/console.py create mode 100644 can_explorer/core/reverse/raster.py create mode 100644 can_explorer/core/someip_arxml.py create mode 100644 can_explorer/gui/bit_raster.py create mode 100644 can_explorer/gui/ethernet.py create mode 100644 can_explorer/gui/j1850.py create mode 100644 can_explorer/gui/mcp_server.py create mode 100644 can_explorer/gui/obd.py create mode 100644 can_explorer/gui/python_highlighter.py create mode 100644 can_explorer/gui/qt/CanExplorer/EthernetView.qml create mode 100644 can_explorer/gui/qt/CanExplorer/J1850View.qml create mode 100644 can_explorer/gui/qt/CanExplorer/McpServerDialog.qml create mode 100644 can_explorer/gui/qt/CanExplorer/ObdView.qml create mode 100644 can_explorer/gui/qt/CanExplorer/ReverseActivityPanel.qml create mode 100644 can_explorer/gui/qt/CanExplorer/ReverseConsolePanel.qml create mode 100644 can_explorer/mcp/http_server.py create mode 100644 docs/assets/screenshots/j1850.png create mode 100644 docs/assets/screenshots/mcp.png create mode 100644 docs/assets/screenshots/obd.png create mode 100644 docs/javascripts/releases.js create mode 100644 docs/user-guide/ethernet.md create mode 100644 docs/user-guide/j1850.md create mode 100644 docs/user-guide/obd.md create mode 100644 packaging/io.github.akj7.CANExplorer.metainfo.xml create mode 100644 tests/data/automotive.pcap create mode 100644 tests/data/someip.arxml create mode 100644 tests/unit/core/test_e2e.py create mode 100644 tests/unit/core/test_elm327_vpw.py create mode 100644 tests/unit/core/test_ethernet.py create mode 100644 tests/unit/core/test_gptp.py create mode 100644 tests/unit/core/test_j1587_decoder.py create mode 100644 tests/unit/core/test_j1850.py create mode 100644 tests/unit/core/test_nmea2000_decoder.py create mode 100644 tests/unit/core/test_obd.py create mode 100644 tests/unit/core/test_raster.py create mode 100644 tests/unit/core/test_reverse_console.py create mode 100644 tests/unit/core/test_someip_arxml.py create mode 100644 tests/unit/gui/test_ethernet_gui.py create mode 100644 tests/unit/gui/test_python_highlighter.py create mode 100644 tests/unit/mcp_interface/test_http_server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b3b88e..7d45509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,115 @@ All notable changes to CANExplorer are documented in this file. The format follo CANExplorer follows [semantic versioning](https://semver.org/) from 1.0.0 on. Between `0.0.1` and `1.0.0`, the development stages are listed by date and commit, newest first. +## [1.6.0] – 2026-10-31 + +### Added + +- **Automotive Ethernet capture** tool: open a **pcap** or **pcapng** file (or capture live on Linux) and see the + decoded packets — Ethernet, 802.1Q VLAN, IPv4/IPv6, ARP, UDP/TCP — with the automotive protocols on top + recognised: **SOME/IP** (and SOME/IP-SD), **DoIP**, **gPTP** (IEEE 802.1AS), **AVTP** (IEEE 1722, with the stream + id and sequence number) and **LLDP** (neighbour chassis/port, system name, TTL). SOME/IP carried over **TCP** + (reassembled across segments) and **SOME/IP-TP** segmented messages are decoded in full; a **SOME/IP services** + card lists the services offered in Service Discovery (ids, version, endpoints, offer count), and when the capture + contains gPTP a **time-sync** card summarises the grandmaster and its clock quality, the clocks and domains seen, + and the configured and observed Sync rate. Filter by protocol, address or port, see the protocol breakdown, and + export back to pcap. Live capture needs root/`CAP_NET_RAW` and a tap or mirror port for a full view. + +- **Much larger MCP surface.** The `can-mcp` server gains **diagnostics** tools — OBD-II (`obd_status`, `obd_dtcs`, + `obd_vin`, `obd_supported`, `obd_read`, `obd_clear`) and UDS (`uds_read_did`, `uds_read_dtcs`, `uds_reset`, + `uds_routine`, `uds_request`) — **protocol decoding** (`decode_protocol`), more **reverse-engineering** verbs + (`timing`, `related_signals`, `capture_reaction`), **transmit by name** (`send_message`), and `list_interfaces`. + The bus is also exposed as read-only **resources** (`can://status`, `can://identifiers`, `can://frames`, + `can://database`) and guided **prompts** (reverse-engineer an identifier, diagnose trouble codes, find a signal). + New **permission tiers** gate them: `--allow-diagnostics` (safe reads), `--allow-control` (active diagnostics) and + `--allow-send` (transmit), each granting the ones below it. `--interface`/`--channel` are now optional — the + server can start unconnected and the agent opens a bus itself with the `connect` tool (after `list_interfaces`), + and can switch buses with `disconnect`. A database can be loaded or cleared at runtime (`load_database` / + `unload_database`). +- **In-app MCP server.** The GUI can host the MCP server for a connection it already has open (the **Agent…** + button on the Trace toolbar), so an agent attaches to the same live bus you are watching — which a separate + `can-mcp` process usually cannot, as most adapters are single-access. It is off by default, listens on + `127.0.0.1` only over the streamable-HTTP transport, is gated by a generated token, and uses the same permission + tiers (read-only by default). +- **OBD-II page** (SAE J1979 over CAN): a dedicated page for the standard emissions diagnostics. Scan a vehicle + for its VIN, MIL status and supported values; watch the live values (engine speed, vehicle speed, temperatures, + load, throttle, fuel level, voltage…) as tiles; and read the stored and pending trouble codes (decoded to + `P`/`C`/`B`/`U` codes) or clear them. Built on the UDS/ISO-TP stack; an `ObdServer` answers for use without a + vehicle. +- **NMEA 2000 and J1587 trace decoders.** NMEA 2000 (the marine protocol, built on J1939) decodes the common PGNs + into fields — engine, position, COG/SOG, heading, wind, depth, speed, battery — in marine units, with Fast Packet + reassembly. J1587 decodes the older heavy-truck bus's messages (MID and PIDs), with common parameters scaled; + J1708 is a serial bus (not CAN), so the decoder works on J1587 message bytes from a log or gateway, not a live + capture. Both are selectable as the connection protocol, like J1939 and CANopen. +- **Bit-transition activity map** in the reverse engineering view (the **Activity** tab): a raster of every bit of + the selected identifier across the whole capture — frames across, bits down (byte 0 at the top) — so counters + (diagonal ramps), constant bits (flat), toggles and multiplexed regions stand out at a glance, with changed bits + highlighted. Complements the frame-at-a-time temporal replay. +- **Interactive scripting console** in the reverse engineering view (the **Console** tab): run Python over the + frames captured so far, with the reverse-engineering primitives (`collect`, `analyse`, `BitField`, `candidates`, + `signal_groups`, `correlate`) and the current frames, found fields and helpers in scope, with Python syntax + highlighting. Variables persist between runs and a trailing expression's value is shown, like a REPL — the + scriptable surface the MCP service gives an AI agent, for a human at the keyboard. +- **Frame-interval (Δt) and per-byte timing graphs** in the reverse engineering view: **Plot Δt** graphs a + selected identifier's inter-frame interval over time (on the Signals page, with cursors, statistics and CSV + export) to spot gateways, bursts and event-triggered messages, and **Plot bytes** graphs every payload byte as + a value over time. +- **GM Class 2 / SAE J1850 VPW** support, for the pre-CAN GM vehicles such as the 1999–2006 P01/P59 PCMs. A new + python-can interface (**elm327_vpw**) drives an ELM327/OBDLink adapter in J1850 VPW mode (`AT SP 2`), so the + 10.4 kbit/s single-wire Class 2 bus appears in the connection dialog and the whole application — trace, + dashboard, reverse engineering, recording — works on it, with each VPW frame's + three-byte header (priority, target, source) in the extended identifier. A **J1850 / Class 2** diagnostics page + (shown on these connections) runs SAE J1979 over the shared adapter: read current-data PIDs and the VIN, read + the stored trouble codes (`P0143`…) or clear them, and send raw requests. Monitoring and diagnostics are + time-multiplexed on the one adapter. Scripts use `can_explorer.core.j1850` (`J1850Client`) and + `can_explorer.core.elm327_vpw`; a `Class2EcuSimulator` allows trying it without hardware. +- **ARXML SOME/IP descriptions**: the SOME/IP tool reads AUTOSAR **ARXML** service interfaces (chosen by the + `.arxml` extension) in addition to FIBEX, resolving the `SOMEIP-SERVICE-INTERFACE-DEPLOYMENT` service/method/event + ids to the referenced interfaces and their data types to decode payloads into named signals. The same subset as + FIBEX is supported — scalars, fixed and dynamic arrays, strings and nested structures. +- **AUTOSAR E2E protection** library (`can_explorer.core.e2e`): the AUTOSAR CRC routines (`crc8`, `crc8h2f`, + `crc16`, `crc32`, `crc32p4`, verified against the published check values) and E2E Profile 2 protect/check, for + verifying a SOME/IP or signal PDU's end-to-end CRC and sequence counter. +- **German, French, Japanese and Chinese translations** for the newer views (Ethernet capture, SOME/IP, OBD-II, + J1850, the AI agent/MCP dialog and the reverse-engineering panels), which were previously untranslated. + +### Changed + +- **`mcp` is now a core dependency**, not an optional extra: the `can-mcp` command and the in-app **Agent** server + work out of the box (no `pip install 'can-explorer[mcp]'`). +- **More reverse-engineering over MCP.** New tools let an agent inspect raw bit behaviour and test and commit its + own hypotheses: `bit_activity` (per-bit set/flip rates and a per-byte summary), `extract_field` (decode a + candidate bit range over time without a database), `mark`/`clear_markers`/`reaction_at_markers` (find the fields + that react at the moments of a repeated action); `timing` gains a `series` option (the Δt over time) and + `promote_to_dbc` can now write field specs the agent supplies (`fields=[…]`), not only auto-found fields. +- **Download buttons on the documentation home page**: per-platform buttons (Windows, macOS, Linux AppImage and + .deb) that link straight to the release's installers, resolved from the GitHub release at page load and with the + visitor's platform highlighted; they fall back to the releases page without JavaScript. +- **Expanded documentation**: the MCP page leads with the in-app server and a screenshot, plus runnable Python + scripting examples for both the in-app server and the `can-mcp` command; the OBD-II page gains a screenshot and an + `ObdClient` scripting example. + +### Fixed + +- **UDS discovery** scans now stop immediately when **Stop** is pressed, and the button returns to **Start**. + Stopping interrupts the in-flight request instead of only taking effect between requests (so a scan of an + unresponsive bus, or an ECU looping on "response pending", no longer appears to ignore Stop until the per-request + timeout elapses), and the running state is updated once the scan task has actually finished. +- **Closing a connection** no longer leaves the remaining connection's view squeezed into a narrow column: the + connection views are no longer hosted in a `StackLayout`, which did not reliably resize its current view when a + connection was added or removed. +- **Closing a connection** no longer empties the sidebar's view list: when a connection was removed, the current + view binding kept pointing at the destroyed delegate, so the per-connection views (Dashboard, Trace, …) + disappeared from the sidebar for the connection that remained; it now re-resolves when connections change. +- **UDS/OBD requests now skip stale responses** left on a shared response id (for example by a live poll that was + just stopped), rather than mistaking the first unrelated response for the answer — a response is accepted only if + it matches the request's service. This fixes an intermittent "Unexpected response …" failure when reading trouble + codes right after toggling live data off. +- **Reverse-engineering frame replay steps by frame, not by time**, so stepping and scrubbing are exact even when a + coarse clock gives several frames the same timestamp (seen on Windows); playback still runs at the recorded pace. +- **Example engine simulation**: the coolant temperature now cools down when the engine is off or idling instead of + only ever warming up — it tracks a load-dependent operating temperature and falls toward ambient when off. + ## [1.5.0] – 2026-10-30 ### Added @@ -625,7 +734,10 @@ Called "Milestone 0.1.0" in the commit message and the README roadmap. - Qt Widgets user interface: main window, new connection dialog, about dialog and a raw CAN frame table. - Pipenv based project setup, VS Code development container, Dockerfile and a CI workflow skeleton. -[Unreleased]: https://github.com/AKJ7/CANExplorer/compare/v1.2.0...HEAD +[1.6.0]: https://github.com/AKJ7/CANExplorer/compare/v1.5.0...HEAD +[1.5.0]: https://github.com/AKJ7/CANExplorer/compare/v1.4.0...v1.5.0 +[1.4.0]: https://github.com/AKJ7/CANExplorer/compare/v1.3.0...v1.4.0 +[1.3.0]: https://github.com/AKJ7/CANExplorer/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/AKJ7/CANExplorer/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/AKJ7/CANExplorer/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/AKJ7/CANExplorer/compare/v1.0.0...v1.1.0 diff --git a/README.md b/README.md index f975b90..b4446e4 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,15 @@ - Remote buses: `can-server` shares the interfaces of e.g. a Raspberry Pi in a vehicle over the network - MQTT: publish decoded signals (and frames) to a broker for dashboards and home automation, from the application or `can-server`, and optionally send frames requested over MQTT -- MCP: `can-mcp` exposes a bus to an AI agent over the Model Context Protocol — inspect identifiers, decode frames, - run the automatic signal analysis, correlate fields to a reference, promote results to a DBC, and optionally transmit +- MCP: `can-mcp` exposes a bus to an AI agent over the Model Context Protocol — inspect identifiers, decode frames + and protocols, run the reverse-engineering analysis, correlate fields, promote results to a DBC, read trouble + codes over OBD-II/UDS, and optionally transmit; as tools, resources and prompts, behind permission tiers - Signal plots with zoom and pan over the whole buffer, two cursors with values and statistics, several signals per chart and CSV export - Reverse engineering: bit and byte statistics per identifier and a notch; automatic detection of checksums, counters, multiplexers and values; matching fields to database signals, CSV/GPS logs or polled OBD-II values; - grouping fields that carry the same signal across messages; event markers; and bit fields to plot without a - database or add to one + grouping fields that carry the same signal across messages; event markers; frame-interval (Δt) and per-byte + timing graphs; and bit fields to plot without a database or add to one **Recording and replay** - ASC, BLF, CSV, candump LOG, PEAK TRC, SQLite, ASAM MF4 and gzip compressed text logs @@ -58,11 +59,15 @@ **Diagnostics and protocols** - UDS over ISO-TP and the LIN transport layer: sessions, reset, data identifiers, security access with your own seed/key algorithm, DTCs, routines; CANdelaStudio CDD and ASAM ODX/PDX descriptions for named and decoded identifiers +- OBD-II (SAE J1979): a dedicated page for the standard emissions diagnostics — scan for supported values, watch + them live, read and clear trouble codes, and read the VIN - ECU flashing over UDS (RequestDownload/TransferData) from Intel HEX, S-record or binary files, with progress - DoIP (Diagnostics over IP, ISO 13400-2): finds DoIP entities, connects with routing activation (optionally over TLS) and runs diagnostics and flashing over Ethernet; a simulated entity works without a vehicle - SOME/IP over automotive Ethernet: listens on UDP, discovers the services offered through SOME/IP-SD and shows the decoded messages; load a FIBEX file to deserialize the payloads into named signals +- Automotive Ethernet capture: open a pcap file (or capture live on Linux) and decode the stack (Ethernet, VLAN, + IPv4/IPv6, UDP/TCP) and the protocols on top — SOME/IP, DoIP, gPTP and AVTP - UDS discovery: scan an ECU for the services, data identifiers, subfunctions and routines it exposes, classifying the answers, for reverse engineering diagnostics; and fuzz it with mutated requests, detecting hangs and crashes - Bus gateway: forward frames between two connections with filtering, identifier rewriting and rate limiting @@ -70,6 +75,9 @@ name from an A2L file, with simulated slaves for trying it without hardware - GMLAN (GMW3110) diagnostics for GM/Opel vehicles — data identifiers, modes, security access and trouble codes — over CAN, including single-wire (SWCAN, 33.3 kbit/s) buses; a simulated ECU works without hardware +- GM Class 2 (SAE J1850 VPW) for the pre-CAN GM vehicles of 1996–2007 (e.g. the 99–06 P01/P59 PCMs): monitor the + 10.4 kbit/s single-wire bus and run SAE J1979 diagnostics (PIDs, VIN, trouble codes) through an ELM327/OBDLink + adapter; a simulated ECU works without hardware - J1939: nodes with their address claims, DM1/DM2 trouble codes and lamps, clearing codes, requests and sending parameter groups by name with BAM or RTS/CTS - CANopen master: NMT, SDO access to object dictionaries from EDS/DCF files, PDO mappings with live values, @@ -78,7 +86,7 @@ PDO assignment and emergencies; real adapters need the `ethercat` extra, a simulated network works without - Physical I/O: switch the digital outputs of a USB I/O adapter (aio_usb) and watch its inputs and analogue values, from the tool or test scripts; needs the `io` extra, a simulated device works without -- Decoding of ISO-TP, UDS, J1939, CANopen and LIN diagnostic frames in the trace +- Decoding of ISO-TP, UDS, J1939, NMEA 2000 (marine, with Fast Packet), CANopen, LIN and J1587 frames in the trace - LIN master schedule tables **Simulation and test automation** @@ -148,6 +156,7 @@ support and the Linux kernel drivers. | GVRET (`gvret`) | ESP32RET boards, Macchina M2 (M2RET), CANDue: SavvyCAN's GVRET protocol over USB serial or WiFi | Windows, Linux, macOS | | J2534 (`j2534`) | J2534 pass-thru devices: Mongoose, CarDAQ, OpenPort 2.0, Bosch, Autel …; 32-bit drivers through a bridge process | Windows | | ELM327 / OBDLink (`elm327`) | OBDLink MX+, LX, SX, EX and ELM327 compatible OBD adapters over USB, Bluetooth or WiFi, for diagnostics | Windows, Linux, macOS | +| J1850 VPW / GM Class 2 (`elm327_vpw`) | GM "Class 2" 10.4 kbit/s single-wire bus of 1996–2007 GM vehicles, through an ELM327/OBDLink adapter (not CAN) | Windows, Linux, macOS | | python-can serial (`serial`) | Adapters with python-can's serial protocol | Windows, Linux, macOS | | Robotell (`robotell`) | Robotell USB-CAN | Windows, Linux, macOS | | Seeed Studio (`seeedstudio`) | USB-CAN Analyzer | Windows, Linux, macOS | diff --git a/can_explorer/core/e2e.py b/can_explorer/core/e2e.py new file mode 100644 index 0000000..9fcbb96 --- /dev/null +++ b/can_explorer/core/e2e.py @@ -0,0 +1,110 @@ +"""AUTOSAR End-to-End (E2E) protection: the CRC routines of the AUTOSAR CRC library and the E2E Profile 2 +protect/check, so a SOME/IP (or signal PDU) payload carrying an E2E header can be verified — the CRC and the +sequence counter catch corrupted, lost, repeated or reordered messages on the wire. + +The CRC functions match the AUTOSAR SWS CRC library (verified against its published check values). Profile 2 is +the one most commonly seen in-vehicle: a one-byte CRC, a 4-bit sequence counter and a 16-entry Data ID list +indexed by the counter. Validating a real PDU needs its E2E configuration (which profile, the Data IDs, the +offset and length) — that comes from the ARXML E2E configuration; this module is the algorithm underneath it. +""" + +from dataclasses import dataclass +from typing import List, Sequence + + +def _reflect(value: int, bits: int) -> int: + result = 0 + for index in range(bits): + if value & (1 << index): + result |= 1 << (bits - 1 - index) + return result + + +def crc8(data: bytes, start: int = 0xFF, first_call: bool = True) -> int: + """AUTOSAR Crc_CalculateCRC8: SAE J1850, polynomial 0x1D, start 0xFF, final XOR 0xFF (chainable via `start`).""" + crc = 0xFF if first_call else (start ^ 0xFF) + for byte in data: + crc ^= byte + for _ in range(8): + crc = ((crc << 1) ^ 0x1D) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF + return crc ^ 0xFF + + +def crc8h2f(data: bytes, start: int = 0xFF, first_call: bool = True) -> int: + """AUTOSAR Crc_CalculateCRC8H2F: polynomial 0x2F, start 0xFF, final XOR 0xFF.""" + crc = 0xFF if first_call else (start ^ 0xFF) + for byte in data: + crc ^= byte + for _ in range(8): + crc = ((crc << 1) ^ 0x2F) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF + return crc ^ 0xFF + + +def crc16(data: bytes, start: int = 0xFFFF) -> int: + """AUTOSAR Crc_CalculateCRC16: CCITT polynomial 0x1021, start 0xFFFF, not reflected, no final XOR.""" + crc = start + for byte in data: + crc ^= byte << 8 + for _ in range(8): + crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF + return crc + + +def _crc32(data: bytes, reversed_poly: int, start: int = 0xFFFFFFFF) -> int: + crc = start + for byte in data: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ reversed_poly if crc & 1 else crc >> 1 + return crc ^ 0xFFFFFFFF + + +def crc32(data: bytes) -> int: + """AUTOSAR Crc_CalculateCRC32: the standard CRC-32 (polynomial 0x04C11DB7, reflected).""" + return _crc32(data, _reflect(0x04C11DB7, 32)) + + +def crc32p4(data: bytes) -> int: + """AUTOSAR Crc_CalculateCRC32P4: polynomial 0xF4ACFB13, reflected (E2E profiles 4 and 4m).""" + return _crc32(data, _reflect(0xF4ACFB13, 32)) + + +@dataclass(frozen=True) +class E2eResult: + ok: bool + counter: int + crc: int + expected_crc: int + + +class Profile2: + """AUTOSAR E2E Profile 2: byte 0 is the CRC, the low nibble of byte 1 is the sequence counter, and the CRC8 + (SAE J1850) is computed over the data after the CRC byte followed by the Data ID selected from the 16-entry + Data ID list by the counter.""" + + def __init__(self, data_ids: Sequence[int]): + if len(data_ids) != 16: + raise ValueError('Profile 2 needs a Data ID list of 16 entries (one per counter value)') + self.data_ids: List[int] = list(data_ids) + + def _crc(self, data: bytes, counter: int) -> int: + crc = crc8(data[1:]) # Over everything after the CRC byte + return crc8(bytes([self.data_ids[counter & 0x0F]]), start=crc, first_call=False) + + def check(self, data: bytes) -> E2eResult: + """Verify a received PDU's CRC against its counter's Data ID.""" + counter = data[1] & 0x0F if len(data) >= 2 else 0 + expected = self._crc(data, counter) + return E2eResult( + ok=len(data) >= 2 and data[0] == expected, + counter=counter, + crc=data[0] if data else 0, + expected_crc=expected, + ) + + def protect(self, data: bytes, counter: int) -> bytes: + """Return `data` with byte 1's counter set and byte 0 set to the computed CRC (data[0] is reserved for it).""" + out = bytearray(data) + out[1] = (out[1] & 0xF0) | (counter & 0x0F) + out[0] = self._crc(bytes(out), counter) + return bytes(out) diff --git a/can_explorer/core/elm327_vpw.py b/can_explorer/core/elm327_vpw.py new file mode 100644 index 0000000..cdd070e --- /dev/null +++ b/can_explorer/core/elm327_vpw.py @@ -0,0 +1,371 @@ +"""A live J1850 VPW transport over an ELM327/OBDLink adapter, for GM Class 2 monitoring and diagnostics. + +ELM327-compatible adapters (and the STN chips in OBDLink dongles) speak SAE J1850 VPW when set to protocol 2 +(`AT SP 2`). This module drives such an adapter over its serial link and presents a `J1850Transport` (the +`send`/`receive` coroutines `J1850Client` expects), so the Class 2 client works against a real P01/P59 PCM the +same way it does against the in-memory `Class2EcuSimulator`. + +The channel is the serial port, exactly as for the CAN `elm327` backend: a USB port (`COM4`, `/dev/ttyUSB0`), the +serial port of a paired Bluetooth adapter, or a WiFi adapter with pyserial's URL syntax (`socket://192.168.0.10:35000`). + +Monitoring and diagnostics are time-multiplexed on a worker thread, as in `elm327.Elm327Bus`: when a frame consumer +(`on_frame`) is attached the adapter monitors the bus with `AT MA` and every received frame is parsed and handed to +the consumer (for the trace and the reverse-engineering tools); when a diagnostic request is sent, monitoring is +interrupted, the header is set with `AT SH`, the payload is written (the adapter adds the J1850 CRC and transmits), +the solicited responses are collected for the client, and monitoring resumes. The adapter is half-duplex, so frames +on the bus during a request are missed — the same trade-off the CAN backend documents. Blocking serial I/O lives on +the worker thread; the coroutines hand work to it and wait without stalling the event loop. +""" + +import asyncio +import queue +import threading +import time +from collections import deque +from dataclasses import dataclass +from typing import Any, Callable, Deque, List, Optional, Tuple + +import can + +from can_explorer.core.elm327 import ( + COMMAND_TIMEOUT, + INIT_TIMEOUT, + INTERRUPT, + SERIAL_BAUDRATES, + LineReader, +) +from can_explorer.core.j1850 import J1850Error, J1850Message + +SEND_TIMEOUT = 5.0 +# SAE J1850 VPW is ELM327 protocol 2. Echo and line feeds off, spaces and headers on, so responses print as +# "48 6B 10 41 0C 0C 6B" (the three header bytes and the data); the adapter checks and removes the CRC. +INIT_COMMANDS = ('ATE0', 'ATL0', 'ATS1', 'ATH1', 'ATSP2', 'ATAL') + + +def _is_hex_byte(token: str) -> bool: + return len(token) == 2 and all(c in '0123456789ABCDEFabcdef' for c in token) + + +def parse_vpw_line(line: str) -> Optional[J1850Message]: + """A J1850 frame printed by the adapter (headers and spaces on): three header bytes then the data. Returns + None for status lines ('NO DATA', 'OK', 'STOPPED', '?', 'BUS INIT', …) and frames flagged with an error.""" + if '<' in line: # ' None: + self._serial.write(text.encode('ascii')) + + def _pump(self) -> None: + """Read what the adapter sent: prompt -> idle; lines of a running command -> collector; else a frame.""" + for event in self._reader.feed(self._serial.read(max(1, self._serial.in_waiting))): + if event is None: + self._idle = True + elif self._collect is not None: + self._collect.append(event) + else: + self._monitored(event) + + def _monitored(self, line: str) -> None: + frame = parse_vpw_line(line) + if frame is not None and self._on_frame is not None: + self._on_frame(frame) + + def _command(self, command: str, timeout: float = COMMAND_TIMEOUT) -> List[str]: + self._collect = [] + self._idle = False + self._write(command + '\r') + deadline = time.monotonic() + timeout + try: + while not self._idle: + if time.monotonic() > deadline: + raise J1850Error(f'{self.port} did not answer {command}') + self._pump() + return [line for line in self._collect if line.replace(' ', '') != command.replace(' ', '')] + finally: + self._collect = None + + def _stop_monitoring(self) -> None: + """Interrupt a running `AT MA` and wait for the prompt.""" + if self._idle: + return + for _ in range(3): + self._serial.write(INTERRUPT) + deadline = time.monotonic() + COMMAND_TIMEOUT / 2 + while not self._idle and time.monotonic() < deadline: + self._pump() + if self._idle: + return + raise J1850Error(f'The adapter on {self.port} did not stop monitoring') + + def _initialise(self, timeout: float) -> None: + baudrates = (self._serial.baudrate,) if '://' in self.port else SERIAL_BAUDRATES + for baudrate in baudrates: + self._serial.baudrate = baudrate + self._serial.write(INTERRUPT) # Stop any running monitor + time.sleep(0.2) + self._serial.reset_input_buffer() + self._reader = LineReader() + try: + lines = self._command('ATZ', timeout=timeout) + except J1850Error: + continue + self.version = next((line for line in lines if 'ELM' in line or 'STN' in line), '') or ' '.join(lines) + break + else: + raise J1850Error(f'No ELM327 or OBDLink adapter answered on {self.port}') + for command in INIT_COMMANDS: + if '?' in self._command(command): + raise J1850Error(f'The adapter on {self.port} does not support {command} (J1850 VPW)') + + def _transmit(self, message: J1850Message) -> None: + self._stop_monitoring() + header = f'{message.priority:02X}{message.target:02X}{message.source:02X}' + if header != self._header: + self._command(f'ATSH {header}') + self._header = header + data_hex = bytes(message.data).hex().upper() + self._collect = [] + self._idle = False + self._write(data_hex + '\r') + deadline = time.monotonic() + SEND_TIMEOUT + try: + while not self._idle: + if time.monotonic() > deadline: + raise J1850Error(f'{self.port} did not answer the request') + self._pump() + lines = [line for line in self._collect if line.replace(' ', '').upper() != data_hex] + finally: + self._collect = None + responses = [frame for frame in (parse_vpw_line(line) for line in lines) if frame is not None] + with self._lock: + self._responses.extend(responses) + for frame in responses: # The responses are bus frames too, so the trace sees them + if self._on_frame is not None: + self._on_frame(frame) + + def _run(self) -> None: + try: + while not self._closed: + try: + request: Optional[_Request] = self._requests.get_nowait() + except queue.Empty: + request = None + if request is not None: + try: + self._transmit(request.message) + except Exception as e: + request.error = e + request.done.set() + elif self._idle and self._on_frame is not None: + self._idle = False + self._write('ATMA\r') # Monitor all frames until a request interrupts it + elif self._idle: + time.sleep(0.01) # No frame consumer: stay at the prompt and wait for requests + else: + self._pump() + except Exception: + pass # The adapter was unplugged or the connection dropped + while True: # Fail any pending requests on close + try: + request = self._requests.get_nowait() + except queue.Empty: + break + request.error = J1850Error(f'{self.port} is closed') + request.done.set() + + # J1850Transport interface + + def submit(self, message: J1850Message, timeout: float = SEND_TIMEOUT) -> None: + """Transmit a message on the worker and wait for it to be sent (blocking). Used by the async `send` and by + `Elm327VpwBus`, which drives the same adapter from python-can's synchronous send.""" + if self._closed or not self._worker.is_alive(): + raise J1850Error(f'{self.port} is closed') + request = _Request(message, threading.Event()) + self._requests.put(request) + if not request.done.wait(timeout): + raise J1850Error(f'Sending on {self.port} timed out') + if request.error is not None: + raise J1850Error(str(request.error)) + + async def send(self, message: J1850Message) -> None: + await asyncio.get_running_loop().run_in_executor(None, self.submit, message) + + async def receive(self, timeout: Optional[float] = None) -> J1850Message: + with self._lock: + if self._responses: + return self._responses.popleft() + raise asyncio.TimeoutError('No J1850 response') + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self._worker.is_alive(): + self._worker.join(1) + try: + self._serial.write(INTERRUPT) # Leave the adapter at the prompt + except Exception: + pass + self._serial.close() + + +def message_to_vpw(msg: can.Message) -> J1850Message: + """A python-can frame back to a J1850 message: the extended id holds the three header bytes.""" + arb = msg.arbitration_id + return J1850Message((arb >> 16) & 0xFF, (arb >> 8) & 0xFF, arb & 0xFF, bytes(msg.data)) + + +def vpw_to_message(frame: J1850Message, channel: Optional[str] = None) -> can.Message: + """A J1850 message as a python-can frame: the header (priority, target, source) packed into the extended + arbitration id, the payload as the data. Payloads over eight bytes are marked CAN FD so they fit.""" + data = bytes(frame.data) + return can.Message( + timestamp=time.time(), + arbitration_id=(frame.priority << 16) | (frame.target << 8) | frame.source, + is_extended_id=True, + is_fd=len(data) > 8, + dlc=len(data), + data=data, + channel=channel, + is_rx=True, + ) + + +class SessionJ1850Transport: + """A `J1850Transport` over a `BusSession` that carries VPW pseudo-CAN frames (an `Elm327VpwBus` connection). + + Diagnostics run over the connection's existing session — the request is sent as a pseudo-CAN frame and the + responses are read from the session's frame stream — so the single adapter is shared with monitoring rather + than opened a second time. The session carries all bus traffic, so `J1850Client` matches its response by + content.""" + + def __init__(self, session: Any): + self.session = session + self._received: asyncio.Queue = asyncio.Queue() + session.add_message_listener(self._on_message) + + def close(self) -> None: + self.session.remove_message_listener(self._on_message) + + def _on_message(self, msg: can.Message) -> None: + if msg.is_rx and msg.is_extended_id and len(msg.data): + self._received.put_nowait(message_to_vpw(msg)) + + async def send(self, message: J1850Message) -> None: + arbitration_id = (message.priority << 16) | (message.target << 8) | message.source + self.session.send(can.Message(arbitration_id=arbitration_id, is_extended_id=True, data=bytes(message.data))) + + async def receive(self, timeout: Optional[float] = None) -> J1850Message: + return await asyncio.wait_for(self._received.get(), timeout) + + +class Elm327VpwBus(can.BusABC): + """The GM Class 2 (SAE J1850 VPW) bus at the OBD port, as a python-can interface ("elm327_vpw"), so the whole + application — the connection dialog, trace, dashboard, reverse engineering and recording — works on a VPW bus + like on any CAN interface. Each VPW frame is a python-can frame with the three header bytes in the extended + arbitration id and the payload as the data; the adapter is monitored and sent to through the one serial link. + + This is not real CAN: it is fixed at 10.4 kbit/s (the bitrate is ignored), it is not CAN FD, and the id is a + J1850 header, not an arbitration id. Diagnostics (`J1850Client`) run over the connection's session, so no + second connection to the adapter is opened.""" + + def __init__( + self, + channel: str, + receive_own_messages: bool = False, + fd: bool = False, + **kwargs: Any, + ): + if fd: + raise can.CanInterfaceNotImplementedError('J1850 VPW is classic Class 2, not CAN FD; turn off CAN FD') + self.port = str(channel) + self.receive_own_messages = receive_own_messages + self._queue: queue.Queue = queue.Queue() + try: + self._transport = Elm327VpwTransport(channel, on_frame=self._on_frame) + except J1850Error as e: + raise can.CanInitializationError(str(e)) from e + name = f'ELM327 J1850 VPW on {self.port}' + self.channel_info = f'{self._transport.version} ({name})' if self._transport.version else name + super().__init__(channel=channel, **kwargs) + + def _on_frame(self, frame: J1850Message) -> None: # Called from the transport's worker thread + self._queue.put(vpw_to_message(frame, self.port)) + + def _recv_internal(self, timeout: Optional[float]) -> Tuple[Optional[can.Message], bool]: + try: + item = self._queue.get(timeout=timeout) if timeout is None or timeout > 0 else self._queue.get_nowait() + except queue.Empty: + return None, False + if isinstance(item, Exception): + raise item + return item, False + + def send(self, msg: can.Message, timeout: Optional[float] = None) -> None: + try: + self._transport.submit(message_to_vpw(msg), timeout or SEND_TIMEOUT) + except J1850Error as e: + raise can.CanOperationError(str(e)) from e + if self.receive_own_messages: + echo = msg.__copy__() + echo.timestamp, echo.is_rx, echo.channel = time.time(), False, self.port + self._queue.put(echo) + + def shutdown(self) -> None: + self._transport.close() + super().shutdown() diff --git a/can_explorer/core/ethernet.py b/can_explorer/core/ethernet.py new file mode 100644 index 0000000..be3db55 --- /dev/null +++ b/can_explorer/core/ethernet.py @@ -0,0 +1,546 @@ +"""Automotive Ethernet capture and decoding: read/write pcap files and decode frames down the stack (Ethernet, +VLAN, IPv4/IPv6, ARP, UDP/TCP) and the automotive protocols on top — SOME/IP (reusing someip.py), DoIP, gPTP +(IEEE 802.1AS), AVTP (IEEE 1722) and LLDP. Qt-free, so it is tested directly and reused by the GUI. + +Both the classic libpcap format and pcapng (Wireshark's default) are read, for the Ethernet link type. The +stateless `decode()` dissects one frame; `StreamDecoder` decodes a sequence, reassembling SOME/IP carried over TCP +(across segments) and SOME/IP-TP (across datagrams) so a message split across packets is decoded in full. +""" + +import socket +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterator, List, Optional, Sequence, Tuple + +from can_explorer.core import doip, someip +from can_explorer.core import gptp as ptp # Aliased so it does not clash with the EthernetPacket.gptp field + +# EtherTypes +ETH_IPV4 = 0x0800 +ETH_ARP = 0x0806 +ETH_VLAN = 0x8100 +ETH_IPV6 = 0x86DD +ETH_PTP = 0x88F7 # gPTP / IEEE 802.1AS +ETH_AVTP = 0x22F0 # IEEE 1722 +ETH_LLDP = 0x88CC + +ETHERTYPE_NAMES = {ETH_ARP: 'ARP', ETH_PTP: 'gPTP', ETH_AVTP: 'AVTP', ETH_LLDP: 'LLDP'} + +PTP_MESSAGE_TYPES = { + 0x0: 'Sync', + 0x1: 'Delay_Req', + 0x2: 'Pdelay_Req', + 0x3: 'Pdelay_Resp', + 0x8: 'Follow_Up', + 0x9: 'Delay_Resp', + 0xA: 'Pdelay_Resp_Follow_Up', + 0xB: 'Announce', + 0xC: 'Signaling', + 0xD: 'Management', +} + +AVTP_SUBTYPES = { + 0x00: '61883/IIDC', + 0x02: 'AAF (audio)', + 0x03: 'CVF (compressed video)', + 0x04: 'CRF (clock reference)', + 0x6E: 'NTSCF', + 0x82: 'ADP (discovery)', + 0xFB: 'AECP', + 0xFC: 'ACMP', + 0xFE: 'MAAP', +} + +DOIP_PAYLOAD_TYPES = { + 0x0000: 'generic NACK', + 0x0001: 'vehicle identification request', + 0x0004: 'vehicle announcement', + 0x0005: 'routing activation request', + 0x0006: 'routing activation response', + 0x0007: 'alive check request', + 0x0008: 'alive check response', + 0x4001: 'entity status request', + 0x4002: 'entity status response', + 0x8001: 'diagnostic message', + 0x8002: 'diagnostic positive ack', + 0x8003: 'diagnostic negative ack', +} + + +class PcapError(ValueError): + pass + + +@dataclass +class RawPacket: + timestamp: float + data: bytes + + +@dataclass +class EthernetPacket: + """A decoded Ethernet frame, flattened for a packet list.""" + + number: int = 0 + timestamp: float = 0.0 + length: int = 0 + src_mac: str = '' + dst_mac: str = '' + vlan: Optional[int] = None + l3: str = '' # 'IPv4', 'IPv6', 'ARP', '' … + src: str = '' # L3 address (IP), or '' for non-IP + dst: str = '' + l4: str = '' # 'UDP', 'TCP', 'ICMP', '' + src_port: Optional[int] = None + dst_port: Optional[int] = None + protocol: str = '' # The highest recognised layer: 'SOME/IP', 'DoIP', 'gPTP', 'AVTP', 'ARP', 'UDP', … + info: str = '' + gptp: Optional[ptp.GptpMessage] = None # The parsed gPTP message, for the time-sync summary + + +# --- pcap file reading and writing (classic libpcap, Ethernet link type) --- + +_PCAP_MAGICS = { + 0xA1B2C3D4: ('>', 1e-6), # Big endian, microseconds + 0xD4C3B2A1: ('<', 1e-6), # Little endian, microseconds + 0xA1B23C4D: ('>', 1e-9), # Big endian, nanoseconds + 0x4D3CB24D: ('<', 1e-9), + 0x4D3CB2A1: ('<', 1e-9), # Little endian, nanoseconds +} +LINKTYPE_ETHERNET = 1 + + +_PCAPNG_MAGIC = b'\x0a\x0d\x0d\x0a' # Section Header Block type (also the file's first bytes) + + +def read_pcap(path: str | Path) -> Iterator[RawPacket]: + """Yield the packets of a pcap or pcapng file (Ethernet link type). Raises PcapError on an unsupported file.""" + data = Path(path).read_bytes() + if len(data) < 24: + raise PcapError('File too short to be a capture') + if data[:4] == _PCAPNG_MAGIC: + yield from _read_pcapng(data) + return + magic = struct.unpack('>I', data[:4])[0] + if magic not in _PCAP_MAGICS: + raise PcapError('Not a pcap file (bad magic number)') + endian, resolution = _PCAP_MAGICS[magic] + link_type = struct.unpack(endian + 'I', data[20:24])[0] + if link_type != LINKTYPE_ETHERNET: + raise PcapError(f'Unsupported link type {link_type} (only Ethernet is supported)') + record = struct.Struct(endian + 'IIII') + offset = 24 + while offset + record.size <= len(data): + ts_sec, ts_frac, incl_len, _orig_len = record.unpack_from(data, offset) + offset += record.size + if offset + incl_len > len(data): + break + yield RawPacket(ts_sec + ts_frac * resolution, data[offset : offset + incl_len]) + offset += incl_len + + +def _pcapng_tsresol(options: bytes, endian: str) -> float: + """The if_tsresol option (code 9) of an Interface Description Block: the seconds-per-timestamp-unit. Default + 1e-6 (microseconds).""" + offset = 0 + while offset + 4 <= len(options): + code, length = struct.unpack_from(endian + 'HH', options, offset) + value = options[offset + 4 : offset + 4 + length] + offset += 4 + length + (-length % 4) # Options are padded to 32 bits + if code == 0: # opt_endofopt + break + if code == 9 and value: # if_tsresol + raw = value[0] + return 2.0 ** -(raw & 0x7F) if raw & 0x80 else 10.0 ** -(raw & 0x7F) + return 1e-6 + + +def _read_pcapng(data: bytes) -> Iterator[RawPacket]: + """Yield the Ethernet packets of a pcapng file. Interfaces that are not Ethernet are skipped; a file with no + Ethernet interface raises PcapError.""" + offset = 0 + endian = '<' + interfaces: List[float] = [] # tsresol per interface id, in the order their IDBs appear + linktypes: List[int] = [] + saw_ethernet = False + while offset + 12 <= len(data): + block_type = struct.unpack_from(endian + 'I', data, offset)[0] if offset else 0x0A0D0D0A + if offset == 0 or block_type == 0x0A0D0D0A: # Section Header Block: sets the byte order for the section + byte_order_magic = struct.unpack_from('' + interfaces, linktypes = [], [] + total_length = struct.unpack_from(endian + 'I', data, offset + 4)[0] + if total_length < 12 or offset + total_length > len(data): + break + body = data[offset + 8 : offset + total_length - 4] + block_type = struct.unpack_from(endian + 'I', data, offset)[0] + if block_type == 0x00000001: # Interface Description Block + link_type = struct.unpack_from(endian + 'H', body, 0)[0] + linktypes.append(link_type) + interfaces.append(_pcapng_tsresol(body[8:], endian)) + saw_ethernet = saw_ethernet or link_type == LINKTYPE_ETHERNET + elif block_type == 0x00000006 and len(body) >= 20: # Enhanced Packet Block + iface, ts_high, ts_low, incl_len = struct.unpack_from(endian + 'IIII', body, 0) + if iface < len(linktypes) and linktypes[iface] == LINKTYPE_ETHERNET: + resolution = interfaces[iface] + timestamp = ((ts_high << 32) | ts_low) * resolution + yield RawPacket(timestamp, bytes(body[20 : 20 + incl_len])) + elif block_type == 0x00000003 and len(body) >= 4: # Simple Packet Block (uses interface 0, no timestamp) + if linktypes and linktypes[0] == LINKTYPE_ETHERNET: + orig_len = struct.unpack_from(endian + 'I', body, 0)[0] + yield RawPacket(0.0, bytes(body[4 : 4 + orig_len])) + offset += total_length + if not saw_ethernet: + raise PcapError('No Ethernet interface in the pcapng file (only Ethernet is supported)') + + +def write_pcap(path: str | Path, packets: Sequence[RawPacket]) -> None: + """Write packets to a classic pcap file (little endian, microseconds, Ethernet).""" + with Path(path).open('wb') as file: + file.write(struct.pack(' str: + return ':'.join(f'{byte:02x}' for byte in data) + + +def decode(data: bytes, timestamp: float = 0.0, number: int = 0) -> EthernetPacket: + """Decode one Ethernet frame into an EthernetPacket (never raises; unknown layers are left blank).""" + packet = EthernetPacket(number=number, timestamp=timestamp, length=len(data)) + if len(data) < 14: + packet.protocol = 'short' + return packet + packet.dst_mac, packet.src_mac = _mac(data[0:6]), _mac(data[6:12]) + ethertype = struct.unpack('>H', data[12:14])[0] + offset = 14 + if ethertype == ETH_VLAN and len(data) >= 18: + packet.vlan = struct.unpack('>H', data[14:16])[0] & 0x0FFF + ethertype = struct.unpack('>H', data[16:18])[0] + offset = 18 + try: + _decode_l3(packet, ethertype, data, offset) + except (struct.error, IndexError, ValueError): + pass + if not packet.protocol: + packet.protocol = packet.l4 or packet.l3 or ETHERTYPE_NAMES.get(ethertype, f'0x{ethertype:04X}') + return packet + + +def _decode_l3(packet: EthernetPacket, ethertype: int, data: bytes, offset: int) -> None: + if ethertype == ETH_PTP: + packet.l3 = packet.protocol = 'gPTP' + message = ptp.parse(data[offset:]) + if message is not None: + message.timestamp = packet.timestamp + packet.gptp = message + packet.info = ptp.describe(message) + else: + message_type = data[offset] & 0x0F if len(data) > offset else 0 + packet.info = PTP_MESSAGE_TYPES.get(message_type, f'type {message_type}') + return + if ethertype == ETH_AVTP: + packet.l3 = packet.protocol = 'AVTP' + packet.info = _avtp_info(data[offset:]) + return + if ethertype == ETH_LLDP: + packet.l3 = packet.protocol = 'LLDP' + packet.info = _lldp_info(data[offset:]) + return + if ethertype == ETH_ARP: + packet.l3 = packet.protocol = 'ARP' + if len(data) >= offset + 28: + opcode = struct.unpack('>H', data[offset + 6 : offset + 8])[0] + sender_ip = socket.inet_ntoa(data[offset + 14 : offset + 18]) + target_ip = socket.inet_ntoa(data[offset + 24 : offset + 28]) + packet.src, packet.dst = sender_ip, target_ip + packet.info = ( + f'who has {target_ip}? tell {sender_ip}' + if opcode == 1 + else f'{sender_ip} is at {_mac(data[offset + 8 : offset + 14])}' + ) + return + if ethertype == ETH_IPV4: + _decode_ipv4(packet, data, offset) + elif ethertype == ETH_IPV6: + _decode_ipv6(packet, data, offset) + + +def _decode_ipv4(packet: EthernetPacket, data: bytes, offset: int) -> None: + if len(data) < offset + 20: + return + packet.l3 = 'IPv4' + ihl = (data[offset] & 0x0F) * 4 + protocol = data[offset + 9] + packet.src = socket.inet_ntoa(data[offset + 12 : offset + 16]) + packet.dst = socket.inet_ntoa(data[offset + 16 : offset + 20]) + _decode_l4(packet, protocol, data, offset + ihl) + + +def _decode_ipv6(packet: EthernetPacket, data: bytes, offset: int) -> None: + if len(data) < offset + 40: + return + packet.l3 = 'IPv6' + next_header = data[offset + 6] + packet.src = socket.inet_ntop(socket.AF_INET6, data[offset + 8 : offset + 24]) + packet.dst = socket.inet_ntop(socket.AF_INET6, data[offset + 24 : offset + 40]) + _decode_l4(packet, next_header, data, offset + 40) + + +def _decode_l4(packet: EthernetPacket, protocol: int, data: bytes, offset: int) -> None: + if protocol == 17 and len(data) >= offset + 8: # UDP + packet.l4 = 'UDP' + packet.src_port, packet.dst_port = struct.unpack('>HH', data[offset : offset + 4]) + _decode_application(packet, data[offset + 8 :]) + elif protocol == 6 and len(data) >= offset + 20: # TCP + packet.l4 = 'TCP' + packet.src_port, packet.dst_port = struct.unpack('>HH', data[offset : offset + 4]) + data_offset = (data[offset + 12] >> 4) * 4 + _decode_application(packet, data[offset + data_offset :]) + elif protocol in (1, 58): + packet.l4 = packet.protocol = 'ICMPv6' if protocol == 58 else 'ICMP' + + +def _decode_application(packet: EthernetPacket, payload: bytes) -> None: + ports = {packet.src_port, packet.dst_port} + if doip.PORT in ports or doip.TLS_PORT in ports: + try: + _version, payload_type, _body = doip.decode_message(payload) + packet.protocol = 'DoIP' + packet.info = DOIP_PAYLOAD_TYPES.get(payload_type, f'payload type 0x{payload_type:04X}') + return + except (doip.DoipError, ValueError, IndexError): + pass + if someip.DEFAULT_SD_PORT in ports or _looks_like_someip(payload): + messages = someip.parse_messages(payload) + if messages: + packet.protocol = 'SOME/IP' + packet.info = ' | '.join(someip.describe(message) for message in messages[:3]) + return + + +_AVTP_STREAM_SUBTYPES = {0x00, 0x02, 0x03, 0x04} # 61883/IIDC, AAF, CVF, CRF carry the common stream header + + +def _avtp_info(payload: bytes) -> str: + """Summarise an AVTP (IEEE 1722) frame: the subtype, and for stream formats the stream id and sequence number + (so lost stream packets show up as sequence gaps).""" + if not payload: + return '' + subtype = payload[0] + name = AVTP_SUBTYPES.get(subtype, f'subtype 0x{subtype:02X}') + if subtype in _AVTP_STREAM_SUBTYPES and len(payload) >= 12: + return f'{name}, stream {payload[4:12].hex()}, seq {payload[2]}' + return name + + +def _lldp_identifier(value: bytes) -> str: + """An LLDP chassis/port id TLV value (a subtype byte then the id) as a MAC, text or hex.""" + subtype, body = value[0], value[1:] + if subtype in (3, 4) and len(body) == 6: # MAC address + return _mac(body) + text = body.decode('ascii', 'replace') + return text if text.isprintable() else body.hex() + + +def _lldp_info(payload: bytes) -> str: + """Summarise an LLDP frame from its mandatory TLVs (chassis id, port id, TTL) and the system name.""" + chassis = port = system = '' + ttl: Optional[int] = None + offset = 0 + while offset + 2 <= len(payload): + header = int.from_bytes(payload[offset : offset + 2], 'big') + tlv_type, length = header >> 9, header & 0x1FF + value = payload[offset + 2 : offset + 2 + length] + offset += 2 + length + if tlv_type == 0: # End of LLDPDU + break + if tlv_type == 1 and value: # Chassis id + chassis = _lldp_identifier(value) + elif tlv_type == 2 and value: # Port id + port = _lldp_identifier(value) + elif tlv_type == 3 and len(value) >= 2: # Time to live + ttl = int.from_bytes(value[:2], 'big') + elif tlv_type == 5: # System name + system = value.decode('ascii', 'replace') + parts = [part for part in (system, f'chassis {chassis}' if chassis else '', f'port {port}' if port else '') if part] + if ttl is not None: + parts.append(f'TTL {ttl}s') + return 'LLDP: ' + (', '.join(parts) if parts else 'neighbor') + + +def _looks_like_someip(payload: bytes) -> bool: + """A conservative check that a self-framed (UDP) payload begins with a plausible SOME/IP message: protocol + version 1 and a length field consistent with the data present.""" + if len(payload) < someip.HEADER.size: + return False + _message_id, length, _request_id, protocol_version = struct.unpack_from('>IIIB', payload) + return protocol_version == 0x01 and someip.HEADER_COVERED <= length <= len(payload) + + +_MAX_SOMEIP_LENGTH = 64 * 1024 * 1024 + + +def _starts_like_someip(data: bytes) -> bool: + """Like `_looks_like_someip`, but for a TCP stream where the rest of the message is still to come: the length + field need only be sane, not already satisfied by the bytes in hand.""" + if len(data) < someip.HEADER.size: + return False + _message_id, length, _request_id, protocol_version = struct.unpack_from('>IIIB', data) + return protocol_version == 0x01 and someip.HEADER_COVERED <= length <= _MAX_SOMEIP_LENGTH + + +# --- stream reassembly (SOME/IP over TCP and SOME/IP-TP) --- + + +def _l4_stream(data: bytes) -> Optional[Tuple[str, Tuple, Optional[int], bytes]]: + """(l4, flow, seq, payload) for a TCP/UDP frame, or None. `flow` identifies one direction of a TCP connection; + `seq` is the TCP sequence number (None for UDP). The payload is bounded by the IP length, not the frame, so + Ethernet padding is excluded.""" + if len(data) < 14: + return None + ethertype = int.from_bytes(data[12:14], 'big') + offset = 14 + if ethertype == ETH_VLAN and len(data) >= 18: + ethertype = int.from_bytes(data[16:18], 'big') + offset = 18 + if ethertype == ETH_IPV4 and len(data) >= offset + 20: + ihl = (data[offset] & 0x0F) * 4 + protocol = data[offset + 9] + src, dst = data[offset + 12 : offset + 16], data[offset + 16 : offset + 20] + ip_end = offset + int.from_bytes(data[offset + 2 : offset + 4], 'big') + l4 = offset + ihl + elif ethertype == ETH_IPV6 and len(data) >= offset + 40: + protocol = data[offset + 6] + src, dst = data[offset + 8 : offset + 24], data[offset + 24 : offset + 40] + ip_end = offset + 40 + int.from_bytes(data[offset + 4 : offset + 6], 'big') + l4 = offset + 40 + else: + return None + ip_end = min(ip_end, len(data)) + if protocol == 17 and l4 + 8 <= len(data): # UDP + src_port, dst_port = struct.unpack_from('>HH', data, l4) + end = min(l4 + int.from_bytes(data[l4 + 4 : l4 + 6], 'big'), ip_end) + return ('UDP', (src, src_port, dst, dst_port), None, data[l4 + 8 : end]) + if protocol == 6 and l4 + 20 <= len(data): # TCP + src_port, dst_port = struct.unpack_from('>HH', data, l4) + seq = struct.unpack_from('>I', data, l4 + 4)[0] + start = l4 + (data[l4 + 12] >> 4) * 4 + return ('TCP', (src, src_port, dst, dst_port), seq, data[start:ip_end] if start <= ip_end else b'') + return None + + +class _TcpStream: + """One direction of a TCP connection, reassembled into a byte stream for SOME/IP framing. Sequence numbers are + treated as plain integers (a 4 GiB wrap within one flow is not handled).""" + + MAX_BUFFER = 8 * 1024 * 1024 + + def __init__(self) -> None: + self.next_seq: Optional[int] = None + self.buffer = bytearray() + self.pending: Dict[int, bytes] = {} # Out-of-order segments, by sequence number + self.is_someip: Optional[bool] = None # Decided once the first header's worth of bytes is contiguous + + def add(self, seq: int, payload: bytes) -> None: + if not payload: + return + if self.next_seq is None: + self.next_seq = seq + if seq == self.next_seq: + self.buffer += payload + self.next_seq += len(payload) + elif seq < self.next_seq: # Overlap or retransmission: keep only bytes past next_seq + tail = payload[self.next_seq - seq :] + self.buffer += tail + self.next_seq += len(tail) + elif len(self.pending) < 64: # Gap (out of order or loss): stash for later, bounded + self.pending[seq] = payload + while self.next_seq in self.pending: + segment = self.pending.pop(self.next_seq) + self.buffer += segment + self.next_seq += len(segment) + if len(self.buffer) > self.MAX_BUFFER: + del self.buffer[: -self.MAX_BUFFER] + + +class StreamDecoder: + """Decodes a sequence of frames like `decode()`, but reassembles SOME/IP carried over TCP (across segments) and + SOME/IP-TP (across datagrams), so a message split across packets is decoded on the packet that completes it and + partial packets are marked as segments. Feed frames in capture order; it also tracks the SOME/IP services + announced by Service Discovery (`services`).""" + + def __init__(self) -> None: + self._tcp: Dict[Tuple, _TcpStream] = {} + self._tp = someip.SomeIpReassembler() + self.services: Dict[Tuple[int, int], someip.Service] = {} + + def decode(self, data: bytes, timestamp: float = 0.0, number: int = 0) -> EthernetPacket: + packet = decode(data, timestamp, number) + stream = _l4_stream(data) + if stream is not None: + try: + self._reassemble(packet, *stream) + except (struct.error, IndexError, ValueError): + pass + return packet + + def _reassemble(self, packet: EthernetPacket, l4: str, flow: Tuple, seq: Optional[int], payload: bytes) -> None: + if l4 == 'UDP': + if payload and ( + someip.DEFAULT_SD_PORT in {packet.src_port, packet.dst_port} or _looks_like_someip(payload) + ): + self._surface(packet, someip.parse_messages(payload)) # A datagram is self-framed + return + stream = self._tcp.get(flow) + if stream is None: + stream = self._tcp[flow] = _TcpStream() + if stream.is_someip is False: + return + stream.add(seq, payload) + if stream.is_someip is None: + if len(stream.buffer) < someip.HEADER.size: + return # Wait for a header's worth to decide whether this flow carries SOME/IP + stream.is_someip = _starts_like_someip(bytes(stream.buffer)) + if not stream.is_someip: + stream.buffer = bytearray() # Not SOME/IP: stop buffering this flow + return + messages, consumed = someip.parse_stream(bytes(stream.buffer)) + del stream.buffer[:consumed] + if messages: + self._surface(packet, messages) + elif payload: + packet.protocol, packet.info = 'SOME/IP', '[SOME/IP segment]' + + def _surface(self, packet: EthernetPacket, messages: List[someip.SomeIpMessage]) -> None: + if not messages: + return + completed = [] + for message in messages: + reassembled = self._tp.feed(message) # SOME/IP-TP: returns None until the last segment + if reassembled is None: + continue + completed.append(reassembled) + if reassembled.is_sd: + someip.update_services(self.services, reassembled) + packet.protocol = 'SOME/IP' + if completed: + packet.info = ' | '.join(someip.describe(message) for message in completed[:3]) + else: + packet.info = '[SOME/IP-TP segment]' + + +def interfaces() -> List[str]: + """The network interface names on this machine (for live capture).""" + try: + return [name for _index, name in socket.if_nameindex()] + except (OSError, AttributeError): + return [] diff --git a/can_explorer/core/gptp.py b/can_explorer/core/gptp.py new file mode 100644 index 0000000..d63f5d6 --- /dev/null +++ b/can_explorer/core/gptp.py @@ -0,0 +1,155 @@ +"""gPTP / IEEE 802.1AS (generalized Precision Time Protocol) decoding and a time-sync summary. + +Parses the PTP common header and the message bodies used in automotive time sync, and aggregates a capture into +the sync picture an engineer wants: the grandmaster and its quality, the clocks (ports) seen, the configured and +observed Sync rate, and the peer-delay (Pdelay) activity. Qt-free; reused by the Ethernet capture tool. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +MESSAGE_TYPES = { + 0x0: 'Sync', + 0x1: 'Delay_Req', + 0x2: 'Pdelay_Req', + 0x3: 'Pdelay_Resp', + 0x8: 'Follow_Up', + 0x9: 'Delay_Resp', + 0xA: 'Pdelay_Resp_Follow_Up', + 0xB: 'Announce', + 0xC: 'Signaling', + 0xD: 'Management', +} +CLOCK_CLASSES = {6: 'synchronized to a primary reference', 7: 'holdover', 248: 'default', 255: 'slave-only'} +TIME_SOURCES = {0x10: 'atomic clock', 0x20: 'GPS', 0x30: 'terrestrial radio', 0x40: 'PTP', 0x60: 'internal oscillator'} +HEADER_SIZE = 34 + + +@dataclass +class GptpMessage: + message_type: int + type: str + domain: int + source_clock: str # Clock identity, e.g. 'aa:bb:cc:ff:fe:dd:ee:ff' + source_port: int + sequence: int + log_interval: int # log2 of the message interval in seconds + correction_ns: float + timestamp: float = 0.0 # Capture time, set by the caller + # Announce only + grandmaster: str = '' + gm_priority1: int = 0 + gm_priority2: int = 0 + gm_clock_class: int = 0 + gm_clock_accuracy: int = 0 + steps_removed: int = 0 + time_source: int = 0 + + @property + def interval_ms(self) -> Optional[float]: + return 2.0**self.log_interval * 1000.0 if -16 <= self.log_interval <= 16 else None + + +def _clock_identity(data: bytes) -> str: + return ':'.join(f'{byte:02x}' for byte in data) + + +def parse(payload: bytes) -> Optional[GptpMessage]: + """Parse a gPTP/PTP message (the Ethernet payload after the 0x88F7 ethertype). Returns None if too short.""" + if len(payload) < HEADER_SIZE: + return None + message_type = payload[0] & 0x0F + domain = payload[4] + correction = int.from_bytes(payload[8:16], 'big', signed=True) / 65536.0 # Scaled nanoseconds + source_clock = _clock_identity(payload[20:28]) + source_port = int.from_bytes(payload[28:30], 'big') + sequence = int.from_bytes(payload[30:32], 'big') + log_interval = int.from_bytes(payload[33:34], 'big', signed=True) + message = GptpMessage( + message_type=message_type, + type=MESSAGE_TYPES.get(message_type, f'type {message_type}'), + domain=domain, + source_clock=source_clock, + source_port=source_port, + sequence=sequence, + log_interval=log_interval, + correction_ns=round(correction, 3), + ) + if message_type == 0xB and len(payload) >= 64: # Announce + message.gm_priority1 = payload[47] + message.gm_clock_class = payload[48] + message.gm_clock_accuracy = payload[49] + message.gm_priority2 = payload[52] + message.grandmaster = _clock_identity(payload[53:61]) + message.steps_removed = int.from_bytes(payload[61:63], 'big') + message.time_source = payload[63] + return message + + +def describe(message: GptpMessage) -> str: + """A one-line summary for the packet list.""" + domain = f'domain {message.domain} ' if message.domain else '' + if message.message_type == 0xB: # Announce + short = message.grandmaster.rsplit(':', 3)[-1] if message.grandmaster else '?' + return f'{domain}Announce GM …{short} prio {message.gm_priority1}/{message.gm_priority2} class {message.gm_clock_class} steps {message.steps_removed}' + return f'{domain}{message.type} seq {message.sequence}' + + +@dataclass +class GptpSummary: + grandmaster: str = '' + gm_priority1: int = 0 + gm_clock_class: int = 0 + gm_time_source: int = 0 + steps_removed: int = 0 + domains: List[int] = field(default_factory=list) + clocks: List[str] = field(default_factory=list) + sync_interval_ms: Optional[float] = None + announce_interval_ms: Optional[float] = None + sync_rate_hz: Optional[float] = None + counts: Dict[str, int] = field(default_factory=dict) + + def as_dict(self) -> Dict[str, Any]: + return { + 'grandmaster': self.grandmaster, + 'gm_priority1': self.gm_priority1, + 'gm_clock_class': self.gm_clock_class, + 'gm_clock_class_name': CLOCK_CLASSES.get(self.gm_clock_class, ''), + 'gm_time_source': TIME_SOURCES.get(self.gm_time_source, ''), + 'steps_removed': self.steps_removed, + 'domains': self.domains, + 'clocks': self.clocks, + 'sync_interval_ms': self.sync_interval_ms, + 'announce_interval_ms': self.announce_interval_ms, + 'sync_rate_hz': self.sync_rate_hz, + 'counts': self.counts, + } + + +def summarize(messages: List[GptpMessage]) -> GptpSummary: + """Aggregate gPTP messages (with capture timestamps) into a time-sync summary.""" + summary = GptpSummary() + if not messages: + return summary + for message in messages: + summary.counts[message.type] = summary.counts.get(message.type, 0) + 1 + summary.domains = sorted({message.domain for message in messages}) + summary.clocks = sorted({message.source_clock for message in messages}) + + announces = [message for message in messages if message.message_type == 0xB] + if announces: + best = min(announces, key=lambda message: (message.gm_priority1, message.gm_clock_class, message.steps_removed)) + summary.grandmaster = best.grandmaster + summary.gm_priority1 = best.gm_priority1 + summary.gm_clock_class = best.gm_clock_class + summary.gm_time_source = best.time_source + summary.steps_removed = best.steps_removed + summary.announce_interval_ms = best.interval_ms + + syncs = [message for message in messages if message.message_type == 0x0] + if syncs: + summary.sync_interval_ms = syncs[-1].interval_ms + span = syncs[-1].timestamp - syncs[0].timestamp + if span > 0 and len(syncs) > 1: + summary.sync_rate_hz = round((len(syncs) - 1) / span, 3) + return summary diff --git a/can_explorer/core/j1850.py b/can_explorer/core/j1850.py new file mode 100644 index 0000000..47b4221 --- /dev/null +++ b/can_explorer/core/j1850.py @@ -0,0 +1,425 @@ +"""SAE J1850 VPW and GM "Class 2" serial data — the 10.4 kbit/s single-wire bus of 1996–2007 GM vehicles. + +The P01/P59 powertrain control modules of 1999–2006 GM trucks, vans and F-bodies (and many other GM cars of the +era) talk over J1850 VPW, not CAN. It is a different physical and data-link layer: a variable-pulse-width bus with +a three-byte header (priority, target address, source address), a payload and an SAE J1850 CRC. python-can has no +backend for it, so this is a self-contained protocol subsystem rather than something layered on a `BusSession`. + +The service layer on top is SAE J1979 (OBD-II) with GM's Class 2 addressing: mode $01 current data, $03/$04 stored +trouble codes, $09 vehicle information, and so on; the positive response mode is the request mode plus 0x40, and a +negative response is `7F `. OBD-II PID definitions are shared with `reverse.obd` so values decode the +same way everywhere. + +`J1850Client` works over any transport offering `send(message)` and `receive(timeout)` coroutines. A live transport +would wrap an ELM327/OBDLink adapter in VPW mode or a J2534 pass-thru device; `J1850Bus` is an in-memory bus so a +`Class2EcuSimulator` can answer a client without any hardware, which is what the tests and the simulation use. +""" + +import asyncio +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Protocol + +from can_explorer.core.frames import format_hex +from can_explorer.core.reverse.obd import PIDS_BY_NUMBER + +# SAE J1979 diagnostic modes (request mode -> name); the positive response mode is the request plus 0x40. +SERVICES = { + 0x01: 'ShowCurrentData', + 0x02: 'ShowFreezeFrameData', + 0x03: 'ShowStoredDTCs', + 0x04: 'ClearDTCs', + 0x05: 'OxygenSensorTestResults', + 0x06: 'OnBoardMonitoringTestResults', + 0x07: 'ShowPendingDTCs', + 0x08: 'ControlOnBoardComponent', + 0x09: 'RequestVehicleInformation', + 0x0A: 'ShowPermanentDTCs', +} + +# Negative response codes (shared with KWP2000, the subset seen on Class 2) +NEGATIVE_RESPONSE_CODES = { + 0x10: 'generalReject', + 0x11: 'serviceNotSupported', + 0x12: 'subFunctionNotSupported', + 0x21: 'busyRepeatRequest', + 0x22: 'conditionsNotCorrect', + 0x31: 'requestOutOfRange', + 0x78: 'requestCorrectlyReceived-ResponsePending', +} + +NEGATIVE_RESPONSE = 0x7F +RESPONSE_PENDING = 0x78 + +P2_TIMEOUT = 1.0 +P2_EXTENDED_TIMEOUT = 5.0 + +# Class 2 addressing. Diagnostic tools use 0xF1; the powertrain control module answers at 0x10; functional +# (all-emissions) requests go to 0x6A. Real vehicles vary, so these are the defaults, not fixed. +TESTER_ADDRESS = 0xF1 +PCM_ADDRESS = 0x10 +FUNCTIONAL_TARGET = 0x6A +DEFAULT_PRIORITY = 0x68 # Header priority/type byte of a physical diagnostic request + + +def service_name(mode: int) -> str: + return SERVICES.get(mode, f'0x{mode:02X}') + + +def nrc_name(code: int) -> str: + return NEGATIVE_RESPONSE_CODES.get(code, f'0x{code:02X}') + + +def j1850_crc(data: bytes) -> int: + """The SAE J1850 CRC-8 (polynomial 0x1D, initial 0xFF, final XOR 0xFF), over the header and payload bytes.""" + crc = 0xFF + for byte in data: + crc ^= byte + for _ in range(8): + crc = ((crc << 1) ^ 0x1D) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF + return crc ^ 0xFF + + +def format_obd_dtc(code: int) -> str: + """A two-byte OBD-II trouble code as SAE J2012 text, e.g. 0x0143 -> 'P0143', 0x0171 -> 'P0171'.""" + letter = 'PCBU'[(code >> 14) & 0x3] + return f'{letter}{(code >> 12) & 0x3}{code & 0x0FFF:03X}' + + +class J1850Error(Exception): + pass + + +class J1850NegativeResponse(J1850Error): + def __init__(self, mode: int, code: int): + self.mode, self.code = mode, code + super().__init__(f'{service_name(mode)}: negative response {nrc_name(code)} (0x{code:02X})') + + +@dataclass(frozen=True, slots=True) +class J1850Message: + """A J1850 frame: a three-byte header (priority, target, source) and a payload, carried with an SAE J1850 CRC.""" + + priority: int + target: int + source: int + data: bytes = b'' + + @property + def header(self) -> bytes: + return bytes([self.priority, self.target, self.source]) + + def encode(self) -> bytes: + """The bytes on the wire: header, payload and the trailing CRC.""" + body = self.header + bytes(self.data) + return body + bytes([j1850_crc(body)]) + + @classmethod + def decode(cls, frame: bytes) -> 'J1850Message': + """Parse a frame (header, payload and CRC). Raises J1850Error on a short frame or a bad CRC.""" + frame = bytes(frame) + if len(frame) < 4: + raise J1850Error(f'J1850 frame too short: {format_hex(frame)}') + if j1850_crc(frame[:-1]) != frame[-1]: + raise J1850Error(f'Bad J1850 CRC: {format_hex(frame)}') + return cls(frame[0], frame[1], frame[2], frame[3:-1]) + + @property + def description(self) -> str: + return f'[{self.priority:02X} {self.target:02X}←{self.source:02X}] {describe_payload(self.data)}' + + +def describe_payload(payload: bytes) -> str: + """Human-readable description of a service payload (a mode byte and its parameters, or a response).""" + payload = bytes(payload) + if not payload: + return '(empty)' + if payload[0] == NEGATIVE_RESPONSE and len(payload) >= 3: + return f'Negative response to {service_name(payload[1])}: {nrc_name(payload[2])} (0x{payload[2]:02X})' + if payload[0] >= 0x40: # A positive response: mode + 0x40 + mode = payload[0] - 0x40 + if mode == 0x01 and len(payload) >= 2 and payload[1] in PIDS_BY_NUMBER: + pid = PIDS_BY_NUMBER[payload[1]] + if len(payload) >= 2 + pid.length: + return f'{pid.name}: {pid.decode(payload[2 : 2 + pid.length]):g} {pid.unit}'.strip() + return f'{service_name(mode)} response {format_hex(payload[1:])}'.strip() + return f'{service_name(payload[0])} {format_hex(payload[1:])}'.strip() + + +describe_request = describe_payload +describe_response = describe_payload + + +@dataclass(frozen=True, slots=True) +class J1850Result: + request: bytes + response: Optional[bytes] + error: Optional[str] = None + + @property + def positive(self) -> bool: + if not self.response or self.response[0] == NEGATIVE_RESPONSE: + return False + return self.response[0] == self.request[0] + 0x40 + + @property + def description(self) -> str: + if self.error: + return self.error + if self.response is None: + return 'No response' + return describe_payload(self.response) + + +class J1850Transport(Protocol): + async def send(self, message: J1850Message) -> None: ... + + async def receive(self, timeout: Optional[float] = None) -> J1850Message: ... + + +class J1850Client: + """An SAE J1979 / GM Class 2 diagnostic client over a J1850 transport (an ELM327/J2534 adapter, or the + in-memory bus). Requests are addressed to `target`; responses addressed back to `source` are collected.""" + + def __init__( + self, + transport: J1850Transport, + source: int = TESTER_ADDRESS, + target: int = PCM_ADDRESS, + priority: int = DEFAULT_PRIORITY, + timeout: float = P2_TIMEOUT, + ): + self.transport = transport + self.source, self.target, self.priority = source, target, priority + self.timeout = timeout + + async def request(self, payload: bytes, functional: bool = False) -> J1850Result: + """Send a request and wait for the response, skipping 'response pending' answers. Never raises.""" + payload = bytes(payload) + mode = payload[0] if payload else 0 + target = FUNCTIONAL_TARGET if functional else self.target + message = J1850Message(self.priority, target, self.source, payload) + try: + await self.transport.send(message) + response = await self._response(self.timeout, mode) + while len(response) >= 3 and response[0] == NEGATIVE_RESPONSE and response[2] == RESPONSE_PENDING: + response = await self._response(P2_EXTENDED_TIMEOUT, mode) + except Exception as e: # Transport errors (timeouts, bus errors) + return J1850Result(payload, None, str(e)) + return J1850Result(payload, response) + + async def _response(self, timeout: float, request_mode: int) -> bytes: + """The payload of the response to our request, within the timeout: a positive response (mode + 0x40) or a + negative response to this mode. Our own transmissions and unrelated bus traffic (when the transport is a + monitored session carrying the whole bus) are skipped, so the right answer is matched by its content.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise asyncio.TimeoutError('No J1850 response') + message = await self.transport.receive(remaining) + if message.source == self.source: + continue # Our own transmission, echoed back + payload = bytes(message.data) + if not payload: + continue + if payload[0] == (request_mode + 0x40) & 0xFF: + return payload + if payload[0] == NEGATIVE_RESPONSE and len(payload) >= 2 and payload[1] == request_mode: + return payload + # Unrelated bus traffic (monitoring): keep waiting for our response + + async def call(self, payload: bytes, functional: bool = False) -> bytes: + """Like request(), but returns the positive response and raises J1850Error otherwise.""" + result = await self.request(payload, functional) + if result.error: + raise J1850Error(result.error) + response = result.response or b'' + if response and response[0] == NEGATIVE_RESPONSE and len(response) >= 3: + raise J1850NegativeResponse(response[1], response[2]) + if not response or response[0] != payload[0] + 0x40: + raise J1850Error(f'Unexpected response {format_hex(response)} to {describe_payload(payload)}') + return response + + async def request_pid(self, pid: int, functional: bool = True) -> bytes: + """ShowCurrentData (mode 0x01) for a PID; returns the value bytes of the answer.""" + response = await self.call(bytes([0x01, pid & 0xFF]), functional) + if len(response) < 2 or response[1] != (pid & 0xFF): + raise J1850Error(f'Response for PID 0x{response[1] if len(response) > 1 else 0:02X}, expected 0x{pid:02X}') + return response[2:] + + async def current_data(self, pid: int) -> float: + """A mode 0x01 PID decoded to its physical value through the shared OBD-II PID table.""" + if pid not in PIDS_BY_NUMBER: + raise J1850Error(f'Unknown OBD-II PID 0x{pid:02X}') + definition = PIDS_BY_NUMBER[pid] + data = await self.request_pid(pid) + if len(data) < definition.length: + raise J1850Error(f'Short data for {definition.name}') + return float(definition.decode(data[: definition.length])) + + async def read_dtcs(self, mode: int = 0x03) -> List[str]: + """Stored (0x03), pending (0x07) or permanent (0x0A) trouble codes as SAE J2012 text.""" + response = await self.call(bytes([mode]), functional=True) + codes = response[1:] + dtcs = [] + for i in range(0, len(codes) - 1, 2): + code = (codes[i] << 8) | codes[i + 1] + if code: + dtcs.append(format_obd_dtc(code)) + return dtcs + + async def clear_dtcs(self) -> None: + """ClearDTCs (mode 0x04): clear stored codes and turn off the MIL.""" + await self.call(bytes([0x04]), functional=True) + + async def vehicle_info(self, pid: int) -> bytes: + """RequestVehicleInformation (mode 0x09), e.g. PID 0x02 for the VIN.""" + return (await self.call(bytes([0x09, pid & 0xFF]), functional=True))[2:] + + +async def j1850_request(transport: J1850Transport, payload: bytes) -> J1850Result: + return await J1850Client(transport).request(payload) + + +class J1850Decoder: + """Annotates J1850 frames (for scripts and monitoring): a request if it comes from the tester, else a response.""" + + def __init__(self, tester: int = TESTER_ADDRESS): + self.tester = tester + + def describe(self, message: J1850Message) -> str: + arrow = 'J1850 →' if message.source == self.tester else 'J1850 ←' + return f'{arrow} {describe_payload(message.data)}' + + +# --- In-memory bus and a simulated Class 2 ECU, for running without hardware ------------------------------------- + + +class J1850Bus: + """A shared in-memory J1850 bus: every node sees the frames the others send, as on the real single-wire bus.""" + + def __init__(self) -> None: + self._nodes: List['J1850Node'] = [] + + def attach(self) -> 'J1850Node': + node = J1850Node(self) + self._nodes.append(node) + return node + + def _detach(self, node: 'J1850Node') -> None: + if node in self._nodes: + self._nodes.remove(node) + + def _broadcast(self, sender: 'J1850Node', message: J1850Message) -> None: + for node in self._nodes: + if node is not sender: + node._inbox.put_nowait(message) + + +class J1850Node: + """A node on a `J1850Bus`; a `J1850Transport` for a client or a simulator.""" + + def __init__(self, bus: J1850Bus) -> None: + self._bus = bus + self._inbox: asyncio.Queue = asyncio.Queue() + + async def send(self, message: J1850Message) -> None: + self._bus._broadcast(self, message) + + async def receive(self, timeout: Optional[float] = None) -> J1850Message: + if timeout is None: + return await self._inbox.get() + return await asyncio.wait_for(self._inbox.get(), timeout) + + def close(self) -> None: + self._bus._detach(self) + + +@dataclass +class Class2Server: + """GM Class 2 / OBD-II server logic. handle() returns the response payloads to send for a request payload.""" + + # Mode 0x01 current data: PID -> value bytes (decoded by the shared OBD-II PID table) + data: Dict[int, bytes] = field( + default_factory=lambda: {0x05: b'\x5a', 0x0C: b'\x0c\x6b', 0x0D: b'\x00', 0x11: b'\x40'} + ) + dtcs: List[int] = field(default_factory=lambda: [0x0143, 0x0171]) # Two-byte OBD-II codes + vehicle_info: Dict[int, bytes] = field(default_factory=lambda: {0x02: b'1GCEK14V5XZ100001'}) + requests: List[bytes] = field(default_factory=list) + + @staticmethod + def _negative(mode: int, code: int) -> List[bytes]: + return [bytes([NEGATIVE_RESPONSE, mode, code])] + + def handle(self, payload: bytes) -> List[bytes]: + payload = bytes(payload) + self.requests.append(payload) + if not payload: + return [] + mode = payload[0] + handler = { + 0x01: self._current_data, + 0x03: self._read_dtcs, + 0x04: self._clear_dtcs, + 0x09: self._vehicle_info, + }.get(mode) + if handler is None: + return self._negative(mode, 0x11) + try: + return handler(payload) + except (IndexError, ValueError): + return self._negative(mode, 0x31) # A truncated/malformed request, not a crash + + def _current_data(self, payload: bytes) -> List[bytes]: + pid = payload[1] + if pid not in self.data: + return self._negative(0x01, 0x31) + return [bytes([0x41, pid]) + self.data[pid]] + + def _read_dtcs(self, payload: bytes) -> List[bytes]: + records = b''.join(bytes([code >> 8, code & 0xFF]) for code in self.dtcs) + return [bytes([0x43]) + records] + + def _clear_dtcs(self, payload: bytes) -> List[bytes]: + self.dtcs = [] + return [bytes([0x44])] + + def _vehicle_info(self, payload: bytes) -> List[bytes]: + pid = payload[1] + if pid not in self.vehicle_info: + return self._negative(0x09, 0x31) + return [bytes([0x49, pid]) + self.vehicle_info[pid]] + + +class Class2EcuSimulator: + """A `Class2Server` answering on a `J1850Bus`, standing in for a GM P01/P59 PCM without any hardware.""" + + def __init__( + self, + bus: J1850Bus, + address: int = PCM_ADDRESS, + priority: int = DEFAULT_PRIORITY, + server: Optional[Class2Server] = None, + ): + self.address, self.priority = address, priority + self.server = server or Class2Server() + self._node = bus.attach() + self._task: Optional[asyncio.Task] = None + + def start(self) -> None: + self._task = asyncio.get_running_loop().create_task(self._run(), name='Class 2 ECU simulator') + + def stop(self) -> None: + if self._task is not None: + self._task.cancel() + self._task = None + self._node.close() + + async def _run(self) -> None: + while True: + message = await self._node.receive() + if message.target not in (self.address, FUNCTIONAL_TARGET): + continue + for response in self.server.handle(message.data): + await self._node.send(J1850Message(self.priority, message.source, self.address, response)) diff --git a/can_explorer/core/obd.py b/can_explorer/core/obd.py new file mode 100644 index 0000000..f2aa496 --- /dev/null +++ b/can_explorer/core/obd.py @@ -0,0 +1,186 @@ +"""OBD-II (SAE J1979 over ISO 15765-4): live data, trouble codes and vehicle information over CAN. + +OBD services are UDS-like — a service byte and data, answered with the service byte + 0x40 — so the client runs on +the UDS/ISO-TP stack (UdsClient over IsoTpClient). Requests go to the functional address 0x7DF (or an ECU's +0x7E0-0x7E7); answers come from 0x7E8-0x7EF. A matching ObdServer drives the simulator for use without a vehicle. + +Supported: service 01 (current data, with supported-PID discovery and the MIL/DTC status), 03 and 07 (stored and +pending trouble codes), 04 (clear) and 09 PID 02 (VIN). The current-data PIDs reuse the table in reverse/obd.py. +""" + +from dataclasses import dataclass +from typing import Dict, List, Optional + +from can_explorer.core.reverse.obd import PIDS_BY_NUMBER +from can_explorer.core.uds import UdsClient, UdsError, UdsNegativeResponse + +FUNCTIONAL_REQUEST = 0x7DF +ECU_RESPONSE = 0x7E8 +PID_BLOCKS = (0x00, 0x20, 0x40, 0x60, 0x80, 0xA0, 0xC0) # "PIDs supported" PIDs + + +def decode_dtc(high: int, low: int) -> str: + """Two bytes to a diagnostic trouble code, e.g. 0x01, 0x33 -> 'P0133'.""" + category = 'PCBU'[(high >> 6) & 0x3] + return f'{category}{(high >> 4) & 0x3}{high & 0xF:X}{low >> 4:X}{low & 0xF:X}' + + +@dataclass(frozen=True) +class Dtc: + code: str + raw: int + + +@dataclass +class ObdStatus: + mil_on: bool # Malfunction indicator lamp ("check engine") + dtc_count: int + + +@dataclass +class ObdValue: + pid: int + name: str + unit: str + value: float + + +class ObdClient: + """OBD-II client over a UDS/ISO-TP transport. Methods raise UdsError (or UdsNegativeResponse) on failure.""" + + def __init__(self, uds: UdsClient): + self._uds = uds + + async def _call(self, payload: bytes) -> bytes: + return await self._uds.call(payload) + + async def supported_pids(self) -> List[int]: + """Service 01 PID 00/20/40…: the PIDs the ECU supports, by walking the support bitmasks.""" + supported: List[int] = [] + for base in PID_BLOCKS: + try: + response = await self._call(bytes([0x01, base])) + except (UdsNegativeResponse, UdsError): + break + if len(response) < 6: + break + mask = int.from_bytes(response[2:6], 'big') + for bit in range(32): + if mask & (1 << (31 - bit)): + supported.append(base + bit + 1) + if not mask & 1: # The last bit marks whether the next block exists + break + return [pid for pid in supported if pid % 0x20 != 0] # Drop the block markers themselves + + async def read_value(self, pid: int) -> Optional[ObdValue]: + """Read and decode one service-01 PID. Returns None when it is unknown or the answer is too short.""" + definition = PIDS_BY_NUMBER.get(pid) + if definition is None: + return None + response = await self._call(bytes([0x01, pid])) + data = response[2:] + if len(data) < definition.length: + return None + return ObdValue(pid, definition.name, definition.unit, float(definition.decode(data[: definition.length]))) + + async def status(self) -> ObdStatus: + """Service 01 PID 01: the MIL state and the number of stored trouble codes.""" + response = await self._call(bytes([0x01, 0x01])) + byte_a = response[2] if len(response) > 2 else 0 + return ObdStatus(bool(byte_a & 0x80), byte_a & 0x7F) + + async def read_dtcs(self, pending: bool = False) -> List[Dtc]: + """Service 03 (stored) or 07 (pending) trouble codes.""" + response = await self._call(bytes([0x07 if pending else 0x03])) + body = response[1:] + if len(body) % 2: # ISO 15765-4 prefixes the DTC count; drop it when present + body = body[1:] + dtcs = [] + for index in range(0, len(body) - 1, 2): + raw = (body[index] << 8) | body[index + 1] + if raw: + dtcs.append(Dtc(decode_dtc(body[index], body[index + 1]), raw)) + return dtcs + + async def clear_dtcs(self) -> None: + """Service 04: clear trouble codes and turn the MIL off.""" + await self._call(b'\x04') + + async def read_vin(self) -> str: + """Service 09 PID 02: the vehicle identification number.""" + response = await self._call(bytes([0x09, 0x02])) + body = response[3:] # Skip 0x49, 0x02 and the number-of-data-items byte + return bytes(byte for byte in body if 0x20 <= byte < 0x7F).decode('ascii', 'replace').strip() + + +class ObdServer: + """OBD-II responder for the simulator: answers services 01, 03, 07, 04 and 09. handle() returns the responses + to a request (the UdsEcuSimulator sends them over ISO-TP).""" + + def __init__( + self, + values: Optional[Dict[int, bytes]] = None, + dtcs: Optional[List[int]] = None, + pending: Optional[List[int]] = None, + vin: str = '1HGCM82633A004352', + mil: bool = True, + ): + # Raw data bytes per service-01 PID (as an ECU would send after 0x41 ) + self.values: Dict[int, bytes] = ( + values + if values is not None + else { + 0x04: bytes([0xAA]), # Engine load ~67 % + 0x05: bytes([0x5A]), # Coolant 50 °C + 0x0C: bytes([0x27, 0x10]), # 2500 rpm + 0x0D: bytes([0x3C]), # 60 km/h + 0x0F: bytes([0x46]), # Intake air 30 °C + 0x11: bytes([0x40]), # Throttle ~25 % + 0x2F: bytes([0x80]), # Fuel level ~50 % + 0x42: bytes([0x30, 0xD4]), # 12.5 V + } + ) + self.dtcs = list(dtcs if dtcs is not None else [0x0133, 0x0420]) # P0133, P0420 + self.pending = list(pending if pending is not None else [0x0171]) # P0171 + self.vin = vin + self.mil = mil + + def _supported(self, base: int) -> bytes: + mask = 0 + for offset in range(1, 33): + pid = base + offset + if pid in self.values or (base == 0 and pid == 0x01): + mask |= 1 << (32 - offset) + if any(base + 0x20 < pid <= base + 0x40 for pid in self.values): + mask |= 1 # Mark that the next block exists + return bytes([0x41, base]) + mask.to_bytes(4, 'big') + + @staticmethod + def _dtc_response(service: int, codes: List[int]) -> bytes: + return bytes([service + 0x40, len(codes)]) + b''.join(code.to_bytes(2, 'big') for code in codes) + + def handle(self, request: bytes) -> List[bytes]: + if not request: + return [] + service = request[0] + if service == 0x01 and len(request) >= 2: + pid = request[1] + if pid in PID_BLOCKS: + return [self._supported(pid)] + if pid == 0x01: + byte_a = (0x80 if self.mil else 0x00) | len(self.dtcs) + return [bytes([0x41, 0x01, byte_a, 0x07, 0xFF, 0x00])] + if pid in self.values: + return [bytes([0x41, pid]) + self.values[pid]] + return [bytes([0x7F, service, 0x12])] # Sub-function not supported + if service == 0x03: + return [self._dtc_response(0x03, self.dtcs)] + if service == 0x07: + return [self._dtc_response(0x07, self.pending)] + if service == 0x04: + self.dtcs = [] + self.mil = False + return [b'\x44'] + if service == 0x09 and len(request) >= 2 and request[1] == 0x02: + return [bytes([0x49, 0x02, 0x01]) + self.vin.encode('ascii', 'replace')] + return [bytes([0x7F, service, 0x11])] # Service not supported diff --git a/can_explorer/core/protocols/__init__.py b/can_explorer/core/protocols/__init__.py index 4704741..328da03 100644 --- a/can_explorer/core/protocols/__init__.py +++ b/can_explorer/core/protocols/__init__.py @@ -3,7 +3,9 @@ from can_explorer.core.decoding import ProtocolDecoder from can_explorer.core.protocols.canopen import CanOpenDecoder from can_explorer.core.protocols.isotp import IsoTpDecoder +from can_explorer.core.protocols.j1587 import J1587Decoder from can_explorer.core.protocols.j1939 import J1939Decoder +from can_explorer.core.protocols.nmea2000 import NmeaDecoder from can_explorer.core.lin import LinDecoder from can_explorer.core.uds import UdsDecoder @@ -12,8 +14,10 @@ IsoTpDecoder.name: IsoTpDecoder, UdsDecoder.name: UdsDecoder, J1939Decoder.name: J1939Decoder, + NmeaDecoder.name: NmeaDecoder, CanOpenDecoder.name: CanOpenDecoder, LinDecoder.name: LinDecoder, + J1587Decoder.name: J1587Decoder, } diff --git a/can_explorer/core/protocols/j1587.py b/can_explorer/core/protocols/j1587.py new file mode 100644 index 0000000..05a8252 --- /dev/null +++ b/can_explorer/core/protocols/j1587.py @@ -0,0 +1,142 @@ +"""Passive SAE J1587 decoder: the application layer of the older heavy-truck diagnostic bus (engine, transmission, +brakes…). A J1587 message is a MID (the module that sent it) followed by parameters, each a PID and its data. + +J1587 runs over J1708, which is a 9600-baud serial (RS-485) bus, **not** CAN — so there is no live J1708 capture +through a CAN interface. This decoder works on J1587 message bytes: a J1708 serial log, a gateway that bridges +J1708 frames onto the bus, or the scripting console. Each frame's data is treated as one complete J1587 message +(MID, parameters and, when present, the trailing checksum). + +A subset of MIDs and PIDs is named, and the well-known parameters are scaled; the rest are shown as raw bytes. +""" + +from typing import Callable, Dict, Optional + +import can + +MID_NAMES = { + 128: 'Engine #1', + 130: 'Transmission', + 136: 'Brakes, Power Unit (ABS)', + 137: 'Brakes, Trailer (ABS)', + 140: 'Instrument Cluster', + 142: 'Vehicle Management System', + 143: 'Fuel System', + 162: 'Vehicle Navigation', + 171: 'Driver Information', + 178: 'Vehicle Security', + 181: 'Communication Unit', + 190: 'Engine #2', +} + +PID_NAMES = { + 84: 'Road Speed', + 85: 'Cruise Control Set Speed', + 91: 'Accelerator Pedal', + 92: 'Engine Load', + 100: 'Engine Oil Pressure', + 102: 'Boost Pressure', + 110: 'Engine Coolant Temperature', + 190: 'Engine Speed', + 191: 'Transmission Output Shaft Speed', + 182: 'Trip Fuel', + 245: 'Total Vehicle Distance', + 247: 'Total Engine Hours', +} + + +def _mph(data: bytes) -> str: + return f'{data[0] * 0.5:.0f} mph' if data else '—' # PID 84/85: 0.5 mph/bit + + +def _rpm(data: bytes) -> str: + return f'{int.from_bytes(data[:2], "little") * 0.25:.0f} rpm' if len(data) >= 2 else '—' # 0.25 rpm/bit + + +def _accelerator(data: bytes) -> str: + return f'{data[0] * 0.4:.0f} %' if data else '—' # PID 91: 0.4 %/bit + + +def _load(data: bytes) -> str: + return f'{data[0] * 0.5:.0f} %' if data else '—' # PID 92: 0.5 %/bit + + +# PID -> how to render its data bytes (the well-known, confidently scaled parameters only) +PID_DECODERS: Dict[int, Callable[[bytes], str]] = { + 84: _mph, + 85: _mph, + 91: _accelerator, + 92: _load, + 190: _rpm, + 191: _rpm, +} + + +def mid_label(mid: int) -> str: + name = MID_NAMES.get(mid) + return f'MID {mid}' + (f' {name}' if name else '') + + +def pid_label(pid: int) -> str: + name = PID_NAMES.get(pid) + return f'PID {pid}' + (f' {name}' if name else '') + + +def _pid_length(pid: int) -> Optional[int]: + """Bytes of a PID's data from its number: 0-127 one byte, 128-191 two bytes, 192+ variable (a count byte + precedes the data). Returns None for a variable-length PID.""" + number = pid if pid < 256 else pid - 256 + if number <= 127: + return 1 + if number <= 191: + return 2 + return None + + +def decode_j1587(data: bytes) -> Optional[str]: + """Decode one J1587 message (MID, parameters, optional trailing checksum). Returns a readable description.""" + if len(data) < 2: + return None + body = data[:-1] if len(data) >= 3 and sum(data) % 256 == 0 else data # Drop a valid trailing checksum + parts = [mid_label(body[0])] + index = 1 + while index < len(body): + pid = body[index] + index += 1 + if pid == 255: # Page-2 extension: the next byte is the PID on page 2 + if index >= len(body): + break + pid = 256 + body[index] + index += 1 + length = _pid_length(pid) + if length is None: # Variable length: a count byte precedes the data + if index >= len(body): + break + length = body[index] + index += 1 + if index + length > len(body): + parts.append(f'{pid_label(pid)}=…') + break + chunk = body[index : index + length] + index += length + decoder = PID_DECODERS.get(pid) + parts.append(f'{pid_label(pid)} {decoder(chunk)}' if decoder is not None else f'{pid_label(pid)}={chunk.hex()}') + return ', '.join(parts) + + +class J1587Decoder: + """Passive SAE J1587 decoder. Each frame's data is treated as one J1587 message (see the module docstring on + the J1708 serial transport, which is not CAN).""" + + name = 'J1587' + + def __init__(self, preview_bytes: int = 32): + self._preview = preview_bytes + + def reset(self) -> None: + pass + + def describe(self, msg: can.Message) -> Optional[str]: + data = bytes(msg.data) + if not data: + return None + return decode_j1587(data) diff --git a/can_explorer/core/protocols/nmea2000.py b/can_explorer/core/protocols/nmea2000.py new file mode 100644 index 0000000..fc93ff8 --- /dev/null +++ b/can_explorer/core/protocols/nmea2000.py @@ -0,0 +1,228 @@ +"""Passive NMEA 2000 decoder: the marine CAN protocol, built on SAE J1939 addressing (29-bit identifiers at +250 kbit/s). It reuses J1939's identifier fields and adds the NMEA 2000 PGN set, Fast Packet reassembly (NMEA +2000's own multi-frame transport, distinct from J1939's TP/BAM) and field decoding for the common PGNs. + +Only a subset of PGNs is decoded into fields; the rest are named and shown as hex. Values use NMEA 2000's standard +resolutions, with marine units (knots, degrees, metres, °C) where natural; a field at its "not available" value is +shown as '—'. +""" + +import math +from dataclasses import dataclass, field +from typing import Callable, Dict, Optional, Tuple + +import can + +from can_explorer.core.frames import format_hex +from can_explorer.core.protocols.j1939 import J1939Id + +RAD_TO_DEG = 180.0 / math.pi +MS_TO_KNOTS = 1.0 / 0.514444 + +# PGN -> name (decimal, as NMEA 2000 is conventionally written) +PGN_NAMES = { + 59392: 'ISO Acknowledgement', + 59904: 'ISO Request', + 60928: 'ISO Address Claim', + 126992: 'System Time', + 126993: 'Heartbeat', + 126996: 'Product Information', + 126998: 'Configuration Information', + 127245: 'Rudder', + 127250: 'Vessel Heading', + 127251: 'Rate of Turn', + 127257: 'Attitude', + 127258: 'Magnetic Variation', + 127488: 'Engine Parameters, Rapid Update', + 127489: 'Engine Parameters, Dynamic', + 127493: 'Transmission Parameters, Dynamic', + 127497: 'Trip Parameters, Engine', + 127505: 'Fluid Level', + 127508: 'Battery Status', + 128259: 'Speed', + 128267: 'Water Depth', + 128275: 'Distance Log', + 129025: 'Position, Rapid Update', + 129026: 'COG & SOG, Rapid Update', + 129029: 'GNSS Position Data', + 129033: 'Time & Date', + 129283: 'Cross Track Error', + 129284: 'Navigation Data', + 129285: 'Route/WP Information', + 129540: 'GNSS Sats in View', + 130306: 'Wind Data', + 130310: 'Environmental Parameters', + 130311: 'Environmental Parameters', + 130312: 'Temperature', + 130313: 'Humidity', + 130314: 'Actual Pressure', + 130316: 'Temperature, Extended Range', +} + +# PGNs that use Fast Packet (payloads longer than one 8-byte frame) +FAST_PACKET_PGNS = { + 126996, + 126998, + 127489, + 127497, + 129029, + 129284, + 129285, + 129540, + 130074, + 130577, +} + + +def _u(data: bytes, offset: int, size: int) -> Optional[int]: + """An unsigned little-endian integer, or None when it is out of the payload or at its 'not available' value.""" + if offset + size > len(data): + return None + value = int.from_bytes(data[offset : offset + size], 'little') + return None if value == (1 << (size * 8)) - 1 else value + + +def _s(data: bytes, offset: int, size: int) -> Optional[int]: + if offset + size > len(data): + return None + value = int.from_bytes(data[offset : offset + size], 'little', signed=True) + return None if value == (1 << (size * 8 - 1)) - 1 else value + + +def _fmt(value: Optional[float], unit: str = '', digits: int = 1) -> str: + return '—' if value is None else f'{value:.{digits}f}{unit}' + + +def _engine_rapid(data: bytes) -> str: + instance = data[0] if data else 0 + speed = _u(data, 1, 2) + rpm = None if speed is None else speed * 0.25 + return f'engine {instance}: {_fmt(rpm, " rpm", 0)}' + + +def _position_rapid(data: bytes) -> str: + lat, lon = _s(data, 0, 4), _s(data, 4, 4) + return f'lat {_fmt(None if lat is None else lat * 1e-7, "°", 6)}, lon {_fmt(None if lon is None else lon * 1e-7, "°", 6)}' + + +def _cog_sog_rapid(data: bytes) -> str: + cog, sog = _u(data, 2, 2), _u(data, 4, 2) + cog_deg = None if cog is None else cog * 0.0001 * RAD_TO_DEG + sog_kn = None if sog is None else sog * 0.01 * MS_TO_KNOTS + return f'COG {_fmt(cog_deg, "°")}, SOG {_fmt(sog_kn, " kn")}' + + +def _water_depth(data: bytes) -> str: + depth = _u(data, 1, 4) + return f'depth {_fmt(None if depth is None else depth * 0.01, " m", 2)}' + + +def _speed(data: bytes) -> str: + stw = _u(data, 1, 2) + return f'STW {_fmt(None if stw is None else stw * 0.01 * MS_TO_KNOTS, " kn")}' + + +def _wind(data: bytes) -> str: + speed, angle = _u(data, 1, 2), _u(data, 3, 2) + speed_kn = None if speed is None else speed * 0.01 * MS_TO_KNOTS + angle_deg = None if angle is None else angle * 0.0001 * RAD_TO_DEG + return f'wind {_fmt(speed_kn, " kn")} @ {_fmt(angle_deg, "°", 0)}' + + +def _heading(data: bytes) -> str: + heading = _u(data, 1, 2) + return f'heading {_fmt(None if heading is None else heading * 0.0001 * RAD_TO_DEG, "°")}' + + +def _battery(data: bytes) -> str: + instance = data[0] if data else 0 + voltage, current = _s(data, 1, 2), _s(data, 3, 2) + return ( + f'battery {instance}: {_fmt(None if voltage is None else voltage * 0.01, " V", 2)}, ' + f'{_fmt(None if current is None else current * 0.1, " A")}' + ) + + +FIELD_DECODERS: Dict[int, Callable[[bytes], str]] = { + 127488: _engine_rapid, + 129025: _position_rapid, + 129026: _cog_sog_rapid, + 128267: _water_depth, + 128259: _speed, + 130306: _wind, + 127250: _heading, + 127508: _battery, +} + + +@dataclass(slots=True) +class _FastPacket: + size: int + data: bytearray = field(default_factory=bytearray) + next_frame: int = 1 + + +class NmeaDecoder: + """Passive NMEA 2000 decoder: identifier fields, Fast Packet reassembly and the common PGNs.""" + + name = 'NMEA 2000' + + def __init__(self, preview_bytes: int = 32): + self._preview = preview_bytes + self._sessions: Dict[Tuple[int, int, int], _FastPacket] = {} # (source, pgn, sequence) -> session + + def reset(self) -> None: + self._sessions.clear() + + @staticmethod + def pgn_label(pgn: int) -> str: + name = PGN_NAMES.get(pgn) + return f'PGN {pgn}' + (f' {name}' if name else '') + + def describe(self, msg: can.Message) -> Optional[str]: + if not msg.is_extended_id: + return None + nmea_id = J1939Id.from_can_id(msg.arbitration_id) + pgn = nmea_id.pgn + header = f'{self.pgn_label(pgn)} SA={nmea_id.source_address} P{nmea_id.priority}' + data = bytes(msg.data) + if pgn in FAST_PACKET_PGNS: + assembled = self._fast_packet(nmea_id.source_address, pgn, data) + if assembled is None: + return f'{header}: Fast Packet …' + payload, note = assembled + detail = self._decode(pgn, payload) + return f'{header}: {note}' + (f' — {detail}' if detail else '') + detail = self._decode(pgn, data) + return f'{header}: {detail}' if detail else header + + @staticmethod + def _decode(pgn: int, payload: bytes) -> Optional[str]: + decoder = FIELD_DECODERS.get(pgn) + return decoder(payload) if decoder is not None else None + + def _fast_packet(self, source: int, pgn: int, data: bytes) -> Optional[Tuple[bytes, str]]: + """Reassemble a Fast Packet sequence. The first byte is (sequence << 5) | frame; the first frame (frame 0) + carries the total byte count in its second byte, then 6 data bytes; later frames carry 7 data bytes.""" + if not data: + return None + sequence = (data[0] >> 5) & 0x7 + frame = data[0] & 0x1F + key = (source, pgn, sequence) + if frame == 0: + if len(data) < 2: + return None + session = self._sessions[key] = _FastPacket(size=data[1]) + session.data.extend(data[2:8]) + else: + session = self._sessions.get(key) + if session is None or frame != session.next_frame: + self._sessions.pop(key, None) + return None + session.data.extend(data[1:8]) + session.next_frame += 1 + if len(session.data) >= session.size: + del self._sessions[key] + payload = bytes(session.data[: session.size]) + return payload, f'complete ({session.size} B): {format_hex(payload, self._preview)}' + return None diff --git a/can_explorer/core/reverse/console.py b/can_explorer/core/reverse/console.py new file mode 100644 index 0000000..ab11ddd --- /dev/null +++ b/can_explorer/core/reverse/console.py @@ -0,0 +1,151 @@ +"""An interactive Python console over a snapshot of the trace, exposing the reverse-engineering primitives. + +This gives a power user the same reach the MCP service (mcp/) has over a bus, and the simulation scripts +(scripting/) have over the live connection: run an ad-hoc frame processor or field hunt with the richer +primitives of ``can_explorer.core.reverse`` — ``collect``, ``analyse``, ``BitField``, ``candidates``, +``signal_groups`` — over the frames captured so far. + +It is not a sandbox. It runs arbitrary Python in-process, like the two features above; it is meant for the tool's +own user exploring their own capture, not for untrusted input. +""" + +import ast +import contextlib +import io +import traceback +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence + +import can + +from can_explorer.core.reverse.analysis import IdAnalysis, IdFrames, analyse, analyse_all, collect +from can_explorer.core.reverse.fields import BitField +from can_explorer.core.reverse.reference import Reference, candidates, correlate, read_reference_csv +from can_explorer.core.reverse.related import signal_groups + +MAX_OUTPUT = 20_000 # Characters of captured output or of a value's repr kept per run + +HELP = """Interactive reverse-engineering console. Runs Python over the frames captured so far. + +Data: + messages - the captured frames (list of can.Message) + frames - collect(messages): {(id, extended): IdFrames} with .times and .payloads + analyses - the fields found by the Automatic analysis tab (list of IdAnalysis) + fields - every FoundField of `analyses`, flattened +Helpers: + ids() - the identifier keys, sorted + frames_of(id, extended=False) - the IdFrames of one identifier (id is int or 'hex') +Primitives (can_explorer.core.reverse): + collect, analyse, analyse_all, BitField, candidates, signal_groups, correlate, read_reference_csv +The value of the last line is shown, like a Python REPL. print() also works. Type HELP for this text. + +Example: + a = analyse(frames_of('100')) + [(f.field.key, f.kind) for f in a.fields] +""" + + +@dataclass +class ConsoleResult: + output: str = '' # Captured stdout/stderr + value: str = '' # repr() of the last expression's value, when it is not None + error: str = '' # Traceback text, when the snippet raised + + @property + def ok(self) -> bool: + return not self.error + + +# The names build_namespace() puts in scope, for the console's syntax highlighting +CONSOLE_NAMES = ( + 'messages', + 'frames', + 'analyses', + 'fields', + 'ids', + 'frames_of', + 'collect', + 'analyse', + 'analyse_all', + 'BitField', + 'candidates', + 'signal_groups', + 'correlate', + 'read_reference_csv', + 'Reference', + 'can', + 'HELP', +) + + +def _key(identifier: Any, extended: bool) -> tuple: + frame_id = int(identifier, 16) if isinstance(identifier, str) else int(identifier) + return (frame_id, extended) + + +def build_namespace(messages: Sequence[can.Message], analyses: Sequence[IdAnalysis] = ()) -> Dict[str, Any]: + """The names available to a console snippet: the captured frames, the found fields and the primitives.""" + frames = collect(messages) + analyses = list(analyses) + + def ids() -> List[tuple]: + return sorted(frames, key=lambda key: (key[1], key[0])) + + def frames_of(identifier: Any, extended: bool = False) -> Optional[IdFrames]: + return frames.get(_key(identifier, extended)) + + return { + 'messages': list(messages), + 'frames': frames, + 'analyses': analyses, + 'fields': [found for analysis in analyses for found in analysis.fields], + 'ids': ids, + 'frames_of': frames_of, + 'collect': collect, + 'analyse': analyse, + 'analyse_all': analyse_all, + 'BitField': BitField, + 'candidates': candidates, + 'signal_groups': signal_groups, + 'correlate': correlate, + 'read_reference_csv': read_reference_csv, + 'Reference': Reference, + 'can': can, + 'HELP': HELP, + } + + +def _truncate(text: str) -> str: + return text if len(text) <= MAX_OUTPUT else text[:MAX_OUTPUT] + f'\n… ({len(text) - MAX_OUTPUT} more characters)' + + +def run(source: str, namespace: Dict[str, Any]) -> ConsoleResult: + """Execute a snippet against `namespace` (kept across calls, so variables persist). The value of a trailing + expression is returned as its repr, as a REPL would; stdout and stderr are captured.""" + source = source.strip() + if not source: + return ConsoleResult() + try: + tree = ast.parse(source, '', 'exec') + except SyntaxError as e: + return ConsoleResult(error=f'SyntaxError: {e.msg} (line {e.lineno})') + last_expression: Optional[ast.Expression] = None + if tree.body and isinstance(tree.body[-1], ast.Expr): + last_expression = ast.Expression(tree.body.pop().value) + ast.copy_location(last_expression, last_expression.body) + captured = io.StringIO() + value = '' + try: + with contextlib.redirect_stdout(captured), contextlib.redirect_stderr(captured): + if tree.body: + exec(compile(tree, '', 'exec'), namespace) + if last_expression is not None: + result = eval(compile(last_expression, '', 'eval'), namespace) + if result is not None: + value = _truncate(repr(result)) + except Exception: + lines = traceback.format_exc().splitlines() + # Drop the frames of this module (compile/exec), keeping the snippet's own traceback + trimmed = [line for line in lines if '' in line or not line.strip().startswith('File "')] + return ConsoleResult(output=_truncate(captured.getvalue()), error=_truncate('\n'.join(trimmed))) + return ConsoleResult(output=_truncate(captured.getvalue()), value=value) diff --git a/can_explorer/core/reverse/raster.py b/can_explorer/core/reverse/raster.py new file mode 100644 index 0000000..18cdc52 --- /dev/null +++ b/can_explorer/core/reverse/raster.py @@ -0,0 +1,75 @@ +"""A bit-transition raster: the value of every bit of one identifier's payload across the whole capture at once. + +Where the bit grid shows how often each bit changes and the temporal replay shows one frame at a time, the raster +shows the structure over time — counters ramp as diagonal stripes, constant bits are solid, toggling bits +alternate, multiplexed regions switch — in a single image. The columns are the frames (downsampled to a target +width); the rows are the bits, byte 0 at the top and the most significant bit of each byte first, as the grid +orders them. + +Each cell is 0..3: the bit's value (0 or 1) in the column's representative frame, plus a "changed" flag set when +the bit flipped anywhere within the column's frames (so a fast toggle reads as active even between samples). +""" + +from dataclasses import dataclass +from typing import List, Sequence + +MAX_COLUMNS = 1200 # Frames are bucketed into at most this many columns; the view scales the image to its width + +CELL_ZERO = 0 +CELL_ONE = 1 +CELL_ZERO_CHANGED = 2 +CELL_ONE_CHANGED = 3 + + +@dataclass +class Raster: + columns: int + rows: int # length * 8 + length: int # payload bytes + frames: int + start: float # First and last timestamp, for a time label + end: float + cells: bytes # rows * columns, row-major; each 0..3 = value | (changed << 1) + + def cell(self, row: int, column: int) -> int: + return self.cells[row * self.columns + column] + + +def build_raster(times: Sequence[float], payloads: Sequence[bytes], max_columns: int = MAX_COLUMNS) -> Raster: + """Build the raster of a list of frames (of one identifier), in time order. The payload length is the longest + frame seen; shorter frames are padded with zero bytes.""" + frames = len(payloads) + length = max((len(payload) for payload in payloads), default=0) + rows = length * 8 + if frames == 0 or rows == 0: + return Raster(0, rows, length, frames, 0.0, 0.0, b'') + + # Each frame as one integer, little-endian, so bit `position` is (value >> position) & 1 + ints: List[int] = [int.from_bytes(bytes(payload).ljust(length, b'\x00')[:length], 'little') for payload in payloads] + # Row r shows byte r // 8, bit 7 - (r % 8): the most significant bit of each byte at the top + positions = [(r // 8) * 8 + (7 - (r % 8)) for r in range(rows)] + + columns = min(max_columns, frames) + cells = bytearray(rows * columns) + previous_last = None + for column in range(columns): + low = column * frames // columns + high = (column + 1) * frames // columns + if high <= low: + high = low + 1 + representative = ints[high - 1] + # Which bits changed anywhere in this column: XOR of consecutive frames, starting from the previous + # column's representative so a change between columns is caught too + mask = 0 + previous = previous_last if previous_last is not None else ints[low] + for index in range(low, high): + mask |= previous ^ ints[index] + previous = ints[index] + previous_last = representative + for row in range(rows): + position = positions[row] + value = (representative >> position) & 1 + changed = (mask >> position) & 1 + cells[row * columns + column] = value | (changed << 1) + + return Raster(columns, rows, length, frames, times[0], times[-1], bytes(cells)) diff --git a/can_explorer/core/signals.py b/can_explorer/core/signals.py index 86f6fed..6b74071 100644 --- a/can_explorer/core/signals.py +++ b/can_explorer/core/signals.py @@ -70,8 +70,11 @@ def __init__(self, max_points: int = 200_000): self.series: Dict[str, SignalSeries] = {} self.fields: Dict[str, BitField] = {} # By key, e.g. '1A0[16|8@1+]' self.multiplexers: Dict[str, Tuple[BitField, Optional[int]]] = {} # Of multiplexed fields, by key + self.intervals: Dict[str, Tuple[int, bool]] = {} # Δt series, by key: (frame_id, extended) self._by_message: Dict[str, Set[str]] = {} self._fields_by_id: Dict[Tuple[int, bool], List[Tuple[str, BitField, Optional[BitField], Optional[int]]]] = {} + self._intervals_by_id: Dict[Tuple[int, bool], List[str]] = {} + self._interval_last: Dict[str, float] = {} # Last frame timestamp seen, per interval key self._changed = False @staticmethod @@ -135,8 +138,51 @@ def backfill(self, key: str, messages) -> None: series.append(msg.timestamp, value) self._changed = True + @staticmethod + def interval_key(frame_id: int, extended: bool) -> str: + """E.g. 'Δt 1A0' or 'Δt 18FEF1FE': the inter-frame interval series of one identifier.""" + return f'Δt {frame_id:08X}' if extended else f'Δt {frame_id:03X}' + + def track_interval(self, frame_id: int, extended: bool, unit: str = 'ms') -> str: + """Track the inter-frame interval (Δt between consecutive frames) of one identifier, as a time series.""" + key = self.interval_key(frame_id, extended) + self.untrack_key(key) + self.series[key] = SignalSeries(self.max_points, unit) + self.intervals[key] = (frame_id, extended) + self._intervals_by_id.setdefault((frame_id, extended), []).append(key) + return key + + def backfill_interval(self, key: str, messages) -> None: + """Add the intervals of a tracked identifier in earlier frames, e.g. of the trace, in time order.""" + info, series = self.intervals.get(key), self.series.get(key) + if info is None or series is None: + return + frame_id, extended = info + last: Optional[float] = None + for msg in messages: + if msg.arbitration_id == frame_id and msg.is_extended_id == extended: + if msg.is_remote_frame or msg.is_error_frame: + continue + if last is not None: + series.append(msg.timestamp, (msg.timestamp - last) * 1000.0) + last = msg.timestamp + if last is not None: + self._interval_last[key] = last + self._changed = True + def untrack_key(self, key: str) -> None: - """Stop tracking a field or signal by its key.""" + """Stop tracking a field, signal or interval by its key.""" + if key in self.intervals: + self.series.pop(key, None) + self._interval_last.pop(key, None) + frame_id, extended = self.intervals.pop(key) + id_key = (frame_id, extended) + remaining = [existing for existing in self._intervals_by_id.get(id_key, []) if existing != key] + if remaining: + self._intervals_by_id[id_key] = remaining + else: + self._intervals_by_id.pop(id_key, None) + return field = self.fields.pop(key, None) if field is not None: self.series.pop(key, None) @@ -153,18 +199,30 @@ def untrack_key(self, key: str) -> None: self.untrack(message, signal) def untrack_all(self, fields: bool = True) -> None: - """Stop tracking the database signals, and the bit fields unless `fields` is false.""" + """Stop tracking the database signals, and the bit fields and intervals unless `fields` is false.""" if fields: self.series.clear() self.fields.clear() self.multiplexers.clear() self._fields_by_id.clear() + self.intervals.clear() + self._intervals_by_id.clear() + self._interval_last.clear() else: - for key in [key for key in self.series if key not in self.fields]: + for key in [key for key in self.series if key not in self.fields and key not in self.intervals]: del self.series[key] self._by_message.clear() def update(self, msg: can.Message, database: Optional[DbcDecoder]) -> None: + if self._intervals_by_id and not msg.is_remote_frame and not msg.is_error_frame: + keys = self._intervals_by_id.get((msg.arbitration_id, msg.is_extended_id)) + if keys: + for key in keys: + last = self._interval_last.get(key) + if last is not None: + self.series[key].append(msg.timestamp, (msg.timestamp - last) * 1000.0) + self._changed = True + self._interval_last[key] = msg.timestamp if self._fields_by_id: fields = self._fields_by_id.get((msg.arbitration_id, msg.is_extended_id)) if fields and not msg.is_remote_frame and not msg.is_error_frame: diff --git a/can_explorer/core/someip.py b/can_explorer/core/someip.py index 2fb2343..4262ed8 100644 --- a/can_explorer/core/someip.py +++ b/can_explorer/core/someip.py @@ -10,6 +10,7 @@ """ import asyncio +import dataclasses import ipaddress import socket import struct @@ -127,9 +128,9 @@ def type_name(self) -> str: return name -def parse_messages(data: bytes) -> List[SomeIpMessage]: - """Every SOME/IP message in a datagram (several may be concatenated), skipping trailing bytes that are too - short to be a header.""" +def parse_stream(data: bytes) -> Tuple[List['SomeIpMessage'], int]: + """Parse as many complete SOME/IP messages as `data` holds and return them with the number of bytes consumed, + so a caller reassembling a TCP stream can keep the trailing incomplete bytes for the next segment.""" messages = [] offset = 0 while offset + HEADER.size <= len(data): @@ -142,7 +143,7 @@ def parse_messages(data: bytes) -> List[SomeIpMessage]: start = offset + HEADER.size end = start + payload_length if end > len(data): - break # Truncated + break # Truncated: wait for more of the stream messages.append( SomeIpMessage( service_id=message_id >> 16, @@ -158,7 +159,13 @@ def parse_messages(data: bytes) -> List[SomeIpMessage]: ) ) offset = end - return messages + return messages, offset + + +def parse_messages(data: bytes) -> List[SomeIpMessage]: + """Every SOME/IP message in a datagram (several may be concatenated), skipping trailing bytes that are too + short to be a header.""" + return parse_stream(data)[0] def parse_message(data: bytes) -> Optional[SomeIpMessage]: @@ -166,6 +173,44 @@ def parse_message(data: bytes) -> Optional[SomeIpMessage]: return messages[0] if messages else None +TP_HEADER_SIZE = 4 # SOME/IP-TP: a 4-byte header (28-bit offset in 16-byte units, 3 reserved bits, 1 "more" bit) + + +class SomeIpReassembler: + """Reassembles SOME/IP-TP segmented messages. feed() returns a complete message when the last segment arrives, + the message unchanged when it is not segmented, or None while earlier segments are still outstanding.""" + + def __init__(self, max_message: int = 4 * 1024 * 1024): + self._max = max_message + self._segments: Dict[Tuple[int, int, int], bytearray] = {} + + def feed(self, message: SomeIpMessage) -> Optional[SomeIpMessage]: + if not message.message_type & TP_FLAG: + return message + if len(message.payload) < TP_HEADER_SIZE: + return None + tp_header = int.from_bytes(message.payload[:TP_HEADER_SIZE], 'big') + offset = (tp_header >> 4) * 16 + more_segments = tp_header & 0x1 + segment = message.payload[TP_HEADER_SIZE:] + key = (message.message_id, message.request_id, message.message_type & ~TP_FLAG) + end = offset + len(segment) + if end > self._max: + self._segments.pop(key, None) + return None + buffer = self._segments.setdefault(key, bytearray()) + if len(buffer) < end: + buffer.extend(bytes(end - len(buffer))) + buffer[offset:end] = segment + if more_segments: + return None + self._segments.pop(key, None) + payload = bytes(buffer) + return dataclasses.replace( + message, message_type=message.message_type & ~TP_FLAG, payload=payload, length=HEADER_COVERED + len(payload) + ) + + def _parse_options(data: bytes) -> List[Optional[Endpoint]]: """The options array into endpoints (None for options that are not an IPv4/IPv6 endpoint).""" options: List[Optional[Endpoint]] = [] @@ -227,6 +272,28 @@ def parse_sd(payload: bytes) -> Tuple[int, List[SdEntry]]: return flags, entries +def update_services(services: Dict[Tuple[int, int], 'Service'], message: SomeIpMessage) -> None: + """Apply a Service Discovery message to a {(service, instance): Service} map: add or refresh offered services + and drop them on a Stop offer (TTL 0).""" + _, entries = parse_sd(message.payload) + for entry in entries: + if entry.type_id != 0x01: # Only OfferService / StopOfferService change the service landscape + continue + key = (entry.service_id, entry.instance_id) + if entry.ttl == 0: + services.pop(key, None) + continue + service = services.get(key) + if service is None: + service = services[key] = Service(entry.service_id, entry.instance_id) + service.major_version = entry.major_version + if entry.minor_version is not None: + service.minor_version = entry.minor_version + service.offers += 1 + for endpoint in entry.endpoints: + service.endpoints[str(endpoint)] = endpoint + + def describe(message: SomeIpMessage) -> str: """A one-line description of a SOME/IP message, expanding Service Discovery entries.""" if message.is_sd: @@ -303,6 +370,7 @@ def __init__(self, max_messages: int = 200_000): self.error: Optional[str] = None self._transports: List[asyncio.BaseTransport] = [] self._listeners: List[Callable[[ReceivedMessage], None]] = [] + self._reassembler = SomeIpReassembler() @property def is_open(self) -> bool: @@ -332,6 +400,9 @@ async def open(self, ports: Iterable[int], multicast: Optional[str] = DEFAULT_SD def _on_datagram(self, data: bytes, addr: Tuple[str, int], local_port: int) -> None: now = time.time() for message in parse_messages(data): + message = self._reassembler.feed(message) # Reassemble SOME/IP-TP; segments return None until complete + if message is None: + continue received = ReceivedMessage(now, f'{addr[0]}:{addr[1]}', local_port, message) self.messages.append(received) self.total += 1 @@ -341,23 +412,7 @@ def _on_datagram(self, data: bytes, addr: Tuple[str, int], local_port: int) -> N listener(received) def _update_services(self, message: SomeIpMessage) -> None: - _, entries = parse_sd(message.payload) - for entry in entries: - if entry.type_id != 0x01: # Only OfferService / StopOfferService change the service landscape - continue - key = (entry.service_id, entry.instance_id) - if entry.ttl == 0: - self.services.pop(key, None) - continue - service = self.services.get(key) - if service is None: - service = self.services[key] = Service(entry.service_id, entry.instance_id) - service.major_version = entry.major_version - if entry.minor_version is not None: - service.minor_version = entry.minor_version - service.offers += 1 - for endpoint in entry.endpoints: - service.endpoints[str(endpoint)] = endpoint + update_services(self.services, message) def clear(self) -> None: self.messages.clear() diff --git a/can_explorer/core/someip_arxml.py b/can_explorer/core/someip_arxml.py new file mode 100644 index 0000000..f578657 --- /dev/null +++ b/can_explorer/core/someip_arxml.py @@ -0,0 +1,185 @@ +"""Read SOME/IP service interfaces from an AUTOSAR ARXML file into a SomeIpDescription (see +someip_serialization.py), so message payloads can be decoded into named signals — the OEM counterpart of the +FIBEX reader (someip_fibex.py). + +AUTOSAR separates the logical SERVICE-INTERFACE (methods, events and their argument data types) from the +SOMEIP-SERVICE-INTERFACE-DEPLOYMENT, which carries the SOME/IP service id and the method/event ids and references +the interface. This reader resolves those references and the data types (via SW-BASE-TYPE and +IMPLEMENTATION-DATA-TYPE) and builds the serialization model. + +A subset is supported: scalars, fixed and variable arrays, strings and nested structures. Unions, optional (TLV) +members, bit fields and application data types are not read; an argument whose type uses one is simply not decoded. +""" + +from pathlib import Path +from typing import Dict, List, Optional +from xml.etree import ElementTree + +from can_explorer.core.someip_fibex import _strip_namespaces +from can_explorer.core.someip_serialization import ( + ArrayType, + DataType, + MessageType, + Scalar, + SomeIpDescription, + StringType, + Struct, +) + +# SW-BASE-TYPE size (bits) -> serialization scalar base, by encoding +_UINT = {8: 'uint8', 16: 'uint16', 32: 'uint32', 64: 'uint64'} +_SINT = {8: 'sint8', 16: 'sint16', 32: 'sint32', 64: 'sint64'} + + +class ArxmlError(ValueError): + pass + + +def _text(element: Optional[ElementTree.Element], path: str) -> Optional[str]: + if element is None: + return None + value = element.findtext(path) + return value.strip() if value and value.strip() else None + + +def _int(element: Optional[ElementTree.Element], path: str) -> Optional[int]: + value = _text(element, path) + try: + return int(value, 0) if value is not None else None + except ValueError: + return None + + +class _Reader: + def __init__(self, root: ElementTree.Element): + self.root = _strip_namespaces(root) + # Index every element that has a SHORT-NAME by its full AUTOSAR path, so *-REF text resolves + self.by_path: Dict[str, ElementTree.Element] = {} + self._index(self.root, '') + + def _index(self, element: ElementTree.Element, prefix: str) -> None: + for child in element: + name = child.findtext('SHORT-NAME') + path = f'{prefix}/{name.strip()}' if name and name.strip() else prefix + if name and name.strip(): + self.by_path[path] = child + self._index(child, path) + + def resolve(self, element: Optional[ElementTree.Element], ref_tag: str) -> Optional[ElementTree.Element]: + reference = element.find(ref_tag) if element is not None else None + return self.by_path.get(reference.text.strip()) if reference is not None and reference.text else None + + def base_type(self, sw_base: ElementTree.Element) -> Optional[str]: + size = _int(sw_base, 'BASE-TYPE-SIZE') + encoding = (_text(sw_base, 'BASE-TYPE-ENCODING') or '').upper() + if size is None: + return None + if 'IEEE754' in encoding or 'FLOAT' in encoding: + return 'float32' if size <= 32 else 'float64' + if size == 1 or encoding == 'BOOLEAN': + return 'bool' + return (_SINT if encoding == '2C' else _UINT).get(size) + + def datatype(self, element: ElementTree.Element, name: str) -> Optional[DataType]: + """Build the serialization type of an IMPLEMENTATION-DATA-TYPE (or TYPE-TREF target).""" + category = (_text(element, 'CATEGORY') or 'VALUE').upper() + if category == 'TYPE_REFERENCE': + target = self.resolve( + element.find('SW-DATA-DEF-PROPS/SW-DATA-DEF-PROPS-VARIANTS/SW-DATA-DEF-PROPS-CONDITIONAL'), + 'IMPLEMENTATION-DATA-TYPE-REF', + ) + return self.datatype(target, name) if target is not None else None + if category == 'STRING': + return StringType(name, 'utf-8') + if category == 'STRUCTURE': + members = [ + self._member(child) for child in element.findall('SUB-ELEMENTS/IMPLEMENTATION-DATA-TYPE-ELEMENT') + ] + return Struct(name, members) if all(member is not None for member in members) and members else None + if category == 'ARRAY': + elements = element.findall('SUB-ELEMENTS/IMPLEMENTATION-DATA-TYPE-ELEMENT') + if not elements: + return None + inner = self._member(elements[0]) + if inner is None: + return None + count = _int(elements[0], 'ARRAY-SIZE') or 0 + return ArrayType(name, inner, length_field_bits=0 if count else 32, fixed_count=count) + sw_base = self.resolve( + element.find('SW-DATA-DEF-PROPS/SW-DATA-DEF-PROPS-VARIANTS/SW-DATA-DEF-PROPS-CONDITIONAL'), + 'BASE-TYPE-REF', + ) + base = self.base_type(sw_base) if sw_base is not None else None + return Scalar(name, base) if base else None + + def _member(self, element: ElementTree.Element) -> Optional[DataType]: + name = _text(element, 'SHORT-NAME') or 'value' + category = (_text(element, 'CATEGORY') or '').upper() + if category in ('STRUCTURE', 'ARRAY', 'STRING', 'VALUE'): + return self.datatype(element, name) # Inline type definition + target = self.resolve(element, 'TYPE-TREF') + return self.datatype(target, name) if target is not None else None + + def parameters(self, prototypes: List[ElementTree.Element], name: str) -> Optional[Struct]: + members: List[DataType] = [] + for prototype in prototypes: + member_name = _text(prototype, 'SHORT-NAME') or 'arg' + target = self.resolve(prototype, 'TYPE-TREF') + if target is None: + return None + built = self.datatype(target, member_name) + if built is None: + return None + members.append(built) + return Struct(name, members) + + +def load_arxml(path: str | Path) -> SomeIpDescription: + try: + root = ElementTree.parse(path).getroot() + except ElementTree.ParseError as e: + raise ArxmlError(f'Invalid XML: {e}') from e + reader = _Reader(root) + deployments = list(reader.root.iter('SOMEIP-SERVICE-INTERFACE-DEPLOYMENT')) + if not deployments: + raise ArxmlError('No SOME/IP service interface deployments found (is this an ARXML with SOME/IP?)') + description = SomeIpDescription() + for deployment in deployments: + service_id = _int(deployment, 'SERVICE-INTERFACE-ID') + interface = reader.resolve(deployment, 'SERVICE-INTERFACE-REF') + if service_id is None or interface is None: + continue + description.service_names[service_id] = _text(interface, 'SHORT-NAME') or '' + _read_methods(reader, description, deployment, interface, service_id) + _read_events(reader, description, deployment, interface, service_id) + return description + + +def _read_methods(reader, description, deployment, interface, service_id) -> None: + for method_deployment in deployment.findall('METHOD-DEPLOYMENTS/SOMEIP-METHOD-DEPLOYMENT'): + method_id = _int(method_deployment, 'METHOD-ID') + operation = reader.resolve(method_deployment, 'METHOD-REF') + if method_id is None or operation is None: + continue + name = _text(operation, 'SHORT-NAME') or '' + arguments = operation.findall('ARGUMENTS/ARGUMENT-DATA-PROTOTYPE') + inputs = [arg for arg in arguments if (_text(arg, 'DIRECTION') or 'IN').upper() == 'IN'] + outputs = [arg for arg in arguments if (_text(arg, 'DIRECTION') or '').upper() == 'OUT'] + request = reader.parameters(inputs, name) + response = reader.parameters(outputs, name) + if request is not None: + description.add(service_id, method_id, MessageType(name, request), is_response=False) + if response is not None: + description.add(service_id, method_id, MessageType(name, response), is_response=True) + + +def _read_events(reader, description, deployment, interface, service_id) -> None: + for event_deployment in deployment.findall('EVENT-DEPLOYMENTS/SOMEIP-EVENT-DEPLOYMENT'): + event_id = _int(event_deployment, 'EVENT-ID') + prototype = reader.resolve(event_deployment, 'EVENT-REF') + if event_id is None or prototype is None: + continue + name = _text(prototype, 'SHORT-NAME') or '' + parameters = reader.parameters([prototype], name) + if parameters is not None: + description.add(service_id, event_id, MessageType(name, parameters), is_response=False) diff --git a/can_explorer/core/uds.py b/can_explorer/core/uds.py index 729151e..84e94b9 100644 --- a/can_explorer/core/uds.py +++ b/can_explorer/core/uds.py @@ -109,6 +109,20 @@ TESTER_PRESENT_INTERVAL = 2.0 +def _is_pending(response: bytes) -> bool: + return len(response) >= 3 and response[0] == NEGATIVE_RESPONSE and response[2] == RESPONSE_PENDING + + +def _is_response_to(request: bytes, response: bytes) -> bool: + """Whether `response` answers `request`: a positive response (service + 0x40) or a negative response that + echoes the request's service. Anything else is stale traffic on a shared response id.""" + if not response or not request: + return False + if response[0] == NEGATIVE_RESPONSE: + return len(response) >= 2 and response[1] == request[0] + return response[0] == request[0] + 0x40 + + class UdsTransport(Protocol): async def send(self, payload: bytes) -> None: ... @@ -263,8 +277,11 @@ async def request(self, payload: bytes) -> UdsResult: if suppressed: return UdsResult(payload, None) response = await self.transport.receive(self.timeout) - while len(response) >= 3 and response[0] == NEGATIVE_RESPONSE and response[2] == RESPONSE_PENDING: - response = await self.transport.receive(P2_EXTENDED_TIMEOUT) + while _is_pending(response) or not _is_response_to(payload, response): + # Skip "response pending", and skip a stale response that does not match this request — one left on + # a shared response id by an earlier or cancelled transaction (e.g. a live poll that was stopped). + timeout = P2_EXTENDED_TIMEOUT if _is_pending(response) else self.timeout + response = await self.transport.receive(timeout) except Exception as e: # Transport errors (timeouts, bus errors) return UdsResult(payload, None, str(e)) return UdsResult(payload, response) diff --git a/can_explorer/gui/app_state.py b/can_explorer/gui/app_state.py index d7195a9..c9bb142 100644 --- a/can_explorer/gui/app_state.py +++ b/can_explorer/gui/app_state.py @@ -24,6 +24,9 @@ 'Cyberpunk', 'Rosé', ) +# Hidden themes: valid and persisted, but kept out of the theme list until unlocked. 'Spirit' (the red diamond) +# is unlocked by the seven havens easter egg in the About dialog (see awaken_spirit). +SECRET_THEMES = ('Spirit',) DEFAULT_THEME = 'Dark' DEFAULT_MAX_TRACE_ROWS = 100_000 DEFAULT_REFRESH_INTERVAL_MS = 33 @@ -185,19 +188,35 @@ def set_preferences(self, max_trace_rows: int, refresh_interval_ms: int) -> None @pyqtProperty(str, notify=preferences_changed) def theme(self) -> str: value = self._settings.value('appearance/theme', DEFAULT_THEME) - return value if value in THEMES else DEFAULT_THEME + return value if value in THEMES or value in SECRET_THEMES else DEFAULT_THEME - @pyqtProperty('QStringList', constant=True) + @pyqtProperty('QStringList', notify=preferences_changed) def themes(self) -> List[str]: - return list(THEMES) + names = list(THEMES) + if self.spirit_unlocked: + names.extend(SECRET_THEMES) + return names + + @pyqtProperty(bool, notify=preferences_changed) + def spirit_unlocked(self) -> bool: + """Whether the hidden 'Spirit' theme has been unlocked (the seven havens easter egg).""" + return str(self._settings.value('appearance/spirit_unlocked', False)).lower() in ('true', '1') @pyqtSlot(str) def set_theme(self, theme: str) -> None: - if theme in THEMES and theme != self.theme: + if (theme in THEMES or theme in SECRET_THEMES) and theme != self.theme: self._settings.setValue('appearance/theme', theme) self._settings.sync() self.preferences_changed.emit() + @pyqtSlot() + def awaken_spirit(self) -> None: + """Easter egg: unlock the hidden 'Spirit' theme (the red diamond spirit) and switch to it.""" + self._settings.setValue('appearance/spirit_unlocked', True) + self._settings.setValue('appearance/theme', 'Spirit') + self._settings.sync() + self.preferences_changed.emit() + # Language @pyqtProperty(str, notify=language_changed) diff --git a/can_explorer/gui/bit_raster.py b/can_explorer/gui/bit_raster.py new file mode 100644 index 0000000..1cda2ae --- /dev/null +++ b/can_explorer/gui/bit_raster.py @@ -0,0 +1,162 @@ +"""A QML item painting the bit-transition raster of the selected identifier (see core/reverse/raster.py). + +The raster is built by RasterController into a core `Raster`; this item turns it into an indexed image (one pixel +per bit per column, four colours for zero/one × stable/changed) and scales it to the item, with byte separators +and labels drawn over it. The image is rebuilt only when the raster changes, not on every repaint.""" + +from typing import Optional + +from PyQt6.QtCore import QObject, QRectF, Qt, pyqtProperty, pyqtSignal +from PyQt6.QtGui import QColor, QFont, QImage, QPainter, QPen +from PyQt6.QtQml import qmlRegisterType +from PyQt6.QtQuick import QQuickPaintedItem + +MARGIN_LEFT = 34 # Room for the byte labels +MARGIN_RIGHT = 8 +MARGIN_TOP = 6 +MARGIN_BOTTOM = 18 # Room for the frame-count axis + + +class BitRaster(QQuickPaintedItem): + """Paints a RasterController's raster: bits down, frames across, byte 0 at the top.""" + + controller_changed = pyqtSignal() + style_changed = pyqtSignal() + + def __init__(self, parent: Optional[QObject] = None): + super().__init__(parent) + self._controller = None + self._colors = [QColor('#24263d'), QColor('#3498db'), QColor('#6b4a1a'), QColor('#f39c12')] + self._grid_color = QColor('#2e3150') + self._text_color = QColor('#8b8fa8') + self._image: Optional[QImage] = None + self._image_for = None # The Raster object the cached image was built from + + @pyqtProperty(QObject, notify=controller_changed) + def controller(self): + return self._controller + + @controller.setter + def controller(self, controller): + if controller is self._controller: + return + if self._controller is not None: + self._controller.changed.disconnect(self._on_changed) + self._controller = controller + if controller is not None: + controller.changed.connect(self._on_changed) + self.controller_changed.emit() + self._on_changed() + + def _on_changed(self) -> None: + self._image = None # Rebuild on the next paint + self.update() + + @pyqtProperty("QVariantList", notify=style_changed) + def colors(self): + return self._colors + + @colors.setter + def colors(self, colors): + # Expected order: zero, one, zero-changed, one-changed + if len(colors) == 4: + self._colors = [QColor(color) for color in colors] + self._image = None + self.style_changed.emit() + self.update() + + @pyqtProperty(QColor, notify=style_changed) + def gridColor(self): + return self._grid_color + + @gridColor.setter + def gridColor(self, color): + self._grid_color = QColor(color) + self.style_changed.emit() + self.update() + + @pyqtProperty(QColor, notify=style_changed) + def textColor(self): + return self._text_color + + @textColor.setter + def textColor(self, color): + self._text_color = QColor(color) + self.style_changed.emit() + self.update() + + def _raster(self): + return self._controller.raster() if self._controller is not None else None + + def _build_image(self, raster) -> Optional[QImage]: + if raster is None or raster.columns == 0 or raster.rows == 0: + return None + columns, rows = raster.columns, raster.rows + stride = (columns + 3) & ~3 # Indexed8 scanlines are 4-byte aligned + buffer = bytearray(stride * rows) + cells = raster.cells + for row in range(rows): + start = row * stride + buffer[start : start + columns] = cells[row * columns : (row + 1) * columns] + image = QImage(bytes(buffer), columns, rows, stride, QImage.Format.Format_Indexed8) + image.setColorCount(4) + for index, color in enumerate(self._colors): + image.setColor(index, color.rgb()) + return image.copy() # Own the pixels, so `buffer` can be released + + def paint(self, painter: QPainter) -> None: + width, height = self.width(), self.height() + plot = QRectF(MARGIN_LEFT, MARGIN_TOP, width - MARGIN_LEFT - MARGIN_RIGHT, height - MARGIN_TOP - MARGIN_BOTTOM) + if plot.width() < 10 or plot.height() < 10: + return + raster = self._raster() + if self._image is None or self._image_for is not raster: + self._image = self._build_image(raster) + self._image_for = raster + font = QFont(painter.font()) + font.setPixelSize(10) + painter.setFont(font) + if self._image is None: + painter.setPen(self._text_color) + painter.drawText(plot, Qt.AlignmentFlag.AlignCenter, "No frames for this identifier yet") + return + + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False) # Crisp cells + painter.drawImage(plot, self._image) + + # Byte separators and labels: each byte is 8 rows + bytes_count = raster.length + row_height = plot.height() / raster.rows + painter.setPen(QPen(self._grid_color, 1)) + for byte in range(bytes_count + 1): + y = plot.top() + byte * 8 * row_height + painter.drawLine(int(plot.left()), int(y), int(plot.right()), int(y)) + painter.setPen(self._text_color) + for byte in range(bytes_count): + y = plot.top() + (byte + 0.5) * 8 * row_height + painter.drawText( + QRectF(0, y - 7, MARGIN_LEFT - 5, 14), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + str(byte), + ) + # Frame-count axis + painter.drawText( + QRectF(plot.left(), plot.bottom() + 2, plot.width(), MARGIN_BOTTOM - 2), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop, + "frame 0", + ) + painter.drawText( + QRectF(plot.left(), plot.bottom() + 2, plot.width(), MARGIN_BOTTOM - 2), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop, + f"{raster.frames:,}", + ) + + +_registered = False + + +def register_bit_raster() -> None: + global _registered + if not _registered: + qmlRegisterType(BitRaster, "CanExplorer.Plots", 1, 0, "BitRaster") + _registered = True diff --git a/can_explorer/gui/connection_manager.py b/can_explorer/gui/connection_manager.py index 87e70c5..1b52dba 100644 --- a/can_explorer/gui/connection_manager.py +++ b/can_explorer/gui/connection_manager.py @@ -39,6 +39,7 @@ from can_explorer.gui.multibus import MultiBusController from can_explorer.gui.database_editor import DatabaseEditorController from can_explorer.gui.doip import DoipController +from can_explorer.gui.ethernet import EthernetController from can_explorer.gui.someip import SomeIpController from can_explorer.gui.gateway import GatewayController from can_explorer.gui.ethercat import EtherCatController @@ -47,14 +48,17 @@ from can_explorer.gui.bushealth import BusHealthController from can_explorer.gui.diagnostics import DiagnosticsController from can_explorer.gui.flashing import FlashController +from can_explorer.gui.obd import ObdController from can_explorer.gui.j1939 import J1939Controller from can_explorer.gui.playback import BusReplay, ReplayPlayback +from can_explorer.gui.mcp_server import McpServerController from can_explorer.gui.mqtt import MqttController from can_explorer.gui.simulation import SimulationController from can_explorer.gui.plot_view import PlotView from can_explorer.gui.reverse import ReverseController from can_explorer.gui.uds_scan import UdsScanController from can_explorer.gui.gmlan import GmlanController +from can_explorer.gui.j1850 import J1850Controller from can_explorer.gui.xcp import XcpController from can_explorer.gui.testing import TestController from can_explorer.gui.trace_tools import TraceTools @@ -139,16 +143,19 @@ def __init__( self.health = BusHealth() # Error frames, states and load over the last hour session.add_message_listener(self.health.on_message) self._diagnostics = DiagnosticsController(self, parent=self) + self._obd = ObdController(self, parent=self) self._j1939 = J1939Controller(self, parent=self) self._canopen = CanOpenController(self, parent=self) self._flashing = FlashController(self._diagnostics, parent=self) self._uds_scan = UdsScanController(self, parent=self) self._xcp = XcpController(self, parent=self) self._gmlan = GmlanController(self, parent=self) + self._j1850 = J1850Controller(self, parent=self) self._bus_health = BusHealthController(self, parent=self) self._reverse = ReverseController(self, parent=self) self._simulation = SimulationController(self, parent=self) self._mqtt = MqttController(self, parent=self) + self._mcp_server = McpServerController(self, parent=self) self._tests = TestController(self, parent=self) self._replay = ReplayPlayback(self, parent=self) self._bus_replay = BusReplay(self, parent=self) @@ -194,6 +201,12 @@ def is_lin(self) -> bool: source = self.session.source return isinstance(source, LiveSource) and source.config.is_lin + @pyqtProperty(bool, constant=True) + def is_j1850(self) -> bool: + """A GM Class 2 / SAE J1850 VPW connection (the elm327_vpw interface).""" + source = self.session.source + return isinstance(source, LiveSource) and source.config.interface == 'elm327_vpw' + @pyqtProperty(str, notify=state_changed) def state(self) -> str: return self.session.state.value @@ -365,10 +378,13 @@ def close(self): self._uds_scan.shutdown() self._xcp.shutdown() self._gmlan.shutdown() + self._j1850.shutdown() self._flashing.shutdown() + self._obd.shutdown() self._diagnostics.shutdown() self._simulation.shutdown() self._reverse.shutdown() + self._mcp_server.shutdown() self.session.stop() def set_refresh_interval(self, interval_ms: int): @@ -625,6 +641,15 @@ def track_field( self.signals_updated.emit() return key + def track_interval(self, frame_id: int, extended: bool) -> str: + """Plot an identifier's inter-frame interval (Δt) over time, from the trace so far on.""" + signals = self.session.signals + key = signals.track_interval(frame_id, extended) + signals.backfill_interval(key, (frame.message for frame in self.session.trace)) + self.tracked_signals_changed.emit() + self.signals_updated.emit() + return key + # Transmission @pyqtSlot(str, str, bool, bool, int, bool, bool, result=str) @@ -675,6 +700,11 @@ def stop_periodic(self): def diagnostics(self) -> DiagnosticsController: return self._diagnostics + @pyqtProperty(QObject, constant=True) + def obd(self) -> ObdController: + """OBD-II (SAE J1979): live data, trouble codes and the VIN.""" + return self._obd + @pyqtProperty(QObject, constant=True) def j1939(self) -> J1939Controller: """Nodes, trouble codes and sending of parameter groups on J1939 connections.""" @@ -710,6 +740,11 @@ def gmlan(self) -> GmlanController: """GMLAN (GMW3110) diagnostics.""" return self._gmlan + @pyqtProperty(QObject, constant=True) + def j1850(self) -> J1850Controller: + """SAE J1850 VPW / GM Class 2 (SAE J1979) diagnostics.""" + return self._j1850 + @pyqtProperty(QObject, constant=True) def canopen(self) -> CanOpenController: """NMT, SDO access and PDOs on CANopen connections.""" @@ -724,6 +759,11 @@ def mqtt(self) -> MqttController: """Publishing the connection to an MQTT broker.""" return self._mqtt + @pyqtProperty(QObject, constant=True) + def mcp_server(self) -> McpServerController: + """The in-app MCP server exposing this connection to an AI agent.""" + return self._mcp_server + @pyqtProperty(QObject, constant=True) def plot_view(self) -> PlotView: """Time range, cursors and charts of the signal plots.""" @@ -856,6 +896,7 @@ def __init__(self, app_state: Optional[AppState] = None, parent: Optional[QObjec self._physical_io = PhysicalIoController(parent=self) self._doip = DoipController(parent=self) self._someip = SomeIpController(parent=self) + self._ethernet = EthernetController(parent=self) self._gateway = GatewayController(self, parent=self) self._updates = UpdateController(self._app_state.settings, parent=self) self._database_editor.saved.connect(self._reload_database) @@ -971,6 +1012,11 @@ def someip(self) -> SomeIpController: """SOME/IP over Ethernet: service discovery and decoded messages.""" return self._someip + @pyqtProperty(QObject, constant=True) + def ethernet(self) -> EthernetController: + """Automotive Ethernet capture: pcap files and live capture, decoded.""" + return self._ethernet + def _reload_database(self, path: str) -> None: """Connections decoding with a database that was edited and saved use the new version.""" saved = Path(path).resolve() @@ -1216,6 +1262,7 @@ def close_all(self): self._physical_io.shutdown() self._doip.shutdown() self._someip.shutdown() + self._ethernet.shutdown() self._gateway.shutdown() def _apply_preferences(self): diff --git a/can_explorer/gui/ethernet.py b/can_explorer/gui/ethernet.py new file mode 100644 index 0000000..37f233c --- /dev/null +++ b/can_explorer/gui/ethernet.py @@ -0,0 +1,304 @@ +"""The Ethernet capture tool for QML: open a pcap file (or capture live, on Linux) and show the decoded packets +(see can_explorer.core.ethernet). Not tied to a CAN connection — it is a standalone network tool.""" + +import asyncio +import socket +import time +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtProperty, pyqtSignal, pyqtSlot + +from can_explorer.core import ethernet, gptp +from can_explorer.sys.log_handler import get_logger + +logger = get_logger(__name__) + +MAX_PACKETS = 200_000 # Packets kept in memory +DISPLAY_ROWS = 2000 # Matching rows shown in the table +REFRESH_INTERVAL_MS = 250 + + +class LiveCapture: + """A non-blocking raw-socket capture (Linux AF_PACKET), delivering decoded frames on the event loop.""" + + def __init__(self, interface: str, on_packet: Callable[[float, bytes], None]): + self._interface = interface + self._on_packet = on_packet + self._socket: Optional[socket.socket] = None + + def start(self) -> None: + if not hasattr(socket, 'AF_PACKET'): + raise OSError('Live capture needs Linux (AF_PACKET); open a pcap file instead') + sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003)) + sock.bind((self._interface, 0)) + sock.setblocking(False) + asyncio.get_running_loop().add_reader(sock.fileno(), self._read) + self._socket = sock + + def _read(self) -> None: + try: + while True: + data = self._socket.recv(65535) + self._on_packet(time.time(), data) + except BlockingIOError: + pass + except OSError as e: + logger.warning('Live capture read failed: %s', e) + self.stop() + + def stop(self) -> None: + if self._socket is not None: + try: + asyncio.get_running_loop().remove_reader(self._socket.fileno()) + except (RuntimeError, ValueError): + pass + self._socket.close() + self._socket = None + + +class EthernetController(QObject): + """Opens pcap files and (on Linux) captures live, decoding automotive Ethernet traffic.""" + + changed = pyqtSignal() # Busy, file, error, capture state + data_changed = pyqtSignal() # Packets + + def __init__(self, parent: Optional[QObject] = None): + super().__init__(parent) + self._raw: List[ethernet.RawPacket] = [] + self._decoded: List[ethernet.EthernetPacket] = [] + self._filter = '' + self._error = '' + self._status = '' + self._file = '' + self._busy = False + self._capture: Optional[LiveCapture] = None + self._decoder = ethernet.StreamDecoder() # Reassembles SOME/IP over TCP and SOME/IP-TP across packets + self._base_time: Optional[float] = None + self._timer = QTimer(self) + self._timer.setInterval(REFRESH_INTERVAL_MS) + self._timer.timeout.connect(self._flush) + self._dirty = False + + # State + + @pyqtProperty(bool, notify=changed) + def busy(self) -> bool: + return self._busy + + @pyqtProperty(bool, notify=changed) + def capturing(self) -> bool: + return self._capture is not None + + @pyqtProperty(str, notify=changed) + def file_name(self) -> str: + return self._file + + @pyqtProperty(str, notify=changed) + def error(self) -> str: + return self._error + + @pyqtProperty(str, notify=changed) + def status(self) -> str: + return self._status + + @pyqtProperty('QStringList', constant=True) + def interfaces(self) -> List[str]: + return ethernet.interfaces() + + @pyqtProperty(str, notify=changed) + def filter(self) -> str: + return self._filter + + @pyqtSlot(str) + def set_filter(self, text: str) -> None: + self._filter = text.strip() + self.changed.emit() + self.data_changed.emit() + + @pyqtProperty(int, notify=data_changed) + def count(self) -> int: + return len(self._decoded) + + @pyqtProperty('QVariantList', notify=data_changed) + def protocols(self) -> List[Dict[str, Any]]: + counts: Dict[str, int] = {} + for packet in self._decoded: + counts[packet.protocol] = counts.get(packet.protocol, 0) + 1 + return [{'protocol': name, 'count': count} for name, count in sorted(counts.items(), key=lambda item: -item[1])] + + @pyqtProperty(bool, notify=data_changed) + def has_gptp(self) -> bool: + return any(packet.gptp is not None for packet in self._decoded) + + @pyqtProperty('QVariantMap', notify=data_changed) + def gptp(self) -> Dict[str, Any]: + """A time-sync (gPTP / 802.1AS) summary of the capture: grandmaster, clocks, Sync rate and intervals.""" + messages = [packet.gptp for packet in self._decoded if packet.gptp is not None] + return gptp.summarize(messages).as_dict() if messages else {} + + @pyqtProperty(bool, notify=data_changed) + def has_someip(self) -> bool: + return bool(self._decoder.services) + + @pyqtProperty('QVariantList', notify=data_changed) + def someip_services(self) -> List[Dict[str, Any]]: + """The SOME/IP services offered in the capture's Service Discovery, for a services table.""" + rows = [] + for service in sorted(self._decoder.services.values(), key=lambda s: s.key): + rows.append( + { + 'service': f'{service.service_id:04X}', + 'instance': f'{service.instance_id:04X}', + 'version': f'{service.major_version}.{service.minor_version}', + 'endpoints': ', '.join(str(endpoint) for endpoint in service.endpoints.values()), + 'offers': service.offers, + } + ) + return rows + + @pyqtProperty('QVariantList', notify=data_changed) + def packets(self) -> List[Dict[str, Any]]: + needle = self._filter.lower() + rows = [] + for packet in self._decoded: + if needle and not self._matches(packet, needle): + continue + rows.append(self._row(packet)) + return rows[-DISPLAY_ROWS:] + + def _matches(self, packet: ethernet.EthernetPacket, needle: str) -> bool: + fields = ( + packet.protocol, + packet.src, + packet.dst, + packet.info, + str(packet.src_port or ''), + str(packet.dst_port or ''), + ) + return any(needle in field.lower() for field in fields) + + def _endpoint(self, address: str, mac: str, port: Optional[int]) -> str: + if address: + return f'{address}:{port}' if port is not None else address + return mac + + def _row(self, packet: ethernet.EthernetPacket) -> Dict[str, Any]: + base = self._base_time if self._base_time is not None else packet.timestamp + return { + 'number': packet.number, + 'time': f'{packet.timestamp - base:.6f}', + 'source': self._endpoint(packet.src, packet.src_mac, packet.src_port), + 'destination': self._endpoint(packet.dst, packet.dst_mac, packet.dst_port), + 'protocol': packet.protocol, + 'length': packet.length, + 'info': packet.info, + 'vlan': packet.vlan if packet.vlan is not None else '', + } + + # Actions + + @pyqtSlot(QUrl, result=str) + def open_pcap(self, url: QUrl) -> str: + """Load and decode a pcap file. Returns an error, or ''.""" + if self._busy: + return 'Busy' + path = url.toLocalFile() if url.isLocalFile() else url.toString() + self.stop_capture() + self._busy, self._error, self._status = True, '', 'Reading…' + self.changed.emit() + asyncio.ensure_future(self._open_pcap(path)) + return '' + + async def _open_pcap(self, path: str) -> None: + decoder = ethernet.StreamDecoder() + try: + raw = await asyncio.to_thread(lambda: list(ethernet.read_pcap(path))[:MAX_PACKETS]) + decoded = await asyncio.to_thread( + lambda: [decoder.decode(packet.data, packet.timestamp, index + 1) for index, packet in enumerate(raw)] + ) + except (ethernet.PcapError, OSError) as e: + self._error, self._status, self._busy = str(e), '', False + self.changed.emit() + return + self._decoder = decoder + self._raw, self._decoded = raw, decoded + self._base_time = raw[0].timestamp if raw else None + self._file = Path(path).name + self._status = f'{len(decoded):,} packets' + self._busy = False + self.changed.emit() + self.data_changed.emit() + + @pyqtSlot(str, result=str) + def start_capture(self, interface: str) -> str: + """Start capturing live on an interface (Linux). Returns an error, or ''.""" + if self._capture is not None: + return '' + if not interface: + return 'Choose an interface' + self.clear() + self._decoder = ethernet.StreamDecoder() + capture = LiveCapture(interface, self._on_live_packet) + try: + capture.start() + except (OSError, PermissionError) as e: + return f'Could not capture on {interface}: {e} (root/CAP_NET_RAW may be needed)' + self._capture = capture + self._file = f'live: {interface}' + self._status = 'Capturing…' + self._timer.start() + self.changed.emit() + return '' + + def _on_live_packet(self, timestamp: float, data: bytes) -> None: + if len(self._raw) >= MAX_PACKETS: + return + if self._base_time is None: + self._base_time = timestamp + self._raw.append(ethernet.RawPacket(timestamp, bytes(data))) + self._decoded.append(self._decoder.decode(bytes(data), timestamp, len(self._raw))) + self._dirty = True + + @pyqtSlot() + def stop_capture(self) -> None: + if self._capture is not None: + self._capture.stop() + self._capture = None + self._timer.stop() + self._status = f'{len(self._decoded):,} packets captured' + self._flush() + self.changed.emit() + + def _flush(self) -> None: + if self._dirty: + self._dirty = False + self.data_changed.emit() + + @pyqtSlot() + def clear(self) -> None: + self._raw = [] + self._decoded = [] + self._decoder = ethernet.StreamDecoder() + self._base_time = None + if self._capture is None: + self._file = '' + self._status = '' + self._error = '' + self.changed.emit() + self.data_changed.emit() + + @pyqtSlot(QUrl, result=str) + def export_pcap(self, url: QUrl) -> str: + """Save the captured packets to a pcap file. Returns a message.""" + if not self._raw: + return 'Nothing to save' + path = url.toLocalFile() if url.isLocalFile() else url.toString() + try: + ethernet.write_pcap(path, self._raw) + except OSError as e: + return f'Could not write {Path(path).name}: {e}' + return f'Saved {len(self._raw):,} packets to {Path(path).name}' + + def shutdown(self) -> None: + self.stop_capture() diff --git a/can_explorer/gui/j1850.py b/can_explorer/gui/j1850.py new file mode 100644 index 0000000..ea80da7 --- /dev/null +++ b/can_explorer/gui/j1850.py @@ -0,0 +1,189 @@ +"""GUI controller for SAE J1850 VPW / GM Class 2 diagnostics of a connection (the "elm327_vpw" interface). + +Diagnostics run over the connection's own session (an `Elm327VpwBus`), so the single ELM327 adapter is shared +between the live trace and these requests. The service layer is SAE J1979 (OBD-II) with GM Class 2 addressing: +current data (PIDs), stored trouble codes, the VIN and raw requests. +""" + +import asyncio +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot + +from can_explorer.core.elm327_vpw import SessionJ1850Transport +from can_explorer.core.frames import format_hex, parse_hex_bytes +from can_explorer.core.j1850 import J1850Client, J1850Error, describe_payload +from can_explorer.core.reverse.obd import PIDS, PIDS_BY_NUMBER +from can_explorer.sys.log_handler import get_logger + +logger = get_logger(__name__) + +MAX_HISTORY = 200 + + +def _parse_int(text: str, maximum: int, what: str) -> int: + value = int(str(text).strip() or '0', 16) + if not 0 <= value <= maximum: + raise ValueError(f'The {what} must be between 0 and 0x{maximum:X}') + return value + + +class J1850Controller(QObject): + """J1850 VPW / GM Class 2 (SAE J1979) diagnostics over the connection's session.""" + + changed = pyqtSignal() + + def __init__(self, connection, parent: Optional[QObject] = None): + super().__init__(parent) + self._connection = connection + self._busy = False + self._history: List[Dict[str, Any]] = [] + self._dtcs: List[Dict[str, str]] = [] + self._vin = '' + + @property + def session(self): + return self._connection.session + + # Properties + + @pyqtProperty(bool, notify=changed) + def busy(self) -> bool: + return self._busy + + @pyqtProperty('QVariantList', notify=changed) + def history(self) -> List[Dict[str, Any]]: + return list(self._history) + + @pyqtProperty('QVariantList', notify=changed) + def dtcs(self) -> List[Dict[str, str]]: + return list(self._dtcs) + + @pyqtProperty(str, notify=changed) + def vin(self) -> str: + return self._vin + + @pyqtProperty('QVariantList', constant=True) + def pids(self) -> List[Dict[str, Any]]: + return [{'number': f'{pid.number:02X}', 'name': pid.name, 'unit': pid.unit} for pid in PIDS] + + def transport_error(self) -> str: + if not self._connection.can_transmit: + return 'This connection cannot send' + if not self.session.is_open: + return 'Connect first' + return '' + + # Running requests + + def _record(self, request: bytes, response: Optional[bytes], description: str, positive: bool, error: bool): + self._history.append( + { + 'request': format_hex(request), + 'request_description': describe_payload(request), + 'response': format_hex(response) if response else '', + 'description': description, + 'positive': positive, + 'error': error, + } + ) + del self._history[:-MAX_HISTORY] + + def _start(self, request: bytes, operation: Callable[[J1850Client], Awaitable[tuple]]) -> str: + if self._busy: + return 'A request is already in progress' + error = self.transport_error() + if error: + return error + self._busy = True + self.changed.emit() + asyncio.ensure_future(self._run(request, operation)) + return '' + + async def _run(self, request: bytes, operation: Callable[[J1850Client], Awaitable[tuple]]): + transport = SessionJ1850Transport(self.session) + try: + response, description, positive = await operation(J1850Client(transport)) + self._record(request, response, description, positive, error=False) + except J1850Error as e: + self._record(request, None, str(e), positive=False, error=True) + except Exception as e: # Transport errors + logger.warning(f'J1850 request failed: {e}') + self._record(request, None, str(e), positive=False, error=True) + finally: + transport.close() + self._busy = False + self.changed.emit() + + # Services + + @pyqtSlot(str, result=str) + def read_pid(self, pid: str) -> str: + try: + number = _parse_int(pid, 0xFF, 'PID') + except ValueError as e: + return str(e) + + async def operation(client: J1850Client): + data = await client.request_pid(number) + definition = PIDS_BY_NUMBER.get(number) + if definition is not None and len(data) >= definition.length: + value = definition.decode(data[: definition.length]) + description = f'{definition.name}: {value:g} {definition.unit}'.strip() + else: + description = f'PID 0x{number:02X} = {format_hex(data)}' + return bytes([0x41, number]) + data, description, True + + return self._start(bytes([0x01, number]), operation) + + @pyqtSlot(result=str) + def read_vin(self) -> str: + async def operation(client: J1850Client): + vin = await client.vehicle_info(0x02) + self._vin = vin.decode('ascii', 'replace').strip() + return bytes([0x49, 0x02]) + vin, f'VIN: {self._vin}', True + + return self._start(bytes([0x09, 0x02]), operation) + + @pyqtSlot(result=str) + def read_dtcs(self) -> str: + async def operation(client: J1850Client): + codes = await client.read_dtcs() + self._dtcs = [{'code': code} for code in codes] + return b'', f'{len(codes)} trouble code(s)', True + + return self._start(bytes([0x03]), operation) + + @pyqtSlot(result=str) + def clear_dtcs(self) -> str: + async def operation(client: J1850Client): + await client.clear_dtcs() + self._dtcs = [] + return b'', 'Diagnostic information cleared', True + + return self._start(bytes([0x04]), operation) + + @pyqtSlot(str, result=str) + def raw_request(self, payload: str) -> str: + try: + request = parse_hex_bytes(payload) + except ValueError as e: + return str(e) + if not request: + return 'Enter the request bytes, e.g. 01 0C' + + async def operation(client: J1850Client): + result = await client.request(request) + if result.error: + raise J1850Error(result.error) + return result.response, result.description, result.positive or result.response is None + + return self._start(request, operation) + + @pyqtSlot() + def clear_history(self): + self._history = [] + self.changed.emit() + + def shutdown(self): + pass diff --git a/can_explorer/gui/mcp_server.py b/can_explorer/gui/mcp_server.py new file mode 100644 index 0000000..53e7d11 --- /dev/null +++ b/can_explorer/gui/mcp_server.py @@ -0,0 +1,116 @@ +"""The in-app MCP server of a connection: an opt-in, localhost, token-gated server that exposes this connection's +live session to an AI agent (see can_explorer.mcp.http_server). Default off; the permission tier is chosen here.""" + +import asyncio +from typing import Dict, Optional + +from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot + +from can_explorer.sys.log_handler import get_logger + +logger = get_logger(__name__) + +TIERS = ('read-only', 'diagnostics', 'control', 'send') +TIER_FLAGS: Dict[str, Dict[str, bool]] = { + 'diagnostics': {'allow_diagnostics': True}, + 'control': {'allow_control': True}, + 'send': {'allow_send': True}, +} + + +class McpServerController(QObject): + """Starts and stops the in-app MCP server for one connection. Exposes the server URL and token for the agent's + client, and the chosen permission tier. It needs the `mcp` extra; `available` says whether it is installed.""" + + changed = pyqtSignal() + + def __init__(self, connection, parent: Optional[QObject] = None): + super().__init__(parent) + self.connection = connection + self._server = None # InAppMcpServer while running + self._tier = 'read-only' + self._url = '' + self._token = '' + self._error = '' + self._starting = False + + @pyqtProperty(bool, constant=True) + def available(self) -> bool: + """Whether the MCP server can run. The `mcp` package is a core dependency, so this is always true; kept for + the QML binding.""" + return True + + @pyqtProperty(bool, notify=changed) + def running(self) -> bool: + return self._server is not None + + @pyqtProperty('QStringList', constant=True) + def tiers(self) -> list: + return list(TIERS) + + @pyqtProperty(str, notify=changed) + def tier(self) -> str: + return self._tier + + @pyqtProperty(str, notify=changed) + def url(self) -> str: + return self._url + + @pyqtProperty(str, notify=changed) + def token(self) -> str: + return self._token + + @pyqtProperty(str, notify=changed) + def error(self) -> str: + return self._error + + @pyqtSlot(str, result=str) + def start(self, tier: str) -> str: + """Start the server with the given permission tier. Returns an error, or ''.""" + if self._server is not None or self._starting: + return '' + if not self.connection.session.is_open: + return 'Connect to a bus first' + if tier in TIERS: + self._tier = tier + from can_explorer.mcp.http_server import InAppMcpServer + from can_explorer.mcp.service import CanService + + service = CanService.attach(self.connection.session, **TIER_FLAGS.get(self._tier, {})) + self._error = '' + self._starting = True + self.changed.emit() + asyncio.ensure_future(self._start(InAppMcpServer(service))) + return '' + + async def _start(self, server) -> None: + try: + url = await server.start() + except Exception as e: + logger.warning('In-app MCP server failed to start: %s', e) + self._error = str(e) + self._starting = False + self.changed.emit() + return + self._server = server + self._url = url + self._token = server.token + self._starting = False + self.changed.emit() + + @pyqtSlot() + def stop(self) -> None: + server, self._server = self._server, None + self._url = self._token = '' + self.changed.emit() + if server is not None: + asyncio.ensure_future(self._stop(server)) + + async def _stop(self, server) -> None: + try: + await server.stop() + except Exception as e: # Stopping must not raise + logger.debug('In-app MCP server stop: %s', e) + + def shutdown(self) -> None: + self.stop() diff --git a/can_explorer/gui/obd.py b/can_explorer/gui/obd.py new file mode 100644 index 0000000..c08d2ca --- /dev/null +++ b/can_explorer/gui/obd.py @@ -0,0 +1,247 @@ +"""The OBD-II (SAE J1979) page of a connection: live data, trouble codes and the VIN, over ISO-TP on CAN. + +It keeps one ISO-TP transport open while the page is used and serializes all requests through a lock, so the live +polling loop and the on-demand reads (trouble codes, clear, scan) never overlap on the bus.""" + +import asyncio +from typing import Any, Dict, List, Optional + +from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot + +from can_explorer.core.isotp_client import IsoTpClient +from can_explorer.core.obd import ECU_RESPONSE, FUNCTIONAL_REQUEST, Dtc, ObdClient, ObdValue +from can_explorer.core.reverse.obd import PIDS_BY_NUMBER +from can_explorer.core.uds import UdsClient +from can_explorer.sys.log_handler import get_logger + +logger = get_logger(__name__) + +LIVE_INTERVAL = 0.2 # Seconds between polling passes of the supported values + + +class ObdController(QObject): + """OBD-II diagnostics: supported-PID discovery, live values, trouble codes and the VIN.""" + + changed = pyqtSignal() # Busy, status, ids, supported set + data_changed = pyqtSignal() # Live values and trouble codes + + def __init__(self, connection, parent: Optional[QObject] = None): + super().__init__(parent) + self._connection = connection + self._request_id, self._response_id = FUNCTIONAL_REQUEST, ECU_RESPONSE + self._transport: Optional[IsoTpClient] = None + self._lock = asyncio.Lock() + self._busy = False + self._status = '' + self._vin = '' + self._mil = False + self._dtc_count = 0 + self._supported: List[int] = [] + self._values: Dict[int, ObdValue] = {} + self._dtcs: List[Dtc] = [] + self._pending: List[Dtc] = [] + self._live_task: Optional[asyncio.Task] = None + + @property + def session(self): + return self._connection.session + + # State + + @pyqtProperty(bool, notify=changed) + def busy(self) -> bool: + return self._busy + + @pyqtProperty(str, notify=changed) + def status(self) -> str: + return self._status + + @pyqtProperty(str, notify=changed) + def vin(self) -> str: + return self._vin + + @pyqtProperty(bool, notify=data_changed) + def mil_on(self) -> bool: + return self._mil + + @pyqtProperty(int, notify=data_changed) + def dtc_count(self) -> int: + return self._dtc_count + + @pyqtProperty(bool, notify=changed) + def scanned(self) -> bool: + return bool(self._supported) + + @pyqtProperty(str, notify=changed) + def request_id(self) -> str: + return f'{self._request_id:X}' + + @pyqtProperty(str, notify=changed) + def response_id(self) -> str: + return f'{self._response_id:X}' + + @pyqtProperty(bool, notify=changed) + def live(self) -> bool: + return self._live_task is not None and not self._live_task.done() + + @pyqtProperty('QVariantList', notify=data_changed) + def values(self) -> List[Dict[str, Any]]: + rows = [] + for pid in self._supported: + definition = PIDS_BY_NUMBER[pid] + value = self._values.get(pid) + rows.append( + { + 'pid': f'{pid:02X}', + 'name': definition.name, + 'unit': definition.unit, + 'value': '' if value is None else f'{value.value:g}', + } + ) + return rows + + @pyqtProperty('QVariantList', notify=data_changed) + def dtcs(self) -> List[Dict[str, str]]: + return [{'code': dtc.code} for dtc in self._dtcs] + + @pyqtProperty('QVariantList', notify=data_changed) + def pending_dtcs(self) -> List[Dict[str, str]]: + return [{'code': dtc.code} for dtc in self._pending] + + # Configuration + + @pyqtSlot(str, str) + def set_ids(self, request_hex: str, response_hex: str): + try: + request_id, response_id = int(request_hex, 16), int(response_hex, 16) + except ValueError: + return + self._request_id, self._response_id = request_id, response_id + self._reset_transport() + self.changed.emit() + + def _client(self) -> ObdClient: + if self._transport is None: + self._transport = IsoTpClient(self.session, self._request_id, self._response_id, fd=self._connection.fd) + return ObdClient(UdsClient(self._transport)) + + def _reset_transport(self) -> None: + self._stop_live() + if self._transport is not None: + self._transport.close() + self._transport = None + + # Actions + + @pyqtSlot() + def scan(self): + """Read the VIN and status and discover the supported PIDs.""" + if not self._busy: + asyncio.ensure_future(self._scan()) + + async def _scan(self) -> None: + if self._connection.is_lin: + self._status = 'OBD-II runs over CAN, not LIN' + self.changed.emit() + return + self._busy, self._status = True, 'Scanning the vehicle…' + self.changed.emit() + try: + async with self._lock: + client = self._client() + try: + self._vin = await client.read_vin() + except Exception: + self._vin = '' + status = await client.status() + self._mil, self._dtc_count = status.mil_on, status.dtc_count + self._supported = [pid for pid in await client.supported_pids() if pid in PIDS_BY_NUMBER] + self._status = ( + f'{len(self._supported)} supported values' if self._supported else 'Connected, no known values' + ) + except Exception as e: + logger.info('OBD scan failed: %s', e) + self._status = f'No response — check the request/response IDs ({e})' + self._reset_transport() + finally: + self._busy = False + self.changed.emit() + self.data_changed.emit() + + @pyqtSlot() + def toggle_live(self): + if self.live: + self._stop_live() + elif self._supported: + self._live_task = asyncio.ensure_future(self._poll()) + self.changed.emit() + + def _stop_live(self) -> None: + if self._live_task is not None: + self._live_task.cancel() + self._live_task = None + + async def _poll(self) -> None: + try: + while True: + for pid in list(self._supported): + async with self._lock: + try: + value = await self._client().read_value(pid) + except Exception: + value = None + if value is not None: + self._values[pid] = value + self.data_changed.emit() + await asyncio.sleep(LIVE_INTERVAL) + except asyncio.CancelledError: + pass + finally: + self.changed.emit() + + @pyqtSlot() + def read_dtcs(self): + if not self._busy: + asyncio.ensure_future(self._read_dtcs('Reading trouble codes…')) + + async def _read_dtcs(self, status: str) -> None: + self._busy, self._status = True, status + self.changed.emit() + try: + async with self._lock: + client = self._client() + self._dtcs = await client.read_dtcs() + self._pending = await client.read_dtcs(pending=True) + vehicle = await client.status() + self._mil, self._dtc_count = vehicle.mil_on, vehicle.dtc_count + self._status = f'{len(self._dtcs)} stored, {len(self._pending)} pending' + except Exception as e: + logger.info('OBD read DTCs failed: %s', e) + self._status = f'Failed ({e})' + finally: + self._busy = False + self.changed.emit() + self.data_changed.emit() + + @pyqtSlot() + def clear_dtcs(self): + if not self._busy: + asyncio.ensure_future(self._clear()) + + async def _clear(self) -> None: + self._busy, self._status = True, 'Clearing trouble codes…' + self.changed.emit() + try: + async with self._lock: + await self._client().clear_dtcs() + except Exception as e: + logger.info('OBD clear failed: %s', e) + self._status = f'Failed ({e})' + self._busy = False + self.changed.emit() + return + self._busy = False + await self._read_dtcs('Cleared — re-reading…') + + def shutdown(self) -> None: + self._reset_transport() diff --git a/can_explorer/gui/python_highlighter.py b/can_explorer/gui/python_highlighter.py new file mode 100644 index 0000000..7094c0f --- /dev/null +++ b/can_explorer/gui/python_highlighter.py @@ -0,0 +1,191 @@ +"""Python syntax highlighting for the reverse-engineering console's editor. + +A QSyntaxHighlighter attached to the editor's QTextDocument (from QML: PythonHighlighter { document: input.textDocument }). +The per-line tokenising is a pure function (single_line_spans) so it can be tested; the highlighter adds the +multi-line (triple-quoted) string handling and the colours, which come from the theme via QML properties. +""" + +import keyword +import re +from typing import List, Optional, Tuple + +from PyQt6.QtCore import pyqtProperty +from PyQt6.QtGui import QColor, QFont, QSyntaxHighlighter, QTextCharFormat +from PyQt6.QtQml import qmlRegisterType +from PyQt6.QtQuick import QQuickTextDocument + +from can_explorer.core.reverse.console import CONSOLE_NAMES + +KEYWORDS = frozenset(keyword.kwlist) | frozenset(keyword.softkwlist) +NAMES = frozenset(CONSOLE_NAMES) +STRING_PREFIXES = frozenset(['r', 'b', 'f', 'u', 'rb', 'br', 'fr', 'rf']) + +_WORD = re.compile(r'[A-Za-z_][A-Za-z0-9_]*') +_NUMBER = re.compile(r'0[xXoObB][0-9a-fA-F_]+|\d[\d_]*\.?[\d_]*(?:[eE][+-]?\d+)?[jJ]?') + +# Span kinds: 'keyword', 'name', 'number', 'string', 'comment', 'definition' +Span = Tuple[int, int, str] # (start, length, kind) + + +def single_line_spans(text: str) -> List[Span]: + """Tokenise one line into coloured spans, without multi-line (triple-quoted) strings. Scanning character by + character keeps a '#' inside a string from looking like a comment, and a keyword inside a string plain.""" + spans: List[Span] = [] + index, length = 0, len(text) + previous_word = '' + while index < length: + char = text[index] + if char == '#': + spans.append((index, length - index, 'comment')) + break + # A string, optionally with a prefix already matched as a word just before it + if char in '\'"': + end = _string_end(text, index) + start = index + if spans and spans[-1][2] == 'word-prefix': # Fold a prefix (r"", f"", …) into the string + start = spans.pop()[0] + spans.append((start, end - start, 'string')) + index = end + continue + if char.isdigit() or (char == '.' and index + 1 < length and text[index + 1].isdigit()): + match = _NUMBER.match(text, index) + if match: + spans.append((index, match.end() - index, 'number')) + index = match.end() + continue + if char.isalpha() or char == '_': + match = _WORD.match(text, index) + word, end = match.group(), match.end() + if word in KEYWORDS: + spans.append((index, end - index, 'keyword')) + elif previous_word in ('def', 'class'): + spans.append((index, end - index, 'definition')) + elif word in NAMES: + spans.append((index, end - index, 'name')) + elif word.lower() in STRING_PREFIXES and end < length and text[end] in '\'"': + spans.append((index, end - index, 'word-prefix')) # Resolved when the string follows + previous_word = word + index = end + continue + if not char.isspace(): + previous_word = '' + index += 1 + return [span for span in spans if span[2] != 'word-prefix'] # Drop any unresolved prefix marker + + +def _string_end(text: str, start: int) -> int: + """The index just past a single-line string beginning at `start` (handling backslash escapes).""" + quote = text[start] + index = start + 1 + length = len(text) + while index < length: + if text[index] == '\\': + index += 2 + continue + if text[index] == quote: + return index + 1 + index += 1 + return length # Unterminated: to the end of the line + + +class PythonHighlighter(QSyntaxHighlighter): + """Highlights Python in the document of a QML TextArea, with theme colours and triple-quoted strings.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._quick_document: Optional[QQuickTextDocument] = None + self._colors = { + 'keyword': QColor('#c678dd'), + 'name': QColor('#61afef'), + 'number': QColor('#d19a66'), + 'string': QColor('#98c379'), + 'comment': QColor('#7f848e'), + 'definition': QColor('#e5c07b'), + } + self._formats = {} + self._rebuild_formats() + + def _get_quick_document(self) -> Optional[QQuickTextDocument]: + return self._quick_document + + def _set_quick_document(self, document: Optional[QQuickTextDocument]) -> None: + if document is self._quick_document: + return + self._quick_document = document + self.setDocument(document.textDocument() if document is not None else None) + + # The QML TextArea's textDocument to highlight + quickDocument = pyqtProperty(QQuickTextDocument, _get_quick_document, _set_quick_document) + + def _set_color(self, kind: str, color) -> None: + color = QColor(color) + if self._colors.get(kind) != color: + self._colors[kind] = color + self._rebuild_formats() + self.rehighlight() + + def _rebuild_formats(self) -> None: + self._formats = {} + for kind, color in self._colors.items(): + text_format = QTextCharFormat() + text_format.setForeground(color) + if kind in ('keyword', 'definition'): + text_format.setFontWeight(QFont.Weight.DemiBold) + self._formats[kind] = text_format + + def highlightBlock(self, text: str) -> None: + for start, length, kind in single_line_spans(text): + self.setFormat(start, length, self._formats[kind]) + # Triple-quoted strings spanning blocks (overwriting the single-line result inside them) + self.setCurrentBlockState(-1) + if not self._match_triple(text, "'''", 1): + self._match_triple(text, '"""', 2) + + def _match_triple(self, text: str, delimiter: str, state: int) -> bool: + if self.previousBlockState() == state: + start, offset = 0, 0 + else: + start = text.find(delimiter) + offset = len(delimiter) + while start >= 0: + end = text.find(delimiter, start + offset) + if end >= 0: + span = end - start + offset + len(delimiter) + self.setCurrentBlockState(-1) + else: + self.setCurrentBlockState(state) + span = len(text) - start + self.setFormat(start, span, self._formats['string']) + start = text.find(delimiter, start + span) + offset = len(delimiter) + return self.currentBlockState() == state + + # Theme colours, set from QML + keywordColor = pyqtProperty( + QColor, lambda self: self._colors['keyword'], lambda self, color: self._set_color('keyword', color) + ) + nameColor = pyqtProperty( + QColor, lambda self: self._colors['name'], lambda self, color: self._set_color('name', color) + ) + numberColor = pyqtProperty( + QColor, lambda self: self._colors['number'], lambda self, color: self._set_color('number', color) + ) + stringColor = pyqtProperty( + QColor, lambda self: self._colors['string'], lambda self, color: self._set_color('string', color) + ) + commentColor = pyqtProperty( + QColor, lambda self: self._colors['comment'], lambda self, color: self._set_color('comment', color) + ) + definitionColor = pyqtProperty( + QColor, lambda self: self._colors['definition'], lambda self, color: self._set_color('definition', color) + ) + + +_registered = False + + +def register_python_highlighter() -> None: + global _registered + if not _registered: + qmlRegisterType(PythonHighlighter, 'CanExplorer.Plots', 1, 0, 'PythonHighlighter') + _registered = True diff --git a/can_explorer/gui/qt/CanExplorer/AboutDialog.qml b/can_explorer/gui/qt/CanExplorer/AboutDialog.qml index 8939a7f..2fcee32 100644 --- a/can_explorer/gui/qt/CanExplorer/AboutDialog.qml +++ b/can_explorer/gui/qt/CanExplorer/AboutDialog.qml @@ -1,22 +1,61 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import CanExplorer 1.0 Dialog { + id: dialog title: qsTr("About %1").arg(metadata.name) modal: true anchors.centerIn: parent standardButtons: Dialog.Ok + // The seven havens easter egg: seven clicks on the logo awaken the hidden red diamond "Spirit" theme. + property int spiritClicks: 0 + onOpened: spiritClicks = 0 + ColumnLayout { spacing: 12 RowLayout { spacing: 16 Logo { + id: aboutLogo Layout.preferredWidth: 80 Layout.preferredHeight: 80 + transformOrigin: Item.Center + + // A springy bounce on every click. + SequentialAnimation { + id: bounce + NumberAnimation { target: aboutLogo; property: "scale"; to: 0.85; duration: 90; easing.type: Easing.OutQuad } + NumberAnimation { target: aboutLogo; property: "scale"; to: 1.0; duration: 260; easing.type: Easing.OutBack } + } + // A spin-and-pop flourish on the seventh click, when the spirit awakens. + SequentialAnimation { + id: awaken + NumberAnimation { target: aboutLogo; property: "scale"; to: 1.3; duration: 180; easing.type: Easing.OutQuad } + ParallelAnimation { + RotationAnimation { target: aboutLogo; property: "rotation"; from: 0; to: 360; duration: 650; easing.type: Easing.InOutBack } + NumberAnimation { target: aboutLogo; property: "scale"; to: 1.0; duration: 650; easing.type: Easing.OutBack } + } + onStopped: aboutLogo.rotation = 0 + } + + MouseArea { + anchors.fill: parent + onClicked: { + if (connectionManager.app_state.spirit_unlocked) { + bounce.restart() + } else if (++dialog.spiritClicks >= 7) { + connectionManager.app_state.awaken_spirit() + awaken.restart() + } else { + bounce.restart() + } + } + } } Label { text: qsTr("%1 (v%2)").arg(metadata.name).arg(metadata.version) @@ -30,6 +69,13 @@ Dialog { textFormat: Text.PlainText } + Label { + visible: connectionManager.app_state.spirit_unlocked + text: qsTr("The red diamond spirit awakens.") + color: Theme.accent + font.italic: true + } + Button { text: qsTr("Copy to Clipboard") onClicked: metadata.copy_app_info_to_clipboard() diff --git a/can_explorer/gui/qt/CanExplorer/ConnectionView.qml b/can_explorer/gui/qt/CanExplorer/ConnectionView.qml index 3d125a8..02e7f59 100644 --- a/can_explorer/gui/qt/CanExplorer/ConnectionView.qml +++ b/can_explorer/gui/qt/CanExplorer/ConnectionView.qml @@ -19,16 +19,18 @@ ColumnLayout { { key: "health", title: qsTr("Bus health"), icon: "♥" }, { key: "reverse", title: qsTr("Reverse engineering"), icon: "⌕" }, ].concat(connection.can_transmit ? [{ key: "diagnostics", title: qsTr("Diagnostics"), icon: "✚" }] : []) + .concat(connection.can_transmit && !connection.is_lin && !connection.is_j1850 ? [{ key: "obd", title: qsTr("OBD-II"), icon: "⊙" }] : []) .concat(connection.can_transmit ? [{ key: "flash", title: qsTr("Flashing"), icon: "⇪" }] : []) .concat(connection.can_transmit ? [{ key: "uds-scan", title: qsTr("UDS discovery"), icon: "⊚" }] : []) .concat(connection.can_transmit ? [{ key: "xcp", title: qsTr("XCP/CCP"), icon: "⟐" }] : []) .concat(connection.can_transmit ? [{ key: "gmlan", title: qsTr("GMLAN"), icon: "◈" }] : []) + .concat(connection.is_j1850 ? [{ key: "j1850", title: qsTr("J1850 / Class 2"), icon: "◇" }] : []) .concat(connection.protocol === "J1939" ? [{ key: "j1939", title: qsTr("J1939"), icon: "⛟" }] : []) .concat(connection.protocol === "CANopen" ? [{ key: "canopen", title: qsTr("CANopen"), icon: "⚙" }] : []) .concat(connection.is_lin ? [{ key: "lin", title: qsTr("LIN Schedule"), icon: "⏱" }] : []) .concat(connection.can_transmit ? [{ key: "simulation", title: qsTr("Simulation"), icon: "▶" }] : []) .concat(connection.can_transmit ? [{ key: "tests", title: qsTr("Tests"), icon: "✓" }] : []) - readonly property var pageKeys: ["dashboard", "trace", "overview", "signals", "health", "reverse", "diagnostics", "flash", "uds-scan", "xcp", "gmlan", "j1939", "canopen", "lin", "simulation", "tests"] + readonly property var pageKeys: ["dashboard", "trace", "overview", "signals", "health", "reverse", "diagnostics", "obd", "flash", "uds-scan", "xcp", "gmlan", "j1850", "j1939", "canopen", "lin", "simulation", "tests"] property string page: "dashboard" readonly property string pageTitle: (pages.find(item => item.key === page) || pages[0]).title readonly property bool tablePage: page === "trace" || page === "overview" @@ -111,6 +113,15 @@ ColumnLayout { : qsTr("Publish the signals and frames to an MQTT broker") onClicked: mqttDialog.open() } + Button { + text: root.connection.mcp_server.running ? qsTr("● Agent") : qsTr("Agent…") + highlighted: root.connection.mcp_server.running + ToolTip.visible: hovered + ToolTip.text: root.connection.mcp_server.running + ? root.connection.mcp_server.url + " (" + root.connection.mcp_server.tier + ")" + : qsTr("Expose this connection to an AI agent over a local MCP server") + onClicked: mcpServerDialog.open() + } Button { text: qsTr("Export…") ToolTip.visible: hovered @@ -235,6 +246,15 @@ ColumnLayout { } } } + Card { + Loader { + anchors.fill: parent + active: root.connection.can_transmit && !root.connection.is_lin && !root.connection.is_j1850 + sourceComponent: ObdView { + connection: root.connection + } + } + } Card { Loader { anchors.fill: parent @@ -273,6 +293,15 @@ ColumnLayout { } } } + Card { + Loader { + anchors.fill: parent + active: root.connection.is_j1850 + sourceComponent: J1850View { + connection: root.connection + } + } + } Card { Loader { anchors.fill: parent @@ -354,6 +383,12 @@ ColumnLayout { connection: root.connection } + McpServerDialog { + id: mcpServerDialog + objectName: "mcpServerDialog" + connection: root.connection + } + Connections { target: root.connection function onRecording_event(text) { diff --git a/can_explorer/gui/qt/CanExplorer/EthernetView.qml b/can_explorer/gui/qt/CanExplorer/EthernetView.qml new file mode 100644 index 0000000..aa98542 --- /dev/null +++ b/can_explorer/gui/qt/CanExplorer/EthernetView.qml @@ -0,0 +1,290 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Dialogs 6.2 +import QtQuick.Layouts 1.15 +import CanExplorer 1.0 + +// Automotive Ethernet capture: open a pcap file (or capture live on Linux) and decode the packets — Ethernet, +// VLAN, IPv4/IPv6, ARP, UDP/TCP, and SOME/IP, DoIP, gPTP and AVTP on top. +ColumnLayout { + id: root + readonly property var eth: connectionManager.ethernet + readonly property string pageTitle: eth.capturing ? qsTr("capturing") : (eth.file_name || qsTr("no capture")) + spacing: Theme.spacing + + RowLayout { + spacing: 8 + Layout.fillWidth: true + + Button { + text: qsTr("Open pcap…") + enabled: !eth.busy && !eth.capturing + onClicked: openDialog.open() + } + ComboBox { + id: ifaceBox + model: eth.interfaces + enabled: !eth.capturing && eth.interfaces.length > 0 + visible: eth.interfaces.length > 0 + Layout.preferredWidth: 140 + ToolTip.visible: hovered + ToolTip.text: qsTr("Interface to capture live from (needs Linux and root/CAP_NET_RAW)") + } + Button { + text: eth.capturing ? qsTr("Stop") : qsTr("Capture") + highlighted: eth.capturing + visible: eth.interfaces.length > 0 + enabled: eth.capturing || ifaceBox.currentText.length > 0 + onClicked: { + if (eth.capturing) + eth.stop_capture() + else { + const error = eth.start_capture(ifaceBox.currentText) + if (error) + statusLabel.show(error) + } + } + } + Button { + text: qsTr("Export…") + enabled: eth.count > 0 && !eth.capturing + onClicked: saveDialog.open() + } + Button { + text: qsTr("Clear") + enabled: eth.count > 0 && !eth.capturing + onClicked: eth.clear() + } + TextField { + id: filterField + placeholderText: qsTr("filter: SOME/IP, 192.168.0.10, 30509…") + text: eth.filter + onTextEdited: eth.set_filter(text) + Layout.preferredWidth: 220 + } + Item { Layout.fillWidth: true } + BusyIndicator { running: eth.busy; visible: running; Layout.preferredHeight: 28; Layout.preferredWidth: 28 } + Pill { + visible: eth.file_name.length > 0 + text: eth.file_name + tint: Theme.chart[4] + } + Pill { + visible: eth.count > 0 + text: qsTr("%1 packets").arg(eth.count.toLocaleString(Qt.locale(), "f", 0)) + tint: Theme.accent + } + } + + Label { + id: statusLabel + visible: text.length > 0 + text: eth.error || eth.status + color: eth.error.length > 0 ? Theme.danger : Theme.muted + Layout.fillWidth: true + Timer { id: statusTimer; interval: 5000; onTriggered: statusLabel.text = Qt.binding(() => eth.error || eth.status) } + function show(message) { text = message; statusTimer.restart() } + } + + Label { + visible: eth.count === 0 && !eth.busy + text: qsTr("Open a pcap capture of automotive Ethernet traffic (classic pcap, Ethernet link type), or capture live on an interface. Frames are decoded down the stack and the automotive protocols — SOME/IP, DoIP, gPTP, AVTP — are recognised. A full capture needs a network tap or a mirror/SPAN port.") + color: Theme.muted + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + // Protocol breakdown + Flow { + visible: eth.protocols.length > 0 + spacing: 6 + Layout.fillWidth: true + Repeater { + model: eth.protocols + Pill { + required property var modelData + text: modelData.protocol + " · " + modelData.count + tint: Theme.chart[["SOME/IP", "DoIP", "gPTP", "AVTP", "ARP"].indexOf(modelData.protocol) % Theme.chart.length] + } + } + } + + // Time sync (gPTP / IEEE 802.1AS) + Card { + id: gptpCard + title: qsTr("Time sync (gPTP)") + visible: eth.has_gptp + Layout.fillWidth: true + Layout.preferredHeight: gptpGrid.implicitHeight + 2 * Theme.spacing + readonly property var g: eth.gptp + + GridLayout { + id: gptpGrid + anchors.fill: parent + columns: 4 + columnSpacing: 24 + rowSpacing: 6 + + Label { text: qsTr("Grandmaster"); color: Theme.muted } + Label { + text: gptpCard.g.grandmaster + ? gptpCard.g.grandmaster + qsTr(" (priority %1, %2)").arg(gptpCard.g.gm_priority1) + .arg(gptpCard.g.gm_clock_class_name || qsTr("class %1").arg(gptpCard.g.gm_clock_class)) + : qsTr("none announced") + color: Theme.text + font.family: "monospace" + font.pixelSize: 12 + Layout.columnSpan: 3 + Layout.fillWidth: true + elide: Text.ElideRight + } + + Label { text: qsTr("Steps removed"); color: Theme.muted } + Label { text: String(gptpCard.g.steps_removed); color: Theme.text; font.pixelSize: 12 } + Label { text: qsTr("Time source"); color: Theme.muted } + Label { text: gptpCard.g.gm_time_source || "—"; color: Theme.text; font.pixelSize: 12 } + + Label { text: qsTr("Sync interval"); color: Theme.muted } + Label { + text: gptpCard.g.sync_interval_ms != null + ? qsTr("%1 ms").arg(gptpCard.g.sync_interval_ms) + + (gptpCard.g.sync_rate_hz != null ? qsTr(" (observed %1 Hz)").arg(gptpCard.g.sync_rate_hz) : "") + : "—" + color: Theme.text + font.pixelSize: 12 + } + Label { text: qsTr("Announce interval"); color: Theme.muted } + Label { text: gptpCard.g.announce_interval_ms != null ? qsTr("%1 ms").arg(gptpCard.g.announce_interval_ms) : "—"; color: Theme.text; font.pixelSize: 12 } + + Label { text: qsTr("Clocks / domains"); color: Theme.muted } + Label { + text: qsTr("%1 clock(s) on domain(s) %2").arg((gptpCard.g.clocks || []).length).arg((gptpCard.g.domains || []).join(", ")) + color: Theme.text + font.pixelSize: 12 + Layout.columnSpan: 3 + Layout.fillWidth: true + elide: Text.ElideRight + } + } + } + + // SOME/IP services (from Service Discovery) + Card { + id: someipCard + title: qsTr("SOME/IP services") + visible: eth.has_someip + Layout.fillWidth: true + Layout.preferredHeight: someipColumn.implicitHeight + 2 * Theme.spacing + + ColumnLayout { + id: someipColumn + anchors.fill: parent + spacing: 4 + + RowLayout { + Layout.fillWidth: true + spacing: 16 + Repeater { + model: [qsTr("Service"), qsTr("Instance"), qsTr("Version"), qsTr("Endpoints"), qsTr("Offers")] + Label { + required property int index + required property var modelData + text: modelData + color: Theme.muted + font.pixelSize: 11 + Layout.preferredWidth: index === 3 ? -1 : 70 + Layout.fillWidth: index === 3 + } + } + } + Repeater { + model: eth.someip_services + RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: 16 + Label { text: modelData.service; color: Theme.text; font.family: "monospace"; font.pixelSize: 12; Layout.preferredWidth: 70 } + Label { text: modelData.instance; color: Theme.text; font.family: "monospace"; font.pixelSize: 12; Layout.preferredWidth: 70 } + Label { text: modelData.version; color: Theme.text; font.pixelSize: 12; Layout.preferredWidth: 70 } + Label { text: modelData.endpoints || "—"; color: Theme.text; font.family: "monospace"; font.pixelSize: 12; Layout.fillWidth: true; elide: Text.ElideRight } + Label { text: String(modelData.offers); color: Theme.muted; font.pixelSize: 12; Layout.preferredWidth: 70 } + } + } + } + } + + // Packets + Card { + title: qsTr("Packets") + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + anchors.fill: parent + spacing: 2 + RowLayout { + Layout.fillWidth: true + spacing: 12 + Repeater { + model: [qsTr("Time"), qsTr("Source"), qsTr("Destination"), qsTr("Protocol"), qsTr("Len"), qsTr("Info")] + Label { + required property int index + required property string modelData + text: modelData + color: Theme.muted + font.bold: true + font.pixelSize: 12 + Layout.preferredWidth: [80, 170, 170, 90, 50, 0][index] || -1 + Layout.fillWidth: index === 5 + } + } + } + ListView { + id: packetList + clip: true + model: eth.packets + Layout.fillWidth: true + Layout.fillHeight: true + ScrollBar.vertical: ScrollBar {} + onCountChanged: if (atYEnd || count <= 1) positionViewAtEnd() + delegate: Rectangle { + required property var modelData + required property int index + width: packetList.width + height: 24 + color: index % 2 ? Theme.alpha(Theme.cardAlt, 0.5) : "transparent" + RowLayout { + anchors.fill: parent + spacing: 12 + Label { text: modelData.time; font.family: "monospace"; font.pixelSize: 12; color: Theme.muted; Layout.preferredWidth: 80 } + Label { text: modelData.source; font.family: "monospace"; font.pixelSize: 12; color: Theme.text; Layout.preferredWidth: 170; elide: Text.ElideRight } + Label { text: modelData.destination; font.family: "monospace"; font.pixelSize: 12; color: Theme.text; Layout.preferredWidth: 170; elide: Text.ElideRight } + Label { text: modelData.protocol; font.pixelSize: 12; color: Theme.accent; Layout.preferredWidth: 90; elide: Text.ElideRight } + Label { text: modelData.length; font.family: "monospace"; font.pixelSize: 12; color: Theme.muted; Layout.preferredWidth: 50 } + Label { text: modelData.info; font.pixelSize: 12; color: Theme.text; elide: Text.ElideRight; Layout.fillWidth: true } + } + } + } + } + } + + FileDialog { + id: openDialog + title: qsTr("Open a pcap capture") + nameFilters: [qsTr("pcap files (*.pcap *.cap)"), qsTr("All files (*)")] + onAccepted: { + const error = eth.open_pcap(selectedFile) + if (error) + statusLabel.show(error) + } + } + + FileDialog { + id: saveDialog + title: qsTr("Save the packets") + fileMode: FileDialog.SaveFile + defaultSuffix: "pcap" + nameFilters: [qsTr("pcap files (*.pcap)")] + onAccepted: statusLabel.show(eth.export_pcap(selectedFile)) + } +} diff --git a/can_explorer/gui/qt/CanExplorer/J1850View.qml b/can_explorer/gui/qt/CanExplorer/J1850View.qml new file mode 100644 index 0000000..55070b1 --- /dev/null +++ b/can_explorer/gui/qt/CanExplorer/J1850View.qml @@ -0,0 +1,175 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import CanExplorer 1.0 + + +// SAE J1850 VPW / GM Class 2 (SAE J1979) diagnostics, over this connection's bus (the elm327_vpw interface). +// Used on the 10.4 kbit/s Class 2 bus of 1996–2007 GM vehicles (e.g. the 99–06 P01/P59 PCMs). +ColumnLayout { + id: root + required property var connection + readonly property var j1850: connection.j1850 + readonly property bool ready: connection.connected && !j1850.busy + + function report(error) { + errorLabel.text = error || "" + } + + spacing: 4 + + RowLayout { + Layout.fillWidth: true + Layout.margins: 4 + Label { text: qsTr("J1850 VPW / GM Class 2"); font.bold: true } + Item { Layout.fillWidth: true } + Label { + text: root.connection.connected ? qsTr("SAE J1979 over J1850 VPW") : qsTr("Connect first") + color: Theme.muted + } + } + + // Current data (mode 0x01 PIDs) + RowLayout { + Layout.fillWidth: true + Layout.margins: 4 + spacing: 6 + Label { text: qsTr("PID") } + ComboBox { + id: pidBox + Layout.preferredWidth: 260 + textRole: "name" + model: root.j1850.pids + } + Button { + text: qsTr("Read") + enabled: root.ready + ToolTip.visible: hovered + ToolTip.text: qsTr("ShowCurrentData (mode 0x01)") + onClicked: root.report(root.j1850.read_pid(root.j1850.pids[pidBox.currentIndex].number)) + } + Button { + text: qsTr("Read VIN") + enabled: root.ready + ToolTip.visible: hovered + ToolTip.text: qsTr("RequestVehicleInformation (mode 0x09)") + onClicked: root.report(root.j1850.read_vin()) + } + Label { + text: root.j1850.vin ? root.j1850.vin : "" + color: Theme.muted + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + // Trouble codes and raw request + RowLayout { + Layout.fillWidth: true + Layout.margins: 4 + spacing: 6 + Button { + text: qsTr("Read DTCs") + enabled: root.ready + onClicked: root.report(root.j1850.read_dtcs()) + } + Button { + text: qsTr("Clear DTCs") + enabled: root.ready + onClicked: root.report(root.j1850.clear_dtcs()) + } + Label { text: qsTr("Raw request") } + TextField { + id: rawField + placeholderText: qsTr("J1979 request, hex, e.g. 01 0C") + Layout.fillWidth: true + onAccepted: root.report(root.j1850.raw_request(text)) + } + Button { + text: qsTr("Send") + enabled: root.ready + onClicked: root.report(root.j1850.raw_request(rawField.text)) + } + } + + Label { + id: errorLabel + visible: text !== "" + color: Theme.danger + wrapMode: Text.WrapAnywhere + Layout.fillWidth: true + Layout.leftMargin: 4 + } + + SplitView { + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + SplitView.preferredWidth: 260 + Label { + text: qsTr("Diagnostic trouble codes (%1)").arg(root.j1850.dtcs.length) + padding: 4 + } + ListView { + id: dtcList + clip: true + Layout.fillWidth: true + Layout.fillHeight: true + ScrollBar.vertical: ScrollBar {} + model: root.j1850.dtcs + delegate: ItemDelegate { + required property var modelData + width: dtcList.width + text: modelData.code + font.family: "monospace" + } + } + } + + ColumnLayout { + SplitView.fillWidth: true + RowLayout { + Label { text: qsTr("History"); padding: 4 } + Item { Layout.fillWidth: true } + Button { + text: qsTr("Clear") + flat: true + onClicked: root.j1850.clear_history() + } + } + ListView { + id: history + clip: true + spacing: 2 + Layout.fillWidth: true + Layout.fillHeight: true + ScrollBar.vertical: ScrollBar {} + model: root.j1850.history + onCountChanged: positionViewAtEnd() + delegate: Frame { + required property var modelData + width: history.width - 12 + x: 4 + padding: 6 + ColumnLayout { + width: parent.width + Label { + text: `→ ${modelData.request} ${modelData.request_description}` + font.family: "monospace" + wrapMode: Text.WrapAnywhere + Layout.fillWidth: true + } + Label { + text: (modelData.response ? `← ${modelData.response} ` : "") + modelData.description + font.family: "monospace" + color: modelData.positive ? Theme.success : Theme.danger + wrapMode: Text.WrapAnywhere + Layout.fillWidth: true + } + } + } + } + } + } +} diff --git a/can_explorer/gui/qt/CanExplorer/McpServerDialog.qml b/can_explorer/gui/qt/CanExplorer/McpServerDialog.qml new file mode 100644 index 0000000..ee1352e --- /dev/null +++ b/can_explorer/gui/qt/CanExplorer/McpServerDialog.qml @@ -0,0 +1,126 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import CanExplorer 1.0 + +// In-app MCP server: expose this connection's live bus to an AI agent over localhost, token-gated. +Dialog { + id: dialog + required property var connection + readonly property var server: connection ? connection.mcp_server : null + + title: qsTr("AI agent (MCP)") + modal: true + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.Close + width: 560 + + ColumnLayout { + anchors.fill: parent + spacing: Theme.spacing + + Label { + text: qsTr("Let an AI agent inspect this connection — the bus you already have open — over a local MCP server. It listens on 127.0.0.1 only and requires the token below.") + color: Theme.muted + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + + Label { text: qsTr("Access"); color: Theme.muted } + ComboBox { + id: tierBox + model: dialog.server.tiers + enabled: !dialog.server.running + Layout.preferredWidth: 160 + Component.onCompleted: currentIndex = Math.max(0, dialog.server.tiers.indexOf(dialog.server.tier)) + } + Item { Layout.fillWidth: true } + Button { + text: dialog.server.running ? qsTr("Stop") : qsTr("Start") + highlighted: !dialog.server.running + onClicked: { + if (dialog.server.running) + dialog.server.stop() + else { + const error = dialog.server.start(tierBox.currentText) + if (error) + statusLabel.text = error + } + } + } + } + + Label { + text: { + switch (tierBox.currentText) { + case "diagnostics": return qsTr("Read-only, plus OBD-II and UDS diagnostic reads (these send requests on the bus).") + case "control": return qsTr("Plus active diagnostics: clear trouble codes, reset ECUs, run routines. Use with care.") + case "send": return qsTr("Plus transmitting arbitrary frames and messages. The agent fully controls the bus.") + default: return qsTr("Read-only: inspect frames, decode and reverse-engineer. Sends nothing.") + } + } + color: tierBox.currentText === "read-only" ? Theme.muted : Theme.warning + wrapMode: Text.Wrap + font.pixelSize: 12 + Layout.fillWidth: true + } + + Label { + id: statusLabel + text: dialog.server.error + visible: text.length > 0 + color: Theme.danger + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + // Connection details, shown while running + Card { + visible: dialog.server.running + Layout.fillWidth: true + Layout.preferredHeight: detailsGrid.implicitHeight + 2 * Theme.spacing + + GridLayout { + id: detailsGrid + anchors.fill: parent + columns: 3 + columnSpacing: 8 + rowSpacing: 6 + + Label { text: qsTr("URL"); color: Theme.muted } + TextField { + id: urlField + text: dialog.server.url + readOnly: true + font.family: "monospace" + Layout.fillWidth: true + } + Button { text: qsTr("Copy"); onClicked: { urlField.selectAll(); urlField.copy() } } + + Label { text: qsTr("Token"); color: Theme.muted } + TextField { + id: tokenField + text: dialog.server.token + readOnly: true + font.family: "monospace" + // echoMode: TextInput.Password + Layout.fillWidth: true + } + Button { text: qsTr("Copy"); onClicked: { tokenField.selectAll(); tokenField.copy() } } + } + } + + Label { + visible: dialog.server.running + text: qsTr("Point your MCP client at the URL using the streamable-HTTP transport, with the token as a Bearer header (Authorization: Bearer ) or appended as ?token=.") + color: Theme.muted + wrapMode: Text.Wrap + font.pixelSize: 12 + Layout.fillWidth: true + } + } +} diff --git a/can_explorer/gui/qt/CanExplorer/ObdView.qml b/can_explorer/gui/qt/CanExplorer/ObdView.qml new file mode 100644 index 0000000..a127b49 --- /dev/null +++ b/can_explorer/gui/qt/CanExplorer/ObdView.qml @@ -0,0 +1,209 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import CanExplorer 1.0 + +// OBD-II (SAE J1979): scan a vehicle for its supported values, watch them live, and read or clear trouble codes. +ColumnLayout { + id: root + required property var connection + readonly property var obd: connection.obd + readonly property string pageTitle: obd.vin ? obd.vin : (obd.scanned ? qsTr("connected") : qsTr("not scanned")) + spacing: Theme.spacing + + RowLayout { + spacing: 8 + Layout.fillWidth: true + + Label { text: qsTr("Request"); color: Theme.muted } + TextField { + id: reqField + text: obd.request_id + enabled: !obd.live + Layout.preferredWidth: 80 + ToolTip.visible: hovered + ToolTip.text: qsTr("Request identifier (hex): 7DF is the OBD-II functional broadcast; 7E0 addresses one ECU") + } + Label { text: qsTr("Response"); color: Theme.muted } + TextField { + id: respField + text: obd.response_id + enabled: !obd.live + Layout.preferredWidth: 80 + ToolTip.visible: hovered + ToolTip.text: qsTr("Response identifier (hex): 7E8 is the first ECU") + } + Button { + text: qsTr("Scan") + highlighted: !obd.scanned + enabled: !obd.busy && !obd.live + onClicked: { obd.set_ids(reqField.text, respField.text); obd.scan() } + } + Button { + text: obd.live ? qsTr("Stop") : qsTr("Live") + highlighted: obd.live + enabled: obd.scanned && !obd.busy + ToolTip.visible: hovered + ToolTip.text: qsTr("Poll the supported values continuously") + onClicked: obd.toggle_live() + } + BusyIndicator { + running: obd.busy + visible: running + Layout.preferredHeight: 28 + Layout.preferredWidth: 28 + } + Item { Layout.fillWidth: true } + Pill { + visible: obd.vin.length > 0 + text: obd.vin + tint: Theme.chart[4] + } + Pill { + visible: obd.scanned + text: obd.mil_on ? qsTr("MIL on · %1 DTC").arg(obd.dtc_count) : qsTr("MIL off") + tint: obd.mil_on ? Theme.danger : Theme.success + } + } + + Label { + visible: obd.status.length > 0 + text: obd.status + color: Theme.muted + Layout.fillWidth: true + elide: Text.ElideRight + } + + Label { + visible: !obd.scanned && !obd.busy + text: qsTr("Connect to a vehicle's OBD-II port (a standard 500 kbit/s CAN bus), then press Scan to read the VIN, the supported live values and the trouble codes. If nothing answers, try the request/response identifiers 7E0 and 7E8 for a single ECU.") + color: Theme.muted + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + // Live values + Card { + title: qsTr("Live data") + visible: obd.scanned + Layout.fillWidth: true + Layout.preferredHeight: Math.min(320, 70 + Math.ceil(obd.values.length / 4) * 74) + + Flow { + anchors.fill: parent + spacing: 10 + Repeater { + model: obd.values + delegate: Rectangle { + id: tile + required property var modelData + width: 150 + height: 64 + radius: Theme.smallRadius + color: Theme.cardAlt + border.color: Theme.alpha(Theme.border, 0.6) + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 0 + Label { + text: tile.modelData.name + color: Theme.muted + font.pixelSize: 11 + elide: Text.ElideRight + Layout.fillWidth: true + } + RowLayout { + spacing: 4 + Label { + text: tile.modelData.value || "—" + color: Theme.text + font.pixelSize: 22 + font.family: "monospace" + } + Label { + text: tile.modelData.unit + color: Theme.muted + font.pixelSize: 12 + Layout.alignment: Qt.AlignBottom + bottomPadding: 3 + } + } + } + } + } + } + } + + // Trouble codes + Card { + title: qsTr("Trouble codes") + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + RowLayout { + spacing: 8 + Layout.fillWidth: true + Button { + text: qsTr("Read codes") + enabled: obd.scanned && !obd.busy + onClicked: obd.read_dtcs() + } + Button { + text: qsTr("Clear codes") + enabled: obd.scanned && !obd.busy + ToolTip.visible: hovered + ToolTip.text: qsTr("Clear the stored trouble codes and turn the MIL off (service 04)") + onClicked: obd.clear_dtcs() + } + Item { Layout.fillWidth: true } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Theme.spacing + + Repeater { + model: [ + { title: qsTr("Stored"), rows: obd.dtcs, empty: qsTr("No stored codes") }, + { title: qsTr("Pending"), rows: obd.pending_dtcs, empty: qsTr("No pending codes") } + ] + delegate: ColumnLayout { + id: column + required property var modelData + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 4 + Label { + text: column.modelData.title + " (" + column.modelData.rows.length + ")" + color: Theme.muted + font.bold: true + font.pixelSize: 12 + } + Label { + visible: column.modelData.rows.length === 0 + text: column.modelData.empty + color: Theme.muted + font.pixelSize: 12 + } + Repeater { + model: column.modelData.rows + delegate: Label { + required property var modelData + text: modelData.code + color: Theme.text + font.family: "monospace" + } + } + Item { Layout.fillHeight: true } + } + } + } + } + } +} diff --git a/can_explorer/gui/qt/CanExplorer/ReverseActivityPanel.qml b/can_explorer/gui/qt/CanExplorer/ReverseActivityPanel.qml new file mode 100644 index 0000000..c76a3eb --- /dev/null +++ b/can_explorer/gui/qt/CanExplorer/ReverseActivityPanel.qml @@ -0,0 +1,100 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import CanExplorer 1.0 +import CanExplorer.Plots 1.0 + +// Bit-transition raster: the value of every bit of the selected identifier across the whole capture, so counters, +// constants, toggles and multiplexed regions stand out at a glance. Frames across, bits down (byte 0 at the top). +ColumnLayout { + id: root + required property var tools + readonly property var raster: tools.raster + + signal fieldUsed() // Part of the common panel interface; the activity map does not use them + signal plotted() + signal message(string text) + + spacing: 8 + + onVisibleChanged: if (visible && tools.selected !== "") raster.refresh() + + Connections { + target: root.tools + function onSelection_changed() { if (root.visible && root.tools.selected !== "") root.raster.refresh() } + } + + RowLayout { + spacing: 10 + Layout.fillWidth: true + + Button { + text: qsTr("Refresh") + enabled: !root.raster.running && root.tools.selected !== "" + ToolTip.visible: hovered + ToolTip.text: qsTr("Rebuild the activity map from the frames captured so far") + onClicked: root.raster.refresh() + } + BusyIndicator { + running: root.raster.running + visible: running + Layout.preferredHeight: 28 + Layout.preferredWidth: 28 + } + // Legend + Repeater { + model: [ + { c: Theme.cardAlt, t: qsTr("0") }, + { c: Theme.accent, t: qsTr("1") }, + { c: Theme.warning, t: qsTr("changed") } + ] + RowLayout { + required property var modelData + spacing: 4 + Rectangle { width: 12; height: 12; radius: 2; color: modelData.c } + Label { text: modelData.t; color: Theme.muted; font.pixelSize: 12 } + } + } + Item { Layout.fillWidth: true } + Label { + text: root.raster.has_data ? qsTr("%1 over %2").arg(root.raster.status).arg(root.raster.span) : root.raster.status + color: Theme.muted + font.pixelSize: 12 + elide: Text.ElideRight + } + } + + Card { + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + anchors.fill: parent + spacing: 6 + + Label { + visible: root.tools.selected === "" + text: qsTr("Select an identifier to see its bits over the whole capture") + color: Theme.muted + Layout.fillWidth: true + } + BitRaster { + id: rasterItem + visible: root.tools.selected !== "" + controller: root.raster + colors: [Theme.cardAlt, Theme.accent, Qt.darker(Theme.warning, 2.4), Theme.warning] + gridColor: Theme.alpha(Theme.border, 0.8) + textColor: Theme.muted + Layout.fillWidth: true + Layout.fillHeight: true + } + Label { + text: qsTr("Each row is a bit (byte 0 at the top, most significant bit first), each column a frame over time. Solid blue is a steady 1, dark a steady 0; orange marks a bit that changed. Counters ramp as diagonal stripes, constants are flat, multiplexers switch.") + color: Theme.muted + font.pixelSize: 11 + wrapMode: Text.Wrap + Layout.fillWidth: true + } + } + } +} diff --git a/can_explorer/gui/qt/CanExplorer/ReverseConsolePanel.qml b/can_explorer/gui/qt/CanExplorer/ReverseConsolePanel.qml new file mode 100644 index 0000000..5572d8d --- /dev/null +++ b/can_explorer/gui/qt/CanExplorer/ReverseConsolePanel.qml @@ -0,0 +1,176 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import CanExplorer 1.0 +import CanExplorer.Plots 1.0 + +// An interactive Python console over the captured frames, with the reverse-engineering primitives (collect, +// analyse, BitField, candidates, signal_groups). A transcript above, an editor below; Ctrl+Enter runs. +ColumnLayout { + id: root + required property var tools + readonly property var consoleTool: tools.console + + signal fieldUsed() // Part of the common panel interface; the console does not use them + signal plotted() + signal message(string text) + + spacing: 8 + + function run() { + if (input.text.trim().length > 0) + root.consoleTool.run(input.text) + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + + Button { + text: qsTr("Run") + highlighted: true + enabled: !root.consoleTool.running && input.text.trim().length > 0 + ToolTip.visible: hovered + ToolTip.text: qsTr("Run the snippet over the frames captured so far (Ctrl+Enter)") + onClicked: root.run() + } + Button { + text: qsTr("Clear") + enabled: root.consoleTool.entries.length > 0 + ToolTip.visible: hovered + ToolTip.text: qsTr("Clear the transcript and forget all variables") + onClicked: root.consoleTool.clear() + } + BusyIndicator { + running: root.consoleTool.running + visible: running + Layout.preferredHeight: 28 + Layout.preferredWidth: 28 + } + Label { + text: root.consoleTool.status + color: Theme.warning + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + // Transcript (or the help text before anything has run) + Card { + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + ScrollView { + visible: root.consoleTool.entries.length === 0 + Layout.fillWidth: true + Layout.fillHeight: true + Label { + text: root.consoleTool.help_text + color: Theme.muted + font.family: "monospace" + font.pixelSize: 12 + wrapMode: Text.Wrap + width: root.width - 2 * Theme.spacing + } + } + + ListView { + id: transcript + visible: root.consoleTool.entries.length > 0 + clip: true + model: root.consoleTool.entries + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + ScrollBar.vertical: ScrollBar {} + onCountChanged: positionViewAtEnd() + + delegate: ColumnLayout { + required property var modelData + width: transcript.width - 12 + spacing: 1 + + Label { + text: "› " + modelData.source.replace(/\n/g, "\n ") + color: Theme.accent + font.family: "monospace" + font.pixelSize: 12 + wrapMode: Text.Wrap + Layout.fillWidth: true + } + Label { + visible: modelData.output.length > 0 + text: modelData.output + color: Theme.muted + font.family: "monospace" + font.pixelSize: 12 + wrapMode: Text.Wrap + Layout.fillWidth: true + } + Label { + visible: modelData.value.length > 0 + text: modelData.value + color: Theme.text + font.family: "monospace" + font.pixelSize: 12 + wrapMode: Text.Wrap + Layout.fillWidth: true + } + Label { + visible: modelData.error.length > 0 + text: modelData.error + color: Theme.danger + font.family: "monospace" + font.pixelSize: 12 + wrapMode: Text.Wrap + Layout.fillWidth: true + } + } + } + } + } + + // Editor + ScrollView { + Layout.fillWidth: true + Layout.preferredHeight: 120 + + TextArea { + id: input + objectName: "consoleInput" + placeholderText: qsTr("Python here, Ctrl+Enter to run — e.g. [(f.field.key, f.kind) for f in analyse(frames_of('100')).fields] (type HELP for the available names)") + font.family: "monospace" + font.pixelSize: 13 + wrapMode: TextEdit.NoWrap + selectByMouse: true + color: Theme.text + background: Rectangle { + color: Theme.cardAlt + radius: Theme.smallRadius + border.color: input.activeFocus ? Theme.accent : Theme.border + border.width: 1 + } + Keys.onPressed: (event) => { + if ((event.modifiers & Qt.ControlModifier) && (event.key === Qt.Key_Return || event.key === Qt.Key_Enter)) { + root.run() + event.accepted = true + } + } + + PythonHighlighter { + quickDocument: input.textDocument + keywordColor: Theme.accent + nameColor: Theme.chart[4] + numberColor: Theme.chart[2] + stringColor: Theme.success + commentColor: Theme.muted + definitionColor: Theme.warning + } + } + } +} diff --git a/can_explorer/gui/qt/CanExplorer/ReverseView.qml b/can_explorer/gui/qt/CanExplorer/ReverseView.qml index cdd4230..b6d8339 100644 --- a/can_explorer/gui/qt/CanExplorer/ReverseView.qml +++ b/can_explorer/gui/qt/CanExplorer/ReverseView.qml @@ -131,11 +131,13 @@ SplitView { objectName: "reverseTabs" Layout.fillWidth: true TabButton { text: qsTr("Bits") } + TabButton { text: qsTr("Activity") } TabButton { text: qsTr("Automatic analysis") } TabButton { text: qsTr("Reference signals") } TabButton { text: qsTr("Markers") } TabButton { text: qsTr("Related signals") } TabButton { text: qsTr("Reaction") } + TabButton { text: qsTr("Console") } } StackLayout { @@ -181,6 +183,32 @@ SplitView { ToolTip.text: qsTr("Play this identifier's captured frames back over time; scrub to any moment and watch the bits flow") onClicked: root.tools.timeline.active ? root.tools.stop_replay() : root.tools.start_replay() } + Button { + text: qsTr("Plot Δt") + enabled: root.tools.selected !== "" + ToolTip.visible: hovered + ToolTip.text: qsTr("Plot this identifier's inter-frame interval (Δt) over time on the Signals page, to spot gateways, bursts and event-triggered messages") + onClicked: { + const error = root.tools.plot_interval() + if (error) + root.showStatus(error) + else + root.showPage("signals") + } + } + Button { + text: qsTr("Plot bytes") + enabled: root.tools.selected !== "" + ToolTip.visible: hovered + ToolTip.text: qsTr("Plot every byte of this identifier's payload as a value over time on the Signals page") + onClicked: { + const error = root.tools.plot_bytes() + if (error) + root.showStatus(error) + else + root.showPage("signals") + } + } Label { text: root.status color: Theme.muted @@ -518,6 +546,10 @@ SplitView { } } + ReverseActivityPanel { + tools: root.tools + onMessage: text => root.showStatus(text) + } ReverseAnalysisPanel { tools: root.tools connection: root.connection @@ -550,6 +582,10 @@ SplitView { onPlotted: root.showPage("signals") onMessage: text => root.showStatus(text) } + ReverseConsolePanel { + tools: root.tools + onMessage: text => root.showStatus(text) + } } Label { diff --git a/can_explorer/gui/qt/CanExplorer/Sidebar.qml b/can_explorer/gui/qt/CanExplorer/Sidebar.qml index 6e95025..a083fba 100644 --- a/can_explorer/gui/qt/CanExplorer/Sidebar.qml +++ b/can_explorer/gui/qt/CanExplorer/Sidebar.qml @@ -236,6 +236,14 @@ Rectangle { onClicked: root.toolSelected("someip") } + NavItem { + text: qsTr("Ethernet capture") + glyph: "🖧" + active: root.currentTool === "ethernet" + dotColor: connectionManager.ethernet.capturing ? Theme.success : "transparent" + onClicked: root.toolSelected("ethernet") + } + NavItem { text: qsTr("Bus gateway") glyph: "⇄" diff --git a/can_explorer/gui/qt/CanExplorer/SomeIpView.qml b/can_explorer/gui/qt/CanExplorer/SomeIpView.qml index c0adb5c..f520af8 100644 --- a/can_explorer/gui/qt/CanExplorer/SomeIpView.qml +++ b/can_explorer/gui/qt/CanExplorer/SomeIpView.qml @@ -53,9 +53,9 @@ ColumnLayout { onClicked: root.someip.clear() } Button { - text: root.someip.description_file ? qsTr("Unload FIBEX") : qsTr("FIBEX…") + text: root.someip.description_file ? qsTr("Unload description") : qsTr("FIBEX / ARXML…") ToolTip.visible: hovered - ToolTip.text: qsTr("Load a FIBEX file to decode the message payloads into named signals") + ToolTip.text: qsTr("Load a FIBEX or ARXML file to decode the message payloads into named signals") onClicked: root.someip.description_file ? root.someip.unload_description() : fibexDialog.open() } Item { Layout.fillWidth: true } @@ -193,8 +193,8 @@ ColumnLayout { FileDialog { id: fibexDialog - title: qsTr("Load SOME/IP FIBEX description") - nameFilters: [qsTr("FIBEX files (*.fibex *.xml)"), qsTr("All files (*)")] + title: qsTr("Load SOME/IP description") + nameFilters: [qsTr("SOME/IP descriptions (*.fibex *.arxml *.xml)"), qsTr("All files (*)")] onAccepted: { const error = root.someip.load_description(selectedFile) if (error) diff --git a/can_explorer/gui/qt/CanExplorer/Theme.qml b/can_explorer/gui/qt/CanExplorer/Theme.qml index 1a8c036..b58f77a 100644 --- a/can_explorer/gui/qt/CanExplorer/Theme.qml +++ b/can_explorer/gui/qt/CanExplorer/Theme.qml @@ -68,6 +68,13 @@ QtObject { background: "#1b0f16", surface: "#231521", card: "#2c1a28", cardAlt: "#21131e", border: "#4a2a40", text: "#fce7f1", muted: "#c58aa8", accent: "#ff4d94", success: "#34d399", warning: "#fbbf24", danger: "#ef4444", chart: ["#ff4d94", "#c084fc", "#38bdf8", "#fbbf24", "#34d399", "#fb7185"] + }, + // Hidden "red diamond spirit" theme, unlocked from the About dialog (the seven havens easter egg): + // a glowing crimson on near-black. Kept out of the normal theme list until it is awakened. + "Spirit": { + background: "#1a0d0f", surface: "#241315", card: "#2e171a", cardAlt: "#200f12", border: "#4a2328", + text: "#f6e7e7", muted: "#b88a8e", accent: "#e23b4e", success: "#2ecc71", warning: "#f39c12", + danger: "#ff6b6b", chart: ["#e23b4e", "#ff7a5c", "#ffb347", "#c0392b", "#9b59b6", "#f1c40f"] } }) readonly property var current: palettes[name] || palettes["Dark"] diff --git a/can_explorer/gui/qt/CanExplorer/qmldir b/can_explorer/gui/qt/CanExplorer/qmldir index f3543a4..9697713 100644 --- a/can_explorer/gui/qt/CanExplorer/qmldir +++ b/can_explorer/gui/qt/CanExplorer/qmldir @@ -10,6 +10,7 @@ FrameTable 1.0 FrameTable.qml TransmitPanel 1.0 TransmitPanel.qml SignalsView 1.0 SignalsView.qml DiagnosticsView 1.0 DiagnosticsView.qml +ObdView 1.0 ObdView.qml FlashView 1.0 FlashView.qml J1939View 1.0 J1939View.qml CanOpenView 1.0 CanOpenView.qml @@ -36,6 +37,8 @@ ReverseReferencePanel 1.0 ReverseReferencePanel.qml ReverseMarkersPanel 1.0 ReverseMarkersPanel.qml ReverseReactionPanel 1.0 ReverseReactionPanel.qml ReverseRelatedPanel 1.0 ReverseRelatedPanel.qml +ReverseConsolePanel 1.0 ReverseConsolePanel.qml +ReverseActivityPanel 1.0 ReverseActivityPanel.qml ReverseTimelineBar 1.0 ReverseTimelineBar.qml WelcomeView 1.0 WelcomeView.qml PlaybackBar 1.0 PlaybackBar.qml @@ -46,9 +49,11 @@ EtherCatView 1.0 EtherCatView.qml PhysicalIoView 1.0 PhysicalIoView.qml DoipView 1.0 DoipView.qml SomeIpView 1.0 SomeIpView.qml +EthernetView 1.0 EthernetView.qml UdsScanView 1.0 UdsScanView.qml XcpView 1.0 XcpView.qml GmlanView 1.0 GmlanView.qml +J1850View 1.0 J1850View.qml GatewayView 1.0 GatewayView.qml MultiBusView 1.0 MultiBusView.qml TestsView 1.0 TestsView.qml @@ -57,4 +62,5 @@ Logo 1.0 Logo.qml TraceFindBar 1.0 TraceFindBar.qml RecordDialog 1.0 RecordDialog.qml MqttDialog 1.0 MqttDialog.qml +McpServerDialog 1.0 McpServerDialog.qml singleton Theme 1.0 Theme.qml diff --git a/can_explorer/gui/qt/main_window.qml b/can_explorer/gui/qt/main_window.qml index e5c69fa..81907ab 100644 --- a/can_explorer/gui/qt/main_window.qml +++ b/can_explorer/gui/qt/main_window.qml @@ -17,10 +17,14 @@ ApplicationWindow { readonly property int currentIndex: Math.min(selectedIndex, connectionManager.connections.length - 1) property int selectedIndex: -1 readonly property var currentConnection: currentIndex >= 0 ? connectionManager.connections[currentIndex] : null - readonly property var currentConnectionView: currentIndex >= 0 ? connectionViews.itemAt(currentIndex) : null + // Depends on connectionViews.count so it re-evaluates when a connection is added or removed, not only when the + // index changes: itemAt() is a plain call, so otherwise removing a connection leaves this pointing at the + // destroyed delegate and the sidebar's view list disappears. + readonly property var currentConnectionView: + currentIndex >= 0 && currentIndex < connectionViews.count ? connectionViews.itemAt(currentIndex) : null property var notifications: [] // A tool shown instead of the connections: "", "database-editor", "multi-bus", "compare", "ethercat", "physical-io", - // "doip", "someip" or "gateway" + // "doip", "someip", "ethernet" or "gateway" property string tool: "" property bool closeConfirmed: false // Dragged with the handle on the right edge of the sidebar @@ -63,7 +67,8 @@ ApplicationWindow { for (const [key, title] of [["database-editor", qsTr("Database editor")], ["multi-bus", qsTr("Multi-bus trace")], ["compare", qsTr("Compare recordings")], ["ethercat", qsTr("EtherCAT (CoE)")], ["physical-io", qsTr("Physical I/O")], ["doip", qsTr("DoIP")], - ["someip", qsTr("SOME/IP")], ["gateway", qsTr("Bus gateway")]]) + ["someip", qsTr("SOME/IP")], ["ethernet", qsTr("Ethernet capture")], + ["gateway", qsTr("Bus gateway")]]) add(qsTr("Tool"), title, () => mainWindow.tool = key) for (const theme of connectionManager.app_state.themes) add(qsTr("Theme"), theme, () => connectionManager.app_state.set_theme(theme)) @@ -269,6 +274,7 @@ ApplicationWindow { : mainWindow.tool === "physical-io" ? qsTr("Physical I/O") : mainWindow.tool === "doip" ? qsTr("DoIP") : mainWindow.tool === "someip" ? qsTr("SOME/IP") + : mainWindow.tool === "ethernet" ? qsTr("Ethernet capture") : mainWindow.tool === "gateway" ? qsTr("Bus gateway") : mainWindow.tool === "multi-bus" ? qsTr("Multi-bus trace") : "" pageTitle: mainWindow.tool === "database-editor" ? connectionManager.database_editor.file_name @@ -277,6 +283,7 @@ ApplicationWindow { : mainWindow.tool === "physical-io" ? physicalIoView.pageTitle : mainWindow.tool === "doip" ? doipView.pageTitle : mainWindow.tool === "someip" ? someIpView.pageTitle + : mainWindow.tool === "ethernet" ? ethernetView.pageTitle : mainWindow.tool === "gateway" ? gatewayView.pageTitle : mainWindow.tool === "multi-bus" ? multiBusView.pageTitle : mainWindow.currentConnectionView ? mainWindow.currentConnectionView.pageTitle : "" @@ -341,6 +348,14 @@ ApplicationWindow { Layout.margins: 24 } + EthernetView { + id: ethernetView + visible: mainWindow.tool === "ethernet" + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: 24 + } + GatewayView { id: gatewayView visible: mainWindow.tool === "gateway" @@ -349,9 +364,11 @@ ApplicationWindow { Layout.margins: 24 } - StackLayout { + // A plain Item with anchored, visibility-toggled views rather than a StackLayout: a StackLayout does + // not reliably resize its current child when the Repeater's model changes (e.g. after closing a + // connection), which left the view squeezed to its implicit width. + Item { visible: mainWindow.tool === "" && connectionManager.connections.length > 0 - currentIndex: mainWindow.currentIndex Layout.fillWidth: true Layout.fillHeight: true Layout.margins: 24 @@ -361,7 +378,10 @@ ApplicationWindow { model: connectionManager.connections ConnectionView { required property var modelData + required property int index connection: modelData + anchors.fill: parent + visible: index === mainWindow.currentIndex } } } diff --git a/can_explorer/gui/reverse.py b/can_explorer/gui/reverse.py index e907cf5..15fdd78 100644 --- a/can_explorer/gui/reverse.py +++ b/can_explorer/gui/reverse.py @@ -13,7 +13,9 @@ from can_explorer.gui.reverse_timeline import ReverseTimeline from can_explorer.gui.reverse_tools import ( AnalysisController, + ConsoleController, MarkerController, + RasterController, ReactionController, ReferenceController, RelatedController, @@ -148,6 +150,8 @@ def __init__(self, connection, parent: Optional[QObject] = None): self._markers = MarkerController(self, parent=self) self._related = RelatedController(self, parent=self) self._reaction = ReactionController(self, parent=self) + self._console = ConsoleController(self, parent=self) + self._raster = RasterController(self, parent=self) self._timeline = ReverseTimeline(self, parent=self) self._timeline.moved.connect(self._on_timeline_moved) @@ -175,6 +179,16 @@ def related(self): def reaction(self): return self._reaction + @pyqtProperty(QObject, constant=True) + def console(self): + """Interactive Python console over the captured frames, with the reverse-engineering primitives.""" + return self._console + + @pyqtProperty(QObject, constant=True) + def raster(self): + """The bit-transition raster (activity map) of the selected identifier.""" + return self._raster + @pyqtProperty(QObject, constant=True) def timeline(self): """Temporal playback of the selected identifier (scrubber and play/pause for the Bits grid).""" @@ -637,6 +651,31 @@ def plot_field(self) -> str: self._connection.track_field(field, self._field['unit'], multiplexer, value) return '' + @pyqtSlot(result=str) + def plot_interval(self) -> str: + """Plot the selected identifier's inter-frame interval (Δt, in ms) over time on the Signals page: spot + gateways, bursts and event-triggered messages. Returns an error, or ''.""" + if self._selected is None: + return 'Select an identifier first' + frame_id, extended = self._selected + self._connection.track_interval(frame_id, extended) + return '' + + @pyqtSlot(result=str) + def plot_bytes(self) -> str: + """Plot each byte of the selected identifier's payload as a value over time on the Signals page. Returns + an error, or ''.""" + if self._selected is None: + return 'Select an identifier first' + item = self._current() + length = item.length if item is not None else 0 + if length == 0: + return 'No frames for this identifier yet' + frame_id, extended = self._selected + for byte in range(length): + self._connection.track_field(BitField(frame_id, extended, byte * 8, 8)) + return '' + @pyqtProperty('QVariantMap', notify=field_changed) def signal_definition(self) -> Dict[str, Any]: """The field as a database signal, for the database editor (see DatabaseEditorController.add_found_signal).""" diff --git a/can_explorer/gui/reverse_timeline.py b/can_explorer/gui/reverse_timeline.py index ef368a6..8afeacf 100644 --- a/can_explorer/gui/reverse_timeline.py +++ b/can_explorer/gui/reverse_timeline.py @@ -1,12 +1,14 @@ """Temporal "flow" playback for the Reverse engineering Bits tab: a scrubber and play/pause over the selected identifier's frames, so the bit grid shows the payload at any point in time and the bits flowing and flipping. -The controller holds an IdTimeline (built from a trace snapshot) and a playback position; while it is active the -ReverseController's bit grid, bytes and field value read the payload at that position instead of the latest frame. - -The position is a fraction (0 at the first frame, 1 at the last). Within a recording it maps to time, so playback -runs at the recorded pace and a frame holds until the next one; when every frame shares a timestamp (a coarse -clock, or a capture made in a tight loop) it maps straight to the frame index, so scrubbing still works. +The controller holds an IdTimeline (built from a trace snapshot) and a current frame index; while it is active the +ReverseController's bit grid, bytes and field value read the payload at that index instead of the latest frame. + +The current frame index is the source of truth: scrubbing and stepping move it directly, so they are frame-exact +even when several frames share a timestamp (a coarse clock, or a capture made in a tight loop, where time no +longer tells frames apart). Playback instead advances a time cursor so it runs at the recorded pace — a frame +holds until the next one — mapping that time back to a frame index as it goes. The slider reads the index as a +fraction (0 at the first frame, 1 at the last). """ import time @@ -30,9 +32,10 @@ def __init__(self, reverse, parent: Optional[QObject] = None): super().__init__(parent) self._reverse = reverse self._timeline: Optional[IdTimeline] = None - self._position = 0.0 # 0 at the first frame, 1 at the last + self._index = 0 # The current frame, source of truth self._speed = 1.0 self._playing = False + self._play_time = 0.0 # Seconds from the start of the recording, advanced during playback self._last_tick = 0.0 self._timer = QTimer(self) self._timer.setInterval(TICK_MS) @@ -46,7 +49,8 @@ def active(self) -> bool: def enable(self, timeline: IdTimeline) -> None: self._timeline = timeline - self._position = 0.0 + self._index = 0 + self._play_time = 0.0 self._playing = False self._timer.stop() self.changed.emit() @@ -57,6 +61,8 @@ def disable(self) -> None: if self._timeline is None and not self._playing: return self._timeline = None + self._index = 0 + self._play_time = 0.0 self._playing = False self._timer.stop() self.changed.emit() @@ -71,35 +77,26 @@ def payload(self) -> bytes: # Position - def _compute_index(self) -> int: - count = len(self._timeline) - if count <= 1: - return 0 if count == 1 else -1 - if self._timeline.duration <= 0: # Every frame at the same time: scrub by frame index - return min(count - 1, max(0, round(self._position * (count - 1)))) - if self._position <= 0.0: - return 0 - if self._position >= 1.0: - return count - 1 - return self._timeline.index_at(self._timeline.start + self._position * self._timeline.duration) - - def _time_value(self) -> float: - return self._timeline.start + self._position * self._timeline.duration + def _clamped_index(self) -> int: + return max(0, min(len(self._timeline) - 1, self._index)) @pyqtProperty(int, notify=moved) def index(self) -> int: - return self._compute_index() if self.active else -1 + return self._clamped_index() if self.active else -1 @pyqtProperty(float, notify=moved) def position(self) -> float: - """0 at the first frame, 1 at the last.""" - return self._position if self.active else 0.0 + """The current frame as a fraction: 0 at the first frame, 1 at the last.""" + count = len(self._timeline) if self.active else 0 + return self._clamped_index() / (count - 1) if count > 1 else 0.0 @pyqtSlot(float) def set_position(self, fraction: float) -> None: + """Scrub to a frame by fraction (0 the first, 1 the last) — by frame index, so every frame is reachable.""" if not self.active: return - self._position = max(0.0, min(1.0, fraction)) + count = len(self._timeline) + self._index = round(max(0.0, min(1.0, fraction)) * (count - 1)) if count > 1 else 0 self.moved.emit() @pyqtProperty(int, notify=moved) @@ -116,7 +113,8 @@ def time_text(self) -> str: if not self.active: return '' offset = self._reverse.session.start_time - return f'{self._time_value() - (offset if offset is not None else self._timeline.start):.3f} s' + base = offset if offset is not None else self._timeline.start + return f'{self._timeline.time_at(self.index) - base:.3f} s' @pyqtSlot(int) def step(self, delta: int) -> None: @@ -124,15 +122,8 @@ def step(self, delta: int) -> None: if not self.active: return self.pause() - count = len(self._timeline) - target = max(0, min(count - 1, self._compute_index() + delta)) - if count <= 1: - self._position = 0.0 - elif self._timeline.duration > 0: - # Land exactly on the frame's time, so the index maps back to it - self._position = (self._timeline.time_at(target) - self._timeline.start) / self._timeline.duration - else: - self._position = target / (count - 1) + self._index = self._clamped_index() + delta + self._index = self._clamped_index() self.moved.emit() # Playback @@ -145,8 +136,9 @@ def playing(self) -> bool: def play(self) -> None: if not self.active or self._playing: return - if self._position >= 1.0: # At the end: start over - self._position = 0.0 + if self._clamped_index() >= len(self._timeline) - 1: # At the end: start over + self._index = 0 + self._play_time = self._timeline.time_at(self._clamped_index()) - self._timeline.start self._playing = True self._last_tick = time.monotonic() self._timer.start() @@ -183,11 +175,13 @@ def _tick(self) -> None: self._last_tick = now duration = self._timeline.duration if duration <= 0: # Nothing to advance through in time: jump to the end - self._position = 1.0 + self._index = len(self._timeline) - 1 self.pause() else: - self._position += elapsed * self._speed / duration - if self._position >= 1.0: - self._position = 1.0 + self._play_time += elapsed * self._speed + if self._play_time >= duration: + self._index = len(self._timeline) - 1 self.pause() + else: + self._index = self._timeline.index_at(self._timeline.start + self._play_time) self.moved.emit() diff --git a/can_explorer/gui/reverse_tools.py b/can_explorer/gui/reverse_tools.py index c544faf..474fabb 100644 --- a/can_explorer/gui/reverse_tools.py +++ b/can_explorer/gui/reverse_tools.py @@ -9,6 +9,9 @@ from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtProperty, pyqtSignal, pyqtSlot from can_explorer.core.reverse.analysis import FoundField, analyse_all, collect +from can_explorer.core.reverse.console import HELP, build_namespace +from can_explorer.core.reverse.console import run as run_console +from can_explorer.core.reverse.raster import Raster, build_raster from can_explorer.core.reverse.markers import Marker, MarkerMatch, find_marker_changes from can_explorer.core.reverse.obd import PIDS, ObdPoller, is_obd_identifier from can_explorer.core.reverse.reactions import IdReaction, capture_reaction @@ -797,3 +800,114 @@ def plot(self, index: int) -> str: return 'Nothing to plot' self.reverse.connection.track_field(item.field) return '' + + +class ConsoleController(_Tool): + """An interactive Python console over the captured frames, with the reverse-engineering primitives (collect, + analyse, BitField, candidates, signal_groups). Variables persist between runs; the frames, found fields and + helpers are refreshed from the trace on each run. It runs the snippet in a worker thread.""" + + changed = pyqtSignal() + running = pyqtProperty(bool, fget=_Tool._get_running, notify=changed) + status = pyqtProperty(str, fget=_Tool._get_status, notify=changed) + + def __init__(self, reverse, parent: Optional[QObject] = None): + super().__init__(reverse, parent) + self._namespace: Dict[str, Any] = {} + self._entries: List[Dict[str, str]] = [] + + @pyqtProperty(str, constant=True) + def help_text(self) -> str: + return HELP + + @pyqtProperty('QVariantList', notify=changed) + def entries(self) -> List[Dict[str, str]]: + return self._entries + + @pyqtSlot(str) + def run(self, source: str): + """Run a snippet over the current trace. Its output and value (or error) are appended to the transcript.""" + if not self._running and source.strip(): + self._start(self._run(source)) + + async def _run(self, source: str) -> None: + self._status = 'Running…' + self.changed.emit() + messages = await self.reverse.snapshot() + analyses = list(self.reverse.analyses) + result = await asyncio.to_thread(self._execute, messages, analyses, source) + self._entries.append( + {'source': source.strip(), 'output': result.output, 'value': result.value, 'error': result.error} + ) + self._status = '' if result.ok else 'Error' + + def _execute(self, messages, analyses, source: str): + self._namespace.update(build_namespace(messages, analyses)) + return run_console(source, self._namespace) + + @pyqtSlot() + def clear(self): + """Clear the transcript and forget all variables.""" + self._entries = [] + self._namespace = {} + self._status = '' + self.changed.emit() + + +class RasterController(_Tool): + """Builds the bit-transition raster of the selected identifier — the value of every bit across the whole + capture — for the activity map. Built in a worker thread; the painter (BitRaster) reads the result.""" + + changed = pyqtSignal() + running = pyqtProperty(bool, fget=_Tool._get_running, notify=changed) + status = pyqtProperty(str, fget=_Tool._get_status, notify=changed) + + def __init__(self, reverse, parent: Optional[QObject] = None): + super().__init__(reverse, parent) + self._raster: Optional[Raster] = None + + def raster(self) -> Optional[Raster]: + """The current raster, read by the BitRaster painter (not a QML-exposed value).""" + return self._raster + + @pyqtProperty(bool, notify=changed) + def has_data(self) -> bool: + return self._raster is not None and self._raster.columns > 0 + + @pyqtProperty(int, notify=changed) + def frames(self) -> int: + return self._raster.frames if self._raster is not None else 0 + + @pyqtProperty(str, notify=changed) + def span(self) -> str: + if self._raster is None or self._raster.frames == 0: + return '' + return f'{self._raster.end - self._raster.start:.1f} s' + + @pyqtSlot() + def refresh(self): + """Rebuild the raster of the selected identifier from the trace so far.""" + if not self._running: + self._start(self._refresh()) + + async def _refresh(self) -> None: + selected = self.reverse.selected + if not selected: + self._raster = None + self._status = 'Select an identifier' + self.changed.emit() + return + key = (int(selected, 16), len(selected) > 3) + self._status = 'Reading the trace…' + self.changed.emit() + messages = await self.reverse.snapshot() + frames = [ + msg + for msg in messages + if (msg.arbitration_id, msg.is_extended_id) == key and not msg.is_remote_frame and not msg.is_error_frame + ] + times = [msg.timestamp for msg in frames] + payloads = [bytes(msg.data) for msg in frames] + self._raster = await asyncio.to_thread(build_raster, times, payloads) + self._status = f'{self._raster.frames:,} frames, {self._raster.length} bytes' if self._raster.frames else '' + self.changed.emit() diff --git a/can_explorer/gui/someip.py b/can_explorer/gui/someip.py index db56051..b763d9a 100644 --- a/can_explorer/gui/someip.py +++ b/can_explorer/gui/someip.py @@ -7,6 +7,7 @@ from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtProperty, pyqtSignal, pyqtSlot from can_explorer.core.someip import DEFAULT_SD_MULTICAST, DEFAULT_SD_PORT, SomeIpMonitor, describe +from can_explorer.core.someip_arxml import ArxmlError, load_arxml from can_explorer.core.someip_fibex import FibexError, load_fibex from can_explorer.core.someip_serialization import SomeIpDescription from can_explorer.sys.log_handler import get_logger @@ -142,11 +143,14 @@ def description_file(self) -> str: @pyqtSlot(QUrl, result=str) def load_description(self, url: QUrl) -> str: - """Load a FIBEX file so payloads are decoded into signals. Returns an error or ''.""" + """Load a FIBEX or ARXML file so payloads are decoded into signals. Returns an error or ''.""" path = url.toLocalFile() if url.isLocalFile() else url.toString() try: - self._description = load_fibex(path) - except (FibexError, OSError) as e: + if path.lower().endswith('.arxml'): + self._description = load_arxml(path) + else: + self._description = load_fibex(path) + except (FibexError, ArxmlError, OSError) as e: return str(e) self._description_path = path logger.info('Loaded SOME/IP description %s (%d message types)', path, len(self._description)) diff --git a/can_explorer/gui/uds_scan.py b/can_explorer/gui/uds_scan.py index 325cf46..0bc8ddc 100644 --- a/can_explorer/gui/uds_scan.py +++ b/can_explorer/gui/uds_scan.py @@ -134,6 +134,9 @@ def start(self, options: Dict[str, Any]) -> str: self._state = 'running' self._stop = asyncio.Event() self._task = asyncio.ensure_future(self._run(plan)) + # _run emits `changed` from its finally while the task is not yet done (running still reads True); emit once + # more after the task has finished so `running` becomes False and the Stop button returns to Start. + self._task.add_done_callback(lambda _: self.changed.emit()) self._timer.start() self.changed.emit() self.results_changed.emit() @@ -274,7 +277,11 @@ def _flush(self) -> None: @pyqtSlot() def stop(self): + # Set the event for a clean stop between requests, and cancel the task so an in-flight request (a long + # per-request timeout, or an ECU looping on "response pending") is interrupted at once. self._stop.set() + if self._task is not None and not self._task.done(): + self._task.cancel() @pyqtSlot() def clear(self): diff --git a/can_explorer/main_application.py b/can_explorer/main_application.py index df737f4..7e55c30 100644 --- a/can_explorer/main_application.py +++ b/can_explorer/main_application.py @@ -23,6 +23,8 @@ from can_explorer.gui.connection_manager import ConnectionManager from can_explorer.desktop import desktop_file_name from can_explorer.gui.signal_plot import register_qml_types +from can_explorer.gui.bit_raster import register_bit_raster +from can_explorer.gui.python_highlighter import register_python_highlighter logger = get_logger(__name__) @@ -37,6 +39,8 @@ def create_qml_engine( # Controls drawn with the theme colours on every platform (the native styles cannot be recoloured) os.environ.setdefault('QT_QUICK_CONTROLS_STYLE', 'CanExplorerStyle') # can_explorer/gui/qt/CanExplorerStyle register_qml_types() + register_bit_raster() + register_python_highlighter() engine = QQmlApplicationEngine() engine.setOutputWarningsToStandardError(False) engine.warnings.connect(on_warnings) diff --git a/can_explorer/mcp/__main__.py b/can_explorer/mcp/__main__.py index 50a4b52..8b3b15c 100644 --- a/can_explorer/mcp/__main__.py +++ b/can_explorer/mcp/__main__.py @@ -1,9 +1,10 @@ """The `can-mcp` command: run the MCP server for one CAN bus over stdio, for use by an MCP client (an agent). -Example (read-only): +Example (read-only, bus given up front): can-mcp --interface socketcan --channel can0 --bitrate 500000 --database vehicle.dbc -Add --allow-send to let the agent transmit (send_frame, query_obd). The `mcp` extra must be installed. +Without --interface/--channel the server starts unconnected and the agent chooses a bus itself (list_interfaces +then connect). Add --allow-diagnostics / --allow-control / --allow-send to grant more. The `mcp` extra is needed. """ import asyncio @@ -19,8 +20,12 @@ @click.command(context_settings={'help_option_names': ['-h', '--help']}) -@click.option('--interface', '-i', required=True, help='python-can interface, e.g. socketcan, pcan, virtual, gvret.') -@click.option('--channel', '-c', required=True, help='Channel, e.g. can0, PCAN_USBBUS1, COM7.') +@click.option( + '--interface', '-i', default=None, help='python-can interface (e.g. socketcan); omit to let the agent connect.' +) +@click.option( + '--channel', '-c', default=None, help='Channel, e.g. can0, PCAN_USBBUS1, COM7; omit to let the agent connect.' +) @click.option('--bitrate', '-b', default=500_000, show_default=True, help='Nominal bitrate.') @click.option('--fd', is_flag=True, help='CAN FD.') @click.option('--data-bitrate', type=int, default=None, help='CAN FD data bitrate (implies --fd).') @@ -32,15 +37,22 @@ type=click.Choice(list(PROTOCOL_DECODERS)), help='Protocol decoder for the frames.', ) -@click.option('--allow-send', is_flag=True, help='Allow the agent to transmit frames (send_frame, query_obd).') -def main(interface, channel, bitrate, fd, data_bitrate, database, protocol, allow_send): +@click.option( + '--allow-diagnostics', is_flag=True, help='Allow safe diagnostic reads (OBD and UDS reads; these send requests).' +) +@click.option( + '--allow-control', is_flag=True, help='Allow active diagnostics (clear DTCs, ECU reset, routines, raw UDS).' +) +@click.option('--allow-send', is_flag=True, help='Allow the agent to transmit arbitrary frames (grants everything).') +def main( + interface, channel, bitrate, fd, data_bitrate, database, protocol, allow_diagnostics, allow_control, allow_send +): """Run the CANExplorer MCP server for one bus over stdio.""" + if bool(interface) != bool(channel): + raise SystemExit('Give both --interface and --channel, or neither (the agent connects with connect()).') # Logs must not go to stdout: stdout carries the MCP protocol. logging.basicConfig(level=logging.INFO, stream=sys.stderr) - try: - from can_explorer.mcp.server import build_mcp - except ImportError as e: - raise SystemExit("The MCP SDK is not installed. Install it with: pip install 'can-explorer[mcp]'") from e + from can_explorer.mcp.server import build_mcp # Imported here so --help stays fast and Qt-free service = CanService.from_config( interface, @@ -51,6 +63,8 @@ def main(interface, channel, bitrate, fd, data_bitrate, database, protocol, allo database=database, protocol=protocol, allow_send=allow_send, + allow_diagnostics=allow_diagnostics, + allow_control=allow_control, ) server = build_mcp(service) try: @@ -61,7 +75,13 @@ def main(interface, channel, bitrate, fd, data_bitrate, database, protocol, allo async def _serve(service: CanService, server) -> None: service.start() - logger.info('CANExplorer MCP server ready (%s)', 'read-write' if service.allow_send else 'read-only') + tier = ( + 'send' + if service.allow_send + else 'control' if service.allow_control else 'diagnostics' if service.allow_diagnostics else 'read-only' + ) + where = 'connected' if service.connected else 'not connected (the agent will connect)' + logger.info('CANExplorer MCP server ready (%s, %s)', tier, where) try: await server.run_stdio_async() finally: diff --git a/can_explorer/mcp/http_server.py b/can_explorer/mcp/http_server.py new file mode 100644 index 0000000..95344c7 --- /dev/null +++ b/can_explorer/mcp/http_server.py @@ -0,0 +1,95 @@ +"""Run the MCP server inside the application over localhost HTTP, so an agent can attach to a live connection the +GUI already has open (which a separate can-mcp process usually cannot, as most adapters are single-access). + +It is loopback-only (127.0.0.1) and token-gated: every request must carry the token as an `Authorization: Bearer` +header or a `?token=` query parameter. It uses the MCP streamable-HTTP transport, served by uvicorn as a task on +the running event loop. Needs the `mcp` extra (which brings uvicorn and starlette). +""" + +import asyncio +import secrets +import socket +from typing import Optional +from urllib.parse import parse_qs + +from can_explorer.sys.log_handler import get_logger + +logger = get_logger(__name__) + +DEFAULT_PATH = '/mcp' + + +def _free_port(host: str) -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind((host, 0)) + return probe.getsockname()[1] + + +class TokenAuth: + """ASGI middleware requiring the token on every HTTP request; non-HTTP scopes (lifespan) pass through.""" + + def __init__(self, app, token: str): + self._app = app + self._token = token + + async def __call__(self, scope, receive, send): + if scope['type'] == 'http' and not self._authorised(scope): + await send({'type': 'http.response.start', 'status': 401, 'headers': [(b'content-type', b'text/plain')]}) + await send({'type': 'http.response.body', 'body': b'Unauthorized: missing or wrong token'}) + return + await self._app(scope, receive, send) + + def _authorised(self, scope) -> bool: + headers = {key.lower(): value for key, value in scope.get('headers') or []} + authorization = headers.get(b'authorization', b'').decode('latin-1') + if authorization.startswith('Bearer ') and secrets.compare_digest(authorization[7:], self._token): + return True + query = parse_qs(scope.get('query_string', b'').decode('latin-1')) + return secrets.compare_digest(query.get('token', [''])[0], self._token) + + +class InAppMcpServer: + """A localhost, token-gated MCP server over streamable HTTP for a CanService, run on the current event loop.""" + + def __init__(self, service, *, host: str = '127.0.0.1', port: int = 0, token: Optional[str] = None): + self.service = service + self.host = host + self.port = port or _free_port(host) + self.token = token or secrets.token_urlsafe(16) + self.path = DEFAULT_PATH + self._server = None + self._task: Optional[asyncio.Future] = None + + @property + def url(self) -> str: + return f'http://{self.host}:{self.port}{self.path}' + + async def start(self) -> str: + """Start serving; returns the URL. Raises if the server could not start.""" + import uvicorn + + from can_explorer.mcp.server import build_mcp + + mcp = build_mcp(self.service) + app = TokenAuth(mcp.streamable_http_app(host=self.host, streamable_http_path=self.path), self.token) + config = uvicorn.Config(app, host=self.host, port=self.port, log_level='warning', lifespan='on') + self._server = uvicorn.Server(config) + self._server.install_signal_handlers = lambda: None # Qt owns the signals; do not touch them + self._task = asyncio.ensure_future(self._server.serve()) + while not self._server.started and not self._task.done(): + await asyncio.sleep(0.02) + if self._task.done() and self._task.exception() is not None: + raise self._task.exception() + logger.info('In-app MCP server listening on %s', self.url) + return self.url + + async def stop(self) -> None: + if self._server is not None: + self._server.should_exit = True + if self._task is not None: + try: + await asyncio.wait_for(asyncio.shield(self._task), timeout=5) + except (asyncio.TimeoutError, asyncio.CancelledError, Exception): # noqa: BLE001 - stop must not raise + self._task.cancel() + self._task = None + self._server = None diff --git a/can_explorer/mcp/server.py b/can_explorer/mcp/server.py index fee4cf4..64777e4 100644 --- a/can_explorer/mcp/server.py +++ b/can_explorer/mcp/server.py @@ -1,34 +1,67 @@ -"""The MCP server: exposes a CanService (service.py) as MCP tools over stdio, using the MCP SDK (v2, MCPServer). +"""The MCP server: exposes a CanService (service.py) as MCP tools, resources and prompts over stdio, using the MCP +SDK (v2, MCPServer). -Needs the `mcp` package (the `mcp` extra). The tools are thin async wrappers around the service, so they run on -the event loop thread, in step with the bus receive pump. The transmitting tools (send_frame, query_obd) are only -registered when the service was started with sending allowed. +Needs the `mcp` package (the `mcp` extra). The tools are thin async wrappers around the service, so they run on the +event loop thread, in step with the bus receive pump. Tools are registered by permission tier: the diagnostic +reads need the diagnostics tier, the active control tools the control tier, and the transmitting tools the send +tier (see CanService.can_diagnose / can_control / allow_send). """ +import json from typing import Any, Dict, List, Optional from mcp.server import MCPServer from can_explorer.mcp.service import CanService -INSTRUCTIONS = """Inspect and (when allowed) drive a CAN bus. +INSTRUCTIONS = """Inspect, reverse-engineer and (when allowed) diagnose or drive a CAN bus. -Start with bus_status to see the bus state and how many frames and identifiers have been seen, and -list_identifiers for the identifiers with their cycle times and (if a database is loaded) names. get_frames -returns recent frames decoded into signals; decode_frame decodes a payload you supply. analyze_identifier runs -the automatic field analysis (checksums, counters, multiplexers, physical values) on an identifier's recent -frames. load_database loads a DBC (or ARXML/KCD/SYM/DBF) for decoding. Identifiers are hex strings such as "1A0". +If bus_status shows "connected": false, first call list_interfaces to see the available CAN interfaces, then +connect(interface, channel) to open one (for example interface "socketcan" channel "can0", or "virtual" channel +"test" to try it). The database, protocol and permissions stay across connections. + +Once connected, start with bus_status (state, counts, and which capabilities are enabled) and list_identifiers. +get_frames returns recent frames decoded into signals; decode_frame and decode_protocol interpret a payload you +supply. timing shows which identifiers are periodic or event-triggered (series=true for the Δt over time). The +reverse-engineering tools: analyze_identifier (automatic fields) and bit_activity (raw per-bit behaviour) to see +what is in a frame; extract_field to decode and test a candidate bit range over time without a database; +related_signals, correlate, and capture_reaction (single action) or mark + reaction_at_markers (a repeated action) +to pin down meaning; promote_to_dbc to write found or hand-specified fields to a DBC. load_database loads a +DBC/ARXML/KCD/SYM/DBF. The resources can://status, can://identifiers, can://frames and can://database mirror this +as readable data; the prompts guide the common workflows. Identifiers are hex strings such as "1A0". + +Diagnostics (OBD-II obd_* and UDS uds_*) and transmitting (send_frame, send_message) are only available when the +server was started with the matching permission; each tool's description says which tier it needs. """ -def build_mcp(service: CanService) -> MCPServer: +def build_mcp(service: CanService) -> MCPServer: # noqa: C901 - a flat list of tool registrations mcp = MCPServer('CANExplorer', instructions=INSTRUCTIONS) + # Read-only: inspection and reverse engineering + @mcp.tool() async def bus_status() -> Dict[str, Any]: - """The bus state, loaded database, and current frame counts, rates and bus load.""" + """The connection state, loaded database, enabled capabilities, and (when connected) frame counts, rates + and bus load. If not connected, use list_interfaces and connect.""" return service.status() + if service.manages_connection: + + @mcp.tool() + async def connect( + interface: str, channel: str, bitrate: int = 500000, fd: bool = False, data_bitrate: Optional[int] = None + ) -> Dict[str, Any]: + """Open a CAN bus and start receiving, closing any current one. interface is a python-can interface + (socketcan, pcan, vector, gvret, virtual, canexplorer_remote…) and channel its channel (can0, + PCAN_USBBUS1, COM7…). Keeps the database and protocol; starts a fresh trace.""" + return service.connect(interface, channel, bitrate=bitrate, fd=fd, data_bitrate=data_bitrate) + + @mcp.tool() + async def disconnect() -> Dict[str, Any]: + """Close the current CAN bus.""" + return service.disconnect() + @mcp.tool() async def list_identifiers(limit: Optional[int] = None) -> List[Dict[str, Any]]: """Every identifier seen, with its direction, count, cycle time, last data and database name.""" @@ -46,6 +79,21 @@ async def decode_frame(identifier: str, data: str, extended: bool = False) -> Di """Decode a payload (hex, e.g. "A0 0F 5A 03") for an identifier with the loaded database. Sends nothing.""" return service.decode_frame(identifier, data, extended) + @mcp.tool() + async def decode_protocol(protocol: str, identifier: str, data: str, extended: bool = False) -> Dict[str, Any]: + """Interpret a payload with a protocol decoder (J1939, "NMEA 2000", CANopen, ISO-TP, UDS, LIN, J1587), + without a database. Sends nothing.""" + return service.decode_protocol(protocol, identifier, data, extended) + + @mcp.tool() + async def timing( + identifier: Optional[str] = None, extended: bool = False, series: bool = False + ) -> List[Dict[str, Any]]: + """The inter-frame timing of each identifier (or one): mean cycle time, jitter, and whether it is periodic + or event-triggered. Spots gateways, bursts and event messages. With series=true, each row also carries a + downsampled intervals_ms series of the inter-frame gaps over time.""" + return service.timing(identifier, extended, series) + @mcp.tool() async def analyze_identifier( identifier: str, extended: bool = False, include_constant: bool = False @@ -55,9 +103,61 @@ async def analyze_identifier( return service.analyze(identifier, extended, include_constant) @mcp.tool() - async def load_database(path: str) -> Dict[str, Any]: - """Load a signal database (DBC, ARXML, KCD, SYM or DBF) so frames are decoded into named signals.""" - return service.load_database(path) + async def bit_activity(identifier: str, extended: bool = False) -> Dict[str, Any]: + """Per-bit behaviour of one identifier: for each bit how often it is set and flips (constant, toggling and + counter bits stand out), plus a per-byte summary (distinct values, range, entropy). The raw view behind + analyze_identifier — use it when a byte does not parse as a clean field.""" + return service.bit_activity(identifier, extended) + + @mcp.tool() + async def extract_field( + identifier: str, + start: int, + length: int, + byte_order: str = 'little_endian', + signed: bool = False, + scale: float = 1.0, + offset: float = 0.0, + extended: bool = False, + max_samples: int = 200, + ) -> Dict[str, Any]: + """Decode a candidate field (a bit range with byte order, sign and scaling) over the recent frames without a + database, to test a hypothesis: returns the value range, how often it changes, and a downsampled value + series over time. start is the least significant bit (little_endian) or most significant bit (big_endian). + Confirm a field here, then write it with promote_to_dbc(fields=[…]).""" + return service.extract_field( + identifier, start, length, byte_order, signed, scale, offset, extended, max_samples + ) + + @mcp.tool() + async def mark(label: str = '') -> Dict[str, Any]: + """Mark the current moment (the newest frame's time) to line up with a physical action. Mark the same + action a few times, then call reaction_at_markers to find what reacts.""" + return service.mark(label) + + @mcp.tool() + async def clear_markers() -> Dict[str, Any]: + """Forget all the markers set with mark().""" + return service.clear_markers() + + @mcp.tool() + async def reaction_at_markers(before: float = 0.5, after: float = 1.5, top: int = 30) -> Dict[str, Any]: + """The fields that change around the moments set with mark() — most specific first — with how many markers + each hits and its background change rate, to tell a real reaction from coincidence. Passive — reads only.""" + return service.reaction_at_markers(before, after, top) + + @mcp.tool() + async def related_signals(top: int = 20, min_correlation: float = 0.99) -> Dict[str, Any]: + """Groups of fields that carry the same signal across the bus (the same quantity rebroadcast, or a scaled + or lagged copy), found without a database.""" + return service.related_signals(top, min_correlation) + + @mcp.tool() + async def capture_reaction(baseline_seconds: float = 5.0, action_seconds: float = 5.0) -> Dict[str, Any]: + """Diff the most recent `action_seconds` of traffic against the `baseline_seconds` before it to find what + changed on the bus (identifiers that appeared, bits that changed). Record a steady baseline, perform the + action, wait `action_seconds`, then call this. Passive — reads only.""" + return service.capture_reaction(baseline_seconds, action_seconds) @mcp.tool() async def correlate( @@ -90,6 +190,16 @@ async def correlate( top=top, ) + @mcp.tool() + async def load_database(path: str) -> Dict[str, Any]: + """Load a signal database (DBC, ARXML, KCD, SYM or DBF) so frames are decoded into named signals.""" + return service.load_database(path) + + @mcp.tool() + async def unload_database() -> Dict[str, Any]: + """Forget the loaded database, so frames are shown as raw bytes again.""" + return service.unload_database() + @mcp.tool() async def promote_to_dbc( path: str, @@ -97,10 +207,91 @@ async def promote_to_dbc( extended: bool = False, include_constant: bool = False, min_confidence: float = 0.0, + fields: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: - """Write the fields the analysis finds (of one identifier, or all) as signals to a DBC at `path`, creating - their messages. An existing database at the path is extended, not overwritten.""" - return service.promote_to_dbc(path, identifier, extended, include_constant, min_confidence) + """Write signals to a DBC at `path`, creating their messages (an existing database is extended, not + overwritten). Without `fields`, the fields the analysis finds (of one identifier, or all) are written. With + `fields`, exactly the given specs are written — a hypothesis you confirmed with extract_field: each is + {identifier, start, length, byte_order?, signed?, scale?, offset?, name?, unit?, comment?, extended?}.""" + return service.promote_to_dbc(path, identifier, extended, include_constant, min_confidence, fields) + + @mcp.tool() + async def list_interfaces() -> List[Dict[str, Any]]: + """The CAN interfaces python-can can detect on this machine.""" + return service.interfaces() + + # Diagnostics reads (diagnostics tier) + + if service.can_diagnose: + + @mcp.tool() + async def query_obd(pid: int, timeout: float = 1.0) -> Dict[str, Any]: + """Request one OBD-II value (service 01, e.g. pid 13 for vehicle speed, 12 for engine speed) with a + single-frame request and return the ECU's decoded answer.""" + return await service.query_obd(pid, timeout) + + @mcp.tool() + async def obd_status(request: str = '7DF', response: str = '7E8') -> Dict[str, Any]: + """The MIL ("check engine") state and the number of stored OBD-II trouble codes.""" + return await service.obd_status(request, response) + + @mcp.tool() + async def obd_dtcs(pending: bool = False, request: str = '7DF', response: str = '7E8') -> Dict[str, Any]: + """The stored (service 03) or pending (service 07) OBD-II trouble codes, decoded to P/C/B/U codes.""" + return await service.obd_dtcs(pending, request, response) + + @mcp.tool() + async def obd_vin(request: str = '7DF', response: str = '7E8') -> Dict[str, Any]: + """The vehicle identification number (OBD service 09 PID 02).""" + return await service.obd_vin(request, response) + + @mcp.tool() + async def obd_supported(request: str = '7DF', response: str = '7E8') -> Dict[str, Any]: + """The OBD-II service-01 PIDs the ECU supports, of those this tool can decode.""" + return await service.obd_supported(request, response) + + @mcp.tool() + async def obd_read(pids: List[int], request: str = '7DF', response: str = '7E8') -> Dict[str, Any]: + """Read and decode OBD-II live values (service 01), e.g. pids [12, 13] for engine and vehicle speed.""" + return await service.obd_read(pids, request, response) + + @mcp.tool() + async def uds_read_did(did: str, request: str = '7E0', response: str = '7E8') -> Dict[str, Any]: + """Read a UDS data identifier (service 0x22), e.g. did "F190" for the VIN.""" + return await service.uds_read_did(did, request, response) + + @mcp.tool() + async def uds_read_dtcs(status_mask: int = 0xFF, request: str = '7E0', response: str = '7E8') -> Dict[str, Any]: + """Read UDS trouble codes (service 0x19 reportDTCByStatusMask).""" + return await service.uds_read_dtcs(status_mask, request, response) + + # Active control (control tier) + + if service.can_control: + + @mcp.tool() + async def obd_clear(request: str = '7DF', response: str = '7E8') -> Dict[str, Any]: + """Clear the OBD-II trouble codes and turn the MIL off (service 04).""" + return await service.obd_clear(request, response) + + @mcp.tool() + async def uds_reset(reset_type: int = 1, request: str = '7E0', response: str = '7E8') -> Dict[str, Any]: + """Reset an ECU (service 0x11; 1 = hard reset).""" + return await service.uds_reset(reset_type, request, response) + + @mcp.tool() + async def uds_routine( + control_type: int, routine: str, data: str = '', request: str = '7E0', response: str = '7E8' + ) -> Dict[str, Any]: + """Routine control (service 0x31; control_type 1 = start, 2 = stop, 3 = request results).""" + return await service.uds_routine(control_type, routine, data, request, response) + + @mcp.tool() + async def uds_request(payload: str, request: str = '7E0', response: str = '7E8') -> Dict[str, Any]: + """Send a raw UDS request (hex, e.g. "22 F1 90") and return the response.""" + return await service.uds_request(payload, request, response) + + # Transmitting (send tier) if service.allow_send: @@ -110,9 +301,71 @@ async def send_frame(identifier: str, data: str, extended: bool = False, fd: boo return service.send_frame(identifier, data, extended, fd) @mcp.tool() - async def query_obd(pid: int, timeout: float = 1.0) -> Dict[str, Any]: - """Request one OBD-II value (service 01, e.g. pid 13 for vehicle speed, 12 for engine speed) and - return the ECU's decoded answer. Transmits a diagnostic request on the bus.""" - return await service.query_obd(pid, timeout) + async def send_message(name: str, signals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Encode a database message by name from its signal values (e.g. {"EngineSpeed": 2500}) and send it.""" + return service.send_message(name, signals) + + # Resources: the same data, as readable documents + + @mcp.resource('can://status', mime_type='application/json') + async def status_resource() -> str: + return json.dumps(service.status(), indent=2) + + @mcp.resource('can://identifiers', mime_type='application/json') + async def identifiers_resource() -> str: + return json.dumps(service.identifiers(), indent=2) + + @mcp.resource('can://frames', mime_type='application/json') + async def frames_resource() -> str: + return json.dumps(service.frames(limit=100), indent=2) + + @mcp.resource('can://database', mime_type='application/json') + async def database_resource() -> str: + database = service.session.decoder.database + if database is None: + return json.dumps({'database': None, 'messages': []}) + messages = [ + {'id': f'{message.frame_id:X}', 'name': message.name, 'signals': [s.name for s in message.signals]} + for message in database.messages() + ] + return json.dumps({'database': str(database.path), 'messages': messages}, indent=2) + + # Prompts: guided workflows + + @mcp.prompt() + async def reverse_engineer_identifier(identifier: str) -> str: + """Guide: reverse-engineer the signals of one identifier.""" + return ( + f"Reverse-engineer identifier {identifier} on the CAN bus.\n" + f"1. Call timing to see how often {identifier} is sent (periodic or event-triggered).\n" + f"2. Call analyze_identifier for {identifier} to find its fields (counters, checksums, values, flags).\n" + "3. Use capture_reaction (after I do an action) or correlate (against a known reference) to pin down " + "what a field means.\n" + "4. Summarise the fields you found with their bit positions and likely meaning, and offer to " + "promote_to_dbc." + ) + + @mcp.prompt() + async def diagnose_trouble_codes() -> str: + """Guide: read and explain the vehicle's trouble codes.""" + return ( + "Diagnose the vehicle over OBD-II.\n" + "1. Call obd_status for the MIL state and code count, and obd_vin for the vehicle.\n" + "2. Call obd_dtcs (stored) and obd_dtcs(pending=true) to list the trouble codes.\n" + "3. Explain each code in plain language, its likely causes and severity.\n" + "4. Read relevant live values with obd_read (e.g. coolant temperature, engine load) to support the " + "diagnosis. Only clear codes with obd_clear if I explicitly ask." + ) + + @mcp.prompt() + async def find_signal(description: str) -> str: + """Guide: find which field carries a described signal.""" + return ( + f"Find the field on the bus that represents: {description}.\n" + "If a reference series is available (a database signal, a CSV log, or values I give you), call " + "correlate with it. Otherwise use related_signals to find fields that move together, and timing and " + "analyze_identifier to narrow down candidates. Report the identifier, bit field, and fitted scale and " + "offset of the best match." + ) return mcp diff --git a/can_explorer/mcp/service.py b/can_explorer/mcp/service.py index 372787f..6a751ac 100644 --- a/can_explorer/mcp/service.py +++ b/can_explorer/mcp/service.py @@ -16,11 +16,16 @@ from can_explorer.core.database_io import DatabaseFormatError, WRITE_SUFFIXES from can_explorer.core.decoding import DbcDecoder, FrameDecoder, open_signal_database from can_explorer.core.frames import parse_hex_bytes +from can_explorer.core.isotp_client import IsoTpClient +from can_explorer.core.obd import ECU_RESPONSE, FUNCTIONAL_REQUEST, ObdClient from can_explorer.core.protocols import PROTOCOL_DECODERS, create_protocol_decoder from can_explorer.core.reverse.analysis import analyse, collect +from can_explorer.core.reverse.bitstats import BitStatistics from can_explorer.core.reverse.fields import BitField +from can_explorer.core.reverse.markers import Marker, find_marker_changes from can_explorer.core.reverse.obd import PIDS_BY_NUMBER, is_obd_identifier, request as obd_request from can_explorer.core.reverse.promote import signal_definitions, write_definitions +from can_explorer.core.reverse.reactions import capture_reaction from can_explorer.core.reverse.reference import ( Reference, candidates, @@ -28,8 +33,13 @@ read_reference_csv, reference_from_signal, ) +from can_explorer.core.reverse.related import signal_groups from can_explorer.core.session import BusSession, SessionError from can_explorer.core.sources import BusConfig, LiveSource, MessageSource +from can_explorer.core.uds import UdsClient, UdsError + +DEFAULT_UDS_REQUEST = 0x7E0 # Physical request to the first ECU; OBD uses the functional 0x7DF +NOT_CONNECTED = 'Not connected to a bus; use connect(interface, channel) first' SCAN_FRAMES = 50_000 # Newest frames scanned when answering about recent frames or analysing an identifier DEFAULT_FRAME_LIMIT = 50 @@ -65,30 +75,98 @@ def _jsonable(value: Any) -> Any: return str(value) +def _field_definition(spec: Dict[str, Any]) -> Dict[str, Any]: + """An explicit field spec {identifier, start, length, byte_order?, …} as a promote.write_definitions entry, + validating the bit range through BitField.""" + frame_id = _parse_id(spec['identifier'] if 'identifier' in spec else spec['id']) + extended = bool(spec.get('extended', False)) + byte_order = spec.get('byte_order', 'little_endian') + signed, scale, offset = ( + bool(spec.get('signed', False)), + float(spec.get('scale', 1.0)), + float(spec.get('offset', 0.0)), + ) + field = BitField(frame_id, extended, int(spec['start']), int(spec['length']), byte_order, signed, scale, offset) + return { + 'frame_id': frame_id, + 'extended': extended, + 'frame_length': int(spec.get('frame_length', field.min_length)), + 'name': spec.get('name') or f'Signal_{field.id_text}_{field.start}', + 'start': field.start, + 'length': field.length, + 'byte_order': byte_order, + 'is_signed': signed, + 'scale': scale, + 'offset': offset, + 'unit': spec.get('unit', ''), + 'comment': spec.get('comment', ''), + } + + +def _downsample(values: List[float], count: int = 200) -> List[float]: + """At most `count` evenly spaced values from `values`, keeping the first and last (so a series stays compact + in a tool result without losing its shape).""" + if len(values) <= count: + return list(values) + step = (len(values) - 1) / (count - 1) + return [values[round(index * step)] for index in range(count)] + + class CanService: - """One CAN bus, opened once and kept running; the tools query the trace and statistics it accumulates.""" + """A CAN bus for an agent to inspect and (when allowed) drive. The bus can be given up front or opened later + with connect(), so the agent can list the interfaces and choose one itself. The database, protocol and + permissions are service-level and persist across connections; each connection starts a fresh trace.""" def __init__( self, - source: MessageSource, + source: Optional[MessageSource] = None, *, database: Optional[DbcDecoder] = None, protocol: str = 'Raw', allow_send: bool = False, + allow_diagnostics: bool = False, + allow_control: bool = False, buffer_size: int = 200_000, ): if protocol not in PROTOCOL_DECODERS: raise ValueError(f'Unknown protocol {protocol!r}; use one of {", ".join(PROTOCOL_DECODERS)}') - decoder = FrameDecoder(create_protocol_decoder(protocol), database) - self.session = BusSession(source, decoder, buffer_size=buffer_size) self.protocol = protocol + self._buffer_size = buffer_size + self._decoder = FrameDecoder(create_protocol_decoder(protocol), database) + self.session: Optional[BusSession] = ( + BusSession(source, self._decoder, buffer_size=buffer_size) if source is not None else None + ) + # Permission tiers, each granting the one below: send (raw transmit) > control (active diagnostics) > + # diagnostics (safe diagnostic reads) > read-only self.allow_send = allow_send + self.allow_diagnostics = allow_diagnostics + self.allow_control = allow_control + self._manage = True # False when attached to a session owned by someone else (the GUI) + self._markers: List[Marker] = [] # Moments the agent marked, for reaction_at_markers + + @classmethod + def attach( + cls, + session: BusSession, + *, + allow_send: bool = False, + allow_diagnostics: bool = False, + allow_control: bool = False, + ) -> 'CanService': + """A service exposing an existing, externally-managed bus session (e.g. a GUI connection). The connect and + disconnect tools are not offered, and stopping the service does not close the session — its owner does.""" + service = cls(allow_send=allow_send, allow_diagnostics=allow_diagnostics, allow_control=allow_control) + service.session = session + service._decoder = session.decoder + service.protocol = getattr(session.decoder.protocol, 'name', None) or 'Raw' + service._manage = False + return service @classmethod def from_config( cls, - interface: str, - channel: str, + interface: Optional[str] = None, + channel: Optional[str] = None, *, bitrate: int = 500_000, fd: bool = False, @@ -96,37 +174,128 @@ def from_config( database: Optional[str | Path] = None, protocol: str = 'Raw', allow_send: bool = False, + allow_diagnostics: bool = False, + allow_control: bool = False, ) -> 'CanService': + """Build a service. With `interface` and `channel`, it opens that bus on start(); without them, it starts + unconnected and the agent opens a bus with connect().""" decoder = open_signal_database(database) if database else None - config = BusConfig(interface, channel, bitrate=bitrate, fd=fd, data_bitrate=data_bitrate) - return cls(LiveSource(config), database=decoder, protocol=protocol, allow_send=allow_send) + source = None + if interface and channel: + source = LiveSource(BusConfig(interface, channel, bitrate=bitrate, fd=fd, data_bitrate=data_bitrate)) + return cls( + source, + database=decoder, + protocol=protocol, + allow_send=allow_send, + allow_diagnostics=allow_diagnostics, + allow_control=allow_control, + ) + + # Permission tiers (a higher tier grants the ones below it) + + @property + def can_control(self) -> bool: + """Active diagnostics: clear DTCs, ECU reset, routine control, write DID, raw UDS.""" + return self.allow_control or self.allow_send + + @property + def can_diagnose(self) -> bool: + """Safe diagnostic reads: OBD values/DTCs/VIN and UDS reads (these still put a request on the bus).""" + return self.allow_diagnostics or self.can_control + + def _require_send(self) -> None: + if not self.allow_send: + raise SendNotAllowed('Sending is disabled; start the server with --allow-send to enable it') + + def _require_diagnostics(self) -> None: + if not self.can_diagnose: + raise SendNotAllowed('Diagnostics are disabled; start with --allow-diagnostics (or --allow-send)') + + def _require_control(self) -> None: + if not self.can_control: + raise SendNotAllowed('Active control is disabled; start with --allow-control (or --allow-send)') # Lifecycle + @property + def connected(self) -> bool: + return self.session is not None + + @property + def manages_connection(self) -> bool: + """True for a standalone service that owns its bus (connect/disconnect allowed); False when attached to a + session owned elsewhere.""" + return self._manage + def start(self) -> None: - """Open the bus and start receiving. Must be called with a running event loop.""" - self.session.start() + """Open the bus (if one was configured and owned) and start receiving. Needs a running event loop.""" + if self._manage and self.session is not None: + self.session.start() def stop(self) -> None: - self.session.stop() + if self._manage: + self.disconnect() + + def connect( + self, + interface: str, + channel: str, + *, + bitrate: int = 500_000, + fd: bool = False, + data_bitrate: Optional[int] = None, + ) -> Dict[str, Any]: + """Open a bus (closing any current one) and start receiving. The database and protocol are kept; the + trace starts fresh. Returns the new status, or an error.""" + self.disconnect() + config = BusConfig(interface, channel, bitrate=bitrate, fd=fd, data_bitrate=data_bitrate) + session = BusSession(LiveSource(config), self._decoder, buffer_size=self._buffer_size) + try: + session.start() + except Exception as e: # Could not open the bus (bad channel, interface not available, …) + return {'connected': False, 'error': str(e)} + self.session = session + return self.status() + + def disconnect(self) -> Dict[str, Any]: + """Close the current bus, if any.""" + if self.session is not None: + self.session.stop() + self.session = None + return {'connected': False} @property def _database(self) -> Optional[DbcDecoder]: - return self.session.decoder.database + return self._decoder.database # Status def status(self) -> Dict[str, Any]: - """The bus state, frame counts and rates.""" - snapshot = self.session.sample_bus_statistics() + """The bus state, frame counts and rates (or that no bus is connected).""" database = self._database + common = { + 'connected': self.connected, + 'protocol': self.protocol, + 'database': str(database.path) if database is not None else None, + 'sending_allowed': self.allow_send, + 'capabilities': {'diagnostics': self.can_diagnose, 'control': self.can_control, 'send': self.allow_send}, + } + if self.session is None: + return { + **common, + 'state': 'disconnected', + 'open': False, + 'frames': 0, + 'identifiers': 0, + 'hint': 'Not connected. Use list_interfaces to see the interfaces, then connect(interface, channel).', + } + snapshot = self.session.sample_bus_statistics() return { + **common, 'state': self.session.state.value, 'open': self.session.is_open, 'description': self.session.source.description, - 'protocol': self.protocol, - 'database': str(database.path) if database is not None else None, - 'sending_allowed': self.allow_send, 'frames': self.session.trace.total, 'identifiers': len(self.session.statistics.entries), 'rx_per_s': round(snapshot.rx_rate, 1), @@ -139,6 +308,8 @@ def status(self) -> Dict[str, Any]: def identifiers(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: """Every identifier seen, with its count, cycle time, last data and database name.""" + if self.session is None: + return [] database = self._database rows = [] for (arbitration_id, extended, rx), entry in self.session.statistics.entries.items(): @@ -163,6 +334,8 @@ def identifiers(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: # Frames def _recent(self, identifier: Optional[int], extended: Optional[bool], limit: int) -> List[can.Message]: + if self.session is None: + return [] trace = self.session.trace start = max(trace.first, trace.total - SCAN_FRAMES) kept: deque = deque(maxlen=limit) @@ -230,13 +403,19 @@ def decode_frame(self, identifier: Any, data: str, extended: bool = False) -> Di def load_database(self, path: str) -> Dict[str, Any]: """Load a signal database (DBC, ARXML, KCD, SYM, DBF) for decoding.""" database = open_signal_database(path) - self.session.decoder.database = database + self._decoder.database = database return { 'database': str(database.path), 'messages': len(database.messages()), 'signals': len(database.signals()), } + def unload_database(self) -> Dict[str, Any]: + """Forget the loaded database, so frames are shown as raw bytes again.""" + had = self._database is not None + self._decoder.database = None + return {'database': None, 'unloaded': had} + # Reverse engineering @staticmethod @@ -260,6 +439,8 @@ def _field(found) -> Dict[str, Any]: def analyze(self, identifier: Any, extended: bool = False, include_constant: bool = False) -> Dict[str, Any]: """Run the automatic field analysis on the recent frames of one identifier (checksums, counters, multiplexers, physical values, states and flags).""" + if self.session is None: + return {'error': NOT_CONNECTED} key: Tuple[int, bool] = (_parse_id(identifier), bool(extended)) messages = self._recent(key[0], key[1], ANALYSIS_FRAMES) frames = collect(frame.message for frame in messages) @@ -300,6 +481,8 @@ def correlate( ) -> Dict[str, Any]: """Rank the fields of the bus by how well they follow a reference series — a database signal ("Message.Signal"), a CSV column, or an inline times/values pair — with the fitted factor, offset and lag.""" + if self.session is None: + return {'error': NOT_CONNECTED} messages = [frame.message for frame in self._recent(None, None, ANALYSIS_FRAMES)] if signal: database = self._database @@ -363,15 +546,26 @@ def promote_to_dbc( extended: bool = False, include_constant: bool = False, min_confidence: float = 0.0, + fields: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: - """Write the fields found by the analysis (of one identifier, or all) as signals to a DBC, creating their - messages. An existing database at `path` is extended; multiplexing is recorded in the signal comment.""" + """Write signals to a DBC, creating their messages (an existing database at `path` is extended). Without + `fields`, the fields the analysis found (of one identifier, or all) are written. With `fields`, exactly the + given field specs are written — a hypothesis you formed yourself: each is {identifier, start, length, + byte_order?, signed?, scale?, offset?, name?, unit?, comment?, extended?}, matching extract_field.""" + if self.session is None: + return {'error': NOT_CONNECTED} target = Path(path) if target.suffix.lower() not in WRITE_SUFFIXES: return { 'error': f'Cannot write {target.suffix or "a file without extension"}; use {", ".join(WRITE_SUFFIXES)}' } - definitions = signal_definitions(self._analyses(identifier, extended), include_constant, min_confidence) + if fields: + try: + definitions = [_field_definition(spec) for spec in fields] + except (KeyError, ValueError, TypeError) as e: + return {'error': f'Invalid field spec: {e}'} + else: + definitions = signal_definitions(self._analyses(identifier, extended), include_constant, min_confidence) if not definitions: return {'error': 'No signals found to promote'} try: @@ -387,13 +581,11 @@ def promote_to_dbc( # Transmitting (only when allowed) - def _require_send(self) -> None: - if not self.allow_send: - raise SendNotAllowed('Sending is disabled; start the server with --allow-send to enable it') - def send_frame(self, identifier: Any, data: str, extended: bool = False, fd: bool = False) -> Dict[str, Any]: """Send a single CAN frame (requires --allow-send).""" self._require_send() + if self.session is None: + return {'sent': False, 'error': NOT_CONNECTED} payload = parse_hex_bytes(data) message = can.Message( arbitration_id=_parse_id(identifier), @@ -410,8 +602,10 @@ def send_frame(self, identifier: Any, data: str, extended: bool = False, fd: boo return {'sent': True, 'id': _id_text(message.arbitration_id, extended), 'data': payload.hex(' ').upper()} async def query_obd(self, pid: int, timeout: float = 1.0) -> Dict[str, Any]: - """Request one OBD-II value (service 01) and return the ECU's answer (requires --allow-send).""" - self._require_send() + """Request one OBD-II value (service 01) and return the ECU's answer (a single-frame request, no ISO-TP).""" + self._require_diagnostics() + if self.session is None: + return {'error': NOT_CONNECTED} pid_number = int(pid) definition = PIDS_BY_NUMBER.get(pid_number) if definition is None: @@ -444,3 +638,481 @@ def listener(message: can.Message) -> None: 'unit': definition.unit, 'answered': True, } + + def send_message(self, name: str, signals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Encode a database message by name from its signal values and send it (requires --allow-send).""" + self._require_send() + if self.session is None: + return {'sent': False, 'error': NOT_CONNECTED} + database = self._database + if database is None: + return {'error': 'Load a database first to send a message by name'} + definition = next((message for message in database.messages() if message.name == name), None) + if definition is None: + return {'error': f'No message named {name!r} in the database'} + try: + data = definition.encode(signals or {}, strict=False, padding=True) + except Exception as e: + return {'error': f'Could not encode {name}: {e}'} + message = can.Message( + arbitration_id=definition.frame_id, + is_extended_id=definition.is_extended_frame, + is_fd=definition.length > 8, + data=data, + check=False, + ) + try: + self.session.send(message) + except SessionError as e: + return {'sent': False, 'error': str(e)} + return { + 'sent': True, + 'name': name, + 'id': _id_text(definition.frame_id, definition.is_extended_frame), + 'data': bytes(data).hex(' ').upper(), + } + + # Protocol decoding and more reverse engineering + + def decode_protocol(self, protocol: str, identifier: Any, data: str, extended: bool = False) -> Dict[str, Any]: + """Interpret a frame with a protocol decoder (J1939, NMEA 2000, CANopen, ISO-TP, UDS, LIN, J1587), without + a database. Sends nothing.""" + if protocol not in PROTOCOL_DECODERS: + return {'error': f'Unknown protocol {protocol!r}; use one of {", ".join(PROTOCOL_DECODERS)}'} + decoder = create_protocol_decoder(protocol) + if decoder is None: + return {'error': "The 'Raw' protocol has no description; choose another"} + message = can.Message( + arbitration_id=_parse_id(identifier), is_extended_id=extended, data=parse_hex_bytes(data), check=False + ) + return { + 'id': _id_text(message.arbitration_id, extended), + 'protocol': protocol, + 'description': decoder.describe(message), + } + + def timing( + self, identifier: Optional[Any] = None, extended: bool = False, series: bool = False + ) -> List[Dict[str, Any]]: + """The inter-frame timing of each identifier (or one): mean cycle time, jitter, and whether it is periodic + or event-triggered, from the recent frames. With `series`, each row also carries a downsampled + `intervals_ms` list of the inter-frame gaps over time, to spot bursts, gateways and event triggers.""" + wanted = None if identifier is None else (_parse_id(identifier), bool(extended)) + times: Dict[Tuple[int, bool], List[float]] = {} + for frame in self._recent(*(wanted or (None, None)), SCAN_FRAMES): + message = frame.message + if message.is_error_frame or message.is_remote_frame: + continue + times.setdefault((message.arbitration_id, message.is_extended_id), []).append(message.timestamp) + rows = [] + for key, stamps in times.items(): + if len(stamps) < 2: + rows.append({'id': _id_text(*key), 'count': len(stamps), 'cycle_ms': None, 'pattern': 'single'}) + continue + deltas = [(stamps[i] - stamps[i - 1]) * 1000 for i in range(1, len(stamps))] + mean = sum(deltas) / len(deltas) + low, high = min(deltas), max(deltas) + periodic = mean > 0 and (high - low) < 0.25 * mean + row = { + 'id': _id_text(*key), + 'count': len(stamps), + 'cycle_ms': round(mean, 2), + 'min_ms': round(low, 2), + 'max_ms': round(high, 2), + 'jitter_ms': round(high - low, 2), + 'pattern': 'periodic' if periodic else 'event', + } + if series: + row['intervals_ms'] = [round(delta, 2) for delta in _downsample(deltas)] + rows.append(row) + rows.sort(key=lambda row: (row['cycle_ms'] is None, row['cycle_ms'] or 0.0)) + return rows + + def related_signals(self, top: int = 20, min_correlation: float = 0.99) -> Dict[str, Any]: + """Groups of fields that carry the same signal across the bus (the same quantity rebroadcast, or a scaled + or lagged copy), found without a database.""" + if self.session is None: + return {'error': NOT_CONNECTED} + messages = [frame.message for frame in self._recent(None, None, ANALYSIS_FRAMES)] + frames = collect(m for m in messages if not is_obd_identifier((m.arbitration_id, m.is_extended_id))) + analyses = [analyse(idframes) for idframes in frames.values()] + groups = signal_groups(frames, analyses, min_correlation=min_correlation, top=max(1, top)) + return { + 'groups': [ + { + 'correlation': round(group.correlation, 4), + 'members': [ + { + 'field': member.field.key, + 'representative': member.representative, + 'scale': member.field.scale, + 'offset': member.field.offset, + 'lag_ms': round(member.lag * 1000, 1), + 'condition': ( + f'{member.multiplexer.key}={member.mux_value}' if member.multiplexer else None + ), + } + for member in group.members + ], + } + for group in groups + ] + } + + def capture_reaction(self, baseline_seconds: float = 5.0, action_seconds: float = 5.0) -> Dict[str, Any]: + """Diff the most recent `action_seconds` of traffic against the `baseline_seconds` before it, to find what + changed on the bus (identifiers that appeared, and bits that were stable then changed). Passive: reads only. + Record a steady baseline, perform the action, wait `action_seconds`, then call this.""" + if self.session is None: + return {'error': NOT_CONNECTED} + messages = [frame.message for frame in self._recent(None, None, SCAN_FRAMES)] + if not messages: + return {'error': 'No frames captured yet'} + end = messages[-1].timestamp + action_start = end - max(0.1, action_seconds) + baseline_start = action_start - max(0.1, baseline_seconds) + baseline = [m for m in messages if baseline_start <= m.timestamp < action_start] + action = [m for m in messages if action_start <= m.timestamp <= end] + report = capture_reaction(baseline, action) + return { + 'baseline_frames': report.baseline_frames, + 'action_frames': report.action_frames, + 'reactions': [ + { + 'id': reaction.id_text, + 'appeared': reaction.appeared, + 'fields': [ + { + 'field': item.field.key, + 'before': item.before, + 'after': item.after, + 'flips': item.action_flips, + } + for item in reaction.fields + ], + } + for reaction in report.reactions + ], + } + + def bit_activity(self, identifier: Any, extended: bool = False) -> Dict[str, Any]: + """Per-bit behaviour of one identifier over the recent frames: for each bit, how often it is set and how + often it flips (so constant, toggling and counter-like bits stand out before fields are grouped), and a + per-byte summary (distinct values, range, entropy). The raw view behind analyze_identifier.""" + if self.session is None: + return {'error': NOT_CONNECTED} + key = (_parse_id(identifier), bool(extended)) + messages = self._recent(key[0], key[1], ANALYSIS_FRAMES) + statistics = BitStatistics() + statistics.update_all(message.message for message in messages) + identifier_stats = statistics.get(key) + if identifier_stats is None or not messages: + return {'id': _id_text(*key), 'frames': 0, 'bits': [], 'bytes': [], 'note': 'No frames for this identifier'} + bits = [ + { + 'bit': bit.bit, + 'value': bit.value, + 'ones': round(bit.ones, 3), + 'flips': round(bit.flips, 4), + 'constant': bit.constant, + } + for bit in identifier_stats.bits() + ] + bytes_summary = [ + { + 'byte': info.index, + 'value': info.value, + 'distinct': info.distinct, + 'min': info.minimum, + 'max': info.maximum, + 'entropy_bits': round(info.entropy, 2), + } + for info in identifier_stats.bytes() + ] + return { + 'id': _id_text(*key), + 'frames': len(messages), + 'length': identifier_stats.length, + 'bits': bits, + 'bytes': bytes_summary, + } + + def extract_field( + self, + identifier: Any, + start: int, + length: int, + byte_order: str = 'little_endian', + signed: bool = False, + scale: float = 1.0, + offset: float = 0.0, + extended: bool = False, + max_samples: int = 200, + ) -> Dict[str, Any]: + """Decode a candidate field (a bit range with a byte order, sign and scaling) over the recent frames, + without a database, to test a hypothesis. Returns the value range, how often it changes, and a downsampled + value series over time. `start` is the least significant bit (little endian) or most significant bit (big + endian); `byte_order` is 'little_endian' (Intel) or 'big_endian' (Motorola).""" + if self.session is None: + return {'error': NOT_CONNECTED} + key = (_parse_id(identifier), bool(extended)) + try: + field = BitField( + key[0], key[1], int(start), int(length), byte_order, bool(signed), float(scale), float(offset) + ) + except ValueError as e: + return {'error': str(e)} + messages = self._recent(key[0], key[1], ANALYSIS_FRAMES) + start_time = self.session.start_time or 0.0 + samples = [] + for frame in messages: + value = field.value(bytes(frame.message.data)) + if value is not None: + samples.append((round(frame.message.timestamp - start_time, 4), value)) + if not samples: + return {'id': _id_text(*key), 'field': field.key, 'frames': 0, 'note': 'No frames decode this field'} + values = [value for _time, value in samples] + changes = sum(1 for index in range(1, len(values)) if values[index] != values[index - 1]) + indices = ( + [ + round(index * (len(samples) - 1) / (min(max_samples, len(samples)) - 1)) + for index in range(min(max_samples, len(samples))) + ] + if len(samples) > 1 + else [0] + ) + return { + 'id': _id_text(*key), + 'field': field.key, + 'frames': len(samples), + 'min': _jsonable(min(values)), + 'max': _jsonable(max(values)), + 'distinct': len(set(values)), + 'changes': changes, + 'series': [{'t': samples[i][0], 'value': _jsonable(samples[i][1])} for i in indices], + } + + def mark(self, label: str = '') -> Dict[str, Any]: + """Mark the current moment on the bus (the time of the newest frame), to line up with an action you do — + press a button, open a door. Mark the same action a few times, then call reaction_at_markers to find the + identifiers and bits that react at those moments.""" + if self.session is None: + return {'error': NOT_CONNECTED} + recent = self._recent(None, None, 1) + time = recent[-1].message.timestamp if recent else 0.0 + self._markers.append(Marker(time, label)) + start_time = self.session.start_time or 0.0 + return { + 'marked': True, + 'count': len(self._markers), + 'markers': [{'t': round(m.time - start_time, 4), 'label': m.label} for m in self._markers], + } + + def clear_markers(self) -> Dict[str, Any]: + """Forget all the markers set with mark().""" + self._markers = [] + return {'cleared': True, 'count': 0} + + def reaction_at_markers(self, before: float = 0.5, after: float = 1.5, top: int = 30) -> Dict[str, Any]: + """The fields that change around the moments marked with mark() — most specific first — with the share of + markers they hit and their background change rate, to tell a real reaction from coincidence. Passive: reads + only. Mark one action several times for a confident result.""" + if self.session is None: + return {'error': NOT_CONNECTED} + if not self._markers: + return {'error': 'No markers set; call mark() at the moments of an action first'} + frames = collect(frame.message for frame in self._recent(None, None, SCAN_FRAMES)) + matches = find_marker_changes(frames, self._markers, before=before, after=after, top=max(1, top)) + return { + 'markers': len(self._markers), + 'reactions': [ + { + 'field': match.field.key, + 'hits': match.hits, + 'of_markers': match.markers, + 'background_per_s': round(match.background, 3), + 'score': round(match.score, 3), + 'transitions': list(match.transitions), + } + for match in matches + ], + } + + @staticmethod + def interfaces() -> List[Dict[str, Any]]: + """The CAN interfaces python-can can detect on this machine (for choosing a channel to connect to).""" + try: + configs = can.detect_available_configs() + except Exception: + return [] + return [{'interface': config.get('interface'), 'channel': str(config.get('channel'))} for config in configs] + + # Diagnostics (OBD-II and UDS, over ISO-TP) + + def _isotp(self, request_id: Any, response_id: Any) -> IsoTpClient: + config = getattr(self.session.source, 'config', None) + return IsoTpClient( + self.session, _parse_id(request_id), _parse_id(response_id), fd=bool(getattr(config, 'fd', False)) + ) + + async def _with_uds(self, request_id, response_id, operation, control: bool = False) -> Dict[str, Any]: + (self._require_control if control else self._require_diagnostics)() + if self.session is None: + return {'error': NOT_CONNECTED} + transport = self._isotp(request_id, response_id) + try: + return await operation(UdsClient(transport)) + except UdsError as e: + return {'error': str(e)} + except (OSError, ValueError) as e: + return {'error': str(e)} + finally: + transport.close() + + async def obd_status(self, request: Any = FUNCTIONAL_REQUEST, response: Any = ECU_RESPONSE) -> Dict[str, Any]: + """The MIL ("check engine") state and the number of stored trouble codes (OBD service 01 PID 01).""" + + async def operation(uds): + status = await ObdClient(uds).status() + return {'mil_on': status.mil_on, 'dtc_count': status.dtc_count} + + return await self._with_uds(request, response, operation) + + async def obd_dtcs( + self, pending: bool = False, request: Any = FUNCTIONAL_REQUEST, response: Any = ECU_RESPONSE + ) -> Dict[str, Any]: + """The stored (service 03) or pending (service 07) OBD-II trouble codes, decoded to P/C/B/U codes.""" + + async def operation(uds): + dtcs = await ObdClient(uds).read_dtcs(pending=pending) + return {'pending': pending, 'dtcs': [dtc.code for dtc in dtcs]} + + return await self._with_uds(request, response, operation) + + async def obd_vin(self, request: Any = FUNCTIONAL_REQUEST, response: Any = ECU_RESPONSE) -> Dict[str, Any]: + """The vehicle identification number (OBD service 09 PID 02).""" + + async def operation(uds): + return {'vin': await ObdClient(uds).read_vin()} + + return await self._with_uds(request, response, operation) + + async def obd_supported(self, request: Any = FUNCTIONAL_REQUEST, response: Any = ECU_RESPONSE) -> Dict[str, Any]: + """The OBD-II service-01 PIDs the ECU supports, of those this tool can decode.""" + + async def operation(uds): + pids = await ObdClient(uds).supported_pids() + known = [ + {'pid': pid, 'name': PIDS_BY_NUMBER[pid].name, 'unit': PIDS_BY_NUMBER[pid].unit} + for pid in pids + if pid in PIDS_BY_NUMBER + ] + return {'supported_pids': [f'{pid:02X}' for pid in pids], 'known': known} + + return await self._with_uds(request, response, operation) + + async def obd_read( + self, pids: List[int], request: Any = FUNCTIONAL_REQUEST, response: Any = ECU_RESPONSE + ) -> Dict[str, Any]: + """Read and decode OBD-II live values (service 01), e.g. pids [12, 13] for engine and vehicle speed.""" + + async def operation(uds): + client = ObdClient(uds) + values = [] + for pid in pids: + try: + value = await client.read_value(int(pid)) + except UdsError as e: + values.append({'pid': int(pid), 'error': str(e)}) + continue + if value is None: + values.append({'pid': int(pid), 'error': 'unknown PID or no answer'}) + else: + values.append( + {'pid': value.pid, 'name': value.name, 'value': _jsonable(value.value), 'unit': value.unit} + ) + return {'values': values} + + return await self._with_uds(request, response, operation) + + async def obd_clear(self, request: Any = FUNCTIONAL_REQUEST, response: Any = ECU_RESPONSE) -> Dict[str, Any]: + """Clear the OBD-II trouble codes and turn the MIL off (service 04). Active: requires --allow-control.""" + + async def operation(uds): + await ObdClient(uds).clear_dtcs() + return {'cleared': True} + + return await self._with_uds(request, response, operation, control=True) + + async def uds_read_did( + self, did: Any, request: Any = DEFAULT_UDS_REQUEST, response: Any = ECU_RESPONSE + ) -> Dict[str, Any]: + """Read a UDS data identifier (service 0x22), e.g. did "F190" for the VIN. Returns the raw bytes and text.""" + did_value = _parse_id(did) + + async def operation(uds): + data = await uds.read_data_by_identifier(did_value) + text = bytes(byte for byte in data if 0x20 <= byte < 0x7F).decode('ascii', 'replace') + return {'did': f'{did_value:04X}', 'data': bytes(data).hex(' ').upper(), 'text': text} + + return await self._with_uds(request, response, operation) + + async def uds_read_dtcs( + self, status_mask: int = 0xFF, request: Any = DEFAULT_UDS_REQUEST, response: Any = ECU_RESPONSE + ) -> Dict[str, Any]: + """Read UDS trouble codes (service 0x19 reportDTCByStatusMask).""" + + async def operation(uds): + _, dtcs = await uds.read_dtcs(int(status_mask)) + return { + 'dtcs': [{'code': dtc.text, 'status': f'0x{dtc.status:02X}', 'flags': dtc.status_flags} for dtc in dtcs] + } + + return await self._with_uds(request, response, operation) + + async def uds_reset( + self, reset_type: int = 1, request: Any = DEFAULT_UDS_REQUEST, response: Any = ECU_RESPONSE + ) -> Dict[str, Any]: + """Reset an ECU (service 0x11; 1 = hard reset). Active: requires --allow-control.""" + + async def operation(uds): + await uds.ecu_reset(int(reset_type)) + return {'reset': True, 'type': int(reset_type)} + + return await self._with_uds(request, response, operation, control=True) + + async def uds_routine( + self, + control_type: int, + routine: Any, + data: str = '', + request: Any = DEFAULT_UDS_REQUEST, + response: Any = ECU_RESPONSE, + ) -> Dict[str, Any]: + """Routine control (service 0x31; control_type 1 = start, 2 = stop, 3 = request results). Active: + requires --allow-control.""" + routine_value = _parse_id(routine) + payload = parse_hex_bytes(data) if data else b'' + + async def operation(uds): + result = await uds.routine_control(int(control_type), routine_value, payload) + return {'routine': f'{routine_value:04X}', 'response': bytes(result).hex(' ').upper()} + + return await self._with_uds(request, response, operation, control=True) + + async def uds_request( + self, payload: str, request: Any = DEFAULT_UDS_REQUEST, response: Any = ECU_RESPONSE + ) -> Dict[str, Any]: + """Send a raw UDS request (hex, e.g. "22 F1 90") and return the response. Active: requires --allow-control + because a raw request can do anything (reset, routines, writes).""" + bytes_payload = parse_hex_bytes(payload) + + async def operation(uds): + result = await uds.request(bytes_payload) + return { + 'request': bytes_payload.hex(' ').upper(), + 'response': bytes(result.response).hex(' ').upper() if result.response else None, + 'error': result.error or None, + } + + return await self._with_uds(request, response, operation, control=True) diff --git a/can_explorer/resources/translations/canexplorer_de.ts b/can_explorer/resources/translations/canexplorer_de.ts index bf73322..1140356 100644 --- a/can_explorer/resources/translations/canexplorer_de.ts +++ b/can_explorer/resources/translations/canexplorer_de.ts @@ -4,17 +4,22 @@ AboutDialog - + About %1 Über %1 - + %1 (v%2) %1 (v%2) - + + The red diamond spirit awakens. + Der Geist des roten Diamanten erwacht. + + + Copy to Clipboard In die Zwischenablage kopieren @@ -801,186 +806,211 @@ B: %3 + OBD-II + OBD-II + + + Flashing Flashen - + UDS discovery UDS-Erkundung - + XCP/CCP XCP/CCP - + GMLAN GMLAN - + + J1850 / Class 2 + J1850 / Class 2 + + + J1939 J1939 - + CANopen CANopen - + LIN Schedule LIN-Schedule - + Simulation Simulation - + Tests Tests - + Replaying %1 on the bus %1 wird auf dem Bus abgespielt - + (pass %1) (Durchlauf %1) - + Pause view Ansicht anhalten - + Clear Leeren - + ● Stop recording ● Aufzeichnung beenden - + Record… Aufzeichnen… - + Replay to bus… Auf Bus abspielen… - + Transmit the frames of a log file on this bus with their recorded timing Die Frames einer Log-Datei mit ihrem aufgezeichneten Timing auf diesem Bus senden - + ● MQTT ● MQTT - + MQTT… MQTT… - + Publish the signals and frames to an MQTT broker Signale und Frames an einen MQTT-Broker senden - + + ● Agent + ● Agent + + + + Agent… + Agent… + + + + Expose this connection to an AI agent over a local MCP server + Diese Verbindung über einen lokalen MCP-Server einem KI-Agenten zugänglich machen + + + Export… Exportieren… - + Save the selected trace rows, or all displayed rows, to a log file Die ausgewählten oder alle angezeigten Trace-Zeilen in einer Log-Datei speichern - + Unload database Datenbank entladen - + Load database… Datenbank laden… - + Edit database Datenbank bearbeiten - + Open the database in the editor; saving it updates the decoding Die Datenbank im Editor öffnen; Speichern aktualisiert die Dekodierung - + All buses Alle Busse - + The database describes several buses; decode with the messages of this one Die Datenbank beschreibt mehrere Busse; mit den Botschaften dieses Busses dekodieren - + Auto-scroll Automatisch scrollen - + ● REC ● REC - + %1 frames %1 Frames - + Bookmark / remove bookmark Ctrl+B Lesezeichen setzen / entfernen Ctrl+B - + Copy selected rows Ctrl+C Ausgewählte Zeilen kopieren Ctrl+C - + Replay from here Ab hier abspielen - + Find… Ctrl+F Suchen… Ctrl+F - + Export trace Trace exportieren - + Load signal database Signaldatenbank laden @@ -2196,6 +2226,219 @@ B: %3 Alle Dateien (*) + + EthernetView + + + capturing + erfasst + + + + no capture + keine Erfassung + + + + Open pcap… + pcap öffnen… + + + + Interface to capture live from (needs Linux and root/CAP_NET_RAW) + Schnittstelle für die Live-Erfassung (benötigt Linux und root/CAP_NET_RAW) + + + + Stop + Stopp + + + + Capture + Erfassen + + + + Export… + Exportieren… + + + + Clear + Leeren + + + + filter: SOME/IP, 192.168.0.10, 30509… + Filter: SOME/IP, 192.168.0.10, 30509… + + + + %1 packets + %1 Pakete + + + + Open a pcap capture of automotive Ethernet traffic (classic pcap, Ethernet link type), or capture live on an interface. Frames are decoded down the stack and the automotive protocols — SOME/IP, DoIP, gPTP, AVTP — are recognised. A full capture needs a network tap or a mirror/SPAN port. + Öffnet eine pcap-Aufzeichnung von Automotive-Ethernet-Verkehr (klassisches pcap, Ethernet-Link-Typ) oder erfasst live an einer Schnittstelle. Frames werden über den gesamten Stack dekodiert und die Automotive-Protokolle – SOME/IP, DoIP, gPTP, AVTP – werden erkannt. Eine vollständige Erfassung benötigt einen Netzwerk-Tap oder einen Mirror-/SPAN-Port. + + + + Time sync (gPTP) + Zeitsynchronisation (gPTP) + + + + Grandmaster + Grandmaster + + + + (priority %1, %2) + (Priorität %1, %2) + + + + class %1 + Klasse %1 + + + + none announced + keine angekündigt + + + + Steps removed + Schritte zum Grandmaster + + + + Time source + Zeitquelle + + + + Sync interval + Sync-Intervall + + + + %1 ms + %1 ms + + + + (observed %1 Hz) + (beobachtet %1 Hz) + + + + Announce interval + Announce-Intervall + + + + Clocks / domains + Uhren / Domänen + + + + %1 clock(s) on domain(s) %2 + %1 Uhr(en) in Domäne(n) %2 + + + + SOME/IP services + SOME/IP-Dienste + + + + Service + Dienst + + + + Instance + Instanz + + + + Version + Version + + + + Endpoints + Endpunkte + + + + Offers + Angebote + + + + Packets + Pakete + + + + Time + Zeit + + + + Source + Quelle + + + + Destination + Ziel + + + + Protocol + Protokoll + + + + Len + Länge + + + + Info + Info + + + + Open a pcap capture + Eine pcap-Aufzeichnung öffnen + + + + pcap files (*.pcap *.cap) + pcap-Dateien (*.pcap *.cap) + + + + All files (*) + Alle Dateien (*) + + + + Save the packets + Die Pakete speichern + + + + pcap files (*.pcap) + pcap-Dateien (*.pcap) + + FlashView @@ -2655,6 +2898,89 @@ B: %3 Python-Dateien (*.py) + + J1850View + + + J1850 VPW / GM Class 2 + J1850 VPW / GM Class 2 + + + + SAE J1979 over J1850 VPW + SAE J1979 über J1850 VPW + + + + Connect first + Zuerst verbinden + + + + PID + PID + + + + Read + Lesen + + + + ShowCurrentData (mode 0x01) + ShowCurrentData (Modus 0x01) + + + + Read VIN + VIN lesen + + + + RequestVehicleInformation (mode 0x09) + RequestVehicleInformation (Modus 0x09) + + + + Read DTCs + DTCs lesen + + + + Clear DTCs + DTCs löschen + + + + Raw request + Rohanfrage + + + + J1979 request, hex, e.g. 01 0C + J1979-Anfrage, hex, z. B. 01 0C + + + + Send + Senden + + + + Diagnostic trouble codes (%1) + Diagnose-Fehlercodes (%1) + + + + History + Verlauf + + + + Clear + Leeren + + J1939View @@ -2896,6 +3222,74 @@ B: %3 Vom Master veröffentlichte Signale (Zahl, 0x-Hex oder logischer Wertname): + + McpServerDialog + + + AI agent (MCP) + KI-Agent (MCP) + + + + Let an AI agent inspect this connection — the bus you already have open — over a local MCP server. It listens on 127.0.0.1 only and requires the token below. + Ermöglicht einem KI-Agenten, diese Verbindung – den bereits geöffneten Bus – über einen lokalen MCP-Server zu untersuchen. Er lauscht nur auf 127.0.0.1 und erfordert das untenstehende Token. + + + + Access + Zugriff + + + + Stop + Stopp + + + + Start + Starten + + + + Read-only, plus OBD-II and UDS diagnostic reads (these send requests on the bus). + Nur-Lesen, plus OBD-II- und UDS-Diagnoselesezugriffe (diese senden Anfragen auf dem Bus). + + + + Plus active diagnostics: clear trouble codes, reset ECUs, run routines. Use with care. + Plus aktive Diagnose: Fehlercodes löschen, Steuergeräte zurücksetzen, Routinen ausführen. Mit Vorsicht verwenden. + + + + Plus transmitting arbitrary frames and messages. The agent fully controls the bus. + Plus Senden beliebiger Frames und Nachrichten. Der Agent kontrolliert den Bus vollständig. + + + + Read-only: inspect frames, decode and reverse-engineer. Sends nothing. + Nur-Lesen: Frames untersuchen, dekodieren und reverse-engineeren. Sendet nichts. + + + + URL + URL + + + + Copy + Kopieren + + + + Token + Token + + + + Point your MCP client at the URL using the streamable-HTTP transport, with the token as a Bearer header (Authorization: Bearer <token>) or appended as ?token=<token>. + Richten Sie Ihren MCP-Client über den Streamable-HTTP-Transport auf die URL, mit dem Token als Bearer-Header (Authorization: Bearer <token>) oder angehängt als ?token=<token>. + + MqttDialog @@ -3150,6 +3544,119 @@ B: %3 Eigenempfang + + ObdView + + + connected + verbunden + + + + not scanned + nicht gescannt + + + + Request + Anfrage + + + + Request identifier (hex): 7DF is the OBD-II functional broadcast; 7E0 addresses one ECU + Anfrage-Identifier (hex): 7DF ist der funktionale OBD-II-Broadcast; 7E0 adressiert ein Steuergerät + + + + Response + Antwort + + + + Response identifier (hex): 7E8 is the first ECU + Antwort-Identifier (hex): 7E8 ist das erste Steuergerät + + + + Scan + Scannen + + + + Stop + Stopp + + + + Live + Live + + + + Poll the supported values continuously + Die unterstützten Werte kontinuierlich abfragen + + + + MIL on · %1 DTC + MIL an · %1 DTC + + + + MIL off + MIL aus + + + + Connect to a vehicle's OBD-II port (a standard 500 kbit/s CAN bus), then press Scan to read the VIN, the supported live values and the trouble codes. If nothing answers, try the request/response identifiers 7E0 and 7E8 for a single ECU. + Verbinden Sie sich mit dem OBD-II-Anschluss eines Fahrzeugs (ein standardmäßiger 500-kbit/s-CAN-Bus) und drücken Sie dann Scannen, um die VIN, die unterstützten Live-Werte und die Fehlercodes zu lesen. Wenn nichts antwortet, versuchen Sie die Anfrage-/Antwort-Identifier 7E0 und 7E8 für ein einzelnes Steuergerät. + + + + Live data + Live-Daten + + + + Trouble codes + Fehlercodes + + + + Read codes + Codes lesen + + + + Clear codes + Codes löschen + + + + Clear the stored trouble codes and turn the MIL off (service 04) + Die gespeicherten Fehlercodes löschen und die MIL ausschalten (Service 04) + + + + Stored + Gespeichert + + + + No stored codes + Keine gespeicherten Codes + + + + Pending + Ausstehend + + + + No pending codes + Keine ausstehenden Codes + + PhysicalIoView @@ -3620,6 +4127,49 @@ B: %3 Log-Datei öffnen + + ReverseActivityPanel + + + Refresh + Aktualisieren + + + + Rebuild the activity map from the frames captured so far + Die Aktivitätskarte aus den bisher erfassten Frames neu aufbauen + + + + 0 + 0 + + + + 1 + 1 + + + + changed + geändert + + + + %1 over %2 + %1 von %2 + + + + Select an identifier to see its bits over the whole capture + Wählen Sie einen Identifier, um seine Bits über die gesamte Aufzeichnung zu sehen + + + + Each row is a bit (byte 0 at the top, most significant bit first), each column a frame over time. Solid blue is a steady 1, dark a steady 0; orange marks a bit that changed. Counters ramp as diagonal stripes, constants are flat, multiplexers switch. + Jede Zeile ist ein Bit (Byte 0 oben, höchstwertiges Bit zuerst), jede Spalte ein Frame über die Zeit. Durchgehendes Blau ist eine stabile 1, Dunkel eine stabile 0; Orange markiert ein Bit, das sich geändert hat. Zähler verlaufen als diagonale Streifen, Konstanten sind flach, Multiplexer schalten um. + + ReverseAnalysisPanel @@ -3693,6 +4243,34 @@ B: %3 Noch keine Ergebnisse + + ReverseConsolePanel + + + Run + Ausführen + + + + Run the snippet over the frames captured so far (Ctrl+Enter) + Das Snippet über die bisher erfassten Frames ausführen (Strg+Enter) + + + + Clear + Leeren + + + + Clear the transcript and forget all variables + Das Protokoll leeren und alle Variablen verwerfen + + + + Python here, Ctrl+Enter to run — e.g. [(f.field.key, f.kind) for f in analyse(frames_of('100')).fields] (type HELP for the available names) + Hier Python, Strg+Enter zum Ausführen – z. B. [(f.field.key, f.kind) for f in analyse(frames_of('100')).fields] (geben Sie HELP ein für die verfügbaren Namen) + + ReverseMarkersPanel @@ -3781,82 +4359,82 @@ B: %3 Start action - + Aktion starten Capture reaction - + Reaktion erfassen Record baseline - + Baseline aufnehmen 1. Record a baseline while the bus is steady 2. Start the action and do it 3. Capture the reaction - + 1. Nehmen Sie eine Baseline auf, während der Bus ruhig ist 2. Starten Sie die Aktion und führen Sie sie aus 3. Erfassen Sie die Reaktion Baseline - + Baseline Action - + Aktion Result - + Ergebnis Reset - + Zurücksetzen Find what a CAN message controls: capture a steady baseline, then do the action (press a button, open a door) and see which identifiers appeared and which bits changed — safely, by watching, not by sending. - + Finden Sie heraus, was eine CAN-Nachricht steuert: Erfassen Sie eine stabile Baseline, führen Sie dann die Aktion aus (einen Knopf drücken, eine Tür öffnen) und sehen Sie, welche Identifier auftauchten und welche Bits sich änderten – sicher, durch Beobachten, nicht durch Senden. ID - + ID Field - + Feld Kind - + Art Change - + Änderung Flips - + Wechsel Nothing changed during the action - + Während der Aktion hat sich nichts geändert The identifiers and bits that react to the action appear here, most reactive first - + Die Identifier und Bits, die auf die Aktion reagieren, erscheinen hier, die reaktivsten zuerst @@ -4088,57 +4666,57 @@ B: %3 ❚❚ - + ❚❚ ▶ - + ▶ Pause - + Pause Play - + Wiedergabe « - + « Previous frame - + Vorheriger Frame » - + » Next frame - + Nächster Frame frame %1 / %2 - + Frame %1 / %2 Speed - + Geschwindigkeit %1× - + %1× @@ -4195,251 +4773,281 @@ B: %3 + Activity + Aktivität + + + Automatic analysis Automatische Analyse - + Reference signals Referenzsignale - + Markers Marker - + Related signals Verwandte Signale - + Reaction - + Reaktion + + + + Console + Konsole - + Notch again Erneut notchen - + Notch Notch - + Hide every bit that changed so far; bits that change afterwards, e.g. when you press a button, stand out Jedes bisher geänderte Bit ausblenden; Bits, die sich danach ändern, z. B. wenn Sie eine Taste drücken, stechen hervor - + Clear notch Notch löschen - + Reset Zurücksetzen - + Forget the statistics and start counting again Die Statistik verwerfen und erneut zu zählen beginnen - + ● Replay - + ● Replay - + Replay - + Replay - + Play this identifier's captured frames back over time; scrub to any moment and watch the bits flow - + Die erfassten Frames dieses Identifiers über die Zeit abspielen; zu jedem Moment springen und den Bitfluss beobachten + + + + Plot Δt + Δt plotten + + + + Plot this identifier's inter-frame interval (Δt) over time on the Signals page, to spot gateways, bursts and event-triggered messages + Das Zwischenframe-Intervall (Δt) dieses Identifiers über die Zeit auf der Signale-Seite plotten, um Gateways, Bursts und ereignisgesteuerte Nachrichten zu erkennen + + + + Plot bytes + Bytes plotten + + + + Plot every byte of this identifier's payload as a value over time on the Signals page + Jedes Byte der Nutzlast dieses Identifiers als Wert über die Zeit auf der Signale-Seite plotten - + Select an identifier to see its bits Einen Identifier auswählen, um seine Bits zu sehen - + Bits of %1 (%2) Bits von %1 (%2) - + Bits of %1 Bits von %1 - + Byte %1 Byte %1 - + Bit %1: set in %2 of the frames, changed in %3 Bit %1: in %2 der Frames gesetzt, in %3 geändert - + , last %1 s ago , zuletzt vor %1 s - + , never changed , nie geändert - + (hidden by the notch) (durch den Notch ausgeblendet) - + Click a bit to start a field, Shift+click another bit to extend it, click a byte label to select the byte. Blue: how often a bit changes; orange: changed just now; outlined orange: changed since the notch. Auf ein Bit klicken, um ein Feld zu beginnen, mit Shift+Klick auf ein anderes Bit erweitern, auf eine Byte-Beschriftung klicken, um das Byte auszuwählen. Blau: wie oft sich ein Bit ändert; Orange: gerade geändert; orange umrandet: seit dem Notch geändert. - + Bit field %1 Bitfeld %1 - + Start bit Startbit - + Length Länge - + Byte order Bytereihenfolge - + Intel (little endian) Intel (Little Endian) - + Motorola (big endian) Motorola (Big Endian) - + Signed Vorzeichenbehaftet - + Factor Faktor - + Offset Offset - + Unit Einheit - + Name Name - + Signal_%1 Signal_%1 - + only if %1 nur wenn %1 - + The field is multiplexed: only frames with this multiplexer value carry it. Click to use all frames. Das Feld ist multiplext: nur Frames mit diesem Multiplexer-Wert tragen es. Zum Verwenden aller Frames klicken. - + Value: %1 Wert: %1 - + Plot Plotten - + Plot the field on the Signals page, from the first frame in the trace on Das Feld auf der Signale-Seite plotten, ab dem ersten Frame im Trace - + Add to database Zur Datenbank hinzufügen - + Add the field as a signal in the database editor (and its message if needed) Das Feld als Signal in den Datenbank-Editor hinzufügen (und seine Nachricht, falls nötig) - + Bytes Bytes - + Byte Byte - + Value Wert - + Distinct Verschiedene - + Min Min - + Max Max - + Entropy Entropie - + Values 00–FF Werte 00–FF @@ -4499,20 +5107,25 @@ B: %3 SOME/IP - + SOME/IP + Ethernet capture + Ethernet-Erfassung + + + Bus gateway Bus-Gateway - + Settings Einstellungen - + Version %1 Version %1 @@ -4763,152 +5376,152 @@ B: %3 listening - + lauscht not listening - + lauscht nicht UDP ports - + UDP-Ports UDP ports to listen on, comma separated (30490 is the usual Service Discovery port) - + UDP-Ports zum Lauschen, kommagetrennt (30490 ist der übliche Service-Discovery-Port) SD multicast - + SD-Multicast Service Discovery multicast group to join (leave empty to listen on unicast only) - + Service-Discovery-Multicast-Gruppe zum Beitreten (leer lassen, um nur auf Unicast zu lauschen) Stop - + Stopp Listen - + Lauschen Clear - + Leeren - Unload FIBEX - + Unload description + Beschreibung entladen - FIBEX… - + FIBEX / ARXML… + FIBEX / ARXML… - Load a FIBEX file to decode the message payloads into named signals - + Load a FIBEX or ARXML file to decode the message payloads into named signals + Eine FIBEX- oder ARXML-Datei laden, um die Nachrichten-Nutzlasten in benannte Signale zu dekodieren %1 messages - + %1 Nachrichten SOME/IP is the service-oriented middleware of automotive Ethernet. Listen on the Service Discovery port to see the services offered on the network and the messages exchanged. A plain UDP socket sees the multicast it joins and traffic addressed to this host; full capture needs a network tap or mirror port. - + SOME/IP ist die serviceorientierte Middleware von Automotive Ethernet. Lauschen Sie am Service-Discovery-Port, um die im Netzwerk angebotenen Dienste und die ausgetauschten Nachrichten zu sehen. Ein einfacher UDP-Socket sieht den beigetretenen Multicast und den an diesen Host adressierten Verkehr; eine vollständige Erfassung benötigt einen Netzwerk-Tap oder Mirror-Port. Services (%1) - + Dienste (%1) Service - + Dienst Instance - + Instanz Version - + Version Endpoints - + Endpunkte Offers - + Angebote Messages - + Nachrichten Time - + Zeit Source - + Quelle Service.Method - + Service.Method Type - + Typ Len - + Länge Description - + Beschreibung - Load SOME/IP FIBEX description - + Load SOME/IP description + SOME/IP-Beschreibung laden - FIBEX files (*.fibex *.xml) - + SOME/IP descriptions (*.fibex *.arxml *.xml) + SOME/IP-Beschreibungen (*.fibex *.arxml *.xml) All files (*) - + Alle Dateien (*) @@ -5858,182 +6471,187 @@ B: %3 main_window - + Command Befehl - + View Ansicht - + Connection Verbindung - + Disconnect %1 %1 trennen - + Connect %1 %1 verbinden - + Resume the trace Trace fortsetzen - + Pause the trace Trace anhalten - + Clear the trace and statistics Trace und Statistik leeren - + Go to Gehe zu - + Connection %1 Verbindung %1 - + Database editor Datenbank-Editor - + Multi-bus trace Multi-Bus-Trace - + Compare recordings Aufzeichnungen vergleichen - + EtherCAT (CoE) EtherCAT (CoE) - + Physical I/O Physische E/A - + DoIP DoIP - + SOME/IP - + SOME/IP - + + Ethernet capture + Ethernet-Erfassung + + + Bus gateway Bus-Gateway - + Tool Werkzeug - + Theme Farbschema - + Language Sprache - + Open workspace Arbeitsbereich öffnen - + Save workspace Arbeitsbereich speichern - + The open connections are closed and replaced by those of the workspace. Die offenen Verbindungen werden geschlossen und durch die des Arbeitsbereichs ersetzt. - + %1 frames %1 Frames - + Rx %1/s Tx %2/s Rx %1/s Tx %2/s - + load %1% Last %1% - + bus %1 Bus %1 - + %1 error frames %1 Error-Frames - + DB: %1 DB: %1 - + ● recording to %1 ● Aufzeichnung nach %1 - + Drag to resize, double-click to reset Ziehen zum Ändern der Breite, Doppelklick zum Zurücksetzen - + Unsaved database changes Ungespeicherte Datenbankänderungen - + Save the changes to %1 before quitting? Änderungen an %1 vor dem Beenden speichern? - + Use "Save as" in the database editor first Zuerst „Speichern unter“ im Datenbank-Editor verwenden - + CANExplorer %1 is available: ⋮ → Check for Updates… CANExplorer %1 ist verfügbar: ⋮ → Nach Updates suchen… diff --git a/can_explorer/resources/translations/canexplorer_fr.ts b/can_explorer/resources/translations/canexplorer_fr.ts index 3f76ef8..3415fed 100644 --- a/can_explorer/resources/translations/canexplorer_fr.ts +++ b/can_explorer/resources/translations/canexplorer_fr.ts @@ -4,17 +4,22 @@ AboutDialog - + About %1 À propos de %1 - + %1 (v%2) %1 (v%2) - + + The red diamond spirit awakens. + L'esprit du diamant rouge s'éveille. + + + Copy to Clipboard Copier dans le presse-papiers @@ -801,186 +806,211 @@ B : %3 + OBD-II + OBD-II + + + Flashing Flashage - + UDS discovery Découverte UDS - + XCP/CCP XCP/CCP - + GMLAN GMLAN - + + J1850 / Class 2 + J1850 / Class 2 + + + J1939 J1939 - + CANopen CANopen - + LIN Schedule Planification LIN - + Simulation Simulation - + Tests Tests - + Replaying %1 on the bus Lecture de %1 sur le bus - + (pass %1) (passe %1) - + Pause view Figer l'affichage - + Clear Effacer - + ● Stop recording ● Arrêter l'enregistrement - + Record… Enregistrer… - + Replay to bus… Rejouer vers le bus… - + Transmit the frames of a log file on this bus with their recorded timing Transmettre les trames d'un fichier journal sur ce bus avec leur timing enregistré - + ● MQTT ● MQTT - + MQTT… MQTT… - + Publish the signals and frames to an MQTT broker Publier les signaux et les trames vers un broker MQTT - + + ● Agent + ● Agent + + + + Agent… + Agent… + + + + Expose this connection to an AI agent over a local MCP server + Exposer cette connexion à un agent IA via un serveur MCP local + + + Export… Exporter… - + Save the selected trace rows, or all displayed rows, to a log file Enregistrer les lignes de trace sélectionnées, ou toutes les lignes affichées, dans un fichier journal - + Unload database Décharger la base de données - + Load database… Charger une base de données… - + Edit database Modifier la base de données - + Open the database in the editor; saving it updates the decoding Ouvrir la base de données dans l'éditeur ; l'enregistrer met à jour le décodage - + All buses Tous les bus - + The database describes several buses; decode with the messages of this one La base de données décrit plusieurs bus ; décoder avec les messages de celui-ci - + Auto-scroll Défilement auto - + ● REC ● REC - + %1 frames %1 trames - + Bookmark / remove bookmark Ctrl+B Ajouter / retirer un signet Ctrl+B - + Copy selected rows Ctrl+C Copier les lignes sélectionnées Ctrl+C - + Replay from here Rejouer à partir d'ici - + Find… Ctrl+F Rechercher… Ctrl+F - + Export trace Exporter la trace - + Load signal database Charger une base de signaux @@ -2196,6 +2226,219 @@ B : %3 Tous les fichiers (*) + + EthernetView + + + capturing + capture en cours + + + + no capture + aucune capture + + + + Open pcap… + Ouvrir un pcap… + + + + Interface to capture live from (needs Linux and root/CAP_NET_RAW) + Interface pour la capture en direct (nécessite Linux et root/CAP_NET_RAW) + + + + Stop + Arrêter + + + + Capture + Capturer + + + + Export… + Exporter… + + + + Clear + Effacer + + + + filter: SOME/IP, 192.168.0.10, 30509… + filtre : SOME/IP, 192.168.0.10, 30509… + + + + %1 packets + %1 paquets + + + + Open a pcap capture of automotive Ethernet traffic (classic pcap, Ethernet link type), or capture live on an interface. Frames are decoded down the stack and the automotive protocols — SOME/IP, DoIP, gPTP, AVTP — are recognised. A full capture needs a network tap or a mirror/SPAN port. + Ouvre une capture pcap de trafic Ethernet automobile (pcap classique, type de lien Ethernet) ou capture en direct sur une interface. Les trames sont décodées sur toute la pile et les protocoles automobiles — SOME/IP, DoIP, gPTP, AVTP — sont reconnus. Une capture complète nécessite un tap réseau ou un port miroir/SPAN. + + + + Time sync (gPTP) + Synchronisation temporelle (gPTP) + + + + Grandmaster + Grandmaster + + + + (priority %1, %2) + (priorité %1, %2) + + + + class %1 + classe %1 + + + + none announced + aucun annoncé + + + + Steps removed + Sauts jusqu’au grandmaster + + + + Time source + Source de temps + + + + Sync interval + Intervalle Sync + + + + %1 ms + %1 ms + + + + (observed %1 Hz) + (observé %1 Hz) + + + + Announce interval + Intervalle Announce + + + + Clocks / domains + Horloges / domaines + + + + %1 clock(s) on domain(s) %2 + %1 horloge(s) sur le(s) domaine(s) %2 + + + + SOME/IP services + Services SOME/IP + + + + Service + Service + + + + Instance + Instance + + + + Version + Version + + + + Endpoints + Points de terminaison + + + + Offers + Offres + + + + Packets + Paquets + + + + Time + Temps + + + + Source + Source + + + + Destination + Destination + + + + Protocol + Protocole + + + + Len + Long. + + + + Info + Info + + + + Open a pcap capture + Ouvrir une capture pcap + + + + pcap files (*.pcap *.cap) + Fichiers pcap (*.pcap *.cap) + + + + All files (*) + Tous les fichiers (*) + + + + Save the packets + Enregistrer les paquets + + + + pcap files (*.pcap) + Fichiers pcap (*.pcap) + + FlashView @@ -2655,6 +2898,89 @@ B : %3 Fichiers Python (*.py) + + J1850View + + + J1850 VPW / GM Class 2 + J1850 VPW / GM Class 2 + + + + SAE J1979 over J1850 VPW + SAE J1979 sur J1850 VPW + + + + Connect first + Connectez-vous d'abord + + + + PID + PID + + + + Read + Lire + + + + ShowCurrentData (mode 0x01) + ShowCurrentData (mode 0x01) + + + + Read VIN + Lire le VIN + + + + RequestVehicleInformation (mode 0x09) + RequestVehicleInformation (mode 0x09) + + + + Read DTCs + Lire les DTC + + + + Clear DTCs + Effacer les DTC + + + + Raw request + Requête brute + + + + J1979 request, hex, e.g. 01 0C + Requête J1979, hex, p. ex. 01 0C + + + + Send + Envoyer + + + + Diagnostic trouble codes (%1) + Codes défaut de diagnostic (%1) + + + + History + Historique + + + + Clear + Effacer + + J1939View @@ -2896,6 +3222,74 @@ B : %3 Signaux publiés par le maître (nombre, hex 0x ou nom de valeur logique) : + + McpServerDialog + + + AI agent (MCP) + Agent IA (MCP) + + + + Let an AI agent inspect this connection — the bus you already have open — over a local MCP server. It listens on 127.0.0.1 only and requires the token below. + Permet à un agent IA d'inspecter cette connexion — le bus déjà ouvert — via un serveur MCP local. Il écoute uniquement sur 127.0.0.1 et nécessite le jeton ci-dessous. + + + + Access + Accès + + + + Stop + Arrêter + + + + Start + Démarrer + + + + Read-only, plus OBD-II and UDS diagnostic reads (these send requests on the bus). + Lecture seule, plus les lectures de diagnostic OBD-II et UDS (celles-ci envoient des requêtes sur le bus). + + + + Plus active diagnostics: clear trouble codes, reset ECUs, run routines. Use with care. + Plus les diagnostics actifs : effacer les codes défaut, réinitialiser les ECU, exécuter des routines. À utiliser avec prudence. + + + + Plus transmitting arbitrary frames and messages. The agent fully controls the bus. + Plus l'émission de trames et de messages arbitraires. L'agent contrôle entièrement le bus. + + + + Read-only: inspect frames, decode and reverse-engineer. Sends nothing. + Lecture seule : inspecter les trames, décoder et faire de la rétro-ingénierie. N’envoie rien. + + + + URL + URL + + + + Copy + Copier + + + + Token + Jeton + + + + Point your MCP client at the URL using the streamable-HTTP transport, with the token as a Bearer header (Authorization: Bearer <token>) or appended as ?token=<token>. + Pointez votre client MCP sur l'URL en utilisant le transport streamable-HTTP, avec le jeton comme en-tête Bearer (Authorization: Bearer <token>) ou ajouté en ?token=<token>. + + MqttDialog @@ -3150,6 +3544,119 @@ B : %3 Auto-réception + + ObdView + + + connected + connecté + + + + not scanned + non analysé + + + + Request + Requête + + + + Request identifier (hex): 7DF is the OBD-II functional broadcast; 7E0 addresses one ECU + Identifiant de requête (hex) : 7DF est la diffusion fonctionnelle OBD-II ; 7E0 adresse un ECU + + + + Response + Réponse + + + + Response identifier (hex): 7E8 is the first ECU + Identifiant de réponse (hex) : 7E8 est le premier ECU + + + + Scan + Analyser + + + + Stop + Arrêter + + + + Live + Direct + + + + Poll the supported values continuously + Interroger en continu les valeurs prises en charge + + + + MIL on · %1 DTC + MIL allumé · %1 DTC + + + + MIL off + MIL éteint + + + + Connect to a vehicle's OBD-II port (a standard 500 kbit/s CAN bus), then press Scan to read the VIN, the supported live values and the trouble codes. If nothing answers, try the request/response identifiers 7E0 and 7E8 for a single ECU. + Connectez-vous au port OBD-II d'un véhicule (un bus CAN standard à 500 kbit/s), puis appuyez sur Analyser pour lire le VIN, les valeurs en direct prises en charge et les codes défaut. Si rien ne répond, essayez les identifiants de requête/réponse 7E0 et 7E8 pour un ECU unique. + + + + Live data + Données en direct + + + + Trouble codes + Codes défaut + + + + Read codes + Lire les codes + + + + Clear codes + Effacer les codes + + + + Clear the stored trouble codes and turn the MIL off (service 04) + Effacer les codes défaut enregistrés et éteindre le MIL (service 04) + + + + Stored + Enregistrés + + + + No stored codes + Aucun code enregistré + + + + Pending + En attente + + + + No pending codes + Aucun code en attente + + PhysicalIoView @@ -3620,6 +4127,49 @@ B : %3 Ouvrir un fichier journal + + ReverseActivityPanel + + + Refresh + Actualiser + + + + Rebuild the activity map from the frames captured so far + Reconstruire la carte d'activité à partir des trames capturées jusqu'à présent + + + + 0 + 0 + + + + 1 + 1 + + + + changed + modifié + + + + %1 over %2 + %1 sur %2 + + + + Select an identifier to see its bits over the whole capture + Sélectionnez un identifiant pour voir ses bits sur toute la capture + + + + Each row is a bit (byte 0 at the top, most significant bit first), each column a frame over time. Solid blue is a steady 1, dark a steady 0; orange marks a bit that changed. Counters ramp as diagonal stripes, constants are flat, multiplexers switch. + Chaque ligne est un bit (octet 0 en haut, bit de poids fort en premier), chaque colonne une trame au fil du temps. Le bleu plein est un 1 stable, le foncé un 0 stable ; l'orange marque un bit qui a changé. Les compteurs défilent en bandes diagonales, les constantes sont plates, les multiplexeurs commutent. + + ReverseAnalysisPanel @@ -3693,6 +4243,34 @@ B : %3 Pas encore de résultats + + ReverseConsolePanel + + + Run + Exécuter + + + + Run the snippet over the frames captured so far (Ctrl+Enter) + Exécuter l'extrait sur les trames capturées jusqu'à présent (Ctrl+Entrée) + + + + Clear + Effacer + + + + Clear the transcript and forget all variables + Effacer la transcription et oublier toutes les variables + + + + Python here, Ctrl+Enter to run — e.g. [(f.field.key, f.kind) for f in analyse(frames_of('100')).fields] (type HELP for the available names) + Python ici, Ctrl+Entrée pour exécuter — p. ex. [(f.field.key, f.kind) for f in analyse(frames_of('100')).fields] (tapez HELP pour les noms disponibles) + + ReverseMarkersPanel @@ -3781,82 +4359,82 @@ B : %3 Start action - + Démarrer l'action Capture reaction - + Capturer la réaction Record baseline - + Enregistrer la référence 1. Record a baseline while the bus is steady 2. Start the action and do it 3. Capture the reaction - + 1. Enregistrez une référence pendant que le bus est stable 2. Démarrez l'action et effectuez-la 3. Capturez la réaction Baseline - + Référence Action - + Action Result - + Résultat Reset - + Réinitialiser Find what a CAN message controls: capture a steady baseline, then do the action (press a button, open a door) and see which identifiers appeared and which bits changed — safely, by watching, not by sending. - + Découvrez ce que contrôle un message CAN : capturez une référence stable, puis effectuez l'action (appuyer sur un bouton, ouvrir une porte) et voyez quels identifiants sont apparus et quels bits ont changé — en toute sécurité, en observant, pas en émettant. ID - + ID Field - + Champ Kind - + Type Change - + Changement Flips - + Basculements Nothing changed during the action - + Rien n'a changé pendant l'action The identifiers and bits that react to the action appear here, most reactive first - + Les identifiants et les bits qui réagissent à l'action apparaissent ici, les plus réactifs en premier @@ -4088,57 +4666,57 @@ B : %3 ❚❚ - + ❚❚ ▶ - + ▶ Pause - + Pause Play - + Lecture « - + « Previous frame - + Trame précédente » - + » Next frame - + Trame suivante frame %1 / %2 - + trame %1 / %2 Speed - + Vitesse %1× - + %1× @@ -4195,251 +4773,281 @@ B : %3 + Activity + Activité + + + Automatic analysis Analyse automatique - + Reference signals Signaux de référence - + Markers Marqueurs - + Related signals Signaux liés - + Reaction - + Réaction + + + + Console + Console - + Notch again Repérer à nouveau - + Notch Repère - + Hide every bit that changed so far; bits that change afterwards, e.g. when you press a button, stand out Masquer tous les bits ayant changé jusqu'ici ; les bits qui changent ensuite, p. ex. quand vous appuyez sur un bouton, ressortent - + Clear notch Effacer le repère - + Reset Réinitialiser - + Forget the statistics and start counting again Oublier les statistiques et recommencer à compter - + ● Replay - + ● Relecture - + Replay - + Relecture - + Play this identifier's captured frames back over time; scrub to any moment and watch the bits flow - + Rejouer les trames capturées de cet identifiant au fil du temps ; se déplacer à tout moment et observer le flux de bits + + + + Plot Δt + Tracer Δt + + + + Plot this identifier's inter-frame interval (Δt) over time on the Signals page, to spot gateways, bursts and event-triggered messages + Tracer l'intervalle inter-trames (Δt) de cet identifiant au fil du temps sur la page Signaux, pour repérer les passerelles, les rafales et les messages déclenchés par événement + + + + Plot bytes + Tracer les octets + + + + Plot every byte of this identifier's payload as a value over time on the Signals page + Tracer chaque octet de la charge utile de cet identifiant comme une valeur au fil du temps sur la page Signaux - + Select an identifier to see its bits Sélectionner un identifiant pour voir ses bits - + Bits of %1 (%2) Bits de %1 (%2) - + Bits of %1 Bits de %1 - + Byte %1 Octet %1 - + Bit %1: set in %2 of the frames, changed in %3 Bit %1 : à 1 dans %2 des trames, changé dans %3 - + , last %1 s ago , il y a %1 s - + , never changed , jamais changé - + (hidden by the notch) (masqué par le repère) - + Click a bit to start a field, Shift+click another bit to extend it, click a byte label to select the byte. Blue: how often a bit changes; orange: changed just now; outlined orange: changed since the notch. Cliquez sur un bit pour commencer un champ, Maj+clic sur un autre bit pour l'étendre, cliquez sur une étiquette d'octet pour sélectionner l'octet. Bleu : fréquence de changement d'un bit ; orange : changé à l'instant ; contour orange : changé depuis le repère. - + Bit field %1 Champ de bits %1 - + Start bit Bit de départ - + Length Longueur - + Byte order Ordre des octets - + Intel (little endian) Intel (petit-boutiste) - + Motorola (big endian) Motorola (gros-boutiste) - + Signed Signé - + Factor Facteur - + Offset Décalage - + Unit Unité - + Name Nom - + Signal_%1 Signal_%1 - + only if %1 seulement si %1 - + The field is multiplexed: only frames with this multiplexer value carry it. Click to use all frames. Le champ est multiplexé : seules les trames ayant cette valeur de multiplexeur le portent. Cliquer pour utiliser toutes les trames. - + Value: %1 Valeur : %1 - + Plot Tracer - + Plot the field on the Signals page, from the first frame in the trace on Tracer le champ sur la page Signaux, à partir de la première trame de la trace - + Add to database Ajouter à la base - + Add the field as a signal in the database editor (and its message if needed) Ajouter le champ comme signal dans l'éditeur de base (et son message si nécessaire) - + Bytes Octets - + Byte Octet - + Value Valeur - + Distinct Distincts - + Min Min - + Max Max - + Entropy Entropie - + Values 00–FF Valeurs 00–FF @@ -4499,20 +5107,25 @@ B : %3 SOME/IP - + SOME/IP + Ethernet capture + Capture Ethernet + + + Bus gateway Passerelle de bus - + Settings Paramètres - + Version %1 Version %1 @@ -4763,152 +5376,152 @@ B : %3 listening - + à l'écoute not listening - + pas à l'écoute UDP ports - + Ports UDP UDP ports to listen on, comma separated (30490 is the usual Service Discovery port) - + Ports UDP à écouter, séparés par des virgules (30490 est le port habituel de Service Discovery) SD multicast - + Multicast SD Service Discovery multicast group to join (leave empty to listen on unicast only) - + Groupe multicast Service Discovery à rejoindre (laisser vide pour n'écouter qu'en unicast) Stop - + Arrêter Listen - + Écouter Clear - + Effacer - Unload FIBEX - + Unload description + Décharger la description - FIBEX… - + FIBEX / ARXML… + FIBEX / ARXML… - Load a FIBEX file to decode the message payloads into named signals - + Load a FIBEX or ARXML file to decode the message payloads into named signals + Charger un fichier FIBEX ou ARXML pour décoder les charges utiles des messages en signaux nommés %1 messages - + %1 messages SOME/IP is the service-oriented middleware of automotive Ethernet. Listen on the Service Discovery port to see the services offered on the network and the messages exchanged. A plain UDP socket sees the multicast it joins and traffic addressed to this host; full capture needs a network tap or mirror port. - + SOME/IP est l'intergiciel orienté services de l'Ethernet automobile. Écoutez sur le port Service Discovery pour voir les services proposés sur le réseau et les messages échangés. Un simple socket UDP voit le multicast rejoint et le trafic adressé à cet hôte ; une capture complète nécessite un tap réseau ou un port miroir. Services (%1) - + Services (%1) Service - + Service Instance - + Instance Version - + Version Endpoints - + Points de terminaison Offers - + Offres Messages - + Messages Time - + Temps Source - + Source Service.Method - + Service.Method Type - + Type Len - + Long. Description - + Description - Load SOME/IP FIBEX description - + Load SOME/IP description + Charger une description SOME/IP - FIBEX files (*.fibex *.xml) - + SOME/IP descriptions (*.fibex *.arxml *.xml) + Descriptions SOME/IP (*.fibex *.arxml *.xml) All files (*) - + Tous les fichiers (*) @@ -5858,182 +6471,187 @@ B : %3 main_window - + Command Commande - + View Vue - + Connection Connexion - + Disconnect %1 Déconnecter %1 - + Connect %1 Connecter %1 - + Resume the trace Reprendre la trace - + Pause the trace Mettre la trace en pause - + Clear the trace and statistics Effacer la trace et les statistiques - + Go to Aller à - + Connection %1 Connexion %1 - + Database editor Éditeur de base de données - + Multi-bus trace Trace multi-bus - + Compare recordings Comparer des enregistrements - + EtherCAT (CoE) EtherCAT (CoE) - + Physical I/O E/S physiques - + DoIP DoIP - + SOME/IP - + SOME/IP - + + Ethernet capture + Capture Ethernet + + + Bus gateway Passerelle de bus - + Tool Outil - + Theme Thème - + Language Langue - + Open workspace Ouvrir l'espace de travail - + Save workspace Enregistrer l'espace de travail - + The open connections are closed and replaced by those of the workspace. Les connexions ouvertes sont fermées et remplacées par celles de l'espace de travail. - + %1 frames %1 trames - + Rx %1/s Tx %2/s Rx %1/s Tx %2/s - + load %1% charge %1 % - + bus %1 bus %1 - + %1 error frames %1 trames d'erreur - + DB: %1 BD : %1 - + ● recording to %1 ● enregistrement vers %1 - + Drag to resize, double-click to reset Glisser pour redimensionner, double-cliquer pour réinitialiser - + Unsaved database changes Modifications de base de données non enregistrées - + Save the changes to %1 before quitting? Enregistrer les modifications de %1 avant de quitter ? - + Use "Save as" in the database editor first Utilisez d'abord « Enregistrer sous » dans l'éditeur de base de données - + CANExplorer %1 is available: ⋮ → Check for Updates… CANExplorer %1 est disponible : ⋮ → Rechercher des mises à jour… diff --git a/can_explorer/resources/translations/canexplorer_ja.ts b/can_explorer/resources/translations/canexplorer_ja.ts index d51bbc2..1b25fee 100644 --- a/can_explorer/resources/translations/canexplorer_ja.ts +++ b/can_explorer/resources/translations/canexplorer_ja.ts @@ -4,17 +4,22 @@ AboutDialog - + About %1 %1 について - + %1 (v%2) %1 (v%2) - + + The red diamond spirit awakens. + 赤いダイヤモンドの精霊が目覚める。 + + + Copy to Clipboard クリップボードにコピー @@ -801,186 +806,211 @@ B:%3 + OBD-II + OBD-II + + + Flashing フラッシュ書き込み - + UDS discovery UDS ディスカバリ - + XCP/CCP XCP/CCP - + GMLAN GMLAN - + + J1850 / Class 2 + J1850 / Class 2 + + + J1939 J1939 - + CANopen CANopen - + LIN Schedule LIN スケジュール - + Simulation シミュレーション - + Tests テスト - + Replaying %1 on the bus バスで %1 を再生中 - + (pass %1) (パス %1) - + Pause view 表示を一時停止 - + Clear クリア - + ● Stop recording ● 記録を停止 - + Record… 記録… - + Replay to bus… バスへ再生… - + Transmit the frames of a log file on this bus with their recorded timing ログファイルのフレームを記録時のタイミングでこのバスに送信 - + ● MQTT ● MQTT - + MQTT… MQTT… - + Publish the signals and frames to an MQTT broker 信号とフレームを MQTT ブローカーに発行 - + + ● Agent + ● エージェント + + + + Agent… + エージェント… + + + + Expose this connection to an AI agent over a local MCP server + この接続をローカルの MCP サーバー経由で AI エージェントに公開する + + + Export… エクスポート… - + Save the selected trace rows, or all displayed rows, to a log file 選択したトレース行、または表示中のすべての行をログファイルに保存 - + Unload database データベースをアンロード - + Load database… データベースを読み込み… - + Edit database データベースを編集 - + Open the database in the editor; saving it updates the decoding データベースをエディターで開きます。保存すると解読に反映されます - + All buses すべてのバス - + The database describes several buses; decode with the messages of this one このデータベースは複数のバスを記述しています。このバスのメッセージで解読します - + Auto-scroll 自動スクロール - + ● REC ● REC - + %1 frames %1 フレーム - + Bookmark / remove bookmark Ctrl+B ブックマークの追加/削除 Ctrl+B - + Copy selected rows Ctrl+C 選択した行をコピー Ctrl+C - + Replay from here ここから再生 - + Find… Ctrl+F 検索… Ctrl+F - + Export trace トレースをエクスポート - + Load signal database 信号データベースを読み込み @@ -2196,6 +2226,219 @@ B:%3 すべてのファイル (*) + + EthernetView + + + capturing + キャプチャ中 + + + + no capture + キャプチャなし + + + + Open pcap… + pcap を開く… + + + + Interface to capture live from (needs Linux and root/CAP_NET_RAW) + ライブキャプチャを行うインターフェース(Linux と root/CAP_NET_RAW が必要) + + + + Stop + 停止 + + + + Capture + キャプチャ + + + + Export… + エクスポート… + + + + Clear + クリア + + + + filter: SOME/IP, 192.168.0.10, 30509… + フィルター: SOME/IP, 192.168.0.10, 30509… + + + + %1 packets + %1 パケット + + + + Open a pcap capture of automotive Ethernet traffic (classic pcap, Ethernet link type), or capture live on an interface. Frames are decoded down the stack and the automotive protocols — SOME/IP, DoIP, gPTP, AVTP — are recognised. A full capture needs a network tap or a mirror/SPAN port. + 自動車用イーサネットトラフィックの pcap キャプチャを開く(クラシック pcap、イーサネットリンクタイプ)か、インターフェースでライブキャプチャします。フレームはスタック全体でデコードされ、自動車用プロトコル(SOME/IP、DoIP、gPTP、AVTP)が認識されます。完全なキャプチャにはネットワークタップまたはミラー/SPAN ポートが必要です。 + + + + Time sync (gPTP) + 時刻同期 (gPTP) + + + + Grandmaster + グランドマスター + + + + (priority %1, %2) + (優先度 %1, %2) + + + + class %1 + クラス %1 + + + + none announced + 通知なし + + + + Steps removed + グランドマスターまでのステップ数 + + + + Time source + 時刻ソース + + + + Sync interval + Sync 間隔 + + + + %1 ms + %1 ms + + + + (observed %1 Hz) + (実測 %1 Hz) + + + + Announce interval + Announce 間隔 + + + + Clocks / domains + クロック / ドメイン + + + + %1 clock(s) on domain(s) %2 + ドメイン %2 上に %1 個のクロック + + + + SOME/IP services + SOME/IP サービス + + + + Service + サービス + + + + Instance + インスタンス + + + + Version + バージョン + + + + Endpoints + エンドポイント + + + + Offers + オファー数 + + + + Packets + パケット + + + + Time + 時刻 + + + + Source + 送信元 + + + + Destination + 宛先 + + + + Protocol + プロトコル + + + + Len + 長さ + + + + Info + 情報 + + + + Open a pcap capture + pcap キャプチャを開く + + + + pcap files (*.pcap *.cap) + pcap ファイル (*.pcap *.cap) + + + + All files (*) + すべてのファイル (*) + + + + Save the packets + パケットを保存 + + + + pcap files (*.pcap) + pcap ファイル (*.pcap) + + FlashView @@ -2655,6 +2898,89 @@ B:%3 Python ファイル (*.py) + + J1850View + + + J1850 VPW / GM Class 2 + J1850 VPW / GM Class 2 + + + + SAE J1979 over J1850 VPW + J1850 VPW 上の SAE J1979 + + + + Connect first + 先に接続してください + + + + PID + PID + + + + Read + 読み取り + + + + ShowCurrentData (mode 0x01) + ShowCurrentData (モード 0x01) + + + + Read VIN + VIN を読み取り + + + + RequestVehicleInformation (mode 0x09) + RequestVehicleInformation (モード 0x09) + + + + Read DTCs + DTC を読み取り + + + + Clear DTCs + DTC を消去 + + + + Raw request + 生リクエスト + + + + J1979 request, hex, e.g. 01 0C + J1979 リクエスト、16 進数、例: 01 0C + + + + Send + 送信 + + + + Diagnostic trouble codes (%1) + 診断トラブルコード (%1) + + + + History + 履歴 + + + + Clear + クリア + + J1939View @@ -2896,6 +3222,74 @@ B:%3 マスターが発行する信号(数値、0x 16 進、または論理値名): + + McpServerDialog + + + AI agent (MCP) + AI エージェント (MCP) + + + + Let an AI agent inspect this connection — the bus you already have open — over a local MCP server. It listens on 127.0.0.1 only and requires the token below. + AI エージェントがこの接続(すでに開いているバス)をローカルの MCP サーバー経由で検査できるようにします。127.0.0.1 のみで待ち受け、下記のトークンが必要です。 + + + + Access + アクセス + + + + Stop + 停止 + + + + Start + 開始 + + + + Read-only, plus OBD-II and UDS diagnostic reads (these send requests on the bus). + 読み取り専用に加えて、OBD-II および UDS の診断読み取り(これらはバス上にリクエストを送信します)。 + + + + Plus active diagnostics: clear trouble codes, reset ECUs, run routines. Use with care. + さらにアクティブ診断: トラブルコードの消去、ECU のリセット、ルーチンの実行。慎重に使用してください。 + + + + Plus transmitting arbitrary frames and messages. The agent fully controls the bus. + さらに任意のフレームとメッセージの送信。エージェントがバスを完全に制御します。 + + + + Read-only: inspect frames, decode and reverse-engineer. Sends nothing. + 読み取り専用: フレームの検査、デコード、リバースエンジニアリング。何も送信しません。 + + + + URL + URL + + + + Copy + コピー + + + + Token + トークン + + + + Point your MCP client at the URL using the streamable-HTTP transport, with the token as a Bearer header (Authorization: Bearer <token>) or appended as ?token=<token>. + MCP クライアントを streamable-HTTP トランスポートでこの URL に向け、トークンを Bearer ヘッダー (Authorization: Bearer <token>) として、または ?token=<token> として付加してください。 + + MqttDialog @@ -3150,6 +3544,119 @@ B:%3 自己受信 + + ObdView + + + connected + 接続済み + + + + not scanned + 未スキャン + + + + Request + リクエスト + + + + Request identifier (hex): 7DF is the OBD-II functional broadcast; 7E0 addresses one ECU + リクエスト識別子 (16 進数): 7DF は OBD-II の機能ブロードキャスト、7E0 は単一の ECU を指定します + + + + Response + レスポンス + + + + Response identifier (hex): 7E8 is the first ECU + レスポンス識別子 (16 進数): 7E8 は最初の ECU です + + + + Scan + スキャン + + + + Stop + 停止 + + + + Live + ライブ + + + + Poll the supported values continuously + サポートされている値を継続的にポーリングする + + + + MIL on · %1 DTC + MIL 点灯 · %1 DTC + + + + MIL off + MIL 消灯 + + + + Connect to a vehicle's OBD-II port (a standard 500 kbit/s CAN bus), then press Scan to read the VIN, the supported live values and the trouble codes. If nothing answers, try the request/response identifiers 7E0 and 7E8 for a single ECU. + 車両の OBD-II ポート(標準的な 500 kbit/s の CAN バス)に接続し、スキャンを押して VIN、サポートされているライブ値、トラブルコードを読み取ります。応答がない場合は、単一の ECU 用にリクエスト/レスポンス識別子 7E0 と 7E8 を試してください。 + + + + Live data + ライブデータ + + + + Trouble codes + トラブルコード + + + + Read codes + コードを読み取り + + + + Clear codes + コードを消去 + + + + Clear the stored trouble codes and turn the MIL off (service 04) + 保存されたトラブルコードを消去し、MIL を消灯します(サービス 04) + + + + Stored + 保存済み + + + + No stored codes + 保存されたコードなし + + + + Pending + 保留中 + + + + No pending codes + 保留中のコードなし + + PhysicalIoView @@ -3620,6 +4127,49 @@ B:%3 ログファイルを開く + + ReverseActivityPanel + + + Refresh + 更新 + + + + Rebuild the activity map from the frames captured so far + これまでにキャプチャしたフレームからアクティビティマップを再構築する + + + + 0 + 0 + + + + 1 + 1 + + + + changed + 変化あり + + + + %1 over %2 + %2 中 %1 + + + + Select an identifier to see its bits over the whole capture + 識別子を選択すると、キャプチャ全体にわたるそのビットが表示されます + + + + Each row is a bit (byte 0 at the top, most significant bit first), each column a frame over time. Solid blue is a steady 1, dark a steady 0; orange marks a bit that changed. Counters ramp as diagonal stripes, constants are flat, multiplexers switch. + 各行は 1 ビット(バイト 0 が上、最上位ビットが先頭)、各列は時系列のフレームです。濃い青は安定した 1、暗色は安定した 0 を表し、オレンジは変化したビットを示します。カウンターは斜めの縞として変化し、定数は平坦、マルチプレクサは切り替わります。 + + ReverseAnalysisPanel @@ -3693,6 +4243,34 @@ B:%3 まだ結果がありません + + ReverseConsolePanel + + + Run + 実行 + + + + Run the snippet over the frames captured so far (Ctrl+Enter) + これまでにキャプチャしたフレームに対してスニペットを実行する (Ctrl+Enter) + + + + Clear + クリア + + + + Clear the transcript and forget all variables + トランスクリプトをクリアし、すべての変数を破棄する + + + + Python here, Ctrl+Enter to run — e.g. [(f.field.key, f.kind) for f in analyse(frames_of('100')).fields] (type HELP for the available names) + ここに Python、Ctrl+Enter で実行 — 例: [(f.field.key, f.kind) for f in analyse(frames_of('100')).fields] (利用可能な名前は HELP と入力) + + ReverseMarkersPanel @@ -3781,82 +4359,82 @@ B:%3 Start action - + アクションを開始 Capture reaction - + 反応をキャプチャ Record baseline - + ベースラインを記録 1. Record a baseline while the bus is steady 2. Start the action and do it 3. Capture the reaction - + 1. バスが安定している間にベースラインを記録します 2. アクションを開始して実行します 3. 反応をキャプチャします Baseline - + ベースライン Action - + アクション Result - + 結果 Reset - + リセット Find what a CAN message controls: capture a steady baseline, then do the action (press a button, open a door) and see which identifiers appeared and which bits changed — safely, by watching, not by sending. - + CAN メッセージが何を制御しているかを突き止めます: 安定したベースラインをキャプチャし、次にアクション(ボタンを押す、ドアを開ける)を実行して、どの識別子が現れ、どのビットが変化したかを確認します — 送信ではなく観測により、安全に行えます。 ID - + ID Field - + フィールド Kind - + 種類 Change - + 変化 Flips - + 反転回数 Nothing changed during the action - + アクション中に変化はありませんでした The identifiers and bits that react to the action appear here, most reactive first - + アクションに反応する識別子とビットがここに表示されます(反応が大きい順) @@ -4088,57 +4666,57 @@ B:%3 ❚❚ - + ❚❚ ▶ - + ▶ Pause - + 一時停止 Play - + 再生 « - + « Previous frame - + 前のフレーム » - + » Next frame - + 次のフレーム frame %1 / %2 - + フレーム %1 / %2 Speed - + 速度 %1× - + %1× @@ -4195,251 +4773,281 @@ B:%3 + Activity + アクティビティ + + + Automatic analysis 自動解析 - + Reference signals 参照信号 - + Markers マーカー - + Related signals 関連信号 - + Reaction - + 反応 + + + + Console + コンソール - + Notch again 再ノッチ - + Notch ノッチ - + Hide every bit that changed so far; bits that change afterwards, e.g. when you press a button, stand out これまでに変化したすべてのビットを隠します。その後に変化するビット(例 ボタンを押したとき)が目立ちます - + Clear notch ノッチをクリア - + Reset リセット - + Forget the statistics and start counting again 統計を破棄して再びカウントを開始 - + ● Replay - + ● リプレイ - + Replay - + リプレイ - + Play this identifier's captured frames back over time; scrub to any moment and watch the bits flow - + この識別子のキャプチャ済みフレームを時系列で再生します。任意の時点にスクラブしてビットの流れを観察できます + + + + Plot Δt + Δt をプロット + + + + Plot this identifier's inter-frame interval (Δt) over time on the Signals page, to spot gateways, bursts and event-triggered messages + この識別子のフレーム間隔 (Δt) を信号ページで時系列にプロットし、ゲートウェイ、バースト、イベント駆動メッセージを見つけます + + + + Plot bytes + バイトをプロット + + + + Plot every byte of this identifier's payload as a value over time on the Signals page + この識別子のペイロードの各バイトを値として信号ページで時系列にプロットします - + Select an identifier to see its bits 識別子を選択するとそのビットが表示されます - + Bits of %1 (%2) %1 のビット (%2) - + Bits of %1 %1 のビット - + Byte %1 バイト %1 - + Bit %1: set in %2 of the frames, changed in %3 ビット %1:フレームの %2 でセット、%3 で変化 - + , last %1 s ago 、最後は %1 s 前 - + , never changed 、変化なし - + (hidden by the notch) (ノッチにより非表示) - + Click a bit to start a field, Shift+click another bit to extend it, click a byte label to select the byte. Blue: how often a bit changes; orange: changed just now; outlined orange: changed since the notch. ビットをクリックしてフィールドを開始、Shift+クリックで別のビットまで拡張、バイトラベルをクリックでそのバイトを選択。青:ビットの変化頻度、オレンジ:たった今変化、オレンジの枠線:ノッチ以降に変化。 - + Bit field %1 ビットフィールド %1 - + Start bit 開始ビット - + Length 長さ - + Byte order バイト順 - + Intel (little endian) Intel(リトルエンディアン) - + Motorola (big endian) Motorola(ビッグエンディアン) - + Signed 符号付き - + Factor 係数 - + Offset オフセット - + Unit 単位 - + Name 名前 - + Signal_%1 Signal_%1 - + only if %1 %1 の場合のみ - + The field is multiplexed: only frames with this multiplexer value carry it. Click to use all frames. このフィールドはマルチプレクスされています:このマルチプレクサ値を持つフレームのみが運びます。クリックですべてのフレームを使用。 - + Value: %1 値:%1 - + Plot プロット - + Plot the field on the Signals page, from the first frame in the trace on トレースの最初のフレームから、信号ページにフィールドをプロット - + Add to database データベースに追加 - + Add the field as a signal in the database editor (and its message if needed) フィールドを信号としてデータベースエディターに追加(必要ならそのメッセージも) - + Bytes バイト - + Byte バイト - + Value 値 - + Distinct 異なる値 - + Min 最小 - + Max 最大 - + Entropy エントロピー - + Values 00–FF 値 00–FF @@ -4499,20 +5107,25 @@ B:%3 SOME/IP - + SOME/IP + Ethernet capture + イーサネットキャプチャ + + + Bus gateway バスゲートウェイ - + Settings 設定 - + Version %1 バージョン %1 @@ -4763,152 +5376,152 @@ B:%3 listening - + 待ち受け中 not listening - + 待ち受けなし UDP ports - + UDP ポート UDP ports to listen on, comma separated (30490 is the usual Service Discovery port) - + 待ち受ける UDP ポート(カンマ区切り、30490 が通常のサービスディスカバリポート) SD multicast - + SD マルチキャスト Service Discovery multicast group to join (leave empty to listen on unicast only) - + 参加するサービスディスカバリのマルチキャストグループ(ユニキャストのみを待ち受けるには空のままにします) Stop - + 停止 Listen - + 待ち受け Clear - + クリア - Unload FIBEX - + Unload description + 記述を解放 - FIBEX… - + FIBEX / ARXML… + FIBEX / ARXML… - Load a FIBEX file to decode the message payloads into named signals - + Load a FIBEX or ARXML file to decode the message payloads into named signals + FIBEX または ARXML ファイルを読み込み、メッセージのペイロードを名前付き信号にデコードします %1 messages - + %1 メッセージ SOME/IP is the service-oriented middleware of automotive Ethernet. Listen on the Service Discovery port to see the services offered on the network and the messages exchanged. A plain UDP socket sees the multicast it joins and traffic addressed to this host; full capture needs a network tap or mirror port. - + SOME/IP は自動車用イーサネットのサービス指向ミドルウェアです。サービスディスカバリポートで待ち受けると、ネットワーク上で提供されているサービスとやり取りされるメッセージを確認できます。単純な UDP ソケットは参加したマルチキャストとこのホスト宛てのトラフィックのみを見ます。完全なキャプチャにはネットワークタップまたはミラーポートが必要です。 Services (%1) - + サービス (%1) Service - + サービス Instance - + インスタンス Version - + バージョン Endpoints - + エンドポイント Offers - + オファー数 Messages - + メッセージ Time - + 時刻 Source - + 送信元 Service.Method - + Service.Method Type - + タイプ Len - + 長さ Description - + 説明 - Load SOME/IP FIBEX description - + Load SOME/IP description + SOME/IP 記述を読み込む - FIBEX files (*.fibex *.xml) - + SOME/IP descriptions (*.fibex *.arxml *.xml) + SOME/IP 記述 (*.fibex *.arxml *.xml) All files (*) - + すべてのファイル (*) @@ -5858,182 +6471,187 @@ B:%3 main_window - + Command コマンド - + View ビュー - + Connection 接続 - + Disconnect %1 %1 を切断 - + Connect %1 %1 に接続 - + Resume the trace トレースを再開 - + Pause the trace トレースを一時停止 - + Clear the trace and statistics トレースと統計をクリア - + Go to 移動 - + Connection %1 接続 %1 - + Database editor データベースエディター - + Multi-bus trace マルチバストレース - + Compare recordings 記録を比較 - + EtherCAT (CoE) EtherCAT (CoE) - + Physical I/O 物理 I/O - + DoIP DoIP - + SOME/IP - + SOME/IP - + + Ethernet capture + イーサネットキャプチャ + + + Bus gateway バスゲートウェイ - + Tool ツール - + Theme テーマ - + Language 言語 - + Open workspace ワークスペースを開く - + Save workspace ワークスペースを保存 - + The open connections are closed and replaced by those of the workspace. 開いている接続は閉じられ、ワークスペースの接続に置き換わります。 - + %1 frames %1 フレーム - + Rx %1/s Tx %2/s Rx %1/s Tx %2/s - + load %1% 負荷 %1% - + bus %1 バス %1 - + %1 error frames %1 個のエラーフレーム - + DB: %1 DB: %1 - + ● recording to %1 ● %1 に記録中 - + Drag to resize, double-click to reset ドラッグでサイズ変更、ダブルクリックでリセット - + Unsaved database changes 未保存のデータベース変更 - + Save the changes to %1 before quitting? 終了する前に %1 への変更を保存しますか? - + Use "Save as" in the database editor first 先にデータベースエディターで「名前を付けて保存」を使用してください - + CANExplorer %1 is available: ⋮ → Check for Updates… CANExplorer %1 が利用可能です:⋮ → 更新を確認… diff --git a/can_explorer/resources/translations/canexplorer_zh.ts b/can_explorer/resources/translations/canexplorer_zh.ts index 5836697..a639046 100644 --- a/can_explorer/resources/translations/canexplorer_zh.ts +++ b/can_explorer/resources/translations/canexplorer_zh.ts @@ -4,17 +4,22 @@ AboutDialog - + About %1 关于 %1 - + %1 (v%2) %1 (v%2) - + + The red diamond spirit awakens. + 红钻之魂觉醒。 + + + Copy to Clipboard 复制到剪贴板 @@ -801,186 +806,211 @@ B:%3 + OBD-II + OBD-II + + + Flashing 刷写 - + UDS discovery UDS 发现 - + XCP/CCP XCP/CCP - + GMLAN GMLAN - + + J1850 / Class 2 + J1850 / Class 2 + + + J1939 J1939 - + CANopen CANopen - + LIN Schedule LIN 调度 - + Simulation 仿真 - + Tests 测试 - + Replaying %1 on the bus 正在总线上回放 %1 - + (pass %1) (第 %1 遍) - + Pause view 暂停视图 - + Clear 清除 - + ● Stop recording ● 停止录制 - + Record… 录制… - + Replay to bus… 回放到总线… - + Transmit the frames of a log file on this bus with their recorded timing 以录制时序在此总线上发送日志文件中的帧 - + ● MQTT ● MQTT - + MQTT… MQTT… - + Publish the signals and frames to an MQTT broker 将信号和帧发布到 MQTT 代理 - + + ● Agent + ● 代理 + + + + Agent… + 代理… + + + + Expose this connection to an AI agent over a local MCP server + 通过本地 MCP 服务器将此连接开放给 AI 代理 + + + Export… 导出… - + Save the selected trace rows, or all displayed rows, to a log file 将所选跟踪行或所有显示的行保存到日志文件 - + Unload database 卸载数据库 - + Load database… 加载数据库… - + Edit database 编辑数据库 - + Open the database in the editor; saving it updates the decoding 在编辑器中打开数据库;保存后更新解码 - + All buses 所有总线 - + The database describes several buses; decode with the messages of this one 该数据库描述了多条总线;使用此总线的报文进行解码 - + Auto-scroll 自动滚动 - + ● REC ● REC - + %1 frames %1 帧 - + Bookmark / remove bookmark Ctrl+B 添加/移除书签 Ctrl+B - + Copy selected rows Ctrl+C 复制所选行 Ctrl+C - + Replay from here 从此处回放 - + Find… Ctrl+F 查找… Ctrl+F - + Export trace 导出跟踪 - + Load signal database 加载信号数据库 @@ -2196,6 +2226,219 @@ B:%3 所有文件 (*) + + EthernetView + + + capturing + 正在捕获 + + + + no capture + 无捕获 + + + + Open pcap… + 打开 pcap… + + + + Interface to capture live from (needs Linux and root/CAP_NET_RAW) + 用于实时捕获的接口(需要 Linux 和 root/CAP_NET_RAW) + + + + Stop + 停止 + + + + Capture + 捕获 + + + + Export… + 导出… + + + + Clear + 清除 + + + + filter: SOME/IP, 192.168.0.10, 30509… + 过滤:SOME/IP, 192.168.0.10, 30509… + + + + %1 packets + %1 个数据包 + + + + Open a pcap capture of automotive Ethernet traffic (classic pcap, Ethernet link type), or capture live on an interface. Frames are decoded down the stack and the automotive protocols — SOME/IP, DoIP, gPTP, AVTP — are recognised. A full capture needs a network tap or a mirror/SPAN port. + 打开一份汽车以太网流量的 pcap 捕获(经典 pcap,以太网链路类型),或在某个接口上实时捕获。帧会按整个协议栈解码,并识别汽车协议 — SOME/IP、DoIP、gPTP、AVTP。完整捕获需要网络分流器(tap)或镜像/SPAN 端口。 + + + + Time sync (gPTP) + 时间同步 (gPTP) + + + + Grandmaster + 主时钟 (Grandmaster) + + + + (priority %1, %2) + (优先级 %1,%2) + + + + class %1 + 类别 %1 + + + + none announced + 未通告 + + + + Steps removed + 到主时钟的跳数 + + + + Time source + 时间源 + + + + Sync interval + Sync 间隔 + + + + %1 ms + %1 ms + + + + (observed %1 Hz) + (实测 %1 Hz) + + + + Announce interval + Announce 间隔 + + + + Clocks / domains + 时钟 / 域 + + + + %1 clock(s) on domain(s) %2 + %2 域上有 %1 个时钟 + + + + SOME/IP services + SOME/IP 服务 + + + + Service + 服务 + + + + Instance + 实例 + + + + Version + 版本 + + + + Endpoints + 端点 + + + + Offers + 提供数 + + + + Packets + 数据包 + + + + Time + 时间 + + + + Source + 源 + + + + Destination + 目的 + + + + Protocol + 协议 + + + + Len + 长度 + + + + Info + 信息 + + + + Open a pcap capture + 打开 pcap 捕获 + + + + pcap files (*.pcap *.cap) + pcap 文件 (*.pcap *.cap) + + + + All files (*) + 所有文件 (*) + + + + Save the packets + 保存数据包 + + + + pcap files (*.pcap) + pcap 文件 (*.pcap) + + FlashView @@ -2655,6 +2898,89 @@ B:%3 Python 文件 (*.py) + + J1850View + + + J1850 VPW / GM Class 2 + J1850 VPW / GM Class 2 + + + + SAE J1979 over J1850 VPW + 基于 J1850 VPW 的 SAE J1979 + + + + Connect first + 请先连接 + + + + PID + PID + + + + Read + 读取 + + + + ShowCurrentData (mode 0x01) + ShowCurrentData(模式 0x01) + + + + Read VIN + 读取 VIN + + + + RequestVehicleInformation (mode 0x09) + RequestVehicleInformation(模式 0x09) + + + + Read DTCs + 读取 DTC + + + + Clear DTCs + 清除 DTC + + + + Raw request + 原始请求 + + + + J1979 request, hex, e.g. 01 0C + J1979 请求,十六进制,例如 01 0C + + + + Send + 发送 + + + + Diagnostic trouble codes (%1) + 诊断故障码 (%1) + + + + History + 历史 + + + + Clear + 清除 + + J1939View @@ -2896,6 +3222,74 @@ B:%3 主机发布的信号(数字、0x 十六进制或逻辑值名称): + + McpServerDialog + + + AI agent (MCP) + AI 代理 (MCP) + + + + Let an AI agent inspect this connection — the bus you already have open — over a local MCP server. It listens on 127.0.0.1 only and requires the token below. + 让 AI 代理通过本地 MCP 服务器检查此连接 — 即你已打开的总线。它仅监听 127.0.0.1,并需要下方的令牌。 + + + + Access + 访问权限 + + + + Stop + 停止 + + + + Start + 启动 + + + + Read-only, plus OBD-II and UDS diagnostic reads (these send requests on the bus). + 只读,另加 OBD-II 和 UDS 诊断读取(这些会在总线上发送请求)。 + + + + Plus active diagnostics: clear trouble codes, reset ECUs, run routines. Use with care. + 另加主动诊断:清除故障码、复位 ECU、运行例程。请谨慎使用。 + + + + Plus transmitting arbitrary frames and messages. The agent fully controls the bus. + 另加发送任意帧和消息。代理将完全控制总线。 + + + + Read-only: inspect frames, decode and reverse-engineer. Sends nothing. + 只读:检查帧、解码和逆向工程。不发送任何内容。 + + + + URL + URL + + + + Copy + 复制 + + + + Token + 令牌 + + + + Point your MCP client at the URL using the streamable-HTTP transport, with the token as a Bearer header (Authorization: Bearer <token>) or appended as ?token=<token>. + 使用 streamable-HTTP 传输将你的 MCP 客户端指向该 URL,并将令牌作为 Bearer 头 (Authorization: Bearer <token>) 或作为 ?token=<token> 附加。 + + MqttDialog @@ -3150,6 +3544,119 @@ B:%3 自接收 + + ObdView + + + connected + 已连接 + + + + not scanned + 未扫描 + + + + Request + 请求 + + + + Request identifier (hex): 7DF is the OBD-II functional broadcast; 7E0 addresses one ECU + 请求标识符(十六进制):7DF 是 OBD-II 功能广播;7E0 寻址单个 ECU + + + + Response + 响应 + + + + Response identifier (hex): 7E8 is the first ECU + 响应标识符(十六进制):7E8 是第一个 ECU + + + + Scan + 扫描 + + + + Stop + 停止 + + + + Live + 实时 + + + + Poll the supported values continuously + 持续轮询支持的值 + + + + MIL on · %1 DTC + MIL 点亮 · %1 个 DTC + + + + MIL off + MIL 熄灭 + + + + Connect to a vehicle's OBD-II port (a standard 500 kbit/s CAN bus), then press Scan to read the VIN, the supported live values and the trouble codes. If nothing answers, try the request/response identifiers 7E0 and 7E8 for a single ECU. + 连接到车辆的 OBD-II 端口(标准的 500 kbit/s CAN 总线),然后按扫描以读取 VIN、支持的实时值和故障码。如果无响应,请尝试用于单个 ECU 的请求/响应标识符 7E0 和 7E8。 + + + + Live data + 实时数据 + + + + Trouble codes + 故障码 + + + + Read codes + 读取代码 + + + + Clear codes + 清除代码 + + + + Clear the stored trouble codes and turn the MIL off (service 04) + 清除存储的故障码并熄灭 MIL(服务 04) + + + + Stored + 已存储 + + + + No stored codes + 无存储的代码 + + + + Pending + 待定 + + + + No pending codes + 无待定代码 + + PhysicalIoView @@ -3620,6 +4127,49 @@ B:%3 打开日志文件 + + ReverseActivityPanel + + + Refresh + 刷新 + + + + Rebuild the activity map from the frames captured so far + 根据目前已捕获的帧重建活动图 + + + + 0 + 0 + + + + 1 + 1 + + + + changed + 已变化 + + + + %1 over %2 + %2 中的 %1 + + + + Select an identifier to see its bits over the whole capture + 选择一个标识符以查看其在整个捕获期间的各位 + + + + Each row is a bit (byte 0 at the top, most significant bit first), each column a frame over time. Solid blue is a steady 1, dark a steady 0; orange marks a bit that changed. Counters ramp as diagonal stripes, constants are flat, multiplexers switch. + 每行是一个位(字节 0 在顶部,最高有效位在前),每列是随时间变化的一帧。实蓝色表示稳定的 1,暗色表示稳定的 0;橙色标记发生变化的位。计数器呈对角条纹递增,常量保持平坦,多路复用器会切换。 + + ReverseAnalysisPanel @@ -3693,6 +4243,34 @@ B:%3 暂无结果 + + ReverseConsolePanel + + + Run + 运行 + + + + Run the snippet over the frames captured so far (Ctrl+Enter) + 对目前已捕获的帧运行该代码片段 (Ctrl+Enter) + + + + Clear + 清除 + + + + Clear the transcript and forget all variables + 清除记录并清空所有变量 + + + + Python here, Ctrl+Enter to run — e.g. [(f.field.key, f.kind) for f in analyse(frames_of('100')).fields] (type HELP for the available names) + 在此输入 Python,Ctrl+Enter 运行 — 例如 [(f.field.key, f.kind) for f in analyse(frames_of('100')).fields] (输入 HELP 查看可用名称) + + ReverseMarkersPanel @@ -3781,82 +4359,82 @@ B:%3 Start action - + 开始动作 Capture reaction - + 捕获反应 Record baseline - + 记录基线 1. Record a baseline while the bus is steady 2. Start the action and do it 3. Capture the reaction - + 1. 在总线稳定时记录基线 2. 开始并执行动作 3. 捕获反应 Baseline - + 基线 Action - + 动作 Result - + 结果 Reset - + 重置 Find what a CAN message controls: capture a steady baseline, then do the action (press a button, open a door) and see which identifiers appeared and which bits changed — safely, by watching, not by sending. - + 找出某条 CAN 消息控制的内容:先捕获稳定的基线,然后执行动作(按下按钮、打开车门),查看出现了哪些标识符以及哪些位发生变化 — 通过观察而非发送,安全地进行。 ID - + ID Field - + 字段 Kind - + 类型 Change - + 变化 Flips - + 翻转次数 Nothing changed during the action - + 动作期间没有任何变化 The identifiers and bits that react to the action appear here, most reactive first - + 对动作有反应的标识符和位显示在此处,反应最强的在前 @@ -4088,57 +4666,57 @@ B:%3 ❚❚ - + ❚❚ ▶ - + ▶ Pause - + 暂停 Play - + 播放 « - + « Previous frame - + 上一帧 » - + » Next frame - + 下一帧 frame %1 / %2 - + 帧 %1 / %2 Speed - + 速度 %1× - + %1× @@ -4195,251 +4773,281 @@ B:%3 + Activity + 活动 + + + Automatic analysis 自动分析 - + Reference signals 参考信号 - + Markers 标记 - + Related signals 相关信号 - + Reaction - + 反应 - + + Console + 控制台 + + + Notch again 重新标记 - + Notch 标记点 - + Hide every bit that changed so far; bits that change afterwards, e.g. when you press a button, stand out 隐藏迄今为止变化的所有位;之后变化的位(例如按下按钮时)会凸显出来 - + Clear notch 清除标记点 - + Reset 重置 - + Forget the statistics and start counting again 清除统计信息并重新开始计数 - + ● Replay - + ● 回放 - + Replay - + 回放 - + Play this identifier's captured frames back over time; scrub to any moment and watch the bits flow - + 随时间回放此标识符捕获的帧;可拖动到任意时刻并观察各位的变化 + + + + Plot Δt + 绘制 Δt + + + + Plot this identifier's inter-frame interval (Δt) over time on the Signals page, to spot gateways, bursts and event-triggered messages + 在信号页面上绘制此标识符的帧间间隔 (Δt) 随时间的变化,以发现网关、突发和事件触发的消息 + + + + Plot bytes + 绘制字节 + + + + Plot every byte of this identifier's payload as a value over time on the Signals page + 在信号页面上将此标识符负载的每个字节作为值随时间绘制 - + Select an identifier to see its bits 选择一个标识符以查看其位 - + Bits of %1 (%2) %1 的位 (%2) - + Bits of %1 %1 的位 - + Byte %1 字节 %1 - + Bit %1: set in %2 of the frames, changed in %3 位 %1:在 %2 的帧中置位,在 %3 中变化 - + , last %1 s ago ,上次 %1 s 前 - + , never changed ,从未变化 - + (hidden by the notch) (被标记点隐藏) - + Click a bit to start a field, Shift+click another bit to extend it, click a byte label to select the byte. Blue: how often a bit changes; orange: changed just now; outlined orange: changed since the notch. 点击一个位开始一个字段,Shift+点击另一个位可扩展,点击字节标签可选择该字节。蓝色:位变化的频率;橙色:刚刚变化;橙色描边:自标记点以来变化。 - + Bit field %1 位字段 %1 - + Start bit 起始位 - + Length 长度 - + Byte order 字节序 - + Intel (little endian) Intel(小端) - + Motorola (big endian) Motorola(大端) - + Signed 有符号 - + Factor 系数 - + Offset 偏移 - + Unit 单位 - + Name 名称 - + Signal_%1 Signal_%1 - + only if %1 仅当 %1 - + The field is multiplexed: only frames with this multiplexer value carry it. Click to use all frames. 该字段是多路复用的:只有具有此多路复用值的帧才承载它。点击以使用所有帧。 - + Value: %1 值:%1 - + Plot 绘图 - + Plot the field on the Signals page, from the first frame in the trace on 在信号页面绘制该字段,从跟踪中的第一帧开始 - + Add to database 添加到数据库 - + Add the field as a signal in the database editor (and its message if needed) 将该字段作为信号添加到数据库编辑器(必要时连同其报文) - + Bytes 字节 - + Byte 字节 - + Value 值 - + Distinct 不同值 - + Min 最小 - + Max 最大 - + Entropy 熵 - + Values 00–FF 值 00–FF @@ -4499,20 +5107,25 @@ B:%3 SOME/IP - + SOME/IP + Ethernet capture + 以太网捕获 + + + Bus gateway 总线网关 - + Settings 设置 - + Version %1 版本 %1 @@ -4763,152 +5376,152 @@ B:%3 listening - + 正在监听 not listening - + 未监听 UDP ports - + UDP 端口 UDP ports to listen on, comma separated (30490 is the usual Service Discovery port) - + 要监听的 UDP 端口,用逗号分隔(30490 是常用的服务发现端口) SD multicast - + SD 组播 Service Discovery multicast group to join (leave empty to listen on unicast only) - + 要加入的服务发现组播组(留空则仅监听单播) Stop - + 停止 Listen - + 监听 Clear - + 清除 - Unload FIBEX - + Unload description + 卸载描述 - FIBEX… - + FIBEX / ARXML… + FIBEX / ARXML… - Load a FIBEX file to decode the message payloads into named signals - + Load a FIBEX or ARXML file to decode the message payloads into named signals + 加载 FIBEX 或 ARXML 文件,将消息负载解码为命名信号 %1 messages - + %1 条消息 SOME/IP is the service-oriented middleware of automotive Ethernet. Listen on the Service Discovery port to see the services offered on the network and the messages exchanged. A plain UDP socket sees the multicast it joins and traffic addressed to this host; full capture needs a network tap or mirror port. - + SOME/IP 是汽车以太网的面向服务中间件。在服务发现端口上监听即可查看网络上提供的服务和交换的消息。普通 UDP 套接字只能看到其加入的组播以及发往本主机的流量;完整捕获需要网络分流器或镜像端口。 Services (%1) - + 服务 (%1) Service - + 服务 Instance - + 实例 Version - + 版本 Endpoints - + 端点 Offers - + 提供数 Messages - + 消息 Time - + 时间 Source - + 源 Service.Method - + Service.Method Type - + 类型 Len - + 长度 Description - + 描述 - Load SOME/IP FIBEX description - + Load SOME/IP description + 加载 SOME/IP 描述 - FIBEX files (*.fibex *.xml) - + SOME/IP descriptions (*.fibex *.arxml *.xml) + SOME/IP 描述 (*.fibex *.arxml *.xml) All files (*) - + 所有文件 (*) @@ -5858,182 +6471,187 @@ B:%3 main_window - + Command 命令 - + View 视图 - + Connection 连接 - + Disconnect %1 断开 %1 - + Connect %1 连接 %1 - + Resume the trace 继续跟踪 - + Pause the trace 暂停跟踪 - + Clear the trace and statistics 清除跟踪和统计信息 - + Go to 跳转到 - + Connection %1 连接 %1 - + Database editor 数据库编辑器 - + Multi-bus trace 多总线跟踪 - + Compare recordings 比较录制 - + EtherCAT (CoE) EtherCAT (CoE) - + Physical I/O 物理 I/O - + DoIP DoIP - + SOME/IP - + SOME/IP - + + Ethernet capture + 以太网捕获 + + + Bus gateway 总线网关 - + Tool 工具 - + Theme 主题 - + Language 语言 - + Open workspace 打开工作区 - + Save workspace 保存工作区 - + The open connections are closed and replaced by those of the workspace. 打开的连接将被关闭,并替换为工作区中的连接。 - + %1 frames %1 帧 - + Rx %1/s Tx %2/s Rx %1/s Tx %2/s - + load %1% 负载 %1% - + bus %1 总线 %1 - + %1 error frames %1 个错误帧 - + DB: %1 数据库:%1 - + ● recording to %1 ● 正在录制到 %1 - + Drag to resize, double-click to reset 拖动以调整大小,双击以重置 - + Unsaved database changes 未保存的数据库更改 - + Save the changes to %1 before quitting? 退出前保存对 %1 的更改吗? - + Use "Save as" in the database editor first 请先在数据库编辑器中使用"另存为" - + CANExplorer %1 is available: ⋮ → Check for Updates… CANExplorer %1 可用:⋮ → 检查更新… diff --git a/docs/assets/screenshots/j1850.png b/docs/assets/screenshots/j1850.png new file mode 100644 index 0000000000000000000000000000000000000000..1e240c02d64d5195d77cb66d02132067fdbf45bc GIT binary patch literal 151211 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ⅆHB&U4*Ax2cFm0JT4vxsk9KDm4hkFlfys6-^K#r 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 literal 0 HcmV?d00001 diff --git a/docs/assets/screenshots/mcp.png b/docs/assets/screenshots/mcp.png new file mode 100644 index 0000000000000000000000000000000000000000..a4543ed14d94a44696c1513054938cc8a6c74054 GIT binary patch literal 179154 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 z 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�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

!t?5%veLGE`6^smQzph?~#wudeW!PKS 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 + +

Fetching the latest release… meanwhile, browse all releases.

+ +Prefer a package manager or running from source? The [installation guide](getting-started/installation.md) covers +the Snap, the archives and building with `uv`. Installed copies [update themselves](user-guide/updates.md). + ## Features
diff --git a/docs/javascripts/releases.js b/docs/javascripts/releases.js new file mode 100644 index 0000000..52cdc28 --- /dev/null +++ b/docs/javascripts/releases.js @@ -0,0 +1,77 @@ +// Point the Download buttons on the home page at the current GitHub release's assets, and highlight the visitor's +// platform. Without JavaScript (or if the API call fails) the buttons keep their default href: the releases page. +(function () { + "use strict"; + + var REPO = "AKJ7/CANExplorer"; + + // data-download key -> a regex matching that platform's asset file name + var PATTERNS = { + windows: /-windows-x86_64-setup\.exe$/, + macos: /-macos-arm64\.dmg$/, + appimage: /-linux-x86_64\.AppImage$/, + deb: /-linux-x86_64\.deb$/, + }; + + function detectPlatform() { + var platform = ( + (navigator.userAgentData && navigator.userAgentData.platform) || + navigator.platform || + "" + ).toLowerCase(); + var ua = (navigator.userAgent || "").toLowerCase(); + if (platform.indexOf("win") !== -1) return "windows"; + if (platform.indexOf("mac") !== -1) return "macos"; + if (platform.indexOf("linux") !== -1 || ua.indexOf("linux") !== -1) return "appimage"; + return null; + } + + function ready(fn) { + if (document.readyState !== "loading") fn(); + else document.addEventListener("DOMContentLoaded", fn); + } + + ready(function () { + var buttons = document.querySelectorAll("[data-download]"); + if (!buttons.length) return; // Only the home page has them + var note = document.getElementById("download-note"); + + var platform = detectPlatform(); + buttons.forEach(function (button) { + if (platform && button.getAttribute("data-download") === platform) { + button.classList.add("md-button--primary"); + } + }); + + fetch("https://api.github.com/repos/" + REPO + "/releases/latest", { + headers: { Accept: "application/vnd.github+json" }, + }) + .then(function (response) { + if (!response.ok) throw new Error("HTTP " + response.status); + return response.json(); + }) + .then(function (release) { + var assets = release.assets || []; + buttons.forEach(function (button) { + var pattern = PATTERNS[button.getAttribute("data-download")]; + var asset = assets.filter(function (a) { return pattern.test(a.name); })[0]; + if (asset) button.href = asset.browser_download_url; + }); + if (note) { + var tag = release.tag_name || release.name || ""; + note.innerHTML = + "Latest release: " + tag + ". Other files (zip archives, checksums) are on the " + + 'release page — ' + + 'see installation for Snap and building from source.'; + } + }) + .catch(function () { + if (note) { + note.innerHTML = + 'Download the latest installers from the ' + + 'release page — ' + + 'see installation for details.'; + } + }); + }); +})(); diff --git a/docs/reference/command-line.md b/docs/reference/command-line.md index 6dd7d19..8743afe 100644 --- a/docs/reference/command-line.md +++ b/docs/reference/command-line.md @@ -76,22 +76,25 @@ can-server --bus NAME=INTERFACE:CHANNEL[:BITRATE[:DATA_BITRATE]] [--bus ...] [-- ## can-mcp Exposes one bus to an AI agent over the [Model Context Protocol](../user-guide/mcp.md) (stdio). Needs the `mcp` -extra. Read-only unless `--allow-send` is given. +extra. Read-only unless a permission flag is given. With `--interface` and `--channel` it opens that bus at start; +without them it starts unconnected and the agent opens a bus with the `connect` tool. ```text -can-mcp --interface INTERFACE --channel CHANNEL [--bitrate BITRATE] [--fd] [--data-bitrate DATA_BITRATE] - [--database FILE] [--protocol {Raw,ISO-TP,UDS,J1939,CANopen,LIN}] [--allow-send] +can-mcp [--interface INTERFACE --channel CHANNEL] [--bitrate BITRATE] [--fd] [--data-bitrate DATA_BITRATE] + [--database FILE] [--protocol PROTOCOL] [--allow-diagnostics] [--allow-control] [--allow-send] ``` | Argument | Description | |---|---| -| `-i`, `--interface` | python-can interface, e.g. `socketcan`, `pcan`, `virtual`, `gvret` (required) | -| `-c`, `--channel` | Channel, e.g. `can0`, `PCAN_USBBUS1`, `COM7` (required) | +| `-i`, `--interface` | python-can interface, e.g. `socketcan`, `pcan`, `virtual`, `gvret` (with `--channel`, or omit both) | +| `-c`, `--channel` | Channel, e.g. `can0`, `PCAN_USBBUS1`, `COM7` (with `--interface`, or omit both) | | `-b`, `--bitrate` | Nominal bitrate; default 500000 | | `--fd`, `--data-bitrate` | CAN FD; a data bitrate enables it | | `-d`, `--database` | Signal database (DBC, ARXML, KCD, SYM, DBF) for decoding | | `--protocol` | Protocol decoder for the frames; default `Raw` | -| `--allow-send` | Allow the agent to transmit (`send_frame`, `query_obd`) | +| `--allow-diagnostics` | Allow safe diagnostic reads (OBD-II and UDS reads; these send requests) | +| `--allow-control` | Allow active diagnostics (clear DTCs, ECU reset, routines, raw UDS) | +| `--allow-send` | Allow the agent to transmit arbitrary frames (grants everything) | ## can-db-convert diff --git a/docs/reference/interfaces.md b/docs/reference/interfaces.md index c3e836f..6dd0c32 100644 --- a/docs/reference/interfaces.md +++ b/docs/reference/interfaces.md @@ -28,6 +28,7 @@ the first column. Install the vendor's driver or library for your adapter first; | *GvretBus* | `gvret` | Devices with SavvyCAN's GVRET protocol: ESP32RET boards, Macchina M2 (M2RET), CANDue (GVRET firmware) | Windows, Linux, macOS | None: a serial port, with `@1` for the device's second bus (`COM5@1`), or `socket://IP:23` for ESP32RET on WiFi (see [below](#gvret-devices)) | – | | *J2534Bus* | `j2534` | J2534 pass-thru devices: Drew Technologies Mongoose and CarDAQ, Tactrix OpenPort 2.0, J2534 drivers of Bosch, Autel, Kvaser, PEAK, Intrepid … (see [below](#j2534-pass-thru-devices)) | Windows | The device's J2534 (04.04) driver; 32-bit drivers run in the J2534 bridge ([below](#32-bit-drivers)) | – | | *Elm327Bus* | `elm327` | OBD adapters with the ELM327 command set: OBDLink MX+, LX, SX, EX and ELM327 compatibles, over USB, Bluetooth or WiFi; made for diagnostics, with limits (see [below](#elm327-and-obdlink-adapters)) | Windows, Linux, macOS | None: a serial port (`COM7`, `/dev/rfcomm0`), or `socket://IP:35000` for WiFi adapters | – | +| *Elm327VpwBus* | `elm327_vpw` | **SAE J1850 VPW / GM Class 2** (not CAN): the 10.4 kbit/s bus of 1996–2007 GM vehicles, through an ELM327/OBDLink adapter (see [below](#gm-class-2-sae-j1850-vpw)) | Windows, Linux, macOS | None: the adapter's serial port, as for `elm327` | – | | *SerialBus* | `serial` | Adapters speaking python-can's own serial protocol | Windows, Linux, macOS | None: a serial port | – | | *robotellBus* | `robotell` | Robotell USB-CAN adapters | Windows, Linux, macOS | None: a serial port | – | | *SeeedBus* | `seeedstudio` | Seeed Studio USB-CAN Analyzer | Windows, Linux, macOS | None: a serial port | – | @@ -141,6 +142,19 @@ These adapters are made for diagnostics, not as CAN interfaces: An adapter is used by one program at a time. +## GM Class 2 (SAE J1850 VPW) + +*Elm327VpwBus* (`elm327_vpw`) opens a **GM "Class 2" / SAE J1850 VPW** bus — the 10.4 kbit/s single-wire bus of +1996–2007 GM vehicles, not CAN — through an ELM327/OBDLink adapter put into protocol 2. The channel is the +adapter's serial port, exactly as for the [ELM327 CAN interface](#elm327-and-obdlink-adapters) (USB, Bluetooth or +`socket://IP:35000` for WiFi). The bitrate is fixed at 10.4 kbit/s. + +Each VPW frame is presented as a python-can frame whose extended identifier holds the three header bytes +(priority, target, source) and whose data is the payload, so the trace, dashboard, reverse engineering and +recording work on it like on any bus. Monitoring and diagnostics share the one adapter, which is half-duplex, so a +frame on the bus during a diagnostic request is missed. See [J1850 VPW / GM Class 2](../user-guide/j1850.md) for +the diagnostics page and scripting. + ## Adapters through SocketCAN On Linux, most adapters have kernel drivers and appear as network interfaces (`can0`, `can1` …) that CANExplorer diff --git a/docs/stylesheets/dracula.css b/docs/stylesheets/dracula.css index 9a3baad..9044462 100644 --- a/docs/stylesheets/dracula.css +++ b/docs/stylesheets/dracula.css @@ -180,3 +180,23 @@ [data-md-color-scheme="slate"] .md-search-result mark { color: var(--dracula-pink); } + +/* Download buttons on the home page */ +.md-typeset .download-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin: 1em 0 0.4em; +} + +.md-typeset .download-buttons .md-button { + margin: 0; + display: inline-flex; + align-items: center; + gap: 0.4em; +} + +.md-typeset .download-note { + font-size: 0.75rem; + color: var(--dracula-comment); +} diff --git a/docs/user-guide/ethernet.md b/docs/user-guide/ethernet.md new file mode 100644 index 0000000..2549397 --- /dev/null +++ b/docs/user-guide/ethernet.md @@ -0,0 +1,55 @@ +# Ethernet capture + +The **Ethernet capture** tool opens a **pcap** or **pcapng** capture of automotive Ethernet traffic (or captures +live) and decodes the packets down the stack — Ethernet, 802.1Q VLAN, IPv4/IPv6, ARP, UDP/TCP — and recognises the +automotive protocols carried on top: **SOME/IP** (and SOME/IP-SD), **DoIP**, **gPTP** (IEEE 802.1AS), **AVTP** +(IEEE 1722) and **LLDP**. It is a standalone tool, separate from the CAN connections. + +Where the [SOME/IP](someip.md) and [DoIP](doip.md) tools interpret one protocol live, this one shows *everything* +on an Ethernet segment — so you can see what is actually on the wire, and which protocols. + +## Opening a capture + +**Open pcap…** reads a capture file. It reads both the classic **pcap** format and **pcapng** (Wireshark's +default), for the Ethernet link type. + +Each packet is shown with its time, source and destination (IP and port, or MAC for non-IP frames), the highest +recognised **protocol**, length and an **info** summary (for example a SOME/IP method and payload size, a DoIP +payload type, a gPTP message type, an AVTP stream and sequence number, or an LLDP neighbour). The **filter** box +narrows the list by any text — a protocol (`SOME/IP`), an address (`192.168.0.10`) or a port (`30509`) — and the +pills above the table show the **protocol breakdown**. **Export…** saves the packets back to a pcap file. + +**SOME/IP spanning packets is reassembled**: a message carried over **TCP** that is split across segments is +decoded on the segment that completes it (the earlier ones show `[SOME/IP segment]`), and a **SOME/IP-TP** +segmented message is reassembled across its datagrams. When the capture contains Service Discovery, a **SOME/IP +services** card lists the services offered — their service and instance ids, version, endpoints and how many times +each was offered. + +## Time sync (gPTP) + +When the capture contains **gPTP** (IEEE 802.1AS) traffic, a **Time sync** card summarises the network's time +synchronisation: + +- the **grandmaster** — its clock identity, priority and clock quality (for example "synchronized to a primary + reference") and its time source (GPS, PTP…), +- how many **clocks** and **domains** are present, and the **steps removed** from the grandmaster, and +- the **Sync** and **Announce** intervals the grandmaster advertises, with the **observed Sync rate** measured from + the capture. + +Each gPTP packet in the list also shows its message type (Announce, Sync, Follow_Up, Pdelay…) with the sequence +number, and the grandmaster and clock class for Announce messages. + +## Capturing live + +On **Linux**, pick an interface and press **Capture** to capture live from it. Raw capture needs privileges +(run as root, or grant the binary `CAP_NET_RAW`), and a full view of the traffic needs a **network tap** or a +**mirror/SPAN port** — a plain host only sees broadcast/multicast and the traffic addressed to it. On other +platforms, capture live with another tool and open the saved pcap here. + +## Limits + +- pcap and pcapng are read for the **Ethernet** link type only. +- Decoding is a summary per packet, not a full field-by-field dissection; load a FIBEX or ARXML file in the + [SOME/IP](someip.md) tool to decode SOME/IP payloads into named signals. +- TCP reassembly follows the stream in capture order (a 4 GiB sequence-number wrap within one connection is not + handled); live capture is Linux only (open a saved pcap/pcapng on other platforms). diff --git a/docs/user-guide/j1850.md b/docs/user-guide/j1850.md new file mode 100644 index 0000000..ab7e6fc --- /dev/null +++ b/docs/user-guide/j1850.md @@ -0,0 +1,71 @@ +# J1850 VPW / GM Class 2 + +CANExplorer supports **SAE J1850 VPW**, the **GM "Class 2"** serial data bus of 1996–2007 GM vehicles — the bus the +**P01/P59 powertrain control modules** of 1999–2006 GM trucks, vans and F-bodies talk on. It is not CAN: a +single-wire 10.4 kbit/s bus with a three-byte header (priority, target, source) and an SAE J1850 CRC. CANExplorer +reaches it through an **ELM327/OBDLink adapter** put into J1850 VPW mode. + +![Reading PIDs, the VIN and trouble codes on a GM Class 2 bus](../assets/screenshots/j1850.png) + +## Connecting + +In the [new-connection dialog](connections.md) choose the **elm327_vpw** interface and give the adapter's serial +port as the channel — a USB port (`COM4`, `/dev/ttyUSB0`), a paired Bluetooth adapter (`/dev/rfcomm0`) or a WiFi +adapter (`socket://192.168.0.10:35000`), exactly as for the +[ELM327 CAN interface](../reference/interfaces.md#elm327-and-obdlink-adapters). The adapter is set to protocol 2 +(SAE J1850 VPW) automatically; the bitrate is fixed at 10.4 kbit/s. + +Each VPW frame appears in the [trace](trace-and-overview.md) as a frame whose extended identifier holds the three +header bytes (priority, target, source) and whose data is the payload, so the dashboard, [filters](filters.md), +[signal plots](signal-plots.md), [reverse engineering](reverse-engineering.md) and +[recording](recording-and-replay.md) all work on a Class 2 bus as on any other. The adapter is half-duplex, so a +frame on the bus while a diagnostic request is being sent is missed — fine for occasional requests, less so when +polling hard. + +## Diagnostics (the J1850 / Class 2 page) + +On an `elm327_vpw` connection a **J1850 / Class 2** page appears, running **SAE J1979** (OBD-II) over the bus +through the one shared adapter (no second connection to the dongle): + +| Button | Service | +|---|---| +| **Read** (PID) | ShowCurrentData (mode 0x01): the chosen value — engine speed, coolant temperature, vehicle speed, load, throttle… — decoded to physical units | +| **Read VIN** | RequestVehicleInformation (mode 0x09) | +| **Read DTCs** | ShowStoredDTCs (mode 0x03): the trouble codes, shown as `P0143`, `P0171`… | +| **Clear DTCs** | ClearDTCs (mode 0x04): clear the stored codes and turn off the MIL | +| **Raw request** | Any J1979 request as hex bytes, e.g. `01 0C`; the response is decoded in the log | + +!!! warning "Clearing codes changes the vehicle" + Clearing trouble codes turns off the malfunction indicator lamp and erases freeze-frame data. Use it on + vehicles you own or are authorised to work on. + +## In scripts + +`can_explorer.core.j1850` drives Class 2 diagnostics from [test scripts](test-automation.md). A `Class2EcuSimulator` +on an in-memory bus allows trying it without a vehicle: + +```python +from can_explorer.core.j1850 import J1850Bus, J1850Client, Class2EcuSimulator + +bus = J1850Bus() # In-memory Class 2 bus (no hardware) +Class2EcuSimulator(bus).start() # A simulated P01/P59 PCM +client = J1850Client(bus.attach()) + +print(await client.current_data(0x0C)) # Engine speed (rpm) +print(await client.read_dtcs()) # ['P0143', 'P0171'] +print((await client.vehicle_info(0x02))) # VIN bytes +``` + +To talk to a real vehicle, swap the in-memory bus for an adapter transport from `can_explorer.core.elm327_vpw`: + +```python +from can_explorer.core.elm327_vpw import Elm327VpwTransport +from can_explorer.core.j1850 import J1850Client + +transport = Elm327VpwTransport('socket://192.168.0.10:35000') +try: + client = J1850Client(transport) + print(await client.current_data(0x0C)) +finally: + transport.close() +``` diff --git a/docs/user-guide/mcp.md b/docs/user-guide/mcp.md index 3d38215..1f14ce2 100644 --- a/docs/user-guide/mcp.md +++ b/docs/user-guide/mcp.md @@ -1,73 +1,262 @@ # MCP interface -`can-mcp` exposes a CAN bus to an AI agent over the [Model Context Protocol](https://modelcontextprotocol.io), -so an MCP client (for example Claude Desktop or an agent you build) can inspect the bus — and, when you allow it, -transmit — through a small set of tools. +CANExplorer speaks the [Model Context Protocol](https://modelcontextprotocol.io), so an AI agent (for example Claude +Desktop, or an agent you build) can inspect a CAN bus — and, when you allow it, run diagnostics or transmit — +through a small set of tools. There are two ways to expose a bus: -It opens one bus with python-can, keeps receiving into a trace, and answers the client's tool calls over stdio. It -is read-only unless started with `--allow-send`. +- the **in-app server**, hosted by the GUI for a connection it already has open, so the agent sees the same live + bus you do — described first below, and +- the **`can-mcp` command**, a standalone process that opens its own bus over stdio. -## Installing and running +Both expose the same tools, [resources](#resources-and-prompts) and [prompts](#resources-and-prompts), and both are +**read-only** until you grant more with a [permission tier](#permission-tiers). -The server needs the `mcp` extra: +## In-app server (attach an agent to the live bus) + +![The AI agent (MCP) dialog started read-only, with the server URL and token](../assets/screenshots/mcp.png) + +The GUI can host the MCP server for a **connection it already has open**, so the agent works on the same live trace +you are watching. This is the way to attach an agent to a bus the GUI is using: most USB/serial adapters are +single-access, so a separate `can-mcp` process cannot open the same channel at the same time. + +**Start it** from a connection's **Trace** toolbar: the **Agent…** button opens the dialog above. Choose the +access tier (read-only by default), press **Start**, and the server binds to `127.0.0.1` on a free port and shows +a **URL** and a **token**. The button turns into **● Agent** while it is running; press it again to stop. The agent +cannot open or close the bus — you manage that from the GUI — but the [permission tiers](#permission-tiers) apply +exactly as for the command. + +**Communicate with it** over the **streamable-HTTP** transport, passing the token on every request as either an +`Authorization: Bearer ` header or a `?token=` query parameter. A request without the right token is +rejected. See [connecting a client](#connecting-a-client) for a config entry and [scripting](#scripting) for a +runnable example. + +!!! warning "A listening server is a bigger surface" + The in-app server is off by default and loopback-only, and the token gates it — but an agent on a `control` or + `send` tier can reset ECUs, clear codes or transmit on your live bus. Start it deliberately, and prefer + read-only unless you need more. + +## The `can-mcp` command (standalone) + +`can-mcp` runs the same server as its own process, talking to one bus with python-can and answering the client's +tool calls over **stdio**. It is installed with CANExplorer (the `mcp` package is a core dependency), so the +`can-mcp` command and the in-app server are available out of the box. + +The simplest form takes no arguments — the agent lists the interfaces and connects to one itself, and loads a +database if it needs one: ```bash -pip install 'can-explorer[mcp]' # or, from source: uv sync --extra mcp +can-mcp ``` -Run it against a bus (read-only), optionally with a database so frames are decoded into named signals: +Everything else is optional. Give a bus up front if you prefer it opened at start: ```bash -can-mcp --interface socketcan --channel can0 --bitrate 500000 --database vehicle.dbc +can-mcp -i socketcan -c can0 # open this bus immediately (read-only) +can-mcp -i socketcan -c can0 -d vehicle.dbc # …and decode frames into named signals ``` -Add `--allow-send` to let the agent transmit. See [`can-mcp`](../reference/command-line.md#can-mcp) for all options. +Either way the `connect(interface, channel)` and `disconnect` tools are always available, so the agent can open or +switch buses at any time, and `load_database` / `unload_database` load or clear a database at runtime. (Omitting +`--interface` and `--channel` just means the server starts unconnected; give both, or neither.) + +Add a permission flag to allow more — for example `--allow-diagnostics` for OBD-II and UDS reads, or `--allow-send` +for full transmit. See [`can-mcp`](../reference/command-line.md#can-mcp) for all options. -!!! warning "Sending is off by default" - Without `--allow-send`, the agent can only read the bus; the `send_frame` and `query_obd` tools are not even - offered. Enable sending only on a bus you are allowed to transmit on, and remember that the agent then controls - what is sent. +!!! warning "Reads are the default; grant the rest deliberately" + Without a permission flag the agent can only read the bus. Grant `--allow-diagnostics`, `--allow-control` or + `--allow-send` only on a bus you are allowed to use that way, and remember the agent then decides what is sent + — a `--allow-control` or `--allow-send` server can reset ECUs, clear trouble codes or transmit arbitrary + frames. ## Connecting a client -Point your MCP client at the command. For a client that launches stdio servers from a config file, the entry is -roughly: +**A stdio client** (the common case) launches `can-mcp` itself from a config file. The simplest entry is just the +command; the agent connects and picks a bus: + +```json +{ + "mcpServers": { + "canexplorer": { "command": "can-mcp" } + } +} +``` + +To pin a bus (and a database, and permissions), add them as `args`: ```json { "mcpServers": { "canexplorer": { "command": "can-mcp", - "args": ["--interface", "socketcan", "--channel", "can0", "--database", "/path/vehicle.dbc"] + "args": ["-i", "socketcan", "-c", "can0", "-d", "/path/vehicle.dbc", "--allow-diagnostics"] } } } ``` +**The in-app server** is already running, so a client attaches to its URL over streamable-HTTP instead of launching +anything. For a client that reads an HTTP MCP server from config, point it at the URL from the **Agent…** dialog and +send the token as a Bearer header: + +```json +{ + "mcpServers": { + "canexplorer-live": { + "url": "http://127.0.0.1:57205/mcp", + "headers": { "Authorization": "Bearer WWPkEFJO7GXuAQJR1tCarQ" } + } + } +} +``` + +(The port and token change each time you start the server — copy them from the dialog. A client that cannot set +headers can instead use the URL with `?token=WWPkEFJO7GXuAQJR1tCarQ` appended.) + +## Scripting + +Any MCP client library works; the examples below use the official [`mcp`](https://pypi.org/project/mcp/) Python SDK, +which CANExplorer already depends on. + +**Against the in-app server** (streamable-HTTP + token). Copy the URL and token from the **Agent…** dialog: + +```python +import asyncio + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +URL = "http://127.0.0.1:57205/mcp" # from the Agent… dialog +TOKEN = "WWPkEFJO7GXuAQJR1tCarQ" # from the Agent… dialog + + +async def main(): + headers = {"Authorization": f"Bearer {TOKEN}"} + async with streamablehttp_client(URL, headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + + tools = await session.list_tools() + print("tools:", [tool.name for tool in tools.tools]) + + status = await session.call_tool("bus_status", {}) + print(status.content[0].text) + + # Every identifier seen, with direction, count and cycle time + identifiers = await session.call_tool("list_identifiers", {}) + print(identifiers.content[0].text) + + # Reverse-engineer one identifier's fields + analysis = await session.call_tool("analyze_identifier", {"identifier": "1A0"}) + print(analysis.content[0].text) + + # Resources can be read directly, like documents + resource = await session.read_resource("can://status") + print(resource.contents[0].text) + + +asyncio.run(main()) +``` + +**Against `can-mcp` over stdio** — the client launches the process (here on a virtual bus, with diagnostics +allowed): + +```python +import asyncio + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + + +async def main(): + params = StdioServerParameters( + command="can-mcp", + args=["-i", "virtual", "-c", "test", "--allow-diagnostics"], + ) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Read the vehicle's stored trouble codes over OBD-II (needs --allow-diagnostics) + dtcs = await session.call_tool("obd_dtcs", {}) + print(dtcs.content[0].text) + + +asyncio.run(main()) +``` + +A tool call returns a result whose `content` is a list of parts; the tools here answer with text (often JSON), read +from `result.content[0].text`. A tool that is not enabled by the current tier is not offered, so calling it fails — +grant the tier it needs first. + +## Permission tiers + +The server is read-only by default. Each flag (or each dialog tier) grants the one below it as well, so +`--allow-send` / the `send` tier enables everything: + +| Flag / tier | Adds | Tools | +|---|---|---| +| *(none)* / `read-only` | Connecting, inspection and reverse engineering (reads files, sends nothing) | `bus_status`, `list_interfaces`, `connect`, `disconnect`, `list_identifiers`, `get_frames`, `decode_frame`, `decode_protocol`, `timing`, `analyze_identifier`, `bit_activity`, `extract_field`, `related_signals`, `capture_reaction`, `mark`, `clear_markers`, `reaction_at_markers`, `correlate`, `promote_to_dbc`, `load_database`, `unload_database` | +| `--allow-diagnostics` / `diagnostics` | Safe diagnostic **reads** (they put a request on the bus) | `query_obd`, `obd_status`, `obd_dtcs`, `obd_vin`, `obd_supported`, `obd_read`, `uds_read_did`, `uds_read_dtcs` | +| `--allow-control` / `control` | **Active** diagnostics (changes ECU state) | `obd_clear`, `uds_reset`, `uds_routine`, `uds_request` | +| `--allow-send` / `send` | Arbitrary transmit | `send_frame`, `send_message` | + +A tool that is not enabled is not even offered to the agent, so it cannot be called. + ## Tools +**Inspection and reverse engineering** (always available): + | Tool | Purpose | |---|---| -| `bus_status` | Bus state, loaded database, frame counts, rates and bus load | +| `bus_status` | Connection state, loaded database, enabled capabilities, frame counts, rates and bus load | +| `list_interfaces` | The CAN interfaces python-can can detect on this machine | +| `connect` / `disconnect` | Open a bus (interface, channel) and start receiving, or close it | | `list_identifiers` | Every identifier seen, with direction, count, cycle time, last data and database name | | `get_frames` | Recent frames (optionally of one identifier), decoded into signals | | `decode_frame` | Decode a payload you supply with the loaded database (sends nothing) | -| `analyze_identifier` | Automatic field analysis of an identifier (checksums, counters, multiplexers, physical values, states, flags) | -| `correlate` | Rank fields by how well they follow a reference — a database signal, a CSV column, or an inline time series — with the fitted factor, offset and lag | -| `promote_to_dbc` | Write the found fields (of one identifier, or all) as signals to a DBC, creating their messages | -| `load_database` | Load a DBC/ARXML/KCD/SYM/DBF for decoding | -| `send_frame` | Send one frame — only with `--allow-send` | -| `query_obd` | Request one OBD-II value (service 01) and return the decoded answer — only with `--allow-send` | - -Identifiers are hex strings such as `1A0`. `analyze_identifier`, `correlate` and `promote_to_dbc` are the same -engine as the [Reverse engineering](reverse-engineering.md) view, so an agent can run a full loop over MCP: survey -the bus, analyse an identifier, match a field to a reference (a GPS/CSV log or an OBD-II value it polled), and write -the result to a DBC — then load that DBC and read the signals back decoded. `correlate` and `promote_to_dbc` read -and (for the DBC) write files but do not transmit, so they are available without `--allow-send`; `promote_to_dbc` -extends an existing database at the path rather than overwriting it. +| `decode_protocol` | Interpret a payload with a protocol decoder (J1939, NMEA 2000, CANopen, ISO-TP, UDS, LIN, J1587) | +| `timing` | Inter-frame timing per identifier: cycle time, jitter, periodic vs event-triggered (`series` for the Δt over time) | +| `analyze_identifier` | Automatic field analysis (checksums, counters, multiplexers, physical values, states, flags) | +| `bit_activity` | Raw per-bit behaviour of one identifier (how often each bit is set and flips) and a per-byte summary | +| `extract_field` | Decode a candidate bit range (byte order, sign, scaling) over time without a database, to test a hypothesis | +| `related_signals` | Groups of fields that carry the same signal across the bus | +| `capture_reaction` | Diff a recent action window against the baseline before it to find what changed (a single action) | +| `mark` / `clear_markers` / `reaction_at_markers` | Mark the moments of a repeated action, then find the fields that react at them | +| `correlate` | Rank fields by how well they follow a reference (a database signal, a CSV column, or an inline series) | +| `promote_to_dbc` | Write the found fields — or field specs you supply (`fields=[…]`) — as signals to a DBC, creating their messages | +| `load_database` / `unload_database` | Load a DBC/ARXML/KCD/SYM/DBF for decoding, or forget it (back to raw bytes) | + +**Diagnostics** (`--allow-diagnostics`; the control tools need `--allow-control`): + +| Tool | Purpose | +|---|---| +| `query_obd` | One OBD-II value (service 01) via a single-frame request | +| `obd_status` / `obd_vin` | MIL state and DTC count; the VIN | +| `obd_dtcs` | Stored (service 03) or pending (service 07) trouble codes, decoded to P/C/B/U | +| `obd_supported` / `obd_read` | The supported PIDs; read and decode live values | +| `uds_read_did` / `uds_read_dtcs` | Read a UDS data identifier (0x22) or trouble codes (0x19) | +| `obd_clear` | Clear trouble codes and the MIL (service 04) — `--allow-control` | +| `uds_reset` / `uds_routine` / `uds_request` | ECU reset, routine control, or a raw UDS request — `--allow-control` | + +**Transmit** (`--allow-send`): `send_frame` (a raw frame) and `send_message` (a database message by name, from its +signal values). + +Identifiers are hex strings such as `1A0`. The reverse-engineering tools are the same engine as the +[Reverse engineering](reverse-engineering.md) view, so an agent can run a full loop over MCP: survey the bus with +`timing` and `list_identifiers`, `analyze_identifier`, match a field to a reference with `correlate` or group them +with `related_signals`, and `promote_to_dbc` — then `load_database` and read the signals back decoded. The +diagnostics tools let it read and explain a vehicle's trouble codes over OBD-II or UDS. + +## Resources and prompts + +The server also exposes the bus as read-only MCP **resources**, so a client can browse (or `read_resource`) them +like documents: `can://status`, `can://identifiers`, `can://frames` and `can://database`. + +And it ships guided **prompts** for the common workflows: `reverse_engineer_identifier`, `diagnose_trouble_codes` +and `find_signal`. ## How it relates to the rest of CANExplorer -The server is a separate command, like [`can-server`](remote-buses.md); it does not need the GUI. It works with any +The `can-mcp` command is separate, like [`can-server`](remote-buses.md); it does not need the GUI. It works with any python-can interface, so it can also sit on top of a [remote bus](remote-buses.md) (`--interface canexplorer_remote`) -shared by `can-server` on another machine. +shared by `can-server` on another machine — another way to share one bus between the GUI and an agent. diff --git a/docs/user-guide/obd.md b/docs/user-guide/obd.md new file mode 100644 index 0000000..2cbd81f --- /dev/null +++ b/docs/user-guide/obd.md @@ -0,0 +1,94 @@ +# OBD-II + +The **OBD-II** page reads a vehicle's emissions diagnostics (SAE J1979 over ISO 15765-4): the live values the ECU +publishes, its trouble codes, and the VIN. It is the standard, manufacturer-independent interface every car built +since the mid-2000s exposes on its OBD-II port — a 500 kbit/s CAN bus. + +![The OBD-II page: live value tiles, the VIN and MIL/DTC status, and the stored and pending trouble codes](../assets/screenshots/obd.png) + +The page appears for CAN connections that can transmit (it sends requests). Connect to the vehicle, then press +**Scan**. + +## Connecting + +Requests go to **7DF**, the OBD-II functional broadcast that every ECU answers, and answers come from **7E8** (the +first ECU). If a vehicle does not respond to the broadcast, address one ECU directly by setting the request and +response identifiers to **7E0** and **7E8** (7E1/7E9 for the second ECU, and so on). + +**Scan** reads: + +- the **VIN** (service 09), +- the **MIL** (the "check engine" lamp) and the number of stored trouble codes (service 01 PID 01), and +- the **supported values** — the PIDs the ECU offers (service 01 PID 00/20/…), of those CANExplorer can decode. + +## Live data + +**Live** polls the supported values continuously and shows each as a tile — engine speed, vehicle speed, coolant +and intake temperatures, engine load, throttle position, fuel level, control-module voltage and more, in their +physical units. **Stop** ends the polling. + +The same values can be recorded as references for reverse engineering from the [Reference signals](reverse-engineering.md#reference-signals) +tab, to match them to the raw traffic. + +## Trouble codes + +**Read codes** reads the **stored** codes (service 03, the ones that lit the MIL) and the **pending** codes +(service 07, seen once but not yet confirmed), decoded to their `P`/`C`/`B`/`U` form — for example `P0133`. + +**Clear codes** sends service 04: it clears the stored codes and turns the MIL off. As the standard intends, this +also clears freeze-frame data and resets the readiness monitors, so the car may need a drive cycle before it is +ready to pass an emissions test again. + +## Scripting + +The same OBD-II client is available as a library, so you can read a vehicle from a script without the GUI. Open a +bus with python-can, wrap it in the ISO-TP and UDS transports, and call `ObdClient` — its methods are coroutines, so +run them on an event loop: + +```python +import asyncio + +from can_explorer.core.obd import ObdClient, FUNCTIONAL_REQUEST, ECU_RESPONSE +from can_explorer.core.isotp_client import IsoTpClient +from can_explorer.core.session import BusSession +from can_explorer.core.sources import BusConfig, LiveSource +from can_explorer.core.uds import UdsClient + + +async def main(): + # Open the OBD-II bus (a 500 kbit/s CAN bus); use the interface and channel of your adapter + session = BusSession(LiveSource(BusConfig("socketcan", "can0"))) + session.start() + # Requests go to the functional address 7DF, answers come from 7E8 (the first ECU) + obd = ObdClient(UdsClient(IsoTpClient(session, FUNCTIONAL_REQUEST, ECU_RESPONSE))) + + print("VIN:", await obd.read_vin()) + status = await obd.status() + print("MIL on:", status.mil_on, "stored DTCs:", status.dtc_count) + + for pid in await obd.supported_pids(): # The PIDs this ECU offers + value = await obd.read_value(pid) + if value is not None: + print(f" {value.name}: {value.value:g} {value.unit}") + + print("stored:", [dtc.code for dtc in await obd.read_dtcs()]) + print("pending:", [dtc.code for dtc in await obd.read_dtcs(pending=True)]) + session.stop() + + +asyncio.run(main()) +``` + +To address a single ECU instead of the broadcast, pass its identifiers — `IsoTpClient(session, 0x7E0, 0x7E8)`. +`ObdClient` methods raise `UdsError` (or `UdsNegativeResponse`) when a request fails, and `clear_dtcs()` sends +service 04. An AI agent can do the same over the [MCP interface](mcp.md) with the `obd_*` tools (grant +`--allow-diagnostics`). + +## Limits + +- Only the common service-01 PIDs are decoded into values; the rest of the (large) PID set and manufacturer-specific + PIDs are not built in. +- Freeze frame (service 02), on-board monitor results (service 06) and the other service-09 items beyond the VIN + are not read. +- OBD-II runs over CAN; the page is not shown for LIN connections. For deeper, manufacturer-specific diagnostics + use the [UDS diagnostics](diagnostics.md) page. diff --git a/docs/user-guide/protocols.md b/docs/user-guide/protocols.md index a848048..df0154d 100644 --- a/docs/user-guide/protocols.md +++ b/docs/user-guide/protocols.md @@ -9,8 +9,10 @@ frame in arrival order, so multi-frame transfers are reassembled. | ISO-TP | ISO 15765-2 single, first, consecutive and flow control frames; reassembled payloads | | UDS | ISO-TP, plus the UDS services in the reassembled payloads | | J1939 | Priority, PGN with names of common PGNs, source and destination address, requests, address claims with the NAME, DM1/DM2 lamps and trouble codes, transport protocol (BAM and RTS/CTS) with reassembly; see also the [J1939 view](j1939.md) | +| NMEA 2000 | The marine protocol, built on J1939: PGN with names of common marine PGNs, source address, Fast Packet reassembly, and fields of the common PGNs (engine, position, COG/SOG, heading, wind, depth, speed, battery) in marine units | | CANopen | NMT, SYNC, EMCY, TIME, PDOs, SDO requests and responses, heartbeat and node guarding; with an object dictionary loaded in the [CANopen view](canopen.md), SDO object names and PDO values | | LIN | Headers and responses, diagnostic frames, see [LIN](lin.md) | +| J1587 | SAE J1587 messages (MID and PIDs) of the older heavy-truck bus, with common parameters scaled. J1587 runs over J1708, a 9600-baud serial bus (**not** CAN), so this decodes J1587 message bytes from a serial log or a gateway, not a live J1708 capture | !!! tip "Choose the protocol of the traffic" ISO-TP and UDS interpret the first byte of every frame as a transport protocol header. On a bus that also @@ -22,8 +24,9 @@ Examples: ```text ISO-TP complete (20 B): 62 F1 90 57 44 42 … | UDS Positive response to ReadDataByIdentifier: F1 90 57 44 42 … PGN 0x0FECA DM1 SA=0x00 DA=0xFF P6: TP.DT #2: complete PGN 0x0FECA DM1 (10 B): 01 02 03 … +PGN 127488 Engine Parameters, Rapid Update SA=35 P2: engine 0: 2400 rpm SDO response node 5: read response 0x1018:01 = 78 56 34 12 -Heartbeat node 5: Operational +MID 128 Engine #1, PID 84 Road Speed 60 mph, PID 190 Engine Speed 1500 rpm ``` With a [diagnostic description](diagnostics.md#diagnostic-descriptions-cdd-odxpdx), UDS (and LIN diagnostics) decode @@ -33,4 +36,7 @@ ReadDataByIdentifier data into fields. - ISO-TP: normal addressing; the extended and mixed addressing formats are not decoded. - J1939: names are known for common PGNs and SPNs; signals of J1939 messages are decoded with a DBC. +- NMEA 2000: names for common PGNs, fields for the most common ones; the full PGN catalogue is not built in. - CANopen: the predefined connection set (default COB-IDs). +- J1587: a subset of MIDs and PIDs is named and the well-known parameters are scaled; others are shown as raw + bytes. There is no live J1708 serial capture — the decoder works on J1587 message bytes. diff --git a/docs/user-guide/reverse-engineering.md b/docs/user-guide/reverse-engineering.md index 12cd104..64bd247 100644 --- a/docs/user-guide/reverse-engineering.md +++ b/docs/user-guide/reverse-engineering.md @@ -15,11 +15,13 @@ Its tabs: | Tab | Purpose | |---|---| | **Bits** | The bits and bytes of an identifier, the notch, and the bit field form | +| **Activity** | A raster of every bit of one identifier across the whole capture, to see its structure at a glance | | **Automatic analysis** | Checksums, counters, multiplexers, values, states and flags of every identifier, found automatically | | **Reference signals** | The fields following a known value: a database signal, a CSV file (e.g. GPS) or OBD-II values | | **Markers** | The bits changing each time you mark that something happened | | **Related signals** | Fields carrying the same signal on several messages, grouped | | **Reaction** | Diff a baseline against an action to find what a control or an event changes on the bus | +| **Console** | An interactive Python console over the captured frames, with the reverse-engineering primitives | The view works on live buses and on replayed log files. The bit statistics start when it is first opened, including the frames already in the trace, and follow the bus from then on; **Clear** in the trace toolbar and @@ -57,6 +59,36 @@ fade — so you can watch the data flow and catch exactly when and how bits move Leave replay (toggle **Replay** off, or select another identifier) to return the grid to the live, latest frame. +## Activity map + +The **Activity** tab shows a bit-transition raster of the selected identifier: every bit of its payload across the +whole capture at once, so its structure jumps out where the frame-at-a-time grid and replay cannot show it. Each +**row is a bit** (byte 0 at the top, the most significant bit of each byte first) and each **column is a frame** +over time; **Refresh** rebuilds it from the frames captured so far. + +A cell is blue for a steady 1, dark for a steady 0, and orange where the bit changed. The patterns name themselves: + +- a **counter** ramps as diagonal stripes, its low bits busier than its high bits; +- a **constant** byte is flat (dark or blue); +- a **physical value** drifts, its high bits slow and its low bits noisy; +- a **toggle** (a flag, a blinker) is a column of orange that comes and goes; +- a **multiplexer** switches whole regions of the payload on and off. + +Read off which bytes carry something and what kind, then switch to **Bits** to define the field or **Automatic +analysis** to have it found. + +## Timing graphs + +Two buttons in the Bits toolbar plot the selected identifier on the [Signals](signal-plots.md) page, from the +frames in the trace so far on: + +- **Plot Δt** graphs the **inter-frame interval** (the time between consecutive frames, in milliseconds) over + time. A flat line is a cyclic message; steps and spikes reveal gateways, bursts and event-triggered messages + (a frame sent only when something happens). The cursors, statistics (min/max/mean interval) and CSV export of + the signal plots all apply. +- **Plot bytes** graphs **each payload byte** as a value over time, one chart per byte — a quick look at which + bytes move and how, without defining a bit field first. + ## Finding the bits of an action: the notch 1. Let the traffic run for a while without doing anything, so that the bits that change by themselves are @@ -232,6 +264,30 @@ Having found the controlling message, confirm it by replaying that captured fram ![Capture reaction: a new identifier, a stepped nibble and a flipped bit found by diffing a baseline against an action](../assets/screenshots/reverse-reaction.png) +## Scripting console + +The **Console** tab is an interactive Python console over the frames captured so far — an ad-hoc frame processor +or field hunt with the same primitives the tools use, for when a point-and-click tool does not cut it. Type a +snippet in the syntax-highlighted editor and press **Ctrl+Enter** (or **Run**); the value of a trailing expression +is shown like a Python REPL, `print()` output is captured, and errors are reported instead of raised. Variables persist between +runs; **Clear** empties the transcript and forgets them. Type `HELP` for the available names. + +In scope: + +- `messages` — the captured frames (`can.Message`); `frames` — `collect(messages)`, keyed by `(id, extended)`; + `analyses` and `fields` — the results of the **Automatic analysis** tab. +- Helpers `ids()` and `frames_of(id)` (the identifier as an int or a hex string). +- The `can_explorer.core.reverse` primitives: `collect`, `analyse`, `analyse_all`, `BitField`, `candidates`, + `signal_groups`, `correlate` and `read_reference_csv`. + +For example, `[(f.field.key, f.kind) for f in analyse(frames_of('100')).fields]` lists the fields found in `100`, +and `{hex(i): len(f.payloads) for (i, e), f in frames.items()}` counts the frames of each identifier. This is the +same scriptable surface the [MCP interface](mcp.md) exposes to an AI agent, at the keyboard. + +!!! note + The console runs Python in the application's own process — it is not a sandbox. It is for exploring your own + capture, so do not paste scripts you do not trust. + ## Multiplexed fields A field found for one multiplexer value carries a condition: the bit field form shows *only if diff --git a/docs/user-guide/someip.md b/docs/user-guide/someip.md index b89466a..e556c0a 100644 --- a/docs/user-guide/someip.md +++ b/docs/user-guide/someip.md @@ -33,16 +33,26 @@ port), the **Service.Method** ID, the message **Type**, the payload length and a - **SOME/IP-SD** messages (service `FFFF`, method `8100`) are expanded into their entries: OfferService, FindService, SubscribeEventgroup and the acknowledgements, with the service, version, endpoints and TTL. -## Decoding payloads (FIBEX) +## Decoding payloads (FIBEX / ARXML) -Load a **FIBEX** file (**FIBEX…**) describing the SOME/IP service interfaces to decode the message payloads into -named signals: the **Description** column then shows the decoded values (for example `speed=4000` or -`status.flags=3, status.temp=-15`) instead of the payload length. A method's request and response are decoded with -their own parameters. +Load a **FIBEX** or AUTOSAR **ARXML** file (**FIBEX / ARXML…**) describing the SOME/IP service interfaces to decode +the message payloads into named signals: the **Description** column then shows the decoded values (for example +`speed=4000` or `status.flags=3, status.temp=-15`) instead of the payload length. A method's request and response +are decoded with their own parameters. The format is chosen by the file extension (`.arxml` for ARXML, otherwise +FIBEX). -A subset of FIBEX 4 is read: service interfaces with their methods (input and return parameters) and events, and -the data types they use — scalars, fixed and dynamic arrays, length-prefixed and fixed strings, and nested -structures. Unions, optional (TLV) members and bit fields are not read; a message whose type uses one keeps its -generic description. ARXML SOME/IP service interfaces are a different format and are not read here. +A subset of each is read: service interfaces with their methods (input and return parameters) and events, and the +data types they use — scalars, fixed and dynamic arrays, length-prefixed and fixed strings, and nested structures. +From ARXML the SOME/IP service/method/event ids come from the `SOMEIP-SERVICE-INTERFACE-DEPLOYMENT` elements, which +reference the service interfaces. Unions, optional (TLV) members and bit fields are not read; a message whose type +uses one keeps its generic description. -SOME/IP over TCP is not decoded yet; the tool reads UDP. +The live SOME/IP tool reads UDP. SOME/IP carried over **TCP** (reassembled across segments) and **SOME/IP-TP** +segmented messages are decoded when you open a capture in the [Ethernet capture](ethernet.md) tool. + +## End-to-end protection (E2E) + +AUTOSAR **E2E** protection (the CRC and sequence counter that guard a PDU against corruption, loss, repetition and +reordering) is available as a library for scripts — `can_explorer.core.e2e` provides the AUTOSAR CRC routines +(`crc8`, `crc8h2f`, `crc16`, `crc32`, `crc32p4`) and E2E **Profile 2** protect/check. Validating a live PDU needs +its E2E configuration (profile, Data IDs, offset) from the ARXML, which the UI does not read yet. diff --git a/examples/simulation/engine_simulation.py b/examples/simulation/engine_simulation.py index c10fa00..defe851 100644 --- a/examples/simulation/engine_simulation.py +++ b/examples/simulation/engine_simulation.py @@ -7,6 +7,10 @@ from can_explorer.scripting import Node, on_message, on_signal, on_start, on_stop, on_variable IDLE_SPEED = 800 +AMBIENT_TEMP = 20.0 # Coolant temperature with the engine cold or off +IDLE_TEMP = 75.0 # Coolant temperature once warm at idle +MAX_TEMP = 95.0 # Coolant temperature under full load +RATED_SPEED = 3600 # Speed at which the coolant reaches MAX_TEMP class Engine(Node): @@ -16,7 +20,7 @@ class Engine(Node): def start(self): self.status = self.message('EngineData') self.speed = 0.0 - self.temperature = 20.0 + self.temperature = AMBIENT_TEMP self.every(0.1, self.update) def update(self): @@ -25,8 +29,15 @@ def update(self): if running: target = max(target, IDLE_SPEED) self.speed += (target - self.speed) * 0.2 # Follow the target smoothly + # Coolant moves toward an operating temperature that rises with engine power, and toward ambient when the + # engine is off; it lags, warming up faster than it cools down, so it falls off at idle or when switched off if running: - self.temperature = min(90.0, self.temperature + 0.2) + load = (self.speed - IDLE_SPEED) / (RATED_SPEED - IDLE_SPEED) + target_temp = IDLE_TEMP + (MAX_TEMP - IDLE_TEMP) * max(0.0, min(1.0, load)) + else: + target_temp = AMBIENT_TEMP + rate = 0.03 if target_temp > self.temperature else 0.01 # Warms quicker than it cools + self.temperature += (target_temp - self.temperature) * rate self.status.EngineSpeed = round(self.speed) self.status.CoolantTemp = round(self.temperature) self.status.GearPosition = self.variables.get('gear', 'Neutral') # Set by the Gearbox node diff --git a/mkdocs.yml b/mkdocs.yml index c42e183..c644c27 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,12 +60,15 @@ nav: - Transmitting: user-guide/transmitting.md - Protocol decoding: user-guide/protocols.md - Diagnostics (UDS): user-guide/diagnostics.md + - OBD-II: user-guide/obd.md - Flashing: user-guide/flashing.md - UDS discovery: user-guide/uds-discovery.md - XCP/CCP: user-guide/xcp.md - GMLAN: user-guide/gmlan.md + - J1850 / Class 2: user-guide/j1850.md - DoIP: user-guide/doip.md - SOME/IP: user-guide/someip.md + - Ethernet capture: user-guide/ethernet.md - J1939: user-guide/j1939.md - CANopen: user-guide/canopen.md - EtherCAT (CoE): user-guide/ethercat.md @@ -144,6 +147,9 @@ plugins: extra_css: - stylesheets/dracula.css +extra_javascript: + - javascripts/releases.js + extra: social: - icon: fontawesome/brands/github diff --git a/packaging/build.py b/packaging/build.py index 8ca8dae..7d8b5e9 100644 --- a/packaging/build.py +++ b/packaging/build.py @@ -40,8 +40,9 @@ # Packages whose data files are read at runtime: the LDF grammar and the lark grammars it imports DATA_PACKAGES = ('ldfparser', 'lark') # python-can imports its interface backends by name at runtime, and pyserial its URL handlers (socket:// etc.), so -# they are not found by import analysis -DYNAMIC_PACKAGES = ('can.interfaces', 'serial.urlhandler') +# they are not found by import analysis. uvicorn/h11 (the in-app MCP Agent server's HTTP transport) load their +# protocol and loop implementations by name too, so they are force-included for the frozen build. +DYNAMIC_PACKAGES = ('can.interfaces', 'serial.urlhandler', 'uvicorn', 'h11') # python-can backends that load their vendor driver when imported, so --collect-submodules skips them on a build machine # without that driver; named explicitly, they are bundled and load the driver on the user's machine DRIVER_MODULES = ( diff --git a/packaging/io.github.akj7.CANExplorer.metainfo.xml b/packaging/io.github.akj7.CANExplorer.metainfo.xml new file mode 100644 index 0000000..800cd1f --- /dev/null +++ b/packaging/io.github.akj7.CANExplorer.metainfo.xml @@ -0,0 +1,88 @@ + + + + io.github.akj7.CANExplorer + CC0-1.0 + MIT + CANExplorer + Monitor, analyse, diagnose and simulate CAN, CAN FD and LIN buses + + AKJ7 + + + +

+ CANExplorer is a cross-platform workbench for CAN, CAN FD and LIN buses, for engineers, tuners and + hobbyists working on vehicles and embedded networks. It works with any python-can adapter, or on a virtual + bus with no hardware at all. +

+

What it does:

+
    +
  • Live trace and dashboard with decoded signals (DBC, DBF, ARXML, KCD, SYM, LDF), frame rates, bus load + and the busiest identifiers, handling millions of frames.
  • +
  • Signal plots with cursors and CSV export, and bus health: error states, TEC/REC and load over time.
  • +
  • Recording and replay in ASC, BLF, CSV, candump LOG, PEAK TRC, SQLite, ASAM MF4 and gzip, with + triggered and split recording, replay onto a bus, and recording comparison.
  • +
  • Reverse engineering of an unknown bus: bit and byte statistics, automatic detection of checksums, + counters, multiplexers and values, reference signals, and a scripting console.
  • +
  • Diagnostics and protocols: UDS over ISO-TP, ECU flashing, DoIP, J1939, CANopen, XCP/CCP, GMLAN and + GM Class 2 (SAE J1850 VPW), with simulated ECUs to try without hardware.
  • +
  • A signal database editor for CAN and LIN, and conversion between the formats.
  • +
  • Simulated nodes with control panels, and test automation with HTML and JUnit reports for CI.
  • +
  • Remote buses, MQTT publishing, six colour themes and a command palette.
  • +
+
+ + canexplorer.desktop + + + + The dashboard: frame rates, bus load, error frames and the busiest identifiers + https://akj7.github.io/CANExplorer/assets/screenshots/dashboard.png + + + The trace and per-identifier overview with decoded signals and changed bytes highlighted + https://akj7.github.io/CANExplorer/assets/screenshots/trace.png + + + Reverse engineering: bit and byte statistics with automatically detected fields + https://akj7.github.io/CANExplorer/assets/screenshots/reverse-engineering.png + + + Signal plots with cursors, statistics and CSV export + https://akj7.github.io/CANExplorer/assets/screenshots/signals.png + + + UDS diagnostics over ISO-TP against an ECU + https://akj7.github.io/CANExplorer/assets/screenshots/diagnostics.png + + + The signal database editor with a bit-layout view + https://akj7.github.io/CANExplorer/assets/screenshots/database-editor.png + + + + https://akj7.github.io/CANExplorer/ + https://github.com/AKJ7/CANExplorer/issues + https://github.com/AKJ7/CANExplorer + + + Development + Electronics + Engineering + + + + CAN + CAN FD + LIN + automotive + UDS + DBC + OBD + + + +
diff --git a/pyproject.toml b/pyproject.toml index 3ded379..7275733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "can-explorer" -version = "1.5.0" +version = "1.6.0" description = "Monitor, analyse, diagnose, simulate and test CAN, CAN FD and LIN buses" authors = [{ name = "AKJ7", email = "akj123429@gmail.com" }] license = "MIT" @@ -38,6 +38,8 @@ dependencies = [ "aiohttp", "click", "ldfparser==0.26.0", # Pinned: pre-1.0, minor versions may break the API + # MCP server (can-mcp and the in-app Agent server), its HTTP transport brings uvicorn and starlette + "mcp>=2", ] [project.urls] @@ -61,6 +63,8 @@ canexplorer_remote = "can_explorer.core.remote:RemoteBus" gvret = "can_explorer.core.gvret:GvretBus" # ELM327 and OBDLink OBD adapters (USB, Bluetooth, WiFi), usable as python-can interface "elm327" elm327 = "can_explorer.core.elm327:Elm327Bus" +# GM Class 2 / SAE J1850 VPW through an ELM327 adapter, usable as python-can interface "elm327_vpw" +elm327_vpw = "can_explorer.core.elm327_vpw:Elm327VpwBus" # J2534 pass-thru devices (SAE J2534-1 04.04 drivers), usable as python-can interface "j2534" j2534 = "can_explorer.core.j2534:J2534Bus" @@ -85,10 +89,6 @@ drivers = [ mqtt = [ "aiomqtt>=2.3", ] -# MCP server (can-mcp): expose a CAN bus to an agent over the Model Context Protocol -mcp = [ - "mcp>=2", -] [dependency-groups] dev = [ diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 215f7c2..bbbfdf4 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -8,9 +8,21 @@ name: canexplorer title: CANExplorer summary: Monitor, analyse, diagnose and simulate CAN, CAN FD and LIN buses description: | - CANExplorer monitors, analyses, diagnoses and simulates CAN, CAN FD and LIN buses: a live trace with decoded - signals (DBC, DBF, ARXML, KCD, SYM, LDF), signal plots, recording and replay, bus health, UDS diagnostics and - flashing, J1939 and CANopen tools, simulated nodes with control panels, test automation, remote buses and MQTT. + CANExplorer is a cross-platform workbench for CAN, CAN FD and LIN buses — for engineers, tuners and hobbyists + working on vehicles and embedded networks. It works with any python-can adapter, or on a virtual bus with no + hardware at all. + + * Live trace and dashboard with decoded signals (DBC, DBF, ARXML, KCD, SYM, LDF), frame rates, bus load and the + busiest identifiers, handling millions of frames. + * Signal plots with cursors and CSV export, and bus health: error states, TEC/REC and load over time. + * Recording and replay in ASC, BLF, CSV, candump LOG, PEAK TRC, SQLite, ASAM MF4 and gzip, with triggered and + split recording, replay onto a bus, and recording comparison. + * Reverse engineering of an unknown bus: bit and byte statistics, automatic detection of checksums, counters, + multiplexers and values, reference signals, and a scripting console. + * Diagnostics and protocols: UDS over ISO-TP, ECU flashing, DoIP, J1939, CANopen, XCP/CCP, GMLAN and GM Class 2 + (SAE J1850 VPW), with simulated ECUs to try without hardware. + * A signal database editor for CAN and LIN, simulated nodes with control panels, test automation with HTML and + JUnit reports, remote buses and MQTT publishing. Connect the interfaces for your hardware once after installing: @@ -49,12 +61,17 @@ parts: canexplorer: plugin: nil source: dist/CANExplorer + # Adopt the screenshots (and other metadata not set above) from the AppStream metainfo file + parse-info: [usr/share/metainfo/io.github.akj7.CANExplorer.metainfo.xml] override-pull: | craftctl default craftctl set version="$(sed -n 's/^version = "\(.*\)"$/\1/p' "$CRAFT_PROJECT_DIR/pyproject.toml" | head -n 1)" override-build: | mkdir -p "$CRAFT_PART_INSTALL/opt/canexplorer" cp -a "$CRAFT_PART_SRC/." "$CRAFT_PART_INSTALL/opt/canexplorer/" + mkdir -p "$CRAFT_PART_INSTALL/usr/share/metainfo" + cp "$CRAFT_PROJECT_DIR/packaging/io.github.akj7.CANExplorer.metainfo.xml" \ + "$CRAFT_PART_INSTALL/usr/share/metainfo/" # Libraries of Qt's xcb platform plugin and of the bundled Python that the gnome extension's runtime lacks stage-packages: - libxcb-cursor0 diff --git a/tests/data/automotive.pcap b/tests/data/automotive.pcap new file mode 100644 index 0000000000000000000000000000000000000000..16711c8bbfe1047fd834907c49ef7990688c71fc GIT binary patch literal 570 zcmbV~Jx;?w5QX2ae=s&A4x&J=f(1fCfTEBJQMjQ+LNr`}BOtd%qDV_eK>;0tj+O#M zT1pO(8<12~v6;7Cg`jkd^wv8&GvBOc{W*BP*KqM`E)<>d={Y|KR+^4Bs|s)g?C>CK z0dEfozW|jdd4e^dsxBD{xF{tRLW;M5L-DCO(jGG{X_d6WNxq8&Sn075LdKOU(7Yq7 zeGGKvgqmY*FxRp?VX3EK?+meIPT(;{LgxYyh>_>DevNktFIbU9Ou>tLD w)>CrRk@S))X~*^ag + + + + + Datatypes + + + uint8_t + 8 + NONE + + + uint16_t + 16 + NONE + + + float32_t + 32 + IEEE754 + + + uint8 + VALUE + + + + /Datatypes/uint8_t + + + + + + uint16 + VALUE + + + + /Datatypes/uint16_t + + + + + + Position + STRUCTURE + + + x + VALUE + + + + /Datatypes/float32_t + + + + + + y + VALUE + + + + /Datatypes/float32_t + + + + + + + + + + Services + + + RadarService + + + Configure + + + mode + /Datatypes/uint8 + IN + + + status + /Datatypes/uint16 + OUT + + + + + + + Target + /Datatypes/Position + + + + + + + Deployment + + + RadarServiceDeployment + /Services/RadarService + 4660 + + + ConfigureDeployment + /Services/RadarService/Configure + 1 + + + + + TargetDeployment + /Services/RadarService/Target + 32769 + + + + + + + diff --git a/tests/unit/core/test_e2e.py b/tests/unit/core/test_e2e.py new file mode 100644 index 0000000..7285593 --- /dev/null +++ b/tests/unit/core/test_e2e.py @@ -0,0 +1,60 @@ +from can_explorer.core import e2e + +# AUTOSAR CRC library published check values (SWS CRC), for two standard inputs. +ZEROS = bytes([0x00, 0x00, 0x00, 0x00]) +F2 = bytes([0xF2, 0x01, 0x83]) + + +def test_crc8_sae_j1850(): + assert e2e.crc8(ZEROS) == 0x59 + assert e2e.crc8(F2) == 0x37 + assert e2e.crc8(bytes([0x0F, 0xAA, 0x00, 0x55])) == 0x79 + + +def test_crc8h2f(): + assert e2e.crc8h2f(ZEROS) == 0x12 + assert e2e.crc8h2f(F2) == 0xC2 + + +def test_crc16_ccitt(): + assert e2e.crc16(ZEROS) == 0x84C0 + assert e2e.crc16(F2) == 0xD374 + assert e2e.crc16(bytes([0x0F, 0xAA, 0x00, 0x55])) == 0x2023 + + +def test_crc32(): + assert e2e.crc32(ZEROS) == 0x2144DF1C + assert e2e.crc32(F2) == 0x24AB9D77 + + +def test_crc32p4(): + assert e2e.crc32p4(ZEROS) == 0x6FB32240 + assert e2e.crc32p4(F2) == 0x4F721A25 + + +def test_profile2_protect_and_check_round_trip(): + data_ids = list(range(0x10, 0x20)) # 16 Data IDs, one per counter value + profile = e2e.Profile2(data_ids) + payload = bytes([0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF]) # byte 0 = CRC slot, byte 1 low nibble = counter + + protected = profile.protect(payload, counter=5) + assert protected[1] & 0x0F == 5 + result = profile.check(protected) + assert result.ok and result.counter == 5 and result.crc == result.expected_crc + + # A corrupted byte fails the check + tampered = bytearray(protected) + tampered[3] ^= 0xFF + assert not profile.check(bytes(tampered)).ok + + # A different counter selects a different Data ID, so the same bytes no longer verify + wrong_counter = bytearray(protected) + wrong_counter[1] = (wrong_counter[1] & 0xF0) | 6 + assert not profile.check(bytes(wrong_counter)).ok + + +def test_profile2_requires_sixteen_ids(): + import pytest + + with pytest.raises(ValueError, match='16 entries'): + e2e.Profile2([1, 2, 3]) diff --git a/tests/unit/core/test_elm327_vpw.py b/tests/unit/core/test_elm327_vpw.py new file mode 100644 index 0000000..258361c --- /dev/null +++ b/tests/unit/core/test_elm327_vpw.py @@ -0,0 +1,223 @@ +import asyncio +import socket +import threading +import time + +import can +import pytest + +from can_explorer.core.elm327_vpw import ( + INIT_COMMANDS, + Elm327VpwBus, + Elm327VpwTransport, + SessionJ1850Transport, + parse_vpw_line, + vpw_to_message, +) +from can_explorer.core.j1850 import J1850Client, J1850Message + + +class VpwAdapter: + """A minimal ELM327 adapter in J1850 VPW mode behind a TCP socket (like a WiFi dongle): answers AT commands, + monitors the bus with `AT MA` until interrupted, records commands and sent payloads, and prints configured + response lines for a data request.""" + + def __init__(self): + self.commands = [] + self.sent = [] # Hex payloads the host transmitted + self.responses = {} # Payload hex -> response lines the adapter prints + self.echo = True + self.monitoring = False + self._lock = threading.Lock() + self._server = socket.create_server(('127.0.0.1', 0)) + self.url = f'socket://127.0.0.1:{self._server.getsockname()[1]}' + self._thread = threading.Thread(target=self._serve, daemon=True) + self._thread.start() + + def _send(self, text): + with self._lock: + self._connection.sendall(text.encode('ascii')) + + def _serve(self): + try: + self._connection, _ = self._server.accept() + except OSError: + return + line = '' + while True: + try: + data = self._connection.recv(4096) + except OSError: + return + if not data: + return + for char in data.decode('ascii'): + if self.monitoring: # Any character stops monitoring and is dropped + self.monitoring = False + self._send('\r>') + elif char == '\r': + self._handle(line.replace(' ', '').upper()) + line = '' + else: + line += char + + def _handle(self, command): + self.commands.append(command) + if self.echo: + self._send(command + '\r') + if command == 'ATZ': + self.echo = True + self._send('\rELM327 v1.5\r\r>') + elif command == 'ATE0': + self.echo = False + self._send('OK\r\r>') + elif command == 'ATMA': + self.monitoring = True # Monitor until interrupted: no prompt + elif command.startswith(('AT', 'ST')): + self._send('OK\r\r>') + else: # A data payload: transmit it and print the configured response + self.sent.append(command) + lines = self.responses.get(command, ['NO DATA']) + self._send(''.join(line + '\r' for line in lines) + '\r>') + + def broadcast(self, line): + """A frame on the bus, printed while monitoring.""" + self._send(line + '\r') + + def close(self): + if hasattr(self, '_connection'): + self._connection.close() + self._server.close() + + +@pytest.fixture +def adapter(): + adapter = VpwAdapter() + yield adapter + adapter.close() + + +async def wait_until(predicate, timeout=2.0): + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not predicate(): + assert loop.time() < deadline, 'Timed out' + await asyncio.sleep(0.01) + + +def test_parse_vpw_line(): + assert parse_vpw_line('48 6B 10 41 0C 0C 6B') == J1850Message(0x48, 0x6B, 0x10, bytes.fromhex('410c0c6b')) + assert parse_vpw_line('6C 10 F1 43 01 43 01 71') == J1850Message(0x6C, 0x10, 0xF1, bytes.fromhex('4301430171')) + for line in ('NO DATA', 'OK', '?', 'STOPPED', 'BUS INIT', 'UNABLE TO CONNECT', '48 6B 10 H', ethertype) + + payload + ) + + +def ipv4(src_ip, dst_ip, protocol, l4): + header = ( + struct.pack('>BBHHHBBH', 0x45, 0, 20 + len(l4), 0, 0, 64, protocol, 0) + + socket.inet_aton(src_ip) + + socket.inet_aton(dst_ip) + ) + return header + + +def udp(sport, dport, payload): + return struct.pack('>HHHH', sport, dport, 8 + len(payload), 0) + payload + + +def udp_frame(src_ip, dst_ip, sport, dport, payload): + datagram = udp(sport, dport, payload) + return eth( + '11:22:33:44:55:66', 'aa:bb:cc:dd:ee:ff', ethernet.ETH_IPV4, ipv4(src_ip, dst_ip, 17, datagram) + datagram + ) + + +SOMEIP = struct.pack('>IIIBBBB', (0x1234 << 16) | 0x0045, 8, 0, 0x01, 0x01, 0x00, 0x00) # Request, no payload + + +def test_decode_someip_over_udp(): + packet = ethernet.decode(udp_frame('192.168.0.10', '192.168.0.20', 50000, 30509, SOMEIP)) + assert packet.l3 == 'IPv4' and packet.l4 == 'UDP' and packet.protocol == 'SOME/IP' + assert packet.src == '192.168.0.10' and packet.dst_port == 30509 and '1234' in packet.info.lower() or packet.info + + +def test_decode_doip(): + packet = ethernet.decode(udp_frame('10.0.0.1', '10.0.0.2', 40000, doip.PORT, doip.encode_message(0x0001))) + assert packet.protocol == 'DoIP' and packet.info == 'vehicle identification request' + + +def test_decode_gptp_and_avtp(): + gptp = ethernet.decode(eth('01:80:c2:00:00:0e', '00:11:22:33:44:55', ethernet.ETH_PTP, bytes([0x1B]) + bytes(40))) + assert gptp.protocol == 'gPTP' and 'Announce' in gptp.info and gptp.gptp is not None + + avtp = ethernet.decode( + eth('91:e0:f0:00:00:01', '00:11:22:33:44:55', ethernet.ETH_AVTP, bytes([0x02, 0, 0, 0]) + bytes(range(8))) + ) + assert avtp.protocol == 'AVTP' and 'AAF' in avtp.info and 'stream' in avtp.info + + +def test_decode_arp_and_vlan(): + arp = ( + struct.pack('>HHBBH', 1, 0x0800, 6, 4, 1) + + bytes(6) + + socket.inet_aton('192.168.0.1') + + bytes(6) + + socket.inet_aton('192.168.0.2') + ) + packet = ethernet.decode(eth('ff:ff:ff:ff:ff:ff', '00:11:22:33:44:55', ethernet.ETH_ARP, arp)) + assert packet.protocol == 'ARP' and 'who has 192.168.0.2' in packet.info + + # VLAN-tagged ARP + tagged = eth( + 'ff:ff:ff:ff:ff:ff', '00:11:22:33:44:55', ethernet.ETH_VLAN, struct.pack('>HH', 7, ethernet.ETH_ARP) + arp + ) + packet = ethernet.decode(tagged) + assert packet.vlan == 7 and packet.protocol == 'ARP' + + +def test_plain_udp_is_not_mistaken_for_someip(): + packet = ethernet.decode(udp_frame('10.0.0.1', '10.0.0.2', 1234, 53, b'\x00\x01hello world payload')) + assert packet.protocol == 'UDP' and packet.l4 == 'UDP' + + +def test_pcap_round_trip(tmp_path): + frames = [udp_frame('10.0.0.1', '10.0.0.2', 1000 + i, 30509, SOMEIP) for i in range(3)] + packets = [ethernet.RawPacket(timestamp=1000.0 + i * 0.5, data=frame) for i, frame in enumerate(frames)] + path = tmp_path / 'capture.pcap' + ethernet.write_pcap(path, packets) + read = list(ethernet.read_pcap(path)) + assert len(read) == 3 + assert read[0].data == frames[0] and abs(read[1].timestamp - 1000.5) < 1e-6 + assert ethernet.decode(read[2].data).protocol == 'SOME/IP' + + +def _pcapng(frames, tsresol=6): + """A minimal little-endian pcapng with one Ethernet interface and the given (timestamp, data) packets.""" + + def block(block_type, body): + total = 12 + len(body) + (-len(body) % 4) + return struct.pack('> 32, units & 0xFFFFFFFF, len(data), len(data)) + data + out += block(0x00000006, epb) + return out + + +def test_read_pcapng(tmp_path): + frames = [(1000.0 + i * 0.25, udp_frame('10.0.0.1', '10.0.0.2', 1000 + i, 30509, SOMEIP)) for i in range(3)] + path = tmp_path / 'capture.pcapng' + path.write_bytes(_pcapng(frames)) + read = list(ethernet.read_pcap(path)) + assert len(read) == 3 + assert read[0].data == frames[0][1] and abs(read[1].timestamp - 1000.25) < 1e-6 + assert ethernet.decode(read[2].data).protocol == 'SOME/IP' + + +def test_read_pcapng_without_ethernet_is_rejected(tmp_path): + path = tmp_path / 'x.pcapng' + path.write_bytes(b'\x0a\x0d\x0d\x0a' + bytes(60)) # No Section Header body / no interface + with pytest.raises(ethernet.PcapError): + list(ethernet.read_pcap(path)) + + +def test_decode_lldp(): + tlv = lambda t, v: struct.pack('>H', (t << 9) | len(v)) + v # noqa: E731 + lldp = ( + tlv(1, bytes([4]) + bytes.fromhex('001122334455')) # Chassis id: MAC + + tlv(2, bytes([5]) + b'eth0') # Port id: interface name + + tlv(3, struct.pack('>H', 120)) # TTL + + tlv(5, b'switch-1') # System name + + tlv(0, b'') # End + ) + packet = ethernet.decode(eth('01:80:c2:00:00:0e', '00:11:22:33:44:55', ethernet.ETH_LLDP, lldp)) + assert packet.protocol == 'LLDP' + assert 'switch-1' in packet.info and '00:11:22:33:44:55'[:5] in packet.info or 'eth0' in packet.info + assert 'TTL 120s' in packet.info + + +def tcp_segment(sport, dport, seq, payload): + offset_flags = (5 << 12) | 0x18 # 20-byte header, PSH+ACK + return struct.pack('>HHIIHHHH', sport, dport, seq, 0, offset_flags, 65535, 0, 0) + payload + + +def tcp_frame(src_ip, dst_ip, sport, dport, seq, payload): + segment = tcp_segment(sport, dport, seq, payload) + return eth('11:22:33:44:55:66', 'aa:bb:cc:dd:ee:ff', ethernet.ETH_IPV4, ipv4(src_ip, dst_ip, 6, segment) + segment) + + +def someip_msg(service, method, message_type, payload=b''): + return ( + struct.pack('>IIIBBBB', (service << 16) | method, 8 + len(payload), 0x00010001, 1, 1, message_type, 0) + payload + ) + + +def test_stream_decoder_reassembles_someip_over_tcp(): + message = someip_msg(0x1234, 0x8001, 0x02, bytes(20)) # 16-byte header + 20-byte payload + half = len(message) // 2 + decoder = ethernet.StreamDecoder() + first = decoder.decode(tcp_frame('10.0.0.1', '10.0.0.2', 40000, 30500, 1000, message[:half])) + assert first.protocol == 'SOME/IP' and 'segment' in first.info # Incomplete so far + second = decoder.decode(tcp_frame('10.0.0.1', '10.0.0.2', 40000, 30500, 1000 + half, message[half:])) + assert second.protocol == 'SOME/IP' and '1234' in second.info and '8001' in second.info + + +def test_stream_decoder_ignores_non_someip_tcp(): + decoder = ethernet.StreamDecoder() + packet = decoder.decode(tcp_frame('10.0.0.1', '10.0.0.2', 1234, 80, 1, b'GET / HTTP/1.1\r\nHost: x\r\n\r\n')) + assert packet.protocol == 'TCP' # Not mistaken for SOME/IP + + +def someip_tp(service, method, offset_units, more, segment): + tp_header = ((offset_units << 4) | (1 if more else 0)).to_bytes(4, 'big') + return someip_msg(service, method, 0x22, tp_header + segment) # 0x22 = NOTIFICATION with the TP flag + + +def test_stream_decoder_reassembles_someip_tp_over_udp(): + decoder = ethernet.StreamDecoder() + first = decoder.decode( + udp_frame('10.0.0.1', '10.0.0.2', 30500, 30501, someip_tp(0x1234, 0x8003, 0, True, b'A' * 16)) + ) + assert first.protocol == 'SOME/IP' and 'TP segment' in first.info + second = decoder.decode( + udp_frame('10.0.0.1', '10.0.0.2', 30500, 30501, someip_tp(0x1234, 0x8003, 1, False, b'B' * 4)) + ) + assert second.protocol == 'SOME/IP' and '1234' in second.info and '8003' in second.info + + +def test_stream_decoder_tracks_services(): + option_body = b'\x00' + socket.inet_aton('192.168.1.5') + b'\x00' + bytes([0x11]) + (30509).to_bytes(2, 'big') + option = len(option_body).to_bytes(2, 'big') + bytes([0x04]) + option_body + entry = bytes([0x01, 0, 0, 1 << 4]) + (0x1234).to_bytes(2, 'big') + (0x0001).to_bytes(2, 'big') + entry += bytes([2]) + (3).to_bytes(3, 'big') + (7).to_bytes(4, 'big') # major 2, TTL 3, minor 7 + sd = bytes([0xC0, 0, 0, 0]) + len(entry).to_bytes(4, 'big') + entry + len(option).to_bytes(4, 'big') + option + decoder = ethernet.StreamDecoder() + packet = decoder.decode(udp_frame('192.168.1.5', '224.0.0.1', 30490, 30490, someip_msg(0xFFFF, 0x8100, 0x02, sd))) + assert packet.protocol == 'SOME/IP' and 'OfferService' in packet.info + assert (0x1234, 0x0001) in decoder.services + service = decoder.services[(0x1234, 0x0001)] + assert service.major_version == 2 and service.minor_version == 7 + assert '192.168.1.5:30509/UDP' in service.endpoints diff --git a/tests/unit/core/test_gptp.py b/tests/unit/core/test_gptp.py new file mode 100644 index 0000000..0647b96 --- /dev/null +++ b/tests/unit/core/test_gptp.py @@ -0,0 +1,84 @@ +import struct + +from can_explorer.core import gptp + +GM = b'\xaa\xbb\xcc\xff\xfe\x11\x22\x33' +CLOCK_A = b'\x00\x11\x22\xff\xfe\x33\x44\x55' + + +def ptp(message_type, *, domain=0, seq=1, clock=CLOCK_A, port=1, log_interval=-3, body=b''): + header = ( + bytes([message_type & 0x0F, 0x02]) + + struct.pack('>H', gptp.HEADER_SIZE + len(body)) + + bytes([domain, 0]) + + b'\x00\x00' # flags + + b'\x00' * 8 # correctionField + + b'\x00' * 4 # reserved + + clock + + struct.pack('>H', port) + + struct.pack('>H', seq) + + b'\x00' # controlField + + struct.pack('>b', log_interval) + ) + return header + body + + +def announce(*, grandmaster=GM, priority1=128, clock_class=6, priority2=128, steps=0, time_source=0x20, **header): + body = ( + bytes(10) # originTimestamp + + struct.pack('>h', 37) # currentUtcOffset + + b'\x00' # reserved + + bytes([priority1]) + + bytes([clock_class, 0x21]) # clockClass, clockAccuracy + + struct.pack('>H', 0x4000) # offsetScaledLogVariance + + bytes([priority2]) + + grandmaster + + struct.pack('>H', steps) + + bytes([time_source]) + ) + return ptp(0xB, body=body, **header) + + +def test_parse_header_and_announce(): + message = gptp.parse(announce(domain=0, seq=7, log_interval=0)) + assert message.type == 'Announce' and message.domain == 0 and message.sequence == 7 + assert message.source_clock == '00:11:22:ff:fe:33:44:55' and message.source_port == 1 + assert message.grandmaster == 'aa:bb:cc:ff:fe:11:22:33' + assert message.gm_priority1 == 128 and message.gm_clock_class == 6 and message.steps_removed == 0 + assert message.interval_ms == 1000.0 # log_interval 0 -> 1 s + + +def test_parse_sync_and_too_short(): + sync = gptp.parse(ptp(0x0, seq=3, log_interval=-3)) + assert sync.type == 'Sync' and sync.sequence == 3 and abs(sync.interval_ms - 125.0) < 0.01 + assert gptp.parse(b'\x00' * 10) is None # Too short + + +def test_describe(): + assert 'Sync seq 3' == gptp.describe(gptp.parse(ptp(0x0, seq=3))) + description = gptp.describe(gptp.parse(announce(seq=1))) + assert 'Announce' in description and 'class 6' in description and 'prio 128' in description + + +def test_summarize_picks_best_grandmaster_and_sync_rate(): + messages = [] + # Two grandmasters announced; the lower priority1 wins + messages.append(_with_time(gptp.parse(announce(grandmaster=GM, priority1=128)), 0.0)) + messages.append(_with_time(gptp.parse(announce(grandmaster=CLOCK_A, clock=CLOCK_A, priority1=100)), 0.1)) + # Four Sync messages 125 ms apart + for i in range(4): + messages.append(_with_time(gptp.parse(ptp(0x0, clock=GM, log_interval=-3)), i * 0.125)) + + summary = gptp.summarize(messages) + # The lower priority1 (100, announced by CLOCK_A) wins over 128 + assert summary.grandmaster == '00:11:22:ff:fe:33:44:55' and summary.gm_priority1 == 100 + assert summary.gm_clock_class == 6 and summary.steps_removed == 0 + assert abs(summary.sync_interval_ms - 125.0) < 0.01 + assert abs(summary.sync_rate_hz - 8.0) < 0.1 # 3 gaps over 0.375 s + assert summary.counts['Sync'] == 4 and summary.counts['Announce'] == 2 + assert len(summary.clocks) == 2 and summary.domains == [0] + + +def _with_time(message, timestamp): + message.timestamp = timestamp + return message diff --git a/tests/unit/core/test_j1587_decoder.py b/tests/unit/core/test_j1587_decoder.py new file mode 100644 index 0000000..6728ca8 --- /dev/null +++ b/tests/unit/core/test_j1587_decoder.py @@ -0,0 +1,34 @@ +import can + +from can_explorer.core.protocols.j1587 import J1587Decoder, decode_j1587 + + +def checksum(body: bytes) -> int: + return (-sum(body)) & 0xFF + + +def test_single_byte_and_two_byte_pids(): + # MID 128 (Engine #1), PID 84 road speed 60 mph (raw 120), PID 190 engine speed 1500 rpm (raw 6000) + body = bytes([128, 84, 120, 190, *(6000).to_bytes(2, 'little')]) + message = body + bytes([checksum(body)]) + description = decode_j1587(message) + assert description == 'MID 128 Engine #1, PID 84 Road Speed 60 mph, PID 190 Engine Speed 1500 rpm' + + +def test_variable_length_pid(): + # PID 200 is variable length: a count byte (3) precedes its 3 data bytes + body = bytes([130, 200, 3, 0xAA, 0xBB, 0xCC]) + description = decode_j1587(body + bytes([checksum(body)])) + assert description == 'MID 130 Transmission, PID 200=aabbcc' + + +def test_unknown_pid_shows_raw_bytes(): + body = bytes([128, 100, 0x40]) # PID 100 Engine Oil Pressure: named but not scaled -> raw + description = decode_j1587(body + bytes([checksum(body)])) + assert description == 'MID 128 Engine #1, PID 100 Engine Oil Pressure=40' + + +def test_decoder_describe_and_short_message(): + description = J1587Decoder().describe(can.Message(arbitration_id=0, data=bytes([140, 91, 125]))) + assert description == 'MID 140 Instrument Cluster, PID 91 Accelerator Pedal 50 %' + assert J1587Decoder().describe(can.Message(arbitration_id=0, data=b'')) is None diff --git a/tests/unit/core/test_j1850.py b/tests/unit/core/test_j1850.py new file mode 100644 index 0000000..f384535 --- /dev/null +++ b/tests/unit/core/test_j1850.py @@ -0,0 +1,91 @@ +import pytest + +from can_explorer.core.j1850 import ( + Class2EcuSimulator, + Class2Server, + J1850Bus, + J1850Client, + J1850Decoder, + J1850Error, + J1850Message, + J1850NegativeResponse, + describe_payload, + format_obd_dtc, + j1850_crc, +) + + +@pytest.fixture +async def ecu(): + bus = J1850Bus() + server = Class2Server() + simulator = Class2EcuSimulator(bus, server=server) + simulator.start() + client = J1850Client(bus.attach()) + yield client, server + simulator.stop() + + +def test_crc_and_frame_round_trip(): + # Known SAE J1850 CRC-8 value (polynomial 0x1D, init 0xFF, final XOR 0xFF). + assert j1850_crc(b'\x00') == 0x3B + message = J1850Message(0x68, 0x6A, 0xF1, bytes([0x01, 0x0C])) + frame = message.encode() + assert frame[:3] == bytes([0x68, 0x6A, 0xF1]) and frame[-1] == j1850_crc(frame[:-1]) + assert J1850Message.decode(frame) == message + + +def test_decode_rejects_bad_crc_and_short_frames(): + with pytest.raises(J1850Error): + J1850Message.decode(b'\x68\x6a') # Too short + good = J1850Message(0x68, 0x10, 0xF1, b'\x01\x0d').encode() + with pytest.raises(J1850Error): + J1850Message.decode(good[:-1] + bytes([good[-1] ^ 0xFF])) # Corrupted CRC + + +def test_formatting_and_descriptions(): + assert format_obd_dtc(0x0143) == 'P0143' + assert format_obd_dtc(0x0171) == 'P0171' + assert format_obd_dtc(0xC100) == 'U0100' + assert describe_payload(bytes([0x01, 0x0C])) == 'ShowCurrentData 0C' + # A mode 0x01 response for PID 0x0C (engine speed) is decoded through the shared OBD-II PID table. + assert describe_payload(bytes([0x41, 0x0C, 0x0C, 0x6B])).startswith('Engine speed:') + assert ( + describe_payload(bytes([0x7F, 0x01, 0x31])) == 'Negative response to ShowCurrentData: requestOutOfRange (0x31)' + ) + + +async def test_current_data(ecu): + client, _ = ecu + assert await client.current_data(0x05) == 50.0 # 0x5A - 40 °C + assert await client.current_data(0x0C) == (256 * 0x0C + 0x6B) / 4 # Engine speed + with pytest.raises(J1850NegativeResponse) as info: + await client.request_pid(0x1F) # Unsupported PID + assert info.value.code == 0x31 + + +async def test_dtcs(ecu): + client, server = ecu + assert await client.read_dtcs() == ['P0143', 'P0171'] + await client.clear_dtcs() + assert server.dtcs == [] + assert await client.read_dtcs() == [] + + +async def test_vehicle_info_and_unsupported_mode(ecu): + client, _ = ecu + assert await client.vehicle_info(0x02) == b'1GCEK14V5XZ100001' + result = await client.request(bytes([0x02, 0x00])) # ShowFreezeFrameData: not handled by the simulator + assert not result.positive and 'serviceNotSupported' in result.description + + +async def test_raw_request_and_decoder(ecu): + client, _ = ecu + result = await client.request(bytes([0x01, 0x0D])) + assert result.positive and result.response[:2] == bytes([0x41, 0x0D]) + + decoder = J1850Decoder() + request = J1850Message(0x68, 0x6A, 0xF1, bytes([0x01, 0x0C])) + response = J1850Message(0x68, 0xF1, 0x10, bytes([0x41, 0x0C, 0x0C, 0x6B])) + assert decoder.describe(request) == 'J1850 → ShowCurrentData 0C' + assert decoder.describe(response).startswith('J1850 ← Engine speed:') diff --git a/tests/unit/core/test_nmea2000_decoder.py b/tests/unit/core/test_nmea2000_decoder.py new file mode 100644 index 0000000..97984cc --- /dev/null +++ b/tests/unit/core/test_nmea2000_decoder.py @@ -0,0 +1,48 @@ +import can + +from can_explorer.core.protocols.nmea2000 import NmeaDecoder + + +def msg(can_id, data): + return can.Message(arbitration_id=can_id, is_extended_id=True, data=bytes(data)) + + +def nmea_id(priority, pgn, source): + # PDU2 (broadcast) PGNs: identifier = priority<<26 | pgn<<8 | source + return (priority << 26) | (pgn << 8) | source + + +def test_engine_rapid_update_is_decoded(): + # PGN 127488, engine instance 0, speed 2400 rpm (raw 9600 at 0.25 rpm/bit) + raw = (9600).to_bytes(2, 'little') + description = NmeaDecoder().describe(msg(nmea_id(2, 127488, 0x23), [0x00, *raw, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])) + assert description == 'PGN 127488 Engine Parameters, Rapid Update SA=35 P2: engine 0: 2400 rpm' + + +def test_position_rapid_update_latitude_longitude(): + lat = round(37.7749 / 1e-7).to_bytes(4, 'little', signed=True) + lon = round(-122.4194 / 1e-7).to_bytes(4, 'little', signed=True) + description = NmeaDecoder().describe(msg(nmea_id(3, 129025, 0x10), [*lat, *lon])) + assert 'lat 37.774900°, lon -122.419400°' in description + + +def test_not_available_values_show_dash(): + description = NmeaDecoder().describe(msg(nmea_id(2, 128267, 0x20), [0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00])) + assert description.endswith('depth —') + + +def test_fast_packet_is_reassembled(): + decoder = NmeaDecoder() + # PGN 129029 (GNSS Position Data) is a Fast Packet PGN; 14 bytes over two frames + payload = bytes(range(1, 15)) + first = decoder.describe(msg(nmea_id(3, 129029, 0x05), [0x00, len(payload), *payload[:6]])) + assert first.endswith('Fast Packet …') + second = decoder.describe(msg(nmea_id(3, 129029, 0x05), [0x01, *payload[6:13]])) + # 6 + 7 = 13 bytes so far, one short; the third frame completes it + assert second.endswith('Fast Packet …') + third = decoder.describe(msg(nmea_id(3, 129029, 0x05), [0x02, payload[13], *[0xFF] * 6])) + assert 'complete (14 B): 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E' in third + + +def test_standard_frames_are_ignored(): + assert NmeaDecoder().describe(can.Message(arbitration_id=0x100, is_extended_id=False)) is None diff --git a/tests/unit/core/test_obd.py b/tests/unit/core/test_obd.py new file mode 100644 index 0000000..2211fe4 --- /dev/null +++ b/tests/unit/core/test_obd.py @@ -0,0 +1,65 @@ +import uuid + +import pytest + +from can_explorer.core.isotp_client import IsoTpClient +from can_explorer.core.obd import ECU_RESPONSE, FUNCTIONAL_REQUEST, ObdClient, ObdServer, decode_dtc +from can_explorer.core.session import BusSession +from can_explorer.core.simulation import UdsEcuSimulator +from can_explorer.core.sources import BusConfig, LiveSource +from can_explorer.core.uds import UdsClient + + +def test_decode_dtc(): + assert decode_dtc(0x01, 0x33) == 'P0133' + assert decode_dtc(0x04, 0x20) == 'P0420' + assert decode_dtc(0xC1, 0x00) == 'U0100' + assert decode_dtc(0x81, 0x23) == 'B0123' + + +@pytest.fixture +async def obd(): + channel = f'obd-{uuid.uuid4().hex}' + tester = BusSession(LiveSource(BusConfig('virtual', channel))) + ecu = BusSession(LiveSource(BusConfig('virtual', channel))) + tester.start() + ecu.start() + server = ObdServer() + # The ECU listens on the functional request id and answers on the ECU id + simulator = UdsEcuSimulator(ecu, request_id=FUNCTIONAL_REQUEST, response_id=ECU_RESPONSE, server=server) + simulator.start() + transport = IsoTpClient(tester, FUNCTIONAL_REQUEST, ECU_RESPONSE) + yield ObdClient(UdsClient(transport)), server + transport.close() + simulator.stop() + tester.stop() + ecu.stop() + + +async def test_supported_pids_and_live_values(obd): + client, _ = obd + supported = await client.supported_pids() + assert {0x04, 0x05, 0x0C, 0x0D, 0x11, 0x2F, 0x42}.issubset(supported) + + speed = await client.read_value(0x0D) + assert speed.name == 'Vehicle speed' and speed.unit == 'km/h' and speed.value == 60 + rpm = await client.read_value(0x0C) + assert rpm.value == 2500 + + +async def test_status_and_dtcs_and_clear(obd): + client, _ = obd + status = await client.status() + assert status.mil_on and status.dtc_count == 2 + + assert [dtc.code for dtc in await client.read_dtcs()] == ['P0133', 'P0420'] + assert [dtc.code for dtc in await client.read_dtcs(pending=True)] == ['P0171'] + + await client.clear_dtcs() + assert await client.read_dtcs() == [] + assert not (await client.status()).mil_on + + +async def test_vin(obd): + client, _ = obd + assert await client.read_vin() == '1HGCM82633A004352' diff --git a/tests/unit/core/test_raster.py b/tests/unit/core/test_raster.py new file mode 100644 index 0000000..ad8f54d --- /dev/null +++ b/tests/unit/core/test_raster.py @@ -0,0 +1,46 @@ +from can_explorer.core.reverse.raster import ( + CELL_ONE, + CELL_ONE_CHANGED, + CELL_ZERO, + CELL_ZERO_CHANGED, + build_raster, +) + + +def test_empty_and_zero_length(): + assert build_raster([], []).columns == 0 + raster = build_raster([0.0, 1.0], [b'', b'']) + assert raster.rows == 0 and raster.columns == 0 + + +def test_constant_bit_is_solid_and_toggling_bit_is_marked_changed(): + # Byte 0: bit 0 (LSB) toggles every frame, bit 7 (MSB) is always 1, the rest are 0 + payloads = [bytes([0x80 | (i & 1)]) for i in range(8)] + raster = build_raster([i * 0.1 for i in range(8)], payloads) + assert raster.rows == 8 and raster.columns == 8 and raster.length == 1 + # Row 0 is byte 0 bit 7 (the MSB): always 1, never changing + assert all(raster.cell(0, c) == CELL_ONE for c in range(8)) + # Row 7 is byte 0 bit 0 (the LSB): toggles, so every column after the first is marked changed + assert raster.cell(7, 0) in (CELL_ZERO, CELL_ONE) + assert all(raster.cell(7, c) in (CELL_ZERO_CHANGED, CELL_ONE_CHANGED) for c in range(1, 8)) + # A constant-zero middle bit stays zero and unchanged + assert all(raster.cell(3, c) == CELL_ZERO for c in range(8)) + + +def test_downsampling_keeps_within_lengths_and_flags_changes_in_a_bucket(): + # 100 frames, byte 0 counts 0..99 -> low bits change within every bucket + payloads = [bytes([i & 0xFF, 0x00]) for i in range(100)] + raster = build_raster([i * 0.01 for i in range(100)], payloads, max_columns=10) + assert raster.columns == 10 and raster.rows == 16 and raster.frames == 100 + # Byte 0 bit 0 (row 7) changes within every bucket of 10 counting frames + assert all(raster.cell(7, c) in (CELL_ZERO_CHANGED, CELL_ONE_CHANGED) for c in range(10)) + # Byte 1 (rows 8..15) is constant zero + assert all(raster.cell(r, c) == CELL_ZERO for r in range(8, 16) for c in range(10)) + + +def test_short_frames_are_zero_padded_to_the_longest(): + raster = build_raster([0.0, 0.1], [b'\xff', b'\xff\xaa']) + assert raster.length == 2 and raster.rows == 16 + # The second byte is absent in frame 0 (padded 0) and 0xAA in frame 1; column 1 represents frame 1. + # Byte 1 bit 7 of 0xAA is 1, and it changed (0 -> 1) from the padded first frame + assert raster.cell(8, 1) == CELL_ONE_CHANGED and raster.cell(8, 1) & CELL_ONE == 1 diff --git a/tests/unit/core/test_reverse_console.py b/tests/unit/core/test_reverse_console.py new file mode 100644 index 0000000..6484b30 --- /dev/null +++ b/tests/unit/core/test_reverse_console.py @@ -0,0 +1,52 @@ +import can + +from can_explorer.core.reverse.console import build_namespace, run + + +def frame(can_id, data, timestamp, extended=False): + return can.Message(arbitration_id=can_id, is_extended_id=extended, data=bytes(data), timestamp=timestamp) + + +def sample_messages(): + messages = [] + for i in range(60): + value = i * 400 # A rising 16-bit value in bytes 2-3 + messages.append(frame(0x100, [i & 0xFF, 0x55, value & 0xFF, (value >> 8) & 0xFF], i * 0.01)) + messages.append(frame(0x200, [0x55, 0xAA], i * 0.01)) + return messages + + +def test_expression_value_and_print_output(): + namespace = build_namespace(sample_messages()) + result = run('print("hi"); 1 + 2', namespace) + assert result.ok and result.value == '3' and result.output.strip() == 'hi' + + +def test_variables_persist_across_runs(): + namespace = build_namespace(sample_messages()) + assert run('x = 40', namespace).value == '' # An assignment has no value + assert run('x + 2', namespace).value == '42' + + +def test_primitives_and_data_are_available(): + namespace = build_namespace(sample_messages()) + assert run('sorted(ids())', namespace).value == '[(256, False), (512, False)]' + assert run('len(frames_of("100").payloads)', namespace).value == '60' + # analyse finds byte 0 as a counter of 0x100 + result = run('a = analyse(frames_of("100")); [f.kind for f in a.fields]', namespace) + assert result.ok and 'counter' in result.value + assert run('BitField(0x100, False, 0, 8).key', namespace).value == "'100[0|8@1+]'" + + +def test_errors_are_captured_not_raised(): + namespace = build_namespace(sample_messages()) + syntax = run('1 +', namespace) + assert not syntax.ok and 'SyntaxError' in syntax.error + runtime = run('frames_of("999").payloads', namespace) # None has no .payloads + assert not runtime.ok and 'AttributeError' in runtime.error + + +def test_output_is_truncated(): + namespace = build_namespace([]) + result = run('"x" * 1_000_000', namespace) + assert result.ok and len(result.value) < 50_000 and 'more characters' in result.value diff --git a/tests/unit/core/test_signals.py b/tests/unit/core/test_signals.py index c470424..49d3899 100644 --- a/tests/unit/core/test_signals.py +++ b/tests/unit/core/test_signals.py @@ -33,6 +33,40 @@ def test_enumerated_signals_are_plotted_numerically(data_dir): assert tracker.series[key].values == [1.0] +def frame(can_id, timestamp, extended=False, is_remote=False): + return can.Message( + arbitration_id=can_id, is_extended_id=extended, data=b'\x00', timestamp=timestamp, is_remote_frame=is_remote + ) + + +def test_interval_series_backfill_and_live_update(): + tracker = SignalTracker() + key = tracker.track_interval(0x100, False) + assert key == 'Δt 100' and tracker.series[key].unit == 'ms' + # Backfill: 0x100 at 0, 10 ms, 30 ms; other identifiers and remote frames are ignored + history = [frame(0x100, 0.0), frame(0x200, 0.005), frame(0x100, 0.01, is_remote=True), frame(0x100, 0.03)] + tracker.backfill_interval(key, history) + assert tracker.series[key].values == pytest.approx([30.0]) # Only the two data frames: 0 -> 30 ms + # Live: the next 0x100 frame 20 ms later adds one Δt; another identifier does not + tracker.update(frame(0x300, 0.04), None) + tracker.update(frame(0x100, 0.05), None) + assert tracker.series[key].values == pytest.approx([30.0, 20.0]) + assert tracker.drain_changed() + + +def test_interval_key_distinguishes_extended_and_survives_signal_clear(): + tracker = SignalTracker() + standard = tracker.track_interval(0x1A0, False) + extended = tracker.track_interval(0x1A0, True) + assert standard == 'Δt 1A0' and extended == 'Δt 000001A0' and standard != extended + tracker.untrack_all(fields=False) # Clears database signals only; intervals (like fields) stay + assert set(tracker.tracked) == {standard, extended} + tracker.untrack_key(standard) + assert tracker.tracked == [extended] and tracker.intervals == {extended: (0x1A0, True)} + tracker.untrack_all() + assert tracker.tracked == [] and tracker.intervals == {} + + def test_series_window_and_trimming(): series = SignalSeries(max_points=10) for i in range(25): diff --git a/tests/unit/core/test_someip.py b/tests/unit/core/test_someip.py index f960c8d..4e9a765 100644 --- a/tests/unit/core/test_someip.py +++ b/tests/unit/core/test_someip.py @@ -4,8 +4,10 @@ from can_explorer.core.someip import ( + TP_FLAG, Endpoint, SomeIpMonitor, + SomeIpReassembler, _ingest, describe, parse_message, @@ -14,6 +16,33 @@ ) +def _tp(service, method, offset_units, more, segment, message_type=0x02): + """A SOME/IP-TP segment datagram: the 4-byte TP header (offset in 16-byte units + More flag) plus a segment.""" + tp_header = ((offset_units << 4) | (1 if more else 0)).to_bytes(4, 'big') + return someip(service, method, message_type | TP_FLAG, tp_header + segment) + + +def test_tp_reassembly(): + reassembler = SomeIpReassembler() + assert reassembler.feed(parse_message(_tp(0x1234, 0x8003, 0, True, b'A' * 16))) is None # First segment + complete = reassembler.feed(parse_message(_tp(0x1234, 0x8003, 1, False, b'B' * 4))) # Last segment at offset 16 + assert complete is not None + assert complete.payload == b'A' * 16 + b'B' * 4 and not complete.message_type & TP_FLAG + assert complete.message_type_name == 'NOTIFICATION' + # A non-segmented message passes straight through + plain = parse_message(someip(0x1234, 0x0045, 0x00, b'\x01\x02')) + assert reassembler.feed(plain) is plain + + +def test_monitor_reassembles_tp(): + monitor = SomeIpMonitor() + _ingest(monitor, _tp(0x1234, 0x8003, 0, True, b'X' * 16)) + assert monitor.total == 0 # A segment is not surfaced on its own + _ingest(monitor, _tp(0x1234, 0x8003, 1, False, b'Y' * 4)) + assert monitor.total == 1 + assert monitor.messages[-1].message.payload == b'X' * 16 + b'Y' * 4 + + def someip(service, method, message_type, payload=b'', client=0x0001, session=0x0001, return_code=0x00, interface=1): length = 8 + len(payload) return ( diff --git a/tests/unit/core/test_someip_arxml.py b/tests/unit/core/test_someip_arxml.py new file mode 100644 index 0000000..0c3ab81 --- /dev/null +++ b/tests/unit/core/test_someip_arxml.py @@ -0,0 +1,38 @@ +import struct + +import pytest + +from can_explorer.core.someip_arxml import ArxmlError, load_arxml + + +@pytest.fixture(scope='module') +def description(data_dir): + return load_arxml(data_dir / 'someip.arxml') + + +def test_service_and_message_names(description): + assert description.service_names == {0x1234: 'RadarService'} + assert description.message(0x1234, 0x0001).name == 'Configure' + assert description.message(0x1234, 0x8001).name == 'Target' + assert len(description) == 3 # Configure request + response, Target event + + +def test_method_request_and_response_differ(description): + assert description.decode(0x1234, 0x0001, bytes([0x05]), is_response=False) == {'mode': 5} + assert description.decode(0x1234, 0x0001, bytes([0x00, 0x2A]), is_response=True) == {'status': 42} + + +def test_decode_struct_event(description): + payload = struct.pack('>ff', 1.0, 2.0) # Position { float32 x, float32 y } as a big-endian pair + assert description.decode(0x1234, 0x8001, payload) == {'Target.x': 1.0, 'Target.y': 2.0} + + +def test_errors(tmp_path): + not_someip = tmp_path / 'x.arxml' + not_someip.write_text('') + with pytest.raises(ArxmlError, match='No SOME/IP service interface deployments'): + load_arxml(not_someip) + broken = tmp_path / 'broken.arxml' + broken.write_text('

Hn?`O!NlFwnSifAf!nYNQOoPi|JVi)~&mIF4Y@Y)npe-H9mQ4YF~J)pC#+MvbZLN||-T~3n7zG(Q~3xa2X z57no}f$(nR>Qp&aQvBvZU-V9gcZF1)N{d;)fBZnk;Uf{~?kG4c< z(dyVRX1N&+%)<9r(>iIN(f@b^a1WUx1~kxgm7GuhvcA#WuhcI3|4~HhpD5C8kgmO# z)T$7k;^m0P`<17EMAt>$F;}gcwX&jJpXq#gwjH}HFv$I>m$4GdoR_ahQP~*aSx(XI z`gq(S|Gd0G(YzL;esW0D5po4ESSI5&P4dgH7JZ)N3JZCg%ve$v)s45ObmNd8v@lS$ z3>qCDOzj8dehLs(T~Nnl>{UR&Yv;_qMTZt~l}YqF0~JK7L}w~uLLfGaa{QS~ByS7C z$R%np+%75Xs7W_2BnKTdhpl(X@P?*X3N(|xu|Zg11#%(Wz##K^#vhO*6g6C*7Dxgr zcMPsn$NrUt;CvJErNySVQG@g8cRT!s!)N&MwVmC_JqOqb+nTUi$TEn_~`$zeIIlHL96 z!S7d7iopI;pzwPh+e7H;^k@rdGOBiKQR#k0%dw%T5ltvGvjIkPzsQ z0mY4OFa^Ea?nV}*X$@W=8*0Vem6Lov*J`W??z$VJ3%mlUqrbyXOQL!^pR3(y{7g#X za)QG9g3%gIW#$o((*0#XtjzJ%eKaq+^zmdK%?`2tAZ%Z%j14wruEHe6uQdRHBW?$_ zt`GS}VSyvf{MI5<3uasMx0!c^HD?V+kLs93a;K@J{a{o!d;4<{k4penCFql*d9vb_ zrSyQ19e_3w6!^{3AT<1`C(?du!sZn3u#Z>S#f9#>zgQ|Nnfco`yuTB^w!X^0wMD|( z2JNq-p%(-N>H6okPZt^My?LYolwS0#dr|6Rb02>t?oT0pvqAsu-HSSu=y7wxml8Cp znBhVh&tTx}RFm5FcXR%OHyYj>X?LzQjuG99&*k8^cjv4_Cxj2ine>9f%q0C#Y|?2M&mp+2^rveIQYewXZjbs z)THeI>5mB3(XH8%G%*KEz}lJ%x+Q%Z4Q&MUds*I#jWA#D99@$GhRp3`@S=z-8`NIU znp}nAqi|cHT*y=kU!6D>dI)~k)tPLaMENpW;8K$`6 zDzhD)LI4Bohq7h|xTo{0SK7O#O~4Vm4G&w6<$F6mwAS=2UUtVWcXQ)i^R!w{2aii&V38Q7FpuXqEY-}tzLzR-C^-aWs@tnrv`~K4K11yq zyJ=)sETyu)cTRPOHL>vWOaD@~v`;b36h~H`YJKxVB!2POhG2IhO_l@+ob)29*7eDQlQt7K-iuqZ2kD-HAf64<=}x>Expkgs*rPsxCu zy@l2uJkyvFmli^Jolwh0j-QUChF5=Gg+OF9Je-+1mLcYOQWACAc%Y2>BJj2jS9~kt zI6n3}(z)(?SOocLs$QQ{#H?p@^L}8rfon*ADZihf_F8|Vu>R*GDv6S1%+sbTjG3C! z>DOl?z2r?IhM`1Yw_I&gq|d_fK|sTw%88?C)~y(k%+K2N+hYwV+-3_8pI1_km?>yi z+bujA-&-C!!0UG0=`D7u=IUgH+iY|Y7{0e`LHmCymo=G&y5?fzMA<>62Fxp$JQ*TQ zpYSn!7vA07{mRtRs+TKBDEjDhM14c00!6b5k9=z16iqSS6_1=vBdPdTmYL2{#y*_& zM)j^7=PEe1_xM~IYl^tfz&GYM2$z?(o2sniPHu4#q|x=0l?~n1VBBis+SsBOce&Lx zK@sL9dR72ktR#w4&7468^gZ!`THWiV0Rl@+43V2mG%cE=d*{)s^*Qs@iKz2-ZkfV7#s*N~8-K2mlTB27<9SH4d#GV;QR6wMFeVdW z*s^t&BE;dUARuTTxbSQp$tx>$8t;1W_oZKYF=(`6LPsME!|y@6ZNIH9d8ufhjEv?5 z@F>M$VhM?yr*08f4Qv_C#G--3FA46B7l3?|D|5>pc(YHd{aW9j*D2I4bxw(ugrd5~ zh%L*zoD#+}^DNuh)$PLE9$y%NmB!k=kxQrO1tDdyCf4zS^UZxEtm%=C`SFAfkq%un zU8vzMbNRuQ!#r(ZX6}(}FWi^2DHEtr7O{8!=d=JheswoV|vcnkB&LU=Vy6XkvO8GSDAZQL!kvIJ@i5AC0e{7{T zSFUlWLIm;Ew|9E$=SUYZ;rD)FSFLgZxA*(b{>|(=yiAqPiIFQ6iDI{}?jl1#xvjlm zrOKIH5MgnnPipU|($fXxOpIWe0=936PRV*)?+`EGnx^LF-bxmYmbIj7~dPfmkB zNVjXh@{^KKY*e28tjU#$3y>MBk+sLQh^AX|eKC2YR^dxVjhQ!<*`$~d942Mg9KTdu zl6FTH<*hfUpVzA(<4_jsko7*gtOX(?pRWN{zsDkkp{Hj=mlE%)00@Oi=6IxHaS6dt z(DE)jLNUQcFVYim^76|K5Bsq2JN8CX&-P|Jwy;RKF`QcB#$=`|n^!rfc~y$L=eh@q zw@)8$+jgUn)%B4kh+(!&(}BEjUFw@H8fmzsA4)-XezT~MAOXW>aLS9Az@Vl|rTV!} zSE&1gOWi!KU8Mu%Vf)wS!>qm6{8yxb_jcc<>!-{|C5cCEF>tc`B%jybXI%nD+p>i_ z%$oZD*312@yEW}xDL$K*=39}jRH;9-RI5C-sjrrliokv_R#; z>!4$xuZW*JcM;{;0vyj$n?=|kx90)m)F6eGq`V`rl_9cBT!7{vWYq{Kjn}t2JoVt4 zV=w{&6}>8^oTATKOoDIj!T~fFfZZ8ey*t9Lxb30d~cB_JR z3u@JUz3ttMldp5I&ZqA_&SOhi^RH@aFD@_86yns)digz;gCTFTgcZAj3`kF8qQj{S zMq7hIL*uu*jo&lLGg_SN(GqdBTLqd9#hI=y?$9mQ7wH~WKMQ5_m)&w2sc{S;r1(`S z^{OgUaQ=OUmsBxmnY{m* zX08_~pm26oX6ftY^LWJ32Zqlr-u1kvwoRt-Wrn!$JejHGek#zClJLyZG>D)uM}G}` zaPq&h(a!Y3r`p01R^?8hJE*P#?`RS%`X(yfanL*>f$YsMQm%5gcSHf{HQ^F8-FBi6 zIR3>$>qfz6k=(9)IYoyG2{Z@Q?rz!mLR)7{msw)N;c5{YhRi#xr(P@h@ZjG-5Ra+x!Mgv zc=@`P<+B(6Qh4q?PyO*Qr6kx=Atk~q(^UH_`@WUIyj7&Pc7h4tv}s5!Ur`nC@?!G_ zIaBMf;(4@{T5=@Th1?XR88umR%CTi}gn#`z_Z_{Vy;HZ=<7*@r-z!@UhqRIoMroJS z`UUp7>Rk@;V&64f>vp@TbH>g!YyhpUbI`#(1yM1#GccoQLzTDir!%+!%`p;jgH0N5 zvf1^XTsY&6f$#6~8a&68?ZKq1xBkJB4dGAae6>sD7m_9p{#@FPlet>lZw{|_rGPlI zp7yHio~_R>v$yqX-f@d`ITsFXq_g20LX(Vv7r@&tDSSf(;4pBYDso+U?NeGo55VQp zEWw~!Wt51SdcTJEs^U01kPS~Kx1wgh%UdNW^1eE4!ME10=y6 zFGIQ`K_$Q@juQd5bW2hgVemaXty1h zY(cb;YR0Iy?d{PU!J3lsMt6-5=YFuOZkD1mI`t4y0$xVu#b*C zd>xC!Ig;BJl~|gOhfBofH@cCbc>aq(W$R9NddfBgZ5w|LN@DW|{nR~dWUm0?)t4LM zG^>vPBv!k&`tse_n$& zDkrLEF6hGmo7!Q?bM(okqI6-{(rL3O!WzVGWN~{yz8)@=bB!5&1Zorp{z3BLS!PJf z`v4Bhyy&QYR#nB7GhH?Hs7<~EqCs*LJG&24Et z@{+aT1Rs~kiPO1t46RW)|EJr@AM$e}B0->J-{-3HJZfs&oau$yg1EP*;E$m8;GCOlWVV- z>B8I98K7N#yE2}b{UW<#!(M5XK&JWib*hoPRY@8J(kAG(U?0R8c=A%|e`eTpOo>?u zuTkg&g4>n}mX2C8*aO(ukii1H_3tajb}-~@6N2-}ri57PalcDrfyz0;ds05CoxY@{ zp?B2#Cal8>?uh!Kz#PJoO*r@UgGkxr5b0-8HrO?0X%_dJa^2yC} z6UQUVEW;R0+!Y!=?8JE&T^t$h&1sv?k-bWX9D%x&AfK1!if!}`ZKFf8Nw=T7Mc~3eqK>1AFwIYhk5tI zzOshsRajit*K+`bhF! zF8Nc!6AUsKRkKG5^Q0ZYxr^+r7Pe0i0x;AF3yNXCoQQ{Yw~72TC=C03JwYe~ z>pR;>*Eg}`ifc|zYW8HioPbTn4F)t78Q z8_>DV@bn%^tA`uFBnQ#Quy6~><8GZZnjLz7lJ<&1w3$-v%(|q{6}>6M15Ta_0TMDs zabl_cl>SdjnY~KceSKtWTiAA)!jf91AzHwAbL~EBlQ@862PrN6UYiK)Vm6nIn&~4# zZ*XdPqGfY(LYfu5{e$8XU1+Fzol2tekDo~PD##2or=6+7*-pCOq72eC9#>~D&y`rai&sj z?0VlIZN>vTbX$!sR6yq_GlV?00nst)X%20J{cbMYo*WyV0>L#EV-U$Y>0U8TUiV4> z5M~qN)dj(b^(Y+7qbCOY z|KKSLF|52N96)f&~cfA-KE4;_kuS-QC^Y-66QUySoK{}if7-Z0Q=&ur0nUwjj3#^^Ex+56a zkHP=^z>}$e;DUku0+SFHP;$$<*mQMMQU;COK6fpc&cn0jOC;)iJC(`!_0-({6yojt z+>)Y=zFb{VQDJ#a?ZVHW;rFY{6(T5M>zriax~qpTf&V+Kus_ts^|`wn0aKgfu@O_- zq2u%`0coAGEhNz(_Q+vGQ7J`LT_#Zwvf%gsa~iZQW^;l3|L(v3y#pk`X#aP~USk}X z#{Yf)f652ViNG8G-zvU}!WIM}%>GxcLC7V+P_zGS<9}#m;bHTF|2It*#!r6u|4qX| zOX7#|ziI3>#Q*>4$kDKk-~Z{_-gglNM$L<~DYi)yc(O!v5d{S_)QI_&DpbKNN(4v* zp#cUb7FvD^ERw&ku;09~Lbg_>xamX`>ejNt zVZb57y?qbyf_l$k200Q1y?iA6Uv`Eu`>h&`m^d5MwaY7`hUDj;qksMi)5nV56#kkn zQ3W)95#^~Bd^R-FpW4gcp~r7wc!C1Dhi�DpknYJUPA&y|21qgCSnLY@B$5@+}=I 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¬Ldgy6mnFXdlT`*wSTCU;HGpkR#dDl&`u4V<+qS81zUAgI9YG}HvLL!jzz zMuM_j7qslaEPls!Z74gKr+dmMqCP;-R91!6z*{kWelo@JLig+6=cUvSiFx zT0+)xx27?SYJ1R_IlO{q1P^xd6Tb(Q(UZcznn z_5$R2iqzv3ZPTAsNLzvOK{N3i1v$m@|+?Zo@32%SwP?+@V7M}e2s_CW}iQV-nVHnnnDL9|5JwDb`ob!XfTpIH) zE|q0x^!ux7u~43QLF2z-d`6dFEfW7F^+|7&g!~ zImJ@rwz;tUieHKgR=%G)=gE#$#a$5Sr=;^T!#r!Y6drTaRoXnZ@ES};kJ}3YwdP$g z)lc)YYrpR~fVins&;^$zqTrT&?p6`629r=z!u&y@Wv1%rxQ_P`hg~7q)R`Z~<%hQ# zY2+w%uK7}~ng)Sf&9O%pUU`(-#K+nSn+&&B%nwM^b;12-pwy7zEI`Kt@GXWawujM) zbNVQ>!@;cYI5LJvAhhbrGq>(h4Q)vf{!6ME(U^c=%pcKlT6`A2UwBiSP6b~c+L+;)L&z9mwSuh|u zsc39((OE}?^{sL9&vKjrp<)lkZ2GexVf_xJgFji@k&2UMqKJkk4Iy91*dJe+h8f`6 zYUj%zVV`*V-B6*<{vjQhK%@gHc^?`J_VYnX0;RY1#?^eim|DpBHcOWpdONc|o)?)hP^}~+ z2F+NAi80IpSd(Y`F+H(!@_Ti)g@o&bYwh1XKO7e-)tV_Vm(D4%Vu0N^V2frIO}`~r zG_c2MJ`4Grfe_fD86p;_O*f>P8)!Ep@mMlwgT<6;R9J^teB`Bk934G0$wG(y z#MP;q{Je>>XyaIty`4@;BT)2*5vi6wnal9W2wwcY&j={&vAj$o)@pYl^3NsGot~PL zdu&!H;l>nY%$}$(S50RfK(=thMQKTSeEH{5eAvtBg>h46t5rMg>@$qCd?of5W?d)oPxg~LyY?kG_v#B*#Jg#_HsICZs zY!z%7P*n$rwIh4Z79 z^Ykv6+<`=XUz`0I_~55lcFIELN*!PTG0^mdUAQZo>7Do0(E7hfvy}eHEVOt;mWhj099`@>EvfR1e(8;+OV?234RBTA7raGlA^`gV)S$TXM$Q0*i;iX8kJP_hp z&8fS2QbXbcT3y~O=XY~$_or|}ly;;wG48FrEmVw5l&l~et^g5Z_}=e-q7cG7P{>1B zD$-?IuC5wX4x@`kU{HWDgtXSTB|YEVF<_!iJyRR*1C_r+>P$XEwj4!m3ypWZzt2QB z-)%OsE6N{fPCY_xCx10BdPkbR?VEg$fUh@tL@GP_Y z^eYN&g-G)jLc&tYymmBSwc44XApJoqneu1hj^%~%aPcY_oi)h<03P?AxgZ;Fo|Yu@04XPYiAQ znQ@eQ4$7#}r>#*Vb1ptM#-*PLfvr`BhdoyUgSo2EbHBbpl9KNHyx>i+j_;oI&m)qF z*xjFr&%d5eOh%DCr|`MM(v5cQ8~(^!ZFwZ08xB#@aR9zC3Kd5r9iN8)lLf=_fuUo|Y$RH$mCoB$&|b=GVL9sY--~Y=n(Hw|+#w|uAAB;}S5Ug^{pbkq_WRoH43=LlY_*jX?Qr z*F0;y+dt7=10#6Z4`6@7Zk%@Ple_b+`)`o<6JWE-bQYmyGS1Z2FkzKvGzq9eZpv(MBgN{xK&s@yTGh!r%X5p{J+F z)6rWb_E?hInJ%&{H*t}J>2itQJA6 z_FZVp^9n5tindiH?Pl<`FL^2XMw31Mjt7`mT{uh}i|;?_lKntGy$$Lh`fI3Yc#?K4 zI~%ui9e}$jqGyeZqv)r!WY0NX^|!P}idkgfd+QOC|9+Mr+Ow{_7NF=@=*-$6V-YIa zU@VSUGudxwJcV)b?*KWP()|7X0OXuuDj5s{n#-z)0Z!@H%&X+Pv?$9Cw)40VQ(CGV^(*1ADYFbp<9%?Fi^ z2!!V!V*kUXEzX5K=Y&d{V>QCpMC^}i6b*~~$EpcbkQ-R9;MMuP8+Ao*B}(t-D^;bt zt>$@$0;|IL3GCh8@2PGN6a|SNEoc-?ERcw}tL21iIh@G(_gy>+S}!zwqeT(qX4Yu*Ba^r(4X- zYVsUhok<%`$i;U~eKcleJbcB()MupXNnm#Xh^(a6-45nUp?M6Ij%mFCYWUk*KYhyg zL%BKe=0Y>HJSj*oB6QAtUkJCW*kd(uemm*Be5|SjyIvN>QJ4bb~$&DN|OQ}_NifD?`@YYn>_4K&Ti>}MaReJ`$%&e zH!$o@0D(K;)u5DtrG)B}|8CHH=Z)o$;jXJ2V3+=Brk_L^-2*l7abR03#qaeK{oYmS z@vg@E1~|H$`t@V&HjRSW;gJnsYpPuHDF2Q6wyZ~}ci|CaQMrRaplo&JnbRLai5RZIgtCfk(VAm#d#6wZotidaT?*`P-R=JzKY zxJ%Zc3eDIer4H2UOB3WP?cv=}Tb{3aAGGdr;toSf`6Kn0ERdP!`_xw^2SxcS2^z{D zD7?S9{9uc{piRZg$UHG@lTM{ObLP5)d1EsM)J@+|i4TKYD3U`s#-G10`bC{~%=1$9 zdBq)8R`j2&QsFce2Q29K)LYF$tBjp0xbI=i$4AanGndFwfZ1U2$gFEMIP&PSp1VHH z_FxnbyAA#j9_`N8&UU&nI9%F*A&KHiXf+7N;pt=|EEa=BzU}`*PkQzT*-yJUY4XG} zj@((?fl8k*bZ3+7`a71RJ>A623&&GAuHMX(jp<1D~ z;#jpN#Pz&?1}*pm^yhf#dL-gO#o0u0&N{u`X}+fKIivu!ZBE2)8|Y-1K@5|h&g{lW zLVh0zkq=iRzfq-+#><#ZeGGTOU1jIpngDNu)k+LPdr!rR9B{|`(}zuEd9+F5n>rL= zNbGKa)c5Vu($zgB4vdW-Um(JeiyX?d$ohhB*tls&!;}6jyQm`eXt6I*egA z*m&Oo-{bb`NDGjBrDDw|%yX^rcfwof)AtpGn#>LqS?Q*jP4FR3Se?i)q#g=Y)jmOM z(rUvrH?@QHgfc;a&^0!=3`j?yND6nncE$zxq!rO*+XfkfYDs;wj7UjO`S@>J06X7I zFITC#o{uzN?6Xol2Ju~`pHz`Tvsn~CaoZ%wpRK3>JoFi-4rS61RM4Exm}Y%)SIND_ zjp=U?6gMetFG};T`+r0fNl1h1{C)C|5a=WxodEgsze1q+h|hny@&CRMg32I@KJ)L_ z{}HbfLQxQkS|h6-TeAuEJ<&>@UIq|(NOnW5lRS`brGV~V!~u#}qG+HH@0S66Zq~*{ zKUqYV9bcWJ=u9U#9n=FRW&T@Do-t}p^!Ah@tyO0SswP(Fed;Nm%E)AxXy6Ec`_6`D`#5yLukpYh-g#8)3*3+#3Q4 zX*zRx5P~w%`(K&hVfc&cjp;nEVc_RV#g`Tv_D{jmrjAy>Q6}C}wVa6!TR+`BU!>2o zjKJnOsc25`P5LvERWy#`3-NKpZ3B-CW3*oRE)5exb5Jmf?As3|<~y}~?N+K}XV@KV z-DLpq6_sFB@kk1HMfqZge+fnMKMA$4@{F;ovb38_tQ}O7a;~RPxkrESTTmZowuOf= zlx6lHuIKJb+o0R;0?uGfA2Lus129AE;jT!7vi$)UIb~sWDF0L5&*R0K6LVksRy(5U z+A9|a$->7c*l}h2+Uo$Z(TW>Hg~j185sp7Md2dttyGrb#|E;M&MN(D$+W@Jg%*?N$ zY3rS#5w!vl%y{+Cp{N8tVg7-=kZ&oD$3~2`KiF}ZbEFJ%q=%~Fuh+)t$Eqr6n8u8b z+FeZiy@mP_mP~tYwTUisr`vORJU*r1)MmYdH}xSmQq9V|(W2aLoWHewTO%owMXo<{ zok0+}JARp2t*Q1)W^wB5_m zG4TZ~fq~c`E?iAiPoyM#fgzRS9_KZ0Cpq15o1O-+`6K5oSH$g>h+wwhogLUx08-WafFea^#e{Z(X zVeyksMtT|2!CvWha%u3lKUnf{le?MvsV?os7>V8-zs1Y8yT{HnDQ+IW_#axGW9IoB z)Zq|&=0U{SPaI?PmU*+mcGcQ179j2SmYcs?VQnis+(Wpr$>-Miub zssRlMn;qha2B>K7-aabs0+;?Y>BwX8Fr-O_Edw<9p5Kj}+_kkz^oVYMeyWIw|e zQ8OY5CVIn$=o)UWP<9+i-*Dxl=`bhRTwlSRFAhMu`Vh}fjA!ZWN5Mpd-` zbND-IF{O(yaF)Cg2fIGBg)=p%EQqxu_u`{FT!@j5+toskvNmoBex=?61!aRJ%=kdy zB&|85rz}6s+C5G6kly%UyyfP0XiU|xiN?-Zu=m(n(rdqFud9Dl+(Dz&f6nZ7FRsy3 z(>PfO<-Zk_0aVZ>AM14o?n29|0FCz>(!!IA)NdBR<=ZXZ|aLO`V6j=(I`r$Lk>lb@BHDpsh>^&jb!Qg_T9PP-++^0dPqITm-p8^16jVV zT$-JpxVl7bx!HAyWS%>=X1RjN(u;x`)!%P+K@Mp~c9>RFi_meF!>7*8E&?$*FBUvK zwD24KUo+@8MNYyXAo;n20{YZD^T{#R5dNd+eqB0(U^N=wJ@%Z?Wv6rG)xqCbtgYt1 z6!eY)Klq1&&3ld_j|BhZk+8K_rQH9@zZ(hOuD@E@n0Zz9(@yEDiI-@+u6gHZa&6_g zzM1c|ddKkh_;Ais;poK5tbQJ5s|MqbquY43=Axq6?(M^Jzd3z|G%(b41Xyn#4AjsH zQ)X{_MfnT9Qs=$j<5gR=$|b#Arsh_cCI#!NT;pT(D&e!u2HbX?t=baGcbKn_q~Kq} z|7YnD~Zr*WHbFpv9un>ExB4jYkt-Z$;XrW>vLY0E3Y+lN*!1GS#obKVal zkABKvsayX>!X1>b{TrE$MF0Ts-l-OTlC%lC0TqH+j9~I8i>c$Js+V${|j-5 zH`&_QODZF#q*`#9#@pE5z*|YsQW@WHozwBiLAlJ3)Z(oYV9ik5$~J0B*pkpW>F_UV<0V} z-%$T6Gwd&a5uHwM5e*LFXSKzEarMac1Mzu&tE{qL9aN0Ml{Vt)u^AJeCoF{6C!?3o z#Fz2i(>4DNC4Wbbf0AR@gc=0B#H&;=p^AHBH1EEuYlkeK*Np`KEA9?Jh0G4 zp1vUbgQ*ZJ^sm~sV*bT~vo@HE0hM?lq%dPfq&_R1bvS6uB&Lo;Q(4%1_7|?qfY8Z5 z;a`MY=s)1uP(hYs8(rzG8hhsQ+0a4dKtUE>XB-GmFvdpySLSN}%3PxN!l~JfYe1s! z!l{Tx_MhnQFWCE63o&B-)i(dclTe;zSNaF!W1kEzV)xMk5qR>yz2{Uy5n`pD^a*pa zl%;{%jH<{8DJ*@2@OU3 zfo;UWZll_qr`q;eo`P76xc4VE1?J;w1Q%Q6o zY6X);#>GG?`6@x!}9K_Xz<3qAYpjrcvcLlMiTi5 zsKSUh&piH{t8*v^)&h@&MG>u; zAa7Yw?4o5Ilr>Aw8&Eq$*RbnT)({Sr>$Quf;2(+Upa=)BV)Gi^qV!Me25q9tgdmOG zHPU`x*lIk*5RYI|OG)`t&PDyLPlznbNDS(wO@dn?Fy#3J6u-0Z33CF>_}O+4Q{C%p zO|N*!`iz|O$QS3i98bIN&|%WhO8N30Ys)_OUbD zvB&>koN?-+d-OZH=37h)B<| zK}+AaK^!oq8^M{4YEbxm=E2}8fj44kM2`YJHB&`CHTAwh9p?H*Vdu75lec-e-y1-_<;o7=8-CNrAK5qUB!>YjbowBH%jYqW4RY~#sH$-Qs;nb7+h3tYiw>$2O z+};Y%C;|qeK@aQi2MW@KN;8GSN?L#q1cZj2it%CwTZHmNh=1kXoa4`g&Z+a`WI%iw zki8J0+;kZ-ZV&skRBq_GtGUgn5V)(T?d763xyaYjIhL#pSS^{_bFt;jzY&E8zQWig z>f5rRO@Z!`2GMHz9PJ1a2os)f)9CrQVP-$(Wi$5S}uT*hdQ^QUw3nxk~vV~E@x5y1kp z^|tfm7R1zO7S1+#oX||3!qOe*{+Q{`)JeX^r!E%|)W7U}BV%|~n7Ya4cig%0YVxzE zLVIY=C#ubaG{kWB*d}AflJSgy>$JWG0#5jvBu6!%mTsa{43>Dj0=%Ga<9CsSE9dbF8f-J?t}>16 zaWQ@C*J1-Hvv14d7al{yN)3k=L9@E~7~^)-GpOkKT+KK}>NtHOf~Ql(?CFIZ5e*j9 zt&`T(^gfj-IP8wlulLyeL;Fm;+o-XbkUsGjvGkg~x=`K8nnVoj(4BF5>=z1iEO{M* zlw*HI!ywNfc%8Gh;zGyvmgDSh9d;HPQm))ku7#!N?G(^9v)rHUXLwG}5FkM$smqt7 zht?cr*SfYcp~T>T-QXaOUc2}ld_0-El7g0Bc_)3A|C~kK0BDHpN)^B>1vAN@O~**# zzvy%6zxupKo$DEGlAjC3v-LBr|A*TeR1?}sxX}m=xUzzTDw~PTmP~$zbT5CZ%6Tkv z>(JT>|FI_V^!Y|9nTPP5i!n4((#orh-m5bfb!@b+wiF#zUmFFFi;(Cu3YSMZT)zUb ze>Xit!x?!h>lGNltnwGTsb6|YikDaAMxEbu;x{Df3Eh}~bh~npvocr9P-E8+Nc$oKUDXyxe?V8|mzCvVe~cG;N9ZJID3c?;>ZjFM zpa8AsTbHU`!K72PB2~cwZCw#&u**ywx&r8LB0;}(Hruz^*lWLehxImSBJG3s;~WnX zdqT*Xo1+Mr+xzqOH3}JC$Fkki(6%>v;_c5xKG!#x2x@Uf(7QO)M|p<&O!IeSt3oPo zNSlIV(kfpo?1Z8q&JNvVJql;Sw=I=#Y!4w+yB+s!q}kox;54Bfg&K7idH-sCy@W9m zvNHNsX{>WGrc=aw?&1n){r=Q&k2z9H9Q6&K2kPAi!IIDy$T0qg`5hMS{I-0wQ_-XB zkN}o|Qw%*r@uktFcThO8crDyLa&s99OFCbKIfQbz zpIxFYhW-r|c{)_y)qakTXAR=*UhIIkUdvI>&22baR-F2fTG|jcvPmM@%lDH8GYy%= z%}QeG>7<`6i0${b%wj-uqZR_rtGEK3v{zC>SqQrI$Z{X2J?eGazhm%ec)c%A>Fvyn z`xL6jXP`>kC*xtuI3+ejmhQ?)fbtR3n@-H(dxC5H{$({&4klih>$WogT43C_Lql&* z(N(9Q2}UlYex{1iTu*KJL$SMfkCFvfZhl&L++M+S4F2$*+|=D#Gl$yQXpS6#+gJ1U z#&$WPu)k?XhPnw?PC-&<&-dNagxp#k7jgPob`_I!t-SUCBBjWnS2Fq1F7eJw4jzi% zWXskSsjRLr5x7k=M#)j>BxHO_@g)}i@L5;E>k6QN`ZWNKXJC}BEHkQGOISC@Us4D$ zx_UNPV6tyo`G^Vm&Atxz4# zB;T%Tr4}hV;7KKhAwG2#ooGOEq(wfITsnaOIjYf_(-0mriOqR{|DA~~bm@ZjM zAEs$mP4Y!HabA$vxdK1>f!Rt>PcpT{Q%>h%-65;x3%A8XHNkihFDbamKuW6{6XDi_ z@5dD&>gvahSMl1EN<@%Rs~QW@RH}gIY14!{n=i}Wv60W^s_V>@-(N`&*`EAeEkqH} zpxq|9zHh9|N%Cq`c8KOfuSoOL5+bW#6lVLbISlHR%|Q4I_A7nUlQJYfNY=*BGekwb2krqr~c#0>FePq4| zVvribd`R+t7z`!k3i*)>7b^p$hWB1#*qQ7%reFcjm`!+SN5%1Bwz;Y2nXYSLFthVM zt}_~X2XJ3PNRrr44}d@gGtIse;I0u(^c)j{aW7-baCXl-w*JMt+oKQkRgpzzzf zV5HXvQ^ArYn0X4so>yo^r@syM(_or9rIoqAOb6-mzhy2F2hZz?-}uQYMX4&rrXU_Y zj8RR7jgd$P+uBr3mTuQJpGI1UR1WwJ2xcB<2s3Ar`I`KEzRilGv8vjm?=jw=hyv9S~~ac&)U#$g&usr_POcNN=>bT z35Rc&-uxKWw^PE}G9|T-sk)};h)C(1ToBivY@KzguX#&PT7TSi%;O_tTkU$1?Kpq6 zI8_v%q36@v*TWdmzV%6*=VG?qiYajH;~Yml^2|WezE?Vs+)GDZyD6&3oPJ1E%;~7% zjO5Ys{m*M7Q&ugb=X zxoX#m!Q~BMR>vgKpDsSfG=4P+X6roY(ST@^=W2YhKP_rBe@DXxzV|w1+jS@DrL1^* zUfsu0W8a7#SF}7NR1)>LtBS|8Ac0Eggrsw!zcrP>)<+ z?w1L=KEXH2a73*~tIkz3sXhqqB`Jj|R;_VgK%ri_eUsNBMzWY98C7uqJi+aY+dQwZ zasMEmolq3_J>J>S5~UtHsnAciuj!KN@GIr5O!4_R(_+s)$h}jpK+JJ!b8$|64H({0-9-4 zE*GW0LD%xt0opMp@}@C;B*>72T zw7tD%$v3|UXJPWju9|4V&0p8dp(mA`{Yo*dAupiPh}6{!nRo@?%g#kj^~C7<^|JL$ zb^iGNyBI>rs6zM-hVRP@NK#YN6meip3g$*;^XBvaod&@V{Qh-@jj`}s`dfs;ep~1I zf{Tfmi|0c(|JG9E{5Nb|&;49$#(f|?kBPYp`;f%SboIKReEK-y#gESa<42z-Ps071 zBhusH95tKC|5BicQr2pQefjYx0A-Z_kFozZ{`@!e{7dVfN4a9@?Qpd$ej%mr@=Oayeq>cARY1b5{1+AC{~85Rsy1gPf-%x z58W&7jw*QEBP*rQ=0ePjgWJpqAEaId$Kb8$unwOq@N;f!N@->@3@D$%xc!PqhCb-LU&deZ zjwc+tUN%RqGQ2iRa62-(?(^y^BXzs-rY-S8?P+*l1+oPJ-(a^y(oPp?g)dzQp!FQy zzTZ^pMU*!9GJ=osuX+w#2PD${ESNNzDely@snq%p}T8rjW=}z zC=uvghwr!gueZFfJCjOB>^aP6SQg%foJ@y9CtVdu5DP{~vskU9bdTq0wjRtjX?NP4 zv?Nyf7*-N~JPh2kem=$$0%-g?%;pTjn2eOFv0dM1;kO_@^tWZA;54Br!2? z8U;0`KRLh>z%H;EkK+_Z5Q~Cb4Q|t(7q3aowcD~>Y=1CP_}Z*-UmzN_{uQC(O`p0N zlaPZ+;Q8afHukG_wL34Yr_&XuQ!iz3$)P zN9_<*pP^5&9}<4LJJ!1!^nKHw_DG^Y-Wmx^#K}$RMx@v2ZO;@8D0y@l23#vFx}QP| z3~~K@hpFs-e3wJna;_#Y@C$390>RS4ByJ@OBZ&;wfM0^uL5Sa}W3pV!jfoJ)9*-=F-Pcm=8T>hpVClRns9x2frk zFG?KC5qZoJf-0@v^cv;hzWUybJIPv;IfEkzQUp`%| zK5t|IZ@dl%Ufbx?$2@81)=Xml=Kh>Ak3VaRwO-koW$opi7{T zf47NV8v9{kMdIr`;_mmVmdjol+1Eb-QwNT#rvxD(CvsmNmLnQXd9t3)9F~_4Qh9zrvS-3e$ZBJOmM&_(gxu=-R?v;zehl8BL#&( zE_tg$I!6;5v(DQCq~C;BaOif6I2aJn_N4uag?N|h*WMZq=yfJ`<@5xktZhDPv!5j* z72~+%*>-C_+5E!g4RsHd^o+}C;d!qSsA^U6C{{|!{MqSosDvf%w)+H# zX+vvxkzRB+VfOI6ejZ~ynpf8&#fz~yrs!m$>!`wRXVZ#0{TO?ESis^2$V0}-K^a#3Wi7yf;9aa+Yx<)0LzS9C zb4zwH?T>)ShODHlbXf_bSI_$@31AHl_N1~71Nj~rn4R)*3z(I zaPIcLOxCq&42sGdreejRa7)EdLf%lnQg|wp$>ZT-|2Yp+Z;qeUB9PPx@d*K_X#+!| z-Aj2#3)2V z+)65VC0Fqc9!ERg)GXFO@S*y}jP=|-XZ2&YcRt8&{K#SRY5GM`EyBbD|K+p#G~b0m zX0`!nxv$6$ASUh1)P#@$P7Ob2rI{9jY!JHl5()Y3O@5e6!Ff$EC{hEAd6>45O+6>J zZ8?BcH5QM?iiEiVMr&%yP4^#>?X|D?eA_tS8`pJ0E;=gMy6=N>EH$h(UHp`(+pCHy zO?{|szwPl?6MvPnmF-rE^M>boXk-_hcd_$F*FC>Jq}ZtSZhGif z-V0ixV1zG=_3QHqh#Yb?+U1Wy?9T1iZH?%u-kx*53@J(BsU$V{3JnKWZ*c3fVXl`v zoTFU+yF!1Zj}nGvu_GT1kUTT zB@fKf$f5H=&{8U4PSGk@)eZ>{>fBebcgGovp_pa3I%%8yjpmHRCNyVoJw=D;68lRq zK?&y@uk%}Qi|+@y+Xt=B_KN9h(q*2u3WVIR&_2V29$5WohH)^w#5^dp$npn)k9B~!7&f70H(iR&i?ZQ|s1AiR zIo4MlhTDv^=}=W7uw}Ib(}*z8HpS)kWO*D#lUL2WvT?yjQ*-e!96RUqC?AsHd+3BA zJ5&8$Yt80WGEce^we6UbpDtk7qE>ay}lzxTOB2lI+uwjxm3?`^<@$|ZWaowo2?JTY6TPdlGJ7fe> z$-XrinqIb66`=Kro-w;}n`+9sbJt;gyhAZwp%GtL#3=MKP-MgzrqqD%iz?69**j)- zC~W)lLqP%?(ExR5?3^kCb*TcF)953icw9O*HV2p1uWDJ%aaCu~1USE!O!CVzfCm*m zo!4F4G8U>gG~b@obcRgEknXhFOvbLY`TP`s?}v92dSl7m6oIW+V*JCT5-fE!xPz`W zmRhW|gz?qMXqf;4Wfnk47w~Opwj? zP;C0x)QChBL~<9N4had4?C-+Ug*l&C9BQM(-@2a4>Vg~akH`S=H?C?Vdducl zouz&=ilIxefXCyFR^QRL0&bX~We7YmsR~z1cCxo-j#6!G#3l)eD8^VGuVSAMiGW2N zyofA0V|Wdeezj(l!kGT$O?U3Dd&Hy1ZsY)pJyrE}u&Cpt`8^u3@QuUGh3WzZ+2ayP z9fy(d5*BBG?mf@8@1fyjqKdbaj8z{a`OZ8j}_VDjOxfFUs)j9 z4?srac8zB1Y{3+{lDIxmgQq>4rxXAPXg9l&t)6mAlvY*x}{tp+cf-9@j$7~Id zRI0-#%7W`jvPk1!cN=sJJ;(NnyJ(dCGm@W+6npSXH=0QrODwzZ#vY;s&wif@(YdyP zvLH3V#WfYquH~)X_COS@2^sFQ8D>m@+j{U_0x!YrmM2-e7rz_WXXd9dUS?JKvyf4VenN~)FDg^bH%aOk1A?6{ulW=KK1n=`# z2l#qd2EG+R!8Y?99;b4;b3W$7V|w(X2}-ECFM|D&z45bph7#9>4~<4wh$3k!%iEXl z4ka7+Wc#4P^>a{yd>%aJM1;yx>i@DQ^}~449Z|D+N@)AGhHi5+P2s!nyP=v3DLx;| zbMf(TB`GedJCs#w-=QM#mR=g|v|KGQTc|)?(~O(h$-CaAujLL?z7kXo$U0p}T<$+| z%#2>ZDn$xQHD87FdK2aC?^s0*N zRcASHd8o>u8%-7Ns@Jna7X!EIph_RwntktWt5nSISRqf2xup~j$m=&$m)2OPPufW{ z1G#;G5rdeU{nU<0JX5F7OSC^2>dSN9QHmhHL{ z9}U5XxBRQ|NuRNbLkEY{SU-Ej82&lUJ&%AD?n$%K>vZExL*>Tjf8rTMKBIP)ek>iR!S6&w z4+H$oQh+!TD+i!2-Mryi`^BL7MfxN$MU+WH);he7vK-CnqzXm z4v^`mM}j8v9uwdYf3; zp0fBiMV9b3!%_u4V0wZ)JC*SVopuvOJAEBIV&U(247oqNfmxOTVd1o>{%dDzWnC+E z4a7xfj}7m*877SP-P2rY`dAMn$VrKZjV_1xZsRl^)L{jkklaX4-%tA7Y2;ru3E?mD&?4rHTX1Icp^t*(UrG%eDk*{rG6a0FZ9%S zL=&$TC|<$5+ktd~Stg0aWi;qR&7sJgj7H*)e$O=3I|m>kv7*aeHhJVWcQopCzLF;R*F`p z4{`T^H^~aejJ8NLX3*i`#ADkb^0g6qBVAizSz><}lfO^vhZmzca_-@F{>fx)H9nlX ztm>7RObRRK$w~cS5{rW)9z_k=(&(=6{4|2(vE<3dz7e-o3J+jFo*WL}_wp#>_FAUx z;n*jWr>3*bN1dp7#aPRM#C7lLa-N$c8G#6-ZjAO?QyS0=6%k# z)XI=4;=$9xU+X2x*c_~T{)!CTPmc4tceIaA2+YQpSvawx z&1@&DUnhw5;geYIFH)=@ZX4V+Gk<5ehT>utrX2%ePT8}aDkdxtR)hLe1riCZT&K~< z6o&G&l$p2QO$Ye>7A}08i_wKN2A$4JdH*WD?~|T{t2w4f_jESOMS((>%SG*Y(=^AD z?xBi-Z!i5U1&7`oP5RR(o!y|7VHT-ISx-A0`hC)&l$}F&Z5lTYBVfgkOPTNdO^{*F zOCt6VU8;@S{pL_`)!QU2&csRaqdkWsRPDtAMpJvL6|{#$ljd}n8&e!&29yy4hm9dP>N8KXeBL^B@B4R)_p`h`b z3ec>;mz%Mcn-M;D!|^(%`u&j%^sDmQn^hRyHn4>0;iVme;o3~Mdc#)xkOsd2m!0hF z9vVW@57l&^n+y`n=A)rt-Pt4&&PT7a%}!ZzUUH~+bb+3sc*!n%o?AA~WtZ7#R)Xzm zS$!3=72^!P@T3YYiBUy*%^OGsLwEN-0epfMHwrk;7lo$-;mX$~c$j}m%pgG|EAR10 z#WyiyLFfHRljyoMZYMf`FP(=+$y-eYVMF-x<`a?cJ_3TBt8Eb7m7NRa@9m~Z3j}SV zth~leE+9} zzJB%lzv^LO{-yb+9_3pcp>WAaRyhT^q&EnmK8;Dv5Gj?hR?0sWl(?U3Dj8|dD4M@h z>0{?_D6au796ZGp*Vf-;{drx#VBkNh#8Q5+ekC^LOz8Oi^gh>K$1S>k=8;Cl!tO-y zUm5a6`a{W@CIGYptZwO_%b%TB!Ip@s%lIgolq-%LDHH_-c^)bp&HNrq}eLgkcbiVr^yovSYL76#fR zom@RNL5zJ5PabLhbn09oyND@NGSX2esgks(IyWR-;T-`31r7typ^Hntp>QZpv!9(m zxe%d(2`LK{y)hebbncVdUh<%Dt*Wx@`|n6X;GRb@zpt@ehD$rhK+~OX%)OO`APA66 z!MoFhR(^nmlyr163%lf7U0;0ug#Bj_xM$!0;|9B3thbjdHwJu%_0DvXGuzL>$(&QyZX&S;dL3GYRz&ypfL@s;RS8G;1C$o6_Cq`2 zgFclQA%CW^Ztz<4MF%V_0oNt5YJFSK4nn59cmhf5Hv{qgNviS3Wng2W&X<5FrvETR ziNDwYE_mp=waZl6DOI&P!$$2nLF;iWjrV~88n2qKn2q1oPBaxA2DsR@(dy{&b47xo z_UjLkhU)4xD z`bRFl{0easLYQWxDe^|=K-v2KtGsq_`ig40;FCW*yTwwV6Ub8J7=*2X6;PQbdQpg| zWn&RD-a`1iPBX?s0QSVL8$5$B23f;|Qo+jdL_*g5;2v9YX#L;%(%SDPgwLpd zUK%wiSiH=LS2K{t854M?G68)QD|Ew~nWsG4L2RVNe1b0kK2sQkf-qjQF<`UtL=HEd zZk^O<)ET>O5o$${fv6fG=nW_I;`F|{OeqwPDCdqS5au*aznB?ef>b#{@n%RNAp;k>3Iqp^X zT@PNjH*W(88vRQC2&g4;ujsi38?n->J9-}7aPx`jW|=!)(XG^Ikr%Qxhi-a8aNZCg?mhcKzCcH$T<(z7=03Z&Jnnp61 z!kH-%{Ot~0)mO;Co0i)|x;M=Moy8fY+6u~+QR)>K`_SyD6wGee!YC$F#O9-dp*N{( ze0hxoAZ^aHfp!ZwAQ?2_FZhS*IqTTcxv$Fzc2e!f<}d=K&+iwV}dPcMm3QUzZM_6T^0;xQe?)Wm5y6~X#aVP;tQBm)xJ=mD( ze}S7;CxGsZQ*{Aml&-bnv{l9z>)gC^Ci?XNtIq$y8>PFFmO))BQ03@{CT})9oCfC= za+{Ga?DAu6xZj*hI$g5)fhv*hiRIE!*pIo3chv|aGkvUimQ5&s1wF=>q`R^*V}1ok zovmBzCLdN^)RG_Qz>HEUi%O6G_fJ0=6Zd+V>8exajN0(}iKYhE}NW+27*Gu-M2R2?xh>Zi1&GQJ4!X0}<1fc7ko&XTUXQ_Y93(h1C-kC|+QnB%J#5f6g&og}I`1CbkY) z6y)&VyhR8e5qvmR*G1{>aQ)Ws%NHf)g=Cp20j?1z!-U8zA{lcYCx`Mg>QL#c6KN>- zH@Q1aWtnv)Sm3S{zI)Qj^O}#a*_C)Uy+cO@-!mK0hs_RZJ47E_l zt;s=!?Pj)YVt*Nb%>fr3dmZhWKG#8qblCP7g=d5-`dw=bbL8vI1;&dxn)5aNr=R~R z2sHm6VE1qTLqa4)pJC<;8O;ADuHe{o`=Vf$ zR5LD}E)F`xz+Vm|2=-P@rH{GK;lSIjYCiSE=rE8R@dD!7Gb_ASeeJ{k_V^3`^Z2r$ zz)sXw4%70HRUTo~krwZ*U#I*7;FSt33^W8#I?#eEGhlCEmHxgc|Ne`!Kr@RH8^g*Tz!M^eIp z=j%oNrF$@C^B zSPnx#b^XrQ_LKtl=T(3GeQi2AV4W#WFbn?Qw z`~H+tsxfLT2GyWkKLryD-Xa$jhL=Der;@wx*X>!WEBpAi*4*>LFL9(Sew3y{H!ZYK z_1?9(40=)2UsV|xg$KRtBbtK9_t_}f(1VVNY`(UL!#Y9^apmBZoDr$pRi^XMLXQgK zT_;6}{XjLgvy3EnA6@!m5__b%3W3qnITI*%&GaE7w|bMo4w_rKb}F-*JM=YpC(7 zR(8HMOWWpk0wPbxl)JT|mfIu8LDwIP3I{cIxu82z$q#x1AiLI{N~yV7Rb)nQu<)$` z5usTYhy_)4TH!HyM~`IWoYl`fHOqm`P1l{^eSr+&)u^%3sLjAIP}kJ(P*Hnv=QY6h zQtBCvkJ!NST<_}bnP1P(g6A_vzFg=dF}=6(vVT(HwBJfda#U*Oaer4EYhi6E$Qftx zn>Hx-iukqucffrfN+ZWt2PPEbj>=x=-5Um5$1d?bW-Oq6gIV$0>>PQ)I~6sk;3lTq>y}Jg3ge_mu9S^L@^t{+3*FwIX>_Nr(g? z^QBgz4TGTwMr>5`bKcpa?~pTdy((uyvVDt?On}?tp?IK0{|!jT`1BFNKk9c=&9xuX zx#+0Qt{=My<|>CMm{kk7CkAKTMx-;koz7BysPx;6J2BeL_@KcKCWLS0hzpuo?(sM} zSapG>35wXZxJ?hU`doBnvFNU$L){L>i3Z=O<&JtJBY5U_jgEp80dKyRyP zV-y>qk2iUXUec`kV~68kQ3ODS!!-(Lmo$ye?RRuhcCq$QU_T+0?&7PJ>X)d1B!UFk z$6T7qXjEVP(J#u@k#FgsVl((gcO&Db&Cui?(b)zIJ2YX}eF?keyzjvK@``v72g0v_ zo+Z*a7ot3B)jb|9emXQxGCfT64EujKdcixjXMH-9DzZ0cShebGmoMu+SpkddhAVT| zZ)q=kS-sWp;2s9wVi!`84>^p`b)dnzA+h{m z$N{}?f5$Ye`oyTk;9?9p>t2QSv@9ex+O%iv zXx7vny4zm;@-EGr#|1Oo^sysYu$1Db1$-6dL?du*)0Z`1rf<|>IYJS|!{u!tZ*!BX z-_Li;Cd27e4B#UxfxEJRh+!UeUVg!{5PAMoCk2L$hkAhiSr972{kBirkYIcqk4nI< zxDw3JGD-h|psdM$`bW4ry$R*Pc(>&(V55vW^B%oG21)iOQ&QO)7Mkl}<6~lxJM(#% z?I`jArxT_Q)q8J!W)y8y-xq8C)3yBIo8p5=ttCn48U^>e#4F^d{C;>^!(K4Se4TV} z&D(JMgmRU>E|#p*kwJD(e*5=p=z<2CZOCIT{gRP8l?RzdJt}D;Y)R{(fNwQzJ3FV{ zm;32G;9Row&8#jbWuy=Rz6?r8%;NpD{k`>kcXoionZWF8oTuLE^bx1G!owbUg8mRE z8n>w1u!kp-ZeoAE_3&P)Nd<*$vEWY;Tqh@$jO;Xpxgzd0yX>s78@rA}7~+e!Za%LX ztK4bfU`qAWTDGq2`}>$>QQl=<71 zg`z9kSJ+iwGU#q*dz}%f7ohUfw){Y!1d34eBm8d;lLC0e0YJpZy9RE*g1E7? z)IeB@Y_j^`_)OGV#zIBT(B)1t5p6E>=fQ}X@||PFM(c(8y{(6_K$Lw47#ds`O0H`U zQ#^m=FOnJqI6=Bxep%slI^C8!16^AhVD?K!0HBpIqr(0BSJ0CDabWj*TBOR|gM|i% z&EgEf*DUIy1{y5b!P>q4g1FZ8>LnSFDqF(9Kr{zLfUKhf6g^|+tj=Y<+I$VseDB?W zWFBp8EDPsS7cYHXv*FL+6Yrd|nNsU9dqlCXwBvE!l@@PZzj%`fRNw3-D-r*IeMxZU z-;)xg$bO7kwSPaT8CXBjnC`M4O5!zmmp;uAt#QF2e)^@c^x@h>+?p?Dp~76Dw)LAH zuzaU-sC+ZO5V5DHPqD}YKkDE+N`N}oy&2Mc`mlvWgDe4m7VDb^canBIlHREqw*mui zhN`ol>?iXXXbaJH@Na^qmzu`kQW4R|19N{mY1oObvCXhp9I~eB*rOKJOHp(5DM)X0 zbFZB?EgX|+cPR+s{@yAc+Fhp2OrCxA?L0xpM_8^GZriL*<9f!K4gx^a9t>QqEF%zi z;=R|ytN!v)P}6%bGn0Rf9arS^0Ko8Zz?jT|Rf2OF#LbqoC7Y@(Ty)_%AwUuKqet~} z*N4`@vKaS84!BoUv=C}(eqqy)Zy5CfPZSA~q|=pqHo;h3n37pX=d6b#(Ur(dI}2R1bOL`Xkp$>@6O4-i2M^Oq`LeGaCT zC-!=NR|Gp>LgaF}v#b@i+sre9Ai2uLWjZ0wQ*uW{bU|tJ`XbKc#>(6XfxbngLkI;015D2w_{*N9fjyYEL=f(rRkW`0T#$(cc8F3kuq_ z1(_6M;xukAaHbL5+C8qA zndR)$TW>GquqRw*9~ql@t~1N63{#Jnr|MNA$1;wMd!Wb_AP#fM9P8$g)HR~O>RGnl8lRt;ZY&e~^zkr{E3EC6ZAL4e(b9!b1$m-KWKDsbp+`RW*mdo(+CcBpH*5!0G9fm3VFhG^= z$d#n+OFOsH(!1}^@E@d&c~|taFBn6bGNDYjFX))RVY7kfb_7c`$~RSOIYX%P*saV# zY)Gvuu(hGk4Qp&IlEEFKn|bTZl9S3P`_g97YniKvRSZm_=lSZtC4b)>E;yvWoB)2? zHj{I&iP83#uNw!QcpX_kBoZQUiHPhv9ZT->dPcwjHN!{;rbhg9wS?{GQcTOI zZNeLS$R443PW!F%DsqHI(Fw)hK!cL?pD+|PwCDj=#CXK%*U* z08Jo;YCgk=XttHUWJ|2pPIa_TyjjXzl2iS-(>GmUvY;xOkciP*kIwU=S98dJ^7;g1 zOp0TQhb@TnJx=<@O)pZSg!oS!BO;y5QOktiYOvY_FQsF*nZnBxw+im1x#1rz@>Llw zbd^450iRKNyiwPbwdn69){7QL#d=+#*Qw``W@Vl_zmJsqm@*n<@o?heg7tGx(xn2d;ZYuZ6m1A!=w+3z2=C?<0y{e#HWMLyl8 zUtnA&CM3@jY8;Mu9K*W38Y-k@rTNDv)cgtf>O4a)RP<)q$GjW{{w*s0D@a1vt+ zk2K0!9fXdfg{ow2Ty5NGY>P|+JAmx3{M#!HO6a;x6rG!{+WCA9}U{#-O;=`znss?V#{v z$a3D|-L>%G>DcZhvHND7?-*-m%};+m*|G5~g8N;1Z-|=xP!;%y$#^wehU;y?`i4L} zTL!uW0xd#ryp zxvQP}OuD^@@Ru~pOR5GeCuoZwqX~LO5bNen+as6l<=8HKe%RUBv;mwooYClQiN~l$ z%)(TqAWaF~lyIg3SbTKv2x6Z5dJ-TjYDdTH@j!5p1q^)QuMOt!Kk$ki_f&RcjJnkANWy#4OY9TWvK8w#UsvD?F-_oEsLfXf_hZv%<-dOgk~_ha9C)2At}#M4v_?}uJDa8v$k zyQL^?{#}95fm%PfrX_pqojQe1wYE&|4Zv2?Ubu$NCWfX!%6ko@zl%mhjkWflr^{di zK1mO4Tc@8vqqp%Sa$CtDCTcl1+*$Kb5qz%uZcxwP-;4}PFp$xBuocXm_X*omZ#n~H z>H})>(U~4zdw6&_Kb(A9dKu?&`Kyg2DP0JtD-n0rtyWrYopme1eUk2aTcvTe^~M(P zgf7kegDsETj(;)1bIEi3qYZ1GV;ya7SdsM#A>|;)<9?e5l$Ex|2=T3qTH-kuy6i^@hql;4CnZ-D-9} zsL#_Og{94-EX2mwb9*(TVX2^nD9_6>pmYg_)~wgvg;UP7{M= zB(x`wW~u4!dZv<&O`B~lQ%98lrkwwep_s#NDg#iQS%62j*vAo z?;t@RH7TmsEB%Ui&XVC8PrR1A9siB2qAp7sjmbuQtQrQK_brxkNIhRqkC>YGka}Ir zVr=_D9PHgIIKPInX*sDM>YPn;Roau*Ko_|R{BYLomX9Gcx%Z9>gf@~c zTe<@in*J(2J?(;n+FyKkhwx8676EOhcgb^Dq#?N_mwC0Dm|P(O5%)tk*?O!Y9a%m; z+GngqSB`+Ks;8&BV5YB=4bok!a>L1R>#af<9TJ9yUwTC#!dvkwSQxMYVMz2{_Vif} zkeRHU3F+B7n56SA>0C`3$?TT5VmH*3m}bxmWoz(A^i4mE_%tEgZ(jU*Rw-Y$Gq z8YCgEVfccXC_(j|;Od6O8z88tMLx+$)y@UyPMdbYQ7>@G=CM$4Zu=wI+i%qp;JwD! z7ikW(NYWcfXyyIBT>^A~G=9}pemJ|c99jFDx{YbKcm@R|4e2D?!@3d#A+}=)M#O4x zmEzh~^1DhEhU~!NJBIgA=@=$y-^_jErrE=wnI#1dxuSkFZ*(KnOY#RClVUMLm)O$C zA1A{J&CQz_ALWp9T}nE^|}!Bi07gh9HK+C%-~D>z|`@oMJ{S< z(zOcLfD^v07L84#IZ=5>gKO00&K~`w^bviLu?(`GnB*YiaCQ9h4H)J133((%7}vzUT=-= zl!K*-CIV**){Eg zxVyVsaCZ#^2^K zdROh*RdroeRngXG!E|*oXQvPdgPcLq54gZ)vi2jvV;SnPNDN8zzzf_s?5uv#;7m8e zuxsp8dT-7J_AuQ8$2Ey5$|w&VAU?cY_TbF(C!W9GVitYefNIv+i1`70;}=ooWgTKIHOA_;tA2W_RNo|Ntm z#ED&>OL|UKUTT8tKFmPc;0}!m3ZHZRbC7kIsX-)d`Xce=PLcxTob5x7qrM``K6 zO}*o`naO}dB+H2JAb1_6Mgh413oh~d6iDv`^OE50Il>g}Btm&zs`g`c!W;HJAvKPx z=?s-e%BLi5v<_Qgf0VUZPPySnUWFLxAR6Y{C^vHA!F6eB)Y=RDQFHJrv3kG!Wj>LZ zhFd5;%PhM~QzT~Z`;*EKcvsFAXohsI0FZL@-UJqWtHZ3_R0MD8JE`EvcT3zCP4>gY zt6T>{NjOXH*he!3ibkJvbG%=j*WEYeM7638ABgF%y0PDkRKk(fm=DXAr&onylgUZD zT2H6#%+{1q!1L-hUNr--GrP9vZ+BLml(vFmqQj7rss+Bel3bY8=8lAmG+k`Z2$ZLi z7aGHE2;UQl?Va>}_~XjP0bue}Ol43{aDLNe3c4)iotlnkL~LY#)LkzQn@{+j;(x4m zh0`XobNz@2CtsY8HhK>aB= z-lQ}d9!sSeZZ_JI&Z;p|sBYT^+$kgsfoQ;XD4-m133RM%)^CCX^h>MWT$Gb(EF zP*G;|RMVEcLwJ@gN{}iwQLQ$B{;-nq2^r89Vr-g^b&mKsC9^N0^ra)#$~~+^D;v&2 zj;1#S=4!s$(S-LyVy4v}^BZ3Df5*-Gms9nd5&k_y879f!xPS@_Wr5>{zlW04|H-QQ zlfVDp0cZ+4L7b^%7XiaNkrv+wQV2p2r4`lH0=4aR;PK(XjH?U`CH^SjGSBCj@+qa+ z>f`^g!>B%I77O_C%8FcJjSDfNVke`juJ6wrHZ{&^!RQ-m>aQr(RkXS>3nxh~mV>xx zK5J>H;gH3m&s<@lC1N5aVId`MEgFE+vMyH7Zv$~g1ax2w>KmDGmU-*Cd9L9LziXI1 zf0#|Ox;$yKbc>o+dBT5IR93MFgl0YyxPTuV9E}?eNwMy_db6CD5wAU^X$sF`u+a7Mmr-zW6)I5BRw zLq1sD>fQ<_amjgTV(yysExJ|~j1+&>Ug)YPwHA_MAfSK%V{1hR{JFWn%SM$TNl0rU zoVZ(;=sN{PJ^KGBPF$MvrH{JePg5wWNN;=l=fNzI>O;ufMY*yV@u zXax17`L)K>CjRdY4HLp*!s!W6FwkI&ok?}_UR8A#Nrm(KYXk zKgH)rwlCo*4NPvAYBgR5NXjzmj`E!=<*j>+rCbe`C@38a1vdP;8d=cw)boK-G(t+b z?@;$R1H$4!r^u1GcA>r$x@pB{TKm4;xadB4zWe1z--fKd>iv$s_G8dXqQaXejGpbm zxXl8YoT-QKL_>`x*OA3KO6E}A8XlK>r&}r9OPQAg5Y^0H%7?rUFt(GIQ^DDFGJebV zmYzpEfVF%<-{JGZ+XK*v_Qco~-;KO7BEakW(DWl2cg|S=o_PRK^>YT8nWv;+ja6WQ z#mx3Q-sqU#~O=jz5M;y?9b>)%`AA;rr@-b49CtxlDzn z?BR!RC%;ufpJkveVKjc=#3gd1WE28jjxG60&6XSch$)#xCMyxBdhonyv*D3 ze%QF^Z#ZTcdDkgGl{uek1#!XMQWLyF3`I(l#h-N5f`CDn19M&YyBp{BstXL9#ZQ8NfRkA~%yU0i z7B~~Q2LM{2Ejll1c6g#3R(>g@}Ffv*5nqe zqff2T-Mw1yrNY<8+q4)OGUdJ{qA@ai>l)paPa$klWx!IwtqH=lmVVDb@H3wuAD~St zyq@AicKy=|NAs&o<{ztVnIJ0+XRlS>5=uITW;@p)YKNKlE%ZSj64qp;-Im5}TTD_+ z|K=d}LI1(&M&w=Dc76(C{=)V58oK1M*kHzyp#uG556WF)jqC=Gv)_^o#zrZ0y#*?K zyxDWdATmy!T2IvA%iAL2wwO1O8w5Sg{j2RFw#S1*ro;I9mK~9_*=FAe&^O7gdA87G z_LnpI)Y(nXkhcrib74=`Q|zcrQmOp;YGyW3Jrw_GZ`gE9EF?IDAA7*-J}|_+c3ug# z0!LLH|5}0iaC`V#`Sqx{Rt_uG@l&FF=k~DcSfzn(IFiw;6r&1;h{fFL2^m znaameX5(6;Yjj=F1;9Bs2h+n9ZP#J*c_Fgp^&7fCE(m&YgE&d=_snp__K?cj!W>@H zzjSoPec7{q(2#$MbC>x=17u^%|4EY3t=YP8ZYfPyyFKbfZZzrlzP$A@RELCJR?gu2 z!4Iii{d7#?E*@-;-59ynL{A2MksAu-^%{RBvZutCHX#Wro?&2_drIaWqKq2Y3X>`0 z=<1==lPbQa#VPQWExDt1EZ}{V-@4Tae ziU2n{8N+{@9Rx>6ld7BRt)-WSAy4-fv-XO`DkPZc4uCp5ZJ89U!Ch-^py25b)T#VI z=;Xw1#7OQ&^;t_(4JX6^i(zhoZjSO(TC72e#yr|}nCr)dqNmdlmp{DV-_cZ}(9Lu~ z@(MAHrvmG}1+5IBzY@fDk|Z|R&5S-g*$|lbg_f*xI zTS1^Pi4yhJD8f$ycIY*7giU?9(#1~4x+*34&5i7MF1t~au$>_zQeQUUJ&xDm39mYi zFPkXmqX27JGdQXwig8 z)KcY!G6P{oe_i}fdOqyXn~$12MB>diam4`&{+s()n`e3E=eh(Q*i|vH%zeESGdsH9wy%cuK#t?67`Q~a(y^jzMfbZr^;ZS=++A$0hT zdXZb!+82g&x@}W1=;QU^xB4ia;IB)+t-gCqA-CEu@@e0967Z^R#X(-U?Z%T_Vbzg5|H|@KFEFJnQ>#Dzw3>~{NCX?yG zcW90dXJ>CG-P5TwKI?oiMo%e7*7rCBk@!D}oNZQRNvQ2hDts0pnhg{SSsf=mXZuzB zGM3^+be1cVWL!zX=*;f zW2tsCc*NVrVQ%82pPHg-<-!}RN=t-{;>+QfdD2;)^B7W+S+d&8QCC!sZVldSEUV$% zTaDYs<=44;@&Ud5)zmfwe-s4jlQvl6@Z6vLCG*S6^D1?gR<~wWbH#Pvw933Hie0vQ zgSVx43GPC~WufE-m2Bf%CeC9a0)BEjVt>ECzTCWI7XUts>@y97oQ0*H!qA-3=P4LwLCSiHg@-9F6c7StJ&=DBGfKqCUlvaIi$qBDmqO z^-g2D#Wkl)vnwh3!z|2QhAhe{*uLVing*I}0N_c&HFuoD2GlbK_BhYa>~2!3ES$%c zsE73=rshw-JfexziBA5tfdZfZDW6a?(#JyMF>8rppIeFe?PiQ&13&tOZuJF&FB<9d z7t1^!)983jMGP6W7QyxBkvieV zA~g)%xK85GNu ztrK|Mc)S^&y1#Nb zP)yBOJq{)4P?ff8*p@G^OXxn`&YEb03`HfVGo8qu+1ifo%XpOt+yoZTG{#v}&$o|{ z*a_f5aK|Qz%l95$yadieCKv|t*ACzIb~|3;*~S4u+t_To4W#>yK+QM9cKB`S^+7B2 zS|dX78+Ko}SBj+78|1SL_0Uw&MEEy10WmKxM7>xd`RN5W$El5dRTG=ih#zj{OMdrCPqeQgs^fC*hG?ar5TN1v*Nz(xbngiU?zATe0HoZeRkzLe zkuaT?Dc*%<*)-JXD?v&?lmzX{MaA8SFB(w zB=KXXMc%6f#U>C=jt94C;oU|S)>xVmX*IZVSP8d5R+`-AW#=WmB)kot(nBxW^W;y` z3Z=dIybA2bZgd?lWnY0Md3CmJh}PgCH-dnTM!p#NnUwTtWC*&qq_ObO+{ zr0S)-j=PPCUwl2|!w)_FFi|-!#Ji711Ff*uXxy2AfoZ!z^e7k#!9p1@wBVlys5FLj=R&A98)NjL5drkod$u~smb->4_A8C)I7$C-UWxT4QRFi!4pfz4-WG%&<%Fn3;R*o8gPgXD|K7$j7c`+~60FsSfK>ER|^ z2ypEp{YVcNXRC92_WIX&@j_ORF7*ZIbbI5E0mDc|EZ*Odm3%{0_I0{((UpMJefRcD zdSW>b77Un|A38Pn+~&s4l{T~p=AOAeK! z2zYiB6D0C2%YCht=IT92&_-rD6}u~4d=(pGedH7S;}#tz)}afFm4}nvaxU!ps9Puwc@e<`+~ug&Py`j3ro<$q<}`9<4;H`>|R_ua`8`1U{sdNpK? zmg2ezqB(gk9Rj&C43<#2=#wuK5--&sW7ttzU6Ew){e+JB0Op=f*6WEatM64c6`p&7@4>U}wsA{~s2lR8*hK%O4$(EM&2STEbc)ZSeXT2UvuJX|& zU-7mLS^&HB+LaNxX|shVy!O|}eBQ=PLEq33m(beVch%MbmplTsM+^GU9coz@m#jtA(%^1H4L>B4_C(xh!B%M?^Egi)cI*}8T z>$qTB6|4?K`!v)TOY%&u4RCbVgt?*DmEu*dtijK&GzrWX8SeS4@|+l)J#1(xjE|W} z{*wD_Dwx$^yHl{_b)o@+Rc`YZj1{>c(J|fqjG>TkRl$C)>wz4(fQJD%{kpoNCuL&_ zXzt^i++1=~yu&qq)nco>xaqi>bqdsI6p&3H{kGl;@--E`W!)FPVI!M(iBD{gi4v)z zzCLS={0f5TUzyQ(+pZJ7G$-G~rJZb0Cq61p^K=hF7Tv76r@PLk&-!(~z{J3#dbs{O z!;BRWw7GllBzNSQwBsdr#^-+$;HLT2W3b`O@4rN6V(dcTPC;u-HH`v?O5`g3%%WKx zmXd-~T#Q65nEeoRr!k)ss-N`gF#_HJtoD_CA; z5JV%F`=NCjJ?-|UH3P{w_5Om`pW`y1}m)t-E5~eHFrLp@dW%YTt z<>1**7MyF)p;<<{&1mo8HRJ(Su=ZFMKlZp4Zdyxj0AtO>3lh>=?NaI}V$sz?cV zLHy11?u-0xc*ynct_xLZ4oguP{PC>gSnZG2gdpA5@UGM_p0l^t!VgMyq;-JJI%f4+FiS7l$%dCEcF6wMAkX1dfuTSm+13~C?Z zwoMyT*IHqxDT#PQuHQZq2%(e zU=?=9_BSQTM(|c1{N%6b%w-n&6dpV3xh}~LEBNL=p_-$$;Hq^$dm8ZV5nzl0rZBO{ zK;En?^n@@m`ll7!1icT9D;SojTFRiIG1;^#rF@}2uHdm0fv@?m>xrPuxpK|$Br-I9#{%^s5c1|M>@KbMz}$kz(n z&z{o|UbtZceYMZ{6;xRcwE`@b9h!g$aOkaRnpU${XeNKNJ26sVH09g(fotHF;~M12 zwJH|UV`VA3%2arnNTSjEFV0j+xO6yz7g^ok@{-&b{^AjiG@l_@rUQzZnBG+|Jg=Yb z;+lL>`_Zr&6@$_xPJLFy4~}}~vxb$^Mo2@27-RH*UjB@oBdW{j(fpehrjBk`2H)WY zQjB(`0rGoFO3>Q;jXFC9$|qG-#i2W!2k3!y^)tyF2wwm-_-%rEB%pj@?Z(EoqNo@J zF*`cqJ~7x&4p>Eh>)-k>4_F$qRu=gh{sqT+?Y-P3uc2X@?#bTeEG7cUih(NoWSB)- zZ*P6hx@x&!4WU4W3iyw%>i3~vzn1Vslu= zt&!@_t@(1Xa*8WW=;wxC+C4MM2@DNaCKo?NPk341_Qb#>3)z~9ZDIK8y&7aITj3Cn zwfzm@Wstst%h53V&PxW)!l-!5QC(Xi`$H_dxAd))kBf}bTyt}=9c22p?)iAFK9tQD zJxV$LE24i2;CI5XQMe6*1Nk#l_;olQT3sGm$=>G}RfyenRWD^Uu2wa!I%ud7WH+!< zwFT8lhZg>t{|i%qt3vVOs6R8{P~afx*EHD+<~hjFVxYJq!nDE!>UQT)Y}q4>AT`{5 z(TX-mNhttNDjwLfH)?395?Tc#FHj)q_mp~40}Di?Df9m(_2=`!r%#M|&iB|)gP|6S z?5D07<>sp@4*x;4tR^aPHT5+On7e~|D7&{H+WU#R)rvk8=I}gW$_)Nh*;+X8{&4() zphem3kMYxTiaK%rFCDXe#QGHRUsNw_P&wg$s>{N^SN^XmJPT7p_-R8>uAz`z6Wq`B z&71^Npm|Ad@ZjS@1_>vYb(u)2+BOAc4UN6JdUe5-a~Xk6Rre_$M>|1_M%oy*G+(O2LIlWx6{ryU z$1d+K1E;&7N--W75%(r?3?7l*ORzh!QW?0r&Vb7jZ)st8y_JB?EI~cbG3KrySj4>M z?s1#>dO~*r|Dvc&i55Z%_Ipa7w)>CGvkF}6=$(YfM_K`nDbM?Dq1 zRgq)=s6MKz8&UHtm%+)L6)>@3)a44eRu}{Kps(rwZcqb(%rq%$vW0{PsEI2Uzebtr zRay}RrcD0!|7EwQqQFL{Kq}eNpXhzDykKoNZowu^sN-Q>30ArZ$2~Z)c9Cv446D9{7 zA_Q)ADjddGE%wnw9k$g>PduKwj>M!cCl^&M#5?!DTO@ssvHV)zr)M+}rQBs&LiMRO zT7OHhyI#urGMu397~u<#Dgo^kwm|{f)($rOcTDVl)vwVDQS@zX=|9mc8_W&}@!3u6 zSCXFdPrR}v;Vwl6*l9-eSElD?YYB&k=+O(Es-ydlEL&r6nxithQ&?4IZC*l9Z-;IO zMZV9PGUwMf0mEXe15Gx=?Boj}=^dVW&w9BxFchOiYy-@wkw@(y_|VMw*dgy8?e@vV4+E-0fJ zst$7VMm$4n5*w!S7IkMFd#F^NiNE2fE`JS@S4sJJLQjC zY#%Ao{oJ}xsdkG+l;k>)=s==2h0krh9MUJl?Me`p2mg@l8wRdN9n{TTuB9DEQH^^h zv-4364-W4!z^J61@tGy?>?J19p|@8G#lV#p-JEeDGpr8bd}_?9jjY4I!l|(ZF*_n3 zSB-LqC}hfp7Hi|FyKXj8YPvgZY`1--tPYJf(zvgeN{6&WO5!xT`#AjKN1fP?O~=X3 zm8RbZcSZF6IlE{n~g#wpXYvT-|<7 zbn52w5?Heh_N6;v6{5vb0xt2kpHL~d=6kj3LjWSLFY zC^PjGp=DCY zixT$uIQ`uBPb4kd`=K3b**vhOhV(6MOh{4(nwl5eYqk6{FPlHl$WJFd>#=B zG`y2zJfJnJ_60j)#Pnk4ca-@?Z3PRX{|_Z<0|+J{8vIg&oN{FLj0rNv*k98JmZNd`1=03*5}p&x z{dU9XSaX;L6c0Wq_`vc=;J?!WH!s!WgtHZl5e?p23SWG!b0^6>OeJmGm*vmJ4s+u3 zQL{)@GBlhaWcMYo2~7O*ctT^0X1aQT#?K)~=#FLNnU95g_X*#2VAEhtE+V>n+|85u zLqnltabW*;KmmsEr_p##y{p|DH%+y=a{$7NQ2pm4r}(tQkchv8nHbi%3cvb7{f1_spP%Y+zm@bIY{^mAnbUo57_C@Wg6guuX zfwaZuYzb>O2;>rL%II|Y%EdRn%QCPRV^Aq%#GZk;Xt*lG2UB&#*iKv(HW-VL#JpY8 z6rLf>nSNqWI8_CBH)7^H*qxg=syiPOl>kJv)f=U+X~6DQdb-q+<9x*#^^7DuK5}qN z?QNoW`DIn1;3!Ybjsnd!oNK-p>7BSwd2%w?xD`&2<-$C)Ca&m?>>o6QqImw^)0)(V z%ZRcvGk9jhe<1X4pLU?lnZit#+HIJQHYAFQV=6VJ20OT_ zc}~Q`Oy%s`P!%B^zgVkcv>o3I1)9BNiRe-h&^6v*TB@rV)(iPJh7VwS<;G4P+f{Y2 zZ4-0IrO;`JqN{CdY)@n@epx%vQ(R+G&V?hUf@c!%8%FnVQ6&&Ls_}GC7aLgl68)!)NrzjDm9KHNq_BZ;gscUXo z1AafDTc{%#3L)D+79X*6$|#Oy*-*8cI0;r^Qf+Qr59woMip0FbZr(1|7>r1yh>)%*l``0;5F=W0CV{Z->1SQ&aFck^b6+3)>^o&Tmj$v|A{ZJF{fUZ&DJi6M zTtp1E?*#%6?G5>uk9^=4HI|V!(KS+7>0PlGCnEL#G9w3Z9w7uZK2|zV#Tdi;S3^8p z7XhA4kM3pDqjzi<+q5p>d^d&R2a6YFC;70Nlm0BeC%&7C*n7e0#dCF@W)A?h)&$atFfzjMgES39Q$G53F9&WmMkXt`L zbeCHpoYU8#K3%|d+4p1Z2Te;0si>fBh6ZlgGqj%X2OE%!cEEB>^){lKbK5s(KELwH z@gLBKh0oMw^EIEv9`^2Xr^U?FsQpK|cZ#N=*eFYTI-0{Cwv4I(FN9qbCtr`OCptx1 zg}VCmc$HPOfFFq#tOqzMxj6P$BT8yvm>98T9GM?@CmS8F$yj(%tVP(Usv=RjcyVW7 z)usdkS|TY>M_Kn1Qg(a6Zd+~O*DK7dxnCFzed;X(ja4GyXKQtbt*0^6#ggxItuW5+ z6m0}a+WLW|AQZqUq}ogP%MM}hj>2}S#MJBwL*95qp-wY)N&An_u^$ExX%Tvz35Q_R zN&VydMs+&kipii4`8^rw%@!~c;uN{bc&(RX?A&IX;Wu1)ZGVH!nr>9Kb#?tuG%atz z!#yHXZ(UgxIp{VgLC&MucgAy42~!+IMN9kK{%MRyJ+9l>MjRHMNup^pbhmVvY;`f? zQKETV5NczLqOb0>JX1q7EuC7bJs_*%bd|=8R>j>L!s+n1LIny3Ct5XYqEwDxZ_v~* zxhQXCU@jJ$NVBuStVP112C=-U4Bw>t#dW3SVtrW>rOh+}w*@ESFWzN0x!%PIQUtH! zqTBRZxu^lTxmbW#Z>GY>D7l}NA^*f||M|!y4t4}#RDLwuT$+qooMG+E%nC4Q@E0M{ zdNMaG%!u>>mT>>zRfk3v^AAQ>;WFcps6f_wCP3pU`^yC&A-ROhvC&h#A6IoRNjJ2m zJ{h?3tUS3)nc~=F>+85Ou@p^hs4?VNtX;zkJ>;xnRGZ>IhAb`sytfxiT$ToJuVopY z+bma8;P7EU%`y9l68V-ix%3l)_U|*(X6d+Ws7QUqcS1x8+tZvwmiUsD)+{w*X^Q^n z?`cOgTdN{|mY|Xf{lc~i)E|NpeKLO62h!jabfclv9&cvbFmnZsf9E>rAzr-BVTwpBTC4U%Pq?~h20%fgCxD<^W9%|MO3*PgCEtX z4Sq_oenKOBzrp%0=$IH0T6!>cVV~Og^l6brVRF9@zt&Is$`sHaF%5Sjr)eyOpOD^? z2nR5~9|$t&z_1?~7SNYTS4S%LG zGoTtm<&f0kdX4JfXLi5H9`dHb7}eaanT$dBlh~i8AtWYIl*-ntI?8I`Oh_Clb25`Kud5rec0!kZSyW1yhW*(HpJmoK6s1hILsRX6H-+M71Pf~v_G-Knox5)dTS*^R*W=)b|gbh3v`&C)+@(#v{h&j^?~lMt_aMGLF|Hd9UL zxHhAl2n@CQZyNAb=1WEReKV@A+evP|p3QN;O{TcQ!$50<_?t8xcu&J-EYc39-Rm0M z)wgxLV$PXhi3z<6+)k~yvRFs|sy#4mbL9~wzcdfi!NdK!Z?F%i zI?#U$C4IgVS##cQK`p4M`LV0VVsm*xhz2A=Z>bBG`25C|y1XD~6CLU&5%2@ zUFE!yK9|B5Dqy8#@G0Bh7U0vA;+EaK0(a<<1K86 z=IqD&sIMdaYhDz7ds^ zM;&o<_M5-V4%%2t8|yA$srBU`Z}olyc9Cvv5Kw*~kK-reAJqA69dPFW@_7>~RTNA1 zI!pT)O8sK7_;UYFq3_8qh0kv5OswgbgLCwlN6(`79L(7E4EG^iL*q?S9JGF(w?E-(pruSf8i8HZyq9wSXh-BTTK-T+n+Z)mtXC& zgUUfS3MonwUn6y{C@t2Xaq`W(LR*`TWwhvhuWy2P+U|K^4g8QyUrrrOCzH6yNV-#5 zeCz%w*OjV$(85djU{zlydlyvL5EZ?M_|!b3f*ZH(@M4O(@`@6n)VYHc1IhZ^Z);Y= zZJ^v4d(bygf2s1wqd@Lq`4tIHack2c$-nTQ@i3s&zCFmt2 z55z8dt4b)y=oHg^Abrg{(tNw)S_PdT5xLsbxNNJVzolopRjXD#pq{lKLtn)P?Db4k-aO2wG{yw_?pB|}dru20KpQH;yFTPf69*VV(z>hFea)86X%HW8U z$0zRk{fe9;7-%M(?NBQZH^%fXm%{O0vX!`ZZ*ItL8wA@|=^gnf6h8)Pk~0R7$6=m8 zR~4&|+3vPY4;<|Rm9_PWQU{`tG-!LXRjzm&O&NK`7}|ea{TTPV>6USuQ-QESUk#AB zm2yrn%+eDG`eh?@<6WB#L*Xd4;MjcaA@{rpa8q&>Rq|+rp0mw zca~6gM?{y3>#9=YRmV(YsWaT0+mJl}cx$GYa&$p%ZIZn=KWk!Im~FISyHxi6M@nL@R+Q-{BtfpEOZ&uFcBj#go_K1hMo zl+m^3Y`e5P?j6^9(CVMPrrTL);h9^%(w^`$7We%LUwog?_|iF0i0gRz#`yDIqrh#A zcLtpmVE%e^RE~o&ArRld(*C1*QvZLI*LYbei%Z2A#OS^p#UK-I*0Li2%UEgdd)!j8 zw4@e&7wC~k(G_K zJMMZziZz6;O~^b<5KX!YsH1{Jwe8pXGM1^6(!P>kUc8=HH;*rydHz*tNB1v~EL3HT zltS=kAmR20OO9h`k;hvA*-m}0)grLTBpvzDl$-Uq+TZRc-_4YK5Lxu5HqN@fQ>}Zf6`BIYTacQ$YFYB|{U! zS*p)xo0%i)Mi#y)iOOR;y)yJ1yg=mj)dI%9WmR!|`Cy+xAb@~?PI1do0Z-r%GNNA1 zpgGS$wZFgBbJ=y6f(SxLU|)Om!i9}XBs zSC(R7Nd_@PYZZm1Rwx^JfryHg*el;tcAI)BJ@@>6*mxjzDn>Q5fzvlgp)0_oYpqEL z$?Gw;Z8H*HA#2atSK|}0@6N#>fNOuzYz28k67xL@5JrAtSoLi9+lzCDzIYefTt)~^ zF--wo?Rsedd1=co$01s(Ihss1efz`c$``<7XiGI5M{{KMoRhi=*5bsW4R6#Zgby0v zJj>wxJ^ALFXzAkf0_yG$k6bxN_BIqA z(0PI{a22c_U9;xP64T~Nntio#zT1tpYaP>}rz4%8{Sc$`?D3clV2VuU`R)YPX2=>v zK+HL|@ojb4!!h2)1TyPRX$|Z4Ne$a}vfFq;u_;~y=!9;awVpjb#PQT8IYIPV$u_MX zw!@&ZL@t!gPM`!zgpi8@lUx6TCVl+WqiB+xo8Q9QhVxOx(Acqk|$#wF>h<`)GEPqaYPb3 z1YZUM@C&PUtHdr}f@J5OQP89@1m=jWDT0emuXXzdlfmy_yat!xHSTzG@e%nw&vO{@X+T3VY!_q%#WP zT1_Pq-`2>H+;X#UajC2G$W=`pgMxH}f^bQMxp_Y(u{>Xu<1SelZ&Vx5%xzwFyzRic z|6Zv}r6G7C+cyj!PaTtY`@m)XywTgzZh4W2K^M#x6S;>gff#X|dYrXtx*7G&hpFe- ze+>2TbbTkGB|`^#TFCdlKqt#2?t90lC%4=>;h^*+02FE}(!GU@U47#%Iffv~k8Mdg z25<$q=1{eJ9&!aFgs8rBcBH7RM)8U7m}4xD$}fN*)qPGueE2@S*%LYn|LyeheOl#a z1^9315#|mfV-y^$0qm#ny~Nf_e}+~=h<^`o1NApuw@g*2qVRZ>Xm<28bP!qp*|wMh z3M}A0tTi@shV_^-*{(p>w|Rs!eV;SVBP#aXvAdqN52HVvw|U!!*4Pp;d{#|XZ)2?n z#qM44?%UD;S;xCMT5scOHK-zR{i>RnAu;;-Q9C;sW*%2D==Nw$VOnR6an;#+Bk4j4 z@y*VZ9%ji);2Cl+Pek++8_yUGYIP>lE(=ZryN@s1S+oR!<(pkAa5XpW&+ex3oIuuR z()~NeT?d}nBqNnY`LCCED~mx(L*htu$xWoal~+&My8N zA(6Bu0T2z4E5po2qbE3Cn+wwV_18zVoz|w??kqI!=wey<92)cN0X&h39io{x?$9%) zJ#pV9OiM!rnzR;c3oHP9`VZ>Yd#YR-YN$A^U8UD{xvF0DCyQR*J?x8jQ#CJk<8<#! zf87PnqzZFH!pwhn>9)7d79H(5XDLL}c+=<2%Q_^#^!mF~sHmo=zX|)H5&uTX@TBx% z5ki}oCKubpAeiNR8(ML<@KHrSum_?IhpiZ)i0faDj52)%^+N zs||#WvC{5@x*5muz1j86H#X^&8TZY(AtCy&6CsE9_CwFH(*`fUR$Z=jU?SP}nhFrf zO|vz`>gs$~FRKXbnzI|06qhj#ZIJs!*PUz#Z# zk$~5J^e2}R#eWZ}UtsF&VQb~ISKN=1#?6RRQOF2noIbR8Aw9JQ-8t=jpOEk5G+3b* zzMa>w^>HM^hdW;4e??u_6d^)pj}LX2+jx`JqQ@vUT5Gv?e0CH=sY8J2evUEXj>f)v zMm#6sIw%<+2l1}(l|kqNW~vgo-g?T`&Nd4Q!Ch$vdqR})s*7_ZVa@{n>S37dF=w?P zig0T=7Cdd1Zo)*qa#S`2Zw4yU<3&$F@?{-TPcMyvyO>)iyCMR)?MO7aK5w+m@eNa! zZ!W%F)*HTPQP`}$BX`9A;MQWs%n$}{=)D1j{^k~BYlQxxJz3Ar{-=OZY$m8D0%WZp5nR88}%$#`n{NAek8`Ll4PVDCiu#Si$?frI4QPnTElY~Hb5Jlev``s% z>ZZGU+Mu^Lw|~A>pod29GqDBqyu235+G|TazdkHHznbR)0CT&-#W)+~RyaELh7-7| zjHY*UWjx-tW-I^?>a_n_ZpYbhYuMU2*;t8p)~ODHT1P2bVx0A_j1fKBts4@?=2FFQr@q(-f=iWvu<<2vp?p7S>pNVO`u_V zjnDWKDaW=z{L6FuV_LmR78034gFySld9q&S?j7ZhP%%JLW_(Wz$-gTcm-pEi`d~4} zxGGg3aVxWfUGBhzu`qVLQ#y?Fdfm9c_+z(tqa`-10tJ^X;v9U!H5q5*Q@rqeGUV}m zrTg9I@`rPum&?|Phtry9W7DR^mvyrpydRLie&EU6dP~ZrlPx*>)Klyk5JazO0_5{qDr$>W!t^tf}dR1RPK6F zZvxM}{n04sIQ=Wx2~>olJx84Scmizwkv=HkrY*N1@U}M|>)O9uHQ+F|W`^N?NG@Ez z*mn`>JJE+Nv{kM>b~8Y>U&ccwOVF2XOWqk@IL~R~Vd+ekiYH1>bZnf9SP@s?EumaB$utFHKYkdewWWfBj7M zwxT24&K~(YVTQwXuR%?LE^~%NT-fN%nJdBP{Ca};ZG3HF%*e}xg=g{5xvRtd! z?wwoT{F*;A^VL*ss!r`%d(qvief3(6<8;%p%I-wZyzT;X=exP*|75!z>4eSzL%SsU za0aY@qqIl+-- z^~GneeUCh0Fz{+%KO5&mwBn5T_WRS5EkW(4Yzt%wuMdhQem4`k6U4ezraw<7t}Y}M z?6ym@LLbjaOM6mhtNokKK*Wyo)&_k86Bk<_CaOLh2)valM%~|P zdt%G;kY^ajtd7+InT-$gCfuLOy?){v@)&+&sTR2By;|6>Il9_+`*kW2E#!#P030)N zTtLT7G5-*TkTshUj!RrO%n?{emF9JE0#m_M{%vM7#ogAEOCD#v$Q1(c3msCtw}tPS0pzHd9>;!D?{Ku?K>eCu3Hx_CqC%Z*9TM&s;r* zG{5X5cEm_YQBrElD)v=DFCZo+xqUoO5LUnH69NrK%%R`!U*EYOZ;s#s55d&oO6`fl z!*9=cEBKYAa4jBGj~SG5rOm~@q< z{^O{v#OMN9Mrz1X_SWCkeYd;_;b|g3o?3J4(t{DD-;^koB^|nB&e_I-UaX_AKuO zc|toqXcsY_3|CnneF9||fT2-tT=k`%KWt&Am^7XV=NBeCRCuB9S7^v9YEmKM7eJKz zTe7ww)|Cu08)fyFp8WLjTEO~lR53$jn?&K-WPk!Z=$g_efoU%G9 zs%+&Bi{veZv|Q@V(h!U?t>z2Zx3%rdxdQd;+0yNYC<__8i}UjElnU?0ET~~({@{1# zJ1T_e14BL9nWfa#7!2=4TPNm;s{^%T3#6R?l;7wWKCRoLIKr%HcMzP=bHlrnt9*Xg zS+btj{^Es|ua`ci|Ab@!ToMTFys{OnN~&w3{13|U6xQ^g*T4}mpMO8>t2nP_yxpPQ z<}7*aoaejX2LO62^ie51AR6{#O&&+So#kpJ(o9=oKlUNmCra-&HKe+nA^PQ4zsj#w zZy^)%I$(DS0s0ktY1({MdLC+e(%Tu}In!y*q^Y^YZX*;b`eejnyY~!)I_Z6$aP!sW zL*i-VLsU{4vf=U*ePSmwkFsre+oe2`x~jk+E4)hkeIct>P!zAtk#KRIG`$@tG~Hm2 z&$6n1q(97p2SaOrX=bDjm!iFd=hkv!WQxGUQZ zVzmj?9d}3Omr$dSSDmt2Z$C9S^KzZT+Tg~lIpsM#d*Y_m1q@A}KX}bL6M(JJ?+)k=NCT;kj;j3*W+N>8&koOIFx~Xax zAq5>xxfB>G-YHKYK9j958Z2Fk+-4BIuIebMTzbMjXyIMtmlM;KFif`QOn zuGp75nfvoWUa@U)+1CwK?W@b-hZNuO5kvXS{_hFR4{Mb4*mZ#mARkH|b=`4Bh1awK zvCMEvbhn){*D@h{W6T;oBE;GgQQb^Cs-_dp^p#G!@-Sw-cWU`y)SE4px@13Mj}?gz ztUImI#yA=DhM^!1Yp~yl3O--*C94O0GDRUpqX-?~x+I52B?G6}viGqJBteV4A9Mde zhC1JwP4bx{6dkmSx)}E{&Mgy>7uQqAure1FX3bqFpsPJlIoP*N-;u^CE$wD#0*HP@ zu;hFtMPbn8y~$fpV5FqDx$gciTNjM!E3e3+h|$tz>IL)xQ9A$D0?=zI-}B|Dhhehp z*Da)8;m&za_- zeqkeO;vHWL-BYN8IZ95lva&sjhHo3BrqiVcbjV08K$a2CJO6(*%A0saaVq%D@2Orw z-DP?A@975pd0$YeZRE%-{61B^Gpn%7`|r>Ce#!6O2K@wM{3E0QxFB$?_W>Ai5cTf^ zXfdG8-UlerAWXgwDENYt^Df+g+)w}eP26V!J1HU|dSnWnVQBFeea6xku&nv;^$?;F z)Q}VLh-)pN&O1F+WGuLZiyQJLeuG z=8WlUpWMVhz+jz`^%fSXUYZeLAN2&1>7peY4WQz-sYN5%U1kd?@Q#=XzFquX_#p4UnQF-5?H@kIt5%FB;B*%{trL2xHdNS^H zHR+7sBwXg|@_C$T_m6s+r7DHY9)b)HUX%eA?E(-Af>E9}@|!!3+1pRt!gy^6CnDDY zE2&>T+}yTihE$&qHX*NdKd4U>&?v>+dM_(}D#vknvYY!|et3CmxazTfBTN6$tof_$ zGWNJJ)EJ;Nqj#aFdiNr2h~26Sxmd>o8zjqW1}zUk5rBF3bSrt@(v#Y8=C84)?>{m)vNq=q^?YBGnIzl+C=>n6og^3tWNU2T3-WWC;a3KHB)>} z(pfTM)`8Oc2%+=&I!)`>2kY6ZG${1n9-p?;GSwJxE9de}7;69Q+d&(8U-tdBj z*%unEtY#tuXFpsGGI3ltxlX*i`RXl?gu5GrQPs|0ydvoIl`~jzLj}!zKBx+@_^Llu zlYaZk!k@p@$#!s*#g@e}ic7uiq&Tf4BSXym)q#%lpzhrvnog$QA)MTO{`5+#3ma_& z>Ol@P)zg>m{w(NyD^m1j^b$wR(MA8f2PXby48HyTj9{J_5fps?dEMiZT)xE-S+MPz zaBl#d%xHd$tkdaYm|S?CX(|VQ0C6d*b!>U2+!THffe#py3^AKO@Y+`Mt05#6Gi2w4 zptdZzw!n=M#)QH+E(6nWX?n|P;v2H%iW=hpTrD=}(X&<$22|x@n@0jBLGOOyW?~H5hR|TGn!(z7#gYeAXX`ihk%Z zb$Aj7g<>kM&myu$aP&M}EDx!uL}tv8eoyAh_i8rozO{0sGZf`f0~&X9b6(X(6IN9u z?E~=a7*!a@YOp}{p&rf4>Fx7_!}=Fs_)m+=x;?(NJm*g|-S6c*G(9*gTM`(`ZQf)w?4(~MHbYe4^{?;(1UHfRhQgY^$Z^ruxXri@nLGX7`IX*rh9O%&*pS3Blvif{l zBV)e&*~Wnn1lSD3i9#!JKTGRY3ErpO-!a?oZ@=8{$CBgl?bz$KZ1K|tYmaI+35<%H zD{f(qJm{|qAUs%csw63CH}1*r><~Uq30)Zy-Iv%ks2h$xZWw1OIxwYVxxg#vBuk@I z=Nvq-tU8Sk$UeRDFbP&B!ZSUwxe7S5n2SW#{%nct*hLe}hEm-%?p5&zrK1lU$bJU{qgo=Gcv){7BXFwzU?0>JdkvPx9RwwxI;P^R< zyv>FvthXO1kR2Zn5bq1e(~kQy%+;w+?6j(7Qh-`Px5LnU{B@kJ^~NM!?9gr0w86*Z-9c%CZ}< ztW`JU+cis97hF4(Ec~E2eTbvGF8=}0IYqiJN9Kq;U($9oUrO#>=UBb%ejE^NOObV0 z25?p6G0tclF(B1h~V6IDtVWad5U}{Q}?TQ0bK;n9M_ur zXgYt<{$?eCb?{?d$6vjMm8S;b}M*+QwrMQY%*tayUWm^*Q}11*_M|5zq*8Tj&mkGTFYd&YpD%%Vwzf(dTr} zLPS>&$wDYYvAVs#1bG_w$3%2R=MoPkYrudFq3A7;>DS;qAmh5_UC0mbiq!t|Z*89S z&-pv?pP+EPkqafSWT!Lk^t%Lcy>9jp@DO=ZHlAuop%q3anwZ(+*f zT|uS!-a`gD}HC+d3Hcs>p~AF@fj zxQc)mGTwPgyWTLBtX)1I>BQZ}eQ;;FSoDBA*dbhW1re4kcz=xPdL&SI@m6rayWaiB zrpv2BAC-6Kp$&g#qK08<-}Zb>Nr&ZUi9B~l<-l|d=k*zE&ru3n#y6BLYM zuJr_P#Qs8q_XZ8lLxP^+iL3kd8Nq&jNtoB< zasDp>qStfnRy})qb%J*IF^9UL;o(A`+htev6nTQa#bF-;p&WmELsO-O`EoKNP8-0A zi?!KG6=EemSJCmS=NHaL)pgJrUy%Z^I}(@8?e2j~bM*o2cKE9!%uOxV`R=%aR-Ns+ zN5&)Ud(3v|nv0bksoqx$sQwKN^TTz)J<@mAn%hnxoh{=|vQuHwn#7$=Pk*)mFLVM- zg>`!>g2#ABQ`=)%$lbt!>DSv+?sP7bN{`CK z+N1$HF@Kyw=f|rBn7c9`X1_dS3ASQKQbVTf{v-UN?Yg(tjJ*&`rvJD^e*GdYzH2dH zO_BWR_6+pNZZU1A1@^B;&Pif6RjbTMD4i+Rv36_vt-PgOC^xf0jI=ZhX3}cDyj{>9 zfY`Us0ToAsy4Aat-BGcTGCKOWvp)gr9DUO2PJxCGDrTmnOb8Fh%UQFD zpcIza`5;!A$u<0d@JOOdmh(ue>HdDQR_aGgwl?^1i>V9G@)oIY)qEL9c^Aw|ok3G! zkP_wyRp-xCP4j|L1R=q{K{Y2(YZbgh8dHWkdTVSMsPWDVDiId$Fn@z-^RNGZnE;dNR!QI6)ta;5jr2rm3njT~$-fea@Fsx_TBo%TH%k6?#Sdvl z>4-Q#J0=7t4p^Akj&?(uukyav%Z5+h_IkQMWQz`#Pd!X8y)<9(m^9UIw6oHdgWs^J zdG9(OI4{{{Hy7ZiakLV>2UE+ef$bp9oN=TlM>`OjiT5kkwosP5k2u*ZZbG9_G=BBj za3VhF=M4}$ZrMuRgFUUszl^&E+gAUe*`48|Gt_asM956lZVq$x)-o5opFyYFj>K#B zeK2N*vA{d{7*&(Vejok)NO)4_Jqh|E&pOBxn7_&$M>Kw@!E3PYlyQ!FnzlyzxrV_cF0_{jwaV_$jQ|rY$7JBr$Gz z)6IrEA8A6zLFhaMEKZH!;?B|hqF<ge~IdOoLN0>s$y!;K}7$F~(psJlS4ldltn~ z5%DNSZ!zg2(*-W4cZWFVhnS4U84DCfX{#W6P;inGN1SCr0f1XihNe=;`p8 zJ3ZfRI7GI>5k-`FDJIJ?!S_~|td7STPrVCE#zw5ToBD_EWyY$@9AUeXpkMh}SCA0b zeW+)3M;i6s*)Wu^fIlZ{=s@kFO$>xrW_`!YX)deBvNZCR^QjBjjILD8^QhgK_+a)*Y;xaqC23e1J@x=V zLZ^v~h*v`!-t3vr9m)ZsWYs>n}_(hd$e9n00&b01gX8xY9 zBk7Z^fFlWS@xMw7|Fe+)_5vjBjoHkVotCR+e-kk3t--g6(wCM)Nyo$qq7m!jnC%Vs zEFOo0-Bk~DG}GsYO^$J`s$sp}+VCSiYC8I`q{P^+%exB=OqM)+je`WU#yco&UqxT7 z-Uy9Mz0V%Jk*}387^N}^5{g*kUx&*C=4uKZ)3Ax95;BwMX>@!2Rf$P-MT`-qxkiMD zrTzdm{kTMiW)d4OI>X5~S*9<6$V4N5_^jZ5-B_HK)g_TQ|J7AlwNof$)+0sc)=YoQ3%w52chnnIGI)ds zXm#!}&-uNDkOeO2(Dy38DLY!~o{RG2$k5}>j*-zf@pK>&3m+HGq$xIE7^*RS9h@H3 zIb5p|qy-$Z6WifCbIj7dH!$YzxqU`Qj_P^kQRVbxGQ9>j^vM%gh{^v zyyLy&UyZ=Gaf=s?A4$9Gu8Z_&;&7*b@@{WWymW(1?&#@>IqN}edP#vO8&19ISLVOF z&_#hIVTqZFY3~zJUmF`l$G+GS-@no|#x-W~gA{7FV^rVK?%RzK6n9~lznE3OKTcx) z#D#SD7Q)s7iyz8rSq-|eYQF!?lg`tF-jVi*=$djrMGj4Y?cd7T@r-7#L>oA>6x&x-uTW?<-#P^!$4biiLJ_sgiT4FIWs@L{rcq3rK2Uo>tZf#;UY>`)Y0#lPp$NW4!SEr=E)nm_zFqqAYFYtPeE|+ zNsi%vb$*TqTocTupCHHiN|{#*F+UxqJvATq;ToOTqoricM`69S;;|mUf#2{==v!0B zk(^tk7I3F1OYV+%%ug&ps1Lns;P+Gmv9tkgJD9iroK=Wz$#hfFw z=83}&$~d2qBr9C!Rtt4!?#m`mB*EiHbTjDjChr;VhVD)@#Ed;Dfhdv_#e#%;@A z>}1d%kq#1}SL(jLCEROtP=Ot6asFjAnq#i|#aK&Xh7Ypt`w(znZ}Zgmk?L(yTZgVN z6&_;4oRJI;D4%K^Z7wI)%V%Er&t8sgM!l|0sybe*Srs~ij*+bf!!?O7=hdMwWC?}> z-a2}vDRgZH*z47`yOAlZKat!CJ>Wuxv8SI*CPun_KvO(A|G``goK1&CUjNC_)FD4d zT=lCgAOj5CJw&@lhk9YZ$9H3NEM~l5d#SkA_LzVSvYvMfj%nE)4_VilLw)0dApwiY zFzn)QIz)};ns5@+ao}j?0DhV7)$81&EclHTx4Y}!w> zeGHW8cNjagJ4-TUJDDS8aI=NvTD#X1)JY#B3b{4v8(FwmO}PrK^DEZn^UHvCp}IBrXdnTTZOdLjRtPD-Y^1|u9>%4X8h zN=V}ksHc#^n9$$5ivaB6`)FkQnc*GMrqv&;1~dkpv()|KLbB9F-mM#OSUKE&yI|Ec zn!tmnX+5bImpUdFkkD$i@M6pFBfK?WS#DxvwF7|?q4^`nXt#P>MXm3n(g*(??R56g zx^KFI7fI(;8qJ{w@qXNOudav-McETU$ovyQR|8jrjIJzOBmTtINq@9jL8bTsIB#-^ zXhJ`&ig;tOVOvZZmocec`#p*t7)oq|GFRh72^lS8%xG`Xz&@~-HVZ@>vwq9&iF7XU z)XH4f-W++J-5jV)ls+_JDot)nZX#82+P4S?r!Homkp;~FL&C!sp(0(rQLWwkc1JNK zF@OAsjqfu&*SZI8kfdHey$q)T_`5e$4+Z~%UtXEpPq+})#>qbcmAg2prrW1^b z+0gqr9zrO+|7c!dY(0<@(58s&N1^3>-ZLJ>Diw`=bvmH;i8@(Y`aZ@#@iT_i8Pp$d z(K^6~Sgr_b+SFf+EUSpmR>6isUOd7FVLc!GFtOI^l@%>cd%50re8AB}Ne!Y7%Gq^q zFt?bcUYAZJFVk4L-(iLl`9c-y_pq~eqBpJjI&0=k0($Ro%I}AOh2OWPNwVIDfX>!! zFNt;^q?y69}j8v(c*WAZ;nHhqg+e{+&&rwQk& zex1gaH`SM$-JgSuqXB2xvJ}wfAwm1@_MAfOJ+=^Vggx)G+HV^s7D};Ybn47OKCanY zD7i7QQ>K@p_XsdC@xHv;59F0}S!Ikof_=SVW7+bGA7?1HYV^YT!<+*TIrJ1N zUViG$nf)3fSo_8IcK$aseDs#U#RWMzrpw>f*UITQUGLEwKw%(8rubf$pu`v*2C)$#A}IkQFd0^P`gE0oGPW zV*dqsc@I(~QKQ|P{D|~X3IL#1Qw1>SY8oE@5rXMfaq=Otg!s}D znxH5?ew>mla&EHRXeM*|6cVg*57&AE43+b&3Irk#?=%2-@7l-#hGUUpOqbS?L?8?aJQhS4|jHO0{GE6Mfj6G;aiuVo1n)~x(X7PXh z-2z7Oo2q>ZG-2b4XV-=+cT9--0zAx^fzSb&Fo}e>=D_FxiUg_N&;dgVG+4AS?yiux z4o&|OYp%+VBoqY_JWeW+BoqyDZel&WuZ;6jJe~C9BzEK|9 z%=~Mz6c|w$>A!H-pTep&=Gx)r6k2QC@O=KWe9KsK68LYmFDpO9e%7V^G0}~x4O77g zlhd3GSmhMIA(8%IZ7q+Y(vC7;Y;J=Q}c4 z;vX(DfrRWuXDOqj{|p=fW3-+@Q&Fq%Bl%79cS>Ot6;N?)9;8&DAP`(@xj!i-MGbhj zt@!S>e(e$yW>5pTy8|j^P5qJ^-C>7EB1pl%`%=E^u!aw0u{o_!b3e~a-^y%0l+lgQ z((B3JzzV-M{Mp>EZz#191UKFtDJ??)Z3dK6%q`2tH=1E$&I!51C8vv{K%!7c&5jEN z{)uF0DMW;Crpm)0jd#U`ICGW_{F1Np!^6?ukSZ;84wXoNm5MDXBp&!9BuZm&AN~9^ z+T$-Tx3tzC+J{Y$Cq*beE9vsZXVg=b0xaY48cE`_ybrU5s@&z4`E}n)0f~P^G1~6vaj;uz8kCh}MaJier{*A%$KncB}T?$Rvy( z;D$$Jmb3+^{}U#fr17NBgwDF?NSUvs%{CHp9;{!cp_imS@a(b)lB@loDZrG@Q2(Qw ztoBqPDJY9$G2|-OQ3=Ju#K`DDSPAc}MIJ{&6!Ra3?V~FVSG8qbUB!pB6cC1?JuGlN zP|yl_rQot?02rz=rOPb^+9%56Nap3$2dmLo<2FYAEswHp*Ann2FO-9s=0hU0E*7vb zYTuEllv;nHi2qt&d_BUV;>DLM(f@A4DXd$y;3p5;t|{~PyD6-}m!1 zi%Ti;ND=Gn3^C2hD*XG$^>T0m!o}y)T-W#Q(iHS5qeLOGG@81ie7VYI>!1m3ugA`l z$e$h|3j(C%7Gl5k)n^=fz406VqT0|$tAx4OO6XtxsaZoddd-ry-GoH)B9 zJ%T7hgc5~aAaLcamDtU|M!#m0kC;+dE<#%~I&BRwdaLUBNPqm{*etoi8gNWnvXD_% z#TQ>uv+%p-Zxz|J2^y^9??g_*!2@@@%HDldGR+pFYyQ-?H=OnbG?mfqVVhipGSyg$ z$z$n#?Uw{Dhtmk*bn3d(={}z4vhpAD+;tX<)dS|#KYMiavs*4lo>6CYgS&%+g^49b zLPwcvP{RplVmPGlOnrhVD=RB0D zbU;bw6Jl8SbQFGpjjcRi3!A2~9^1(*yxT#B1BrCjD#CDt+IXF}FZ5~b=$4`_YyN(m z(@HV5oUTV2ln_$nIbPbWJK@8lP>K6{je$i=P9I2Cj&_b>CVju-{&lSAVvj`TdZ8j zHq%ZXIo}Ked4GJhB-;rPq`%rOO;#DRb;gI3^J{a(-8H4I<=X~^F7LKVFyqlbKxUx~ zQ9e$p1}{>ekT(~|%jyGEcXX!L`P_BJ6Zf6mVZVE^YyN$WBhU85(&E^AbW+LmMwUau z$ArciB7H7PkMH&o{befnZ-oE8rOlRfA@zmwUOZ5{);tg6 z_&gozoOS)3qp3?vC+_1yEf=9CVt$FM{Eg?1bPL+6Ks8)d24*^rzZ*7MqBP*VZ~CQP zF0_k-vQpGHk0*-rY<*Rm)ekcw_ zM*(R>2&aFHd4d{=em}K`EhTG&rx@5Hw9ep{sdL!zH4BJLVBPc2iwi}i7up@rnaVWF zgD$1(N37I(Ho<(`YfQ?VJ}Vb-4YB7f@y|NVfJNH@l}jOl?jGhC)13hF`)}^hr*}uv za1RWPLLs43B%T@4{z|O|* zyJT!EMd*TsF)BowhrTD;Q<%vv)x`VoD(&Y*bjUcTybBcxU+;(KGOvDdQRIQEj$ot1XnaaTgJH~N$t3W+#01_&@NN6gfUkyt|2U;&<|^nF*_^X&?h#+%Ghr0QOn zOGz4g4vpDYi3OX+W6zNXLv$-P6dt(C!we9{AKpESzC&>)t z4dyZ->IFXP48Ap5+B~(*y|BqD;d}L9^0e>ilz2ybr3w#ua!5|gwpRyy?CZjIe3j-& zHYRCF_{Aymi2F{07ZrzD?~iQrf{MnqMuTqFae^kVsL&@%%jI8SaxBCftbq|%%zOUb zSxYrCq9rs2<0%mzT~;Eha7Q$MFRggho6Y?+81*^#HX&LG4HkHsK1&i;!NC5}ZZ(&@ z$x4Ml++Xhf`WbivDNe74Tw)-u4<1&ajmP=n0}Y7u^Gv|R-aTPiD&MWoTV4$jz6+~ zgSfec-r;0rjn-sGv46dtcnWh4k%;81`Aezxs1)yi(h6L0evr!K%vH&`H@os`*5K5o zs48VAe_lC&d%c|f+-Wr*xPMzE>Y+(_r7p2DEdG#L2M*rq^fLn^s@*q0XynK;QtT~! zptiVPS(b`Z%jA9*%3QM`1My!PWa|;2XjV5siIJgf#3G}Lg+hiQwn=i3{V6)*ykq?X zQ9+d2m82cJS&Kebfw6g^GRmQP?ef54)>mw6%9D_Hs7s<`->bVmoMFVsA)Bes^fB z)94mNYC4^J(phb>|ep5fvc#hRPM*2mqX$^HT+$p znYnv#qzlX-d?#;nAKV%G9Qg#^>#@hf8xpkV_UM5v1})v8)jVmeL~D(m=oG8PKT*;f zyct#fuEGN{5utmWx*&Wt3^oG^CeM@1Axz2otyu9Sl>(|6-iAa=pl0t*Qp(&~6)9+; znU|kB8OyHw>xgD!r=^x+y$JB%YpH3vKDSqJ{v;p$e~$>JyggQz2rKN<96g)@)N~xRB}il+|1-(o=cli-f&c_B&m=g! z3AZxu(8I_}W)}h->=(B$+b!O)r>6?Ja%{Qfzk-?;;shUHsdZ{#s=pq)y;SmCdv~VL z_pp_Je)$A}nE@I+`mHF)vm|$k&18*c%>>WY^?CQe9x3~n^rmFU0jt*L1udNIx@1P@ z`M!yqU9w2Fz=hRwTP-p2-L~*D2EwWD9^+Z9{lXXs#`udGti3m{{K<^;;ugHa`%N69dL8NM{A^02 zGN3hh?RJ*PIu{;CLsOh~PANQ&+arz&P3`YGgvL8(n&aAs6sgcU7h1Yy2o4KbJm7lCXeonW{14q(vJH&5;{-e>3Zy{J`=xW1p_q4;a*p$xuaxtv3{34PFqk1xzj>_ zl^xZr3)1!ueC?-azYU^-sdr8@ItoCzKZ7dHcatE;$-+Irq2k@{pc_q8vwUvI_{;u( zuoqfUCJsa)nfFNQjXl<6@Q^?gWiqH#_gxE6o{{Ww;;CK&{*T97!8R!lYw(PD@QvA{c zmarh+v@q(Q<@?Pr&F9jo7Aq=|!oDlC{xe;i*t{8VER6hr;Z*?$Jd#~PGA$59dJ5>C z0SnZdcOw*giS#nsqKwc`kG8>+VUPgxRSsH|YxyG>Rs>>JOFZNG+&WN8>Di0}4J{{7 z!frzW8EM90i{$Yp!|g_)9B{3>KYCYeekHGwWcAZP?YF1s9mGs&YzEk_#z*tj`?j4Hd$i7v}}$qiISI z-dV87Xt6=lN>d1usy~`z>c~TW>{S^o^CYzja>ApSOU!YZjpDPQnyG@aC79HoU`N5d zkymHY`fmvT1Fe3%sTx2eOfqzpTjWxmrZ0}b0ky^c10~UeF}!rly+}kaG$oUD1Lk%6p7Gf71p;~t zWG(c(vKlbeq>yJkxMUUAv`_(u07BiRPi38Bi z+6EU0v!n__si}&@Wl~4<3`S&3jKoPXadS4?&=Cr9C@`tBiS#CJM7=6E`r*%CnVrhm zYhI5D_|z1@CZ%cPhlWb$4~;$~U8j-}ltPAuHDR--VI->>5D5&3tEaxgOE&+o=UV|8 zLugTsetS`j=71JfmhdsBbdMV;S$;;)c?hS^XuRt0vJp=DJ?*{+&!iytg!O-BBuJ6e zU=@}2-)Yo!cqyIX=6$!OM1k@!{DDjaq+So@rmfik3fM&Il^_Mq(m)DYh1S7h;m@Db zGN%{-QsHE^nZCwU2?1Zst`}^A|BGz@`SENu!4C15{d>3i%TMq~dOgW3olDvI|1f|c zh$n8P7&vrEYz`f_MV)c&4h$(cVY#&R$nC^|6p}tuzGWBRw-a4S$6QUF)Q0^<#rh35~ z9)QB95T8Ci>e3j#=E3u#A9z}31@Cfo%NjPnM~W7c@g=Dze4|6gy5zfy!qF1vk>YG$ zg)fLPJj8Yy^Zu}#n?pRq&DE4e6fq0Zx2!74p7Sfn96tFrqdBS%V~_jC3LDi+O$ix= zEEI;3&~rBFDRpXXtg?!*nhDiyWQ~xCDvqy_mv3q1j_T}}WK$}_YgGAY4r$||{AQAn z$P0HX7yR_*nBRs-p#_}!6T{tU=Hil(W(+rj(*nzpWZ{>!XH7#UY^37PV#;7>%cDwk z^fE)N6u+Oy`MF!^B6%=X)G+kbgMLG8qtEAdt9_9()@150Aoex-x$igZmtPPZP6l&S zk|J#Nv&N%WFm;gKlDcc-TV8$*aB-Gv4#^^UtmoHRA1L*Od$1!5Eb}E0mxaG z`avdKm_~5So!XGLq-ZhzAN>gfAN6uT1G@uf`fg?c`NubJ)Biz{jpZHPaCfW7Zd+>02eiK4cqoP8rM z?G`l#u@Dg+pEw7{j)kZj#NA^Jw52Ys{7sMdm1bOK`O?)Blo(#d#j~i33*(Zbx>6OM z5zRfl_Z{lL(&q-3Z3%rXMthF3Nk0&({RHi-OPRjPI@@AglGRrttEM7>pA(DpASL-s z5z+=ga*D+{AX(Z7kI!C0h7&T&9m-&nZ<^!Lntti;$g`+qo%wpnJ+>Y)fh{`3 ztE+24!cO!tei}lXcQ$~FNxy1s{47ednGfbmrlkg_mp{Zn`o~;S&j-Gf66LAmOMUIe zY|1DYZo8@!NQq&+R{>@$=mR3DO^pt4P-zUe$4UqppXQGj>tsCWgUch$Ru@DkhK~GZqy^{fLx>E7uD#eGh>gRaE(hDsl-Af zhx3zxVY537i@g@nSDJSUgwFaVvqs}K~9hbQpg z=yZq6TIK;S0MnXJ*Dt`s45tt|$*E~X49`x(^Y28H6rqA4jE^DuB?e>VSKatjmGg#; zT3-;ewg+<^5d?3~ez8-~bKp1D)tChm3t1T&oroidAG@8-6>@u1FFoCjC`V_-#jAcT z#a|qF?ZmAZR_V`csQW}iVqU4S3$|{DC&l7)Nb<6t#=idfrgg!+X11pk`73Vc!r4y_ ztF_k5lYh)_eH`qEqi7+->6>cZ%!HmyCeMmxJ}U{lC`bY-Hx-HXTc4AxysbIhH?O=N zFEsOJycjIt=C9^&-3wZ^aMfxx8tutXC53iE?ceo_~*+I9s=&tVuNBkIT0B;mvOt?ax)CD!hc}5 z2cX3;ZpEPF7YBPGqt}Ca%PFg2G|uZ{J5i9^C#vVh49xWXot=B+d|PonHHi+B0N$~+ zbyBQ1$$98nCf0wVn)uLVx{;d!`6Z$07THQmX*g^9)C>VSv9pfg^_g>~FO!&|zyGAt z_LJu|wmrjK#>o&Qdh3zFm5omzj4lw&HtDS4s zeU_0q=Pm=ZlhH_XK)-ao4nMowXg9CdH_gkzh3q{Tl#3DXO>5i!7HMSwr%I{~H6Lh- zK`yaHlNv=2=Uj}%IS*fhBa-VDT|$_J9IE|o5v1_gkU}t3-4aoKtxmK7w+@F_)qoJpSSyUVWPQuFu(c{m)Jqg; zuw^n6)uHC-+XMnVd5I!ILeys>uH5@8{5{pex^27ExSwyQJkO1jq|IbGod7~CQ4qzd zR3}6~xoDMDBy1oCYtIp4k^cALRrSAkr6REo~v3`t9HHBquFkzAw99?SKhEy&mQ^FV7UqFQ>ZSX^b z6ph4qv|B%N8`l9{l^usIbMe~z=NYQk_5$nDx1PLk6sa^jBRs%U2o5Y&Y`t2hJiU@# zsy?XKyYRE+*iU_>kI_izsdl<0a!3T60*60fV$RnQ3-?I`{z(!IF^dKZrsM2*f{Pfg za%`6j0br7%L`G$aT0mf3X*_*K7N8{8&uB}dT=s!RPk*Xj-a9c^piAq2ea_ zs=M-D6%YvjP~xP<%2sI>FVarL94Bd1Tol4&cb^aM7wFhWymZDrvB{hf`oSWAFhlb$ zXYEba`hx(mH|Pn_pkc=a=~04I6^{@fkJfi_Du!Gnb&i(Gz~aZH zO@cA4k1kGq=8Mjs42?RXA`#$^np03+?S4K`=GGV`7{`5#h9+vUk{NSZU;LJLsD<9R zED2>hXQZl<*XkWOA@7nPMrC_SyrdErQp6<}=9u{SLVZ|mcPS0Pa!3iO-E|DpT~fGX z^SQLpibt|+L^mAf(g&8XXfO`4Fn2F5%Eu#ej+T9Vy(nfj5~YepAQ$s?>j2-H*%^3a zFkB?QRwtCZl9a_jozrGnqr6L>`~0Fb}qh@*Kevg zB-Z`sXC?|FrwG@~fej=%9C548bW^V1dP|Hf7Za372ByT?t&||@ziTS|g&I!YHg(v6 z2B=N^&r#hsX?zlex7EMab8|jE%@fpG%(MStrD~_bFkbSO7!*hg*=#jXR56_r=QE+V z(rhbz>GBxyx>$0GWU{13Ruh*QKoy7>D50>)jy3b<RH(=~oDHXBO zXId(z;ZUSZu6U!zx3MT&?+$DwjAev(cRoUP0&1aR%dYW{ntB23e$=qR@yW?N*uWt? z*G1Gehv+!h)C3r!LZ6+eQocu9uyxNpvDMSm`ju>p4SLHjy|QwB-EHelIThUp8m_!h zYf(#pviO{?fcH0i5|ndBQlgI)6YHDjz-74)4m1QVth-SWD0foOnJ~L^Qi^qqVrSe{SWG0yv>DU8}^A z|1sM*7D%hSS_cxj3FhuZcbx^=;2#`1IkH7UT|v|ebgaZAeg_}9^iru6G>Lddea4?+ zQS>4JK;3eIuHNuX+Rj{(3O1?fRDYqBWO|obipJ6D^~?TA!|#RZ*fl50C+8ffxR)F8 zfNfh4z9n16YP#HbUYA?Y2bH;efklnU;f?VzymamiW=k#(c7XeiK6*p7-dgaA_q8Cp zhzxDCiOn%@MP14`l}B^P$%7f=?~#mZZK&71BHZa)Z>mu93GQ(YW~qv=DVOf~*+&n! z$l1HSyJg8ELe$-iZ|>J|A&KF@N37egDR*dYt+Yo7y0YNI|t0@%K}E zR7$&!|3c@2v@7ptkoK#alCBUE+sC-j4h>wPYR-Geks|IHcw&@^2Dl~@%$?Y7#1=w8 z30REORZ38n`6p|2FiH5`tn7$=AR|-}H~xXlk02=tEF`izP_C5A{d*$LWc|FCkVr|y zp#DH5rsQel^r{9c{L`41*N(|mE@~;BR605Qa*({OYPW zmZ%A?A43}aH`(JoMysV6qko!l^++$#C0^pEumiQJkFA;R1*rUoxk=_#hZ0~fn7{V& zBa4K6V{O|Rd(KhY7d_;9y6=m1?{C`>>%SD~I67Lu(4>sdpx*S(UV(eniOUSX7@2=Y z)39I=H4@vaE*-eJ;c<+n-p9zJIlcfQ#&RawfIZGf4Dwk#dY!?-5rgq32}(0oif@2T z?=FVDrM25yrq}z7S;wijVr5cQM+gZc0KG=SkjW)_qb^~tgF{+9f%kLH)#qX# zx(c9z(B)*1GdlkEcfP|X<2ijK!!fJLeqmff@$I?;=P4q_wF~sQD#n5XAAv;}zvix3 z_nNr<2hJxNUH9+#{l}a4@o`fWsM_}1cGAM>n|$r+M;in2*pv9$39RP}SI5>VjZffd zndc^QK5$V|9@2mzQEVr5&IUqGYgZc00jOzJSJ{xL>wQ5~J$GUlqlCC}o z;x6QgmZ;qO?=cISaIrGOqD|coQQ5842o&}7FMT6TpWN?`^?&NkkxqQu`*CTyOAUML zE#P)_fVX^U%b5 z+tKF3AlcljnO2mhFcyO zva_2(_Owpc%anGQ9K3#+%*RX7i2@C0jYyTDJS-7FzKe2t2C!;P@`W20X zvBxVk-@19}$OzxKA2$TB8Spu^Q0UCPAlSvxLOf1vlk~!otH?wpFJdJ$S6XvIFN4$x zp%ueLGwv~gh}F-*0pXKfh%O_=BK3c~_dtHVoq{7r1&H=Srbb-g%}Kq2j$lgHfJn4* z0AjMsjiEHgYHPguXOPo3*IU22-G=iWbcX#{-~!?CMy1ZV-Tpyde!t|wJc2LC!|{PB z)aDtdXUOBT+2kZ0`fk)~WXWOE_NDMes-RnEl7NY!+%*n{E{C$4)tX#*Nk795>mQRU znt-d5bF+sf(`v0<-o`- zYOW32jtxB&nMXt~Jw`uMFaEE1DR*4FEq`f#F!;0ZbZn$lIp0DxYI8~brU~bPwB#Pw zEVHj7cp<9MDgBKi>M6QZ#^p`=ug(n9v(PQJMuZ*z#>*QQiXHt9Qn}|uMx!}TDaJx9 zZ9e_(cXq4hBJdl;Lzi25J8wWsD$mMz$#xj12Jd!*BVd&RRps5m+DhN>l6)MmxHCHo zU4!af@XcqA3~ABwYI?M>+LLjKw?M{Rc8lKo-=)Js0B=)lpuh;d&o0Md2b?a@ z@ua%@e%7>w6N>hIgw!;pX@|cTwx^Zmo6J&x+4^NMsW{`dm^8{%UqN-xT>K#~QILQ4 z*e^c$+<793-YG+Dw0tn^k6GE0c|YACupE%j@!|Z=kjI(+o1HF$9JP z*CX|dp0QF0i>z!(6}}40Lb26*56L@&q`H`G%kMp|^L*ZP*oeD#;_tX-uhbiNgBXP)2*worBf&K0Q57VUuyoJ5Js7JfQ#W zzI?jAXzY^F@R?Kf>7U_eU3B*R13L@x&<{GIPYPV`_ozM@=Q4c%tP?FCiKi9PhaV1PU=Uqfvv0Mvt$omN;IQ=}sDeoAo@TW-LrV@E4cb>#VVZ?SXV9-n|fqIZkgE=sWt&|XUI{fTJfe>m zwCcBw(ry%E}0qEX&)OP`0DN>aO0lmP;dXZ%7+T-a!3t z>F!MC%yfnd2{Ka0!ZJh&I(<;U+Vxwhhyr`$BHy%c7uGR$#0IEKBSwes2=^lTMZB9+ zt2S=7GhXo&4rR+2gHu92&hIykmXP+TZaD#HhnnSsp6y zcRfaVA~71ODr#8Xud-R1)=8AR{Z7rQrhP4}l6y~ZR1gnWsG;&h8<0@M3-$CGSV*u^ zcwAtC#K-kZ5p+E;tTJ_sfU)LXRRruk410_;6^zXZ2ns0Le(C$Ndm#IUOF4V{40H`B z2i-A>tz1_~V#Rh;80e?@6fN2pr;BD@$Ahwv&@deq3@#9 zd=apI`IIKr8jAQjvhGitjA>=T$&*D3v0!~Bj}?{DBO^VPn8Ep)G!-C_0abE3E*SvZ zv?5e1rLLvK?c3;mTw^L3N7vEv-u;I`A zFB;BI3lda6A=E(}R)RuGum<^*&g5vV;8<5iuxfSaDf+lo2eK2jGA~9U^))P*wu5bE zjiR`9BoabFp^coJKuesA2|azuocQid?hqTbRveNc*>h|svl{W@yAt(C2lrB59-1>R z$Bvv0y}M1qS7xkGlK!AQ2TmeB+IEfnjkkcTcPr0NL9q*@sBi&q)xx{YBvCBmg<)u~ z(}_ZCGZi_*9j9n|7r17KxZcu3zvAIMmz1KMu}ZjBE9`O*8Y@N?;WOjw=fjE?!k& zaXR^-Z@2P&5lcNEntH0p5YI}6E>*Ik@{+(a;n;iIX?ZXBp6uuBMpy8g{bthc=XDE4 zH(wEstX8YTpW5%#9EgP;F%+C*l72V&pdLY*HoR{Y_C;owYsvgct5eg+B(e< zdQA)~uLC7x=}NvnF#I90Yws*(SlLv#4*Y~%an&wYbb^^mN@9m)^`p7j*-=7~xzN+S zC!p+f{4qyF(iF{Oj)Ok%)P<}CF0+BDa=vPI9UX0Ve?wOA2L1MjMy%49`OYz+-QyMS zX+y(J2UULeI}3Rc`S&kN#7;kfwh5nArRxUV3!LSy|z z0jv%CL+jCU0}vm!#z(TE%b!S0Oq2#iZ;5P6roB&)8Yc-TZqo#u|MgU|#ov7<2wSUv zYaA=~-B3JUhzq@-sjL4!%!65ZgkL35iFwfP$Y))x2bvfgf-DKt1&9yzSKpl43jjRv z=ZPqXgU}?W^5=`5#ksH!lTwzSd2IkrG@>lQm@VXizaK(3X1Dj!nvb!N?YXhsA|PsaIPd+rH99GuSLJmq#d*L=s96OAz)52kXeM^TzzVQn0Zx}z1iBbztmZJ)~ z$b9O$x;JOSOD!p;D@hKS4aP_loZ={HRy}!)4~YgzIH81pmGM678hE&o{3J(fs|_IM<^Phaxg>Cj2~&B4PhY%FX4$M@`8_=56uf=6&?M_UjLovOnA9=$Q{^r%ChV!agOTa(YV>qBo{+8l^$XCzNHsbIA@83d}ZxQwbg{>RAY&qqWJMxb2Y#QTdw%IVgP~j696|w zD8nFiy8E<%OV`mC?Awv!HhsTNmM|s5Yoe@d>y36tM&$m9i{v)aYyJ;hd{V4v#rYX8 z%d4~FYP%XK`sJAsZa3{C#v2$MMjV!$Gq`mZw5+RI){+54ADh5OOD;gFAV3QV8<$zo zm#vbsFgujNuqnna^R{MaF61-+pe&dtUy}G$?&Qr?20cksg06!`yhLn%p_cw`WxnhB z>xl5Jr2gr_T1>!Pu2LVR*o}35M1|?~19XKqZBEK7JiQGl3>!0E@I*wZph$c3+a&)^hwtB<}2jl$0j z3wJ9yU>uqADB0S`;3-!S)xkZo|Jh(pb53XlIdWPbDShEXOaj)F`8)<(;R*gC`>!J+ z^!6V|BsSWUZ=%5uYo`@?tWoU^g}72eUWgzt#o4-JI-+`u)w}I}vh$3&x*~(PA7Gx6 z^5ezZ0g-*Ft}8hcZQ-wr(XM|iXo~^iF#XzyHMV&7vrsj={pOL%qPE40;<&j^qPYi0 z<;fX+B;iHaT;#68Omm0!pkf@mmm9;S?!6F(k_yr#%r*lbM6Cv z!gY@~xN{&a4eUTuU&>+T81L-8&2mC zkYy}_1&WY1(yW7#3A#}mBu4X>SUN9p*+!Cl2dacn)Ds0eT$M$~3)WZ&e z*ob0{w{e3l3(`T9X(`#y^2Tgte^)1L9a+=n_oSGrb}sh_nBo)^uJ0BFtuD4lRE`ER z3MFs9&e~8neKA#O)X@?{F0$IadfcaN2(G#HIQEm;pidRvt2>PqU_1mubqu8c4x6-E zCj99fxtySA>@?3?sVpg7iaa>5jA8z{f>qD4+&yM|>#0DNlv-pb_}A5|B}|$YWH}U< zwS67VMW~l3;*)|B7|8ixAh<~t^-bwF%uqy1z!Vl*1{ zi?jHBOD%kX!rRmDn@7QSsE)s%_~%lBG~<}d505f$igWPkX>orY!WJ7V6FL*XPy=vG z_9JN^0PNYzl)ifinm@sWAL4vStE;&mcfEHLh_fSC(wk5>p7-O&_%OZ@Qho1Y$h0ToXoBchus~TS!b?~osz4X&i zHW0*{7kY?|Xl>%(AcBKv%V|o4b6jD4rW3n49IkulRU?6^4KQaeSC)v>IsKON+jqkE zAN&rC8=VZ55Kq8s7tO+sBVOegXvRUQeo9E&rSNDx2AKoau6nlpCM?7s;zLeH>RqS3 zrdBNw!W2xvf31TS+nl?Yq{bV$Wp-Y#e)Zhm{T*r)|IA9%e~S>)5}oRWbCKmEnEJ4!w6k@Zv-Iw0;455FHu zf^^?-r*u5vrkT*(qFPUFG4;bY{HnZ0ZFHDQ|sD z#n_rw+ApUl8VTw*Rc$nd+la=&YDAsa-4)fK#TT}M%c`&UJ;3_4u0p_kt99YH6CopJ zWc=VS?Xhi(Gdy8X9l7K@aP^6gwO zB?k+`>^a+NB-6m@JG5nb0;h#9%Q^iThmxeKF)PYZjUJcf@dBoSI3E5u$cu62>mqR` z~oTR37$-JpZGgb9&Q3-@!WD!@Zu(@&mP~v&8r*dp+tr zV&W)5J97ISPR;TRVYF6Qx3dk~q|_ID0#k%9WmMnm^bwc75UanWcipvh^Y+>T4(nsu zmksi~qjb(&u_>M)=-F{E7%M9tTraNk2=kPG?-8|G&)3{f(Wdi}skEGGp2)nkAuG4z zrNdt-k(ovd=HYWrx)sl+1}?Iox2n3twM=EKbXueXzo(T4TrnM_P~{eNc`+zave+(e zDK+pkryv?eDt3(_H?E|WRT>ts;BGl2BvdAVtdurpZeyaU1=TX;Pa{K>Dyk7ln6cjj zjwTV`6?Zwa`$V{(?SabX2!t5EPscgx@dHYgarVdkuo-SoEIAC5;#(M&RF^tnESZ5= zl=gzXmBI_TO2Vb<;-nZARf}VRI{Pgz%u`Z{xdu zQo0OxCU&-8=DK*-SDW@H{Q@ew^n(jFUBiu+;9sqVaJLAJdj8sIcMQ{I*l7x!S{B#P zpQ?!NcEJ-9qCt3+6&gl~pDLfD1JH3pi)u=2jH9By#wj2HO1(+;Ma!3|toQPz6kTG4 z53**-*%iy?Hh~*M)izY%X&}+LAcjrQCyx?_=Om5KqE?|ztn_BRfbc1cUOpM=@ttLvnx9@Q(6~vPm zKPd(3H2L=Wa$<5-VZTKTud*jW3!r08uUVRU)#&^J0|~F88UeWtHFb6WB_&lHW#)}n ziIRtYug(uKHk30jP63&HJszuJ)%6H^bE`>N*%37Ld01E0DsIHph9P_Jo0rXS1)b?} zNymV|5zX#yyTNwFo$b*jm)8O_Q=~LF-A$ zP(=;FtR}>PR@f&MyDN*jA}5X=XDO2e^YXbjFVhl9#IuUGE=5GXNFU)I=C-_OxQ;bm z)Al2yo5dG-Qz%1o;pNGVt1)3kpkFcg?fCgl}r`AS!+Q?A369n+H0P3l0nf(V0TWvl)ShArt*TXF`wDJ5F=1C zQOewaf<#S(yf>Z^!(uS6eL?9*8#oW6+p@ACQ`7(JabtIl*WUA#vr2f}^f5F+FLKsw9(@I z{1+l_paT&7m*mY=_=H+HLo1J7 zIu$MSTz3wXPKdxt8Hc0^DT_)fWAj`|06B8LDyUvoVR3CMinn-Gz|W2xdDIHgP0#fB z8q^aziUsCIG9|k#=IQkYHwZhl_g~Oi6yu-ZFIat?<5{5Np|N8Hy~l?*P0NV>v<(&r zUEROnvj+!DS|acJisk$`N#C1yj`Gz=!yC7cH!6NdrFc3jsLlj|Fe*dI$OPh^nZ-~GeB>2 vF+9KRzftRd`|N*O@ZZ_L2mfE!A`z4g<4wKBd|hrx&l^BaRkljn?92ZH{!I82 literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 2c080be..65ddbbb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,22 @@ buses. It runs on Linux, Windows and macOS with any interface supported by ![CANExplorer showing a trace with decoded signals](assets/screenshots/trace.png) +## Download + +Ready-to-run installers of the latest release — no Python needed. The button for your platform is highlighted: + +