mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
Fix linked virtual light controller authority
Issue: #174 User-Visible: yes
This commit is contained in:
+121
-73
@@ -7,10 +7,12 @@
|
||||
*/
|
||||
import {
|
||||
forcedLightEntityOf,
|
||||
incomingLightControls,
|
||||
persistedExternalControls,
|
||||
resolvedDeviceStateEntities,
|
||||
resolvedLightSources,
|
||||
type ResolvedLightSource,
|
||||
type IncomingLightControl,
|
||||
} from './devices';
|
||||
import { COVER_GUARDED_CLASSES, isControllable } from './logic';
|
||||
import type { DevItem } from './types';
|
||||
@@ -389,17 +391,6 @@ function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
|
||||
].filter((eid): eid is string => !!eid))];
|
||||
}
|
||||
|
||||
function ownControllableCandidate(
|
||||
device: DevItem, registryHass: any,
|
||||
): { entityId: string; via: ToggleTargetVia } | null {
|
||||
const role = ownRoleCandidates(device, registryHass);
|
||||
const entityId = role.find(isControllable) || null;
|
||||
return entityId ? {
|
||||
entityId,
|
||||
via: device.bindingKind === 'entity' ? 'binding' : 'device-role',
|
||||
} : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the first supported member of the already-selected functional role.
|
||||
* Missing/unavailable/secure members remain the explained target and never
|
||||
@@ -487,27 +478,18 @@ function singleIntent(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
|
||||
const { hass, device, devices } = options;
|
||||
const registryHass = options.registryHass || hass;
|
||||
const refs = persistedExternalControls(
|
||||
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
|
||||
);
|
||||
const sources = options.lightSources
|
||||
|| resolvedLightSources(hass, devices, null, options.virtualLights);
|
||||
const markerSources = new Map<string, ResolvedLightSource<DevItem>[]>();
|
||||
for (const source of sources) {
|
||||
if (!source.key.startsWith('marker:')) continue;
|
||||
const list = markerSources.get(source.key) || [];
|
||||
list.push(source);
|
||||
markerSources.set(source.key, list);
|
||||
}
|
||||
const own = ownControllableCandidate(device, registryHass);
|
||||
const markerDevices = new Map<string, DevItem>();
|
||||
for (const item of devices) {
|
||||
const markerId = String(item.marker?.id || item.id || '');
|
||||
if (markerId) markerDevices.set(markerId, item);
|
||||
}
|
||||
interface GroupEntityRef {
|
||||
entityId: string;
|
||||
via: ToggleTargetVia;
|
||||
ref: string;
|
||||
}
|
||||
|
||||
function resolveGroupEntities(
|
||||
hass: any,
|
||||
registryHass: any,
|
||||
entries: readonly GroupEntityRef[],
|
||||
initialSkipped: readonly SkippedToggleTarget[] = [],
|
||||
): ResolvedToggleIntent {
|
||||
const byEntity = new Map<string, ResolvedToggleTarget>();
|
||||
const skippedTargets: SkippedToggleTarget[] = [];
|
||||
const skippedKeys = new Set<string>();
|
||||
@@ -517,46 +499,12 @@ function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
|
||||
skippedKeys.add(key);
|
||||
skippedTargets.push(target);
|
||||
};
|
||||
const addEntity = (entityId: string, via: ToggleTargetVia, ref: string): void => {
|
||||
const result = resolveEntity(hass, registryHass, entityId, via, ref);
|
||||
if (result.target) byEntity.set(entityId, byEntity.get(entityId) || result.target);
|
||||
else if (result.skipped) addSkipped(result.skipped);
|
||||
};
|
||||
|
||||
for (const ref of refs) {
|
||||
if (!ref.startsWith('marker:')) {
|
||||
if (!isControllable(ref)) {
|
||||
addSkipped(skipped(hass, registryHass, ref, ref.includes('.') ? ref : null, 'unsupported'));
|
||||
} else addEntity(ref, 'control-entity', ref);
|
||||
continue;
|
||||
}
|
||||
const linked = markerSources.get(ref) || [];
|
||||
if (!linked.length) {
|
||||
const target = markerDevices.get(ref.slice('marker:'.length));
|
||||
if (target?.bindingStatus?.kind === 'ha_disabled') {
|
||||
const entityId = target.bindingStatus.allEntityIds[0] || null;
|
||||
addSkipped({
|
||||
ref,
|
||||
entityId,
|
||||
name: target.name || (entityId ? entityName(hass, registryHass, entityId) : null),
|
||||
reason: 'ha-disabled',
|
||||
});
|
||||
} else {
|
||||
addSkipped(skipped(hass, registryHass, ref, null, 'missing'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const serviceEntities = [...new Set(linked.flatMap((source) => source.serviceEids))];
|
||||
if (serviceEntities.length) {
|
||||
for (const entityId of serviceEntities) addEntity(entityId, 'control-entity', ref);
|
||||
continue;
|
||||
}
|
||||
const passive = linked.some((source) => source.passive);
|
||||
if (passive && own && isControllable(own.entityId)) {
|
||||
addEntity(own.entityId, 'control-marker-driver', ref);
|
||||
} else {
|
||||
addSkipped(skipped(hass, registryHass, ref, null, passive ? 'missing' : 'unsupported'));
|
||||
}
|
||||
for (const target of initialSkipped) addSkipped(target);
|
||||
for (const entry of entries) {
|
||||
const result = resolveEntity(hass, registryHass, entry.entityId, entry.via, entry.ref);
|
||||
if (result.target) {
|
||||
byEntity.set(entry.entityId, byEntity.get(entry.entityId) || result.target);
|
||||
} else if (result.skipped) addSkipped(result.skipped);
|
||||
}
|
||||
|
||||
const targets = [...byEntity.values()];
|
||||
@@ -580,7 +528,9 @@ function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
|
||||
origin: 'explicit-toggle', kind: 'group', semantics: 'group-power', targets: [],
|
||||
skippedTargets: [
|
||||
...skippedTargets,
|
||||
...targets.map((target) => skipped(hass, registryHass, target.entityId, target.entityId, 'unsupported')),
|
||||
...targets.map((target) => skipped(
|
||||
hass, registryHass, target.entityId, target.entityId, 'unsupported',
|
||||
)),
|
||||
],
|
||||
noneReason: 'unsupported', nextEffect: null, command: null,
|
||||
};
|
||||
@@ -600,6 +550,101 @@ function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
|
||||
};
|
||||
}
|
||||
|
||||
function stableDeviceId(device: DevItem): string {
|
||||
return String(device.marker?.id || device.id || '');
|
||||
}
|
||||
|
||||
function resolveIncomingControllers(
|
||||
options: ResolveToggleOptions,
|
||||
incoming: IncomingLightControl<DevItem>,
|
||||
): ResolvedToggleIntent {
|
||||
const registryHass = options.registryHass || options.hass;
|
||||
return resolveGroupEntities(
|
||||
options.hass,
|
||||
registryHass,
|
||||
incoming.driverEids.map((entityId) => ({
|
||||
entityId,
|
||||
via: 'control-marker-driver' as const,
|
||||
ref: `marker:${incoming.markerId}`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
|
||||
const { hass, device, devices } = options;
|
||||
const registryHass = options.registryHass || hass;
|
||||
const refs = persistedExternalControls(
|
||||
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
|
||||
);
|
||||
const sources = options.lightSources
|
||||
|| resolvedLightSources(hass, devices, null, options.virtualLights);
|
||||
const markerSources = new Map<string, ResolvedLightSource<DevItem>[]>();
|
||||
for (const source of sources) {
|
||||
if (!source.key.startsWith('marker:')) continue;
|
||||
const list = markerSources.get(source.key) || [];
|
||||
list.push(source);
|
||||
markerSources.set(source.key, list);
|
||||
}
|
||||
const incomingByMarker = incomingLightControls(devices);
|
||||
const markerDevices = new Map<string, DevItem>();
|
||||
for (const item of devices) {
|
||||
const markerId = String(item.marker?.id || item.id || '');
|
||||
if (markerId) markerDevices.set(markerId, item);
|
||||
}
|
||||
const entries: GroupEntityRef[] = [];
|
||||
const skippedTargets: SkippedToggleTarget[] = [];
|
||||
|
||||
for (const ref of refs) {
|
||||
if (!ref.startsWith('marker:')) {
|
||||
if (!isControllable(ref)) {
|
||||
skippedTargets.push(skipped(
|
||||
hass, registryHass, ref, ref.includes('.') ? ref : null, 'unsupported',
|
||||
));
|
||||
} else entries.push({ entityId: ref, via: 'control-entity', ref });
|
||||
continue;
|
||||
}
|
||||
const linked = markerSources.get(ref) || [];
|
||||
if (!linked.length) {
|
||||
const target = markerDevices.get(ref.slice('marker:'.length));
|
||||
if (target?.bindingStatus?.kind === 'ha_disabled') {
|
||||
const entityId = target.bindingStatus.allEntityIds[0] || null;
|
||||
skippedTargets.push({
|
||||
ref,
|
||||
entityId,
|
||||
name: target.name || (entityId ? entityName(hass, registryHass, entityId) : null),
|
||||
reason: 'ha-disabled',
|
||||
});
|
||||
} else {
|
||||
skippedTargets.push(skipped(hass, registryHass, ref, null, 'missing'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const serviceEntities = [...new Set(linked.flatMap((source) => source.serviceEids))];
|
||||
if (serviceEntities.length) {
|
||||
for (const entityId of serviceEntities) {
|
||||
entries.push({ entityId, via: 'control-entity', ref });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const passive = linked.some((source) => source.passive);
|
||||
const targetId = ref.slice('marker:'.length);
|
||||
const controllerId = stableDeviceId(device);
|
||||
const controller = incomingByMarker.get(targetId)?.controllers.find(
|
||||
(candidate) => stableDeviceId(candidate.device) === controllerId,
|
||||
);
|
||||
if (passive && controller?.driverEids.length) {
|
||||
for (const entityId of controller.driverEids) {
|
||||
entries.push({ entityId, via: 'control-marker-driver', ref });
|
||||
}
|
||||
} else {
|
||||
skippedTargets.push(skipped(
|
||||
hass, registryHass, ref, null, passive ? 'missing' : 'unsupported',
|
||||
));
|
||||
}
|
||||
}
|
||||
return resolveGroupEntities(hass, registryHass, entries, skippedTargets);
|
||||
}
|
||||
|
||||
/** Resolve the current exact target and command for one toggle-origin marker. */
|
||||
export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedToggleIntent | null {
|
||||
const { hass, device } = options;
|
||||
@@ -612,6 +657,9 @@ export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedTogg
|
||||
// changes the marker's own server state and never calls an HA service.
|
||||
const marker = device.marker;
|
||||
if (origin === 'explicit-toggle' && isManualVirtualLightMarker(marker)) {
|
||||
const markerId = String(marker?.id || device.id || '');
|
||||
const incoming = incomingLightControls(options.devices).get(markerId);
|
||||
if (incoming) return resolveIncomingControllers(options, incoming);
|
||||
const on = virtualLightIsOn(marker, options.virtualLights);
|
||||
return {
|
||||
origin,
|
||||
|
||||
+64
-22
@@ -244,6 +244,21 @@ export interface ResolvedLightSource<D extends LightSourceDevice = LightSourceDe
|
||||
on: boolean;
|
||||
}
|
||||
|
||||
/** One controller's real HA drivers for an incoming `marker:*` link. */
|
||||
export interface IncomingLightController<D extends LightSourceDevice = LightSourceDevice> {
|
||||
device: D;
|
||||
/** Real entity ids only. The linked marker id is never a service target. */
|
||||
driverEids: string[];
|
||||
}
|
||||
|
||||
/** Plan-wide reverse edge for one forced source marker. */
|
||||
export interface IncomingLightControl<D extends LightSourceDevice = LightSourceDevice> {
|
||||
markerId: string;
|
||||
controllers: IncomingLightController<D>[];
|
||||
/** Deterministic union of every controller's driver entities. */
|
||||
driverEids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls are OTHER light targets operated by this marker. A marker already
|
||||
* operates its own bound entity/device through the normal tap action; storing
|
||||
@@ -347,6 +362,7 @@ interface CachedLightGraph<D extends LightSourceDevice> {
|
||||
visible: D[];
|
||||
markerById: Map<string, D>;
|
||||
persistedByDevice: Map<D, string[]>;
|
||||
incomingByMarker: Map<string, IncomingLightControl<D>>;
|
||||
}
|
||||
|
||||
const LIGHT_GRAPH_CACHE = new WeakMap<object, CachedLightGraph<any>>();
|
||||
@@ -402,11 +418,49 @@ function lightGraphOf<D extends LightSourceDevice>(devices: readonly D[]): Cache
|
||||
? device.marker?.id || device.id : null;
|
||||
if (id) markerById.set(String(id), device);
|
||||
}
|
||||
const value = { fingerprint, visible, markerById, persistedByDevice };
|
||||
const incomingByMarker = new Map<string, IncomingLightControl<D>>();
|
||||
for (const controller of devices) {
|
||||
const persisted = persistedByDevice.get(controller) || [];
|
||||
const activeEntityTargets = new Set(
|
||||
(controller.controls === undefined
|
||||
? persisted.filter(isControllable)
|
||||
: controller.controls.filter(isControllable)),
|
||||
);
|
||||
const ownDriver = forcedLightEntityOf(controller);
|
||||
const driverEids = activeEntityTargets.size
|
||||
? [...activeEntityTargets]
|
||||
: ownDriver ? [ownDriver] : [];
|
||||
for (const ref of persisted) {
|
||||
if (!ref.startsWith('marker:')) continue;
|
||||
const markerId = ref.slice('marker:'.length);
|
||||
if (!markerId || markerId === String(controller.marker?.id || controller.id || '')) continue;
|
||||
if (!markerById.has(markerId)) continue;
|
||||
const previous = incomingByMarker.get(markerId) || {
|
||||
markerId,
|
||||
controllers: [],
|
||||
driverEids: [],
|
||||
};
|
||||
previous.controllers.push({ device: controller, driverEids: [...driverEids] });
|
||||
previous.driverEids = [...new Set([...previous.driverEids, ...driverEids])];
|
||||
incomingByMarker.set(markerId, previous);
|
||||
}
|
||||
}
|
||||
const value = { fingerprint, visible, markerById, persistedByDevice, incomingByMarker };
|
||||
LIGHT_GRAPH_CACHE.set(devices as object, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical reverse projection for #84/#174. State resolution and Toggle must
|
||||
* consume this same cached graph so a linked lamp cannot display one relay
|
||||
* while an action operates another.
|
||||
*/
|
||||
export function incomingLightControls<D extends LightSourceDevice>(
|
||||
devices: readonly D[],
|
||||
): ReadonlyMap<string, IncomingLightControl<D>> {
|
||||
return lightGraphOf(devices).incomingByMarker;
|
||||
}
|
||||
|
||||
/** Whether the marker currently has a real, position-bearing source of its own. */
|
||||
export function hasOwnSpatialSource(hass: any, d: LightSourceDevice): boolean {
|
||||
const controls = effectiveMarkerControls(
|
||||
@@ -504,7 +558,7 @@ export function resolvedLightSources<D extends LightSourceDevice>(
|
||||
&& cached.registry === hass?.entities) return cached.sources;
|
||||
|
||||
type Candidate = ResolvedLightSource<D>;
|
||||
const { visible, markerById, persistedByDevice } = lightGraphOf(devices);
|
||||
const { visible, markerById, persistedByDevice, incomingByMarker } = lightGraphOf(devices);
|
||||
// Room consumers need sources owned by that room, not a full resolution of
|
||||
// every marker followed by a final filter. Controllers outside the room are
|
||||
// still scanned below because they may drive a source whose owner is inside.
|
||||
@@ -533,7 +587,6 @@ export function resolvedLightSources<D extends LightSourceDevice>(
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = new Map<string, { linked: boolean; drivers: Set<string> }>();
|
||||
const activeEntityTargetsByDevice = new Map<D, Set<string>>();
|
||||
for (const controller of devices) {
|
||||
const persisted = persistedByDevice.get(controller) || [];
|
||||
@@ -543,31 +596,20 @@ export function resolvedLightSources<D extends LightSourceDevice>(
|
||||
controller.controls === undefined ? persisted.filter(isControllable) : controller.controls,
|
||||
);
|
||||
activeEntityTargetsByDevice.set(controller, activeEntityTargets);
|
||||
const ownDriver = forcedLightEntityOf(controller);
|
||||
for (const ref of persisted) {
|
||||
if (ref.startsWith('marker:')) {
|
||||
const targetId = ref.slice('marker:'.length);
|
||||
if (targetId === String(controller.marker?.id || controller.id || '')) continue;
|
||||
const target = markerById.get(targetId);
|
||||
if (!target || !ownByDevice.has(target)) continue;
|
||||
const state = incoming.get(targetId) || { linked: false, drivers: new Set<string>() };
|
||||
state.linked = true;
|
||||
const drivers = activeEntityTargets.size ? activeEntityTargets : new Set(ownDriver ? [ownDriver] : []);
|
||||
for (const eid of drivers) state.drivers.add(eid);
|
||||
incoming.set(targetId, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const sources of ownByDevice.values()) {
|
||||
for (const source of sources) {
|
||||
if (!source.passive) continue;
|
||||
const markerId = source.key.slice('marker:'.length);
|
||||
const control = incoming.get(markerId);
|
||||
source.on = !control?.linked || [...control.drivers].some((eid) => hass.states?.[eid]?.state === 'on');
|
||||
// Manual persistent state overrides incoming controller links only while
|
||||
// the exact virtual + light + toggle eligibility triple remains active.
|
||||
if (isManualVirtualLightMarker(source.device.marker)) {
|
||||
const control = incomingByMarker.get(markerId);
|
||||
source.on = control
|
||||
? control.driverEids.some((eid) => hass.states?.[eid]?.state === 'on')
|
||||
: true;
|
||||
// #174: real controller state is authoritative whenever a saved,
|
||||
// runtime-valid incoming link exists. The manual #107 state remains a
|
||||
// lossless fallback and becomes visible again after the last link goes.
|
||||
if (!control && isManualVirtualLightMarker(source.device.marker)) {
|
||||
source.on = virtualLightIsOn(source.device.marker, virtualLights);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user