Add identity admin panel: nicknames, visit history, device rights, chore assignment

Extends Phase 6's identity rather than adding a service — all of this is facts
about who someone is, already identity's job.

- admin.html/admin.js: manage people/guests, edit every field, prune, history,
  access log. Deliberately not kiosk-shaped and not linked from any wall panel —
  a panel anyone can walk up to shouldn't carry a "prune these people" button.
- Nicknames are an input alias only. /resolve accepts them; every payload also
  carries speak_name (always the real name) and chores/ uses it for reminders.
  A nickname colliding with anyone's name or nickname is refused; a spoken string
  matching two people refuses with ambiguous_name rather than picking one.
- Visit history sampled from identity's own /presence. home:null writes nothing,
  ever — an HA outage must not record a departure that didn't happen. Departures
  need sustained absence (BLE flaps) and are stamped at the last time actually
  seen. "With whom" is an interval-overlap query, not a second table.
- Pruning splits selecting from deleting: the filter fills the checkboxes, the
  delete goes by explicit id, and the filter is never re-run at delete time.
- Device rights are an answer, never an action — identity stores grants and
  answers GET /device-access; HA asks and HA acts. Deny is the default and the
  only fallback. Grants can expire; every check is logged, denials included.
- Chore assignment prefers an assignee who's home but doesn't let an absent one
  block the chore (CHORE_ASSIGNMENT_STRICT flips that). Litter still ignores
  both exemption and assignment.
- Admin-added identifiers still enforce TRUSTED_ENTITY_PREFIXES; only the
  "in range right now" check is relaxed, never the allowlist.

Resolves open decision #26; adds #34-37 for the new unknowns (DEPARTURE_GRACE_
SECONDS is untuned, the panel has never been opened in a browser).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
digest-per-person-and-agendas
Amir Alexander Abdelbaki 2026-07-31 09:05:14 +02:00
parent 2565cf99d0
commit 5c05b67a5a
12 changed files with 2439 additions and 90 deletions

View File

@ -43,9 +43,11 @@ firmware/
identity/ Person <-> BLE-identifier registry: multi-phone support, identity/ Person <-> BLE-identifier registry: multi-phone support,
anti-spoofing (allowlisted IRK-resolved/fixed-tag anti-spoofing (allowlisted IRK-resolved/fixed-tag
entities only, never a raw MAC), voice/touch entities only, never a raw MAC), voice/touch
registration, presence resolution (write API + registration, presence resolution, visit history
frontend/ static serving, consumed by kitchen-display ("who was home when, with whom"), nicknames, per-device
and door-panel) 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 digest-engine/ Quarter-daily LLM digest: mail/message/news/financial
ingestion, LLM synthesis, digest-canvas SDK rendering ingestion, LLM synthesis, digest-canvas SDK rendering
admin-canvas/ On-demand sys-admin-llm display surface for the thin 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` - [ ] 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` - [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes — built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md`
- [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as `<name>`" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline**`TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md` - [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as `<name>`" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline**`TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md`
- [ ] `identity` also corroborates presence from Frigate face recognition (Phase 20, Tapo pan/tilt cameras) — an OR-ed-in second signal only, **never** a registration signal; and gained two per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, set via `POST /people/<id>/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 `<date>`"** pruning (the filter selects, a human confirms the exact list, the filter is never re-run at delete time), **per-device rights** for self-entry via a smart lock (`identity` only ever *answers* `GET /device-access` — HA asks and HA acts, deny is the default), 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" - [ ] `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 - [ ] 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 - [ ] `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

View File

@ -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 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." 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 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`, nudges whoever `identity` reports home right now — preferring anyone **assigned**
see below). If the chore is still open `NEGLECT_THRESHOLD_HOURS` after the last 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 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 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 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 below. Each nudge's wording is a plain template unless the target has a
`chore_reminder_style` set, see below. `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 ## Chore-exempt people — everyone except litter
Set via `identity`'s `POST /people/<id>/chore-settings` (see identity/README.md, Set in the same place (`identity`'s admin panel, or `POST /people/<id>/chore-settings`
no frontend for it yet — call the endpoint directly). A `chore_exempt` person is directly). A `chore_exempt` person is
dropped from the nudge rotation entirely — the "cousin visits often but doesn't owe 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` 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 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 - **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, 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. 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 - **No confirmation that an assignee actually did it** — assignment changes who gets
direct `POST /people/<id>/chore-settings` call to `identity` until one exists. 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 ## Configure

