"""identity — the household's person <-> BLE-identifier registry, from
docs/project-plan.md Phase 6.

Solves two problems, both from the same design: a person owning **multiple phones**
(private + work) and defending registration against **MAC spoofing/randomization**.

THE MODEL: a person has zero or more *identifiers*. An identifier is a Home Assistant
entity_id that resolves presence for one physical device. Multi-phone support falls
out of this for free — the same person just accumulates a second identifier the
second time they register with their other phone in hand.

ANTI-SPOOFING, the actual point of this module: a raw Bluetooth MAC address —
especially a randomized one, which is the default on iOS/Android — is never accepted
as an identifier by itself. Only entity_ids matching `TRUSTED_ENTITY_PREFIXES` are
eligible candidates at registration time, and that allowlist is meant to contain only
HA's own IRK-resolved Private BLE Device entities (survive MAC rotation because HA
resolved them cryptographically, not by matching a MAC string) and manually
provisioned fixed-MAC tag entities. An attacker spoofing an arbitrary advertised MAC
never produces a trusted candidate; spoofing a specific person's *resolved* identity
would require their device's actual IRK secret, a materially harder bar. This is a
defense against passive/opportunistic spoofing, not a claim of cryptographic
non-repudiation — see README.md's threat-model section for the honest boundary.

NEVER AUTO-COMMIT ON AMBIGUITY: if registration finds zero, more than one, or an
already-claimed candidate, nothing is written — the caller gets back a reason and (for
the ambiguous case) the candidate list, and a human has to disambiguate via the
touchscreen (POST /register again with an explicit entity_id). The one case that DOES
commit in a single call is the clean one (exactly one trusted, unclaimed candidate),
because the spoken "register me as <name>" command *is* the human confirmation —
requiring a second round-trip for the unambiguous case would just be friction for no
safety benefit. This mirrors, rather than weakens, this project's existing "an
identity merge must never auto-commit silently" rule (docs/project-plan.md's Identity
store row): ambiguity is exactly the case that still requires a human.

NICKNAMES ARE FOR HUMANS TO SAY, NEVER FOR THE MACHINE TO SAY BACK: a person may have
a `nickname` that household members use to refer to them, and /resolve accepts it as
an *input* alias so "is Bibi home?" works. But every payload this service emits also
carries `speak_name`, which is ALWAYS the person's real name — voice/TTS consumers are
required to read `speak_name`, never `nickname`. The asymmetry is deliberate and is
the whole point of the field: a nickname is something people grant each other, not
something a machine should presume to use.

VISITS ARE SAMPLED, NOT REPORTED: nothing pushes an arrival/departure event at this
service — `_presence_sampler_loop()` polls presence() on a timer and writes visit rows
on transitions. That makes the history honest about its own resolution (you know when
someone was *observed* home, to within one poll interval) and means "who was home when,
with whom" needs no separate table: co-presence is an interval-overlap query over
`visits`, not a second copy of the same truth that could drift from it.

DEVICE RIGHTS ARE AN ANSWER, NEVER AN ACTION: `device_grants` records that a person may
operate a specific HA entity (the "let my cousin unlock the front door" case), and
GET /device-access answers yes/no with a reason. This service never calls a lock, opens
a door, or talks to a device — HA asks, HA acts, exactly the "HA mediates, nothing
auto-acts" rule the rest of this project follows. Every check is logged to
`device_access_events` whether it was allowed or denied, because for a door lock the
denied ones are the interesting ones.

Endpoints:
  - POST /register/photo   raw image bytes -> stored as an audit artifact only,
                            NOT run through any face-matching (see README.md)
  - POST /register          the main call, described above
  - GET  /people             admin/audit list of registered people + identifiers
  - POST /people/<id>        edit any editable field on a person (the admin panel)
  - GET/POST /people/<id>/photo   the profile picture — GET serves it, POST replaces
                            it from the admin panel without a walk to the door panel
  - GET  /person-colors      the per-person colour palette (see PERSON_COLORS)
  - DELETE /people/<id>/identifiers/<id>   revoke a mistaken/compromised identifier
  - GET  /presence           who's currently home, resolved from registered identifiers
  - GET  /resolve            spoken name OR nickname -> the canonical person
  - GET  /people/<id>/visits, GET /visits, GET /co-presence   the visit history
  - GET  /prune/candidates, POST /people/prune   last-visited-before cleanup
  - GET  /device-access, POST/DELETE /people/<id>/device-grants   per-device rights
  - GET/POST /people/<id>/chore-assignments   who owes which chore
  - GET  /speaker             who just spoke in a given area, resolved from BLE +
                            face presence — the voice path's automatic recognition
  - GET/POST /people/<id>/digest-settings   which digests digest-engine generates for
                            this person; GET /digest-preferences is the household-wide
                            shape digest-engine itself reads
  - POST /people/<id>/test-notification   prove an ntfy topic actually works
  - GET  /weather             proxies the household smarthome/weather/current MQTT topic
"""

from __future__ import annotations

import json
import logging
import math
import os
import re
import sqlite3
import sys
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit

import paho.mqtt.client as mqtt

LOG = logging.getLogger("identity")

TOKEN = os.environ.get("IDENTITY_TOKEN", "")

DB_PATH = Path(os.environ.get("IDENTITY_DB_PATH", "/data/identity.db"))
PHOTO_DIR = Path(os.environ.get("IDENTITY_PHOTO_DIR", "/data/photos"))

HA_URL = os.environ.get("HA_URL", "http://homeassistant:8123").rstrip("/")
HA_TOKEN = os.environ.get("HA_TOKEN", "")

# Comma-separated entity_id PREFIXES that are trusted as registration candidates.
# MUST be edited to match your real HA entity IDs (Developer Tools -> States) — these
# defaults are plausible-looking placeholders, not confirmed against a real HA
# instance. Only put Private BLE Device / fixed-tag entities here — never a raw
# bluetooth_le_tracker/device_tracker entity backed by an unresolved random MAC.
TRUSTED_ENTITY_PREFIXES = tuple(
    p.strip() for p in os.environ.get(
        "TRUSTED_ENTITY_PREFIXES", "device_tracker.pble_,device_tracker.bletag_"
    ).split(",") if p.strip()
)

# HA states considered "this device is present/nearby right now." device_tracker's
# own vocabulary is home/not_home; Bermuda-style room-level sensors may report a room
# name instead — PRESENT_STATES intentionally does not try to guess a room-level
# state string, since which room a device is in isn't needed for registration, only
# whether it's near the registering endpoint at all. Extend this list if your trusted
# entities use different state values (VERIFY against your instance).
PRESENT_STATES = {"home"}

# Floor-plan groundwork (docs/project-plan.md Phase 6 open decisions): the attribute
# key Bermuda (or whatever room-presence integration you use) attaches to a trusted
# entity's state to say which room/Area it's currently closest to — VERIFY the exact
# name against your own instance's Developer Tools -> States (Bermuda's own area-
# reporting attribute name has not been confirmed here; this is a plausible guess,
# same honesty rule as TRUSTED_ENTITY_PREFIXES). presence() reads this opportunistically
# and degrades to `room: null` if it's missing or unset, never breaking the payload —
# this is what lets a future floor-plan UI show "which room," not just "home/away,"
# without this service needing to change again once that UI actually gets built. The
# rendering itself (a floor-plan image, room<->coordinate mapping) is deliberately NOT
# built here — same "don't build against a guess" rule as everywhere else in this
# project; there's no floor plan or room list to design against yet.
AREA_ATTRIBUTE = os.environ.get("AREA_ATTRIBUTE", "area_id")

MAX_IMAGE_BYTES = int(os.environ.get("IDENTITY_MAX_IMAGE_MB", "15")) * 1024 * 1024
MAX_JSON_BYTES = 32 * 1024

NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 '.-]{0,63}$")
PHOTO_ID_RE = re.compile(r"^[a-f0-9]{16}$")
# Same character class as a name — a nickname is a name, just not the one on the
# birth certificate. Also matched against NAME_RE's length cap for the same reason.
NICKNAME_RE = NAME_RE
ENTITY_ID_RE = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")

# --- Visit sampling (see the module docstring's "VISITS ARE SAMPLED") ---------------
# How often the sampler asks presence() who's home. 60s is deliberately coarse: this
# is a household log ("was Amir home Tuesday evening"), not a security audit trail,
# and every sample is a full HA /api/states fetch.
PRESENCE_POLL_SECONDS = int(os.environ.get("PRESENCE_POLL_SECONDS", "60"))
# How long a person has to read as NOT home before their visit is closed. BLE presence
# flaps — a phone in a pocket in the far corner of the flat drops off and comes back —
# and without this every such flap would end one visit and start another, turning one
# evening at home into forty "visits". Closing uses the LAST time they were actually
# seen, not the moment the grace expired, so the recorded departure stays honest.
DEPARTURE_GRACE_SECONDS = int(os.environ.get("DEPARTURE_GRACE_SECONDS", "900"))
# Safety net for a visit that never gets a definite "not home" to close it — someone
# device-less who was marked home by hand and never marked away, or a person whose
# identifier disappeared from HA entirely. Closed with close_reason='timed_out' rather
# 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()

# --- Digest section preferences ------------------------------------------------------
# Which of digest-engine's four digests a person wants generated for them. The keys are
# digest-engine's own section ids (synth/llm_client.py's SECTIONS and the filenames
# under synth/prompts/) — deliberately the same strings on both sides, so nothing has to
# translate between a preference stored here and a prompt run over there. The friendlier
# words the admin panel shows ("Social", "Political / news") are a UI label only.
#
# This registry stores the preference; it never generates anything. digest-engine reads
# GET /digest-preferences at the start of each run, generates the UNION of what the
# household asked for (a section nobody wants is never sent to the LLM at all), and each
# surface then shows a person only their own subset.
DIGEST_SECTIONS = ("network", "household", "personal", "political")

_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()
_last_weather: dict = {"available": False}

# Frigate face-recognition as a SECOND, corroborating presence signal (docs/
# project-plan.md Phase 20 — Tapo pan/tilt cameras) — never a registration signal
# (see the module docstring: the camera is audit-only for /register, always).
# `frigate/events`' recognized-name field is written from Frigate's documented
# `sub_label` shape (`["name", confidence]`), NOT verified against a real Frigate
# 0.16+ face-recognition deployment — VERIFY before trusting this. Degrades to
# simply never firing (BLE/manual presence still work) if the topic/shape is wrong.
FRIGATE_EVENTS_TOPIC = os.environ.get("FRIGATE_EVENTS_TOPIC", "frigate/events")
FACE_PRESENCE_WINDOW_SECONDS = int(os.environ.get("FACE_PRESENCE_WINDOW_SECONDS", "600"))
_face_lock = threading.Lock()
_last_face_seen: dict[str, float] = {}  # lowercased name -> unix timestamp


def _now() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _db() -> sqlite3.Connection:
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(DB_PATH, timeout=10)
    conn.execute("PRAGMA foreign_keys = ON")
    conn.row_factory = sqlite3.Row
    return conn


def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
    """Add a column to an existing table if it isn't there yet. `CREATE TABLE IF NOT
    EXISTS` only ever creates a table that's missing entirely — it silently does
    nothing to a table that already exists with an older shape, so a deployment that
    has been running since before a column was added would never gain it. Cheap enough
    to run unconditionally at every startup (one PRAGMA per column).
    """
    existing = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
    if column not in existing:
        LOG.info("identity: migrating — adding %s.%s", table, column)
        conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")


