mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
(a) documented: deleting a vacuum marker erases its server trail at once and a re-added marker starts from scratch (VACUUM.md + both USER-GUIDEs). (b) smoothVacPath reports dropped non-finite segments — one console warn per call with the count — instead of hiding the whole trail silently on a broken calibration matrix. (c) room climate (#317) now reaches legacy markers whose exported config carries an ABSENT area key rather than an explicit null: `== null` where the placement is decided. (d) the armed furniture preview follows Shift without mouse movement — window keydown/keyup listeners live exactly as long as the palette is armed, detached at every palette teardown. (e) only the primary mouse button places decor/furniture: a right or middle click with an armed tool is a no-op, touch/pen untouched. (f) a device whose registry entities were ALL deliberately disabled by the user no longer glows as an alive controller — the #318 entityless-active rule now requires a genuinely empty roster. (g) furniture-pack author corrected to Sergey Matyunin (Сергей Матюнин) per the owner's decision — LICENSE.md, README.md, pack.json, docs/FURNITURE.md, the provenance check in generate-furniture-assets and its unit; the source archive bytes are unchanged and the README notes the romanisation fix. Proofs: units for (b)/(c)/(f) including the #318 regression pair; new smoke_furniture_polish for (d)/(e) with listener add/remove counters and both mouse buttons; five registry mutants, one per code change. Issue: #369 User-Visible: yes
671 lines
28 KiB
TypeScript
Executable File
671 lines
28 KiB
TypeScript
Executable File
/**
|
||
* Live robot vacuums: coordinate math and integration adapters.
|
||
*
|
||
* Pure logic, no Lit — everything here is unit-tested directly. The renderer
|
||
* consumes three things: a solved affine matrix (vacuum mm → plan canvas
|
||
* units), normalised telemetry from whatever integration the user happens to
|
||
* run, and a thinned trail. See docs/VACUUM.md for the approved contract.
|
||
*/
|
||
|
||
export type Affine = [number, number, number, number, number, number];
|
||
export type Pt = [number, number];
|
||
export type VacPath = Pt[][];
|
||
|
||
export type VacPathCommand =
|
||
| { kind: 'move'; point: Pt }
|
||
| { kind: 'line'; point: Pt }
|
||
| { kind: 'quadratic'; control: Pt; point: Pt };
|
||
|
||
export const VAC_TRAIL_SMOOTH_RADIUS_CM = 17.5;
|
||
|
||
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 {
|
||
return [m[0] * x + m[1] * y + m[2], m[3] * x + m[4] * y + m[5]];
|
||
}
|
||
|
||
/**
|
||
* Least-squares affine over point pairs (≥3). Solves two independent 3-unknown
|
||
* systems via normal equations; returns null for degenerate input (collinear
|
||
* points make the normal matrix singular — the wizard asks for a spread).
|
||
*/
|
||
export function solveAffine(pairs: Array<[Pt, Pt]>): Affine | null {
|
||
if (pairs.length < 3) return null;
|
||
// normal matrix A^T A (3x3) and right-hand sides for tx and ty
|
||
let sxx = 0, sxy = 0, sx = 0, syy = 0, sy = 0, n = 0;
|
||
let bx0 = 0, bx1 = 0, bx2 = 0, by0 = 0, by1 = 0, by2 = 0;
|
||
for (const [[x, y], [tx, ty]] of pairs) {
|
||
if (![x, y, tx, ty].every(Number.isFinite)) return null;
|
||
sxx += x * x; sxy += x * y; sx += x; syy += y * y; sy += y; n += 1;
|
||
bx0 += x * tx; bx1 += y * tx; bx2 += tx;
|
||
by0 += x * ty; by1 += y * ty; by2 += ty;
|
||
}
|
||
const A = [sxx, sxy, sx, sxy, syy, sy, sx, sy, n];
|
||
const solve3 = (b: number[]): number[] | null => {
|
||
// Cramer via explicit inverse of the symmetric 3x3
|
||
const [a, b1, c, d, e, f, g, h, i] = A;
|
||
const det = a * (e * i - f * h) - b1 * (d * i - f * g) + c * (d * h - e * g);
|
||
if (!Number.isFinite(det) || Math.abs(det) < 1e-9) return null;
|
||
const inv = [
|
||
(e * i - f * h) / det, (c * h - b1 * i) / det, (b1 * f - c * e) / det,
|
||
(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det,
|
||
(d * h - e * g) / det, (b1 * g - a * h) / det, (a * e - b1 * d) / det,
|
||
];
|
||
return [
|
||
inv[0] * b[0] + inv[1] * b[1] + inv[2] * b[2],
|
||
inv[3] * b[0] + inv[4] * b[1] + inv[5] * b[2],
|
||
inv[6] * b[0] + inv[7] * b[1] + inv[8] * b[2],
|
||
];
|
||
};
|
||
const rx = solve3([bx0, bx1, bx2]);
|
||
const ry = solve3([by0, by1, by2]);
|
||
if (!rx || !ry) return null;
|
||
const m: Affine = [rx[0], rx[1], rx[2], ry[0], ry[1], ry[2]];
|
||
return m.every(Number.isFinite) ? m : null;
|
||
}
|
||
|
||
/** Worst residual in target units — the wizard warns above a threshold. */
|
||
export function affineResidual(m: Affine, pairs: Array<[Pt, Pt]>): number {
|
||
let worst = 0;
|
||
for (const [s, t] of pairs) {
|
||
const p = applyAffine(m, s[0], s[1]);
|
||
worst = Math.max(worst, Math.hypot(p[0] - t[0], p[1] - t[1]));
|
||
}
|
||
return worst;
|
||
}
|
||
|
||
// ---------------- telemetry adapters ----------------
|
||
|
||
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: 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;
|
||
}
|
||
|
||
const finitePoint = (point: Pt): boolean => Number.isFinite(point[0]) && Number.isFinite(point[1]);
|
||
const samePoint = (a: Pt, b: Pt): boolean => a[0] === b[0] && a[1] === b[1];
|
||
|
||
/**
|
||
* Turn calibrated plan-space trail points into bounded rounded-corner commands.
|
||
*
|
||
* Each corner is trimmed by at most half of either adjacent segment and by the
|
||
* caller's physical radius. The quadratic lies in the P-B-Q convex hull, so it
|
||
* cannot be farther from the source polyline than that radius. This function
|
||
* deliberately knows nothing about SVG, Lit, scale conversion or flat/iso
|
||
* projection; the renderer serialises and projects the typed commands.
|
||
*/
|
||
export function smoothVacPath(
|
||
path: VacPath, maxRadius: number,
|
||
warn: (message: string) => void = console.warn,
|
||
): VacPathCommand[][] {
|
||
if (!Number.isFinite(maxRadius) || maxRadius <= 0) return [];
|
||
const out: VacPathCommand[][] = [];
|
||
// #369(б): a NaN calibration matrix used to hide the whole trail silently —
|
||
// one warn per call (a file may carry hundreds of segments) names the count.
|
||
let droppedSegments = 0;
|
||
for (const rawSegment of path) {
|
||
if (!rawSegment.every(finitePoint)) { droppedSegments++; continue; }
|
||
const segment = rawSegment.filter((point, index) => index === 0 || !samePoint(point, rawSegment[index - 1]));
|
||
if (segment.length < 2) continue;
|
||
const commands: VacPathCommand[] = [{ kind: 'move', point: segment[0] }];
|
||
if (segment.length === 2) {
|
||
commands.push({ kind: 'line', point: segment[1] });
|
||
out.push(commands);
|
||
continue;
|
||
}
|
||
|
||
for (let index = 1; index < segment.length - 1; index++) {
|
||
const a = segment[index - 1], b = segment[index], c = segment[index + 1];
|
||
const inX = b[0] - a[0], inY = b[1] - a[1];
|
||
const outX = c[0] - b[0], outY = c[1] - b[1];
|
||
const inLength = Math.hypot(inX, inY), outLength = Math.hypot(outX, outY);
|
||
const dot = (inX * outX + inY * outY) / (inLength * outLength);
|
||
// An almost exact reversal would fold a quadratic back onto itself. Keep
|
||
// that telemetry cusp literal instead of inventing a loop around it.
|
||
if (!Number.isFinite(dot) || dot <= -0.999) {
|
||
commands.push({ kind: 'line', point: b });
|
||
continue;
|
||
}
|
||
const radius = Math.min(maxRadius, inLength / 2, outLength / 2);
|
||
if (!Number.isFinite(radius) || radius <= 0) {
|
||
commands.push({ kind: 'line', point: b });
|
||
continue;
|
||
}
|
||
const before: Pt = [b[0] - inX * radius / inLength, b[1] - inY * radius / inLength];
|
||
const after: Pt = [b[0] + outX * radius / outLength, b[1] + outY * radius / outLength];
|
||
commands.push({ kind: 'line', point: before });
|
||
commands.push({ kind: 'quadratic', control: b, point: after });
|
||
}
|
||
commands.push({ kind: 'line', point: segment[segment.length - 1] });
|
||
out.push(commands);
|
||
}
|
||
if (droppedSegments > 0) {
|
||
warn(`[houseplan] vacuum trail: ${droppedSegments} segment(s) dropped — non-finite point (check map calibration)`);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Map-id normalisation contract, shared with the backend recorder
|
||
* (custom_components/houseplan/trails.py: resolve_map_id). The FIRST value
|
||
* that is not null/undefined wins — truthiness is wrong here, because a
|
||
* zero-based `map_index: 0` is a perfectly valid first map and an empty
|
||
* string is still an id. The backend used an `or`-chain and dropped the
|
||
* zero, so server trails were stored under a key the renderer never looked
|
||
* up (HP-1540-02).
|
||
*/
|
||
export function vacMapIdFromAttrs(attrs: Record<string, any>): string {
|
||
return String(attrs.map_name ?? attrs.current_map ?? attrs.map_index ?? attrs.selected_map ?? 'default');
|
||
}
|
||
|
||
/**
|
||
* The card-side fallback half of that contract (HP-1541-01): when source
|
||
* telemetry names no map ('default'), the vacuum entity's own selected_map
|
||
* decides — under the SAME not-nullish rule as above. The old truthiness
|
||
* check in _vacMapId turned `selected_map: 0` into 'default' while the
|
||
* server recorder (trails.py resolve_map_id) stored the trail under '0', so
|
||
* calibration and saved runs lived under a key the renderer never matched.
|
||
*/
|
||
export function vacMapIdWithFallback(teleMapId: string, selectedMap: unknown): string {
|
||
if (teleMapId !== 'default') return teleMapId;
|
||
return selectedMap != null ? String(selectedMap) : 'default';
|
||
}
|
||
|
||
/**
|
||
* Normalise the attribute zoo. One parser instead of per-brand classes: the
|
||
* three Tier-A integrations (Xiaomi Cloud Map Extractor, Tasshack
|
||
* dreame-vacuum, Valetudo camera) all descend from the map-card conventions
|
||
* and differ only in field spellings.
|
||
*/
|
||
export function readVacTelemetry(attrs: Record<string, any> | null | undefined): VacTelemetry | null {
|
||
if (!attrs) return null;
|
||
const p = attrs.vacuum_position || attrs.robot_position || null;
|
||
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},…], [[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;
|
||
const entries: Array<[string, any]> = Array.isArray(rawRooms)
|
||
? rawRooms.map((r: any, i: number) => [String(r?.id ?? i), r])
|
||
: rawRooms && typeof rawRooms === 'object' ? Object.entries(rawRooms) : [];
|
||
for (const [id, r] of entries) {
|
||
if (!r || typeof r !== 'object') continue;
|
||
const name = String(r.name ?? r.label ?? '').trim();
|
||
// 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;
|
||
}
|
||
if (name && cx != null && cy != null) {
|
||
const room: VacRoom = { id, name, cx, cy };
|
||
if (bbox) [room.x0, room.y0, room.x1, room.y1] = bbox;
|
||
rooms.push(room);
|
||
}
|
||
}
|
||
const mapId = vacMapIdFromAttrs(attrs);
|
||
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 !!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:
|
||
* the residual check below rejects a bad fit, and the user always sees a live
|
||
* preview before accepting.
|
||
*/
|
||
export function autoCalibrate(
|
||
vacRooms: VacRoom[],
|
||
planRooms: Array<{ name: string; cx: number; cy: number }>,
|
||
): { matrix: Affine; matched: string[]; residual: number } | null {
|
||
const byName = new Map(planRooms.map((r) => [canonName(r.name), r]));
|
||
const pairs: Array<[Pt, Pt]> = [];
|
||
const matched: string[] = [];
|
||
for (const vr of vacRooms) {
|
||
const pr = byName.get(canonName(vr.name));
|
||
if (!pr) continue;
|
||
pairs.push([[vr.cx, vr.cy], [pr.cx, pr.cy]]);
|
||
matched.push(vr.name);
|
||
}
|
||
if (pairs.length < 3) return null;
|
||
const matrix = solveAffine(pairs);
|
||
if (!matrix) return null;
|
||
return { matrix, matched, residual: affineResidual(matrix, pairs) };
|
||
}
|
||
|
||
// ---------------- trail ----------------
|
||
|
||
export const TRAIL_MAX = 600;
|
||
export const VAC_TELEPORT_GAP_MS = 10000;
|
||
export const VAC_STALE_MS = 60000;
|
||
export const VAC_TRAIL_LINGER_MS = 10 * 60000;
|
||
|
||
/** Ramer–Douglas–Peucker; keeps ends, drops points under eps deviation. */
|
||
export function thinPath(pts: Pt[], eps: number): Pt[] {
|
||
if (pts.length < 3) return pts.slice();
|
||
const keep = new Uint8Array(pts.length);
|
||
keep[0] = keep[pts.length - 1] = 1;
|
||
const stack: Array<[number, number]> = [[0, pts.length - 1]];
|
||
while (stack.length) {
|
||
const [a, b] = stack.pop()!;
|
||
const [ax, ay] = pts[a]; const [bx, by] = pts[b];
|
||
const dx = bx - ax, dy = by - ay;
|
||
const len = Math.hypot(dx, dy) || 1e-9;
|
||
let worst = 0, wi = -1;
|
||
for (let i = a + 1; i < b; i++) {
|
||
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
|
||
if (d > worst) { worst = d; wi = i; }
|
||
}
|
||
if (wi > 0 && worst > eps) {
|
||
keep[wi] = 1;
|
||
stack.push([a, wi], [wi, b]);
|
||
}
|
||
}
|
||
const out: Pt[] = [];
|
||
for (let i = 0; i < pts.length; i++) if (keep[i]) out.push(pts[i]);
|
||
return out;
|
||
}
|
||
|
||
/** Append a point; over the cap → thin, and if thinning was not enough, decimate. */
|
||
export function pushTrailPoint(buf: Pt[], p: Pt, epsHint: number): Pt[] {
|
||
const last = buf[buf.length - 1];
|
||
if (last && last[0] === p[0] && last[1] === p[1]) return buf;
|
||
buf.push(p);
|
||
if (buf.length <= TRAIL_MAX) return buf;
|
||
let thinned = thinPath(buf, epsHint);
|
||
if (thinned.length > TRAIL_MAX) thinned = thinned.filter((_, i) => i % 2 === 0 || i === thinned.length - 1);
|
||
return thinned;
|
||
}
|
||
|
||
/** true → the robot is actively driving (a puck should exist). */
|
||
export function isVacMoving(state: string | undefined): boolean {
|
||
return state === 'cleaning' || state === 'returning' || state === 'on';
|
||
}
|
||
|
||
// ---------------- the fit panel (drag + corner-stretch calibration) ----------------
|
||
|
||
/** What the user manipulates; folds into the same stored 6-number matrix. */
|
||
export interface FitParams {
|
||
ox: number; oy: number; // translation, canvas units
|
||
s: number; // uniform scale, canvas units per robot unit
|
||
rot: 0 | 90 | 180 | 270; // whole-quarter rotation
|
||
mir: boolean; // mirror (robots' Y usually grows the other way)
|
||
}
|
||
|
||
const ROT_CS: Record<number, [number, number]> = { 0: [1, 0], 90: [0, 1], 180: [-1, 0], 270: [0, -1] };
|
||
|
||
/** target = S · R(rot) · diag(mir ? −1 : 1, 1) · source + (ox, oy) */
|
||
export function fitMatrix(p: FitParams): Affine {
|
||
const [c, s_] = ROT_CS[p.rot] || [1, 0];
|
||
const mx = p.mir ? -1 : 1;
|
||
return [p.s * c * mx, -p.s * s_, p.ox, p.s * s_ * mx, p.s * c, p.oy];
|
||
}
|
||
|
||
/**
|
||
* Decompose a stored matrix back into panel params. Rotation snaps to the
|
||
* nearest quarter — a legacy 3-point matrix reopens as an editable start,
|
||
* not verbatim, and that is fine: the ghost shows the result live.
|
||
*/
|
||
export function fitFromMatrix(m: Affine): FitParams | null {
|
||
const det = m[0] * m[4] - m[1] * m[3];
|
||
if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
|
||
const mir = det < 0;
|
||
const s = Math.sqrt(Math.abs(det));
|
||
// the second column (b, e) = S·(−sin, cos) is mirror-free
|
||
let ang = Math.atan2(-m[1], m[4]) * 180 / Math.PI;
|
||
ang = ((Math.round(ang / 90) * 90) % 360 + 360) % 360;
|
||
return { ox: m[2], oy: m[5], s, rot: ang as FitParams['rot'], mir };
|
||
}
|
||
|
||
/** A sane opening position: the robot map centred over the plan at 60% size. */
|
||
export function initialFit(
|
||
rooms: VacRoom[],
|
||
vb: [number, number, number, number],
|
||
): FitParams {
|
||
const bx: number[] = [], by: number[] = [];
|
||
for (const r of rooms) {
|
||
if (r.x0 != null) { bx.push(r.x0, r.x1!); by.push(r.y0!, r.y1!); }
|
||
else { bx.push(r.cx); by.push(r.cy); }
|
||
}
|
||
// no rooms at all: an arbitrary honest guess the user will drag anyway
|
||
if (!bx.length) return { ox: vb[0] + vb[2] / 2, oy: vb[1] + vb[3] / 2, s: vb[2] / 10000, rot: 0, mir: true };
|
||
const minX = Math.min(...bx), maxX = Math.max(...bx);
|
||
const minY = Math.min(...by), maxY = Math.max(...by);
|
||
const span = Math.max(maxX - minX, maxY - minY) || 1;
|
||
const s = (Math.min(vb[2], vb[3]) * 0.6) / span;
|
||
// mirror on by default: every robot map seen so far has Y flipped vs screen
|
||
const p: FitParams = { ox: 0, oy: 0, s, rot: 0, mir: true };
|
||
const m = fitMatrix(p);
|
||
const [ccx, ccy] = applyAffine(m, (minX + maxX) / 2, (minY + maxY) / 2);
|
||
p.ox = vb[0] + vb[2] / 2 - ccx;
|
||
p.oy = vb[1] + vb[3] / 2 - ccy;
|
||
return p;
|
||
}
|
||
|
||
/** Re-anchor params so the source point (sx, sy) stays at the same target spot. */
|
||
export function reanchorFit(p: FitParams, prev: FitParams, sx: number, sy: number): FitParams {
|
||
const [px, py] = applyAffine(fitMatrix(prev), sx, sy);
|
||
const trial = fitMatrix({ ...p, ox: 0, oy: 0 });
|
||
const [qx, qy] = applyAffine(trial, sx, sy);
|
||
return { ...p, ox: px - qx, oy: py - qy };
|
||
}
|
||
|
||
export type VacTrailMode = 'never' | 'cleaning' | 'always';
|
||
|
||
/** marker.vacuum → display mode; legacy bool maps in (false = never). */
|
||
export function vacTrailMode(v: { trail?: boolean | null; trail_mode?: string | null } | null | undefined): VacTrailMode {
|
||
const m = v?.trail_mode;
|
||
if (m === 'never' || m === 'cleaning' || m === 'always') return m;
|
||
if (v?.trail === false) return 'never';
|
||
return 'cleaning';
|
||
}
|