View File

@ -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 told to put trash they left out into the bin that isn't "doing a chore," it's
cleaning up after yourself. 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 `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 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()`. 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. # — so they're kept as separate names rather than reusing one for both purposes.
_EXEMPTIONS_DONT_APPLY = {"litter"} _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: def _now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") 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 # --- 3. Nudging — presence/calendar-driven, "whoever's around," see module docstring
def _presence() -> list[dict]: def _presence() -> list[dict]:
"""Each dict: {"name", "home", "room", "face_seen_recently", "chore_exempt", """Each dict: {"name", "speak_name", "nickname", "home", "room",
"chore_reminder_style"} the raw shape identity's own /presence returns. Empty "face_seen_recently", "chore_exempt", "chore_reminder_style", "chore_assignments"}
list (never raises past this point) if identity is unreachable nudging just the raw shape identity's own /presence returns. Empty list (never raises past
waits for the next run. 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: if not IDENTITY_URL or not IDENTITY_TOKEN:
return [] return []
@ -365,6 +400,34 @@ def _household_currently_busy() -> bool:
return False 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: def _likely_culprit(candidates: list[dict]) -> dict | None:
"""For litter-type chores: prefer whoever was MOST RECENTLY seen by camera face """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 recognition a best-effort "who was just here" guess, not a certainty. Falls
@ -408,6 +471,24 @@ def nudge_open_chores(conn) -> None:
) )
continue 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 # "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) — # 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 # 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] different = [p for p in eligible if p["name"] != last_nudged]
target = (different or eligible)[0] 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( conn.execute(
"UPDATE chores SET assigned_to = ?, assigned_at = ?, reminder_count = reminder_count + 1, " "UPDATE chores SET assigned_to = ?, assigned_at = ?, reminder_count = reminder_count + 1, "
"last_reminder_at = ? WHERE id = ?", "last_reminder_at = ? WHERE id = ?",

View File

@ -77,5 +77,20 @@ NTFY_URL=http://127.0.0.1:8090
NTFY_TOPIC=chores NTFY_TOPIC=chores
NEGLECT_THRESHOLD_HOURS=4 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 CHORES_DB_PATH=/data/chores.db
LOG_LEVEL=INFO LOG_LEVEL=INFO

View File

@ -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 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 | | 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 | | 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 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` read-only — same role as `pantry-web`/`digest-web`/`admin-web` | | 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 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 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 | | 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 entities, or a real voice pipeline — see the itemized list in
`identity/README.md`, `TRUSTED_ENTITY_PREFIXES`' defaults above all. `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 ### Phase 7 — Kitchen inventory kiosk
1. Deploy Grocy via Compose. 1. Deploy Grocy via Compose.
2. Pi + touchscreen running Chromium kiosk mode pointed at Grocy's PWA. 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/<camera>/ptz/move/<preset>` is assumed from Frigate's general PTZ feature set, not a real deployment. This is the single highest-risk unknown in Phase 20, same class of risk as open decision #18's vision-model pick for Phase 17. 23. **No Tapo camera model or count has been chosen, and Frigate's PTZ move-to-preset API shape is assumed** (new, Phase 20) — §1.17 lists a placeholder model/price only; whether a specific Tapo model even exposes RTSP without go2rtc as a bridge is unverified, and `chores/check.py`'s `_frigate_snapshot()`'s `POST /api/<camera>/ptz/move/<preset>` is assumed from Frigate's general PTZ feature set, not a real deployment. This is the single highest-risk unknown in Phase 20, same class of risk as open decision #18's vision-model pick for Phase 17.
24. **Vision-model accuracy for bin-fullness/dishes/litter checks is completely unmeasured** (new, Phase 20) — same caveat as open decision #18, applied to a different prompt; a wrong FULL/DIRTY/YES answer just means a chore opens or stays open incorrectly, never a hard failure, but nobody has checked how often that actually happens. 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`. 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/<id>/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. 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). 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. 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. 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. 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). 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 `<dialog>`-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.

View File

@ -1414,6 +1414,9 @@ if [[ "$ENABLE_IDENTITY" == "true" ]]; then
echo " Identity API : http://${HOST_IP}:${IDENTITY_PORT} (bearer-token gated)" 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=<IDENTITY_TOKEN>&device=<name>" echo " Register page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/register.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=<IDENTITY_TOKEN>&device=<name>"
echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=<IDENTITY_TOKEN>" echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=<IDENTITY_TOKEN>"
echo " Admin panel : http://${HOST_IP}:${IDENTITY_WEB_PORT}/admin.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=<IDENTITY_TOKEN>"
echo " (people/guests, pruning, visit history, device rights —"
echo " NOT a kiosk page; keep this URL off the wall panels)"
fi fi
if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then
echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})" echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})"

View File

@ -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 on `room` being populated at all; it degrades to `null` if missing, never breaks the
response. 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://<host>:8098/admin.html?api=http://<host>:8097&token=<IDENTITY_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 `<date>`" 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://<container-host>: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/` ## Chore-system settings — owned here, used by `chores/`
Two per-person fields, set via `POST /people/<id>/chore-settings`. **No frontend Three per-person things live here, not in `chores/`. All of them are editable in the
for this exists yet** — neither `register.html` nor `dashboard.html` expose a way admin panel (which is what open decision #26 was waiting for), and `chores/` reads
to set them — call the endpoint directly (an HA script/automation, or `curl`) until all of them off the same `GET /presence` call it already made.
one is built. Read by `chores/` off `GET /presence`:
**Assignment** (`POST /people/<id>/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/<id>/chore-settings`:
- **`chore_exempt`** — a household member who's tracked for presence/identity like - **`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 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, assignment decision (see `chores/README.md`). Empty/unset falls back to a plain,
un-styled template with no LLM call at all. un-styled template with no LLM call at all.
Both fields live on `people` (not a separate table) because they're household- Both of those live as columns on `people` (not a separate table) because they're
standing facts about a person, same category as their name or photo — `identity` is household-standing facts about a person, same category as their name or photo —
already this project's source of truth for who someone is, so this is where "how do `identity` is already this project's source of truth for who someone is, so this is
I relate to this specific household member" facts belong, not duplicated into where "how do I relate to this specific household member" facts belong, not duplicated
`chores/`'s own database. 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 ## 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/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` | `{"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 | | `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/<id>` | edit any editable field — `{"name"?, "nickname"?, "note"?, "chore_exempt"?, "chore_reminder_style"?, "clear_photo"?}`. Omitted keys are left alone |
| `GET /people/<id>/photo` | the person's profile picture (raw JPEG) — their most recent registration photo | | `GET /people/<id>/photo` | the person's profile picture (raw JPEG) — their most recent registration photo |
| `POST /people/<id>/identifiers` | `{"entity_id"}` — attach an identifier by hand (a fixed BLE tag not in range yet). Still enforces `TRUSTED_ENTITY_PREFIXES` |
| `DELETE /people/<id>/identifiers/<id>` | revoke a mistaken or compromised identifier | | `DELETE /people/<id>/identifiers/<id>` | revoke a mistaken or compromised identifier |
| `DELETE /people/<id>` | remove a person entirely (their identifiers go with them) — mainly for cleaning up stale Guest records | | `DELETE /people/<id>` | remove a person entirely (their identifiers, grants and visits go with them) |
| `GET /resolve?q=<spoken>` | spoken name **or nickname** -> the canonical person, with `speak_name` (see below) |
| `GET /prune/candidates?last_visit_before=<date>` | 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/<id>/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/<id>/device-grants` | `{"entity_id", "permission"?, "expires_at"?, "note"?}` — grant a right |
| `DELETE /people/<id>/device-grants/<id>` | revoke one |
| `GET /device-access/events?limit=` | the audit log of every access check, allowed and denied |
| `GET`/`POST /people/<id>/chore-assignments` | read/replace this person's assigned chore types (`{"chore_types": [...]}`) |
| `GET /chore-assignments` | the same facts keyed by chore type — the shape `chores/` reads |
| `POST /presence/manual` | `{"person_id", "home"}` — hand-operated Home/Away for anyone with no identifiers | | `POST /presence/manual` | `{"person_id", "home"}` — hand-operated Home/Away for anyone with no identifiers |
| `POST /people/<id>/chore-settings` | `{"chore_exempt"?, "chore_reminder_style"?}` — see below; either field omitted/`null` leaves it unchanged | | `POST /people/<id>/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 | | `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 **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 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 `/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. 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 8. The blob+`createObjectURL()` profile-picture fetch (all three frontends) has not
checked for a memory leak from never calling `URL.revokeObjectURL()` on the old 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 — 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 likely fine at household scale and dashboard.js's 60s poll cadence, not measured
over a multi-day uptime. 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 `<dialog>`
editor, the prune checkboxes) has not been opened in a real browser on this
machine. `<dialog>`'s `showModal()` needs a reasonably current browser; the door
panel's Chromium is fine, an ancient one wouldn't be.
11. **Nothing enforces that voice/TTS consumers actually read `speak_name`** — the
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.

View File

@ -0,0 +1,185 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Household admin</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="admin">
<!--
identity's admin panel — managing people, guests, rights and history, as opposed to
register.html (the door-panel kiosk's own self-service flow) and dashboard.html (the
ambient at-a-glance screen).
THIS IS THE ONE PAGE HERE NOT DESIGNED FOR A KIOSK. The other two are big-touch-
target, glanceable, and running unattended on a wall panel; this one is dense, has
destructive buttons on it, and is meant for a phone or laptop where you sat down
intending to administer something. That's why it opts into `body.admin`'s tighter
scale rather than inheriting the 64px-button kiosk defaults — a wall panel anyone
can walk up to should not have a "Prune 6 people" button on it.
Config comes from the URL exactly like the sibling pages: ?api=&token=.
-->
<div id="tabs">
<button type="button" class="tab active" data-panel="people">👥<span>People</span></button>
<button type="button" class="tab" data-panel="prune">🧹<span>Prune</span></button>
<button type="button" class="tab" data-panel="history">🕘<span>History</span></button>
<button type="button" class="tab" data-panel="access">🔑<span>Access log</span></button>
</div>
<main>
<!-- ---------------------------------------------------------------- People -->
<section class="panel active" id="panel-people">
<section class="block">
<h2>People</h2>
<p class="hint">Tap a person to edit every field, their devices, door rights and chores.</p>
<div id="people-list" class="card-list"><p class="hint">Loading…</p></div>
</section>
</section>
<!-- ----------------------------------------------------------------- Prune -->
<section class="panel" id="panel-prune">
<section class="block">
<h2>Prune old records</h2>
<p class="hint">
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.
</p>
<div class="row">
<label class="field">
Last visited before
<input id="prune-date" type="date">
</label>
<button type="button" id="prune-select" class="btn">Select all</button>
</div>
<p id="prune-summary" class="hint"></p>
<div id="prune-list" class="card-list"></div>
<div class="row" style="margin-top:12px">
<button type="button" id="prune-delete" class="btn danger" disabled>Delete selected</button>
<span id="prune-status" class="hint"></span>
</div>
</section>
</section>
<!-- --------------------------------------------------------------- History -->
<section class="panel" id="panel-history">
<section class="block">
<h2>Who was home, when</h2>
<div class="row">
<label class="field">
Person
<select id="history-person"><option value="">Everyone</option></select>
</label>
<label class="field">
Since
<input id="history-since" type="date">
</label>
<button type="button" id="history-load" class="btn">Load</button>
</div>
<div id="visit-list" class="card-list"><p class="hint">Pick a range and load.</p></div>
</section>
<section class="block">
<h2>…and with whom</h2>
<p class="hint">Overlapping visits, worked out from the history above.</p>
<div id="copresence-list" class="card-list"></div>
</section>
</section>
<!-- ------------------------------------------------------------ Access log -->
<section class="panel" id="panel-access">
<section class="block">
<h2>Device access checks</h2>
<p class="hint">
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.
</p>
<div id="access-list" class="card-list"><p class="hint">Loading…</p></div>
</section>
</section>
</main>
<!-- The person editor. One dialog reused for whoever's selected, rather than an
always-rendered form per person — the list can get long and only one is ever
being edited. -->
<dialog id="editor">
<form method="dialog" id="editor-form">
<header class="editor-head">
<span class="avatar" id="edit-avatar">👤</span>
<h3 id="edit-title">Edit</h3>
<button type="button" class="btn ghost" id="edit-close">Close</button>
</header>
<div class="editor-body">
<label class="field">Name <span class="hint">— what the assistant says out loud</span>
<input id="edit-name" type="text" autocomplete="off">
</label>
<label class="field">Nickname <span class="hint">— what people call them; the assistant never uses it</span>
<input id="edit-nickname" type="text" autocomplete="off" placeholder="none">
</label>
<label class="field">Note
<input id="edit-note" type="text" autocomplete="off" placeholder="e.g. cousin, visits at Christmas">
</label>
<fieldset class="field-group">
<legend>Chores</legend>
<label class="check">
<input id="edit-chore-exempt" type="checkbox">
Exempt from chores
<span class="hint">— litter is always the exception; everyone cleans up what they left out</span>
</label>
<label class="field">Reminder style
<input id="edit-reminder-style" type="text" placeholder="e.g. be gentle, give me a few minutes">
</label>
<div class="field">
Assigned chores
<div id="edit-chore-types" class="chip-row"></div>
<p class="hint">
A strong preference, not a lock: if the assignee isn't home, whoever is
gets nudged instead. Litter ignores assignment entirely.
</p>
</div>
</fieldset>
<fieldset class="field-group">
<legend>Devices used for presence</legend>
<div id="edit-identifiers" class="card-list"></div>
<div class="row">
<input id="edit-new-identifier" type="text" placeholder="device_tracker.pble_…" class="grow">
<button type="button" id="edit-add-identifier" class="btn">Add</button>
</div>
<p class="hint">
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.
</p>
</fieldset>
<fieldset class="field-group">
<legend>Device rights</legend>
<div id="edit-grants" class="card-list"></div>
<div class="row">
<input id="grant-entity" type="text" placeholder="lock.front_door" class="grow">
<input id="grant-permission" type="text" placeholder="operate" style="max-width:110px">
<input id="grant-expires" type="date" title="Optional expiry">
<button type="button" id="grant-add" class="btn">Grant</button>
</div>
<p class="hint">
Lets this person operate that entity — e.g. unlocking the front door for
themselves. An expiry date makes it a temporary key.
</p>
</fieldset>
<div class="row editor-footer">
<button type="button" id="edit-save" class="btn primary">Save changes</button>
<button type="button" id="edit-delete" class="btn danger">Delete person</button>
<span id="edit-status" class="hint"></span>
</div>
</div>
</form>
</dialog>
<script src="admin.js"></script>
</body>
</html>

525
identity/frontend/admin.js Normal file
View File

@ -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 =
'<p class="error" style="padding:24px">identity not configured — missing ' +
"?api=&token= in the URL.</p>";
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 <img src="..."> 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 = '<p class="hint">Nobody registered yet.</p>';
return;
}
el.innerHTML = people
.map(
(p) =>
`<button type="button" class="card as-button" data-person="${p.id}">
<span class="avatar" data-avatar="${p.id}">👤</span>
<span class="card-body">
<span class="card-name">${escapeHtml(p.name)}${p.currently_home_since ? ' <span class="dot-home" title="home now"></span>' : ""}</span>
<span class="card-meta">${escapeHtml(personSubtitle(p))}</span>
</span>
</button>`
)
.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 = `<p class="error">Could not load people: ${escapeHtml(err.message)}</p>`;
});
}
// --- 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) =>
`<label class="chip"><input type="checkbox" data-chore="${type}"${
editing.chore_assignments.includes(type) ? " checked" : ""
}> ${escapeHtml(type)}</label>`
).join("");
renderIdentifiers();
renderGrants();
setStatus("");
editor.showModal();
}
function renderIdentifiers() {
const el = document.getElementById("edit-identifiers");
if (!editing.identifiers.length) {
el.innerHTML = '<p class="hint">No devices — presence has to be set by hand.</p>';
return;
}
el.innerHTML = editing.identifiers
.map(
(i) =>
`<div class="card compact">
<span class="card-body">
<span class="card-name mono">${escapeHtml(i.ha_entity_id)}</span>
<span class="card-meta">added ${escapeHtml(fmtDate(i.registered_at))} via ${escapeHtml(i.registered_via_device || "?")}</span>
</span>
<button type="button" class="btn danger small" data-drop-identifier="${i.id}">Revoke</button>
</div>`
)
.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 = '<p class="hint">No device rights.</p>';
return;
}
const now = new Date().toISOString();
el.innerHTML = editing.device_grants
.map((g) => {
const expired = g.expires_at && g.expires_at <= now;
return `<div class="card compact${expired ? " expired" : ""}">
<span class="card-body">
<span class="card-name mono">${escapeHtml(g.ha_entity_id)}</span>
<span class="card-meta">${escapeHtml(g.permission)}${
g.expires_at ? ` · ${expired ? "expired" : "until"} ${escapeHtml(fmtDate(g.expires_at))}` : " · no expiry"
}${g.note ? ` · ${escapeHtml(g.note)}` : ""}</span>
</span>
<button type="button" class="btn danger small" data-drop-grant="${g.id}">Revoke</button>
</div>`;
})
.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) =>
`<label class="card compact">
<input type="checkbox" value="${p.id}" checked>
<span class="card-body">
<span class="card-name">${escapeHtml(p.name)}${p.nickname ? `${escapeHtml(p.nickname)}` : ""}</span>
<span class="card-meta">last visit ${escapeHtml(fmtDate(p.last_visit_at))}${
p.last_visit_is_estimated ? " (never actually recorded — registered then)" : ""
} · ${p.visit_count} visit${p.visit_count === 1 ? "" : "s"}</span>
</span>
</label>`
)
.join("");
pruneList.querySelectorAll("input").forEach((c) => c.addEventListener("change", syncPruneButton));
syncPruneButton();
})
.catch((err) => {
document.getElementById("prune-summary").innerHTML = `<span class="error">${escapeHtml(err.message)}</span>`;
});
});
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 = `<span class="error">${escapeHtml(err.message)}</span>`;
});
});
// --- History --------------------------------------------------------------------------
function populatePersonSelect() {
const select = document.getElementById("history-person");
const current = select.value;
select.innerHTML =
'<option value="">Everyone</option>' +
people.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).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 = '<p class="hint">Loading…</p>';
api(visitPath)
.then((data) => {
const visits = data.visits || [];
if (!visits.length) {
el.innerHTML = '<p class="hint">No visits recorded in that range.</p>';
return;
}
el.innerHTML = visits
.map(
(v) =>
`<div class="card compact">
<span class="card-body">
<span class="card-name">${escapeHtml(v.name)}</span>
<span class="card-meta">${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" : ""
}</span>
</span>
</div>`
)
.join("");
})
.catch((err) => {
el.innerHTML = `<p class="error">Could not load visits: ${escapeHtml(err.message)}</p>`;
});
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 = '<p class="hint">Nobody overlapped in that range.</p>';
return;
}
copEl.innerHTML = overlaps
.map(
(o) =>
`<div class="card compact">
<span class="card-body">
<span class="card-name">${escapeHtml(o.people.map((p) => p.name).join(" + "))}</span>
<span class="card-meta">${escapeHtml(fmt(o.from))} ${
o.ongoing ? "now" : escapeHtml(fmt(o.until))
} · ${escapeHtml(duration(o.from, o.ongoing ? null : o.until))}</span>
</span>
</div>`
)
.join("");
})
.catch((err) => {
copEl.innerHTML = `<p class="error">Could not load co-presence: ${escapeHtml(err.message)}</p>`;
});
}
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 = '<p class="hint">Nothing has asked yet.</p>';
return;
}
el.innerHTML = events
.map(
(e) =>
`<div class="card compact ${e.allowed ? "allowed" : "denied"}">
<span class="card-body">
<span class="card-name">${escapeHtml(e.name || `person ${e.person_id}`)} <span class="mono">${escapeHtml(e.ha_entity_id)}</span></span>
<span class="card-meta">${escapeHtml(fmt(e.created_at))} · ${escapeHtml(e.permission)} · ${escapeHtml(e.reason)}${
e.requested_via ? ` · via ${escapeHtml(e.requested_via)}` : ""
}</span>
</span>
<span class="verdict">${e.allowed ? "allowed" : "denied"}</span>
</div>`
)
.join("");
})
.catch((err) => {
el.innerHTML = `<p class="error">Could not load access log: ${escapeHtml(err.message)}</p>`;
});
}
loadPeople();

View File

@ -194,3 +194,305 @@ section.block h2 {
background: rgba(110, 168, 254, 0.14); background: rgba(110, 168, 254, 0.14);
display: inline-block; 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;
}

View File

@ -55,6 +55,30 @@ MQTT_PASSWORD=
FRIGATE_EVENTS_TOPIC=frigate/events FRIGATE_EVENTS_TOPIC=frigate/events
FACE_PRESENCE_WINDOW_SECONDS=600 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 # Run behaviour
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

File diff suppressed because it is too large Load Diff