mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
/**
|
|
* Pure device-catalog projection for #29.
|
|
*
|
|
* The module deliberately owns neither Lit state nor persistence. The card
|
|
* supplies one registry snapshot and executes the capabilities exposed here
|
|
* through its existing marker/config transactions.
|
|
*/
|
|
import type { HaBindingStatus } from './ha-binding-status';
|
|
import { isRemovedPlanEntity, removedPlanBindings } from './devices';
|
|
import type { DevItem, Marker } from './types';
|
|
|
|
export type DeviceInboxCategory = 'on_plan' | 'available' | 'hidden' | 'readd';
|
|
export type DeviceInboxReason =
|
|
| 'visible_auto' | 'visible_explicit'
|
|
| 'manual_hidden' | 'automatic_hidden'
|
|
| 'service_entry' | 'excluded_integration' | 'excluded_domain'
|
|
| 'grouped_light' | 'represented_by_parent'
|
|
| 'removed' | 'available' | 'no_bound_room';
|
|
|
|
export interface BindingCandidate {
|
|
value: string;
|
|
label: string;
|
|
sub: string;
|
|
kind: 'device' | 'entity';
|
|
ref: string;
|
|
areaId: string;
|
|
model: string;
|
|
parentDeviceId?: string;
|
|
}
|
|
|
|
export interface BindingCandidateLabels {
|
|
device: string;
|
|
z2mGroup: string;
|
|
group: string;
|
|
helper: string;
|
|
entity: string;
|
|
}
|
|
|
|
export interface BindingCandidateInput {
|
|
hass: any;
|
|
devices: readonly DevItem[];
|
|
markers: readonly Marker[];
|
|
showEntities: boolean;
|
|
currentBinding?: string;
|
|
currentDeviceId?: string;
|
|
labels: BindingCandidateLabels;
|
|
}
|
|
|
|
const HELPER_PLATFORMS = new Set([
|
|
'group', 'template', 'derivative', 'min_max', 'threshold', 'integration',
|
|
'statistics', 'trend', 'utility_meter', 'tod', 'switch_as_x', 'schedule',
|
|
]);
|
|
|
|
/** One shared Add/catalog eligibility implementation. Filtering and paging
|
|
* happen after this function so an exact entity can never disappear behind a
|
|
* pre-filter hard cap. */
|
|
export function bindingCandidates(input: BindingCandidateInput): BindingCandidate[] {
|
|
const { hass: h, devices, markers, showEntities, currentBinding, currentDeviceId, labels } = input;
|
|
const removed = removedPlanBindings(markers);
|
|
const removedBindings = new Set(markers.filter((m) => m.removed).map((m) => m.binding));
|
|
const taken = new Set<string>();
|
|
for (const dev of devices) {
|
|
if (dev.id === currentDeviceId) continue;
|
|
if (dev.bindingKind === 'device' && dev.bindingRef) taken.add(`device:${dev.bindingRef}`);
|
|
if (dev.bindingKind === 'entity' && dev.bindingRef) taken.add(`entity:${dev.bindingRef}`);
|
|
}
|
|
|
|
const shownKeys = new Set<string>();
|
|
for (const dev of devices) {
|
|
if (dev.bindingKind === 'device' && dev.name) shownKeys.add(`${dev.name.trim()}|${dev.area || ''}`);
|
|
}
|
|
|
|
const list: BindingCandidate[] = [];
|
|
for (const dev of Object.values<any>(h?.devices || {})) {
|
|
if (!dev || dev.entry_type === 'service') continue;
|
|
const value = `device:${dev.id}`;
|
|
if (taken.has(value)) continue;
|
|
const name = String(dev.name_by_user || dev.name || dev.id).trim();
|
|
if (value !== currentBinding && !removedBindings.has(value)
|
|
&& shownKeys.has(`${name}|${dev.area_id || ''}`)) continue;
|
|
list.push({
|
|
value, label: name,
|
|
sub: (dev.model || labels.device) + (dev.model === 'Group' ? labels.z2mGroup : ''),
|
|
kind: 'device', ref: dev.id, areaId: dev.area_id || '', model: dev.model || '',
|
|
});
|
|
}
|
|
|
|
for (const [eid, reg] of Object.entries<any>(h?.entities || {})) {
|
|
const value = `entity:${eid}`;
|
|
if (taken.has(value)) continue;
|
|
if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value)) continue;
|
|
const isHelper = HELPER_PLATFORMS.has(reg?.platform);
|
|
const isGroup = reg?.platform === 'group';
|
|
if (!isHelper && !isGroup) continue;
|
|
if (reg?.hidden && !removedBindings.has(value)) continue;
|
|
const state = h?.states?.[eid];
|
|
list.push({
|
|
value,
|
|
label: reg?.name || state?.attributes?.friendly_name || eid,
|
|
sub: `${eid.split('.')[0]} · ${isGroup ? labels.group : labels.helper}`,
|
|
kind: 'entity', ref: eid,
|
|
areaId: reg?.area_id || (reg?.device_id && h?.devices?.[reg.device_id]?.area_id) || '',
|
|
model: '', parentDeviceId: reg?.device_id || undefined,
|
|
});
|
|
}
|
|
|
|
if (showEntities) {
|
|
const seen = new Set(list.map((item) => item.value));
|
|
for (const [eid, reg] of Object.entries<any>(h?.entities || {})) {
|
|
const value = `entity:${eid}`;
|
|
if (taken.has(value) || seen.has(value) || (reg?.hidden && !removedBindings.has(value))) continue;
|
|
const childOfRemovedDevice = !!reg?.device_id && removed.devices.has(reg.device_id);
|
|
if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value) && !childOfRemovedDevice) continue;
|
|
const state = h?.states?.[eid];
|
|
const parent = reg?.device_id ? h?.devices?.[reg.device_id] : null;
|
|
const parentName = parent ? String(parent.name_by_user || parent.name || '') : '';
|
|
list.push({
|
|
value,
|
|
label: reg?.name || state?.attributes?.friendly_name || eid,
|
|
sub: `${eid.split('.')[0]} · ${labels.entity}${parentName ? ` · ${parentName}` : ''}`,
|
|
kind: 'entity', ref: eid,
|
|
areaId: reg?.area_id || parent?.area_id || '', model: '',
|
|
parentDeviceId: reg?.device_id || undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
return list.sort((a, b) => a.label.localeCompare(b.label) || a.value.localeCompare(b.value));
|
|
}
|
|
|
|
export interface DeviceInboxRow {
|
|
key: string;
|
|
binding: string;
|
|
category: DeviceInboxCategory;
|
|
status: HaBindingStatus;
|
|
reason: DeviceInboxReason;
|
|
deviceId?: string;
|
|
markerId?: string;
|
|
name: string;
|
|
icon: string;
|
|
model: string;
|
|
integration: string;
|
|
areaId: string;
|
|
areaName: string;
|
|
spaceId: string;
|
|
spaceName: string;
|
|
kind: 'device' | 'entity';
|
|
isNew: boolean;
|
|
searchText: string;
|
|
canFind: boolean;
|
|
canEdit: boolean;
|
|
canHide: boolean;
|
|
canShow: boolean;
|
|
canAdd: boolean;
|
|
}
|
|
|
|
export interface DeviceInboxInput {
|
|
devices: readonly DevItem[];
|
|
markers: readonly Marker[];
|
|
candidates: readonly BindingCandidate[];
|
|
statuses: ReadonlyMap<string, HaBindingStatus>;
|
|
newDeviceIds: ReadonlySet<string>;
|
|
showHiddenOnPlan: boolean;
|
|
areaNames?: Readonly<Record<string, string>>;
|
|
spaceNames?: Readonly<Record<string, string>>;
|
|
spaceByArea?: Readonly<Record<string, string>>;
|
|
integrationByBinding?: Readonly<Record<string, string>>;
|
|
/** Best-effort explanation of an automatically hidden/represented binding. */
|
|
reasonByBinding?: Readonly<Record<string, DeviceInboxReason>>;
|
|
}
|
|
|
|
const ACTIVE_STATUS: HaBindingStatus = {
|
|
kind: 'active', enabledEntityIds: [], allEntityIds: [],
|
|
};
|
|
|
|
function markerBindingKind(binding: string): 'device' | 'entity' {
|
|
return binding.startsWith('entity:') ? 'entity' : 'device';
|
|
}
|
|
|
|
function autoHiddenMarker(marker: Marker): boolean {
|
|
const split = marker.binding.indexOf(':');
|
|
const ref = split >= 0 ? marker.binding.slice(split + 1) : '';
|
|
const meaningful = Object.keys(marker).filter((key) => !['id', 'binding', 'hidden'].includes(key));
|
|
return marker.id === `h${ref}` && meaningful.length === 0;
|
|
}
|
|
|
|
/** Build one deterministic row for every exact binding in the catalog. */
|
|
export function buildDeviceInbox(input: DeviceInboxInput): DeviceInboxRow[] {
|
|
const {
|
|
devices, markers, candidates, statuses, newDeviceIds, showHiddenOnPlan,
|
|
areaNames = {}, spaceNames = {}, spaceByArea = {}, integrationByBinding = {},
|
|
reasonByBinding = {},
|
|
} = input;
|
|
const runtimeByBinding = new Map<string, DevItem>();
|
|
for (const device of devices) {
|
|
if (device.virtual || !device.bindingKind || device.bindingKind === 'virtual' || !device.bindingRef) continue;
|
|
runtimeByBinding.set(`${device.bindingKind}:${device.bindingRef}`, device);
|
|
}
|
|
const liveByBinding = new Map<string, Marker>();
|
|
const removedByBinding = new Map<string, Marker>();
|
|
for (const marker of markers) {
|
|
if (!marker?.binding || marker.binding === 'virtual') continue;
|
|
if (marker.removed) removedByBinding.set(marker.binding, marker);
|
|
else liveByBinding.set(marker.binding, marker);
|
|
}
|
|
const candidateByBinding = new Map(candidates.map((item) => [item.value, item]));
|
|
const keys = new Set<string>([
|
|
...runtimeByBinding.keys(), ...liveByBinding.keys(), ...candidateByBinding.keys(),
|
|
...[...removedByBinding.keys()].filter((key) => candidateByBinding.has(key)),
|
|
]);
|
|
|
|
const rows: DeviceInboxRow[] = [];
|
|
for (const binding of keys) {
|
|
const runtime = runtimeByBinding.get(binding);
|
|
const live = liveByBinding.get(binding);
|
|
const removed = live ? undefined : removedByBinding.get(binding);
|
|
const candidate = candidateByBinding.get(binding);
|
|
let category: DeviceInboxCategory;
|
|
if (removed && candidate) category = 'readd';
|
|
else if (live?.hidden === true) category = 'hidden';
|
|
else if (runtime || live) category = 'on_plan';
|
|
else if (candidate) category = 'available';
|
|
else continue;
|
|
|
|
const status = runtime?.bindingStatus || statuses.get(binding) || ACTIVE_STATUS;
|
|
const marker = live || removed;
|
|
const kind = markerBindingKind(binding);
|
|
const areaId = runtime?.area || candidate?.areaId || marker?.area || '';
|
|
const spaceId = runtime?.space || marker?.space || spaceByArea[areaId] || '';
|
|
const name = runtime?.name || marker?.name || candidate?.label || binding;
|
|
const model = runtime?.model || marker?.model || candidate?.model || '';
|
|
const representedByParent = !!candidate?.parentDeviceId
|
|
&& runtimeByBinding.has(`device:${candidate.parentDeviceId}`);
|
|
const reason: DeviceInboxReason = category === 'readd' ? 'removed'
|
|
: category === 'hidden' ? (live && autoHiddenMarker(live)
|
|
? reasonByBinding[binding] || 'automatic_hidden' : 'manual_hidden')
|
|
: category === 'on_plan' ? (live ? 'visible_explicit' : 'visible_auto')
|
|
: reasonByBinding[binding] || (representedByParent ? 'represented_by_parent'
|
|
: areaId && spaceId ? 'available' : 'no_bound_room');
|
|
const isRendered = !!runtime && (!runtime.hidden || showHiddenOnPlan);
|
|
const active = status.kind === 'active';
|
|
const canFind = isRendered && (active
|
|
|| (status.kind === 'ha_disabled' && showHiddenOnPlan));
|
|
const searchText = [
|
|
name, model, integrationByBinding[binding], areaNames[areaId], spaceNames[spaceId],
|
|
binding, candidate?.sub,
|
|
].filter(Boolean).join(' ').toLocaleLowerCase();
|
|
rows.push({
|
|
key: binding, binding, category, status, reason,
|
|
deviceId: runtime?.id,
|
|
markerId: marker?.id,
|
|
name, icon: runtime?.icon || marker?.icon || (kind === 'entity' ? 'mdi:code-braces' : 'mdi:devices'),
|
|
model, integration: integrationByBinding[binding] || '',
|
|
areaId, areaName: areaNames[areaId] || '',
|
|
spaceId, spaceName: spaceNames[spaceId] || '',
|
|
kind, isNew: !!runtime && newDeviceIds.has(runtime.id), searchText,
|
|
canFind,
|
|
canEdit: !!runtime || !!live,
|
|
canHide: category === 'on_plan' && active,
|
|
canShow: category === 'hidden' && active,
|
|
canAdd: (category === 'available' || category === 'readd') && active,
|
|
});
|
|
}
|
|
const statusRank = (row: DeviceInboxRow): number => row.status.kind === 'active' ? 1 : 0;
|
|
return rows.sort((a, b) => a.category.localeCompare(b.category)
|
|
|| Number(b.isNew) - Number(a.isNew)
|
|
|| statusRank(a) - statusRank(b)
|
|
|| a.name.localeCompare(b.name) || a.binding.localeCompare(b.binding));
|
|
}
|
|
|
|
export function filterDeviceInbox(
|
|
rows: readonly DeviceInboxRow[], category: DeviceInboxCategory, query: string, onlyNew = false,
|
|
): DeviceInboxRow[] {
|
|
const needle = query.trim().toLocaleLowerCase();
|
|
return rows.filter((row) => row.category === category
|
|
&& (!onlyNew || row.isNew)
|
|
&& (!needle || row.searchText.includes(needle)));
|
|
}
|