This commit is contained in:
Matysh
2026-07-31 13:15:12 +03:00
26 changed files with 1313 additions and 135 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.54.0"
VERSION = "1.54.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.54.0"
"version": "1.54.1"
}
+112 -60
View File
@@ -9,6 +9,7 @@ want to see where the cleanup has already been).
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
@@ -28,6 +29,28 @@ FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
"""Map-id normalisation contract, shared with the frontend.
Mirrors src/vacuum.ts vacMapIdFromAttrs (source attrs, `??`-chain) plus the
card's _vacMapId fallback to the vacuum entity's selected_map. The FIRST
value that is not None wins — truthiness is wrong here: a zero-based
`map_index: 0` is a valid first map and an empty string is still an id.
The old `or`-chain dropped the zero, so the server stored trails under a
key the renderer never looked up (HP-1540-02).
"""
for v in (
src_attrs.get("map_name"),
src_attrs.get("current_map"),
src_attrs.get("map_index"),
src_attrs.get("selected_map"),
vac_attrs.get("selected_map"),
):
if v is not None:
return str(v)
return "default"
class TrailBook:
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
@@ -76,44 +99,69 @@ class TrailRecorder:
self.rt = rt
self.store = Store(hass, 1, f"{DOMAIN}.trails")
self.book = TrailBook()
self.pairs: dict[str, tuple[str, str]] = {} # source → (marker, vacuum)
# HP-1540-03: one source may feed SEVERAL markers — the same robot
# placed on two floors is the documented multi-floor case, and a plain
# source → (marker, vacuum) dict silently kept only the last one
self.pairs: dict[str, list[tuple[str, str]]] = {} # source → [(marker, vacuum), …]
self._unsub_track = None
self._unsub_save = None
self._last_fire = 0.0
# HP-1540-05: config/set fires refresh as a detached task; two of them
# interleaving across the awaited load both subscribed and the loser's
# unsub handle was overwritten — a leak until HA restart
self._refresh_lock = asyncio.Lock()
self._closed = False
async def async_setup(self) -> None:
self.book = TrailBook(await self.store.async_load() or {})
await self.async_refresh()
async def async_refresh(self) -> None:
"""(Re)subscribe after any config change — markers may come and go."""
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
stored = await self.rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
self.pairs = {}
for m in cfg.get("markers") or []:
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
continue
vac = self._vacuum_entity(m)
if vac:
self.pairs[src] = (str(m.get("id")), vac)
ents = set(self.pairs) | {vac for _, vac in self.pairs.values()}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
# A run already in progress (HA restarted mid-cleanup, or the user just
# finished calibrating) must start recording NOW, not at the next
# state change — otherwise the first seconds of the path are lost.
for src in self.pairs:
self._sample(src, time.time())
"""(Re)subscribe after any config change — markers may come and go.
Serialised (HP-1540-05): the lock makes unsubscribe-then-resubscribe
atomic across the awaited config load, so overlapping refresh tasks can
no longer both subscribe and strand one callback forever. The _closed
check covers teardown() racing a refresh that is parked on its await.
"""
async with self._refresh_lock:
stored = await self.rt.config_store.async_load() or {}
if self._closed:
return
cfg = stored.get("config") or {}
pairs: dict[str, list[tuple[str, str]]] = {}
for m in cfg.get("markers") or []:
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
continue
vac = self._vacuum_entity(m)
if vac:
# HP-1540-03: append, never overwrite — every floor's
# marker records its own copy of the run
pairs.setdefault(src, []).append((str(m.get("id")), vac))
self.pairs = pairs
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
# A run already in progress (HA restarted mid-cleanup, or the user
# just finished calibrating) must start recording NOW, not at the
# next state change — otherwise the first seconds of the path are
# lost.
for src in self.pairs:
self._sample(src, time.time())
def teardown(self) -> None:
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
# not re-subscribe after this cleanup has already run
self._closed = True
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
@@ -133,45 +181,49 @@ class TrailRecorder:
return None
def _sample(self, src: str, now: float) -> bool:
"""Record one point (or end the run) for a single source entity."""
pair = self.pairs.get(src)
if not pair:
return False
marker, vac = pair
st_vac = self.hass.states.get(vac)
if not st_vac or st_vac.state not in MOVING_STATES:
return self.book.end_run(marker, now)
st_src = self.hass.states.get(src)
attrs = st_src.attributes if st_src else {}
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
# Server-side these attributes are often OBJECTS (Tasshack keeps a
# Point dataclass in memory — it only becomes a dict when serialised
# to the frontend). Caught live on the owner's X50: the recorder saw
# every state change and rejected every single one.
if isinstance(raw, dict):
px, py = raw.get("x"), raw.get("y")
else:
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
try:
x, y = float(px), float(py) # type: ignore[arg-type]
except (TypeError, ValueError):
return False
map_id = str(
attrs.get("map_name")
or attrs.get("current_map")
or attrs.get("map_index")
or st_vac.attributes.get("selected_map")
or "default"
)
return self.book.on_point(marker, map_id, x, y, now)
"""Record one point (or end the run) for EVERY marker fed by src.
HP-1540-03: the same source serves one marker per floor — all of them
must receive the point, not just whichever survived the dict.
"""
changed = False
for marker, vac in self.pairs.get(src) or ():
st_vac = self.hass.states.get(vac)
# "no state yet" is NOT "stopped": during HA boot the vacuum reads
# unavailable and ending the run here would split one cleanup into
# current+previous on every restart (observed live: 21 points
# became previous, the same run restarted at 5)
if not st_vac or st_vac.state in ("unavailable", "unknown"):
continue
if st_vac.state not in MOVING_STATES:
changed |= self.book.end_run(marker, now)
continue
st_src = self.hass.states.get(src)
attrs = st_src.attributes if st_src else {}
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
# Server-side these attributes are often OBJECTS (Tasshack keeps a
# Point dataclass in memory — it only becomes a dict when
# serialised to the frontend). Caught live on the owner's X50: the
# recorder saw every state change and rejected every single one.
if isinstance(raw, dict):
px, py = raw.get("x"), raw.get("y")
else:
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
try:
x, y = float(px), float(py) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
map_id = resolve_map_id(attrs, st_vac.attributes)
changed |= self.book.on_point(marker, map_id, x, y, now)
return changed
@callback
def _on_state(self, event: Any) -> None:
eid = event.data.get("entity_id")
now = time.time()
changed = False
for src, (_marker, vac) in self.pairs.items():
if eid in (src, vac):
for src, pair_list in self.pairs.items():
if eid == src or any(eid == vac for _, vac in pair_list):
changed |= self._sample(src, now)
if changed:
self._schedule_save()
+108
View File
@@ -0,0 +1,108 @@
// v1.54.0 audit regressions (HP-1540-01/-04/-06): the FIRST-USE path.
// An auto-discovered vacuum has NO marker in the config — cfg.markers stays
// empty here, unlike smoke_vacuum.mjs which always pushes one by hand (the
// audit called that gap out explicitly). Every vacuum action must materialise
// the marker and actually persist; plan rooms are legacy RECTANGLES.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const o = {};
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
// legacy rectangle rooms (x/y/w/h, HP-1540-04) in the garden space where
// the auto-discovered vacuum.mower already lives
const g = cfg.spaces.find((s) => s.id === 'garden');
g.rooms = [
{ id: 'ga', name: 'Кухня', area: 'garden', x: 0.05, y: 0.10, w: 0.25, h: 0.30 },
{ id: 'gb', name: 'Зал', x: 0.40, y: 0.10, w: 0.25, h: 0.30 },
{ id: 'gc', name: 'Спальня', x: 0.05, y: 0.50, w: 0.25, h: 0.30 },
];
// robot rooms: same centres in "robot mm" (scale 4), map_index is a
// NUMERIC ZERO — the cross-runtime zero-map contract (HP-1540-02)
const robotRooms = {
1: { name: 'Кухня', x0: 300, y0: 600, x1: 1100, y1: 1400 },
2: { name: 'Зал', x0: 1700, y0: 600, x1: 2500, y1: 1400 },
3: { name: 'Спальня', x0: 300, y0: 2200, x1: 1100, y1: 3000 },
};
const camAttrs = { vacuum_position: { x: 700, y: 1000, a: 0 }, map_index: 0, rooms: robotRooms };
c.hass = { ...c.hass,
entities: { ...c.hass.entities,
'camera.mower_map': { entity_id: 'camera.mower_map', device_id: 'd_mower', platform: 'demo' } },
states: { ...c.hass.states,
'camera.mower_map': { state: 'idle', attributes: camAttrs } } };
c._regSignature = '';
c.requestUpdate(); await c.updateComplete;
// spy on config writes: a success toast is only honest after one of these
const writes = [];
const realWS = c.hass.callWS;
c.hass.callWS = async (m) => { if (m.type === 'houseplan/config/set') writes.push(m); return realWS(m); };
const dev = c._devices.find((x) => x.id === 'd_mower');
o.devFound = !!dev;
o.freshNoMarker = (cfg.markers || []).length === 0 && !dev.marker;
// ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ----
c._vacAutoCalibrate(dev);
await c.updateComplete;
const m1 = (cfg.markers || []).find((x) => x.id === 'd_mower');
o.markerMaterialised = !!m1 && m1.binding === 'device:d_mower';
const cal0 = m1?.vacuum?.calibration?.['0'];
o.rectRoomsCalibrated = Array.isArray(cal0) && cal0.length === 6 && cal0.every(Number.isFinite);
o.sourceStored = m1?.vacuum?.source === 'camera.mower_map';
// the matrix really maps robot mm → canvas: centre of «Кухня» → (175, 250)
const ap = (mm, x, y) => [mm[0] * x + mm[1] * y + mm[2], mm[3] * x + mm[4] * y + mm[5]];
const hit = cal0 ? ap(cal0, 700, 1000) : [0, 0];
o.matrixMapsRooms = Math.hypot(hit[0] - 175, hit[1] - 250) < 2;
o.successToastShown = (c._toast || '').startsWith('Done: bound via 3 rooms');
await new Promise((r) => setTimeout(r, 700)); // debounced _saveConfig
o.configWritePersisted = writes.length >= 1
&& !!writes[writes.length - 1].config.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'];
// ---- manual fit from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
const dev2 = c._devices.find((x) => x.id === 'd_mower');
o.fitFreshNoMarker = !dev2.marker;
c._vacStartFit(dev2); await c.updateComplete;
c._vacFitSave(); await c.updateComplete;
const m2 = (cfg.markers || []).find((x) => x.id === 'd_mower');
const calFit = m2?.vacuum?.calibration?.['0'];
o.fitMaterialises = !!m2 && Array.isArray(calFit) && calFit.length === 6;
o.fitToastAfterSave = c._toast === c._t('vac.cal_done');
// ---- live checkbox + trail select from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
const liveBox = sr().querySelector('.vacbox input[type=checkbox]');
o.vacSectionShown = !!liveBox;
if (liveBox) { liveBox.click(); await c.updateComplete; }
const m3 = (cfg.markers || []).find((x) => x.id === 'd_mower');
o.liveToggleMaterialises = !!m3 && m3.vacuum?.live === false;
const sel = sr().querySelector('.vacbox select');
if (sel) { sel.value = 'always'; sel.dispatchEvent(new Event('change')); await c.updateComplete; }
o.trailModePersists = m3?.vacuum?.trail_mode === 'always';
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
// ---- error copy never points at the removed point calibration (HP-1540-06) ----
const toasts = ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.autocal_res_warn']
.map((k) => c._t(k)).join(' | ');
o.noPointCalibrationCopy = !/point|точк/i.test(toasts);
// and the real no-rooms path shows the reworded toast
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } };
await c.updateComplete;
c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
o.noRoomsToastReworded = c._toast === c._t('vac.autocal_no_rooms') && !/point|точк/i.test(c._toast);
c.hass.callWS = realWS;
return o;
});
checkAll(out);
await finish(browser, out);
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
# Demo-stand extras
Things that live on the public stand (demo.houseplan.tech) but are not part
of the shipped integration.
- `demo_robot/` — the scripted robot vacuum (docs/VACUUM.md, "demo stand gets
a scripted synthetic robot"). Deployed by copying the folder to
`custom_components/demo_robot` in both stand seeds and adding `demo_robot:`
to configuration.yaml; the dev stand picks it up automatically from this
path (`/opt/hp/bin/hp-update-dev.sh`). The rest of the stand-only config
(template LQI sensors, alarm helpers, the smoke automation) lives in the
seeds on the stand host — see `docs/TESTING-DEMO.md` and the memory note
`houseplan-demo-stand`.
+181
View File
@@ -0,0 +1,181 @@
"""Scripted robot vacuum for the public demo stand.
The real feature (docs/VACUUM.md) targets Tier-A map integrations — Xiaomi
Cloud Map Extractor, Tasshack dreame-vacuum, Valetudo. None of them can run
on the stand (no hardware), so the stand ships this simulator instead: a
`vacuum` entity with start/pause/return_to_base and a `sensor` whose
attributes follow the Tasshack shape (`vacuum_position` as a dict, `rooms`
with names matching the plan rooms, `map_index`). Room names match the demo
house on purpose — that is what makes «Настроить автоматически» demonstrable.
Coordinates are millimetres in 0..8000 with the Y axis flipped versus the
screen (like every real robot map seen so far) so the mirror toggle in the
fit panel has meaning on the stand too.
"""
from __future__ import annotations
import math
from datetime import timedelta
from typing import Any, Callable
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import discovery
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
DOMAIN = "demo_robot"
TICK_S = 2 # telemetry cadence
SPEED_MM = 600 # per tick ≈ 0.3 m/s, a realistic robot pace
DOCK = (3900.0, 2600.0)
# Robot-map rooms, mm, Y up (flipped vs screen). Bboxes mirror the plan's
# room polygons; the names MUST equal the plan room names — auto-calibration
# matches by name.
ROOMS: dict[str, tuple[str, float, float, float, float]] = {
"1": ("Гостиная", 480, 4320, 4160, 7360),
"2": ("Кухня", 4160, 5120, 7520, 7360),
"3": ("Спальня", 4160, 2400, 7520, 5120),
"4": ("Кабинет", 480, 2400, 2720, 4320),
"5": ("Прихожая", 2720, 2400, 4160, 4320),
}
# cleaning order: start at the dock in the hallway, end next to it
CLEAN_ORDER = ("5", "4", "1", "2", "3")
INSET = 350.0 # keep lanes away from walls
LANE_MM = 650.0 # serpentine lane spacing
def _serpentine(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]:
"""Boustrophedon lanes across a room bbox, like a real vacuum drives."""
x0, y0, x1, y1 = x0 + INSET, y0 + INSET, x1 - INSET, y1 - INSET
pts: list[tuple[float, float]] = []
y = y0
left_to_right = True
while y <= y1 + 1:
xs = (x0, x1) if left_to_right else (x1, x0)
pts.append((xs[0], y))
pts.append((xs[1], y))
left_to_right = not left_to_right
y += LANE_MM
return pts
def build_route() -> list[tuple[float, float]]:
route: list[tuple[float, float]] = [DOCK]
for rid in CLEAN_ORDER:
_, x0, y0, x1, y1 = ROOMS[rid]
route.extend(_serpentine(x0, y0, x1, y1))
route.append(DOCK)
return route
class RobotSim:
"""One shared simulation both entities read from."""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self.route = build_route()
self.state = "docked" # docked|cleaning|paused|returning
self.pos: tuple[float, float] = DOCK
self.heading = 0.0
self._seg = 0 # index of the segment being driven
self._seg_done = 0.0 # mm driven inside the current segment
self._back: list[tuple[float, float]] | None = None # manual return path
self._listeners: list[Callable[[], None]] = []
# ---- entity plumbing ----
def add_listener(self, cb: Callable[[], None]) -> Callable[[], None]:
self._listeners.append(cb)
def _remove() -> None:
if cb in self._listeners:
self._listeners.remove(cb)
return _remove
def _notify(self) -> None:
for cb in list(self._listeners):
cb()
# ---- commands (called by the vacuum entity) ----
def start(self) -> None:
if self.state == "paused":
self.state = "returning" if self._back else "cleaning"
else:
self.state = "cleaning"
self._seg = 0
self._seg_done = 0.0
self._back = None
self.pos = DOCK
self._notify()
def pause(self) -> None:
if self.state in ("cleaning", "returning"):
self.state = "paused"
self._notify()
def stop(self) -> None:
self.pause()
def return_to_base(self) -> None:
if self.state == "docked":
return
# retrace the visited waypoints backwards — a straight line to the
# dock would cut through walls
visited = self.route[: self._seg + 1]
self._back = [self.pos] + list(reversed(visited))
self._seg = 0
self._seg_done = 0.0
self.state = "returning"
self._notify()
# ---- physics ----
def _polyline(self) -> list[tuple[float, float]]:
return self._back if self._back is not None else self.route
def tick(self, _now: Any = None) -> None:
if self.state not in ("cleaning", "returning"):
return
line = self._polyline()
# manual return drives twice as fast, like the real thing hurrying home
budget = SPEED_MM * (2 if self._back is not None else 1)
while budget > 0 and self._seg < len(line) - 1:
ax, ay = line[self._seg]
bx, by = line[self._seg + 1]
seg_len = math.hypot(bx - ax, by - ay)
left = seg_len - self._seg_done
if left <= budget:
budget -= left
self._seg += 1
self._seg_done = 0.0
continue
self._seg_done += budget
t = self._seg_done / seg_len
self.pos = (ax + (bx - ax) * t, ay + (by - ay) * t)
self.heading = math.degrees(math.atan2(by - ay, bx - ax)) % 360
budget = 0
if self._seg >= len(line) - 1:
# arrived: either the route is finished or the manual return is
self.pos = line[-1]
self.state = "docked"
self._back = None
elif self._back is None and self._seg >= len(line) - 2:
# the last route leg is the drive home
self.state = "returning"
self._notify()
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
sim = RobotSim(hass)
hass.data[DOMAIN] = sim
@callback
def _tick(now: Any) -> None:
sim.tick(now)
async_track_time_interval(hass, _tick, timedelta(seconds=TICK_S))
hass.async_create_task(
discovery.async_load_platform(hass, "vacuum", DOMAIN, {}, config)
)
hass.async_create_task(
discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config)
)
return True
+10
View File
@@ -0,0 +1,10 @@
{
"domain": "demo_robot",
"name": "Demo Robot Vacuum",
"codeowners": [],
"dependencies": [],
"documentation": "https://github.com/Matysh/houseplan-card/tree/dev/demo/stand/demo_robot",
"iot_class": "calculated",
"requirements": [],
"version": "1.0.0"
}
+56
View File
@@ -0,0 +1,56 @@
"""The map sensor of the stand simulator — Tasshack-shaped attributes.
`vacuum_position` is a plain dict on purpose: the real Tasshack integration
keeps a Point OBJECT in memory (houseplan's trail recorder handles both), but
a dict is what any template/attribute tooling on the stand can also read.
"""
from __future__ import annotations
from typing import Any
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from . import DOMAIN, ROOMS, RobotSim
async def async_setup_platform(hass: HomeAssistant, config, add_entities, discovery_info=None) -> None:
add_entities([DemoRobotMap(hass.data[DOMAIN])])
class DemoRobotMap(SensorEntity):
_attr_should_poll = False
_attr_name = "Карта робота-пылесоса"
_attr_unique_id = "demo_robot_map"
_attr_icon = "mdi:map"
def __init__(self, sim: RobotSim) -> None:
self._sim = sim
self.entity_id = "sensor.demo_robot_map"
async def async_added_to_hass(self) -> None:
self.async_on_remove(self._sim.add_listener(self.async_write_ha_state))
@property
def native_value(self) -> str:
return self._sim.state
@property
def extra_state_attributes(self) -> dict[str, Any]:
x, y = self._sim.pos
return {
"vacuum_position": {
"x": round(x, 1),
"y": round(y, 1),
"a": round(self._sim.heading, 1),
},
"rooms": {
rid: {
"name": name,
"x0": x0, "y0": y0, "x1": x1, "y1": y1,
"x": (x0 + x1) / 2, "y": (y0 + y1) / 2,
}
for rid, (name, x0, y0, x1, y1) in ROOMS.items()
},
"map_index": 1,
}
+65
View File
@@ -0,0 +1,65 @@
"""The vacuum entity of the stand simulator."""
from __future__ import annotations
from typing import Any
from homeassistant.components.vacuum import (
StateVacuumEntity,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant
from . import DOMAIN, RobotSim
ACTIVITY = {
"docked": VacuumActivity.DOCKED,
"cleaning": VacuumActivity.CLEANING,
"paused": VacuumActivity.PAUSED,
"returning": VacuumActivity.RETURNING,
}
async def async_setup_platform(hass: HomeAssistant, config, add_entities, discovery_info=None) -> None:
add_entities([DemoRobotVacuum(hass.data[DOMAIN])])
class DemoRobotVacuum(StateVacuumEntity):
_attr_should_poll = False
_attr_name = "Робот-пылесос"
_attr_unique_id = "demo_robot_vacuum"
_attr_supported_features = (
VacuumEntityFeature.START
| VacuumEntityFeature.STOP
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.STATE
)
def __init__(self, sim: RobotSim) -> None:
self._sim = sim
self.entity_id = "vacuum.demo_robot"
async def async_added_to_hass(self) -> None:
self.async_on_remove(self._sim.add_listener(self.async_write_ha_state))
@property
def activity(self) -> VacuumActivity:
return ACTIVITY.get(self._sim.state, VacuumActivity.IDLE)
@property
def extra_state_attributes(self) -> dict[str, Any]:
# the card resolves the active map name through the vacuum entity
return {"selected_map": "1"}
async def async_start(self) -> None:
self._sim.start()
async def async_pause(self) -> None:
self._sim.pause()
async def async_stop(self, **kwargs: Any) -> None:
self._sim.stop()
async def async_return_to_base(self, **kwargs: Any) -> None:
self._sim.return_to_base()
+13 -13
View File
File diff suppressed because one or more lines are too long
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## v1.54.1 — 2026-07-31
Patch release: everything the adversarial audit of v1.54.0 found
(HP-1540-01..06), each fix pinned by a regression test that fails on the
old code.
- **Fix: the first calibration of a freshly discovered vacuum silently did
nothing.** Until the device dialog was saved once, the robot had no config
marker — yet the «Живая позиция» section was fully interactive, and every
handler quietly bailed out while auto-calibration still announced success.
Any vacuum edit now materialises the marker itself, and the success toast
only appears after the matrix has verifiably landed in the config.
- **Fix: a robot whose first map is `map_index: 0` lost its server-side
trail.** The backend picked the map id by truthiness and dropped the zero,
so the recorded run was stored under a key the card never looked up. Both
sides now share one explicit rule — the first value that exists wins, and
zero is a value.
- **Fix: one robot on two floors recorded history for the last floor only.**
The recorder kept a single marker per source entity, so the second
placement silently evicted the first. Every marker fed by a source now
gets its own copy of the run.
- **Fix: auto-calibration ignored plans drawn with rectangle rooms.** The
room matcher only accepted polygon outlines and then blamed the room
names. Legacy rectangles count like everywhere else in the card.
- **Fix: overlapping recorder refreshes leaked state subscriptions.** Two
config saves racing each other could both subscribe and strand one
callback until restart; refreshes are serialised now and teardown wins
over any refresh still in flight.
- The «no rooms» / «no match» toasts and docs/VACUUM.md no longer send you
to the three-point calibration that no longer exists — they point at
«Подогнать вручную», which does.
## v1.54.0 — 2026-07-31
### Live robot vacuums
+33
View File
@@ -6,6 +6,39 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.54.1 — 2026-07-31
Патч-релиз: всё, что нашёл adversarial-аудит v1.54.0 (HP-1540-01..06),
каждый фикс закреплён регрессией, падающей на старом коде.
- **Исправлено: первая калибровка только что обнаруженного пылесоса молча
ничего не делала.** Пока диалог устройства ни разу не сохранён, у робота
нет маркера в конфиге — а раздел «Живая позиция» уже полностью
интерактивен: каждый обработчик тихо выходил, но автокалибровка всё равно
объявляла успех. Теперь любая правка пылесоса сама создаёт маркер, а тост
успеха появляется только после того, как матрица действительно легла в
конфиг.
- **Исправлено: робот с первой картой `map_index: 0` терял серверный
след.** Бэкенд выбирал id карты по «истинности» и выбрасывал ноль, так
что записанная уборка хранилась под ключом, который карточка не искала.
Теперь обе стороны живут по одному явному правилу: побеждает первое
существующее значение, и ноль — значение.
- **Исправлено: один робот на двух этажах писал историю только для
последнего этажа.** Рекордер держал по одному маркеру на источник, и
второе размещение молча вытесняло первое. Теперь каждый маркер источника
получает свою копию уборки.
- **Исправлено: автокалибровка не видела планы из прямоугольных комнат.**
Сопоставление принимало только полигоны, а потом сваливало вину на имена
комнат. Старые прямоугольники считаются, как и во всей остальной
карточке.
- **Исправлено: пересекающиеся обновления рекордера текли подписками.**
Два одновременных сохранения конфига могли подписаться оба и бросить один
callback до перезапуска; обновления теперь сериализованы, а teardown
выигрывает у любого ещё летящего обновления.
- Тосты «нет комнат» / «имена не совпали» и docs/VACUUM.md больше не
отправляют к несуществующей калибровке по трём точкам — они ведут к
«Подогнать вручную», которая существует.
## v1.54.0 — 2026-07-31
### Роботы-пылесосы вживую
+2 -2
View File
@@ -15,7 +15,7 @@
| Item | State |
|---|---|
| Version | **v1.54.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.54.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
@@ -24,7 +24,7 @@
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Host: `ssh -i ~/.ssh/hp_stand hp@135.106.166.146`; layout, seeds and gotchas in the memory note `houseplan-demo-stand` |
| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Host: `ssh -i ~/.ssh/hp_stand hp@135.106.166.146`; layout, seeds and gotchas in the memory note `houseplan-demo-stand`. Since 2026-07-31 the stand covers most of the manual checklist: a scripted robot vacuum (`demo/stand/demo_robot` — Tasshack-shaped map sensor, serpentine run, pre-solved calibration, seeded server trail), Zigbee-style LQI template sensors, hand/auto-triggered leak+smoke alarms, an hvac_action climate marker and working script/scene/automation targets for tap-run. The stand-specific how-to-check guide is **docs/TESTING-DEMO.md** |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
+366
View File
@@ -0,0 +1,366 @@
# Чек-лист ручного тестирования на публичном демо-стенде
> **Стенд:** https://demo.houseplan.tech — вход **demo / demo** (админ).
> Dev-версия: https://dev.houseplan.tech (basic auth `hp` / `houseplan-dev-2026`, дальше тот же demo/demo).
>
> **Стенд сбрасывается каждый час** к эталонному состоянию — это фича: ломайте
> смело, удаляйте комнаты, заливайте планы, через час всё вернётся. Из этого же
> следует: долгоживущие проверки («файл исчез через сутки», «пережило рестарт»)
> на стенде не живут.
>
> Источник истины — [TESTING.md](TESTING.md): формулировки пунктов и их
> `[auto:…]`-маркеры смотреть там. Здесь каждый пункт сокращён до сути и дополнен
> строкой «Стенд: …» — где и как ткнуть именно на демо-стенде.
>
> **Демо-дом:** дашборд «House plan» (виды: План / Киоск / Схема), пространства
> «1 этаж» (Гостиная, Кухня, Спальня, Кабинет, Прихожая) и «Двор». Ключевые
> демо-устройства: робот-пылесос (Прихожая, симулятор `demo_robot`), датчик
> протечки (Кухня, включается помощником `input_boolean.demo_leak`), датчик дыма
> (Спальня, сам срабатывает каждые 10 минут на 30 с), «Тёплый пол» (Кабинет,
> climate.heatpump, hvac_action=heating), 4 «Zigbee»-термометра + дверной датчик
> с атрибутом `linkquality` (отличный/средний/слабый сигнал по комнатам),
> маркер «Открыть шторы» (tap → запуск скрипта с подтверждением).
## Вводные пункты (v1.43.xv1.44.x)
- Smoke-harness сам по себе — Стенд: не проверяется (это CI), только локально.
- Шестерёнка «⚙ Room» на каждой карточке комнаты в редакторе плана — Стенд: открыть «Редактор плана», убедиться что у всех 5 комнат есть кнопка ⚙ фиксированного размера, открывает настройки комнаты.
- Метрики 0.75 от имени комнаты — Стенд: включить метрики в настройках пространства (⚙ у «1 этаж») и посмотреть подпись под именем комнаты.
- Тултип не появляется после touch — Стенд: открыть с планшета/телефона, потыкать комнаты и иконки — hover-тултипов быть не должно.
- Флаг «источник света» у выключателя — Стенд: у «Decorative Lights» (Гостиная) в диалоге устройства включить «Это источник света» — при включении розетка светится в glow-заливке.
- Карточка устройства: управляемые сущности сверху, ≥30 px, замки не переключаются — Стенд: тапнуть «Котёл» или медиаплеер; для замка — открыть карточку двери (бейдж замка на двери в Прихожей).
- Инвариант замков на всех путях — Стенд: дверь в Прихожей: Unlock просит подтверждение, Lock — нет; иконка замка никогда не тогглится тапом.
- Транзакционная миграция вложений — Стенд: прикрепить PDF к устройству, перепривязать маркер на другую сущность — файл открывается после перепривязки.
- Планы и PDF в реальном браузере — Стенд: залить план в настройках пространства (любой PNG/SVG), DevTools → Network: `/api/houseplan/content/...` = 200 с `authSig`, тот же URL без authSig = 401.
- Единая политика прав (may_write) — Стенд: не проверяется напрямую (нужен не-админ; на стенде один пользователь demo-админ) — покрыто tests_backend.
- NaN/Infinity и лимиты openings — Стенд: не проверяется руками (нужен ручной WS-вызов) — tests_backend.
- Захват указателя при drag, декор не утаскивается за границы — Стенд: «Редактор фона», нарисовать фигуру и попытаться утащить её далеко за план.
- Скрытые термометры питают карточку комнаты — Стенд: скрыть «Термометр в гостиной» (галка «скрыть с плана») — температура в карточке Гостиной остаётся.
- Тултип комнаты без «открыть area» — Стенд: навести на комнату с десктопа: имя + температура/сигнал, никакого «открыть».
## Матрица окружений ★
- Chrome/Edge desktop — Стенд: да, просто открыть.
- Firefox desktop — Стенд: да, со своей машины.
- Safari (macOS) — Стенд: да, при наличии Mac.
- HA Companion Android (холодный старт!) — Стенд: да: добавить `https://demo.houseplan.tech` как сервер в приложении (demo/demo), убить приложение, открыть заново на дашборде House plan.
- HA Companion iOS — Стенд: аналогично Android.
- Планшет-киоск — Стенд: частично: открыть вид «Киоск» браузером планшета во весь экран; реальный настенный планшет — только на железе.
- Телефон портрет ≤400 px — Стенд: да, или DevTools device mode.
- Тёмная/светлая тема — Стенд: профиль demo → тема; проверить бейджи/диалоги/контраст.
- RU/EN локали — Стенд: сменить язык в профиле пользователя demo; опцию `language:` карточки можно проверить, отредактировав вид дашборда.
## Установка / обновление / удаление
- Свежая установка через HACS — Стенд: не проверяется (интеграция ставится файлами, HACS нет) — локально.
- `single_config_entry` — Стенд: Настройки → Устройства и службы → Добавить интеграцию → «House Plan» недоступна второй раз.
- Обновление через HACS (`?v=` после рестарта) — Стенд: не проверяется (нет HACS; версию ресурса видно в DevTools: `houseplan-card.js?v=1.54.0`).
- YAML-mode Lovelace / extra_module_url — Стенд: частично: стенд как раз грузит карточку через `extra_module_url` (см. configuration.yaml) — если дашборд открылся, механизм жив.
- Удаление entry → ресурс исчезает, `.storage/houseplan.*` живёт — Стенд: можно (Настройки → Интеграции → House Plan → удалить), стенд сам восстановится при сбросе; переустановку файлами проверить нельзя.
- Diagnostics download + REDACTED — Стенд: Настройки → Интеграции → House Plan → «Загрузить диагностику», убедиться что name/link/description/pdfs = `**REDACTED**`.
## Режимы ★
- Всегда открывается во View — Стенд: войти в редактор, перезагрузить страницу — карточка снова во View.
- View: только pan/zoom/tap/long-press, иконки не таскаются — Стенд: попробовать утащить любую иконку в режиме просмотра — должен идти pan.
- Шапка View: табы пространств, счётчик, zoom, табы редакторов, ⚙ — Стенд: глазами в шапке «1 этаж | Двор».
- Plan: тулбар разметки, иконки скрыты, оранжевая рамка — Стенд: вкладка «Редактор плана».
- Devices: drag иконок, клик открывает редактор маркера — Стенд: вкладка «Редактор устройств».
- Табы скрыты для не-админа — Стенд: не проверяется (второго, не-админского, пользователя на стенде нет).
- Открывания во View инертны (дверь/окно — чистый рисунок) — Стенд: во View потыкать дверь в Прихожей мимо бейджа замка — ничего не происходит.
- Бейдж замка — единственное исключение — Стенд: клик по замочку на двери открывает карточку двери; в редакторе плана он инертен.
- Курсор pointer во View, grab в Devices — Стенд: сравнить курсор над иконкой в двух режимах.
- В Plan открывание интерактивно (drag по стенам, 3 px порог) — Стенд: редактор плана → потаскать дверь вдоль стены, одиночный тап открывает свойства.
## Онбординг ★
Все пункты онбординга требуют ПУСТОЙ конфиг. На стенде: Настройки → Устройства и службы → House Plan можно удалить и добавить интеграцию заново, либо стереть все пространства руками — и пройти онбординг; сброс через час всё вернёт.
- Мастер импорта этажей — Стенд: у демо-дома этажи HA не заведены, мастер предложит «Start from scratch»; полный сценарий с этажами — локально (или создать этажи в Настройки → Зоны и этажи, затем удалить пространства карточки).
- Остальные пункты мастера (снять галки → Create disabled, очередь диалогов, Skip/Cancel, авто-разметка с тостом, пустое состояние) — Стенд: в рамках того же прогона после удаления пространств.
## Пространства ★
- Создание с картинкой (SVG/PNG/JPG/WebP), чёткость на зуме — Стенд: «+» в шапке → загрузить свой файл (демо-планы нарисованы без подложки — тем интереснее добавить).
- >8 MB → читаемый тост — Стенд: залить большой файл.
- «Без изображения — нарисую сам», ориентация — Стенд: «+» → режим рисования.
- Белый холст в draw-режиме — Стенд: оба демо-пространства такие: борта/имена читаемы на белом.
- Rename / замена картинки / image→draw отвязывает план — Стенд: ⚙ пространства.
- Удаление пространства — Стенд: удалить «Двор», убедиться что «1 этаж» не тронут; сброс вернёт.
- Настройки отображения: борта, имена, цвет+прозрачность, заливки — Стенд: ⚙ «1 этаж» — там всё живое, предпросмотр после сохранения.
- Заливка «zigbee»: красный→зелёный по среднему LQI — Стенд: ⚙ «1 этаж» → заливка «Сигнал Zigbee»: Гостиная зелёная (LQI ~206), Кабинет красный (~24), Двор не залит (нет zigbee).
- Заливка «lights» — Стенд: включить, зажечь свет в комнате с плана — комната жёлтая, потушить — серая.
- Заливка «temperature» + границы комфорта — Стенд: включить: Спальня синяя (~18.7°), Кухня жёлтая (~26°), Гостиная зелёная (~22.8°); границы 20–25 редактируются тут же.
- Радиогруппа заливок без легенды — Стенд: глазами в ⚙ пространства.
- Hover затемняет текущую заливку — Стенд: навести на комнату при любой заливке.
- Тултип комнаты со средней температурой — Стенд: навести на Кухню — «≈26°».
- Средняя температура только от термометров — Стенд: рядом с «Тёплым полом» (Кабинет) убедиться, что его current_temperature не ломает среднее (в Кабинете свой термометр).
- Ширина диалога 500 px, компактные поля — Стенд: глазами в ⚙ пространства.
- Общие настройки (⚙ в шапке): цвета по режимам + Reset — Стенд: покрутить цвета glow/temp/lqi, Reset.
- Пользовательские цвета и на статичной карточке — Стенд: сменить цвет заливки, открыть вид «Схема».
- Градиент LQI между настроенными цветами — Стенд: сменить цвета weak/strong в ⚙ и посмотреть заливку zigbee.
- Per-space «Показывать LQI» — Стенд: выключить в ⚙ «1 этаж» — бейджи цифр у термометров и строка сигнала в тултипах пропадают только там.
- Центровка бейджа и глифа — Стенд: глазами на любой иконке при максимальном зуме.
- Hover-подсветка при кастомных бортах — Стенд: покрасить борта и навести.
- Настройки живут на сервере — Стенд: сменить цвет, открыть стенд в приватном окне/другом браузере — цвет тот же.
## Редактор разметки комнат ★
Всё проверяется в «Редакторе плана» на «1 этаже» (или создайте своё пространство — сброс простит всё).
- Сетка, снап, общие стены — Стенд: дорисовать комнату впритык к существующей.
- Линейка сегмента (метры; cm/cell из настроек) — Стенд: начать рисовать контур, смотреть на бегущую длину.
- Незакрытый контур не сохраняется — Стенд: начать контур, уйти из разметки, вернуться — линий нет.
- Удаление комнаты бережёт общие стены — Стенд: удалить Прихожую (⚙ комнаты → удалить), стены Кабинета/Спальни на месте; сброс вернёт.
- Нет инструмента «Erase» в тулбаре — Стенд: глазами.
- Пересечения запрещены (клик внутри чужой комнаты → тост) — Стенд: попытаться начать контур внутри Гостиной.
- Контур вокруг существующей комнаты отклоняется — Стенд: обвести Кабинет снаружи.
- Merge двух смежных / отказ для несмежных — Стенд: слить Кабинет+Прихожую, тост при попытке слить Кабинет+Кухню; сброс вернёт.
- Split: две точки на стенах, бОльшая часть сохраняет имя — Стенд: разрезать Гостиную; Cancel в диалоге — комната цела.
- Split с точкой вне стены / вдоль стены → тост — Стенд: там же.
- Split на не-грид комнатах (снап к стене) — Стенд: комнаты демо на гриде; полноценно — локально, но снап виден: клик чуть мимо стены цепляется.
- Промах далеко от стены → тост — Стенд: кликнуть в центр комнаты.
- Esc/Ctrl+Z снимает точку, Reset чистит — Стенд: во время рисования.
- Замыкание (≥4 точек) → диалог комнаты — Стенд: нарисовать комнату во «Дворе».
- В диалоге только свободные зоны; выбор зоны подставляет имя — Стенд: все 6 зон заняты — список пуст: создать комнату «без зоны».
- «Без зоны» требует имя, `area: null` — Стенд: там же.
- Cancel диалога возвращает контур — Стенд: там же.
- Сохранение с зоной раскладывает устройства зоны — Стенд: не проверяется как есть (у demo-сущностей нет device registry — автораскладка по зонам не работает, маркеры на стенде явные); полноценно — локально/на даче.
- Erase удаляет линию, удаление комнаты — полигон — Стенд: инструментом «ластик линий» в тулбаре (клик по своей линии).
- Иконки скрыты в разметке — Стенд: глазами.
## Устройства на плане ★
- Авто-устройства только в комнатах своей зоны — Стенд: не проверяется (см. выше: без device registry автонаполнение молчит; все маркеры стенда — явные entity-маркеры).
- Фильтрация мостов/групп/сцен, 👁 «показать все» — Стенд: «Редактор устройств» → 👁: появляются скрытые сущности (сцены/скрипты в списке добавления отфильтрованы).
- Нумерация дубликатов имён — Стенд: добавить второй маркер той же сущности не даст (занята) — проверяется локально.
- Группы света сворачивают лампы — Стенд: не проверяется (в демо нет light-групп с device registry).
- Drag иконок, снап к сетке, позиция per-space — Стенд: редактор устройств: перетащить иконку, F5 — позиция жива; у «Двора» своя раскладка.
- ↺ сброс автолейаута — Стенд: не проверяется: кнопка Reset убрана в v1.33.2 (пункт в TESTING.md исторический).
- Бейдж температуры, LQI с цветом — Стенд: под «Zigbee»-термометрами цифра LQI (цвет от красного к зелёному: Кабинет красный, Гостиная зелёный), температура на бейдже.
- Живые состояния: свет жёлтый, открытая штора оранжевая, unavailable блёклый — Стенд: пощёлкать свет; открыть штору («Открыть шторы» или карточка); unavailable в демо нет — эмулировать нечем, но у робота после паузы всё живое.
- Морфинг иконок по состоянию — Стенд: дверь/замок: открыть lock.front_door из карточки двери — иконка меняется; гаражные ворота во «Дворе».
- «Значение вместо иконки» — Стенд: у термометра в диалоге выбрать display «значение» — на плане цифра.
- RGB только в glow/ripple — Стенд: включить RGBWW-лампу в Гостиной цветом: бейдж жёлтый стандартный, цвет только в пятне glow.
- Красная пульсация тревоги — Стенд: включить `input_boolean.demo_leak` (Настройки → Устройства и службы → Помощники) — датчик на Кухне пульсирует красным; дым в Спальне сам вспыхивает каждые 10 минут на 30 с.
- Стоимость рендера (geometry once) — Стенд: не проверяется руками — smoke_render_perf.
- Тап vs drag открывания — Стенд: редактор плана, тап по двери = свойства, drag на место = ничего не записано.
- Вогнутый остров — Стенд: нарисовать U-комнату во «Дворе» и остров внутри.
- Backend hardening (B2B5) — Стенд: не проверяется руками — unit/backend.
- Гонка сохранений (markup vs Save) — Стенд: два окна: в одном правка разметки, в другом Save диалога — правка выживает; остальное — тесты.
- Niche split — Стенд: разрез, начинающийся и кончающийся на одной стене — вырезается ниша.
- Контент только через подписанные URL — Стенд: залить план, скопировать URL картинки из DevTools, убрать authSig → 401; старые пути `/houseplan_files/plans` → 404.
- Каждая опция редактора сохраняема — Стенд: перебрать display/tap/fill в диалогах — ошибок валидации быть не должно.
- Кнопка настроек комнаты в геометрическом центре — Стенд: редактор плана, глазами.
- Один индикатор всегда (лампа жёлтая в plan-редакторе glow-пространства) — Стенд: включить свет, открыть редактор плана — бейдж жёлтый (glow-слоя там нет).
- Size/angle parity — Стенд: задать маркеру size 3 / angle 37 в диалоге → и на «Схеме» тоже ×3 и повёрнут.
- Tap запускает автоматизацию — Стенд: тап по «Открыть шторы» (Гостиная) → диалог подтверждения → штора едет; в диалоге маркера видно «Run» + поиск по automation/script/scene (есть скрипт «Открыть шторы», автоматизация «Вечерний сценарий», сцены «Кино», «Вечер в гостиной»); Esc = не выполнять.
- Бейджи light-source в glow — Стенд: заливка glow (по умолчанию): у горящей лампы бейдж стандартный (индикатор — пятно), у розетки-«источника света» жёлтый.
- Множитель размера иконки растит глиф — Стенд: size 3 у любого маркера.
- Auto-grid parity, ripple ghost, hidden LQI parity, ghost без цифр — Стенд: скрыть «Zigbee»-термометр: LQI комнаты не меняется, «Схема» совпадает с планом; «Show hidden» в редакторе устройств показывает синий пунктирный призрак без значений.
- Флаг «скрыть с плана» — Стенд: галка в любом диалоге устройства; скрытый исчезает из всех режимов и счётчика, но LQI комнаты держит.
- «Жёлтый = работает» — Стенд: «Тёплый пол» в Кабинете: hvac_action=heating → жёлтая подложка. (Выключить нагрев можно, поставив target ниже 20° в карточке — бейдж гаснет.)
- Жесты редактора на touch — Стенд: с телефона в редакторе плана: pinch = zoom, палец = pan, отпускание не ставит точку.
- Legacy-геометрия, положительные размеры, границы 1e100, no-op repair — Стенд: не проверяется (нужен подложенный битый store, у тестировщика нет доступа к .storage) — unit/backend.
- Карточка под другим контентом дашборда — Стенд: вид «Схема» — карточка стоит после других карточек и не схлопывается.
- Stranded migration repair / geometry repair — Стенд: не проверяется руками (WS-команда) — backend.
- Редакторы видят весь холст — Стенд: «Двор» (одна комната): в редакторе плана виден весь белый квадрат, во View — контент-фит.
- Save ждёт пропорции выбранного плана — Стенд: прикрепить «уже загруженный» план и мгновенно Save.
- Зум ниже фита (пол 0.4x) — Стенд: минусовать зум ниже 100%.
- Migration crash / parallel upload quota — Стенд: не проверяется (kill HA между записями невозможен) — backend.
- Зум открывается на контенте — Стенд: «Двор» открывается комнатой на весь экран.
- Удаление используемого плана запрещено — Стенд: залить план, в списке «уже загруженные» его delete неактивен.
- Квота загрузок — Стенд: можно доливать планы до квоты — тост; сброс почистит.
- Миграция на квадратный холст — Стенд: не проверяется (миграция уже прошла; нужен старый store) — unit.
- Картинка плана по центру с полями — Стенд: залить широкую картинку — поля сверху/снизу, без растяжения.
- Re-attach отвязанного плана — Стенд: ⚙ пространства → «рисование» → Save → reload → «Уже загруженные» → прикрепить обратно.
- Detach хранит файл — Стенд: там же: после отвязки файл в списке; замена плана убирает старый сразу.
- Перепривязка не ест мануалы — Стенд: маркер с 2 PDF перепривязать на другую сущность — оба открываются.
- Ничего не копится на idle — Стенд: не проверяется полноценно (ежечасный сброс раньше суточного sweep) — backend.
- Drag выигрывает у удалённого move — Стенд: два окна, тащить разные иконки одновременно.
- Одноимённые загрузки из двух вкладок — Стенд: можно, два вложения обязаны выжить; тонкости — unit.
- Временные файлы не выживают — Стенд: не проверяется (нужен доступ к ФС) — backend.
- Две полные карточки согласны в позициях — Стенд: два окна: drag в одном виден во втором без reload.
- Загруженный SVG инертен (script не исполняется) — Стенд: залить SVG со `<script>alert(1)</script>`, открыть его подписанный URL в новой вкладке — alert нет, план рендерится.
- Вложение не перезаписывает другое — Стенд: приложить manual.pdf к двум маркерам — два независимых файла.
- Два быстрых эдита выживают — Стенд: быстро сделать две правки подряд (throttle сети в DevTools) — обе на месте.
- Открытые границы следуют геометрии — Стенд: открыть границу (инструмент «Открыть границу») между Гостиной и Кухней, потом утащить вершину стены — разрыв едет вместе.
- Внутренние лимиты (max+1) — Стенд: не проверяется руками — unit/backend.
- Большие файлы стримятся (50 MB) — Стенд: можно залить 50 MB мануал и скачать дважды; память HA снаружи не видна — полноценно только локально.
- Паритет статичной карточки (fill none) — Стенд: у комнаты поставить fill «нет» и сравнить со «Схемой».
- Layout доходит до статичной карточки — Стенд: drag иконки на «Плане» → на «Схеме» (другая вкладка того же дашборда) двигается.
- Repair-issue смертны — Стенд: удалить пространство с битым планом… проще: удалить пространство — связанные предупреждения из Настройки → Ремонт исчезают.
- Не-подписанный путь не зацикливает запросы — Стенд: не проверяется руками — unit.
- Подписи не усиливаются на плохой сети — Стенд: DevTools throttling + Network: один sign-запрос на URL.
- Битая папка планов не валит save — Стенд: не проверяется (нужна ФС) — backend.
- Два редактора, один план — Стенд: две вкладки, залить подложку в каждой по очереди — служится последняя, файлы целы.
- Фон статичной карточки — Стенд: залить план «1 этажу», открыть «Схему»: картинка есть, в Network нет неподписанных запросов.
- Отклонённый save не трогает план — Стенд: две вкладки: обе меняют план, вторая получает конфликт — старый план жив.
- Кэш подписей на 200+ URL — Стенд: не воспроизводимо (нет 200 файлов) — smoke_sign_cap.
- Климат не дорожает с комнатами — Стенд: не проверяется руками — smoke_climate_once.
- Upload при конкурентной ревизии — Стенд: две вкладки: приложить фон при открытом втором редакторе.
- Подписанный фон плана — Стенд: см. выше: authSig в Network, нет failed login в журнале HA (Настройки → Система → Журналы).
- Зомби-диалоги — Стенд: Esc во время сохранения при throttling — диалог закрыт, тост об ошибке живёт.
- Нет hover-тултипов на touch — Стенд: с телефона.
- Слайдеры шрифтов (50–300%) — Стенд: ⚙ пространства + ⚙ комнаты: живой образец в диалоге следует слайдеру.
- Room settings tier 3 (имя, зона, fill override, источник T°/влажности) — Стенд: ⚙ любой комнаты; источник температуры прописан у комнат демо через зонные термометры — переопределить и посмотреть карточку.
- PDF переживает перепривязку — Стенд: см. «перепривязка не ест мануалы».
- Киоск-режим — Стенд: вид «Киоск» дашборда: шапки нет, свайп листает пространства с точками-индикатором, дабл-тап сброс зума, лонг-пресс 3 с на пустом месте — поповер размеров; авто-листание cycle — задано в конфиге вида; реальный планшет — руками на своём.
- Иконка-ссылка зоны у имени комнаты — Стенд: во View у имён комнат есть ↗ (зоны привязаны) — клик ведёт в зону HA; в редакторах иконки нет.
- Смарт-гайды — Стенд: тащить иконку в редакторе устройств — пунктирные направляющие от соседей, бейдж длина·угол.
- Свет тогглится по умолчанию — Стенд: тап по любой лампе во View = вкл/выкл без настройки.
- Производные стены тоже режутся (.seg) — Стенд: открыть границу и смотреть в редакторе плана: сплошной линии под пунктиром нет.
- Пунктир открытых границ в разметке — Стенд: там же.
- Nav-персистентность (пространство+режим) — Стенд: уйти в «Двор», закрыть вкладку, открыть — снова «Двор»; `#space=floor1` в URL сильнее.
- Чистка tap-действий + правый клик — Стенд: ПКМ по иконке во View = more-info HA.
- Редизайн секции биндинга — Стенд: «+» в редакторе устройств: радио Виртуальное/Из списка HA, «показать сущности», поиск в дропдауне.
- Настоящий пунктир открытой границы — Стенд: см. открытые границы.
- Hover открытия стены — Стенд: инструмент «Открыть границу»: у общей стены курсор pointer + янтарный пунктир-превью; на уже открытой — красное превью.
- Открытые границы: свет течёт по зоне — Стенд: открыть границу Гостиная-Кухня при glow: свет горящей лампы заливает обе.
- Секторы дверей не темнят комнату — Стенд: glow: открыть дверь Прихожей, смотреть сектор света.
- Per-source радиус glow — Стенд: поле «Радиус свечения» в диалоге лампы.
- Скрытая light-primary — Стенд: не проверяется (нет скрытых-в-реестре ламп в демо) — локально.
- Marker controls (тогл связки ламп) — Стенд: у «Котла»/виртуального маркера прописать «Управляет источниками света» + Toggle и тапнуть: обе лампы Гостиной разом.
- Glow fill в целом — Стенд: заливка по умолчанию на «1 этаже»: зажечь RGBWW-лампу цветом — цветное пятно, радиус в ⚙.
- Островные комнаты — Стенд: нарисовать остров во «Дворе».
- Иконка не прыгает при перепривязке — Стенд: перепривязать маркер: позиция та же; смена комнаты в том же пространстве тоже не двигает.
- Плейсхолдер автоиконки — Стенд: диалог без явной иконки: «Auto: mdi:…» с превью.
- Нет кнопки Reset в тулбаре Devices — Стенд: глазами: add / 👁 / ⬡.
- Сетка во всех редакторах + fade декора — Стенд: редактор фона: комнаты/иконки блёкнут до 35%, декор яркий.
- Редактор фона (фигуры/текст/ластик) — Стенд: порисовать во «Дворе»: прямоугольник-«газон», текст.
- Превью открывания (призрак 90 см) — Стенд: инструмент «Открывание», водить у стены.
- Split-polyline, курсоры, Esc-цепочка — Стенд: split Гостиной ломаной через середину.
- Подсветка выбранного в merge/split — Стенд: янтарная обводка первой комнаты.
- Карточка vs инструмент — Стенд: в редакторе плана тащить карточку комнаты при активном Draw — точек не ставится.
- Карточки комнат (метрики, drag, resize) — Стенд: включить метрики в ⚙ пространства; в редакторе плана карточку можно тащить и растягивать за углы.
- Esc закрывает диалоги по одному — Стенд: наоткрывать вложенных диалогов.
- Общая ⚙ видна во всех режимах — Стенд: глазами.
- Табы редакторов (2 шт + X) — Стенд: глазами.
- ⚙ пространства в каждом режиме — Стенд: глазами.
- Lock action (Unlock красный + подтверждение) — Стенд: карточка двери в Прихожей.
- Флаг нового устройства (красная точка) — Стенд: включить незанятую сущность… проще: не проверяется напрямую (набор сущностей демо фиксирован; точка была бы у новых) — локально.
- Ноль устройств в HA — Стенд: не проверяется (в демо 100+ сущностей) — smoke_new_device.
## Диалог устройства (маркеры) ★
- Все поля сохраняются — Стенд: любой маркер: имя/иконка/модель/ссылка/описание, F5.
- Перепривязка с поиском, занятые исключены — Стенд: дропдаун биндинга: уже размещённые сущности в списке отсутствуют.
- Виртуальное устройство: имя+комната, пунктир — Стенд: «Котёл» — образец; создать своё.
- Комнаты без зоны в списке комнат — Стенд: нарисовать комнату «без зоны» и поместить туда маркер.
- Room override центрирует — Стенд: сменить комнату маркера.
- Tap-action override — Стенд: перебрать все варианты у одного маркера.
- PDF: ok / >50 MB / .exe — Стенд: приложить файлы к маркеру; большие и .exe должны дать читаемую ошибку.
- `javascript:` в ссылке не кликабелен — Стенд: вписать `javascript:alert(1)` в поле ссылки — в карточке не ссылка.
- Remove: авто → скрытый маркер, виртуальное → насовсем — Стенд: удалить «Котёл» (вернётся сбросом) и любой entity-маркер.
## Правила иконок ★
- ⬡ открывает редактор с текущими правилами — Стенд: редактор устройств → ⬡.
- Тест-поле, добавление/удаление/порядок — Стенд: там же, ввести имя «Термометр…».
- Битый regex краснеет и пропускается — Стенд: вписать `([`.
- Reset к дефолтам — Стенд: там же.
- Правила переиконивают существующие; per-device иконка сильнее; замки всегда mdi:lock — Стенд: правило `.*свет.* → mdi:star` и смотреть план.
- Живут после reload и во втором браузере — Стенд: приватное окно.
## Tap-действия и жесты ★
- Тап → карточка, `toggle` тогглит только свет/розетки/вентиляторы — Стенд: пощёлкать лампы (toggle по умолчанию), медиаплеер (карточка).
- Замки не тогглятся никогда; шторы только с явным override — Стенд: замок двери; штора Гостиной: выставить «Toggle» в диалоге — начнёт тогглиться, гаражные ворота — нет.
- Long-press всегда карточка — Стенд: зажать лампу на 600 мс — карточка вместо toggle.
- Drag >3 px отменяет tap/long-press — Стенд: потащить палец с иконки.
- pointercancel без фантомов — Стенд: с телефона: начать тап и свернуть браузер.
## Zoom / pan / подписи
- Wheel у курсора, +/−, fit, % — Стенд: глазами.
- Pinch + двухпальцевый pan — Стенд: с телефона.
- Зум живёт per-space в localStorage — Стенд: зазумить «1 этаж», перейти в «Двор» и назад, F5.
- Resize окна перефитит — Стенд: свернуть боковую панель HA.
- Подписи комнат таскаются (rl_*) — Стенд: в редакторе плана утащить имя комнаты, F5.
- Читаемость подписей на мин/макс зуме — Стенд: глазами на белом плане.
## Мультиклиент ★
- Две вкладки: drag виден без reload — Стенд: два окна рядом.
- Конфликт конфига → тост, авторесинк, retry — Стенд: два окна: одновременно править настройки пространства.
- Точечные layout-апдейты не трутся — Стенд: тащить разные иконки в двух окнах.
- `admin_only` для не-админа — Стенд: не проверяется (нет второго пользователя) — backend.
## Крайние случаи
- HA без устройств/зон — Стенд: не проверяется (демо полон сущностей) — локально.
- Пространство без комнат — Стенд: создать пустое, видна подсказка разметки.
- Комната без зоны + борта — Стенд: нарисовать, кликнуть — ничего.
- Нет zigbee вовсе — Стенд: «Двор»: заливка lqi оставляет двор незалитым, бейджей нет.
- 100+ устройств в одном пространстве — Стенд: не проверяется (в «1 этаже» ~30 маркеров) — локальная синтетика.
- Длинные имена — Стенд: переименовать маркер в очень длинное имя.
- HTML/эмодзи в именах — Стенд: назвать маркер `<b>xss</b>🚿` — текст, не разметка.
- План удалён с диска → Repairs — Стенд: не проверяется (нет доступа к ФС) — backend.
- Битый houseplan.config — Стенд: не проверяется (нет доступа к .storage) — backend.
- Рестарт HA при открытом диалоге — Стенд: Настройки → Система → Перезапустить (админ может!), диалог держать открытым; после рестарта save даст чистую ошибку. Помнить: рестарт контейнера = сброс, но сам HA-рестарт изнутри сброс не триггерит.
- Legacy layout v1 — Стенд: не проверяется — unit.
- Киоск холодный старт в приложении — Стенд: Companion app + вид «Киоск», убить и открыть.
## Релизные регрессии
- Ноль ошибок консоли — Стенд: DevTools console на дашборде, в разметке, диалогах, зуме.
- Ноль houseplan-ошибок в логе HA — Стенд: Настройки → Система → Журналы, фильтр «houseplan».
- npm test / pytest / CI — Стенд: не про стенд — CI.
- Скриншоты README — Стенд: не про стенд.
## houseplan-space-card (вид «Схема»)
- Рендер идентичен плану, без шапки — Стенд: вид «Схема» дашборда.
- Полностью неинтерактивна — Стенд: покликать по «Схеме» — ничего, даже тултипов.
- Кнопка-футер открывает полную карточку на этом пространстве — Стенд: клик по кнопке под «Схемой».
- Несколько с разными space — Стенд: «Схема» содержит обе (1 этаж и Двор) — один общий WS-запрос (Network).
- Неизвестный space → аккуратная ошибка — Стенд: отредактировать вид, вписать space: nope.
- show_button: false — Стенд: правкой YAML вида.
- #space= диплинк — Стенд: открыть `…/plan#space=yard`.
## Presence ripples / per-device иконка
- «Только пульсация»: бейдж исчезает, кольца при on — Стенд: у датчика движения «Movement Backyard» (Кухня) поставить display «пульсация» — он периодически on/off сам.
- «Иконка + пульсация» — Стенд: там же.
- Цвет и размер ×2..×8 — Стенд: там же.
- unavailable останавливает пульс — Стенд: не проверяется (нет unavailable-сущностей в демо) — smoke.
- Размер ×0.5..×3 и поворот; бейджи масштабируются — Стенд: любой маркер.
- Риплы при выключенных live-состояниях — Стенд: выключить «живые состояния» в конфиге карточки (правка вида) — риплы живут.
- reduce motion — Стенд: включить в ОС тестера.
## Двери и окна
Дверь с замком (Прихожая, lock.front_door) и окно с датчиком (Кухня, contact = «Basement Floor Wet») уже стоят.
- Клик мимо стены → тост; у стены → диалог — Стенд: инструмент «Открывание» в разметке.
- Дверь: косяки+полотно+дуга, длина в см — Стенд: глазами у входной двери.
- Контакт: open → створка + дуга акцентом — Стенд: датчик окна Кухни переключается сам (это «мокрый пол» из демо — он мигает); либо повесить contact на `binary_sensor.demo_leak` и дёргать помощник.
- Unavailable → статичный дефолт — Стенд: не проверяется (нет unavailable) — локально.
- Замок: зелёный/оранжевый/серый бейдж — Стенд: замок двери, открыть/закрыть из карточки.
- Карточка с двумя состояниями, замок не тогглится с плана — Стенд: клик по двери/замку во View.
- Flip зеркалит петли/сторону — Стенд: диалог открывания (двойной клик с инструментом).
- Клик с инструментом → редактирование; Delete — Стенд: там же.
- Hover: акцент + grab во View — Стенд: навести на дверь.
- Drag вдоль стен с переснапом за углы — Стенд: тащить дверь по периметру Прихожей.
- Один клик = карточка, дабл = свойства, drag = ничего — Стенд: там же.
## Живые пылесосы (docs/VACUUM.md)
Симулятор: «Робот-пылесос» в Прихожей (база = маркер), карта `sensor.demo_robot_map`
в стиле Tasshack (позиция-словарь, комнаты с именами как на плане, mm, ось Y
перевёрнута), калибровка уже прописана. Полный цикл уборки ~3.5 минуты.
- Docked: только маркер базы — Стенд: глазами (робот в Прихожей).
- Уборка: круглая пульсирующая шайба едет, база на месте — Стенд: карточка робота → Start (или Настройки → Устройства и службы → Сущности → vacuum.demo_robot); шайба змейкой объезжает все 5 комнат.
- Глайд ~1.2 с, телепорт при zoom/pan/switch — Стенд: во время уборки позумить.
- Режимы следа never/cleaning/always — Стенд: диалог маркера робота → «Показывать путь робота»; в always виден след прошлой уборки (40%) — он уже записан в эталоне.
- Серверный след (две уборки, переживает reload) — Стенд: запустить уборку, F5 посреди — линия продолжается с места; вторая вкладка видит ту же линию.
- Стиль: тёмный ореол + светлое ядро — Стенд: глазами поверх glow-заливки.
- Скрытый маркер: ни шайбы, ни следа — Стенд: скрыть маркер робота на время уборки.
- Калибровка: «Настроить автоматически» — Стенд: диалог робота → «Живая позиция»: имена комнат карты совпадают с планом (все 5) — автокалибровка сходится; панель подгонки (призрак, углы, поворот, зеркало — зеркало осмысленно: ось Y в карте перевёрнута) тоже можно открыть и покрутить, Esc/Cancel не портит.
- Мультиэтаж (матрица на карту) — Стенд: частично: карта одна (map 1), матрица хранится по её id; мультикарта — только на реальном Dreame.
- Pause/Return — Стенд: кнопки в карточке робота: пауза замирает, «домой» возвращает по своим следам с двойной скоростью.
## Чего на стенде нет принципиально
- Реальное Zigbee/Z-Wave железо, реальные роботы (Dreame/Xiaomi/Valetudo), реальный настенный планшет.
- HACS-путь установки/обновления, YAML-mode Lovelace, переустановка интеграции с сохранением конфига.
- Всё, что требует доступа к файловой системе стенда: битые store, kill -9 между записями, недоступные папки, наблюдение за памятью процесса.
- Долгоживущие сценарии (суточный sweep, «часом позже») — сброс стенда наступает раньше.
- Не-админский пользователь (admin_only, скрытие табов) — на стенде один админ demo.
+10 -6
View File
@@ -91,12 +91,16 @@ marker is edited as usual.
## Setup UX
A «Живая позиция» section in the device dialog, vacuum markers only:
status line (which source was found / room-only / nothing), one button
(«Настроить автоматически» for Tier A, «Калибровка по трём точкам» for
Tier B), three checkboxes (live position / trail / room highlight, all
on), and for multi-map robots a per-map calibration status list. The
source entity is discovered via the device registry — no YAML, no
entity pickers.
status line (which source was found / nothing), the «Настроить
автоматически» button (Tier A — the integration reports a room list),
the «Подогнать вручную» button opening the fit panel (drag the ghost
map, stretch by the corners, rotate/mirror), one «Живая позиция на
плане» checkbox (on by default), the «Показывать путь робота» select
(never / while cleaning / always), and for multi-map robots a list of
calibrated maps. The source entity is discovered via the device
registry — no YAML, no entity pickers. The section works before the
marker is ever saved: the first vacuum edit materialises the marker
itself (HP-1540-01).
## Storage & validation
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.54.0",
"version": "1.54.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.54.0",
"version": "1.54.1",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.54.0",
"version": "1.54.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+57 -14
View File
@@ -42,7 +42,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.54.0';
const CARD_VERSION = '1.54.1';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -4423,6 +4423,35 @@ class HouseplanCard extends LitElement {
/**
* HP-1540-01: an auto-discovered vacuum has NO config marker until the first
* general Save of its dialog, yet the «Живая позиция» section is already
* interactive. Every vacuum handler used to `cfg.markers.find(...)` and
* silently no-op without one auto-calibration even toasted success while
* saving nothing. Materialise a minimal marker (same id/binding the dialog
* Save would produce see _saveMarker/markerIdForBinding) before any
* vacuum write, so the write has somewhere real to land.
*/
private _vacEnsureMarker(d: DevItem): Marker | null {
const cfg = this._serverCfg;
if (!cfg) return null;
cfg.markers = cfg.markers || [];
const existing = cfg.markers.find((x: Marker) => x.id === d.id);
if (existing) return existing;
if ((d.bindingKind !== 'device' && d.bindingKind !== 'entity') || !d.bindingRef) return null;
const m: Marker = {
id: d.id,
binding: d.bindingKind + ':' + d.bindingRef,
space: d.space || null,
area: d.area || null,
// explicit like _saveMarker: a marker of any kind tells the seeder
// "the user decided" (docs/FILTERING.md)
hidden: d.hidden ? true : false,
};
cfg.markers.push(m);
return m;
}
/** «Живая позиция» in the device dialog — vacuum markers only. */
private _renderVacSection(dlg: any): TemplateResult | typeof nothing {
const dev = this._devices.find((x) => x.id === dlg.devId);
@@ -4436,8 +4465,9 @@ class HouseplanCard extends LitElement {
: this._t('vac.status_none');
const cals = Object.keys(v.calibration || {});
const setVac = (patch: Record<string, unknown>) => {
const cfg = this._serverCfg;
const m = cfg?.markers?.find((x: Marker) => x.id === dev.id);
// HP-1540-01: materialise the marker first — find() alone silently
// dropped every edit for a vacuum that had never been saved
const m = this._vacEnsureMarker(dev);
if (!m) return;
m.vacuum = { ...(m.vacuum || {}), ...patch };
this._regSignature = '';
@@ -4481,11 +4511,15 @@ class HouseplanCard extends LitElement {
return sel ? String(sel) : 'default';
}
/** Persist a solved matrix into marker.vacuum.calibration[mapId]. */
private _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine): void {
const cfg = this._serverCfg;
const m = cfg?.markers?.find((x: Marker) => x.id === markerId);
if (!m) return;
/** Persist a solved matrix into marker.vacuum.calibration[mapId].
* Returns whether the write actually landed callers must not toast
* success otherwise (HP-1540-01). */
private _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine): boolean {
// HP-1540-01: a first-use vacuum has no marker yet — materialise it
const dev = this._devices.find((x) => x.id === markerId);
const m = dev ? this._vacEnsureMarker(dev)
: this._serverCfg?.markers?.find((x: Marker) => x.id === markerId);
if (!m) return false;
const v = { ...(m.vacuum || {}) };
v.source = source;
v.calibration = { ...(v.calibration || {}), [mapId]: matrix.map((n) => Number(n.toFixed(6))) };
@@ -4493,6 +4527,7 @@ class HouseplanCard extends LitElement {
this._regSignature = '';
this._saveConfig();
this.requestUpdate();
return true;
}
/** «Настроить автоматически»: robot rooms ↔ plan rooms by name. */
@@ -4505,9 +4540,14 @@ class HouseplanCard extends LitElement {
}
const sp = this._spaceModel(d.space);
const planRooms = (sp?.rooms || [])
.filter((r: any) => r.name && r.poly?.length >= 3)
.map((r: any) => {
const c = poleOfInaccessibility(r.poly);
// HP-1540-04: legacy rectangle rooms (x/y/w/h) are still first-class
// everywhere else — roomPoly() gives the same 4-corner outline the
// renderer uses, so they must count for name-matching too. The old
// r.poly?.length filter dropped them and then blamed the room names.
.map((r: any) => ({ r, poly: roomPoly(r) }))
.filter(({ r, poly }: any) => r.name && poly)
.map(({ r, poly }: any) => {
const c = poleOfInaccessibility(poly);
return { name: r.name, cx: c[0], cy: c[1] };
});
const res = autoCalibrate(tele.rooms, planRooms);
@@ -4516,10 +4556,12 @@ class HouseplanCard extends LitElement {
return;
}
// residual gate: 5% of the canvas ≈ 40 cm in a typical house
// HP-1540-01: toast success ONLY after the matrix verifiably landed in
// the config — the old code always claimed victory, even as a no-op
if (!this._vacSaveMatrix(d.id, src, this._vacMapId(d, tele), res.matrix)) return;
if (res.residual > NORM_W * 0.05) {
this._showToast(subst(this._t('vac.autocal_res_warn'), { rooms: String(res.matched.length) }));
}
this._vacSaveMatrix(d.id, src, this._vacMapId(d, tele), res.matrix);
this._showToast(subst(this._t('vac.autocal_done'), { rooms: String(res.matched.length) }));
}
@@ -4545,9 +4587,10 @@ class HouseplanCard extends LitElement {
private _vacFitSave(): void {
const f = this._vacFit;
if (!f) return;
this._vacSaveMatrix(f.markerId, f.source, f.mapId, fitMatrix(f.p));
// HP-1540-01: no success toast for a save that did not happen
const ok = this._vacSaveMatrix(f.markerId, f.source, f.mapId, fitMatrix(f.p));
this._vacFit = null;
this._showToast(this._t('vac.cal_done'));
if (ok) this._showToast(this._t('vac.cal_done'));
}
/** Rotate/mirror around the ghost centre so the map does not fly away. */
+3 -3
View File
@@ -363,9 +363,9 @@
"vac.live": "Live position on the plan",
"vac.trail": "Show the robot's path",
"vac.cal_maps": "Calibrated maps: {maps}",
"vac.autocal_no_rooms": "The integration reports no room list — use point calibration",
"vac.autocal_no_match": "Room names did not match (need ≥3 in common) — use point calibration",
"vac.autocal_res_warn": "Matched {rooms} rooms but the fit is rough — verify and refine with points if needed",
"vac.autocal_no_rooms": "The integration reports no room list — open “Fit manually”",
"vac.autocal_no_match": "Room names did not match (need ≥3 in common) — open “Fit manually”",
"vac.autocal_res_warn": "Matched {rooms} rooms but the fit is rough — verify and refine via “Fit manually” if needed",
"vac.autocal_done": "Done: bound via {rooms} rooms. Start a cleanup and check",
"vac.cal_need_pos": "The robot is not reporting coordinates — start a cleanup and pause it",
"vac.cal_done": "Calibration saved. Start a cleanup and check",
+3 -3
View File
@@ -363,9 +363,9 @@
"vac.live": "Живая позиция на плане",
"vac.trail": "Показывать путь робота",
"vac.cal_maps": "Откалиброваны карты: {maps}",
"vac.autocal_no_rooms": "Интеграция не отдаёт список комнат — используйте калибровку по точкам",
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — используйте калибровку по точкам",
"vac.autocal_res_warn": "Совпало комнат: {rooms}, но привязка грубовата — проверьте и при необходимости откалибруйте по точкам",
"vac.autocal_no_rooms": "Интеграция не отдаёт список комнат — откройте «Подогнать вручную»",
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — откройте «Подогнать вручную»",
"vac.autocal_res_warn": "Совпало комнат: {rooms}, но привязка грубовата — проверьте и при необходимости откройте «Подогнать вручную»",
"vac.autocal_done": "Готово: привязка по {rooms} комнатам. Запустите уборку и проверьте",
"vac.cal_need_pos": "Робот сейчас не отдаёт координаты — запустите уборку и поставьте на паузу",
"vac.cal_done": "Калибровка сохранена. Запустите уборку и проверьте",
+14 -1
View File
@@ -81,6 +81,19 @@ const num = (v: unknown): number | null => {
return Number.isFinite(n) ? n : null;
};
/**
* Map-id normalisation contract, shared with the backend recorder
* (custom_components/houseplan/trails.py: resolve_map_id). The FIRST value
* that is not null/undefined wins truthiness is wrong here, because a
* zero-based `map_index: 0` is a perfectly valid first map and an empty
* string is still an id. The backend used an `or`-chain and dropped the
* zero, so server trails were stored under a key the renderer never looked
* up (HP-1540-02).
*/
export function vacMapIdFromAttrs(attrs: Record<string, any>): string {
return String(attrs.map_name ?? attrs.current_map ?? attrs.map_index ?? attrs.selected_map ?? 'default');
}
/**
* Normalise the attribute zoo. One parser instead of per-brand classes: the
* three Tier-A integrations (Xiaomi Cloud Map Extractor, Tasshack
@@ -134,7 +147,7 @@ export function readVacTelemetry(attrs: Record<string, any> | null | undefined):
rooms.push(room);
}
}
const mapId = String(attrs.map_name ?? attrs.current_map ?? attrs.map_index ?? attrs.selected_map ?? 'default');
const mapId = vacMapIdFromAttrs(attrs);
if (!pos && !rooms.length && !path) return null;
return { pos, path, rooms, mapId };
}
+8
View File
@@ -25,3 +25,11 @@ test('i18n: placeholders match between languages', () => {
assert.equal(ph(en[k]), ph(ru[k]), `placeholder mismatch in ${k}`);
}
});
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
for (const [lang, d] of [['en', en], ['ru', ru]]) {
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.autocal_res_warn']) {
assert.ok(!/point|точк/i.test(d[k]), `${lang}:${k} still points at point calibration`);
}
}
});
+18 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { solveAffine, applyAffine, affineResidual, readVacTelemetry, autoCalibrate, thinPath, pushTrailPoint, isVacMoving, isVacSourceState } from '../test-build/vacuum.js';
import { solveAffine, applyAffine, affineResidual, readVacTelemetry, autoCalibrate, thinPath, pushTrailPoint, isVacMoving, isVacSourceState, vacMapIdFromAttrs } from '../test-build/vacuum.js';
test('solveAffine recovers rotation+scale+mirror+offset exactly', () => {
// target = mirror-X, rotate 90°, scale 0.02, offset (300, 400)
@@ -135,3 +135,20 @@ test('fitMatrix/fitFromMatrix round-trip, mirror and quarters', async () => {
const c2 = applyAffine(fitMatrix(r2), 500, 500);
assert.ok(Math.abs(c2[0] - c[0]) < 1e-6 && Math.abs(c2[1] - c[1]) < 1e-6);
});
// HP-1540-02: the map-id contract, mirrored by trails.py resolve_map_id —
// the first value that is not null/undefined wins; zero and '' are valid ids.
test('vacMapIdFromAttrs: first NOT-nullish value wins, zero survives', () => {
assert.equal(vacMapIdFromAttrs({ map_index: 0 }), '0');
assert.equal(vacMapIdFromAttrs({ map_index: '0' }), '0');
assert.equal(vacMapIdFromAttrs({ map_name: '', selected_map: 'Floor' }), '');
assert.equal(vacMapIdFromAttrs({ map_name: 'A', map_index: 0 }), 'A');
assert.equal(vacMapIdFromAttrs({ current_map: 2 }), '2');
assert.equal(vacMapIdFromAttrs({ selected_map: 'Vac' }), 'Vac');
assert.equal(vacMapIdFromAttrs({}), 'default');
});
test('readVacTelemetry keeps numeric zero map_index as map id (HP-1540-02)', () => {
const t = readVacTelemetry({ vacuum_position: { x: 1, y: 2 }, map_index: 0 });
assert.equal(t.mapId, '0');
});
+178 -1
View File
@@ -85,7 +85,7 @@ def _rec():
}
hass = Hass(states)
rec = trails.TrailRecorder(hass, None)
rec.pairs = {"camera.map": ("m1", "vacuum.x50")}
rec.pairs = {"camera.map": [("m1", "vacuum.x50")]}
return rec, hass, states
@@ -140,3 +140,180 @@ def test_object_style_position_is_read():
states["camera.map"] = S("idle", {"vacuum_position": Point(2020, 3096), "map_index": 1})
assert rec._sample("camera.map", 5.0)
assert rec.book.data["m1"]["current"]["points"] == [[2020.0, 3096.0]]
def test_unavailable_vacuum_is_no_verdict():
# HA boot: the vacuum reads unavailable — the open run must NOT be ended
rec, _hass, states = _rec()
rec._sample("camera.map", 1.0)
states["vacuum.x50"] = S("unavailable", {})
assert not rec._sample("camera.map", 2.0)
assert rec.book.data["m1"]["current"]["ended"] is None
del states["vacuum.x50"]
assert not rec._sample("camera.map", 3.0)
assert rec.book.data["m1"]["current"]["ended"] is None
# ---------------- v1.54.0 audit regressions ----------------
def _run_isolated(coro):
"""Run a coroutine on a private loop WITHOUT touching the ambient one.
asyncio.run() clears the thread's current-loop slot when it finishes; in
the CI HA harness (pytest-asyncio keeps a session event loop) that
poisoned the setup of every test that followed 'There is no current
event loop in thread MainThread' across whole files.
"""
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def test_map_index_zero_matches_frontend_contract():
# HP-1540-02: `map_index: 0` is a VALID first map. The old or-chain
# dropped it and fell through to selected_map — the server stored the
# run under a key the renderer never looked up.
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": {"x": 10, "y": 20}, "map_index": 0})
assert rec._sample("camera.map", 1.0)
assert rec.book.data["m1"]["current"]["map_id"] == "0"
def test_map_id_contract_first_not_none_wins():
# HP-1540-02: the shared contract — first NOT-None value, stringified
cases = [
({"map_index": 0}, {"selected_map": "Floor"}, "0"),
({"map_index": "0"}, {}, "0"),
({"map_name": ""}, {"selected_map": "Floor"}, ""),
({"map_name": "A", "map_index": 0}, {}, "A"),
({"current_map": 2}, {}, "2"),
({"selected_map": "Src"}, {"selected_map": "Vac"}, "Src"),
({}, {"selected_map": "Vac"}, "Vac"),
({}, {}, "default"),
]
for src_attrs, vac_attrs, want in cases:
assert trails.resolve_map_id(src_attrs, vac_attrs) == want, (src_attrs, vac_attrs)
def test_one_source_two_floor_markers_both_record():
# HP-1540-03: the multi-floor case — the same robot placed on two floors.
# Both markers must receive the server-side run, on every map.
states = {
"vacuum.x50": S("cleaning", {}),
"camera.map": S("idle", {"vacuum_position": {"x": 100, "y": 200}, "map_index": 0}),
}
hass = Hass(states)
rec = trails.TrailRecorder(hass, None)
rec.pairs = {"camera.map": [("m_floor1", "vacuum.x50"), ("m_floor2", "vacuum.x50")]}
rec._on_state(E("camera.map"))
# the robot moves to the second map: BOTH books rotate to the new run
states["camera.map"] = S("idle", {"vacuum_position": {"x": 300, "y": 400}, "map_index": 1})
rec._on_state(E("camera.map"))
for marker in ("m_floor1", "m_floor2"):
book = rec.book.data[marker]
assert book["previous"]["map_id"] == "0", marker
assert book["previous"]["points"] == [[100.0, 200.0]], marker
assert book["current"]["map_id"] == "1", marker
assert book["current"]["points"] == [[300.0, 400.0]], marker
def test_refresh_builds_pair_lists_and_dedups_subscription():
# HP-1540-03: two markers over one source/vacuum → one entity set, both pairs
import asyncio
tracked = []
def track(hass, ents, cb):
tracked.append(list(ents))
return lambda: None
old_track = trails.async_track_state_change_event
trails.async_track_state_change_event = track
try:
cfgm = [
{"id": "m_f1", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
{"id": "m_f2", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
]
class CS:
async def async_load(self):
return {"config": {"markers": cfgm}}
class RT:
config_store = CS()
hass = Hass({
"vacuum.x50": S("docked", {}),
"camera.map": S("idle", {}),
})
rec = trails.TrailRecorder(hass, RT())
_run_isolated(rec.async_refresh())
assert rec.pairs == {"camera.map": [("m_f1", "vacuum.x50"), ("m_f2", "vacuum.x50")]}
assert tracked == [["camera.map", "vacuum.x50"]]
finally:
trails.async_track_state_change_event = old_track
def test_overlapping_refreshes_leave_one_subscription_teardown_zero():
# HP-1540-05: two config/set refreshes racing across the awaited load used
# to BOTH subscribe; teardown removed only the last handle and the other
# callback leaked until HA restart.
import asyncio
active = []
seq = {"n": 0}
def track(hass, ents, cb):
seq["n"] += 1
hid = seq["n"]
active.append(hid)
return lambda: active.remove(hid)
old_track = trails.async_track_state_change_event
trails.async_track_state_change_event = track
try:
gate = asyncio.Event()
class CS:
async def async_load(self):
await gate.wait()
return {"config": {"markers": [
{"id": "m1", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
]}}
class RT:
config_store = CS()
hass = Hass({
"vacuum.x50": S("docked", {}),
"camera.map": S("idle", {}),
})
async def scenario():
rec = trails.TrailRecorder(hass, RT())
t1 = asyncio.ensure_future(rec.async_refresh())
t2 = asyncio.ensure_future(rec.async_refresh())
for _ in range(3): # both tasks are launched; one parks on the gate
await asyncio.sleep(0)
gate.set()
await t1
await t2
assert len(active) == 1, f"exactly one live subscription, got {active}"
# teardown during an in-flight refresh must also end with zero
gate.clear()
t3 = asyncio.ensure_future(rec.async_refresh())
for _ in range(3):
await asyncio.sleep(0)
rec.teardown()
gate.set()
await t3
assert active == [], f"teardown must leave zero subscriptions, got {active}"
_run_isolated(scenario())
finally:
trails.async_track_state_change_event = old_track