787 lines
33 KiB
Python
Executable File
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())
|