feat(ha): reintegrate HA control stack onto main + trigger automations from the video - #609
Closed
badbread wants to merge 18 commits into
Closed
feat(ha): reintegrate HA control stack onto main + trigger automations from the video#609badbread wants to merge 18 commits into
badbread wants to merge 18 commits into
Conversation
….trigger Cherry-pick the HA actuator-control server layer (3f9af3f from the 8-day-stale feat/ha-control-server) onto current main, and add the automation domain so an operator can trigger a Home Assistant automation from a camera view. - crumb_common::ha::HaClient::call_service -> POST /api/services/<domain>/<service> - per-domain action allowlist (no raw service/entity passthrough), now including ("automation", &["trigger"]); guard test updated - POST /cameras/:id/ha/action gated by the new default-off `actuators` capability (admin implies); client sends link_id, never a raw entity id - migration renumbered 0074 -> 0072 (next free slot on main) and registered in the MIGRATIONS array Conflicts from 8 days of drift resolved: db.rs MIGRATIONS (only the actuators migration kept, not the two unrelated sibling migrations that never reached main); auth_rbac.rs taken from main (actuator RBAC coverage lives in the new tests/ha_action_rbac.rs); DECISIONS.md keeps only the HA-control entry. Signed-off-by: badbread <badbread@users.noreply.github.com>
…card (#425) Adds the one write the Apple client makes to Home Assistant: POST /cameras/:id/ha/action from the tap detail card (HAStateCard), the same more-info surface reached from an on-video badge and from the per-camera entity sheet. Gating is deny-by-default and doubled: the new `actuators` capability from GET /auth/me AND an `actuator`-role link. Without both, the card renders exactly the read-only Phase 1 UI. Buttons come from a per-domain table mirroring the server allow-list: light/switch/fan/siren On+Off, cover Open/Stop/Close, lock Lock/Unlock, button/input_button Press, scene Activate, script Run. Unknown domains get no buttons rather than guessing at a call the server would reject. Locks and covers confirm first (a confirmationDialog titled with the action and the entity, e.g. "Unlock Front Door?"); lights, switches and buttons fire immediately. While a call is in flight its button shows a spinner and all buttons disable, so a double tap cannot queue two service calls at a lock. Success never flips state locally: the existing 3s /ha/states poll stays the only source of truth, preserving the state-honesty invariant. Failures show a compact inline message, with 403 read as "not permitted" and 502 as "Home Assistant did not respond". Decoding is defensive throughout, since the server change lands in parallel: `actuators` is decodeIfPresent-defaulted to false (an older server that omits it hides the controls and must never fail decoding of the whole capability set), and HaLink accepts either `id` or `link_id` for the link id it sends back. Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit de92783)
…426) Phase 2 of the Home Assistant integration adds control to the desktop client's read-only badge overlay: tapping a placed badge whose link is an `actuator` now offers the buttons its domain allows, firing `POST /cameras/{id}/ha/action {link_id, action}`. - `ha_actions.dart` mirrors the server's allow-list by domain (light/ switch/fan/siren, cover, lock, button/input_button, scene, script), so the UI never offers a button the server would refuse. Every other domain yields no actions and the card stays exactly as it is today. - Gated on `GET /auth/me` -> `capabilities.actuators`, read via the new `MeResponse.canActuate` and plumbed shell -> wall -> tile/pane -> overlay layer. Absent key (older server) parses false, so controls simply do not render. A viewer without the capability sees no hint that controls exist. - Locks and covers require an explicit confirm ("Unlock Front Door?") before firing; lights, switches, buttons and scenes fire immediately. - A fired action shows an in-flight spinner then a short "sent" settle; the card never flips state locally, the existing 3s /ha/states poll converges the badge. Failures come back as a toast, with 403 reading as not-permitted. Client-side only; the endpoint and its RBAC land with the server change. Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit e1aff2f)
… cover/lock (#428) (#429) Field feedback after #187 shipped: toggling a light shouldn't cost click -> card -> On/Off. Refine the desktop HA overlay interaction so a controllable badge for a one-tap domain fires its primary action on a plain click, and reserve the detail card for the domains a single tap cannot express. - ha_actions.dart: add haPrimaryAction(domain) (light/switch/fan/siren -> toggle, button/input_button -> press, scene/script -> turn_on; null for read-only/unknown) and haNeedsCard(domain) (cover/lock). The simple-vs-card split lives next to the server-mirroring action table so they cannot drift. - ha_overlay_layer.dart: route badge taps through _handleTap. A controllable simple-domain badge fires directly via _fireDirect, reusing the existing haAction POST + toast-on-failure and never flipping state locally; a brief spinner rides the badge through the settle window while the 3s /ha/states poll converges. cover/lock and every read-only / non-controllable badge keep opening the card (cover/lock retain their multi-action buttons + confirm). Hover already reveals state/age for all badges (unchanged) and is now the primary way to read an entity's state. - docs/DECISIONS.md: note the client interaction model on the 2026-08-01 HA control entry. Client-only; the backend allowlist already accepts toggle. Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit 148597d)
…/lock (#428) (#430) Field feedback on #187: toggling a light shouldn't take tap -> card -> On/Off. Make the on-video badge and the per-camera entity-sheet row touch-appropriate. For a link the caller can control (`actuators` capability AND an actuator-role link), a single tap now fires the primary action directly, no card: light/switch/fan/siren -> toggle, button/input_button -> press, scene/script -> turn_on. The tap opens `HAStateCard` only for `cover` and `lock`, the genuinely multi-action domains, and lock/cover keep their confirmation dialog. Read-only links and unknown domains are unchanged: a tap opens the read-only card exactly as before. Detail stays reachable for a directly-controllable actuator via a long-press (consistent on both the badge and the row), so a user can inspect state/stats without actuating. Chosen over an info button because the on-video badge is a tiny dot with no room for a second affordance. The domain split lives in one place on the `HA` helper: `needsCard(_:)` (cover/lock, with a note that a future dimmer/value control joins it) and `primaryAction(for:)` (the single direct-tap action). `HAController` gains `directTapAction(for:)`, which folds in the capability + role + domain gate. Direct-fire reuses the existing action POST plus the in-flight / poll-converge path (state is never flipped locally), shows a brief spinner on the badge/row, and surfaces failures through the shared `HA.actionMessage(for:)` (extracted from the card so both surfaces phrase 403/502/4xx identically). Client-only; the backend endpoint already accepts `toggle`. Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit f7ccd0d)
…action model (#431) Adds the actuator control half of HA Phase 2 (#187) to the Android client, built to the refined single-tap interaction model (#428) rather than the click -> card -> buttons flow. - New `actuators` capability (default-off, admin-implied) on CapabilitiesDto; parsed from /auth/me and gated in the UI as `store.isAdmin || store.capabilities.actuators`. Absent on older servers -> false, controls hide. - POST /cameras/{id}/ha/action wired through CrumbApi + CrumbRepository; the shown state is never flipped locally, the /ha/states poll converges it. - HaLinkDto parses `link_id` (resolved via actionLinkId = link_id ?: id); one helper (haPrimaryAction / haNeedsSheet) holds the domain -> interaction split. - Badge single tap: fires the primary action directly for light/switch/fan/ siren (toggle), button/input_button (press), scene/script (turn_on), with an in-flight spinner; opens the confirm-guarded control dialog for cover/lock; else read-only detail. Long-press always opens the read-only detail (inspect without actuating). Non-controllable badges behave exactly as before. - cover/lock actions require a confirmation prompt before firing. Controls also reachable from the per-camera entity sheet's more-info detail. Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit 4b56425)
…verlay cell (#432) Records HA control Phase 2 (#187) + the single-tap-actuates interaction refinement (#428) across web-admin/desktop/Android/iOS in the cross-client parity table: the POST /cameras/:id/ha/action endpoint, the per-domain action allowlist, the default-off actuators capability (migration 0074), and the per-client control UX (single click/tap actuates; card/confirm for cover/lock; hover/long-press for detail). Also corrects the read-only HA overlay row, which listed Android as Deferred though it ships the badges + entity sheet. Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit 8f15926)
Android had two divergent Home Assistant visual mappings: the on-video badge used `badgeVisual`/`edgeOn` (a faithful port of the desktop `haVisualFor`, matching the other clients) while the entity sheet and more-info dialog used a separate `haVisual` with a different palette (open door #FFC107 vs #FFB143), a different active-set (unlocked/playing/ active treated as active), and different glyphs (motion -> Sensors vs figure.run). The same entity read differently between the badge and the sheet, and a badge tap even rendered the badge one way but its detail dialog another. Collapse both onto one canonical source of truth. Extract the badge mapping (palette, `BadgeVisual`, `edgeOn`, `defaultVisual`, `badgeVisual`, `parseHexColor`) into a shared `HaVisual.kt` that both the badge overlay and the sheet/dialog call, and delete the divergent `haVisual`. Fold the sheet's extra active-state tokens (opening/unlocked/playing/active, plus locked -> off) into the one `edgeOn` so those entities keep reading as active under the unified map instead of falling back to indeterminate. The icon vocabulary is deliberately left as the badge's existing set; the closed-vocabulary rework is tracked separately (#438). Closes #437 Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit 3a3897b)
…mains (#448) The entity picker only offered binary_sensor/light/switch/scene, so the action allowlist's cover/lock/fan/siren/button/input_button/script domains and numeric sensors had no pick path. The confirm-gated lock/garage control had buttons wired but no way to create the link. Backend (ha.rs): the 'controls' alias now derives its domain set from HA_ACTION_ALLOWLIST via control_domains(), so the picker can never drift from what the action endpoint accepts. Add a 'sensors' alias for numeric sensor; the omitted-domain default is now the union of all pickable roles. Doc-comment + unit tests updated. Admin console + desktop dialog: add a '+ Add value' numeric-sensor path and route each link role to its server domain via a single helper; the widened controls set flows through the existing 'controls' request automatically. Also fix a stale validation message: motion_source accepts 'ha' too, so the 400 text now reads 'pixel', 'frigate', or 'ha'. Closes #433 Closes #436 Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit a6c83a6)
Two backend-contract items for the HA management overhaul (epic #445), Tier 1. #434 — server-side role/domain validation on HA link writes. `PUT /cameras/:id/ha/links` now rejects link roles that cannot work on their entity's domain, instead of silently storing a dead link: - `actuator` requires a controllable domain (derived from the same `control_domains()` helper the picker uses, itself built from `HA_ACTION_ALLOWLIST`, so it can never drift from what the action endpoint accepts); a `sensor`/`binary_sensor`/`climate`/... actuator link is a 400. - `motion` requires a `binary_sensor` (only on/off domains produce the edges recording keys on); anything else is a 400. - `sensor` (status-only display) stays permissive on any domain. `device_class` is unchanged: still a client-provided snapshot, now travelling with a validated role. New pure helper `validate_link_role` with unit tests for every role-vs-domain pairing. #449 (partial, backend contract) — the `GET /ha/states` entity objects gain an optional `unit: Option<String>`, passed through verbatim from HA's `attributes.unit_of_measurement` so clients can render numeric sensors as `<state> <unit>`. No server-side number formatting. Unit test covers present / missing / no-attributes. #435 needs no backend change: "controllable" is the existing `role='actuator'` (migration 0048 CHECK), there is no `controllable` column, and the role-based control gating from #187/#428 stays. Closes #434 Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit d270362)
…ity (#453) Make each per-camera Home Assistant link row editable on both the admin console and the desktop link dialog, instead of the role being fixed by which "+Add" button was pressed and device_class/label being read-only. Each row now has: - a role selector (Motion / Sensor / Control) editable after add, where Control is the existing actuator role, spelled out as an operable entity so an operator can mark a link controllable-or-not visibly; - an editable device_class text field pre-filled with the HA-reported value; - an inline editable display label. All three persist through the existing replace-the-set PUT /cameras/:id/ha/links (its payload already carries role, device_class, and label). The backend now validates role against the entity's domain and returns a 400 for a bad pairing; both surfaces show that reason inline and as a toast/snackbar rather than failing silently. Closes #434, #435 Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit 7433ff6)
#454) Numeric HA sensor entities (temperature/humidity/power/...) are now linkable (#448), but the badge caption and entity detail showed a bare value ("72") with no unit. The /ha/states entity objects now carry an optional `unit` (from HA attributes.unit_of_measurement); render it as "<state> <unit>" ("72 °F", "48 %") when present, unchanged when null. Parsed defensively with a null default in all three clients so a payload from an un-updated server still parses/decodes: - Desktop: HaEntityState.unit (as String?); haStateDisplay() helper in ha_icons.dart, used by the badge caption and state card. - Android: HaEntityState.unit (@SerialName, default null); haStateDisplay() in HaVisual.kt, used by the badge caption, entity tile, and more-info dialog (unit threaded through HaMoreInfoDialog callers). - iOS: HaEntityState.unit (decodeIfPresent via synthesized decoder, key added to CodingKeys); stateTextWithUnit() folds the unit into HAVisual.stateText, read by all three display surfaces. A unit is only appended to a real measurement (edgeOn == nil, not an indeterminate placeholder); on/off/open/closed labels are never suffixed. Closes #449 (with the backend `unit` field sibling change). Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit dd10f57)
…all clients (#455) Define the single source of truth for on-video HA badge icon slugs server-side (CANONICAL_ICON_SLUGS, 67 slugs, in services/api/src/ha.rs) and enforce it: the placement handler now rejects an overlay_icon that is shape-valid but not a member of the closed vocabulary, in addition to the existing [a-z0-9_] shape check. This is the contract the future console icon picker (#439) will share. Map EVERY canonical slug to a native glyph on all three clients so an icon an operator picks (blinds, valve, ev, pool, ...) renders the same everywhere instead of silently degrading to a generic glyph on a client that never knew the slug: - desktop kHaBadgeIconChoices (ha_icons.dart): + cover, outlet, temperature, gas, script, button - Android badgeIconSlugs (HaVisual.kt): expanded from ~20 to the full 67, with the matching material-icons-extended imports - iOS iconSlugToSymbol (HomeAssistant.swift): expanded to the full 67, every symbol a real SF Symbol available on iOS 16 / macOS 13 Unify the device-class default derivation (used by BOTH the badge and the entity sheet, one map per client) and restore the richness #437 flattened: a lock, smoke, gas/CO, or leak/moisture problem sensor now derives its own glyph and an active/alert color (attention red for smoke/gas) consistently on desktop, iOS, and Android. The client device-class mapping is a documented superset of the backend's label_for_device_class; the shared classes stay aligned with it. Closes #438 Signed-off-by: badbread <badbread@users.noreply.github.com> Co-authored-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit d8af541)
…, enforced + honored on all clients Tier 2 of the HA management overhaul (epic #445): add two per-link control knobs to camera_ha_links and enforce/honor them end to end, so an operator can require a confirm on any device and restrict which actions a link may fire. Schema (migration 0075, registered in the MIGRATIONS array): - require_confirm boolean NOT NULL DEFAULT false - allowed_actions text[] (NULL = all of the domain's actions) Both additive and idempotent; defaults reproduce today's behavior byte for byte. Backend: - HaLinkInput accepts optional require_confirm (default false) + allowed_actions (default null); put_links validates each allowed_actions entry against the entity domain's allowlist and persists both. - HaLinkDto exposes both (additive, so old clients ignore them). - post_action ENFORCES allowed_actions: after the domain-allowlist check, a non-null list that omits the requested action is refused with 403 before HA is contacted. require_confirm is a client UX gate, exposed but not enforced server-side. - Both columns loaded in every link read path. Clients honor the fields (desktop / Android / iOS), all parsing defensively so an older server without them behaves exactly as today: - Confirm when require_confirm OR the domain is cover/lock (existing safety cases stay). - When allowed_actions is non-null, present ONLY those actions (intersected with the domain's action set); null = the full domain set as before. The console editor to set these is deferred to #439; this change only exposes and enforces them. Closes #440 Signed-off-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit feb10eb)
Pure formatting; rustfmt collapses a two-line assert! onto one line. No behavior change. Signed-off-by: badbread <badbread@users.noreply.github.com> (cherry picked from commit eab8c35)
On main the next free migration numbers after 0071 are 0072 (actuators) and 0073, so the control-config migration lands as 0073 rather than the branch's original 0075 (whose 0072/0073/0074 siblings never reached main). Keeps the migration sequence contiguous. Comment references updated. Signed-off-by: badbread <badbread@users.noreply.github.com>
The server allowlist accepts automation.trigger and the widened picker (derived from the allowlist) makes automation entities linkable, but the client action tables stopped at scene/script, so an automation badge rendered read-only. Add the automation domain to each client's action mapping so a single tap fires automation.trigger (immediate, no confirm, like scene/script): - desktop: ha_actions.dart (haActionsForDomain + haPrimaryAction) - android: HaModels.kt (haPrimaryAction + haFullActions) - ios: HomeAssistant.swift (actions(for:) + primaryAction(for:)) Signed-off-by: badbread <badbread@users.noreply.github.com>
…te copy Badge control (incl. automation.trigger) now ships, so the docs-site HA page no longer claims controls are read-only or unactuable. Updates the intro, the Controls picker description (all actuator domains + numeric sensors), the connect note (reads state AND calls services), and the tap behavior (act directly, or a confirm card for cover/lock). Signed-off-by: badbread <badbread@users.noreply.github.com>
Owner
Author
|
Closing: this was built on a stale local checkout (fcccec0, ~40 commits behind origin/main). origin/main (v0.2.0) already contains the full HA control stack, and a MORE advanced version (value controls / HaActionSpec, HA hub, pill layout, alert templates), so this branch is redundant and would regress main. The one genuinely missing piece, automation.trigger, will be added directly on top of current main in a focused PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Reintegrates the entire Home Assistant control stack onto current
main, and adds triggering HA automations from the video, the feature this branch was opened for.Why
HA control (tap a badge to actuate) was built weeks ago across a fleet of
feat/ha-control-*branches, gated, and left unmerged.mainonly ever had HA display (badges + live state), so v0.2.0 shipped display-only even though the clients advertise control. This lands the real thing, cleanly, on top of everything in v0.2.0.What's included
Backend
HaClient::call_service→POST /api/services/<domain>/<service>; strict per-domain action allowlist (no raw service/entity passthrough), plusautomation→trigger.POST /cameras/:id/ha/actiongated by a new default-offactuatorscapability (admin implies); client sends alink_id, never a raw entity id.require_confirm+allowed_actions).role_actuators_capability) and 0073 (ha_link_control_config), both registered in theMIGRATIONSarray. (Renumbered from the branch's 0074/0075 to the next free slots onmain; sequence is contiguous.)Clients (desktop / Android / iOS)
automation.trigger(immediate, no confirm, like scene/script). Without this the badge rendered read-only, so this is what makes the feature actually usable.Docs
actuatorspermission.Verification (all green)
-D warnings+cargo test --workspacecompileDebugKotlin+testDebugUnitTestBUILD SUCCESSFULflutter build windows --debugcrumb_desktop.exexcodebuild(XcodeGen, no signing)BUILD SUCCEEDEDThe reintegration cherry-picked cleanly (the control work is purely additive to the HA display code already on
main); the only hand-work was resolving 8 days of drift indb.rs/auth_rbac.rs/DECISIONS.md, renumbering the two migrations, and adding theautomationdomain to the allowlist + the three client action tables.Reviewer notes
actuatorscapability is deny-by-default; existing roles gain the explicit key via migration 0072 with no effective permission change.