diff --git a/README.md b/README.md index aee614d..53208ca 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ chores/ Presence/calendar-driven household chore nudging + - [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes — built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md` - [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as ``" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline** — `TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md` - [ ] `identity` also corroborates presence from Frigate face recognition (Phase 20, Tapo pan/tilt cameras) — an OR-ed-in second signal only, **never** a registration signal; and owns the per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, plus chore assignment) consumed by `chores/`, see `identity/README.md` -- [ ] `identity`'s admin panel (`identity/frontend/admin.html`, Phase 6b) — managing people/guests: edit every field, **nicknames** (an input alias only — `/resolve` accepts them, but the assistant always speaks the real `speak_name`), **visit history** sampled from `/presence` plus a derived "who was home with whom" view, **"select all that last visited before ``"** pruning (the filter selects, a human confirms the exact list, the filter is never re-run at delete time), **per-device rights** for self-entry via a smart lock (`identity` only ever *answers* `GET /device-access` — HA asks and HA acts, deny is the default), chore assignment, and **opt-in arrival push notifications** ("tell me when someone gets home", via the self-hosted ntfy this stack already runs — `identity` itself never touches the WAN; ntfy stays LAN-only and remote delivery rides a WireGuard split tunnel — see `docs/network-integration.md` §2.2 for why a DMZ/port-forward was weighed and rejected). Deliberately **not** a kiosk page and not linked from any wall panel. Covered by API-level tests; **never opened in a real browser**, and `DEPARTURE_GRACE_SECONDS` is an untuned guess — see `identity/README.md` +- [ ] `identity`'s admin panel (`identity/frontend/admin.html`, Phase 6b) — managing people/guests: edit every field, **nicknames** (an input alias only — `/resolve` accepts them, but the assistant always speaks the real `speak_name`), **visit history** sampled from `/presence` plus a derived "who was home with whom" view, **"select all that last visited before ``"** pruning (the filter selects, a human confirms the exact list, the filter is never re-run at delete time), **per-device rights** for self-entry via a smart lock (`identity` only ever *answers* `GET /device-access` — HA asks and HA acts, deny is the default), chore assignment, a **floorplan editor** (draw levels and rooms as polygons, map each to an HA area, and watch occupied rooms light up — resolves the long-deferred open decision #22), and **opt-in arrival push notifications** ("tell me when someone gets home", via the self-hosted ntfy this stack already runs — `identity` itself never touches the WAN; ntfy stays LAN-only and remote delivery rides a WireGuard split tunnel — see `docs/network-integration.md` §2.2 for why a DMZ/port-forward was weighed and rejected). Deliberately **not** a kiosk page and not linked from any wall panel. Covered by API-level tests; **never opened in a real browser**, and `DEPARTURE_GRACE_SECONDS` is an untuned guess — see `identity/README.md` - [ ] `trash-calendar` + `transit` (Phase 19, Kennelbach AT trash pickup + Vorarlberg public transit) — built and wired into `setup-container-host.sh` (`ENABLE_TRASH_CALENDAR`/`ENABLE_TRANSIT`/`ENABLE_TRIP_PLANNING`, all off by default), **nothing run against a live ICS feed, a live GTFS feed, or a real OpenTripPlanner instance** — trip planning also needs a manually-built OTP graph this repo does not build for you, see `trash-calendar/README.md` and `transit/README.md`'s "Route planning scope" - [ ] `chores` (Phase 20) — presence/calendar-driven household chore nudging: "I don't care who does it, as long as it gets done" — prefers whoever's been assigned a chore in `identity`'s admin panel but falls through to whoever's actually home rather than waiting (`CHORE_ASSIGNMENT_STRICT` flips that), redirects to someone else if a chore goes neglected, keeps a passive fairness tally that never feeds back into who gets nudged, and camera-checks trash bins/dishes/litter via Frigate + an Ollama vision model. **Litter remains the exception to everything** — it ignores both chore-exemption and assignment, because cleaning up what you left out was never a task anyone could be assigned. Built and wired into `setup-container-host.sh` (`ENABLE_CHORES`, off by default, every-2-hours systemd timer), **no Tapo camera hardware chosen and nothing run against real hardware**, see `chores/README.md` - [ ] Music Assistant (optional, additive multi-room audio) — wired into `setup-container-host.sh` (`ENABLE_MUSIC_ASSISTANT`, off by default), **its default port is an unverified guess that collides with `PANTRY_VISION_PORT`** if both are enabled together, see `docs/project-plan.md` open decision #31 diff --git a/docs/project-plan.md b/docs/project-plan.md index 014bccc..e5860b6 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -879,7 +879,7 @@ These need a decision before their respective implementation steps can be built 19. **`pantry-vision`'s Grocy API integration is written from documentation, not a live instance** (new, Phase 17) — `GET /api/stock`'s response shape, `POST /api/objects/products`'s minimum required fields, and the Recipes/fulfillment endpoints' setup requirements are all assumed; `pantry-vision/README.md` points at each real Grocy instance's own live OpenAPI spec (`/api/openapi/specification`) as the way to check before trusting any of it. `GROCY_DEFAULT_LOCATION_ID`/`GROCY_DEFAULT_QU_ID` are fresh-install-default guesses that need confirming against Settings → Locations/Quantity units on the real instance too. 20. **`identity`'s `TRUSTED_ENTITY_PREFIXES` default is a guess, and it's the single highest-risk unknown in Phase 6** (new) — the whole anti-spoofing design rests on this allowlist actually matching real Private BLE Device / fixed-tag entity IDs; until it's checked against Developer Tools -> States on a real HA instance, registration will most likely just report "no candidate" for everything. Same open dependency as §1.5's original Bermuda/Private BLE Device setup, which itself has never been built (see the top-level README status checklist). 21. **Identity's HA-side voice wiring (custom sentence + intent script + `rest_command`) is written from HA's documented shape, not tested** (new, Phase 6) — `identity/README.md` has the worked example; nothing under this repo builds or verifies it, same convention as admin-canvas's/digest-engine's own HA-side integration points. -22. **The floor-plan UI itself doesn't exist** (new, Phase 6) — `identity`'s `/presence` reports a best-effort `room` per person as groundwork, but there is no floor-plan image, room↔coordinate mapping, or rendering anywhere in this repo, and `AREA_ATTRIBUTE`'s default is an unconfirmed guess at what Bermuda actually publishes. Needs a real floor plan and room list before there's anything to design a coordinate format against — deliberately deferred rather than built against a guess. +22. ~~The floor-plan UI itself doesn't exist~~ — **built** (Phase 6b): `identity`'s admin panel has a Floorplan tab where levels and rooms are **drawn by hand** (polygons, optional background image, normalised 0–1 coordinates so the plan renders at any size), each room mapped to the HA area string `/presence` already reported. Occupied rooms light up live. Nothing is auto-placed — nothing here knows the shape of this flat, and drawing it is what removed the need to invent a coordinate format against a guess. **Still genuinely open**: `AREA_ATTRIBUTE`'s default (`area_id`) is still an unconfirmed guess at what Bermuda publishes, so until that's verified every room will simply never light up — the editor now surfaces this directly by showing which areas HA is actually reporting, and listing people who are home but unplaceable. The editor has also never been opened in a real browser. 23. **No Tapo camera model or count has been chosen, and Frigate's PTZ move-to-preset API shape is assumed** (new, Phase 20) — §1.17 lists a placeholder model/price only; whether a specific Tapo model even exposes RTSP without go2rtc as a bridge is unverified, and `chores/check.py`'s `_frigate_snapshot()`'s `POST /api//ptz/move/` is assumed from Frigate's general PTZ feature set, not a real deployment. This is the single highest-risk unknown in Phase 20, same class of risk as open decision #18's vision-model pick for Phase 17. 24. **Vision-model accuracy for bin-fullness/dishes/litter checks is completely unmeasured** (new, Phase 20) — same caveat as open decision #18, applied to a different prompt; a wrong FULL/DIRTY/YES answer just means a chore opens or stays open incorrectly, never a hard failure, but nobody has checked how often that actually happens. 25. **`chores`' household calendar busy-check is household-wide, not per-person** (new, Phase 20) — `_household_currently_busy()` can't tell that only one person is in a flagged-busy calendar event and nudge someone else who's free; everyone's nudges pause together. A real per-person availability model would need per-person calendars, which this project doesn't have. Documented as a known limitation, not a bug, in `chores/README.md`. diff --git a/identity/README.md b/identity/README.md index 092c2f6..9e2e695 100644 --- a/identity/README.md +++ b/identity/README.md @@ -99,7 +99,42 @@ at least once, `/presence` reports `home: null` ("unknown") for that person — `false`**, since defaulting a device-less person to "away" would be actively wrong the moment they're actually sitting in the next room, not just uninformative. -## Floor-plan groundwork (not the floor plan itself) +## The floor plan + +**Draw it in the admin panel's Floorplan tab.** Add a level, optionally upload a +background (a scan, a screenshot of an architect's PDF, a photo of a sketch), then +click corners to trace each room and drag the handles to adjust. Rooms are polygons, +not rectangles, because real rooms aren't rectangles. + +The piece that makes it *live* is the **Home Assistant area** field on each room. That +string is matched against whatever `/presence` reports as a person's `room` — i.e. +whatever `AREA_ATTRIBUTE` holds on their trusted entity. The editor offers a pick-list +of the areas HA is actually reporting right now (`GET /floorplan/areas`) rather than +asking you to retype an area_id from Developer Tools, for the same anti-typo reason +`tools/CoreSystemConfig.json` exists. Tick **Show who's home** and occupied rooms light +up. + +Three deliberate choices worth knowing: + +- **Coordinates are normalised 0–1, not pixels.** The plan has to render on a laptop + now and possibly a wall panel later, and pixel coordinates would be right on exactly + one of them. The tradeoff: replacing a background image with one of a *different + aspect ratio* distorts existing rooms. Same-ratio replacements are fine. +- **Nothing is ever placed automatically.** No auto-detection of rooms, no inference + from BLE distances. Nothing in this project knows the shape of this flat, and the + alternative to drawing it was inventing a coordinate format against a guess — which + is exactly why this stayed deferred rather than half-built (open decision #22). +- **A person who can't be placed is shown, not dropped.** Home but no room resolved + (the normal case without room-level BLE), or reporting an area no room claims — both + are listed under the plan, and unclaimed areas are named so you know what's left to + draw. A floor plan that quietly loses people would be worse than no floor plan. + +A room with no HA area is legal and labelled "no HA area" on the plan: drawing the flat +and wiring up presence are separate jobs, and you should be able to finish the first +without the second. Two rooms may not claim the same area — both would light up for one +person, which looks like a presence bug rather than a mapping mistake. + +## Floor-plan groundwork (the `/presence` half) `/presence` also reports a best-effort `room` per person (`server.py`'s `AREA_ATTRIBUTE`, default `area_id`) — read from whichever HA area/room attribute @@ -477,6 +512,14 @@ not network placement. | `GET /device-access/events?limit=` | the audit log of every access check, allowed and denied | | `GET`/`POST /people//chore-assignments` | read/replace this person's assigned chore types (`{"chore_types": [...]}`) | | `GET /chore-assignments` | the same facts keyed by chore type — the shape `chores/` reads | +| `GET /floorplan` | every level and its drawn rooms (polygons in normalised 0–1 coordinates) | +| `POST /floorplan/levels` | create or rename a level — `{"id"?, "name", "sort_order"?}` | +| `DELETE /floorplan/levels/` | remove a level and its rooms | +| `GET`/`POST /floorplan/levels//image` | the level's background image (raw bytes) | +| `POST /floorplan/rooms` | create or update a room — `{"id"?, "level_id", "name", "ha_area_id"?, "points", "color"?}` | +| `DELETE /floorplan/rooms/` | remove a room | +| `GET /floorplan/presence` | the plan **plus who is in each room right now**, with `unplaced`/`unmapped_areas` | +| `GET /floorplan/areas` | area values HA is currently reporting — the editor's pick list | | `POST /presence/manual` | `{"person_id", "home"}` — hand-operated Home/Away for anyone with no identifiers | | `POST /people//chore-settings` | `{"chore_exempt"?, "chore_reminder_style"?}` — see below; either field omitted/`null` leaves it unchanged | | `GET /presence` | `{"people": [{"id", "name", "nickname", "speak_name", "home", "room", "has_device", "has_photo", "chore_exempt", "chore_reminder_style", "chore_assignments"}], "generated_at"}` — `home` is `true`/`false`/`null` (unknown), `room` is best-effort floor-plan groundwork (see below) | @@ -526,16 +569,22 @@ no way to send an `Authorization` header. a real BLE presence entity over a day to tune it. The visit log's usefulness rests almost entirely on this number, and it's the first thing to check once there's real data in `GET /visits`. -10. **The admin panel has been exercised against the API, not in a browser** — every +10. **The floor-plan editor's usefulness is gated entirely on `AREA_ATTRIBUTE` being + right** — every room can be drawn and mapped correctly and still never light up, if + the attribute `/presence` reads isn't what Bermuda actually publishes. The editor + makes this diagnosable rather than mysterious (it lists the areas HA is really + reporting, and names anyone home who couldn't be placed), but it can't fix it. This + is the same unconfirmed guess as verification item 7, now with a UI depending on it. +11. **The admin panel has been exercised against the API, not in a browser** — every endpoint it calls is covered by the route tests, but the page itself (the `` editor, the prune checkboxes) has not been opened in a real browser on this machine. ``'s `showModal()` needs a reasonably current browser; the door panel's Chromium is fine, an ancient one wouldn't be. -11. **Nothing enforces that voice/TTS consumers actually read `speak_name`** — the +12. **Nothing enforces that voice/TTS consumers actually read `speak_name`** — the field is there and documented, and `chores/` uses it, but a future HA intent script that reaches for `nickname` instead would be wrong in a way this repo can't catch. Worth a look whenever a new consumer of `/presence` or `/resolve` gets written. -12. **Arrival pushes have never been delivered to a real phone from here** — the +13. **Arrival pushes have never been delivered to a real phone from here** — the notification logic is covered by tests (subscription, opt-out, dedup, the startup-baseline guard), but nothing has been sent through a real ntfy server to a real device. Two things to check: that the ntfy **Android** app's instant delivery @@ -544,7 +593,7 @@ no way to send an `Authorization` header. `NTFY_URL` from outside. The split tunnel is the more likely of the two to need fiddling — get `AllowedIPs` wrong and it fails either silently or by breaking the phone's connectivity on café Wi-Fi. -13. **The device-rights HA wiring above is a worked example, not a tested one** — same +14. **The device-rights HA wiring above is a worked example, not a tested one** — same caveat as the voice-registration YAML. The consequence of getting it wrong is worse here than anywhere else in this repo: an automation that calls `lock.unlock` *outside* the `condition: template` guard would open the door regardless of what diff --git a/identity/frontend/admin.html b/identity/frontend/admin.html index 7d9e259..0aaf8f5 100644 --- a/identity/frontend/admin.html +++ b/identity/frontend/admin.html @@ -25,6 +25,7 @@ + @@ -87,6 +88,85 @@ + + +
+
+
+ + + + + + + +
+

+ Click on the plan to drop points, then Finish (or double-click) to close + the room. Drag a point to adjust it. Click a finished room to edit it. +

+
+ +
+
+ + + + + + +
+ + + + +
+
+ + +
+
+
diff --git a/identity/frontend/admin.js b/identity/frontend/admin.js index 03b8d75..247a80c 100644 --- a/identity/frontend/admin.js +++ b/identity/frontend/admin.js @@ -95,6 +95,12 @@ document.querySelectorAll(".tab").forEach((tab) => { p.classList.toggle("active", p.id === `panel-${tab.dataset.panel}`); }); if (tab.dataset.panel === "access") loadAccessLog(); + if (tab.dataset.panel === "floorplan") { + // Loaded on open rather than at startup: it costs an HA round trip for the area + // suggestions, and most visits to this page never touch the floorplan. + loadFloorplan().then(loadFloorplanPresence); + loadAreaSuggestions(); + } }); }); @@ -545,4 +551,413 @@ function loadAccessLog() { }); } +// --- Floorplan editor ------------------------------------------------------------------ +// Rooms are polygons in NORMALISED 0..1 coordinates (see the schema comment in +// server.py): the plan has to render on a laptop now and possibly a wall panel later, +// and pixel coordinates would be correct on exactly one of them. Everything below +// converts to the SVG's fixed 1000x700 viewBox only at draw time. +const FP = { W: 1000, H: 700 }; +let fpLevels = []; +let fpLevelId = null; +let fpSelected = null; // the room being edited (a COPY — see selectRoom) +let fpDraft = null; // points of a room currently being drawn +let fpLive = null; // latest /floorplan/presence payload + +const svg = document.getElementById("fp-canvas"); +const fpStatus = document.getElementById("fp-status"); + +function fpSetStatus(text, isError) { + fpStatus.textContent = text || ""; + fpStatus.className = isError ? "error" : "hint"; +} + +function toNorm(evt) { + // Uses the SVG's own coordinate space rather than clientX/clientY arithmetic, so the + // mapping stays correct however the element is scaled, scrolled, or letterboxed by + // preserveAspectRatio. + const pt = svg.createSVGPoint(); + pt.x = evt.clientX; + pt.y = evt.clientY; + const local = pt.matrixTransform(svg.getScreenCTM().inverse()); + return [Math.min(1, Math.max(0, local.x / FP.W)), Math.min(1, Math.max(0, local.y / FP.H))]; +} + +function svgEl(tag, attrs) { + const el = document.createElementNS("http://www.w3.org/2000/svg", tag); + Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v)); + return el; +} + +function pointsAttr(points) { + return points.map(([x, y]) => `${x * FP.W},${y * FP.H}`).join(" "); +} + +function currentLevel() { + return fpLevels.find((l) => l.id === fpLevelId) || null; +} + +function renderFloorplan() { + const level = currentLevel(); + const roomsG = document.getElementById("fp-rooms"); + const handlesG = document.getElementById("fp-handles"); + const draftG = document.getElementById("fp-draft"); + roomsG.replaceChildren(); + handlesG.replaceChildren(); + draftG.replaceChildren(); + + const bg = document.getElementById("fp-bg"); + if (level && level.has_image) { + // Blob-fetched like every other image here — the endpoint is token-gated and an + // has no way to send an Authorization header. + fetch(`${API}/floorplan/levels/${level.id}/image`, { headers: { Authorization: `Bearer ${TOKEN}` } }) + .then((res) => (res.ok ? res.blob() : Promise.reject())) + .then((blob) => bg.setAttribute("href", URL.createObjectURL(blob))) + .catch(() => bg.removeAttribute("href")); + } else { + bg.removeAttribute("href"); + } + + if (!level) return; + + const liveRooms = {}; + if (fpLive) { + (fpLive.levels || []).forEach((l) => (l.rooms || []).forEach((r) => (liveRooms[r.id] = r))); + } + const showLive = document.getElementById("fp-live").checked; + + level.rooms.forEach((room) => { + const occupants = ((liveRooms[room.id] || {}).occupants) || []; + const occupied = showLive && occupants.length > 0; + const selected = fpSelected && fpSelected.id === room.id; + roomsG.appendChild( + svgEl("polygon", { + points: pointsAttr(room.points), + class: `fp-room${occupied ? " occupied" : ""}${selected ? " selected" : ""}`, + fill: room.color || "#6ea8fe", + "data-room": room.id, + }) + ); + + // Label at the average of the vertices. A true centroid would still sit outside an + // L-shaped room, so the extra maths buys nothing a human wouldn't just drag anyway. + const cx = (room.points.reduce((s, p) => s + p[0], 0) / room.points.length) * FP.W; + const cy = (room.points.reduce((s, p) => s + p[1], 0) / room.points.length) * FP.H; + const label = svgEl("text", { x: cx, y: cy, class: "fp-label", "text-anchor": "middle" }); + label.textContent = room.name; + roomsG.appendChild(label); + + if (occupied) { + const who = svgEl("text", { x: cx, y: cy + 22, class: "fp-occupants", "text-anchor": "middle" }); + who.textContent = occupants.map((o) => o.name).join(", "); + roomsG.appendChild(who); + } + if (!room.ha_area_id) { + // A drawn room with no HA area can never light up, and looking at a plan where + // one room never reacts is a confusing way to discover that. + const warn = svgEl("text", { x: cx, y: cy + 40, class: "fp-warn", "text-anchor": "middle" }); + warn.textContent = "no HA area"; + roomsG.appendChild(warn); + } + }); + + roomsG.querySelectorAll("[data-room]").forEach((poly) => { + poly.addEventListener("click", (e) => { + if (fpDraft) return; // don't hijack clicks meant for the polygon being drawn + e.stopPropagation(); + selectRoom(Number(poly.dataset.room)); + }); + }); + + // Vertex handles for the selected room — dragging one is how a wall gets nudged. + if (fpSelected) { + fpSelected.points.forEach((point, index) => { + const handle = svgEl("circle", { + cx: point[0] * FP.W, + cy: point[1] * FP.H, + r: 8, + class: "fp-handle", + }); + handle.addEventListener("pointerdown", (e) => { + e.stopPropagation(); + handle.setPointerCapture(e.pointerId); + const move = (ev) => { + fpSelected.points[index] = toNorm(ev); + renderFloorplan(); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener( + "pointerup", + () => { + handle.removeEventListener("pointermove", move); + fpSetStatus("Moved a corner — Save room to keep it."); + }, + { once: true } + ); + }); + handlesG.appendChild(handle); + }); + } + + if (fpDraft && fpDraft.length) { + draftG.appendChild(svgEl("polyline", { points: pointsAttr(fpDraft), class: "fp-draft-line" })); + fpDraft.forEach((p) => + draftG.appendChild(svgEl("circle", { cx: p[0] * FP.W, cy: p[1] * FP.H, r: 6, class: "fp-draft-point" })) + ); + } +} + +function renderRoomList() { + const el = document.getElementById("fp-room-list"); + const level = currentLevel(); + if (!level || !level.rooms.length) { + el.innerHTML = '

No rooms on this level yet — draw one.

'; + return; + } + el.innerHTML = level.rooms + .map( + (r) => + `` + ) + .join(""); + el.querySelectorAll("[data-pick]").forEach((b) => + b.addEventListener("click", () => selectRoom(Number(b.dataset.pick))) + ); +} + +function selectRoom(roomId) { + const level = currentLevel(); + const room = level && level.rooms.find((r) => r.id === roomId); + if (!room) return; + // Edited on a deep copy: dragging handles mutates points as you go, and abandoning + // an edit has to leave the stored room untouched. + fpSelected = JSON.parse(JSON.stringify(room)); + document.getElementById("fp-room-editor").hidden = false; + document.getElementById("fp-room-name").value = room.name; + document.getElementById("fp-room-area").value = room.ha_area_id || ""; + document.getElementById("fp-room-color").value = room.color || "#6ea8fe"; + document.getElementById("fp-editor-title").textContent = room.name; + fpSetStatus(""); + renderFloorplan(); +} + +function clearSelection() { + fpSelected = null; + document.getElementById("fp-room-editor").hidden = true; + document.getElementById("fp-editor-title").textContent = "Rooms"; + renderFloorplan(); +} + +function loadFloorplan(keepSelection) { + return api("/floorplan") + .then((data) => { + fpLevels = data.levels || []; + if (!fpLevels.some((l) => l.id === fpLevelId)) fpLevelId = fpLevels.length ? fpLevels[0].id : null; + const select = document.getElementById("fp-level"); + select.innerHTML = fpLevels.map((l) => ``).join(""); + if (fpLevelId) select.value = fpLevelId; + if (!keepSelection) clearSelection(); + renderRoomList(); + renderFloorplan(); + }) + .catch((err) => fpSetStatus(err.message, true)); +} + +function loadFloorplanPresence() { + if (!document.getElementById("fp-live").checked) { + fpLive = null; + document.getElementById("fp-unplaced").textContent = ""; + renderFloorplan(); + return Promise.resolve(); + } + return api("/floorplan/presence") + .then((data) => { + fpLive = data; + const bits = []; + if ((data.unplaced || []).length) { + bits.push( + "Home but not on the plan: " + + data.unplaced + .map((p) => escapeHtml(p.name) + (p.reported_area ? ` (area “${escapeHtml(p.reported_area)}”)` : "")) + .join(", ") + ); + } + if ((data.unmapped_areas || []).length) { + bits.push( + "Areas HA is reporting that no room claims: " + + data.unmapped_areas.map(escapeHtml).join(", ") + + " — draw them, or map an existing room to them." + ); + } + document.getElementById("fp-unplaced").innerHTML = bits.join("
"); + renderFloorplan(); + }) + .catch(() => {}); +} + +function loadAreaSuggestions() { + api("/floorplan/areas") + .then((data) => { + document.getElementById("fp-areas").innerHTML = (data.areas || []) + .map((a) => `