Your first virtual ECU: a hazard-light walkthrough
From zero to a tested virtual ECU with RemotiveLabs, file by file: signal databases, a Python behavioral model, topology build, and a pytest that proves it works.
What we’re building
I’ve written about why a virtual car is just a Docker Compose stack. This post is the hands-on companion: a walkthrough you can type along with, from an empty folder to a passing test — no hardware, no prior automotive background needed.
The example: press the hazard-light button → the Body Control Module (BCM) sets both turn-light requests → a test asserts the front-light module actually receives them. Small enough to fit in a post, real enough to touch every layer: two CAN buses, a signal database each, one behavioral model, and a containerized pytest. It’s based on RemotiveLabs’ public getting-started example, so every file here is reproducible.
Setup (10 minutes, once)
Prerequisites: Python ≥ 3.10, native Docker (not Docker Desktop — its hidden VM breaks host networking later), and a RemotiveLabs account for the trial.
There are two separate installs, and you need both:
# 1. The CLI — runs topologies (pipx keeps it isolated and on PATH)
pipx install remotivelabs-cli
# 2. The Python library — what your ECU code imports
pip install -U remotivelabs-topology
# Verify both
remotive --version && remotive topology version
Then authenticate and start the trial — remotive topology build refuses to run without an active subscription:
remotive cloud auth login
remotive topology start-trial # 30-day trial
remotive topology subscription status
Two gotchas that bite everyone: pin the broker tag (broker and platform schema versions are coupled; a mismatch fails with E004), and if remotive behaves oddly, check for PATH drift with which -a remotive — a stray copy from an old install shadows the pipx one.
The file layout
Seven files, four layers — database, platform, model, instance:
getting_started/
├─ platform/
│ ├─ topology.platform.yaml # buses → databases
│ └─ databases/
│ ├─ driver_can.dbc # DriverCan0 (hazard button)
│ └─ body_can.dbc # BodyCan0 (turn lights)
├─ models/
│ ├─ bcm/__main__.py # BCM logic
│ └─ bcm.instance.yaml # BCM → container
├─ tests/tester.instance.yaml # FLCM + pytest
├─ instances/main.instance.yaml # ties it together
└─ Dockerfile + pyproject.toml + uv.lock
Step 1 — the signal databases
.dbc is the ancient, ubiquitous CAN database format. Here’s the driver bus — one 1-byte message, one 1-bit signal:
BU_:
SCCM
BCM
BO_ 100 HazardLightButton: 1 SCCM
SG_ HazardLightButton : 0|1@1+ (1,0) [0|1] "" BCM
VAL_ 100 HazardLightButton 0 "Off" 1 "On";
BA_ "GenMsgCycleTime" BO_ 100 50;
Reading it: BU_ lists the ECUs; BO_ 100 is a 1-byte frame sent by the steering-column module (SCCM); SG_ is a 1-bit signal inside it, received by BCM; VAL_ maps 0/1 to Off/On; the cycle time is 50 ms. The body bus database is the same idea — frame TurnLightControl (id 103) from BCM with LeftTurnLightRequest and RightTurnLightRequest bits, received by the light modules.
This file is the contract. Everything else in the stack decodes against it.
Step 2 — the platform
The platform binds each bus to its database — the “physical wiring” of the car:
schema: remotive-topology-platform:0.17
channels:
DriverCan0:
type: can
database: ./databases/driver_can.dbc
BodyCan0:
type: can
database: ./databases/body_can.dbc
Step 3 — the behavioral model
The BCM is a small async Python program. Wiring first: one CanNamespace per bus, a restbus that periodically transmits BCM’s frames, and an input handler routing the button frame to a callback:
async with BrokerClient(url=avp.url, auth=avp.auth) as broker:
body_can_0 = CanNamespace("BCM-BodyCan0", broker,
restbus_configs=[RestbusConfig([filters.SenderFilter(ecu_name="BCM")])])
driver_can_0 = CanNamespace("BCM-DriverCan0", broker)
bcm = BCM(body_can_0)
async with BehavioralModel("BCM",
namespaces=[body_can_0, driver_can_0], broker_client=broker,
input_handlers=[driver_can_0.create_input_handler(
[filters.FrameFilter("HazardLightButton")], bcm.on_hazard_light)],
) as bm:
await bm.run_forever()
And the actual ECU logic — the entire point of the exercise — is four lines:
async def on_hazard_light(self, frame: Frame):
val = frame.signals["HazardLightButton.HazardLightButton"]
# mirror the hazard press to BOTH turn lights
await self.body_can_0.restbus.update_signals(
("TurnLightControl.RightTurnLightRequest", val),
("TurnLightControl.LeftTurnLightRequest", val),
)
Note what’s absent: no bit offsets, no frame packing, no CAN IDs. Everything is by signal name; the broker does the encoding against the .dbc. The restbus is the detail people miss — real buses have periodic keep-alive traffic, and the restbus synthesizes it, so your updated signal values ride out on the next scheduled frame instead of needing explicit sends.
Step 4 — instances
Instances declare what actually runs. The main instance pulls in the platform and the parts; the BCM instance says “run this model as a container”:
# instances/main.instance.yaml
schema: remotive-topology-instance:0.17
name: getting-started
platform:
includes: [../platform/topology.platform.yaml]
includes:
- ../models/bcm.instance.yaml
- ../tests/tester.instance.yaml
# models/bcm.instance.yaml
ecus:
BCM:
models:
bcm:
type: container
container:
build: { dockerfile: ../Dockerfile }
command: python -m bcm
Step 5 — the test as part of the car
This is my favorite design decision in the whole stack: the test rig is just another instance file. FLCM is a real ECU stub under test, SCCM is a mock (the test sends the button through it), and tester is a pytest container talking to the broker:
containers:
tester:
profiles: [tester]
command: "pytest --broker_url=http://topology-broker.com:50051 -s -vv"
depends_on: [ FLCM-broker.com ]
ecus:
FLCM: {}
SCCM:
mock: {}
Steps 6–8 — build and run
One Dockerfile builds all ECU containers (the one rule: models/ must live inside the Docker build context, or COPY can’t see it). Then instance YAMLs go in, a docker-compose comes out:
remotive topology build \
-f getting_started/instances/main.instance.yaml \
-f getting_started/settings/can_over_udp.settings.instance.yaml \
--name getting_started build
Each -f layers another instance file — the second one here switches CAN to run over UDP, which avoids installing the CAN Docker plugin entirely. Then:
docker compose -f build/getting_started/docker-compose.yml \
--profile tester up --abort-on-container-exit --build
A green pytest means: the button frame crossed the driver bus, the BCM decoded it, mirrored it to two signals on the body bus, the restbus carried them out, and the front-light module received both. That’s a virtual car, tested, on your laptop.
The checklist and the failure table
Everything a runnable virtual ECU needs: (1) signal databases, (2) a platform binding buses to them, (3) a behavioral model per ECU, (4) instances wiring it together, (5) a Dockerfile with the code inside the build context, (6) matching schema/broker versions and a valid trial.
And the errors you’ll actually hit:
| Symptom | Fix |
|---|---|
E004 version mismatch | Match platform schema to broker version — pin both |
SOME/IP name_conflict on restart | Always up -d the full stack, never one service |
type: can needs a plugin | Use CAN-over-UDP (default_driver: udp) |
remotive not found / weird version | PATH drift — which -a remotive |
| Playback runs 1000× too fast | Offsets are microseconds — write --offset 10s, never a bare number |
From here, the same four layers scale to the real thing: more buses, more ECUs, a real Android head unit, and a physics simulator on the chassis bus. The layers never change — that’s what makes the small example worth learning properly.