diff --git a/README.md b/README.md index 2e364b2..9369af6 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,11 @@ firmware/ identity/ Person <-> BLE-identifier registry: multi-phone support, anti-spoofing (allowlisted IRK-resolved/fixed-tag entities only, never a raw MAC), voice/touch - registration, presence resolution (write API + - frontend/ static serving, consumed by kitchen-display - and door-panel) + registration, presence resolution, visit history + ("who was home when, with whom"), nicknames, per-device + rights, and an admin panel (write API + frontend/ + static serving, consumed by kitchen-display and + door-panel) digest-engine/ Quarter-daily LLM digest: mail/message/news/financial ingestion, LLM synthesis, digest-canvas SDK rendering admin-canvas/ On-demand sys-admin-llm display surface for the thin @@ -90,9 +92,10 @@ chores/ Presence/calendar-driven household chore nudging + - [ ] Sway touch panel (`hosts/touch-panel/`) — touch-driven Sway image: full Spotify GUI (Flathub), a dedicated Home Assistant Chromium kiosk window, a general web browser, an always-on touch dock for app switching, an on-screen keyboard (toggled manually, no auto-show), and `touchpanel-agent` (HA MQTT control, same LLM-mediated-through-HA security model as the thin client) — built, **no touch-panel hardware chosen and nothing booted on real metal**, see `hosts/touch-panel/README.md` - [ ] 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 gained two per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, set via `POST /people//chore-settings`, no frontend for it yet) consumed by `chores/`, see `identity/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), and chore assignment. 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" — nudges whoever's home, 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. 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` +- [ ] `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 - [ ] `docs/network-integration.md` (OPNsense VLAN segmentation + why nothing here should be port-forwarded to the WAN) — written, not run against a real OPNsense instance diff --git a/chores/README.md b/chores/README.md index fe6964b..3aab0f5 100644 --- a/chores/README.md +++ b/chores/README.md @@ -23,8 +23,8 @@ module docstring for the full reasoning on each: FULL/PARTIAL/EMPTY", "is this counter DIRTY/CLEAN", "is there litter left out here, YES/NO"), opens a chore on "needs attention," auto-closes one on "clear." 3. **Nudging** — ASAP, not on a fixed schedule: the first run after a chore opens - nudges whoever `identity` reports home right now (minus anyone `chore_exempt`, - see below). If the chore is still open `NEGLECT_THRESHOLD_HOURS` after the last + nudges whoever `identity` reports home right now — preferring anyone **assigned** + that chore type, minus anyone `chore_exempt`, both below. If the chore is still open `NEGLECT_THRESHOLD_HOURS` after the last nudge (and the household calendar isn't showing a busy window), the nudge goes to **someone different from who was last asked** — "the next person that walks by" — rather than re-nagging the same person. `litter` chores are special-cased @@ -34,10 +34,32 @@ module docstring for the full reasoning on each: below. Each nudge's wording is a plain template unless the target has a `chore_reminder_style` set, see below. +## Assignment is a preference, not a lock + +Set in `identity`'s admin panel (`admin.html`, People → a person → Chores), stored as +that person's `chore_assignments` and read off the same `/presence` call as everything +else here. + +When someone assigned to a chore type **is home**, they get nudged instead of whoever +happens to be nearest — that's the point of assigning it. When **no assignee is home**, +the nudge falls through to the ordinary whoever's-here rotation rather than waiting, +because the stated principle is still *"I don't care who does it, as long as it gets +done."* An absent assignee doesn't get to be the reason the bin stays full. + +Set **`CHORE_ASSIGNMENT_STRICT=true`** if your household wants the opposite: the chore +waits for its assignee and nobody else is asked. Both readings of "assign chores" are +legitimate and this file can't pick for you, so it's one env var rather than a +hard-coded opinion. Strict mode still never stalls a chore that was assigned to +*nobody* — that falls through regardless. + +**Litter can't be assigned** (`_ASSIGNMENTS_DONT_APPLY` in `check.py`), for the same +reason it ignores exemptions: it goes to whoever left the mess, and cleaning up after +yourself was never a task anyone could be handed. + ## Chore-exempt people — everyone except litter -Set via `identity`'s `POST /people//chore-settings` (see identity/README.md, -no frontend for it yet — call the endpoint directly). A `chore_exempt` person is +Set in the same place (`identity`'s admin panel, or `POST /people//chore-settings` +directly). A `chore_exempt` person is dropped from the nudge rotation entirely — the "cousin visits often but doesn't owe me chores" case. **Litter is the deliberate exception** (`_EXEMPTIONS_DONT_APPLY` in `check.py`): an exempt person still gets told to put trash they left out into @@ -114,8 +136,10 @@ ingestion. - **No re-check of `_compose_message()`'s LLM output** — whatever the model returns (if anything) is sent as-is, no validation that it's actually on-topic, on-tone, or even non-empty garbage beyond the plain empty-string fallback check. -- **No frontend for setting `chore_exempt`/`chore_reminder_style`** — set via a - direct `POST /people//chore-settings` call to `identity` until one exists. +- **No confirmation that an assignee actually did it** — assignment changes who gets + asked, not who gets credited. Attribution when a chore closes is still the same + best-effort "whoever was nudged most recently" heuristic, which an assignment doesn't + make any more truthful. See "The tally is passive" above. ## Configure diff --git a/chores/check.py b/chores/check.py index 83ab81a..bc298fa 100644 --- a/chores/check.py +++ b/chores/check.py @@ -40,6 +40,24 @@ exception** (`_EXEMPTIONS_DONT_APPLY` below): everyone, exempt or not, still get told to put trash they left out into the bin — that isn't "doing a chore," it's cleaning up after yourself. +## Assignment is a preference, not a lock + +`identity`'s per-person `chore_assignments` (set in its admin panel, read off the same +`/presence` call as everything else here) says who *owes* a given chore type. When +someone assigned to a chore is home, they get nudged instead of whoever happens to be +nearest — that's the whole point of assigning it. + +**But an assignee who isn't home doesn't block the chore.** The stated principle above +is "I don't care who does it, as long as it gets done," so if nobody assigned is +around, the nudge falls through to the ordinary whoever's-here rotation rather than +waiting. Set `CHORE_ASSIGNMENT_STRICT=true` if you'd rather it wait for the assignee — +that's the honest opposite reading of the same feature, and which one a household +wants isn't something this file can decide for it. + +**`litter` ignores assignment entirely** (`_ASSIGNMENTS_DONT_APPLY`), for the same +reason it ignores exemptions: it goes to whoever left the mess, and "cleaning up after +yourself" was never a task anyone could be assigned in the first place. + `identity`'s `chore_reminder_style` free-text field (same endpoint) is passed to an LLM that **phrases** the ntfy message in that person's preferred tone ("be assertive," "be gentle, give me a few minutes of grace") — see `_compose_message()`. @@ -135,6 +153,18 @@ _ATTRIBUTE_TO_RECENT_VIEWER = {"litter"} # — so they're kept as separate names rather than reusing one for both purposes. _EXEMPTIONS_DONT_APPLY = {"litter"} +# Chore types that can't be assigned to anyone — see the module docstring's +# "Assignment is a preference, not a lock". Third set with the same one member as the +# two above, and kept separate for the third distinct reason: attribution, then +# eligibility, now assignability. If they ever diverge (a chore that's assignable but +# exempt-proof, say) collapsing them now would be the thing that made that painful. +_ASSIGNMENTS_DONT_APPLY = {"litter"} + +# Whether an assigned person who ISN'T home blocks the chore from falling through to +# whoever is. Default false — "as long as it gets done" is the house rule; true makes +# assignment binding instead. See the module docstring. +CHORE_ASSIGNMENT_STRICT = os.environ.get("CHORE_ASSIGNMENT_STRICT", "false").strip().lower() == "true" + def _now() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") @@ -317,10 +347,15 @@ def check_cameras(conn) -> None: # --- 3. Nudging — presence/calendar-driven, "whoever's around," see module docstring def _presence() -> list[dict]: - """Each dict: {"name", "home", "room", "face_seen_recently", "chore_exempt", - "chore_reminder_style"} — the raw shape identity's own /presence returns. Empty - list (never raises past this point) if identity is unreachable — nudging just - waits for the next run. + """Each dict: {"name", "speak_name", "nickname", "home", "room", + "face_seen_recently", "chore_exempt", "chore_reminder_style", "chore_assignments"} + — the raw shape identity's own /presence returns. Empty list (never raises past + this point) if identity is unreachable — nudging just waits for the next run. + + Everything this module needs about a person arrives in this one call, including + who's assigned what; there's no second lookup per person. (`_anyone_assigned()` + does make one extra call, but only in strict mode and only about a chore type, not + a person.) """ if not IDENTITY_URL or not IDENTITY_TOKEN: return [] @@ -365,6 +400,34 @@ def _household_currently_busy() -> bool: return False +def _anyone_assigned(home: list[dict], chore_type: str) -> bool: + """Is this chore type assigned to ANYONE — including people who aren't home? + + Only consulted in CHORE_ASSIGNMENT_STRICT mode, and only once the cheaper check + (is an assignee home?) has already come back empty, which is why it's allowed to + cost an extra request. The distinction it draws matters: strict mode should wait + for an absent assignee, but must not wait forever on a chore nobody was ever + assigned — that would silently stop unassigned chores from being nudged at all. + + Fails OPEN (returns False, i.e. "nobody's assigned, go ahead and nudge whoever's + around") if identity can't be reached — same rule as the calendar busy-check: a + broken lookup must never be the reason chores stop getting done. + """ + if any(chore_type in (p.get("chore_assignments") or []) for p in home): + return True + if not IDENTITY_URL or not IDENTITY_TOKEN: + return False + try: + req = urllib.request.Request(f"{IDENTITY_URL}/chore-assignments") + req.add_header("Authorization", f"Bearer {IDENTITY_TOKEN}") + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + return bool((data.get("assignments") or {}).get(chore_type)) + except Exception: + LOG.warning("chores: could not reach identity for /chore-assignments", exc_info=True) + return False + + def _likely_culprit(candidates: list[dict]) -> dict | None: """For litter-type chores: prefer whoever was MOST RECENTLY seen by camera face recognition — a best-effort "who was just here" guess, not a certainty. Falls @@ -408,6 +471,24 @@ def nudge_open_chores(conn) -> None: ) continue + # Whoever's actually been assigned this chore type in identity's admin panel, + # and is home right now — see the module docstring's "Assignment is a + # preference, not a lock" for why an empty list here doesn't stop the nudge. + assigned = ( + [] + if chore["type"] in _ASSIGNMENTS_DONT_APPLY + else [p for p in eligible if chore["type"] in (p.get("chore_assignments") or [])] + ) + if assigned: + eligible = assigned + elif CHORE_ASSIGNMENT_STRICT and _anyone_assigned(home, chore["type"]): + LOG.info( + "chores: %r chore #%d is assigned, but no assignee is home and " + "CHORE_ASSIGNMENT_STRICT is on — waiting rather than redirecting", + chore["type"], chore["id"], + ) + continue + # "The next person that walks by": prefer someone home right now who ISN'T # who we last nudged (a real redirect, not the same person nagged again) — # falls back to re-nudging the same person if they're genuinely the only @@ -420,7 +501,12 @@ def nudge_open_chores(conn) -> None: different = [p for p in eligible if p["name"] != last_nudged] target = (different or eligible)[0] - name = target["name"] + # identity's speak_name is ALWAYS the person's real name, never a nickname + # they've been given — see identity/server.py's module docstring. Reminders go + # out as text and get read aloud by whatever's showing them, so this is one of + # the consumers that rule exists for. Falls back to `name` for an identity + # older than the nickname feature. + name = target.get("speak_name") or target["name"] conn.execute( "UPDATE chores SET assigned_to = ?, assigned_at = ?, reminder_count = reminder_count + 1, " "last_reminder_at = ? WHERE id = ?", diff --git a/chores/chores.env.example b/chores/chores.env.example index 7e1d9bf..6ff4bf9 100644 --- a/chores/chores.env.example +++ b/chores/chores.env.example @@ -77,5 +77,20 @@ NTFY_URL=http://127.0.0.1:8090 NTFY_TOPIC=chores NEGLECT_THRESHOLD_HOURS=4 +# --------------------------------------------------------------------------- +# CHORE_ASSIGNMENT_STRICT: what an assignment (set in identity's admin panel) +# actually means when the assigned person isn't home. +# +# false (default) — nudge whoever IS home instead. The house rule stays "I don't +# care who does it, as long as it gets done"; an absent assignee +# doesn't get to be the reason the bin stays full. +# true — wait for the assignee; nobody else is asked. +# +# Both are legitimate readings of "let me assign chores" — pick the one your +# household means. Either way, a chore assigned to nobody still falls through to the +# ordinary rotation, and litter ignores assignment entirely (see README.md). +# --------------------------------------------------------------------------- +CHORE_ASSIGNMENT_STRICT=false + CHORES_DB_PATH=/data/chores.db LOG_LEVEL=INFO diff --git a/docs/project-plan.md b/docs/project-plan.md index 0583ca0..414a673 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -216,8 +216,8 @@ real hardware" callouts for everything downstream of this.)* | Grocery inventory backend | **pantry-vision** (custom Python, stdlib `http.server`) | `POST /identify` (photo → proposal), `POST /confirm` (human-reviewed proposal → Grocy stock write), `GET /inventory`/`GET /recipes` (proxy Grocy, reshaped). Bearer-token gated, **and — unlike admin-canvas — LAN-published**, since the kitchen display's kiosk browser calls it directly rather than through Home Assistant | | Grocery inventory storage | **Grocy** (already running, Phase 1/7) | The single source of truth for stock/best-before-dates/recipes; `pantry-vision` is a client of Grocy's own REST API, not a replacement for it | | Kitchen-display static serving | **pantry-web** (nginx:alpine) | Serves `pantry-vision/frontend/`'s Scan/Inventory/Recipes single-page app read-only to the kitchen display — same role `digest-web`/`admin-web` play for their own hosts | -| Identity registry backend | **identity** (custom Python, stdlib `http.server` + `sqlite3`) | Person <-> BLE-identifier registry: registration (voice or touchscreen), presence resolution, a weather proxy. **Published**, unlike admin-canvas — `hosts/kitchen-display/`'s and `hosts/door-panel/`'s kiosk browsers call it directly, bearer-token gated | -| Identity static serving | **identity-web** (nginx:alpine) | Serves `identity/frontend/`'s `register.html`/`dashboard.html` read-only — same role as `pantry-web`/`digest-web`/`admin-web` | +| Identity registry backend | **identity** (custom Python, stdlib `http.server` + `sqlite3`) | Person <-> BLE-identifier registry: registration (voice or touchscreen), presence resolution, visit history, per-device rights, a weather proxy. **Published**, unlike admin-canvas — `hosts/kitchen-display/`'s and `hosts/door-panel/`'s kiosk browsers call it directly, bearer-token gated | +| Identity static serving | **identity-web** (nginx:alpine) | Serves `identity/frontend/`'s `register.html`/`dashboard.html`/`admin.html` read-only — same role as `pantry-web`/`digest-web`/`admin-web`. The two kiosk pages are what the wall panels load; `admin.html` is deliberately not linked from either (see Phase 6b) | | Door-panel OS build | **live-build** (custom config, `hosts/door-panel/live-build/`) | Reuses the thin client's build tool/convention, structurally `hosts/kitchen-display/`'s twin — see Phase 18 | | Door-panel scripted control | **door-panel-agent** (custom) | HA MQTT-discovery entity for **Show home/registration** only — identical security shape to every other host's agent | | Door-panel voice | **wyoming-satellite** + **openWakeWord** | Same components as the thin client's Phase 11.8 rooms and `hosts/kitchen-display/`'s opt-in mic, but **on by default** here — voice registration is this device's actual purpose | @@ -366,6 +366,56 @@ shipped API at all. entities, or a real voice pipeline — see the itemized list in `identity/README.md`, `TRUSTED_ENTITY_PREFIXES`' defaults above all. +### Phase 6b — Household admin panel (people, history, rights) + +Extends Phase 6's `identity` rather than adding a service: all of this is facts about +who someone is, which this project already made `identity`'s job. + +1. **`identity/frontend/admin.html` — the one page here that isn't kiosk-shaped.** + `register.html`/`dashboard.html` are wall panels read from across a room; the admin + page is dense, has destructive actions, and is meant for a phone or laptop. A wall + panel anyone can walk up to must not carry a "prune these six people" button, which + is why it's a separate URL and nothing in `hosts/door-panel/` links to it. +2. **Nicknames are an input alias, never an output one.** A person can have a + `nickname` that `/resolve` accepts ("is Bibi home?"), but every payload also carries + `speak_name`, always the real name, and voice/TTS consumers read that. The asymmetry + *is* the feature: a nickname is something people grant each other, not something a + machine should presume back. A nickname colliding with anyone else's name or + nickname is refused; a spoken string that somehow matches two people refuses with + `ambiguous_name` rather than picking, extending Phase 6's own never-auto-commit rule + to a second kind of ambiguity. +3. **Visit history is sampled by `identity` itself**, not pushed at it — a poller + writes arrival/departure rows off its own `/presence`. `home: null` (unknown) writes + **nothing**, ever: an HA outage must not record a departure that didn't happen. A + departure needs `DEPARTURE_GRACE_SECONDS` of sustained absence (BLE flaps) and is + recorded as of the last moment the person was actually seen. +4. **"Who was home with whom" is a query, not a table** — overlapping visit intervals, + computed on read. No second copy of the same truth to drift; O(visits²) in the + window, fine for a household, stated plainly rather than hidden. +5. **Pruning splits selecting from deleting.** `GET /prune/candidates` fills the + checkboxes; `POST /people/prune` deletes the explicit ids that came back. The filter + is never re-run at delete time, so nobody who walks in the door mid-review gets + swept up by a filter that quietly re-evaluated. +6. **Per-device rights are an answer, never an action** (the "let my cousin unlock the + front door herself" case). `identity` stores grants and answers + `GET /device-access`; **HA asks and HA acts**, same "HA mediates, nothing auto-acts" + rule as every other control path in this plan. Deny is the default and the only + fallback — the one place in this service that fails closed rather than degrading, + because a lock has no useful "unknown". Grants can expire; every check is logged, + allowed and denied alike. +7. **Chore assignment is a preference, not a lock** — an assignee who's home gets + nudged first, an assignee who's away doesn't block the chore ("I don't care who does + it, as long as it gets done"), and `CHORE_ASSIGNMENT_STRICT` flips that for + households that meant the stronger thing. **Litter still can't be assigned**, for the + same reason it ignores exemptions. +8. Admin-added identifiers **stay behind the anti-spoofing boundary** — + `TRUSTED_ENTITY_PREFIXES` is still enforced. What the admin path relaxes is only + "must be in range right now" (so a fixed BLE tag can be provisioned before it's near + the door), never the allowlist itself. An admin panel is not a reason to hand out an + exception to the one security property this service has. +9. Covered by API-level tests, not browser ones — see `identity/README.md`'s + verification list, `DEPARTURE_GRACE_SECONDS`' untuned default above all. + ### Phase 7 — Kitchen inventory kiosk 1. Deploy Grocy via Compose. 2. Pi + touchscreen running Chromium kiosk mode pointed at Grocy's PWA. @@ -809,7 +859,7 @@ These need a decision before their respective implementation steps can be built 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`. -26. **No frontend exists yet for setting `chore_exempt`/`chore_reminder_style`** (new, Phase 20) — set via a direct `POST /people//chore-settings` call to `identity` (HA script/automation, or `curl`) until a UI is built into `register.html`/`dashboard.html`. +26. ~~No frontend exists yet for setting `chore_exempt`/`chore_reminder_style`~~ — **resolved** (Phase 6b): `identity/frontend/admin.html` edits both, plus chore assignment, on the People tab. **Still open in a smaller way**: `admin.js`'s `CHORE_TYPES` list is kept in sync with `chores/check.py`'s `_CHORE_PROMPTS` **by hand**, because `chores` is a oneshot timer job with no HTTP surface to query for it — adding a fourth chore type means editing both files, and nothing catches it if you don't. 27. **No way to manually mark a chore done exists yet** (new, Phase 20) — the only way a `chores` chore currently closes is a camera re-check finding it clear, or direct SQLite surgery; a real deployment probably wants an HA button or voice phrase for "mark the trash as done," deliberately left out of this pass rather than guessed at. 28. **Which UniFi/CalDAV/Matter/1-Wire/Proxmox/Steam/Discord/HP-iLO/GTFS HA integrations actually get installed is unresolved** (new) — all nine are catalogued in §2's "HA integrations catalog" as available options with their purpose/notes, but none has been installed, configured, or verified against real hardware/accounts; several (Matter, 1-Wire, Proxmox, HP iLO) also depend on hardware/infrastructure decisions this plan hasn't made yet (whether anything in the household actually uses those platforms at all). 29. **Music Assistant has not been installed or configured** (new) — catalogued in §2 as an optional, additive HA add-on; whether it's worth adding on top of the existing per-room spotifyd/librespot/Spotify-client setup (which keeps working standalone regardless) is a real usage-pattern question, not answerable until the existing per-room setups (Phase 11.6/15/16) are actually running. @@ -817,3 +867,7 @@ These need a decision before their respective implementation steps can be built 31. **Music Assistant's default port is a guess, and it collides with `PANTRY_VISION_PORT` in this exact stack** (new) — assumed 8095 from Music Assistant's own docs, not confirmed against a running instance; `PANTRY_VISION_PORT` is also 8095. Because Music Assistant runs with `network_mode: host` (needed for player-discovery mDNS), Docker Compose's own port-collision checking doesn't catch this the way a normal `ports:` mapping would — `setup-container-host.sh` warns if both `ENABLE_MUSIC_ASSISTANT` and `ENABLE_PANTRY_VISION` are set, but resolving the actual clash (changing Music Assistant's configured listen port) is a manual step, not automated. 32. ~~RuView's semantic-state MQTT entities have no opt-out or visibility restriction beyond this network's normal trust boundary~~ — **household decision made**: real automations are now built on this data (sleep → dim lights, possible-distress → whole-household alert, concurrent elevated heart rate → colored lighting, bathroom occupancy → an external door indicator — see `firmware/ruview/README.md` §5 and `firmware/ruview/automations.yaml.example`). **Still genuinely open**: there is no technical opt-out for a specific person/room and no access restriction on these MQTT topics beyond this network's normal trust boundary — worth revisiting if anyone not on board with being sensed this way ever stays over. Every automation's `entity_id` is also still an unconfirmed placeholder (see #33), and rule 3 (concurrent two-person heart rate) rests on an unconfirmed assumption that a single RuView node can report two people's heart rates at once — multi-target vital-sign separation from WiFi CSI is a genuinely hard, unconfirmed capability, not something to trust until checked against real entities. 33. **RuView's build/flash commands and `provision.py`'s exact flags beyond `--port`/`--ssid`/`--password`/`--mqtt` are transcribed from its README, not independently run** (new, Phase 2) — see `firmware/ruview/README.md`'s own "Manual verification still outstanding," same category of risk as every other "written from documentation, not a live instance" open decision in this list (#19, #21). +34. **`identity`'s `DEPARTURE_GRACE_SECONDS` default (15 min) is an untuned guess at how much a real Private BLE Device setup flaps** (new, Phase 6b) — too low and one evening at home is recorded as several separate "visits," too high and a quick trip out never registers. The entire usefulness of the visit history and the co-presence view rests on this number, and nobody has watched a real BLE presence entity over a day to pick it. First thing to check once `GET /visits` has real data in it. +35. **The device-rights HA wiring is a worked example, not a tested one, and this is the one place where getting it wrong opens a door** (new, Phase 6b) — `identity` only ever *answers* `GET /device-access`; an HA automation that calls `lock.unlock` outside the `condition: template` guard in `identity/README.md`'s example would unlock regardless of that answer, and nothing on this side can detect or prevent that. Same "documented shape, not verified" caveat as #21, with a materially worse failure mode. +36. **The admin panel has never been opened in a real browser** (new, Phase 6b) — every endpoint it calls is covered by API-level tests, but the page itself (the ``-based person editor, the prune checkbox flow) has only been checked for syntax. `showModal()` needs a reasonably current browser; the door panel's Chromium qualifies, something older might not. +37. **Nothing enforces that voice/TTS consumers actually read `speak_name` rather than `nickname`** (new, Phase 6b) — the field exists, is documented, and `chores/` uses it, but a future HA intent script that reaches for the friendlier-looking `nickname` would break the "the assistant uses real names" rule silently. Worth checking whenever a new consumer of `/presence` or `/resolve` is written. diff --git a/hosts/container-host/scripts/setup-container-host.sh b/hosts/container-host/scripts/setup-container-host.sh index 3860a4b..a7dab04 100755 --- a/hosts/container-host/scripts/setup-container-host.sh +++ b/hosts/container-host/scripts/setup-container-host.sh @@ -1414,6 +1414,9 @@ if [[ "$ENABLE_IDENTITY" == "true" ]]; then echo " Identity API : http://${HOST_IP}:${IDENTITY_PORT} (bearer-token gated)" echo " Register page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/register.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=&device=" echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=" + echo " Admin panel : http://${HOST_IP}:${IDENTITY_WEB_PORT}/admin.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=" + echo " (people/guests, pruning, visit history, device rights —" + echo " NOT a kiosk page; keep this URL off the wall panels)" fi if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})" diff --git a/identity/README.md b/identity/README.md index bae5588..2224edf 100644 --- a/identity/README.md +++ b/identity/README.md @@ -113,12 +113,164 @@ real Bermuda-tracked entity's attributes (Developer Tools -> States) before rely on `room` being populated at all; it degrades to `null` if missing, never breaks the response. +## The admin panel + +`frontend/admin.html` — the third page here, and the only one **not** designed for a +kiosk. `register.html` and `dashboard.html` are big-touch-target screens running +unattended on a wall; this one is dense, has destructive buttons, and is meant for a +phone or laptop belonging to someone who sat down intending to administer something. +**A wall panel anyone can walk up to should not have a "Prune 6 people" button on +it** — that's why the admin page is a separate URL rather than a tab on the door +panel, and why nothing in `hosts/door-panel/` or `hosts/kitchen-display/` links to it. + +It's served by the same read-only `identity-web` nginx container as its siblings, and +configured the same way: + +``` +http://:8098/admin.html?api=http://:8097&token= +``` + +Four tabs: **People** (tap anyone to edit every field, their devices, their door +rights and their chores), **Prune**, **History**, and **Access log**. + +> The token is in the URL, exactly like the two kiosk pages — that's the existing +> pattern here, not a new decision, and it's why this service treats the token as the +> real boundary rather than network placement. Bookmark the admin URL somewhere +> private; anyone with it has full administrative access to the person registry. + +## Nicknames: people say them, the assistant doesn't + +A person can have a **`nickname`** — what the household actually calls them. It is an +**input alias only**: + +- `GET /resolve?q=bibi` finds Linus. So does `q=Linus`. Registering a second phone as + "Bibi" attaches it to Linus's existing record rather than creating a duplicate. +- **Every payload also carries `speak_name`, which is always the real name.** Voice/TTS + consumers must read `speak_name`, never `nickname`. `chores/` already does this for + reminder text. + +The asymmetry is the entire point of the field, not an implementation detail: a +nickname is something people grant each other, and a machine reading it back is a +different thing from a friend saying it. Assign one in the admin panel, and the +assistant keeps calling them by their name. + +A nickname may not collide with anyone else's name **or** nickname — the edit is +refused with the conflicting person named. If a spoken string somehow matches two +people anyway, registration refuses with `reason: "ambiguous_name"` rather than +picking one, the same never-auto-commit-on-ambiguity rule as the BLE candidate case +above. + +## Visit history — who was home when, and with whom + +`identity` samples its **own** `/presence` every `PRESENCE_POLL_SECONDS` and writes +arrival/departure rows. Nothing pushes events at it. Three deliberate consequences: + +- **The history is honest about its resolution.** You know when someone was *observed* + home, to within one poll interval. +- **`home: null` (unknown) never writes anything.** Not a visit, and — more + importantly — never a departure. An HA outage or a device-less person nobody has + toggled must not put a fake "left the house" into the record; an inferred absence + written down as an observed one is a lie the log can never un-tell. An + `ha_unreachable` sample is skipped in full. +- **BLE flapping doesn't shred the log.** A person has to read as away for + `DEPARTURE_GRACE_SECONDS` before their visit closes, and the departure is recorded as + of the last moment they were actually *seen*, not when that window ran out. + +A visit that never gets a definite "not home" (the device-less, hand-toggled case) is +eventually closed by `VISIT_MAX_OPEN_HOURS` with `close_reason: "timed_out"` rather +than `"departed"` — the two are never conflated, and the admin panel labels the +difference ("departure never observed"). + +**"With whom" is a query, not a table.** `GET /co-presence` overlaps visit intervals on +read. There's no second copy of the same truth to drift out of sync, and a visit +corrected later automatically corrects the co-presence answer. The tradeoff is stated +plainly: it's O(visits²) within the window, which is fine for a household and would not +be for a venue. + +## Pruning: the filter selects, the human deletes + +"Select all that have last visited before ``" is two endpoints on purpose: + +1. `GET /prune/candidates?last_visit_before=…` — a **read**. Fills in the checkboxes. +2. `POST /people/prune` with `{"person_ids": [...]}` — deletes exactly the ids that + came back and stayed ticked. + +The filter is **never re-run at delete time**. Someone who walks in the door between +"Select all" and "Delete selected" can't be swept up by a filter that quietly +re-evaluated — the list you approved is the list that gets deleted. That's worth one +extra round trip for an irreversible operation on people's records. + +Someone with no recorded visits falls back to their `created_at` (flagged +`last_visit_is_estimated`), so a person registered once and never seen again — the most +prunable record there is — is findable rather than invisible to the filter. + +## Per-device rights — an answer, never an action + +`device_grants` records that a person may operate a specific HA entity: the "let my +cousin unlock the front door herself" case. `GET /device-access` answers yes/no with a +reason. + +**This service never touches a device.** It has no path to one. Home Assistant asks, +Home Assistant acts — the same "HA mediates, nothing auto-acts" rule as every other +control path in this project. The flow is: BLE/face resolves who's at the door → HA +calls `GET /device-access?person_id=…&entity_id=lock.front_door` → HA calls +`lock.unlock` if and only if the answer was `allowed: true`. + +**Deny is the default and the only fallback.** No grant, unknown person, expired +grant — all `allowed: false`. This is the one place here that fails *closed* rather +than degrading gracefully: everything else in this service would rather report +"unknown" than guess, but a lock has no useful "unknown", and the safe half of +"open/don't open" is "don't". + +Grants can carry an `expires_at` (a weekend key for a visiting cousin), checked at +answer time rather than by a sweep, so a lapsed grant stops working the instant it +lapses. **Every check is logged** to `device_access_events`, allowed and denied +alike — for a door lock the denied ones are the interesting ones — and the admin +panel's Access log tab shows them. + +A worked HA example, unverified against a running instance like every other HA-side +snippet in this repo: + +```yaml +# configuration.yaml (excerpt) +rest_command: + identity_may_operate: + url: "http://:8097/device-access?person_id={{ person_id }}&entity_id={{ entity_id }}&via=door-panel" + method: GET + headers: + Authorization: "Bearer !secret identity_token" + +script: + cousin_self_entry: + sequence: + - service: rest_command.identity_may_operate + data: + person_id: "{{ person_id }}" + entity_id: lock.front_door + response_variable: verdict + # The lock is only ever touched inside this guard. + - condition: template + value_template: "{{ verdict.content.allowed }}" + - service: lock.unlock + target: + entity_id: lock.front_door +``` + ## Chore-system settings — owned here, used by `chores/` -Two per-person fields, set via `POST /people//chore-settings`. **No frontend -for this exists yet** — neither `register.html` nor `dashboard.html` expose a way -to set them — call the endpoint directly (an HA script/automation, or `curl`) until -one is built. Read by `chores/` off `GET /presence`: +Three per-person things live here, not in `chores/`. All of them are editable in the +admin panel (which is what open decision #26 was waiting for), and `chores/` reads +all of them off the same `GET /presence` call it already made. + +**Assignment** (`POST /people//chore-assignments`) says who owes which chore type. +It's a strong **preference, not a lock**: an assignee who's home gets nudged instead of +whoever's nearest, but an assignee who's *away* doesn't block the chore — the nudge +falls through to whoever is around, because the house rule is still "I don't care who +does it, as long as it gets done." `CHORE_ASSIGNMENT_STRICT=true` in `chores.env` +flips that to waiting for the assignee instead. **Litter can't be assigned to anyone**, +for the same reason it ignores exemptions — see below. + +The other two fields, set via `POST /people//chore-settings`: - **`chore_exempt`** — a household member who's tracked for presence/identity like anyone else but never nudged about chores in general (the "cousin visits often @@ -134,11 +286,12 @@ one is built. Read by `chores/` off `GET /presence`: assignment decision (see `chores/README.md`). Empty/unset falls back to a plain, un-styled template with no LLM call at all. -Both fields live on `people` (not a separate table) because they're household- -standing facts about a person, same category as their name or photo — `identity` is -already this project's source of truth for who someone is, so this is where "how do -I relate to this specific household member" facts belong, not duplicated into -`chores/`'s own database. +Both of those live as columns on `people` (not a separate table) because they're +household-standing facts about a person, same category as their name or photo — +`identity` is already this project's source of truth for who someone is, so this is +where "how do I relate to this specific household member" facts belong, not duplicated +into `chores/`'s own database. Assignments get their own table only because they're +many-per-person, not because they belong anywhere else. ## Camera face recognition — a second presence signal, never a registration one @@ -241,13 +394,26 @@ not network placement. | `POST /register/photo` | raw image bytes -> `{"photo_id": "..."}` — an audit artifact, and also becomes the person's profile picture (see below) | | `POST /register` | `{"name", "device_id", "photo_id"?, "entity_id"?, "no_device"?}` -> registers, or returns a reason it couldn't (see above) | | `POST /register/guest` | `{"device_id", "photo_id"?}` -> registers "Guest N", no name needed | -| `GET /people` | admin/audit list of every registered person + their identifiers + `has_photo` | +| `GET /people` | admin/audit list of every person: identifiers, device grants, chore assignments, `nickname`/`speak_name`, `last_visit_at`, `visit_count`, `currently_home_since` | +| `POST /people/` | edit any editable field — `{"name"?, "nickname"?, "note"?, "chore_exempt"?, "chore_reminder_style"?, "clear_photo"?}`. Omitted keys are left alone | | `GET /people//photo` | the person's profile picture (raw JPEG) — their most recent registration photo | +| `POST /people//identifiers` | `{"entity_id"}` — attach an identifier by hand (a fixed BLE tag not in range yet). Still enforces `TRUSTED_ENTITY_PREFIXES` | | `DELETE /people//identifiers/` | revoke a mistaken or compromised identifier | -| `DELETE /people/` | remove a person entirely (their identifiers go with them) — mainly for cleaning up stale Guest records | +| `DELETE /people/` | remove a person entirely (their identifiers, grants and visits go with them) | +| `GET /resolve?q=` | spoken name **or nickname** -> the canonical person, with `speak_name` (see below) | +| `GET /prune/candidates?last_visit_before=` | everyone whose last visit predates that date — a **read**, it selects and never deletes | +| `POST /people/prune` | `{"person_ids": [...]}` — bulk delete by explicit id, never by filter (see below) | +| `GET /people//visits`, `GET /visits` | visit history; both take `?since=&limit=` | +| `GET /co-presence?person_id=&since=` | who was home at the same time as whom, derived from overlapping visits | +| `GET /device-access?person_id=&entity_id=&permission=&via=` | **may this person operate this device?** -> `{"allowed", "reason", ...}` | +| `POST /people//device-grants` | `{"entity_id", "permission"?, "expires_at"?, "note"?}` — grant a right | +| `DELETE /people//device-grants/` | revoke one | +| `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 | | `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", "home", "room", "has_device", "has_photo", "chore_exempt", "chore_reminder_style"}], "generated_at"}` — `home` is `true`/`false`/`null` (unknown), `room` is best-effort floor-plan groundwork (see below) | +| `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) | | `GET /weather` | proxies `smarthome/weather/current`, same JSON shape (`temperature`/`condition`/`location`) `hosts/thin-client`'s weather overlay already uses | **Every person gets a profile picture, automatically** — whichever registration photo @@ -283,8 +449,29 @@ no way to send an `Authorization` header. attaches to a trusted entity's state — unconfirmed, and the whole `room` field in `/presence` degrades to `null` silently if it's wrong, so this could easily go unnoticed until someone builds the actual floor-plan UI and finds it empty. -8. The blob+`createObjectURL()` profile-picture fetch (both frontends) has not been - checked for a memory leak from never calling `URL.revokeObjectURL()` on the old +8. The blob+`createObjectURL()` profile-picture fetch (all three frontends) has not + been checked for a memory leak from never calling `URL.revokeObjectURL()` on the old blob URL when `/people`/`/presence` refreshes and re-fetches the same photo — likely fine at household scale and dashboard.js's 60s poll cadence, not measured over a multi-day uptime. +9. **`DEPARTURE_GRACE_SECONDS`' default (15 min) is a guess at how much a real Private + BLE Device setup actually flaps** — too low and one evening at home becomes several + "visits"; too high and a quick trip out doesn't register at all. Nobody has watched + 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 + 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 + 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. **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 + this service answered. `identity` cannot enforce that from its side — it only ever + answers the question. diff --git a/identity/frontend/admin.html b/identity/frontend/admin.html new file mode 100644 index 0000000..8b4e9df --- /dev/null +++ b/identity/frontend/admin.html @@ -0,0 +1,185 @@ + + + + + +Household admin + + + + +
+ + + + +
+ +
+ +
+
+

