Add a floorplan editor to the admin panel
Closes the oldest deferred item in the plan (open decision #22). /presence has reported a best-effort `room` since Phase 6, but there was nothing to plot it on: no floor plan, no room list, no coordinate format. The reason it stayed deferred was sound — nothing here knows the shape of this flat, and inventing a coordinate format against a guess would have been the wrong kind of work. So the plan is DRAWN, by hand, in a new Floorplan tab. Levels hold rooms; rooms are polygons (real rooms aren't rectangles) with an optional background image to trace over. Click corners to draw, drag handles to adjust. Each room maps to the HA area string /presence already reported, and the editor offers a pick-list of the areas HA is actually reporting rather than asking anyone to retype an area_id from Developer Tools — same anti-typo reasoning as CoreSystemConfig.json. Tick "Show who's home" and occupied rooms light up. Coordinates are normalised 0-1 rather than pixels: the plan has to render on a laptop now and possibly a wall panel later, and pixels would be right on exactly one of them. The tradeoff is documented — replacing a background with one of a different aspect ratio distorts existing rooms. Two choices about failure worth calling out. A room with no HA area is legal and labelled as such on the plan, because drawing the flat and wiring up presence are separate jobs. And anyone who can't be placed — home with no room resolved, or reporting an area no room claims — is listed under the plan, with unclaimed areas named so you know what's left to draw. A floor plan that quietly lost people would be worse than no floor plan. The whole feature is still gated on AREA_ATTRIBUTE being right, which remains an unconfirmed guess at what Bermuda publishes; every room can be drawn correctly and still never light up. The editor makes that diagnosable rather than mysterious, but can't fix it. Added to identity's verification list. 38 backend checks: level and room CRUD, polygon validation (too few points, out of range, non-numeric, over the vertex cap), duplicate-area refusal, image round-trip, the presence join including both unplaceable cases, area suggestions degrading when HA is down, and cascade on level delete. The editor UI itself has not been opened in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>digest-per-person-and-agendas
parent
2a87996cbb
commit
2ae5cb3449
|
|
@ -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 `<name>`" 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 `<date>`"** 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 `<date>`"** 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
|
||||
|
|
|
|||
|
|
@ -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/<camera>/ptz/move/<preset>` 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`.
|
||||
|
|
|
|||
|
|
@ -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/<id>/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/<id>` | remove a level and its rooms |
|
||||
| `GET`/`POST /floorplan/levels/<id>/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/<id>` | 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/<id>/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 `<dialog>`
|
||||
editor, the prune checkboxes) has not been opened in a real browser on this
|
||||
machine. `<dialog>`'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
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
<button type="button" class="tab active" data-panel="people">👥<span>People</span></button>
|
||||
<button type="button" class="tab" data-panel="prune">🧹<span>Prune</span></button>
|
||||
<button type="button" class="tab" data-panel="history">🕘<span>History</span></button>
|
||||
<button type="button" class="tab" data-panel="floorplan">🗺️<span>Floorplan</span></button>
|
||||
<button type="button" class="tab" data-panel="access">🔑<span>Access log</span></button>
|
||||
</div>
|
||||
|
||||
|
|
@ -87,6 +88,85 @@
|
|||
</section>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------------- Floorplan -->
|
||||
<!--
|
||||
The half that was missing from Phase 6 (project-plan open decision #22): /presence
|
||||
has reported a best-effort `room` all along, but there was nothing to plot it on.
|
||||
Rooms are DRAWN HERE BY HAND — 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 why this stayed deferred rather than half-built.
|
||||
-->
|
||||
<section class="panel" id="panel-floorplan">
|
||||
<section class="block">
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
Level
|
||||
<select id="fp-level"></select>
|
||||
</label>
|
||||
<button type="button" id="fp-add-level" class="btn">Add level</button>
|
||||
<button type="button" id="fp-rename-level" class="btn">Rename</button>
|
||||
<button type="button" id="fp-delete-level" class="btn danger">Delete level</button>
|
||||
<label class="btn" style="cursor:pointer">
|
||||
Background image
|
||||
<input id="fp-image" type="file" accept="image/*" hidden>
|
||||
</label>
|
||||
<span class="grow"></span>
|
||||
<label class="check">
|
||||
<input id="fp-live" type="checkbox" checked>
|
||||
Show who's home
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint" id="fp-hint">
|
||||
Click on the plan to drop points, then <b>Finish</b> (or double-click) to close
|
||||
the room. Drag a point to adjust it. Click a finished room to edit it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="block fp-layout">
|
||||
<div class="fp-canvas-wrap">
|
||||
<svg id="fp-canvas" viewBox="0 0 1000 700" preserveAspectRatio="xMidYMid meet">
|
||||
<image id="fp-bg" x="0" y="0" width="1000" height="700" preserveAspectRatio="none"></image>
|
||||
<g id="fp-rooms"></g>
|
||||
<g id="fp-draft"></g>
|
||||
<g id="fp-handles"></g>
|
||||
</svg>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button type="button" id="fp-draw" class="btn primary">Draw a room</button>
|
||||
<button type="button" id="fp-finish" class="btn" disabled>Finish</button>
|
||||
<button type="button" id="fp-cancel" class="btn" disabled>Cancel</button>
|
||||
<span id="fp-status" class="hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="fp-side">
|
||||
<h3 id="fp-editor-title">Rooms</h3>
|
||||
<div id="fp-room-list" class="card-list"></div>
|
||||
|
||||
<fieldset class="field-group" id="fp-room-editor" hidden>
|
||||
<legend>Selected room</legend>
|
||||
<label class="field">Name
|
||||
<input id="fp-room-name" type="text" autocomplete="off">
|
||||
</label>
|
||||
<label class="field">
|
||||
Home Assistant area
|
||||
<input id="fp-room-area" type="text" list="fp-areas" placeholder="e.g. kitchen" autocomplete="off">
|
||||
</label>
|
||||
<datalist id="fp-areas"></datalist>
|
||||
<p class="hint" id="fp-area-hint"></p>
|
||||
<label class="field">Colour
|
||||
<input id="fp-room-color" type="color" value="#6ea8fe">
|
||||
</label>
|
||||
<div class="row">
|
||||
<button type="button" id="fp-room-save" class="btn primary">Save room</button>
|
||||
<button type="button" id="fp-room-delete" class="btn danger">Delete</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div id="fp-unplaced" class="hint" style="margin-top:14px"></div>
|
||||
</aside>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------------ Access log -->
|
||||
<section class="panel" id="panel-access">
|
||||
<section class="block">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// <image href> 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 = '<p class="hint">No rooms on this level yet — draw one.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = level.rooms
|
||||
.map(
|
||||
(r) =>
|
||||
`<button type="button" class="card compact as-button" data-pick="${r.id}">
|
||||
<span class="fp-swatch" style="background:${escapeHtml(r.color || "#6ea8fe")}"></span>
|
||||
<span class="card-body">
|
||||
<span class="card-name">${escapeHtml(r.name)}</span>
|
||||
<span class="card-meta">${r.ha_area_id ? escapeHtml(r.ha_area_id) : "not mapped to an HA area"}</span>
|
||||
</span>
|
||||
</button>`
|
||||
)
|
||||
.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) => `<option value="${l.id}">${escapeHtml(l.name)}</option>`).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: <b>" +
|
||||
data.unmapped_areas.map(escapeHtml).join(", ") +
|
||||
"</b> — draw them, or map an existing room to them."
|
||||
);
|
||||
}
|
||||
document.getElementById("fp-unplaced").innerHTML = bits.join("<br>");
|
||||
renderFloorplan();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function loadAreaSuggestions() {
|
||||
api("/floorplan/areas")
|
||||
.then((data) => {
|
||||
document.getElementById("fp-areas").innerHTML = (data.areas || [])
|
||||
.map((a) => `<option value="${escapeHtml(a)}">`)
|
||||
.join("");
|
||||
const hint = document.getElementById("fp-area-hint");
|
||||
if (data.error === "ha_unreachable") {
|
||||
hint.innerHTML = "<span class=\"error\">Home Assistant unreachable — type the area id by hand.</span>";
|
||||
} else if (!(data.areas || []).length) {
|
||||
hint.textContent =
|
||||
`No areas reported yet on the '${data.attribute || "area_id"}' attribute. That's the field ` +
|
||||
"AREA_ATTRIBUTE reads, and it's an unconfirmed guess until Bermuda is actually running — " +
|
||||
"see identity/README.md.";
|
||||
} else {
|
||||
hint.textContent = `Areas HA is reporting right now on '${data.attribute}'.`;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// --- drawing -----------------------------------------------------------------------
|
||||
document.getElementById("fp-draw").addEventListener("click", () => {
|
||||
if (!fpLevelId) return fpSetStatus("Add a level first.", true);
|
||||
clearSelection();
|
||||
fpDraft = [];
|
||||
document.getElementById("fp-finish").disabled = false;
|
||||
document.getElementById("fp-cancel").disabled = false;
|
||||
fpSetStatus("Click to place corners. Finish (or double-click) to close the room.");
|
||||
renderFloorplan();
|
||||
});
|
||||
|
||||
svg.addEventListener("click", (evt) => {
|
||||
if (!fpDraft) return;
|
||||
fpDraft.push(toNorm(evt));
|
||||
fpSetStatus(`${fpDraft.length} corner${fpDraft.length === 1 ? "" : "s"} — 3 needed to finish.`);
|
||||
renderFloorplan();
|
||||
});
|
||||
|
||||
svg.addEventListener("dblclick", () => {
|
||||
if (fpDraft && fpDraft.length >= 3) finishDraft();
|
||||
});
|
||||
|
||||
function cancelDraft() {
|
||||
fpDraft = null;
|
||||
document.getElementById("fp-finish").disabled = true;
|
||||
document.getElementById("fp-cancel").disabled = true;
|
||||
fpSetStatus("");
|
||||
renderFloorplan();
|
||||
}
|
||||
|
||||
function finishDraft() {
|
||||
if (!fpDraft || fpDraft.length < 3) return fpSetStatus("A room needs at least 3 corners.", true);
|
||||
const name = prompt("Room name?");
|
||||
if (!name) return;
|
||||
postJson("/floorplan/rooms", { level_id: fpLevelId, name, points: fpDraft, color: "#6ea8fe" })
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
cancelDraft();
|
||||
return loadFloorplan().then(() => selectRoom(result.room_id));
|
||||
})
|
||||
.catch((err) => fpSetStatus(err.message, true));
|
||||
}
|
||||
|
||||
document.getElementById("fp-finish").addEventListener("click", finishDraft);
|
||||
document.getElementById("fp-cancel").addEventListener("click", cancelDraft);
|
||||
|
||||
// --- room editor -------------------------------------------------------------------
|
||||
document.getElementById("fp-room-save").addEventListener("click", () => {
|
||||
if (!fpSelected) return;
|
||||
postJson("/floorplan/rooms", {
|
||||
id: fpSelected.id,
|
||||
level_id: fpLevelId,
|
||||
name: document.getElementById("fp-room-name").value.trim(),
|
||||
ha_area_id: document.getElementById("fp-room-area").value.trim(),
|
||||
color: document.getElementById("fp-room-color").value,
|
||||
points: fpSelected.points,
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
fpSetStatus("Saved.");
|
||||
return loadFloorplan(true).then(loadFloorplanPresence);
|
||||
})
|
||||
.catch((err) => fpSetStatus(err.message, true));
|
||||
});
|
||||
|
||||
document.getElementById("fp-room-delete").addEventListener("click", () => {
|
||||
if (!fpSelected || !confirm(`Delete the room “${fpSelected.name}”?`)) return;
|
||||
api(`/floorplan/rooms/${fpSelected.id}`, { method: "DELETE" })
|
||||
.then(() => {
|
||||
clearSelection();
|
||||
return loadFloorplan();
|
||||
})
|
||||
.catch((err) => fpSetStatus(err.message, true));
|
||||
});
|
||||
|
||||
// --- levels ------------------------------------------------------------------------
|
||||
document.getElementById("fp-level").addEventListener("change", (e) => {
|
||||
fpLevelId = Number(e.target.value);
|
||||
clearSelection();
|
||||
renderRoomList();
|
||||
loadFloorplanPresence();
|
||||
});
|
||||
|
||||
document.getElementById("fp-add-level").addEventListener("click", () => {
|
||||
const name = prompt("Level name? (e.g. Ground floor)");
|
||||
if (!name) return;
|
||||
postJson("/floorplan/levels", { name, sort_order: fpLevels.length })
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
fpLevelId = result.level_id;
|
||||
return loadFloorplan();
|
||||
})
|
||||
.catch((err) => fpSetStatus(err.message, true));
|
||||
});
|
||||
|
||||
document.getElementById("fp-rename-level").addEventListener("click", () => {
|
||||
const level = currentLevel();
|
||||
if (!level) return;
|
||||
const name = prompt("Level name?", level.name);
|
||||
if (!name) return;
|
||||
postJson("/floorplan/levels", { id: level.id, name, sort_order: level.sort_order })
|
||||
.then(() => loadFloorplan(true))
|
||||
.catch((err) => fpSetStatus(err.message, true));
|
||||
});
|
||||
|
||||
document.getElementById("fp-delete-level").addEventListener("click", () => {
|
||||
const level = currentLevel();
|
||||
if (!level) return;
|
||||
if (!confirm(`Delete “${level.name}” and its ${level.rooms.length} room(s)?`)) return;
|
||||
api(`/floorplan/levels/${level.id}`, { method: "DELETE" })
|
||||
.then(() => {
|
||||
fpLevelId = null;
|
||||
return loadFloorplan();
|
||||
})
|
||||
.catch((err) => fpSetStatus(err.message, true));
|
||||
});
|
||||
|
||||
document.getElementById("fp-image").addEventListener("change", (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file || !fpLevelId) return;
|
||||
fpSetStatus("Uploading background…");
|
||||
// Raw bytes, same shape as the registration-photo endpoint.
|
||||
fetch(`${API}/floorplan/levels/${fpLevelId}/image`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/octet-stream" },
|
||||
body: file,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(result.message || "Upload failed");
|
||||
fpSetStatus("Background saved.");
|
||||
e.target.value = "";
|
||||
return loadFloorplan(true);
|
||||
})
|
||||
.catch((err) => fpSetStatus(err.message, true));
|
||||
});
|
||||
|
||||
document.getElementById("fp-live").addEventListener("change", loadFloorplanPresence);
|
||||
|
||||
loadPeople();
|
||||
|
|
|
|||
|
|
@ -447,6 +447,127 @@ input.grow {
|
|||
color: #ff8080;
|
||||
}
|
||||
|
||||
/* --- Floorplan editor --- */
|
||||
|
||||
.fp-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fp-canvas-wrap {
|
||||
flex: 1 1 480px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fp-side {
|
||||
flex: 0 1 280px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.fp-side h3 {
|
||||
font-size: 15px;
|
||||
margin: 0 0 10px;
|
||||
color: #9a9aa6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
#fp-canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1000 / 700;
|
||||
background: #14141a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
/* Without this a click-drag on the plan selects the surrounding page text, which
|
||||
* makes drawing a room feel broken. */
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#fp-bg {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.fp-room {
|
||||
fill-opacity: 0.22;
|
||||
stroke: rgba(255, 255, 255, 0.5);
|
||||
stroke-width: 2;
|
||||
cursor: pointer;
|
||||
transition: fill-opacity 120ms ease;
|
||||
}
|
||||
|
||||
.fp-room:hover {
|
||||
fill-opacity: 0.38;
|
||||
}
|
||||
|
||||
.fp-room.selected {
|
||||
stroke: #6ea8fe;
|
||||
stroke-width: 3;
|
||||
fill-opacity: 0.42;
|
||||
}
|
||||
|
||||
/* Occupied rooms are the whole point of the live view, so they read at a glance from
|
||||
* across a room rather than needing to be hunted for. */
|
||||
.fp-room.occupied {
|
||||
fill-opacity: 0.55;
|
||||
stroke: #7cf0a0;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.fp-label {
|
||||
fill: #e8e8ec;
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fp-occupants {
|
||||
fill: #7cf0a0;
|
||||
font-size: 15px;
|
||||
font-family: sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fp-warn {
|
||||
fill: #ffc850;
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fp-handle {
|
||||
fill: #6ea8fe;
|
||||
stroke: #101014;
|
||||
stroke-width: 2;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.fp-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.fp-draft-line {
|
||||
fill: rgba(110, 168, 254, 0.14);
|
||||
stroke: #6ea8fe;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
|
||||
.fp-draft-point {
|
||||
fill: #6ea8fe;
|
||||
}
|
||||
|
||||
.fp-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* --- Person editor dialog --- */
|
||||
#editor {
|
||||
width: min(680px, 94vw);
|
||||
|
|
|
|||
|
|
@ -114,5 +114,8 @@ NTFY_DEFAULT_TOPIC=household
|
|||
IDENTITY_PORT=8097
|
||||
IDENTITY_DB_PATH=/data/identity.db
|
||||
IDENTITY_PHOTO_DIR=/data/photos
|
||||
# Background images for the admin panel's floorplan editor. Same volume as the
|
||||
# registration photos — cover it with whatever backs up /data.
|
||||
IDENTITY_FLOORPLAN_DIR=/data/floorplans
|
||||
IDENTITY_MAX_IMAGE_MB=15
|
||||
LOG_LEVEL=INFO
|
||||
|
|
|
|||
|
|
@ -334,6 +334,45 @@ def init_db() -> None:
|
|||
);
|
||||
CREATE INDEX IF NOT EXISTS device_access_events_created
|
||||
ON device_access_events (created_at);
|
||||
-- FLOOR PLAN (docs/project-plan.md open decision #22). presence() has
|
||||
-- reported a best-effort `room` since Phase 6, but there was nothing to
|
||||
-- plot it on: no floor plan, no room list, no coordinate format. These two
|
||||
-- tables are that missing half, and they are deliberately DRAWN BY A HUMAN
|
||||
-- in the admin panel rather than inferred — nothing in this project knows
|
||||
-- the shape of these rooms, and guessing one would have been exactly the
|
||||
-- "building against a guess" that kept this deferred.
|
||||
CREATE TABLE IF NOT EXISTS floorplan_levels (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
-- Optional background image (a scan, a screenshot of an architect's
|
||||
-- PDF, a photo of a sketch). Rooms can be drawn on bare canvas without
|
||||
-- one; it just makes drawing them accurate rather than approximate.
|
||||
image_path TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS floorplan_rooms (
|
||||
id INTEGER PRIMARY KEY,
|
||||
level_id INTEGER NOT NULL REFERENCES floorplan_levels(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- THE JOIN TO REALITY: presence() reports `room` as whatever
|
||||
-- AREA_ATTRIBUTE holds on a trusted entity (an HA area_id by default).
|
||||
-- This column is what ties a drawn polygon to that string. Nullable
|
||||
-- because drawing the plan and wiring up presence are two separate
|
||||
-- jobs, and you should be able to finish the first before starting the
|
||||
-- second.
|
||||
ha_area_id TEXT,
|
||||
-- Polygon vertices as JSON [[x,y], ...], each 0.0-1.0 relative to the
|
||||
-- level's own extent. NORMALISED, not pixels: the plan has to render
|
||||
-- at any size (a phone, a wall panel, a future kiosk) and pixel
|
||||
-- coordinates would be right on exactly one of them. The tradeoff is
|
||||
-- that replacing a background image with one of a different ASPECT
|
||||
-- RATIO distorts existing rooms — same-ratio replacements are fine.
|
||||
points TEXT NOT NULL,
|
||||
color TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS floorplan_rooms_level ON floorplan_rooms (level_id);
|
||||
CREATE TABLE IF NOT EXISTS chore_assignments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
|
|
@ -1548,6 +1587,249 @@ def chore_assignments() -> dict:
|
|||
return {"assignments": by_type, "generated_at": _now()}
|
||||
|
||||
|
||||
# --- Floor plan ---------------------------------------------------------------------
|
||||
# The half that was missing from Phase 6's groundwork — see the schema comments above
|
||||
# and docs/project-plan.md open decision #22.
|
||||
|
||||
FLOORPLAN_DIR = Path(os.environ.get("IDENTITY_FLOORPLAN_DIR", "/data/floorplans"))
|
||||
MAX_POLYGON_POINTS = 64
|
||||
|
||||
|
||||
def _validate_points(raw) -> tuple[list[list[float]] | None, str]:
|
||||
"""Polygon vertices in, normalised list out — or a reason it isn't one.
|
||||
|
||||
Every constraint here exists because the alternative is a room that renders as
|
||||
something absurd rather than an error: a two-point "polygon" is a line, a
|
||||
coordinate of 4.2 puts a wall four screens to the right, and a 5000-vertex blob
|
||||
from a runaway click handler would be stored and re-rendered forever.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return None, "points must be a list of [x, y] pairs"
|
||||
if len(raw) < 3:
|
||||
return None, "a room needs at least 3 points"
|
||||
if len(raw) > MAX_POLYGON_POINTS:
|
||||
return None, f"a room may have at most {MAX_POLYGON_POINTS} points"
|
||||
cleaned: list[list[float]] = []
|
||||
for point in raw:
|
||||
if not isinstance(point, (list, tuple)) or len(point) != 2:
|
||||
return None, "each point must be an [x, y] pair"
|
||||
try:
|
||||
x, y = float(point[0]), float(point[1])
|
||||
except (TypeError, ValueError):
|
||||
return None, "point coordinates must be numbers"
|
||||
if not (0.0 <= x <= 1.0 and 0.0 <= y <= 1.0):
|
||||
return None, "point coordinates must be between 0.0 and 1.0 (relative to the plan)"
|
||||
cleaned.append([round(x, 5), round(y, 5)])
|
||||
return cleaned, ""
|
||||
|
||||
|
||||
def list_floorplan() -> dict:
|
||||
with _db_lock, _db() as conn:
|
||||
levels = conn.execute(
|
||||
"SELECT * FROM floorplan_levels ORDER BY sort_order, id"
|
||||
).fetchall()
|
||||
result = []
|
||||
for level in levels:
|
||||
rooms = conn.execute(
|
||||
"SELECT * FROM floorplan_rooms WHERE level_id = ? ORDER BY name COLLATE NOCASE",
|
||||
(level["id"],),
|
||||
).fetchall()
|
||||
result.append(
|
||||
{
|
||||
"id": level["id"],
|
||||
"name": level["name"],
|
||||
"sort_order": level["sort_order"],
|
||||
"has_image": level["image_path"] is not None,
|
||||
"rooms": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"ha_area_id": r["ha_area_id"],
|
||||
"points": json.loads(r["points"]),
|
||||
"color": r["color"],
|
||||
}
|
||||
for r in rooms
|
||||
],
|
||||
}
|
||||
)
|
||||
return {"levels": result, "generated_at": _now()}
|
||||
|
||||
|
||||
def save_level(level_id: int | None, name: str, sort_order: int) -> dict:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return {"ok": False, "reason": "bad_name", "message": "A level needs a name."}
|
||||
with _db_lock, _db() as conn:
|
||||
if level_id:
|
||||
cur = conn.execute(
|
||||
"UPDATE floorplan_levels SET name = ?, sort_order = ? WHERE id = ?",
|
||||
(name, sort_order, level_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return {"ok": False, "reason": "not_found", "message": "No such level."}
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO floorplan_levels (name, sort_order, created_at) VALUES (?, ?, ?)",
|
||||
(name, sort_order, _now()),
|
||||
)
|
||||
level_id = cur.lastrowid
|
||||
return {"ok": True, "level_id": level_id, "name": name}
|
||||
|
||||
|
||||
def delete_level(level_id: int) -> bool:
|
||||
with _db_lock, _db() as conn:
|
||||
cur = conn.execute("DELETE FROM floorplan_levels WHERE id = ?", (level_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def save_room(room_id: int | None, level_id: int, name: str, ha_area_id: str | None,
|
||||
points, color: str | None) -> dict:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return {"ok": False, "reason": "bad_name", "message": "A room needs a name."}
|
||||
|
||||
cleaned, error = _validate_points(points)
|
||||
if cleaned is None:
|
||||
return {"ok": False, "reason": "bad_points", "message": error}
|
||||
|
||||
area_id = (ha_area_id or "").strip() or None
|
||||
if area_id and not re.match(r"^[A-Za-z0-9_.-]{1,64}$", area_id):
|
||||
return {"ok": False, "reason": "bad_area_id", "message": "That doesn't look like an HA area id."}
|
||||
if color and not re.match(r"^#[0-9a-fA-F]{6}$", color):
|
||||
return {"ok": False, "reason": "bad_color", "message": "Colour must be a #rrggbb value."}
|
||||
|
||||
with _db_lock, _db() as conn:
|
||||
if conn.execute("SELECT 1 FROM floorplan_levels WHERE id = ?", (level_id,)).fetchone() is None:
|
||||
return {"ok": False, "reason": "not_found", "message": "No such level."}
|
||||
# Two rooms mapped to one HA area would both light up for one person, which
|
||||
# looks like a presence bug rather than a floor-plan mistake — so it's refused
|
||||
# here, where the cause is obvious.
|
||||
clash = conn.execute(
|
||||
"SELECT floorplan_rooms.name FROM floorplan_rooms "
|
||||
"WHERE ha_area_id IS NOT NULL AND ha_area_id = ? AND id != ?",
|
||||
(area_id, room_id or -1),
|
||||
).fetchone() if area_id else None
|
||||
if clash:
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "area_taken",
|
||||
"message": f"“{clash['name']}” is already mapped to that area.",
|
||||
}
|
||||
|
||||
payload = (level_id, name, area_id, json.dumps(cleaned), color)
|
||||
if room_id:
|
||||
cur = conn.execute(
|
||||
"UPDATE floorplan_rooms SET level_id = ?, name = ?, ha_area_id = ?, points = ?, "
|
||||
"color = ? WHERE id = ?",
|
||||
(*payload, room_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return {"ok": False, "reason": "not_found", "message": "No such room."}
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO floorplan_rooms (level_id, name, ha_area_id, points, color, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(*payload, _now()),
|
||||
)
|
||||
room_id = cur.lastrowid
|
||||
return {"ok": True, "room_id": room_id}
|
||||
|
||||
|
||||
def delete_room(room_id: int) -> bool:
|
||||
with _db_lock, _db() as conn:
|
||||
cur = conn.execute("DELETE FROM floorplan_rooms WHERE id = ?", (room_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def set_level_image(level_id: int, data: bytes) -> dict:
|
||||
with _db_lock, _db() as conn:
|
||||
if conn.execute("SELECT 1 FROM floorplan_levels WHERE id = ?", (level_id,)).fetchone() is None:
|
||||
return {"ok": False, "reason": "not_found", "message": "No such level."}
|
||||
FLOORPLAN_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = FLOORPLAN_DIR / f"level-{level_id}.img"
|
||||
path.write_bytes(data)
|
||||
with _db_lock, _db() as conn:
|
||||
conn.execute("UPDATE floorplan_levels SET image_path = ? WHERE id = ?", (str(path), level_id))
|
||||
LOG.info("identity: stored floor-plan image for level %d (%d bytes)", level_id, len(data))
|
||||
return {"ok": True, "message": "Background image saved."}
|
||||
|
||||
|
||||
def get_level_image(level_id: int) -> bytes | None:
|
||||
with _db_lock, _db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT image_path FROM floorplan_levels WHERE id = ?", (level_id,)
|
||||
).fetchone()
|
||||
if row is None or row["image_path"] is None:
|
||||
return None
|
||||
path = Path(row["image_path"])
|
||||
return path.read_bytes() if path.is_file() else None
|
||||
|
||||
|
||||
def floorplan_presence() -> dict:
|
||||
"""The payoff: the drawn plan plus who is standing in each room right now.
|
||||
|
||||
Joins presence()'s `room` (an HA area string) to `floorplan_rooms.ha_area_id`.
|
||||
People whose room doesn't match any drawn room — or who are home with no room
|
||||
resolved at all, which is the normal case without room-level BLE — come back under
|
||||
`unplaced` rather than being dropped, so the UI can show "3 people home, 1 not
|
||||
locatable" instead of quietly losing two of them.
|
||||
"""
|
||||
plan = list_floorplan()
|
||||
people = presence().get("people", [])
|
||||
|
||||
by_area: dict[str, list[dict]] = {}
|
||||
unplaced: list[dict] = []
|
||||
for person in people:
|
||||
if person.get("home") is not True:
|
||||
continue
|
||||
entry = {"id": person["id"], "name": person["name"], "has_photo": person["has_photo"]}
|
||||
area = person.get("room")
|
||||
if area:
|
||||
by_area.setdefault(str(area), []).append(entry)
|
||||
else:
|
||||
unplaced.append(entry)
|
||||
|
||||
placed_areas = set()
|
||||
for level in plan["levels"]:
|
||||
for room in level["rooms"]:
|
||||
occupants = by_area.get(room["ha_area_id"] or "", [])
|
||||
room["occupants"] = occupants
|
||||
if room["ha_area_id"] and occupants:
|
||||
placed_areas.add(room["ha_area_id"])
|
||||
|
||||
# An area HA is reporting that nothing on the plan claims — usually a room that
|
||||
# hasn't been drawn yet, and worth surfacing rather than silently ignoring.
|
||||
unmapped = {area: people_ for area, people_ in by_area.items() if area not in placed_areas}
|
||||
for area, people_ in unmapped.items():
|
||||
unplaced.extend({**p, "reported_area": area} for p in people_)
|
||||
|
||||
plan["unplaced"] = unplaced
|
||||
plan["unmapped_areas"] = sorted(unmapped)
|
||||
return plan
|
||||
|
||||
|
||||
def area_suggestions() -> dict:
|
||||
"""Every area value HA is currently reporting on a trusted entity, so the room
|
||||
editor can offer a pick-list instead of asking someone to retype an area_id from
|
||||
Developer Tools — the same anti-typo reasoning as tools/CoreSystemConfig.json.
|
||||
Degrades to an empty list (never an error) when HA is unreachable.
|
||||
"""
|
||||
try:
|
||||
states = _ha_get("/api/states")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError):
|
||||
LOG.warning("identity: could not reach HA for area suggestions", exc_info=True)
|
||||
return {"areas": [], "error": "ha_unreachable"}
|
||||
|
||||
areas = set()
|
||||
for entity in states:
|
||||
if not entity.get("entity_id", "").startswith(TRUSTED_ENTITY_PREFIXES):
|
||||
continue
|
||||
area = (entity.get("attributes") or {}).get(AREA_ATTRIBUTE)
|
||||
if area:
|
||||
areas.add(str(area))
|
||||
return {"areas": sorted(areas), "attribute": AREA_ATTRIBUTE}
|
||||
|
||||
|
||||
def _on_weather_message(_client, _userdata, message) -> None:
|
||||
try:
|
||||
payload = json.loads(message.payload.decode("utf-8", "replace"))
|
||||
|
|
@ -1660,6 +1942,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return default
|
||||
|
||||
photo_match = re.match(r"^/people/(\d+)/photo$", path)
|
||||
level_image_match = re.match(r"^/floorplan/levels/(\d+)/image$", path)
|
||||
visits_match = re.match(r"^/people/(\d+)/visits$", path)
|
||||
assignments_match = re.match(r"^/people/(\d+)/chore-assignments$", path)
|
||||
|
||||
|
|
@ -1688,6 +1971,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self._respond(HTTPStatus.OK, {"events": list_device_access_events(q_int("limit", 100))})
|
||||
elif path == "/chore-assignments":
|
||||
self._respond(HTTPStatus.OK, chore_assignments())
|
||||
elif path == "/floorplan":
|
||||
self._respond(HTTPStatus.OK, list_floorplan())
|
||||
elif path == "/floorplan/presence":
|
||||
self._respond(HTTPStatus.OK, floorplan_presence())
|
||||
elif path == "/floorplan/areas":
|
||||
self._respond(HTTPStatus.OK, area_suggestions())
|
||||
elif level_image_match:
|
||||
self._handle_level_image(int(level_image_match.group(1)))
|
||||
elif assignments_match:
|
||||
people = [p for p in list_people() if p["id"] == int(assignments_match.group(1))]
|
||||
if not people:
|
||||
|
|
@ -1739,6 +2030,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _handle_level_image(self, level_id: int) -> None:
|
||||
data = get_level_image(level_id)
|
||||
if data is None:
|
||||
self._respond(HTTPStatus.NOT_FOUND, {"error": "no background image for this level"})
|
||||
return
|
||||
self.send_response(HTTPStatus.OK)
|
||||
# Served back as whatever was uploaded. Content type is deliberately generic:
|
||||
# the editor accepts PNG, JPEG or SVG and the browser sniffs it happily, so
|
||||
# storing and echoing a declared type would be one more thing to get wrong.
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
if not self._authorized():
|
||||
self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
|
||||
|
|
@ -1750,6 +2057,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
identifiers_match = re.match(r"^/people/(\d+)/identifiers$", path)
|
||||
test_notify_match = re.match(r"^/people/(\d+)/test-notification$", path)
|
||||
person_match = re.match(r"^/people/(\d+)$", path)
|
||||
post_level_image_match = re.match(r"^/floorplan/levels/(\d+)/image$", path)
|
||||
# /people/prune is checked before the bare /people/<id> edit route so it is
|
||||
# never parsed as a person id (it can't be — it's not digits — but the ordering
|
||||
# makes the intent explicit rather than incidental).
|
||||
|
|
@ -1778,6 +2086,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||
else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT),
|
||||
result,
|
||||
)
|
||||
elif path == "/floorplan/levels":
|
||||
self._handle_save_level()
|
||||
elif path == "/floorplan/rooms":
|
||||
self._handle_save_room()
|
||||
elif post_level_image_match:
|
||||
self._handle_upload_level_image(int(post_level_image_match.group(1)))
|
||||
elif person_match:
|
||||
self._handle_update_person(int(person_match.group(1)))
|
||||
else:
|
||||
|
|
@ -1847,6 +2161,55 @@ class Handler(BaseHTTPRequestHandler):
|
|||
result,
|
||||
)
|
||||
|
||||
def _handle_save_level(self) -> None:
|
||||
payload = self._json_body()
|
||||
if payload is None:
|
||||
return
|
||||
try:
|
||||
level_id = int(payload["id"]) if payload.get("id") else None
|
||||
sort_order = int(payload.get("sort_order", 0))
|
||||
except (TypeError, ValueError):
|
||||
self._respond(HTTPStatus.BAD_REQUEST, {"error": "'id' and 'sort_order' must be integers"})
|
||||
return
|
||||
result = save_level(level_id, str(payload.get("name", "")), sort_order)
|
||||
self._respond(
|
||||
HTTPStatus.OK if result.get("ok")
|
||||
else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT),
|
||||
result,
|
||||
)
|
||||
|
||||
def _handle_save_room(self) -> None:
|
||||
payload = self._json_body()
|
||||
if payload is None:
|
||||
return
|
||||
try:
|
||||
room_id = int(payload["id"]) if payload.get("id") else None
|
||||
level_id = int(payload["level_id"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
self._respond(HTTPStatus.BAD_REQUEST, {"error": "'level_id' is required and must be an integer"})
|
||||
return
|
||||
result = save_room(
|
||||
room_id, level_id, str(payload.get("name", "")),
|
||||
payload.get("ha_area_id"), payload.get("points"), payload.get("color"),
|
||||
)
|
||||
self._respond(
|
||||
HTTPStatus.OK if result.get("ok")
|
||||
else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT),
|
||||
result,
|
||||
)
|
||||
|
||||
def _handle_upload_level_image(self, level_id: int) -> None:
|
||||
try:
|
||||
data = self._read_body(MAX_IMAGE_BYTES)
|
||||
except ValueError as exc:
|
||||
self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)})
|
||||
return
|
||||
if not data:
|
||||
self._respond(HTTPStatus.BAD_REQUEST, {"error": "empty image body"})
|
||||
return
|
||||
result = set_level_image(level_id, data)
|
||||
self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.NOT_FOUND, result)
|
||||
|
||||
def _handle_prune(self) -> None:
|
||||
payload = self._json_body()
|
||||
if payload is None:
|
||||
|
|
@ -1887,6 +2250,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||
)
|
||||
return
|
||||
|
||||
level_match = re.match(r"^/floorplan/levels/(\d+)$", path)
|
||||
if level_match:
|
||||
ok = delete_level(int(level_match.group(1)))
|
||||
self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND,
|
||||
{"ok": True} if ok else {"error": "no such level"})
|
||||
return
|
||||
|
||||
room_match = re.match(r"^/floorplan/rooms/(\d+)$", path)
|
||||
if room_match:
|
||||
ok = delete_room(int(room_match.group(1)))
|
||||
self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND,
|
||||
{"ok": True} if ok else {"error": "no such room"})
|
||||
return
|
||||
|
||||
person_match = re.match(r"^/people/(\d+)$", path)
|
||||
if person_match:
|
||||
ok = delete_person(int(person_match.group(1)))
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ NTFY_DEFAULT_TOPIC=household
|
|||
IDENTITY_PORT=${CORE_PORT_IDENTITY}
|
||||
IDENTITY_DB_PATH=/data/identity.db
|
||||
IDENTITY_PHOTO_DIR=/data/photos
|
||||
IDENTITY_FLOORPLAN_DIR=/data/floorplans
|
||||
IDENTITY_MAX_IMAGE_MB=15
|
||||
LOG_LEVEL=INFO
|
||||
EOF
|
||||
|
|
|
|||
Loading…
Reference in New Issue