Release v1.61.0-beta.1
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 3m2s
Validate / golden (push) Failing after 51s
Validate / backend (push) Failing after 6m50s
Validate / smoke (push) Failing after 13m44s
Validate / performance (push) Failing after 24m13s

This commit is contained in:
Matysh
2026-08-09 21:51:33 +03:00
parent 5814cfe0c2
commit 112c260314
78 changed files with 5442 additions and 1286 deletions
+42
View File
@@ -0,0 +1,42 @@
/**
* Persisted Houseplan colours use one intentionally small contract.
*
* Keeping this stricter than the browser CSS grammar makes old, imported or
* manually edited config safe before it reaches an inline style/SVG paint
* sink. The backend enforces the same exact form on new writes.
*/
export const STORED_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
export function safeStoredColor(value: unknown, fallback: string): string;
export function safeStoredColor(value: unknown, fallback: null): string | null;
export function safeStoredColor(value: unknown, fallback: string | null): string | null {
return typeof value === 'string' && STORED_COLOR_RE.test(value) ? value : fallback;
}
const RGB_COMPONENT = '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])';
const GENERATED_RGB_RE = new RegExp(
`^rgb\\(${RGB_COMPONENT}, ${RGB_COMPONENT}, ${RGB_COMPONENT}\\)$`,
);
/**
* Render-boundary guard for the two colour forms the application can produce:
* strict persisted hex and canonical rgb() generated below. This must not be
* used to broaden the persisted colour contract.
*/
export function safeRenderColor(value: unknown, fallback: string): string;
export function safeRenderColor(value: unknown, fallback: null): string | null;
export function safeRenderColor(value: unknown, fallback: string | null): string | null {
return typeof value === 'string'
&& (STORED_COLOR_RE.test(value) || GENERATED_RGB_RE.test(value))
? value
: fallback;
}
/** Canonical CSS rgb() from untrusted HA attributes; invalid input has no colour. */
export function generatedRgbColor(values: unknown): string | null {
if (!Array.isArray(values) || values.length < 3) return null;
const channels = values.slice(0, 3);
if (!channels.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
const [r, g, b] = channels.map((value) => Math.round(Math.min(255, Math.max(0, value))));
return `rgb(${r}, ${g}, ${b})`;
}
+3 -1
View File
@@ -1,6 +1,7 @@
/** Shared icon/value/badge/activity DOM for every device surface. */
import { html, nothing, type TemplateResult } from 'lit';
import type { ResolvedDevicePresentation } from './device-presentation';
import { safeRenderColor } from './color';
export interface DeviceFaceOptions {
surface: 'interactive-plan' | 'preview' | 'static-card';
@@ -15,7 +16,8 @@ export function deviceFaceStyle(presentation: ResolvedDevicePresentation): strin
if (presentation.scale !== 1) out.push(`--dev-scale:${presentation.scale}`);
if (presentation.display === 'icon_ripple') {
out.push(`--ripple-scale:${presentation.rippleScale}`);
if (presentation.rippleColor) out.push(`--ripple-color:${presentation.rippleColor}`);
const rippleColor = safeRenderColor(presentation.rippleColor, null);
if (rippleColor) out.push(`--ripple-color:${rippleColor}`);
}
return out;
}
+3 -1
View File
@@ -19,6 +19,7 @@ import {
type DeviceDisplayMode,
} from './logic';
import type { DevItem } from './types';
import { safeStoredColor } from './color';
export type PresentationSourceKind =
| 'cover' | 'light' | 'controls' | 'device_role' | 'primary' | 'none';
@@ -448,7 +449,8 @@ export function resolveDevicePresentation(
const scale = Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1;
const angle = Number(d.marker?.angle) || 0;
const rippleScale = Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3;
const rippleColor = staticIcon ? null : d.marker?.ripple_color || lightColor || null;
const configuredRippleColor = safeStoredColor(d.marker?.ripple_color, null);
const rippleColor = staticIcon ? null : configuredRippleColor || lightColor || null;
const valueText = display === 'value' && !effectiveHidden ? value.text : null;
const disabledReason = status?.kind === 'ha_disabled' ? status.reason : null;
const reason = explanationReason(
+3 -4
View File
@@ -1,4 +1,5 @@
import type { DecorShape, DecorStyle } from './types';
import { safeStoredColor } from '../../color';
export interface DecorBox {
x: number;
@@ -121,15 +122,13 @@ export function decorStyleOf(
): DecorStyle {
const fillable = shape?.kind === 'rect' || shape?.kind === 'ellipse';
const source = shape as any;
const color = /^#[0-9a-f]{6}$/i.test(String(source?.color || ''))
? String(source.color) : fallback.color;
const color = safeStoredColor(source?.color, fallback.color);
return {
color,
opacity: clamp01(source?.opacity, fallback.opacity),
widthCm: decorStrokeCm(shape, cellCm, gridPitch, fallback.widthCm),
fill: fillable ? source?.fill === true : false,
fillColor: /^#[0-9a-f]{6}$/i.test(String(source?.fill_color || ''))
? String(source.fill_color) : (source?.fill ? color : fallback.fillColor),
fillColor: safeStoredColor(source?.fill_color, source?.fill ? color : fallback.fillColor),
// Legacy fill was hard-coded to 0.25.
fillOpacity: fillable && source?.fill
? clamp01(source?.fill_opacity, 0.25)
+10 -4
View File
@@ -440,16 +440,22 @@ export function resolveHaBindingStatus(
return { kind: 'active', enabledEntityIds, allEntityIds };
}
const entity = entities[ref];
if (!entity) return { kind: 'orphaned', reason: 'entity_missing', enabledEntityIds: [], allEntityIds: [] };
if (!isRegistryEntryEnabled(entity)) {
// YAML platforms without a unique_id (notably Xiaomi Cloud Map
// Extractor cameras) legitimately have a live state but no registry row.
// A disabled row still wins, otherwise that exact live state is positive
// evidence of existence even under an authoritative registry snapshot.
if (entity && !isRegistryEntryEnabled(entity)) {
return { kind: 'ha_disabled', reason: 'entity', enabledEntityIds: [], allEntityIds: [ref] };
}
if (entity.device_id && !devices[entity.device_id]) {
if (entity?.device_id && !devices[entity.device_id]) {
return { kind: 'orphaned', reason: 'device_missing', enabledEntityIds: [], allEntityIds: [ref] };
}
if (entity.device_id && !isRegistryEntryEnabled(devices[entity.device_id])) {
if (entity?.device_id && !isRegistryEntryEnabled(devices[entity.device_id])) {
return { kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds: [ref] };
}
if (!entity && !hass?.states?.[ref]) {
return { kind: 'orphaned', reason: 'entity_missing', enabledEntityIds: [], allEntityIds: [] };
}
return { kind: 'active', enabledEntityIds: [ref], allEntityIds: [ref] };
}
+284 -64
View File
@@ -69,10 +69,14 @@ import {
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
import {
Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, vacMapIdWithFallback, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
Affine, applyAffine, readVacTelemetry,
autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, vacMapIdWithFallback,
parseVacSourceCandidate, resolveVacSource, resolveCurrentVacPath, trimVacPathTarget, areaCentroid,
vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM,
VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
VAC_TRAIL_LINGER_MS, Pt as VacPt,
Pt as VacPt, type VacPath, type VacSourceCandidate,
type VacSourceResolution, type VacSourceStatus,
} from './vacuum';
import {
buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
@@ -138,8 +142,9 @@ import {
type DecorBox, type SnapGeometry,
} from './editors/decor/geometry';
import { renderOpeningTunnelFills } from './render/opening-tunnels';
import { safeStoredColor } from './color';
const CARD_VERSION = '1.60.3';
const CARD_VERSION = '1.61.0-beta.1';
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
@@ -1161,6 +1166,15 @@ class HouseplanCard extends LitElement {
private _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 = null;
/** Marker whose lazy «All cameras» candidate section is expanded. */
private _vacAllCamerasFor: string | null = null;
/** One snapshot per currently open global-camera section; rebuilt on reopen. */
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; matrix: Affine;
rooms: number; error: string;
} | null = null;
private _kioskDots = false;
private _kioskDotsTimer?: number;
private _kioskHoldTimer?: number;
@@ -1248,6 +1262,8 @@ class HouseplanCard extends LitElement {
_dtDrag: { state: true },
_kioskDialog: { state: true },
_vacFit: { state: true },
_vacAllCamerasFor: { state: true },
_vacCalConfirm: { state: true },
_kioskDots: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
@@ -1421,6 +1437,7 @@ class HouseplanCard extends LitElement {
if (e.key === 'Escape') {
// close the topmost open dialog; info popups first, then editors
if (this._tapConfirm) { this._tapConfirm = null; return; }
if (this._vacCalConfirm) { this._vacCalConfirm = null; return; }
if (this._decorEraseConfirm) { this._decorEraseConfirm = null; return; }
if (this._openingInfo) { this._openingInfo = null; return; }
if (this._infoCard) { this._infoCard = null; return; }
@@ -6217,7 +6234,8 @@ class HouseplanCard extends LitElement {
: `${text}${text ? ' ' : ''}${legacyToken}`;
}
this._decorTextDialog = {
id: shape.id, x: shape.x, y: shape.y, text, color: shape.color || this._decorStyle.color,
id: shape.id, x: shape.x, y: shape.y, text,
color: safeStoredColor(shape.color, this._decorStyle.color),
opacity: clamp01(shape.opacity, this._decorStyle.opacity),
angle: this._angleField(shape.angle),
sizeCm: this._decorTextSizeCm(shape),
@@ -7491,7 +7509,7 @@ class HouseplanCard extends LitElement {
}
private get _editorSecondaryDialogBlocked(): boolean {
return !!(this._tapConfirm || this._roomDialog || this._mergeDialog
return !!(this._tapConfirm || this._vacCalConfirm || this._roomDialog || this._mergeDialog
|| this._openingDialog || this._physicalDialog || this._openingInfo
|| this._decorTextDialog || this._decorShapeDialog || this._backdropDialog
|| this._decorEraseConfirm || this._spaceDialog || this._markerDialog
@@ -9319,6 +9337,10 @@ class HouseplanCard extends LitElement {
// ================= DEVICE EDITOR (markers) =================
private _openMarkerDialog(d?: DevItem): void {
// A global-camera snapshot belongs to one explicit expansion only; never
// carry it across device-dialog sessions.
this._vacAllCamerasFor = null;
this._vacAllCameraCache = null;
if (d) this._ackNewDevice(d.id);
if (!this._norm) {
this._showToast(this._t('toast.marker_needs_server'));
@@ -9337,7 +9359,7 @@ class HouseplanCard extends LitElement {
icon: d.marker?.icon || '',
autoIcon: d.icon || '',
display: normalizeDeviceDisplay(d.marker?.display),
rippleColor: d.marker?.ripple_color || '',
rippleColor: safeStoredColor(d.marker?.ripple_color, ''),
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3,
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
angle: Number(d.marker?.angle) || 0,
@@ -11784,6 +11806,21 @@ class HouseplanCard extends LitElement {
${this._decorEraseConfirm ? this._renderDecorEraseConfirm() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._vacCalConfirm ? html`<hp-dialog .hass=${this.hass}
.title=${this._t('vac.residual_title')} icon="mdi:map-marker-alert-outline"
dismiss-on-scrim @hp-close=${() => (this._vacCalConfirm = null)}>
<div class="body">
<p>${this._t('vac.residual_message', { error: this._vacCalConfirm.error })}</p>
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() => (this._vacCalConfirm = null)}>${this._t('btn.cancel')}</button>
<span class="spacer"></span>
<button class="btn ghost" @click=${() => this._vacApplyCalibrationProposal(true)}>${this._t('vac.fit')}</button>
<button class="btn on" @click=${() => this._vacApplyCalibrationProposal(false)}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('vac.apply_proposal')}
</button>
</div>
</hp-dialog>` : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}
${this._rulesDialog ? this._renderRulesDialog() : nothing}
${this._settingsDialog ? this._renderSettingsDialog() : nothing}
@@ -11835,15 +11872,74 @@ class HouseplanCard extends LitElement {
// ---------------- live robot vacuums (docs/VACUUM.md) ----------------
/** The live-position source entity for a vacuum device, or null. */
private _vacSource(d: DevItem): string | null {
const v = d.marker?.vacuum;
if (v?.live === false) return null;
if (v?.source && this.hass?.states[v.source]) return v.source;
for (const eid of d.entities || []) {
if (isVacSourceState(this.hass?.states[eid])) return eid;
private _vacCandidateStatus(entityId: string, candidate: VacSourceCandidate | null): VacSourceStatus {
const binding = this._bindingStatus('entity:' + entityId);
if (binding.kind === 'ha_disabled') return 'disabled';
if (binding.kind === 'orphaned') return 'missing';
if (binding.kind === 'unverified') return 'unverified';
const state = this.hass?.states?.[entityId];
if (state?.state === 'unavailable') return 'unavailable';
return candidate?.hasPosition ? 'ok' : 'unsupported';
}
/** Snapshot the expensive global camera classification once per opening. */
private _vacOpenAllCameras(d: DevItem): void {
const registry = this._haRegistry.entities || {};
const candidates: VacSourceCandidate[] = [];
for (const [entityId, state] of Object.entries<any>(this.hass?.states || {})) {
if (!entityId.startsWith('camera.')) continue;
const candidate = parseVacSourceCandidate(entityId, state, registry[entityId]);
if (candidate) candidates.push(candidate);
}
return null;
this._vacAllCameraCache = { devId: d.id, candidates };
this._vacAllCamerasFor = d.id;
}
/** One sticky, order-independent source resolver for render/dialog/fit. */
private _vacSourceResolution(d: DevItem, includeAllCameras = false): VacSourceResolution {
const vacuum = d.marker?.vacuum;
const pinned = typeof vacuum?.source === 'string' && !!vacuum.source;
const sameDevice = new Set(d.entities || []);
const ids = new Set<string>(sameDevice);
if (pinned) ids.add(vacuum!.source!);
const registry = this._haRegistry.entities || {};
const candidates: VacSourceCandidate[] = [];
for (const entityId of ids) {
const state = this.hass?.states?.[entityId];
const candidate = parseVacSourceCandidate(entityId, state, registry[entityId]);
if (candidate) candidates.push(candidate);
else if (pinned && entityId === vacuum!.source) {
candidates.push({
entityId,
name: String(state?.attributes?.friendly_name || entityId),
platform: registry[entityId]?.platform ? String(registry[entityId].platform) : null,
category: entityId.startsWith('camera.') ? 'camera' : 'partial',
hasPosition: false, hasRooms: false, hasPath: false, hasMapId: false, score: 0,
});
}
}
if (includeAllCameras && this._vacAllCameraCache?.devId === d.id) {
const existing = new Set(candidates.map((candidate) => candidate.entityId));
for (const candidate of this._vacAllCameraCache.candidates) {
if (!existing.has(candidate.entityId)) candidates.push(candidate);
}
}
const statuses: Record<string, VacSourceStatus> = {};
for (const candidate of candidates) {
statuses[candidate.entityId] = this._vacCandidateStatus(candidate.entityId, candidate);
}
return resolveVacSource(vacuum?.source, sameDevice, candidates, statuses);
}
/** The sticky/automatic live-position source entity, or null. */
private _vacSource(d: DevItem): string | null {
if (d.marker?.vacuum?.live === false) return null;
const resolution = this._vacSourceResolution(d);
// A disabled/unavailable/unverified source must never leak stale HA
// attributes into plan overlays. `unsupported` remains usable because it
// may still provide rooms/path for calibration and trail rendering.
return resolution.status === 'ok' || resolution.status === 'unsupported'
? resolution.entityId : null;
}
private _vacEntity(d: DevItem): string | null {
@@ -11987,7 +12083,7 @@ class HouseplanCard extends LitElement {
this._vacRt.set(d.id, rt);
}
if (moving && !rt.moving) { rt.trail = []; rt.lastPos = null; } // a fresh run
const wantTrail = vacTrailMode(d.marker?.vacuum) !== 'never' && !tele?.path;
const wantTrail = vacTrailMode(d.marker?.vacuum) !== 'never' && !tele?.path.length;
if (!moving && rt.moving) {
rt.endedTs = Date.now();
// the run is over: the puck has arrived everywhere it was going
@@ -12050,12 +12146,17 @@ class HouseplanCard extends LitElement {
const dev = this._devices.find((x) => x.id === dlg.devId);
if (!dev || !this._isVacDev(dev)) return nothing;
const v = dev.marker?.vacuum || {};
const src = this._vacSource(dev);
const includeAllCameras = this._vacAllCamerasFor === dev.id;
const resolution = this._vacSourceResolution(dev, includeAllCameras);
const src = resolution.entityId;
const selected = resolution.candidates.find((candidate) => candidate.entityId === src) || null;
const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null;
const tierA = !!(tele && tele.rooms.length >= 3);
const status = tele?.pos
? subst(this._t('vac.status_found'), { name: src || '' })
: this._t('vac.status_none');
const canUseSource = v.live !== false
&& (resolution.status === 'ok' || resolution.status === 'unsupported');
const planRooms = this._vacPlanRoomAnchors(dev.space);
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
@@ -12064,18 +12165,105 @@ class HouseplanCard extends LitElement {
if (!m) return;
m.vacuum = { ...(m.vacuum || {}), ...patch };
this._regSignature = '';
// First-use markers are materialised above, so the dialog must rebuild
// immediately instead of waiting for an unrelated HA state tick.
this._maybeRebuildDevices();
this._saveConfig();
this.requestUpdate();
};
const sameDevice = new Set(dev.entities || []);
const primaryCandidates = resolution.candidates.filter((candidate) =>
sameDevice.has(candidate.entityId) || candidate.entityId === v.source);
const globalCandidates = includeAllCameras
? resolution.candidates.filter((candidate) => candidate.entityId.startsWith('camera.')
&& !primaryCandidates.some((entry) => entry.entityId === candidate.entityId))
: [];
const capability = (candidate: VacSourceCandidate) => [
candidate.hasPosition ? this._t('vac.cap_position') : '',
candidate.hasRooms ? this._t('vac.cap_rooms_short') : '',
candidate.hasPath ? this._t('vac.cap_path') : '',
candidate.hasMapId ? this._t('vac.cap_map') : '',
].filter(Boolean).join(' · ') || this._t('vac.cap_none');
const candidateButton = (candidate: VacSourceCandidate) => html`
<button type="button" class="vacsource ${candidate.entityId === src ? 'on' : ''}"
@click=${() => setVac({ source: candidate.entityId })}>
<span><b>${candidate.name}</b><small>${candidate.entityId}</small></span>
<span class="vacsource-meta">${candidate.platform || this._t('vac.platform_unknown')} · ${capability(candidate)}</span>
</button>`;
const knownSameDeviceXcme = resolution.candidates.some((candidate) =>
sameDevice.has(candidate.entityId) && candidate.category === 'known_xcme_incomplete');
const showXcmeHint = knownSameDeviceXcme
|| (!!resolution.pinned && !!src?.startsWith('camera.') && !selected?.hasPosition);
const openPicker = () => {
const picker = [...this.renderRoot.querySelectorAll<HTMLDetailsElement>('details.vacpicker')]
.find((details) => details.dataset.devId === dev.id);
if (!picker) return;
picker.open = true;
picker.querySelector<HTMLElement>('summary')?.focus();
};
return html`
<label>${this._t('vac.section')}</label>
<div class="bindbox vacbox">
<div class="rhint">${status}</div>
${tele ? html`
<div class="vacbtns">
${tierA ? html`<button class="btn" @click=${() => this._vacAutoCalibrate(dev)}>${this._t('vac.autocal')}</button>` : nothing}
<button class="btn ghostbtn" @click=${() => this._vacStartFit(dev)}>${this._t('vac.fit')}</button>
<div class="vacdiag" role="status">
<div><span>${this._t('vac.diag_source')}</span><b>${src || this._t('vac.source_none')}</b></div>
${selected?.platform ? html`<div><span>${this._t('vac.diag_platform')}</span><b>${selected.platform}</b></div>` : nothing}
<div><span>${this._t('vac.diag_status')}</span><b>${this._t((`vac.source_status_${resolution.status}`) as any)}</b></div>
<div><span>${this._t('vac.diag_position')}</span><b>${tele?.pos ? this._t('common.yes') : this._t('common.no')}</b></div>
<div><span>${this._t('vac.diag_rooms')}</span><b>${subst(this._t('vac.diag_rooms_value'), {
total: String(tele?.rooms.length || 0), matched: String(roomMatches),
readiness: this._t(roomMatches >= 3 ? 'vac.autocal_ready' : 'vac.autocal_not_ready'),
})}</b></div>
<div><span>${this._t('vac.diag_path')}</span><b>${tele?.path.length ? this._t('common.yes') : this._t('common.no')}</b></div>
<div><span>${this._t('vac.diag_map')}</span><b>${tele?.mapId ?? 'default'}</b></div>
</div>
${(resolution.status === 'missing' || resolution.status === 'disabled' || resolution.status === 'unverified')
? html`<div class="warn vacsource-warning">
<span>${this._t((`vac.source_banner_${resolution.status}`) as any)}</span>
<button type="button" class="btn ghostbtn" @click=${openPicker}>${this._t('vac.choose_source')}</button>
</div>` : nothing}
${showXcmeHint ? html`<div class="warn vacxcme">
<b>${this._t('vac.xcme_hint')}</b>
<pre>attributes:
- vacuum_position
- rooms
- path
- map_name</pre>
</div>` : nothing}
<details class="vacpicker" data-dev-id=${dev.id}>
<summary class="btn ghostbtn">${this._t('vac.choose_source')}</summary>
<div class="vacsource-list">
<button type="button" class="vacsource ${!resolution.pinned ? 'on' : ''}"
@click=${() => setVac({ source: null })}>
<span><b>${this._t('vac.source_auto')}</b><small>${this._t('vac.source_auto_hint')}</small></span>
</button>
${primaryCandidates.map(candidateButton)}
<details ?open=${includeAllCameras}
@toggle=${(event: Event) => {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open) this._vacOpenAllCameras(dev);
else {
this._vacAllCamerasFor = null;
this._vacAllCameraCache = null;
}
}}>
<summary>${this._t('vac.all_cameras')}</summary>
<div class="rhint">${this._t('vac.all_cameras_warn')}</div>
${includeAllCameras
? (globalCandidates.length ? globalCandidates.map(candidateButton)
: html`<div class="rhint">${this._t('vac.all_cameras_empty')}</div>`)
: nothing}
</details>
</div>
</details>
<div class="vacbtns">
${tierA ? html`<button class="btn" @click=${() => this._vacAutoCalibrate(dev)}>${this._t('vac.autocal')}</button>` : nothing}
${canUseSource
? html`<button class="btn ghostbtn" @click=${() => this._vacStartFit(dev)}>${this._t('vac.fit')}</button>`
: nothing}
<a class="btn ghostbtn" href="https://github.com/Matysh/houseplan-card/blob/main/docs/VACUUM.md"
target="_blank" rel="noopener">${this._t('vac.documentation')}</a>
</div>
${tele ? html`
<label class="srcrow">
${this._boolInput(v.live !== false, (on) => setVac({ live: on ? null : false }))}
<span>${this._t('vac.live')}</span>
@@ -12118,11 +12306,28 @@ class HouseplanCard extends LitElement {
v.calibration = { ...(v.calibration || {}), [mapId]: matrix.map((n) => Number(n.toFixed(6))) };
m.vacuum = v;
this._regSignature = '';
this._maybeRebuildDevices();
this._saveConfig();
this.requestUpdate();
return true;
}
/** The exact plan-room set accepted by auto-calibration and diagnostics. */
private _vacPlanRoomAnchors(spaceId: string | null | undefined): Array<{
name: string; cx: number; cy: number;
}> {
return (this._spaceModel(spaceId || undefined)?.rooms || [])
// HP-1540-04: legacy rectangle rooms (x/y/w/h) are still first-class;
// roomPoly() gives the same outline the renderer uses for them.
.map((room: any) => ({ room, poly: roomPoly(room) }))
.filter(({ room, poly }: any) => room.name && poly)
.map(({ room, poly }: any) => {
const centroid = areaCentroid(poly);
return centroid ? { name: String(room.name), cx: centroid[0], cy: centroid[1] } : null;
})
.filter(Boolean) as Array<{ name: string; cx: number; cy: number }>;
}
/** «Настроить автоматически»: robot rooms ↔ plan rooms by name. */
private _vacAutoCalibrate(d: DevItem): void {
const src = this._vacSource(d);
@@ -12131,33 +12336,50 @@ class HouseplanCard extends LitElement {
this._showToast(this._t('vac.autocal_no_rooms'));
return;
}
const sp = this._spaceModel(d.space);
const planRooms = (sp?.rooms || [])
// HP-1540-04: legacy rectangle rooms (x/y/w/h) are still first-class
// everywhere else — roomPoly() gives the same 4-corner outline the
// renderer uses, so they must count for name-matching too. The old
// r.poly?.length filter dropped them and then blamed the room names.
.map((r: any) => ({ r, poly: roomPoly(r) }))
.filter(({ r, poly }: any) => r.name && poly)
.map(({ r, poly }: any) => {
const c = poleOfInaccessibility(poly);
return { name: r.name, cx: c[0], cy: c[1] };
});
const planRooms = this._vacPlanRoomAnchors(d.space);
const res = autoCalibrate(tele.rooms, planRooms);
if (!res) {
this._showToast(this._t('vac.autocal_no_match'));
return;
}
// residual gate: 5% of the canvas ≈ 40 cm in a typical house
// HP-1540-01: toast success ONLY after the matrix verifiably landed in
// the config — the old code always claimed victory, even as a no-op
if (!this._vacSaveMatrix(d.id, src, this._vacMapId(d, tele), res.matrix)) return;
if (res.residual > NORM_W * 0.05) {
this._showToast(subst(this._t('vac.autocal_res_warn'), { rooms: String(res.matched.length) }));
const rawSpace = this._serverCfg?.spaces?.find((space: any) => space.id === d.space);
const rawCellCm = Number(rawSpace?.cell_cm);
const cellCm = Number.isFinite(rawCellCm) && rawCellCm > 0 ? rawCellCm : 5;
const residualCm = vacCalibrationResidualCm(res.residual, this._gridPitch, cellCm);
const mapId = this._vacMapId(d, tele);
if (residualCm > VAC_CALIBRATION_WARN_CM) {
this._vacCalConfirm = {
markerId: d.id, source: src, mapId, matrix: res.matrix,
rooms: res.matched.length,
error: formatLength(residualCm, this.hass?.config?.unit_system?.length === 'mi'),
};
return;
}
if (!this._vacSaveMatrix(d.id, src, mapId, res.matrix)) return;
this._showToast(subst(this._t('vac.autocal_done'), { rooms: String(res.matched.length) }));
}
private _vacApplyCalibrationProposal(manual: boolean): void {
const proposal = this._vacCalConfirm;
if (!proposal) return;
this._vacCalConfirm = null;
if (manual) {
const dev = this._devices.find((candidate) => candidate.id === proposal.markerId);
const fit = fitFromMatrix(proposal.matrix);
if (!dev || !fit) return;
this._markerDialog = null;
if (dev.space !== this._space) this._space = dev.space;
this._vacFit = {
markerId: proposal.markerId, source: proposal.source,
mapId: proposal.mapId, p: fit, drag: null,
};
return;
}
if (this._vacSaveMatrix(
proposal.markerId, proposal.source, proposal.mapId, proposal.matrix,
)) this._showToast(subst(this._t('vac.autocal_done'), { rooms: String(proposal.rooms) }));
}
/** «Подогнать вручную»: open the fit overlay and leave the dialog. */
private _vacStartFit(d: DevItem): void {
const src = this._vacSource(d);
@@ -12373,27 +12595,25 @@ class HouseplanCard extends LitElement {
}).join(' ');
trails.push(svg`<g class="prev"><polyline class="case" points="${pts}"></polyline><polyline class="core" points="${pts}"></polyline></g>`);
}
// trail source order: server current run (survives reloads, shared by
// every screen) → integration path → the local live buffer
if (showCur && (moving || srvCur)) {
// any server/integration path ends at the CURRENT target — trim the
// live tail while moving so the line never runs ahead of the puck
const full: VacPt[] = (srvCur?.points as VacPt[]) || tele.path || rt?.trail || [];
const trim = moving && (srvCur || tele.path) && full.length > 1;
const raw: VacPt[] = trim ? full.slice(0, -1) : full;
if (raw.length > 1) {
const ptsStr = raw.map(([x, y]) => {
// One arbitration authority: integration multi-subpath → server run →
// local runtime. Only drawable segments participate.
if (showCur) {
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.
const raw: VacPath = moving && (current.source === 'integration' || current.source === 'server')
? trimVacPathTarget(current.path) : current.path;
if (raw.length) {
const pathD = raw.map((segment) => segment.map(([x, y], index) => {
const [cx, cy] = applyAffine(matrix, x, y);
return cx.toFixed(1) + ',' + cy.toFixed(1);
}).join(' ');
// cartography casing: a dark halo under a light core. Neutral and
// visible over ANY room fill — blend modes all have a blind
// luminance where the line vanishes (owner request 2026-07-31).
trails.push(svg`<polyline class="case" points="${ptsStr}"></polyline><polyline class="core" points="${ptsStr}"></polyline>`);
// the LAST segment grows glued to the icon (owner: «след появлялся
// строго за иконкой»): a rAF sampler drags x2/y2 to the puck centre
if (moving) {
const [ax, ay] = applyAffine(matrix, raw[raw.length - 1][0], raw[raw.length - 1][1]);
return `${index ? 'L' : 'M'} ${cx.toFixed(1)} ${cy.toFixed(1)}`;
}).join(' ')).join(' ');
// A single SVG path with several M commands keeps gaps literal.
trails.push(svg`<path class="case" d="${pathD}"></path><path class="core" d="${pathD}"></path>`);
const last = raw[raw.length - 1];
if (moving && last?.length >= 2) {
const anchor = last[last.length - 1];
const [ax, ay] = applyAffine(matrix, anchor[0], anchor[1]);
const a1 = ax.toFixed(1), a2 = ay.toFixed(1);
trails.push(svg`<line class="case tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line><line class="core tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line>`);
}
+7 -5
View File
@@ -1,4 +1,5 @@
import { LitElement, css, html, nothing } from 'lit';
import { safeStoredColor } from './color';
/**
* Compact colour + opacity picker used by decor defaults and object dialogs.
@@ -258,29 +259,30 @@ export class HpColorOpacity extends LitElement {
render() {
const pct = Math.round(Math.min(1, Math.max(0, Number(this.opacity) || 0)) * 100);
const title = `${this.label || 'Color'}: ${this.color}, ${pct}%`;
const color = safeStoredColor(this.color, '#607d8b');
const title = `${this.label || 'Color'}: ${color}, ${pct}%`;
return html`
${this.label ? html`<span class="label">${this.label}</span>` : nothing}
<button class="trigger" type="button" .disabled=${this.disabled}
aria-label=${title} aria-haspopup="dialog" aria-expanded=${this._open ? 'true' : 'false'}
title=${title} @click=${this._toggle}>
<span class="swatch" style=${`background:${this.color};opacity:${pct / 100}`}></span>
<span class="swatch" style=${`background:${color};opacity:${pct / 100}`}></span>
</button>
${this._open && !this.disabled ? html`
<div class="picker" popover="manual" role="dialog" aria-label=${this.label || 'Color'}>
<div class="row">
<span class="caption">${this.label || 'Color'}</span>
<input type="color" .value=${this.color} aria-label=${this.label || 'Color'}
<input type="color" .value=${color} aria-label=${this.label || 'Color'}
@input=${(e: Event) => this._emit((e.target as HTMLInputElement).value, this.opacity)} />
</div>
<div class="row">
<span class="caption">${this.opacityLabel}</span>
<input type="range" min="0" max="100" step="1" .value=${String(pct)}
aria-label=${this.opacityLabel}
@input=${(e: Event) => this._emit(this.color, Number((e.target as HTMLInputElement).value) / 100)} />
@input=${(e: Event) => this._emit(color, Number((e.target as HTMLInputElement).value) / 100)} />
<input type="number" min="0" max="100" step="1" .value=${String(pct)}
aria-label=${`${this.opacityLabel}, %`}
@change=${(e: Event) => this._emit(this.color, Number((e.target as HTMLInputElement).value) / 100)} />
@change=${(e: Event) => this._emit(color, Number((e.target as HTMLInputElement).value) / 100)} />
<span class="pct">%</span>
</div>
</div>` : nothing}
+41 -4
View File
@@ -551,15 +551,12 @@
"toast.run_target_required": "Pick an automation, script or scene",
"btn.run": "Run",
"vac.section": "Robot vacuum: live position",
"vac.status_found": "Position source found: {name}",
"vac.status_none": "The integration reports no coordinates — the robot will only be shown at its base",
"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_res_warn": "Matched {rooms} rooms but the fit is rough — verify and refine via “Fit manually” if needed",
"vac.autocal_done": "Done: bound via {rooms} rooms. Start a cleanup and check",
"vac.cal_need_pos": "The robot is not reporting coordinates — start a cleanup and pause it",
"vac.cal_done": "Calibration saved. Start a cleanup and check",
@@ -659,5 +656,45 @@
"furn.sym_stairs": "Stairs",
"furn.sym_fireplace": "Fireplace",
"furn.sym_plant": "Plant",
"furn.sym_rug": "Rug"
"furn.sym_rug": "Rug",
"common.yes": "Yes",
"common.no": "No",
"vac.diag_source": "Source",
"vac.diag_platform": "Integration",
"vac.diag_status": "Status",
"vac.diag_position": "Position",
"vac.diag_rooms": "Rooms",
"vac.diag_rooms_value": "{total} · {matched} names match · {readiness}",
"vac.autocal_ready": "auto-calibration available",
"vac.autocal_not_ready": "need 3 matching names",
"vac.diag_path": "Integration path",
"vac.diag_map": "Map ID",
"vac.source_none": "not selected",
"vac.source_status_ok": "Ready",
"vac.source_status_missing": "Missing",
"vac.source_status_disabled": "Disabled in Home Assistant",
"vac.source_status_unavailable": "Unavailable",
"vac.source_status_unverified": "Cannot verify with current permissions",
"vac.source_status_unsupported": "No position data",
"vac.source_status_none": "No source",
"vac.source_banner_missing": "The saved source no longer exists. It was not replaced automatically; choose another source or restore it in Home Assistant.",
"vac.source_banner_disabled": "The saved source is disabled in Home Assistant. Enable it there or choose another source.",
"vac.source_banner_unverified": "Current Home Assistant permissions cannot verify this saved source. It remains pinned and will not be replaced automatically.",
"vac.choose_source": "Choose source",
"vac.source_auto": "Automatic",
"vac.source_auto_hint": "Use a compatible entity from this device",
"vac.all_cameras": "All cameras",
"vac.all_cameras_warn": "A camera may not provide robot data. Choose one only if it is your vacuum map.",
"vac.all_cameras_empty": "No other camera entities found.",
"vac.platform_unknown": "unknown integration",
"vac.cap_position": "position",
"vac.cap_rooms_short": "rooms",
"vac.cap_path": "path",
"vac.cap_map": "map ID",
"vac.cap_none": "no robot data detected",
"vac.xcme_hint": "Enable these Xiaomi Cloud Map Extractor attributes:",
"vac.documentation": "Documentation",
"vac.residual_title": "Check automatic calibration",
"vac.residual_message": "The matched rooms disagree by up to {error}. Apply this approximate calibration, refine it manually, or cancel without changing the saved setup.",
"vac.apply_proposal": "Apply"
}
+41 -4
View File
@@ -551,15 +551,12 @@
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
"btn.run": "Выполнить",
"vac.section": "Робот-пылесос: живая позиция",
"vac.status_found": "Источник координат найден: {name}",
"vac.status_none": "Интеграция не отдаёт координаты — робот будет показан только на базе",
"vac.autocal": "Настроить автоматически",
"vac.live": "Живая позиция на плане",
"vac.trail": "Показывать путь робота",
"vac.cal_maps": "Откалиброваны карты: {maps}",
"vac.autocal_no_rooms": "Интеграция не отдаёт список комнат — откройте «Подогнать вручную»",
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — откройте «Подогнать вручную»",
"vac.autocal_res_warn": "Совпало комнат: {rooms}, но привязка грубовата — проверьте и при необходимости откройте «Подогнать вручную»",
"vac.autocal_done": "Готово: привязка по {rooms} комнатам. Запустите уборку и проверьте",
"vac.cal_need_pos": "Робот сейчас не отдаёт координаты — запустите уборку и поставьте на паузу",
"vac.cal_done": "Калибровка сохранена. Запустите уборку и проверьте",
@@ -659,5 +656,45 @@
"furn.sym_stairs": "Лестница",
"furn.sym_fireplace": "Камин",
"furn.sym_plant": "Растение",
"furn.sym_rug": "Ковёр"
"furn.sym_rug": "Ковёр",
"common.yes": "Да",
"common.no": "Нет",
"vac.diag_source": "Источник",
"vac.diag_platform": "Интеграция",
"vac.diag_status": "Статус",
"vac.diag_position": "Позиция",
"vac.diag_rooms": "Комнаты",
"vac.diag_rooms_value": "{total} · совпало имён: {matched} · {readiness}",
"vac.autocal_ready": "автокалибровка доступна",
"vac.autocal_not_ready": "нужно 3 совпавших имени",
"vac.diag_path": "Путь интеграции",
"vac.diag_map": "ID карты",
"vac.source_none": "не выбран",
"vac.source_status_ok": "Готов",
"vac.source_status_missing": "Не найден",
"vac.source_status_disabled": "Деактивирован в Home Assistant",
"vac.source_status_unavailable": "Недоступен",
"vac.source_status_unverified": "Нельзя проверить с текущими правами",
"vac.source_status_unsupported": "Нет данных позиции",
"vac.source_status_none": "Нет источника",
"vac.source_banner_missing": "Сохранённый источник больше не существует. Он не заменён автоматически: выберите другой или восстановите его в Home Assistant.",
"vac.source_banner_disabled": "Сохранённый источник деактивирован в Home Assistant. Активируйте его там или выберите другой.",
"vac.source_banner_unverified": "Текущих прав Home Assistant недостаточно для проверки источника. Привязка сохранена и не будет заменена автоматически.",
"vac.choose_source": "Выбрать источник",
"vac.source_auto": "Автоматически",
"vac.source_auto_hint": "Использовать совместимую сущность этого устройства",
"vac.all_cameras": "Все камеры",
"vac.all_cameras_warn": "Камера может не отдавать данные робота. Выбирайте её только если это карта вашего пылесоса.",
"vac.all_cameras_empty": "Других сущностей камеры не найдено.",
"vac.platform_unknown": "интеграция неизвестна",
"vac.cap_position": "позиция",
"vac.cap_rooms_short": "комнаты",
"vac.cap_path": "путь",
"vac.cap_map": "ID карты",
"vac.cap_none": "данные робота не обнаружены",
"vac.xcme_hint": "Включите атрибуты Xiaomi Cloud Map Extractor:",
"vac.documentation": "Документация",
"vac.residual_title": "Проверьте автокалибровку",
"vac.residual_message": "Совпавшие комнаты расходятся максимум на {error}. Примените приблизительную калибровку, подгоните её вручную либо отмените без изменения сохранённых настроек.",
"vac.apply_proposal": "Применить"
}
+8 -14
View File
@@ -2,6 +2,7 @@
* Pure functions with no Lit/DOM dependencies — easy to cover with unit tests.
*/
import { union } from 'polyclip-ts';
import { generatedRgbColor, safeStoredColor } from './color';
/** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
export function lqiColor(lqi: number): string {
@@ -1282,7 +1283,7 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
return {
showBorders: s.show_borders ?? noPlan,
showNames: s.show_names ?? noPlan,
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
color: safeStoredColor(s.room_color, DEFAULT_ROOM_COLOR),
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
fill: ['lqi', 'light', 'temp', 'glow'].includes(s.fill_mode) ? s.fill_mode : 'none',
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
@@ -1295,7 +1296,7 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
labelHum: s.label_hum === true,
labelLqi: s.label_lqi === true,
labelLight: s.label_light === true,
bgColor: typeof s.bg_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.bg_color) ? s.bg_color : null,
bgColor: safeStoredColor(s.bg_color, null),
// absent = false = today's rendering, so no plan changes by being read
hideDecor: s.hide_decor === true,
hideOpenings: s.hide_openings === true,
@@ -1310,7 +1311,7 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
export function stageBgOf(settings: any, disp: { bgColor: string | null }): string {
if (disp.bgColor) return disp.bgColor;
const g = settings?.bg_color;
return typeof g === 'string' && /^#[0-9a-f]{6}$/i.test(g) ? g : '';
return safeStoredColor(g, '');
}
// ---------------- global fill colors ----------------
@@ -1352,8 +1353,6 @@ export const DEFAULT_FILL_COLORS: FillColors = {
wall_fill: { c: '#ffffff', a: 1 },
};
const HEX_RE = /^#[0-9a-f]{6}$/i;
/** Merge stored overrides over the defaults, dropping malformed entries. */
export function fillColorsOf(settings: any): FillColors {
const out: any = {};
@@ -1362,7 +1361,7 @@ export function fillColorsOf(settings: any): FillColors {
const d = DEFAULT_FILL_COLORS[k];
const v = src[k];
out[k] = {
c: v && typeof v.c === 'string' && HEX_RE.test(v.c) ? v.c : d.c,
c: safeStoredColor(v?.c, d.c),
a: v && typeof v.a === 'number' ? Math.min(1, Math.max(0, v.a)) : d.a,
};
}
@@ -1586,11 +1585,7 @@ export function stateIcon(
*/
export function lightColorOf(state: any): string | null {
if (!state || state.state !== 'on') return null;
const rgb = state.attributes?.rgb_color;
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v))) {
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
}
return null;
return generatedRgbColor(state.attributes?.rgb_color);
}
// ---------------- glow fill (light sources) ----------------
@@ -1616,9 +1611,8 @@ export function glowColorOf(state: any, fallback: string): { c: string; bri: num
const a = state.attributes || {};
const briRaw = Number(a.brightness);
const bri = Number.isFinite(briRaw) && briRaw > 0 ? Math.max(0.15, Math.min(1, briRaw / 255)) : 1;
const rgb = a.rgb_color;
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v)))
return { c: `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`, bri };
const rgb = generatedRgbColor(a.rgb_color);
if (rgb) return { c: rgb, bri };
const kelvin = Number(a.color_temp_kelvin) || (Number(a.color_temp) > 0 ? 1e6 / Number(a.color_temp) : NaN);
if (Number.isFinite(kelvin) && kelvin > 0) {
const [r, g, b] = kelvinToRgb(kelvin);
+23 -1
View File
@@ -2098,7 +2098,8 @@ export const cardStyles = css`
z-index: 5;
overflow: visible;
}
.vactrail polyline {
.vactrail polyline,
.vactrail path {
fill: none;
stroke-linejoin: round;
stroke-linecap: round;
@@ -2116,6 +2117,27 @@ export const cardStyles = css`
stroke-width: 0.9;
}
.vacbox .vacbtns { display: flex; gap: var(--sp-4); margin: var(--sp-3) 0; flex-wrap: wrap; }
.vacdiag { display: grid; gap: 4px; margin-bottom: var(--sp-3); }
.vacdiag > div { display: flex; justify-content: space-between; gap: var(--sp-5); }
.vacdiag > div > span { color: var(--secondary-text-color); }
.vacdiag > div > b { text-align: right; overflow-wrap: anywhere; }
.vacpicker { margin: var(--sp-3) 0; }
.vacsource-warning { display: grid; gap: 8px; }
.vacsource-warning .btn { justify-self: start; }
.vacpicker > summary { display: inline-flex; width: auto; list-style: none; cursor: pointer; }
.vacpicker > summary::-webkit-details-marker { display: none; }
.vacsource-list { display: grid; gap: 6px; margin-top: 8px; }
.vacsource-list details { padding: 6px 0 0; }
.vacsource-list details > summary { cursor: pointer; font-weight: 600; }
.vacsource { display: flex; align-items: center; justify-content: space-between; gap: 12px;
width: 100%; min-width: 0; padding: 9px 10px; border: 1px solid var(--divider-color);
border-radius: 10px; color: var(--primary-text-color); background: var(--secondary-background-color);
text-align: left; cursor: pointer; }
.vacsource.on { border-color: var(--accent-color); box-shadow: inset 3px 0 var(--accent-color); }
.vacsource > span:first-child { min-width: 0; display: grid; gap: 2px; }
.vacsource small { color: var(--secondary-text-color); overflow-wrap: anywhere; }
.vacsource-meta { color: var(--secondary-text-color); text-align: right; font-size: 0.82em; }
.vacxcme pre { margin: 8px 0 0; white-space: pre-wrap; user-select: text; }
.vacfit {
position: absolute;
inset: 0;
+301 -29
View File
@@ -9,6 +9,10 @@
export type Affine = [number, number, number, number, number, number];
export type Pt = [number, number];
export type VacPath = Pt[][];
export const VAC_PATH_MAX_SEGMENTS = 64;
export const VAC_PATH_MAX_POINTS = 4000;
/** target = [a b; d e]·source + [c f] */
export function applyAffine(m: Affine, x: number, y: number): Pt {
@@ -71,16 +75,159 @@ export interface VacRoom { id: string; name: string; cx: number; cy: number;
x0?: number; y0?: number; x1?: number; y1?: number }
export interface VacTelemetry {
pos: { x: number; y: number; a: number | null } | null;
path: Pt[] | null; // integration-provided full path, vacuum coords
path: VacPath; // integration-provided drawable subpaths, vacuum coords
rooms: VacRoom[]; // for auto-calibration; empty when unknown
mapId: string; // multi-floor robots: one calibration per map
}
const num = (v: unknown): number | null => {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const pointOf = (value: unknown): Pt | null => {
const candidate = value as any;
const x = num(Array.isArray(candidate) ? candidate[0] : candidate?.x);
const y = num(Array.isArray(candidate) ? candidate[1] : candidate?.y);
return x == null || y == null ? null : [x, y];
};
const looksLikePoint = (value: unknown): boolean => {
if (Array.isArray(value)) {
return value.length >= 2 && !Array.isArray(value[0])
&& (typeof value[0] !== 'object' || value[0] == null);
}
return !!value && typeof value === 'object'
&& ('x' in (value as object) || 'y' in (value as object));
};
/** Shoelace area centroid. A zero-area outline deterministically falls back
* to its vertex average; invalid/non-finite outlines have no centroid. */
export function areaCentroid(raw: unknown): Pt | null {
if (!Array.isArray(raw)) return null;
const points: Pt[] = [];
for (const value of raw) {
const point = pointOf(value);
if (!point) return null;
points.push(point);
}
if (points.length > 1) {
const first = points[0], last = points[points.length - 1];
if (first[0] === last[0] && first[1] === last[1]) points.pop();
}
if (points.length < 3) return null;
let crossSum = 0, cx = 0, cy = 0;
for (let i = 0; i < points.length; i++) {
const a = points[i], b = points[(i + 1) % points.length];
const cross = a[0] * b[1] - b[0] * a[1];
crossSum += cross;
cx += (a[0] + b[0]) * cross;
cy += (a[1] + b[1]) * cross;
}
if (Math.abs(crossSum) < 1e-12) {
return [
points.reduce((sum, point) => sum + point[0], 0) / points.length,
points.reduce((sum, point) => sum + point[1], 0) / points.length,
];
}
return [cx / (3 * crossSum), cy / (3 * crossSum)];
}
function outlineGeometry(raw: unknown): { centroid: Pt; bbox: [number, number, number, number] } | null {
if (!Array.isArray(raw)) return null;
const points: Pt[] = [];
for (const value of raw) {
const point = pointOf(value);
if (!point) return null;
points.push(point);
}
if (points.length > 1) {
const first = points[0], last = points[points.length - 1];
if (first[0] === last[0] && first[1] === last[1]) points.pop();
}
const centroid = areaCentroid(points);
if (!centroid) return null;
const xs = points.map((point) => point[0]);
const ys = points.map((point) => point[1]);
return { centroid, bbox: [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] };
}
function thinPathToTarget(points: Pt[], target: number): Pt[] {
if (target >= points.length) return points.slice();
if (target <= 2) return [points[0], points[points.length - 1]];
const out: Pt[] = [];
for (let i = 0; i < target; i++) {
const index = Math.round((i * (points.length - 1)) / (target - 1));
out.push(points[index]);
}
return out;
}
/** Normalize legacy flat paths and integration multi-subpaths, then enforce
* drawable/cap/budget rules without ever joining across a gap. */
export function normalizeVacPath(raw: unknown): VacPath {
let value: unknown = raw;
if (value && typeof value === 'object' && !Array.isArray(value)) {
const object = value as any;
value = object.path ?? object.points ?? value;
}
if (!Array.isArray(value) || !value.length) return [];
const flat = value.some((entry) => looksLikePoint(entry));
const rawSegments: unknown[][] = flat ? [value] : value.filter(Array.isArray) as unknown[][];
let segments = rawSegments
.map((segment) => segment.map(pointOf).filter((point): point is Pt => point != null))
.filter((segment) => segment.length >= 2);
if (segments.length > VAC_PATH_MAX_SEGMENTS) segments = segments.slice(-VAC_PATH_MAX_SEGMENTS);
const total = segments.reduce((sum, segment) => sum + segment.length, 0);
if (total <= VAC_PATH_MAX_POINTS) return segments.map((segment) => segment.slice());
const base = segments.length * 2;
const remaining = VAC_PATH_MAX_POINTS - base;
const weights = segments.map((segment) => segment.length - 2);
const weightTotal = weights.reduce((sum, weight) => sum + weight, 0);
const quotas = weights.map((weight) => remaining * weight / weightTotal);
const allocations = quotas.map(Math.floor);
let left = remaining - allocations.reduce((sum, allocation) => sum + allocation, 0);
const order = quotas.map((quota, index) => ({ index, fraction: quota - Math.floor(quota) }))
.sort((a, b) => b.fraction - a.fraction || a.index - b.index);
for (let i = 0; i < left; i++) allocations[order[i].index]++;
return segments.map((segment, index) => thinPathToTarget(segment, 2 + allocations[index]));
}
export type VacCurrentPathSource = 'integration' | 'server' | 'local' | 'none';
/** One authority for the current visible path. Only drawable geometry wins. */
export function resolveCurrentVacPath(
tele: { path?: unknown } | unknown,
server: { points?: unknown } | unknown,
local: unknown,
): { path: VacPath; source: VacCurrentPathSource } {
const telePath = normalizeVacPath(
tele && typeof tele === 'object' && !Array.isArray(tele) && 'path' in tele
? (tele as { path?: unknown }).path : tele,
);
if (telePath.length) return { path: telePath, source: 'integration' };
const serverPath = normalizeVacPath(
server && typeof server === 'object' && !Array.isArray(server) && 'points' in server
? (server as { points?: unknown }).points : server,
);
if (serverPath.length) return { path: serverPath, source: 'server' };
const localPath = normalizeVacPath(local);
if (localPath.length) return { path: localPath, source: 'local' };
return { path: [], source: 'none' };
}
/** Remove the live target from only the final drawable subpath. */
export function trimVacPathTarget(path: VacPath): VacPath {
const out = path.map((segment) => segment.slice());
const last = out[out.length - 1];
if (!last) return [];
if (last.length > 2) last.pop();
else out.pop();
return out;
}
/**
* Map-id normalisation contract, shared with the backend recorder
* (custom_components/houseplan/trails.py: resolve_map_id). The FIRST value
@@ -119,18 +266,8 @@ export function readVacTelemetry(attrs: Record<string, any> | null | undefined):
const pos = p && num(p.x) != null && num(p.y) != null
? { x: num(p.x)!, y: num(p.y)!, a: num(p.a ?? p.angle ?? p.theta) }
: null;
// path: [{x,y},…] (Map Extractor) or [[x,y],…]
let path: Pt[] | null = null;
const rawPath = attrs.path?.points ?? attrs.path;
if (Array.isArray(rawPath) && rawPath.length) {
path = [];
for (const q of rawPath) {
const x = num(Array.isArray(q) ? q[0] : q?.x);
const y = num(Array.isArray(q) ? q[1] : q?.y);
if (x != null && y != null) path.push([x, y]);
}
if (!path.length) path = null;
}
// path: [{x,y},…], [[x,y],…] or XCME path.path: [[{x,y},…], …]
const path = normalizeVacPath(attrs.path?.path ?? attrs.path?.points ?? attrs.path);
// rooms: {id:{name,x0,y0,x1,y1}} | [{id,name,x0..}] | {id:{name,outline}}
const rooms: VacRoom[] = [];
const rawRooms = attrs.rooms;
@@ -140,41 +277,176 @@ export function readVacTelemetry(attrs: Record<string, any> | null | undefined):
for (const [id, r] of entries) {
if (!r || typeof r !== 'object') continue;
const name = String(r.name ?? r.label ?? '').trim();
let cx = num(r.cx ?? r.center?.x); let cy = num(r.cy ?? r.center?.y);
if (cx == null || cy == null) {
const x0 = num(r.x0), y0 = num(r.y0), x1 = num(r.x1), y1 = num(r.y1);
if (x0 != null && y0 != null && x1 != null && y1 != null) {
cx = (x0 + x1) / 2; cy = (y0 + y1) / 2;
}
// Anchor pairs are atomic: never mix cx with center.y (or another tier)
// when an integration publishes one incomplete spelling.
const anchorPairs = [
[num(r.cx), num(r.cy)],
[num(r.center?.x), num(r.center?.y)],
// Tasshack dreame-vacuum: the room centre is plain x/y (verified
// against a live X50 Master; x/y sits within its own x0..x1 bbox).
[num(r.x), num(r.y)],
];
const anchor = anchorPairs.find(([x, y]) => x != null && y != null);
let cx = anchor?.[0] ?? null; let cy = anchor?.[1] ?? null;
const outline = outlineGeometry(r.outline);
const x0 = num(r.x0), y0 = num(r.y0), x1 = num(r.x1), y1 = num(r.y1);
const explicitBbox = x0 != null && y0 != null && x1 != null && y1 != null
? [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)] as const
: null;
const bbox = explicitBbox || outline?.bbox || null;
if ((cx == null || cy == null) && outline) [cx, cy] = outline.centroid;
// Owner override after review F6: bbox-only dialects still get a usable
// ghost/calibration anchor. This is deliberately the LAST tier, after
// every explicit centre and the area centroid of a real outline.
if ((cx == null || cy == null) && bbox) {
cx = (bbox[0] + bbox[2]) / 2;
cy = (bbox[1] + bbox[3]) / 2;
}
// Tasshack dreame-vacuum: the room centre is plain x/y (verified against
// a live X50 Master; x/y sits within its own x0..x1 bbox)
if (cx == null || cy == null) { cx = num(r.x); cy = num(r.y); }
if (name && cx != null && cy != null) {
const room: VacRoom = { id, name, cx, cy };
const x0 = num(r.x0), y0 = num(r.y0), x1 = num(r.x1), y1 = num(r.y1);
if (x0 != null && y0 != null && x1 != null && y1 != null) {
room.x0 = Math.min(x0, x1); room.y0 = Math.min(y0, y1);
room.x1 = Math.max(x0, x1); room.y1 = Math.max(y0, y1);
}
if (bbox) [room.x0, room.y0, room.x1, room.y1] = bbox;
rooms.push(room);
}
}
const mapId = vacMapIdFromAttrs(attrs);
if (!pos && !rooms.length && !path) return null;
if (!pos && !rooms.length && !path.length) return null;
return { pos, path, rooms, mapId };
}
/** Attribute sets that mark an entity as a live-position source. */
export function isVacSourceState(st: { attributes?: Record<string, any> } | null | undefined): boolean {
const a = st?.attributes;
return !!(a && (a.vacuum_position || a.robot_position));
return !!pointOf(a?.vacuum_position ?? a?.robot_position);
}
export type VacSourceCandidateCategory =
| 'compatible' | 'partial' | 'known_xcme_incomplete' | 'camera';
export interface VacSourceCandidate {
entityId: string;
name: string;
platform: string | null;
category: VacSourceCandidateCategory;
hasPosition: boolean;
hasRooms: boolean;
hasPath: boolean;
hasMapId: boolean;
score: number;
}
export type VacSourceStatus =
| 'ok' | 'missing' | 'disabled' | 'unavailable' | 'unverified' | 'unsupported' | 'none';
export interface VacSourceResolution {
entityId: string | null;
status: VacSourceStatus;
pinned: boolean;
candidates: VacSourceCandidate[];
}
/**
* Resolve one source without depending on entity/registry iteration order.
* A saved source is sticky even while broken; automatic mode only considers
* proven-compatible entities attached to the same HA device.
*/
export function resolveVacSource(
savedSource: string | null | undefined,
sameDeviceIds: Iterable<string>,
rawCandidates: Iterable<VacSourceCandidate>,
statuses: Readonly<Record<string, VacSourceStatus>>,
): VacSourceResolution {
const pinned = typeof savedSource === 'string' && savedSource.length > 0;
const sameDevice = new Set(sameDeviceIds);
const candidates = Array.from(rawCandidates).sort((a, b) => {
if (pinned && a.entityId === savedSource) return -1;
if (pinned && b.entityId === savedSource) return 1;
return b.score - a.score || a.entityId.localeCompare(b.entityId);
});
if (pinned) {
const selected = candidates.find((candidate) => candidate.entityId === savedSource);
return {
entityId: savedSource,
// Missing is an authoritative conclusion supplied by the caller. An
// absent status is insufficient evidence, so the pure helper stays
// conservative instead of inventing deletion.
status: statuses[savedSource] || (selected?.hasPosition ? 'ok' : 'unverified'),
pinned: true,
candidates,
};
}
const automatic = candidates.find((candidate) => sameDevice.has(candidate.entityId)
&& candidate.hasPosition && statuses[candidate.entityId] === 'ok');
return automatic
? { entityId: automatic.entityId, status: 'ok', pinned: false, candidates }
: { entityId: null, status: 'none', pinned: false, candidates };
}
/** Physical calibration error in centimetres; invalid scales cannot pass. */
export function vacCalibrationResidualCm(
residualPlanUnits: number,
gridPitch: number,
cellCm: number,
): number {
if (![residualPlanUnits, gridPitch, cellCm].every(Number.isFinite)
|| residualPlanUnits < 0 || gridPitch <= 0 || cellCm <= 0) return Number.POSITIVE_INFINITY;
return (residualPlanUnits / gridPitch) * cellCm;
}
export const VAC_CALIBRATION_WARN_CM = 40;
/** Classify one entity without relying on device/entity array order. */
export function parseVacSourceCandidate(
entityId: string,
state: { attributes?: Record<string, any> } | null | undefined,
registryEntry?: Record<string, any> | null,
): VacSourceCandidate | null {
if (!entityId || !state) return null;
const attrs = state.attributes || {};
const domain = entityId.split('.')[0] || '';
const platform = registryEntry?.platform != null ? String(registryEntry.platform) : null;
const hasPosition = !!pointOf(attrs.vacuum_position ?? attrs.robot_position);
const hasRooms = !!(attrs.rooms && typeof attrs.rooms === 'object');
const hasPath = normalizeVacPath(attrs.path?.path ?? attrs.path?.points ?? attrs.path).length > 0;
const hasMapId = [attrs.map_name, attrs.current_map, attrs.map_index, attrs.selected_map]
.some((value) => value != null);
const knownXcme = platform === 'xiaomi_cloud_map_extractor';
const hasVacuumClue = hasRooms || hasPath || hasMapId;
let category: VacSourceCandidateCategory | null = null;
if (hasPosition) category = 'compatible';
else if (knownXcme) category = 'known_xcme_incomplete';
else if (hasVacuumClue) category = 'partial';
else if (domain === 'camera') category = 'camera';
if (!category) return null;
const score = hasPosition ? (domain === 'camera' ? 300 : 200)
: knownXcme ? 100 : hasVacuumClue ? 50 : 0;
return {
entityId,
name: String(attrs.friendly_name || entityId),
platform,
category,
hasPosition,
hasRooms,
hasPath,
hasMapId,
score,
};
}
// ---------------- auto-calibration by rooms ----------------
const canonName = (s: string): string => s.toLowerCase().replace(/[\s_\-.,]+/g, '');
/** Diagnostic name coverage uses the exact same canonicalisation as solve. */
export function vacRoomNameMatchCount(vacRooms: VacRoom[], planRoomNames: string[]): number {
const plan = new Set(planRoomNames.map(canonName).filter(Boolean));
const matched = new Set<string>();
for (const room of vacRooms) {
const name = canonName(room.name || '');
if (name && plan.has(name)) matched.add(name);
}
return matched.size;
}
/**
* Match the robot's room list against plan rooms by name and solve the
* transform over centroids. Room centroids are coarse anchors, which is fine: