Dashboard control card (DESIGN + shipped W1โW3)¶
Status: W1โW3 SHIPPED (branch
feat/dashboard-card). The compact multi-room control card, the embedded map, and the polish pass are all live. This file keeps the original design rationale below (issue #34); the banner tracks what actually shipped. For the architecture of the cards + bundles + map host, see Card Topology & Bundles.W1 โ control card (list-based): -
src/cards/dashboard-dispatch.jsโ pure, unit-tested run-launcher logic (nextArmedmutual-exclusivity reducer,planStartdispatcher,armedIsValidre-validation).dashboard-dispatch.test.mjs. -src/cards/_shared.jsโ helpers shared with the room-card (esc,vocab,roomSwitchesFor,adapterOptions,committedRoomFields,isMopMode,chipRow,callResponse,registerCard,defineCard, the language control). -src/cards/dashboard-card.jsโ thevacuum-agent-dashboardelement + editor. - Backend:scene_selectdeclared in the Eufy adapterentitiesblock, surfaced (existence-checked) onget_dashboard_snapshot. Roborock degrades toNone. - Global cards bundle:src/cards-standalone.jsโfrontend/eufy-vacuum-cards.js(cards only, no panel), registered as a global ES module viafrontend.add_extra_js_urlinasync_setupโ defined on every page, even a cold dashboard.defineCardmakes the panel bundle's duplicate define a no-op.W2 โ the embedded map. The map subsystem was extracted into a reusable
<eufy-vacuum-map>custom element (src/cards/vacuum-map-host.js) that mounts the existingVacuumCard{State,Renderers,Bindings,Actions}mixins onto a card-shim host. It ships as its own lazily-loaded bundle (frontend/eufy-vacuum-map.js), imported on demand by the dashboard card. Full VA-render backdrop, zone-draw, rotate, layers panel, mascot controls โ the same map as the panel. See Card Topology & Bundles.W3 โ polish. Collapsible map + Rooms groups; the per-user language globe in both cards; strict-order toggle (Roborock); Eufy scene "None" filter; pinned pan/zoom across reloads (per-device, per-context); drag-to-move room-name labels (per-device). Locale files de-bundled + cache-busted;
vacuum_card.*keys translated into all 7 shipped locales.Verified: frontend
node --test+ backend pytest green; i18n + styles gates clean; multi-agent adversarial review at each wave (notably: a first-loadresetMapTransformthat wiped the restored pan/zoom, and a double-escape class โesc()over already-escapedt()output โ both caught + fixed). Pending: merge โ release (version bump + CHANGELOG + HACS).
1. Goal¶
Give users a compact control card they drop into their own dashboard (next to their lights), instead of opening the full sidebar panel โ pick rooms + clean mode, run a saved setup, start, dock. This is the adoption lever: most HA users live in their dashboards, not sidebar panels. Requested by Pistakkio (#34) with a Dreame card as the visual reference (status header โ map with room blobs โ mode/passes โ Room/All/Zone โ Clean/Dock).
The headline: the engine is already built and tested. Every backend service the
card needs exists; the existing single-room EufyRoomCard already proves the
card+editor+i18n form factor. This is mostly assembly, with one honest exception
(the map โ see ยง7).
2. What it does¶
A new multi-room control card (vacuum-agent-dashboard, display "Vacuum Agent โ Dashboard Mode"),
config-driven, sections capability-gated:
- Header โ vacuum name, status, battery, area (from
get_dashboard_snapshot). - Run source (pick ONE):
- Rooms โ a list of collapsing per-room rows: toggle a room to include it, expand the row to set that room's own mode / suction / water / passes (the room-card's setting body, repeated per row โ an accordion).
- Your profiles โ a dropdown of saved run profiles (
get_saved_run_profiles). - App scenes โ Eufy only โ a dropdown of the vendor-app scenes
(
select.<object_id>_scene); hidden on Roborock (no such entity). - Actions โ Start (commits the armed run), Dock, and optional dock
actions (Wash/Dry/Empty) gated by
get_dock_action_status. - (Later) Map โ the interactive room map in a compact box (ยง7).
3. The core contract โ arm, then Start¶
Selecting is inert; only Start fires. The user builds a selection โ rooms, mode,
a profile, a scene โ entirely in card-local draft state (the same pattern
EufyRoomCard already uses with _fields). Nothing reaches the vacuum until Start.
This is a hard requirement: "we don't want a run to fire just because they were
setting it up." It is also load-bearing for the Eufy scene (see ยง6 โ select_option
is the fire).
Start is a small dispatcher, by what's armed:
| Armed source | Start does |
|---|---|
| App scene (Eufy) | select.select_option(select.<obj>_scene, <name>) โ this is the run; no VA dispatch |
| Run profile | eufy_vacuum.start_run_profile({vacuum_entity_id, profile_id, โฆ}) |
| Manual rooms | enable the chosen room switches (switch.turn_on / update_room_fields), then eufy_vacuum.start_selected_rooms({vacuum_entity_id, map_id, โฆ}) |
Mutual exclusivity: a scene or a profile is a complete pre-baked run, so arming one greys/disables the manual room+mode pickers (and vice-versa). Exactly one source is ever live. Enforced client-side (no backend gate needed). Dock is its own button, independent of the armed source.
Stepped profiles dispatch opaquely. A saved profile may now carry an ordered
steps list โ room groups broken up by native charge/wait stops (dock + poll
battery to a target %, or dock + hold N minutes) or ๐ฏ saved-zone steps (clean a
saved zone as one phase). The card arms and fires it exactly as before:
start_run_profile runs the whole stepped sequence, so the dashboard card stays
agnostic to steps and needs no change. All of the authoring and monitoring of steps
lives in the panel, not this card: the steps editor, the "This run" preview, the
+ Charge/+ Wait/+ Zone composer, the live charging/waiting/zone banners, the
live queue (the running job flattened to done/current/upcoming chips โ
dashboardLiveQueue โ renderLiveQueue, collapsible), and the run-time queue lock
(room toggles / reorder / Select-All / Clear-Queue disabled while a job runs) are all
run-profiles.js / rooms.js / learning.js renderers โ see
Module reference; the phase machinery is in
Phase runner and the snapshot shapes (queue_steps,
live_queue, zone_phase_*) in Backend contract.
The snapshot's has_charge_steps flag is likewise a panel-render concern; the card
never reads it.
4. What it reuses (this is why it's cheap)¶
| Card piece | Reuses (verify-vs-code) |
|---|---|
| Card + editor + registration | EufyRoomCard / EufyRoomCardEditor patterns (src/room-card.js) โ setConfig, set hass, customElements.define, window.customCards.push, getConfigElement, getStubConfig |
| Room list + per-room settings chips | the room-switch finders + _adapterOptions() + the chipRow() builder (src/room-card.js) โ the switch.* entities already carry room_id + *_options attrs |
| Draft/dirty state | _fields / _committedFields / _isDirty pattern |
| Mode/suction/water/path/passes vocab | get_dashboard_snapshot().adapter_vocabulary (clean_mode_options / fan_speed_options / โฆ) + tVocab |
| Status header | eufy_vacuum.get_dashboard_snapshot (lifecycle, job_progress, battery, area) |
| Run profiles | eufy_vacuum.get_saved_run_profiles (list) + eufy_vacuum.start_run_profile |
| Room clean | switch.turn_on/off + eufy_vacuum.update_room_fields + eufy_vacuum.start_selected_rooms |
| Dock + dock actions | vacuum.return_to_base + eufy_vacuum.{wash_mop,dry_mop,empty_dust}, gated by eufy_vacuum.get_dock_action_status |
| Capability gating | get_dashboard_snapshot().capabilities (max_clean_passes, supports_base_station, supports_room_profiles, honors_clean_order, โฆ) |
| i18n | the same i18n module (translate / resolveLang / tVocab); new vacuum_card.* keys, reuse vocab.* |
Everything in that table already exists and is tested. No new backend services.
5. Architecture โ a NEW sibling card, not an extension¶
EufyRoomCard is deliberately single-room (config = vacuum_entity_id + room_id).
The control card is multi-room + profiles + scenes + dock โ a different shape.
Recommendation: a new sibling card (vacuum-agent-dashboard) that reuses the room-card's
patterns/helpers (extract the shared bits โ chipRow, the entity/option readers, the
i18n helpers, the draft-state pattern โ into a small src/cards/_shared.js both import),
rather than overloading the room-card with a mode flag. Both register in
window.customCards; both ship in the existing bundle (npm run build:deploy, never
hand-edit the minified bundle โ).
6. App scenes โ Eufy-only, fires-on-select, adapter-gated¶
Confirmed on-device (): selecting an option in eufy-clean's
select.<object_id>_scene immediately runs the scene. So:
- Build the dropdown from the entity's
optionsattribute (read-only โ safe). - Never call
select_optionuntil Start (arm = local highlight only). - Gating: the scene group shows only when the scene entity resolves. Cleanest
is to declare it in the adapter โ add a
scene_selectkey to the adapterentitiesblock (Eufy:"select.{object_id}_scene"; Roborock: absent) and surface the resolved id onget_dashboard_snapshot(likesetting_entities/live_map_image_entityalready are). The card reads it from the snapshot; Roborock simply has none โ group hidden. (Fallback if we don't touch the adapter: the card probesselect.<object_id>_sceneinhass.statesโ works, but the declared path is adapter-pattern-consistent and brand-future-proof. Recommend the adapter route.)
This is the adapter pattern paying off: a brand-specific capability is one declared entity; the card stays brand-agnostic; Roborock degrades gracefully instead of showing a dead control.
7. The map โ honest scope (the one real lift)¶
Chris's "scale the map, simple math" is half right: the coordinate math is already
solved โ the renderer works in normalized 0โ1 coords, object-fit: contain in a square
box, so it reprojects to any container size for free. But the map is a renderer
mixin, not a standalone component (src/renderers/map.js + src/state/map.js +
src/bindings/map.js). It's coupled to the monolithic EufyVacuumCommandCenter:
this.card._state, this.card._actions, this.card._scheduleRender(), and
this.card.shadowRoot. Dropping it into a separate card means extracting a component:
clone the state mixin + render + bindings onto a new element, provide an actions/scheduler
shim, scope DOM to the new shadowRoot, feed it hass + the snapshot, and share the
.evcc-map-* CSS. That's real work, not "a smaller div."
So the map is staged out:
- W1 ships without the map โ a clean room list (multi-select chips), which is
the room-card's proven path and delivers the full functional ask (select rooms + mode
+ profiles + scenes + start/dock).
- W2 adds the map โ extract the map renderer into a reusable
<eufy-vacuum-map> element (also lets the panel reuse it), or, cheaper, render a
static, tap-selectable room map (the VA-render raster / segment polygons with
click-to-toggle, no zoom/pan/overlays). Decide at W2.
Calling this out so the estimate is honest: the list card is days; the embedded map is its own chunk.
8. Config schema + editor¶
type: custom:vacuum-agent-dashboard
vacuum_entity_id: vacuum.alfred # required
title: Vacuum # optional
show_profiles: true # default true (hidden if supports_room_profiles=false)
show_scenes: auto # auto = show iff the scene entity resolves (Eufy)
show_dock: true # default true (hidden if supports_base_station=false)
show_map: true # default true; the embedded map shipped (shown unless set to false)
Editor: extend the EufyRoomCardEditor pattern โ a vacuum_entity_id picker + the
show_* toggles. getStubConfig returns the first discovered vacuum.
9. i18n¶
New vacuum_card.* keys for the new labels (Rooms / Your profiles / App scenes /
Clean / Clean all / Dock / "scene runs immediately" hint / etc.) โ keyed at creation per, +7 locales drafted as usual. Slot values
(modes/suction/etc.) reuse the existing vocab.*. The card runtime-loads non-English
locales the same way the room-card does (a card on a view with no main panel is the only
thing that triggers the loader).
10. Edge cases / gotchas¶
- Scene fires on select โ never call
select_optionuntil Start (ยง6); a careless hover/preview must not dispatch. - Roborock has no scenes โ group hidden, not a dead control.
honors_clean_order=false(Roborock) โ if we expose room order, show it as advisory (path-optimizing), mirroring the panel.max_clean_passescaps the passes chips (Eufy 2, Roborock 3);passes_is_globalchanges per-room vs whole-run semantics.- Two cards on one dashboard โ โ
pan/zoom (and moved room-name labels) are keyed
per context (panel vs card) + per device, so the panel and a dashboard card keep
independent map views (
_mapCtxon the state; see Card Topology & Bundles). - Multi-map vacuum โ the room list is filtered to the active map only
(
_roomsForActiveMap(), mirroring the panel'sgetRoomsForActiveMap), so a device with several maps (e.g. Alfred: map 6 + map 7) doesn't list every map's rooms at once. Every operate-on-rooms site (render, room toggle,_startContext,_turnOffAllRooms, room-by-id lookup) uses the filtered set, which also prevents a Start from targeting an off-map room when two maps shareroom_idvalues. Unfiltered_rooms()survives only where it must (the_activeMapId()fallback and theshouldUpdatediff). - No live entity โ degrade every section independently; a missing snapshot field hides its control, never throws (wrap reads).
- Mid-run โ show progress in the header; Start becomes "already cleaning" / the
pause/resume controls (reuse
job_controlfrom the snapshot).
11. Phasing (waves)¶
- W0 โ this design + approval pause.
- W1 (MVP, list-based). New
vacuum-agent-dashboard+ editor; header (snapshot); multi-room chip select + mode/passes; run-launcher (profiles + Eufy scenes, adapter-declaredscene_select); arm-only/Start dispatcher; Clean / Dock. Eufy + Roborock (scenes Eufy-only).vacuum_card.*i18n. No map. - W2 โ the map. โ
Extracted the reusable
<eufy-vacuum-map>host (src/cards/vacuum-map-host.js), lazily loaded; full VA-render map, zone-draw, rotate, layers, mascot. - W3 โ polish. โ Collapsible map + Rooms; language globe in both cards; strict-order toggle (Roborock); scene "None" filter; pinned pan/zoom across reloads; drag-to-move room-name labels.
12. Testing¶
The card is frontend JS: unit-test the Start dispatcher (armed scene โ select_option;
profile โ start_run_profile; rooms โ start_selected_rooms; nothing fires before
Start) and the mutual-exclusivity logic with node --test, and add it to the
render harness (Playwright visual-reg, incl. a Cyrillic locale + a Roborock config
that hides the scenes group). The backend services are already covered. Gate:
node --test + the harness.
13. Decisions (resolved 2026-06-28, Chris)¶
- Scene gating โ โ
declare
scene_selectin the adapterentitiesblock (Eufyselect.{object_id}_scene, Roborock absent), surfaced onget_dashboard_snapshot. Not probing. - Map โ โ
in scope. Extract a reusable
<eufy-vacuum-map>component (the panel reuses it too); ships in W2. - Card โ โ
a NEW sibling card. The existing single-room
EufyRoomCardSTAYS โ Chris likes it one-per-room โ and the new card is the multi-room control card. Shared helpers extracted tosrc/cards/_shared.js. - Per-room settings โ โ per-room, as collapsing rows. Each room is a row you toggle to include; expand it to set that room's own mode/suction/water/passes โ i.e. the room-card's setting body, repeated per row (an accordion). Not one-mode-per-run.
- Name โ "Vacuum Agent โ Dashboard Mode" (display),
type: custom:vacuum-agent-dashboard.
14. References¶
- Issue #34; the Dreame reference card.
- Reuse map (this design):
src/room-card.js,src/renderers/map.js+src/state/map.js src/bindings/map.js,custom_components/eufy_vacuum/services.yaml,adapters/{config_schema,eufy/adapter,roborock/adapter}.py,profiles/manager.py,core/manager.py::get_dashboard_snapshot.- Memory: (scene fires-on-select), (edit src/, build:deploy),, the voice wizard's sibling "meet users where they are" play in.