def init_db() -> None:
    with _db_lock, _db() as conn:
        conn.executescript(
            """
            CREATE TABLE IF NOT EXISTS people (
                id INTEGER PRIMARY KEY,
                name TEXT NOT NULL UNIQUE COLLATE NOCASE,
                created_at TEXT NOT NULL,
                photo_path TEXT,
                -- Chore-system settings (see chores/README.md). Owned here, not in
                -- chores/ itself, because a person's identity/household-standing is
                -- this service's job — chores just reads /people and /presence.
                -- chore_exempt: household member who's still tracked (a frequent
                -- guest, e.g. a cousin) but never nudged about chores in general.
                -- Litter is the deliberate exception (see chores/check.py's
                -- _EXEMPTIONS_DONT_APPLY) — everyone is responsible for putting
                -- trash they left in the bin, exempt or not.
                chore_exempt INTEGER NOT NULL DEFAULT 0,
                -- chore_reminder_style: free text, e.g. "be assertive, don't let up"
                -- or "be gentle, give me a few minutes of grace" — passed to the
                -- LLM that phrases (never decides) chore reminders. NULL/empty means
                -- the plain, un-styled template.
                chore_reminder_style TEXT,
                -- nickname: what the household CALLS this person. An input alias for
                -- /resolve only — never what a voice assistant says back, see the
                -- module docstring and speak_name below. Deliberately not UNIQUE at
                -- the SQL level: the constraint that actually matters is "must not
                -- collide with anyone else's name OR nickname," which spans two
                -- columns and is enforced in _nickname_conflict().
                nickname TEXT,
                -- note: free-text admin scratchpad ("Sarah's cousin, visits at
                -- Christmas"). Never parsed by anything, never shown to the LLM.
                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,
                -- digest_sections: which of digest-engine's four digests this person
                -- wants generated for them, as a comma-separated list of section keys
                -- (see DIGEST_SECTIONS below). NULL means "all of them" rather than
                -- "none": a household that never opens this panel keeps the digest it
                -- had before this column existed, and an empty string is a real,
                -- deliberate "generate nothing for me" that must not be confused with
                -- the never-set default.
                digest_sections TEXT
            );
            CREATE TABLE IF NOT EXISTS visits (
                id INTEGER PRIMARY KEY,
                person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
                arrived_at TEXT NOT NULL,
                -- NULL means "still here" — exactly one open row per person at a time.
                departed_at TEXT,
                -- The most recent sample that still said "home." Departure is recorded
                -- as of THIS, not as of when the grace window expired, so a visit never
                -- claims someone was home during the 15 minutes we were waiting to be
                -- sure they'd gone.
                last_seen_at TEXT NOT NULL,
                -- Which signal opened the visit: 'ble' | 'face' | 'manual'. Kept
                -- because they are not equally trustworthy and a history that mixes
                -- them without saying so would be quietly misleading.
                source TEXT NOT NULL,
                -- 'departed' (observed a definite not-home) | 'timed_out' (never got
                -- one, closed by VISIT_MAX_OPEN_HOURS). Never conflated — see that
                -- constant's comment.
                close_reason TEXT
            );
            CREATE INDEX IF NOT EXISTS visits_person_arrived
                ON visits (person_id, arrived_at);
            -- Partial index over open visits only: the sampler's hottest query is
            -- "who has a visit open right now," once per person per poll.
            CREATE INDEX IF NOT EXISTS visits_open
                ON visits (person_id) WHERE departed_at IS NULL;
            CREATE TABLE IF NOT EXISTS device_grants (
                id INTEGER PRIMARY KEY,
                person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
                ha_entity_id TEXT NOT NULL,
                -- Free-form verb, defaulting to 'operate'. Not an enum because what
                -- "operate" means is the caller's business (HA's), not this registry's
                -- — it stores who may do what, it does not model device capabilities.
                permission TEXT NOT NULL DEFAULT 'operate',
                granted_at TEXT NOT NULL,
                -- NULL = open-ended. A set expiry is the "cousin has the front door
                -- for the weekend" case; it is checked at answer time, never by a
                -- sweep, so a grant that lapses does so instantly rather than whenever
                -- a cleanup job next happens to run.
                expires_at TEXT,
                note TEXT,
                UNIQUE (person_id, ha_entity_id, permission)
            );
            CREATE TABLE IF NOT EXISTS device_access_events (
                id INTEGER PRIMARY KEY,
                -- Nullable on purpose: a check for an unknown person_id is exactly the
                -- kind of thing worth having a row for.
                person_id INTEGER,
                ha_entity_id TEXT NOT NULL,
                permission TEXT NOT NULL,
                allowed INTEGER NOT NULL,
                reason TEXT NOT NULL,
                requested_via TEXT,
                created_at TEXT NOT NULL
            );
            CREATE INDEX IF NOT EXISTS device_access_events_created
                ON device_access_events (created_at);
            -- FLOOR PLAN (docs/project-plan.md open decision #22). presence() has
            -- reported a best-effort `room` since Phase 6, but there was nothing to
            -- plot it on: no floor plan, no room list, no coordinate format. These two
            -- tables are that missing half, and they are deliberately DRAWN BY A HUMAN
            -- in the admin panel rather than inferred — nothing in this project knows
            -- the shape of these rooms, and guessing one would have been exactly the
            -- "building against a guess" that kept this deferred.
            CREATE TABLE IF NOT EXISTS floorplan_levels (
                id INTEGER PRIMARY KEY,
                name TEXT NOT NULL,
                sort_order INTEGER NOT NULL DEFAULT 0,
                -- Optional background image (a scan, a screenshot of an architect's
                -- PDF, a photo of a sketch). Rooms can be drawn on bare canvas without
                -- one; it just makes drawing them accurate rather than approximate.
                image_path TEXT,
                -- How wide this level is in REAL METRES, across its whole 0..1 extent.
                -- Required for exact positions and for nothing else: a sensor reports
                -- "target at 2.1m", and turning that into a point on a normalised plan
                -- needs a scale. NULL means positions cannot be computed for this level,
                -- which is reported honestly rather than guessed at.
                metres_wide REAL,
                created_at TEXT NOT NULL
            );
            -- WHERE A POSITION SENSOR PHYSICALLY SITS, on the plan.
            --
            -- A room-level presence signal answers "which room". Exact positions need a
            -- sensor that reports coordinates — mmWave (LD2450-class) is the realistic
            -- one — and its readings are RELATIVE TO ITSELF. So the plan has to know
            -- where each sensor is and which way it faces, or the coordinates land
            -- somewhere arbitrary. Drawn by a human in the admin panel, exactly like the
            -- rooms, because nothing here can infer it.
            CREATE TABLE IF NOT EXISTS floorplan_sensors (
                id INTEGER PRIMARY KEY,
                level_id INTEGER NOT NULL REFERENCES floorplan_levels(id) ON DELETE CASCADE,
                name TEXT NOT NULL,
                -- The HA entity prefix its targets appear under. An ESPHome LD2450
                -- publishes sensor.<prefix>_target_1_x / _y and so on; this stores
                -- <prefix> and the reader walks the targets.
                ha_entity_prefix TEXT NOT NULL,
                -- Its own spot on the plan, normalised 0..1 like the room polygons.
                x REAL NOT NULL,
                y REAL NOT NULL,
                -- Which way it faces, degrees clockwise from "up" on the plan. Getting
                -- this wrong mirrors or rotates every target it reports, which is the
                -- single most likely reason positions look plausible but wrong.
                rotation_deg REAL NOT NULL DEFAULT 0,
                created_at TEXT NOT NULL
            );
            CREATE INDEX IF NOT EXISTS floorplan_sensors_level ON floorplan_sensors (level_id);
            CREATE TABLE IF NOT EXISTS floorplan_rooms (
                id INTEGER PRIMARY KEY,
                level_id INTEGER NOT NULL REFERENCES floorplan_levels(id) ON DELETE CASCADE,
                name TEXT NOT NULL,
                -- THE JOIN TO REALITY: presence() reports `room` as whatever
                -- AREA_ATTRIBUTE holds on a trusted entity (an HA area_id by default).
                -- This column is what ties a drawn polygon to that string. Nullable
                -- because drawing the plan and wiring up presence are two separate
                -- jobs, and you should be able to finish the first before starting the
                -- second.
                ha_area_id TEXT,
                -- Polygon vertices as JSON [[x,y], ...], each 0.0-1.0 relative to the
                -- level's own extent. NORMALISED, not pixels: the plan has to render
                -- at any size (a phone, a wall panel, a future kiosk) and pixel
                -- coordinates would be right on exactly one of them. The tradeoff is
                -- that replacing a background image with one of a different ASPECT
                -- RATIO distorts existing rooms — same-ratio replacements are fine.
                points TEXT NOT NULL,
                color TEXT,
                created_at TEXT NOT NULL
            );
            CREATE INDEX IF NOT EXISTS floorplan_rooms_level ON floorplan_rooms (level_id);
            CREATE TABLE IF NOT EXISTS chore_assignments (
                id INTEGER PRIMARY KEY,
                person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
                chore_type TEXT NOT NULL,
                created_at TEXT NOT NULL,
                UNIQUE (person_id, chore_type)
            );
            CREATE TABLE IF NOT EXISTS identifiers (
                id INTEGER PRIMARY KEY,
                person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
                ha_entity_id TEXT NOT NULL UNIQUE,
                registered_at TEXT NOT NULL,
                registered_via_device TEXT
            );
            CREATE TABLE IF NOT EXISTS registration_events (
                id INTEGER PRIMARY KEY,
                person_id INTEGER,
                device_id TEXT,
                photo_path TEXT,
                outcome TEXT NOT NULL,
                created_at TEXT NOT NULL
            );
            CREATE TABLE IF NOT EXISTS manual_presence (
                person_id INTEGER PRIMARY KEY REFERENCES people(id) ON DELETE CASCADE,
                home INTEGER NOT NULL,
                updated_at TEXT NOT NULL
            );
            """
        )
        # Columns added to `people` after the table first shipped — see
        # _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")
        _ensure_column(conn, "people", "digest_sections", "TEXT")
        _ensure_column(conn, "people", "color", "TEXT")
        _ensure_column(conn, "floorplan_levels", "metres_wide", "REAL")
        _backfill_colors(conn)


# --- Per-person colour -------------------------------------------------------------
# WHAT THIS IS FOR: telling two people apart at a glance on a surface too small for
# their name. The floorplan view marks an occupied room with each occupant's initial,
# and a household with an Anna and an Amir gets two identical "A"s — the colour is
# what makes that readable. Everything downstream (the admin panel, the dashboard's
# floorplan, and any watch/panel face built on GET /floorplan/presence) uses the same
# assignment rather than each picking its own, so a person is the same colour
# everywhere they appear.
#
# WHY THESE EIGHT: every channel is 0x00/0x55/0xAA/0xFF, which is exactly the 2-bit-
# per-channel colour space a colour Pebble renders natively (64 colours). Anything
# else gets dithered or snapped by the watch, and a colour that shifts between the
# admin panel and the watch defeats the entire point of having one. They are also
# spread across lightness, not just hue, so they stay distinguishable when a
# black-and-white watch reduces them to a grey — and for the same reason they survive
# the most common colour-vision deficiencies better than a rainbow would.
PERSON_COLORS = [
    "#FF0000",  # red
    "#0055FF",  # blue
    "#FFAA00",  # amber
    "#00AA00",  # green
    "#AA00FF",  # purple
    "#00AAAA",  # teal
    "#FF55AA",  # pink
    "#AA5500",  # brown
]

COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")


def _person_initial(name: str) -> str:
    """The single character a small display shows for this person.

    First letter of the real name, never the nickname — the same rule speak_name
    follows, and for the same reason: what a machine shows for somebody should be
    derived from who they are, not from what the household happens to call them this
    year. Non-alphanumeric leading characters are skipped so a name that starts with a
    quote or an accent-mark artifact still yields a letter.
    """
    for char in (name or "").strip():
        if char.isalnum():
            return char.upper()
    return "?"


def _pick_color(conn: sqlite3.Connection, name: str, person_id: int | None = None) -> str:
    """The least-contended colour for this person.

    Two rules, in order. First, avoid any colour already worn by somebody whose name
    starts with the same letter — that collision is the entire reason this field
    exists, and it is worth spending the whole palette on. Second, among what is left,
    take the least-used colour overall, so a small household ends up with eight
    distinct colours rather than three people sharing red.

    Falls back to the least-used colour when the palette is exhausted (more than eight
    people sharing an initial), because a repeat is better than an empty field — the
    admin panel can always override it by hand.
    """
    rows = conn.execute("SELECT id, name, color FROM people WHERE color IS NOT NULL").fetchall()
    taken: dict[str, int] = {color: 0 for color in PERSON_COLORS}
    same_initial: set[str] = set()
    initial = _person_initial(name)
    for row in rows:
        if person_id is not None and row["id"] == person_id:
            continue
        color = str(row["color"]).upper()
        taken[color] = taken.get(color, 0) + 1
        if _person_initial(row["name"]) == initial:
            same_initial.add(color)

    preferred = [c for c in PERSON_COLORS if c not in same_initial] or PERSON_COLORS
    return min(preferred, key=lambda c: (taken.get(c, 0), PERSON_COLORS.index(c)))


def _backfill_colors(conn: sqlite3.Connection) -> None:
    """Give everyone who predates this column a colour, oldest first.

    Oldest first so the assignment is stable across restarts and matches the order the
    household actually acquired people — re-running this must never reshuffle colours
    somebody has already learned.
    """
    rows = conn.execute(
        "SELECT id, name FROM people WHERE color IS NULL OR color = '' ORDER BY id"
    ).fetchall()
    for row in rows:
        conn.execute("UPDATE people SET color = ? WHERE id = ?", (_pick_color(conn, row["name"], row["id"]), row["id"]))
    if rows:
        LOG.info("identity: assigned a colour to %d person/people that had none", len(rows))


def _ha_get(path: str):
    if not HA_TOKEN:
        raise RuntimeError("HA_TOKEN is not configured")
    req = urllib.request.Request(f"{HA_URL}{path}")
    req.add_header("Authorization", f"Bearer {HA_TOKEN}")
    with urllib.request.urlopen(req, timeout=10) as resp:
        return json.loads(resp.read())


def _trusted_present_candidates() -> list[dict]:
    """Entity_ids matching TRUSTED_ENTITY_PREFIXES whose current HA state indicates
    presence right now. This is the ONLY source of registration candidates — see the
    module docstring for why raw/unresolved MACs never reach this list.
    """
    states = _ha_get("/api/states")
    candidates = []
    for entity in states:
        entity_id = entity.get("entity_id", "")
        if not entity_id.startswith(TRUSTED_ENTITY_PREFIXES):
            continue
        if entity.get("state") not in PRESENT_STATES:
            continue
        candidates.append(
            {
                "entity_id": entity_id,
                "friendly_name": (entity.get("attributes") or {}).get("friendly_name", entity_id),
            }
        )
    return candidates


def _already_claimed_by(conn: sqlite3.Connection, entity_id: str) -> sqlite3.Row | None:
    return conn.execute(
        """
        SELECT people.id, people.name FROM identifiers
        JOIN people ON people.id = identifiers.person_id
        WHERE identifiers.ha_entity_id = ?
        """,
        (entity_id,),
    ).fetchone()


def _match_by_name_or_nickname(conn: sqlite3.Connection, spoken: str) -> list[sqlite3.Row]:
    """Every person whose real name OR nickname matches `spoken`, case-insensitively.
    Returns a LIST, not a single row, because the interesting case is when it has more
    than one entry: someone's nickname colliding with someone else's real name is
    exactly the ambiguity this project refuses to guess its way through. Callers must
    decide what to do with >1 rather than silently taking the first.
    """
    return conn.execute(
        "SELECT * FROM people WHERE name = ? COLLATE NOCASE OR nickname = ? COLLATE NOCASE",
        (spoken, spoken),
    ).fetchall()


def _nickname_conflict(conn: sqlite3.Connection, nickname: str, person_id: int) -> str | None:
    """The name a nickname must not steal: anyone ELSE's real name or nickname. Returns
    the conflicting person's name, or None if the nickname is free. Enforced here in
    Python rather than as a SQL constraint because it spans two columns across all
    rows — see the `nickname` column comment.
    """
    row = conn.execute(
        "SELECT name FROM people WHERE id != ? "
        "AND (name = ? COLLATE NOCASE OR nickname = ? COLLATE NOCASE)",
        (person_id, nickname, nickname),
    ).fetchone()
    return row["name"] if row else None


def _find_or_create_person(conn: sqlite3.Connection, name: str) -> tuple[int, bool]:
    """Resolves an existing person by real name or NICKNAME before creating one — so
    "register me as Bibi" attaches Bibi's second phone to the person already recorded
    as Linus rather than minting a duplicate. Raises AmbiguousName if the spoken string
    matches two different people (see _match_by_name_or_nickname); callers turn that
    into the same refuse-and-ask-a-human response as an ambiguous BLE candidate.
    """
    matches = _match_by_name_or_nickname(conn, name)
    if len(matches) > 1:
        raise AmbiguousName([m["name"] for m in matches])
    if matches:
        return matches[0]["id"], False
    cur = conn.execute(
        "INSERT INTO people (name, created_at, color) VALUES (?, ?, ?)",
        (name, _now(), _pick_color(conn, name)),
    )
    assert cur.lastrowid is not None
    return cur.lastrowid, True


class AmbiguousName(Exception):
    """A spoken name matched more than one person (one by real name, another by
    nickname). Never resolved by picking one — see the module docstring's
    NEVER AUTO-COMMIT ON AMBIGUITY rule, which this is a second instance of.
    """

    def __init__(self, names: list[str]) -> None:
        super().__init__(", ".join(names))
        self.names = names


def _ambiguous_name_response(exc: AmbiguousName) -> dict:
    """The refusal shape for AmbiguousName, shared by every registration path. Names
    the colliding people out loud — the human standing at the panel is the one who can
    fix this (by picking, or by changing a nickname), and they can't if the message
    just says "ambiguous".
    """
    return {
        "ok": False,
        "reason": "ambiguous_name",
        "candidates": [{"name": n} for n in exc.names],
        "message": (
            f"That name matches more than one person here ({', '.join(exc.names)}) — "
            "say a full name, or clear up the nickname in the admin panel."
        ),
    }


def _set_profile_photo(conn: sqlite3.Connection, person_id: int, photo_path: str | None) -> None:
    """Every successful registration that captured a photo updates the person's
    profile picture to it — "most recent registration photo wins" rather than
    "first one wins," a simple, defensible default with no extra UI needed to pick
    one. A no-op if this call didn't have a photo (camera unavailable, etc.) —
    doesn't clear an existing profile photo just because a later re-registration
    happened to skip the camera.
    """
    if photo_path is None:
        return
    conn.execute("UPDATE people SET photo_path = ? WHERE id = ?", (photo_path, person_id))


def register(
    name: str, device_id: str, photo_path: str | None, forced_entity_id: str | None, no_device: bool = False
) -> dict:
    name = name.strip()
    if not NAME_RE.match(name):
        return {"ok": False, "reason": "bad_name", "message": "That name doesn't look valid."}

    # THE "MY GRANDMOTHER DOESN'T HAVE A PHONE" CASE: registration doesn't have to
    # find a device at all. A person with zero identifiers is a fully valid record —
    # just one /presence can never resolve automatically (see presence()'s "unknown",
    # not "away", handling below, and set_manual_presence() for the hand-operated
    # alternative). This is an explicit opt-in flag, not a fallback for "no candidate
    # found" — those stay separate cases (a real phone that just isn't nearby right
    # now is a different situation from someone who was never going to have one).
    if no_device:
        with _db_lock, _db() as conn:
            try:
                person_id, is_new_person = _find_or_create_person(conn, name)
            except AmbiguousName as exc:
                return _ambiguous_name_response(exc)
            _set_profile_photo(conn, person_id, photo_path)
            _log_event(conn, person_id, device_id, photo_path, "registered_no_device")
            return {
                "ok": True,
                "person_id": person_id,
                "person_name": name,
                "entity_id": None,
                "is_new_person": is_new_person,
                "is_new_device": False,
                "message": (
                    f"Welcome, {name}! You're registered without a device — "
                    "use the door panel to mark yourself home or away by hand."
                ) if is_new_person else f"{name} is already registered.",
            }

    try:
        candidates = _trusted_present_candidates()
    except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError) as exc:
        LOG.warning("identity: could not reach HA for candidates", exc_info=True)
        return {"ok": False, "reason": "ha_unreachable", "message": f"Could not reach Home Assistant: {exc}"}

    with _db_lock, _db() as conn:
        if forced_entity_id:
            matches = [c for c in candidates if c["entity_id"] == forced_entity_id]
            if not matches:
                return {
                    "ok": False,
                    "reason": "not_a_candidate",
                    "message": "That device isn't showing as nearby right now.",
                }
            chosen = matches[0]
        elif len(candidates) == 0:
            _log_event(conn, None, device_id, photo_path, "no_candidate")
            return {
                "ok": False,
                "reason": "no_candidate",
                "message": "I couldn't find your phone nearby — make sure Bluetooth is on and try again.",
            }
        elif len(candidates) > 1:
            _log_event(conn, None, device_id, photo_path, "ambiguous")
            return {
                "ok": False,
                "reason": "ambiguous",
                "candidates": candidates,
                "message": "I found more than one device nearby — pick yours on the screen.",
            }
        else:
            chosen = candidates[0]

        # Whether this device is already someone's is compared against the RESOLVED
        # person, not the raw spoken string — otherwise registering a second phone as
        # "Bibi" when the first is filed under Linus (whose nickname is Bibi) would
        # look like a stranger claiming Linus's device and get refused.
        spoken_matches = _match_by_name_or_nickname(conn, name)
        if len(spoken_matches) > 1:
            return _ambiguous_name_response(AmbiguousName([m["name"] for m in spoken_matches]))
        spoken_person_id = spoken_matches[0]["id"] if spoken_matches else None

        claimed_by = _already_claimed_by(conn, chosen["entity_id"])
        if claimed_by is not None and claimed_by["id"] != spoken_person_id:
            _log_event(conn, claimed_by["id"], device_id, photo_path, "already_claimed")
            return {
                "ok": False,
                "reason": "already_claimed",
                "message": "That device is already registered to someone else.",
            }
        if claimed_by is not None:
            # Same person re-registering the same device — a harmless no-op, not an
            # error (e.g. re-running the flow after a network hiccup).
            _set_profile_photo(conn, claimed_by["id"], photo_path)
            _log_event(conn, claimed_by["id"], device_id, photo_path, "already_registered")
            return {
                "ok": True,
                "person_id": claimed_by["id"],
                "person_name": claimed_by["name"],
                "entity_id": chosen["entity_id"],
                "is_new_person": False,
                "is_new_device": False,
                "message": f"You're already registered, {claimed_by['name']}.",
            }

        try:
            person_id, is_new_person = _find_or_create_person(conn, name)
        except AmbiguousName as exc:
            return _ambiguous_name_response(exc)
        conn.execute(
            "INSERT INTO identifiers (person_id, ha_entity_id, registered_at, registered_via_device) "
            "VALUES (?, ?, ?, ?)",
            (person_id, chosen["entity_id"], _now(), device_id),
        )
        _set_profile_photo(conn, person_id, photo_path)
        _log_event(conn, person_id, device_id, photo_path, "registered")

        device_count = conn.execute(
            "SELECT COUNT(*) AS n FROM identifiers WHERE person_id = ?", (person_id,)
        ).fetchone()["n"]

        message = (
            f"Welcome, {name}!" if is_new_person
            else f"Got it — that's device number {device_count} for {name}."
        )
        return {
            "ok": True,
            "person_id": person_id,
            "person_name": name,
            "entity_id": chosen["entity_id"],
            "is_new_person": is_new_person,
            "is_new_device": True,
            "device_count": device_count,
            "message": message,
        }


def _log_event(conn, person_id, device_id, photo_path, outcome) -> None:
    conn.execute(
        "INSERT INTO registration_events (person_id, device_id, photo_path, outcome, created_at) "
        "VALUES (?, ?, ?, ?, ?)",
        (person_id, device_id, photo_path, outcome, _now()),
    )


def _person_payload(conn: sqlite3.Connection, person: sqlite3.Row) -> dict:
    """One person's full admin-facing record. Shared by list_people() and the
    single-person reads so the admin panel never has to reconcile two shapes.
    """
    identifiers = conn.execute(
        "SELECT id, ha_entity_id, registered_at, registered_via_device "
        "FROM identifiers WHERE person_id = ? ORDER BY registered_at",
        (person["id"],),
    ).fetchall()
    grants = conn.execute(
        "SELECT id, ha_entity_id, permission, granted_at, expires_at, note "
        "FROM device_grants WHERE person_id = ? ORDER BY ha_entity_id",
        (person["id"],),
    ).fetchall()
    assignments = conn.execute(
        "SELECT chore_type FROM chore_assignments WHERE person_id = ? ORDER BY chore_type",
        (person["id"],),
    ).fetchall()
    stats = conn.execute(
        "SELECT COUNT(*) AS n, MAX(arrived_at) AS last_arrived FROM visits WHERE person_id = ?",
        (person["id"],),
    ).fetchone()
    open_visit = conn.execute(
        "SELECT arrived_at FROM visits WHERE person_id = ? AND departed_at IS NULL",
        (person["id"],),
    ).fetchone()

    return {
        "id": person["id"],
        "name": person["name"],
        "nickname": person["nickname"],
        # THE FIELD VOICE/TTS CONSUMERS MUST USE. Always the real name, never the
        # nickname, no matter which of the two the human said to get here — see the
        # module docstring. Emitted as its own key rather than leaving callers to
        # "just use name" because a caller that has both fields in front of it will
        # eventually reach for the friendlier-looking one.
        "speak_name": person["name"],
        "note": person["note"],
        "created_at": person["created_at"],
        # The path itself is never exposed — an internal container
        # filesystem detail — just whether GET /people/<id>/photo has
        # anything to serve.
        "has_photo": person["photo_path"] is not None,
        # How a display that has no room for a name shows this person: their colour and
        # their initial, decided here so every surface agrees. See PERSON_COLORS.
        "color": person["color"] or PERSON_COLORS[0],
        "initial": _person_initial(person["name"]),
        "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"],
        # Which of digest-engine's digests this person wants generated for them. Always
        # a concrete list, never null — the "never set" default is resolved here (to all
        # four) rather than leaving every consumer to reinvent it.
        "digest_sections": _stored_digest_sections(person["digest_sections"]),
        # 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"],
        # Falls back to created_at for someone with no recorded visits at all — a
        # person registered before visit sampling existed, or one who has genuinely
        # never been seen home. Pruning needs SOME date for everyone or the never-
        # visited (the most prunable people of all) would silently never be selected.
        "last_visit_at": stats["last_arrived"] or person["created_at"],
        "last_visit_is_estimated": stats["last_arrived"] is None,
        "currently_home_since": open_visit["arrived_at"] if open_visit else None,
    }


def list_people() -> list[dict]:
    with _db_lock, _db() as conn:
        people = conn.execute("SELECT * FROM people ORDER BY name COLLATE NOCASE").fetchall()
        return [_person_payload(conn, person) for person in people]


def set_chore_settings(person_id: int, chore_exempt: bool | None, reminder_style: str | None) -> bool:
    """Either field left as None leaves that column untouched — lets a caller update
    just one of the two without needing to know the other's current value first.
    """
    with _db_lock, _db() as conn:
        exists = conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone()
        if not exists:
            return False
        if chore_exempt is not None:
            conn.execute("UPDATE people SET chore_exempt = ? WHERE id = ?", (int(chore_exempt), person_id))
        if reminder_style is not None:
            conn.execute(
                "UPDATE people SET chore_reminder_style = ? WHERE id = ?",
                (reminder_style.strip() or None, person_id),
            )
        return True


def _stored_digest_sections(value) -> list[str]:
    """The stored column -> the list of sections this person actually wants.

    NULL (never set) means every section, so a household that upgrades into this
    feature keeps exactly the digest it had before. An empty string is different and
    is honoured as written: somebody ticked all four boxes off, and "generate nothing
    for me" is a legitimate answer that must not silently become "generate everything."
    Unknown keys — a section removed from digest-engine since the preference was saved
    — are dropped on read rather than passed on to a prompt file that isn't there.
    """
    if value is None:
        return list(DIGEST_SECTIONS)
    stored = {part.strip().lower() for part in str(value).split(",") if part.strip()}
    return [section for section in DIGEST_SECTIONS if section in stored]


def set_digest_sections(person_id: int, sections: list) -> dict:
    """Replaces this person's whole set — the admin panel edits it as a row of
    checkboxes, so "what's ticked now" is the natural unit, same as chore assignments.

    Deliberately stores the empty string, not NULL, when nothing is ticked: see
    _stored_digest_sections() for why those two must stay distinguishable.
    """
    requested = {str(section).strip().lower() for section in sections if str(section).strip()}
    unknown = sorted(requested - set(DIGEST_SECTIONS))
    if unknown:
        return {
            "ok": False,
            "reason": "unknown_section",
            "message": f"Not a digest section: {', '.join(unknown)}.",
        }
    cleaned = [section for section in DIGEST_SECTIONS if section in requested]
    with _db_lock, _db() as conn:
        if conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone() is None:
            return {"ok": False, "reason": "not_found", "message": "No such person."}
        conn.execute(
            "UPDATE people SET digest_sections = ? WHERE id = ?", (",".join(cleaned), person_id)
        )
    return {"ok": True, "digest_sections": cleaned}


def digest_preferences() -> dict:
    """What digest-engine reads at the start of every run.

    `wanted` is the union: the sections at least one person in the household asked for,
    and therefore the only ones worth spending an LLM call on. It is emitted here rather
    than left for the caller to compute so that "which digests get generated" has one
    definition, in the service that owns the preference.
    """
    with _db_lock, _db() as conn:
        rows = conn.execute(
            "SELECT id, name, nickname, digest_sections, notify_topic "
            "FROM people ORDER BY name COLLATE NOCASE"
        ).fetchall()

    people = [
        {
            "id": row["id"],
            "name": row["name"],
            "nickname": row["nickname"],
            "digest_sections": _stored_digest_sections(row["digest_sections"]),
            # The same ntfy topic arrival notifications go to, so digest-engine can
            # push "your digest is ready" to the person whose digest it is rather
            # than to a household topic everyone shares. NULL means they have none
            # of their own and the caller falls back to its own default — resolved
            # there rather than here, because this service's fallback
            # (NTFY_DEFAULT_TOPIC) is for arrivals and need not be the digest's.
            "notify_topic": row["notify_topic"],
        }
        for row in rows
    ]
    wanted = {section for person in people for section in person["digest_sections"]}
    return {
        "people": people,
        "sections": list(DIGEST_SECTIONS),
        "wanted": [section for section in DIGEST_SECTIONS if section in wanted],
        "generated_at": _now(),
    }


