"""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 " 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. Five 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 - DELETE /people//identifiers/ revoke a mistaken/compromised identifier - GET /presence who's currently home, resolved from registered identifiers - GET /weather proxies the household smarthome/weather/current MQTT topic """ from __future__ import annotations import json import logging import os import re import sqlite3 import sys import threading import time import urllib.error import urllib.request from datetime import datetime, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import 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}$") _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 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 ); 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 ); """ ) 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 _find_or_create_person(conn: sqlite3.Connection, name: str) -> tuple[int, bool]: row = conn.execute("SELECT id FROM people WHERE name = ? COLLATE NOCASE", (name,)).fetchone() if row: return row["id"], False cur = conn.execute("INSERT INTO people (name, created_at) VALUES (?, ?)", (name, _now())) assert cur.lastrowid is not None return cur.lastrowid, True 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: person_id, is_new_person = _find_or_create_person(conn, name) _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] claimed_by = _already_claimed_by(conn, chosen["entity_id"]) if claimed_by is not None and claimed_by["name"].lower() != name.lower(): _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']}.", } person_id, is_new_person = _find_or_create_person(conn, name) 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 list_people() -> list[dict]: with _db_lock, _db() as conn: people = conn.execute( "SELECT id, name, created_at, photo_path, chore_exempt, chore_reminder_style " "FROM people ORDER BY name COLLATE NOCASE" ).fetchall() result = [] for person in people: 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() result.append( { "id": person["id"], "name": person["name"], "created_at": person["created_at"], # The path itself is never exposed — an internal container # filesystem detail — just whether GET /people//photo has # anything to serve. "has_photo": person["photo_path"] is not None, "chore_exempt": bool(person["chore_exempt"]), "chore_reminder_style": person["chore_reminder_style"], "identifiers": [dict(i) for i in identifiers], } ) return result 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 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 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"], "home": home, "room": room, "has_device": bool(person["identifiers"]), "has_photo": person["has_photo"], "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"], } ) payload = {"people": result, "generated_at": _now()} if not ha_ok: payload["error"] = "ha_unreachable" return payload 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 path = urlsplit(self.path).path photo_match = re.match(r"^/people/(\d+)/photo$", path) if path == "/people": self._respond(HTTPStatus.OK, {"people": list_people()}) elif path == "/presence": self._respond(HTTPStatus.OK, presence()) 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_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 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) 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 chore_settings_match: self._handle_chore_settings(int(chore_settings_match.group(1))) else: self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"}) 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 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 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: 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 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: 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 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: 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 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: 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 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 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", ""), ) 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())