From 483cbce728c914ab17b607767e67b9d07dcb0da4 Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 10 Aug 2026 15:03:45 +0200 Subject: [PATCH] Exact positions inside a room, fused from radar and BLE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3D floorplan could say which room somebody was in. This makes it able to say where in the room — from a different sensor, kept deliberately separate from the one that knows who they are. Room-level presence comes from BLE, which cannot give coordinates: RSSI-to- distance is noisy enough that trilateration in a house lands in the wrong room. Coordinates come from mmWave radar (LD2450-class), which tracks moving targets and reports x/y — and cannot say who anybody is, because it sees a moving blob. So the two are fused, by a rule that refuses far more often than it commits: exactly one occupant in the room AND exactly one target in the room -> that target is that person anything else -> targets stay anonymous, occupants stay unpositioned Two people in a room are two blobs that cannot be told apart. Guessing which is which would put a name on the wrong person, and a display that does that occasionally is worse than one that never tries — its wrong answers are indistinguishable from its right ones. position_ambiguous says so out loud. Which room a target is in is computed from the polygon rather than from which sensor saw it: a radar in an open-plan kitchen sees into the living room, and attributing by sensor would put people through walls. identity floorplan_levels gains metres_wide; without it positions are not computed and the API reports that rather than guessing a scale. New floorplan_sensors table holds where each radar sits on the plan and which way it faces — drawn by a human, because a wrong rotation mirrors every target it reports and the result looks plausible rather than broken. Targets at exactly (0,0) are dropped: that is how these radars say "nothing here", and treating it as a detection grows a phantom person on top of every sensor. render/floorplan-3d A fused person is drawn at their coordinate with a footprint dot, since a marker floated above the floor otherwise reads as further back in the room. An unattributed target is a hollow dashed puck with a question mark — no colour, no initial, because every visual language here for a person is reserved for people the system can name. A radar target lights the room even with nobody named: somebody is in there, and that the house cannot say who is a fact about the house. Hardware: HLK-LD2450 added to components.md, ~EUR 15-25 per room, with the advice to buy one and check its facing before buying more. Position maths and the fusion rule are unit-tested headlessly. No radar has been bought, mounted or read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FanS1vyE2gLhGkqKq6HtYj --- docs/components.md | 23 ++- docs/endpoint-surfaces.md | 46 +++++ identity/README.md | 39 ++++ identity/server.py | 271 ++++++++++++++++++++++++++- render/README.md | 8 + render/floorplan-3d/floorplan-app.js | 17 +- render/floorplan-3d/floorplan3d.js | 85 ++++++++- 7 files changed, 471 insertions(+), 18 deletions(-) diff --git a/docs/components.md b/docs/components.md index 2a80e43..c61bfac 100644 --- a/docs/components.md +++ b/docs/components.md @@ -309,6 +309,24 @@ use, and it is the one thing worth checking on the listing before buying. after the first is a repeat of a solved problem; the first one is where you find out whether the model was the right pick. +#mmWave presence radar — need 1 to start, then 1 per room you care about +For **exact positions inside a room** on the 3D floorplan. BLE answers "which room"; +this answers "where in it", and the two are fused in `identity` — see +`docs/endpoint-surfaces.md` for the rule and for why it is deliberately timid. +- [HLK-LD2450 24GHz mmWave radar module](https://www.amazon.com/Waveshare-Millimeter-Detection-Multi-Target-Trajectory/dp/B0CX5QNXH2) + (~€15–25 each) — tracks up to **three moving targets** and reports each one's x/y, + which is the specific capability that makes this worth doing at all. Native ESPHome + support (`ld2450`), so it rides the same firmware pipeline as the other ESP nodes and + needs an ESP32 per sensor (the RuView boards in this list are a separate job — do not + double up firmware on one chip). +- **It cannot tell you who.** A radar sees a moving blob. Identity still comes from BLE, + and with two people in one room neither blob gets a name — by design, because guessing + is worse than not knowing. +- **Buy one, mount it, and check the rotation before buying more.** Getting the facing + wrong mirrors every position it reports, and the failure looks plausible rather than + broken. One room proves the whole chain (sensor → ESPHome → HA → identity's fusion → + the 3D plan) for ~€20. + #Fixed BLE tags — need ~4 to start For household members who don't (or shouldn't have to) carry a phone for presence to work — a grandmother without a smartphone is the concrete case, but this is also just @@ -395,14 +413,15 @@ local pricing/VAT/shipping. | innr RB 285 C Zigbee RGB bulb | ~16 (TBD) | €15–20 | €240–320 | | iTag-Tiny fixed BLE tag | 4 | €3–8 | €12–32 | | Espressif ESP32-S3-DevKitC-1-N8R2 (RuView) | 6 | €8–12 | €48–72 | +| HLK-LD2450 mmWave radar *(buy 1 first — see that section)* | ~3 | €15–25 | €45–75 | | Aqara Zigbee door contact sensor (appliance doors) | ~3 | €10–15 | €30–45 | | Tapo C120 doorway camera *(buy 1 first — see that section)* | ~2 | €30–40 | €60–80 | | Tapo C120 network camera (rooms/workshop view) | ~3 | €30–40 | €90–120 | | Workshop monitor/TV *(reuse one first if you have it)* | 1 | €120–250 | €120–250 | | USB inspection/macro camera (workshop) | 1 | €30–60 | €30–60 | -**Subtotal (excludes the optional beamer mount): ~€2,244–2,846** -**Subtotal, including the optional beamer mount: ~€2,259–2,871** +**Subtotal (excludes the optional beamer mount): ~€2,289–2,921** +**Subtotal, including the optional beamer mount: ~€2,304–2,946** The workshop line items are the ones to sequence rather than buy at once: the macro camera is cheap and decides whether the OCR-first identification works at all, the diff --git a/docs/endpoint-surfaces.md b/docs/endpoint-surfaces.md index cc43502..07de32b 100644 --- a/docs/endpoint-surfaces.md +++ b/docs/endpoint-surfaces.md @@ -135,6 +135,52 @@ a face — is gone: showing everyone who is home but unlocatable. They are the people you are most often looking for. +### Exact positions within a room + +Room-level presence answers *which room*. Exact positions need a sensor that reports +**coordinates**, and BLE is not that sensor: RSSI-to-distance is noisy enough that +trilateration in a house lands in the wrong room often enough to be useless. + +The realistic source is **mmWave radar** (LD2450-class, ~€15–25 per room, ESPHome +native), which tracks up to three moving targets and publishes each one's x/y in +millimetres. What it cannot do is say **who** — it sees a moving blob. + +So position and identity come from different sensors, and the whole design is in how +they are combined: + +| Signal | Source | Gives | +|---|---|---| +| who | BLE / Bermuda | a person, resolved to a room | +| where | mmWave | a coordinate, with no name | + +**The fusion rule is deliberately timid** (`floorplan_presence()` in `identity`): + +> exactly one occupant in the room **and** exactly one target in the room → that target +> is that person. Anything else → the targets stay anonymous and the occupants stay +> unpositioned. + +Two people in a room produce two blobs that cannot be told apart. Guessing which is +which would put a name on the wrong person, and a presence display that does that +occasionally is worse than one that never tries — you cannot tell the wrong answers +from the right ones. `position_ambiguous` says out loud when this has happened. + +What the plan needs before any of it works, both drawn by a human because nothing can +infer them: + +- **`metres_wide` per level** — the real-world width of the 0–1 extent. Without it a + reading in metres cannot become a point on the plan, and the API reports that rather + than guessing a scale. +- **A sensor placement per radar** — where it is on the plan and *which way it faces*. + A wrong rotation mirrors every target it reports, which is the single most likely way + to get positions that look plausible and are wrong. + +Rendering follows the same honesty: a named person at a fused position gets their +marker with a footprint dot on the floor (without it, a marker floated above the floor +reads as being further back in the room); an unattributed target gets a **hollow dashed +puck with a question mark** — no colour, no initial, because every visual language this +view has for a person is reserved for people it can name. A radar target lights the +room even with nobody named, because somebody *is* in there. + ### The honest constraint — and how it was resolved The plan was to vendor three.js and call it a deliberate break with the diff --git a/identity/README.md b/identity/README.md index d361a48..1d93d1d 100644 --- a/identity/README.md +++ b/identity/README.md @@ -123,6 +123,44 @@ applies to replacing one. A person with no picture is not a gap in a UI: their initial on their colour is the fallback everywhere, and on the small screens it is the *preferred* rendering anyway. +## Exact positions inside a room + +`/floorplan/presence` answers "which room" from BLE. It can also answer "**where in +it**", from a different sensor, and the two are kept apart on purpose. + +| Signal | Source | Gives | +|---|---|---| +| who | BLE / Bermuda | a person, resolved to a room | +| where | mmWave radar (LD2450-class) | a coordinate, **with no name** | + +**The fusion rule is deliberately timid:** + +> exactly one occupant in the room **and** exactly one target in the room → that target +> is that person (`position.source: "fused"`). Anything else → targets stay anonymous +> in `room.targets`, occupants stay unpositioned, and `position_ambiguous` is true. + +Two people in a room are two blobs that cannot be told apart. Guessing would put a name +on the wrong person, and a display that does that occasionally is worse than one that +never tries — you can't tell its wrong answers from its right ones. + +Which room a target is in is computed **from the polygon**, never from which sensor saw +it: a radar in an open-plan kitchen sees into the living room, and attributing by sensor +puts people through walls. + +Two things have to be drawn by a human first, because nothing can infer them: + +- **`metres_wide` on the level** — the real width of its 0–1 extent. Without it, + positions are simply not computed, and the API says so rather than guessing a scale. +- **A sensor placement** (`POST /floorplan/sensors`): where the radar sits on the plan, + and **which way it faces**. A wrong `rotation_deg` mirrors every target it reports — + the most likely way to get positions that look plausible and are wrong. + +Readings are assumed to be **millimetres** (ESPHome's LD2450 default); +`POSITION_UNIT_DIVISOR` overrides that, because "which unit is this number in" differs +between integrations and getting it wrong scales everything by a thousand instead of +failing visibly. A target at exactly (0,0) is how these radars say *nothing here*, so +those are dropped — otherwise every sensor grows a phantom person sitting on top of it. + ## People without a device Two paths, distinct on purpose because they solve different problems: @@ -615,6 +653,7 @@ not network placement. | `GET /people//photo` | the person's profile picture (raw JPEG) | | `POST /people//photo` | raw image bytes -> set the profile picture directly, without a walk to the door panel | | `GET /person-colors` | the eight-colour palette the admin panel offers (see below) | +| `GET/POST /floorplan/sensors`, `DELETE /floorplan/sensors/` | where each position radar sits on the plan, and which way it faces | | `POST /people//identifiers` | `{"entity_id"}` — attach an identifier by hand (a fixed BLE tag not in range yet). Still enforces `TRUSTED_ENTITY_PREFIXES` | | `DELETE /people//identifiers/` | revoke a mistaken or compromised identifier | | `DELETE /people/` | remove a person entirely (their identifiers, grants and visits go with them) | diff --git a/identity/server.py b/identity/server.py index 60b5dcc..15de284 100755 --- a/identity/server.py +++ b/identity/server.py @@ -84,6 +84,7 @@ from __future__ import annotations import json import logging +import math import os import re import sqlite3 @@ -378,8 +379,40 @@ def init_db() -> None: -- PDF, a photo of a sketch). Rooms can be drawn on bare canvas without -- one; it just makes drawing them accurate rather than approximate. image_path TEXT, + -- How wide this level is in REAL METRES, across its whole 0..1 extent. + -- Required for exact positions and for nothing else: a sensor reports + -- "target at 2.1m", and turning that into a point on a normalised plan + -- needs a scale. NULL means positions cannot be computed for this level, + -- which is reported honestly rather than guessed at. + metres_wide REAL, created_at TEXT NOT NULL ); + -- WHERE A POSITION SENSOR PHYSICALLY SITS, on the plan. + -- + -- A room-level presence signal answers "which room". Exact positions need a + -- sensor that reports coordinates — mmWave (LD2450-class) is the realistic + -- one — and its readings are RELATIVE TO ITSELF. So the plan has to know + -- where each sensor is and which way it faces, or the coordinates land + -- somewhere arbitrary. Drawn by a human in the admin panel, exactly like the + -- rooms, because nothing here can infer it. + CREATE TABLE IF NOT EXISTS floorplan_sensors ( + id INTEGER PRIMARY KEY, + level_id INTEGER NOT NULL REFERENCES floorplan_levels(id) ON DELETE CASCADE, + name TEXT NOT NULL, + -- The HA entity prefix its targets appear under. An ESPHome LD2450 + -- publishes sensor._target_1_x / _y and so on; this stores + -- and the reader walks the targets. + ha_entity_prefix TEXT NOT NULL, + -- Its own spot on the plan, normalised 0..1 like the room polygons. + x REAL NOT NULL, + y REAL NOT NULL, + -- Which way it faces, degrees clockwise from "up" on the plan. Getting + -- this wrong mirrors or rotates every target it reports, which is the + -- single most likely reason positions look plausible but wrong. + rotation_deg REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS floorplan_sensors_level ON floorplan_sensors (level_id); CREATE TABLE IF NOT EXISTS floorplan_rooms ( id INTEGER PRIMARY KEY, level_id INTEGER NOT NULL REFERENCES floorplan_levels(id) ON DELETE CASCADE, @@ -440,6 +473,7 @@ def init_db() -> None: _ensure_column(conn, "people", "notify_topic", "TEXT") _ensure_column(conn, "people", "digest_sections", "TEXT") _ensure_column(conn, "people", "color", "TEXT") + _ensure_column(conn, "floorplan_levels", "metres_wide", "REAL") _backfill_colors(conn) @@ -1997,6 +2031,75 @@ def _validate_points(raw) -> tuple[list[list[float]] | None, str]: return cleaned, "" +# How many targets to read per sensor. LD2450-class radars track three; asking for more +# is free and simply finds nothing. +POSITION_TARGETS_PER_SENSOR = int(os.environ.get("POSITION_TARGETS_PER_SENSOR", "3")) +# Readings are published in millimetres by ESPHome's LD2450 component. Overridable +# because "which unit is this number in" is exactly the kind of thing that differs +# between one integration and the next, and getting it wrong scales every position by +# a thousand rather than failing visibly. +POSITION_UNIT_DIVISOR = float(os.environ.get("POSITION_UNIT_DIVISOR", "1000")) + + +def _sensor_targets(states_by_id: dict, prefix: str) -> list[dict]: + """The live targets one sensor reports, in metres relative to itself. + + A target at exactly (0, 0) is how these radars say "nothing here" — they publish + zero rather than going unavailable — so those are dropped. Treating them as a real + detection would put a phantom person on top of every sensor on the plan. + """ + targets = [] + for index in range(1, POSITION_TARGETS_PER_SENSOR + 1): + raw_x = states_by_id.get(f"sensor.{prefix}_target_{index}_x") + raw_y = states_by_id.get(f"sensor.{prefix}_target_{index}_y") + try: + x = float(raw_x) / POSITION_UNIT_DIVISOR + y = float(raw_y) / POSITION_UNIT_DIVISOR + except (TypeError, ValueError): + continue + if abs(x) < 0.01 and abs(y) < 0.01: + continue + targets.append({"x": x, "y": y}) + return targets + + +def _plan_position(sensor, target, metres_wide: float) -> dict | None: + """A sensor-relative reading, placed on the normalised plan. + + Rotate by the sensor's own bearing, scale metres into plan units, offset by where + the sensor sits. Returns None when the result lands outside the plan, which is the + honest outcome for a bad rotation or a wrong scale — better a missing marker than a + confident one in the garden. + """ + if not metres_wide or metres_wide <= 0: + return None + angle = math.radians(sensor["rotation_deg"] or 0) + # Plan y grows downward, so a target "in front of" the sensor moves it up-plan. + rx = target["x"] * math.cos(angle) - target["y"] * math.sin(angle) + ry = target["x"] * math.sin(angle) + target["y"] * math.cos(angle) + x = sensor["x"] + rx / metres_wide + y = sensor["y"] - ry / metres_wide + if not (-0.05 <= x <= 1.05 and -0.05 <= y <= 1.05): + return None + return {"x": round(x, 4), "y": round(y, 4)} + + +def _point_in_polygon(x: float, y: float, points: list) -> bool: + """Standard ray casting. Which room a target is in has to be computed rather than + assumed from which sensor saw it: a radar in an open-plan kitchen sees into the + living room, and attributing by sensor would put people through walls.""" + inside = False + n = len(points) + for i in range(n): + x1, y1 = points[i] + x2, y2 = points[(i + 1) % n] + if (y1 > y) != (y2 > y): + xin = (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-9) + x1 + if x < xin: + inside = not inside + return inside + + def list_floorplan() -> dict: with _db_lock, _db() as conn: levels = conn.execute( @@ -2014,6 +2117,7 @@ def list_floorplan() -> dict: "name": level["name"], "sort_order": level["sort_order"], "has_image": level["image_path"] is not None, + "metres_wide": level["metres_wide"], "rooms": [ { "id": r["id"], @@ -2029,22 +2133,24 @@ def list_floorplan() -> dict: return {"levels": result, "generated_at": _now()} -def save_level(level_id: int | None, name: str, sort_order: int) -> dict: +def save_level(level_id: int | None, name: str, sort_order: int, + metres_wide: float | None = None) -> dict: name = (name or "").strip() if not name: return {"ok": False, "reason": "bad_name", "message": "A level needs a name."} with _db_lock, _db() as conn: if level_id: cur = conn.execute( - "UPDATE floorplan_levels SET name = ?, sort_order = ? WHERE id = ?", - (name, sort_order, level_id), + "UPDATE floorplan_levels SET name = ?, sort_order = ?, metres_wide = ? WHERE id = ?", + (name, sort_order, metres_wide, level_id), ) if cur.rowcount == 0: return {"ok": False, "reason": "not_found", "message": "No such level."} else: cur = conn.execute( - "INSERT INTO floorplan_levels (name, sort_order, created_at) VALUES (?, ?, ?)", - (name, sort_order, _now()), + "INSERT INTO floorplan_levels (name, sort_order, metres_wide, created_at) " + "VALUES (?, ?, ?, ?)", + (name, sort_order, metres_wide, _now()), ) level_id = cur.lastrowid return {"ok": True, "level_id": level_id, "name": name} @@ -2147,6 +2253,24 @@ def floorplan_presence() -> dict: resolved at all, which is the normal case without room-level BLE — come back under `unplaced` rather than being dropped, so the UI can show "3 people home, 1 not locatable" instead of quietly losing two of them. + + EXACT POSITIONS ARE A SECOND, WEAKER SIGNAL, AND ARE KEPT SEPARATE + ------------------------------------------------------------------ + If the level has position sensors and a metre scale, each room also gets `targets`: + coordinates where a radar can see *somebody*. Those are ANONYMOUS — an mmWave sensor + reports a moving blob, not a name — and identity comes from BLE, which is room-level. + + So attribution follows one rule, and the rule is deliberately timid: + + exactly one occupant in the room AND exactly one target in the room + -> that target is that person, marked `source: "fused"` + anything else + -> the targets stay anonymous and the occupants stay unpositioned + + Two people in a room produce two blobs that cannot be told apart, and guessing + which is which would put a name on a stranger — the failure that makes a presence + display worse than none. `position_ambiguous` says out loud when that has happened, + so a UI can show two unnamed dots rather than implying it knows. """ plan = list_floorplan() people = presence().get("people", []) @@ -2172,11 +2296,29 @@ def floorplan_presence() -> dict: else: unplaced.append(entry) + # Live targets, per level, projected onto the plan. Best-effort: no sensors, no + # scale, or an unreachable HA all end in the same place — rooms with no `targets`, + # which the renderer draws exactly as it always did. + targets_by_level = _live_targets(plan) + placed_areas = set() for level in plan["levels"]: + level_targets = targets_by_level.get(level["id"], []) for room in level["rooms"]: occupants = by_area.get(room["ha_area_id"] or "", []) room["occupants"] = occupants + + # Which room a target is in is computed from the polygon, never from which + # sensor saw it — a radar in an open-plan kitchen sees into the living room. + inside = [t for t in level_targets if _point_in_polygon(t["x"], t["y"], room["points"])] + room["targets"] = inside + room["position_ambiguous"] = len(inside) > 1 or (len(inside) >= 1 and len(occupants) > 1) + + if len(inside) == 1 and len(occupants) == 1: + occupants[0]["position"] = {**inside[0], "source": "fused"} + # Attributed, so it is no longer an unnamed dot for the renderer to draw. + room["targets"] = [] + if room["ha_area_id"] and occupants: placed_areas.add(room["ha_area_id"]) @@ -2188,9 +2330,100 @@ def floorplan_presence() -> dict: plan["unplaced"] = unplaced plan["unmapped_areas"] = sorted(unmapped) + plan["positions_available"] = any(targets_by_level.values()) return plan +def _live_targets(plan: dict) -> dict[int, list[dict]]: + """{level_id: [{x, y}]} — every position sensor's current targets, on the plan. + + Read from the same HA state dump presence() uses. Fails soft in every direction: + an unreachable HA, a level with no metre scale, a sensor whose entities do not + exist yet — all produce no targets, and the floorplan renders room-level as before. + """ + result: dict[int, list[dict]] = {} + with _db_lock, _db() as conn: + sensors = conn.execute("SELECT * FROM floorplan_sensors").fetchall() + if not sensors: + return result + + try: + states = _ha_get("/api/states") + except Exception: + LOG.warning("identity: could not read HA states for position sensors", exc_info=True) + return result + + states_by_id = {s.get("entity_id"): s.get("state") for s in states if isinstance(s, dict)} + scale_by_level = {level["id"]: level.get("metres_wide") for level in plan["levels"]} + + for sensor in sensors: + metres_wide = scale_by_level.get(sensor["level_id"]) + if not metres_wide: + # A sensor on a level with no scale cannot be placed. Logged once per run + # rather than silently skipped, because the fix is one number in the editor. + LOG.info("identity: level %s has no metres_wide, so sensor %r cannot be placed", + sensor["level_id"], sensor["name"]) + continue + for target in _sensor_targets(states_by_id, sensor["ha_entity_prefix"]): + point = _plan_position(sensor, target, float(metres_wide)) + if point: + result.setdefault(sensor["level_id"], []).append(point) + return result + + +def save_sensor(payload: dict) -> dict: + """Place (or move) a position sensor on the plan. Same human-drawn discipline as + the rooms: nothing here can infer where a radar is bolted to a wall.""" + try: + level_id = int(payload.get("level_id")) + except (TypeError, ValueError): + return {"ok": False, "reason": "bad_field", "message": "'level_id' is required."} + name = str(payload.get("name") or "").strip() + prefix = str(payload.get("ha_entity_prefix") or "").strip() + if not name or not prefix: + return {"ok": False, "reason": "bad_field", + "message": "'name' and 'ha_entity_prefix' are both required. The prefix is " + "the part before _target_1_x in the sensor's entity ids."} + try: + x = float(payload.get("x")) + y = float(payload.get("y")) + rotation = float(payload.get("rotation_deg", 0)) + except (TypeError, ValueError): + return {"ok": False, "reason": "bad_field", "message": "'x', 'y' must be numbers."} + if not (0 <= x <= 1 and 0 <= y <= 1): + return {"ok": False, "reason": "bad_field", + "message": "x and y are normalised 0..1, the same coordinate space the " + "room polygons use."} + + with _db_lock, _db() as conn: + sensor_id = payload.get("id") + if sensor_id: + conn.execute( + "UPDATE floorplan_sensors SET name = ?, ha_entity_prefix = ?, x = ?, y = ?, " + "rotation_deg = ? WHERE id = ?", + (name, prefix, x, y, rotation, int(sensor_id)), + ) + else: + cur = conn.execute( + "INSERT INTO floorplan_sensors (level_id, name, ha_entity_prefix, x, y, " + "rotation_deg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (level_id, name, prefix, x, y, rotation, _now()), + ) + sensor_id = cur.lastrowid + return {"ok": True, "id": sensor_id} + + +def list_sensors() -> dict: + with _db_lock, _db() as conn: + rows = conn.execute("SELECT * FROM floorplan_sensors ORDER BY level_id, name").fetchall() + return {"sensors": [dict(r) for r in rows]} + + +def delete_sensor(sensor_id: int) -> bool: + with _db_lock, _db() as conn: + return conn.execute("DELETE FROM floorplan_sensors WHERE id = ?", (sensor_id,)).rowcount > 0 + + def area_suggestions() -> dict: """Every area value HA is currently reporting on a trusted entity, so the room editor can offer a pick-list instead of asking someone to retype an area_id from @@ -2367,6 +2600,8 @@ class Handler(BaseHTTPRequestHandler): self._respond(HTTPStatus.OK, floorplan_presence()) elif path == "/floorplan/areas": self._respond(HTTPStatus.OK, area_suggestions()) + elif path == "/floorplan/sensors": + self._respond(HTTPStatus.OK, list_sensors()) elif path == "/person-colors": # Served rather than duplicated in the admin panel's JS, so the palette # has one definition — see PERSON_COLORS for what makes these eight @@ -2515,6 +2750,8 @@ class Handler(BaseHTTPRequestHandler): self._handle_save_level() elif path == "/floorplan/rooms": self._handle_save_room() + elif path == "/floorplan/sensors": + self._handle_save_sensor() elif post_level_image_match: self._handle_upload_level_image(int(post_level_image_match.group(1))) elif person_photo_match: @@ -2598,13 +2835,28 @@ class Handler(BaseHTTPRequestHandler): except (TypeError, ValueError): self._respond(HTTPStatus.BAD_REQUEST, {"error": "'id' and 'sort_order' must be integers"}) return - result = save_level(level_id, str(payload.get("name", "")), sort_order) + # The real-world width of the level, needed for exact positions and nothing + # else. Absent leaves it NULL, which reports honestly as "positions cannot be + # computed here" rather than guessing a scale. + try: + metres_wide = float(payload["metres_wide"]) if payload.get("metres_wide") else None + except (TypeError, ValueError): + self._respond(HTTPStatus.BAD_REQUEST, {"error": "'metres_wide' must be a number"}) + return + result = save_level(level_id, str(payload.get("name", "")), sort_order, metres_wide) self._respond( HTTPStatus.OK if result.get("ok") else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT), result, ) + def _handle_save_sensor(self) -> None: + payload = self._json_body() + if payload is None: + return + result = save_sensor(payload) + self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.BAD_REQUEST, result) + def _handle_save_room(self) -> None: payload = self._json_body() if payload is None: @@ -2659,6 +2911,13 @@ class Handler(BaseHTTPRequestHandler): return path = urlsplit(self.path).path + sensor_match = re.match(r"^/floorplan/sensors/(\d+)$", path) + if sensor_match: + ok = delete_sensor(int(sensor_match.group(1))) + self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, + {"ok": True} if ok else {"error": "no such sensor"}) + return + id_match = re.match(r"^/people/(\d+)/identifiers/(\d+)$", path) if id_match: ok = delete_identifier(int(id_match.group(1)), int(id_match.group(2))) diff --git a/render/README.md b/render/README.md index d46852c..a2daedf 100644 --- a/render/README.md +++ b/render/README.md @@ -67,6 +67,14 @@ and divergence would mean one of them is lying: the watch and in the admin panel. - `unplaced` people get a **visible shelf**. They are who you are most often looking for. +**Exact positions**, when the level has a metre scale and mmWave sensors placed on it: +a person fused to a single radar target is drawn *at* that coordinate with a footprint +dot on the floor (without it, a marker floated above the floor reads as further back in +the room). An unattributed target is a **hollow dashed puck with a question mark** — no +colour, no initial, because every visual language here for a *person* is reserved for +people the system can name. A radar target lights the room even with nobody named: +somebody is in there, and that the house can't say who is a fact about the house. + Drag to orbit; pitch is clamped and there is no free-fly, because an unconstrained camera on a wall panel is something people knock askew and cannot get back. diff --git a/render/floorplan-3d/floorplan-app.js b/render/floorplan-3d/floorplan-app.js index 47d7f44..23e376a 100644 --- a/render/floorplan-3d/floorplan-app.js +++ b/render/floorplan-3d/floorplan-app.js @@ -51,9 +51,20 @@ function load() { (n, room) => n + (room.occupants || []).length, 0 ); const unplaced = (data.unplaced || []).length; - $("summary").textContent = unplaced - ? `${placed} placed · ${unplaced} home but not locatable` - : `${placed} placed`; + const rooms = level ? level.rooms || [] : []; + const exact = rooms.reduce( + (n, room) => n + (room.occupants || []).filter((p) => p.position).length, 0 + ); + const anonymous = rooms.reduce((n, room) => n + (room.targets || []).length, 0); + + // Every number the view actually knows, said out loud. A summary that reported + // only "3 placed" would imply the picture is complete when two of the dots are + // unnamed blobs and one person is somewhere unknown. + const parts = [`${placed} placed`]; + if (exact) parts.push(`${exact} exact`); + if (anonymous) parts.push(`${anonymous} unidentified`); + if (unplaced) parts.push(`${unplaced} home but not locatable`); + $("summary").textContent = parts.join(" · "); }) .catch((err) => { $("error").hidden = false; diff --git a/render/floorplan-3d/floorplan3d.js b/render/floorplan-3d/floorplan3d.js index 613226b..0b34592 100644 --- a/render/floorplan-3d/floorplan3d.js +++ b/render/floorplan-3d/floorplan3d.js @@ -169,7 +169,12 @@ // reports is neither occupied nor confirmed-empty, and drawing it as empty is a // quiet lie — "nobody is in the study" and "nothing can see the study" are // different sentences. - if ((room.occupants || []).length) return "occupied"; + // + // A radar target counts as occupied even with nobody named. Somebody IS in that + // room; that the house cannot say who is a fact about the house, not about the + // room being empty — and a guest, or a household member whose phone is flat, is + // exactly the case where lighting the room matters most. + if ((room.occupants || []).length || (room.targets || []).length) return "occupied"; if (!room.ha_area_id) return "unknown"; return "empty"; } @@ -291,12 +296,35 @@ _drawOccupants(room, points, height) { const occupants = room.occupants || []; - if (!occupants.length) return; - const centre = this._centroid(points, height); + const targets = room.targets || []; const radius = 17; - const spread = Math.min(occupants.length - 1, 3) * (radius + 4); - occupants.slice(0, 4).forEach((person, index) => { + // ANONYMOUS TARGETS FIRST. A radar sees a moving blob, not a name. These are + // drawn where they actually are and left unlabelled, because identity comes from + // BLE (room-level) and putting a name on the wrong blob is the failure that makes + // a presence display worse than none. + for (const target of targets) { + this._drawAnonymousTarget(target, height); + } + + if (!occupants.length) return; + + // Anyone the server could fuse to a single target is drawn AT that position. + // Everyone else falls back to the room centroid, which is the room-level answer + // this view has always given. + const positioned = occupants.filter((p) => p.position); + const unpositioned = occupants.filter((p) => !p.position); + + for (const person of positioned) { + const p = this._project(person.position.x, person.position.y, height); + this._drawFootprint(p); + this._drawMarker(person, p.x, p.y - radius - 12, radius); + } + + if (!unpositioned.length) return; + const centre = this._centroid(points, height); + const spread = Math.min(unpositioned.length - 1, 3) * (radius + 4); + unpositioned.slice(0, 4).forEach((person, index) => { const x = centre.x - spread / 2 + index * (radius + 4); // Floated above the floor so the marker reads as standing in the room rather // than as painted on it — a flat sprite on the floor is unreadable at a glance, @@ -304,14 +332,57 @@ this._drawMarker(person, x, centre.y - radius - 14, radius); }); - if (occupants.length > 4) { + if (unpositioned.length > 4) { const { ctx } = this; ctx.fillStyle = "rgba(30,18,45,0.8)"; ctx.font = "600 11px system-ui, sans-serif"; - ctx.fillText(`+${occupants.length - 4}`, centre.x + spread / 2 + radius, centre.y - radius - 10); + ctx.fillText(`+${unpositioned.length - 4}`, centre.x + spread / 2 + radius, centre.y - radius - 10); } } + /** + * A dot on the floor under a positioned marker, so the eye can tell WHERE the + * person is from where the disc merely floats. Without it a marker lifted 30px off + * the floor reads as being 30px further back in the room. + */ + _drawFootprint(p) { + const { ctx } = this; + ctx.beginPath(); + ctx.ellipse(p.x, p.y, 7, 3.5, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(30,18,45,0.35)"; + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x, p.y - 12); + ctx.strokeStyle = "rgba(30,18,45,0.35)"; + ctx.lineWidth = 1; + ctx.stroke(); + } + + /** + * Somebody is here, and the system does not know who. Drawn as a hollow puck — no + * initial, no colour — because every visual language this view has for a PERSON is + * reserved for people it can actually name. + */ + _drawAnonymousTarget(target, height) { + const { ctx } = this; + const p = this._project(target.x, target.y, height); + this._drawFootprint(p); + ctx.beginPath(); + ctx.arc(p.x, p.y - 20, 11, 0, Math.PI * 2); + ctx.fillStyle = "rgba(120,100,160,0.35)"; + ctx.fill(); + ctx.strokeStyle = "rgba(200,180,235,0.75)"; + ctx.lineWidth = 2; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = "rgba(30,18,45,0.8)"; + ctx.font = "700 12px system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("?", p.x, p.y - 16); + } + _drawMarker(person, x, y, radius) { const { ctx } = this; const colour = person.color || "#c084fc";