From 0caa21fc421f46d5b2688aa3d7a371b715ec65ad Mon Sep 17 00:00:00 2001 From: The_miro Date: Fri, 31 Jul 2026 09:37:10 +0200 Subject: [PATCH] Add opt-in arrival push notifications; fix chores' unreachable container URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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 --- README.md | 2 +- chores/chores.env.example | 19 +++- docs/project-plan.md | 23 +++- identity/README.md | 79 ++++++++++++- identity/frontend/admin.html | 21 ++++ identity/frontend/admin.js | 23 ++++ identity/identity.env.example | 29 +++++ identity/server.py | 202 +++++++++++++++++++++++++++++++++- 8 files changed, 386 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9369af6..5e8da85 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ chores/ Presence/calendar-driven household chore nudging + - [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes — built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md` - [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as ``" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline** — `TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md` - [ ] `identity` also corroborates presence from Frigate face recognition (Phase 20, Tapo pan/tilt cameras) — an OR-ed-in second signal only, **never** a registration signal; and owns the per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, plus chore assignment) consumed by `chores/`, see `identity/README.md` -- [ ] `identity`'s admin panel (`identity/frontend/admin.html`, Phase 6b) — managing people/guests: edit every field, **nicknames** (an input alias only — `/resolve` accepts them, but the assistant always speaks the real `speak_name`), **visit history** sampled from `/presence` plus a derived "who was home with whom" view, **"select all that last visited before ``"** pruning (the filter selects, a human confirms the exact list, the filter is never re-run at delete time), **per-device rights** for self-entry via a smart lock (`identity` only ever *answers* `GET /device-access` — HA asks and HA acts, deny is the default), and chore assignment. Deliberately **not** a kiosk page and not linked from any wall panel. Covered by API-level tests; **never opened in a real browser**, and `DEPARTURE_GRACE_SECONDS` is an untuned guess — see `identity/README.md` +- [ ] `identity`'s admin panel (`identity/frontend/admin.html`, Phase 6b) — managing people/guests: edit every field, **nicknames** (an input alias only — `/resolve` accepts them, but the assistant always speaks the real `speak_name`), **visit history** sampled from `/presence` plus a derived "who was home with whom" view, **"select all that last visited before ``"** pruning (the filter selects, a human confirms the exact list, the filter is never re-run at delete time), **per-device rights** for self-entry via a smart lock (`identity` only ever *answers* `GET /device-access` — HA asks and HA acts, deny is the default), chore assignment, and **opt-in arrival push notifications** ("tell me when someone gets home", via the self-hosted ntfy this stack already runs — `identity` itself never touches the WAN; getting the push while you're *away* is `docs/network-integration.md`'s WireGuard answer, and iOS is a documented exception). Deliberately **not** a kiosk page and not linked from any wall panel. Covered by API-level tests; **never opened in a real browser**, and `DEPARTURE_GRACE_SECONDS` is an untuned guess — see `identity/README.md` - [ ] `trash-calendar` + `transit` (Phase 19, Kennelbach AT trash pickup + Vorarlberg public transit) — built and wired into `setup-container-host.sh` (`ENABLE_TRASH_CALENDAR`/`ENABLE_TRANSIT`/`ENABLE_TRIP_PLANNING`, all off by default), **nothing run against a live ICS feed, a live GTFS feed, or a real OpenTripPlanner instance** — trip planning also needs a manually-built OTP graph this repo does not build for you, see `trash-calendar/README.md` and `transit/README.md`'s "Route planning scope" - [ ] `chores` (Phase 20) — presence/calendar-driven household chore nudging: "I don't care who does it, as long as it gets done" — prefers whoever's been assigned a chore in `identity`'s admin panel but falls through to whoever's actually home rather than waiting (`CHORE_ASSIGNMENT_STRICT` flips that), redirects to someone else if a chore goes neglected, keeps a passive fairness tally that never feeds back into who gets nudged, and camera-checks trash bins/dishes/litter via Frigate + an Ollama vision model. **Litter remains the exception to everything** — it ignores both chore-exemption and assignment, because cleaning up what you left out was never a task anyone could be assigned. Built and wired into `setup-container-host.sh` (`ENABLE_CHORES`, off by default, every-2-hours systemd timer), **no Tapo camera hardware chosen and nothing run against real hardware**, see `chores/README.md` - [ ] Music Assistant (optional, additive multi-room audio) — wired into `setup-container-host.sh` (`ENABLE_MUSIC_ASSISTANT`, off by default), **its default port is an unverified guess that collides with `PANTRY_VISION_PORT`** if both are enabled together, see `docs/project-plan.md` open decision #31 diff --git a/chores/chores.env.example b/chores/chores.env.example index 6ff4bf9..668a3f3 100644 --- a/chores/chores.env.example +++ b/chores/chores.env.example @@ -8,9 +8,17 @@ # --------------------------------------------------------------------------- # identity — required for nudging (who's home, who's near which room, who was -# recently seen by a camera). Same token identity itself uses. +# recently seen by a camera, and who's assigned which chore). Same token identity +# itself uses. +# +# NOTE: this was `http://127.0.0.1:8097` and could never have worked — inside the +# chores container that address is the chores container itself. Since _presence() +# fails soft (logs, returns an empty list, waits for the next run), the symptom would +# have been nudging that simply never happened, with no error anywhere obvious. +# identity is an ordinary service on the same compose bridge network, so its +# container name resolves via Docker's DNS. # --------------------------------------------------------------------------- -IDENTITY_URL=http://127.0.0.1:8097 +IDENTITY_URL=http://identity:8097 IDENTITY_TOKEN= # --------------------------------------------------------------------------- @@ -73,7 +81,12 @@ OLLAMA_TEXT_MODEL= # redirecting the nudge to someone else who's around ("the next person that walks # by") instead of re-nagging whoever was last nudged. # --------------------------------------------------------------------------- -NTFY_URL=http://127.0.0.1:8090 +# NOTE: this was `http://127.0.0.1:8090` and could never have worked — inside the +# chores container, 127.0.0.1 is the chores container itself, not the host, so every +# nudge would have failed to send (silently: _notify() logs and moves on). ntfy is an +# ordinary service on the same compose bridge network, so its container name resolves +# via Docker's DNS, on its INTERNAL port 80 rather than the 8090 published to the LAN. +NTFY_URL=http://ntfy NTFY_TOPIC=chores NEGLECT_THRESHOLD_HOURS=4 diff --git a/docs/project-plan.md b/docs/project-plan.md index 414a673..dc5500b 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -413,8 +413,23 @@ who someone is, which this project already made `identity`'s job. "must be in range right now" (so a fixed BLE tag can be provisioned before it's near the door), never the allowlist itself. An admin panel is not a reason to hand out an exception to the one security property this service has. -9. Covered by API-level tests, not browser ones — see `identity/README.md`'s - verification list, `DEPARTURE_GRACE_SECONDS`' untuned default above all. +9. **Arrival notifications ride the same transition as the visit log** — opt-in per + person (`notify_on_arrival`), with a separate per-person opt-*out* of being + announced (`announce_arrivals`, default on) for anyone who doesn't want their + comings and goings broadcast, the same concern open decision #32 raises about + RuView. Delivered via the self-hosted ntfy this stack already runs for `chores` — + **`identity` itself never touches the WAN**. The first sample after startup + notifies nobody, establishing a baseline instead of firing a burst of false + arrivals for everyone already home. +10. **Whether the push reaches a phone that's away from home is a network decision, + not a feature of this service** — `docs/network-integration.md`'s existing answer + (WireGuard in, never a port-forward) applies unchanged. Android + WireGuard keeps + this fully local; **iOS can't**, since ntfy's iOS app needs APNs and therefore an + upstream relay through a third party. That's an Apple constraint, but it makes + "everyone gets arrival notifications" a household-platform question worth deciding + consciously — see `identity/README.md`'s table. +11. Covered by API-level tests, not browser ones — see `identity/README.md`'s + verification list, `DEPARTURE_GRACE_SECONDS`' untuned default above all. ### Phase 7 — Kitchen inventory kiosk 1. Deploy Grocy via Compose. @@ -870,4 +885,6 @@ These need a decision before their respective implementation steps can be built 34. **`identity`'s `DEPARTURE_GRACE_SECONDS` default (15 min) is an untuned guess at how much a real Private BLE Device setup flaps** (new, Phase 6b) — too low and one evening at home is recorded as several separate "visits," too high and a quick trip out never registers. The entire usefulness of the visit history and the co-presence view rests on this number, and nobody has watched a real BLE presence entity over a day to pick it. First thing to check once `GET /visits` has real data in it. 35. **The device-rights HA wiring is a worked example, not a tested one, and this is the one place where getting it wrong opens a door** (new, Phase 6b) — `identity` only ever *answers* `GET /device-access`; an HA automation that calls `lock.unlock` outside the `condition: template` guard in `identity/README.md`'s example would unlock regardless of that answer, and nothing on this side can detect or prevent that. Same "documented shape, not verified" caveat as #21, with a materially worse failure mode. 36. **The admin panel has never been opened in a real browser** (new, Phase 6b) — every endpoint it calls is covered by API-level tests, but the page itself (the ``-based person editor, the prune checkbox flow) has only been checked for syntax. `showModal()` needs a reasonably current browser; the door panel's Chromium qualifies, something older might not. -37. **Nothing enforces that voice/TTS consumers actually read `speak_name` rather than `nickname`** (new, Phase 6b) — the field exists, is documented, and `chores/` uses it, but a future HA intent script that reaches for the friendlier-looking `nickname` would break the "the assistant uses real names" rule silently. Worth checking whenever a new consumer of `/presence` or `/resolve` is written. +37. **iOS cannot receive `identity`'s arrival notifications without WAN egress through a third party** (new, Phase 6b) — ntfy's iOS app depends on Apple's APNs, so a self-hosted server must relay via `upstream-base-url` (i.e. through ntfy.sh) for iOS devices to be woken, *even on the home Wi-Fi*. Android needs none of that (instant delivery straight to the LAN server), and a WireGuard-connected phone reaches the LAN server from anywhere. This is an Apple architecture constraint, not something this repo can engineer around, but it turns "who gets arrival notifications" into a household-platform decision that should be made consciously rather than discovered. Nothing here has been delivered to a real phone of either kind yet. +38. **Both of `chores`' shipped container URLs pointed at `127.0.0.1` and could never have worked** (found while wiring Phase 6b's arrival notifications) — inside the `chores` container that address is the chores container itself, not the host. `NTFY_URL=http://127.0.0.1:8090` meant every nudge failed to send, and `IDENTITY_URL=http://127.0.0.1:8097` meant `_presence()` never reached identity at all, so **`chores` would have done nothing whatsoever** — and silently, since both failure paths log and continue by design (a deliberate never-block-on-a-dependency choice that here hid a total outage). Corrected in the template to `http://ntfy` and `http://identity:8097` (compose-bridge DNS; ntfy on its internal port 80, not the 8090 published to the LAN). **An already-deployed `/opt/smart-home/chores/chores.env` still has the old values** — templates are copied once at setup and never re-synced, so existing installs need this edited by hand. The broader lesson worth acting on: several services fail soft on an unreachable dependency, which means a misconfigured address produces silence rather than an error — nothing in this repo currently distinguishes "nothing to do" from "never reached the thing that would have told me". +39. **Nothing enforces that voice/TTS consumers actually read `speak_name` rather than `nickname`** (new, Phase 6b) — the field exists, is documented, and `chores/` uses it, but a future HA intent script that reaches for the friendlier-looking `nickname` would break the "the assistant uses real names" rule silently. Worth checking whenever a new consumer of `/presence` or `/resolve` is written. diff --git a/identity/README.md b/identity/README.md index 2224edf..845819b 100644 --- a/identity/README.md +++ b/identity/README.md @@ -187,6 +187,69 @@ corrected later automatically corrects the co-presence answer. The tradeoff is s 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//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 ``" is two endpoints on purpose: @@ -395,7 +458,8 @@ not network placement. | `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/` | edit any editable field — `{"name"?, "nickname"?, "note"?, "chore_exempt"?, "chore_reminder_style"?, "clear_photo"?}`. Omitted keys are left alone | +| `POST /people/` | 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//test-notification` | push a test message to this person's ntfy topic, to prove it works | | `GET /people//photo` | the person's profile picture (raw JPEG) — their most recent registration photo | | `POST /people//identifiers` | `{"entity_id"}` — attach an identifier by hand (a fixed BLE tag not in range yet). Still enforces `TRUSTED_ENTITY_PREFIXES` | | `DELETE /people//identifiers/` | revoke a mistaken or compromised identifier | @@ -469,7 +533,18 @@ no way to send an `Authorization` header. 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. **The device-rights HA wiring above is a worked example, not a tested one** — same +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. 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_URL` while 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 via `upstream-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. +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 diff --git a/identity/frontend/admin.html b/identity/frontend/admin.html index 8b4e9df..7d9e259 100644 --- a/identity/frontend/admin.html +++ b/identity/frontend/admin.html @@ -143,6 +143,27 @@ +
+ Arrival notifications + + +
+ + +
+

