feat: assign each robot map to a floor in the device editor

User-Visible: yes
Issue: #162
This commit is contained in:
Codex
2026-09-03 19:18:13 +03:00
parent fec99b907b
commit ca6ebc3035
38 changed files with 2169 additions and 1579 deletions
+181
View File
@@ -0,0 +1,181 @@
/**
* Device editor: "Maps and floors" for a multi-floor robot (#162).
*
* Lives outside the editor runtime on purpose. The runtime is at its size
* ceiling (test/core-file-budget.test.mjs), and this block is only ever needed
* with an opened device dialog, so it belongs in the lazy editor graph rather
* than in a core file.
*/
import { TemplateResult, html, nothing } from 'lit';
import { langOf } from '../i18n';
import { supportT, type SupportI18nKey } from '../i18n/support';
import { VacuumMapRoute, effectiveRoutes, observedMapIds, resolveRoute } from '../vacuum-routes';
import {
addRoute, changeRouteSpace, convertLegacyRoutes, newRouteId, removeRoute,
} from '../vacuum-route-edit';
export interface VacuumMapsHost {
host: any;
_saveConfig: () => void;
_vacAutoCalibrate: (dev: any) => void;
_vacStartFit: (dev: any, routeId?: string) => void;
}
const spaceName = (host: any, spaceId: string): string => {
const space = (host._serverCfg?.spaces || []).find((item: any) => item?.id === spaceId);
return String(space?.name || space?.id || spaceId);
};
/**
* Render the block, and own every routing edit it offers.
*
* `setVac` is the same patch writer the rest of the vacuum section uses, so a
* routing change goes through one save path with everything else.
*/
export function renderVacuumMapsSection(
runtime: VacuumMapsHost, dev: any, setVac: (patch: Record<string, unknown>) => void,
): TemplateResult | typeof nothing {
const host = runtime.host;
const lang = langOf(host.hass, host._config?.language);
const t = (key: string, vars?: Record<string, string>) => supportT(
lang, key as SupportI18nKey, vars);
const vacuum = dev.marker?.vacuum || {};
const explicit = Array.isArray(vacuum.map_routes) && vacuum.map_routes.length > 0;
const rootSource: string = host._vacSource(dev) || '';
const routes: VacuumMapRoute[] = effectiveRoutes(dev.id, vacuum, dev.space, rootSource);
const spaces: Array<{ id: string; name: string }> = (host._serverCfg?.spaces || [])
.filter((space: any) => typeof space?.id === 'string' && space.id)
.map((space: any) => ({ id: space.id, name: String(space.name || space.id) }));
const spaceIds = new Set(spaces.map((space) => space.id));
const observed = observedMapIds(routes, [rootSource],
(source) => host._vacObservedMapId(dev, source));
const resolution = resolveRoute({ routes, observed, spaceIds });
const activeId = (resolution.kind === 'ready' || resolution.kind === 'needs_calibration'
|| resolution.kind === 'missing_space') ? resolution.route.id : '';
const currentMapId = rootSource ? observed[rootSource] : undefined;
/** Every routing edit converts legacy data first — all of it, or none. */
const writeRoutes = (next: (current: VacuumMapRoute[]) => VacuumMapRoute[]): boolean => {
let base: VacuumMapRoute[] | null = explicit ? vacuum.map_routes : null;
if (!base) {
base = convertLegacyRoutes(vacuum, dev.space, rootSource,
(taken) => newRouteId(taken)) ?? [];
if (!rootSource && Object.keys(vacuum.calibration || {}).length) return false;
}
setVac({ map_routes: next(base), calibration: undefined });
return true;
};
const takenIds = () => new Set(routes.map((route) => route.id));
const addCurrent = () => {
if (!rootSource || currentMapId === undefined) return;
writeRoutes((current) => addRoute(
current,
{
source: rootSource,
map_id: currentMapId,
space: current.length ? '' : dev.space,
},
newRouteId(takenIds()),
));
};
const retarget = async (route: VacuumMapRoute, space: string) => {
if (!space || space === route.space) return;
const accepted = await host._confirmDanger({
key: 'vacuum_route_space',
kind: 'warning',
title: t('vac.route_space_title'),
message: t('vac.route_space_body'),
objectName: route.map_id || t('vac.route_map_default'),
confirmLabel: t('vac.route_space_confirm'),
cancelLabel: host._t('btn.cancel'),
});
if (!accepted) return;
writeRoutes((current) => changeRouteSpace(current, route.id, space, newRouteId(takenIds())));
};
const drop = async (route: VacuumMapRoute) => {
const accepted = await host._confirmDanger({
key: 'vacuum_route_delete',
kind: 'danger',
title: t('vac.route_delete_title'),
message: t('vac.route_delete_body'),
objectName: route.map_id || t('vac.route_map_default'),
confirmLabel: t('vac.route_delete_confirm'),
cancelLabel: host._t('btn.cancel'),
});
if (!accepted) return;
writeRoutes((current) => removeRoute(current, route.id));
};
// Space order, then map id, then source: a stable reading order that never
// depends on the order the routes happen to be stored in.
const order = new Map(spaces.map((space, index) => [space.id, index]));
const rows = [...routes].sort((a, b) => (order.get(a.space) ?? 1e6) - (order.get(b.space) ?? 1e6)
|| a.map_id.localeCompare(b.map_id) || a.source.localeCompare(b.source));
const statusOf = (route: VacuumMapRoute): string => {
if (!spaceIds.has(route.space)) return t('vac.route_status_missing_space');
if (!route.calibration) return t('vac.route_status_needs_calibration');
if (resolution.kind === 'ambiguous' && resolution.routeIds.includes(route.id)) {
return t('vac.route_status_ambiguous');
}
return t('vac.route_status_ready');
};
const canAddCurrent = !!rootSource && currentMapId !== undefined
&& !routes.some((route) => route.source === rootSource && route.map_id === currentMapId);
return html`
<label>${t('vac.routes_section')}</label>
<div class="bindbox vacroutes">
<div class="vacdiag" role="status">
<div><span>${t('vac.routes_current_map')}</span><b>${currentMapId === undefined
? t('vac.routes_no_map') : (currentMapId || t('vac.route_map_default'))}</b></div>
<div><span>${t('vac.routes_status')}</span><b>${t(`vac.routes_state_${resolution.kind}`)}</b></div>
</div>
${rows.length ? html`<ul class="vacroute-list">
${rows.map((route) => html`
<li class="vacroute ${route.id === activeId ? 'on' : ''}">
<div class="vacroute-head">
<b>${route.map_id || t('vac.route_map_default')}</b>
${route.id === activeId
? html`<span class="vacroute-active">${t('vac.route_active')}</span>` : nothing}
</div>
<small>${route.source}</small>
<div class="vacroute-status">${statusOf(route)}</div>
<label class="srcrow">
<span>${t('vac.route_space')}</span>
<select class="areasel" @change=${(event: Event) => {
const select = event.target as HTMLSelectElement;
const next = select.value;
select.value = route.space;
void retarget(route, next);
}}>
<option value="" ?selected=${!route.space}>${t('vac.route_space_none')}</option>
${spaces.map((space) => html`
<option value=${space.id} ?selected=${space.id === route.space}>${space.name}</option>`)}
</select>
</label>
<div class="vacbtns">
<button type="button" class="btn ghostbtn"
@click=${() => runtime._vacStartFit(dev, route.id)}>${host._t('vac.fit')}</button>
<button type="button" class="btn ghostbtn danger"
@click=${() => void drop(route)}>${t('vac.route_delete')}</button>
</div>
</li>`)}
</ul>` : html`<div class="rhint">${t('vac.routes_empty')}</div>`}
<div class="vacbtns">
<button type="button" class="btn" ?disabled=${!canAddCurrent}
@click=${addCurrent}>${t('vac.route_add_current')}</button>
</div>
${rows.some((route) => !spaceIds.has(route.space)) ? html`
<div class="warn">${t('vac.route_missing_space_hint')}</div>` : nothing}
${routes.some((route) => route.map_id === 'default') ? html`
<div class="rhint">${t('vac.route_default_hint')}</div>` : nothing}
${rows.length ? html`<div class="rhint">${t('vac.route_target_hint', {
space: spaceName(host, dev.space),
})}</div>` : nothing}
</div>`;
}
+1 -1
View File
@@ -2503,7 +2503,7 @@ export class HouseplanCard extends LitElement {
};
private _resumeSettling = false;
private _viewportInvalidAt = 0;
private _vacFit: { markerId: string; source: string; mapId: string; p: FitParams;
private _vacFit: { markerId: string; source: string; mapId: string; routeId?: string; p: FitParams;
drag: null | { kind: 'move' | 'scale'; sx: number; sy: number; p0: FitParams;
fx: number; fy: number } } | null = null;
/** Marker whose lazy «All cameras» candidate section is expanded. */
+19 -18
View File
@@ -8,6 +8,8 @@
*/
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
import { guard } from 'lit/directives/guard.js';
import { renderVacuumMapsSection } from './editors/vacuum-maps-section';
import { planVacuumFit, writeVacuumMatrix } from './vacuum-route-edit';
import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog';
import type { HpDialog } from './hp-dialog';
@@ -1149,7 +1151,7 @@ export interface HouseplanEditorHostPort {
_vacEnsureMarker: (d: DevItem) => Marker | null;
_vacEntity: (d: DevItem) => string | null;
_vacMapId: (d: DevItem, tele: { mapId: string }, planHass?: any) => string;
_vacFit: { markerId: string; source: string; mapId: string; p: FitParams; drag: null | { kind: "move" | "scale"; sx: number; sy: number; p0: FitParams; fx: number; fy: number; }; } | null;
_vacFit: { markerId: string; source: string; mapId: string; routeId?: string; p: FitParams; drag: null | { kind: "move" | "scale"; sx: number; sy: number; p0: FitParams; fx: number; fy: number; }; } | null;
_vacOpenAllCameras: (d: DevItem) => void;
_vacRt: Map<string, { trail: VacPt[]; lastKey: string; lastTs: number; moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | null; }>;
_vacSource: (d: DevItem, planHass?: any) => string | null;
@@ -10850,7 +10852,6 @@ public _renderVacSection(dlg: any): TemplateResult | typeof nothing {
const roomMatches = tele
? vacRoomNameMatchCount(tele.rooms, planRooms.map((room) => room.name)) : 0;
const tierA = !!(canUseSource && tele && roomMatches >= 3);
const cals = Object.keys(v.calibration || {});
const setVac = (patch: Record<string, unknown>) => {
// HP-1540-01: materialise the marker first — find() alone silently
// dropped every edit for a vacuum that had never been saved
@@ -10967,7 +10968,7 @@ public _renderVacSection(dlg: any): TemplateResult | typeof nothing {
${(['never', 'cleaning', 'always'] as const).map((mv) => html`
<option value=${mv} ?selected=${vacTrailMode(v) === mv}>${this.host._t(('vac.trail_' + mv) as any)}</option>`)}
</select>
${cals.length ? html`<div class="rhint">${subst(this.host._t('vac.cal_maps'), { maps: cals.join(', ') })}</div>` : nothing}
${renderVacuumMapsSection(this, dev, setVac)}
` : nothing}
</div>`;
}
@@ -10978,16 +10979,17 @@ public _vacMapId(d: DevItem, tele: { mapId: string }, planHass = this.host._plan
return this.host._vacMapId(d, tele, planHass);
}
public _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine): boolean {
public _vacSaveMatrix(
markerId: string, source: string, mapId: string, matrix: Affine, routeId = '',
): boolean {
// HP-1540-01: a first-use vacuum has no marker yet — materialise it
const dev = this.host._devices.find((x) => x.id === markerId);
const m = dev ? this.host._vacEnsureMarker(dev)
: this.host._serverCfg?.markers?.find((x: Marker) => x.id === markerId);
if (!m) return false;
const v = { ...(m.vacuum || {}) };
v.source = source;
v.calibration = { ...(v.calibration || {}), [mapId]: matrix.map((n) => Number(n.toFixed(6))) };
m.vacuum = v;
// #162: with explicit routes the matrix belongs to the route, not to a
// marker-wide dictionary that cannot tell two floors apart.
m.vacuum = writeVacuumMatrix(m.vacuum || {}, { source, mapId, routeId, matrix }) as any;
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this._saveConfig();
@@ -11061,7 +11063,7 @@ public _vacApplyCalibrationProposal(manual: boolean): void {
)) this.host._showToast(subst(this.host._t('vac.autocal_done'), { rooms: String(proposal.rooms) }));
}
public _vacStartFit(d: DevItem): void {
public _vacStartFit(d: DevItem, routeId = ''): void {
const src = this.host._vacSource(d);
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
if (!src || !tele) {
@@ -11069,22 +11071,21 @@ public _vacStartFit(d: DevItem): void {
return;
}
const mapId = this._vacMapId(d, tele);
const existing = d.marker?.vacuum?.calibration?.[mapId] as Affine | undefined;
const sp = this.host._spaceModelById(d.space);
if (!sp) return;
const vb = sp.vb as [number, number, number, number];
const p = (existing && existing.length === 6 && fitFromMatrix(existing))
|| initialFit(tele.rooms, vb);
const plan = planVacuumFit(d.id, d.marker?.vacuum ?? null, {
routeId, source: src, mapId, dockSpace: d.space, rooms: tele.rooms,
viewBoxOf: (id) => (this.host._spaceModelById(id)?.vb as [number, number, number, number]) ?? null,
});
if (!plan) return;
this.host._markerDialog = null;
if (d.space !== this.host._space && !this.host._commitSpace(d.space)) return;
this.host._vacFit = { markerId: d.id, source: src, mapId, p, drag: null };
if (plan.space !== this.host._space && !this.host._commitSpace(plan.space)) return;
this.host._vacFit = { markerId: d.id, source: src, mapId, routeId: plan.routeId, p: plan.params, drag: null };
}
public _vacFitSave(): void {
const f = this.host._vacFit;
if (!f) return;
// HP-1540-01: no success toast for a save that did not happen
const ok = this._vacSaveMatrix(f.markerId, f.source, f.mapId, fitMatrix(f.p));
const ok = this._vacSaveMatrix(f.markerId, f.source, f.mapId, fitMatrix(f.p), f.routeId);
this.host._vacFit = null;
if (ok) this.host._showToast(this.host._t('vac.cal_done'));
}
-1
View File
@@ -814,7 +814,6 @@
"vac.autocal": "Automatisch einrichten",
"vac.live": "Aktuelle Position im Plan",
"vac.trail": "Roboterpfad anzeigen",
"vac.cal_maps": "Kalibrierte Karten: {maps}",
"vac.autocal_no_rooms": "Die Integration meldet keine Raumliste — öffnen Sie \"Manuell anpassen\"",
"vac.autocal_no_match": "Raumnamen stimmten nicht überein (mindestens 3 müssen übereinstimmen) — öffnen Sie \"Manuell anpassen\"",
"vac.autocal_done": "Fertig: über {rooms} Räume zugeordnet. Eine Reinigung starten und das Ergebnis prüfen.",
-1
View File
@@ -814,7 +814,6 @@
"vac.autocal": "Set up automatically",
"vac.live": "Live position on the plan",
"vac.trail": "Show the robot's path",
"vac.cal_maps": "Calibrated maps: {maps}",
"vac.autocal_no_rooms": "The integration reports no room list — open “Fit manually”",
"vac.autocal_no_match": "Room names did not match (need ≥3 in common) — open “Fit manually”",
"vac.autocal_done": "Done: bound via {rooms} rooms. Start a cleanup and check",
-1
View File
@@ -814,7 +814,6 @@
"vac.autocal": "Configurer automatiquement",
"vac.live": "Position en direct sur le plan",
"vac.trail": "Afficher le trajet du robot",
"vac.cal_maps": "Cartes calibrées : {maps}",
"vac.autocal_no_rooms": "L’intégration ne fournit aucune liste de pièces — ouvrez « Ajuster manuellement »",
"vac.autocal_no_match": "Les noms de pièces ne correspondent pas (il en faut ≥3 en commun) — ouvrez « Ajuster manuellement »",
"vac.autocal_done": "Terminé : association via {rooms} pièces. Lancez un nettoyage et vérifiez",
-1
View File
@@ -814,7 +814,6 @@
"vac.autocal": "Настроить автоматически",
"vac.live": "Живая позиция на плане",
"vac.trail": "Показывать путь робота",
"vac.cal_maps": "Откалиброваны карты: {maps}",
"vac.autocal_no_rooms": "Интеграция не отдаёт список комнат — откройте «Подогнать вручную»",
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — откройте «Подогнать вручную»",
"vac.autocal_done": "Готово: привязка по {rooms} комнатам. Запустите уборку и проверьте",
+31 -1
View File
@@ -42,5 +42,35 @@
"support.error.support_rate_limited": "Zu viele Berichte oder Vorschauen wurden angefordert. Versuchen Sie es später erneut.",
"support.error.support_unavailable": "Der private Supportdienst ist vorübergehend nicht verfügbar. Der Entwurf bleibt in diesem Fenster erhalten.",
"support.error.support_rejected": "Der Bericht hat die Prüfung des Supportdienstes nicht bestanden.",
"support.error.unauthorized": "Sie dürfen diesen Bericht nicht senden."
"support.error.unauthorized": "Sie dürfen diesen Bericht nicht senden.",
"vac.routes_section": "Karten und Etagen",
"vac.routes_current_map": "Aktuelle Karte",
"vac.routes_no_map": "keine Telemetrie",
"vac.routes_status": "Zuordnung",
"vac.routes_state_ready": "Karte zugeordnet und kalibriert",
"vac.routes_state_needs_calibration": "Karte zugeordnet, Kalibrierung nötig",
"vac.routes_state_unmapped": "aktuelle Karte keiner Etage zugeordnet",
"vac.routes_state_ambiguous": "zwei Karten nicht unterscheidbar — keine Etage gewählt",
"vac.routes_state_missing_space": "der Raum der Karte wurde gelöscht",
"vac.routes_state_none": "keine Kartendaten",
"vac.routes_empty": "Noch keine Karte einer Etage zugeordnet.",
"vac.route_map_default": "ohne Karten-ID",
"vac.route_active": "Aktiv",
"vac.route_space": "Raum",
"vac.route_space_none": "nicht gewählt",
"vac.route_status_ready": "bereit",
"vac.route_status_needs_calibration": "Kalibrierung nötig",
"vac.route_status_ambiguous": "mehrdeutig",
"vac.route_status_missing_space": "Raum gelöscht",
"vac.route_add_current": "Aktuelle Karte hinzufügen",
"vac.route_delete": "Karte entfernen",
"vac.route_delete_title": "Karte von der Etage entfernen?",
"vac.route_delete_body": "Kalibrierung und gespeicherte Pfade dieser Karte werden entfernt. Fortfahren?",
"vac.route_delete_confirm": "Entfernen",
"vac.route_space_title": "Karte in einen anderen Raum verschieben?",
"vac.route_space_body": "Kalibrierung und gespeicherte Pfade dieser Karte werden entfernt. Fortfahren?",
"vac.route_space_confirm": "Verschieben",
"vac.route_missing_space_hint": "Eine Karte verweist auf einen gelöschten Raum: neuen wählen oder Karte entfernen. Bis dahin wird der Roboter für diese Karte nicht gezeichnet.",
"vac.route_default_hint": "Die Quelle meldet keine stabile Karten-ID. Mehrere Etagen lassen sich ohne andere Quelle oder die vom Roboter gewählte Karte nicht unterscheiden.",
"vac.route_target_hint": "Die Station bleibt unabhängig von der aktuellen Karte im Raum „{space}“."
}
+31 -1
View File
@@ -42,5 +42,35 @@
"support.error.support_rate_limited": "Too many reports or previews were requested. Try again later.",
"support.error.support_unavailable": "The private support service is temporarily unavailable. Your draft remains here.",
"support.error.support_rejected": "The report did not pass the support service validation.",
"support.error.unauthorized": "You do not have permission to send this report."
"support.error.unauthorized": "You do not have permission to send this report.",
"vac.routes_section": "Maps and floors",
"vac.routes_current_map": "Current map",
"vac.routes_no_map": "no telemetry",
"vac.routes_status": "Routing",
"vac.routes_state_ready": "map assigned and calibrated",
"vac.routes_state_needs_calibration": "map assigned, calibration needed",
"vac.routes_state_unmapped": "current map is not assigned to a floor",
"vac.routes_state_ambiguous": "two maps are indistinguishable — no floor is chosen",
"vac.routes_state_missing_space": "the map's space was deleted",
"vac.routes_state_none": "no map data",
"vac.routes_empty": "No map is assigned to a floor yet.",
"vac.route_map_default": "no map id",
"vac.route_active": "Active",
"vac.route_space": "Space",
"vac.route_space_none": "not selected",
"vac.route_status_ready": "ready",
"vac.route_status_needs_calibration": "needs calibration",
"vac.route_status_ambiguous": "ambiguous",
"vac.route_status_missing_space": "space deleted",
"vac.route_add_current": "Add current map",
"vac.route_delete": "Remove map",
"vac.route_delete_title": "Remove the map from its floor?",
"vac.route_delete_body": "Calibration and saved paths for this map will be removed. Continue?",
"vac.route_delete_confirm": "Remove",
"vac.route_space_title": "Move the map to another space?",
"vac.route_space_body": "Calibration and saved paths for this map will be removed. Continue?",
"vac.route_space_confirm": "Move",
"vac.route_missing_space_hint": "One of the maps points at a deleted space: pick a new one or remove the map. Until then the robot is not drawn for that map.",
"vac.route_default_hint": "The source reports no stable map id. Several floors cannot be told apart without another source or the robot's own selected map.",
"vac.route_target_hint": "The dock stays in the “{space}” space regardless of the current map."
}
+31 -1
View File
@@ -42,5 +42,35 @@
"support.error.support_rate_limited": "Trop de rapports ou d’aperçus ont été demandés. Réessayez plus tard.",
"support.error.support_unavailable": "Le service d’assistance privé est temporairement indisponible. Le brouillon reste dans cette fenêtre.",
"support.error.support_rejected": "Le rapport n’a pas passé la validation du service d’assistance.",
"support.error.unauthorized": "Vous n’êtes pas autorisé à envoyer ce rapport."
"support.error.unauthorized": "Vous n’êtes pas autorisé à envoyer ce rapport.",
"vac.routes_section": "Cartes et étages",
"vac.routes_current_map": "Carte actuelle",
"vac.routes_no_map": "aucune télémétrie",
"vac.routes_status": "Routage",
"vac.routes_state_ready": "carte assignée et calibrée",
"vac.routes_state_needs_calibration": "carte assignée, calibrage requis",
"vac.routes_state_unmapped": "carte actuelle non assignée à un étage",
"vac.routes_state_ambiguous": "deux cartes indiscernables — aucun étage choisi",
"vac.routes_state_missing_space": "l'espace de la carte a été supprimé",
"vac.routes_state_none": "aucune donnée de carte",
"vac.routes_empty": "Aucune carte n'est encore assignée à un étage.",
"vac.route_map_default": "sans identifiant",
"vac.route_active": "Active",
"vac.route_space": "Espace",
"vac.route_space_none": "non sélectionné",
"vac.route_status_ready": "prête",
"vac.route_status_needs_calibration": "calibrage requis",
"vac.route_status_ambiguous": "ambiguë",
"vac.route_status_missing_space": "espace supprimé",
"vac.route_add_current": "Ajouter la carte actuelle",
"vac.route_delete": "Supprimer la carte",
"vac.route_delete_title": "Retirer la carte de son étage ?",
"vac.route_delete_body": "Le calibrage et les trajets enregistrés de cette carte seront supprimés. Continuer ?",
"vac.route_delete_confirm": "Supprimer",
"vac.route_space_title": "Déplacer la carte vers un autre espace ?",
"vac.route_space_body": "Le calibrage et les trajets enregistrés de cette carte seront supprimés. Continuer ?",
"vac.route_space_confirm": "Déplacer",
"vac.route_missing_space_hint": "Une carte pointe vers un espace supprimé : choisissez-en un autre ou supprimez la carte. D'ici là, le robot n'est pas dessiné pour cette carte.",
"vac.route_default_hint": "La source ne fournit pas d'identifiant de carte stable. Plusieurs étages ne peuvent pas être distingués sans une autre source ou la carte sélectionnée du robot.",
"vac.route_target_hint": "La station reste dans l'espace « {space} » quelle que soit la carte actuelle."
}
+31 -1
View File
@@ -42,5 +42,35 @@
"support.error.support_rate_limited": "Запрошено слишком много репортов или предпросмотров. Повторите позже.",
"support.error.support_unavailable": "Закрытая служба поддержки временно недоступна. Черновик сохранён в этом окне.",
"support.error.support_rejected": "Репорт не прошёл проверку службы поддержки.",
"support.error.unauthorized": "У вас нет прав на отправку этого репорта."
"support.error.unauthorized": "У вас нет прав на отправку этого репорта.",
"vac.routes_section": "Карты и этажи",
"vac.routes_current_map": "Текущая карта",
"vac.routes_no_map": "источник молчит",
"vac.routes_status": "Маршрутизация",
"vac.routes_state_ready": "карта сопоставлена и откалибрована",
"vac.routes_state_needs_calibration": "карта сопоставлена, нужна калибровка",
"vac.routes_state_unmapped": "текущая карта не сопоставлена этажу",
"vac.routes_state_ambiguous": "две карты неразличимы — этаж не выбирается",
"vac.routes_state_missing_space": "пространство карты удалено",
"vac.routes_state_none": "нет данных о карте",
"vac.routes_empty": "Ни одна карта ещё не сопоставлена этажу.",
"vac.route_map_default": "без идентификатора",
"vac.route_active": "Текущая",
"vac.route_space": "Пространство",
"vac.route_space_none": "не выбрано",
"vac.route_status_ready": "готова",
"vac.route_status_needs_calibration": "нужна калибровка",
"vac.route_status_ambiguous": "неоднозначна",
"vac.route_status_missing_space": "пространство удалено",
"vac.route_add_current": "Добавить текущую карту",
"vac.route_delete": "Удалить карту",
"vac.route_delete_title": "Удалить карту с этажа?",
"vac.route_delete_body": "Калибровка и сохранённые пути этой карты будут удалены. Продолжить?",
"vac.route_delete_confirm": "Удалить",
"vac.route_space_title": "Перенести карту в другое пространство?",
"vac.route_space_body": "Калибровка и сохранённые пути этой карты будут удалены. Продолжить?",
"vac.route_space_confirm": "Перенести",
"vac.route_missing_space_hint": "Одна из карт ссылается на удалённое пространство: выберите новое или удалите карту. Пока ссылка не исправлена, робот на этой карте не рисуется.",
"vac.route_default_hint": "Источник не сообщает стабильный идентификатор карты. Несколько этажей нельзя различить без другого источника или собственной карты робота.",
"vac.route_target_hint": "Док остаётся в пространстве «{space}» независимо от текущей карты."
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Editor-only half of vacuum map routing (#162).
*
* Adding, re-targeting and deleting routes only ever happens in the device
* editor, so these helpers live in the lazy editor graph: the View card must
* not pay for code it can never run (docs/ARCHITECTURE.md, #367).
*/
import { FitParams, fitFromMatrix, initialFit, VacRoom } from './vacuum';
import {
Affine, VacuumMapRoute, VacuumRouteMarkerCfg, effectiveRoutes, normalizeRouteMatrix,
} from './vacuum-routes';
/** A marker-local id that no existing route uses. */
export function newRouteId(existing: Iterable<string>, seed: () => number = Math.random): string {
const taken = new Set(existing);
for (let attempt = 0; attempt < 64; attempt += 1) {
const id = 'vr_' + Math.floor(seed() * 0xffffff).toString(36).padStart(5, '0');
if (!taken.has(id)) return id;
}
return 'vr_' + Date.now().toString(36);
}
export function addRoute(
routes: VacuumMapRoute[], route: Omit<VacuumMapRoute, 'id'>, id: string,
): VacuumMapRoute[] {
return [...routes, { ...route, id, calibration: route.calibration ?? null }];
}
export function removeRoute(routes: VacuumMapRoute[], routeId: string): VacuumMapRoute[] {
return routes.filter((route) => route.id !== routeId);
}
/**
* Re-target a route to another space.
*
* A new space is a NEW identity, not an edited one: the calibration was solved
* against the old space's geometry and the recorded runs were filed under the
* old id, so both must go rather than silently apply to a different floor.
*/
export function changeRouteSpace(
routes: VacuumMapRoute[], routeId: string, space: string, newId: string,
): VacuumMapRoute[] {
return routes.map((route) => (route.id === routeId
? { ...route, id: newId, space, calibration: null } : route));
}
export function saveRouteCalibration(
routes: VacuumMapRoute[], routeId: string, matrix: Affine,
): VacuumMapRoute[] {
return routes.map((route) => (route.id === routeId ? { ...route, calibration: matrix } : route));
}
/**
* Turn a legacy `calibration` dictionary into explicit routes — all of it.
*
* Converting only the map the user happens to be looking at would drop the
* other floors' calibrations on the first routing edit, so this is all or
* nothing: without an exact source there is no conversion at all and the
* caller must ask for one first (spec 7.3).
*/
export function convertLegacyRoutes(
marker: VacuumRouteMarkerCfg | null | undefined,
dockSpace: string,
source: string,
nextId: (taken: Set<string>) => string,
): VacuumMapRoute[] | null {
if (!source) return null;
const calibration = marker?.calibration;
if (!calibration || typeof calibration !== 'object') return null;
const taken = new Set<string>();
const routes: VacuumMapRoute[] = [];
for (const mapId of Object.keys(calibration)) {
const matrix = normalizeRouteMatrix(calibration[mapId]);
if (!matrix) continue;
const id = nextId(taken);
taken.add(id);
routes.push({ id, source, map_id: mapId, space: dockSpace, calibration: matrix });
}
return routes.length ? routes : null;
}
/**
* Where a solved matrix belongs.
*
* With explicit routes the matrix is the route's own property; an unmapped map
* still falls back to the Stage 1 dictionary, because refusing to save a
* calibration the user just solved would lose real work over bookkeeping.
*/
export function writeVacuumMatrix(
vacuum: VacuumRouteMarkerCfg | null | undefined,
opts: { source: string; mapId: string; routeId?: string; matrix: Affine },
): VacuumRouteMarkerCfg {
const rounded = opts.matrix.map((n) => Number(n.toFixed(6))) as Affine;
const routes = vacuum?.map_routes;
if (Array.isArray(routes) && routes.length) {
const target = routes.find((route) => route.id === opts.routeId)
|| routes.find((route) => route.source === opts.source && route.map_id === opts.mapId);
if (target) {
return { ...vacuum, map_routes: saveRouteCalibration(routes, target.id, rounded) };
}
}
return {
...vacuum,
source: opts.source,
calibration: { ...(vacuum?.calibration || {}), [opts.mapId]: rounded },
};
}
export interface VacuumFitPlan {
space: string;
routeId: string;
params: FitParams;
}
/**
* Which space the manual fit canvas opens on, and from which matrix.
*
* Calibration is solved against the geometry of the space the map is assigned
* to — not the dock's space, which is where a multi-floor robot's fit used to
* land regardless of the map on screen (#162).
*/
export function planVacuumFit(
markerId: string,
vacuum: VacuumRouteMarkerCfg | null | undefined,
opts: {
routeId?: string; source: string; mapId: string; dockSpace: string; rooms: VacRoom[];
viewBoxOf: (spaceId: string) => [number, number, number, number] | null;
},
): VacuumFitPlan | null {
const routes = effectiveRoutes(markerId, vacuum, opts.dockSpace, opts.source);
const route = routes.find((item) => item.id === opts.routeId)
|| routes.find((item) => item.source === opts.source && item.map_id === opts.mapId)
|| null;
const space = route?.space || opts.dockSpace;
const viewBox = opts.viewBoxOf(space);
if (!viewBox) return null;
const stored = normalizeRouteMatrix(route ? route.calibration : vacuum?.calibration?.[opts.mapId]);
const params = (stored && fitFromMatrix(stored)) || initialFit(opts.rooms, viewBox);
return { space, routeId: route?.id || '', params };
}
+2
View File
@@ -11,6 +11,8 @@
* each guessing a floor. Nothing here touches the DOM, `hass` or storage.
*/
import { FitParams, fitFromMatrix, initialFit, VacRoom } from './vacuum';
export type Affine = [number, number, number, number, number, number];
/** One saved answer to "this exact map of this exact source lives here". */