Skip to content

feat(ha): reintegrate HA control stack onto main + trigger automations from the video - #609

Closed
badbread wants to merge 18 commits into
mainfrom
feat/ha-control-integrate
Closed

feat(ha): reintegrate HA control stack onto main + trigger automations from the video#609
badbread wants to merge 18 commits into
mainfrom
feat/ha-control-integrate

Conversation

@badbread

Copy link
Copy Markdown
Owner

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. main only 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_servicePOST /api/services/<domain>/<service>; strict per-domain action allowlist (no raw service/entity passthrough), plus automationtrigger.
  • POST /cameras/:id/ha/action gated by a new default-off actuators capability (admin implies); client sends a link_id, never a raw entity id.
  • Picker widened to all controllable domains + numeric sensors; role/domain validation; editable role/label per link; canonical icon vocabulary; per-link control config (require_confirm + allowed_actions).
  • Migrations 0072 (role_actuators_capability) and 0073 (ha_link_control_config), both registered in the MIGRATIONS array. (Renumbered from the branch's 0074/0075 to the next free slots on main; sequence is contiguous.)

Clients (desktop / Android / iOS)

  • The on-video control UIs (single-tap actuate for simple domains; confirm card for cover/lock).
  • Automation triggering added to every client action table — a tap on an automation badge fires 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

  • docs-site HA page corrected: control is shipped, automations included, widened domains, actuators permission.

Verification (all green)

Layer Where Result
Rust: fmt + clippy -D warnings + cargo test --workspace dev2 (+ throwaway PG) ✅ pass
Android: compileDebugKotlin + testDebugUnitTest dev2 BUILD SUCCESSFUL
Desktop Flutter: flutter build windows --debug winbuild ✅ built crumb_desktop.exe
macOS/iOS Swift: xcodebuild (XcodeGen, no signing) macmini BUILD SUCCEEDED

The 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 in db.rs/auth_rbac.rs/DECISIONS.md, renumbering the two migrations, and adding the automation domain to the allowlist + the three client action tables.

Reviewer notes

badbread and others added 18 commits August 9, 2026 16:27
….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>
@badbread

Copy link
Copy Markdown
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.

@badbread badbread closed this Aug 10, 2026
@badbread
badbread deleted the feat/ha-control-integrate branch August 10, 2026 00:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant