fix: refine a high-residual calibration on the map's own floor

User-Visible: no
Issue: #162
This commit is contained in:
Codex
2026-09-03 19:55:52 +03:00
parent 4b8cf6e04d
commit 49f69f60db
30 changed files with 424 additions and 317 deletions
+39
View File
@@ -9,6 +9,7 @@
import { TemplateResult, html, nothing } from 'lit';
import type { HpConfirmRequest } from '../danger-confirm';
import type { DevItem } from '../types';
import type { VacSourceCandidate, VacSourceResolution } from '../vacuum';
import { langOf, type I18nKey } from '../i18n';
import { supportT, type SupportI18nKey } from '../i18n/support';
import { VacuumMapRoute, effectiveRoutes, observedMapIds, resolveRoute } from '../vacuum-routes';
@@ -49,6 +50,7 @@ const spaceName = (host: VacuumMapsCardHost, spaceId: string): string => {
*/
export function renderVacuumMapsSection(
runtime: VacuumMapsHost, dev: DevItem, setVac: (patch: Record<string, unknown>) => void,
sources: VacSourceResolution,
): TemplateResult | typeof nothing {
const host = runtime.host;
const lang = langOf(host.hass, host._config?.language);
@@ -96,6 +98,27 @@ export function renderVacuumMapsSection(
));
};
/**
* Second supported integration shape: one camera per map (spec 9.3).
*
* The map id is read from the picked source with the same fallback the rest
* of the card uses. A source that names no map cannot be told apart from
* another later, so no route is created and the reason is shown instead of
* a route that could never resolve.
*/
const addSource = (candidate: VacSourceCandidate) => {
const mapId = host._vacObservedMapId(dev, candidate.entityId);
if (mapId === undefined) return;
writeRoutes((current) => addRoute(
current,
{ source: candidate.entityId, map_id: mapId, space: current.length ? '' : dev.space },
newRouteId(takenIds()),
));
};
const spare = sources.candidates.filter((candidate) => candidate.entityId !== rootSource
&& !routes.some((route) => route.source === candidate.entityId));
const retarget = async (route: VacuumMapRoute, space: string) => {
if (!space || space === route.space) return;
const accepted = await host._confirmDanger({
@@ -186,6 +209,22 @@ export function renderVacuumMapsSection(
<button type="button" class="btn" ?disabled=${!canAddCurrent}
@click=${addCurrent}>${t('vac.route_add_current')}</button>
</div>
${spare.length ? html`<details class="vacroute-sources">
<summary class="btn ghostbtn">${t('vac.route_add_source')}</summary>
<div class="rhint">${t('vac.route_add_source_hint')}</div>
<div class="vacsource-list">
${spare.map((candidate) => {
const mapId = host._vacObservedMapId(dev, candidate.entityId);
return html`<button type="button" class="vacsource" ?disabled=${mapId === undefined}
@click=${() => addSource(candidate)}>
<span><b>${candidate.name}</b><small>${candidate.entityId}</small></span>
<span class="vacsource-meta">${mapId === undefined
? t('vac.route_source_no_map')
: (mapId || t('vac.route_map_default'))}</span>
</button>`;
})}
</div>
</details>` : nothing}
${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`
+1 -1
View File
@@ -2512,7 +2512,7 @@ export class HouseplanCard extends LitElement {
private _vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[] } | null = null;
/** Proposed high-residual auto-calibration. Config remains untouched until Apply. */
private _vacCalConfirm: {
markerId: string; source: string; mapId: string; routeId?: string; matrix: Affine;
markerId: string; source: string; mapId: string; routeId?: string; space?: string; matrix: Affine;
rooms: number; error: string;
} | null = null;
private _kioskDots = false;
+9 -11
View File
@@ -1147,7 +1147,7 @@ export interface HouseplanEditorHostPort {
_undoPoint: () => void;
_vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[]; } | null;
_vacAllCamerasFor: string | null;
_vacCalConfirm: { markerId: string; source: string; mapId: string; routeId?: string; matrix: Affine; rooms: number; error: string; } | null;
_vacCalConfirm: { markerId: string; source: string; mapId: string; routeId?: string; space?: string; matrix: Affine; rooms: number; error: string; } | null;
_vacEnsureMarker: (d: DevItem) => Marker | null;
_vacEntity: (d: DevItem) => string | null;
_vacMapId: (d: DevItem, tele: { mapId: string }, planHass?: any) => string;
@@ -10970,7 +10970,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>
${renderVacuumMapsSection(this, dev, setVac)}
${renderVacuumMapsSection(this, dev, setVac, resolution)}
` : nothing}
</div>`;
}
@@ -10981,9 +10981,7 @@ 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, routeId = '',
): 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)
@@ -11034,7 +11032,7 @@ public _vacAutoCalibrate(d: DevItem): void {
const residualCm = vacCalibrationResidualCm(res.residual, this.host._gridPitch, cellCm);
if (residualCm > VAC_CALIBRATION_WARN_CM) {
this.host._vacCalConfirm = {
markerId: d.id, source: src, mapId, routeId: target.routeId, matrix: res.matrix,
markerId: d.id, source: src, mapId, routeId: target.routeId, space: target.space, matrix: res.matrix,
rooms: res.matched.length,
error: formatLength(residualCm, this.host.hass?.config?.unit_system?.length === 'mi'),
};
@@ -11053,11 +11051,11 @@ public _vacApplyCalibrationProposal(manual: boolean): void {
const fit = fitFromMatrix(proposal.matrix);
if (!dev || !fit) return;
this.host._markerDialog = null;
if (dev.space !== this.host._space && !this.host._commitSpace(dev.space)) return;
this.host._vacFit = {
markerId: proposal.markerId, source: proposal.source, routeId: proposal.routeId,
mapId: proposal.mapId, p: fit, drag: null,
};
// #162: матрица решена против пространства МАРШРУТА, а не дока.
const space = proposal.space || dev.space;
if (space !== this.host._space && !this.host._commitSpace(space)) return;
this.host._vacFit = { markerId: proposal.markerId, source: proposal.source,
routeId: proposal.routeId, mapId: proposal.mapId, p: fit, drag: null };
return;
}
if (this._vacSaveMatrix(
+4 -1
View File
@@ -72,5 +72,8 @@
"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}“."
"vac.route_target_hint": "Die Station bleibt unabhängig von der aktuellen Karte im Raum „{space}“.",
"vac.route_add_source": "Kartenquelle hinzufügen",
"vac.route_add_source_hint": "Für Integrationen, die jeder Karte eine eigene Kamera geben. Der Abschnitt „Alle Kameras“ oben erweitert diese Liste.",
"vac.route_source_no_map": "keine Karte gemeldet"
}
+4 -1
View File
@@ -72,5 +72,8 @@
"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."
"vac.route_target_hint": "The dock stays in the “{space}” space regardless of the current map.",
"vac.route_add_source": "Add a map source",
"vac.route_add_source_hint": "For integrations that give each map its own camera. The “All cameras” section above widens this list.",
"vac.route_source_no_map": "no map reported"
}
+4 -1
View File
@@ -72,5 +72,8 @@
"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."
"vac.route_target_hint": "La station reste dans l'espace « {space} » quelle que soit la carte actuelle.",
"vac.route_add_source": "Ajouter une source de carte",
"vac.route_add_source_hint": "Pour les intégrations qui donnent à chaque carte sa propre caméra. La section « Toutes les caméras » ci-dessus élargit cette liste.",
"vac.route_source_no_map": "aucune carte signalée"
}
+4 -1
View File
@@ -72,5 +72,8 @@
"vac.route_space_confirm": "Перенести",
"vac.route_missing_space_hint": "Одна из карт ссылается на удалённое пространство: выберите новое или удалите карту. Пока ссылка не исправлена, робот на этой карте не рисуется.",
"vac.route_default_hint": "Источник не сообщает стабильный идентификатор карты. Несколько этажей нельзя различить без другого источника или собственной карты робота.",
"vac.route_target_hint": "Док остаётся в пространстве «{space}» независимо от текущей карты."
"vac.route_target_hint": "Док остаётся в пространстве «{space}» независимо от текущей карты.",
"vac.route_add_source": "Добавить источник карты",
"vac.route_add_source_hint": "Для интеграций, где у каждой карты своя камера. Список расширяется разделом «Все камеры» выше.",
"vac.route_source_no_map": "карта не читается"
}