Add a Dashboard Card for a New Device¶
A "widget" in the iDryer ecosystem is a device card on the portal dashboard — a product-specific React component that composes the device's UI from reusable blocks. There is no widget registry, no generated React files, no contracts/widgets/ directory.
This page covers adding such a card for a new device type. For firmware-only work see 01-add-new-product.md.
What lives where¶
| Layer | Repository | Source of truth |
|---|---|---|
| Contract (deviceType, capabilities, invoke actions, telemetry, menu) | idryer-core |
contracts/mqtt_contract.yaml |
| Generated headers / TS types / Dart types | idryer-core |
contracts/_generated/* |
| Firmware | per device repo (e.g. iHeater-link, iDryer-Storage) |
hand-written |
| Dashboard card | iDryerPortal/frontend-v2 |
src/components/dashboard/cards/*.tsx |
| Reusable card blocks | iDryerPortal/frontend-v2 |
src/components/device/*.tsx |
| Card registration | iDryerPortal/frontend-v2 |
src/components/dashboard/DeviceDashboardCard.tsx |
| UIKit preview | iDryerPortal/frontend-v2 |
src/pages/UiKitPage.tsx |
Command channel rules¶
Cards talk to devices via two MQTT paths defined in the contract:
- Invoke (form A) — emit a menu action by its
id, no args. Use when the action is already in the device menu and reachable from another client. - Invoke (form B) — emit
{action, args}directly, bypassing the menu. Use for parameterized actions (heat.startwithtempC/durationMin,led.pulsewithr/g/b/animation). - Set — write a config value (
set <role> <value>). Use only for persistent settings, not for processes with a beginning and an end.
For processes that start and finish (heating, drying, animation), always use invoke, never set heat_active=true style toggles.
Telemetry null policy¶
If a sensor is missing or its reading is unavailable, the firmware must omit the field from the telemetry payload (not send null, not send 0, not send NaN). The card must treat an absent field as "no data" and render a placeholder instead of guessing zero.
See rules.telemetry_null_policy in the contract for the canonical wording.
Checklist — adding a card for a new device¶
- Contract — add the device profile and any new capabilities, roles and invoke actions to
contracts/mqtt_contract.yaml. Run./regen.shand commit the regenerated_generated/*. - Firmware — implement
onCommand("invoke")for the new actions; emit telemetry per the null policy. - Card component — create
iDryerPortal/frontend-v2/src/components/dashboard/cards/<DeviceType>Card.tsx. Compose it from the reusable blocks in src/components/device/. - Register — add the new
deviceTypeto the switch in DeviceDashboardCard.tsx. - DeviceDetailPage — extend
controlsOrProgressin DeviceDetailPage.tsx so the same card appears on the device page. - UIKit — add an Idle + Active example to the "Device Widgets" group in UiKitPage.tsx with mock data so the card can be inspected at
/uikitwithout a real device. - Test — run the portal locally, verify the card renders correctly Idle and Active, sends the expected invoke payload, and reacts to telemetry updates.
- PR — open one PR in
idryer-core(contract + firmware submodule bumps) and one iniDryerPortal(card + registration + UIKit). Link them in the description.
Reusable card blocks¶
These live in src/components/device/ and should be the first building blocks you reach for. Compose them before writing custom JSX.
| Block | Purpose |
|---|---|
DeviceHeader |
Device name, status pill, online/offline indicator |
DeviceTelemetryBlock |
Renders a list of telemetry rows, hides missing fields by default |
ActiveSessionBlock |
Progress UI for processes with target + remaining time |
NumberInput |
Numeric input bound to a min/max/step from the menu metadata |
CardActions |
Bottom-row button group (Start / Stop etc.) |
If a new block would be reused by ≥ 2 cards, add it under src/components/device/ rather than inlining it.
Existing cards (reference)¶
| Card | Device type | Notes |
|---|---|---|
HeaterCard |
IHEATER_LINK |
Idle: temp + duration inputs + Start. Active: ActiveSessionBlock with remaining time + Stop. |
StorageCard |
STORAGE_LINK |
SHT31 telemetry + LED animation/color picker + Turn On/Off (invoke led.pulse). |
IDryerCard |
fallback | Generic card for devices without a dedicated implementation. |
Open them as concrete examples before starting a new card.
What used to exist and why it's gone¶
Earlier the project tried to keep widgets inside idryer-core/contracts/widgets/, ship them through regen.sh to the portal, and register them in a widget-registry.tsx. That layer was removed on 2026-05-27: dashboard cards are product-specific React code, the contract has no opinion on JSX, and an extra copy-step between repositories added friction without value.
If you find references to the old approach (widget-registry, contracts/widgets/, Co2DisplayWidget-style names) in older docs or branches — they are obsolete. Use this page.