i99dash docs
Concepts

Vehicle profile

How the host identifies the active car, what capabilities it exposes, and how the catalog filter + CarCommandRouter both consult one source of truth.

The host's CarProfile is the single object every gate consults when it needs to answer "what works on this car right now?". One source of truth, two consumers, four-tuple identity, five-tier fallback chain. This page is the canonical home for the model; guides and recipes link back here for the why.

Mini-apps run with full host capability — there is no manifest-side gating. What you read at runtime from display.list().vehicle.capabilities is the complete, authoritative answer for the active car. See Capability detection for the runtime patterns.

ProfileKey — the four-tuple identity

A vehicle profile is keyed on:

type ProfileKey = {
  dilinkFamily: "di5.0" | "di5.1" | "unknown";
  variantId: "l5" | "l8" | "l5l" | "l7" | "han_l" | "" /* aggregate */;
  subTrim: "flagship" | "navigator" | "ultra" | "lidar" | "base" | "";
  fingerprint: string /* ro.build.fingerprint, exact */;
};

The four slots are most-coarse → most-precise on purpose. Empty strings are valid wire values and represent fallback aggregate rows the backend walks server-side:

Slot emptyReadsUse case
fingerprintsub-trim aggregateBrand-new ROM build, sub-trim already probed
subTrimtrim aggregateSub-trim couldn't be detected from outswver
variantIdDiLink-defaultUnknown trim on a known DiLink generation

Why this shape and not just variantId: one "L5" badge ships in multiple sub-trims (Flagship has massage seats + HUD; Navigator doesn't), and the same Leopard 5 hardware ships under multiple ROM fingerprints over its lifetime. Collapsing to variantId alone loses both axes — the catalog filter would silently let a tile through that depends on a feature only Flagship has, on a Navigator.

The host derives the active key on boot via CarIdentity:

// android/.../car/CarIdentity.kt — abridged
val outsw   = systemProperty("apps.setting.product.outswver")  // "34.1.23"
val vt      = systemProperty("ro.vehicle.type")                // "Di5.0_5.0UI"

val dilinkFamily = when {
  vt.startsWith("Di5.1") -> "di5.1"
  vt.startsWith("Di5.0") -> "di5.0"
  else                   -> "unknown"
}
val subTrim = when (outsw?.take(7)) {
  "34.1.23" -> SubTrim.ULTRA      // L5 Ultra
  "23.1.4"  -> SubTrim.NAVIGATOR  // L5 base trim
  // ...
}

Mini-app code never builds a ProfileKey — it reads the resolved one from display.list().vehicle.

The 5-tier fallback chain

When the host needs the active car's capability set, it asks the backend for the precise ProfileKey row. The backend walks four tiers; if all four miss, the host falls back to its own compiled-in seed:

   ┌─────────────────────────────────────────────────────────┐
   │  Tier 1 — Backend precise                               │
   │  (dilink, variant, subTrim, fingerprint) exact          │  ← isFallback: false
   └─────────────────────────────────────────────────────────┘
                          │ miss

   ┌─────────────────────────────────────────────────────────┐
   │  Tier 2 — Backend sub-trim aggregate                    │
   │  (dilink, variant, subTrim, "") — UNION across ROMs     │  ← unknown_fingerprint
   └─────────────────────────────────────────────────────────┘
                          │ miss

   ┌─────────────────────────────────────────────────────────┐
   │  Tier 3 — Backend trim aggregate                        │
   │  (dilink, variant, "", "") — UNION across sub-trims     │  ← unknown_sub_trim
   └─────────────────────────────────────────────────────────┘
                          │ miss

   ┌─────────────────────────────────────────────────────────┐
   │  Tier 4 — Backend DiLink-default                        │
   │  (dilink, "", "", "") — generation-only floor           │  ← unknown_variant
   └─────────────────────────────────────────────────────────┘
                          │ miss

   ┌─────────────────────────────────────────────────────────┐
   │  Tier 5 — Static seed (compiled into the APK)           │
   │  Per-trim VehicleProfile + DiShare/cluster heuristics   │  ← static_default
   └─────────────────────────────────────────────────────────┘

Each tier sets isFallback: true and a fallbackReason string on the snapshot so the UI can render a soft "best-effort" hint without changing functional behaviour. Tier 1 is the only "no fallback" state. Mini-apps consume the verdict via VehicleCapabilitiesSnapshot.isFallback.

The backend's UPSERT semantics keep this monotonic: every probe report writes the precise row AND the three aggregate rows in one transaction, OR-ing the new bits into each. A flaky probe missing a cap can never strip it from any row — bits only ever turn on.

Who reads what

The same CarProfile is consumed by two gates and (occasionally) mini-app code:

ReaderWhat it consultsWhenHot path?
Catalog filter (_mergeInstallState)capabilityBits onlyEvery catalog renderYes — single bitmask AND per app
CarCommandRouter (host's CarGate)actionSupport[actionId]Every car-actuator dispatchYes — short-circuits known-bad before rate-limit + bridge round-trip
Mini-app JSvehicle.capabilities / vehicle.capabilityBits from display.listPer-render gating UINo — cache the first snapshot for the session

The catalog and CarGate consult the same providercarProfileProvider on the Dart side, backed by the i99dash/car_profile MethodChannel. There's no second source of truth; if the host's idea of "what works on this car" changes (probe sync, profile-override toggle), both gates pick it up on the next provider invalidation.

When the snapshot is stale

The CarProfile snapshot is process-stable — the underlying signals (getprop, ROM fingerprint, trim id) don't change without a reboot. The host caches the snapshot for the process lifetime; the only time it re-reads is after a successful CapabilityProber.sync() writes a new backend overlay.

Mini-app code can rely on this: read the vehicle block from your first display.list() call and cache it for the session. You don't need to re-fetch on tab switch, app foreground/background, etc.

Static seed coverage

When tier 5 (compiled-in seed) answers, the bitmask comes from CarProfileSeed which derives it from VehicleProfile booleans:

CapabilitySeeded as supported when
display.read / pkg.readAlways (every BYD trim)
pkg.launch.ivi / surface.write.iviAlways (default display)
pkg.launch.passenger / surface.write.passengerprofile.showFission2 == true
pkg.launch.cluster.pixel / surface.write.clusterprofile.showCluster == true
pkg.launch.cluster.iconsL5 family (l5 / l5u / l5l) — MCU mux works even when pixel control doesn't
pkg.launch.dishareDi5.0 trims (l5 / l5u) — passenger panel reachable only via DiShare
cursor.write / gesture.dispatchAlways (a11y bridge granted by AdbBootstrap)
ac.get / ac.set / door.set / window.setAlways (every BYD trim ships the binders)

For the per-trim breakdown of which sub-trims have which capabilities by default, see the trim × capability matrix.

Display-side fields the profile contributes

Beyond the capability bits, the active VehicleProfile annotates each entry in display.list().displays:

Field on DisplaySnapshotProfile sourceWhat it means
hiddenhiddenDisplayIds.contains(id)Shadow / duplicate of another addressable display (e.g. id 3 is the L8 cluster's mirror of id 5). Pickers should hide these.
overrideLabeloverrideLabels[id]Friendlier per-trim label. The reserved value 'Driver' (enumerated in RESERVED_OVERRIDE_LABELS on the SDK side) means the driver-eyeline display on this trim, regardless of role — cluster overlay on L8 / L5 Lidar, labeled passenger panel on Song Plus / L7 / HAN L. See the driver-eyeline guide section for the resolution priority.
clusterAvailableshowClusterTrue when the active profile says the cluster is reachable (L8 / L5 Lidar). Mirrors the pkg.launch.cluster.pixel cap bit; mini-apps gate cluster UI on either.
cursorDisplayId / inputSourceDisplayId / zoomDisplayIdcursorRemap / inputRemap / zoomRemapPer-id remaps for the launch-vs-input asymmetry on XDJA-virtualised clusters (see the cluster section).

These fields are emitted starting with host 1.6 and surface in the SDK's DisplaySnapshot interface from 2.0; older host emissions arrive as undefined on each field, never as null or empty defaults.

Probe-driven refinement

The static seed is conservative — it grants every BYD trim the basic actuators because every BYD trim ships the binders. Whether a specific actuator (e.g. set_seat_massage) actually fires depends on hardware: Flagship has massage seats, Navigator doesn't.

The CapabilityProber (host-side, not yet shipped — see roadmap) runs once on first-boot for each unfamiliar (variant, sub-trim, fingerprint) tuple. It tries each candidate cap, records the verdict, and POSTs the result to the backend. The backend folds it into the precise row + 3 aggregate rows. Subsequent boots on the same car see Tier 1 directly; cars on the same sub-trim but a fresh ROM see Tier 2; etc.

Quick Report — the 1-tap thumb-up/thumb-down on action-failure — feeds the same aggregator from the user side. When enough thumbs-down corrections accrue for a (ProfileKey, action) pair, the offline aggregator flips the static seed to mark it as supported.

Worked example — Leopard 5 Flagship vs Navigator

A car-i99dash host on a Leopard 5 Flagship boots:

  1. CarIdentity reads outsw=34.1.23 → SubTrim.ULTRA. Wait, that's Ultra, not Flagship — Flagship's outsw key isn't yet recorded. subTrim resolves to null → wire string "".
  2. Host POSTs GET /api/v1/vehicle-capabilities?dilinkFamily=di5.0&variantId=l5&subTrim=&fingerprint=BYD/....
  3. Backend walks: tier-1 miss (empty subTrim), tier-2 miss (same), tier-3 hits (the variant-aggregate row for L5 has accumulated probes from L5L / Ultra / Navigator).
  4. Backend returns {capabilityBits: 0xXXXX, isFallback: true, fallbackReason: 'unknown_sub_trim'}.
  5. Mini-app code reads vehicle.capabilities from display.list(). The seat-massage cap bit isn't set — even though Flagship actually has massage seats — because the trim aggregate UNIONs over Navigator (which doesn't), so the bit isn't set.
  6. UI hides the seat-massage control on this snapshot. User taps the (always-visible) "Try anyway" or invokes via voice; the daemon says yes.
  7. Quick Report banner: "Feature unavailable — your car's sub-trim wasn't detected. [Correct] [Works]".
  8. User taps Works. Backend logs the correction.
  9. Two months later, when 50 Flagship users have submitted similar thumbs-down on set_seat_massage, the offline aggregator stamps the bit on (variant=l5, subTrim=flagship). Future Flagship hosts that successfully detect their sub-trim get Tier-2 with the bit set. Hosts that still detect as unknown sub-trim continue getting tier-3 — but Tier-3 itself updates over time because it's a UNION over all sub-trims that ever probed.

That's the loop. Static seed for safety, probe + Quick Report for empirical refinement, fallback chain so unknown cars still launch.

On this page