mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
Server-side trails: the current run and one previous
The integration now records the path itself (trails.py): it watches the source entity's state changes, so recording needs no open card, has no multi-tab write races, and every screen sees the same line — reloads included, which retires the localStorage snapshot after one day of life. Stored per marker: the current run plus exactly one previous (owner call — users want cleaned-vs-uncleaned at a glance). The previous run renders at 40% opacity; the current one still trims its live tail so it never outruns the puck. Runs rotate on start or map switch, points cap at 2000 with decimation, store writes debounce 10 s, and houseplan_trail_updated pushes live cards. TrailBook is pure under 5 backend tests; the WS command degrades silently on older backends.
This commit is contained in:
@@ -50,6 +50,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
||||
entry.runtime_data = data
|
||||
|
||||
# server-side vacuum trails: the integration records the path itself
|
||||
from .trails import TrailRecorder
|
||||
recorder = TrailRecorder(hass, data)
|
||||
await recorder.async_setup()
|
||||
hass.data[DOMAIN]["trail_recorder"] = recorder
|
||||
|
||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||
files_path = Path(hass.config.path(FILES_DIR))
|
||||
@@ -194,6 +200,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if rec:
|
||||
rec.teardown()
|
||||
"""Unload the entry.
|
||||
|
||||
WS commands and the HTTP view are global (async_setup) and stay registered —
|
||||
|
||||
File diff suppressed because one or more lines are too long
Executable
+170
@@ -0,0 +1,170 @@
|
||||
"""Server-side vacuum trails.
|
||||
|
||||
The integration records the robot's path ITSELF by watching the source
|
||||
entity's state changes — no card involvement. This removes every client-side
|
||||
race (N open tabs would fight over writes), survives page reloads by
|
||||
construction, and keeps recording while no card is open at all. Stored: the
|
||||
current run and one previous run per marker (owner call 2026-07-31 — users
|
||||
want to see where the cleanup has already been).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
|
||||
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
class TrailBook:
|
||||
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
|
||||
|
||||
A run is {"map_id", "started", "ended", "points": [[x, y], …]} in RAW
|
||||
robot coordinates — recalibration never invalidates a stored trail.
|
||||
"""
|
||||
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
|
||||
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
rec["previous"] = cur
|
||||
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
return False
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
half = pts[0::2]
|
||||
if half[-1] != pts[-1]:
|
||||
half.append(pts[-1])
|
||||
cur["points"] = half
|
||||
return True
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and not cur.get("ended"):
|
||||
cur["ended"] = now
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TrailRecorder:
|
||||
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
|
||||
self.hass = hass
|
||||
self.rt = rt
|
||||
self.store = Store(hass, 1, f"{DOMAIN}.trails")
|
||||
self.book = TrailBook()
|
||||
self.pairs: dict[str, tuple[str, str]] = {} # source → (marker, vacuum)
|
||||
self._unsub_track = None
|
||||
self._unsub_save = None
|
||||
self._last_fire = 0.0
|
||||
|
||||
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()}
|
||||
if ents:
|
||||
self._unsub_track = async_track_state_change_event(
|
||||
self.hass, sorted(ents), self._on_state
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
|
||||
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
|
||||
b = str(m.get("binding") or "")
|
||||
if b.startswith("entity:vacuum."):
|
||||
return b[len("entity:"):]
|
||||
if b.startswith("device:"):
|
||||
reg = er.async_get(self.hass)
|
||||
for e in er.async_entries_for_device(reg, b[len("device:"):]):
|
||||
if e.entity_id.startswith("vacuum."):
|
||||
return e.entity_id
|
||||
return None
|
||||
|
||||
@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 not in (src, vac):
|
||||
continue
|
||||
st_vac = self.hass.states.get(vac)
|
||||
moving = bool(st_vac and st_vac.state in MOVING_STATES)
|
||||
if not moving:
|
||||
changed |= self.book.end_run(marker, now)
|
||||
continue
|
||||
st_src = self.hass.states.get(src)
|
||||
attrs = st_src.attributes if st_src else {}
|
||||
pos = attrs.get("vacuum_position") or attrs.get("robot_position")
|
||||
if not isinstance(pos, dict):
|
||||
continue
|
||||
try:
|
||||
x, y = float(pos["x"]), float(pos["y"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
map_id = str(
|
||||
attrs.get("map_name")
|
||||
or attrs.get("current_map")
|
||||
or attrs.get("map_index")
|
||||
or (st_vac.attributes.get("selected_map") if st_vac else None)
|
||||
or "default"
|
||||
)
|
||||
changed |= self.book.on_point(marker, map_id, x, y, now)
|
||||
if changed:
|
||||
self._schedule_save()
|
||||
if now - self._last_fire >= FIRE_THROTTLE_S:
|
||||
self._last_fire = now
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
if self._unsub_save:
|
||||
return
|
||||
|
||||
async def _save(_now: Any) -> None:
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
|
||||
self._unsub_save = async_call_later(self.hass, SAVE_DELAY_S, _save)
|
||||
@@ -40,6 +40,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_get)
|
||||
websocket_api.async_register_command(hass, ws_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
@@ -692,6 +693,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded files failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
@@ -767,3 +769,18 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
connection.send_error(msg["id"], err.reason, err.detail)
|
||||
return
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
|
||||
def _refresh_trail_recorder(hass: HomeAssistant) -> None:
|
||||
"""Markers changed — the trail recorder must re-resolve what it watches."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
if rec:
|
||||
hass.async_create_task(rec.async_refresh())
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
|
||||
"""Current + previous cleanup runs per marker, raw robot coordinates."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
|
||||
|
||||
+21
-19
@@ -103,25 +103,27 @@ const out = await page.evaluate(async () => {
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
|
||||
|
||||
// ---- the trail survives a page reload (localStorage snapshot) ----
|
||||
// simulate a reload: wipe the runtime map, keep LS, tick again
|
||||
for (const [x, y] of [[930, 1130], [950, 1150], [990, 1190]]) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x, y, a: 0 }, map_name: 'm1' } } } };
|
||||
await c.updateComplete;
|
||||
}
|
||||
o.trailPersisted = !!localStorage.getItem('hp_vactrail_e_vacuum_robo');
|
||||
const savedTrailLen = (JSON.parse(localStorage.getItem('hp_vactrail_e_vacuum_robo') || '{}').t || []).length;
|
||||
c._vacRt.clear(); // ← the reload
|
||||
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
|
||||
c._vacSrvTrails = { e_vacuum_robo: {
|
||||
current: { map_id: 'm1', started: 1, ended: null,
|
||||
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
|
||||
previous: { map_id: 'm1', started: 0, ended: 1,
|
||||
points: [[600, 1300], [800, 1300], [800, 1500]] },
|
||||
} };
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 950, y: 1150, a: 0 }, map_name: 'm1' } } } };
|
||||
await c.updateComplete;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
|
||||
const restored = c._vacRt.get('e_vacuum_robo');
|
||||
o.trailRestoredAfterReload = !!restored && restored.trail.length >= savedTrailLen && savedTrailLen >= 1;
|
||||
// …but a NEW run after the reload discards the old snapshot
|
||||
c._vacRt.clear();
|
||||
localStorage.setItem('hp_vactrail_e_vacuum_robo', JSON.stringify({ t: [[1, 1], [2, 2]], ts: Date.now(), moving: false, mapId: 'm1' }));
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
|
||||
o.newRunDropsOldTrail = (c._vacRt.get('e_vacuum_robo')?.trail || []).length === 0;
|
||||
const prevG = sr().querySelector('.vactrail g.prev');
|
||||
o.srvPrevRunShown = !!prevG && prevG.querySelectorAll('polyline').length === 2
|
||||
&& prevG.querySelector('.case').getAttribute('points').split(' ').length === 3;
|
||||
const curLines = [...sr().querySelectorAll('.vactrail > polyline.case, .vactrail g:not(.prev) polyline.case')];
|
||||
const cur = curLines.find((x) => !x.closest('g.prev'));
|
||||
// 4 server points, the live tail trimmed while moving -> 3 rendered
|
||||
o.srvCurTrimmed = !!cur && cur.getAttribute('points').split(' ').length === 3;
|
||||
o.srvPrevFaded = !!prevG && getComputedStyle(prevG).opacity === '0.4';
|
||||
c._vacSrvTrails = {};
|
||||
|
||||
// ---- the fit panel (drag + corner-stretch) end to end ----
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
@@ -202,7 +204,7 @@ checkAll(out, {
|
||||
trailCasing: true, trailToggleOff: true,
|
||||
puckGoneWhenDocked: true, trailLingers: true, hiddenNoPuck: true,
|
||||
unknownMapNoPuck: true,
|
||||
trailPersisted: true, trailRestoredAfterReload: true, newRunDropsOldTrail: true,
|
||||
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
|
||||
fitDevFound: true, fitOverlay: true, fitGhostRooms: true, fitLabels: true,
|
||||
fitHandles: true, fitHandleHittable: true, fitGhostHittable: true, fitBar: true, fitMirrorDefault: true, fitDragMoves: true,
|
||||
fitRotateKeepsCentre: true, fitCornerScales: true, fitSavedMatrix: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+23
-22
File diff suppressed because one or more lines are too long
+1
-1
@@ -84,7 +84,7 @@ marker is edited as usual.
|
||||
Douglas-Peucker thinning, one SVG polyline.
|
||||
- Style fixed, no options: cartography casing — a dark translucent halo under a light core. Neutral (pure black/white alphas) and readable over any room fill; blend modes were rejected: each has a blind luminance where the line vanishes, and mix-blend-mode is costly on old kiosk WebViews. Lifecycle:
|
||||
appears on cleaning start, lives until dock + 10 min, dissolves;
|
||||
`docked → cleaning` clears the old trail. Ephemeral on the server; the raw points snapshot into localStorage per marker so a page reload mid-run keeps the whole trail (freshness-gated by the same linger window; a new run or another map discards the snapshot).
|
||||
`docked → cleaning` clears the old trail. Recorded SERVER-SIDE by the integration itself (trails.py): it watches the source entity, so the path records with zero cards open and every screen sees the same line. Stored per marker: the current run and ONE previous run (owner call — users compare cleaned vs uncleaned). The previous run renders at 40% opacity even at rest; the current run trims its live tail while moving. Rotation on run start or map switch; 2000-point cap with decimation; store writes debounced 10 s; houseplan_trail_updated notifies live cards.
|
||||
- Marker option «Показывать след уборки», on by default where data
|
||||
exists.
|
||||
|
||||
|
||||
+40
-38
@@ -353,6 +353,9 @@ class HouseplanCard extends LitElement {
|
||||
space switch) or a tab return must TELEPORT the puck — animating left/top
|
||||
through a viewport change reads as «едет через весь план» (owner). */
|
||||
private _vacViewKey = '';
|
||||
/** server-recorded runs per marker: {current, previous} in raw robot coords */
|
||||
private _vacSrvTrails: Record<string, any> = {};
|
||||
private _unsubTrail?: () => void;
|
||||
private _vacJumpOnce = false;
|
||||
private _vacVisHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
@@ -891,6 +894,20 @@ class HouseplanCard extends LitElement {
|
||||
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
|
||||
}, 'houseplan_config_updated');
|
||||
}
|
||||
// server-side trails are additive: an older backend without the WS
|
||||
// command just leaves the map empty and the card shows live-only trails
|
||||
this.hass.callWS({ type: 'houseplan/trail/get' })
|
||||
.then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); })
|
||||
.catch(() => undefined);
|
||||
if (!this._unsubTrail) {
|
||||
this._unsubTrail = await this.hass.connection.subscribeEvents(async () => {
|
||||
try {
|
||||
const r: any = await this.hass.callWS({ type: 'houseplan/trail/get' });
|
||||
this._vacSrvTrails = r?.trails || {};
|
||||
this.requestUpdate();
|
||||
} catch { /* transient WS hiccup — the next event retries */ }
|
||||
}, 'houseplan_trail_updated');
|
||||
}
|
||||
if (!this._unsubLayout) {
|
||||
// Positions are separate state. The static card learned to follow them
|
||||
// in v1.46.0 and the full one did not, so two full cards side by side
|
||||
@@ -4371,34 +4388,15 @@ class HouseplanCard extends LitElement {
|
||||
let rt = this._vacRt.get(d.id);
|
||||
if (!rt) {
|
||||
rt = { trail: [], lastKey: '', lastTs: 0, moving: false, jump: false, endedTs: 0, lastPos: null };
|
||||
// a page reload must not eat the run's trail (owner request): the raw
|
||||
// points live in localStorage per marker, freshness-gated by the same
|
||||
// linger window the on-screen trail obeys
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem('hp_vactrail_' + d.id) || 'null');
|
||||
if (raw && Array.isArray(raw.t) && Date.now() - raw.ts < VAC_TRAIL_LINGER_MS
|
||||
&& raw.mapId === (tele?.mapId ?? raw.mapId)
|
||||
// moving now but the snapshot had already ended → that is a NEW
|
||||
// run, the old trail must not leak into it
|
||||
&& !(moving && !raw.moving)) {
|
||||
rt.trail = raw.t.filter((q: unknown) => Array.isArray(q) && q.length === 2);
|
||||
rt.moving = moving && raw.moving;
|
||||
rt.endedTs = raw.moving ? 0 : raw.ts;
|
||||
}
|
||||
} catch { /* corrupt snapshot — start clean */ }
|
||||
this._vacRt.set(d.id, rt);
|
||||
}
|
||||
if (moving && !rt.moving) {
|
||||
rt.trail = []; rt.lastPos = null; // a fresh run
|
||||
try { localStorage.removeItem('hp_vactrail_' + d.id); } catch { /* quota */ }
|
||||
}
|
||||
if (moving && !rt.moving) { rt.trail = []; rt.lastPos = null; } // a fresh run
|
||||
const wantTrail = d.marker?.vacuum?.trail !== false && !tele?.path;
|
||||
if (!moving && rt.moving) {
|
||||
rt.endedTs = Date.now();
|
||||
// the run is over: the puck has arrived everywhere it was going
|
||||
if (wantTrail && rt.lastPos) rt.trail = pushTrailPoint(rt.trail, rt.lastPos, 40);
|
||||
rt.lastPos = null;
|
||||
this._vacPersistTrail(d.id, rt, tele?.mapId);
|
||||
}
|
||||
rt.moving = moving;
|
||||
const pos = tele?.pos;
|
||||
@@ -4413,10 +4411,7 @@ class HouseplanCard extends LitElement {
|
||||
// the trail lags ONE point behind: when a new target arrives the puck
|
||||
// has just (visually) reached the previous one — a segment must never
|
||||
// outrun the icon (owner report 2026-07-31)
|
||||
if (wantTrail && rt.lastPos) {
|
||||
rt.trail = pushTrailPoint(rt.trail, rt.lastPos, 40);
|
||||
this._vacPersistTrail(d.id, rt, tele!.mapId);
|
||||
}
|
||||
if (wantTrail && rt.lastPos) rt.trail = pushTrailPoint(rt.trail, rt.lastPos, 40);
|
||||
rt.lastPos = [pos.x, pos.y];
|
||||
}
|
||||
}
|
||||
@@ -4674,13 +4669,6 @@ class HouseplanCard extends LitElement {
|
||||
@pointercancel=${(e: PointerEvent) => this._vacFitPointer(e, view)}>${rects}${dot}${handles}</svg>`;
|
||||
}
|
||||
|
||||
private _vacPersistTrail(id: string, rt: { trail: VacPt[]; moving: boolean }, mapId?: string): void {
|
||||
try {
|
||||
localStorage.setItem('hp_vactrail_' + id,
|
||||
JSON.stringify({ t: rt.trail, ts: Date.now(), moving: rt.moving, mapId: mapId ?? 'default' }));
|
||||
} catch { /* quota/private mode — the trail just will not survive a reload */ }
|
||||
}
|
||||
|
||||
/** Puck + trail for every live vacuum of the space. */
|
||||
private _renderVacuums(devs: DevItem[], view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
|
||||
if (this._markup || this._mode === 'decor') return nothing;
|
||||
@@ -4701,13 +4689,27 @@ class HouseplanCard extends LitElement {
|
||||
const rt = this._vacRt.get(d.id);
|
||||
const moving = rt?.moving ?? false;
|
||||
const lingering = !moving && rt?.endedTs && Date.now() - rt.endedTs < VAC_TRAIL_LINGER_MS;
|
||||
// trail: integration path wins (it predates the card being opened)
|
||||
if (d.marker?.vacuum?.trail !== false && (moving || lingering)) {
|
||||
// an integration path ends at the CURRENT target — trim the live
|
||||
// tail while moving so the line never runs ahead of the puck
|
||||
const raw: VacPt[] = tele.path
|
||||
? (moving && tele.path.length > 1 ? tele.path.slice(0, -1) : tele.path)
|
||||
: rt?.trail || [];
|
||||
const srv = this._vacSrvTrails[d.id];
|
||||
const mapNow = this._vacMapId(d, tele);
|
||||
const srvCur = srv?.current?.map_id === mapNow && Array.isArray(srv.current.points) ? srv.current : null;
|
||||
const srvPrev = srv?.previous?.map_id === mapNow && Array.isArray(srv.previous.points) ? srv.previous : null;
|
||||
// the PREVIOUS run stays visible even at rest: users compare where the
|
||||
// robot has been against where it has not (owner call 2026-07-31)
|
||||
if (d.marker?.vacuum?.trail !== false && srvPrev && srvPrev.points.length > 1) {
|
||||
const pts = srvPrev.points.map(([x, y]: number[]) => {
|
||||
const [cx2, cy2] = applyAffine(matrix, x, y);
|
||||
return cx2.toFixed(1) + ',' + cy2.toFixed(1);
|
||||
}).join(' ');
|
||||
trails.push(svg`<g class="prev"><polyline class="case" points="${pts}"></polyline><polyline class="core" points="${pts}"></polyline></g>`);
|
||||
}
|
||||
// trail source order: server current run (survives reloads, shared by
|
||||
// every screen) → integration path → the local live buffer
|
||||
if (d.marker?.vacuum?.trail !== false && (moving || lingering || srvCur)) {
|
||||
// any server/integration path ends at the CURRENT target — trim the
|
||||
// live tail while moving so the line never runs ahead of the puck
|
||||
const full: VacPt[] = (srvCur?.points as VacPt[]) || tele.path || rt?.trail || [];
|
||||
const trim = moving && (srvCur || tele.path) && full.length > 1;
|
||||
const raw: VacPt[] = trim ? full.slice(0, -1) : full;
|
||||
if (raw.length > 1) {
|
||||
const ptsStr = raw.map(([x, y]) => {
|
||||
const [cx, cy] = applyAffine(matrix, x, y);
|
||||
|
||||
@@ -1297,6 +1297,7 @@ export const cardStyles = css`
|
||||
}
|
||||
/* dark halo + light core: neutral, and one of the two always contrasts
|
||||
with whatever fill is underneath */
|
||||
.vactrail g.prev { opacity: 0.4; }
|
||||
.vactrail .case {
|
||||
stroke: rgba(0, 0, 0, 0.4);
|
||||
stroke-width: 2.25;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""TrailBook: the pure part of the server-side vacuum trails."""
|
||||
import sys, pathlib
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan"))
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"trailbook_pure",
|
||||
pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan" / "trails.py",
|
||||
)
|
||||
# import only TrailBook without HA deps: read the source and exec the class
|
||||
src = (pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan" / "trails.py").read_text(encoding="utf-8")
|
||||
ns = {"Any": object, "annotations": None}
|
||||
exec(src[src.index("TRAIL_CAP"):src.index("class TrailRecorder")], ns)
|
||||
TrailBook = ns["TrailBook"]
|
||||
TRAIL_CAP = ns["TRAIL_CAP"]
|
||||
|
||||
|
||||
def test_append_dedups_and_records():
|
||||
b = TrailBook()
|
||||
assert b.on_point("m", "0", 1.0, 2.0, 100.0)
|
||||
assert not b.on_point("m", "0", 1.0, 2.0, 101.0) # same point
|
||||
assert b.on_point("m", "0", 3.0, 4.0, 102.0)
|
||||
run = b.data["m"]["current"]
|
||||
assert run["points"] == [[1.0, 2.0], [3.0, 4.0]]
|
||||
assert run["ended"] is None
|
||||
|
||||
|
||||
def test_end_then_new_run_rotates_current_to_previous():
|
||||
b = TrailBook()
|
||||
b.on_point("m", "0", 1, 1, 1.0)
|
||||
b.on_point("m", "0", 2, 2, 2.0)
|
||||
assert b.end_run("m", 3.0)
|
||||
assert not b.end_run("m", 4.0) # idempotent
|
||||
b.on_point("m", "0", 9, 9, 5.0)
|
||||
rec = b.data["m"]
|
||||
assert rec["previous"]["points"] == [[1, 1], [2, 2]]
|
||||
assert rec["previous"]["ended"] == 3.0
|
||||
assert rec["current"]["points"] == [[9, 9]]
|
||||
# a third run forgets the first entirely — exactly two are kept
|
||||
b.end_run("m", 6.0)
|
||||
b.on_point("m", "0", 7, 7, 7.0)
|
||||
assert rec if rec is b.data["m"] else True
|
||||
assert b.data["m"]["previous"]["points"] == [[9, 9]]
|
||||
|
||||
|
||||
def test_map_switch_mid_run_starts_a_new_run():
|
||||
b = TrailBook()
|
||||
b.on_point("m", "floor1", 1, 1, 1.0)
|
||||
b.on_point("m", "floor2", 2, 2, 2.0)
|
||||
assert b.data["m"]["current"]["map_id"] == "floor2"
|
||||
assert b.data["m"]["previous"]["map_id"] == "floor1"
|
||||
|
||||
|
||||
def test_cap_decimates_but_keeps_the_freshest_point():
|
||||
b = TrailBook()
|
||||
for i in range(TRAIL_CAP + 1):
|
||||
b.on_point("m", "0", float(i), 0.0, float(i))
|
||||
pts = b.data["m"]["current"]["points"]
|
||||
assert len(pts) <= TRAIL_CAP // 2 + 2
|
||||
assert pts[-1] == [float(TRAIL_CAP), 0.0]
|
||||
|
||||
|
||||
def test_junk_store_data_tolerated():
|
||||
b = TrailBook("not a dict")
|
||||
assert b.data == {}
|
||||
b.on_point("m", "0", 1, 1, 1.0)
|
||||
assert b.data["m"]["current"]["points"] == [[1, 1]]
|
||||
Reference in New Issue
Block a user