mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
feat: route live vacuum overlays to the active map space
User-Visible: yes Issue: #162
This commit is contained in:
+54
-12
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user