diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 284babf9..9897b0dc 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index 816bd869..3d475a90 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -8,6 +8,11 @@
## Не выпущено
+- Робот-пылесос с несколькими картами теперь показывается на своём этаже: док
+ остаётся в своём пространстве, а живое положение и след рисуются в том
+ пространстве, которому назначена активная карта; несопоставленная,
+ неоднозначная или незакалиброванная карта не рисует ничего вместо того, чтобы
+ угадывать этаж ([#162](https://github.com/Matysh/houseplan-card/issues/162)).
- Пользовательские изображения подложки теперь отклоняют специальные файловые
объекты без зависания Home Assistant, переживают исчезновение файлов во время
подсчёта лимита и отличают повреждённое изображение от переполненного
diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs
index ea9b4336..8fd13ba2 100644
--- a/scripts/mutation-gate.mjs
+++ b/scripts/mutation-gate.mjs
@@ -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 '
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 6bc3f5d4..0f399803 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -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. */}
${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`
`);
}
}
// 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;
diff --git a/src/types.ts b/src/types.ts
index cb239fc1..5a94c46d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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
;
segment_map?: Record;
+ /** 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';
diff --git a/src/vacuum-routes.ts b/src/vacuum-routes.ts
index f09c241e..54f2a928 100644
--- a/src/vacuum-routes.ts
+++ b/src/vacuum-routes.ts
@@ -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,
+ read: (source: string) => string | undefined,
+): Record {
+ const sources = new Set();
+ 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 = {};
+ 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 };
+}
diff --git a/test/isometric-contract.test.mjs b/test/isometric-contract.test.mjs
index 862bf57d..124e9df0 100644
--- a/test/isometric-contract.test.mjs
+++ b/test/isometric-contract.test.mjs
@@ -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\]\)/);
diff --git a/test/vacuum-routes.test.mjs b/test/vacuum-routes.test.mjs
index 0f60bb34..437c2d5f 100644
--- a/test/vacuum-routes.test.mjs
+++ b/test/vacuum-routes.test.mjs
@@ -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, 'прогон активной карты виден, как и раньше');
+});
diff --git a/tests_backend/test_vacuum_routes.py b/tests_backend/test_vacuum_routes.py
index 77279b5d..4d816c92 100644
--- a/tests_backend/test_vacuum_routes.py
+++ b/tests_backend/test_vacuum_routes.py
@@ -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]