Add opt-in arrival push notifications; fix chores' unreachable container URLs

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>
digest-per-person-and-agendas
Amir Alexander Abdelbaki 2026-07-31 09:37:10 +02:00
parent 5c05b67a5a
commit 0caa21fc42
8 changed files with 386 additions and 12 deletions

View File

@ -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` - [ ] 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 `<name>`" 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` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as `<name>`" 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` 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 `<date>`"** 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 `<date>`"** 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" - [ ] `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` - [ ] `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 - [ ] 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

View File

@ -8,9 +8,17 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# identity — required for nudging (who's home, who's near which room, who was # 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= IDENTITY_TOKEN=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -73,7 +81,12 @@ OLLAMA_TEXT_MODEL=
# redirecting the nudge to someone else who's around ("the next person that walks # redirecting the nudge to someone else who's around ("the next person that walks
# by") instead of re-nagging whoever was last nudged. # 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 NTFY_TOPIC=chores
NEGLECT_THRESHOLD_HOURS=4 NEGLECT_THRESHOLD_HOURS=4

View File

@ -413,7 +413,22 @@ 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 "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 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. exception to the one security property this service has.
9. Covered by API-level tests, not browser ones — see `identity/README.md`'s 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. verification list, `DEPARTURE_GRACE_SECONDS`' untuned default above all.
### Phase 7 — Kitchen inventory kiosk ### Phase 7 — Kitchen inventory kiosk
@ -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. 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. 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 `<dialog>`-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. 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 `<dialog>`-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.

View File

@ -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 plainly: it's O(visits²) within the window, which is fine for a household and would not
be for a venue. be for a venue.
## Arrival notifications — "tell me when someone gets home"
Opt-in per person, in the admin panel. Fires on **the same arrival transition the
visit log is built from** — a trusted identifier coming into range and HA registering
it — so there is exactly one definition of "arrived" in this service rather than two
that could disagree.
Three per-person settings:
- **`notify_on_arrival`** (default **off**) — send *this* person a push when someone
else gets home. The "if enabled" half.
- **`announce_arrivals`** (default **on**) — whether *this* person's own arrivals may
be announced. Untick it for anyone who doesn't want their comings and goings
broadcast to the household — the same concern the project plan's open decision #32
raises about RuView. It defaults **on** deliberately: if both flags defaulted off,
ticking "notify me" would appear broken until every other person also opted in.
- **`notify_topic`** — this person's own ntfy topic; blank falls back to
`NTFY_DEFAULT_TOPIC`, so a household that never sets these still works.
Rules that fall out of it:
- **The arriving person is never notified about themselves.**
- **Topics are deduplicated.** With no per-person topics, everyone shares
`NTFY_DEFAULT_TOPIC` — without dedup a five-person household would get five
identical pushes for one person walking in.
- **Subscribers who are away still get notified.** "Did the kid get home?" is most of
the reason to want this.
- **The first sample after startup notifies nobody.** It establishes a baseline
instead. Otherwise a restart following a gap long enough for visits to have closed
would fire "X just got home" for everyone who's been on the sofa for hours. The cost
is one genuinely missed notification if somebody walks in during that first pass —
a fair trade against crying wolf on every container restart, and the visit is
recorded correctly either way.
- **A camera sighting says so.** Face-recognition arrivals read "was just recognised
at home", not "just got home" — the two signals aren't equally reliable and the
reader deserves to know which one fired.
- **A failed push never costs you history.** Visits are committed before any network
call; pushes are best-effort and isolated from each other.
`POST /people/<id>/test-notification` pushes a test message to that person's topic —
because the alternative way to discover a typo'd topic is to wait for somebody to walk
through the door and then notice nothing happened.
### Getting the push while you're actually away
**`identity` never touches the WAN.** It POSTs to the self-hosted ntfy this stack
already runs for `chores` (`setup-container-host.sh`'s `ENABLE_NTFY`, 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 ## Pruning: the filter selects, the human deletes
"Select all that have last visited before `<date>`" is two endpoints on purpose: "Select all that have last visited before `<date>`" 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` | `{"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 | | `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` | | `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"?, "clear_photo"?}`. Omitted keys are left alone | | `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 | | `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` | | `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>/identifiers/<id>` | 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 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. 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. 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 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` 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 *outside* the `condition: template` guard would open the door regardless of what

View File

