All posts
Engineering · · 12 min read

Building the diagnostics layer for a SoDeV-style Zephyr guest

Two months ago we wrote that AGL's SoDeV reference platform picks Zephyr for its real-time guest domains, and leaves the diagnostics path to the vehicle to the teams building on it. That post made an argument. This one is the code.

There is now a Zephyr example in EDS — examples/vehicle_state_ecu — shaped deliberately like SoDeV's own reference demo application: the same two vehicle-state inputs, the same single visual output stage, plus the diagnostics layer that application does not have. Build it and run the campaign on your laptop. No board, no Yocto, no hypervisor.

What follows is a walkthrough of what is in it, the exact commands, and — because this is the part that matters to anyone evaluating a vendor — a plain statement of what has not been run.


Where this sits

The short version of the platform recap, since the earlier post covers it properly: SoDeV is a virtualisation-first SDV reference platform, hardware-optional by design, using Zephyr for the real-time guest domains alongside Linux for the compute side. Scan its component list for UDS, ISO-TP, DoIP or SOVD and you will not find them. That is a reasonable scoping decision — diagnostics is an application-layer concern — but it means the requirement lands on the team building on the platform rather than disappearing.

SoDeV's reference demo application makes the gap concrete. sodev-demo-led-controller is a plain Zephyr app: it reads vehicle speed and engine RPM, and drives a WS2812 LED strip. That is an entirely sensible demo of the platform's signal path. It also has no DIDs, no DTCs, no session management and no tester interface — nothing an end-of-line station or a workshop tool could talk to.

So the example takes the same two inputs and the same shape of output, and gives them an ISO 14229 face.


What your ECU firmware actually needs

Three things, independent of which reference platform you land on.

A UDS server inside the Zephyr guest. Session management, DID dispatch, SecurityAccess, DTC storage that survives a reset. The "ECU" being a guest domain rather than a discrete box changes nothing about this list — the production line and the type-approval process ask the same questions either way.

Transport matched to your topology. Inside a partitioned vehicle platform the diagnostic path may be CAN — virtualised, or physical passthrough — or Ethernet from day one: DoIP terminating in the guest, or a gateway on the Linux side exposing SOVD with UDS behind it. In EDS the UDS core is unchanged across these; the transport is what you swap. (That gateway pattern is the subject of our AAOS-SDV guide. To be unambiguous, since the two posts share a shape: AAOS-SDV is a different platform, Google-originated and unrelated to SoDeV. The SOVD-over-UDS architecture is reusable; nothing in that post is a claim about SoDeV.)

Validation in the same CI as the platform build. This is where the hardware-optional philosophy earns its keep. If the platform is designed to run virtual, there is no good reason for the diagnostics validation to be the one thing that waits for a board — virtual transports, simulated ECUs, campaigns as code, running on every firmware commit.

The example below is the third point made concrete, with the first two as its substrate.


The DIDs

Five, from one diagnostics_config.yaml that is both the firmware's codegen source and the test campaign's ECU description.

Three are read-only stub identity, standard ISO 14229-1 Annex F: 0xF190 VIN, 0xF18C ECU serial, 0xF189 ECU software version. The two that carry the application are:

0x0C00  VehicleState_EngineSpeed_rpm    uint16 BE, 1 rpm/bit        read, write
0x0D00  VehicleState_VehicleSpeed_kph   uint16 BE, 0.1 km/h/bit     read, write

The numbering follows the OBD-II Mode 01 PID convention (PID 0x0C engine RPM, PID 0x0D vehicle speed), which is what EDS's basic_ecu example already uses for engine speed.

Why those two are writable is the design decision worth explaining, because a reviewer should flinch at a writable speed signal. This ECU does not own the sensors — on a vehicle it receives both over the bus. On native_sim there is no bus. So writing these DIDs via UDS 0x2E is the bench signal-injection path: it is how a test campaign drives the ECU's inputs and provokes its diagnostic monitors. The write is gated behind a SecurityAccess level 1 challenge (service 0x27, AES-128-CMAC), and the config comment says plainly what to do before shipping: remove write from access, or compile the injection path out of the build.


The DTCs — detected, not just declared

Two, both real SAE J2012 codes, both evaluated by a 100 ms monitor loop in src/vehicle_state.c:

0xC10000   U0100-00   Lost communication with engine control module
                      engine speed pinned above 8000 rpm for >= 500 ms

0x050064   P0500-64   Vehicle speed signal implausible against engine speed
                      speed > 5.0 km/h while engine reads exactly 0 rpm,
                      for >= 1000 ms

The distinction between a DTC that exists in a table and a DTC that a monitor can actually set is the whole difference between a diagnostic configuration and a diagnostic implementation, and it is the first thing worth checking in anyone's example.

One modelling note, since it is the kind of thing a reviewer will reasonably question: U0100 — "lost communication" — is modelled here as an out-of-range pinned value rather than the more obvious "the value stopped changing." That is because these two DIDs are tester-injected in this configuration, so a value holding steady between writes is normal rather than a fault. The staleness formulation would fire constantly on the bench. On a real bus you would reach for it; here it would be a broken monitor pretending to work.


The actuation path — and why RoutineControl rather than 0x2F

Standing in for the demo app's LED strip is an indicator output stage with two routines, RIDs in the ISO 14229-1 vehicleManufacturerSpecific range:

0xC100 IndicatorOutputSelfTest — asynchronous. startRoutine acknowledges immediately, within the 25 ms P2 budget, and the walk through all four indicator patterns runs in the application's 100 ms task over roughly 400 ms. You poll requestRoutineResults for the verdict.

0xC101 IndicatorLampCheck — the actuation moment. startRoutine takes over the output and holds an all-on pattern; stopRoutine releases it and returns the restored application state. It is also released automatically whenever the session drops back to Default — including on an S3server timeout — so a diagnostic client that disappears mid-test cannot leave the output stuck on.

The obvious question is why an actuation path uses RoutineControl rather than InputOutputControlByIdentifier (0x2F), which is what the service is nominally for. Two concrete reasons, not a preference:

First, core/uds_services/service_0x2F.c exists in the runtime, but no example in the repository wires it up. Doing so needs a callback registered against a DID through a core API that was not designed for runtime re-pointing, and there is no YAML or codegen path to it — you would hand-register it in main.c.

Second, and more decisively: the free xaloqi-tester campaign runner has no io_control action at all. A 0x2F-based actuation path could not be exercised by the free campaign this example ships, which would have made the demonstration unrunnable for exactly the reader it is written for.


Build it

west build -b native_sim examples/vehicle_state_ecu
./build/zephyr/zephyr.exe

That is the whole command — the board overlay and config under boards/ are picked up automatically, and generated/ is pre-committed, so the build needs neither pyyaml/jinja2 nor the commercial codegen templates.

The build is 1123/1123 objects with no warnings in the new files. The binary boots, logs its configuration, and starts the diagnostic task:

UDS stack ready: 5 DIDs  2 DTCs  RX=0x7E0 TX=0x7E8

Addressing is ISO 15765-4 physical for ECU address 0 — 0x7E0 request, 0x7E8 response — rather than the 0x7DF functional broadcast. An explicit own-address is a deliberate choice here, and one the codegen's ASIL-B rule set requires.


Run the campaign

campaigns/vehicle_state.yaml ships two jobs. The first, vehicle_state_validation, needs no build and no hardware at all:

pip install xaloqi-tester
testlab-run --config   examples/vehicle_state_ecu/diagnostics_config.yaml \
            --campaign examples/vehicle_state_ecu/campaigns/vehicle_state.yaml \
            --job      vehicle_state_validation \
            --virtual \
            --json     reports/virtual.json

It runs identity reads, inject-then-read-back on both vehicle-state DIDs, both routines, and the DTC read/clear/re-read round trip. Against the free tier's in-process virtual ECU, the real output:

  [01/22] tester_present                     → OK           (0 ms)
  [02/22] session(extended)                  → OK           (0 ms)
  [03/22] security_access(level=1)           → OK          (18 ms)
  [04/22] read_did(0xF190)                   → OK           (0 ms)
  ...
  [13/22] routine(0xC100,start)              → OK           (0 ms)
  [15/22] routine(0xC100,requestResults)     → OK           (0 ms)
  [16/22] routine(0xC101,start)              → OK           (0 ms)
  [18/22] routine(0xC101,stop)               → OK           (0 ms)
  [19/22] read_dtc                           → OK           (0 ms)
  [20/22] clear_dtc                          → OK           (0 ms)
  [21/22] read_dtc                           → OK           (0 ms)
  [22/22] session(default)                   → OK           (0 ms)

  Result:  PASS
  Steps:   22/22 steps passed

22 of 22 steps pass. The campaign is written to run unchanged against the built native_sim firmware over a real CAN transport too — nothing in it is simulator-specific. Whether that firmware-side run has actually happened is a different question, and the honest answer is in the boundary section below: not yet.


The job that is supposed to fail

The second job, vehicle_state_fault_injection, provokes both monitors and watches each DTC appear and then heal. It is firmware-only by design, and the reason to mention it here is the negative control rather than the feature.

A campaign that passes against a simulator tells you very little unless you know the simulator could have failed it. So: run the fault-injection job under --virtual and it fails, at the right step, for the right reason.

  [06/22] delay(1500ms)                      → OK        (1501 ms)
  [07/22] read_dtc                           → FAIL  Expected 1 DTC(s), ECU reported 0

  ABORTED at step 7 (on_failure: abort)
  Result:  FAIL

The virtual ECU answers 0x2E for any DID, so the injection writes succeed — and then nothing sets a DTC, because the virtual ECU genuinely has no fault monitors. That is the firmware's monitor loop being load-bearing, demonstrated by its absence, rather than a config error producing a convenient red result.


Where the evidence stops

Stated plainly, because a claim without boundaries is not a claim.

