# identity

The household's person <-> BLE-identifier registry, from
[Phase 6 of the project plan](../docs/project-plan.md). Solves two concrete problems
in one design:

1. **Multiple phones per person** (the classic private/work phone situation).
2. **MAC address spoofing/randomization**, so registration can't be tricked or
   accidentally fed garbage by a phone's own privacy features.
3. **People with no device at all** (a grandmother without a smartphone, a one-off
   guest) — the system stays useful instead of just not knowing they exist.

Also backs the "who's home" and "register me" pieces of `hosts/door-panel/` and
`hosts/kitchen-display/`, and proxies the household weather topic those two
dashboards both need.

## The model

A **person** has zero or more **identifiers**. An identifier is a Home Assistant
`entity_id` that resolves presence for one physical device. That's the entire schema
(`server.py`'s `people`/`identifiers` tables) — multi-phone support isn't a special
case, it falls straight out of it: register once with your private phone, register
again later with your work phone in hand, same spoken name, and you now have two
identifiers under one person.

## Anti-spoofing — the actual security boundary

**A raw Bluetooth MAC address is never accepted as an identifier by itself,
especially not a randomized one** (the iOS/Android default — a phone's advertised
MAC rotates every few minutes specifically so it *can't* be tracked as a stable
identifier by anyone, including this system). Registration only ever looks at
`entity_id`s matching `TRUSTED_ENTITY_PREFIXES` — meant to contain **only**:

- Home Assistant's **Private BLE Device** integration entities (Bermuda/HA resolve
  the rotating MAC back to a stable identity via the device's IRK — a cryptographic
  resolution, not string-matching a MAC), or
