Skip to content

can-full: the DC-DC as an AUTOSAR ECU, described by an ARXML - #18

Merged
tkeairns merged 39 commits into
mainfrom
feat/autosar-ecu
Oct 20, 2026
Merged

tkeairns merged 39 commits into
mainfrom
feat/autosar-ecu

Conversation

@tkeairns

@tkeairns tkeairns commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Merge with or after zelos-extension-can #90: the can-full README says Zelos decodes the bench from the ARXML, which needs that extension's ARXML support. No library release gates this PR's CI.

Rebased on main b63ff97 (adds #20, #25: zelos-can 0.0.11a12 and the full image's Waveform frame). #26 (ISO-TP stray flow control) was folded in and closed; its Zephyr patch has since been removed (2edc8d2): the image builds against Zephyr v4.4.2 as released. Conflicts: nodes/full/src/main.c's header comment and can-full's README node table, resolved keeping both sides (AUTOSAR DC-DC row and the signal generator row).

Finalize: what changed on top of the rebase

Commit What
43557f4, e5e6d78, 3a29180 #26's ISO-TP west patch, its two HIL tests and a fix to it. All removed by 2edc8d2 (see below)
41a0fcf seal() also flushes the DTC store's mount. A reboot before the mount finished sealed RAM without flash's permanent DTCs; the next boot kept that memory, skipped the restore, and wrote the reduced set back to flash
54c7bab arxml/can-full.arxml describes main's Waveform (0x7F0, SIG, 2 ms) as bench.dbc has it. The DC-DC's generated tables are byte-identical
d0fd7d6 The two CommunicationControl tests hold their session with a functional, suppressed TesterPresent (keep_session()) through their host-timed wait; see below
6fb902c test_live_values_are_typed: the uptime DID is the model's 50 ms sample, so two reads 1 s apart differ by 1000 or 1050 ms; the tolerance allows one step

Why #26 broke the CommunicationControl tests under Renode

It did not, in the firmware. The two tests had a latent dependency on Renode's pace, and the runs that happened to be fast were the patched ones.

  • test_communication_control_stops_the_broadcast and test_communication_control_silences_the_pdus enter the extended session, send 28 01 01, then wait with no request: collect(bus, bound(0.5)) and time.sleep(bound(0.5)). Under Renode bound() scales by the default time scale 5, so the wait is 2.5 s of host time. bound() is meant as a safety bound on a wait that ends on the node's frames, not as a wait length.
  • S3 (5 s) runs on the node's clock. Renode throttles only when virtual time is ahead of host time, comparing the two cumulatively (TimeSourceBase.ElapsedVirtualHostTimeDifference, BaseCPU sleeps only while it is positive). A bench that fell behind (CI: 0.4-0.55x on average, a deficit of ~100 s by the uds suite) runs flat out until it catches up. On a fast runner that reached about 2x over a 2.5 s window: more than S3 of node time.
  • S3 then ended the session, as ISO 14229 requires: the DC-DC sent again (the uds test's "DCDC_Status sent with transmission disabled", 6 frames) or 28 00 met the default session (the autosar test's NRC 0x7F). In the failing CI run with request logging, 28 01 was handled at node time 75.538 s, S3 expired at 80.539 s and 28 00 arrived at 80.862 s.
  • The patch is not on that path: the CC requests and responses are single frames, and no failing run logged an ISO-TP error. The failing runs (patched) peaked at 1.84-1.99x of host time over 2.5 s windows; the passing runs with logs peaked at 1.06x (patched) and 1.47x (unpatched).
  • Reproduced without the patch: unpatched firmware, the old tests, one-node Renode bench at ~1x, --time-scale 12 (a 6 s wait): both tests fail with the CI signatures (19 DCDC_Status frames; NRC 0x7F). The fixed tests on the patched firmware, same run: 2 passed.
  • Fix at the test layer: TesterPresent every 0.1 s holds the session through any wait, which is what a tester does while it keeps normal communication off. The node, the patch and the Renode model are correct.

Stock Zephyr, and a response nothing acknowledges (on top of the finalize)

Commit What
2edc8d2 The ISO-TP west patch (#26, and its 3a29180 fix) goes: the image builds against Zephyr v4.4.2 as released, as most Zephyr projects use it. The two second-tester tests go, since they asserted the patched behaviour (see Known limits). It also tried one TX buffer to keep CFs in order; b1a33e9 reverts that.
4bd479b A response nothing acknowledges ends. send()'s wait on ISO-TP gets a backstop above the slowest send ISO 15765-2 allows, and every N_As it checks for the error ISO-TP records when a CF's can_send() fails. On a send with no end, or one ending in N_As / N_Bs, the controller restarts (zelos_can_restart()), dropping the frame M_CAN would retransmit for ever. A send left behind gives back its flow control filter. The UDS thread then goes on, and S3 ends the session and the 2F override.
189c7ec 19 1A with no record byte answers NRC 0x13 (was 0x12): ISO 14229-1's NRC order puts the length check first.
ad09124 The no-ACK test closes and reopens the suite's client itself, leaving the vendored zelos_testing/uds.py unchanged.
7dd4795 A request mid-response (N_UNEXP_PDU) is not a send left behind: stock ISO-TP ends it after its CF loop. Treating it as one cut CI's coexistence soak short (push run on ad09124).
b1a33e9 16 TX buffers again. One buffer keeps ISO-TP's CFs in order, but ISO-TP sends straight to the driver, so its CFs then take turns with lib/can's lower identifiers instead of yielding to them. In the HIL soak (image e4eab5c3), DCDC_Status (50 ms) had an 81 ms gap and lib/can's queue peaked at 16 (was 3). The Renode no-ACK test allows for the backstop, since with free buffers a CF can sit in one unacknowledged.
c388415 15 TX buffers, 16 TX events. Renode's M_CAN model reads a full TX event FIFO as empty, and its frames then never complete. That happened to all 16 at once when the no-ACK test put the node back on the bus, and every later response met N_As. The message RAM shrinks; FDCAN2 stays where it was.
6c006c1 An N_As / N_Bs end restarts the controller only when it is error-passive or bus-off (can_get_state()). A frame nothing acknowledges drives it there within a few retransmissions; a tester that only withholds flow control leaves it error-active, so the restart no longer fails the other protocols' pending frames on a healthy bus. A send with no end still restarts unconditionally.
3a09e98 test_dtc_reports counts with the list's mask (TF | TFSLC | CDTC | PDTC): a model cycle passing the DTCs between the 19 01 FF count and the list read made it fail about 4% of the time.
452db85 A pending UDS response waits the server's P2* (5 s of node time, P2_STAR_S): the client's P2* was the larger of 5 s and its response timeout, 2 s of node time on Renode, as long as the self-test routine, and test_busy_while_pending waited the routine's 2 s for its result. With the emulation below a fifth of real time the tester gave up before the result, and the still-pending routine answered the next tests' session changes with 0x21. Test-only: testing/src/zelos_testing/uds.py, testing/suites/uds/test_uds_conformance.py.

No ACK on the bus: what the node does now

  • Backstop: SEND_BACKSTOP_MS = 585 CFs (4095 bytes) x 127 ms + (N_WFTmax + 1) x N_Bs + N_As = 86.3 s, derived in code from CONFIG_ISOTP_*.
  • Per step:
    • A frame waiting for the TX buffer longer than N_As fails in ISO-TP; send() restarts the controller if it is error-passive or bus-off.
    • An unacknowledged first frame times out at N_Bs; the controller is error-passive by then (16 ACK errors take TEC to 128), and restarts. A tester that only skips flow control also ends in N_Bs, but the controller stays error-active: no restart.
    • A CF that cannot get the buffer strands stock ISO-TP: it records N_As, then never runs again. send() sees this at its next N_As check, about 3-4 s in all.
    • A CF that is in the buffer but never acknowledged is only ended by the backstop.
  • Restart:
    • can_stop() fails every frame the controller holds with -ENETDOWN.
    • For N_Bs + 127 ms with the controller stopped, every ISO-TP path ends or strands. Then can_start().
    • lib/can's TX thread holds a gate around can_send() and the restart takes it first. Stock can_mcan_send() checks started before it waits for a buffer, not after, so a sender woken by can_stop() fills the freed buffer while stopped. That frame never completes. With one buffer the node then stayed mute after reconnecting (seen on Renode once the test re-requested only what TXBRP showed pending); with more, each restart would lose a buffer.

No-ACK tests

Test Path Result
ztest test_unacknowledged_first_frame FF never confirmed, controller error-passive: N_Bs, restart pass
ztest test_flow_control_never_sent FF acknowledged, no FC, controller error-active: N_Bs, no restart pass; on c388415 it restarted
ztest test_cf_without_a_buffer CF can_send() times out: stranded, seen at the next N_As pass, about 2.3 s
ztest test_unacknowledged_cf CF held for ever: still held at 85.3 s (the slowest legitimate send), recovered by 89.3 s pass
ztest test_request_mid_response N_UNEXP_PDU mid-CF-loop: no restart, the send ends pass; with the old condition it fails with CI's signature
Renode test_a_tester_gone_mid_response_releases_the_output 2F to 15 V, FF + 7 CFs at STmin 127 ms. After CF1 the bench takes full off its CAN hub (monitor: connector Disconnect). After S3 + backstop + 3.6 s of node time it reconnects and re-requests whatever TXBRP shows pending, as a controller retransmits. Then the session must be default at once, and the output at the setpoint. new image: pass (A/B below); image 590cdc50: fail, the UDS thread hung until reconnect, then the read hit the response's FC filter (P2 timeout)
HIL not run: a no-ACK case needs can0 down or unplugged, which the bench rules forbid

Renode 1.16.1's M_CAN model never sets PSR.EP or PSR.BO (tagged, unimplemented), so on Renode the node always reads error-active and the gated restart never runs. The Renode no-ACK test still covers the restart: its CF finds no buffer, the send is stranded, and the unconditional path restarts (response 0x62: no end (-1), restarting CAN in every run). The gated path is covered by the two ztests above and on HIL (below).

Gates (head 452db85, firmware 480fa7be)

Gate Command Result
Build cd can-full && just build (stock Zephyr v4.4.2) full.elf sha256 480fa7befc3bef0e037eb78a7ac177b4924830dee4c5a2d0c61057a25779cfb2
twister just twister 19 configurations (7 run, 12 build-only), 52/52 test cases, 0 failed
Renode, can-full as CI runs it, tests at 6c006c1 just up && just test, 3 runs 105 passed, 15 skipped twice; one run failed 8 (below)
Renode, can-full as CI runs it, head just up && just test, 5 runs 105 passed, 15 skipped in 4; one failed test_xcp_and_uds_share_the_ecu with 0x37 (below)
Renode, the no-ACK test then the conformance and OBD suites, head 20 fresh benches, two at a time 20/20: 66 passed, 8 skipped each
test_dtc_reports x300, can-full on Renode the test parametrized 300 times in one session before (c388415's test): 11 failed, all on the count line; after: 0 of 300, twice
HIL, NUCLEO-H753ZI, can0 500 k just hil full can0 122 passed, 4 skipped
HIL, uds suite with 3a09e98's test uv run pytest suites/uds --channel can0 64 passed, 1 skipped (the Renode-only no-ACK test)
HIL, uds and obd suites at head uv run pytest suites/uds suites/obd --channel can0 81 passed, 1 skipped
HIL, 22 F190 with no FC, 5 times (and 5 with FC) raw socket on can0, node console captured console: response 0x62: -2 (N_Bs) 5 times, no restart; 0 frames missing on 0x300 / 0x310-0x315, largest gap 20.1 ms either way; can0 ERROR-ACTIVE, berr 0/0

The board is left on 480fa7be.

The 8-failure Renode run, root-caused. Not the no-ACK path: the failures came about 50 tests after the reconnect, and the node logged no N_As / N_Bs end. The self-test got its 0x78, then the client's P2* (10 s host, 2 s of node time) ran out before the routine's result (2 s of node time); the routine, still pending, answered the next five tests' session changes with 0x21, and its late result was read by the OBD suite as the answer to 01 00. The bench host (zelosnuc, i7-1260P, powersave) runs this three-node bench at 0.25-0.30 of real time, dipping under 0.2, which bound()'s factor 5 assumes; in a capture of a passing run the result came 6.7-7.3 s after the 0x78. Reproduced on demand with --time-scale 3: the same 6 failures on c388415 (d09e5221) and on 6c006c1, so it predates this PR's firmware; with 452db85 the self-test and busy tests pass there. GitHub's runners run the bench at 0.4-0.55.

The 0x37 run. test_xcp_and_uds_share_the_ecu waits out the 10 s power-up security delay after the previous test's reset within bound(11) = 55 s; the node ran at 0.18 of real time for that minute (10.5 s of node time in 58 s), so the delay outlasted the bound. Same cause, a slow host, and no protocol timer shorter than the node's: left as is.

Gates (head c388415, firmware d09e5221)

Gate Command Result
Build cd can-full && just build (stock Zephyr v4.4.2 workspace, no west patch) vcu, bms, full; full.elf sha256 d09e5221
twister just twister 19 configurations (7 run, 12 build-only), 51/51 test cases, 0 failed (4 new no-ACK ztests; 19 1A assertion added)
Renode, can-full as CI runs it just up && just test (bench host) 105 passed, 15 skipped
Renode, image alone can_raw / obd on can-full/build/full.elf 6 passed; 17 passed
Renode no-ACK A/B the new test alone d09e5221: pass. 590cdc50 (this PR before, patched ISO-TP): fail, the UDS thread hung until reconnect
ztest A/B, request mid-response test_request_mid_response with wait_sent()'s old condition fails with CI's signature (no end (-6), restarting CAN); passes now
Renode soak A/B, 16 TX buffers vs one suites/full -s, 2 runs each both pass; no overflow, deferred or tx_error either way
CI on c388415 can-full push and PR, firmware, can-bench, serial-bench all green; can-full 105 passed / 15 skipped, can_raw 6, obd 17. The first push attempt and can-bench failed in setup (the runner kernel had no linux-modules-extra package) and passed on rerun
HIL, NUCLEO-H753ZI, can0 500 k just hil full can0 (xcp, uds, obd, j1939, canopen, autosar, full, can_raw) 122 passed, 4 skipped (no foreign CANopen manager; the no-ACK test is Renode only)
HIL, again uv run pytest suites/uds suites/obd suites/autosar --channel can0 --a2l ../can-full/build/full.a2l 93 passed, 1 skipped
HIL soak with counters uv run pytest suites/full -s --channel can0 passed; overflow / deferred / tx_error 0, queue peak 3. With one TX buffer (image e4eab5c3) it failed: DCDC_Status 81 ms gap, queue peak 16
Bus health can0 after every run ERROR-ACTIVE, berr 0/0

Known limits (stock ISO-TP)

  • A second tester's flow control is the node's own.
    • Zephyr v4.4.2's sender takes any frame on 0x7E0 in WAIT_FC as its FC. A late FC from a second tester (a bench PLC, DLC 3) releases CFs early, and the tester's real FC then aborts the response (N_UNEXP_PDU). An FC reaching a receive context aborts the request being received.
    • The bench saw it as P2 timeouts in the 600 s coexistence soak and in test_programming_sequence on HIL, with the RevPi answering the node's first frames with 7E0 [3] 30 00 00. The patch had fixed it.
    • The released CF can also overtake the one pending in M_CAN's priority queue (equal identifiers go by buffer index), so the tester sees CFs out of order. This is upstream behaviour: keep a second tester off the bus during just hil.
    • No configuration avoids it. M_CAN's TX FIFO mode is not selectable in Zephyr's driver (it hardcodes TFQM). One TX buffer keeps the order but inverts ISO-TP's priority (b1a33e9).
  • A CF the controller holds but nothing acknowledges ends only at the backstop (86.3 s plus the drain). Until then the 2F override stays. Stock ISO-TP waits on that CF with K_FOREVER in the system work queue, and nothing above it can see the wait.
  • The backstop covers STmin and wait frames, not block pacing. A tester that sends BS 1 and answers each FC just under N_Bs could legitimately take up to 585 x N_Bs (about 10 min). The backstop cuts such a send at 86 s.
  • A restart fails every frame pending in the controller's TX buffers (any protocol) and holds lib/can's queue for the drain (N_Bs + 127 ms). It runs after a send with no end, and after an N_As / N_Bs end only when the controller is error-passive or bus-off. A tester that skips flow control on a healthy bus causes none.
  • A held single frame or last CF also ends only at the backstop, since stock ISO-TP times neither.
  • The Renode no-ACK test re-requests the pending buffer on reconnect. Renode retries a pending frame only when another is requested, whereas a real controller retransmits until acknowledged.
  • No HIL no-ACK case: it would need can0 down or the tester unplugged.

Open from the finalize review

  • CommunicationControl stops the AUTOSAR PDUs and DCDC_Status at the next model sample, up to 50 ms after the positive response.
  • Resolved here: the no-ACK hang (backstop + restart, above) and 19 1A with no record (0x13).

What

  • arxml/can-full.arxml: the can-full bench as an AUTOSAR R19-11 System Template. bench.dbc's frames unchanged, six new DC-DC frames, UDS as DCM I-PDUs over CanTp, XCP as general-purpose I-PDUs.

    Frame PDU Cycle
    0x310 DcdcOutputP1 E2E profile 1 (all 16 bits of Data ID 0x0310) 20 ms
    0x311 DcdcThermalP2 E2E profile 2 (Data IDs 0x30..0x3F) 50 ms
    0x312 DcdcPowerP5 E2E profile 5 (Data ID 0x0312) 20 ms
    0x313 DcdcContainer container, short headers: three PDUs at 60 ms, offsets 0/20/40, one per frame 20 ms
    0x314 DcdcMux multiplexed, selector byte 0: static state and voltage, two alternatives at 100 ms, offsets 0/50 50 ms
    0x315 DcdcSecured SecOC profile 1: 4-byte payload, 8-bit freshness, 24-bit AES-128 CMAC 100 ms
  • tools/arxml2c.py, just arxml: the DC-DC's C tables (nodes/dcdc/src/autosar_pdus.{c,h}) generated from the ARXML. The firmware workflow fails when they differ. Descriptions the core cannot send fail generation: big-endian signals, long or little-endian container headers, unaligned SecOC fields, other E2E profiles, raw ranges beyond float precision.

  • lib/autosar: a sans-IO core (signal packing, E2E profiles 1/2/5 per the PRS, container short header, multiplexed segments, SecOC trailer) and its Zephyr side: one thread sends the schedule through lib/can, earliest deadline first, below the application threads; AES-128 CMAC through PSA Crypto (mbedtls, tf-psa-crypto added to the west allowlist).

  • -S autosar on the DC-DC; nodes/full takes it. UDS CommunicationControl stops these PDUs together with the node's other normal messages.

  • testing/suites/autosar: tests the node from the ARXML alone, using cantools plus cryptography for the MAC. can-full runs it in CI and in just hil full.

  • frames.recording(): candump runs as its own process with drop monitoring (-d) and writes into a pipe the test process drains. The autosar suite and the coexistence soak both record through it, and a host drop fails the test as an incomplete record.

  • UDS suite: the self-test test reads its socket during the 2 s routine. At the added 200 frames/s its idle socket overflowed and dropped the final response.

Why

A production-style AUTOSAR ECU on the bench, with one description for the firmware, the tests and host tools. A tester switching from the DBC to the ARXML decodes the bench the same way and also sees E2E, containers, multiplexing, SecOC and the diagnostic transport.

How tested

Gate Command Result
ARXML loaders autosar-data 0.17 strict, cantools 41.3.1 strict AUTOSAR 4.5.0 (R19-11), 0 warnings, 0 dangling references
ARXML vs generated tables just arxml + git diff --exit-code (CI); independent raw-XML comparison of every id, length, cycle, offset, compu factor/offset/limit, texttable value, E2E and SecOC parameter, header id, selector and segment identical; 320 comparisons, 0 mismatches
Spec vectors, recomputed outside the firmware CRC library check values; PRS E2E P01 x4 modes, P02 counters 1..15/0, P05 offset 0 and 64; RFC 4493 ex. 1-2 all match the ztests
twister just twister (nodes + tests, nucleo_h753zi + native_sim) 19 configurations, 38/38 test cases, no warnings
Renode, as can-full CI runs it cd can-full && just build && just up && just test 89 passed, 11 skipped, 0 failed, coexistence 120 s included: before the pipe recorder (7 min 9 s) and after it (9 min 16 s)
HIL, AUTOSAR full (this branch's firmware) on NUCLEO-H753ZI, can0 500 k uv run pytest suites/<s> --channel can0 --a2l build/full.a2l autosar 12/12, uds 64/64, xcp 6/6, can_raw 6/6, j1939 6/6, canopen 8/8 (+3 skipped: no foreign CANopen manager on the bus)
HIL coexistence soak 600 s uv run pytest suites/full --channel can0 --a2l build/full.a2l passed. 607.75 s of node time. Node counters: sent 230025, overflow 0, deferred 0, tx_error 0, rx_overrun 0, queue peak 3. 77 UDS 64 KiB download and upload cycles, 12156 DAQ samples on the bus, none lost
Bus truth, 60 s passive capture each, default image vs AUTOSAR image candump -d -L can0 node ids added: exactly 0x310-0x315, none removed. Existing periods unchanged: DCDC_Status 49.71 vs 49.72 ms (sd 0.14 vs 0.11), EEC1 99.41 vs 99.44, ET1/DM1 994 vs 994, heartbeat 995 vs 995
Every AUTOSAR frame in that capture offline, cantools + cryptography + a hand P01 CRC P01 3017/3017, P02 1206/1206, P05 3017/3017 (12 wraps 255 to 0), container 3017 (ids 0x100001-3 in turn, length 4), mux 1206 alternating 0/1, SecOC 603/603 verified; every counter and freshness step 1
Timing same capture each PDU sd 0.10-0.12 ms, worst gap 1.0 ms off its mean; node clock 0.57 % fast. Mux gaps alternate 48/51 ms: at the shared 100 ms tick six frames queue and 0x314 leaves last by id
Stack thread-analyzer build of full AUTOSAR thread peaks at 1072 of 3072 B (35 %) with every builder and the CMAC run
Size full.elf vs #16's text +10.8 KB, rodata +1.2 KB, RAM +13.3 KB: 3 KB thread stack, 8 KB AES tables (bss), 1.1 KB PSA state
Bus health ip -details -statistics link show can0, before and after every hardware run ERROR-ACTIVE, berr 0/0, restarts, bus errors, arbitration lost, warn, passive, off all 0

The coexistence soak's recorder

The 600 s soak had failed twice on this image, each time on one missing DAQ sample while the node's counters were clean. With candump -d the record shows why:

  • The suite's candump, writing to a file on the host's disk, dropped 501 frames in four bursts.
  • A second candump in the same run, writing to tmpfs, dropped none of 1,856,848 frames.
  • That second record shows the node clean: every DAQ sample (ECU-time gaps 49.97-50.04 ms), DCDC_Status, P01, P05 and the SecOC freshness value all continuous, no EMCY.

A write that waits on the disk stalls candump until its ~300-frame socket buffer overflows. With the pipe, the same soak passed, and a second tmpfs candump again dropped nothing. pyxcp's in-process socket still loses samples under this load (103 gaps); the suite uses that record for information only.

Known limits

  • SecOC is a demo. The key is RFC 4493's example key, public on purpose. The full 8-bit freshness value is sent, so every frame verifies on its own and there is no replay protection to speak of. Real keys come from a key manager and never from the ARXML.
  • The node only sends: no AUTOSAR reception, E2E checking or SecOC verification on the node.
  • E2E counters advance per transmission, not per model sample (P01 / P05 at 20 ms carry each 50 ms sample 2-3 times), so a stalled model keeps sending valid, counting E2E and a receiver cannot detect it. Decided: kept as is.
  • The generator covers what this bench uses: little-endian signals, MSB-first short container headers, byte-aligned SecOC fields, E2E profiles 1, 2 and 5. Anything else fails at generation.
  • +200 frames/s on the bus, about 5 % of 500 kbit/s.
  • The README line on Zelos decoding the ARXML assumes the CAN extension's ARXML support has shipped. Merge after it, or drop that line.

Follow-ups

  • Ethernet side of the same ECU: a SOME/IP service with service discovery, and a DoIP entity (ISO 13400-2) in front of the existing UDS core, both described in the same ARXML (Ethernet cluster, service instances, DoIP logical addresses).
    • The DoIP entity must pass the existing UDS conformance suite over DoIP: logical addresses and routing activation codes, ACK before the response, NACKs, inactivity timers, a timer-driven 0x78, and a response flushed before ECU reset.
    • Hardware prerequisites: the board's Ethernet port cabled directly to a dedicated host NIC; the board's RMII jumpers set; a static address on that NIC with no default route; packet capture rights for the test runner.
  • Stack: the AUTOSAR thread could drop to 2 KB (1 KB RAM). AES tables could move to flash (8 KB RAM, 8 KB flash). Neither is needed today.

OBD-II

What

  • The DC-DC is an OBD-II responder on its UDS identifiers (SAE J1979 on 0x7DF / 0x7E0, answers on 0x7E8), in every -S uds build (CONFIG_ZELOS_UDS_OBD, default on), so in full. Legacy: $01 PIDs 00 / 20 / 40, 01, 1C, 1F, 42, 46, 5B; $02 frame 0; $03 / $07 / $0A; $04; $09 VIN (multi-frame), CALID, CVN, ECU name. J1979-2 on the same core: 22 F810, F400 + PID, F501, F802 / F804 / F806 / F80A, 19 42 (severity 0x02), 19 55 (4-byte records), 19 56, 19 04 records 0x00 / 0xF0, 14 FFFF33. lib/uds/core/obd.c; the node's PIDs are a table of formulas over its DIDs.
  • One DTC memory for UDS and OBD, per 13 CCR 1968.2 (d)(2.2)-(2.5): pending in the first failing operation cycle; confirmed, MIL (warningIndicatorRequested) and permanent in the second; MIL off after 3 passing cycles; confirmedDTC erased 40 cycles later; a permanent DTC whose MIL is off released after one cycle in which its test ran and never failed. Kconfig ZELOS_UDS_DTC_CONFIRM_CYCLES 2, _HEALING_CYCLES 3, _AGING_CYCLES 40. An operation cycle is one boot.
  • Permanent DTCs in flash (ZELOS_UDS_NVS, Zephyr ZMS), written by a lowest-priority work queue; the uds snippet moves storage_partition to flash bank 2 so an erase never stalls code fetch from bank 1. An orderly reboot (ECUReset) waits out a queued or running write.
  • Freeze frames: first (records 0x00 / 0x01, legacy frame 0) with the pending code, latest (0xF0) per later failing cycle.
  • The CVN (CRC-32 of the 64 KiB download window) is computed at start and after a programming-session erase or TransferData, never per request: 09 06 / 22 F806 need no security and are served functionally, and a per-request CRC held the lock the control loop takes.
  • DTCs carry SAE J2012 generic numbers: P0A94-00, P0A09-00, P0A08-00, U0111-00 (were 0x1A0098, 0x1A0116, 0x1A0219, 0xC11187).
  • testing/suites/obd (17 tests): a scan tool's view of all of the above, each test citing its clause; in can-full's just test and alone on the image in CI.
  • Renode bench: a reset no longer reloads the ELF (LoadELF zero-filled .noinit, so every ECUReset was a power loss); the reset macro sets the vector table offset to the boot address and the SysTick frequency.

Why

A legislated-ECU counterpart for the OBD-II scan tool and the passive decoder: the same node, over the same bus, answers what a generic scan tool and an inspection read, with the DTC lifecycle they judge.

UDS behaviour changes

  • Status bytes follow the two-trip lifecycle (first failure 0x27, not 0xAF); aging 40 cycles with the MIL off (was 3).
  • A multi-DID 22 answers the supported subset for every DID (22 F186 1234 -> 62 F186 01, was 7F 22 31).
  • 19 03 lists records 0x00 / 0x01 / 0xF0; 19 04 FF returns all three.
  • 14 FFFFFF keeps permanent DTCs.
  • Physical SIDs 0x01-0x0A are OBD: answered in any session and during a pending routine, unsupported or malformed ones get no response (was 7F xx 11). 19 42 / 55 / 56 / 1A and 14 FFFF33 are served (were 0x12 / 0x31). A 22 whose first DID is F4xx-F8xx / E0xx is an OBD request: no 0x13, no 0x21.
  • DTC memory layout changed: the first boot after the update starts with an empty DTC memory.
  • A functional OBD request restarts S3 like any request (ISO 14229-2), so a scan tool polling functionally keeps a non-default session alive.

How tested

On the bench host's mirror, twister and Renode in the repo's containers; HIL on NUCLEO-H753ZI, can0 500 k.

Gate Command Result
ztests just twister (tests/lib + nodes, native_sim run + H753 build-only) after the rebase: 20 configurations (main's motor node added), 0 failed, 43/43 cases, no warnings
ARXML tables just arxml no diff
Renode, image alone just test-bench obd / uds on can-full/build/full.elf obd 17 passed; uds (conformance + node) 60 passed, 4 real-time skips (one run at the head: 59 + test_dtc_reports, a model cycle passing the DTCs between its count and list reads; the rerun 60)
Renode, can-full as CI runs it cd can-full && just build && just up && just test, 2 runs at the head and 2 before the last two firmware commits 102 passed, 15 skipped, each run
HIL, this branch's head (image 92aa5854) just hil full can0, 2 runs obd 17/17 each time; the full run's tests error at setup once the bench adapter reports ERROR-PASSIVE (bus_health), 37 and 87 of them, from a disturbance elsewhere on the bench bus: the node's run time (PID 1F) shows no reboot, and with no test running the adapter's error-warning / error-passive counts kept rising. The ECUReset tests plus the next OBD test, 2 runs each on this image, the P062F image without the async mount, then this image again: 4/4 every run, ERROR-ACTIVE before and after
HIL, after the rebase (image e5b9012c, P062F without the async mount) just hil full can0 118 passed, 3 skipped, 2 failed: test_programming_sequence (UDS upload, P2 timeout) and test_every_protocol_at_once (10 s soak on hardware since #24, UDS P2 timeout); both failed again on 2 reruns. A capture shows another tester on the bench bus answering the node's first frames with its own flow control (7E0 [3] 30 00 00) beside ours: a bench condition, not the node. Before the rebase on the previous image: 119 passed, 1 failed (the soak), test_programming_sequence passed
HIL, obd suite alone uv run pytest suites/obd --channel can0 --a2l ../can-full/build/full.a2l 17 passed
HIL, OBD load vs control timing DCDC_Status (0x300) periods over 10 s idle, then 10 s with a functional flood of 09 06, 22 F806, 01 42 idle: 200 periods, mean 49.759 ms, sd 0.118, 49.305-50.166; flood 210 requests/s (2103 sent, 2103 answered): mean 49.745, sd 0.361, 48.667-50.862 ms
HIL, unordered reset (probe-rs NRST) confirmed + permanent U0111, reset after reset 03 empty, MIL off, 0A U0111 from flash; one passing cycle releases it; still released after a second reset
Bench capture, 60 s scan-tool sequence on 0x7DF every 100 ms candump -L can0, checked with zelos-can's independent ISO-TP reassembler, J1979 formulas and ISO 15765-4 rules 600 requests, every supported one answered 40/40, unsupported PID silent 40/40, request to first response 0.7 / 5.9 / 11.7 ms min / median / max (P2CAN 50 ms), DLC 8 on every node frame, 0 drops, 0 broken transfers, no finding on the node
Size full.elf vs the AUTOSAR image FLASH 110556 B, RAM 114744 B (P062F and the async mount: +512 B, +192 B)

Flash store failure: P062F

  • With CONFIG_ZELOS_UDS_NVS, a storage partition that does not mount, or a write of the permanent DTCs that fails, sets P062F-00 (SAE J2012 internal control module EEPROM error; 0x062F00, legacy 0x062F) through the normal DTC path: it reports on 03, 0A and 19 42 and lights the MIL; the node runs on with its permanent DTCs in RAM; the existing log line stays. AUTOSAR NvM / Dem report a failed NV block the same way. CCR 1968.2 (g)(4.4.6)(C).
  • Confirmed at its first failure (its pending state could not outlive a power loss with no store); never written to the store (the stored and compared mask is the node's DTCs only); in the DTC memory only from its first failure until it ages out or is cleared, as the node's last DTC, and kept over a reset. While the store works, nothing changes: the supported DTC list and every report are as before.
  • ztest test_store_failure_dtc; on Renode, an image whose storage partition is one sector (ZMS needs two, so the mount fails): 03 and 0A list 062F, PID 01 MIL on with a count, 19 42 status confirmed + MIL, 19 0A lists five DTCs with P062F last; the same after an ECUReset (the mount fails again).

The flash store mounts after start

  • zms_mount() ran in zelos_uds_start() on the main thread, before the node's threads: a blank partition (first boot, or a garbage collection cut short) erases a 128 KB sector there. It is now the first job of the store's lowest-priority work queue, submitted once the UDS thread runs. Until it completes, the permanent DTCs are those in RAM and requests are answered from them (no 0x21 on OBD); writes queue behind it; a reboot waits out a mount in progress as it does a write. After a power loss, flash's permanent DTCs join when it has mounted (zelos_uds_core_restore()). A failed mount sets P062F then.
  • ztest test_permanent_dtcs_restored_after_the_mount.
  • HIL, reset to the first DCDC_Status, storage partition (flash bank 2, 256 KiB) written 0xFF with probe-rs first: mount in init 0.99 / 1.02 s; this branch 0.12 / 0.12 s, the same as on a formatted partition (0.13-0.18 s).

Known limits

  • Not verified against paid text: F810 on a non-engine ECU, severity class and match rule, F501 past PID 01's bytes, 19 04 record bodies, readiness group numbers (19 56 always 0x31).
  • No test results or in-use ratios (comprehensive components only): 19 1A / 19 06 of 0x91 / 0x92 answer 0x31.
  • 11-bit only on this bench.
  • An operation cycle is one boot, so every ECUReset ends one. A tester can walk the DTC lifecycle with resets: after a clear, the first reset that ends a cycle in which the DTC's test ran and passed releases its permanent DTC, seconds later on this bench, well short of the drive-cycle criteria CARB sets before a permanent DTC is erased after a clear. Decided: kept simple, documented here.
  • A ZMS mount failure (e.g. an ECC error in the partition) sets P062F and leaves permanent DTCs in RAM only until the partition is erased; there is no automatic recovery.
  • True power-cut test needs remote power control of the board.
  • Under a 210 requests/s functional OBD flood (10x J1699's burst rate) the DCDC_Status period sd rises from 0.12 to 0.36 ms, worst 1.1 ms off 50 ms; mean unchanged. Bus arbitration and the shared TX queue against the core lock are not separated.
  • A second tester on the bench bus (DLC 3 flow control on 0x7E0) breaks the node's transfers (the 600 s soak and test_programming_sequence above): stock Zephyr ISO-TP, see Known limits at the top.

Follow-ups

  • zelos-extension-obd vendors testing/suites/obd/test_dcdc_obd.py: re-vendor it for the 19 1A 0x13 assertion, and its demo vehicle must answer the same. zelos-extension-uds pdu: representative CANopen node, with SYNC, TIME and a short example suite #5 also vendors an older zelos_testing/uds.py and test_uds_conformance.py: re-vendor from this head.
  • The bench's second tester sends DLC 3 flow control on 0x7E0 for the node's transfers: keep it off the bus during just hil.
  • A recovery policy for a failed mount (erase and remount).
  • Back off ZMS write retries after an error.
  • BUILD_ASSERT that storage_partition is outside the code bank on other boards.
  • An E2E test that floods OBD requests and asserts XCP DAQ timing (DCDC_Status is covered above).

OBD load on the control loop (HIL)

DCDC_Status period with and without an OBD request flood at about 10x the J1699 burst rate. Accepted as is: the mean does not move and the worst case stays within normal CAN arbitration variance.

Condition Mean Jitter (sd) Worst
Idle 49.76 ms 0.12 ms 0.45 ms off
210 OBD requests/s, all 2103 answered 49.75 ms 0.36 ms 1.1 ms off

CI before finalize

On 634291b every check passed: can-full (firmware build, then the Renode bench: every suite as just test runs them 104 passed, 15 skipped, including the 120 s coexistence soak; can_raw alone 6 passed; obd alone 17 passed), firmware, twister, can-bench, serial-bench.

Release order

This PR is in step 5 of the AUTOSAR / OBD-II train (zeloscloud/src #1707 has the full list): merge it with or after zelos-extension-can #90. zelos-extension-uds #5 and zelos-extension-obd #1 vendor its UDS and OBD-II suites (both re-vendor from this head after it merges), so merge it in the same step as those. Nothing here is released or published.

@tkeairns
tkeairns changed the base branch from feat/full-image to main October 18, 2026 02:57
@tkeairns
tkeairns force-pushed the feat/autosar-ecu branch 4 times, most recently from 634aa7d to 634291b Compare October 19, 2026 04:23
@tkeairns
tkeairns marked this pull request as ready for review October 19, 2026 04:40
…m it

arxml/can-full.arxml describes the can-full bench as an AUTOSAR R19-11
System Template: bench.dbc's frames unchanged, six new DC-DC frames on
0x310-0x315 (E2E profiles 1, 2 and 5, a container I-PDU with short headers,
a multiplexed I-PDU, a SecOC profile 1 secured I-PDU), UDS as DCM I-PDUs over
CanTp, XCP as general-purpose I-PDUs. It loads strictly, without warnings or
dangling references, in autosar-data and cantools.

tools/arxml2c.py generates the DC-DC's C tables from it: ids, timing, signal
layouts and scaling, E2E and SecOC parameters. `just arxml` runs it and the
firmware workflow fails when the tables and the ARXML disagree. A description
the core cannot send (big-endian signals, long or little-endian container
headers, unaligned SecOC fields, other E2E profiles, raw ranges beyond float
precision) fails generation rather than the node.
lib/autosar is a sans-IO core (signal packing, E2E profiles 1, 2 and 5 per
the PRS, the container short header, multiplexed segments, the SecOC
trailer) and its Zephyr side: one thread sends a schedule of PDUs on their
ARXML timing through lib/can, earliest deadline first and below the
application threads, and AES-128 CMAC comes from PSA Crypto (mbedtls and
tf-psa-crypto join the west allowlist).

The DC-DC maps each model sample onto the generated tables, one build
function per PDU; a PDU the ARXML adds without one does not compile.
CommunicationControl stops them with its other normal messages. nodes/full
takes the snippet, adding 200 frames/s (about 5 % of 500 kbit/s). The SecOC
key is RFC 4493's example key: public on purpose, a demo key.

full.elf grows by 10.8 KB of text and 13.3 KB of RAM: the 3 KB thread stack,
8 KB of AES tables, PSA state.
suites/autosar tests the DC-DC as a bench tests an AUTOSAR ECU, with
cantools reading arxml/can-full.arxml: ids, lengths and cycles as described;
E2E profile 1 by hand (cantools has none), 2 and 5 by cantools; the
container's PDUs in turn; the multiplexed PDU's alternatives; the SecOC MAC
verified with the key (cryptography); every value equal to DCDC_Status's of
the same sample; UDS on the CanTp addressing and flow control the ARXML
gives; 0x28 silencing the PDUs. It records the bus with candump, out of
process, and fails when the host dropped frames rather than blaming the node.
can-full runs it in CI and on hardware.

ztests check the core against the PRS E2E example tables, RFC 4493's CMAC
examples and signal scaling at its edges.

The UDS suite's self-test test now reads its socket during the 2 s routine:
at the added 200 frames/s the idle socket overflowed and dropped the final
response, which udsoncan had received.
On hardware the 600 s coexistence soak failed on one missing DAQ sample
while the node's counters were clean. With candump -d the record shows why:
the suite's candump, writing to a file, dropped 501 frames in four bursts,
while a second candump on the same run writing to tmpfs dropped none of
1.86 M frames and saw every DAQ sample. A write waiting on the disk stalls
candump until its socket overflows.

frames.recording() now has candump write into a pipe the test process
drains, records every frame when given no ids, and fails as an incomplete
record when the host drops frames instead of as a lost sample. The soak
records through it; its assertions are unchanged.
P0A94-00 DC/DC converter performance (overtemperature), P0A09-00 status
circuit low (input undervoltage), P0A08-00 status circuit (output
overcurrent), U0111-00 lost communication with the battery energy control
module (BMS_Limits missing). A scan tool shows the generic text instead of
manufacturer codes. Before: 0x1A0098, 0x1A0116, 0x1A0219, 0xC11187.
SAE J1979 services 0x01 (support bitmaps, MIL and DTC count, OBD standard,
the node's PIDs), 0x02 (freeze frame 0 from snapshot record 0x01 of the
first confirmed DTC), 0x03/0x07/0x0A (confirmed, pending, permanent DTCs as
2-byte codes), 0x04 (clear) and 0x09 (VIN from DID 0xF190, CALID, CVN, ECU
name) on the UDS identifiers. Unsupported requests get nothing, physically
or functionally. SAE J1979-2 on the same core: DIDs 0xF400 + PID, 0xF501,
0xF8xx; ReadDTCInformation 0x42, 0x55, 0x56; ClearDTC group 0xFFFF33.
CONFIG_ZELOS_UDS_OBD, on with the UDS server.

The DC-DC's PIDs are a four-row table, each a formula over one of its DIDs,
so the freeze frame reports them from the snapshot: 0x1F run time, 0x42
module voltage, 0x46 ambient temperature, 0x5B pack life.

New for every build: a DTC confirmed with its indicator requested is
permanent. ClearDTC and 0x04 leave it; it goes once its test has passed in
three consecutive operation cycles.
A scan tool's view of the DC-DC: single frames on 0x7DF, an ISO-TP stack
per responder for the flow control a multi-frame response needs. PIDs
walked from the bitmaps and checked against DCDC_Status; MIL and count; a
lost BMS pending, confirmed and in the freeze frame; a permanent DTC
surviving 0x04 and 0x14; the VIN by multi-frame; J1979-2 DIDs and 0x19
reports equal to the legacy services; silence to unsupported requests.

Its fault tests play the BMS, so beside the bench's BMS they skip; CI also
runs the suite on the image alone, as it runs can_raw.
DTCs follow 13 CCR 1968.2 (d)(2.2)-(2.5) on the one DTC memory UDS and OBD
share. An operation cycle is still one boot (any ECUReset ends it).

- Two-cycle confirmation: a failure stores pendingDTC and the freeze frame;
  confirmedDTC, warningIndicatorRequested and permanent follow when it fails
  in a second tested cycle (CONFIG_ZELOS_UDS_DTC_CONFIRM_CYCLES, 2).
- The MIL is warningIndicatorRequested: it stays on while the test passes and
  goes off after three passed cycles (..._HEALING_CYCLES), taking the
  permanent DTC with it. confirmedDTC then ages out after 40 cycles without
  a failure (..._AGING_CYCLES), one cycle being one warm-up cycle here.
- A permanent DTC whose MIL is off, after a clear or a power loss, goes after
  one cycle in which its test ran and never failed. core_init() takes the
  permanent set to restore after a power loss.
- Freeze frames: the first with the pending code (records 0x00 and 0x01, OBD
  freeze frame 0), the second (0xF0) at the first failure of each later
  cycle; erased with an unconfirmed pending code or the aged confirmed one.
  OBD freeze frame 0 belongs to the DTC stored first, not the lowest index.
- PID 0x01: MIL from warningIndicatorRequested; comprehensive components
  supported and complete. CONFIG_ZELOS_UDS_OBD_VIN: only one ECU reports
  the VIN.

J1979-2: DID 0xF810 = 0x01; ReadDataByIdentifier answers the DIDs it
supports and 0x31 only if none is (every DID, ISO 14229-1); 0x19 0x42
reports severity class 0x02 and applies the severity mask; 0x19 0x55
records are DTC and status, 4 bytes; 0x19 0x56 with no DTC in the group is
0x31; 0x19 0x04 records 0x00 and 0xF0 carry DIDs 0xF400 + PID; OBD requests
are never answered 0x13 or 0x21. Legacy: requests mixing availability and
data PIDs, more than 6 PIDs or 3 freeze frame pairs get nothing; 0x09 takes
up to 6 availability infotypes.

The uds suite's DTC test follows: pending before confirmed, the MIL kept
until three passed cycles, two failed cycles in extended data 0x01, three
snapshot records in 0x19 0x03, and a multi-DID read keeps the supported
DIDs. Renode zeroes RAM on a reset (its reset reloads the ELF), so the test
needing the memory to survive one skips there.
13 CCR 1968.2 (g)(4.4.6)(C): no power loss erases a permanent DTC. With
CONFIG_ZELOS_UDS_NVS (default with OBD-II and a storage partition) the
permanent DTCs are one Zephyr Memory Storage (ZMS) entry, their 3-byte
numbers, read at start and handed to the core, which restores them when the
RAM memory was lost. ZMS, not NVS: NVS takes sectors of 64 KB at most, and
the H753's erase sector is 128 KB. A work queue at the lowest application
priority rewrites the entry when the set changes: the flash driver
busy-waits, and nothing of the application, XCP or the server may wait on it.

The uds snippet moves the H753's storage partition to flash bank 2: a sector
erase stalls fetches from its own bank, and the code runs from bank 1.
Each new test names the clause it checks:
- a fault is pending in its first failing cycle, confirmed with the MIL and
  permanent in its second (13 CCR 1968.2 (d)(2.2.1)-(2.2.2), (d)(2.2.5));
- the MIL goes off after three passing cycles, the code stays confirmed and
  the permanent code goes with the MIL ((d)(2.3.2), (d)(2.4), (d)(2.5.1));
- a permanent code survives 0x04 and 0x14 0xFFFF33 and goes after one
  passing cycle ((g)(4.4.6)(C), (d)(2.5.2)(A));
- the freeze frame is stored with the pending code, and J1979-2 record 0x00
  carries it as DIDs 0xF400 + PID ((d)(2.2.7), (g)(4.3.1));
- 22 F810 = 0x01 (ISO 15765-4:2021 6.1.1), a multi-DID read answers the
  supported subset, malformed OBD requests get no 0x13 ((g)(3.4.2)(A)),
  19 56 of an empty group is 0x31, mixed availability and data PIDs get
  nothing (AUTOSAR SWS_Dcm_00943/00972).

0x19 0x42 and 0x55 are read with the suite's own byte layout (severity,
DTC, status; DTC, status), no longer through udsoncan, whose 0x55 parser
expects a severity byte. Operation cycles end with ECUReset; the fault tests
play or silence BMS_Limits, so they run against any DC-DC responder that
watches it, not only the node, and skip under Renode where a reset loses RAM.
The reset macro reloaded the ELF on every reset, including the firmware's
own reboot, and LoadELF zero-fills the RW segment that holds .bss and
.noinit. Every ECUReset under Renode was therefore a power loss: the DTC
memory kept in .noinit was erased, and the lifecycle tests that end
operation cycles with 0x11 0x01 had to skip there.

The ELF now loads once, when the machine is created. A machine reset keeps
memory but puts the vector table offset back to 0, where Renode's part has
an empty ITCM, so the CPU halts with SP and PC 0; the macro sets it to the
part's boot address (flash, 0x08000000) beside the SysTick frequency.

The obd suite's three cycle tests and the uds suite's operation-cycle test
no longer skip under Renode. Renode bench, full.elf alone: obd 15 passed (was
12 + 3 skipped), uds 60 passed + 4 timing skips (was 59 + 5). The can-full
bench is unchanged at 100 passed, 15 skipped: these tests silence the BMS,
and skip beside the bench's.
ISO 15765-4:2005 Table 7: the external test equipment sends one flow
control with block size 0 and STmin 0. The suite's scan tool sent
can-isotp's default block size, 8. Padding is the tester's choice; the
scan tool now pads with 0xCC, as the UDS client does.
ReadDTCInformation 0x1A of record 0x91 (in-use ratio) or 0x92 (test
results) answers 0x31: no DTC of the DC-DC has a ratio or test-result
monitor (comprehensive components only, 13 CCR 1968.2 (g)(4.5.1), (d)(5.1);
SWS_Dcm_01609), nothing functionally. 0x06 and 0x1A of those records are
OBD requests, so a malformed one gets no 0x13 and none gets 0x21.

The obd suite asserts that a J1979-2 scan tool reads what a legacy one does:
readiness (01 01 = F501 = F401), the support bitmaps, the DTC lists, and no
test results, ratios or PID 41 either way.
PID 42 is the live rail voltage; two reads at different times differed by
more than the bound under a loaded bench. F442 must lie in the span of the
PID 42 reads before and after it.
09 06 and 22 F806 computed the CRC-32 of the whole download window (64 KB
on the DC-DC) on every request: in any session, without security,
functionally, while a routine was pending, and under core_lock. The
control loop takes that lock on every DTC report, so each such request
could hold it off for the CRC's duration, with the UDS thread raised to its
priority meanwhile.

The CVN is now computed at start, and again on the first read after an
erase or a TransferData write. Both need the programming session with its
security level unlocked, so outside one no request computes a CRC; inside,
a read computes at most one per such change.
seal() took core_lock and returned, so an ECUReset right after a DTC
confirmation could reboot mid zms_write: a half-programmed flash word,
possibly an ECC error that fails zms_mount on every later boot, after
which permanent DTCs are never stored again.

seal() now finishes the queued or running write before taking core_lock,
which the write takes too, and then takes a lock the write holds through
the flash access, so a write begun in between is waited out and none
begins after. The reboot waits only for writes queued or begun before it.
… span

Frame 0's PID 5B was compared with itself whenever the pack voltage was
known; it is now checked against the snapshot's pack voltage, encoded as
the node does. F446 in a multi-DID read is live temperature: it must lie
within a degree of two PID 46 reads around it, not equal one taken later.

Dropped asserts other tests already make: F400 and F440 against PID 00 and
40, F40C's 0x31, and the silence of 06 00 and 09 08.
The memory window holds the last control cycle's state, so a read right
after the previous test restored the setpoint could still see 12.34 V.
The old check subtracted one second from PID 1F and failed on a responder
up for less than a second.
With CONFIG_ZELOS_UDS_NVS, a storage partition that does not mount, or a
write of the permanent DTCs that fails, sets P062F-00 (SAE J2012 internal
control module EEPROM error; 0x062F00, legacy 0x062F) through the normal DTC
path: it reports on 0x03, 0x0A and 0x19 0x42 and lights the MIL. AUTOSAR
NvM/Dem report a failed non-volatile block the same way, as an ECU-internal
DTC. The node runs on with its permanent DTCs in RAM, and the existing log
line stays. CCR 1968.2 (g)(4.4.6)(C) requires permanent DTCs to survive a
power loss; this is the ECU saying it cannot.

- Confirmed at its first failure: its pending state would live only in RAM,
  which a power loss takes with the store broken, so a two-cycle rule would
  never confirm it.
- Never written to flash: the mask the store holds and compares is the
  node's DTCs only, so its own permanent bit cannot retrigger the write that
  failed.
- In the core only from its first failure until it ages out or is cleared,
  as the node's last DTC (the glue appends it with zelos_uds_core_extend()),
  and kept across a reset (zelos_uds_core_kept()): while the store works,
  the supported DTC list and every report are unchanged.
- A mount, or a write that succeeds, passes it; it heals as any other.

ztest test_store_failure_dtc: P062F appended cleared, the node's DTC memory
kept, confirmed + MIL at one failure (0x03, 0x07, PID 0x01, 0x19 0x42), its
permanent bit, kept over a reset and passing with the MIL kept, and gone
after a power loss with a working store.
The same helper, now shared by the uds suites.
zms_mount() ran in zelos_uds_start(), on the main thread before the node's
threads: a blank storage partition (first boot, or a garbage collection cut
short) erases a 128 KB sector there, so CAN started 1-2 s late. The mount is
now the first job of the store's work queue, at the lowest application
priority, submitted once the UDS thread runs: the node starts at once.

- Until the mount, the permanent DTCs are those in RAM and requests are
  answered from them as always (no 0x21 on OBD); writes queue behind the
  mount, and a reboot waits out a mount in progress as it does a write.
- After a power loss, flash's permanent DTCs join when it has mounted
  (zelos_uds_core_restore(): applied only if the memory started cleared,
  as zelos_uds_core_init()'s argument was).
- A mount that fails sets P062F then, as before.

ztest test_permanent_dtcs_restored_after_the_mount: none before the mount,
flash's after it on a power-on, RAM's kept over a reset.
A second tester on 0x7E0/0x7E8 flow-controls the node's responses too.
Zephyr's ISO-TP (v4.4.2) took its FC, arriving while the FF or a block's
last CF was still pending, as the one due: it released the next CF with
one in flight, and the M_CAN priority queue sends equal identifiers by
buffer index, so CFs left out of order. An FC reaching the receiver
aborted the request being received, or discarded one just received.
Either way the tester timed out.

A west patch, applied when just updates the workspace, makes both sides
ignore an FC they are not waiting for (ISO 15765-2) and keeps one frame
in flight. Two HIL tests drive the node by hand with a second tester's FC
after every frame of ours.
python-can marks every frame from this host as not received, and on the
Renode bench the node is on this host: the hand reader saw nothing.
main added SIG's Waveform (0x7F0, 2 ms) to bench.dbc; the description
carries the bench DBC unchanged, so it describes it too. The DC-DC's
generated tables do not change.
can_send()'s callback can run before it returns. Counted after, a first
frame or CF completed that early left tx_backlog at 1 with nothing in
flight: every flow control after it was ignored, or the CF loop waited
for good. The send context also starts at 0.
seal() flushed the write only. A reboot before the mount finished sealed
memory without flash's permanent DTCs; the next boot kept that memory,
skipped the restore, and wrote the reduced set back to flash.
Both held a non-default session through a wait timed on the host with no
request in it: bound(0.5), which is 2.5 s under Renode (time scale 5). A
Renode bench behind the host runs flat out until it catches up, and on a
fast runner that reached about 2x: the wait passed S3 (5 s) on the
node's clock, the session ended, and the DC-DC sent again or 28 00 met
the default session. A functional, suppressed TesterPresent every 0.1 s
holds the session through any wait, as a tester does.
@tkeairns
tkeairns marked this pull request as draft October 19, 2026 18:16
0x0104 is the DC-DC model's sample, updated every 50 ms, so two reads
1 s apart differ by 1000 or 1050 ms. On hardware 1050 came up in 2-6 of
15 runs on every image, before this branch's firmware too.
@tkeairns
tkeairns marked this pull request as ready for review October 19, 2026 19:00
The west patch on subsys/canbus/isotp/isotp.c goes: the image builds
against Zephyr v4.4.2 as released, as most Zephyr projects use it.

M_CAN's TX buffers are a priority queue, which sends equal identifiers
by buffer index. Zephyr's ISO-TP can queue a CF while the one before it
is still pending (a flow control it takes early, a completion that comes
late), and the two then left out of order. FDCAN1 now has one TX buffer:
one frame in flight, so frames leave in the order they are queued.
lib/can's queue already orders frames as the bus arbitrates. Two TX
events, as Renode's model reads a full event FIFO as empty.

A second tester's flow control is taken as the node's own again, as
upstream does: the two tests asserting it is ignored go.
With nothing on the bus to acknowledge a frame (the tester unplugged
mid-response, the node alone), M_CAN retransmits it for ever and Zephyr's
ISO-TP never ends the send: the UDS thread waited on it for good, so S3
never ended the session and a 2F override stayed in force.

send() now bounds its wait: above the slowest send ISO 15765-2 allows
(4095 bytes, a CF per 127 ms, N_WFTmax waits, N_Bs and N_As). It checks
ISO-TP's error each N_As, as its CF path records one and then stops
without ending the send. A send with no end, or one ending in N_As or N_Bs,
restarts the controller through zelos_can_restart(), which drops the frame
it held; a send left behind gives back its flow control filter. The thread
then goes on, and S3 ends the session.

The driver checks it is started before it waits for a free buffer, not
after: lib/can's thread, woken by can_stop(), would fill the freed buffer
while stopped, and that frame never completes. A restart therefore waits
for the thread to leave can_send().

ztests drive the three ends against a fake controller (unacknowledged
first frame, CF with no buffer, unacknowledged CF to the backstop); the
uds suite takes the node off Renode's CAN hub mid-response.
ISO 14229-1's NRC order checks the length before the record's value:
a request with no DTCExtDataRecordNumber is NRC 0x13, not 0x12.
@tkeairns
tkeairns marked this pull request as draft October 19, 2026 21:38
zelos_testing/uds.py is vendored by the UDS and OBD extensions: the
test closes and reopens its client instead of adding to it.
A request on the physical identifier during a response is N_UNEXP_PDU,
which ISO-TP ends itself once its CF loop is done. Taken for a send left
behind, it cut the response short and restarted the controller (CI's
coexistence soak). Only N_As and N_ERROR, which ISO-TP's CF path records
when a CF's can_send() fails, leave a send without an end.
Zephyr's ISO-TP sends straight to the driver. With one TX buffer its CFs
took turns with lib/can's frames instead of yielding to lower identifiers
in M_CAN's priority queue. In the HIL coexistence soak, DCDC_Status (50 ms)
then had an 81 ms gap, and lib/can's queue peaked at 16 (was 3). CF order
under a second tester's flow control is again upstream behaviour.

The no-ACK test now allows for the backstop: with free buffers, a CF can
sit in one unacknowledged, and only the backstop ends the send.
Renode's M_CAN model reads a full TX event FIFO as empty, so its frames
never complete: 16 at once, when the no-ACK test puts the node back on
the bus, and every response after that met N_As. 15 TX buffers and 16
events; the message RAM shrinks and FDCAN2 stays where it was.
@tkeairns
tkeairns marked this pull request as ready for review October 20, 2026 00:05
A response that ended in N_Bs restarted the controller even on a healthy
bus, where it only means the tester sent no flow control, and the restart
failed every frame pending in the controller. A frame nothing acknowledges
drives M_CAN error-passive within a few retransmissions, so the restart
now waits for that (or bus-off). A send with no end still restarts as
before.
A model cycle passing the DTCs between the count (19 01 FF) and the list
read changed what the two saw, and the test failed about 4% of the time.
Counting with TF | TFSLC | CDTC | PDTC, which a passed test never sets,
matches the empty list read just before.
The client gave a 0x78 the larger of 5 s and its response timeout, 2 s of
node time on Renode, as much as the self-test routine itself takes, and
the busy test waited the routine's 2 s for its result. When the
emulation fell below a fifth of real time the tester gave up before the
result, and the routine, still pending, answered the next tests' session
changes with 0x21. Both now wait the server's P2* (5 s of node time).
@tkeairns
tkeairns merged commit efce988 into main Oct 20, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant