mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
118 lines
4.9 KiB
TypeScript
118 lines
4.9 KiB
TypeScript
import { union, type Geom } from 'polyclip-ts';
|
|
import { formatArea } from './area-format';
|
|
import { removedPlanBindings } from './devices';
|
|
import { geometryArea, floorMinusBodies } from './physical-geometry';
|
|
import { prepareSpacePhysicalGeometryInputs } from './plan-geometry-preflight';
|
|
import { GRID_PITCH, GRID_STEP_N, NORM_W } from './space-geometry';
|
|
import { hassValue, roomPoly, valueWithUnit } from './logic';
|
|
import { innerContourForRoom } from './wall-thickness';
|
|
import type { Marker, ServerConfig, SpaceModel, SummaryPanelSource } from './types';
|
|
import type { HaRegistrySnapshot } from './ha-binding-status';
|
|
import type { SummaryHass } from './summary-panel-host';
|
|
|
|
/** Count the unique real HA devices represented anywhere on the plan. */
|
|
export function representedHaDeviceIds(input: {
|
|
registry: HaRegistrySnapshot;
|
|
areaToSpace: Record<string, string>;
|
|
spaceIds: ReadonlySet<string>;
|
|
firstSpaceId: string;
|
|
markers: readonly Marker[];
|
|
}): Set<string> | null {
|
|
if (!input.registry.authoritative) return null;
|
|
const out = new Set<string>();
|
|
const removed = removedPlanBindings(input.markers);
|
|
for (const device of Object.values(input.registry.devices || {})) {
|
|
if (!device?.id || device.entry_type === 'service' || removed.devices.has(device.id)) continue;
|
|
if (device.area_id && input.areaToSpace[device.area_id]) out.add(device.id);
|
|
}
|
|
for (const marker of input.markers || []) {
|
|
if (marker.removed || marker.binding === 'virtual') continue;
|
|
const separator = String(marker.binding || '').indexOf(':');
|
|
if (separator < 1) continue;
|
|
const kind = marker.binding.slice(0, separator);
|
|
const ref = marker.binding.slice(separator + 1);
|
|
const device = kind === 'device' ? input.registry.devices?.[ref] : null;
|
|
const entity = kind === 'entity' ? input.registry.entities?.[ref] : null;
|
|
const deviceId = kind === 'device' ? ref : entity?.device_id;
|
|
const registryArea = kind === 'device' ? device?.area_id
|
|
: entity?.area_id || (deviceId && input.registry.devices?.[deviceId]?.area_id);
|
|
const manualRoomWithoutArea = typeof marker.room_id === 'string'
|
|
&& marker.room_id.length > 0 && marker.area === null;
|
|
const area = manualRoomWithoutArea ? '' : marker.area || registryArea || '';
|
|
const space = area && input.areaToSpace[area] || marker.space || input.firstSpaceId;
|
|
if (deviceId && input.registry.devices?.[deviceId]
|
|
&& input.spaceIds.has(space)
|
|
&& (!removed.devices.has(deviceId) || kind === 'entity' && removed.liveEntities.has(ref))) {
|
|
out.add(deviceId);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function unionGeometry(current: Geom | null, next: Geom): Geom {
|
|
if (!current?.length) return next;
|
|
if (!next?.length) return current;
|
|
return union(current, next);
|
|
}
|
|
|
|
/** Canonical clean-floor union, in physical square metres, for every space. */
|
|
export function totalCleanFloorAreaM2(
|
|
config: ServerConfig,
|
|
models: readonly SpaceModel[],
|
|
): number | null {
|
|
try {
|
|
let total = 0;
|
|
for (const space of models) {
|
|
const raw = config.spaces.find((item) => String(item?.id) === space.id);
|
|
if (!raw) continue;
|
|
const prepared = prepareSpacePhysicalGeometryInputs(raw, space);
|
|
let spaceFloor: Geom | null = null;
|
|
for (const room of space.rooms) {
|
|
if (!room.id) continue;
|
|
const poly = roomPoly(room);
|
|
if (!poly) continue;
|
|
const inner = innerContourForRoom(
|
|
space.rooms, room.id, prepared.walls, prepared.openCuts,
|
|
GRID_STEP_N, prepared.cellCm, GRID_PITCH, NORM_W,
|
|
) || poly;
|
|
const clean = floorMinusBodies(inner, prepared.physicalBodies) as Geom;
|
|
spaceFloor = unionGeometry(spaceFloor, clean);
|
|
}
|
|
const cmPerUnit = prepared.cellCm / GRID_PITCH;
|
|
total += geometryArea(spaceFloor) * cmPerUnit * cmPerUnit / 1e4;
|
|
}
|
|
return total;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function summarySystemValue(
|
|
source: SummaryPanelSource,
|
|
values: { deviceCount: number | null; areaM2: number | null; now: Date },
|
|
hass: SummaryHass | undefined,
|
|
locale: string,
|
|
): string | null {
|
|
if (source.type !== 'system') return null;
|
|
if (source.key === 'device_count') return values.deviceCount === null ? null : String(values.deviceCount);
|
|
if (source.key === 'total_area') {
|
|
if (values.areaM2 === null) return null;
|
|
return formatArea(values.areaM2, hass?.config?.unit_system?.length === 'mi');
|
|
}
|
|
try {
|
|
return new Intl.DateTimeFormat(locale || undefined, {
|
|
dateStyle: 'short', timeStyle: 'short', timeZone: hass?.config?.time_zone || undefined,
|
|
}).format(values.now);
|
|
} catch {
|
|
return values.now.toLocaleString();
|
|
}
|
|
}
|
|
|
|
export function summaryEntityValue(hass: SummaryHass | undefined, entityId: string): string | null {
|
|
const state = hass?.states?.[entityId];
|
|
if (!state) return null;
|
|
const value = hassValue(hass, entityId);
|
|
if (!value) return null;
|
|
return valueWithUnit(value, String(state.attributes?.unit_of_measurement || ''));
|
|
}
|