Exact positions inside a room, fused from radar and BLE

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FanS1vyE2gLhGkqKq6HtYj
main
Amir Alexander Abdelbaki 2026-08-10 15:03:45 +02:00
parent a948f4b375
commit 483cbce728
7 changed files with 471 additions and 18 deletions

View File

@ -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)
(~€1525 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) | €1520 | €240320 |
| iTag-Tiny fixed BLE tag | 4 | €38 | €1232 |
| Espressif ESP32-S3-DevKitC-1-N8R2 (RuView) | 6 | €812 | €4872 |
| HLK-LD2450 mmWave radar *(buy 1 first — see that section)* | ~3 | €1525 | €4575 |
| Aqara Zigbee door contact sensor (appliance doors) | ~3 | €1015 | €3045 |
| Tapo C120 doorway camera *(buy 1 first — see that section)* | ~2 | €3040 | €6080 |
| Tapo C120 network camera (rooms/workshop view) | ~3 | €3040 | €90120 |
| Workshop monitor/TV *(reuse one first if you have it)* | 1 | €120250 | €120250 |
| USB inspection/macro camera (workshop) | 1 | €3060 | €3060 |
**Subtotal (excludes the optional beamer mount): ~€2,2442,846**
**Subtotal, including the optional beamer mount: ~€2,2592,871**
**Subtotal (excludes the optional beamer mount): ~€2,2892,921**
**Subtotal, including the optional beamer mount: ~€2,3042,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

View File

@ -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, ~€1525 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 01 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

View File

@ -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 01 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/<id>/photo` | the person's profile picture (raw JPEG) |
| `POST /people/<id>/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/<id>` | where each position radar sits on the plan, and which way it faces |
| `POST /people/<id>/identifiers` | `{"entity_id"}` — attach an identifier by hand (a fixed BLE tag not in range yet). Still enforces `TRUSTED_ENTITY_PREFIXES` |
| `DELETE /people/<id>/identifiers/<id>` | revoke a mistaken or compromised identifier |
| `DELETE /people/<id>` | remove a person entirely (their identifiers, grants and visits go with them) |

View File

@ -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.<prefix>_target_1_x / _y and so on; this stores
-- <prefix> and the reader walks the targets.
ha_entity_prefix TEXT NOT NULL,
-- Its own spot on the plan, normalised 0..1 like the room polygons.
x REAL NOT NULL,
y REAL NOT NULL,
-- Which way it faces, degrees clockwise from "up" on the plan. Getting
-- this wrong mirrors or rotates every target it reports, which is the
-- single most likely reason positions look plausible but wrong.
rotation_deg REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS floorplan_sensors_level ON floorplan_sensors (level_id);
CREATE TABLE IF NOT EXISTS floorplan_rooms (
id INTEGER PRIMARY KEY,
level_id INTEGER NOT NULL REFERENCES floorplan_levels(id) ON DELETE CASCADE,
@ -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)))

View File

@ -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.

View File

@ -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;

View File

@ -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";