Update, same day: both jobs have now been run against the real firmware over a real transport, and both pass. At original publish, driving the built binary needed a vcan0 SocketCAN interface and root that weren't available in the build environment — only the build-time layering below that path had been checked (it correctly attempted to open a real SocketCAN device and failed with ENODEV because the interface didn't exist yet, not for any reason in the code). With a vcan0 interface in place, the same two jobs run against the built native_sim binary: 22/22 steps pass, and the firmware's own log lines confirm both DTC monitors firing and healing for real —

<wrn> vehicle_state: DTC P0500-64 set: speed=450 (0.1 km/h) with engine_rpm=0
<inf> vehicle_state: DTC P0500-64 cleared: plausible again
<wrn> vehicle_state: DTC U0100-00 set: engine_rpm=9000 exceeds plausible max
<inf> vehicle_state: DTC U0100-00 cleared: engine_rpm=800 back in range

Getting there surfaced two real bugs in the test tooling itself, not in the firmware: the SecurityAccess key derivation was still computing the superseded XOR algorithm the firmware's own header comment says was replaced by AES-128-CMAC, and the seed extraction was silently truncated to 4 of the real 8 bytes. Both were specific to the Python test harness, not core/uds_security_algo.c; fixed and confirmed against the exact real transcript that caught them. The point of saying so here is the same point this whole section exists to make: a claim is only as good as what was actually run, including the parts that almost ran and didn't.

Nothing here has run on SoDeV. Not the platform, not an image, not under a hypervisor. EDS is an independent ISO 14229 diagnostics stack; it is not part of, built on, validated by, compatibility-certified for, or endorsed by Automotive Grade Linux, SoDeV or the Linux Foundation. Those names describe the application shape this example mirrors, and nothing more. The example was also written clean rather than derived from any SoDeV source — both projects are Apache-2.0, so copying would have been permitted; their app is small enough that reimplementing the shape was simply the clearer thing to do.

The arm64 result is a compile result. Separately from this example, EDS has been built for xenvm — the Zephyr board AGL SoDeV's DomZ guest domain actually uses. Configure succeeded, compilation surfaced one real bug (a timer-opaque size constant sized for 32-bit, overflowing by 8 bytes on arm64; filed and fixed in principle), and with that corrected plus board-appropriate config, the core, transport, config and platform tree compiled and linked cleanly for arm64. The earned claim is exactly "EDS compiles cleanly for the arm64 Zephyr board that guest uses." It has not booted in a Xen guest domain, because no hypervisor was available to boot it on.

No silicon target, and specifically not the demo's own board. native_sim is the tryout, full stop. Worth saying outright because it is the first thing anyone who knows that demo app will assume: sodev-demo-led-controller builds for rpi_pico and rpi_pico2, and this example does not. EDS has no RP2040 or RP2350 port at all, so there is no Pico binary to hand you and no honest way to pretend otherwise. Native simulation also says nothing about timing, WCET, or any particular MCU's CAN peripheral — the example is portable to any Zephyr board with a CAN controller, but it ships no board-specific hardware target of its own.

No ISO 14229 conformance certification, and the example application is not safety-assessed. The native_sim build also uses placeholder AES keys and no TRNG, and says so loudly in its own boot log — OEM keys from OTP and a real RNG callback are a production prerequisite, not a detail.


What doesn't change for you

The platform layer — Xen partitioning, VirtIO device access, the Yocto build, the Linux-side orchestration — is the platform integrator's problem. If you are writing the ECU function that runs as a Zephyr guest, none of it reaches your diagnostic code. Your interface is UDS over whatever transport the topology hands you.

What changes is the client. The diagnostic client in a consolidated platform is less likely to be a human with a laptop and more likely to be an automated sequence — a production-line script, an OTA orchestrator, a CI job. Automated clients are not forgiving of a session state machine that is nearly right. That is what moves the work from "write a UDS handler" to "validate a UDS handler," and it is the reason the campaign ships alongside the firmware rather than after it.


The practical starting point

If you are scoping diagnostics for an ECU function on a SoDeV-style Zephyr guest:

Run the campaign first. pip install xaloqi-tester and the --virtual command above — no build, no board, no licence. It is the cheapest way to see the shape of what you are scoping.

Then build the example. One west build -b native_sim against the example on GitHub, and there is real firmware with a real monitor loop behind the same campaign.

Point the config at your own signals. diagnostics_config.yaml is the single source: it generates the firmware tables and describes the ECU to the campaign runner. Your DIDs and DTCs are an edit, not a rewrite.

Decide the transport separately and late. CAN/ISO-TP, DoIP, or SOVD at a gateway with UDS behind it — the UDS core does not change when you choose. It is not the decision to front-load.

Put the campaign in the same CI as the platform build. This is the one that pays compound interest, and it is the one teams defer. If the platform is hardware-optional, your validation gets to be too.

The example and its campaign are Apache-2.0 in the EDS repo; the EDS runtime is GPL v2. TestLab is €990/yr for the SocketCAN and DoIP transports. Browse vehicle_state_ecu →