From 20d2285a3542cbbae5570258f93bd23fc1595631 Mon Sep 17 00:00:00 2001 From: badbread Date: Sat, 8 Aug 2026 10:30:35 -0700 Subject: [PATCH] feat(admin): mode-aware HA entity entry rework in the camera editor Rework the per-camera Home Assistant link editor so adding an entity is obvious and the role copy is honest regardless of record mode. Presentation and copy only: roles (motion|sensor|actuator), device_class, and the whole-list PUT /cameras/:id/ha/links contract are unchanged. - Mode-aware role copy: the motion role no longer says "triggers recording" on a Continuous (24/7) camera, where a linked binary sensor feeds the additive motion source (timeline + notifications) rather than starting a recording. Derived from the camera's effective policy mode. - One primary "+ Add Home Assistant entity" action replaces the three ghost buttons; the picker gains a Motion/Reading/Control segment toggle with a mode-aware helper line. - Rows are grouped under role headers (Motion & contact / Sensor readings / Controls), keeping each link's real HA_LINKS index so all handlers stay wired. - The device_class box moves into the Icon & style panel (it only sets binary_sensor glyphs); still saved via Save links. - Save links gains an "Unsaved changes" indicator, set on any whole-list mutation and cleared on a successful save. Updates the COMPONENT-MAP HA row and adds a DECISIONS entry for the mode-aware labeling so it is not "unified" back to a static label later. Signed-off-by: badbread --- docs/COMPONENT-MAP.md | 2 +- docs/DECISIONS.md | 38 +++++++++ services/api/src/admin.html | 151 +++++++++++++++++++++++++++++------- 3 files changed, 163 insertions(+), 28 deletions(-) diff --git a/docs/COMPONENT-MAP.md b/docs/COMPONENT-MAP.md index 7548169e..6431c256 100644 --- a/docs/COMPONENT-MAP.md +++ b/docs/COMPONENT-MAP.md @@ -300,7 +300,7 @@ is not. The web admin console doubles as the desktop's management surface | Settings | server settings + users/RBAC | settings + embedded `/admin` | `settings/` | `Settings/` | | Notifications | rules + history CRUD | toasts | poll + local notifications | `Settings/` | | Update notice (issue #7) | Server settings toggle + status/"Check now" (the console's own update IS the server update) | Shipped: update banner + check controller + About panel (`apps/desktop-flutter/lib/ui/updates/`, `docs/UPDATE-SYSTEM-PLAN.md` §7 C2) | Shipped: `feature/update/` (`UpdateBanner.kt`, `UpdateViewModel.kt`, `SemVer.kt`) (§7 C3) | Models only (`Models/UpdateModels.swift`), no banner yet; iOS lowest priority per D5 (§7 C4) | -| Home Assistant overlay (`docs/DECISIONS.md` 2026-07-10; backend: `services/api/src/ha.rs` + `services/common/src/ha.rs`, `ha_config`/`camera_ha_links` migrations, `GET/PUT /config/ha` admin + `POST /config/ha/test` + `GET /ha/entities`/`/ha/states` + `GET/PUT /cameras/:id/ha/links` + `PUT /cameras/:id/ha/links/:link_id/placement`; token write-only; env fallback `HA_BASE_URL`/`HA_TOKEN`/`HA_TOKEN_FILE`) — **whole-object REPLACE, NOT a merge** (`docs/DECISIONS.md` 2026-08-07, issue #552): a body-level `null` clears the placement, an omitted style key means exactly what an explicit `null` means (that override is unset), and `x`/`y` are required so a style-only body 422s. Deliberately the opposite of `PUT /config/server`'s merge convention (#472/#533), because these columns have no `""` state to clear with; every writer must send the badge's CURRENT values forward, not just a delta. `label` is the one exception, riding the `PUT /config/ha` token convention (omitted ⇒ unchanged, `""` ⇒ clear). Pinned by `services/api/tests/ha_placement_replace_semantics.rs`| **Settings → Home Assistant** (dedicated hub, issue #441 — was split across Detection & clips + the camera editor, now one coherent area): connect (base URL + long-lived token, relocated verbatim from Detection & clips); global **links overview** across every camera (camera, entity_id, role, device_class, label, live state), aggregated client-side from the per-camera links endpoint (no new bulk endpoint), with **orphaned-link** detection (a link's `entity_id` absent from `GET /ha/states` ⇒ no longer exists in HA) and a filter/orphans-only toggle; per-camera link editor stays in the camera's Motion tab, reachable from either page: add/remove links, edit role/device_class/label (#453); per-link **icon & style** authoring (icon from the closed `CANONICAL_ICON_SLUGS` vocabulary, shape, color, bg_color, size, opacity via `PUT .../placement`, issue #439) with a static badge-mockup preview (no drag-to-position here, an unplaced badge saves centered until repositioned in a live view) | Entity **badges** on live video (`apps/desktop-flutter/lib/ui/ha_overlay/`: palette, badge-style editor, overlay layer/controller); OFF until configured | Read-only entity **badges** on live video + per-camera entity sheet (`feature/live/HaBadgeOverlay.kt`, `HaEntitiesSheet.kt`); polls `/ha/states`. Global **hide-overlays** quick-toggle (eye button in the fullscreen controls; app-wide `feature/live/HaOverlayVisibility.kt` over `SecureStore.showHaOverlays`, persisted): display-only, suppresses every on-video badge (and pauses the states poll when nothing else needs it) while the entities sheet and its popups stay reachable. **Deferred:** desktop/iOS equivalents | Read-only entity **badges** on live video (letterbox-positioned) + per-camera entity sheet + tap detail card (`apps/ios/Crumb/Features/HomeAssistant/`); polls `/ha/states` (3s), state-honesty grey on unknown/stale; no admin editor (link/place via console/desktop) | +| Home Assistant overlay (`docs/DECISIONS.md` 2026-07-10; backend: `services/api/src/ha.rs` + `services/common/src/ha.rs`, `ha_config`/`camera_ha_links` migrations, `GET/PUT /config/ha` admin + `POST /config/ha/test` + `GET /ha/entities`/`/ha/states` + `GET/PUT /cameras/:id/ha/links` + `PUT /cameras/:id/ha/links/:link_id/placement`; token write-only; env fallback `HA_BASE_URL`/`HA_TOKEN`/`HA_TOKEN_FILE`) — **whole-object REPLACE, NOT a merge** (`docs/DECISIONS.md` 2026-08-07, issue #552): a body-level `null` clears the placement, an omitted style key means exactly what an explicit `null` means (that override is unset), and `x`/`y` are required so a style-only body 422s. Deliberately the opposite of `PUT /config/server`'s merge convention (#472/#533), because these columns have no `""` state to clear with; every writer must send the badge's CURRENT values forward, not just a delta. `label` is the one exception, riding the `PUT /config/ha` token convention (omitted ⇒ unchanged, `""` ⇒ clear). Pinned by `services/api/tests/ha_placement_replace_semantics.rs`| **Settings → Home Assistant** (dedicated hub, issue #441 — was split across Detection & clips + the camera editor, now one coherent area): connect (base URL + long-lived token, relocated verbatim from Detection & clips); global **links overview** across every camera (camera, entity_id, role, device_class, label, live state), aggregated client-side from the per-camera links endpoint (no new bulk endpoint), with **orphaned-link** detection (a link's `entity_id` absent from `GET /ha/states` ⇒ no longer exists in HA) and a filter/orphans-only toggle; per-camera link editor stays in the camera's Motion tab, reachable from either page: add/remove links, edit role/device_class/label (#453). Rows are grouped by role and added via one **+ Add Home Assistant entity** action whose picker carries a Motion/Reading/Control segment toggle; role labels are **mode-aware** (a Continuous-mode camera never shows "triggers recording" copy — only a Motion-mode camera does, though the motion role feeds the additive motion source either way); the `device_class` field lives inside the **icon & style** panel (it only sets `binary_sensor` glyphs) but still saves via the whole-list `PUT .../ha/links`, which shows an **Unsaved changes** indicator until saved; per-link **icon & style** authoring (icon from the closed `CANONICAL_ICON_SLUGS` vocabulary, shape, color, bg_color, size, opacity via `PUT .../placement`, issue #439) with a static badge-mockup preview (no drag-to-position here, an unplaced badge saves centered until repositioned in a live view) | Entity **badges** on live video (`apps/desktop-flutter/lib/ui/ha_overlay/`: palette, badge-style editor, overlay layer/controller); OFF until configured | Read-only entity **badges** on live video + per-camera entity sheet (`feature/live/HaBadgeOverlay.kt`, `HaEntitiesSheet.kt`); polls `/ha/states`. Global **hide-overlays** quick-toggle (eye button in the fullscreen controls; app-wide `feature/live/HaOverlayVisibility.kt` over `SecureStore.showHaOverlays`, persisted): display-only, suppresses every on-video badge (and pauses the states poll when nothing else needs it) while the entities sheet and its popups stay reachable. **Deferred:** desktop/iOS equivalents | Read-only entity **badges** on live video (letterbox-positioned) + per-camera entity sheet + tap detail card (`apps/ios/Crumb/Features/HomeAssistant/`); polls `/ha/states` (3s), state-honesty grey on unknown/stale; no admin editor (link/place via console/desktop) | | Per-state HA badge background (badge-customization rework **wave A, server contract only**; `docs/DECISIONS.md` 2026-08-06 per-state-background entry; backend: `overlay_bg_color_on` column migration `0076` with a `'#RRGGBB'` CHECK, `CameraHaLink.overlay_bg_color_on` in `services/common/src/types.rs`, `HaOverlayPlacement.bg_color_on` + every SELECT/RETURNING/carry-over path in `services/common/src/db.rs`, `HaLinkDto.overlay_bg_color_on` + `PlacementInput.bg_color_on` validated by `valid_overlay_color` in `services/api/src/ha.rs`. `overlay_bg_color` is the BASE (off + indeterminate/stale), `overlay_bg_color_on` overrides only while `edge_on`; resolution in every renderer is on ⇒ `bg_color_on ?? bg_color ?? #17171B`, otherwise ⇒ `bg_color ?? default`. Null everywhere ⇒ today's rendering, unchanged) | Wave B shipped (#490): second colour well + "Auto (follows Off)" clear in the HA link icon & style panel (`admin.html`) | Wave B shipped (#491): `apps/desktop-flutter/lib/ui/ha_overlay/ha_badge_popover.dart`, `ha_api.dart`, `ha_models.dart` | Wave B shipped (#489): `feature/live/HaVisual.kt` / `HaModels.kt`, tests in `HaVisualActiveTest.kt` | Wave B shipped (#492): `Features/HomeAssistant/HomeAssistant.swift` + `Models.swift`, tests in `HaBadgeVisualTests.swift` | | HA badge PILL LAYOUT (issue #497; `docs/DECISIONS.md` 2026-08-06 pill-layout entry; backend: `overlay_pill_width` + `overlay_text_align` columns migration `0078` with value CHECKs, `CameraHaLink.*` in `services/common/src/types.rs`, `HaOverlayPlacement.pill_width`/`.text_align` + every SELECT/RETURNING/carry-over path in `services/common/src/db.rs`, `HaLinkDto.overlay_pill_width`/`overlay_text_align` + `PlacementInput.pill_width`/`text_align` validated against `HA_PILL_WIDTH_MODES`/`HA_TEXT_ALIGNS` in `services/api/src/ha.rs`. **The vocabulary is the contract and must be byte-identical in all five places**: `auto`/`narrow`/`medium`/`wide` and `start`/`center`/`end`. Width resolution in every renderer is `auto`/null/unrecognized ⇒ measured content width, else `4x`/`6x`/`8x` the badge HEIGHT (exact, not a minimum — an over-long label ellipsizes); align resolution is `start`/null/unrecognized ⇒ leading, `center`, `end`. Pill only; a dot ignores both. Null everywhere ⇒ today's rendering, unchanged) | Pill width + Text align selects in the HA link style panel (`admin.html` `haStylePanel`, `HA_PILL_WIDTH_FACTORS`/`HA_TEXT_ALIGN_JUSTIFY`, mock preview honors both) | Width + Align rows in the badge popover, pill-only (`ha_badge_popover.dart`); `haPillWidthFactor`/`haPillAlignment` + `HaOverlayBadgeItem.pillBaseWidth(widthMode:)` in `ha_overlay_layer.dart`/`ha_overlay_controller.dart`; `pill_width`/`text_align` on the placement PUT (`ha_api.dart`) | `HaBadgeMetrics.pillWidthFactor`/`pillWidth(..., widthMode)` + Row `horizontalArrangement` in `feature/live/HaBadgeOverlay.kt`, `HaLinkDto` fields in `data/HaModels.kt` | `HA.pillWidthFactor`/`HA.pillAlignment` + the pill `.frame(width:height:alignment:)` in `Features/HomeAssistant/HomeAssistant.swift`, `HaLink` fields in `Models/Models.swift` | | Home Assistant control (Phase 2, #187 + interaction refinement #428 + value controls #442 Slice 1, `docs/DECISIONS.md` 2026-08-01 (two entries); backend: `POST /cameras/:id/ha/action` `{link_id, action, value?}` in `services/api/src/ha.rs`, strict per-domain action allowlist as a structured spec table `HaActionSpec {action, service, value: Option}` (`light`/`switch`/`fan`/`siren` on/off/toggle, `cover` open/stop/close, `lock` lock/unlock, `button` press, `scene`/`script` turn_on, plus percent-kind **value actions** `light.set_brightness`→turn_on, `cover.set_position`→set_cover_position, `fan.set_speed`→set_percentage; word≠service now) built server-side from the stored entity, `HaClient::call_service`/`call_service_with`; value validated 0..100 server-side (`validate_value`) before HA, rounded to a JSON int; client capability discovery = optional `control` descriptor on `GET /ha/states` entries (action/kind/value/min/max/step/unit, projected from cached attributes, no HA round-trip); per-link `require_confirm`/`allowed_actions` control config (migration `0075`, issue #440, `null`⇒all actions incl. value words) enforced in `post_action`; new role capability `actuators` (default-off, admin implies, migration `0074`) surfaced at `/auth/me` `capabilities.actuators`; `ha_actuation` audit row per attempt (incl. parsed value); climate `set_temperature` deferred to Slice 2; validated live end-to-end on real HA) | roles editor **Control linked devices** capability checkbox (`admin.html`); per-**actuator**-link **Controls** panel in the HA link editor authors `require_confirm` + an `allowed_actions` restriction (checkboxes seeded from the domain's action set, incl. the value words "Set brightness"/"Set position"/"Set speed"; no per-link value clamp in v1), saved via `PUT .../ha/links` (issue #439); no live wall so no actuation surface | Single-**click** actuates a badge directly (simple domains), card + confirm for `cover`/`lock`, hover shows state; **value slider** (brightness/position/speed) shipped for dimmable lights/covers/fans (#461, card icon/rows #466) (`ui/ha_overlay/ha_actions.dart`, `ha_overlay_layer.dart`) | Single-**tap** actuates directly, long-press for detail, `cover`/`lock` confirm dialog; **value slider** (brightness/position/speed) shipped (#463, #468) (`feature/live/HaBadgeOverlay.kt`, `HaEntitiesSheet.kt`) | Single-**tap** actuates directly, long-press for detail, `cover`/`lock` confirm; **value slider** (brightness/position/speed) shipped (#462, badge-color match #467) (`Features/HomeAssistant/HomeAssistant.swift`) | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 970bdfdf..030d7442 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8,6 +8,44 @@ revisit. --- +## 2026-08-08, Home Assistant entity role labels in the camera editor are mode-aware (copy only) + +**Context.** The per-camera HA link editor (`admin.html`, `renderHaLinks` &c.) +labeled the `motion` role "Motion (triggers recording)". That is only true on a +camera whose recording policy is Motion-triggered. On a Continuous (24/7) camera +a linked motion binary sensor never starts a recording — it feeds the additive +multi-source motion stream (timeline events + notifications) only. The static +label read as a contradiction on a 24/7 camera and confused the maintainer about +how to add an HA entity at all. + +**Decision.** + +- The `motion` role's label and the picker/helper copy are derived from the open + camera's effective policy mode (`profileById(cam.policy_id) || cam.policy`, + the same source the storage tab and banner use), surfaced as + `HA_CAM_MOTION_MODE`. Motion-mode ⇒ "Motion sensor (triggers recording + marks + the timeline)"; Continuous ⇒ "Motion sensor (marks the timeline, can notify)". + The `sensor`/`actuator` labels are mode-independent. +- This is **presentation only**. The stored roles remain `motion|sensor|actuator`, + `device_class` and the whole-list `PUT /cameras/:id/ha/links` contract are + unchanged; no migration, no schema, no server change. The same rework moved the + `device_class` box into the icon & style panel, replaced the three add buttons + with one "+ Add Home Assistant entity" + a role segment toggle, grouped rows by + role, and added a Save-links "Unsaved changes" indicator. + +**Rejected.** A single static label for all cameras (the status quo) — rejected +because "triggers recording" is factually wrong on a Continuous camera and was +the reported source of confusion. Encoding the behavior difference in the schema +(e.g. a per-link "notify" flag) — rejected as out of scope; it is genuinely new +data, flagged in the PR for a future decision, not built here. + +**Revisit if.** The motion role's server-side behavior stops depending on the +camera's record mode (e.g. motion links always trigger recording, or never do), +or a per-link notify/behavior toggle is added — at which point the copy should +follow the new behavior and this entry is superseded. + +--- + ## 2026-08-07, Timeline motion-intensity is bucketed in SQL (GROUP BY over a `generate_series`-expanded range), not by fetching every segment to Rust **Context.** The desktop Playback/clip timeline "intensity ribbon" took roughly diff --git a/services/api/src/admin.html b/services/api/src/admin.html index 70fe6011..12d964fa 100644 --- a/services/api/src/admin.html +++ b/services/api/src/admin.html @@ -5205,6 +5205,8 @@ let HA_STYLE_OPEN = null; // index of the HA_LINKS row whose icon/style editor is expanded let HA_CTRL_OPEN = null; // index of the HA_LINKS row whose control-config editor is expanded let HA_STYLE_PREVIEW_ON = {}; // HA_LINKS index -> bool: which state (on/off) the style mockup is currently showing +let HA_CAM_MOTION_MODE = false; // true when the open camera records on a Motion-triggered policy; drives mode-aware role copy +let HA_DIRTY = false; // true when the working set has whole-list edits not yet persisted by Save links // device_classes worth linking to a camera; the rest hide under "Other sensors". const HA_SENSOR_CLASSES = ['motion','occupancy','presence','moving','door','window','opening','garage_door']; @@ -5600,6 +5602,13 @@ HA_STYLE_OPEN = null; HA_CTRL_OPEN = null; HA_STYLE_PREVIEW_ON = {}; + HA_DIRTY = false; + // Derive whether this camera records on a Motion-triggered policy so the role + // copy can drop "triggers recording" on a 24/7 (Continuous) camera. Same + // effective-policy source the storage tab / banner use (profileById || cam.policy). + const _cam = (typeof CAMS !== 'undefined' && CAMS) ? CAMS.find(x => x.id === camId) : null; + const _pol = _cam ? (profileById(_cam.policy_id) || _cam.policy) : null; + HA_CAM_MOTION_MODE = !!(_pol && _pol.mode === 'motion'); const configured = !!(cfg && cfg.enabled && cfg.base_url && cfg.has_token); renderHaLinks(configured); } catch (e) { @@ -5609,15 +5618,32 @@ /* The three link roles an operator can pick per row. "Control" is the server's `actuator` role (an entity you can operate); the parenthetical spells that out - so it reads as controllable, not just another sensor. */ -const HA_ROLE_OPTS = [ - ['motion', 'Motion (triggers recording)'], - ['sensor', 'Sensor (status only)'], - ['actuator', 'Control (operate)'], -]; + so it reads as controllable, not just another sensor. The motion role's copy is + mode-aware: on a Motion-triggered policy a linked binary sensor really does + trigger recording, but on a 24/7 (Continuous) camera it only feeds the timeline + and notifications, so the "triggers recording" wording is dropped there. */ +function haRoleOpts(isMotionMode) { + return [ + ['motion', isMotionMode + ? 'Motion sensor (triggers recording + marks the timeline)' + : 'Motion sensor (marks the timeline, can notify)'], + ['sensor', 'Sensor reading (shows on the video, status only)'], + ['actuator', 'Control (operate from the video: lights, locks, covers)'], + ]; +} + +/* One-line description of a role, matching the select copy, for the picker and + the empty-state helper. Mode-aware for the motion role, same as haRoleOpts. */ +function haRoleHelp(role, isMotionMode) { + if (role === 'motion') return isMotionMode + ? 'Motion or contact binary sensors. Linking one adds a motion source that triggers recording and marks the timeline.' + : 'Motion or contact binary sensors. Linking one marks the timeline and can send notifications.'; + if (role === 'sensor') return 'Numeric readings like temperature, power or humidity. Shown on the video, status only.'; + return 'Anything you operate from the video: lights, switches, covers, locks, fans, sirens.'; +} function haRoleSelect(i, role) { - const opts = HA_ROLE_OPTS.map(([v, t]) => + const opts = haRoleOpts(HA_CAM_MOTION_MODE).map(([v, t]) => ``).join(''); return ``; } @@ -5629,7 +5655,13 @@ ''; return; } - const rows = HA_LINKS.map((l, i) => { + // One row for a single link, keyed by its REAL index into HA_LINKS so every + // handler (haSetRole/haSetLabel/haToggleStyle/removeHaLink/…) stays wired to the + // right entry even though rows are rendered grouped by role. The device_class + // box has moved into the Icon & style expander (it only sets binary_sensor + // glyphs), so the main row is: role | label | entity id | Icon & style | + // Controls (actuators only) | Remove. + const rowHtml = (l, i) => { const canStyle = l.id != null; const isActuator = l.role === 'actuator'; return ` @@ -5637,28 +5669,43 @@
${haRoleSelect(i, l.role)} - ${esc(l.entity_id)} - + ${isActuator ? `` : ''}
${HA_STYLE_OPEN === i ? haStylePanel(i, l) : ''} ${HA_CTRL_OPEN === i ? haCtrlPanel(i, l) : ''} `; - }).join('') || '
No links yet.
'; + }; + // Group the rows under role headers in a stable order. Iterate HA_LINKS once so + // each row carries its real index; do NOT sort HA_LINKS in place (that would + // invalidate HA_STYLE_OPEN/HA_CTRL_OPEN indices while a panel is open). + const GROUPS = [ + ['motion', 'Motion & contact sensors'], + ['sensor', 'Sensor readings'], + ['actuator', 'Controls'], + ]; + let rows; + if (!HA_LINKS.length) { + rows = '
Nothing linked yet. Add a motion sensor, a reading, or a control from Home Assistant.
'; + } else { + rows = GROUPS.map(([role, title]) => { + const inRole = HA_LINKS.map((l, i) => [l, i]).filter(([l]) => l.role === role); + if (!inRole.length) return ''; + return `
${title}
${inRole.map(([l, i]) => rowHtml(l, i)).join('')}
`; + }).join(''); + } + const dirtyBadge = HA_DIRTY ? ' Unsaved changes' : ''; + // Datalist for the per-link class box, which now lives in each row's Icon & style expander. const devClassOpts = ['door', 'garage_door', 'window', 'opening', 'motion', 'occupancy', 'presence', 'vibration', 'moisture', 'smoke', 'gas', 'carbon_monoxide', 'lock'] .map(c => ``).join(''); el.innerHTML = `
${rows}
${devClassOpts} -
The class box on each row is optional and only affects binary_sensor badge glyphs; it is auto-filled from Home Assistant. Leave it blank for lights, switches and covers.
-
Where does an entity go? Motion sensor = motion or contact binary sensors. Sensor reading = numeric readings (temperature, power, humidity). Control = anything you operate (lights, switches, covers, locks, fans, sirens).
-
- - - - +
+ +
`; @@ -5739,6 +5786,9 @@
+ +
+
Class only affects binary_sensor badge glyphs and saves with Save links. Leave blank for lights, switches and covers.
@@ -6003,6 +6097,7 @@ overlay_show_state: false, overlay_show_age: false, overlay_opacity: null, overlay_shape: null, overlay_bg_color: null, overlay_bg_color_on: null, overlay_pill_width: null, overlay_text_align: null, overlay_outline: false, }); + HA_DIRTY = true; renderHaLinks(true); } @@ -6011,6 +6106,7 @@ HA_STYLE_OPEN = null; HA_CTRL_OPEN = null; HA_STYLE_PREVIEW_ON = {}; + HA_DIRTY = true; renderHaLinks(true); } @@ -6031,6 +6127,7 @@ HA_STYLE_OPEN = null; HA_CTRL_OPEN = null; HA_STYLE_PREVIEW_ON = {}; + HA_DIRTY = false; toast('Home Assistant links saved.'); renderHaLinks(true); } catch (e) {