People

+

Tap a person to edit every field, their devices, door rights and chores.

+

Loading…

+
+
+ + +
+
+

Prune old records

+

+ Select everyone whose last visit was before a date — for clearing out + one-off guests. Nothing is deleted until you confirm the exact list below. +

+
+ + +
+

+
+
+ + +
+
+
+ + +
+
+

Who was home, when

+
+ + + +
+

Pick a range and load.

+
+ +
+

…and with whom

+

Overlapping visits, worked out from the history above.

+
+
+
+ + +
+
+

Device access checks

+

+ Every time something asked whether a person may operate a device — allowed and + denied alike. identity only ever answers these; Home Assistant is what actually + moves a lock. +

+

Loading…

+
+
+
+ + + +
+
+ 👤 +

Edit

+ +
+ +
+ + + + +
+ Chores + + +
+ Assigned chores +
+

+ A strong preference, not a lock: if the assignee isn't home, whoever is + gets nudged instead. Litter ignores assignment entirely. +

+
+
+ +
+ Devices used for presence +
+
+ + +
+

+ Must match TRUSTED_ENTITY_PREFIXES — only IRK-resolved or fixed-tag entities + can identify a person. For provisioning a tag that isn't in range yet. +

+
+ +
+ Device rights +
+
+ + + + +
+