+
+
Devices used for presence
diff --git a/identity/frontend/admin.js b/identity/frontend/admin.js index 5c8b430..03b8d75 100644 --- a/identity/frontend/admin.js +++ b/identity/frontend/admin.js @@ -109,6 +109,8 @@ function personSubtitle(p) { if (p.identifiers.length) bits.push(`${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}`); else bits.push("no device"); if (p.device_grants.length) bits.push(`${p.device_grants.length} right${p.device_grants.length === 1 ? "" : "s"}`); + if (p.notify_on_arrival) bits.push(p.notify_deliverable ? "🔔 arrivals" : "🔔 arrivals (undeliverable)"); + if (!p.announce_arrivals) bits.push("not announced"); if (p.chore_exempt) bits.push("chore-exempt"); if (p.chore_assignments.length) bits.push(`chores: ${p.chore_assignments.join(", ")}`); return bits.join(" · "); @@ -171,6 +173,14 @@ function openEditor(personId) { document.getElementById("edit-note").value = editing.note || ""; document.getElementById("edit-chore-exempt").checked = editing.chore_exempt; document.getElementById("edit-reminder-style").value = editing.chore_reminder_style || ""; + document.getElementById("edit-notify-on-arrival").checked = editing.notify_on_arrival; + document.getElementById("edit-announce-arrivals").checked = editing.announce_arrivals; + document.getElementById("edit-notify-topic").value = editing.notify_topic || ""; + // Says out loud when a ticked box still can't deliver, rather than leaving someone + // to cross-reference a checkbox against an env file to work out why nothing arrives. + document.getElementById("notify-hint").innerHTML = editing.notify_deliverable + ? "Pushes go out via the household ntfy server." + : 'No ntfy topic reachable — set NTFY_URL and NTFY_DEFAULT_TOPIC on the server, or a topic here.'; const avatar = document.getElementById("edit-avatar"); avatar.replaceChildren(document.createTextNode("👤")); @@ -278,6 +288,9 @@ document.getElementById("edit-save").addEventListener("click", () => { note: document.getElementById("edit-note").value.trim(), chore_exempt: document.getElementById("edit-chore-exempt").checked, chore_reminder_style: document.getElementById("edit-reminder-style").value.trim(), + notify_on_arrival: document.getElementById("edit-notify-on-arrival").checked, + announce_arrivals: document.getElementById("edit-announce-arrivals").checked, + notify_topic: document.getElementById("edit-notify-topic").value.trim(), }) .then((result) => { if (!result.ok) throw new Error(result.message || "Could not save."); @@ -290,6 +303,16 @@ document.getElementById("edit-save").addEventListener("click", () => { .catch((err) => setStatus(err.message, true)); }); +// Deliberately sends to whatever is SAVED, not what's typed in the box — a test that +// silently used unsaved input would prove a topic works and then leave a different one +// stored. Save first, then test. +document.getElementById("edit-test-notify").addEventListener("click", () => { + setStatus("Sending test…"); + postJson(`/people/${editing.id}/test-notification`, {}) + .then((result) => setStatus(result.message, !result.ok)) + .catch((err) => setStatus(err.message, true)); +}); + document.getElementById("edit-add-identifier").addEventListener("click", () => { const input = document.getElementById("edit-new-identifier"); const entityId = input.value.trim(); diff --git a/identity/identity.env.example b/identity/identity.env.example index 6030a27..96f1948 100644 --- a/identity/identity.env.example +++ b/identity/identity.env.example @@ -79,6 +79,35 @@ PRESENCE_POLL_SECONDS=60 DEPARTURE_GRACE_SECONDS=900 VISIT_MAX_OPEN_HOURS=72 +# --------------------------------------------------------------------------- +# Arrival push notifications — "tell me when someone gets home." Fires on the same +# arrival transition the visit log above is built from (a trusted identifier coming +# into range and HA registering it), so there's exactly one definition of "arrived." +# +# Per-person opt-in: tick "Push me when someone gets home" in the admin panel. A +# person can also untick "announce my arrivals" to keep their own comings and goings +# out of everyone else's notifications. +# +# NTFY_URL — the same self-hosted ntfy this stack already runs for `chores` +# (setup-container-host.sh's ENABLE_NTFY). This is a call from one container to +# another; identity itself never talks to the WAN. +# +# Unlike HA_URL above, a container NAME works here: ntfy is an ordinary service on +# the compose bridge network, so Docker's own DNS resolves "ntfy" — and the port is +# its INTERNAL 80, not the 8090 published to the LAN. (homeassistant is the odd one +# out because it runs network_mode: host, which is why HA_URL needs a real IP.) +# NTFY_DEFAULT_TOPIC — fallback for anyone without their own topic set in the admin +# panel. Leave both blank to turn the whole feature off; it then silently no-ops. +# +# WHETHER YOUR PHONE ACTUALLY RINGS WHILE YOU'RE OUT IS A NETWORK QUESTION, NOT AN +# identity ONE — and "did they get home while I was out?" is most of the point of +# this feature. docs/network-integration.md's answer applies unchanged: WireGuard +# back into your own network, never a port-forward. See identity/README.md's +# "Getting the push while you're actually away". +# --------------------------------------------------------------------------- +NTFY_URL=http://ntfy +NTFY_DEFAULT_TOPIC=household + # --------------------------------------------------------------------------- # Run behaviour # --------------------------------------------------------------------------- diff --git a/identity/server.py b/identity/server.py index bd5f9c5..2d0dd5a 100755 --- a/identity/server.py +++ b/identity/server.py @@ -68,6 +68,7 @@ Endpoints: - GET /prune/candidates, POST /people/prune last-visited-before cleanup - GET /device-access, POST/DELETE /people//device-grants per-device rights - GET/POST /people//chore-assignments who owes which chore + - POST /people//test-notification prove an ntfy topic actually works - GET /weather proxies the household smarthome/weather/current MQTT topic """ @@ -161,8 +162,28 @@ DEPARTURE_GRACE_SECONDS = int(os.environ.get("DEPARTURE_GRACE_SECONDS", "900")) # than 'departed' so the record never claims to have observed a departure it didn't. VISIT_MAX_OPEN_HOURS = float(os.environ.get("VISIT_MAX_OPEN_HOURS", "72")) +# --- Arrival push notifications ------------------------------------------------------ +# "Tell me when someone gets home." Fires off the same arrival transition the visit log +# is built from — the moment a trusted identifier comes into range and HA registers it +# — so there is exactly one definition of "arrived" in this service, not two that could +# disagree. ntfy, the same notification channel chores/ already uses; unset NTFY_URL +# means the whole feature silently no-ops (nothing else changes). +NTFY_URL = os.environ.get("NTFY_URL", "").rstrip("/") +# Fallback topic for anyone who hasn't got their own `notify_topic` set. A household +# that never sets per-person topics still works — everyone just shares one. +NTFY_DEFAULT_TOPIC = os.environ.get("NTFY_DEFAULT_TOPIC", "").strip() + _sampler_stop = threading.Event() +# THE FIRST SAMPLE AFTER STARTUP NEVER NOTIFIES. It establishes a baseline instead. +# Without this, a restart that happens to follow a gap long enough for visits to have +# closed would open a visit for everyone currently home and fire a burst of "X just got +# home" pushes for people who have been on the sofa for hours. The cost is one genuinely +# missed notification if somebody walks in during that very first pass — a fair trade +# against crying wolf every time the container restarts, and the visit itself is still +# recorded correctly either way. +_sampler_primed = False + _db_lock = threading.Lock() _weather_lock = threading.Lock() @@ -238,7 +259,23 @@ def init_db() -> None: nickname TEXT, -- note: free-text admin scratchpad ("Sarah's cousin, visits at -- Christmas"). Never parsed by anything, never shown to the LLM. - note TEXT + note TEXT, + -- notify_on_arrival: send THIS person a push when someone else gets + -- home. Opt-in (default off) — an arrival notification nobody asked + -- for is a nuisance, and this is the "if enabled" half of the feature. + notify_on_arrival INTEGER NOT NULL DEFAULT 0, + -- announce_arrivals: whether THIS person's own arrivals may be + -- announced to subscribers. Defaults ON, unlike the flag above, and + -- the asymmetry is deliberate: if both defaulted off, ticking + -- "notify me" would appear broken until every other person also opted + -- in. Untick it for anyone who doesn't want their comings and goings + -- broadcast to the household — the same concern docs/project-plan.md's + -- open decision #32 raises about RuView. + announce_arrivals INTEGER NOT NULL DEFAULT 1, + -- notify_topic: this person's own ntfy topic. NULL falls back to + -- NTFY_DEFAULT_TOPIC, so a household that never sets these still + -- works — everyone just shares one topic. + notify_topic TEXT ); CREATE TABLE IF NOT EXISTS visits ( id INTEGER PRIMARY KEY, @@ -330,6 +367,9 @@ def init_db() -> None: # _ensure_column()'s docstring for why the CREATE TABLE above isn't enough. _ensure_column(conn, "people", "nickname", "TEXT") _ensure_column(conn, "people", "note", "TEXT") + _ensure_column(conn, "people", "notify_on_arrival", "INTEGER NOT NULL DEFAULT 0") + _ensure_column(conn, "people", "announce_arrivals", "INTEGER NOT NULL DEFAULT 1") + _ensure_column(conn, "people", "notify_topic", "TEXT") def _ha_get(path: str): @@ -646,6 +686,15 @@ def _person_payload(conn: sqlite3.Connection, person: sqlite3.Row) -> dict: "chore_exempt": bool(person["chore_exempt"]), "chore_reminder_style": person["chore_reminder_style"], "chore_assignments": [a["chore_type"] for a in assignments], + # Arrival-notification settings — see the columns' own comments for why one + # defaults off and the other on. + "notify_on_arrival": bool(person["notify_on_arrival"]), + "announce_arrivals": bool(person["announce_arrivals"]), + "notify_topic": person["notify_topic"], + # Whether a push would actually go anywhere right now. The admin panel shows + # this rather than making someone cross-reference a tickbox against an env + # file to work out why they aren't getting notifications. + "notify_deliverable": bool(NTFY_URL and (person["notify_topic"] or NTFY_DEFAULT_TOPIC)), "identifiers": [dict(i) for i in identifiers], "device_grants": [dict(g) for g in grants], "visit_count": stats["n"], @@ -736,8 +785,22 @@ def update_person(person_id: int, fields: dict) -> dict: } updates.append(("nickname", nickname)) - if "chore_exempt" in fields: - updates.append(("chore_exempt", int(bool(fields["chore_exempt"])))) + for flag in ("chore_exempt", "notify_on_arrival", "announce_arrivals"): + if flag in fields: + updates.append((flag, int(bool(fields[flag])))) + + if "notify_topic" in fields: + topic = str(fields["notify_topic"] or "").strip() + # ntfy topics are path segments — a slash or space would silently produce a + # URL that posts somewhere else entirely (or 404s), and the failure would + # only ever show up as "my notifications don't work". + if topic and not re.match(r"^[A-Za-z0-9_-]{1,64}$", topic): + return { + "ok": False, + "reason": "bad_topic", + "message": "A notification topic can only use letters, numbers, - and _.", + } + updates.append(("notify_topic", topic or None)) for text_field in ("chore_reminder_style", "note"): if text_field in fields: @@ -835,6 +898,35 @@ def resolve_name(spoken: str) -> dict: } +def send_test_notification(person_id: int) -> dict: + """Push a test message to this person's own topic. Exists because the alternative + way to find out whether a topic is right is to wait for somebody to walk through + the door and then notice nothing happened — a feedback loop measured in hours, for + a setting that's one typo away from silently going nowhere. + """ + with _db_lock, _db() as conn: + person = conn.execute( + "SELECT name, notify_topic FROM people WHERE id = ?", (person_id,) + ).fetchone() + if person is None: + return {"ok": False, "reason": "not_found", "message": "No such person."} + if not NTFY_URL: + return {"ok": False, "reason": "no_ntfy_url", "message": "NTFY_URL isn't configured on the server."} + topic = person["notify_topic"] or NTFY_DEFAULT_TOPIC + if not topic: + return { + "ok": False, + "reason": "no_topic", + "message": "No topic for this person, and NTFY_DEFAULT_TOPIC isn't set.", + } + sent = _push(topic, "Test notification", f"Arrival notifications are working, {person['name']}.", tags="bell") + return { + "ok": sent, + "topic": topic, + "message": f"Sent to “{topic}”." if sent else f"Could not reach ntfy for “{topic}” — check the server log.", + } + + def get_person_photo(person_id: int) -> bytes | None: with _db_lock, _db() as conn: row = conn.execute("SELECT photo_path FROM people WHERE id = ?", (person_id,)).fetchone() @@ -1012,6 +1104,65 @@ def _parse_iso(value: str) -> datetime: return datetime.fromisoformat(value.replace("Z", "+00:00")) +def _push(topic: str, title: str, message: str, tags: str = "") -> bool: + """One ntfy push. Best-effort by design: a notification that fails to send must + never take down the sampler that was recording a visit at the time, so this + swallows everything and reports a bool rather than raising. + """ + if not NTFY_URL or not topic: + return False + try: + req = urllib.request.Request( + f"{NTFY_URL}/{topic}", data=message.encode("utf-8"), method="POST" + ) + req.add_header("Title", title) + if tags: + req.add_header("Tags", tags) + urllib.request.urlopen(req, timeout=10).close() + return True + except Exception: + LOG.warning("identity: ntfy push to %r failed", topic, exc_info=True) + return False + + +def _arrival_subscribers(conn: sqlite3.Connection, arriver_id: int) -> list[str]: + """The ntfy topics that should hear about this arrival, deduplicated. + + Dedup is the point: with no per-person topics configured everyone falls back to + NTFY_DEFAULT_TOPIC, and without this a five-person household would get five + identical pushes for one person walking in. + + The arriving person is excluded — being told you just got home is noise, and it's + the one exclusion that needs no configuration to be obviously right. Subscribers + who are themselves away are NOT excluded: "did the kid get home?" is most of the + reason to want this at all. + """ + rows = conn.execute( + "SELECT notify_topic FROM people WHERE notify_on_arrival = 1 AND id != ?", + (arriver_id,), + ).fetchall() + topics = [] + for row in rows: + topic = row["notify_topic"] or NTFY_DEFAULT_TOPIC + if topic and topic not in topics: + topics.append(topic) + return topics + + +def _notify_arrival(arriver: str, room: str | None, source: str, topics: list[str]) -> None: + where = f" ({room})" if room else "" + # The REAL name, never the nickname — same rule as every other machine-generated + # message in this project (see the module docstring). A push is text a machine + # wrote, not a household member speaking. + message = f"{arriver} just got home{where}." + if source == "face": + # Say so rather than dressing a camera sighting up as a device registration — + # they are not equally reliable and the reader deserves to know which it was. + message = f"{arriver} was just recognised at home{where}." + for topic in topics: + _push(topic, "Someone's home", message, tags="house") + + def _record_presence_sample(sample: dict) -> None: """One sampler pass: turn "who reads as home right now" into open/closed visit rows. Three states in, three different behaviours out, and the third is the one @@ -1025,6 +1176,8 @@ def _record_presence_sample(sample: dict) -> None: departure into the history — an inferred absence recorded as an observed one is a lie the history can never be un-told. """ + global _sampler_primed + if sample.get("error") == "ha_unreachable": # Every BLE-backed person reads as unknown during an outage, so there's nothing # trustworthy to record. Skipping the whole pass (rather than just the unknowns) @@ -1033,6 +1186,11 @@ def _record_presence_sample(sample: dict) -> None: return now = _now() + # Collected inside the lock, sent outside it — an ntfy endpoint that hangs must + # never hold _db_lock for its full timeout, which would stall every request this + # service is serving at the time. + pending_pushes: list[tuple[str, str | None, str, list[str]]] = [] + with _db_lock, _db() as conn: for person in sample.get("people", []): person_id = person["id"] @@ -1051,6 +1209,20 @@ def _record_presence_sample(sample: dict) -> None: (person_id, now, now, source), ) LOG.info("identity: %s arrived (source: %s)", person["name"], source) + + # The arrival push rides on this exact transition, so "arrived" + # means precisely what the visit log says it means. Suppressed on + # the first pass after startup (see _sampler_primed) and for anyone + # who has opted out of being announced. + announced = conn.execute( + "SELECT announce_arrivals FROM people WHERE id = ?", (person_id,) + ).fetchone() + if _sampler_primed and announced and announced["announce_arrivals"]: + topics = _arrival_subscribers(conn, person_id) + if topics: + pending_pushes.append( + (person["speak_name"] or person["name"], person.get("room"), source, topics) + ) else: conn.execute("UPDATE visits SET last_seen_at = ? WHERE id = ?", (now, open_visit["id"])) @@ -1073,6 +1245,20 @@ def _record_presence_sample(sample: dict) -> None: if stale.rowcount: LOG.info("identity: closed %d visit(s) that went stale past %sh", stale.rowcount, VISIT_MAX_OPEN_HOURS) + # Lock released — safe to make network calls now. Each arrival is isolated: one + # push blowing up must not swallow the arrivals queued behind it, and must not + # skip the priming below. The visit rows are already committed by this point, so + # nothing here can cost us history — only notifications. + for arriver, room, source, topics in pending_pushes: + try: + _notify_arrival(arriver, room, source, topics) + except Exception: + LOG.warning("identity: arrival notification for %s failed", arriver, exc_info=True) + + if not _sampler_primed: + LOG.info("identity: presence baseline established — arrival notifications are live") + _sampler_primed = True + def _presence_sampler_loop() -> None: """The only thing in this service that runs on its own initiative. Deliberately a @@ -1562,6 +1748,7 @@ class Handler(BaseHTTPRequestHandler): assignments_match = re.match(r"^/people/(\d+)/chore-assignments$", path) grants_match = re.match(r"^/people/(\d+)/device-grants$", path) identifiers_match = re.match(r"^/people/(\d+)/identifiers$", path) + test_notify_match = re.match(r"^/people/(\d+)/test-notification$", path) person_match = re.match(r"^/people/(\d+)$", path) # /people/prune is checked before the bare /people/ edit route so it is # never parsed as a person id (it can't be — it's not digits — but the ordering @@ -1584,6 +1771,13 @@ class Handler(BaseHTTPRequestHandler): self._handle_grant_device(int(grants_match.group(1))) elif identifiers_match: self._handle_add_identifier(int(identifiers_match.group(1))) + elif test_notify_match: + result = send_test_notification(int(test_notify_match.group(1))) + self._respond( + HTTPStatus.OK if result.get("ok") + else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT), + result, + ) elif person_match: self._handle_update_person(int(person_match.group(1))) else: @@ -1826,6 +2020,8 @@ def main() -> int: "identity: sampling presence every %ds (departure grace %ds)", PRESENCE_POLL_SECONDS, DEPARTURE_GRACE_SECONDS, ) + if not NTFY_URL: + LOG.info("identity: NTFY_URL is not set — arrival notifications are off") port = int(os.environ.get("IDENTITY_PORT", "8097")) server = ThreadingHTTPServer(("0.0.0.0", port), Handler)