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
+41
View File
@@ -180,6 +180,47 @@ test('issue 107 manual virtual source owns its face despite saved outgoing contr
assert.ok(on.classes.includes('on'));
});
test('issue 174 linked manual source and controller presentation follow the real relay', () => {
const h = hass({ 'switch.wall': state('switch.wall', 'on') });
const controller = device({
id: 'controller', name: 'Wall relay', entities: ['switch.wall'], primary: 'switch.wall',
bindingRef: 'switch.wall',
marker: {
id: 'controller', binding: 'entity:switch.wall', controls: ['marker:lamp'],
},
});
const lamp = device({
id: 'lamp', name: 'Dumb lamp', entities: [], primary: null,
bindingKind: 'virtual', bindingRef: 'lamp',
marker: {
id: 'lamp', binding: 'virtual', is_light: true, tap_action: 'toggle', controls: [],
},
});
const devices = [controller, lamp];
const manualOff = { rev: 7, configRev: 2, off: new Set(['lamp']) };
let graph = resolvedLightSources(h, devices, null, manualOff);
let target = resolveDevicePresentation(h, lamp, {
...options, lightDevices: devices, lightSources: graph,
});
let control = resolveDevicePresentation(h, controller, {
...options, lightDevices: devices, lightSources: graph,
});
assert.equal(target.visual.status, 'working');
assert.equal(control.visual.status, 'working');
h.states['switch.wall'].state = 'off';
graph = resolvedLightSources(h, devices, null, manualOff);
target = resolveDevicePresentation(h, lamp, {
...options, lightDevices: devices, lightSources: graph,
});
control = resolveDevicePresentation(h, controller, {
...options, lightDevices: devices, lightSources: graph,
});
assert.equal(target.visual.status, 'neutral');
assert.equal(control.visual.status, 'neutral');
});
test('passive sensor source keeps its normal scalar value and never probes marker ids', () => {
const hits = [];
const h = hass(new Proxy({
+115
View File
@@ -105,6 +105,121 @@ test('issue 107: exact manual virtual light wins over saved HA controls', () =>
assert.equal(toggleOperation(resumed).kind, 'ha-service', 'saved controls resume outside the triple');
});
test('issue 174: linked manual lamp redirects its toggle to the real controller driver', () => {
const lamp = device({
id: 'lamp', name: 'Dumb lamp',
marker: { id: 'lamp', binding: 'virtual', is_light: true, tap_action: 'toggle', controls: [] },
});
const controller = device({
id: 'wall', name: 'Wall relay', bindingKind: 'entity', bindingRef: 'switch.wall',
primary: 'switch.wall', entities: ['switch.wall'],
marker: {
id: 'wall', binding: 'entity:switch.wall', tap_action: 'toggle', controls: ['marker:lamp'],
},
controls: [],
});
const devices = [controller, lamp];
const h = hass({ 'switch.wall': state('switch.wall', 'on') });
const manualOff = { rev: 5, configRev: 9, off: new Set(['lamp']) };
const on = resolveToggleIntent({ hass: h, devices, device: lamp, virtualLights: manualOff });
assert.equal(toggleOperation(on).kind, 'ha-service');
assert.deepEqual(toggleCommandEntityIds(on.command), ['switch.wall']);
assert.equal(on.command.service, 'turn_off');
assert.equal(on.targets[0].via, 'control-marker-driver');
h.states['switch.wall'].state = 'off';
const off = resolveToggleIntent({ hass: h, devices, device: lamp, virtualLights: manualOff });
assert.equal(off.command.service, 'turn_on');
assert.equal(sameToggleOperationTargets(on, off), true, 'direction changes but the relay does not');
const unlinked = resolveToggleIntent({ hass: h, devices: [lamp], device: lamp, virtualLights: manualOff });
assert.deepEqual(toggleOperation(unlinked), { kind: 'virtual-light', markerId: 'lamp' });
assert.equal(unlinked.targets[0].state, 'off', 'unlink restores the stored manual state');
assert.equal(sameToggleOperationTargets(on, unlinked), false, 'confirmation cannot cross modes');
});
test('issue 174: source unions all drivers while each controller toggles only its own group', () => {
const lamp = device({
id: 'lamp', name: 'Dumb lamp',
marker: { id: 'lamp', binding: 'virtual', is_light: true, tap_action: 'toggle', controls: [] },
});
const relayA = device({
id: 'relay-a', primary: 'switch.a', entities: ['switch.a'],
bindingKind: 'entity', bindingRef: 'switch.a', controls: [],
marker: {
id: 'relay-a', binding: 'entity:switch.a', tap_action: 'toggle', controls: ['marker:lamp'],
},
});
const relayB = device({
id: 'relay-b', bindingKind: 'virtual', bindingRef: 'relay-b',
controls: ['switch.b'],
marker: {
id: 'relay-b', binding: 'virtual', tap_action: 'toggle',
controls: ['switch.b', 'marker:lamp'],
},
});
const devices = [relayA, relayB, lamp];
const h = hass({
'switch.a': state('switch.a', 'on'),
'switch.b': state('switch.b', 'off'),
});
const source = resolveToggleIntent({ hass: h, devices, device: lamp });
assert.deepEqual(toggleCommandEntityIds(source.command), ['switch.a', 'switch.b']);
assert.equal(source.command.service, 'turn_off', 'any-on applies to the union');
const first = resolveToggleIntent({ hass: h, devices, device: relayA });
assert.deepEqual(toggleCommandEntityIds(first.command), ['switch.a']);
const second = resolveToggleIntent({ hass: h, devices, device: relayB });
assert.deepEqual(toggleCommandEntityIds(second.command), ['switch.b']);
});
test('issue 174: linked source keeps partial availability and never falls back to manual state', () => {
const lamp = device({
id: 'lamp', name: 'Dumb lamp',
marker: { id: 'lamp', binding: 'virtual', is_light: true, tap_action: 'toggle', controls: [] },
});
const partial = device({
id: 'partial', bindingKind: 'virtual', bindingRef: 'partial',
controls: ['switch.missing', 'switch.unavailable', 'switch.disabled', 'switch.ok'],
marker: {
id: 'partial', binding: 'virtual',
controls: [
'switch.missing', 'switch.unavailable', 'switch.disabled', 'switch.ok', 'marker:lamp',
],
},
});
const h = hass({
'switch.unavailable': state('switch.unavailable', 'unavailable'),
'switch.disabled': state('switch.disabled', 'off'),
'switch.ok': state('switch.ok', 'off'),
}, {
'switch.disabled': { entity_id: 'switch.disabled', platform: 'test', disabled_by: 'user' },
});
const manualOff = { rev: 3, configRev: 4, off: new Set(['lamp']) };
const intent = resolveToggleIntent({ hass: h, devices: [partial, lamp], device: lamp, virtualLights: manualOff });
assert.deepEqual(toggleCommandEntityIds(intent.command), ['switch.ok']);
assert.equal(intent.command.service, 'turn_on');
assert.deepEqual(intent.skippedTargets.map((target) => [target.entityId, target.reason]), [
['switch.missing', 'missing'],
['switch.unavailable', 'unavailable'],
['switch.disabled', 'ha-disabled'],
]);
const dormant = device({
id: 'dormant', bindingKind: 'virtual', bindingRef: 'dormant', controls: [],
marker: { id: 'dormant', binding: 'virtual', controls: ['marker:lamp'] },
});
const none = resolveToggleIntent({
hass: h, devices: [dormant, lamp], device: lamp, virtualLights: manualOff,
});
assert.equal(none.kind, 'group');
assert.equal(none.command, null);
assert.equal(none.noneReason, 'configured-targets-missing');
assert.equal(toggleOperation(none), null, 'linked zero-driver state does not use virtual-light');
});
test('exact entity binding never retargets to a controllable sibling', () => {
const h = hass({
'sensor.room': state('sensor.room', '21'),
+47 -3
View File
@@ -9,6 +9,7 @@ import {
hasOwnStatefulLightSource, ownControllableEntities, forcedLightEntityOf,
removeMarkerControlReferences,
rewriteMarkerControlReferences, markerControlWouldCycle, resolveDeviceLightSettings,
incomingLightControls,
} from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
@@ -1306,7 +1307,7 @@ test('issue 84: Always without an own entity is a constant passive source', () =
assert.deepEqual(resolvedLightStats([source]), { on: 1, total: 1 });
});
test('issue 107: manual virtual state is canonical and invalidates the light cache', () => {
test('issue 174: linked manual virtual source follows controller state, not its stored fallback', () => {
const target = {
id: 'dumb', area: 'bedroom', entities: [],
marker: {
@@ -1327,13 +1328,56 @@ test('issue 107: manual virtual state is canonical and invalidates the light cac
const first = resolvedLightSources(hass, devices, { id: 'bed', area: null }, onSnapshot);
assert.equal(first[0].on, true);
const second = resolvedLightSources(hass, devices, { id: 'bed', area: null }, offSnapshot);
assert.equal(second[0].on, false, 'manual off overrides an incoming on controller');
assert.equal(second[0].on, true, 'incoming controller overrides the dormant manual off fallback');
assert.notEqual(second, first, 'operational revision participates in resolver caching');
hass.states['switch.wall'].state = 'off';
const driverOff = resolvedLightSources(hass, devices, { id: 'bed', area: null }, offSnapshot);
assert.equal(driverOff[0].on, false, 'external HA state is authoritative without a card action');
const manualOnDriverOff = resolvedLightSources(
hass, devices, { id: 'bed', area: null }, onSnapshot,
);
assert.equal(manualOnDriverOff[0].on, false, 'manual on cannot revive a linked driver-off lamp');
const unlinked = resolvedLightSources(hass, [target], { id: 'bed', area: null }, offSnapshot);
assert.equal(unlinked[0].on, false, 'removing the final link restores the exact manual fallback');
const legacy = resolvedLightSources(hass, [{
...target, marker: { ...target.marker, tap_action: 'info' },
}, controller], { id: 'bed', area: null }, offSnapshot);
assert.equal(legacy[0].on, true, 'leaving the exact triple restores issue #84 semantics');
assert.equal(legacy[0].on, false, 'leaving the exact triple keeps issue #84 controller semantics');
});
test('issue 174: one cached reverse graph owns linked state and all controller drivers', () => {
const target = {
id: 'dumb', area: 'bedroom', entities: [],
marker: {
id: 'dumb', binding: 'virtual', is_light: true, tap_action: 'toggle', room_id: 'bed',
},
};
const controllerA = {
id: 'a', area: 'hall', primary: 'switch.a', entities: ['switch.a'],
marker: { id: 'a', binding: 'entity:switch.a', controls: ['marker:dumb'] },
};
const controllerB = {
id: 'b', area: 'yard', entities: [], controls: ['light.external'],
marker: { id: 'b', binding: 'virtual', controls: ['light.external', 'marker:dumb'] },
};
const devices = [controllerA, controllerB, target];
const relation = incomingLightControls(devices).get('dumb');
assert.deepEqual(relation.driverEids, ['switch.a', 'light.external']);
assert.deepEqual(
relation.controllers.map((entry) => [entry.device.id, entry.driverEids]),
[['a', ['switch.a']], ['b', ['light.external']]],
);
const hass = { states: {
'switch.a': { state: 'off' }, 'light.external': { state: 'on' },
} };
const snapshot = { rev: 9, configRev: 3, off: new Set(['dumb']) };
assert.equal(resolvedLightSources(hass, devices, null, snapshot)[0].on, true);
hass.states['light.external'].state = 'off';
assert.equal(resolvedLightSources(hass, devices, null, snapshot)[0].on, false);
});
test('issue 84: passive target state is OR of controller drivers and remains target-owned', () => {