mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
251 lines
13 KiB
JavaScript
251 lines
13 KiB
JavaScript
// «Открыть/закрыть» on a curtain whose cover entity used to lose to an option
|
|
// switch in registry/domain order.
|
|
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
|
|
// по-прежнему инфо-карточка»).
|
|
//
|
|
// The device below is his, entity for entity: an Aqara «Roller shade driver
|
|
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
|
|
// `switch.*_reverse_direction` beside it. The shared role resolver must pick
|
|
// the real cover for status, while the explicit tap action decides whether a
|
|
// click controls it. A mixed lamp+cover still stays a lamp unless the owner
|
|
// explicitly selects the cover action.
|
|
//
|
|
// End to end: the action is chosen in the dialog and SAVED through the normal
|
|
// path, and the click is done on the marker the card rebuilt from that config.
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
const { page, browser } = await launch();
|
|
const out = await page.evaluate(async () => {
|
|
const o = {};
|
|
const c = window.__card;
|
|
const sr = () => c.shadowRoot || c.renderRoot;
|
|
const calls = [];
|
|
|
|
// ---- the owner's curtain driver, joined to the demo house --------------
|
|
const ENTS = {
|
|
// hidden by the integration — exactly as z2m ships it
|
|
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
|
|
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
|
|
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
|
|
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
|
|
// a MIXED device: a lamp that also happens to ship a cover. Its primary is
|
|
// meaningfully the light, and nothing below may take that away from it.
|
|
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
|
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
|
};
|
|
const coverState = (state, attrs = {}) => ({
|
|
entity_id: 'cover.office_curtain', state,
|
|
attributes: { friendly_name: 'Office curtain', supported_features: 11, ...attrs },
|
|
});
|
|
const push = async (state, attrs = {}) => {
|
|
c.hass = {
|
|
...c.hass,
|
|
devices: { ...c.hass.devices,
|
|
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
|
|
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null },
|
|
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
|
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null } },
|
|
entities: { ...c.hass.entities, ...ENTS },
|
|
states: { ...c.hass.states,
|
|
'cover.office_curtain': coverState(state, attrs),
|
|
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
|
|
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
|
|
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
|
|
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
|
'cover.mixed': { entity_id: 'cover.mixed', state: 'opening', attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
|
},
|
|
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
|
};
|
|
c._regSignature = '';
|
|
c._maybeRebuildDevices();
|
|
// park both extra markers in their own corners: the DOM assertions below
|
|
// find a marker by its position, and the auto grid would sit them on top
|
|
// of the demo house's own bedroom devices
|
|
const cu = c._devices.find((x) => x.bindingRef === 'd_curtain');
|
|
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
|
if (cu && mx) {
|
|
c._layout = { ...c._layout,
|
|
[cu.id]: { s: cu.space, x: 0.06, y: 0.94 },
|
|
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 } };
|
|
}
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
};
|
|
|
|
await push('closed', { device_class: 'curtain' });
|
|
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
|
|
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
|
|
o.deviceIsOnThePlan = !!curtain();
|
|
// Registry order and visibility no longer let an option switch outrank the
|
|
// actual functional entity.
|
|
o.primaryIsTheCover = curtain()?.primary === 'cover.office_curtain';
|
|
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
|
|
|
|
// ---- the dialog offers the unified action and SAVES an intentional edit
|
|
c._setMode('devices'); await c.updateComplete;
|
|
c._openMarkerDialog(curtain()); await c.updateComplete;
|
|
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
|
|
o.dialogOffersUnifiedToggle = optionValues().includes('toggle')
|
|
&& !optionValues().includes('cover');
|
|
c._markerDialog = {
|
|
...c._markerDialog,
|
|
tapAction: 'toggle', tapActionTouched: true, display: 'icon_ripple',
|
|
};
|
|
await c.updateComplete;
|
|
await c._saveMarker();
|
|
await c.updateComplete;
|
|
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
|
o.markerSavedAsUnifiedToggle = saved?.tap_action === 'toggle';
|
|
|
|
// ---- and the tap on THAT marker opens the curtain -----------------------
|
|
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
|
c.requestUpdate(); await c.updateComplete;
|
|
const tap = async () => {
|
|
c._clickDevice({ stopPropagation() {} }, curtain());
|
|
await c.updateComplete;
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
};
|
|
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'toggle';
|
|
o.coverServicesDeclared = ['open_cover', 'close_cover', 'stop_cover']
|
|
.every((service) => Object.hasOwn(c.hass.services?.cover || {}, service));
|
|
calls.length = 0;
|
|
const closedIntent = c._toggleIntent(curtain());
|
|
o.closedIntentResolvesExactTarget = closedIntent?.command?.service === 'open_cover'
|
|
&& closedIntent.command.data.entity_id === 'cover.office_curtain';
|
|
await tap();
|
|
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
|
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
|
o.noInfoCard = !c._infoCard;
|
|
c._infoCard = null;
|
|
|
|
await push('open', { device_class: 'curtain' });
|
|
calls.length = 0;
|
|
await tap();
|
|
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
|
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
|
|
|
|
await push('opening', { device_class: 'curtain' });
|
|
calls.length = 0;
|
|
await tap();
|
|
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
|
|
|
|
// the service switch, which used to be the whole story, is never touched
|
|
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
|
|
|
|
// ---- guarded classes are an explicit secure no-op -----------------------
|
|
await push('closed', { device_class: 'garage' });
|
|
calls.length = 0;
|
|
await tap();
|
|
o.garageCallsNothing = calls.length === 0;
|
|
o.garageDoesNotFallbackToInfo = !c._infoCard;
|
|
c._infoCard = null; await c.updateComplete;
|
|
c._setMode('devices'); await c.updateComplete;
|
|
c._openMarkerDialog(curtain()); await c.updateComplete;
|
|
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
|
c._markerDialog = null;
|
|
c._setMode('view'); await c.updateComplete;
|
|
|
|
// ---- and the marker SHOWS that cover, not the service switch -----------
|
|
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
|
|
// "открыто", ни морфинга иконки». (The «открыто» frame itself was retired
|
|
// for covers later the same day — the marker still had to start speaking for
|
|
// the cover, which is what this section pins.) Same cause, same helper:
|
|
// _stateClass and
|
|
// the icon morph read d.primary, so the plan reported the state of
|
|
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
|
|
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
|
|
// exactly when the owner has explicitly chosen «Открыть/закрыть».
|
|
const devEl = (dev) => {
|
|
// The viewport may refit asynchronously after an editor transition. Use
|
|
// the stable public styling hook instead of racing rendered percentages
|
|
// against the next viewBox.
|
|
return dev ? sr().querySelector(`.devlayer .dev[data-id="${CSS.escape(dev.id)}"]`) : null;
|
|
};
|
|
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
|
|
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
|
const setSwitch = async (state) => {
|
|
c.hass = { ...c.hass, states: { ...c.hass.states,
|
|
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state, attributes: {} } } };
|
|
c.requestUpdate(); await c.updateComplete;
|
|
};
|
|
|
|
await push('closed', { device_class: 'curtain' });
|
|
o.markerIsOnScreen = !!devEl(curtain());
|
|
o.closedIsNeitherOpenNorMoving =
|
|
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('activity-transition');
|
|
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
|
|
|
|
await push('open', { device_class: 'curtain' });
|
|
// the plate is neutral in every cover state since 2026-08-04 — the morph is
|
|
// the whole open/closed story (smoke_cover_no_plate.mjs)
|
|
o.openWearsNoColouredPlate =
|
|
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('on');
|
|
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
|
|
|
|
await push('opening', { device_class: 'curtain' });
|
|
o.travellingBreathes = clsOf(curtain()).includes('activity-transition');
|
|
await push('closing', { device_class: 'curtain' });
|
|
o.closingBreathesToo = clsOf(curtain()).includes('activity-transition');
|
|
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('activity-transition');
|
|
|
|
// the service switch never speaks for the marker again: reverse-direction
|
|
// ON used to paint the curtain yellow («включено») while it stood still
|
|
await push('closed', { device_class: 'curtain' });
|
|
await setSwitch('on');
|
|
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
|
|
|
|
// Status and action are independent: taking the explicit action away keeps
|
|
// the resolved cover status, but a click returns to the ordinary info path.
|
|
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
|
savedMarker.tap_action = 'info';
|
|
await push('opening', { device_class: 'curtain' });
|
|
await setSwitch('on');
|
|
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
|
|
&& !clsOf(curtain()).includes('on');
|
|
calls.length = 0;
|
|
c._infoCard = null;
|
|
// A local card must not depend on a second registry read at click time. The
|
|
// already-rendered curtain remains a valid informational snapshot while HA's
|
|
// registry is transiently unavailable; service-backed actions stay gated.
|
|
const liveBindingStatus = c._bindingStatus;
|
|
c._bindingStatus = () => ({
|
|
kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [],
|
|
});
|
|
try { await tap(); } finally { c._bindingStatus = liveBindingStatus; }
|
|
o.infoActionOpensTheHousePlanCard = c._infoCard?.id === curtain()?.id;
|
|
o.infoActionCallsNoHaService = calls.length === 0;
|
|
c._infoCard = null;
|
|
savedMarker.tap_action = 'cover';
|
|
await push('closed', { device_class: 'curtain' });
|
|
await setSwitch('off');
|
|
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
|
|
|
|
// ---- the auditor's own probe: BOTH entities visible ---------------------
|
|
// The same rule must hold when BOTH entities are visible.
|
|
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
|
|
await push('closed', { device_class: 'curtain' });
|
|
await setSwitch('off');
|
|
o.visibleCoverIsThePrimary = curtain()?.primary === 'cover.office_curtain';
|
|
calls.length = 0;
|
|
await tap();
|
|
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
|
|
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
|
o.visibleCoverAlsoIndicates = iconOf(curtain()) === 'mdi:curtains-closed';
|
|
o.noInfoCardForTheVisibleCover = !c._infoCard;
|
|
|
|
// ---- a MIXED device is not hijacked ------------------------------------
|
|
// A lit lamp that also owns a cover keeps the lamp: no explicit «Open/close»
|
|
// on this marker, so nothing changes for it — and even if there were one,
|
|
// a shining light still wins the yellow plate (the glow and the badge may
|
|
// never disagree, owner's principle 2026-07-29).
|
|
o.mixedPrimaryIsTheLight = mixed()?.primary === 'light.mixed';
|
|
o.mixedCoverIsTravelling = c.hass.states['cover.mixed'].state === 'opening';
|
|
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('activity-transition');
|
|
// its icon is the device's own auto icon, NOT the cover's open/closed pair
|
|
o.mixedIconIsNotMorphedByTheCover =
|
|
iconOf(mixed()) === mixed()?.icon && !iconOf(mixed()).includes('curtains');
|
|
|
|
return o;
|
|
});
|
|
checkAll(out);
|
|
await finish(browser, out);
|