* Phase 2: Entity status panel framework spec
Rewrite docs/dev/frontend/entity-status-panels.md to describe the new
panel framework before any code lands. The spec is the target the
Phase 3 implementation commits will produce. Key concepts:
- Panels are Python declarations under
src/hi/apps/entity/state_panels/<name>/panel.py with templates and
static assets in mirrored directory trees. Autodiscovery at app-ready
time; adding the panel.py file is the entire registration.
- Each declaration carries name, entity_type, display_contexts (Set),
priority (required int), required_roles + optional_roles (disjoint
Sets of EntityStateRole), and template_name. No filename convention
on templates.
- Dispatch is a view-layer concern: a resolver helper selects the
matching panel from (EntityType, DisplayContext, EntityStateRoles
present), ordered by priority. Multiple panels per EntityType are
the supported way to express layout variants; templates stay
context-agnostic.
- Display contract: declared roles must be displayed by the panel;
roles outside the declared set are "extras" owned by the framework.
- Modal context auto-appends an expandable "Other states" section for
extras, giving the user a framework-level invariant that every
EntityState is reachable in the modal view. Grid and list ignore
extras silently.
- Required-role access in templates needs no defensive {% if %}.
Also extend entity-status-display.md with one paragraph clarifying
that the polling apply pass tolerates state-set changes across polls
(missing ids are no-ops; new ids picked up on subsequent ticks),
formalizing the contract panel JS handlers already assume.
No code change in this commit. The render_entity_status_panel
template tag, today's three monolithic panels, and the current
fallback-chain dispatch remain in place until Phase 3 lands.
* Tweaks to Claude /pickup command.
* Phase 3a: Entity status panel framework core
Lands the new framework's Python primitives. No callers wired yet: the
existing render_entity_status_panel template tag (and its
resolve_panel_template helper) stay in place until Phase 3b removes
them and migrates the wrapper views.
- DisplayContext enum (MODAL / LIST / GRID) added to entity.enums.
- EntityStatusPanel as a plain @dataclass with __post_init__ validation
only. No registry coupling; required vs optional fields are
expressed by dataclass defaults.
- EntityStatusPanelRegistry singleton: discover() walks
state_panels/<name>/panel.py and registers every EntityStatusPanel
instance found at module scope (one panel per module is typical;
multiple are supported). Identity-based dedup in register().
- resolve_panel(entity_type, display_context, present_roles) returns a
PanelResolution carrying the chosen panel, the computed extras
(present_roles - declared_roles), and a trace for ?debug_panel=1.
Type-specific matches win over entity_type=None fallback panels;
within either group, lower priority wins, name is the alphabetical
tiebreaker.
- entity.apps.DeviceConfig.ready() triggers discovery.
- 18 unit tests cover declaration validation, registry register/dedup,
and the full dispatch decision tree (typed wins, priority ordering,
alphabetical tiebreak, missing-required disqualification, context
mismatch, extras computation, no-fallback raise).
Doc: rewrite docs/dev/frontend/entity-status-panels.md so the
authoring contract matches the implementation (dataclass instance in
panel.py, not subclass; module-scope scan, not __init_subclass__).
* Phase 3b: retrofit panels onto new framework + wire view layer
Cuts the entity status modal and the collection card wrappers over to
the new EntityStatusPanel framework. Each existing panel becomes a
set of EntityStatusPanel declarations (one per DisplayContext, since
templates are context-agnostic under the new framework), and the
view layer resolves which panel to render before the template runs.
Panel declarations under hi/apps/entity/state_panels/<name>/panel.py:
- thermostat_{modal,list,grid}: required THERMOSTAT_CURRENT_TEMPERATURE,
optional THERMOSTAT_TARGET_TEMPERATURE + HVAC_ACTION. An entity
marked THERMOSTAT without a current-temperature state now falls
back to the flat state list rather than rendering a sparse dial.
- smoke_detector_{modal,list,grid}: required SMOKE, optional
BATTERY_LEVEL.
- camera_{modal,list,grid}: no required roles (panel renders against
has_live_view).
- fallback_{modal,list,grid}: entity_type=None catch-all.
Dispatch layer (state_panel_dispatch.py) gains EntityPanelData — a
single dataclass that encapsulates everything a wrapper template
needs (entity_status_data, panel_template, panel_context, trace, plus
forwarded entity / display_category / display_only_svg_icon_item /
extras_state_data_list). build_entity_panel_data(entity_display_data,
display_context) is the single helper both EntityStatusView and
CollectionManager call; both flow the same object into the same
{% include_panel entity_panel_data %} tag. The old per-panel
{% render_entity_status_panel %} dispatch tag and its supporting
legacy resolver are gone.
build_panel_context filters state_status_data_list and
state_status_data_by_role to the panel's declared (required +
optional) roles. Roles outside that set are extras; the modal wrapper
auto-appends an "Other states" expandable section for them, grid and
list ignore them silently. Fallback panels (entity_type=None) see the
unfiltered state list, so the fallback path behaves like today.
Dead {% if current_data %} / {% if smoke_data %} guards on required
roles are removed from the affected panel templates — the framework
guarantees those roles are present whenever the panel renders.
Append ?debug_panel=1 to the entity status modal URL to see the
dispatch trace as an HTML comment.
Test isolation: EntityStatusPanelRegistry gains snapshot_for_tests /
restore_for_tests so framework tests can spin up an isolated registry
and restore production state in teardown.
* Refactor entity state panel naming + dispatch class
Two coupled refactors on the new panel framework, no behavior changes.
1. Wrap the panel dispatch module-level functions in a
``StatePanelDispatcher`` class with classmethods, matching the
codebase convention of namespacing related operations on a class
rather than at module scope. ``PanelResolution`` and the renamed
``EntityStatePanelData`` stay module-level as result types.
2. Disambiguate "Status" vs "State" in the framework's class names.
The framework renders only the *state* subset of an
``EntityStatusView``; the directory / module / dispatcher names
already use "state_panel," but the class names drifted back to
"Status." The classes now match the file layout:
- ``EntityStatusPanel`` -> ``EntityStatePanel``
- ``EntityStatusPanelRegistry`` -> ``EntityStatePanelRegistry``
- ``EntityPanelData`` -> ``EntityStatePanelData``
- method ``build_entity_panel_data`` -> ``build_state_panel_data``
- variable ``entity_panel_data`` / ``entity_panel_data_list``
-> ``state_panel_data`` / ``state_panel_data_list``
- doc ``entity-status-panels.md`` -> ``entity-state-panels.md``
``EntityStatusView`` / ``EntityStatusData`` keep their names: those
genuinely scope to the broader status view that *contains* the
state panel.
* Address pre-Phase-4 review feedback on state panel framework
Fixes a real bug and applies a batch of framework hardening + cleanup
items surfaced by the four-agent review of the state panel work.
Bug fix:
- ``CollectionViewType.is_grid`` / ``is_list`` were methods, not
properties. The Phase 3b dispatcher read ``.is_grid`` without
calling it (truthy bound method), so collections always resolved
through the GRID context regardless of view type. Convert to
``@property`` for consistency with ``CollectionType.is_cameras``;
templates auto-call either form.
Framework hardening:
- ``EntityStatePanelRegistry.discover()`` catches ``Exception``
per-module and sets ``_discovered`` only after a successful pass,
so a malformed third-party panel can't poison Django startup.
- ``{% include_panel %}`` tag passes ``request=`` to ``Template.render``
so the inner panel renders under a fresh ``RequestContext``,
re-running context processors.
- ``StatePanelDispatcher.resolve_panel`` gains a ``debug`` parameter;
trace string formatting is skipped unless ``debug=True`` or
DEBUG-level logging is enabled. The collection hot path (one
dispatch per card) no longer pays the trace cost.
- ``EntityStatusView`` opts into trace construction only when
``?debug_panel=1`` is set.
API and naming:
- Rename ``EntityStatePanelData.entity_status_data`` field to
``entity_display_data`` so it matches the ``EntityDisplayData``
type it holds; collection card wrappers updated accordingly.
- Drop ``TYPE_CHECKING`` forward-reference; ``EntityDisplayData`` is
imported directly. Type-annotate ``entity_display_data`` parameters
on the dispatcher methods.
- Forward ``entity_for_video`` on ``EntityStatePanelData`` to match
the other entity-level forwards.
- Promote ``extras_state_data_list`` to a typed dataclass field
(was derived through a dict-key lookup on ``panel_context``).
- Extract role-filtering to ``EntityDisplayData.filter_to_roles``;
the dispatcher's ``build_state_panel_data`` is now pure assembly.
Cleanup:
- Delete ``EntityDisplayData.to_template_context``; production paths
flow through the dispatcher, and the two tests that referenced it
now assert on the dataclass's properties directly.
- Drop ``EntityStatePanelRegistry.reset_for_tests``; ``snapshot``/
``restore`` with an empty snapshot covers the same need with one
fewer public method reaching into private state.
- Sweep ``EntityStatusPanel`` stragglers in JS / CSS comments.
- Drop dead ``{% with sensor_response %}`` in
``fallback/state_row.html`` and ``{% if state_status_data_list %}``
in ``camera/modal.html`` (camera declares no roles, so its
filtered state list is always empty).
- Add NaN guard in ``thermostat.js`` so an empty ``data-temp-value``
hides the marker rather than parking it at the dial's top.
- Standardize validation error prefixes on
``EntityStatePanel(<name>): ...``.
Tests added:
- ``EntityStatusView`` falls back to the framework fallback panel
when a typed entity is missing its required roles (integration).
- ``discover()`` skips a broken panel module and continues
registering the others.
- ``{% include_panel %}`` merges ``panel_context`` over the parent
context (panel values win on key collision).
* Phase 4a: thermostat panel completion + role-data template aliases
Brings the thermostat EntityStatePanel up to the original mock and adds
a framework convenience for panels with many role lookups.
Framework extension — role_data_template_aliases:
EntityStatePanel gains an optional ``role_data_template_aliases``
declaration: a dict mapping template-context variable names to
declared EntityStateRoles. The dispatcher resolves each alias against
the entity's by-role map at render time and seeds ``panel_context``
with the named variables, so templates use ``{{ current_data.X }}``
directly instead of chaining ``{% with %}`` blocks (which Django
cannot parse across newlines, making chains of more than a few
bindings unwieldy). Validated at construction: every aliased role
must be in ``required_roles | optional_roles``; values must be
``EntityStateRole`` members. Recommended threshold is 5+ named
roles per panel — smaller panels keep the lighter ``{% with %}``
form.
Thermostat panel completion:
- Required role unchanged: THERMOSTAT_CURRENT_TEMPERATURE.
- Optional roles expanded to cover dual setpoint
(THERMOSTAT_TARGET_TEMPERATURE_LOW + _HIGH), HVAC_MODE, FAN_MODE,
PRESET_MODE, HUMIDITY (plus the prior TARGET / HVAC_ACTION).
SWING_MODE intentionally excluded — not exposed by the HA simulator.
- Nine role_data_template_aliases declared so the modal / list / grid
templates read at the top level (current_data, target_data, low_data,
high_data, action_data, mode_data, fan_data, preset_data,
humidity_data). Each template opens with a comment listing the
aliases.
- Modal template gains a controls grid with a setpoint stepper
(single-row or dual-row depending on which setpoint roles are
present) plus Mode / Fan / Preset rows that delegate to the existing
controller-widget machinery when controllable and fall back to
read-only labels otherwise. Humidity surfaces as a secondary
readout. Custom −/+ stepper widget posts each adjustment through
the same async-form mechanism the slider uses, but routes the
response into a hidden sink so the buttons survive across clicks;
polling refreshes both the visible label and the hidden form value.
- List card shows dual-or-single setpoint summary + humidity line.
- Grid card shows dual-or-single setpoint in the mode summary line.
- CSS extended for the modal controls grid, stepper widget, and
secondary readout row.
- JS gains a stepper click handler that bumps the form's hidden
value by the button's data-stepper-delta and submits.
Sense-vs-controller asymmetry: each interactive widget checks
``<role_data>.controller_data_list`` to decide whether to render the
controllable variant or a plain readout, so a future integration
exposing thermostat target as sense-only still renders correctly.
Spec doc: ``role_data_template_aliases`` added to the declaration
table and a two-paths section explains when to prefer the alias form
over a single ``{% with %}``.
* Phase 4a polish: thermostat dynamic UI + unified status-apply path
Wraps up the thermostat panel work with the runtime polish that
emerged from exercising it in the browser, plus a framework-level
refactor that benefits every panel.
Thermostat polish:
- Mode-driven single/dual setpoint visibility. HA exposes target /
target_low / target_high simultaneously, so role-presence alone
can't pick the active setpoint shape. The panel root carries
``data-hvac-mode`` (initial value rendered server-side, kept in
sync at runtime by the JS update handler against the polled mode
state); CSS rules at end-of-file show the dual block under
``heat_cool`` / ``auto`` and the single block otherwise. Both
blocks render so the toggle is a CSS swap, not a re-render.
- Layout stability across the toggle. Setpoints occupy their own
centered row; Mode / Fan / Preset live in an independent 2-col
grid below. Setpoint count no longer reflows the modes row.
- Custom thermostat select widget that bypasses antinode. The
standard ``controller_data.html`` form-submit replaces the entire
widget with the server response on every change, which conflicts
with the dial / panel-root state the thermostat owns. The
thermostat-local ``role_control.html`` renders the same
``.discrete-select`` widget (preserving the global styling) but
drops ``onchange-async`` / ``data-async`` and lets the panel's
own JS handler POST to the controller endpoint.
- Single-line ``{% with %}`` everywhere. Django's tag lexer doesn't
span newlines despite the ``re.DOTALL`` apparent suggestion;
multi-line ``{% with %}`` silently parses as text and orphans
``{% endwith %}``.
Framework: unified status-apply path
(``src/hi/static/js/entity_state_status.js``)
- Generic ``change`` listener on every
``[data-state-id][data-controller-value]`` element synthesizes a
one-entry statusMap from the new value and runs it through the
existing ``Hi.entityStateStatus.apply`` pipeline. All dependent
DOM (display labels, marker positioning, panel-root attributes
like ``data-hvac-mode``) updates immediately on user interaction,
not on the next polling tick. Polling reconciles or corrects.
- The server-bound submit path (antinode's ``onchange-async``, or a
panel's custom ``$.post``) is unchanged and runs independently of
this handler — error responses still surface through the normal
mechanism; rejected changes are reverted by the next poll.
- Optimistic format preservation. Numeric synthesis reads the
decimal precision and unit suffix from the paired
``[data-display-text]`` element and applies them to the new value,
so an optimistic ``73.0°F`` matches the canonical ``72.0°F``
formatting until the next poll. Heuristic, but covers the common
decimal + unit case; a follow-up issue should add a server-driven
format spec to the polling payload for the general case.
Collection cards:
- The entity-name link in collection cards now opens the entity
status modal in non-edit mode (matching LocationView click
behavior). Now that cards render a per-type panel rather than the
full flat status list, the modal is where complete status lives.
* Phase 4b: smoke detector panel completion + low-battery framework status
Brings the smoke detector EntityStatePanel up to the mock and adds
a framework-level low-battery status derivation that any panel
exposing a BATTERY_LEVEL state can opt into.
Smoke detector panel:
- All three contexts (modal / list / grid) restructured to the
thermostat-era patterns: ``role_data_template_aliases``
(smoke_data, battery_data), top-of-template alias comment, and
``svg_status_style.status_value`` for initial render so the
decay-aware token (``idle`` / ``active`` / ``recent`` / ``past``)
matches the vocabulary the polling pipeline emits — no dual
``smoke_clear`` / ``smoke_detected`` CSS rule set anymore.
- Four-state badge hero per mock: shield-check (idle), exclamation
(active), and two clock variants (recent / past). Color tints
follow the existing decay-color palette. Active-state pulses
via CSS keyframes.
- Grid card gains a tinted background per state and a dedicated
``Low Battery`` warning shown only in low-battery state.
- The status detail line (``Triggered just now`` / ``Alarm 14m
ago`` / ``No events in the last 30 days``) is intentionally
deferred — those text variants need recent-sensor-history data
that the framework doesn't yet expose. Captured for a follow-up
framework discussion after Phase 4c.
Framework: BATTERY_LEVEL status derivation:
- ``EntityStateDisplayData._get_svg_status_style`` now dispatches
to ``_get_battery_level_status_style`` for BATTERY_LEVEL states,
emitting ``StatusStyle.BatteryLow`` when the value is below
``BATTERY_LOW_THRESHOLD_PCT`` (20%) and ``StatusStyle.BatteryOk``
otherwise. The polling pipeline carries the token through the
existing ``data-status`` opt-in, so any panel can react with
CSS rules on ``[status="low"]``. Threshold lives on the class so
it's adjustable in one place.
Simulator:
- New ``HassSmokeDetectorWithBatteryFields`` combo entity (smoke +
battery states), modeled on the existing leak-sensor +
battery shape. Zoo profile gets a second smoke detector entry
named ``Zoo Smoke Detector (Battery)`` to exercise the
battery aux readout and low-battery escalation paths.
* Phase 4c: camera panel completion + motion chip + extras inline
Brings the camera EntityStatePanel up to the polish bar set by the
earlier panels, and adjusts the framework extras section so its
modal presentation matches the "every state reachable" invariant.
Camera panel:
- One declaration per DisplayContext (no role-based variant split —
the motion treatment is a small visual enhancement, not a layout
fork, so the optional-role pattern from the smoke detector
applies). MOVEMENT is declared optional with a ``motion_data``
alias; the template guards the chip with ``{% if motion_data %}``.
- Motion chip overlay on the stream corner when the camera entity
carries a MOVEMENT role. Uses the framework's decay-aware status
tokens (active / recent / past / idle) so the chip color
matches the rest of the app's motion treatment, including the
``--on-status-*-color`` companion tokens for foreground contrast.
Pulses on ``active``. 36px in modal, 24px in compact contexts.
- Per the user's redirection of the mock, the live feed continues
to flow through the existing ``entity_live_view_w_link.html``
template — the panel handles sizing and the chip overlay, not
the complications of stream rendering / offline placeholders.
- Connectivity, "Last seen X min ago", and ZM-Function-specific
layout from the mock intentionally deferred: per-camera
connectivity isn't tracked anywhere today, recent-history
timestamps require framework work that hasn't landed, and the
ZM Function controller falls cleanly into the framework's
extras section without bespoke handling.
Framework: extras section inline:
- ``extras_section.html`` no longer wraps content in
``<details><summary>Other states</summary>``. The collapsible
predated the framework decision that modal must surface every
state unconditionally; with that invariant in place, the
collapsible defeats the contract. The "Other states" heading
is also dropped — "state" isn't user-facing nomenclature, and a
section label adds nothing of value.
* Extract shared partials for smoke detector badge and camera motion chip
The badge SVG icons (idle/active/recent/past) and the camera motion chip
markup were duplicated across modal/list/grid contexts. Move each into a
single included partial so the icon/chip definitions live in one place
and the per-context templates only carry their layout-specific wrapping.
* Phase 5+6: Expose recent state-value history to panels; adopt in smoke detector
SensorResponseManager already caches the last 5 deduped sensor values
per state, and EntityStateDisplayData already holds that list. Promote
it from internal threshold input to a first-class, display-ready
accessor — recent_state_value_summary — so panels can surface "how
recent" detail without DB queries.
Each cached entry's value flows through the same ConsoleConverterHelper
pipeline as latest_display_label, so history rows render in the user's
preferred unit. The summary is server-render-only (deliberately not in
the polling payload) and makes no completeness claims about the cache
window.
Smoke detector modal and list contexts now render "Triggered N minutes
ago" when status decays to recent or past; idle and active suppress.
Grid is intentionally left tight — the badge color carries the in-glance
signal and the modal carries the detail.
4.8 KiB
Entity Display — Architecture Overview
How entity state flows from sensor to pixel, and where the responsibilities live. Read this first when you're new to the frontend; reach for the focused docs (linked below) when you're authoring or modifying a specific surface.
The flow
Sensor reading (HA / ZM / etc.)
│
▼
EntityStateStatusData ─ raw per-state data container
│
▼
EntityStateDisplayData ─ display projections (StatusStyle, formatted text, etc.)
│
▼
StatusDisplayManager ─ assembles entityStateStatusMap, keyed by state id
│
▼ ────── /api/status response ──────► JS dispatcher (entity_state_status.js)
│
▼
DOM elements with data-state-id
(icons, paths, panels, sensor cards)
Server side: each EntityState gets one row in the status map. The row carries whatever update payloads the surfaces consuming this state might need — a status string, formatted display text, a controller widget value, an SVG style bundle. Source of truth: EntityStateDisplayData.to_polling_update_dict.
Client side: the dispatcher walks every DOM element carrying data-state-id="<id>", looks up the row for that state, and writes whatever subset the element opted into via declaration attributes (data-status, data-display-text, etc.). No descendant traversal, no class-as-join — each element is self-describing.
The four display surfaces
The same polling pipeline drives four visually distinct surfaces. Each surface has its own authoring conventions but speaks the same wire contract.
| Surface | Where it appears | Authoring doc |
|---|---|---|
| LocationView SVG icons | The map view's <g> icon elements per positioned entity |
entity-visual-configuration.md |
| LocationView SVG paths | The map view's <path> elements per area-based entity |
entity-visual-configuration.md |
| Entity status modal | Body of the per-entity status dialog | entity-state-panels.md |
| Collection cards | Per-entity cards in the list and grid layouts of a collection view | entity-state-panels.md |
The modal and collection-card surfaces share an EntityStatePanel dispatch: a panel for a given EntityType provides up to three templates (modal.html / list.html / grid.html), with a framework fallback supplying a flat state list when no per-type panel exists.
The polling contract is the connective tissue
All four surfaces ultimately render DOM elements whose live behavior is driven by the same per-element contract:
<element data-state-id="<entity_state.id>"
data-status ← optional: receive the status attribute
data-display-text ← optional: receive the formatted text
data-display-magnitude ← optional: receive the magnitude only
data-display-unit ← optional: receive the unit only
data-controller-value ← optional: receive form value updates
data-svg-style ← optional: receive the full SVG style bundle
...>
The full grammar, the server payload shape, and the rules about which declarations belong on which element shapes are documented in entity-status-display.md.
Where to look next
By task:
- Adding visual support for a new
EntityTypeon the map (icon or path) —entity-visual-configuration.md. - Authoring a custom panel for a new
EntityType(modal / list / grid bodies) —entity-state-panels.md. - Modifying the polling-update mechanism, the wire format, the color palette, or the per-element declaration grammar —
entity-status-display.md.
By component:
- Backend:
StatusDisplayManager,EntityStateDisplayData/EntityDisplayData,EntityStateStatusData. - Frontend dispatcher:
entity_state_status.js. - Panel dispatcher:
state_panel_dispatch.py+state_panel_tags.py. - CSS palette and SVG status rules:
main.css(search:rootfor variables,g[statusand.hi-status-display[statusfor rules).