Rides the same arrival transition the visit log is built from, so "arrived" has exactly one definition in this service rather than two that could disagree. - notify_on_arrival (default off) is the "if enabled" half: push me when someone gets home. announce_arrivals (default ON) is a separate per-person opt-OUT of being announced, for anyone who doesn't want their comings and goings broadcast. The asymmetry is deliberate — if both defaulted off, ticking "notify me" would look broken until everyone else opted in too. - Per-person notify_topic falling back to NTFY_DEFAULT_TOPIC. Topics are deduplicated, or a five-person household sharing one topic would get five identical pushes per arrival. The arriver is never told about themselves; subscribers who are away still are, since that's most of the point. - The first sample after startup notifies nobody and establishes a baseline instead — otherwise a restart after a gap would fire "X just got home" for everyone already on the sofa. Costs one missed notification in that window. - Face-recognition arrivals say "was just recognised at home", not "just got home" — the signals aren't equally reliable and the reader should know which. - Pushes are sent outside _db_lock and isolated from each other, so a hanging ntfy can't stall request handling and one bad push can't swallow the rest. - POST /people/<id>/test-notification, because the alternative way to find a typo'd topic is to wait for someone to walk in and notice nothing happened. identity itself never touches the WAN — it POSTs to the ntfy already in this stack. Whether the push reaches a phone that's AWAY is a network question, and docs/network-integration.md's existing answer (WireGuard in, never a port-forward) applies unchanged; iOS can't do this locally at all, since ntfy's iOS app needs APNs and therefore an upstream relay. Documented as a table. Also fixes two pre-existing bugs found while wiring this up: chores' template pointed IDENTITY_URL and NTFY_URL at 127.0.0.1, which inside that container is the container itself. chores would have reached neither identity nor ntfy, and done nothing at all — silently, since both paths fail soft by design. Deployed chores.env files still carry the old values and need editing by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| 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.
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=bibifinds Linus. So doesq=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 readspeak_name, nevernickname.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. Anha_unreachablesample is skipped in full.- BLE flapping doesn't shred the log. A person has to read as away for
DEPARTURE_GRACE_SECONDSbefore 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 toNTFY_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, published on
:8090) — one container to another over the LAN. Whether the message then reaches
your phone is a network question, and it's the one that decides whether this
feature is useful, since "did they get home while I was out?" is most of the point:
| Where your phone is | What's needed |
|---|---|
| On the home Wi-Fi, Android | Nothing. The ntfy Android app holds a connection straight to your self-hosted server ("instant delivery") — no Google services, no WAN |
| Away from home | WireGuard back into your own network — docs/network-integration.md's existing answer, unchanged. On the VPN your phone reaches LAN ntfy exactly as if it were at home |
| Away, without a VPN | Would need ntfy exposed to the WAN — which docs/network-integration.md explicitly tells you not to do, or ntfy's upstream-base-url relay, which puts a third party in the path |
| iOS, anywhere | ntfy's iOS app depends on Apple's APNs, so a self-hosted server needs upstream-base-url set to relay through ntfy.sh — WAN egress and a third party, even on your own Wi-Fi. An Android-only or VPN-only household avoids this entirely |
The honest summary: Android + WireGuard keeps this fully local and works anywhere; iOS cannot, because of Apple's push architecture, not because of anything here. Neither path has been tested against a real phone from this repo — see the verification list below.
Pruning: the filter selects, the human deletes
"Select all that have last visited before <date>" is two endpoints on purpose:
GET /prune/candidates?last_visit_before=…— a read. Fills in the checkboxes.POST /people/prunewith{"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:
# 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_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 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:
# 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 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 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 (all three 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. 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 inGET /visits.- 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>'sshowModal()needs a reasonably current browser; the door panel's Chromium is fine, an ancient one wouldn't be. - Nothing enforces that voice/TTS consumers actually read
speak_name— the field is there and documented, andchores/uses it, but a future HA intent script that reaches fornicknameinstead would be wrong in a way this repo can't catch. Worth a look whenever a new consumer of/presenceor/resolvegets written. - 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. The two things to check first: that the ntfy Android app's
instant-delivery against a self-hosted LAN server behaves as its docs describe, and
that a WireGuard-connected phone reaches
NTFY_URLwhile away (both per the table above). iOS is expected not to work fully locally at all — ntfy's iOS app needs APNs, so a self-hosted server has to relay viaupstream-base-url, which is real WAN egress through a third party and directly contrary to this project's local-first default. That's an Apple constraint, not something this repo can fix, but it should be a conscious choice rather than a surprise. - 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.unlockoutside thecondition: templateguard would open the door regardless of what this service answered.identitycannot enforce that from its side — it only ever answers the question.