mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
feat: assign each robot map to a floor in the device editor
User-Visible: yes Issue: #162
This commit is contained in:
@@ -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>`;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} комнатам. Запустите уборку и проверьте",
|
||||
|
||||
@@ -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}“."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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}» независимо от текущей карты."
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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". */
|
||||
|
||||
Reference in New Issue
Block a user