+ Lets this person operate that entity — e.g. unlocking the front door for + themselves. An expiry date makes it a temporary key. +

+
+ + +
+
+
+ + + + diff --git a/identity/frontend/admin.js b/identity/frontend/admin.js new file mode 100644 index 0000000..5c8b430 --- /dev/null +++ b/identity/frontend/admin.js @@ -0,0 +1,525 @@ +// identity's admin panel logic. Vanilla JS, no framework, no build step — same +// "vendored, dependency-free" choice as every other frontend in this project. See +// admin.html's top comment for why this page is the one that isn't kiosk-shaped. +"use strict"; + +const params = new URLSearchParams(location.search); +const API = (params.get("api") || "").replace(/\/$/, ""); +const TOKEN = params.get("token") || ""; + +if (!API || !TOKEN) { + document.body.innerHTML = + '

identity not configured — missing ' + + "?api=&token= in the URL.

"; + throw new Error("admin: missing ?api=/&token= query params"); +} + +// Chore types the checkboxes offer. Kept in sync BY HAND with chores/check.py's +// _CHORE_PROMPTS — there is no endpoint that lists them, because chores/ is a +// oneshot timer job with no HTTP surface at all, and inventing one just so this +// dropdown could be generated would be a lot of moving parts for a list that changes +// about once a year. "litter" is deliberately absent: it can't be assigned to anyone +// (see chores/README.md), so offering it here would be offering a lie. +const CHORE_TYPES = ["trash", "bin_full", "dishes"]; + +function api(path, options) { + options = options || {}; + options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {}); + return fetch(`${API}${path}`, options).then((res) => + res.json().then((body) => { + // 409 carries a real, human-readable refusal ("that nickname collides with…"), + // so it's a result to display rather than an error to throw — same convention + // as register.js. + if (!res.ok && res.status !== 409) throw new Error(body.error || body.message || `${res.status} ${res.statusText}`); + return body; + }) + ); +} + +function postJson(path, body) { + return api(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +// Dates are stored as UTC ISO strings; a household admin reading "was Amir home +// Tuesday evening" wants them in their own timezone, which is what toLocaleString +// gives without this page needing to know what that timezone is. +function fmt(iso) { + if (!iso) return "—"; + const d = new Date(iso); + return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); +} + +function fmtDate(iso) { + if (!iso) return "—"; + const d = new Date(iso); + return isNaN(d) ? iso : d.toLocaleDateString(undefined, { dateStyle: "medium" }); +} + +function duration(fromIso, toIso) { + const from = new Date(fromIso); + const to = toIso ? new Date(toIso) : new Date(); + const mins = Math.round((to - from) / 60000); + if (isNaN(mins) || mins < 0) return ""; + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + return hours < 24 ? `${hours}h ${mins % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`; +} + +// Same blob-fetch approach as register.js/dashboard.js — every identity endpoint +// requires an Authorization header, and a plain has no way to send +// one. See identity/README.md. +function loadAvatar(container, personId) { + fetch(`${API}/people/${personId}/photo`, { headers: { Authorization: `Bearer ${TOKEN}` } }) + .then((res) => (res.ok ? res.blob() : Promise.reject())) + .then((blob) => { + const img = document.createElement("img"); + img.src = URL.createObjectURL(blob); + container.replaceChildren(img); + }) + .catch(() => {}); +} + +// --- Tabs ------------------------------------------------------------------------ +document.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === tab)); + document.querySelectorAll(".panel").forEach((p) => { + p.classList.toggle("active", p.id === `panel-${tab.dataset.panel}`); + }); + if (tab.dataset.panel === "access") loadAccessLog(); + }); +}); + +// --- People list ------------------------------------------------------------------- +let people = []; + +function personSubtitle(p) { + const bits = []; + if (p.nickname) bits.push(`“${p.nickname}”`); + if (p.currently_home_since) bits.push(`home since ${fmt(p.currently_home_since)}`); + else bits.push(`last seen ${fmtDate(p.last_visit_at)}${p.last_visit_is_estimated ? " (never recorded)" : ""}`); + if (p.identifiers.length) bits.push(`${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}`); + else bits.push("no device"); + if (p.device_grants.length) bits.push(`${p.device_grants.length} right${p.device_grants.length === 1 ? "" : "s"}`); + if (p.chore_exempt) bits.push("chore-exempt"); + if (p.chore_assignments.length) bits.push(`chores: ${p.chore_assignments.join(", ")}`); + return bits.join(" · "); +} + +function loadPeople() { + const el = document.getElementById("people-list"); + return api("/people") + .then((data) => { + people = data.people || []; + if (!people.length) { + el.innerHTML = '

Nobody registered yet.

'; + return; + } + el.innerHTML = people + .map( + (p) => + `` + ) + .join(""); + el.querySelectorAll("[data-person]").forEach((btn) => { + btn.addEventListener("click", () => openEditor(Number(btn.dataset.person))); + }); + people.forEach((p) => { + if (p.has_photo) { + const avatar = el.querySelector(`[data-avatar="${p.id}"]`); + if (avatar) loadAvatar(avatar, p.id); + } + }); + populatePersonSelect(); + }) + .catch((err) => { + el.innerHTML = `

Could not load people: ${escapeHtml(err.message)}

`; + }); +} + +// --- Person editor ------------------------------------------------------------------ +const editor = document.getElementById("editor"); +const editStatus = document.getElementById("edit-status"); +let editing = null; + +function setStatus(text, isError) { + editStatus.textContent = text; + editStatus.className = isError ? "error" : "hint"; +} + +function openEditor(personId) { + editing = people.find((p) => p.id === personId); + if (!editing) return; + + document.getElementById("edit-title").textContent = editing.name; + document.getElementById("edit-name").value = editing.name; + document.getElementById("edit-nickname").value = editing.nickname || ""; + document.getElementById("edit-note").value = editing.note || ""; + document.getElementById("edit-chore-exempt").checked = editing.chore_exempt; + document.getElementById("edit-reminder-style").value = editing.chore_reminder_style || ""; + + const avatar = document.getElementById("edit-avatar"); + avatar.replaceChildren(document.createTextNode("👤")); + if (editing.has_photo) loadAvatar(avatar, editing.id); + + document.getElementById("edit-chore-types").innerHTML = CHORE_TYPES.map( + (type) => + `` + ).join(""); + + renderIdentifiers(); + renderGrants(); + setStatus(""); + editor.showModal(); +} + +function renderIdentifiers() { + const el = document.getElementById("edit-identifiers"); + if (!editing.identifiers.length) { + el.innerHTML = '

No devices — presence has to be set by hand.

'; + return; + } + el.innerHTML = editing.identifiers + .map( + (i) => + `
+ + ${escapeHtml(i.ha_entity_id)} + added ${escapeHtml(fmtDate(i.registered_at))} via ${escapeHtml(i.registered_via_device || "?")} + + +
` + ) + .join(""); + el.querySelectorAll("[data-drop-identifier]").forEach((btn) => { + btn.addEventListener("click", () => { + api(`/people/${editing.id}/identifiers/${btn.dataset.dropIdentifier}`, { method: "DELETE" }) + .then(() => refreshEditing("Device revoked.")) + .catch((err) => setStatus(err.message, true)); + }); + }); +} + +function renderGrants() { + const el = document.getElementById("edit-grants"); + if (!editing.device_grants.length) { + el.innerHTML = '

No device rights.

'; + return; + } + const now = new Date().toISOString(); + el.innerHTML = editing.device_grants + .map((g) => { + const expired = g.expires_at && g.expires_at <= now; + return `
+ + ${escapeHtml(g.ha_entity_id)} + ${escapeHtml(g.permission)}${ + g.expires_at ? ` · ${expired ? "expired" : "until"} ${escapeHtml(fmtDate(g.expires_at))}` : " · no expiry" + }${g.note ? ` · ${escapeHtml(g.note)}` : ""} + + +
`; + }) + .join(""); + el.querySelectorAll("[data-drop-grant]").forEach((btn) => { + btn.addEventListener("click", () => { + api(`/people/${editing.id}/device-grants/${btn.dataset.dropGrant}`, { method: "DELETE" }) + .then(() => refreshEditing("Right revoked.")) + .catch((err) => setStatus(err.message, true)); + }); + }); +} + +// Re-reads /people and re-points the open dialog at the fresh record, so a sub-action +// (revoking a device, adding a grant) doesn't leave the dialog showing stale data or +// force the admin to close and reopen it. +function refreshEditing(message) { + const id = editing.id; + return loadPeople().then(() => { + editing = people.find((p) => p.id === id); + if (!editing) { + editor.close(); + return; + } + renderIdentifiers(); + renderGrants(); + if (message) setStatus(message); + }); +} + +document.getElementById("edit-close").addEventListener("click", () => editor.close()); + +document.getElementById("edit-save").addEventListener("click", () => { + const chores = Array.from(document.querySelectorAll("[data-chore]:checked")).map((c) => c.dataset.chore); + setStatus("Saving…"); + // Two calls because they're two different resources, not one form: the person's own + // fields, and their chore assignments. Sequenced rather than parallel so a rejected + // rename (a nickname collision, say) surfaces its message instead of racing the + // assignment call's own status text. + postJson(`/people/${editing.id}`, { + name: document.getElementById("edit-name").value.trim(), + nickname: document.getElementById("edit-nickname").value.trim(), + note: document.getElementById("edit-note").value.trim(), + chore_exempt: document.getElementById("edit-chore-exempt").checked, + chore_reminder_style: document.getElementById("edit-reminder-style").value.trim(), + }) + .then((result) => { + if (!result.ok) throw new Error(result.message || "Could not save."); + return postJson(`/people/${editing.id}/chore-assignments`, { chore_types: chores }); + }) + .then(() => refreshEditing("Saved.")) + .then(() => { + document.getElementById("edit-title").textContent = editing ? editing.name : ""; + }) + .catch((err) => setStatus(err.message, true)); +}); + +document.getElementById("edit-add-identifier").addEventListener("click", () => { + const input = document.getElementById("edit-new-identifier"); + const entityId = input.value.trim(); + if (!entityId) return; + postJson(`/people/${editing.id}/identifiers`, { entity_id: entityId }) + .then((result) => { + if (!result.ok) throw new Error(result.message); + input.value = ""; + return refreshEditing("Device added."); + }) + .catch((err) => setStatus(err.message, true)); +}); + +document.getElementById("grant-add").addEventListener("click", () => { + const entity = document.getElementById("grant-entity"); + const permission = document.getElementById("grant-permission"); + const expires = document.getElementById("grant-expires"); + if (!entity.value.trim()) return; + postJson(`/people/${editing.id}/device-grants`, { + entity_id: entity.value.trim(), + permission: permission.value.trim() || "operate", + // A date input gives "2026-08-01"; the grant expires at the START of that day, so + // "until 1 Aug" means the last usable day is 31 Jul. Sent explicitly as UTC + // midnight rather than left for the server to interpret a bare date. + expires_at: expires.value ? `${expires.value}T00:00:00Z` : null, + }) + .then((result) => { + if (!result.ok) throw new Error(result.message); + entity.value = ""; + permission.value = ""; + expires.value = ""; + return refreshEditing("Right granted."); + }) + .catch((err) => setStatus(err.message, true)); +}); + +document.getElementById("edit-delete").addEventListener("click", () => { + if (!confirm(`Delete ${editing.name} completely? Their devices, rights and visit history go too.`)) return; + api(`/people/${editing.id}`, { method: "DELETE" }) + .then(() => { + editor.close(); + loadPeople(); + }) + .catch((err) => setStatus(err.message, true)); +}); + +// --- Prune --------------------------------------------------------------------------- +// The two-step shape here is deliberate and matches the backend: /prune/candidates +// SELECTS, POST /people/prune DELETES the exact ids that came back and stayed ticked. +// The filter never gets re-run at delete time, so someone who walks in the door +// between "Select all" and "Delete selected" can't be swept up by a filter that +// silently re-evaluated. See prune_people()'s docstring in server.py. +const pruneList = document.getElementById("prune-list"); +const pruneDelete = document.getElementById("prune-delete"); +const pruneStatus = document.getElementById("prune-status"); + +function selectedPruneIds() { + return Array.from(pruneList.querySelectorAll("input[type=checkbox]:checked")).map((c) => Number(c.value)); +} + +function syncPruneButton() { + const n = selectedPruneIds().length; + pruneDelete.disabled = n === 0; + pruneDelete.textContent = n ? `Delete ${n} selected` : "Delete selected"; +} + +document.getElementById("prune-select").addEventListener("click", () => { + const date = document.getElementById("prune-date").value; + if (!date) { + document.getElementById("prune-summary").textContent = "Pick a date first."; + return; + } + pruneStatus.textContent = ""; + api(`/prune/candidates?last_visit_before=${encodeURIComponent(date)}`) + .then((data) => { + const summary = document.getElementById("prune-summary"); + if (!data.count) { + summary.textContent = `Nobody has a last visit before ${fmtDate(date)}.`; + pruneList.innerHTML = ""; + syncPruneButton(); + return; + } + summary.textContent = `${data.count} match${data.count === 1 ? "" : "es"} — untick anyone you want to keep.`; + pruneList.innerHTML = data.candidates + .map( + (p) => + `` + ) + .join(""); + pruneList.querySelectorAll("input").forEach((c) => c.addEventListener("change", syncPruneButton)); + syncPruneButton(); + }) + .catch((err) => { + document.getElementById("prune-summary").innerHTML = `${escapeHtml(err.message)}`; + }); +}); + +pruneDelete.addEventListener("click", () => { + const ids = selectedPruneIds(); + if (!ids.length) return; + const names = ids.map((id) => (people.find((p) => p.id === id) || {}).name || id); + if (!confirm(`Delete ${ids.length} record(s)?\n\n${names.join("\n")}\n\nThis cannot be undone.`)) return; + pruneStatus.textContent = "Deleting…"; + postJson("/people/prune", { person_ids: ids }) + .then((result) => { + pruneStatus.textContent = `Deleted ${result.deleted.length} record(s).`; + pruneList.innerHTML = ""; + document.getElementById("prune-summary").textContent = ""; + syncPruneButton(); + loadPeople(); + }) + .catch((err) => { + pruneStatus.innerHTML = `${escapeHtml(err.message)}`; + }); +}); + +// --- History -------------------------------------------------------------------------- +function populatePersonSelect() { + const select = document.getElementById("history-person"); + const current = select.value; + select.innerHTML = + '' + + people.map((p) => ``).join(""); + select.value = current; +} + +function loadHistory() { + const personId = document.getElementById("history-person").value; + const sinceDate = document.getElementById("history-since").value; + const since = sinceDate ? `${sinceDate}T00:00:00Z` : ""; + const query = since ? `?since=${encodeURIComponent(since)}` : ""; + const visitPath = personId ? `/people/${personId}/visits${query}` : `/visits${query}`; + + const el = document.getElementById("visit-list"); + el.innerHTML = '

Loading…

'; + api(visitPath) + .then((data) => { + const visits = data.visits || []; + if (!visits.length) { + el.innerHTML = '

No visits recorded in that range.

'; + return; + } + el.innerHTML = visits + .map( + (v) => + `
+ + ${escapeHtml(v.name)} + ${escapeHtml(fmt(v.arrived_at))} → ${ + v.ongoing ? "still home" : escapeHtml(fmt(v.departed_at)) + } · ${escapeHtml(duration(v.arrived_at, v.departed_at))} · via ${escapeHtml(v.source)}${ + v.close_reason === "timed_out" ? " · departure never observed" : "" + } + +
` + ) + .join(""); + }) + .catch((err) => { + el.innerHTML = `

Could not load visits: ${escapeHtml(err.message)}

`; + }); + + const copEl = document.getElementById("copresence-list"); + const copQuery = new URLSearchParams(); + if (personId) copQuery.set("person_id", personId); + if (since) copQuery.set("since", since); + api(`/co-presence?${copQuery.toString()}`) + .then((data) => { + const overlaps = data.overlaps || []; + if (!overlaps.length) { + copEl.innerHTML = '

Nobody overlapped in that range.

'; + return; + } + copEl.innerHTML = overlaps + .map( + (o) => + `
+ + ${escapeHtml(o.people.map((p) => p.name).join(" + "))} + ${escapeHtml(fmt(o.from))} → ${ + o.ongoing ? "now" : escapeHtml(fmt(o.until)) + } · ${escapeHtml(duration(o.from, o.ongoing ? null : o.until))} + +
` + ) + .join(""); + }) + .catch((err) => { + copEl.innerHTML = `

Could not load co-presence: ${escapeHtml(err.message)}

`; + }); +} + +document.getElementById("history-load").addEventListener("click", loadHistory); + +// --- Access log ------------------------------------------------------------------------ +function loadAccessLog() { + const el = document.getElementById("access-list"); + api("/device-access/events?limit=200") + .then((data) => { + const events = data.events || []; + if (!events.length) { + el.innerHTML = '

Nothing has asked yet.

'; + return; + } + el.innerHTML = events + .map( + (e) => + `
+ + ${escapeHtml(e.name || `person ${e.person_id}`)} → ${escapeHtml(e.ha_entity_id)} + ${escapeHtml(fmt(e.created_at))} · ${escapeHtml(e.permission)} · ${escapeHtml(e.reason)}${ + e.requested_via ? ` · via ${escapeHtml(e.requested_via)}` : "" + } + + ${e.allowed ? "allowed" : "denied"} +
` + ) + .join(""); + }) + .catch((err) => { + el.innerHTML = `

Could not load access log: ${escapeHtml(err.message)}

`; + }); +} + +loadPeople(); diff --git a/identity/frontend/style.css b/identity/frontend/style.css index 65f8721..d6c506b 100644 --- a/identity/frontend/style.css +++ b/identity/frontend/style.css @@ -194,3 +194,305 @@ section.block h2 { background: rgba(110, 168, 254, 0.14); display: inline-block; } + +/* --- Admin panel (admin.html) --------------------------------------------------- + * Everything under `body.admin` only. The rules above are tuned for a wall-mounted + * kiosk read from across a room — 64px buttons, 84px tabs, no scrolling; the admin + * page is read at arm's length on a phone or laptop by someone who came to do + * administration, so it opts into a denser scale rather than fighting those defaults. + * Sharing the file (rather than a second stylesheet) keeps the colour palette, + * card/avatar shapes and status colours identical across all three pages, which is + * the part that should NOT diverge. */ + +body.admin { + overflow: auto; +} + +body.admin #tabs { + height: 60px; + position: sticky; + top: 0; + z-index: 5; +} + +body.admin .tab { + font-size: 20px; +} + +body.admin main { + height: auto; + max-width: 860px; + margin: 0 auto; + overflow: visible; +} + +body.admin .card { + min-height: 0; + padding: 10px 14px; + width: 100%; + text-align: left; +} + +.card.as-button { + border: none; + color: inherit; + font: inherit; + cursor: pointer; +} + +.card.as-button:hover { + background: rgba(255, 255, 255, 0.10); +} + +.card-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +/* Long entity_ids and free-text notes must not stretch a card past the viewport — + * they wrap instead, since truncating them would hide the exact thing (which lock? + * which entity?) the row exists to show. */ +.card-body .card-meta, +.card-body .card-name { + overflow-wrap: anywhere; +} + +.card.compact { + gap: 10px; + font-size: 14px; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; +} + +.dot-home { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #7cf0a0; + vertical-align: middle; +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 14px; + color: #c8c8d0; +} + +.field.grow, +input.grow { + flex: 1; + min-width: 180px; +} + +.field input, +.field select, +.row input, +.row select { + min-height: 40px; + font-size: 15px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: #1a1a20; + color: #e8e8ec; +} + +.field-group { + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 10px; + padding: 12px 14px; + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.field-group legend { + font-size: 13px; + font-weight: 600; + color: #9a9aa6; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0 6px; +} + +.check { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + flex-wrap: wrap; +} + +.check input { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + font-size: 14px; +} + +.chip input { + width: 18px; + height: 18px; +} + +/* A deliberately smaller button than .big-btn: this page has destructive actions on + * it, and they should not be the easiest thing on screen to hit by accident. */ +.btn { + min-height: 40px; + padding: 0 16px; + font-size: 14px; + font-weight: 600; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.10); + color: #e8e8ec; + cursor: pointer; +} + +.btn:hover { + background: rgba(255, 255, 255, 0.16); +} + +.btn.primary { + background: #6ea8fe; + border-color: #6ea8fe; + color: #101014; +} + +.btn.danger { + color: #ff8080; + border-color: rgba(255, 128, 128, 0.4); + background: rgba(255, 128, 128, 0.10); +} + +.btn.danger:hover { + background: rgba(255, 128, 128, 0.20); +} + +.btn.ghost { + background: transparent; + border-color: transparent; + color: #9a9aa6; +} + +.btn.small { + min-height: 32px; + padding: 0 10px; + font-size: 13px; + flex-shrink: 0; +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.card.expired { + opacity: 0.55; +} + +.card.allowed { + border-left: 3px solid #7cf0a0; +} + +.card.denied { + border-left: 3px solid #ff8080; +} + +.verdict { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9a9aa6; + flex-shrink: 0; +} + +.card.allowed .verdict { + color: #7cf0a0; +} + +.card.denied .verdict { + color: #ff8080; +} + +/* --- Person editor dialog --- */ +#editor { + width: min(680px, 94vw); + max-height: 92vh; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 14px; + background: #17171d; + color: #e8e8ec; + overflow: hidden; +} + +#editor::backdrop { + background: rgba(0, 0, 0, 0.62); +} + +#editor form { + display: flex; + flex-direction: column; + max-height: 92vh; +} + +.editor-head { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.editor-head h3 { + flex: 1; + margin: 0; + font-size: 17px; +} + +.editor-body { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + overflow-y: auto; +} + +.editor-footer { + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 14px; + align-items: center; +} diff --git a/identity/identity.env.example b/identity/identity.env.example index 425a7ed..6030a27 100644 --- a/identity/identity.env.example +++ b/identity/identity.env.example @@ -55,6 +55,30 @@ MQTT_PASSWORD= FRIGATE_EVENTS_TOPIC=frigate/events FACE_PRESENCE_WINDOW_SECONDS=600 +# --------------------------------------------------------------------------- +# Visit history — "who was home when, and with whom". identity samples its own +# /presence on a timer and writes arrival/departure rows; nothing pushes events at +# it. See server.py's "VISITS ARE SAMPLED, NOT REPORTED". +# +# PRESENCE_POLL_SECONDS how often to sample. Each sample is a full HA /api/states +# fetch, so this is deliberately coarse — it's a household +# log, not a security audit trail. +# DEPARTURE_GRACE_SECONDS how long someone must read as "not home" before their +# visit is closed. BLE presence flaps (a phone in a pocket +# in the far room drops out and comes back); without this, +# one evening at home becomes forty "visits". The recorded +# departure time is the last moment they were actually seen, +# not when this window expired. +# VISIT_MAX_OPEN_HOURS safety net for a visit that never gets a definite "not +# home" to close it (someone device-less marked home by hand +# and never marked away). Closed as 'timed_out', never as +# 'departed' — the log never claims to have observed a +# departure it didn't. +# --------------------------------------------------------------------------- +PRESENCE_POLL_SECONDS=60 +DEPARTURE_GRACE_SECONDS=900 +VISIT_MAX_OPEN_HOURS=72 + # --------------------------------------------------------------------------- # Run behaviour # --------------------------------------------------------------------------- diff --git a/identity/server.py b/identity/server.py index 4cc26ca..bd5f9c5 100755 --- a/identity/server.py +++ b/identity/server.py @@ -32,13 +32,42 @@ safety benefit. This mirrors, rather than weakens, this project's existing "an identity merge must never auto-commit silently" rule (docs/project-plan.md's Identity store row): ambiguity is exactly the case that still requires a human. -Five endpoints: +NICKNAMES ARE FOR HUMANS TO SAY, NEVER FOR THE MACHINE TO SAY BACK: a person may have +a `nickname` that household members use to refer to them, and /resolve accepts it as +an *input* alias so "is Bibi home?" works. But every payload this service emits also +carries `speak_name`, which is ALWAYS the person's real name — voice/TTS consumers are +required to read `speak_name`, never `nickname`. The asymmetry is deliberate and is +the whole point of the field: a nickname is something people grant each other, not +something a machine should presume to use. + +VISITS ARE SAMPLED, NOT REPORTED: nothing pushes an arrival/departure event at this +service — `_presence_sampler_loop()` polls presence() on a timer and writes visit rows +on transitions. That makes the history honest about its own resolution (you know when +someone was *observed* home, to within one poll interval) and means "who was home when, +with whom" needs no separate table: co-presence is an interval-overlap query over +`visits`, not a second copy of the same truth that could drift from it. + +DEVICE RIGHTS ARE AN ANSWER, NEVER AN ACTION: `device_grants` records that a person may +operate a specific HA entity (the "let my cousin unlock the front door" case), and +GET /device-access answers yes/no with a reason. This service never calls a lock, opens +a door, or talks to a device — HA asks, HA acts, exactly the "HA mediates, nothing +auto-acts" rule the rest of this project follows. Every check is logged to +`device_access_events` whether it was allowed or denied, because for a door lock the +denied ones are the interesting ones. + +Endpoints: - POST /register/photo raw image bytes -> stored as an audit artifact only, NOT run through any face-matching (see README.md) - POST /register the main call, described above - GET /people admin/audit list of registered people + identifiers + - POST /people/ edit any editable field on a person (the admin panel) - DELETE /people//identifiers/ revoke a mistaken/compromised identifier - GET /presence who's currently home, resolved from registered identifiers + - GET /resolve spoken name OR nickname -> the canonical person + - GET /people//visits, GET /visits, GET /co-presence the visit history + - GET /prune/candidates, POST /people/prune last-visited-before cleanup + - GET /device-access, POST/DELETE /people//device-grants per-device rights + - GET/POST /people//chore-assignments who owes which chore - GET /weather proxies the household smarthome/weather/current MQTT topic """ @@ -54,11 +83,11 @@ import threading import time import urllib.error import urllib.request -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import urlsplit +from urllib.parse import parse_qs, urlsplit import paho.mqtt.client as mqtt @@ -110,6 +139,29 @@ MAX_JSON_BYTES = 32 * 1024 NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 '.-]{0,63}$") PHOTO_ID_RE = re.compile(r"^[a-f0-9]{16}$") +# Same character class as a name — a nickname is a name, just not the one on the +# birth certificate. Also matched against NAME_RE's length cap for the same reason. +NICKNAME_RE = NAME_RE +ENTITY_ID_RE = re.compile(r"^[a-z_]+\.[a-z0-9_]+$") + +# --- Visit sampling (see the module docstring's "VISITS ARE SAMPLED") --------------- +# How often the sampler asks presence() who's home. 60s is deliberately coarse: this +# is a household log ("was Amir home Tuesday evening"), not a security audit trail, +# and every sample is a full HA /api/states fetch. +PRESENCE_POLL_SECONDS = int(os.environ.get("PRESENCE_POLL_SECONDS", "60")) +# How long a person has to read as NOT home before their visit is closed. BLE presence +# flaps — a phone in a pocket in the far corner of the flat drops off and comes back — +# and without this every such flap would end one visit and start another, turning one +# evening at home into forty "visits". Closing uses the LAST time they were actually +# seen, not the moment the grace expired, so the recorded departure stays honest. +DEPARTURE_GRACE_SECONDS = int(os.environ.get("DEPARTURE_GRACE_SECONDS", "900")) +# Safety net for a visit that never gets a definite "not home" to close it — someone +# device-less who was marked home by hand and never marked away, or a person whose +# identifier disappeared from HA entirely. Closed with close_reason='timed_out' rather +# than 'departed' so the record never claims to have observed a departure it didn't. +VISIT_MAX_OPEN_HOURS = float(os.environ.get("VISIT_MAX_OPEN_HOURS", "72")) + +_sampler_stop = threading.Event() _db_lock = threading.Lock() @@ -141,6 +193,19 @@ def _db() -> sqlite3.Connection: return conn +def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None: + """Add a column to an existing table if it isn't there yet. `CREATE TABLE IF NOT + EXISTS` only ever creates a table that's missing entirely — it silently does + nothing to a table that already exists with an older shape, so a deployment that + has been running since before a column was added would never gain it. Cheap enough + to run unconditionally at every startup (one PRAGMA per column). + """ + existing = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} + if column not in existing: + LOG.info("identity: migrating — adding %s.%s", table, column) + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}") + + def init_db() -> None: with _db_lock, _db() as conn: conn.executescript( @@ -163,7 +228,81 @@ def init_db() -> None: -- or "be gentle, give me a few minutes of grace" — passed to the -- LLM that phrases (never decides) chore reminders. NULL/empty means -- the plain, un-styled template. - chore_reminder_style TEXT + chore_reminder_style TEXT, + -- nickname: what the household CALLS this person. An input alias for + -- /resolve only — never what a voice assistant says back, see the + -- module docstring and speak_name below. Deliberately not UNIQUE at + -- the SQL level: the constraint that actually matters is "must not + -- collide with anyone else's name OR nickname," which spans two + -- columns and is enforced in _nickname_conflict(). + nickname TEXT, + -- note: free-text admin scratchpad ("Sarah's cousin, visits at + -- Christmas"). Never parsed by anything, never shown to the LLM. + note TEXT + ); + CREATE TABLE IF NOT EXISTS visits ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + arrived_at TEXT NOT NULL, + -- NULL means "still here" — exactly one open row per person at a time. + departed_at TEXT, + -- The most recent sample that still said "home." Departure is recorded + -- as of THIS, not as of when the grace window expired, so a visit never + -- claims someone was home during the 15 minutes we were waiting to be + -- sure they'd gone. + last_seen_at TEXT NOT NULL, + -- Which signal opened the visit: 'ble' | 'face' | 'manual'. Kept + -- because they are not equally trustworthy and a history that mixes + -- them without saying so would be quietly misleading. + source TEXT NOT NULL, + -- 'departed' (observed a definite not-home) | 'timed_out' (never got + -- one, closed by VISIT_MAX_OPEN_HOURS). Never conflated — see that + -- constant's comment. + close_reason TEXT + ); + CREATE INDEX IF NOT EXISTS visits_person_arrived + ON visits (person_id, arrived_at); + -- Partial index over open visits only: the sampler's hottest query is + -- "who has a visit open right now," once per person per poll. + CREATE INDEX IF NOT EXISTS visits_open + ON visits (person_id) WHERE departed_at IS NULL; + CREATE TABLE IF NOT EXISTS device_grants ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + ha_entity_id TEXT NOT NULL, + -- Free-form verb, defaulting to 'operate'. Not an enum because what + -- "operate" means is the caller's business (HA's), not this registry's + -- — it stores who may do what, it does not model device capabilities. + permission TEXT NOT NULL DEFAULT 'operate', + granted_at TEXT NOT NULL, + -- NULL = open-ended. A set expiry is the "cousin has the front door + -- for the weekend" case; it is checked at answer time, never by a + -- sweep, so a grant that lapses does so instantly rather than whenever + -- a cleanup job next happens to run. + expires_at TEXT, + note TEXT, + UNIQUE (person_id, ha_entity_id, permission) + ); + CREATE TABLE IF NOT EXISTS device_access_events ( + id INTEGER PRIMARY KEY, + -- Nullable on purpose: a check for an unknown person_id is exactly the + -- kind of thing worth having a row for. + person_id INTEGER, + ha_entity_id TEXT NOT NULL, + permission TEXT NOT NULL, + allowed INTEGER NOT NULL, + reason TEXT NOT NULL, + requested_via TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS device_access_events_created + ON device_access_events (created_at); + CREATE TABLE IF NOT EXISTS chore_assignments ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + chore_type TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (person_id, chore_type) ); CREATE TABLE IF NOT EXISTS identifiers ( id INTEGER PRIMARY KEY, @@ -187,6 +326,10 @@ def init_db() -> None: ); """ ) + # Columns added to `people` after the table first shipped — see + # _ensure_column()'s docstring for why the CREATE TABLE above isn't enough. + _ensure_column(conn, "people", "nickname", "TEXT") + _ensure_column(conn, "people", "note", "TEXT") def _ha_get(path: str): @@ -231,15 +374,78 @@ def _already_claimed_by(conn: sqlite3.Connection, entity_id: str) -> sqlite3.Row ).fetchone() +def _match_by_name_or_nickname(conn: sqlite3.Connection, spoken: str) -> list[sqlite3.Row]: + """Every person whose real name OR nickname matches `spoken`, case-insensitively. + Returns a LIST, not a single row, because the interesting case is when it has more + than one entry: someone's nickname colliding with someone else's real name is + exactly the ambiguity this project refuses to guess its way through. Callers must + decide what to do with >1 rather than silently taking the first. + """ + return conn.execute( + "SELECT * FROM people WHERE name = ? COLLATE NOCASE OR nickname = ? COLLATE NOCASE", + (spoken, spoken), + ).fetchall() + + +def _nickname_conflict(conn: sqlite3.Connection, nickname: str, person_id: int) -> str | None: + """The name a nickname must not steal: anyone ELSE's real name or nickname. Returns + the conflicting person's name, or None if the nickname is free. Enforced here in + Python rather than as a SQL constraint because it spans two columns across all + rows — see the `nickname` column comment. + """ + row = conn.execute( + "SELECT name FROM people WHERE id != ? " + "AND (name = ? COLLATE NOCASE OR nickname = ? COLLATE NOCASE)", + (person_id, nickname, nickname), + ).fetchone() + return row["name"] if row else None + + def _find_or_create_person(conn: sqlite3.Connection, name: str) -> tuple[int, bool]: - row = conn.execute("SELECT id FROM people WHERE name = ? COLLATE NOCASE", (name,)).fetchone() - if row: - return row["id"], False + """Resolves an existing person by real name or NICKNAME before creating one — so + "register me as Bibi" attaches Bibi's second phone to the person already recorded + as Linus rather than minting a duplicate. Raises AmbiguousName if the spoken string + matches two different people (see _match_by_name_or_nickname); callers turn that + into the same refuse-and-ask-a-human response as an ambiguous BLE candidate. + """ + matches = _match_by_name_or_nickname(conn, name) + if len(matches) > 1: + raise AmbiguousName([m["name"] for m in matches]) + if matches: + return matches[0]["id"], False cur = conn.execute("INSERT INTO people (name, created_at) VALUES (?, ?)", (name, _now())) assert cur.lastrowid is not None return cur.lastrowid, True +class AmbiguousName(Exception): + """A spoken name matched more than one person (one by real name, another by + nickname). Never resolved by picking one — see the module docstring's + NEVER AUTO-COMMIT ON AMBIGUITY rule, which this is a second instance of. + """ + + def __init__(self, names: list[str]) -> None: + super().__init__(", ".join(names)) + self.names = names + + +def _ambiguous_name_response(exc: AmbiguousName) -> dict: + """The refusal shape for AmbiguousName, shared by every registration path. Names + the colliding people out loud — the human standing at the panel is the one who can + fix this (by picking, or by changing a nickname), and they can't if the message + just says "ambiguous". + """ + return { + "ok": False, + "reason": "ambiguous_name", + "candidates": [{"name": n} for n in exc.names], + "message": ( + f"That name matches more than one person here ({', '.join(exc.names)}) — " + "say a full name, or clear up the nickname in the admin panel." + ), + } + + def _set_profile_photo(conn: sqlite3.Connection, person_id: int, photo_path: str | None) -> None: """Every successful registration that captured a photo updates the person's profile picture to it — "most recent registration photo wins" rather than @@ -269,7 +475,10 @@ def register( # now is a different situation from someone who was never going to have one). if no_device: with _db_lock, _db() as conn: - person_id, is_new_person = _find_or_create_person(conn, name) + try: + person_id, is_new_person = _find_or_create_person(conn, name) + except AmbiguousName as exc: + return _ambiguous_name_response(exc) _set_profile_photo(conn, person_id, photo_path) _log_event(conn, person_id, device_id, photo_path, "registered_no_device") return { @@ -319,8 +528,17 @@ def register( else: chosen = candidates[0] + # Whether this device is already someone's is compared against the RESOLVED + # person, not the raw spoken string — otherwise registering a second phone as + # "Bibi" when the first is filed under Linus (whose nickname is Bibi) would + # look like a stranger claiming Linus's device and get refused. + spoken_matches = _match_by_name_or_nickname(conn, name) + if len(spoken_matches) > 1: + return _ambiguous_name_response(AmbiguousName([m["name"] for m in spoken_matches])) + spoken_person_id = spoken_matches[0]["id"] if spoken_matches else None + claimed_by = _already_claimed_by(conn, chosen["entity_id"]) - if claimed_by is not None and claimed_by["name"].lower() != name.lower(): + if claimed_by is not None and claimed_by["id"] != spoken_person_id: _log_event(conn, claimed_by["id"], device_id, photo_path, "already_claimed") return { "ok": False, @@ -342,7 +560,10 @@ def register( "message": f"You're already registered, {claimed_by['name']}.", } - person_id, is_new_person = _find_or_create_person(conn, name) + try: + person_id, is_new_person = _find_or_create_person(conn, name) + except AmbiguousName as exc: + return _ambiguous_name_response(exc) conn.execute( "INSERT INTO identifiers (person_id, ha_entity_id, registered_at, registered_via_device) " "VALUES (?, ?, ?, ?)", @@ -379,34 +600,69 @@ def _log_event(conn, person_id, device_id, photo_path, outcome) -> None: ) +def _person_payload(conn: sqlite3.Connection, person: sqlite3.Row) -> dict: + """One person's full admin-facing record. Shared by list_people() and the + single-person reads so the admin panel never has to reconcile two shapes. + """ + identifiers = conn.execute( + "SELECT id, ha_entity_id, registered_at, registered_via_device " + "FROM identifiers WHERE person_id = ? ORDER BY registered_at", + (person["id"],), + ).fetchall() + grants = conn.execute( + "SELECT id, ha_entity_id, permission, granted_at, expires_at, note " + "FROM device_grants WHERE person_id = ? ORDER BY ha_entity_id", + (person["id"],), + ).fetchall() + assignments = conn.execute( + "SELECT chore_type FROM chore_assignments WHERE person_id = ? ORDER BY chore_type", + (person["id"],), + ).fetchall() + stats = conn.execute( + "SELECT COUNT(*) AS n, MAX(arrived_at) AS last_arrived FROM visits WHERE person_id = ?", + (person["id"],), + ).fetchone() + open_visit = conn.execute( + "SELECT arrived_at FROM visits WHERE person_id = ? AND departed_at IS NULL", + (person["id"],), + ).fetchone() + + return { + "id": person["id"], + "name": person["name"], + "nickname": person["nickname"], + # THE FIELD VOICE/TTS CONSUMERS MUST USE. Always the real name, never the + # nickname, no matter which of the two the human said to get here — see the + # module docstring. Emitted as its own key rather than leaving callers to + # "just use name" because a caller that has both fields in front of it will + # eventually reach for the friendlier-looking one. + "speak_name": person["name"], + "note": person["note"], + "created_at": person["created_at"], + # The path itself is never exposed — an internal container + # filesystem detail — just whether GET /people//photo has + # anything to serve. + "has_photo": person["photo_path"] is not None, + "chore_exempt": bool(person["chore_exempt"]), + "chore_reminder_style": person["chore_reminder_style"], + "chore_assignments": [a["chore_type"] for a in assignments], + "identifiers": [dict(i) for i in identifiers], + "device_grants": [dict(g) for g in grants], + "visit_count": stats["n"], + # Falls back to created_at for someone with no recorded visits at all — a + # person registered before visit sampling existed, or one who has genuinely + # never been seen home. Pruning needs SOME date for everyone or the never- + # visited (the most prunable people of all) would silently never be selected. + "last_visit_at": stats["last_arrived"] or person["created_at"], + "last_visit_is_estimated": stats["last_arrived"] is None, + "currently_home_since": open_visit["arrived_at"] if open_visit else None, + } + + def list_people() -> list[dict]: with _db_lock, _db() as conn: - people = conn.execute( - "SELECT id, name, created_at, photo_path, chore_exempt, chore_reminder_style " - "FROM people ORDER BY name COLLATE NOCASE" - ).fetchall() - result = [] - for person in people: - identifiers = conn.execute( - "SELECT id, ha_entity_id, registered_at, registered_via_device " - "FROM identifiers WHERE person_id = ? ORDER BY registered_at", - (person["id"],), - ).fetchall() - result.append( - { - "id": person["id"], - "name": person["name"], - "created_at": person["created_at"], - # The path itself is never exposed — an internal container - # filesystem detail — just whether GET /people//photo has - # anything to serve. - "has_photo": person["photo_path"] is not None, - "chore_exempt": bool(person["chore_exempt"]), - "chore_reminder_style": person["chore_reminder_style"], - "identifiers": [dict(i) for i in identifiers], - } - ) - return result + people = conn.execute("SELECT * FROM people ORDER BY name COLLATE NOCASE").fetchall() + return [_person_payload(conn, person) for person in people] def set_chore_settings(person_id: int, chore_exempt: bool | None, reminder_style: str | None) -> bool: @@ -427,6 +683,158 @@ def set_chore_settings(person_id: int, chore_exempt: bool | None, reminder_style return True +def update_person(person_id: int, fields: dict) -> dict: + """The admin panel's "edit every field" call. Only keys actually present in + `fields` are touched — a partial edit never blanks the fields it didn't mention, + which is what makes the frontend able to send one changed input rather than + round-tripping the whole record and racing anyone else editing it. + + `created_at` is deliberately NOT editable: it records when this person entered the + household's records, which is a fact about what happened, not a preference. Neither + is `id`. Everything a human might actually want to change is here, including + renaming a "Guest 4" into a real person once you learn who they are — that's the + intended promotion path, not a separate endpoint. + """ + with _db_lock, _db() as conn: + person = conn.execute("SELECT * FROM people WHERE id = ?", (person_id,)).fetchone() + if person is None: + return {"ok": False, "reason": "not_found", "message": "No such person."} + + updates: list[tuple[str, object]] = [] + + if "name" in fields: + name = str(fields["name"] or "").strip() + if not NAME_RE.match(name): + return {"ok": False, "reason": "bad_name", "message": "That name doesn't look valid."} + clash = conn.execute( + "SELECT name FROM people WHERE id != ? " + "AND (name = ? COLLATE NOCASE OR nickname = ? COLLATE NOCASE)", + (person_id, name, name), + ).fetchone() + if clash: + return { + "ok": False, + "reason": "name_taken", + "message": f"{name} already belongs to someone here.", + } + updates.append(("name", name)) + + if "nickname" in fields: + raw = fields["nickname"] + nickname = str(raw or "").strip() + if not nickname: + updates.append(("nickname", None)) # clearing it is a legitimate edit + elif not NICKNAME_RE.match(nickname): + return {"ok": False, "reason": "bad_nickname", "message": "That nickname doesn't look valid."} + else: + clash = _nickname_conflict(conn, nickname, person_id) + if clash: + return { + "ok": False, + "reason": "nickname_taken", + "message": f"“{nickname}” would collide with {clash} — pick another.", + } + updates.append(("nickname", nickname)) + + if "chore_exempt" in fields: + updates.append(("chore_exempt", int(bool(fields["chore_exempt"])))) + + for text_field in ("chore_reminder_style", "note"): + if text_field in fields: + value = fields[text_field] + if value is not None and not isinstance(value, str): + return {"ok": False, "reason": "bad_field", "message": f"'{text_field}' must be text."} + updates.append((text_field, (value or "").strip() or None)) + + # Clearing the profile picture is an edit like any other. The file itself is + # left on disk on purpose — it's also a registration_events audit artifact, + # and "stop showing this photo" is a different request from "destroy the + # record that this photo was taken." + if fields.get("clear_photo"): + updates.append(("photo_path", None)) + + for column, value in updates: + conn.execute(f"UPDATE people SET {column} = ? WHERE id = ?", (value, person_id)) + + updated = conn.execute("SELECT * FROM people WHERE id = ?", (person_id,)).fetchone() + return {"ok": True, "person": _person_payload(conn, updated), "changed": [c for c, _ in updates]} + + +def add_identifier(person_id: int, entity_id: str) -> dict: + """Attach an identifier by hand, for the case registration can't cover: a fixed-MAC + BLE tag provisioned for someone before they (or the tag) are anywhere near the + door panel. + + STILL BEHIND THE ANTI-SPOOFING BOUNDARY: the entity_id must match + TRUSTED_ENTITY_PREFIXES, exactly like a registration candidate. What this endpoint + relaxes is only the "must be present RIGHT NOW" requirement — being in the room is + a convenience check, whereas the trusted-prefix allowlist is the actual security + property (see the module docstring), and an admin panel is not a reason to hand out + an exception to it. + """ + entity_id = entity_id.strip() + if not ENTITY_ID_RE.match(entity_id): + return {"ok": False, "reason": "bad_entity_id", "message": "That doesn't look like an entity_id."} + if not entity_id.startswith(TRUSTED_ENTITY_PREFIXES): + return { + "ok": False, + "reason": "untrusted_entity", + "message": ( + "That entity isn't in TRUSTED_ENTITY_PREFIXES — only IRK-resolved " + "Private BLE Device or fixed-tag entities can be identifiers." + ), + } + with _db_lock, _db() as conn: + if conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone() is None: + return {"ok": False, "reason": "not_found", "message": "No such person."} + claimed = _already_claimed_by(conn, entity_id) + if claimed is not None: + return { + "ok": False, + "reason": "already_claimed", + "message": f"That device already belongs to {claimed['name']}.", + } + conn.execute( + "INSERT INTO identifiers (person_id, ha_entity_id, registered_at, registered_via_device) " + "VALUES (?, ?, ?, ?)", + (person_id, entity_id, _now(), "admin-panel"), + ) + return {"ok": True, "message": f"Added {entity_id}."} + + +def resolve_name(spoken: str) -> dict: + """Spoken name or nickname -> the canonical person. The point of the whole nickname + feature on the input side: household members say "is Bibi home?", this turns that + into Linus's record. The response always carries `speak_name` (the real name) so + the assistant that asked can phrase its answer with the right one — see the module + docstring's NICKNAMES ARE FOR HUMANS TO SAY rule. + """ + spoken = (spoken or "").strip() + if not spoken: + return {"matched": False, "reason": "empty"} + with _db_lock, _db() as conn: + matches = _match_by_name_or_nickname(conn, spoken) + if not matches: + return {"matched": False, "reason": "unknown", "query": spoken} + if len(matches) > 1: + return { + "matched": False, + "reason": "ambiguous_name", + "query": spoken, + "candidates": [{"id": m["id"], "name": m["name"]} for m in matches], + } + person = matches[0] + return { + "matched": True, + "query": spoken, + "id": person["id"], + "name": person["name"], + "nickname": person["nickname"], + "speak_name": person["name"], + "matched_on": "name" if (person["name"] or "").lower() == spoken.lower() else "nickname", + } + + def get_person_photo(person_id: int) -> bytes | None: with _db_lock, _db() as conn: row = conn.execute("SELECT photo_path FROM people WHERE id = ?", (person_id,)).fetchone() @@ -571,6 +979,11 @@ def presence() -> dict: { "id": person["id"], "name": person["name"], + "nickname": person["nickname"], + # Always the real name — see the module docstring. `chores/` phrases + # reminders off this, so a nudge never addresses someone by a nickname + # the machine wasn't given. + "speak_name": person["speak_name"], "home": home, "room": room, "has_device": bool(person["identifiers"]), @@ -580,6 +993,7 @@ def presence() -> dict: # for how these are used (never anything presence-related itself). "chore_exempt": person["chore_exempt"], "chore_reminder_style": person["chore_reminder_style"], + "chore_assignments": person["chore_assignments"], } ) @@ -589,6 +1003,365 @@ def presence() -> dict: return payload +# --- Visit history: who was home when, and with whom ------------------------------- +# See the module docstring's "VISITS ARE SAMPLED, NOT REPORTED" for the shape of this +# and why co-presence is a query rather than a table. + + +def _parse_iso(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _record_presence_sample(sample: dict) -> None: + """One sampler pass: turn "who reads as home right now" into open/closed visit + rows. Three states in, three different behaviours out, and the third is the one + that matters: + + home is True -> open a visit, or extend the open one's last_seen_at + home is False -> close the open visit, but only once DEPARTURE_GRACE_SECONDS + of not-home have passed (BLE flaps; see that constant) + home is None -> DO NOTHING AT ALL. "Unknown" is not "away". A device-less + person nobody has toggled, or an HA outage, must never write a + departure into the history — an inferred absence recorded as an + observed one is a lie the history can never be un-told. + """ + if sample.get("error") == "ha_unreachable": + # Every BLE-backed person reads as unknown during an outage, so there's nothing + # trustworthy to record. Skipping the whole pass (rather than just the unknowns) + # also keeps the VISIT_MAX_OPEN_HOURS sweep from counting an outage against a + # visit it can't currently see the state of. + return + + now = _now() + with _db_lock, _db() as conn: + for person in sample.get("people", []): + person_id = person["id"] + open_visit = conn.execute( + "SELECT * FROM visits WHERE person_id = ? AND departed_at IS NULL", (person_id,) + ).fetchone() + + if person.get("home") is True: + if open_visit is None: + source = "face" if person.get("face_seen_recently") else ( + "ble" if person.get("has_device") else "manual" + ) + conn.execute( + "INSERT INTO visits (person_id, arrived_at, last_seen_at, source) " + "VALUES (?, ?, ?, ?)", + (person_id, now, now, source), + ) + LOG.info("identity: %s arrived (source: %s)", person["name"], source) + else: + conn.execute("UPDATE visits SET last_seen_at = ? WHERE id = ?", (now, open_visit["id"])) + + elif person.get("home") is False and open_visit is not None: + gone_for = (_parse_iso(now) - _parse_iso(open_visit["last_seen_at"])).total_seconds() + if gone_for >= DEPARTURE_GRACE_SECONDS: + conn.execute( + "UPDATE visits SET departed_at = ?, close_reason = 'departed' WHERE id = ?", + (open_visit["last_seen_at"], open_visit["id"]), + ) + LOG.info("identity: %s departed (last seen %s)", person["name"], open_visit["last_seen_at"]) + + # The stuck-visit sweep — see VISIT_MAX_OPEN_HOURS. + cutoff = (datetime.now(timezone.utc) - timedelta(hours=VISIT_MAX_OPEN_HOURS)).isoformat().replace("+00:00", "Z") + stale = conn.execute( + "UPDATE visits SET departed_at = last_seen_at, close_reason = 'timed_out' " + "WHERE departed_at IS NULL AND last_seen_at < ?", + (cutoff,), + ) + if stale.rowcount: + LOG.info("identity: closed %d visit(s) that went stale past %sh", stale.rowcount, VISIT_MAX_OPEN_HOURS) + + +def _presence_sampler_loop() -> None: + """The only thing in this service that runs on its own initiative. Deliberately a + plain daemon thread rather than a systemd timer + separate entrypoint (like + chores/): the visit log is this service's own state, sampled from its own + presence() — handing that to an external scheduler would mean a second process + writing the same SQLite file for no gain. + + Never lets an exception kill the thread: a sampler that dies quietly would leave a + permanently frozen visit history that still *looks* fine from the outside, the + worst possible failure mode for a log. + """ + while not _sampler_stop.wait(PRESENCE_POLL_SECONDS): + try: + _record_presence_sample(presence()) + except Exception: + LOG.warning("identity: presence sampler pass failed, will retry", exc_info=True) + + +def list_visits(person_id: int | None = None, since: str | None = None, limit: int = 200) -> list[dict]: + clauses, params = [], [] + if person_id is not None: + clauses.append("visits.person_id = ?") + params.append(person_id) + if since: + clauses.append("visits.arrived_at >= ?") + params.append(since) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + params.append(max(1, min(limit, 1000))) + with _db_lock, _db() as conn: + rows = conn.execute( + f""" + SELECT visits.*, people.name, people.nickname + FROM visits JOIN people ON people.id = visits.person_id + {where} + ORDER BY visits.arrived_at DESC + LIMIT ? + """, + params, + ).fetchall() + return [ + { + "id": r["id"], + "person_id": r["person_id"], + "name": r["name"], + "speak_name": r["name"], + "nickname": r["nickname"], + "arrived_at": r["arrived_at"], + "departed_at": r["departed_at"], + "last_seen_at": r["last_seen_at"], + "source": r["source"], + "close_reason": r["close_reason"], + "ongoing": r["departed_at"] is None, + } + for r in rows + ] + + +def co_presence(person_id: int | None = None, since: str | None = None, limit: int = 200) -> dict: + """"Who was home when, with whom" — computed by overlapping visit intervals, never + stored. Two visits overlap iff each starts before the other ends, with an open + visit (departed_at IS NULL) treated as ending now. + + Deriving it means it can never disagree with the visit log it came from, and it + costs nothing to keep correct when a visit is later closed or corrected. The + tradeoff is honest: this is O(visits²) inside the window, which is fine for a + household's worth of rows and would not be for a venue's. + """ + visits = list_visits(person_id=None, since=since, limit=1000) + now = _now() + + def overlap(a: dict, b: dict) -> tuple[str, str] | None: + start = max(a["arrived_at"], b["arrived_at"]) + end = min(a["departed_at"] or now, b["departed_at"] or now) + return (start, end) if start < end else None + + subject = [v for v in visits if person_id is None or v["person_id"] == person_id] + overlaps = [] + for visit in subject: + for other in visits: + if other["person_id"] == visit["person_id"] or other["id"] == visit["id"]: + continue + # Without this, every pair shows up twice (once from each side) whenever + # the caller didn't narrow to one person. + if person_id is None and other["id"] < visit["id"]: + continue + window = overlap(visit, other) + if window is None: + continue + overlaps.append( + { + "from": window[0], + "until": window[1], + "ongoing": visit["departed_at"] is None and other["departed_at"] is None, + "people": [ + {"id": visit["person_id"], "name": visit["name"]}, + {"id": other["person_id"], "name": other["name"]}, + ], + } + ) + overlaps.sort(key=lambda o: o["from"], reverse=True) + return {"overlaps": overlaps[: max(1, min(limit, 1000))], "generated_at": now} + + +# --- Pruning: last-visited-before --------------------------------------------------- +def prune_candidates(last_visit_before: str) -> dict: + """Everyone whose most recent visit predates `last_visit_before` — the admin + panel's "select all that have last visited before " checkbox filter. A READ. + It selects; it never deletes. See prune_people() for why those are separate calls. + + Someone with no recorded visits at all falls back to their created_at (see + _person_payload) — otherwise a person registered once and never seen since, the + single most prunable record there is, would be the one this filter could never + find. + """ + people = list_people() + matching = [p for p in people if p["last_visit_at"] < last_visit_before] + return { + "last_visit_before": last_visit_before, + "candidates": matching, + "count": len(matching), + "generated_at": _now(), + } + + +def prune_people(person_ids: list[int]) -> dict: + """Bulk-delete BY EXPLICIT ID, never by filter. The frontend runs prune_candidates() + to fill in the checkboxes and then sends back the ids the human actually looked at + and confirmed. + + That indirection is the point: a filter re-evaluated at delete time could quietly + take someone who came home in the seconds between the preview and the click, and + "the list I approved is the list that got deleted" is worth one extra round trip + for an irreversible operation on people's records. + """ + deleted, missing = [], [] + with _db_lock, _db() as conn: + for person_id in person_ids: + row = conn.execute("SELECT name FROM people WHERE id = ?", (person_id,)).fetchone() + if row is None: + missing.append(person_id) + continue + conn.execute("DELETE FROM people WHERE id = ?", (person_id,)) + deleted.append({"id": person_id, "name": row["name"]}) + if deleted: + LOG.info("identity: pruned %d person record(s): %s", len(deleted), ", ".join(d["name"] for d in deleted)) + return {"ok": True, "deleted": deleted, "not_found": missing} + + +# --- Per-device rights -------------------------------------------------------------- +# See the module docstring's "DEVICE RIGHTS ARE AN ANSWER, NEVER AN ACTION". + + +def grant_device(person_id: int, entity_id: str, permission: str, expires_at: str | None, note: str | None) -> dict: + entity_id = (entity_id or "").strip() + if not ENTITY_ID_RE.match(entity_id): + return {"ok": False, "reason": "bad_entity_id", "message": "That doesn't look like an entity_id."} + permission = (permission or "operate").strip() or "operate" + if expires_at: + try: + _parse_iso(expires_at) + except ValueError: + return {"ok": False, "reason": "bad_expiry", "message": "expires_at must be an ISO-8601 timestamp."} + with _db_lock, _db() as conn: + if conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone() is None: + return {"ok": False, "reason": "not_found", "message": "No such person."} + conn.execute( + "INSERT INTO device_grants (person_id, ha_entity_id, permission, granted_at, expires_at, note) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT(person_id, ha_entity_id, permission) DO UPDATE SET " + "expires_at = excluded.expires_at, note = excluded.note, granted_at = excluded.granted_at", + (person_id, entity_id, permission, _now(), expires_at or None, (note or "").strip() or None), + ) + LOG.info("identity: granted %s on %s to person %d", permission, entity_id, person_id) + return {"ok": True, "message": f"Granted {permission} on {entity_id}."} + + +def revoke_device_grant(person_id: int, grant_id: int) -> bool: + with _db_lock, _db() as conn: + cur = conn.execute("DELETE FROM device_grants WHERE id = ? AND person_id = ?", (grant_id, person_id)) + return cur.rowcount > 0 + + +def check_device_access(person_id: int, entity_id: str, permission: str, requested_via: str | None) -> dict: + """"May this person operate this device?" — the call HA makes before it unlocks + anything. Answers, logs, and returns; it never touches the device itself. + + DENY IS THE DEFAULT AND THE ONLY FALLBACK. Every path that isn't an unexpired + matching grant returns allowed: false, including a person who doesn't exist and a + grant whose expiry has passed. This is the one place in this service where failing + closed matters more than degrading gracefully: everything else here would rather + report "unknown" than guess, but a lock has no useful "unknown" — the door is + either opened or it isn't, and the safe half of that is "isn't". + """ + permission = (permission or "operate").strip() or "operate" + entity_id = (entity_id or "").strip() + now = _now() + + with _db_lock, _db() as conn: + person = conn.execute("SELECT name FROM people WHERE id = ?", (person_id,)).fetchone() + if person is None: + allowed, reason, grant = False, "unknown_person", None + else: + grant = conn.execute( + "SELECT * FROM device_grants WHERE person_id = ? AND ha_entity_id = ? AND permission = ?", + (person_id, entity_id, permission), + ).fetchone() + if grant is None: + allowed, reason = False, "no_grant" + elif grant["expires_at"] and grant["expires_at"] <= now: + allowed, reason = False, "expired" + else: + allowed, reason = True, "granted" + + conn.execute( + "INSERT INTO device_access_events " + "(person_id, ha_entity_id, permission, allowed, reason, requested_via, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (person_id, entity_id, permission, int(allowed), reason, requested_via, now), + ) + + LOG.info( + "identity: device-access %s — person %d on %s (%s): %s", + "ALLOW" if allowed else "DENY", person_id, entity_id, permission, reason, + ) + return { + "allowed": allowed, + "reason": reason, + "person_id": person_id, + "person_name": person["name"] if person else None, + "entity_id": entity_id, + "permission": permission, + "expires_at": grant["expires_at"] if grant else None, + "checked_at": now, + } + + +def list_device_access_events(limit: int = 100) -> list[dict]: + with _db_lock, _db() as conn: + rows = conn.execute( + "SELECT device_access_events.*, people.name FROM device_access_events " + "LEFT JOIN people ON people.id = device_access_events.person_id " + "ORDER BY device_access_events.created_at DESC LIMIT ?", + (max(1, min(limit, 1000)),), + ).fetchall() + return [{**dict(r), "allowed": bool(r["allowed"])} for r in rows] + + +# --- Chore assignments -------------------------------------------------------------- +def set_chore_assignments(person_id: int, chore_types: list[str]) -> dict: + """Replaces this person's whole assignment set — the admin panel edits it as a row + of checkboxes, so "what's ticked now" is the natural unit, not per-type add/remove. + + `chores/` treats an assignment as a strong PREFERENCE, not a lock (see + chores/README.md): the household principle is still "I don't care who does it as + long as it gets done," so if the assignee isn't home the nudge falls through to + whoever is, unless CHORE_ASSIGNMENT_STRICT is on over there. Litter ignores + assignments entirely, for the same reason it ignores exemptions — putting your own + trash in the bin was never a chore anyone could be assigned. + """ + cleaned = sorted({str(t).strip().lower() for t in chore_types if str(t).strip()}) + with _db_lock, _db() as conn: + if conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone() is None: + return {"ok": False, "reason": "not_found", "message": "No such person."} + conn.execute("DELETE FROM chore_assignments WHERE person_id = ?", (person_id,)) + for chore_type in cleaned: + conn.execute( + "INSERT INTO chore_assignments (person_id, chore_type, created_at) VALUES (?, ?, ?)", + (person_id, chore_type, _now()), + ) + return {"ok": True, "chore_assignments": cleaned} + + +def chore_assignments() -> dict: + """chore_type -> the people assigned to it. The shape `chores/` actually wants; + /people carries the same facts per-person for the admin panel's benefit. + """ + with _db_lock, _db() as conn: + rows = conn.execute( + "SELECT chore_assignments.chore_type, people.id, people.name " + "FROM chore_assignments JOIN people ON people.id = chore_assignments.person_id " + "ORDER BY chore_assignments.chore_type, people.name COLLATE NOCASE" + ).fetchall() + by_type: dict[str, list[dict]] = {} + for row in rows: + by_type.setdefault(row["chore_type"], []).append({"id": row["id"], "name": row["name"]}) + return {"assignments": by_type, "generated_at": _now()} + + def _on_weather_message(_client, _userdata, message) -> None: try: payload = json.loads(message.payload.decode("utf-8", "replace")) @@ -687,12 +1460,54 @@ class Handler(BaseHTTPRequestHandler): if not self._authorized(): self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"}) return - path = urlsplit(self.path).path + split = urlsplit(self.path) + path = split.path + query = parse_qs(split.query) + + def q(key: str, default: str = "") -> str: + return (query.get(key) or [default])[0] + + def q_int(key: str, default: int) -> int: + try: + return int(q(key, str(default))) + except ValueError: + return default + photo_match = re.match(r"^/people/(\d+)/photo$", path) + visits_match = re.match(r"^/people/(\d+)/visits$", path) + assignments_match = re.match(r"^/people/(\d+)/chore-assignments$", path) + if path == "/people": self._respond(HTTPStatus.OK, {"people": list_people()}) elif path == "/presence": self._respond(HTTPStatus.OK, presence()) + elif path == "/resolve": + self._respond(HTTPStatus.OK, resolve_name(q("q") or q("name"))) + elif path == "/visits": + self._respond(HTTPStatus.OK, {"visits": list_visits(since=q("since") or None, limit=q_int("limit", 200))}) + elif visits_match: + self._respond( + HTTPStatus.OK, + {"visits": list_visits(person_id=int(visits_match.group(1)), since=q("since") or None, + limit=q_int("limit", 200))}, + ) + elif path == "/co-presence": + person_id = q_int("person_id", 0) or None + self._respond(HTTPStatus.OK, co_presence(person_id, q("since") or None, q_int("limit", 200))) + elif path == "/prune/candidates": + self._handle_prune_candidates(q("last_visit_before")) + elif path == "/device-access": + self._handle_device_access(q_int("person_id", 0), q("entity_id"), q("permission", "operate"), q("via") or None) + elif path == "/device-access/events": + self._respond(HTTPStatus.OK, {"events": list_device_access_events(q_int("limit", 100))}) + elif path == "/chore-assignments": + self._respond(HTTPStatus.OK, chore_assignments()) + elif assignments_match: + people = [p for p in list_people() if p["id"] == int(assignments_match.group(1))] + if not people: + self._respond(HTTPStatus.NOT_FOUND, {"error": "no such person"}) + else: + self._respond(HTTPStatus.OK, {"chore_assignments": people[0]["chore_assignments"]}) elif path == "/weather": with _weather_lock: self._respond(HTTPStatus.OK, dict(_last_weather)) @@ -701,6 +1516,30 @@ class Handler(BaseHTTPRequestHandler): else: self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"}) + def _handle_prune_candidates(self, last_visit_before: str) -> None: + if not last_visit_before: + self._respond( + HTTPStatus.BAD_REQUEST, + {"error": "'last_visit_before' (an ISO-8601 date or timestamp) is required"}, + ) + return + # A bare date ("2026-01-01") is what a browser's hands over, + # and comparing it against a full timestamp string would silently include + # everyone who last visited ON that date. Widening it to the end of the day + # keeps "before 2026-01-01" meaning what a person reading it expects. + if re.match(r"^\d{4}-\d{2}-\d{2}$", last_visit_before): + last_visit_before = f"{last_visit_before}T00:00:00Z" + self._respond(HTTPStatus.OK, prune_candidates(last_visit_before)) + + def _handle_device_access(self, person_id: int, entity_id: str, permission: str, via: str | None) -> None: + if not person_id or not entity_id: + self._respond( + HTTPStatus.BAD_REQUEST, + {"allowed": False, "error": "'person_id' and 'entity_id' are required"}, + ) + return + self._respond(HTTPStatus.OK, check_device_access(person_id, entity_id, permission, via)) + def _handle_get_photo(self, person_id: int) -> None: data = get_person_photo(person_id) if data is None: @@ -720,6 +1559,13 @@ class Handler(BaseHTTPRequestHandler): return path = urlsplit(self.path).path chore_settings_match = re.match(r"^/people/(\d+)/chore-settings$", path) + assignments_match = re.match(r"^/people/(\d+)/chore-assignments$", path) + grants_match = re.match(r"^/people/(\d+)/device-grants$", path) + identifiers_match = re.match(r"^/people/(\d+)/identifiers$", path) + person_match = re.match(r"^/people/(\d+)$", path) + # /people/prune is checked before the bare /people/ 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). if path == "/register/photo": self._handle_register_photo() elif path == "/register": @@ -728,11 +1574,101 @@ class Handler(BaseHTTPRequestHandler): self._handle_register_guest() elif path == "/presence/manual": self._handle_presence_manual() + elif path == "/people/prune": + self._handle_prune() elif chore_settings_match: self._handle_chore_settings(int(chore_settings_match.group(1))) + elif assignments_match: + self._handle_set_assignments(int(assignments_match.group(1))) + elif grants_match: + self._handle_grant_device(int(grants_match.group(1))) + elif identifiers_match: + self._handle_add_identifier(int(identifiers_match.group(1))) + elif person_match: + self._handle_update_person(int(person_match.group(1))) else: self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"}) + def _json_body(self) -> dict | None: + """Returns the parsed body, or None after having already sent a 400 — callers + check for None and return immediately. Replaces the identical try/except that + every handler below used to repeat. + """ + try: + raw = self._read_body(MAX_JSON_BYTES) + payload = json.loads(raw or b"{}") + except (ValueError, json.JSONDecodeError) as exc: + self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"}) + return None + if not isinstance(payload, dict): + self._respond(HTTPStatus.BAD_REQUEST, {"error": "body must be a JSON object"}) + return None + return payload + + def _handle_update_person(self, person_id: int) -> None: + payload = self._json_body() + if payload is None: + return + result = update_person(person_id, payload) + if result.get("ok"): + self._respond(HTTPStatus.OK, result) + else: + self._respond( + HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT, + result, + ) + + def _handle_add_identifier(self, person_id: int) -> None: + payload = self._json_body() + if payload is None: + return + result = add_identifier(person_id, str(payload.get("entity_id", ""))) + self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.CONFLICT, result) + + def _handle_set_assignments(self, person_id: int) -> None: + payload = self._json_body() + if payload is None: + return + types = payload.get("chore_types") + if not isinstance(types, list): + self._respond(HTTPStatus.BAD_REQUEST, {"error": "'chore_types' must be a list of strings"}) + return + result = set_chore_assignments(person_id, types) + self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.NOT_FOUND, result) + + def _handle_grant_device(self, person_id: int) -> None: + payload = self._json_body() + if payload is None: + return + result = grant_device( + person_id, + str(payload.get("entity_id", "")), + str(payload.get("permission", "operate")), + payload.get("expires_at"), + payload.get("note"), + ) + self._respond( + HTTPStatus.OK if result.get("ok") + else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.BAD_REQUEST), + result, + ) + + def _handle_prune(self) -> None: + payload = self._json_body() + if payload is None: + return + ids = payload.get("person_ids") + # Explicit ids only, never a filter — see prune_people()'s docstring for why + # this endpoint deliberately can't be handed the same query /prune/candidates + # takes. + if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids): + self._respond(HTTPStatus.BAD_REQUEST, {"error": "'person_ids' must be a list of integers"}) + return + if not ids: + self._respond(HTTPStatus.BAD_REQUEST, {"error": "'person_ids' is empty — nothing to prune"}) + return + self._respond(HTTPStatus.OK, prune_people(ids)) + def do_DELETE(self): # noqa: N802 if not self._authorized(): self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"}) @@ -748,6 +1684,15 @@ class Handler(BaseHTTPRequestHandler): ) return + grant_match = re.match(r"^/people/(\d+)/device-grants/(\d+)$", path) + if grant_match: + ok = revoke_device_grant(int(grant_match.group(1)), int(grant_match.group(2))) + self._respond( + HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, + {"ok": True} if ok else {"error": "no such grant"}, + ) + return + person_match = re.match(r"^/people/(\d+)$", path) if person_match: ok = delete_person(int(person_match.group(1))) @@ -777,11 +1722,8 @@ class Handler(BaseHTTPRequestHandler): self._respond(HTTPStatus.OK, {"photo_id": photo_id}) def _handle_register(self) -> None: - try: - raw = self._read_body(MAX_JSON_BYTES) - payload = json.loads(raw or b"{}") - except (ValueError, json.JSONDecodeError) as exc: - self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"}) + payload = self._json_body() + if payload is None: return name = str(payload.get("name", "")) @@ -813,11 +1755,8 @@ class Handler(BaseHTTPRequestHandler): return True, (str(candidate_path) if candidate_path.exists() else None) def _handle_register_guest(self) -> None: - try: - raw = self._read_body(MAX_JSON_BYTES) - payload = json.loads(raw or b"{}") - except (ValueError, json.JSONDecodeError) as exc: - self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"}) + payload = self._json_body() + if payload is None: return device_id = str(payload.get("device_id", "unknown")) @@ -829,11 +1768,8 @@ class Handler(BaseHTTPRequestHandler): self._respond(HTTPStatus.OK, result) def _handle_presence_manual(self) -> None: - try: - raw = self._read_body(MAX_JSON_BYTES) - payload = json.loads(raw or b"{}") - except (ValueError, json.JSONDecodeError) as exc: - self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"}) + payload = self._json_body() + if payload is None: return try: @@ -847,11 +1783,8 @@ class Handler(BaseHTTPRequestHandler): self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, {"ok": ok}) def _handle_chore_settings(self, person_id: int) -> None: - try: - raw = self._read_body(MAX_JSON_BYTES) - payload = json.loads(raw or b"{}") - except (ValueError, json.JSONDecodeError) as exc: - self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"}) + payload = self._json_body() + if payload is None: return chore_exempt = payload.get("chore_exempt") @@ -886,6 +1819,14 @@ def main() -> int: os.environ.get("MQTT_PASSWORD", ""), ) + # The visit-history sampler — see _presence_sampler_loop(). Daemon, so a Ctrl-C / + # container stop doesn't wait out a poll interval before exiting. + threading.Thread(target=_presence_sampler_loop, name="presence-sampler", daemon=True).start() + LOG.info( + "identity: sampling presence every %ds (departure grace %ds)", + PRESENCE_POLL_SECONDS, DEPARTURE_GRACE_SECONDS, + ) + port = int(os.environ.get("IDENTITY_PORT", "8097")) server = ThreadingHTTPServer(("0.0.0.0", port), Handler) LOG.info("identity listening on :%d (HA: %s, db: %s)", port, HA_URL, DB_PATH)