All posts
Engineering · June 2026 · 7 min read

UDS campaigns in CI — running protocol tests without hardware

In most embedded projects, CI verifies that the firmware compiles. UDS protocol testing happens on a bench, by a test engineer with a PCAN adapter and a physical ECU. If that engineer is busy, or the bench is shared, or the firmware is still being integrated — the protocol test waits.

This post describes a different approach: running full UDS campaigns — session control, SecurityAccess, DID reads and writes, DTC management, firmware download — as part of every CI run, against a virtual ECU, with no hardware required. The campaigns that run in CI are identical to the ones that run on your bench. The same YAML. The same assertions. The same pass/fail output.


Why UDS testing belongs in CI

The class of bugs that manual bench testing catches late:

Session gate regressions. You add a new DID to the extended session. A refactor accidentally moves it back to default. The firmware ships. Your field team notices three weeks later that the calibration tool can no longer read it.

Security access protocol errors. The seed/key algorithm changes. The key derivation is subtly wrong for one specific ECU variant. You find this during system integration, not during development.

NRC confusion. A service handler returns 0x31 (requestOutOfRange) where it should return 0x7F (requestOutOfRange-in-wrong-session). The difference matters: 0x31 says "this DID doesn't exist," 0x7F says "this DID exists but not in your session." A tester working against spec notices immediately. CI with a protocol-aware assertion catches it on commit.

These aren't exotic edge cases. They're the bugs that appear whenever someone touches a UDS service handler and doesn't have a bench to test against.


The --virtual flag

TestLab's campaign runner has a --virtual flag that runs the ECU simulator in-process — no vcan, no CAN hardware, no kernel modules. The simulator implements all 14 UDS services (0x10, 0x11, 0x14, 0x19, 0x22, 0x27, 0x28, 0x2E, 0x31, 0x34, 0x36, 0x37, 0x3E, 0x85) against an in-memory ECU state.

testlab-run \
    --config   diagnostics_config.yaml \
    --campaign campaigns/basic_validation.yaml \
    --job      eol_production_check \
    --virtual \
    --json     reports/ci_run.json

The transport layer is VirtualBus — two Python queue objects wired as a loopback. The tester sends a UDS PDU. The ECU receives it, processes it, and writes a response. The tester reads the response. No CAN controller involved.

What you get from --virtual that you don't get from unit tests: the full ISO-TP framing layer runs. Multi-frame transfers (segmented DIDs, firmware download sequences) go through the complete ISO-TP state machine. SecurityAccess runs the real seed/key exchange — not a mock. Session state transitions are enforced. NRC codes are generated by the same dispatch logic that runs when the ECU state machine rejects a request in production.


A campaign in three files

diagnostics_config.yaml — your ECU's DID/DTC table, identical to what EDS uses:

dids:
  - id: "0xF190"
    name: "VIN"
    data_length: 17
    access: [read]
    min_session: default
    read_security_level: 0

  - id: "0xF187"
    name: "SparePart"
    data_length: 11
    access: [read, write]
    min_session: extended
    read_security_level: 0
    write_security_level: 1

campaigns/basic_validation.yaml — the test sequence:

jobs:
  eol_production_check:
    description: EOL gate — verify DID access and DTC state
    on_failure: continue
    steps:
      - action: session
        value:  default
      - action: read_did
        did:    "0xF190"
        save_as: vin
      - action: assert
        var:    vin
        length: 17
      - action: session
        value:  extended
      - action: security_access
        level:  1
      - action: read_did
        did:    "0xF187"
        save_as: spare_part
      - action: foreach_did
        min_session: extended
        expect_ok:   true
      - action: clear_dtc
      - action: read_dtc

GitHub Actions step:

- name: Run UDS campaign (virtual ECU)
  env:
    XALOQI_TESTLAB_KEY: ${{ secrets.XALOQI_TESTLAB_KEY }}
  run: |
    testlab-run \
        --config   docker/ecu_sim/diagnostics_config.yaml \
        --campaign campaigns/basic_validation.yaml \
        --job      eol_production_check \
        --virtual \
        --json     reports/ci_run_${{ github.run_id }}.json

No sudo modprobe vcan. No apt-get install linux-modules-extra-azure. The runner needs Python and the TestLab package. That's it.


Structured output

Every run produces a JSON file with a stable schema (schema_version: 1):