def resolve_speaker(area: str | None) -> dict:
    """Who just spoke, for a voice-activated per-person surface. Best-effort, and
    honest when it cannot tell.

    The digest canvas is never shown because somebody walked past a screen — it is
    shown when somebody asks for it out loud. That makes "who asked" a question this
    registry has to answer without anyone typing a name, which is what this is: the
    two presence signals it already fuses (an IRK-resolved BLE identifier, and a
    Frigate face sighting inside FACE_PRESENCE_WINDOW_SECONDS), narrowed by the area
    the wake word fired in.

    In order:
      1. One person in that area -> that is them.
      2. Several -> the one a camera recognised most recently, if any did inside the
         face window. A face seen thirty seconds ago in a room is the best evidence
         available that a particular person is the one standing there talking.
      3. Nobody in the area, but exactly one person home -> them.
      4. Otherwise `person` is null, with the candidates named.

    WHAT AN UNRESOLVED ANSWER MEANS IS "SHOW LESS", NOT "ASK". The caller's fallback
    is a digest without the personal section (see digest-engine's render.js), not a
    prompt and never a guess — the plan's rule that this system must never show one
    person's mail to another on an inference is unchanged by automating the
    recognition; automating it is only allowed *because* the unresolved case still
    fails closed.

    There is no speaker identification here and this does not pretend otherwise: it
    identifies who is in the room, not whose voice it was. Two people in a kitchen
    where one was just recognised by a camera will resolve to that one even if the
    other spoke. That is the honest limit of the signals this house has.
    """
    snapshot = presence()
    people = snapshot.get("people", [])
    wanted_area = (area or "").strip().lower()

    home = [person for person in people if person.get("home")]
    in_area = [
        person for person in home
        if wanted_area and str(person.get("room") or "").strip().lower() == wanted_area
    ]

    def _answer(person, reason, candidates):
        return {
            "person": person,
            "reason": reason,
            "area": area or None,
            "candidates": [
                {"id": c["id"], "name": c["name"], "speak_name": c["speak_name"]}
                for c in candidates
            ],
            "generated_at": _now(),
        }

    candidates = in_area or ([] if wanted_area else home)

    if len(candidates) == 1:
        return _answer(candidates[0], "the only person in the area" if in_area else "the only person home", candidates)

    if len(candidates) > 1:
        with _face_lock:
            seen = dict(_last_face_seen)
        cutoff = time.time() - FACE_PRESENCE_WINDOW_SECONDS
        recent = [
            (seen.get(str(person["name"]).lower(), 0), person)
            for person in candidates
            if seen.get(str(person["name"]).lower(), 0) >= cutoff
        ]
        if recent:
            recent.sort(key=lambda entry: entry[0], reverse=True)
            return _answer(recent[0][1], "recognised by a camera most recently", candidates)
        return _answer(None, "more than one person here and no recent camera sighting", candidates)

    if not wanted_area and not home:
        return _answer(None, "nobody is home", [])

    if len(home) == 1:
        return _answer(home[0], "nobody resolved to that area, but only one person is home", home)

    return _answer(None, "nobody could be resolved for that area", home)


def update_person(person_id: int, fields: dict) -> dict:
    """The admin panel's "edit every field" call. Only keys actually present in
    `fields` are touched — a partial edit never blanks the fields it didn't mention,
    which is what makes the frontend able to send one changed input rather than
    round-tripping the whole record and racing anyone else editing it.

    `created_at` is deliberately NOT editable: it records when this person entered the
    household's records, which is a fact about what happened, not a preference. Neither
    is `id`. Everything a human might actually want to change is here, including
    renaming a "Guest 4" into a real person once you learn who they are — that's the
    intended promotion path, not a separate endpoint.
    """
    with _db_lock, _db() as conn:
        person = conn.execute("SELECT * FROM people WHERE id = ?", (person_id,)).fetchone()
        if person is None:
            return {"ok": False, "reason": "not_found", "message": "No such person."}

        updates: list[tuple[str, object]] = []

        if "name" in fields:
            name = str(fields["name"] or "").strip()
            if not NAME_RE.match(name):
                return {"ok": False, "reason": "bad_name", "message": "That name doesn't look valid."}
            clash = conn.execute(
                "SELECT name FROM people WHERE id != ? "
                "AND (name = ? COLLATE NOCASE OR nickname = ? COLLATE NOCASE)",
                (person_id, name, name),
            ).fetchone()
            if clash:
                return {
                    "ok": False,
                    "reason": "name_taken",
                    "message": f"{name} already belongs to someone here.",
                }
            updates.append(("name", name))

        if "nickname" in fields:
            raw = fields["nickname"]
            nickname = str(raw or "").strip()
            if not nickname:
                updates.append(("nickname", None))  # clearing it is a legitimate edit
            elif not NICKNAME_RE.match(nickname):
                return {"ok": False, "reason": "bad_nickname", "message": "That nickname doesn't look valid."}
            else:
                clash = _nickname_conflict(conn, nickname, person_id)
                if clash:
                    return {
                        "ok": False,
                        "reason": "nickname_taken",
                        "message": f"“{nickname}” would collide with {clash} — pick another.",
                    }
                updates.append(("nickname", nickname))

        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))

        # Editable from the same "save the whole editor" call as everything else, so the
        # admin panel doesn't need a second round trip just for four checkboxes. An
        # explicit [] is a real answer ("no digests for me") — see set_digest_sections().
        if "digest_sections" in fields:
            requested = fields["digest_sections"]
            if not isinstance(requested, list):
                return {
                    "ok": False,
                    "reason": "bad_field",
                    "message": "'digest_sections' must be a list of section names.",
                }
            cleaned = {str(section).strip().lower() for section in requested if str(section).strip()}
            unknown = sorted(cleaned - set(DIGEST_SECTIONS))
            if unknown:
                return {
                    "ok": False,
                    "reason": "unknown_section",
                    "message": f"Not a digest section: {', '.join(unknown)}.",
                }
            updates.append(
                ("digest_sections", ",".join(s for s in DIGEST_SECTIONS if s in cleaned))
            )

        if "color" in fields:
            color = str(fields["color"] or "").strip()
            if not color:
                # Emptying the field re-derives one rather than leaving a person with
                # no colour: every surface that draws people needs *a* colour, and a
                # blank would only push that decision into four different renderers.
                updates.append(("color", _pick_color(conn, person["name"], person_id)))
            elif not COLOR_RE.match(color):
                return {
                    "ok": False,
                    "reason": "bad_color",
                    "message": "A colour has to look like #RRGGBB.",
                }
            else:
                # Any valid hex is accepted, but only PERSON_COLORS render exactly on a
                # colour Pebble (2 bits per channel) — anything else is snapped by the
                # watch and will not match what the admin panel shows. The panel offers
                # the palette; this permits going outside it knowingly.
                updates.append(("color", color.upper()))

        for text_field in ("chore_reminder_style", "note"):
            if text_field in fields:
                value = fields[text_field]
                if value is not None and not isinstance(value, str):
                    return {"ok": False, "reason": "bad_field", "message": f"'{text_field}' must be text."}
                updates.append((text_field, (value or "").strip() or None))

        # Clearing the profile picture is an edit like any other. The file itself is
        # left on disk on purpose — it's also a registration_events audit artifact,
        # and "stop showing this photo" is a different request from "destroy the
        # record that this photo was taken."
        if fields.get("clear_photo"):
            updates.append(("photo_path", None))

        for column, value in updates:
            conn.execute(f"UPDATE people SET {column} = ? WHERE id = ?", (value, person_id))

        updated = conn.execute("SELECT * FROM people WHERE id = ?", (person_id,)).fetchone()
        return {"ok": True, "person": _person_payload(conn, updated), "changed": [c for c, _ in updates]}


def add_identifier(person_id: int, entity_id: str) -> dict:
    """Attach an identifier by hand, for the case registration can't cover: a fixed-MAC
    BLE tag provisioned for someone before they (or the tag) are anywhere near the
    door panel.

    STILL BEHIND THE ANTI-SPOOFING BOUNDARY: the entity_id must match
    TRUSTED_ENTITY_PREFIXES, exactly like a registration candidate. What this endpoint
    relaxes is only the "must be present RIGHT NOW" requirement — being in the room is
    a convenience check, whereas the trusted-prefix allowlist is the actual security
    property (see the module docstring), and an admin panel is not a reason to hand out
    an exception to it.
    """
    entity_id = entity_id.strip()
    if not ENTITY_ID_RE.match(entity_id):
        return {"ok": False, "reason": "bad_entity_id", "message": "That doesn't look like an entity_id."}
    if not entity_id.startswith(TRUSTED_ENTITY_PREFIXES):
        return {
            "ok": False,
            "reason": "untrusted_entity",
            "message": (
                "That entity isn't in TRUSTED_ENTITY_PREFIXES — only IRK-resolved "
                "Private BLE Device or fixed-tag entities can be identifiers."
            ),
        }
    with _db_lock, _db() as conn:
        if conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone() is None:
            return {"ok": False, "reason": "not_found", "message": "No such person."}
        claimed = _already_claimed_by(conn, entity_id)
        if claimed is not None:
            return {
                "ok": False,
                "reason": "already_claimed",
                "message": f"That device already belongs to {claimed['name']}.",
            }
        conn.execute(
            "INSERT INTO identifiers (person_id, ha_entity_id, registered_at, registered_via_device) "
            "VALUES (?, ?, ?, ?)",
            (person_id, entity_id, _now(), "admin-panel"),
        )
        return {"ok": True, "message": f"Added {entity_id}."}


def resolve_name(spoken: str) -> dict:
    """Spoken name or nickname -> the canonical person. The point of the whole nickname
    feature on the input side: household members say "is Bibi home?", this turns that
    into Linus's record. The response always carries `speak_name` (the real name) so
    the assistant that asked can phrase its answer with the right one — see the module
    docstring's NICKNAMES ARE FOR HUMANS TO SAY rule.
    """
    spoken = (spoken or "").strip()
    if not spoken:
        return {"matched": False, "reason": "empty"}
    with _db_lock, _db() as conn:
        matches = _match_by_name_or_nickname(conn, spoken)
        if not matches:
            return {"matched": False, "reason": "unknown", "query": spoken}
        if len(matches) > 1:
            return {
                "matched": False,
                "reason": "ambiguous_name",
                "query": spoken,
                "candidates": [{"id": m["id"], "name": m["name"]} for m in matches],
            }
        person = matches[0]
        return {
            "matched": True,
            "query": spoken,
            "id": person["id"],
            "name": person["name"],
            "nickname": person["nickname"],
            "speak_name": person["name"],
            "matched_on": "name" if (person["name"] or "").lower() == spoken.lower() else "nickname",
        }


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()
    if row is None or row["photo_path"] is None:
        return None
    path = Path(row["photo_path"])
    if not path.is_file():
        return None
    return path.read_bytes()


def set_person_photo(person_id: int, image: bytes) -> dict:
    """Set someone's profile picture directly, from the admin panel.

    Until this existed the only way to get a profile picture was to walk to the door
    panel and re-register, because `_set_profile_photo()` only ever runs on the
    registration path ("most recent registration photo wins"). That is a fine rule for
    the picture the *registration* captured and a poor one for "this is what this
    person looks like" — a device-less household member registered by hand had no way
    to have a face at all.

    Written into the same PHOTO_DIR as a registration capture and with the same
    filename shape, so anything that serves or backs up one serves and backs up the
    other. It is deliberately NOT recorded as a registration_event: nobody registered.
    """
    with _db_lock, _db() as conn:
        person = conn.execute("SELECT id FROM people WHERE id = ?", (person_id,)).fetchone()
        if person is None:
            return {"ok": False, "reason": "no_such_person", "message": "No person with that id."}

        PHOTO_DIR.mkdir(parents=True, exist_ok=True)
        photo_path = PHOTO_DIR / f"{int(time.time())}{os.getpid() % 10000:04d}.jpg"
        photo_path.write_bytes(image)
        # The previous file is left on disk on purpose, the same reasoning as
        # clear_photo's: it may also be a registration_events audit artifact, and
        # replacing a profile picture is not a request to destroy the record that an
        # earlier one was taken.
        conn.execute("UPDATE people SET photo_path = ? WHERE id = ?", (str(photo_path), person_id))
        updated = conn.execute("SELECT * FROM people WHERE id = ?", (person_id,)).fetchone()
        LOG.info("identity: set profile photo for person %d (%d bytes)", person_id, len(image))
        return {"ok": True, "person": _person_payload(conn, updated)}


def delete_identifier(person_id: int, identifier_id: int) -> bool:
    with _db_lock, _db() as conn:
        cur = conn.execute(
            "DELETE FROM identifiers WHERE id = ? AND person_id = ?", (identifier_id, person_id)
        )
        return cur.rowcount > 0


def delete_person(person_id: int) -> bool:
    """Mainly for cleaning up stale/anonymous Guest records (see register_guest) —
    equally usable for any person, ON DELETE CASCADE takes their identifiers with
    them.
    """
    with _db_lock, _db() as conn:
        cur = conn.execute("DELETE FROM people WHERE id = ?", (person_id,))
        return cur.rowcount > 0


def register_guest(device_id: str, photo_path: str | None) -> dict:
    """The "doesn't need to know who it is" path — no name, no BLE candidate lookup
    at all, just a household record that someone who isn't a registered resident is
    around. Always creates a NEW person ("Guest 1", "Guest 2", ...) rather than
    reusing one — unlike named registration, two guest visits are not assumed to be
    the same person the way two registrations of "Amir" are. See delete_person() for
    cleaning up a stale guest entry afterwards; nothing here expires them
    automatically.
    """
    with _db_lock, _db() as conn:
        existing = conn.execute("SELECT COUNT(*) AS n FROM people WHERE name LIKE 'Guest %'").fetchone()["n"]
        name = f"Guest {existing + 1}"
        person_id, _ = _find_or_create_person(conn, name)
        _set_profile_photo(conn, person_id, photo_path)
        _log_event(conn, person_id, device_id, photo_path, "registered_guest")
        return {
            "ok": True,
            "person_id": person_id,
            "person_name": name,
            "entity_id": None,
            "is_new_person": True,
            "is_new_device": False,
            "message": f"Added {name}.",
        }


def set_manual_presence(person_id: int, home: bool) -> bool:
    """The hand-operated alternative to BLE presence, for anyone with zero
    identifiers (grandmother, a guest) — see presence()'s handling below. Silently
    accepted for a person who *does* have identifiers too (harmless, just ignored by
    presence() in that case) rather than rejected, since there's no safety reason to
    forbid it and one fewer edge case to special-case in the caller.
    """
    with _db_lock, _db() as conn:
        exists = conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone()
        if not exists:
            return False
        conn.execute(
            "INSERT INTO manual_presence (person_id, home, updated_at) VALUES (?, ?, ?) "
            "ON CONFLICT(person_id) DO UPDATE SET home = excluded.home, updated_at = excluded.updated_at",
            (person_id, int(home), _now()),
        )
        return True


