mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
feat: route live vacuum overlays to the active map space
User-Visible: yes Issue: #162
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- A robot vacuum with several maps can now show each map on its own floor: the
|
||||
dock stays in its own space while the live position and the trail appear in
|
||||
the space the active map is assigned to, and a map that is unassigned,
|
||||
ambiguous or uncalibrated draws nothing instead of guessing a floor
|
||||
([#162](https://github.com/Matysh/houseplan-card/issues/162)).
|
||||
- Custom background assets now reject special filesystem entries without
|
||||
blocking Home Assistant, tolerate files disappearing during quota scans and
|
||||
report corrupt images separately from exhausted storage; pen/touch movement
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Робот-пылесос с несколькими картами теперь показывается на своём этаже: док
|
||||
остаётся в своём пространстве, а живое положение и след рисуются в том
|
||||
пространстве, которому назначена активная карта; несопоставленная,
|
||||
неоднозначная или незакалиброванная карта не рисует ничего вместо того, чтобы
|
||||
угадывать этаж ([#162](https://github.com/Matysh/houseplan-card/issues/162)).
|
||||
- Пользовательские изображения подложки теперь отклоняют специальные файловые
|
||||
объекты без зависания Home Assistant, переживают исчезновение файлов во время
|
||||
подсчёта лимита и отличают повреждённое изображение от переполненного
|
||||
|
||||
@@ -291,6 +291,30 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " if (candidates.length >= 1) return { kind: 'adopted', route: candidates[0] };",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'vacuum-overlay-ignores-the-rendered-space',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/vacuum-routes.test.mjs',
|
||||
because: 'the live puck belongs to the ACTIVE ROUTE space, not to every space that '
|
||||
+ 'happens to be on screen: dropping the check draws one robot on both floors (#162)',
|
||||
patches: [{
|
||||
file: 'src/vacuum-routes.ts',
|
||||
find: " const live = active && active.space === input.renderSpace\n ? normalizeRouteMatrix(active.calibration) : null;",
|
||||
replace: ' const live = active ? normalizeRouteMatrix(active.calibration) : null;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'vacuum-previous-run-follows-the-robot',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/vacuum-routes.test.mjs',
|
||||
because: '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 (#162, AC10)',
|
||||
patches: [{
|
||||
file: 'src/vacuum-routes.ts',
|
||||
find: ' const previousAllowed = !!previousRoute\n && previousRoute.space === input.renderSpace',
|
||||
replace: ' const previousAllowed = !!previousRoute\n && !!active && active.space === input.renderSpace',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'area-snapshot-cleanup-ignores-authority',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
+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 };
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ test('Stage 2 uses inert shared-projection SVG geometry without a second light m
|
||||
test('all current floor/live layers remain in the one main scene', () => {
|
||||
for (const renderer of [
|
||||
'_renderDecorLayer()', '_renderRoomHoverFill(roomHover)', '_renderGlowLayer(space, disp)',
|
||||
'_renderSunRays(space)', '_renderOpenings(disp)', '_renderVacuums(devs, view)',
|
||||
'_renderSunRays(space)', '_renderOpenings(disp)',
|
||||
'_renderVacuums(this._renderDevices, view, space.id)',
|
||||
]) assert.ok(card.includes(renderer), `missing ${renderer}`);
|
||||
assert.match(card, /const point = this\._scenePoint\(\[pos\.x, pos\.y\]\)/);
|
||||
assert.match(card, /const point = this\._scenePoint\(\[p\.x, p\.y\]\)/);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
validateMarkerRoutes, effectiveRoutes, legacyRouteId, resolveRoute, adoptLegacyRun,
|
||||
normalizeRouteMatrix, isEntityIdLike, VAC_ROUTE_LIMIT, VAC_ROUTE_ERROR,
|
||||
observedMapIds, runRoute, planVacuumOverlay,
|
||||
} from '../test-build/vacuum-routes.js';
|
||||
|
||||
const fixture = (name) => JSON.parse(readFileSync(
|
||||
@@ -135,3 +136,105 @@ test('run без map id усыновлению не подлежит', () => {
|
||||
assert.equal(adoptLegacyRun({}, [route()], 'camera.robot').kind, 'orphan_run');
|
||||
assert.equal(adoptLegacyRun({ map_id: 1 }, [route()], 'camera.robot').kind, 'orphan_run');
|
||||
});
|
||||
|
||||
const M1 = [1, 0, 0, 0, 1, 0];
|
||||
const M2 = [2, 0, 0, 0, 2, 0];
|
||||
const twoFloors = [
|
||||
{ id: 'r1', source: 'camera.robot', map_id: 'm1', space: 'floor1', calibration: M1 },
|
||||
{ id: 'r2', source: 'camera.robot', map_id: 'm2', space: 'floor2', calibration: M2 },
|
||||
];
|
||||
const ready = (mapId) => resolveRoute({
|
||||
routes: twoFloors, observed: { 'camera.robot': mapId }, spaceIds: spaces('floor1', 'floor2'),
|
||||
});
|
||||
|
||||
test('телеметрия читается по всем источникам маршрутов плюс discovery', () => {
|
||||
const seen = [];
|
||||
const observed = observedMapIds(twoFloors, ['camera.extra', null, ''], (source) => {
|
||||
seen.push(source);
|
||||
return source === 'camera.robot' ? 'm2' : undefined;
|
||||
});
|
||||
assert.deepEqual(seen, ['camera.extra', 'camera.robot']);
|
||||
assert.deepEqual(observed, { 'camera.robot': 'm2' });
|
||||
});
|
||||
|
||||
test('run опознаётся своим route_id, а без него — усыновлением', () => {
|
||||
assert.equal(runRoute({ route_id: 'r2', map_id: 'm1' }, twoFloors, 'camera.robot').id, 'r2');
|
||||
assert.equal(runRoute({ route_id: 'нет такого' }, twoFloors, 'camera.robot'), null);
|
||||
assert.equal(runRoute({ map_id: 'm1' }, twoFloors, 'camera.robot').id, 'r1');
|
||||
assert.equal(runRoute(null, twoFloors, 'camera.robot'), null);
|
||||
});
|
||||
|
||||
test('док остаётся на первом этаже, живой оверлей уезжает на второй (AC2)', () => {
|
||||
const resolution = ready('m2');
|
||||
const onFloor2 = planVacuumOverlay({
|
||||
resolution, routes: twoFloors, renderSpace: 'floor2', explicitRoutes: true,
|
||||
});
|
||||
assert.deepEqual(onFloor2.live, M2);
|
||||
const onFloor1 = planVacuumOverlay({
|
||||
resolution, routes: twoFloors, renderSpace: 'floor1', explicitRoutes: true,
|
||||
});
|
||||
assert.equal(onFloor1.live, null, 'на этаже дока живого робота нет');
|
||||
});
|
||||
|
||||
test('возврат на первую карту возвращает оверлей без правки маршрутов (AC3)', () => {
|
||||
const back = planVacuumOverlay({
|
||||
resolution: ready('m1'), routes: twoFloors, renderSpace: 'floor1', explicitRoutes: true,
|
||||
});
|
||||
assert.deepEqual(back.live, M1);
|
||||
assert.deepEqual(twoFloors[0].calibration, M1, 'маршруты не переписаны');
|
||||
});
|
||||
|
||||
test('неоднозначность и незакалиброванный маршрут не рисуют ничего', () => {
|
||||
for (const resolution of [
|
||||
{ kind: 'ambiguous', routeIds: ['r1', 'r2'] },
|
||||
{ kind: 'needs_calibration', route: twoFloors[0] },
|
||||
{ kind: 'unmapped', source: 'camera.robot', mapId: 'm9' },
|
||||
{ kind: 'missing_space', route: twoFloors[1] },
|
||||
{ kind: 'none' },
|
||||
]) {
|
||||
for (const renderSpace of ['floor1', 'floor2']) {
|
||||
const plan = planVacuumOverlay({ resolution, routes: twoFloors, renderSpace, explicitRoutes: true });
|
||||
assert.equal(plan.live, null, `${resolution.kind} / ${renderSpace}`);
|
||||
assert.equal(plan.currentRunMatches, false, resolution.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('прошлый прогон остаётся в пространстве своего маршрута (AC10)', () => {
|
||||
const plan = planVacuumOverlay({
|
||||
resolution: ready('m2'), routes: twoFloors, renderSpace: 'floor1', explicitRoutes: true,
|
||||
rootSource: 'camera.robot',
|
||||
serverPrevious: { route_id: 'r1', map_id: 'm1', points: [] },
|
||||
});
|
||||
assert.deepEqual(plan.previous, M1, 'прошлый прогон виден на своём этаже');
|
||||
assert.equal(plan.live, null, 'а робот при этом здесь не рисуется');
|
||||
});
|
||||
|
||||
test('текущий серверный прогон принимается только от активного маршрута', () => {
|
||||
const base = {
|
||||
resolution: ready('m2'), routes: twoFloors, renderSpace: 'floor2',
|
||||
explicitRoutes: true, rootSource: 'camera.robot',
|
||||
};
|
||||
assert.equal(planVacuumOverlay({ ...base, serverCurrent: { route_id: 'r2' } }).currentRunMatches, true);
|
||||
assert.equal(planVacuumOverlay({ ...base, serverCurrent: { route_id: 'r1' } }).currentRunMatches, false);
|
||||
assert.equal(planVacuumOverlay({ ...base, serverCurrent: { map_id: 'm1' } }).currentRunMatches, false);
|
||||
assert.equal(planVacuumOverlay({ ...base, serverCurrent: { map_id: 'm2' } }).currentRunMatches, true);
|
||||
});
|
||||
|
||||
test('легаси-конфиг сохраняет прежнее правило прошлого прогона (AC13)', () => {
|
||||
const marker = { source: 'camera.robot', calibration: { m1: M1, m2: M2 } };
|
||||
const routes = effectiveRoutes('mk', marker, 'floor1');
|
||||
const resolution = resolveRoute({
|
||||
routes, observed: { 'camera.robot': 'm2' }, spaceIds: spaces('floor1'),
|
||||
});
|
||||
const legacy = planVacuumOverlay({
|
||||
resolution, routes, renderSpace: 'floor1', explicitRoutes: false,
|
||||
rootSource: 'camera.robot', serverPrevious: { map_id: 'm1' },
|
||||
});
|
||||
assert.equal(legacy.previous, null, 'прошлый прогон другой карты по-прежнему скрыт');
|
||||
const same = planVacuumOverlay({
|
||||
resolution, routes, renderSpace: 'floor1', explicitRoutes: false,
|
||||
rootSource: 'camera.robot', serverPrevious: { map_id: 'm2' },
|
||||
});
|
||||
assert.deepEqual(same.previous, M2, 'прогон активной карты виден, как и раньше');
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""vacuum_routes.py — питоновское зеркало src/vacuum-routes.ts (#162).
|
||||
|
||||
Модуль чистый и Home Assistant не тянет, поэтому импортируется напрямую:
|
||||
sys.path/sys.modules не трогаются намеренно (класс #389).
|
||||
"""
|
||||
"""vacuum_routes.py — питоновское зеркало src/vacuum-routes.ts (#162)."""
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import re
|
||||
import types
|
||||
|
||||
ROOT = pathlib.Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(ROOT / "custom_components" / "houseplan"))
|
||||
import vacuum_routes as vr # noqa: E402
|
||||
# Модуль читается текстом и исполняется в собственном пространстве имён: ни
|
||||
# sys.path, ни sys.modules не трогаются намеренно (#393, класс #389).
|
||||
_source = (ROOT / "custom_components" / "houseplan" / "vacuum_routes.py").read_text(
|
||||
encoding="utf-8")
|
||||
vr = types.ModuleType("vacuum_routes_under_test")
|
||||
vr.__dict__["re"] = re
|
||||
exec(compile(_source, "vacuum_routes.py", "exec"), vr.__dict__)
|
||||
|
||||
FIXTURES = ROOT / "test" / "fixtures" / "vacuum-routes"
|
||||
IDENTITY = [1, 0, 0, 0, 1, 0]
|
||||
|
||||
Reference in New Issue
Block a user