{
  "schema_version": 1,
  "ecu": "TestLab-Sim v1.0",
  "job": "eol_production_check",
  "timestamp": "2026-06-03T14:22:10Z",
  "transport": "virtual",
  "steps": [
    {
      "action": "read_did",
      "did": "0xF190",
      "status": "PASS",
      "data": "5748495350455230303030303030303030",
      "duration_ms": 2
    },
    {
      "action": "security_access",
      "level": 1,
      "status": "PASS",
      "duration_ms": 4
    }
  ],
  "summary": { "pass": 8, "fail": 0, "skip": 0 }
}

Accumulate these across runs and you get trend data:

testlab trend --results reports/ci_run_*.json
Run   Date        Pass  Fail  Transport
----  ----------  ----  ----  ---------
042   2026-06-03  8     0     virtual
041   2026-06-02  8     0     virtual
040   2026-06-01  7     1     virtual    ← regression on 0xF187
039   2026-05-31  8     0     virtual

HTML reports are self-contained single files — attach them to a bug ticket, forward by email, open them without a web server.

testlab report --results reports/ci_run_*.json --out reports/weekly.html

When a step fails

testlab explain interprets the NRC codes in a failed run. No API key needed for the offline table:

testlab explain --results reports/ci_run_042.json --offline
Step 3 — read_did(0xF187)  ·  NRC 0x33 securityAccessDenied
─────────────────────────────────────────────────────────────
  securityAccessDenied — The ECU requires security access before this
  operation. Add a 'security_access' step with the required level
  before this read/write.

With an Anthropic API key, it reads your diagnostics_config.yaml and names the DID, the required session, and the required security level from your actual config — not a generic NRC description.


Same campaign, two RTOS targets

If you're building the same UDS stack on Zephyr and FreeRTOS — which EDS supports — you can run the identical campaign against both virtual targets and diff the results:

testlab-run \
    --config   diagnostics_config.yaml \
    --campaign campaigns/basic_validation.yaml \
    --job      basic_validation \
    --rtos     zephyr,freertos \
    --rtos-interfaces vcan0,vcan1 \
    --rtos-out reports/rtos_diff.html

The diff report shows every step where the two targets diverged — different NRC, different timing, different data. If Zephyr returns 0x7F on a session gate and FreeRTOS returns 0x31, that's a protocol divergence worth understanding before both go to production.

This is the CI-level equivalent of "run the same diagnostic session on both targets and manually compare the traces in a CAN analyzer." Except it runs automatically on every commit and produces a structured diff.


From virtual to hardware

The same campaign YAML that runs in CI against --virtual also runs against a physical ECU on your bench. Swap the flag:

testlab-run \
    --config   diagnostics_config.yaml \
    --campaign campaigns/basic_validation.yaml \
    --job      eol_production_check \
    --interface vcan0 \
    --json     reports/hw_run.json

Or with a PEAK PCAN USB adapter:

testlab-run \
    --config    diagnostics_config.yaml \
    --campaign  campaigns/basic_validation.yaml \
    --job       eol_production_check \
    --interface PCAN_USBBUS1 \
    --json      reports/hw_run.json

The campaign is identical. The assertions are identical. The JSON output schema is identical. You can run testlab compare between a virtual run and a hardware run and see exactly where the virtual ECU's behavior diverges from the real one.

That comparison is useful during development — it tells you what the simulator doesn't cover. And it's useful at release — it tells you whether the firmware on the bench matches the protocol behavior you've been testing in CI for the past three months.


What this catches vs. what it doesn't

Virtual ECU testing catches protocol-layer bugs: wrong NRC codes, session gate regressions, security access sequence errors, DID length mismatches, DTC state machine errors, ISO-TP multi-frame framing bugs.

It doesn't catch timing-dependent bugs that require real CAN bus arbitration, hardware interrupt latency issues, or bugs in the CAN peripheral driver that only appear on specific silicon. For those, you need hardware. But hardware testing finds bugs much faster when the protocol layer is already verified.

The practical pattern: virtual campaigns in CI on every commit, hardware campaigns in a nightly job against a bench ECU (or pre-release against a HIL rig). By the time the hardware campaign runs, the protocol bugs are already gone.

Xaloqi TestLab is available at €990/yr. Bundles with Xaloqi EDS are available at €1,490/yr (Developer) and €2,690/yr (Professional). See pricing →