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:
- Returns immediately if already seeded.
- Joins an in-flight seed future if one is running.
- 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 throughcarRegistryClientProvider. Multiple clients = multiple EventChannel subs = duplicate frames. - Don't poll
liveFeatures()on a timer. Subscribe to the features you care about viafeatureValueProvider. - **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()orwatch(). Reads and writes are separate paths. Writes go throughinvoke().
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.