def presence() -> dict:
    """Who's home. For a person with at least one registered identifier, resolved
    from that identifier's current HA state, same as before. For a person with
    ZERO identifiers (grandmother, a guest — see register()'s no_device path and
    register_guest()) there is nothing to resolve automatically, so this reports
    `home: null` ("unknown") rather than `false` ("away") unless a manual override
    has been set via set_manual_presence() — reporting them as away by default would
    be actively wrong, not just uninformative, the moment they're actually home.
    Does not assume or require pre-existing HA `person.*` entities, since this
    service is itself the source of truth for name<->identifier mapping. Degrades to
    an empty list (never an error page) if nobody is registered yet or HA is
    unreachable, same "degrade, don't blank" rule as every other renderer in this
    project.
    """
    people = list_people()
    if not people:
        return {"people": [], "generated_at": _now()}

    try:
        states = {s["entity_id"]: s for s in _ha_get("/api/states")}
        ha_ok = True
    except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError):
        LOG.warning("identity: could not reach HA for presence", exc_info=True)
        states = {}
        ha_ok = False

    with _db_lock, _db() as conn:
        manual = {
            row["person_id"]: bool(row["home"])
            for row in conn.execute("SELECT person_id, home FROM manual_presence")
        }

    with _face_lock:
        seen_recently = {
            name for name, ts in _last_face_seen.items() if time.time() - ts <= FACE_PRESENCE_WINDOW_SECONDS
        }

    result = []
    for person in people:
        room = None
        face_seen = person["name"].strip().lower() in seen_recently
        if person["identifiers"] and ha_ok:
            entity_states = [states[i["ha_entity_id"]] for i in person["identifiers"] if i["ha_entity_id"] in states]
            # A second, corroborating signal — see FRIGATE_EVENTS_TOPIC's
            # module-level comment. Camera face recognition is never a registration
            # signal (see the module docstring), only ever an OR-ed-in presence one.
            home = face_seen or any(s.get("state") in PRESENT_STATES for s in entity_states)
            # Floor-plan groundwork — see AREA_ATTRIBUTE's module-level comment.
            # First identifier that actually reports one wins; a person with two
            # phones in two different rooms is a real but rare edge case not worth
            # more than "pick one" for a v1 that has no map to show it on yet anyway.
            for s in entity_states:
                area = (s.get("attributes") or {}).get(AREA_ATTRIBUTE)
                if area:
                    room = area
                    break
        elif person["identifiers"]:
            home = True if face_seen else None  # HA unreachable, but a camera sighting still counts
        else:
            # Device-less (grandmother, a guest): face recognition can confirm
            # "home" even though nothing here can ever confirm "away" from a
            # camera alone (not seen recently just means not seen, not proven
            # absent) — so a recent sighting overrides a stale/never-set manual
            # flag, but a manual "away" is never second-guessed by a camera miss.
            home = True if face_seen else manual.get(person["id"])

        result.append(
            {
                "id": person["id"],
                "name": person["name"],
                "nickname": person["nickname"],
                # Always the real name — see the module docstring. `chores/` phrases
                # reminders off this, so a nudge never addresses someone by a nickname
                # the machine wasn't given.
                "speak_name": person["speak_name"],
                "home": home,
                "room": room,
                "has_device": bool(person["identifiers"]),
                "has_photo": person["has_photo"],
                # Carried through from /people so a consumer that only ever calls
                # /presence (the floorplan, a watch face) can draw somebody without a
                # second request per person — see PERSON_COLORS.
                "color": person["color"],
                "initial": person["initial"],
                "face_seen_recently": face_seen,
                # Chore-system settings, straight passthrough — see chores/README.md
                # for how these are used (never anything presence-related itself).
                "chore_exempt": person["chore_exempt"],
                "chore_reminder_style": person["chore_reminder_style"],
                "chore_assignments": person["chore_assignments"],
            }
        )

    payload = {"people": result, "generated_at": _now()}
    if not ha_ok:
        payload["error"] = "ha_unreachable"
    return payload


