mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
168 lines
7.6 KiB
TypeScript
168 lines
7.6 KiB
TypeScript
/** Pure #485 presence-radar client model. No HA state interpretation lives here. */
|
|
import type { I18nKey } from './i18n';
|
|
import type { Marker, MarkerRadar } from './types';
|
|
|
|
export function isMarkerRadarV1(value: unknown): value is MarkerRadar {
|
|
return !!value && typeof value === 'object'
|
|
&& (value as { version?: unknown }).version === 1;
|
|
}
|
|
|
|
export function radarMarkerLiveInSpace(marker: Marker, space: string): boolean {
|
|
const radar = marker.radar;
|
|
return marker.space === space && marker.hidden !== true && marker.removed !== true
|
|
&& isMarkerRadarV1(radar) && radar.enabled === true && radar.show_live !== false;
|
|
}
|
|
|
|
const RADAR_HEALTH_KEYS: Partial<Record<string, I18nKey>> = {
|
|
ok: 'radar.health_ok', off: 'radar.health_off', stale: 'radar.health_stale',
|
|
partial: 'radar.health_partial', restricted: 'radar.health_restricted',
|
|
incomplete: 'radar.health_incomplete',
|
|
position_unavailable: 'radar.health_position_unavailable',
|
|
unavailable: 'radar.health_unavailable', needs_setup: 'radar.health_needs_setup',
|
|
inconsistent: 'radar.health_inconsistent', unknown: 'radar.health_unknown',
|
|
};
|
|
|
|
export function radarHealthI18nKey(radar: MarkerRadar, health?: string): I18nKey {
|
|
if (radar.enabled !== true) return 'radar.health_disabled';
|
|
return RADAR_HEALTH_KEYS[health || ''] || 'radar.health_unknown';
|
|
}
|
|
|
|
export interface RadarLiveTarget {
|
|
slot: string;
|
|
x: number;
|
|
y: number;
|
|
reported_at: number;
|
|
expires_at: number;
|
|
pair_quality: 'bounded_latest' | 'coherent';
|
|
included: boolean;
|
|
smooth?: boolean;
|
|
reason?: string | null;
|
|
}
|
|
|
|
export interface RadarLiveRange {
|
|
id: string; x: number; y: number; radius: number;
|
|
heading_deg?: number; fov_deg?: number | null;
|
|
reported_at: number; expires_at: number;
|
|
segments?: readonly (readonly (readonly [number, number])[])[];
|
|
}
|
|
|
|
export interface RadarLiveZone {
|
|
id: string;
|
|
state: boolean | number | null;
|
|
/** Server-owned geometry for a verified adapter; the client never invents it. */
|
|
polygon?: readonly (readonly [number, number])[];
|
|
}
|
|
|
|
export interface RadarLiveFrame {
|
|
server_session_id: string;
|
|
marker_id: string;
|
|
source_generation?: string;
|
|
calibration_revision?: string;
|
|
seq: number;
|
|
reported_at?: number;
|
|
expires_at?: number;
|
|
health: string;
|
|
reported_presence?: boolean | null;
|
|
complete: boolean;
|
|
targets: RadarLiveTarget[];
|
|
ranges: RadarLiveRange[];
|
|
zones: RadarLiveZone[];
|
|
}
|
|
|
|
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
|
|
|
export function normalizeRadarFrame(value: unknown): RadarLiveFrame | null {
|
|
if (!value || typeof value !== 'object') return null;
|
|
const raw = value as Record<string, unknown>;
|
|
if (typeof raw.marker_id !== 'string' || typeof raw.server_session_id !== 'string'
|
|
|| !Number.isSafeInteger(raw.seq) || typeof raw.health !== 'string') return null;
|
|
const targets = Array.isArray(raw.targets) ? raw.targets.slice(0, 256).flatMap((entry) => {
|
|
if (!entry || typeof entry !== 'object') return [];
|
|
const item = entry as Record<string, unknown>;
|
|
if (typeof item.slot !== 'string' || !finite(item.x) || !finite(item.y)
|
|
|| !finite(item.expires_at) || !finite(item.reported_at)
|
|
|| (item.pair_quality !== 'bounded_latest' && item.pair_quality !== 'coherent')) return [];
|
|
return [{ ...item, included: item.included === true,
|
|
smooth: item.smooth === true } as RadarLiveTarget];
|
|
}) : [];
|
|
const ranges = Array.isArray(raw.ranges) ? raw.ranges.slice(0, 64).flatMap((entry) => {
|
|
if (!entry || typeof entry !== 'object') return [];
|
|
const item = entry as Record<string, unknown>;
|
|
if (typeof item.id !== 'string' || !finite(item.x) || !finite(item.y)
|
|
|| !finite(item.radius) || !finite(item.expires_at) || !finite(item.reported_at)) return [];
|
|
const segments = Array.isArray(item.segments) ? item.segments.slice(0, 64).flatMap((segment) => {
|
|
if (!Array.isArray(segment)) return [];
|
|
const points = segment.slice(0, 181).flatMap((point) => (
|
|
Array.isArray(point) && point.length === 2 && finite(point[0]) && finite(point[1])
|
|
? [[point[0], point[1]] as const] : []
|
|
));
|
|
return points.length >= 2 ? [points] : [];
|
|
}) : undefined;
|
|
return [{ ...item, ...(segments ? { segments } : {}) } as unknown as RadarLiveRange];
|
|
}) : [];
|
|
const zones = Array.isArray(raw.zones) ? raw.zones.slice(0, 64).flatMap((entry) => {
|
|
if (!entry || typeof entry !== 'object') return [];
|
|
const item = entry as Record<string, unknown>;
|
|
if (typeof item.id !== 'string') return [];
|
|
const state = item.state;
|
|
if (!(state === null || typeof state === 'boolean' || finite(state))) return [];
|
|
const polygon = Array.isArray(item.polygon) ? item.polygon.slice(0, 64).flatMap((point) => {
|
|
if (!Array.isArray(point) || point.length !== 2 || !finite(point[0]) || !finite(point[1])) return [];
|
|
return [[point[0], point[1]] as const];
|
|
}) : [];
|
|
return [{ id: item.id, state, ...(polygon.length >= 3 ? { polygon } : {}) } as RadarLiveZone];
|
|
}) : [];
|
|
return {
|
|
server_session_id: raw.server_session_id,
|
|
marker_id: raw.marker_id,
|
|
source_generation: typeof raw.source_generation === 'string' ? raw.source_generation : undefined,
|
|
calibration_revision: typeof raw.calibration_revision === 'string' ? raw.calibration_revision : undefined,
|
|
seq: raw.seq as number,
|
|
reported_at: finite(raw.reported_at) ? raw.reported_at : undefined,
|
|
expires_at: finite(raw.expires_at) ? raw.expires_at : undefined,
|
|
health: raw.health,
|
|
reported_presence: typeof raw.reported_presence === 'boolean' ? raw.reported_presence : null,
|
|
complete: raw.complete === true,
|
|
targets, ranges, zones,
|
|
};
|
|
}
|
|
|
|
export function radarFrameAccepts(previous: RadarLiveFrame | undefined,
|
|
next: RadarLiveFrame): boolean {
|
|
if (!previous) return true;
|
|
if (next.server_session_id !== previous.server_session_id) return true;
|
|
if (next.source_generation !== previous.source_generation
|
|
|| next.calibration_revision !== previous.calibration_revision) return true;
|
|
return next.seq > previous.seq;
|
|
}
|
|
|
|
export function radarFrameExpired(frame: RadarLiveFrame, nowMs = Date.now()): boolean {
|
|
return finite(frame.expires_at) && frame.expires_at * 1000 <= nowMs;
|
|
}
|
|
|
|
/** Remaining lease at receipt. Subsequent expiry uses a monotonic clock so a
|
|
* wall-clock correction cannot resurrect or prematurely erase an observation. */
|
|
export function radarFrameLeaseMs(frame: RadarLiveFrame, wallNowMs = Date.now()): number | null {
|
|
if (!finite(frame.expires_at)) return null;
|
|
if (!finite(frame.reported_at)) return Math.max(0, frame.expires_at * 1000 - wallNowMs);
|
|
const fullLease = Math.max(0, (frame.expires_at - frame.reported_at) * 1000);
|
|
const transportAge = Math.max(0, wallNowMs - frame.reported_at * 1000);
|
|
return Math.max(0, fullLease - transportAge);
|
|
}
|
|
|
|
export function radarArcPath(range: RadarLiveRange): string | null {
|
|
const fov = finite(range.fov_deg) && range.fov_deg! > 0 ? Math.min(360, range.fov_deg!) : null;
|
|
if (!fov) return null;
|
|
if (fov >= 359.999) {
|
|
return `M ${range.x - range.radius} ${range.y} A ${range.radius} ${range.radius} 0 1 0 ${range.x + range.radius} ${range.y} A ${range.radius} ${range.radius} 0 1 0 ${range.x - range.radius} ${range.y}`;
|
|
}
|
|
const center = (range.heading_deg || 0) - 90;
|
|
const a = (center - fov / 2) * Math.PI / 180;
|
|
const b = (center + fov / 2) * Math.PI / 180;
|
|
const ax = range.x + Math.cos(a) * range.radius;
|
|
const ay = range.y + Math.sin(a) * range.radius;
|
|
const bx = range.x + Math.cos(b) * range.radius;
|
|
const by = range.y + Math.sin(b) * range.radius;
|
|
return `M ${ax} ${ay} A ${range.radius} ${range.radius} 0 ${fov > 180 ? 1 : 0} 1 ${bx} ${by}`;
|
|
}
|