Fix linked virtual light controller authority

Issue: #174
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-18 20:17:04 +03:00
parent da10b3df3a
commit 2f9cced7ee
16 changed files with 1152 additions and 495 deletions
+121 -73
View File
@@ -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
View File
@@ -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);
}
}