DEV Community

aki1770-del
aki1770-del

Posted on

Connecting Flutter to Vehicle Signals: Building a Dart SDK for Eclipse KUKSA

When I started building a winter-road navigation app in Flutter, I needed a question answered that no existing package could answer: is the road icy right now?

Not from a pre-loaded map tile. Not from a scheduled weather API. From the vehicle itself — the ESC friction estimate, the TCS event flag, the wiper intensity that the car already computes every 20ms and nobody was surfacing to the UI layer.

The answer to that question lives in Eclipse KUKSA. This post explains what I found when I tried to connect a Flutter navigation app to the KUKSA databroker, and introduces the Dart SDK I built in the process.


The Stack

The signal chain looks like this:

Vehicle ECUs (ABS, ESC, TPMS, wiper controller)
    │  CAN / SOME/IP / LIN
    ▼
kuksa-databroker (Rust, gRPC, VSS schema enforcement)
    │  Zenoh transport layer (v0.8+)
    ▼
Flutter navigation app
    │
    ▼
Driver sees: "Route adjusted for icy conditions ahead"
Enter fullscreen mode Exit fullscreen mode

The KUKSA databroker is the critical piece. It ingests raw ECU signals, maps them to Vehicle Signal Specification (VSS) paths, enforces type safety, and exposes them over gRPC. A Flutter app subscribes to Vehicle.ADAS.ESC.RoadFriction.MostProbable and gets a float between 0.0 and 1.0, where anything below 0.3 means ice.

Until recently, there was no Dart client for the kuksa.val.v2 gRPC API. I wrote one.


kuksa_dart_sdk 0.1.0

kuksa_dart_sdk is a Dart/Flutter client for the Eclipse KUKSA Vehicle Abstraction Layer. It wraps the generated kuksa.val.v2 gRPC stubs in a Dart-idiomatic API:

final client = KuksaClient(host: 'localhost', port: 55555);
await client.connect();

// Subscribe to all snow-safety signals
await for (final update in client.subscribe(kSnowSafetySignals)) {
  final friction = update[kRoadFrictionMostProbable]?.floatValue;
  final tcsActive = update[kTcsIsEngaged]?.boolValue ?? false;

  if ((friction ?? 1.0) < 0.3 || tcsActive) {
    navigationBloc.add(SnowConditionsDetected());
  }
}
Enter fullscreen mode Exit fullscreen mode

The package includes pre-defined signal path constants for the signals most relevant to winter navigation — road friction, TCS/ABS engagement, wiper intensity, air temperature, tire pressure, and the proposed Vehicle.Exterior.RoadSurfaceCondition signal currently in review at COVESA/VSS PR #892.


What I Found in the Databroker

When a subscriber registers for a wildcard path like Vehicle.ADAS.*, the databroker expands the glob pattern to matching signal IDs at subscribe time and stores them in a ChangeSubscription struct alongside a Tokio broadcast::Sender (broker.rs, ~line 150). Critically, this struct carries no expected_type field — only signal IDs, requested fields, permissions, and timing metadata. The fanout delivers EntryUpdates through the broadcast channel unconditionally, with no per-subscriber type re-validation.

The type check that does exist runs at the write gate: Entry::validate() (broker.rs, ~line 265) calls validate_value() against the signal's registered DataType before the value is stored, returning UpdateError::WrongType on mismatch. This is a pre-storage check, not a per-subscriber fan-out check. The internal type system has no Any or untyped bypass — DataType has 24 concrete variants (String through DoubleArray), and DataValue has 17 (types.rs, ~line 89). The gap is not a missing type variant but the absence of any validation hook between the broadcast channel and the subscriber.

Here's what this looks like at the Dart surface. The subscribe() method (kuksa_client.dart, lines 140–149) wraps every gRPC response entry into a Datapoint unconditionally — no type filter, no error handler. When a caller reads floatValue on a Datapoint whose protobuf oneof is actually set to bool_12, the type getter's hasXxx() probe chain (datapoint.dart, lines 48–67) resolves to DatapointType.boolean before ever reaching the hasFloat() check. The accessor guard — type == DatapointType.float ? raw.value.float.toDouble() : null (line 101) — returns null. The stream does not terminate; no exception is thrown; nothing is logged.

