Kuksa-to-LIVI Telemetry Bridge
The Kuksa-to-LIVI Telemetry Bridge (kuksa-livi-bridge) is an Ankaios workload on the Raspberry Pi 5 that feeds live VSS values from the Kuksa Databroker into the LIVI IVI head unit running on the Raspberry Pi 4.
It is the symmetric counterpart of the MQTT-to-Kuksa gRPC Bridge: where that bridge ingests sensor data into the databroker, the LIVI bridge forwards databroker state to the dashboard.
Source: devices/raspberry-pi5/grpc-to-LIVI-telemetry-bridge/bridge.py
Role in the system
How it works
The bridge is a small Python service (mirrored after the existing grpc-mqtt-bridge) that:
- Opens a gRPC connection to the Kuksa Databroker using the official
kuksa-clientVSSClient. - Subscribes to a configurable list of VSS paths via
subscribe_current_values(...). - Looks up each updated path in its mapping table and applies an optional enum map, scale/offset, and type coercion.
- Merges the result into a
TelemetryPayloadJSON object — including nested keys such asgps.lat. - Coalesces all changes inside a sliding window (default 250 ms) and pushes them as a single batched event to LIVI's Socket.IO endpoint (
ws://<livi-host>:4000, eventtelemetry:push).
This matches the same wire-format that LIVI's own pnpm -C scripts/tools run telemetry:set … CLI uses:
# What the CLI does: What the bridge sends:
pnpm telemetry:set turn=left → { "ts": 1717238400000, "turn": "left" }
pnpm telemetry:set gps.lat=53.59 \ → { "ts": ..., "gps": { "lat": 53.59,
gps.lng=10.01 "lng": 10.01 } }
pnpm telemetry:set _repeatMs=1000 \ → controlled by bridge `push.intervalMs`
speedKph=90 rpm=2500
Container image
The bridge is published as a multi-arch (linux/amd64, linux/arm64) OCI image via the GitHub Actions workflow publish-kuksa-livi-bridge.yml:
ghcr.io/<owner>/e2e-vehicle-signals/kuksa-livi-bridge:main
It is launched on the Pi 5 by Ankaios as the kuksa-livi-bridge workload (see vehicle-signals.yaml) with --net=host, so it reaches both the local Databroker (localhost:55555) and the IVI Pi 4 (192.168.88.110:4000) over the wired link.
Signal mapping: VSS → LIVI TelemetryPayload
The mapping table lives in devices/raspberry-pi5/ankaios/grpc-livi.yaml. Each entry has a vssPath, a dotted liviField, and optional type / enumMap / scale / offset transforms.
Default mappings (demo signals)
| VSS path (Kuksa, gRPC) | LIVI field | Type | Transform | Notes |
|---|---|---|---|---|
Vehicle.Body.Lights.DirectionIndicator.Left.IsSignaling and …Right.IsSignaling | turn | string | composite | Both booleans feed a single turn enum ('left' / 'right' / 'none') via the composites: block — see Composites below. |
Vehicle.Body.Lights.Brake.IsActive | hazards | bool | INACTIVE/ACTIVE → false, ADAPTIVE → true | LIVI hazards bool — flashes on emergency braking |
Vehicle.Driver.Identifier.Subject | driverId | string | identity | Custom extension field (TelemetryPayload's open [key: string]: unknown) |
Fleet Management telemetry signals (csv-provider)
When the Fleet Management blueprint is running, the csv-provider replays the recording in signalsFmsRecording.csv into the Kuksa Databroker. The bridge picks every one of those signals up over gRPC and forwards them to LIVI:
| # | VSS path (from csv-provider) | LIVI field | Type | Transform | Notes |
|---|---|---|---|---|---|
| 1 | Vehicle.VehicleIdentification.VIN | vin | string | identity | Custom extension field — displayed in LIVI "About vehicle" |
| 2 | Vehicle.Speed | speedKph | float | identity | Primary cluster speed (km/h) |
| 3 | Vehicle.Tachograph.VehicleSpeed | gps.speedMs | float | scale: 0.27778 | Tachograph reports km/h; LIVI's gps.speedMs is m/s (÷ 3.6). AA prefers this over speedKph |
| 4 | Vehicle.Powertrain.CombustionEngine.Speed | rpm | int | identity | Engine RPM |
| 5 | Vehicle.Powertrain.FuelSystem.Tank.First.RelativeLevel | fuelPct | float | identity | Already in 0..100 % |
| 6 | Vehicle.Powertrain.CombustionEngine.DieselExhaustFluid.Level | defLevelPct | float | identity | Custom extension — AdBlue / DEF level |
| 7 | Vehicle.Powertrain.CombustionEngine.EngineHours | engineHours | float | identity | Custom extension — operating hours |
| 8 | Vehicle.Chassis.ParkingBrake.IsEngaged | parkingBrake | bool | identity | Native LIVI boolean, routed Dash + AA |
| 9 | Vehicle.Exterior.AirTemperature | ambientC | float | identity | LIVI also forwards to AA env channel |
| 10 | Vehicle.TraveledDistanceHighRes | odometerKm | float | scale: 0.001 | FMS reports metres (5 m resolution) → LIVI odometer in km |
| 11 | Vehicle.CurrentOverallWeight | weightKg | float | identity | Custom extension — gross vehicle weight |
| 12 | Vehicle.Tachograph.Driver.Driver1.WorkingState | driver1State | string | identity | Custom extension — REST / DRIVER_AVAILABLE / WORK / DRIVE |
| 13 | Vehicle.Tachograph.Driver.Driver2.WorkingState | driver2State | string | identity | Custom extension |
| 14 | Vehicle.Tachograph.Driver.Driver1.IsCardPresent | driver1CardPresent | bool | identity | Custom extension |
| 15 | Vehicle.Tachograph.Driver.Driver2.IsCardPresent | driver2CardPresent | bool | identity | Custom extension |
All
Vehicle.Tachograph.…andVehicle.VehicleIdentification.VIN,Vehicle.CurrentOverallWeight,Vehicle.Powertrain.CombustionEngine.DieselExhaustFluid.Level,Vehicle.Powertrain.CombustionEngine.EngineHoursfields land in LIVI's open[key: string]: unknownextension slot. LIVI's Dashboard merges them into its central store like any other field, so they can be surfaced by custom widgets without breaking the upstreamTelemetryPayloadcontract.
Optional extended cluster mapping
Beyond the demo and FMS signals above, LIVI's TelemetryPayload (see Telemetry.ts) exposes additional cluster fields. The bridge supports them out of the box — wire them up by adding new entries to grpc-livi.yaml once the corresponding VSS signals are produced:
| LIVI field | LIVI route (Dash / AA / Dongle) | Suggested VSS source | Transform |
|---|---|---|---|
speedKph | ✓ ✓ · | Vehicle.Speed | type: float |
rpm | ✓ ✓ · | Vehicle.Powertrain.CombustionEngine.Speed | type: int |
gear | ✓ ✓ · | Vehicle.Powertrain.Transmission.SelectedGear | identity (string or int) |
reverse | ✓ ✓ · | derived from gear == -1 | LIVI auto-derives if gear is sent |
lights | ✓ ✓ · | Vehicle.Body.Lights.Beam.Low.IsOn | type: bool |
highBeam | ✓ ✓ · | Vehicle.Body.Lights.Beam.High.IsOn | type: bool |
parkingBrake | ✓ ✓ · | Vehicle.Chassis.ParkingBrake.IsEngaged | type: bool |
fuelPct | ✓ ✓ · | Vehicle.Powertrain.FuelSystem.Level | type: float |
rangeKm | ✓ ✓ · | Vehicle.Powertrain.FuelSystem.Range | scale: 0.001 (VSS = m, LIVI = km) |
coolantC | ✓ · · | Vehicle.Powertrain.CombustionEngine.ECT | type: float |
ambientC | ✓ ✓ · | Vehicle.Exterior.AirTemperature | type: float |
batteryV | ✓ · · | Vehicle.LowVoltageBattery.CurrentVoltage | type: float |
odometerKm | ✓ ✓ · | Vehicle.TraveledDistance | scale: 0.001 |
gps.lat | ✓ ✓ ✓ | Vehicle.CurrentLocation.Latitude | type: float |
gps.lng | ✓ ✓ ✓ | Vehicle.CurrentLocation.Longitude | type: float |
gps.alt | ✓ ✓ · | Vehicle.CurrentLocation.Altitude | type: float |
gps.heading | ✓ ✓ · | Vehicle.CurrentLocation.Heading | type: float |
gps.speedMs | ✓ ✓ · | derived from Vehicle.Speed / 3.6 | LIVI prefers this over speedKph for AA |
nightMode | ✓ ✓ ✓ | Vehicle.Cabin.Light.AmbientLight.IsLightOn (or similar) | type: bool |
ts | ✓ · · | bridge injects int(time.time() * 1000) | Always added if absent |
The routing column comes directly from the TELEMETRY_ROUTES table in Telemetry.ts — ✓ = consumed, · = ignored by that receiver.
Nested fields
LIVI shallow-merges sub-blocks such as gps and can. The bridge supports dotted liviField notation, so:
- vssPath: Vehicle.CurrentLocation.Latitude
liviField: gps.lat
type: float
- vssPath: Vehicle.CurrentLocation.Longitude
liviField: gps.lng
type: float
produces a payload of the shape { "gps": { "lat": ..., "lng": ... } } — exactly what LIVI's GpsPayload expects.
Value transforms
Field in mappings | Order applied | Purpose |
|---|---|---|
enumMap | 1 | Map a categorical VSS value (bool, string enum, int) to a LIVI enum (e.g. 'ADAPTIVE' → true). |
scale | 2 | Multiply numeric values (e.g. metres → kilometres with 0.001). |
offset | 2 | Additive offset applied after scale. |
type | 3 | Final cast (bool, int, float, string). For bool from strings, "ACTIVE" / "ADAPTIVE" / "true" / "1" / "yes" / "on" resolve to true. |
skipValues | pre-1 | Raw VSS values that suppress the push entirely — useful to drop noisy false edges that would otherwise clobber a shared LIVI field. |
null / missing VSS values are skipped by default to avoid clobbering known LIVI state; set sendNone: true on a mapping to forward them anyway.
Composites — derived fields from multiple VSS inputs
Some LIVI fields are best computed jointly from several VSS paths — the canonical
example is turn, which combines both DirectionIndicator.*.IsSignaling booleans
into a single 'left' | 'right' | 'none' enum without one side clobbering the
other. The bridge supports this through the top-level composites: block in
grpc-livi.yaml:
composites:
- liviField: turn
inputs:
- Vehicle.Body.Lights.DirectionIndicator.Left.IsSignaling
- Vehicle.Body.Lights.DirectionIndicator.Right.IsSignaling
rules:
- when: { Vehicle.Body.Lights.DirectionIndicator.Left.IsSignaling: true }
value: left
- when: { Vehicle.Body.Lights.DirectionIndicator.Right.IsSignaling: true }
value: right
default: none
Semantics:
- The bridge subscribes to every path that appears in
inputs:(in addition to allmappings:paths) and caches the latest value of each. - Whenever any input changes, the composite is re-evaluated: rules are
checked top-to-bottom, the first one whose
when:matches the current VSS snapshot wins, and itsvalue:is pushed toliviField. If no rule matches,default:is used (or nothing is sent whendefault:is absent). - Composite outputs are de-duplicated: a re-evaluation that yields the same value as the previous push is suppressed, so a fast train of identical updates does not flood LIVI.
- Booleans are compared strictly (
True != 1), so awhen: { …: true }rule only fires for an actual VSS boolean oftrue.
Composites participate in the same 250 ms batching window as simple mappings.
Communication workflow
Operations
Bridge logs:
podman logs kuksa-livi-bridge(on the Pi 5). Enable--log-level DEBUGto see every VSS → LIVI translation.Manual smoke test from a workstation:
python -m socketio --url ws://192.168.88.110:4000 \
emit telemetry:push '{"speedKph": 73, "turn": "left"}'…or directly with LIVI's own CLI on the Pi 4:
pnpm -C scripts/tools run telemetry:set speedKph=73 turn=left.Adding new signals: append a
mappings:entry ingrpc-livi.yaml, redeploy thekuksa-livi-bridgeAnkaios workload. No changes to the bridge image are required.
See also
- IVI Head Unit (LIVI) — the Pi 4 device that consumes the telemetry stream.
- MQTT-to-Kuksa gRPC Bridge — sister bridge in the opposite direction (Mosquitto → Kuksa).
- gRPC-to-Gamecontroller Bridge — third consumer of the central databroker.