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
parent
2565cf99d0
commit
5c05b67a5a
13
README.md
13
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = ?",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
1055
identity/server.py
1055
identity/server.py
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue