State Management¶
This doc covers the card's state layer: the state/ module inventory, how each module stores and clears its data, the hass setter and its load-once pattern, and the rule that state modules never talk to each other directly β everything routes through the card instance. It is one spoke of the frontend doc set; start at architecture-overview.md for the hub, and see module-reference.md for the per-file navigation map of every other frontend directory (actions, bindings, renderers, styles, theme-tokens, i18n, cards/, and the entry points).
Module inventory¶
The table below covers the state/ modules. For every other frontend module β actions,
bindings, renderers, styles, theme-tokens, i18n, the cards/ elements, and the entry points β
see the per-file navigation map in module-reference.md.
| Module | File | What it owns |
|---|---|---|
| core | state/core.js |
hass.states access helpers; vacuum entity, state, attributes, battery; vacuumObjectId |
| rooms | state/rooms.js |
Room list building from switch entities; active map resolution; enabled room counting; access graph logic |
| rooms-order | state/rooms-order.js |
Order adapter for rooms; drag/selector state for room reordering |
| steps-queue-order | state/steps-queue-order.js |
Pure order-adapter extension (applyStepsQueueOrderState; chains onto getOrderAdapter, must apply AFTER rooms-order). Adds scope "steps": presents the live ad-hoc queue (enabled rooms + queue_breaks charge/wait stops) as ONE reorderable list so the move-to-position modal and drag both show rooms and breaks as chips through the one shared ordering engine. The queue is DERIVED, not stored β persist() splits a reordered list back to its two real backings (room order β the per-room number entities, only when a room actually moved; the full breaks list β set_queue_breaks, always, since a room move can shift a break's after_index); a pure reorder never edits a break's own params. Any other scope falls through to the previous (rooms-order) adapter. |
| access-graph-model | state/access-graph-model.js |
Pure graph-question functions, not registered on the prototype (imported by state/rooms.js and state/room-access.js only β the room-access binding and renderer import state/access-issue-label.js, not this module). The room access graph is a TREE (single-inbound constraint = Nβ1 parent assignments); this is the ONE place that answers questions about it β graphFromRooms, withEdges/withDockRoom (immutable edits), claimedTargets, hasCycle, offerableTargets, unplacedRooms, dockHeldByOther, dockGate, validateEdit β taking an explicit {dockRoomIds, grants} snapshot rather than reading storage, so the same functions serve the per-room access modal (stored graph + this room's draft overlaid) and a future whole-map graph builder (its draft directly) without two copies to drift apart. dockRoomIds is a LIST even though exactly one is legal, because two dock rooms is a real state the validator observes (multiple_dock_rooms) β collapsing to a scalar here would silently offer the second one as a target while the map is invalid. |
| coded-label | state/coded-label.js |
Pure resolver, not registered on the prototype. resolveCodedLabel(descriptor, t, options) answers one question β "resolve a backend {code, params, message} to a translated sentence" β for the three vocabularies that ask it: access-graph issues (room_access.issue.card.* then room_access.issue.*), start block reasons and start warnings (rooms.block_reason.*), and setup delete-protection reasons (setup.protection_reason.*); callers supply only their namespace. Preference order: translated code+params β the backend's own message (last fallback, kept deliberately server-emitted so a code this card release has never heard of still renders instead of going silent) β never a bare key, never blank. Imported by state/access-issue-label.js, renderers/rooms.js and renderers/setup.js. |
| access-issue-label | state/access-issue-label.js |
Pure resolver, not registered on the prototype. accessIssueLabel(issue, t) β the access-graph-scoped wrapper over coded-label.js's resolveCodedLabel (A6-AGX-4: every access-graph issue used to reach the user as English prose composed in Python; now the backend emits code + params and this resolves them to a translated phrase). Imported by bindings/room-access.js and renderers/room-access.js. |
| room-access | state/room-access.js |
Room access editor open/close state |
| room-editor | state/room-editor.js |
In-modal field editor state (active room, field values, profile picker); the derived supportsSettableMop() getter (reads dashboardSnapshot().supports_water_control) that gates whether the editor shows the clean_mode picker or a read-only observe-only tank indicator β Roborock is genuinely per-model (S6 false, S7+ true); the Eufy value is install-dependent, not a fixed brand fact (see backend-contract-and-data-shapes.md) |
| room-estimate | state/room-estimate.js |
Room-level time estimates storage |
| room-profiles | state/room-profiles.js |
Room profile library cache |
| room-rules | state/room-rules.js |
Room rules editor state |
| run-profiles | state/run-profiles.js |
Saved run profile library cache (setRunProfilesLibrary / savedRunProfiles), selected profile, and the profile editor draft. The draft carries an ordered steps list β room GROUPS interleaved with charge_wait / wait STOP steps (runProfileDraftSteps / addDraftChargeStep / addDraftWaitStep / setDraftChargeTarget / setDraftWaitMinutes / removeDraftStep / moveDraftStep / captureCurrentRoomsAsDraftGroup, all delegating the immutable array math to state/steps-order.js); _normalizeRunProfile surfaces two backend flags: has_charge_steps (charge-only β any charge_wait step) and the broader has_stops (a SEQUENCED run β any wait/charge_wait boundary OR more than one room_group); has_stops is what drives the stepped card UI (preview/chips/'Runs as' summary/Start routing), with _deriveHasStops as a local fallback when the backend hasn't stamped it. Also tracks the applied-profile slice β setAppliedRunProfile / pendingStepRunProfileId (the applied profile's id IFF it has has_stops, the signal that Start should dispatch start_run_profile instead of a flat start_selected_rooms; gates on has_stops β NOT charge-only has_charge_steps β so a wait-only or multi-group profile still routes through the stepped path; cleared when the user hand-edits rooms) β and the collapsible "This run" preview state (isSteppedPreviewCollapsed / toggleSteppedPreviewCollapsed). Registered in state/index.js via applyRunProfilesState |
| steps-order | state/steps-order.js |
Pure step-mutation helpers for the run-profile steps editor (no card state, not registered on the prototype β imported by state/run-profiles.js). Immutably insert/remove/move/retarget STEP-level entries (insertChargeStep / insertWaitStep / removeStep / moveStep / setChargeTarget / setWaitMinutes), classify steps (isRoomGroupStep / isChargeStep / isWaitStep / isZoneStep β zone is a fourth real step type, rendered at steps-manifest.js:38), ask whether a break sits in an unsupported position (isUnsupportedBreakPosition) and whether a list already contains a room group or a charge step (stepsHaveRoomGroup / stepsHaveChargeStep), clamp targets to the CHARGE_TARGET_MIN/CHARGE_TARGET_MAX (1β100, default 95) and WAIT_MIN_MINUTES/WAIT_MAX_MINUTES (1β1440, default 30) ranges, snapshot the enabled Rooms view into a room_group step (roomsToGroupStep, omitting unset per-room fields so they fall through to the global room settings at dispatch), and sanitizeStepsForSave mirroring the backend normalize_run_profile_steps. Has its own steps-order.test.mjs |
| steps-manifest | state/steps-manifest.js |
Pure "Runs As" step-manifest renderer (renderStepsManifest; no card state, not registered on the prototype). Takes a profile's steps, a room-idβname lookup (nameById), a zone-idβname lookup (zoneNameById), and the caller's i18n (t), vocab resolver (tVocab, which localizes a setting VALUE such as clean_mode and returns escape-safe HTML) + HTML escaper, and returns the manifest HTML string ("" when there are no steps) β shared by the command-center run-profiles panel (renderers/run-profiles.js) and the standalone cards/profile-card.js so the two surfaces can't drift on how a routine reads. Has its own steps-manifest.test.mjs |
| dock | state/dock.js |
Dock action status; pause-timeout settings |
| maintenance | state/maintenance.js |
Maintenance snapshot; dock event data |
| metrics | state/metrics.js |
Metrics snapshot; filter state |
| review | state/review.js |
Learning history snapshot; filter state |
| external-jobs | state/external-jobs.js |
External-run review: subtab selection, pending list, and the confirm wizard (split/merge toggles + per-segment assignments). See 30 β External Runs |
| learning | state/learning.js |
Live-job learning state: estimate, reanchored estimate, next room, completed rooms, job-active flag; incomplete run log; trouble rooms log |
| faults | state/faults.js |
Fault-label resolution (CARD-3 / RF-DOCK): faultLabel(key, code) β the backend hands the card a fault's i18n KEY (fault.<brand>.<slug>), never its text, so this is the ONE place that turns it into display text via t(), with a documented deliberate fallback to the raw vendor code when the adapter has no label for it ("Error 6013" is honest and searchable; an invented label is not). Keeps the vendor-codes-vs-locale-strings split intact: core never learns a brand's codes itself. |
| job-summary | state/job-summary.js |
Job-summary modal open/close state: openJobSummary(jobId)/closeJobSummary()/isJobSummaryOpen(). Holds a job_id, not a job object β the learning-history snapshot is refetched on a poll, so a captured object would go stale while the modal is open; activeJobSummary() looks the job up fresh from the current snapshot on every call and returns null if it's gone, which is what makes a vanished job close the modal rather than freeze a stale copy. |
| order | state/order.js |
Generic order selector (scope, item, position) shared by rooms and run profiles |
| theme | state/theme.js |
Active theme id, working draft, draft dirty flag, theme library; editor UI state (search query, group filter, open groups) |
| map | state/map.js |
Map segments data; zoom/pan transform; segment selection + segmentβroom overlay; dot-anchor overlay; active segmentation_mode; the named custom layouts β customLayouts() / activeCustomLayoutId() / activeCustomLayout() plus the layout-editor slice (openNewLayoutEditor / openRenameLayoutEditor / closeLayoutEditor / isLayoutEditorOpen / layoutEditorMode / layoutDraftName / setLayoutDraftName); the custom-segment composer draft (shapes, grouping/merge/cut, move-scope, rotate, nudge step) via proto.compose* β the draft load and mascot anchors are keyed on ${map_id}:${active_custom_layout_id} (setMapSegmentsData resets the draft when either changes; _composeKey/maybeLoadComposeDraft reload on a layout switch); animal selection/scale; mapAnimalEnabled plus the split mapFloorTextureEnabled / roomFloorTextureEnabled toggles (localStorage evcc_animal_on_<vac> / evcc_floor_tex_map_<vac> / evcc_floor_tex_rooms_<vac>, default on); the live-map display-rotation slice (mapRotation / setMapRotationOptimistic / the _mapRotationOverlay optimistic value β applied only to the live image, never to CV/custom maps); the dwell-debounced mascot follow (mascotDwelledRoomId, committing a room only after sustained dwell); the live-backdrop URL slice β mapImageUrl (the active backdrop URL, short-circuiting to the live image via isLiveBackdropActive when the active scope is a backdrop_source: "live" layout) and _liveMapImageUrl (appends the live entity's last_updated as a query param to cache-bust a stable-token camera. entity each frame); and the per-vacuum room-label visibility toggle mapRoomLabelsEnabled (localStorage evcc_map_labels_<vac>, default on) gating the .evcc-map-label render so VA's labels don't stack on a label-baked live backdrop. It also owns the zone-clean draft (zoneDrafts / zoneDrawMode / canDrawZone / zoneMax / addZoneDraft β the rectangles fed to start_zone_clean), the hidden-regions draw slice (hiddenRegions / hideDrawMode / canDrawHideArea β set_hidden_regions), the area-label anchor slice (areaLabelAnchor β set_area_label_anchor), the overlay-visibility slice (mapOverlayVisibility / isOverlayVisible / setOverlayVisibilityOptimistic / clearOverlayVisibilityOptimistic β set_map_overlay_visibility; note overlaysAligned is a different predicate β "is a grid-frame backdrop displayed" β and touches neither the visibility state nor that service), and the live-pose slice (livePose, fed by get_map_live_pose) β each with its own *.test.mjs under src/state/ (zone-draft, hidden-regions, area-label-anchor, live-pose-overlay). Note: liveMapImageEntity is owned by state/learning.js (reads dashboardSnapshot().live_map_image_entity), not this module β but mapRotation, mascotDwelledRoomId, the live-URL slice, and mapRoomLabelsEnabled live here |
| live-trail | state/live-trail.js |
Pure cleaning-trail accumulator (accumulateTrail; no card state, not registered on the prototype β imported by state/map.js, which owns the _liveTrail / liveTrail() / resetLiveTrail() slice). Folds one live-pose anchor sample into the position-built trail: appends while cleaning (dedup stationary repeats, bounded length) and FREEZES while docked so a mid-clean recharge pauses rather than splits the trace. Reset is external β the card calls resetLiveTrail() from its own clean-dispatch points. Has its own live-trail.test.mjs |
| mascot-facing | state/mascot-facing.js |
Pure mascot-facing helpers (mascotFacingSign / commitFacing; not registered on the prototype β imported by state/map.js). Derive travel direction for the follow-mode mascot from the CHANGE in the robot anchor between live-pose updates (Eufy exposes no heading), projecting the anchor delta through the map display rotation and applying a jitter deadband + hold so the sprite mirrors instead of "moonwalking". Has its own mascot-facing.test.mjs |
| setup | state/setup.js |
Setup status; setup loading flag |
| confirmations | state/confirmations.js |
Two-tap confirm state for destructive actions |
| toasts | state/toasts.js |
Transient toast / notice queue |
| viewport | state/viewport.js |
Viewport / responsive (mobile vs desktop) state |
| saved-zones | state/saved-zones.js |
Saved-zone library (savedZones / setSavedZonesLibrary, backed by _savedZonesLibrary with a map-segments fallback); panel multi-select "will be cleaned" set (toggleSavedZoneSelection / selectedSavedZoneIds / selectedSavedZoneCount / clearSavedZoneSelection); collapsible-section state (savedZonesCollapsed); grouping-by-room for the panel (savedZonesGrouped β one group per room in map order, Unassigned bucket last); and the queue zone-picker slice (queueZonePickerOpen / openQueueZonePicker / closeQueueZonePicker / toggleQueueZonePick / isQueueZonePicked / queueZonePickerSelected) β a transient multi-select for inserting a zone STEP into the live queue, held in its own _queueZonePickerSel Set deliberately apart from the "clean N now" selection so the two flows can't cross-contaminate (driven from bindings/rooms.js and rendered by renderers/rooms.js). Registered in state/index.js via applySavedZonesState |
| dialog | state/dialog.js |
Card-native confirm/alert/prompt dialog spec (openDialog / pendingDialog / resolveDialog / cancelDialog), replacing browser-native window.confirm/alert/prompt (which use the browser locale and are suppressed in the HA webview). One dialog open at a time; the spec carries a resolve promise fn and a kind-appropriate cancel value (confirm β false, prompt β null, alert β undefined). Registered in state/index.js via applyDialogState |
Init shape and clear shape¶
Each state module stores data in plain properties on this (the VacuumCardState instance). There is no central store object β properties are scattered across the prototype by module. The pattern is consistent:
- A
set*method assigns the property. - A getter method reads it with a fallback.
- A
clear*method (where appropriate) resets to null or{}.
Example:
// state/dock.js
proto.setDockActionStatus = function (payload) {
this._ensureDockState().actionStatus = payload ?? null;
};
proto.dockActionStatus = function () {
return this._ensureDockState().actionStatus ?? null;
};
Properties are not initialized in the constructor (it assigns only hass/config) β they are created lazily on first use. Twelve modules (confirmations, dock, learning, maintenance, metrics, order, review, room-profiles, run-profiles, setup, theme, toasts) hold their fields inside ONE namespaced store object built by an _ensure*State() helper, so the store is created by whichever of the getter or setter fires first, with every field pre-seeded to its own empty value β usually null, but also "" (dock's pendingAction), [] (learning's completedRooms, toasts' items), {} / new Map() (learning's roomEstimates, confirmations' entries), false (learning's jobActive) and enum defaults (maintenance's activeTab, review's sort); the remaining modules use plain this._foo properties created by the first setter call. Either way a getter that fires before the first set yields that field's seeded empty value rather than undefined β null for the snapshot-shaped fields, which is the intended "not yet loaded" sentinel.
The hass setter and the load-once pattern¶
The hass setter in main.js runs on every HA state push. It:
- Calls
state.sync(hass, config)andactions.sync(hass, state)to refresh references. - Reads the theme sensor attributes and calls
state.setBackendThemeState(). - Calls
_scheduleRender(). - Schedules debounced refreshes for all service-fetched data (dashboard snapshot, start status, dock action status, pause timeout, metrics, learning history, run profiles, saved zones, incomplete run log, trouble rooms log).
Most of these scheduled refreshes use clearTimeout + setTimeout with different delays (350 ms to 1400 ms) to avoid hammering the backend on rapid HA state bursts.
Load-once pattern: Some fetches should only happen once per session because they are expensive or their data rarely changes. The card implements this with boolean flags (_themeLoaded, _incompleteRunLogLoaded, _troubleRoomsLogLoaded). Once set to true, the corresponding scheduler exits early:
The theme library is loaded once via _loadInitialThemeState(), which is also guarded by this._themeLoaded. Subsequent HA pushes only sync the theme sensor attributes (cheap β already in hass.states); they do not re-fetch the library.
How state modules communicate¶
They don't. Every inter-module interaction routes through the card instance:
- Bindings hold
this.cardand callthis.card._state.someMethod()andthis.card._actions.someAction(). - Actions hold
this.stateand read from it but never write to other action modules. - Renderers hold
this.cardand readthis.card._state.
If a binding needs a value from two different state modules, it calls each module's getter separately and combines the results inline.