identity/ (Phase 6, rewritten from the original Node-RED sketch): a
person <-> BLE-identifier registry built directly as a Python service.
Solves multi-phone (multiple identifiers per person), anti-spoofing
(only allowlisted IRK-resolved/fixed-tag entities are ever accepted as
registration candidates, never a raw or randomized MAC), device-less
people (a grandmother without a smartphone gets a no_device registration
plus a hand-operated home/away toggle, reported as "unknown" rather than
a false "away"), and anonymous guests (POST /register/guest, no name
needed). Every person gets an automatic profile picture from their most
recent registration photo. /presence also reports a best-effort room per
person as groundwork for an eventual floor-plan view (not built).
Registration is single-utterance voice ("register me as <name>") or a
touchscreen form; ambiguous/conflicting candidates never auto-commit.
hosts/door-panel/ (Phase 18, new host): structurally kitchen-display's
twin - one Sway workspace, one Chromium kiosk window - defaulting to
identity's weather+clothing/who's-home/groceries-running-low dashboard,
with voice registration as its actual purpose (mic on by default, unlike
every other host's opt-in).
hosts/kitchen-display/: adds opt-in voice satellite and a "Show
registration" screen pointed at identity's register.html, reusing the
same camera-equipped-endpoint registration flow.
pantry-vision/: adds GET /shopping-list (Grocy's own volatile/missing
products, reshaped) for the door panel's "running low" section.
setup-container-host.sh: wires ENABLE_IDENTITY (identity + identity-web,
published like pantry-vision since kiosk browsers call it directly, plus
a persistent SQLite/photos volume unlike pantry-vision's stateless
design) and fixes the HA_URL example to the host's real LAN IP (HA runs
network_mode: host, unreachable by container name - the same situation
Node-RED's own config already documents).
docs/: Phase 6 rewritten, Phase 18 added, hardware/software/guardrail/
open-decision entries throughout project-plan.md and README.md.
components.md gains RuView presence nodes (one per room), fixed BLE tags,
and 2 spare webcams, with the price estimate updated to match.
Nothing here has been run against real hardware, a real HA instance, or
real Private BLE Device entities - TRUSTED_ENTITY_PREFIXES above all
needs checking against a live instance before registration finds
anything. See identity/README.md and hosts/door-panel/README.md for the
itemized verification lists.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CrKjW3yVptUnjG35HjC1
|
||
|---|---|---|
| .. | ||
| frontend | ||
| Dockerfile | ||
| README.md | ||
| identity.env.example | ||
| requirements.txt | ||
| server.py | ||
README.md
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.
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 |
GET /presence |
{"people": [{"id", "name", "home", "room", "has_device", "has_photo"}], "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.