SmartestHome/identity/server.py

787 lines
33 KiB
Python
Executable File

"""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.
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/<id>/identifiers/<id> 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}
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
);
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 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/<id>/photo has
# anything to serve.
"has_photo": person["photo_path"] is not None,
"identifiers": [dict(i) for i in identifiers],
}
)
return result
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")
}
result = []
for person in people:
room = None
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]
home = 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 = None # HA unreachable
else:
home = manual.get(person["id"]) # None if never manually set either
result.append(
{
"id": person["id"],
"name": person["name"],
"home": home,
"room": room,
"has_device": bool(person["identifiers"]),
"has_photo": person["has_photo"],
}
)
payload = {"people": result, "generated_at": _now()}
if not ha_ok:
payload["error"] = "ha_unreachable"
return payload
def _on_mqtt_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 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)
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
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()
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 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())