17 KiB
identity
The household's person <-> BLE-identifier registry, from Phase 6 of the project plan. Solves two concrete problems in one design:
- Multiple phones per person (the classic private/work phone situation).
- MAC address spoofing/randomization, so registration can't be tricked or accidentally fed garbage by a phone's own privacy features.
- 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_ids 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 /registerwith"no_device": trueand 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.
Chore-system settings — owned here, used by chores/
Two per-person fields, set via POST /people/<id>/chore-settings. No frontend
for this exists yet — neither register.html nor dashboard.html expose a way
to set them — call the endpoint directly (an HA script/automation, or curl) until
one is built. Read by chores/ off GET /presence:
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_APPLYstill 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 (seechores/README.md). Empty/unset falls back to a plain, un-styled template with no LLM call at all.
Both fields live on people (not a separate table) because they're household-
standing facts about a person, same category as their name or photo — identity is
already this project's source of truth for who someone is, so this is where "how do
I relate to this specific household member" facts belong, not duplicated into
chores/'s own database.
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:
# 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.
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
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 actualentity_idprefixes 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 registered person + their identifiers + has_photo |
GET /people/<id>/photo |
the person's profile picture (raw JPEG) — their most recent registration photo |
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 |
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", "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 /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 anAuthorizationheader.
Manual verification still outstanding
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.- The worked
intent_script/rest_command/custom-sentence YAML above is written against HA's documented shape, not tested against a running HA instance. PRESENT_STATES = {"home"}assumes Private BLE Device'sdevice_trackerentities use the standardhome/not_homevocabulary — check yours actually does.- The
>1 candidate(ambiguous) andalready_claimed(conflict) paths are logically covered but never exercised against two real phones in the same room. - 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. - SQLite at
/data/identity.dbhas no backup wiring yet — ifENABLE_BACKUPSis on insetup-container-host.sh, confirm/opt/smart-home/identityis actually covered by whatever paths restic is pointed at (photos under/data/photostoo — losing them just loses profile pictures, not the person records themselves, but still worth covering). AREA_ATTRIBUTE's default (area_id) is a guess at what Bermuda actually attaches to a trusted entity's state — unconfirmed, and the wholeroomfield in/presencedegrades tonullsilently if it's wrong, so this could easily go unnoticed until someone builds the actual floor-plan UI and finds it empty.- The blob+
createObjectURL()profile-picture fetch (both frontends) has not been checked for a memory leak from never callingURL.revokeObjectURL()on the old blob URL when/people//presencerefreshes 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.