Skip to main content

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:

  1. Opens a gRPC connection to the Kuksa Databroker using the official kuksa-client VSSClient.
  2. Subscribes to a configurable list of VSS paths via subscribe_current_values(...).
  3. Looks up each updated path in its mapping table and applies an optional enum map, scale/offset, and type coercion.
  4. Merges the result into a TelemetryPayload JSON object — including nested keys such as gps.lat.
  5. 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, event telemetry: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 fieldTypeTransformNotes
Vehicle.Body.Lights.DirectionIndicator.Left.IsSignaling and …Right.IsSignalingturnstringcompositeBoth booleans feed a single turn enum ('left' / 'right' / 'none') via the composites: block — see Composites below.
Vehicle.Body.Lights.Brake.IsActivehazardsboolINACTIVE/ACTIVE → false, ADAPTIVE → trueLIVI hazards bool — flashes on emergency braking
Vehicle.Driver.Identifier.SubjectdriverIdstringidentityCustom 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 fieldTypeTransformNotes
1Vehicle.VehicleIdentification.VINvinstringidentityCustom extension field — displayed in LIVI "About vehicle"
2Vehicle.SpeedspeedKphfloatidentityPrimary cluster speed (km/h)
3Vehicle.Tachograph.VehicleSpeedgps.speedMsfloatscale: 0.27778Tachograph reports km/h; LIVI's gps.speedMs is m/s (÷ 3.6). AA prefers this over speedKph
4Vehicle.Powertrain.CombustionEngine.SpeedrpmintidentityEngine RPM
5Vehicle.Powertrain.FuelSystem.Tank.First.RelativeLevelfuelPctfloatidentityAlready in 0..100 %
6Vehicle.Powertrain.CombustionEngine.DieselExhaustFluid.LeveldefLevelPctfloatidentityCustom extension — AdBlue / DEF level
7Vehicle.Powertrain.CombustionEngine.EngineHoursengineHoursfloatidentityCustom extension — operating hours
8Vehicle.Chassis.ParkingBrake.IsEngagedparkingBrakeboolidentityNative LIVI boolean, routed Dash + AA
9Vehicle.Exterior.AirTemperatureambientCfloatidentityLIVI also forwards to AA env channel
10Vehicle.TraveledDistanceHighResodometerKmfloatscale: 0.001FMS reports metres (5 m resolution) → LIVI odometer in km
11Vehicle.CurrentOverallWeightweightKgfloatidentityCustom extension — gross vehicle weight
12Vehicle.Tachograph.Driver.Driver1.WorkingStatedriver1StatestringidentityCustom extension — REST / DRIVER_AVAILABLE / WORK / DRIVE
13Vehicle.Tachograph.Driver.Driver2.WorkingStatedriver2StatestringidentityCustom extension
14Vehicle.Tachograph.Driver.Driver1.IsCardPresentdriver1CardPresentboolidentityCustom extension
15Vehicle.Tachograph.Driver.Driver2.IsCardPresentdriver2CardPresentboolidentityCustom extension

All Vehicle.Tachograph.… and Vehicle.VehicleIdentification.VIN, Vehicle.CurrentOverallWeight, Vehicle.Powertrain.CombustionEngine.DieselExhaustFluid.Level, Vehicle.Powertrain.CombustionEngine.EngineHours fields land in LIVI's open [key: string]: unknown extension 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 upstream TelemetryPayload contract.

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 fieldLIVI route (Dash / AA / Dongle)Suggested VSS sourceTransform
speedKph✓ ✓ ·Vehicle.Speedtype: float
rpm✓ ✓ ·Vehicle.Powertrain.CombustionEngine.Speedtype: int
gear✓ ✓ ·Vehicle.Powertrain.Transmission.SelectedGearidentity (string or int)
reverse✓ ✓ ·derived from gear == -1LIVI auto-derives if gear is sent
lights✓ ✓ ·Vehicle.Body.Lights.Beam.Low.IsOntype: bool
highBeam✓ ✓ ·Vehicle.Body.Lights.Beam.High.IsOntype: bool
parkingBrake✓ ✓ ·Vehicle.Chassis.ParkingBrake.IsEngagedtype: bool
fuelPct✓ ✓ ·Vehicle.Powertrain.FuelSystem.Leveltype: float
rangeKm✓ ✓ ·Vehicle.Powertrain.FuelSystem.Rangescale: 0.001 (VSS = m, LIVI = km)
coolantC✓ · ·Vehicle.Powertrain.CombustionEngine.ECTtype: float
ambientC✓ ✓ ·Vehicle.Exterior.AirTemperaturetype: float
batteryV✓ · ·Vehicle.LowVoltageBattery.CurrentVoltagetype: float
odometerKm✓ ✓ ·Vehicle.TraveledDistancescale: 0.001
gps.lat✓ ✓ ✓Vehicle.CurrentLocation.Latitudetype: float
gps.lng✓ ✓ ✓Vehicle.CurrentLocation.Longitudetype: float
gps.alt✓ ✓ ·Vehicle.CurrentLocation.Altitudetype: float
gps.heading✓ ✓ ·Vehicle.CurrentLocation.Headingtype: float
gps.speedMs✓ ✓ ·derived from Vehicle.Speed / 3.6LIVI 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 mappingsOrder appliedPurpose
enumMap1Map a categorical VSS value (bool, string enum, int) to a LIVI enum (e.g. 'ADAPTIVE' → true).
scale2Multiply numeric values (e.g. metres → kilometres with 0.001).
offset2Additive offset applied after scale.
type3Final cast (bool, int, float, string). For bool from strings, "ACTIVE" / "ADAPTIVE" / "true" / "1" / "yes" / "on" resolve to true.
skipValuespre-1Raw 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 all mappings: 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 its value: is pushed to liviField. If no rule matches, default: is used (or nothing is sent when default: 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 a when: { …: true } rule only fires for an actual VSS boolean of true.

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 DEBUG to 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 in grpc-livi.yaml, redeploy the kuksa-livi-bridge Ankaios workload. No changes to the bridge image are required.

See also