# --- Visit history: who was home when, and with whom -------------------------------
# See the module docstring's "VISITS ARE SAMPLED, NOT REPORTED" for the shape of this
# and why co-presence is a query rather than a table.


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
    that matters:

      home is True   -> open a visit, or extend the open one's last_seen_at
      home is False  -> close the open visit, but only once DEPARTURE_GRACE_SECONDS
                        of not-home have passed (BLE flaps; see that constant)
      home is None   -> DO NOTHING AT ALL. "Unknown" is not "away". A device-less
                        person nobody has toggled, or an HA outage, must never write a
                        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)
        # also keeps the VISIT_MAX_OPEN_HOURS sweep from counting an outage against a
        # visit it can't currently see the state of.
        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"]
            open_visit = conn.execute(
                "SELECT * FROM visits WHERE person_id = ? AND departed_at IS NULL", (person_id,)
            ).fetchone()

            if person.get("home") is True:
                if open_visit is None:
                    source = "face" if person.get("face_seen_recently") else (
                        "ble" if person.get("has_device") else "manual"
                    )
                    conn.execute(
                        "INSERT INTO visits (person_id, arrived_at, last_seen_at, source) "
                        "VALUES (?, ?, ?, ?)",
                        (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"]))

            elif person.get("home") is False and open_visit is not None:
                gone_for = (_parse_iso(now) - _parse_iso(open_visit["last_seen_at"])).total_seconds()
                if gone_for >= DEPARTURE_GRACE_SECONDS:
                    conn.execute(
                        "UPDATE visits SET departed_at = ?, close_reason = 'departed' WHERE id = ?",
                        (open_visit["last_seen_at"], open_visit["id"]),
                    )
                    LOG.info("identity: %s departed (last seen %s)", person["name"], open_visit["last_seen_at"])

        # The stuck-visit sweep — see VISIT_MAX_OPEN_HOURS.
        cutoff = (datetime.now(timezone.utc) - timedelta(hours=VISIT_MAX_OPEN_HOURS)).isoformat().replace("+00:00", "Z")
        stale = conn.execute(
            "UPDATE visits SET departed_at = last_seen_at, close_reason = 'timed_out' "
            "WHERE departed_at IS NULL AND last_seen_at < ?",
            (cutoff,),
        )
        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
    plain daemon thread rather than a systemd timer + separate entrypoint (like
    chores/): the visit log is this service's own state, sampled from its own
    presence() — handing that to an external scheduler would mean a second process
    writing the same SQLite file for no gain.

    Never lets an exception kill the thread: a sampler that dies quietly would leave a
    permanently frozen visit history that still *looks* fine from the outside, the
    worst possible failure mode for a log.
    """
    while not _sampler_stop.wait(PRESENCE_POLL_SECONDS):
        try:
            _record_presence_sample(presence())
        except Exception:
            LOG.warning("identity: presence sampler pass failed, will retry", exc_info=True)


def list_visits(person_id: int | None = None, since: str | None = None, limit: int = 200) -> list[dict]:
    clauses, params = [], []
    if person_id is not None:
        clauses.append("visits.person_id = ?")
        params.append(person_id)
    if since:
        clauses.append("visits.arrived_at >= ?")
        params.append(since)
    where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
    params.append(max(1, min(limit, 1000)))
    with _db_lock, _db() as conn:
        rows = conn.execute(
            f"""
            SELECT visits.*, people.name, people.nickname
            FROM visits JOIN people ON people.id = visits.person_id
            {where}
            ORDER BY visits.arrived_at DESC
            LIMIT ?
            """,
            params,
        ).fetchall()
    return [
        {
            "id": r["id"],
            "person_id": r["person_id"],
            "name": r["name"],
            "speak_name": r["name"],
            "nickname": r["nickname"],
            "arrived_at": r["arrived_at"],
            "departed_at": r["departed_at"],
            "last_seen_at": r["last_seen_at"],
            "source": r["source"],
            "close_reason": r["close_reason"],
            "ongoing": r["departed_at"] is None,
        }
        for r in rows
    ]


def co_presence(person_id: int | None = None, since: str | None = None, limit: int = 200) -> dict:
    """"Who was home when, with whom" — computed by overlapping visit intervals, never
    stored. Two visits overlap iff each starts before the other ends, with an open
    visit (departed_at IS NULL) treated as ending now.

    Deriving it means it can never disagree with the visit log it came from, and it
    costs nothing to keep correct when a visit is later closed or corrected. The
    tradeoff is honest: this is O(visits²) inside the window, which is fine for a
    household's worth of rows and would not be for a venue's.
    """
    visits = list_visits(person_id=None, since=since, limit=1000)
    now = _now()

    def overlap(a: dict, b: dict) -> tuple[str, str] | None:
        start = max(a["arrived_at"], b["arrived_at"])
        end = min(a["departed_at"] or now, b["departed_at"] or now)
        return (start, end) if start < end else None

    subject = [v for v in visits if person_id is None or v["person_id"] == person_id]
    overlaps = []
    for visit in subject:
        for other in visits:
            if other["person_id"] == visit["person_id"] or other["id"] == visit["id"]:
                continue
            # Without this, every pair shows up twice (once from each side) whenever
            # the caller didn't narrow to one person.
            if person_id is None and other["id"] < visit["id"]:
                continue
            window = overlap(visit, other)
            if window is None:
                continue
            overlaps.append(
                {
                    "from": window[0],
                    "until": window[1],
                    "ongoing": visit["departed_at"] is None and other["departed_at"] is None,
                    "people": [
                        {"id": visit["person_id"], "name": visit["name"]},
                        {"id": other["person_id"], "name": other["name"]},
                    ],
                }
            )
    overlaps.sort(key=lambda o: o["from"], reverse=True)
    return {"overlaps": overlaps[: max(1, min(limit, 1000))], "generated_at": now}


# --- Pruning: last-visited-before ---------------------------------------------------
def prune_candidates(last_visit_before: str) -> dict:
    """Everyone whose most recent visit predates `last_visit_before` — the admin
    panel's "select all that have last visited before <date>" checkbox filter. A READ.
    It selects; it never deletes. See prune_people() for why those are separate calls.

    Someone with no recorded visits at all falls back to their created_at (see
    _person_payload) — otherwise a person registered once and never seen since, the
    single most prunable record there is, would be the one this filter could never
    find.
    """
    people = list_people()
    matching = [p for p in people if p["last_visit_at"] < last_visit_before]
    return {
        "last_visit_before": last_visit_before,
        "candidates": matching,
        "count": len(matching),
        "generated_at": _now(),
    }


def prune_people(person_ids: list[int]) -> dict:
    """Bulk-delete BY EXPLICIT ID, never by filter. The frontend runs prune_candidates()
    to fill in the checkboxes and then sends back the ids the human actually looked at
    and confirmed.

    That indirection is the point: a filter re-evaluated at delete time could quietly
    take someone who came home in the seconds between the preview and the click, and
    "the list I approved is the list that got deleted" is worth one extra round trip
    for an irreversible operation on people's records.
    """
    deleted, missing = [], []
    with _db_lock, _db() as conn:
        for person_id in person_ids:
            row = conn.execute("SELECT name FROM people WHERE id = ?", (person_id,)).fetchone()
            if row is None:
                missing.append(person_id)
                continue
            conn.execute("DELETE FROM people WHERE id = ?", (person_id,))
            deleted.append({"id": person_id, "name": row["name"]})
    if deleted:
        LOG.info("identity: pruned %d person record(s): %s", len(deleted), ", ".join(d["name"] for d in deleted))
    return {"ok": True, "deleted": deleted, "not_found": missing}


# --- Per-device rights --------------------------------------------------------------
# See the module docstring's "DEVICE RIGHTS ARE AN ANSWER, NEVER AN ACTION".


def grant_device(person_id: int, entity_id: str, permission: str, expires_at: str | None, note: str | None) -> dict:
    entity_id = (entity_id or "").strip()
    if not ENTITY_ID_RE.match(entity_id):
        return {"ok": False, "reason": "bad_entity_id", "message": "That doesn't look like an entity_id."}
    permission = (permission or "operate").strip() or "operate"
    if expires_at:
        try:
            _parse_iso(expires_at)
        except ValueError:
            return {"ok": False, "reason": "bad_expiry", "message": "expires_at must be an ISO-8601 timestamp."}
    with _db_lock, _db() as conn:
        if conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone() is None:
            return {"ok": False, "reason": "not_found", "message": "No such person."}
        conn.execute(
            "INSERT INTO device_grants (person_id, ha_entity_id, permission, granted_at, expires_at, note) "
            "VALUES (?, ?, ?, ?, ?, ?) "
            "ON CONFLICT(person_id, ha_entity_id, permission) DO UPDATE SET "
            "expires_at = excluded.expires_at, note = excluded.note, granted_at = excluded.granted_at",
            (person_id, entity_id, permission, _now(), expires_at or None, (note or "").strip() or None),
        )
    LOG.info("identity: granted %s on %s to person %d", permission, entity_id, person_id)
    return {"ok": True, "message": f"Granted {permission} on {entity_id}."}


def revoke_device_grant(person_id: int, grant_id: int) -> bool:
    with _db_lock, _db() as conn:
        cur = conn.execute("DELETE FROM device_grants WHERE id = ? AND person_id = ?", (grant_id, person_id))
        return cur.rowcount > 0


def check_device_access(person_id: int, entity_id: str, permission: str, requested_via: str | None) -> dict:
    """"May this person operate this device?" — the call HA makes before it unlocks
    anything. Answers, logs, and returns; it never touches the device itself.

    DENY IS THE DEFAULT AND THE ONLY FALLBACK. Every path that isn't an unexpired
    matching grant returns allowed: false, including a person who doesn't exist and a
    grant whose expiry has passed. This is the one place in this service where failing
    closed matters more than degrading gracefully: everything else here would rather
    report "unknown" than guess, but a lock has no useful "unknown" — the door is
    either opened or it isn't, and the safe half of that is "isn't".
    """
    permission = (permission or "operate").strip() or "operate"
    entity_id = (entity_id or "").strip()
    now = _now()

    with _db_lock, _db() as conn:
        person = conn.execute("SELECT name FROM people WHERE id = ?", (person_id,)).fetchone()
        if person is None:
            allowed, reason, grant = False, "unknown_person", None
        else:
            grant = conn.execute(
                "SELECT * FROM device_grants WHERE person_id = ? AND ha_entity_id = ? AND permission = ?",
                (person_id, entity_id, permission),
            ).fetchone()
            if grant is None:
                allowed, reason = False, "no_grant"
            elif grant["expires_at"] and grant["expires_at"] <= now:
                allowed, reason = False, "expired"
            else:
                allowed, reason = True, "granted"

        conn.execute(
            "INSERT INTO device_access_events "
            "(person_id, ha_entity_id, permission, allowed, reason, requested_via, created_at) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (person_id, entity_id, permission, int(allowed), reason, requested_via, now),
        )

    LOG.info(
        "identity: device-access %s — person %d on %s (%s): %s",
        "ALLOW" if allowed else "DENY", person_id, entity_id, permission, reason,
    )
    return {
        "allowed": allowed,
        "reason": reason,
        "person_id": person_id,
        "person_name": person["name"] if person else None,
        "entity_id": entity_id,
        "permission": permission,
        "expires_at": grant["expires_at"] if grant else None,
        "checked_at": now,
    }


def list_device_access_events(limit: int = 100) -> list[dict]:
    with _db_lock, _db() as conn:
        rows = conn.execute(
            "SELECT device_access_events.*, people.name FROM device_access_events "
            "LEFT JOIN people ON people.id = device_access_events.person_id "
            "ORDER BY device_access_events.created_at DESC LIMIT ?",
            (max(1, min(limit, 1000)),),
        ).fetchall()
    return [{**dict(r), "allowed": bool(r["allowed"])} for r in rows]


# --- Chore assignments --------------------------------------------------------------
def set_chore_assignments(person_id: int, chore_types: list[str]) -> dict:
    """Replaces this person's whole assignment set — the admin panel edits it as a row
    of checkboxes, so "what's ticked now" is the natural unit, not per-type add/remove.

    `chores/` treats an assignment as a strong PREFERENCE, not a lock (see
    chores/README.md): the household principle is still "I don't care who does it as
    long as it gets done," so if the assignee isn't home the nudge falls through to
    whoever is, unless CHORE_ASSIGNMENT_STRICT is on over there. Litter ignores
    assignments entirely, for the same reason it ignores exemptions — putting your own
    trash in the bin was never a chore anyone could be assigned.
    """
    cleaned = sorted({str(t).strip().lower() for t in chore_types if str(t).strip()})
    with _db_lock, _db() as conn:
        if conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone() is None:
            return {"ok": False, "reason": "not_found", "message": "No such person."}
        conn.execute("DELETE FROM chore_assignments WHERE person_id = ?", (person_id,))
        for chore_type in cleaned:
            conn.execute(
                "INSERT INTO chore_assignments (person_id, chore_type, created_at) VALUES (?, ?, ?)",
                (person_id, chore_type, _now()),
            )
    return {"ok": True, "chore_assignments": cleaned}


def chore_assignments() -> dict:
    """chore_type -> the people assigned to it. The shape `chores/` actually wants;
    /people carries the same facts per-person for the admin panel's benefit.
    """
    with _db_lock, _db() as conn:
        rows = conn.execute(
            "SELECT chore_assignments.chore_type, people.id, people.name "
            "FROM chore_assignments JOIN people ON people.id = chore_assignments.person_id "
            "ORDER BY chore_assignments.chore_type, people.name COLLATE NOCASE"
        ).fetchall()
    by_type: dict[str, list[dict]] = {}
    for row in rows:
        by_type.setdefault(row["chore_type"], []).append({"id": row["id"], "name": row["name"]})
    return {"assignments": by_type, "generated_at": _now()}


# --- Floor plan ---------------------------------------------------------------------
# The half that was missing from Phase 6's groundwork — see the schema comments above
# and docs/project-plan.md open decision #22.

FLOORPLAN_DIR = Path(os.environ.get("IDENTITY_FLOORPLAN_DIR", "/data/floorplans"))
MAX_POLYGON_POINTS = 64


def _validate_points(raw) -> tuple[list[list[float]] | None, str]:
    """Polygon vertices in, normalised list out — or a reason it isn't one.

    Every constraint here exists because the alternative is a room that renders as
    something absurd rather than an error: a two-point "polygon" is a line, a
    coordinate of 4.2 puts a wall four screens to the right, and a 5000-vertex blob
    from a runaway click handler would be stored and re-rendered forever.
    """
    if not isinstance(raw, list):
        return None, "points must be a list of [x, y] pairs"
    if len(raw) < 3:
        return None, "a room needs at least 3 points"
    if len(raw) > MAX_POLYGON_POINTS:
        return None, f"a room may have at most {MAX_POLYGON_POINTS} points"
    cleaned: list[list[float]] = []
    for point in raw:
        if not isinstance(point, (list, tuple)) or len(point) != 2:
            return None, "each point must be an [x, y] pair"
        try:
            x, y = float(point[0]), float(point[1])
        except (TypeError, ValueError):
            return None, "point coordinates must be numbers"
        if not (0.0 <= x <= 1.0 and 0.0 <= y <= 1.0):
            return None, "point coordinates must be between 0.0 and 1.0 (relative to the plan)"
        cleaned.append([round(x, 5), round(y, 5)])
    return cleaned, ""


# How many targets to read per sensor. LD2450-class radars track three; asking for more
# is free and simply finds nothing.
POSITION_TARGETS_PER_SENSOR = int(os.environ.get("POSITION_TARGETS_PER_SENSOR", "3"))
# Readings are published in millimetres by ESPHome's LD2450 component. Overridable
# because "which unit is this number in" is exactly the kind of thing that differs
# between one integration and the next, and getting it wrong scales every position by
# a thousand rather than failing visibly.
POSITION_UNIT_DIVISOR = float(os.environ.get("POSITION_UNIT_DIVISOR", "1000"))


def _sensor_targets(states_by_id: dict, prefix: str) -> list[dict]:
    """The live targets one sensor reports, in metres relative to itself.

    A target at exactly (0, 0) is how these radars say "nothing here" — they publish
    zero rather than going unavailable — so those are dropped. Treating them as a real
    detection would put a phantom person on top of every sensor on the plan.
    """
    targets = []
    for index in range(1, POSITION_TARGETS_PER_SENSOR + 1):
        raw_x = states_by_id.get(f"sensor.{prefix}_target_{index}_x")
        raw_y = states_by_id.get(f"sensor.{prefix}_target_{index}_y")
        try:
            x = float(raw_x) / POSITION_UNIT_DIVISOR
            y = float(raw_y) / POSITION_UNIT_DIVISOR
        except (TypeError, ValueError):
            continue
        if abs(x) < 0.01 and abs(y) < 0.01:
            continue
        targets.append({"x": x, "y": y})
    return targets


def _plan_position(sensor, target, metres_wide: float) -> dict | None:
    """A sensor-relative reading, placed on the normalised plan.

    Rotate by the sensor's own bearing, scale metres into plan units, offset by where
    the sensor sits. Returns None when the result lands outside the plan, which is the
    honest outcome for a bad rotation or a wrong scale — better a missing marker than a
    confident one in the garden.
    """
    if not metres_wide or metres_wide <= 0:
        return None
    angle = math.radians(sensor["rotation_deg"] or 0)
    # Plan y grows downward, so a target "in front of" the sensor moves it up-plan.
    rx = target["x"] * math.cos(angle) - target["y"] * math.sin(angle)
    ry = target["x"] * math.sin(angle) + target["y"] * math.cos(angle)
    x = sensor["x"] + rx / metres_wide
    y = sensor["y"] - ry / metres_wide
    if not (-0.05 <= x <= 1.05 and -0.05 <= y <= 1.05):
        return None
    return {"x": round(x, 4), "y": round(y, 4)}


def _point_in_polygon(x: float, y: float, points: list) -> bool:
    """Standard ray casting. Which room a target is in has to be computed rather than
    assumed from which sensor saw it: a radar in an open-plan kitchen sees into the
    living room, and attributing by sensor would put people through walls."""
    inside = False
    n = len(points)
    for i in range(n):
        x1, y1 = points[i]
        x2, y2 = points[(i + 1) % n]
        if (y1 > y) != (y2 > y):
            xin = (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-9) + x1
            if x < xin:
                inside = not inside
    return inside


def list_floorplan() -> dict:
    with _db_lock, _db() as conn:
        levels = conn.execute(
            "SELECT * FROM floorplan_levels ORDER BY sort_order, id"
        ).fetchall()
        result = []
        for level in levels:
            rooms = conn.execute(
                "SELECT * FROM floorplan_rooms WHERE level_id = ? ORDER BY name COLLATE NOCASE",
                (level["id"],),
            ).fetchall()
            result.append(
                {
                    "id": level["id"],
                    "name": level["name"],
                    "sort_order": level["sort_order"],
                    "has_image": level["image_path"] is not None,
                    "metres_wide": level["metres_wide"],
                    "rooms": [
                        {
                            "id": r["id"],
                            "name": r["name"],
                            "ha_area_id": r["ha_area_id"],
                            "points": json.loads(r["points"]),
                            "color": r["color"],
                        }
                        for r in rooms
                    ],
                }
            )
    return {"levels": result, "generated_at": _now()}


def save_level(level_id: int | None, name: str, sort_order: int,
               metres_wide: float | None = None) -> dict:
    name = (name or "").strip()
    if not name:
        return {"ok": False, "reason": "bad_name", "message": "A level needs a name."}
    with _db_lock, _db() as conn:
        if level_id:
            cur = conn.execute(
                "UPDATE floorplan_levels SET name = ?, sort_order = ?, metres_wide = ? WHERE id = ?",
                (name, sort_order, metres_wide, level_id),
            )
            if cur.rowcount == 0:
                return {"ok": False, "reason": "not_found", "message": "No such level."}
        else:
            cur = conn.execute(
                "INSERT INTO floorplan_levels (name, sort_order, metres_wide, created_at) "
                "VALUES (?, ?, ?, ?)",
                (name, sort_order, metres_wide, _now()),
            )
            level_id = cur.lastrowid
    return {"ok": True, "level_id": level_id, "name": name}


def delete_level(level_id: int) -> bool:
    with _db_lock, _db() as conn:
        cur = conn.execute("DELETE FROM floorplan_levels WHERE id = ?", (level_id,))
        return cur.rowcount > 0


def save_room(room_id: int | None, level_id: int, name: str, ha_area_id: str | None,
              points, color: str | None) -> dict:
    name = (name or "").strip()
    if not name:
        return {"ok": False, "reason": "bad_name", "message": "A room needs a name."}

    cleaned, error = _validate_points(points)
    if cleaned is None:
        return {"ok": False, "reason": "bad_points", "message": error}

    area_id = (ha_area_id or "").strip() or None
    if area_id and not re.match(r"^[A-Za-z0-9_.-]{1,64}$", area_id):
        return {"ok": False, "reason": "bad_area_id", "message": "That doesn't look like an HA area id."}
    if color and not re.match(r"^#[0-9a-fA-F]{6}$", color):
        return {"ok": False, "reason": "bad_color", "message": "Colour must be a #rrggbb value."}

    with _db_lock, _db() as conn:
        if conn.execute("SELECT 1 FROM floorplan_levels WHERE id = ?", (level_id,)).fetchone() is None:
            return {"ok": False, "reason": "not_found", "message": "No such level."}
        # Two rooms mapped to one HA area would both light up for one person, which
        # looks like a presence bug rather than a floor-plan mistake — so it's refused
        # here, where the cause is obvious.
        clash = conn.execute(
            "SELECT floorplan_rooms.name FROM floorplan_rooms "
            "WHERE ha_area_id IS NOT NULL AND ha_area_id = ? AND id != ?",
            (area_id, room_id or -1),
        ).fetchone() if area_id else None
        if clash:
            return {
                "ok": False,
                "reason": "area_taken",
                "message": f"“{clash['name']}” is already mapped to that area.",
            }

        payload = (level_id, name, area_id, json.dumps(cleaned), color)
        if room_id:
            cur = conn.execute(
                "UPDATE floorplan_rooms SET level_id = ?, name = ?, ha_area_id = ?, points = ?, "
                "color = ? WHERE id = ?",
                (*payload, room_id),
            )
            if cur.rowcount == 0:
                return {"ok": False, "reason": "not_found", "message": "No such room."}
        else:
            cur = conn.execute(
                "INSERT INTO floorplan_rooms (level_id, name, ha_area_id, points, color, created_at) "
                "VALUES (?, ?, ?, ?, ?, ?)",
                (*payload, _now()),
            )
            room_id = cur.lastrowid
    return {"ok": True, "room_id": room_id}


def delete_room(room_id: int) -> bool:
    with _db_lock, _db() as conn:
        cur = conn.execute("DELETE FROM floorplan_rooms WHERE id = ?", (room_id,))
        return cur.rowcount > 0


def set_level_image(level_id: int, data: bytes) -> dict:
    with _db_lock, _db() as conn:
        if conn.execute("SELECT 1 FROM floorplan_levels WHERE id = ?", (level_id,)).fetchone() is None:
            return {"ok": False, "reason": "not_found", "message": "No such level."}
    FLOORPLAN_DIR.mkdir(parents=True, exist_ok=True)
    path = FLOORPLAN_DIR / f"level-{level_id}.img"
    path.write_bytes(data)
    with _db_lock, _db() as conn:
        conn.execute("UPDATE floorplan_levels SET image_path = ? WHERE id = ?", (str(path), level_id))
    LOG.info("identity: stored floor-plan image for level %d (%d bytes)", level_id, len(data))
    return {"ok": True, "message": "Background image saved."}


def get_level_image(level_id: int) -> bytes | None:
    with _db_lock, _db() as conn:
        row = conn.execute(
            "SELECT image_path FROM floorplan_levels WHERE id = ?", (level_id,)
        ).fetchone()
    if row is None or row["image_path"] is None:
        return None
    path = Path(row["image_path"])
    return path.read_bytes() if path.is_file() else None


def floorplan_presence() -> dict:
    """The payoff: the drawn plan plus who is standing in each room right now.

    Joins presence()'s `room` (an HA area string) to `floorplan_rooms.ha_area_id`.
    People whose room doesn't match any drawn room — or who are home with no room
    resolved at all, which is the normal case without room-level BLE — come back under
    `unplaced` rather than being dropped, so the UI can show "3 people home, 1 not
    locatable" instead of quietly losing two of them.

    EXACT POSITIONS ARE A SECOND, WEAKER SIGNAL, AND ARE KEPT SEPARATE
    ------------------------------------------------------------------
    If the level has position sensors and a metre scale, each room also gets `targets`:
    coordinates where a radar can see *somebody*. Those are ANONYMOUS — an mmWave sensor
    reports a moving blob, not a name — and identity comes from BLE, which is room-level.

    So attribution follows one rule, and the rule is deliberately timid:

        exactly one occupant in the room AND exactly one target in the room
            -> that target is that person, marked `source: "fused"`
        anything else
            -> the targets stay anonymous and the occupants stay unpositioned

    Two people in a room produce two blobs that cannot be told apart, and guessing
    which is which would put a name on a stranger — the failure that makes a presence
    display worse than none. `position_ambiguous` says out loud when that has happened,
    so a UI can show two unnamed dots rather than implying it knows.
    """
    plan = list_floorplan()
    people = presence().get("people", [])

    by_area: dict[str, list[dict]] = {}
    unplaced: list[dict] = []
    for person in people:
        if person.get("home") is not True:
            continue
        # Everything a floorplan occupant marker needs, and nothing else: a name for
        # the surfaces with room for one, an initial and a colour for the ones without
        # (a wall panel at a glance, a watch face — see docs/pebble-presence-watchface.md).
        entry = {
            "id": person["id"],
            "name": person["name"],
            "initial": person["initial"],
            "color": person["color"],
            "has_photo": person["has_photo"],
        }
        area = person.get("room")
        if area:
            by_area.setdefault(str(area), []).append(entry)
        else:
            unplaced.append(entry)

    # Live targets, per level, projected onto the plan. Best-effort: no sensors, no
    # scale, or an unreachable HA all end in the same place — rooms with no `targets`,
    # which the renderer draws exactly as it always did.
    targets_by_level = _live_targets(plan)

    placed_areas = set()
    for level in plan["levels"]:
        level_targets = targets_by_level.get(level["id"], [])
        for room in level["rooms"]:
            occupants = by_area.get(room["ha_area_id"] or "", [])
            room["occupants"] = occupants

            # Which room a target is in is computed from the polygon, never from which
            # sensor saw it — a radar in an open-plan kitchen sees into the living room.
            inside = [t for t in level_targets if _point_in_polygon(t["x"], t["y"], room["points"])]
            room["targets"] = inside
            room["position_ambiguous"] = len(inside) > 1 or (len(inside) >= 1 and len(occupants) > 1)

            if len(inside) == 1 and len(occupants) == 1:
                occupants[0]["position"] = {**inside[0], "source": "fused"}
                # Attributed, so it is no longer an unnamed dot for the renderer to draw.
                room["targets"] = []

            if room["ha_area_id"] and occupants:
                placed_areas.add(room["ha_area_id"])

    # An area HA is reporting that nothing on the plan claims — usually a room that
    # hasn't been drawn yet, and worth surfacing rather than silently ignoring.
    unmapped = {area: people_ for area, people_ in by_area.items() if area not in placed_areas}
    for area, people_ in unmapped.items():
        unplaced.extend({**p, "reported_area": area} for p in people_)

    plan["unplaced"] = unplaced
    plan["unmapped_areas"] = sorted(unmapped)
    plan["positions_available"] = any(targets_by_level.values())
    return plan


def _live_targets(plan: dict) -> dict[int, list[dict]]:
    """{level_id: [{x, y}]} — every position sensor's current targets, on the plan.

    Read from the same HA state dump presence() uses. Fails soft in every direction:
    an unreachable HA, a level with no metre scale, a sensor whose entities do not
    exist yet — all produce no targets, and the floorplan renders room-level as before.
    """
    result: dict[int, list[dict]] = {}
    with _db_lock, _db() as conn:
        sensors = conn.execute("SELECT * FROM floorplan_sensors").fetchall()
    if not sensors:
        return result

    try:
        states = _ha_get("/api/states")
    except Exception:
        LOG.warning("identity: could not read HA states for position sensors", exc_info=True)
        return result

    states_by_id = {s.get("entity_id"): s.get("state") for s in states if isinstance(s, dict)}
    scale_by_level = {level["id"]: level.get("metres_wide") for level in plan["levels"]}

    for sensor in sensors:
        metres_wide = scale_by_level.get(sensor["level_id"])
        if not metres_wide:
            # A sensor on a level with no scale cannot be placed. Logged once per run
            # rather than silently skipped, because the fix is one number in the editor.
            LOG.info("identity: level %s has no metres_wide, so sensor %r cannot be placed",
                     sensor["level_id"], sensor["name"])
            continue
        for target in _sensor_targets(states_by_id, sensor["ha_entity_prefix"]):
            point = _plan_position(sensor, target, float(metres_wide))
            if point:
                result.setdefault(sensor["level_id"], []).append(point)
    return result


def save_sensor(payload: dict) -> dict:
    """Place (or move) a position sensor on the plan. Same human-drawn discipline as
    the rooms: nothing here can infer where a radar is bolted to a wall."""
    try:
        level_id = int(payload.get("level_id"))
    except (TypeError, ValueError):
        return {"ok": False, "reason": "bad_field", "message": "'level_id' is required."}
    name = str(payload.get("name") or "").strip()
    prefix = str(payload.get("ha_entity_prefix") or "").strip()
    if not name or not prefix:
        return {"ok": False, "reason": "bad_field",
                "message": "'name' and 'ha_entity_prefix' are both required. The prefix is "
                           "the part before _target_1_x in the sensor's entity ids."}
    try:
        x = float(payload.get("x"))
        y = float(payload.get("y"))
        rotation = float(payload.get("rotation_deg", 0))
    except (TypeError, ValueError):
        return {"ok": False, "reason": "bad_field", "message": "'x', 'y' must be numbers."}
    if not (0 <= x <= 1 and 0 <= y <= 1):
        return {"ok": False, "reason": "bad_field",
                "message": "x and y are normalised 0..1, the same coordinate space the "
                           "room polygons use."}

    with _db_lock, _db() as conn:
        sensor_id = payload.get("id")
        if sensor_id:
            conn.execute(
                "UPDATE floorplan_sensors SET name = ?, ha_entity_prefix = ?, x = ?, y = ?, "
                "rotation_deg = ? WHERE id = ?",
                (name, prefix, x, y, rotation, int(sensor_id)),
            )
        else:
            cur = conn.execute(
                "INSERT INTO floorplan_sensors (level_id, name, ha_entity_prefix, x, y, "
                "rotation_deg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
                (level_id, name, prefix, x, y, rotation, _now()),
            )
            sensor_id = cur.lastrowid
    return {"ok": True, "id": sensor_id}


def list_sensors() -> dict:
    with _db_lock, _db() as conn:
        rows = conn.execute("SELECT * FROM floorplan_sensors ORDER BY level_id, name").fetchall()
    return {"sensors": [dict(r) for r in rows]}


def delete_sensor(sensor_id: int) -> bool:
    with _db_lock, _db() as conn:
        return conn.execute("DELETE FROM floorplan_sensors WHERE id = ?", (sensor_id,)).rowcount > 0


def area_suggestions() -> dict:
    """Every area value HA is currently reporting on a trusted entity, so the room
    editor can offer a pick-list instead of asking someone to retype an area_id from
    Developer Tools — the same anti-typo reasoning as tools/CoreSystemConfig.json.
    Degrades to an empty list (never an error) when HA is unreachable.
    """
    try:
        states = _ha_get("/api/states")
    except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError):
        LOG.warning("identity: could not reach HA for area suggestions", exc_info=True)
        return {"areas": [], "error": "ha_unreachable"}

    areas = set()
    for entity in states:
        if not entity.get("entity_id", "").startswith(TRUSTED_ENTITY_PREFIXES):
            continue
        area = (entity.get("attributes") or {}).get(AREA_ATTRIBUTE)
        if area:
            areas.add(str(area))
    return {"areas": sorted(areas), "attribute": AREA_ATTRIBUTE}


def _on_weather_message(_client, _userdata, message) -> None:
    try:
        payload = json.loads(message.payload.decode("utf-8", "replace"))
    except ValueError:
        return
    with _weather_lock:
        global _last_weather
        _last_weather = {**payload, "available": True}


def _on_frigate_event(_client, _userdata, message) -> None:
    """Best-effort only — see FRIGATE_EVENTS_TOPIC's module-level comment. A
    malformed or unexpected payload just means this particular event contributes no
    presence signal, never an error that could take the weather subscription (or
    anything else) down with it.
    """
    try:
        payload = json.loads(message.payload.decode("utf-8", "replace"))
        sub_label = ((payload.get("after") or {}).get("sub_label"))
        name = sub_label[0] if isinstance(sub_label, list) and sub_label else None
        if not name:
            return
        with _face_lock:
            _last_face_seen[str(name).strip().lower()] = time.time()
    except Exception:
        pass


def _on_mqtt_message(client, userdata, message) -> None:
    if message.topic == "smarthome/weather/current":
        _on_weather_message(client, userdata, message)
    elif message.topic == FRIGATE_EVENTS_TOPIC:
        _on_frigate_event(client, userdata, message)


def start_mqtt(broker_host: str, broker_port: int, username: str, password: str) -> None:
    if not broker_host:
        LOG.warning("identity: MQTT_BROKER_HOST not set — /weather will always report unavailable")
        return

    callback_api = getattr(mqtt, "CallbackAPIVersion", None)
    client = mqtt.Client(callback_api.VERSION1) if callback_api is not None else mqtt.Client()
    if username:
        client.username_pw_set(username, password or None)

    def on_connect(c, _userdata, _flags, rc):
        if rc == 0:
            c.subscribe("smarthome/weather/current", qos=1)
            c.subscribe(FRIGATE_EVENTS_TOPIC, qos=0)

    client.on_connect = on_connect
    client.on_message = _on_mqtt_message
    client.connect_async(broker_host, broker_port, keepalive=60)
    client.loop_start()


class Handler(BaseHTTPRequestHandler):
    server_version = "identity/1"

    def log_message(self, format, *args):  # noqa: A002
        LOG.info("%s - %s", self.address_string(), format % args)

    def _authorized(self) -> bool:
        if not TOKEN:
            return False
        return self.headers.get("Authorization", "") == f"Bearer {TOKEN}"

    def _respond(self, status: HTTPStatus, payload) -> None:
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def _read_body(self, max_bytes: int) -> bytes:
        try:
            length = int(self.headers.get("Content-Length") or 0)
        except ValueError:
            raise ValueError("missing or invalid Content-Length") from None
        if length <= 0:
            return b""
        if length > max_bytes:
            raise ValueError(f"body too large ({length} > {max_bytes} bytes)")
        return self.rfile.read(length)

    def do_OPTIONS(self):  # noqa: N802
        self.send_response(HTTPStatus.NO_CONTENT)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
        self.end_headers()

    def do_GET(self):  # noqa: N802
        if not self._authorized():
            self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
            return
        split = urlsplit(self.path)
        path = split.path
        query = parse_qs(split.query)

        def q(key: str, default: str = "") -> str:
            return (query.get(key) or [default])[0]

        def q_int(key: str, default: int) -> int:
            try:
                return int(q(key, str(default)))
            except ValueError:
                return default

        photo_match = re.match(r"^/people/(\d+)/photo$", path)
        level_image_match = re.match(r"^/floorplan/levels/(\d+)/image$", path)
        visits_match = re.match(r"^/people/(\d+)/visits$", path)
        assignments_match = re.match(r"^/people/(\d+)/chore-assignments$", path)
        digest_settings_match = re.match(r"^/people/(\d+)/digest-settings$", path)

        if path == "/people":
            self._respond(HTTPStatus.OK, {"people": list_people()})
        elif path == "/presence":
            self._respond(HTTPStatus.OK, presence())
        elif path == "/resolve":
            self._respond(HTTPStatus.OK, resolve_name(q("q") or q("name")))
        elif path == "/visits":
            self._respond(HTTPStatus.OK, {"visits": list_visits(since=q("since") or None, limit=q_int("limit", 200))})
        elif visits_match:
            self._respond(
                HTTPStatus.OK,
                {"visits": list_visits(person_id=int(visits_match.group(1)), since=q("since") or None,
                                       limit=q_int("limit", 200))},
            )
        elif path == "/co-presence":
            person_id = q_int("person_id", 0) or None
            self._respond(HTTPStatus.OK, co_presence(person_id, q("since") or None, q_int("limit", 200)))
        elif path == "/prune/candidates":
            self._handle_prune_candidates(q("last_visit_before"))
        elif path == "/device-access":
            self._handle_device_access(q_int("person_id", 0), q("entity_id"), q("permission", "operate"), q("via") or None)
        elif path == "/device-access/events":
            self._respond(HTTPStatus.OK, {"events": list_device_access_events(q_int("limit", 100))})
        elif path == "/chore-assignments":
            self._respond(HTTPStatus.OK, chore_assignments())
        elif path == "/digest-preferences":
            self._respond(HTTPStatus.OK, digest_preferences())
        elif path == "/speaker":
            # "Who just spoke in this room?" — the voice path's automatic person
            # resolution. See resolve_speaker() for what an unresolved answer means.
            self._respond(HTTPStatus.OK, resolve_speaker(q("area") or q("room")))
        elif path == "/floorplan":
            self._respond(HTTPStatus.OK, list_floorplan())
        elif path == "/floorplan/presence":
            self._respond(HTTPStatus.OK, floorplan_presence())
        elif path == "/floorplan/areas":
            self._respond(HTTPStatus.OK, area_suggestions())
        elif path == "/floorplan/sensors":
            self._respond(HTTPStatus.OK, list_sensors())
        elif path == "/person-colors":
            # Served rather than duplicated in the admin panel's JS, so the palette
            # has one definition — see PERSON_COLORS for what makes these eight
            # specific values the palette.
            self._respond(HTTPStatus.OK, {"colors": PERSON_COLORS})
        elif level_image_match:
            self._handle_level_image(int(level_image_match.group(1)))
        elif assignments_match:
            people = [p for p in list_people() if p["id"] == int(assignments_match.group(1))]
            if not people:
                self._respond(HTTPStatus.NOT_FOUND, {"error": "no such person"})
            else:
                self._respond(HTTPStatus.OK, {"chore_assignments": people[0]["chore_assignments"]})
        elif digest_settings_match:
            people = [p for p in list_people() if p["id"] == int(digest_settings_match.group(1))]
            if not people:
                self._respond(HTTPStatus.NOT_FOUND, {"error": "no such person"})
            else:
                self._respond(
                    HTTPStatus.OK,
                    {"digest_sections": people[0]["digest_sections"], "sections": list(DIGEST_SECTIONS)},
                )
        elif path == "/weather":
            with _weather_lock:
                self._respond(HTTPStatus.OK, dict(_last_weather))
        elif photo_match:
            self._handle_get_photo(int(photo_match.group(1)))
        else:
            self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})

    def _handle_prune_candidates(self, last_visit_before: str) -> None:
        if not last_visit_before:
            self._respond(
                HTTPStatus.BAD_REQUEST,
                {"error": "'last_visit_before' (an ISO-8601 date or timestamp) is required"},
            )
            return
        # A bare date ("2026-01-01") is what a browser's <input type="date"> hands over,
        # and comparing it against a full timestamp string would silently include
        # everyone who last visited ON that date. Widening it to the end of the day
        # keeps "before 2026-01-01" meaning what a person reading it expects.
        if re.match(r"^\d{4}-\d{2}-\d{2}$", last_visit_before):
            last_visit_before = f"{last_visit_before}T00:00:00Z"
        self._respond(HTTPStatus.OK, prune_candidates(last_visit_before))

    def _handle_device_access(self, person_id: int, entity_id: str, permission: str, via: str | None) -> None:
        if not person_id or not entity_id:
            self._respond(
                HTTPStatus.BAD_REQUEST,
                {"allowed": False, "error": "'person_id' and 'entity_id' are required"},
            )
            return
        self._respond(HTTPStatus.OK, check_device_access(person_id, entity_id, permission, via))

    def _handle_get_photo(self, person_id: int) -> None:
        data = get_person_photo(person_id)
        if data is None:
            self._respond(HTTPStatus.NOT_FOUND, {"error": "no photo for this person"})
            return
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", "image/jpeg")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Cache-Control", "no-cache")
        self.end_headers()
        self.wfile.write(data)

    def _handle_set_person_photo(self, person_id: int) -> None:
        """POST /people/<id>/photo — raw image bytes, same shape as the level-image
        upload and as /register/photo. No multipart: every client of this API is
        either this project's own JS or a curl, and multipart parsing in the stdlib is
        more failure surface than a Blob body is inconvenience.
        """
        try:
            data = self._read_body(MAX_IMAGE_BYTES)
        except ValueError as exc:
            self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)})
            return
        if not data:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "empty image body"})
            return
        result = set_person_photo(person_id, data)
        self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.NOT_FOUND, result)

    def _handle_level_image(self, level_id: int) -> None:
        data = get_level_image(level_id)
        if data is None:
            self._respond(HTTPStatus.NOT_FOUND, {"error": "no background image for this level"})
            return
        self.send_response(HTTPStatus.OK)
        # Served back as whatever was uploaded. Content type is deliberately generic:
        # the editor accepts PNG, JPEG or SVG and the browser sniffs it happily, so
        # storing and echoing a declared type would be one more thing to get wrong.
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Cache-Control", "no-cache")
        self.end_headers()
        self.wfile.write(data)

    def do_POST(self):  # noqa: N802
        if not self._authorized():
            self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
            return
        path = urlsplit(self.path).path
        chore_settings_match = re.match(r"^/people/(\d+)/chore-settings$", path)
        assignments_match = re.match(r"^/people/(\d+)/chore-assignments$", path)
        digest_settings_match = re.match(r"^/people/(\d+)/digest-settings$", 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)
        person_photo_match = re.match(r"^/people/(\d+)/photo$", path)
        post_level_image_match = re.match(r"^/floorplan/levels/(\d+)/image$", path)
        # /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
        # makes the intent explicit rather than incidental).
        if path == "/register/photo":
            self._handle_register_photo()
        elif path == "/register":
            self._handle_register()
        elif path == "/register/guest":
            self._handle_register_guest()
        elif path == "/presence/manual":
            self._handle_presence_manual()
        elif path == "/people/prune":
            self._handle_prune()
        elif chore_settings_match:
            self._handle_chore_settings(int(chore_settings_match.group(1)))
        elif assignments_match:
            self._handle_set_assignments(int(assignments_match.group(1)))
        elif digest_settings_match:
            self._handle_digest_settings(int(digest_settings_match.group(1)))
        elif grants_match:
            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 path == "/floorplan/levels":
            self._handle_save_level()
        elif path == "/floorplan/rooms":
            self._handle_save_room()
        elif path == "/floorplan/sensors":
            self._handle_save_sensor()
        elif post_level_image_match:
            self._handle_upload_level_image(int(post_level_image_match.group(1)))
        elif person_photo_match:
            self._handle_set_person_photo(int(person_photo_match.group(1)))
        elif person_match:
            self._handle_update_person(int(person_match.group(1)))
        else:
            self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})

    def _json_body(self) -> dict | None:
        """Returns the parsed body, or None after having already sent a 400 — callers
        check for None and return immediately. Replaces the identical try/except that
        every handler below used to repeat.
        """
        try:
            raw = self._read_body(MAX_JSON_BYTES)
            payload = json.loads(raw or b"{}")
        except (ValueError, json.JSONDecodeError) as exc:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"})
            return None
        if not isinstance(payload, dict):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "body must be a JSON object"})
            return None
        return payload

    def _handle_update_person(self, person_id: int) -> None:
        payload = self._json_body()
        if payload is None:
            return
        result = update_person(person_id, payload)
        if result.get("ok"):
            self._respond(HTTPStatus.OK, result)
        else:
            self._respond(
                HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT,
                result,
            )

    def _handle_add_identifier(self, person_id: int) -> None:
        payload = self._json_body()
        if payload is None:
            return
        result = add_identifier(person_id, str(payload.get("entity_id", "")))
        self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.CONFLICT, result)

    def _handle_set_assignments(self, person_id: int) -> None:
        payload = self._json_body()
        if payload is None:
            return
        types = payload.get("chore_types")
        if not isinstance(types, list):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'chore_types' must be a list of strings"})
            return
        result = set_chore_assignments(person_id, types)
        self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.NOT_FOUND, result)

    def _handle_grant_device(self, person_id: int) -> None:
        payload = self._json_body()
        if payload is None:
            return
        result = grant_device(
            person_id,
            str(payload.get("entity_id", "")),
            str(payload.get("permission", "operate")),
            payload.get("expires_at"),
            payload.get("note"),
        )
        self._respond(
            HTTPStatus.OK if result.get("ok")
            else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.BAD_REQUEST),
            result,
        )

    def _handle_save_level(self) -> None:
        payload = self._json_body()
        if payload is None:
            return
        try:
            level_id = int(payload["id"]) if payload.get("id") else None
            sort_order = int(payload.get("sort_order", 0))
        except (TypeError, ValueError):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'id' and 'sort_order' must be integers"})
            return
        # The real-world width of the level, needed for exact positions and nothing
        # else. Absent leaves it NULL, which reports honestly as "positions cannot be
        # computed here" rather than guessing a scale.
        try:
            metres_wide = float(payload["metres_wide"]) if payload.get("metres_wide") else None
        except (TypeError, ValueError):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'metres_wide' must be a number"})
            return
        result = save_level(level_id, str(payload.get("name", "")), sort_order, metres_wide)
        self._respond(
            HTTPStatus.OK if result.get("ok")
            else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT),
            result,
        )

    def _handle_save_sensor(self) -> None:
        payload = self._json_body()
        if payload is None:
            return
        result = save_sensor(payload)
        self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.BAD_REQUEST, result)

    def _handle_save_room(self) -> None:
        payload = self._json_body()
        if payload is None:
            return
        try:
            room_id = int(payload["id"]) if payload.get("id") else None
            level_id = int(payload["level_id"])
        except (KeyError, TypeError, ValueError):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'level_id' is required and must be an integer"})
            return
        result = save_room(
            room_id, level_id, str(payload.get("name", "")),
            payload.get("ha_area_id"), payload.get("points"), payload.get("color"),
        )
        self._respond(
            HTTPStatus.OK if result.get("ok")
            else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT),
            result,
        )

    def _handle_upload_level_image(self, level_id: int) -> None:
        try:
            data = self._read_body(MAX_IMAGE_BYTES)
        except ValueError as exc:
            self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)})
            return
        if not data:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "empty image body"})
            return
        result = set_level_image(level_id, data)
        self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.NOT_FOUND, result)

    def _handle_prune(self) -> None:
        payload = self._json_body()
        if payload is None:
            return
        ids = payload.get("person_ids")
        # Explicit ids only, never a filter — see prune_people()'s docstring for why
        # this endpoint deliberately can't be handed the same query /prune/candidates
        # takes.
        if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'person_ids' must be a list of integers"})
            return
        if not ids:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'person_ids' is empty — nothing to prune"})
            return
        self._respond(HTTPStatus.OK, prune_people(ids))

    def do_DELETE(self):  # noqa: N802
        if not self._authorized():
            self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
            return
        path = urlsplit(self.path).path

        sensor_match = re.match(r"^/floorplan/sensors/(\d+)$", path)
        if sensor_match:
            ok = delete_sensor(int(sensor_match.group(1)))
            self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND,
                          {"ok": True} if ok else {"error": "no such sensor"})
            return

        id_match = re.match(r"^/people/(\d+)/identifiers/(\d+)$", path)
        if id_match:
            ok = delete_identifier(int(id_match.group(1)), int(id_match.group(2)))
            self._respond(
                HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND,
                {"ok": True} if ok else {"error": "no such identifier"},
            )
            return

        grant_match = re.match(r"^/people/(\d+)/device-grants/(\d+)$", path)
        if grant_match:
            ok = revoke_device_grant(int(grant_match.group(1)), int(grant_match.group(2)))
            self._respond(
                HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND,
                {"ok": True} if ok else {"error": "no such grant"},
            )
            return

        level_match = re.match(r"^/floorplan/levels/(\d+)$", path)
        if level_match:
            ok = delete_level(int(level_match.group(1)))
            self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND,
                          {"ok": True} if ok else {"error": "no such level"})
            return

        room_match = re.match(r"^/floorplan/rooms/(\d+)$", path)
        if room_match:
            ok = delete_room(int(room_match.group(1)))
            self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND,
                          {"ok": True} if ok else {"error": "no such room"})
            return

        person_match = re.match(r"^/people/(\d+)$", path)
        if person_match:
            ok = delete_person(int(person_match.group(1)))
            self._respond(
                HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND,
                {"ok": True} if ok else {"error": "no such person"},
            )
            return

        self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})

    def _handle_register_photo(self) -> None:
        try:
            data = self._read_body(MAX_IMAGE_BYTES)
        except ValueError as exc:
            self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)})
            return
        if not data:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "empty image body"})
            return

        photo_id = f"{int(time.time())}{os.getpid() % 10000:04d}"
        PHOTO_DIR.mkdir(parents=True, exist_ok=True)
        photo_path = PHOTO_DIR / f"{photo_id}.jpg"
        photo_path.write_bytes(data)
        LOG.info("identity: stored registration photo %s (%d bytes)", photo_id, len(data))
        self._respond(HTTPStatus.OK, {"photo_id": photo_id})

    def _handle_register(self) -> None:
        payload = self._json_body()
        if payload is None:
            return

        name = str(payload.get("name", ""))
        device_id = str(payload.get("device_id", "unknown"))
        photo_id = payload.get("photo_id")
        forced_entity_id = payload.get("entity_id")
        no_device = bool(payload.get("no_device", False))

        ok, photo_path = self._resolve_photo_path(photo_id)
        if not ok:
            return  # error response already sent

        result = register(name, device_id, photo_path, forced_entity_id, no_device)
        self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.CONFLICT, result)

    def _resolve_photo_path(self, photo_id) -> tuple[bool, str | None]:
        """Shared by /register and /register/guest. `(True, None)` if no photo_id
        was given, `(True, path)` if it resolves to a real stored photo, `(False,
        None)` if photo_id was present but invalid — callers must check the first
        element and return early on False, since the error response is already sent
        by the time this returns it.
        """
        if not photo_id:
            return True, None
        if not PHOTO_ID_RE.match(str(photo_id)):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "invalid photo_id"})
            return False, None
        candidate_path = PHOTO_DIR / f"{photo_id}.jpg"
        return True, (str(candidate_path) if candidate_path.exists() else None)

    def _handle_register_guest(self) -> None:
        payload = self._json_body()
        if payload is None:
            return

        device_id = str(payload.get("device_id", "unknown"))
        ok, photo_path = self._resolve_photo_path(payload.get("photo_id"))
        if not ok:
            return

        result = register_guest(device_id, photo_path)
        self._respond(HTTPStatus.OK, result)

    def _handle_presence_manual(self) -> None:
        payload = self._json_body()
        if payload is None:
            return

        try:
            person_id = int(payload.get("person_id"))
            home = bool(payload.get("home"))
        except (TypeError, ValueError):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'person_id' (int) and 'home' (bool) are required"})
            return

        ok = set_manual_presence(person_id, home)
        self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, {"ok": ok})

    def _handle_chore_settings(self, person_id: int) -> None:
        payload = self._json_body()
        if payload is None:
            return

        chore_exempt = payload.get("chore_exempt")
        if chore_exempt is not None:
            chore_exempt = bool(chore_exempt)
        reminder_style = payload.get("chore_reminder_style")
        if reminder_style is not None and not isinstance(reminder_style, str):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'chore_reminder_style' must be a string"})
            return

        ok = set_chore_settings(person_id, chore_exempt, reminder_style)
        self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, {"ok": ok})

    def _handle_digest_settings(self, person_id: int) -> None:
        payload = self._json_body()
        if payload is None:
            return

        sections = payload.get("digest_sections")
        if not isinstance(sections, list):
            self._respond(
                HTTPStatus.BAD_REQUEST,
                {"error": "'digest_sections' must be a list of section names"},
            )
            return

        result = set_digest_sections(person_id, sections)
        self._respond(
            HTTPStatus.OK if result.get("ok")
            else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.BAD_REQUEST),
            result,
        )


def main() -> int:
    logging.basicConfig(
        level=os.environ.get("LOG_LEVEL", "INFO").upper(),
        format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
    )

    if not TOKEN:
        LOG.error("IDENTITY_TOKEN is not set — every request will be rejected until it is.")
    if not HA_TOKEN:
        LOG.warning("HA_TOKEN is not set — /register and /presence will fail until it is configured.")

    init_db()

    start_mqtt(
        os.environ.get("MQTT_BROKER_HOST", ""),
        int(os.environ.get("MQTT_BROKER_PORT") or 1883),
        os.environ.get("MQTT_USERNAME", ""),
        os.environ.get("MQTT_PASSWORD", ""),
    )

    # The visit-history sampler — see _presence_sampler_loop(). Daemon, so a Ctrl-C /
    # container stop doesn't wait out a poll interval before exiting.
    threading.Thread(target=_presence_sampler_loop, name="presence-sampler", daemon=True).start()
    LOG.info(
        "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)
    LOG.info("identity listening on :%d (HA: %s, db: %s)", port, HA_URL, DB_PATH)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    return 0


if __name__ == "__main__":
    sys.exit(main())