This null is indistinguishable from "signal not yet received." In the snow-routing example from the SDK's own docstring, friction == null causes the if (friction != null && friction < 0.3) guard to silently skip activation. A unit test at kuksa_dart_sdk_test.dart lines 45–50 confirms this null-return behavior by design — but no integration test exercises the full subscribe-stream path with a mismatched type. The failure mode is silent, and the existing test suite validates that silence rather than flagging it.

I've filed the broadcast observability half of this in eclipse-kuksa/kuksa-databroker issue #200 (no broadcast_drops_total OTel counter — the Tokio broadcast channel drops with no observable signal). The type-safety gap at the fanout is the structural companion: both stem from the same broadcast::Sender being a fire-and-forget path with no per-subscriber feedback channel. A ChangeSubscription enrichment that adds expected_type: Option<DataType> and a filter stage between the broadcast and the subscriber callback would address both.


The Zenoh Transport Layer

The databroker connects to the broader automotive SDV stack via Zenoh (as of v0.8). This matters because the wildcard subscription gap described above interacts with how Zenoh routes messages: if a Zenoh session reconnects after a network partition, the subscriber receives a synthetic liveliness update carrying an empty payload — no DataPoint, but the same key expression as a real signal. The broadcast::Sender fanout has no mechanism to distinguish this from a genuine update, so the subscriber's type accessor silently returns null again, for a different reason.

I've been contributing to this reconnect surface upstream. eclipse-zenoh/zenoh PR #2564 addresses duplicate liveliness sample delivery in FetchingSubscriber. I've also reached out to @evshary at ZettaScale, who has been working on the Autoware FMS WebSocket reconnect problem (zenoh_autoware_fms #18, open since December 2023) — the root cause overlaps with the same reconnect lifecycle gap.


What's Next

Three things I'm working on in parallel:

1. VSS RoadSurfaceCondition signal

COVESA/VSS PR #892 proposes Vehicle.Exterior.RoadSurfaceCondition as an 8-value enum: UNKNOWN | DRY | WET | SNOW | ICE | SLUSH | WET_ICE | LOOSE_GRAVEL. This signal would let a navigation app switch routing modes in a single conditional rather than composing friction + temperature + wiper heuristics. kuksa_dart_sdk already defines kRoadSurfaceCondition as a constant, waiting for the signal to land in VSS and propagate to a kuksa-someip-provider implementation.

2. ros2/rcl integration

For the navigation stack (ros-navigation/navigation2) to benefit from KUKSA signals, the ROS 2 middleware layer needs to be reliable. ros2/rcl PR #1313 — recently approved by @fujitatomoya — fixes a silent error-clobber in rcl_node_init that caused the original RMW error string to be overwritten with a generic message, making debugging navigation node failures significantly harder.

3. CAN bus binding for Dart

For embedded systems where KUKSA hasn't been deployed yet, ardera/dart_periphery PR #1 adds a native SocketCAN binding — CanSocket / CanFrame — to the existing periphery package. This lets a Flutter app on an embedded Linux IVI system read raw CAN frames directly, without the full KUKSA stack as a prerequisite.


Try It

# pubspec.yaml
dependencies:
  kuksa_dart_sdk: ^0.1.0
Enter fullscreen mode Exit fullscreen mode

Source: github.com/aki1770-del/kuksa_dart_sdk

The package targets embedded Linux IVI (Raspberry Pi 4, Renesas R-Car, i.MX 8) running a KUKSA databroker alongside a Flutter application. For development, the databroker mock mode covers all signal paths without a real vehicle:

docker run --rm -p 55555:55555 \
  ghcr.io/eclipse-kuksa/kuksa-databroker:latest --mock-datapoints
Enter fullscreen mode Exit fullscreen mode

AI-assisted — authored with Claude, reviewed by Komada.

Top comments (0)