i99dash docs
BYD API

Consumer SDK

CarRegistryClient and featureValueProvider — the minimal Dart surface for consuming the BYD framework. Discovery, reactive watch, write.

The Dart side of the BYD API is two classes and ~200 lines of code. This page is the complete reference.

Why a single client

One CarRegistryClient per app. One EventChannel subscription. Every widget, voice tool, mini-app bridge, and diagnostic surface fans out from this one client. Centralizing the subscription is a correctness rule, not just an optimization — multiple subscribers to i99dash/car/registry would each receive duplicate frames and fight over the EventSink lifecycle.

The whole surface

import 'package:i99dash_car/sdk/car_registry_client.dart';

final api = ref.watch(carRegistryClientProvider);

// Discovery
final live   = await api.liveFeatures();      // Map<String, int>  ~9k
final names  = await api.allCatalogNames();   // Iterable<String>  ~21k

// Reactive watch
final lock = ref.watch(featureValueProvider(
  'Door.DOOR_LOCK_COMMAND_AREA_LEFT_FRONT',
));
// lock is AsyncValue<int?> — emits seed value, then on every push

// Sync read (use sparingly)
final batt = api.value('Statistic.STATISTIC_SOC_BATTERY_PERCENTAGE');

// Write
await api.invoke('door.lock');
final ok = await api.invokeOk('ac.power_on');

// Enumerate actions
final actions = await api.knownActions();

That's it. There are no typed facades, no permission scopes, no value semantics. Higher layers add those when they need them.

CarRegistryClient

allCatalogNames()

Future<Iterable<String>> allCatalogNames();

Every catalog name the framework knows about (~21k on DiLink 5.1+). Cached after first call.

liveFeatures()

Future<Map<String, int>> liveFeatures();

Every catalog name currently bound on this car (~9k). Push-fed thereafter. The first call triggers a one-time seed read from the bridge; subsequent calls return the in-memory cache.

watch(name)

Stream<int?> watch(String name);

Reactive stream of value changes for name. Emits the cached value immediately (or null if never seen), then every push frame.

Always prefer featureValueProvider(name) over calling watch directly — the provider handles autoDispose and the AsyncValue<int?> lifecycle.

value(name)

int? value(String name);

Synchronous read of the most-recent cached value. Use in non-widget code (controllers, voice tool handlers) where you need a snapshot read; for widgets, watch the provider.

invoke(actionId, args?)

Future<Map<String, Object?>> invoke(
  String actionId, [
  Map<String, Object?> args = const {},
]);

Run a fast_action or unit_action by id. Returns the host's reply map — {ok: true} on success, {error: ..., code: ...} otherwise.

await api.invoke('ac.set_temp', {'temp': 22});

invokeOk(actionId, args?)

Future<bool> invokeOk(String actionId, [Map args = const {}]);

Convenience wrapper — returns true iff the host returned ok: true.

knownActions()

Future<List<String>> knownActions();

Every action id the host can dispatch — fast_actions + unit_actions, sorted. Pull once and stash; the list is stable for the app lifetime.

featureValueProvider

final featureValueProvider =
    StreamProvider.autoDispose.family<int?, String>((ref, name) {
  final client = ref.watch(carRegistryClientProvider);
  return client.watch(name);
});

A per-feature reactive provider. Drop into any widget that needs to react to one catalog value.

class LockIcon extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final v = ref.watch(featureValueProvider(
      'Door.DOOR_LOCK_COMMAND_AREA_LEFT_FRONT',
    ));
    return v.when(
      data:    (n) => Icon(n == 1 ? Icons.lock : Icons.lock_open),
      loading: () => const Icon(Icons.lock_outline),
      error:   (_, __) => const Icon(Icons.error_outline),
    );
  }
}
  • autoDispose — when no widget watches a name, its controller drops. The upstream EventChannel sub is shared across the whole app and stays.
  • family<int?, String> — keyed by catalog name. Each name has its own broadcast controller.

App-wide singleton

final carRegistryClientProvider = Provider<CarRegistryClient>((ref) {
  final c = CarRegistryClient(ref.watch(carBridgeProvider));
  ref.onDispose(c.dispose);
  return c;
});

One client per app. Watch the provider from anywhere; Riverpod guarantees you get the same instance.

Seeding race-safety

watch() and liveFeatures() both call an internal _ensureSeeded() that:

  1. Returns immediately if already seeded.
  2. Joins an in-flight seed future if one is running.
  3. Otherwise starts a seed and stashes the future for joiners.

Concurrent first-call from a dozen widgets results in one bridge call, not twelve.

Values that arrive via push during the seed window aren't overwritten — the seed uses putIfAbsent. The push value is fresher than the snapshot, so we prefer it.

Anti-patterns

  • Don't construct your own CarRegistryClient. Always go through carRegistryClientProvider. Multiple clients = multiple EventChannel subs = duplicate frames.
  • Don't poll liveFeatures() on a timer. Subscribe to the features you care about via featureValueProvider.
  • **Don't call value(name) from build()** unless you also read the provider so the widget rebuilds on update. value()` is a sync snapshot — by itself it's a stale-read footgun.
  • Don't watch features your widget can't render. Each watched name keeps a broadcast controller alive while the widget mounts; thousands of orphaned watches add up.
  • Don't try to write through value() or watch(). Reads and writes are separate paths. Writes go through invoke().

Testing

Inject a fake CarTransport (the bridge interface) via carBridgeProvider's override. CarRegistryClient accepts any transport with the right shape — the EventChannel attach is isolated to the constructor so you can also pass a fake bridge that returns canned snapshots without touching native code.

final container = ProviderContainer(overrides: [
  carBridgeProvider.overrideWithValue(FakeTransport()),
]);

See Testing for the broader pattern.

On this page