Real-time car status widget
End-to-end recipe — scaffold, wire `client.car.read` + `client.car.subscribe`, render door / battery / lock state with live updates, ship it. ~30 minutes.
End-to-end: scaffold a mini-app, wire it to the unified client.car
controller, render door / lock / battery state with live updates,
ship it. ~30 minutes if you've never used the SDK.
The finished widget:
┌─────────────────────────────┐
│ Car: ****4567 🔒 │
│ Battery: 88% │
│ Doors: 🚪 driver open │
│ Last seen: 3 s ago │
└─────────────────────────────┘The widget shows live state and degrades cleanly when the host has
no signal. It uses every relevant SDK feature: getContext,
client.car.read for the first paint, client.car.subscribe for
deltas, client.car.connectionSubscribe for the banner,
client.car.identity for the masked car ID, proper cleanup, and
error handling for the offline / no-host case.
Prerequisites
- Node 20+
- pnpm (or npm; the recipe uses pnpm)
- 10 min of patience for the dev-server's first launch
1. Scaffold
npx i99dash init car-status-widget
cd car-status-widget
pnpm installThe CLI creates a vanilla TS template (src/main.ts,
src/index.html, manifest.json). If you want React / Vue,
--framework react or --framework vue. Recipe stays vanilla so
the SDK calls are visible.
2. Bump minHostVersion for bridge v2
The unified client.car controller requires host bridge v2
(SDK ≥ 5.0). Edit your generated manifest.json:
{
"id": "car_status_widget",
"name": { "en": "Car Status", "ar": "حالة السيارة" },
"minHostVersion": "5.0.0",
"safeWhileDriving": true,
"permissions": ["car.read"]
}safeWhileDriving: true lets the host show the widget while
moving — appropriate for a read-only status display.
3. Wire the SDK
Edit src/main.ts:
import {
MiniAppClient,
BridgeTransportError,
type CarConnectionState,
type CarSignalEvent,
} from 'i99dash';
const client = MiniAppClient.fromWindow();
const SIGNALS = ['battery_pct', 'door_lock', 'door_lf', 'speed_kmh'] as const;
const root = document.querySelector('#root')!;
let idTail = '----';
let values = new Map<string, unknown>();
let conn: CarConnectionState = 'disconnected';
let lastSeen = 0;
function render() {
if (conn === 'disconnected') {
root.innerHTML = `<p class="banner">No car signal</p>`;
return;
}
if (values.size === 0) {
root.innerHTML = `<p>Loading…</p>`;
return;
}
const lock = values.get('door_lock') === 1 ? '🔒' : '🔓';
const battery = values.get('battery_pct') ?? '—';
const driver = values.get('door_lf') === 1 ? 'open' : 'closed';
root.innerHTML = `
<div class="widget">
<div class="row">Car: ****${idTail} ${lock}</div>
<div class="row">Battery: ${battery}%</div>
<div class="row">Doors: 🚪 driver ${driver}</div>
<div class="row last-seen">Last seen: ${ageLabel(lastSeen)}</div>
</div>
`;
}
function ageLabel(at: number): string {
if (!at) return '—';
const seconds = Math.round((Date.now() - at) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds} s ago`;
return `${Math.round(seconds / 60)} min ago`;
}
// One-shot first paint — no waiting for the first delta.
async function bootstrap() {
try {
const ident = await client.car.identity();
idTail = ident.deviceId.slice(-4);
const snap = await client.car.read([...SIGNALS]);
for (const r of snap.results) {
if (r.ok) values.set(r.name, r.value);
}
lastSeen = Date.now();
} catch (e) {
if (e instanceof BridgeTransportError) {
// Older host or test bridge that doesn't ship `car.*`.
root.innerHTML = `<p>This car doesn't support live status.</p>`;
return;
}
throw e;
}
render();
// Subscribe to deltas — store the unsub fns for cleanup.
const offSignals = await client.car.subscribe({
names: [...SIGNALS],
onEvent: (e: CarSignalEvent) => {
values.set(e.name, e.value);
lastSeen = Date.now();
render();
},
});
const offConn = await client.car.connectionSubscribe((next) => {
conn = next;
render();
});
// Tear down on tab close (the host doesn't kill us automatically
// when the user navigates away inside our shell).
window.addEventListener('beforeunload', () => {
offSignals();
offConn();
});
}
bootstrap();A quick read-through of what's here:
MiniAppClient.fromWindow()— finds the host bridge. In dev, the bridge comes fromi99dash dev.client.car.identity()— memoised per car. We only use thedeviceIdtail for the masked label.client.car.read([...names])— one-shot batched read for the first paint. Avoid showing a loading spinner forever waiting for the first delta event.BridgeTransportError— catches the "older host doesn't ship bridge v2" path. Renders a fallback instead of crashing.client.car.subscribe+client.car.connectionSubscribe— return async unsubscribe fns. Both are stored sobeforeunloadcan clean them up.- Device-ID masking — only the last 4 characters render. See Best practices — Privacy.
4. Run it locally
pnpm devThe CLI launches the dev-server (i99dash dev) and opens your default browser at
http://localhost:5173 with the dev-server's bridge shim pre-installed.
You'll see a fake "parked, doors locked, 88% battery" status because
the dev-server's default fixture says so.
To exercise live updates, open the dev-server's UI at
http://localhost:5174/_dev and toggle the door / lock / moving
state. Each change drives an onEvent callback in your widget
within ~100 ms.
5. Test the cleanup path
Add a Vitest test:
// src/main.test.ts
import { describe, expect, it, vi } from 'vitest';
import { MiniAppClient } from 'i99dash';
describe('car-status widget', () => {
it('cleans up subscriptions on teardown', async () => {
const unsubscribed = vi.fn(async () => ({ ok: true }));
const subscribed = vi.fn(async () => ({ subscriptionId: 'sub-1' }));
const callHandler = async (name: string) => {
if (name === 'car.subscribe') return subscribed();
if (name === 'car.unsubscribe') return unsubscribed();
if (name === 'car.identity') return { deviceId: 'byd:BYDMCKLE0PARD8801', brand: 'byd' };
if (name === 'car.read') return { results: [] };
return null;
};
const bridge = {
getContext: async () => ({
userId: 'u', activeCarId: 'V', locale: 'en',
isDark: false, appVersion: '1.0.0', appId: 'car_status_widget',
}),
callHandler,
};
const client = MiniAppClient.withBridge(bridge as any);
const off = await client.car.subscribe({
names: ['battery_pct'],
onEvent: () => {},
});
expect(subscribed).toHaveBeenCalledOnce();
off();
await Promise.resolve();
expect(unsubscribed).toHaveBeenCalledOnce();
});
});pnpm testIf unsubscribed is never called, your code has a leak — the host
enforces a per-mini-app subscriber cap and you'll eventually start
seeing rejected subscribes. See
Subscriptions.
6. Validate the manifest + build
pnpm i99dash validate # zod-checks manifest.json
pnpm i99dash build # produces dist/bundle.tar.gzThe validate step catches missing minHostVersion, malformed
permissions, and (in this case) reminds you that safeWhileDriving: true requires explicit opt-in justification in your PR.
7. Publish
pnpm i99dash login # SSH-key sign-in if not already logged in
pnpm i99dash publishThe catalog picks up the new version on next refresh; users with host ≥ 5.0 see your widget tile in the store.
What you didn't have to write
- Throttling. The host token-buckets pushes at ~10/sec; you don't need to debounce.
- Page Visibility handling. The SDK suppresses callbacks while
hidden and fires one catch-up on resume. Your
render()doesn't thrash when the user backgrounds the tab. - Schema validation. Every event the host pushes is zod-validated
by the SDK. A malformed payload silently drops with a
console.warnin dev. - Reconnect logic.
connectionSubscribereflects the host's polling-loop health; banner updates automatically when the signal comes back.
Troubleshooting
| Symptom | Likely cause + fix |
|---|---|
| "This car doesn't support live status." | You're on an older host (pre-bridge-v2). Bump minHostVersion to 5.0.0 so the catalog hides your app from incompatible cars. |
| Widget never updates | Check pnpm dev's console for console.warn from the SDK — the host may be pushing a malformed payload. Otherwise, check that client.car.subscribe is awaited before bootstrap returns. |
Subscribe call rejects with too_many_names | You're requesting more than CAR_MAX_NAMES (64) signals in one subscription. Split into multiple. |
NotInsideHostError in browser | Opened the bundle directly via file://. Run through pnpm dev so the dev-server attaches the bridge. |
Related
CarController— full API reference.- v5 migration — how the per-family controllers collapsed into
client.car. - Subscriptions — full lifecycle deep-dive.
- Testing — more test patterns.
- Troubleshooting — known SDK gotchas.
Cabin dashboard with i99dash/react
A four-tile dashboard that combines car status, climate, AQI, and now-playing — using i99dash/react hooks. Shows the per-family fallback pattern + how `client.has` lets you hide tiles on hosts that don't ship a family.
Call a third-party API
The minimal pattern — declare an HTTPS origin in manifest.network, then fetch() it. No SDK proxy, no envelope. ~5 minutes.