mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
feat: add persistent virtual light toggles
Issue: #107 User-Visible: yes
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// #107: persistent manual virtual light, shared by two full cards and one static card.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1200, height: 900 });
|
||||
const result = await page.evaluate(async () => {
|
||||
const first = window.__card;
|
||||
const markerId = 'smoke_manual_virtual_light';
|
||||
const space = first._model[0];
|
||||
const room = space.rooms.find((candidate) => candidate.id && candidate.area);
|
||||
const serverConfig = structuredClone(first._serverCfg);
|
||||
serverConfig.markers = [...(serverConfig.markers || []), {
|
||||
id: markerId,
|
||||
name: 'Manual virtual lamp',
|
||||
binding: 'virtual',
|
||||
is_light: true,
|
||||
tap_action: 'toggle',
|
||||
tap_confirm: false,
|
||||
controls: ['light.ceiling'],
|
||||
space: space.id,
|
||||
area: room.area,
|
||||
room_id: room.id,
|
||||
}];
|
||||
const serverLayout = {
|
||||
...first._layout,
|
||||
[markerId]: { s: space.id, x: 0.32, y: 0.32 },
|
||||
};
|
||||
const virtual = { rev: 0, config_rev: first._cfgRev, off: [] };
|
||||
const subscriptions = new Map();
|
||||
const serviceCalls = [];
|
||||
let toggleCalls = 0;
|
||||
const emit = (event, data) => {
|
||||
for (const listener of subscriptions.get(event) || []) listener({ data });
|
||||
};
|
||||
const baseHass = first.hass;
|
||||
const baseCallWS = baseHass.callWS.bind(baseHass);
|
||||
const connection = {
|
||||
...baseHass.connection,
|
||||
addEventListener: baseHass.connection?.addEventListener?.bind(baseHass.connection),
|
||||
removeEventListener: baseHass.connection?.removeEventListener?.bind(baseHass.connection),
|
||||
subscribeMessage: baseHass.connection?.subscribeMessage?.bind(baseHass.connection),
|
||||
subscribeEvents: async (callback, event) => {
|
||||
const listeners = subscriptions.get(event) || new Set();
|
||||
listeners.add(callback);
|
||||
subscriptions.set(event, listeners);
|
||||
return () => listeners.delete(callback);
|
||||
},
|
||||
};
|
||||
const hass = {
|
||||
...baseHass,
|
||||
connection,
|
||||
callService: async (domain, service, data) => {
|
||||
serviceCalls.push({ domain, service, data });
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return {
|
||||
config: serverConfig,
|
||||
rev: virtual.config_rev,
|
||||
can_write: true,
|
||||
virtual_lights: structuredClone(virtual),
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get') return {
|
||||
layout: serverLayout,
|
||||
rev: first._layoutRev,
|
||||
};
|
||||
if (message.type === 'houseplan/virtual_light/toggle') {
|
||||
toggleCalls++;
|
||||
const off = new Set(virtual.off);
|
||||
if (off.has(message.marker_id)) off.delete(message.marker_id);
|
||||
else off.add(message.marker_id);
|
||||
virtual.off = [...off].sort();
|
||||
virtual.rev++;
|
||||
const reply = {
|
||||
marker_id: message.marker_id,
|
||||
on: !off.has(message.marker_id),
|
||||
rev: virtual.rev,
|
||||
};
|
||||
queueMicrotask(() => emit('houseplan_virtual_light_updated', reply));
|
||||
return reply;
|
||||
}
|
||||
return baseCallWS(message);
|
||||
},
|
||||
};
|
||||
|
||||
// Reconnect the already mounted card to the fake server authority.
|
||||
first._unsubVirtual?.();
|
||||
first._unsubVirtual = null;
|
||||
first.hass = hass;
|
||||
await first._reloadConfigOnly(true);
|
||||
first._layout = serverLayout;
|
||||
first._regSignature = '';
|
||||
first._maybeRebuildDevices();
|
||||
first._ensureLiveSyncSubscriptions();
|
||||
await first.updateComplete;
|
||||
|
||||
const second = document.createElement('houseplan-card');
|
||||
second.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
|
||||
document.body.append(second);
|
||||
second.hass = hass;
|
||||
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: space.id });
|
||||
document.body.append(staticCard);
|
||||
staticCard.hass = hass;
|
||||
|
||||
const waitFor = async (predicate, timeout = 6000) => {
|
||||
const started = Date.now();
|
||||
while (!predicate() && Date.now() - started < timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
return !!predicate();
|
||||
};
|
||||
await waitFor(() => second._devices?.some((device) => device.id === markerId));
|
||||
await waitFor(() => staticCard.renderRoot?.querySelector(`[data-id="${markerId}"]`));
|
||||
await second.updateComplete;
|
||||
await staticCard.updateComplete;
|
||||
|
||||
const deviceOf = (card) => card._devices.find((device) => device.id === markerId);
|
||||
const on = (card) => card._stateClass(deviceOf(card)).includes('on');
|
||||
const staticOn = () => staticCard.renderRoot
|
||||
.querySelector(`[data-id="${markerId}"]`)?.classList.contains('on');
|
||||
const initialEverywhereOn = on(first) && on(second) && staticOn();
|
||||
|
||||
const markerNode = first.renderRoot.querySelector(`.dev[data-id="${markerId}"]`);
|
||||
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await waitFor(() => virtual.rev === 1 && !on(second) && staticOn() === false);
|
||||
await first.updateComplete;
|
||||
const clickEverywhereOff = !on(first) && !on(second) && staticOn() === false;
|
||||
|
||||
// A second real click returns to on; the saved outgoing control never calls HA.
|
||||
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await waitFor(() => virtual.rev === 2 && on(second) && staticOn() === true);
|
||||
const secondClickEverywhereOn = on(first) && on(second) && staticOn();
|
||||
|
||||
const beforeTouch = toggleCalls;
|
||||
markerNode.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
|
||||
clientX: 100, clientY: 100,
|
||||
}));
|
||||
markerNode.dispatchEvent(new PointerEvent('pointerup', {
|
||||
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
|
||||
clientX: 100, clientY: 100,
|
||||
}));
|
||||
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await waitFor(() => virtual.rev === 3 && !on(second) && staticOn() === false);
|
||||
const touchSingleToggle = toggleCalls === beforeTouch + 1;
|
||||
|
||||
// A fresh card must paint the server's off snapshot, not wait for an event.
|
||||
const reloaded = document.createElement('houseplan-card');
|
||||
reloaded.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
|
||||
document.body.append(reloaded);
|
||||
reloaded.hass = hass;
|
||||
await waitFor(() => reloaded._devices?.some((device) => device.id === markerId));
|
||||
await reloaded.updateComplete;
|
||||
const reloadFirstStateOff = !on(reloaded);
|
||||
|
||||
second.remove();
|
||||
staticCard.remove();
|
||||
reloaded.remove();
|
||||
return {
|
||||
initialEverywhereOn,
|
||||
clickEverywhereOff,
|
||||
secondClickEverywhereOn,
|
||||
touchSingleToggle,
|
||||
reloadFirstStateOff,
|
||||
oneServerTogglePerGesture: toggleCalls === 3,
|
||||
noHaServiceCalls: serviceCalls.length === 0,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
+121
-121
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user