@ -143,6 +143,27 @@
</div> </div>
</fieldset> </fieldset>
<fieldset class="field-group">
<legend>Arrival notifications</legend>
<label class="check">
<input id="edit-notify-on-arrival" type="checkbox">
Push me when someone gets home
</label>
<label class="check">
<input id="edit-announce-arrivals" type="checkbox">
Announce <em>my</em> arrivals to others
<span class="hint">— untick to keep your comings and goings private</span>
</label>
<div class="row">
<label class="field grow">
ntfy topic
<input id="edit-notify-topic" type="text" placeholder="(shared household topic)">
</label>
<button type="button" id="edit-test-notify" class="btn">Send test</button>
</div>
<p id="notify-hint" class="hint"></p>
</fieldset>
<fieldset class="field-group"> <fieldset class="field-group">
<legend>Devices used for presence</legend> <legend>Devices used for presence</legend>
<div id="edit-identifiers" class="card-list"></div> <div id="edit-identifiers" class="card-list"></div>

View File

@ -109,6 +109,8 @@ function personSubtitle(p) {
if (p.identifiers.length) bits.push(`${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}`); if (p.identifiers.length) bits.push(`${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}`);
else bits.push("no device"); 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.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_exempt) bits.push("chore-exempt");
if (p.chore_assignments.length) bits.push(`chores: ${p.chore_assignments.join(", ")}`); if (p.chore_assignments.length) bits.push(`chores: ${p.chore_assignments.join(", ")}`);
return bits.join(" · "); return bits.join(" · ");
@ -171,6 +173,14 @@ function openEditor(personId) {
document.getElementById("edit-note").value = editing.note || ""; document.getElementById("edit-note").value = editing.note || "";
document.getElementById("edit-chore-exempt").checked = editing.chore_exempt; document.getElementById("edit-chore-exempt").checked = editing.chore_exempt;
document.getElementById("edit-reminder-style").value = editing.chore_reminder_style || ""; 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."
: '<span class="error">No ntfy topic reachable — set NTFY_URL and NTFY_DEFAULT_TOPIC on the server, or a topic here.</span>';
const avatar = document.getElementById("edit-avatar"); const avatar = document.getElementById("edit-avatar");
avatar.replaceChildren(document.createTextNode("👤")); avatar.replaceChildren(document.createTextNode("👤"));
@ -278,6 +288,9 @@ document.getElementById("edit-save").addEventListener("click", () => {
note: document.getElementById("edit-note").value.trim(), note: document.getElementById("edit-note").value.trim(),
chore_exempt: document.getElementById("edit-chore-exempt").checked, chore_exempt: document.getElementById("edit-chore-exempt").checked,
chore_reminder_style: document.getElementById("edit-reminder-style").value.trim(), 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) => { .then((result) => {
if (!result.ok) throw new Error(result.message || "Could not save."); 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)); .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", () => { document.getElementById("edit-add-identifier").addEventListener("click", () => {
const input = document.getElementById("edit-new-identifier"); const input = document.getElementById("edit-new-identifier");
const entityId = input.value.trim(); const entityId = input.value.trim();

View File

@ -79,6 +79,35 @@ PRESENCE_POLL_SECONDS=60
DEPARTURE_GRACE_SECONDS=900 DEPARTURE_GRACE_SECONDS=900
VISIT_MAX_OPEN_HOURS=72 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 # Run behaviour
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -68,6 +68,7 @@ Endpoints:
- GET /prune/candidates, POST /people/prune last-visited-before cleanup - GET /prune/candidates, POST /people/prune last-visited-before cleanup
- GET /device-access, POST/DELETE /people/<id>/device-grants per-device rights - GET /device-access, POST/DELETE /people/<id>/device-grants per-device rights
- GET/POST /people/<id>/chore-assignments who owes which chore - GET/POST /people/<id>/chore-assignments who owes which chore
- POST /people/<id>/test-notification prove an ntfy topic actually works
- GET /weather proxies the household smarthome/weather/current MQTT topic - 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. # 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")) 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() _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() _db_lock = threading.Lock()
_weather_lock = threading.Lock() _weather_lock = threading.Lock()
@ -238,7 +259,23 @@ def init_db() -> None:
nickname TEXT, nickname TEXT,
-- note: free-text admin scratchpad ("Sarah's cousin, visits at -- note: free-text admin scratchpad ("Sarah's cousin, visits at
-- Christmas"). Never parsed by anything, never shown to the LLM. -- 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 ( CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY, 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()'s docstring for why the CREATE TABLE above isn't enough.
_ensure_column(conn, "people", "nickname", "TEXT") _ensure_column(conn, "people", "nickname", "TEXT")
_ensure_column(conn, "people", "note", "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): 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_exempt": bool(person["chore_exempt"]),
"chore_reminder_style": person["chore_reminder_style"], "chore_reminder_style": person["chore_reminder_style"],
"chore_assignments": [a["chore_type"] for a in assignments], "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], "identifiers": [dict(i) for i in identifiers],
"device_grants": [dict(g) for g in grants], "device_grants": [dict(g) for g in grants],
"visit_count": stats["n"], "visit_count": stats["n"],
@ -736,8 +785,22 @@ def update_person(person_id: int, fields: dict) -> dict:
} }
updates.append(("nickname", nickname)) updates.append(("nickname", nickname))
if "chore_exempt" in fields: for flag in ("chore_exempt", "notify_on_arrival", "announce_arrivals"):
updates.append(("chore_exempt", int(bool(fields["chore_exempt"])))) 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"): for text_field in ("chore_reminder_style", "note"):
if text_field in fields: 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: def get_person_photo(person_id: int) -> bytes | None:
with _db_lock, _db() as conn: with _db_lock, _db() as conn:
row = conn.execute("SELECT photo_path FROM people WHERE id = ?", (person_id,)).fetchone() 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")) 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: def _record_presence_sample(sample: dict) -> None:
"""One sampler pass: turn "who reads as home right now" into open/closed visit """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 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 departure into the history an inferred absence recorded as an
observed one is a lie the history can never be un-told. observed one is a lie the history can never be un-told.
""" """
global _sampler_primed
if sample.get("error") == "ha_unreachable": if sample.get("error") == "ha_unreachable":
# Every BLE-backed person reads as unknown during an outage, so there's nothing # 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) # trustworthy to record. Skipping the whole pass (rather than just the unknowns)
@ -1033,6 +1186,11 @@ def _record_presence_sample(sample: dict) -> None:
return return
now = _now() 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: with _db_lock, _db() as conn:
for person in sample.get("people", []): for person in sample.get("people", []):
person_id = person["id"] person_id = person["id"]
@ -1051,6 +1209,20 @@ def _record_presence_sample(sample: dict) -> None:
(person_id, now, now, source), (person_id, now, now, source),
) )
LOG.info("identity: %s arrived (source: %s)", person["name"], 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: else:
conn.execute("UPDATE visits SET last_seen_at = ? WHERE id = ?", (now, open_visit["id"])) 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: if stale.rowcount:
LOG.info("identity: closed %d visit(s) that went stale past %sh", stale.rowcount, VISIT_MAX_OPEN_HOURS) 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: def _presence_sampler_loop() -> None:
"""The only thing in this service that runs on its own initiative. Deliberately a """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) assignments_match = re.match(r"^/people/(\d+)/chore-assignments$", path)
grants_match = re.match(r"^/people/(\d+)/device-grants$", path) grants_match = re.match(r"^/people/(\d+)/device-grants$", path)
identifiers_match = re.match(r"^/people/(\d+)/identifiers$", 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) person_match = re.match(r"^/people/(\d+)$", path)
# /people/prune is checked before the bare /people/<id> edit route so it is # /people/prune is checked before the bare /people/<id> edit route so it is
# never parsed as a person id (it can't be — it's not digits — but the ordering # 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))) self._handle_grant_device(int(grants_match.group(1)))
elif identifiers_match: elif identifiers_match:
self._handle_add_identifier(int(identifiers_match.group(1))) 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: elif person_match:
self._handle_update_person(int(person_match.group(1))) self._handle_update_person(int(person_match.group(1)))
else: else:
@ -1826,6 +2020,8 @@ def main() -> int:
"identity: sampling presence every %ds (departure grace %ds)", "identity: sampling presence every %ds (departure grace %ds)",
PRESENCE_POLL_SECONDS, DEPARTURE_GRACE_SECONDS, 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")) port = int(os.environ.get("IDENTITY_PORT", "8097"))
server = ThreadingHTTPServer(("0.0.0.0", port), Handler) server = ThreadingHTTPServer(("0.0.0.0", port), Handler)