Files
home-information/docs/dev/frontend/entity-display-overview.md
Tony C 4a7d6d9507 Complete the three initial EntityStatusPanel implementations (#340)
* 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.
2026-05-17 21:19:30 -05:00

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:

By component: