feat: route live vacuum overlays to the active map space

User-Visible: yes
Issue: #162
This commit is contained in:
Codex
2026-09-03 19:18:13 +03:00
parent 9126e5b430
commit c38501ef50
9 changed files with 294 additions and 21 deletions
+54 -12
View File
@@ -121,6 +121,10 @@ import {
Pt as VacPt, type VacPath, type VacSourceCandidate,
type VacSourceResolution, type VacSourceStatus,
} from './vacuum';
import {
effectiveRoutes, observedMapIds, resolveRoute, planVacuumOverlay,
type VacuumMapRoute, type VacuumRouteResolution,
} from './vacuum-routes';
import {
buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, climateTempFor,
areaTemp, areaHum, effectiveExcludedIntegrations, sourceValue, roomClimateKey, roomClimateMap,
@@ -4792,9 +4796,19 @@ export class HouseplanCard extends LitElement {
const source = this._vacSource(device, planHass);
const telemetry = source ? readVacTelemetry(planHass?.states?.[source]?.attributes) : null;
const runtime = this._vacRt.get(device.id);
// One routing authority per frame: render() must not re-derive which
// map the robot is on, or two answers become possible (#162).
const routes = effectiveRoutes(device.id, device.marker?.vacuum ?? null, device.space, source);
const resolution = resolveRoute({
routes,
observed: observedMapIds(routes, [source], (src) => this._vacObservedMapId(device, src, planHass)),
spaceIds: new Set(this._model.map((space) => space.id)),
});
facts.set(`vacuum:${device.id}`, {
source,
telemetry,
routes,
resolution,
mapId: telemetry ? this._vacMapId(device, telemetry, planHass) : null,
runtime: runtime ? {
trail: runtime.trail,
@@ -11777,7 +11791,7 @@ export class HouseplanCard extends LitElement {
kiosk multipliers still feed --dev-size. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--device-base-size:${iconCqw(deviceBasePct, space, view.w, this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-icon-size:${iconCqw(iconPct, space, this._roomLabelReferenceViewWidth(view), this._kiosk ? this._kioskScale.icon : 1).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderVacuums(devs, view)}
${this._renderVacuums(this._renderDevices, view, space.id)}
${this._renderVacFit(view)}
${this._renderOpeningLocks(view)}
${disp.showNames || this._markup
@@ -12232,6 +12246,12 @@ export class HouseplanCard extends LitElement {
return vacMapIdWithFallback(tele.mapId, sel);
}
/** The map id an exact source reports right now, or undefined if silent. */
private _vacObservedMapId(d: DevItem, source: string, planHass = this._planHass): string | undefined {
const tele = readVacTelemetry(planHass?.states?.[source]?.attributes);
return tele ? this._vacMapId(d, tele, planHass) : undefined;
}
/** Persist a solved matrix into marker.vacuum.calibration[mapId].
* Returns whether the write actually landed — callers must not toast
* success otherwise (HP-1540-01). */
@@ -12327,7 +12347,14 @@ export class HouseplanCard extends LitElement {
}
/** 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 {
/**
* Live overlays for every robot whose ACTIVE map routes into this space.
*
* The device list is deliberately not the space-filtered one: the dock keeps
* living in `marker.space`, but the puck and the trails belong to the space
* of the active route, which is the whole point of #162.
*/
private _renderVacuums(devs: readonly DevItem[], view: { x: number; y: number; w: number; h: number }, spaceId: string): TemplateResult | typeof nothing {
if (this._markup || this._mode === 'decor') return nothing;
const viewKey = this._space + '|' + view.x + '|' + view.y + '|' + view.w + '|' + view.h;
const jumpAll = this._vacJumpOnce || viewKey !== this._vacViewKey;
@@ -12343,30 +12370,45 @@ export class HouseplanCard extends LitElement {
if (!src) continue;
const tele = fact?.telemetry ?? readVacTelemetry(this._renderPlanHass?.states[src]?.attributes);
if (!tele) continue;
const mapNow = String(fact?.mapId ?? this._vacMapId(d, tele, this._renderPlanHass));
const matrix = d.marker?.vacuum?.calibration?.[mapNow] as Affine | undefined;
if (!matrix || matrix.length !== 6) continue;
const routes: VacuumMapRoute[] = fact?.routes
?? effectiveRoutes(d.id, d.marker?.vacuum ?? null, d.space, src);
const resolution: VacuumRouteResolution = fact?.resolution ?? resolveRoute({
routes,
observed: observedMapIds(routes, [src], (source) => this._vacObservedMapId(d, source)),
spaceIds: new Set(this._model.map((space) => space.id)),
});
const srv0 = fact?.server ?? this._vacSrvTrails[d.id];
const plan = planVacuumOverlay({
resolution, routes, renderSpace: spaceId,
rootSource: d.marker?.vacuum?.source ?? null,
serverCurrent: srv0?.current ?? null,
serverPrevious: srv0?.previous ?? null,
explicitRoutes: Array.isArray(d.marker?.vacuum?.map_routes)
&& (d.marker?.vacuum?.map_routes?.length ?? 0) > 0,
});
if (!plan.live && !plan.previous) continue;
const matrix = plan.live;
const rt = fact?.runtime ?? this._vacRt.get(d.id);
const moving = rt?.moving ?? false;
const tmode = vacTrailMode(d.marker?.vacuum);
// owner 2026-07-31: hide when the cleanup is over (default), unless the
// mode says always; the previous run only ever shows in 'always'
const showCur = tmode === 'always' || (tmode === 'cleaning' && moving);
const srv = fact?.server ?? this._vacSrvTrails[d.id];
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;
const srv = srv0;
const srvCur = plan.currentRunMatches && Array.isArray(srv?.current?.points) ? srv.current : null;
const srvPrev = plan.previous && 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 (tmode === 'always' && srvPrev) {
if (tmode === 'always' && srvPrev && plan.previous) {
const previous = normalizeVacPath(srvPrev.points);
const pathD = this._vacTrailPathD(previous, matrix);
const pathD = this._vacTrailPathD(previous, plan.previous);
if (pathD) {
trails.push(svg`<g class="prev"><path class="case" d="${pathD}"></path><path class="core" d="${pathD}"></path></g>`);
}
}
// One arbitration authority: integration multi-subpath → server run →
// local runtime. Only drawable segments participate.
if (showCur) {
if (showCur && matrix) {
const current = resolveCurrentVacPath(tele, srvCur, rt?.trail || []);
// Server/integration paths include the current target. Remove it from
// the final subpath only while moving so the line cannot outrun puck.
@@ -12386,7 +12428,7 @@ export class HouseplanCard extends LitElement {
}
}
}
if (!moving || !tele.pos) continue;
if (!moving || !tele.pos || !matrix) continue;
const [cx, cy] = applyAffine(matrix, tele.pos.x, tele.pos.y);
const point = this._scenePoint([cx, cy]);
const left = ((point[0] - view.x) / view.w) * 100;
+12
View File
@@ -145,8 +145,11 @@ export interface Marker {
trail_mode?: VacuumTrailMode | null;
room_highlight?: boolean | null;
source?: string | null;
/** Legacy-read after #162: `map_id -> matrix`, no source, no space. */
calibration?: Record<string, number[]>;
segment_map?: Record<string, string>;
/** Canonical map->space routing (#162, docs/VACUUM.md). */
map_routes?: VacuumMapRoute[] | null;
} | null;
/** Manual placement into a House Plan room without an HA Area. Room-aware
* consumers resolve it together with `space`; visual placement is not its
@@ -194,6 +197,15 @@ export interface Marker {
use_climate_temp?: boolean | null;
}
/** One saved answer to "this exact map of this exact source lives here" (#162). */
export interface VacuumMapRoute {
id: string;
source: string;
map_id: string;
space: string;
calibration?: [number, number, number, number, number, number] | null;
}
/** A door, window, gate or open passage: plan geometry (normalized coords). */
export interface PartitionOpeningHost {
kind: 'partition';
+79
View File
@@ -250,3 +250,82 @@ export function adoptLegacyRun(
if (candidates.length === 0) return { kind: 'orphan_run' };
return { kind: 'ambiguous_run', routeIds: candidates.map((route) => route.id).sort() };
}
/** Every source worth reading telemetry from, routes plus discovery. */
export function observedMapIds(
routes: VacuumMapRoute[],
extraSources: Array<string | null | undefined>,
read: (source: string) => string | undefined,
): Record<string, string> {
const sources = new Set<string>();
for (const route of routes) if (route.source) sources.add(route.source);
for (const extra of extraSources) if (extra) sources.add(extra);
const observed: Record<string, string> = {};
for (const source of [...sources].sort()) {
const mapId = read(source);
if (mapId !== undefined) observed[source] = mapId;
}
return observed;
}
/** The route a stored run belongs to: its own id first, adoption for old data. */
export function runRoute(
run: { map_id?: unknown; route_id?: unknown } | null | undefined,
routes: VacuumMapRoute[],
rootSource: string | null | undefined,
): VacuumMapRoute | null {
if (!run) return null;
const routeId = typeof run.route_id === 'string' ? run.route_id : '';
if (routeId) return routes.find((route) => route.id === routeId) || null;
const adopted = adoptLegacyRun(run, routes, rootSource);
return adopted.kind === 'adopted' ? adopted.route : null;
}
export interface VacuumOverlayInput {
resolution: VacuumRouteResolution;
routes: VacuumMapRoute[];
/** The space being drawn right now — not the dock's space. */
renderSpace: string;
rootSource?: string | null;
serverCurrent?: { map_id?: unknown; route_id?: unknown } | null;
serverPrevious?: { map_id?: unknown; route_id?: unknown } | null;
/** False for a config that still has only legacy `calibration` (see below). */
explicitRoutes: boolean;
}
export interface VacuumOverlayPlan {
/** Matrix for the live puck and the current trail here, or null. */
live: Affine | null;
/** Whether the stored current run belongs to the route being drawn. */
currentRunMatches: boolean;
/** Matrix for the previous run here, or null. */
previous: Affine | null;
}
/**
* What, if anything, this robot draws in the space currently on screen.
*
* The dock never moves: it stays in `marker.space` and is drawn by the
* ordinary device path. Everything live belongs to the active route's space
* instead, which is what lets floor 2 show the robot while the dock stays on
* floor 1.
*
* The previous run belongs to the space of its own route, so it keeps showing
* where the robot has been after it moved to another map. Legacy configs
* (`explicitRoutes: false`) keep the older, narrower rule — previous run only
* for the map that is active now — because until the user edits routing at
* all, #162 promises the picture does not change.
*/
export function planVacuumOverlay(input: VacuumOverlayInput): VacuumOverlayPlan {
const active = input.resolution.kind === 'ready' ? input.resolution.route : null;
const live = active && active.space === input.renderSpace
? normalizeRouteMatrix(active.calibration) : null;
const currentRoute = runRoute(input.serverCurrent, input.routes, input.rootSource);
const currentRunMatches = !!active && !!currentRoute && currentRoute.id === active.id;
const previousRoute = runRoute(input.serverPrevious, input.routes, input.rootSource);
const previousAllowed = !!previousRoute
&& previousRoute.space === input.renderSpace
&& (input.explicitRoutes || (!!active && previousRoute.id === active.id));
const previous = previousAllowed ? normalizeRouteMatrix(previousRoute!.calibration) : null;
return { live, currentRunMatches, previous };
}