553 lines
32 KiB
Markdown
553 lines
32 KiB
Markdown
# 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.**
|
|
|
|
## 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.
|
|
|
|
## Floor-plan groundwork (not the floor plan itself)
|
|
|
|
`/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 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.
|
|
|
|
## 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.
|
|
|
|
## 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"?, "chore_exempt"?, "chore_reminder_style"?, "notify_on_arrival"?, "announce_arrivals"?, "notify_topic"?, "clear_photo"?}`. Omitted keys are left alone |
|
|
| `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) — 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>` | 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 /people/<id>/chore-settings` | `{"chore_exempt"?, "chore_reminder_style"?}` — see below; either field omitted/`null` leaves it unchanged |
|
|
| `GET /presence` | `{"people": [{"id", "name", "nickname", "speak_name", "home", "room", "has_device", "has_photo", "chore_exempt", "chore_reminder_style", "chore_assignments"}], "generated_at"}` — `home` is `true`/`false`/`null` (unknown), `room` is best-effort floor-plan groundwork (see below) |
|
|
| `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 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. **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.
|
|
13. **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.
|