- manually provisioned **fixed-MAC BLE tag** entities (a physical tag handed to a
  person specifically because its MAC doesn't rotate).

An attacker broadcasting an arbitrary spoofed MAC never produces a trusted candidate
— it just doesn't show up in `TRUSTED_ENTITY_PREFIXES` at all, because the untrusted
raw entity is a different `entity_id` than the resolved one. Spoofing a *specific*
person's resolved identity would require their device's actual IRK secret, a
materially higher bar than MAC spoofing. **This is a defense against passive/
opportunistic spoofing, not a claim of cryptographic non-repudiation** — if a
household member's phone (and its IRK) is itself compromised, this system has no way
to know that. Threat-model it as "keeps a stranger's phone from registering itself
as you," not "biometric-grade proof of identity."

## Never auto-commit on ambiguity

If a registration attempt finds zero, more than one, or an already-claimed
candidate, **nothing is written**. The caller gets a reason back (and, for the
ambiguous case, the candidate list) and a human disambiguates on the touchscreen —
calling `POST /register` again with an explicit `entity_id`. The one case that *does*
commit within a single call is the clean one (exactly one trusted, unclaimed
candidate) — because the spoken "register me as `<name>`" command **is** the human
confirmation; requiring a second round-trip for the unambiguous case would be pure
friction with no safety benefit. This mirrors, rather than weakens, this project's
existing "an identity merge must never auto-commit silently" rule: ambiguity is
exactly the case that still needs a person.

## The photo is an audit trail, not face recognition

`POST /register/photo` stores whatever the calling kiosk's camera captured at
registration time, purely as a **"who did this, when" reference photo** — it is
**never run through any face-matching or biometric pipeline**. Building that would
mean either standing up a new ML pipeline from scratch or wiring this device's camera
into Frigate's existing face recognition (Phase 5) as a second camera source — both
real, both out of scope for this pass. BLE/IRK resolution, not the camera, is what
actually decides who's registering. **If you want camera-based identity later,
Frigate's own face-recognition + enrollment (0.16+) is the piece to wire in, not a
new pipeline here.**

## How a person looks on a screen too small for their name

Every person carries two derived fields, served on `/people`, `/presence` and each
occupant in `/floorplan/presence`:

- **`initial`** — the first letter of the **real name**, never the nickname. Same rule
  as `speak_name`, same reason: what a machine shows for somebody should follow who
  they are, not what the household happens to call them this year.
- **`color`** — one of eight, assigned automatically at registration and editable in
  the admin panel.

**The colour exists because initials collide.** A household with an Anna and an Amir
gets two identical "A"s on a wall panel or a watch face, and the colour is what makes
that readable. So the assignment rule is: first avoid any colour already worn by
somebody with the same initial — that is worth spending the whole palette on — then
take the least-used colour overall. Past eight people sharing one letter it repeats,
and you pick by hand.

**Why those eight values.** Every channel is `00`/`55`/`AA`/`FF`, which is exactly the
2-bits-per-channel space a colour Pebble renders natively. Anything else is snapped or
dithered by the watch, and a colour that shifts between the admin panel and the watch
defeats the point of having one. They also vary in lightness rather than only in hue,
so they stay distinguishable when a black-and-white screen reduces them to greys, and
they survive the common colour-vision deficiencies better than a rainbow would. A hex
outside the palette is accepted if you set one deliberately; it just won't be exact on
a watch. See `docs/pebble-presence-watchface.md`.

Existing people are backfilled with a colour at startup, oldest first, so the
assignment is stable across restarts and never reshuffles a colour somebody has
already learned.

## The photo, and where it comes from

Two ways a person gets a profile picture:

- **Registration** — the door panel's capture becomes the picture, "most recent
  registration wins". Good for the picture registration took; useless for a device-less
  household member registered by hand, who could never have one at all.
- **`POST /people/<id>/photo`** — upload one from the admin panel. Written into the
  same photo directory with the same filename shape, so anything that serves or backs
  up a registration capture handles this identically. Deliberately **not** recorded as
  a registration event: nobody registered.

Removing a picture (`clear_photo`) blanks the reference but leaves the file on disk —
it may also be a `registration_events` audit artifact, and "stop showing this photo" is
a different request from "destroy the record that it was taken". The same reasoning
applies to replacing one.

A person with no picture is not a gap in a UI: their initial on their colour is the
fallback everywhere, and on the small screens it is the *preferred* rendering anyway.

## Exact positions inside a room

`/floorplan/presence` answers "which room" from BLE. It can also answer "**where in
it**", from a different sensor, and the two are kept apart on purpose.

| Signal | Source | Gives |
|---|---|---|
| who | BLE / Bermuda | a person, resolved to a room |
| where | mmWave radar (LD2450-class) | a coordinate, **with no name** |

**The fusion rule is deliberately timid:**

> exactly one occupant in the room **and** exactly one target in the room → that target
> is that person (`position.source: "fused"`). Anything else → targets stay anonymous
> in `room.targets`, occupants stay unpositioned, and `position_ambiguous` is true.

Two people in a room are two blobs that cannot be told apart. Guessing would put a name
on the wrong person, and a display that does that occasionally is worse than one that
never tries — you can't tell its wrong answers from its right ones.

Which room a target is in is computed **from the polygon**, never from which sensor saw
it: a radar in an open-plan kitchen sees into the living room, and attributing by sensor
puts people through walls.

Two things have to be drawn by a human first, because nothing can infer them:

- **`metres_wide` on the level** — the real width of its 0–1 extent. Without it,
  positions are simply not computed, and the API says so rather than guessing a scale.
- **A sensor placement** (`POST /floorplan/sensors`): where the radar sits on the plan,
  and **which way it faces**. A wrong `rotation_deg` mirrors every target it reports —
  the most likely way to get positions that look plausible and are wrong.

Readings are assumed to be **millimetres** (ESPHome's LD2450 default);
`POSITION_UNIT_DIVISOR` overrides that, because "which unit is this number in" differs
between integrations and getting it wrong scales everything by a thousand instead of
failing visibly. A target at exactly (0,0) is how these radars say *nothing here*, so
those are dropped — otherwise every sensor grows a phantom person sitting on top of it.

## People without a device

Two paths, distinct on purpose because they solve different problems:

- **A known person with no device** (the grandmother case) — `POST /register` with
  `"no_device": true` and a real name. Skips candidate lookup entirely; creates the
  person (or reuses them by name, same dedup as the normal path) with **zero**
  identifiers. On the touchscreen this is the "I don't have a phone or tag" checkbox
  next to the name field; there's no voice phrasing for it yet (a boolean flag
  doesn't fit the single-utterance design cleanly — say it on the touchscreen for
  now). The long-term fix for this exact case is a physical fixed-address BLE tag
  (docs/components.md's "Fixed BLE tags" line) so they *do* get automatic presence
  eventually — this flag is what makes the household not have to wait for that
  before the person exists in the system at all.
- **Someone the system doesn't need to identify** (a one-off guest) — `POST
  /register/guest`, no name, no device. Always creates a new record ("Guest 1",
  "Guest 2", ...; never reused/deduped, unlike named people) — the touchscreen's
  "Add a guest" button. `DELETE /people/<id>` cleans up a stale one afterwards.

Neither path can ever resolve automatic presence (there's no identifier to check a
state on) — that's what `POST /presence/manual` (`{"person_id", "home"}`) is for: a
hand-operated Home/Away toggle, surfaced directly on `hosts/door-panel/`'s dashboard
next to anyone with `has_device: false` in `/presence`'s response. Until it's tapped
at least once, `/presence` reports `home: null` ("unknown") for that person — **never
`false`**, since defaulting a device-less person to "away" would be actively wrong
the moment they're actually sitting in the next room, not just uninformative.

## The floor plan

**Draw it in the admin panel's Floorplan tab.** Add a level, optionally upload a
background (a scan, a screenshot of an architect's PDF, a photo of a sketch), then
click corners to trace each room and drag the handles to adjust. Rooms are polygons,
not rectangles, because real rooms aren't rectangles.

The piece that makes it *live* is the **Home Assistant area** field on each room. That
string is matched against whatever `/presence` reports as a person's `room` — i.e.
whatever `AREA_ATTRIBUTE` holds on their trusted entity. The editor offers a pick-list
of the areas HA is actually reporting right now (`GET /floorplan/areas`) rather than
asking you to retype an area_id from Developer Tools, for the same anti-typo reason
`tools/CoreSystemConfig.json` exists. Tick **Show who's home** and occupied rooms light
up.

Three deliberate choices worth knowing:

- **Coordinates are normalised 0–1, not pixels.** The plan has to render on a laptop
  now and possibly a wall panel later, and pixel coordinates would be right on exactly
  one of them. The tradeoff: replacing a background image with one of a *different
  aspect ratio* distorts existing rooms. Same-ratio replacements are fine.
- **Nothing is ever placed automatically.** No auto-detection of rooms, no inference
  from BLE distances. Nothing in this project knows the shape of this flat, and the
  alternative to drawing it was inventing a coordinate format against a guess — which
  is exactly why this stayed deferred rather than half-built (open decision #22).
- **A person who can't be placed is shown, not dropped.** Home but no room resolved
  (the normal case without room-level BLE), or reporting an area no room claims — both
  are listed under the plan, and unclaimed areas are named so you know what's left to
  draw. A floor plan that quietly loses people would be worse than no floor plan.

A room with no HA area is legal and labelled "no HA area" on the plan: drawing the flat
and wiring up presence are separate jobs, and you should be able to finish the first
without the second. Two rooms may not claim the same area — both would light up for one
person, which looks like a presence bug rather than a mapping mistake.

## Floor-plan groundwork (the `/presence` half)

`/presence` also reports a best-effort `room` per person (`server.py`'s
`AREA_ATTRIBUTE`, default `area_id`) — read from whichever HA area/room attribute
your room-presence integration (Bermuda) attaches to a trusted entity's state, so a
future floor-plan UI has live room-level data to plot without this service changing
again. **The floor plan itself — an image, a room<->coordinate mapping, any
rendering — is deliberately not built here.** There's no floor plan or fixed room
list to design a coordinate format against yet; building one now would be guessing,
not engineering. `AREA_ATTRIBUTE`'s exact name is also a guess — verify it against a
real Bermuda-tracked entity's attributes (Developer Tools -> States) before relying
on `room` being populated at all; it degrades to `null` if missing, never breaks the
response.

## The admin panel

`frontend/admin.html` — the third page here, and the only one **not** designed for a
kiosk. `register.html` and `dashboard.html` are big-touch-target screens running
unattended on a wall; this one is dense, has destructive buttons, and is meant for a
phone or laptop belonging to someone who sat down intending to administer something.
**A wall panel anyone can walk up to should not have a "Prune 6 people" button on
it** — that's why the admin page is a separate URL rather than a tab on the door
panel, and why nothing in `hosts/door-panel/` or `hosts/kitchen-display/` links to it.

It's served by the same read-only `identity-web` nginx container as its siblings, and
configured the same way:

```
http://<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, their chores and which digests get generated for them), **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.

## Arrival notifications — "tell me when someone gets home"

Opt-in per person, in the admin panel. Fires on **the same arrival transition the
visit log is built from** — a trusted identifier coming into range and HA registering
it — so there is exactly one definition of "arrived" in this service rather than two
that could disagree.

Three per-person settings:

- **`notify_on_arrival`** (default **off**) — send *this* person a push when someone
  else gets home. The "if enabled" half.
- **`announce_arrivals`** (default **on**) — whether *this* person's own arrivals may
  be announced. Untick it for anyone who doesn't want their comings and goings
  broadcast to the household — the same concern the project plan's open decision #32
  raises about RuView. It defaults **on** deliberately: if both flags defaulted off,
  ticking "notify me" would appear broken until every other person also opted in.
- **`notify_topic`** — this person's own ntfy topic; blank falls back to
  `NTFY_DEFAULT_TOPIC`, so a household that never sets these still works.

Rules that fall out of it:

- **The arriving person is never notified about themselves.**
- **Topics are deduplicated.** With no per-person topics, everyone shares
  `NTFY_DEFAULT_TOPIC` — without dedup a five-person household would get five
  identical pushes for one person walking in.
- **Subscribers who are away still get notified.** "Did the kid get home?" is most of
  the reason to want this.
- **The first sample after startup notifies nobody.** It establishes a baseline
  instead. Otherwise a restart following a gap long enough for visits to have closed
  would fire "X just got home" for everyone who's been on the sofa for hours. The cost
  is one genuinely missed notification if somebody walks in during that first pass —
  a fair trade against crying wolf on every container restart, and the visit is
  recorded correctly either way.
- **A camera sighting says so.** Face-recognition arrivals read "was just recognised
  at home", not "just got home" — the two signals aren't equally reliable and the
  reader deserves to know which one fired.
- **A failed push never costs you history.** Visits are committed before any network
  call; pushes are best-effort and isolated from each other.

`POST /people/<id>/test-notification` pushes a test message to that person's topic —
because the alternative way to discover a typo'd topic is to wait for somebody to walk
through the door and then notice nothing happened.

### Getting the push while you're actually away

**`identity` never touches the WAN.** It POSTs to the self-hosted ntfy this stack
already runs for `chores` (`setup-container-host.sh`'s `ENABLE_NTFY`) — one container
to another on the compose network, never even reaching the firewall.

Getting the message onto a phone is a **network** question, and it's settled in
`docs/network-integration.md` §2.2: **ntfy stays LAN-only.** At home, ntfy's Android
app holds a connection straight to it ("instant delivery" — no Google services, no
WAN). Away, a **WireGuard split tunnel** routing just the smart-home VLAN (§2.1)
reaches it exactly as if you were sitting at home. No DMZ, no port forward, no
certificates, no firewall rule.

That doc records why exposing ntfy in a DMZ — with or without NAT reflection — was
weighed and rejected, so the reasoning doesn't have to be re-derived later.

> Apple footnote, for completeness only: this household uses no Apple devices. If one
> ever joins, note that ntfy's iOS app can only be woken via Apple's APNs, so a
> self-hosted server would need `upstream-base-url` relaying through ntfy.sh — real WAN
> egress through a third party, even on your own Wi-Fi. That would reopen §2.2's
> decision. Android needs none of it.

## 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/`

Three per-person things live here, not in `chores/`. All of them are editable in the
admin panel (which is what open decision #26 was waiting for), and `chores/` reads
all of them off the same `GET /presence` call it already made.

**Assignment** (`POST /people/<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
  anyone else but never nudged about chores in general (the "cousin visits often
  but doesn't owe me chores" case). **Litter is the deliberate exception** —
  `chores/check.py`'s `_EXEMPTIONS_DONT_APPLY` still nudges an exempt person about
  putting trash they left out into the bin, because that responsibility isn't
  "doing a chore," it's cleaning up after yourself.
- **`chore_reminder_style`** — free text describing how a person wants to be
  reminded ("be assertive, don't let up" / "be gentle, give me a few minutes of
  grace"). `chores/` passes this to an LLM that **phrases** the reminder message in
  that style — it never decides *who* or *when* to nudge, only *how the words come
  out*, per that system's own hard rule that presence/schedule drives every
  assignment decision (see `chores/README.md`). Empty/unset falls back to a plain,
  un-styled template with no LLM call at all.

Both of those live as columns on `people` (not a separate table) because they're
household-standing facts about a person, same category as their name or photo —
`identity` is already this project's source of truth for who someone is, so this is
where "how do I relate to this specific household member" facts belong, not duplicated
into `chores/`'s own database. Assignments get their own table only because they're
many-per-person, not because they belong anywhere else.

## "Who just spoke?" — automatic recognition for the voice path

`GET /speaker?area=<ha_area>` answers who is asking, so a spoken "play my digest"
shows *that person's* digest without anybody typing or spelling a name, and
without the assistant interrogating the room.

It resolves from the two presence signals this service already fuses:

1. **One person in that area** — that's them.
2. **Several** — the one a camera recognised most recently, if any did inside
   `FACE_PRESENCE_WINDOW_SECONDS`. A face seen thirty seconds ago is the best
   evidence available that a particular person is the one standing there talking.
3. **Nobody in the area but exactly one person home** — them.
4. **Otherwise `person` is null**, with the candidates named.

**An unresolved answer means show less, not ask.** The caller's fallback is a
digest with no personal section — never a prompt, never a guess. Automating the
recognition is only defensible *because* the ambiguous case still fails closed;
that is the same rule the registry applies to registration, applied to display.

**Two things it is not.** It is not speaker identification — nothing here listens
to a voice; it works out who is *in the room*, so two people in a kitchen where
one was just recognised by a camera resolve to that one even if the other spoke.
And it is not a display trigger: nothing in this project shows a digest because
somebody walked past a screen. The canvas opens when it is asked for, and this
endpoint only answers *by whom*.

## Digest settings — owned here, used by `digest-engine/`

Which of `digest-engine`'s four digests get generated for a person — **network**,
**household**, **social** (its `personal` section: mail and messages) and **political /
news** — is a per-person setting on this registry, editable in the admin panel's person
editor. Same reasoning as the chore fields above: it is a standing fact about a
household member, and `identity` is already this project's source of truth for those,
so it lives as a column on `people` rather than in a second database over in
`digest-engine`.

`digest-engine` reads `GET /digest-preferences` once at the start of each of its four
daily runs — the same shape-for-the-consumer pattern as `GET /chore-assignments` — and:

- generates the **union** of what the household asked for. A section nobody has ticked
  costs no LLM call, and `digest-engine` also skips ingesting the sources only that
  section reads (turn the political one off for everybody and its news, financial and
  flight/naval fetches stop happening at all).
- filters each surface to the person Home Assistant resolved, using the per-person sets
  this endpoint hands over.

**The second half is a display filter, not an access control**, and it should not be
described to anyone as privacy: `digest-web` serves the rendered digest read-only to
anything on the LAN, so an unticked section is off somebody's screen and out of their
narration, not out of their reach. The part that genuinely does not exist anywhere is
the part that was never generated.

Two deliberate defaults:

- **Never set means all four.** A household that never opens this panel keeps exactly
  the digest it had before the setting existed. An explicitly empty set is different and
  is honoured as written — "generate nothing for me" is a real answer, and the column
  stores `''` rather than `NULL` to keep the two distinguishable.
- **This service being unreachable means all four too.** `digest-engine` treats any
  failed lookup as "no preferences known" and generates everything, because one
  container being down must never silently cost the household its whole digest.

## Camera face recognition — a second presence signal, never a registration one

If Tapo pan/tilt cameras are wired into Frigate as additional camera sources
(`docs/project-plan.md` Phase 20) and their faces are enrolled in Frigate's own
0.16+ face recognition, `identity` subscribes to `FRIGATE_EVENTS_TOPIC`
(`frigate/events` by default) and treats a recognized name matching a registered
person (case-insensitive) as a **corroborating** presence signal — `home` becomes
`true` if either their BLE identifier reports present *or* their face was seen in
the last `FACE_PRESENCE_WINDOW_SECONDS`. This is genuinely useful for the
device-less case too (a grandmother with no phone can now show as home the moment a
camera recognizes her, not just via the manual toggle).

**It is never a registration signal** — `/register` never reads
`_last_face_seen`, and there's no path from "camera saw a face" to "a new person
got created." That stays BLE/IRK-only and human-confirmed, per this file's
"Anti-spoofing" section above; the camera can corroborate an existing person's
presence, never mint a new identity.

`sub_label = ["name", confidence]` is Frigate's documented shape for object
sub-labels generally (used for both face and license-plate recognition plugins);
whether Frigate 0.16+'s specific face-recognition feature publishes into that exact
field on `frigate/events` is **not verified against a real deployment** — a wrong
topic or field name just means this signal never fires, degrading silently back to
BLE/manual presence only.

## Voice: single-utterance, not multi-turn

The whole flow is designed around one spoken sentence: **"register me as `<name>`"**
— not a multi-turn conversation ("what's your name?" / *reply* / "confirm?"). This is
deliberate: HA Assist's multi-turn/continue-conversation support is newer and more
version-sensitive than a single custom-sentence intent with a captured `{name}` slot,
and a one-shot command is materially more robust to build against. The tradeoff is
explicit up front: say your name in the same breath as the command, or use the
touchscreen's own form instead.

**Nothing under this repo builds the HA-side wiring** — same convention as
digest-engine's/admin-canvas's HA integration points. You need, in Home Assistant's
own config:

```yaml
# configuration.yaml (excerpt) — a custom sentence + intent script that calls this
# service's /register endpoint. VERIFY against your own HA version; this is a worked
# example, not a tested one.
intent_script:
  RegisterPerson:
    speech:
      text: "{{ message }}"
    action:
      - service: rest_command.identity_register
        data:
          name: "{{ name }}"
          device_id: "{{ trigger.device_id | default('unknown') }}"
        response_variable: reg_result
      - variables:
          message: "{{ reg_result.content.message }}"

rest_command:
  identity_register:
    url: "http://<container-host>:8097/register"
    method: POST
    headers:
      Authorization: "Bearer !secret identity_token"
      Content-Type: "application/json"
    payload: '{"name": "{{ name }}", "device_id": "{{ device_id }}"}'
```

Plus a custom sentence file (`custom_sentences/en/register.yaml`) mapping
`"register me as {name}"` to the `RegisterPerson` intent — see
[HA's custom sentences docs](https://www.home-assistant.io/voice_control/custom_sentences/).
The same `IDENTITY_TOKEN` from `identity.env` has to be pasted into HA's `secrets.yaml`
by hand; there's no way for this repo to push it there for you.

## Configure

```sh
cp identity/identity.env.example /opt/smart-home/identity/identity.env
openssl rand -hex 32   # IDENTITY_TOKEN
chmod 600 /opt/smart-home/identity/identity.env
$EDITOR /opt/smart-home/identity/identity.env
```

Two things that must be filled in with real values before this does anything useful:

- **`HA_TOKEN`** — a Long-Lived Access Token from HA's own UI (profile -> Security).
- **`TRUSTED_ENTITY_PREFIXES`** — the actual `entity_id` prefixes your Private BLE
  Device / fixed-tag setup produces. The shipped default
  (`device_tracker.pble_,device_tracker.bletag_`) is a plausible guess, **not
  confirmed against a real HA instance** — check Developer Tools -> States yourself.

## API

All endpoints are bearer-token gated (`Authorization: Bearer <IDENTITY_TOKEN>`),
including the GETs — same reasoning as `pantry-vision`: this service has a published
port because kiosk browsers call it directly, so the token is the actual boundary,
not network placement.

| Endpoint | What it does |
|---|---|
| `POST /register/photo` | raw image bytes -> `{"photo_id": "..."}` — an audit artifact, and also becomes the person's profile picture (see below) |
| `POST /register` | `{"name", "device_id", "photo_id"?, "entity_id"?, "no_device"?}` -> registers, or returns a reason it couldn't (see above) |
| `POST /register/guest` | `{"device_id", "photo_id"?}` -> registers "Guest N", no name needed |
| `GET /people` | admin/audit list of every 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"?, "color"?, "chore_exempt"?, "chore_reminder_style"?, "notify_on_arrival"?, "announce_arrivals"?, "notify_topic"?, "digest_sections"?, "clear_photo"?}`. Omitted keys are left alone; an empty `color` re-derives one rather than blanking it |
| `POST /people/<id>/test-notification` | push a test message to this person's ntfy topic, to prove it works |
| `GET /people/<id>/photo` | the person's profile picture (raw JPEG) |
| `POST /people/<id>/photo` | raw image bytes -> set the profile picture directly, without a walk to the door panel |
| `GET /person-colors` | the eight-colour palette the admin panel offers (see below) |
| `GET/POST /floorplan/sensors`, `DELETE /floorplan/sensors/<id>` | where each position radar sits on the plan, and which way it faces |
| `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>` | 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 |
| `GET`/`POST /people/<id>/digest-settings` | read/replace which digests are generated for this person (`{"digest_sections": ["network", "household", "personal", "political"]}`) |
| `GET /digest-preferences` | every person's set plus `wanted`, the union — the shape `digest-engine/` reads |
| `GET /speaker?area=` | who just spoke in that area — `{"person", "reason", "candidates"}`, `person` null when it can't tell (see above) |
| `GET /floorplan` | every level and its drawn rooms (polygons in normalised 0–1 coordinates) |
| `POST /floorplan/levels` | create or rename a level — `{"id"?, "name", "sort_order"?}` |
| `DELETE /floorplan/levels/<id>` | remove a level and its rooms |
| `GET`/`POST /floorplan/levels/<id>/image` | the level's background image (raw bytes) |
| `POST /floorplan/rooms` | create or update a room — `{"id"?, "level_id", "name", "ha_area_id"?, "points", "color"?}` |
| `DELETE /floorplan/rooms/<id>` | remove a room |
| `GET /floorplan/presence` | the plan **plus who is in each room right now**, with `unplaced`/`unmapped_areas` |
| `GET /floorplan/areas` | area values HA is currently reporting — the editor's pick list |
| `POST /presence/manual` | `{"person_id", "home"}` — hand-operated Home/Away for anyone with no identifiers |
| `POST /people/<id>/chore-settings` | `{"chore_exempt"?, "chore_reminder_style"?}` — see below; either field omitted/`null` leaves it unchanged |
| `GET /presence` | `{"people": [{"id", "name", "nickname", "speak_name", "home", "room", "has_device", "has_photo", "color", "initial", "chore_exempt", "chore_reminder_style", "chore_assignments"}], "generated_at"}` — `home` is `true`/`false`/`null` (unknown), `room` is best-effort floor-plan groundwork (see below) |
| `GET /weather` | proxies `smarthome/weather/current`, same JSON shape (`temperature`/`condition`/`location`) `hosts/thin-client`'s weather overlay already uses |

**Every person gets a profile picture, automatically** — whichever registration photo
was captured most recently for them (`_set_profile_photo()` in `server.py`), no
separate upload step. A device-less registration or a flaky camera just means no
photo yet, not a missing feature — both frontends fall back to a plain circular
placeholder (`👤`) until one exists. Fetched via `GET /people/<id>/photo` with a blob
+ `createObjectURL()` on the frontend side, not a bare `<img src="...">` — the
endpoint is bearer-token gated like everything else here, and a plain `<img>` tag has
no way to send an `Authorization` header.

## Manual verification still outstanding

1. `TRUSTED_ENTITY_PREFIXES`' defaults are guessed, not confirmed against a real
   Private BLE Device / Bermuda setup — the single biggest thing to check before
   trusting registration at all.
2. The worked `intent_script`/`rest_command`/custom-sentence YAML above is written
   against HA's documented shape, not tested against a running HA instance.
3. `PRESENT_STATES = {"home"}` assumes Private BLE Device's `device_tracker` entities
   use the standard `home`/`not_home` vocabulary — check yours actually does.
4. The `>1 candidate` (ambiguous) and `already_claimed` (conflict) paths are
   logically covered but never exercised against two real phones in the same room.
5. Multi-device dedup relies on exact-name case-insensitive matching
   (`WHERE name = ? COLLATE NOCASE`) — two different people who happen to share a
   first name would collide into one record. Register with full names if that's a
   real risk in your household; nothing here disambiguates same-name people.
6. SQLite at `/data/identity.db` has no backup wiring yet — if `ENABLE_BACKUPS` is on
   in `setup-container-host.sh`, confirm `/opt/smart-home/identity` is actually
   covered by whatever paths restic is pointed at (photos under `/data/photos` too —
   losing them just loses profile pictures, not the person records themselves, but
   still worth covering).
7. `AREA_ATTRIBUTE`'s default (`area_id`) is a guess at what Bermuda actually
   attaches to a trusted entity's state — unconfirmed, and the whole `room` field in
   `/presence` degrades to `null` silently if it's wrong, so this could easily go
   unnoticed until someone builds the actual floor-plan UI and finds it empty.
8. The blob+`createObjectURL()` profile-picture fetch (all three frontends) has not
   been checked for a memory leak from never calling `URL.revokeObjectURL()` on the old
   blob URL when `/people`/`/presence` refreshes and re-fetches the same photo —
   likely fine at household scale and dashboard.js's 60s poll cadence, not measured
   over a multi-day uptime.
9. **`DEPARTURE_GRACE_SECONDS`' default (15 min) is a guess at how much a real Private
   BLE Device setup actually flaps** — too low and one evening at home becomes several
   "visits"; too high and a quick trip out doesn't register at all. Nobody has watched
   a real BLE presence entity over a day to tune it. The visit log's usefulness rests
   almost entirely on this number, and it's the first thing to check once there's real
   data in `GET /visits`.
10. **The floor-plan editor's usefulness is gated entirely on `AREA_ATTRIBUTE` being
    right** — every room can be drawn and mapped correctly and still never light up, if
    the attribute `/presence` reads isn't what Bermuda actually publishes. The editor
    makes this diagnosable rather than mysterious (it lists the areas HA is really
    reporting, and names anyone home who couldn't be placed), but it can't fix it. This
    is the same unconfirmed guess as verification item 7, now with a UI depending on it.
11. **The admin panel has been exercised against the API, not in a browser** — every
    endpoint it calls is covered by the route tests, but the page itself (the `<dialog>`
    editor, the prune checkboxes) has not been opened in a real browser on this
    machine. `<dialog>`'s `showModal()` needs a reasonably current browser; the door
    panel's Chromium is fine, an ancient one wouldn't be.
12. **Nothing enforces that voice/TTS consumers actually read `speak_name`** — the
    field is there and documented, and `chores/` uses it, but a future HA intent script
    that reaches for `nickname` instead would be wrong in a way this repo can't catch.
    Worth a look whenever a new consumer of `/presence` or `/resolve` gets written.
13. **Arrival pushes have never been delivered to a real phone from here** — the
    notification logic is covered by tests (subscription, opt-out, dedup, the
    startup-baseline guard), but nothing has been sent through a real ntfy server to a
    real device. Two things to check: that the ntfy **Android** app's instant delivery
    against a self-hosted LAN server behaves as its docs describe, and that a phone on
    the **WireGuard split tunnel** (`docs/network-integration.md` §2.1) actually reaches
    `NTFY_URL` from outside. The split tunnel is the more likely of the two to need
    fiddling — get `AllowedIPs` wrong and it fails either silently or by breaking the
    phone's connectivity on café Wi-Fi.
14. **The device-rights HA wiring above is a worked example, not a tested one** — same
    caveat as the voice-registration YAML. The consequence of getting it wrong is worse
    here than anywhere else in this repo: an automation that calls `lock.unlock`
    *outside* the `condition: template` guard would open the door regardless of what
    this service answered. `identity` cannot enforce that from its side — it only ever
    answers the question.
15. **The digest-section list in `frontend/admin.js` is kept in step with `server.py`'s
    `DIGEST_SECTIONS` by hand**, like `CHORE_TYPES` above it. Unlike `CHORE_TYPES` the
    server does validate these, so drift shows up as a visible refusal rather than a
    silent bad write — but the labels ("Social", "Political / news") are the panel's
    own words and match nothing on the server, so renaming a section in
    `digest-engine` needs all three places checked. `digest-engine`'s end of it (which
    sections a run actually generates, and what a stopped `identity` does to a run) is
    on that component's own verification list.
