mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
v1.59.0-beta.10: unify device visuals and wall refinements
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
// Capture: a curtain on the move — the breathing ring (.covermove) around a
|
||||
// Capture: a curtain on the move — the semantic transition ring around a
|
||||
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
|
||||
// the shot is deterministic.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
|
||||
@@ -14,13 +17,13 @@ await page.evaluate(async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.covermove').getBoundingClientRect();
|
||||
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-transition').getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Capture: one curtain in the four states an owner sees — closed, open,
|
||||
// opening, closing (owner's contract 2026-08-04). The plate is the plain
|
||||
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
|
||||
// and the two travelling ones add the breathing .covermove ring (frozen at a
|
||||
// and the two travelling ones add the breathing transition ring (frozen at a
|
||||
// visible frame so the shot is deterministic).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
|
||||
@@ -22,7 +22,8 @@ await page.evaluate(async (STATES) => {
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states } };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i, tap_action: 'cover' })),
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i,
|
||||
tap_action: 'cover', display: 'icon_ripple' })),
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
|
||||
] };
|
||||
@@ -37,7 +38,7 @@ await page.evaluate(async (STATES) => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
}, STATES);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.binding !== 'device:d_motion');
|
||||
c._serverCfg.markers.push({ id: 'd_motion', binding: 'device:d_motion', display: 'icon_ripple' });
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
// the flash is keyed to a WITNESSED off→on transition (owner's rule
|
||||
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
|
||||
const set = async (state) => {
|
||||
@@ -14,13 +17,13 @@ await page.evaluate(async () => {
|
||||
await set('on');
|
||||
// freeze the pulse at a visible frame for a deterministic capture
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.senseflash::after{animation-delay:-0.4s;animation-play-state:paused;}';
|
||||
st.textContent = '.activity-ring.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.senseflash');
|
||||
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-event');
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@ const res = await page.evaluate(async () => {
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights, icon: 'mdi:light-switch' };
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights,
|
||||
icon: 'mdi:light-switch', display: 'icon_ripple' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.name === 'Выключатель');
|
||||
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
|
||||
@@ -36,6 +37,7 @@ const res = await page.evaluate(async () => {
|
||||
// 3) значок отражает цели: горит одна → класс on
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
|
||||
out.stateOn = !!c._stateClass(dev2()).includes('on');
|
||||
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
out.stateOff = c._stateClass(dev2()) === '';
|
||||
// 4) без tap_action=toggle клик НЕ переключает (инфо)
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
// underneath the breathing ring. Now a cover returns no plate class at all:
|
||||
//
|
||||
// closed / open / ajar -> neutral plate, the ICON tells the state
|
||||
// opening / closing -> neutral plate + the .covermove ring (kept, the
|
||||
// owner approved it 2026-08-03)
|
||||
// opening / closing -> neutral plate + activity-transition when the
|
||||
// marker opts into «Icon + activity»
|
||||
//
|
||||
// The frame itself is NOT gone: door/window binary sensors and locks still
|
||||
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
|
||||
@@ -43,7 +43,7 @@ const out = await page.evaluate(async () => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover' },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
],
|
||||
};
|
||||
@@ -84,12 +84,14 @@ const out = await page.evaluate(async () => {
|
||||
};
|
||||
|
||||
plateIsNeutral('closed');
|
||||
o.closedNoRing = !cls().includes('covermove');
|
||||
o.closedNoRing = !cls().includes('activity-transition');
|
||||
o.closedIconClosed = icon() === 'mdi:curtains-closed';
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
plateIsNeutral('open');
|
||||
o.openNoRing = !cls().includes('covermove');
|
||||
// This fixture jumps closed → open without an intermediate HA state, so
|
||||
// the unified resolver supplies the agreed ~3.3 s transition fallback.
|
||||
o.openDirectTransition = cls().includes('activity-transition');
|
||||
o.openIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
|
||||
@@ -99,15 +101,16 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
plateIsNeutral('opening');
|
||||
o.openingRings = cls().includes('covermove');
|
||||
o.openingRings = cls().includes('activity-transition');
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const cs = getComputedStyle(devEl(), '::after');
|
||||
return cs.animationName === 'hp-covermove' && cs.animationDuration === '2.2s';
|
||||
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
|
||||
const cs = ring ? getComputedStyle(ring) : null;
|
||||
return !!cs && cs.animationName === 'hp-activity-breathe' && cs.animationDuration === '2.2s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
plateIsNeutral('closing');
|
||||
o.closingRings = cls().includes('covermove');
|
||||
o.closingRings = cls().includes('activity-transition');
|
||||
|
||||
// --- the morph covers every class, both ways ---------------------------
|
||||
const morph = async (dc, closedIcon, openIcon) => {
|
||||
@@ -135,7 +138,7 @@ const out = await page.evaluate(async () => {
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
plateIsNeutral('unknown');
|
||||
o.unknownNoMorph = icon() === 'mdi:garage-variant';
|
||||
o.unknownNoRing = !cls().includes('covermove');
|
||||
o.unknownNoRing = !cls().includes('activity-transition');
|
||||
|
||||
// --- NOT touched: the «открыто» frame elsewhere ------------------------
|
||||
// the demo house has a lock and a window sensor on the ground floor
|
||||
@@ -186,10 +189,10 @@ const out = await page.evaluate(async () => {
|
||||
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
|
||||
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
|
||||
// and the cover's own class string, straight from the source
|
||||
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: null };
|
||||
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: { display: 'icon_ripple' } };
|
||||
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
|
||||
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'activity-transition';
|
||||
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'activity-transition';
|
||||
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
|
||||
return o;
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ const out = await page.evaluate(async () => {
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
|
||||
o.dialogOffersCover = optionValues().includes('cover');
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' };
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover', display: 'icon_ripple' };
|
||||
await c.updateComplete;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
@@ -165,7 +165,7 @@ const out = await page.evaluate(async () => {
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
o.markerIsOnScreen = !!devEl(curtain());
|
||||
o.closedIsNeitherOpenNorMoving =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('covermove');
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('activity-transition');
|
||||
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
@@ -176,10 +176,10 @@ const out = await page.evaluate(async () => {
|
||||
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
o.travellingBreathes = clsOf(curtain()).includes('covermove');
|
||||
o.travellingBreathes = clsOf(curtain()).includes('activity-transition');
|
||||
await push('closing', { device_class: 'curtain' });
|
||||
o.closingBreathesToo = clsOf(curtain()).includes('covermove');
|
||||
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('covermove');
|
||||
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
|
||||
@@ -194,7 +194,7 @@ const out = await page.evaluate(async () => {
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.withoutTheActionThePrimarySpeaks = clsOf(curtain()).includes('on')
|
||||
&& !clsOf(curtain()).includes('covermove');
|
||||
&& !clsOf(curtain()).includes('activity-transition');
|
||||
savedMarker.tap_action = 'cover';
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
@@ -223,7 +223,7 @@ const out = await page.evaluate(async () => {
|
||||
// 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('covermove');
|
||||
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');
|
||||
|
||||
@@ -54,14 +54,14 @@ const out = await page.evaluate(async () => {
|
||||
/** The auditor's marker #1: primary is a LIT light, the cover is explicit. */
|
||||
const litLamp = {
|
||||
id: 'p_lamp', primary: 'light.mixed', entities: ['light.mixed', 'cover.mixed'],
|
||||
tapAction: 'cover', marker: null,
|
||||
tapAction: 'cover', marker: { display: 'icon_ripple' },
|
||||
};
|
||||
/** The auditor's marker #2: a cover marker whose bound control is on. */
|
||||
const controlled = {
|
||||
id: 'p_ctrl', primary: 'cover.mixed', entities: ['cover.mixed'],
|
||||
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'] },
|
||||
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'], display: 'icon_ripple' },
|
||||
};
|
||||
const CV = { closed: '', open: '', opening: 'covermove', closing: 'covermove' };
|
||||
const CV = { closed: '', open: '', opening: 'activity-transition', closing: 'activity-transition' };
|
||||
for (const [state, want] of Object.entries(CV)) {
|
||||
const states = {
|
||||
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
|
||||
@@ -78,14 +78,15 @@ const out = await page.evaluate(async () => {
|
||||
o.controlledUnavailFades = withStates({
|
||||
'switch.control': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(controlled)) === 'unavail';
|
||||
// …and a cover entity with no state at all is simply neutral, not yellow
|
||||
o.litLampNoCoverStateIsNeutral = withStates({
|
||||
// …and a selected cover with no state is unavailable, never borrowed from
|
||||
// the lit neighbour that the explicit cover action deliberately outranks.
|
||||
o.litLampNoCoverStateIsUnavailable = withStates({
|
||||
'light.mixed': st('on', {}),
|
||||
}, () => c._stateClass(litLamp)) === '';
|
||||
}, () => c._stateClass(litLamp)) === 'unavail';
|
||||
|
||||
// ---- the OTHER side of the rule: nothing changed for the rest ----------
|
||||
// the very same mixed device WITHOUT the explicit action keeps its primary
|
||||
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null };
|
||||
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null, marker: null };
|
||||
o.mixedWithoutTheOptionStaysYellow = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === 'on';
|
||||
@@ -104,7 +105,7 @@ const out = await page.evaluate(async () => {
|
||||
// a curtain marker with controls that are OFF is still just a curtain
|
||||
o.controlledOffStillRings = withStates({
|
||||
'switch.control': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(controlled)) === 'covermove';
|
||||
}, () => c._stateClass(controlled)) === 'activity-transition';
|
||||
// an explicit «cover» marker with NO cover among its entities falls back to
|
||||
// the old order — the statement is only as strong as the entity behind it
|
||||
const noCover = {
|
||||
@@ -143,8 +144,8 @@ const out = await page.evaluate(async () => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover' },
|
||||
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'] },
|
||||
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'], display: 'icon_ripple' },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
@@ -187,7 +188,7 @@ const out = await page.evaluate(async () => {
|
||||
o[tag + 'NotYellow'] = bg !== YELLOW;
|
||||
o[tag + 'NotOrange'] = bg !== ORANGE;
|
||||
o[tag + 'NoOnClass'] = !el.classList.contains('on') && !el.classList.contains('open');
|
||||
o[tag + 'Rings'] = el.classList.contains('covermove');
|
||||
o[tag + 'Rings'] = el.classList.contains('activity-transition');
|
||||
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
|
||||
};
|
||||
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
|
||||
@@ -200,7 +201,7 @@ const out = await page.evaluate(async () => {
|
||||
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
|
||||
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
|
||||
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
|
||||
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('covermove');
|
||||
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('activity-transition');
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
|
||||
+12
-11
@@ -10,8 +10,8 @@
|
||||
// The option is offered ONLY for a binding that HAS a cover entity, and never
|
||||
// for the guarded classes (garage/door/gate) — for those a value smuggled into
|
||||
// the config degrades to 'info', exactly like a card-wide toggle does.
|
||||
// While travelling the icon breathes a soft ring (.covermove, the vacuum
|
||||
// puck's 2.2s period) and the plate stays NEUTRAL — yellow means «включено».
|
||||
// With «Icon + activity», travelling breathes a soft activity ring (the
|
||||
// vacuum puck's 2.2s period) and the plate stays NEUTRAL — yellow means work.
|
||||
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
|
||||
// frame included: open/closed is told by the icon morph alone (the full
|
||||
// contract lives in smoke_cover_no_plate.mjs).
|
||||
@@ -45,7 +45,8 @@ const out = await page.evaluate(async () => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction, tap_confirm: confirm || null },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction,
|
||||
tap_confirm: confirm || null, display: 'icon_ripple' },
|
||||
...(solo ? [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] : []),
|
||||
],
|
||||
};
|
||||
@@ -149,9 +150,9 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
// ---- the indication ----------------------------------------------------
|
||||
const afterAnim = () => {
|
||||
const el = devEl();
|
||||
const el = devEl()?.querySelector('.activity-ring.transition i:first-child');
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el, '::after');
|
||||
const cs = getComputedStyle(el);
|
||||
return { name: cs.animationName, dur: cs.animationDuration };
|
||||
};
|
||||
await setMarker('cover', false, true); // the gate alone on the plan
|
||||
@@ -160,33 +161,33 @@ const out = await page.evaluate(async () => {
|
||||
// mower used, so a plate colour read in the same frame is still mid-fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
|
||||
o.pulseWhileOpening = sr().querySelectorAll('.dev.covermove').length === 1;
|
||||
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
const a1 = afterAnim();
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-covermove' && a1?.dur === '2.2s';
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-activity-breathe' && a1?.dur === '2.2s';
|
||||
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
|
||||
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.pulseWhileClosing = sr().querySelectorAll('.dev.covermove').length === 1;
|
||||
o.pulseWhileClosing = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
o.closingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
o.noPulseWhenOpen = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.noPulseWhenOpen = sr().querySelectorAll('.dev.activity-transition').length === 0;
|
||||
o.iconMorphOpen = icons().includes('mdi:curtains') && !icons().includes('mdi:curtains-closed');
|
||||
// owner 2026-08-04: never a coloured plate on a cover — not even «открыто»
|
||||
o.openWearsNoFrame = sr().querySelectorAll('.dev.open').length === 0;
|
||||
o.openPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
o.noPulseWhenClosed = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.directCloseGetsFallback = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
o.iconMorphClosed = icons().includes('mdi:curtains-closed') && !icons().includes('mdi:curtains');
|
||||
|
||||
await setCover('open', { device_class: 'shutter' });
|
||||
o.iconMorphShutterOpen = icons().includes('mdi:window-shutter-open');
|
||||
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
o.noPulseWhenUnknown = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.noPulseWhenUnknown = sr().querySelectorAll('.dev.activity-transition').length === 0;
|
||||
o.noMorphWhenUnknown = icons().includes(gate().icon)
|
||||
&& !icons().some((i) => i.startsWith('mdi:curtains'));
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ const res = await page.evaluate(async () => {
|
||||
await upd();
|
||||
out.fieldDefault = c._drawWallFieldValue === '15' || c._drawWallFieldValue === '15.0';
|
||||
out.fieldInBar = !!sr().querySelector('.editbar .drawwall input');
|
||||
const wallsButton = sr().querySelector('.editbar button.on');
|
||||
out.drawButtonNamedWalls = ['Walls', 'Стены'].includes((wallsButton?.textContent || '').trim());
|
||||
out.fieldImmediatelyAfterWalls = wallsButton?.nextElementSibling?.matches('label.drawwall') === true;
|
||||
|
||||
// draw a small room away from existing rooms (garden/demo f1 may have rooms)
|
||||
// use empty space if available, else clear rooms on a copy path
|
||||
|
||||
@@ -100,7 +100,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
|
||||
// --- Legacy ripple → icon+activity; ghost keeps icon and has no effect -----
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
@@ -114,11 +114,10 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
|
||||
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.ripple'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.activity-ring'));
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
|
||||
+51
-54
@@ -1,11 +1,7 @@
|
||||
// Живой текст в декоре (docs/LIVE-TEXT.md): текстовая фигура может показывать
|
||||
// значение ОДНОЙ сущности. Шаблон — поле text, место значения — {}; единица по
|
||||
// умолчанию из сущности; мёртвая/отсутствующая сущность — прочерк «—»; ничего
|
||||
// не округляем и не переформатируем. Смок проверяет, что значение читается
|
||||
// живьём (новый hass — новый текст в DOM, без пересоздания карточки), что
|
||||
// редактор и просмотр рисуют одно и то же и что все три поля переживают
|
||||
// сохранение конфига.
|
||||
// ПАДАЕТ на сборке до этой фичи: подстановки нет, в DOM остаётся сырой шаблон.
|
||||
// Живой текст в декоре (docs/LIVE-TEXT.md): все связи с HA живут прямо в
|
||||
// тексте как {entity} / {entity:attribute}. Один блок может смешивать обычный
|
||||
// текст и несколько переменных; выбор значения вставляет токен в позицию
|
||||
// курсора. Старые entity/attr/unit читаются, но первое сохранение мигрирует их.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
@@ -33,15 +29,16 @@ const res = await page.evaluate(async () => {
|
||||
out.decorMode = c._mode === 'decor';
|
||||
c._curSpaceCfg.decor = [];
|
||||
|
||||
// ---------- 1. связанная и обычная надписи -------------------------------
|
||||
c._decorTextDialog = { x: 0.3, y: 0.3, text: 'Бак {}', color: '#112233',
|
||||
entity: 'sensor.living_temp', attr: '', unit: '' };
|
||||
// ---------- 1. несколько inline-переменных и обычная надпись -------------
|
||||
c._decorTextDialog = { x: 0.3, y: 0.3,
|
||||
text: 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}',
|
||||
color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const live = c._decorList.find((x) => x.kind === 'text');
|
||||
out.linkStored = live.entity === 'sensor.living_temp';
|
||||
out.noEmptyFieldsStored = !('attr' in live) && !('unit' in live);
|
||||
out.templateStoredRaw = live.text === 'Бак {}'; // в конфиге — ШАБЛОН
|
||||
out.liveValueRendered = dom(live.id) === 'Бак 22.4 °C';
|
||||
out.onlyTextStored = !('entity' in live) && !('attr' in live) && !('unit' in live);
|
||||
out.templateStoredRaw = live.text ===
|
||||
'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
|
||||
out.multipleValuesRendered = dom(live.id) === 'Бак 22.4 °C; LQI 154';
|
||||
|
||||
c._decorTextDialog = { x: 0.6, y: 0.3, text: 'Сауна', color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
@@ -51,63 +48,64 @@ const res = await page.evaluate(async () => {
|
||||
// ---------- 2. значение живое: новый hass — новый текст -------------------
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.9',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C';
|
||||
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C; LQI 154';
|
||||
out.plainStillUntouched = dom(plain.id) === 'Сауна';
|
||||
// как отдаёт HA: ни округления, ни переформатирования
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.94781',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.noRounding = dom(live.id) === 'Бак 23.94781 °C';
|
||||
out.noRounding = dom(live.id) === 'Бак 23.94781 °C; LQI 154';
|
||||
|
||||
// ---------- 3. мёртвая сущность — прочерк, шаблон остаётся ---------------
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unavailable',
|
||||
attributes: { unit_of_measurement: '°C' } });
|
||||
out.deadIsDash = dom(live.id) === 'Бак —';
|
||||
out.deadIsDash = dom(live.id) === 'Бак —; LQI —';
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unknown', attributes: {} });
|
||||
out.unknownIsDash = dom(live.id) === 'Бак —';
|
||||
out.unknownIsDash = dom(live.id) === 'Бак —; LQI —';
|
||||
await push('sensor.living_temp', null);
|
||||
out.missingIsDash = dom(live.id) === 'Бак —';
|
||||
out.missingIsDash = dom(live.id) === 'Бак —; LQI —';
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.backAlive = dom(live.id) === 'Бак 22.4 °C';
|
||||
out.backAlive = dom(live.id) === 'Бак 22.4 °C; LQI 154';
|
||||
|
||||
// ---------- 4. атрибут и единица ----------------------------------------
|
||||
open(live); await c.updateComplete;
|
||||
out.dialogHasTheLink = c._decorTextDialog?.id === live.id
|
||||
&& c._decorTextDialog?.entity === 'sensor.living_temp';
|
||||
c._decorTextDialog = { ...c._decorTextDialog, attr: 'linkquality' };
|
||||
// ---------- 4. picker вставляет полный токен ровно в позицию курсора ------
|
||||
c._decorTextDialog = { x: 0.45, y: 0.45, text: 'До после', color: '#112233',
|
||||
pickerEntity: 'sensor.living_temp' };
|
||||
c._decorTextSelection = { start: 3, end: 3 };
|
||||
c._decorInsertLiveVariable('linkquality'); await c.updateComplete;
|
||||
out.insertedAtCaret = c._decorTextDialog.text ===
|
||||
'До {sensor.living_temp:linkquality}после';
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.attrRead = dom(live.id) === 'Бак 154';
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, unit: 'LQI' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.unitOverride = dom(live.id) === 'Бак 154 LQI';
|
||||
// атрибута нет — прочерк, но остальная надпись жива
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, attr: 'nope', unit: '' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.missingAttrIsDash = dom(live.id) === 'Бак —';
|
||||
const inserted = c._decorList.find((x) => x.text?.startsWith('До '));
|
||||
out.insertedRenders = dom(inserted.id) === 'До 154после';
|
||||
|
||||
// ---------- 5. без плейсхолдера значение уезжает в конец ------------------
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'Бак', attr: '', unit: '' };
|
||||
// ---------- 5. ручная dotted-форма и отсутствующий атрибут ---------------
|
||||
c._decorTextDialog = { x: 0.55, y: 0.55,
|
||||
text: '{sensor.living_temp.linkquality} / {sensor.living_temp:nope}',
|
||||
color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.appendedWithoutSlot = dom(live.id) === 'Бак 22.4 °C';
|
||||
const manual = c._decorList.find((x) => x.text?.includes(':nope'));
|
||||
out.manualTokensWork = dom(manual.id) === '154 / —';
|
||||
|
||||
// ---------- 6. отвязали — снова обычная надпись --------------------------
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'Бак {}', entity: '' };
|
||||
// ---------- 6. старая связка мигрирует при первом редактировании ----------
|
||||
const legacy = { id: 'legacy_live', kind: 'text', x: 0.7, y: 0.7,
|
||||
text: 'Старый {}', color: '#112233', entity: 'sensor.living_temp' };
|
||||
c._curSpaceCfg.decor = [...c._decorList, legacy];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyStillRenders = dom(legacy.id) === 'Старый 22.4 °C';
|
||||
open(legacy); await c.updateComplete;
|
||||
out.legacyOpensAsToken = c._decorTextDialog?.text ===
|
||||
'Старый {sensor.living_temp}';
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const unlinked = c._decorList.find((x) => x.id === live.id);
|
||||
out.linkCleared = !('entity' in unlinked) && !('attr' in unlinked) && !('unit' in unlinked);
|
||||
out.rawTemplateShown = dom(live.id) === 'Бак {}';
|
||||
const migrated = c._decorList.find((x) => x.id === legacy.id);
|
||||
out.legacyMigrated = !('entity' in migrated)
|
||||
&& migrated.text === 'Старый {sensor.living_temp}'
|
||||
&& dom(legacy.id) === 'Старый 22.4 °C';
|
||||
|
||||
// ---------- 7. просмотр и киоск рисуют то же самое ------------------------
|
||||
open(unlinked); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, entity: 'sensor.living_temp' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const editorText = dom(live.id);
|
||||
c._setMode('view'); await c.updateComplete; await sleep(60);
|
||||
out.sameInViewMode = dom(live.id) === editorText && editorText === 'Бак 22.4 °C';
|
||||
out.sameInViewMode = dom(live.id) === editorText
|
||||
&& editorText === 'Бак 22.4 °C; LQI 154';
|
||||
|
||||
const kiosk = document.createElement('houseplan-card');
|
||||
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true });
|
||||
@@ -120,20 +118,19 @@ const res = await page.evaluate(async () => {
|
||||
&& [...kEl.querySelectorAll('tspan')].map((t) => t.textContent).join('\n') === editorText;
|
||||
kiosk.remove();
|
||||
|
||||
// ---------- 8. все три поля переживают запись конфига ---------------------
|
||||
// ---------- 8. round-trip хранит только текстовый шаблон ------------------
|
||||
const sent = [];
|
||||
const ws = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return ws(m); } };
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, attr: 'linkquality', unit: 'LQI' };
|
||||
c._decorSaveText();
|
||||
await sleep(900);
|
||||
const set = sent.filter((m) => m.type === 'houseplan/config/set').pop();
|
||||
const shape = set && (set.config.spaces.find((s) => s.id === c._space).decor || [])
|
||||
.find((x) => x.id === live.id);
|
||||
out.roundTrip = !!shape && shape.entity === 'sensor.living_temp'
|
||||
&& shape.attr === 'linkquality' && shape.unit === 'LQI' && shape.text === 'Бак {}';
|
||||
out.roundTrip = !!shape && !('entity' in shape) && !('attr' in shape) && !('unit' in shape)
|
||||
&& shape.text === 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
+49
-33
@@ -1,11 +1,11 @@
|
||||
// Правило владельца (2026-08-01, вариант «б»): «движение = разовая вспышка
|
||||
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
|
||||
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
|
||||
// off→on получает класс 'senseflash' — короткая вспышка кольца (hp-sense,
|
||||
// 3 удара, ~3.3 c), после чего класс снимается, ДАЖЕ если сущность всё ещё
|
||||
// off→on получает activity-event — три последовательные волны
|
||||
// (hp-activity-event, ~3.3 c), после чего эффект снимается, ДАЖЕ если сущность всё ещё
|
||||
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
|
||||
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
|
||||
// 'sensehold' — статичное жёлтое кольцо без анимации (opacity 0.4). Жёлтая
|
||||
// activity-presence — статичное кольцо без анимации (opacity 0.4). Жёлтая
|
||||
// заливка .dev.on в обоих случаях не участвует; ghost — без кольца
|
||||
// (призрак — конфигурация, не статус).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
@@ -14,6 +14,17 @@ const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// Activity is an explicit presentation: opt this fixture into it.
|
||||
const motionDevice = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
|
||||
c._serverCfg.markers = (c._serverCfg.markers || [])
|
||||
.filter((m) => m.binding !== 'device:' + motionDevice.bindingRef);
|
||||
c._serverCfg.markers.push({
|
||||
id: motionDevice.id,
|
||||
binding: 'device:' + motionDevice.bindingRef,
|
||||
display: 'icon_ripple',
|
||||
});
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const setMotion = async (state, device_class) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
|
||||
@@ -22,19 +33,21 @@ const res = await page.evaluate(async () => {
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// --- motion off→on: вспышка senseflash, 3 удара, подложка НЕ жёлтая -----
|
||||
// --- motion off→on: event, 3 волны, подложка НЕ жёлтая -------------------
|
||||
await setMotion('off', 'motion');
|
||||
out.offNoFlash = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
|
||||
out.offNoFlash = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
|
||||
await setMotion('on', 'motion');
|
||||
const flashEl = sr().querySelector('.dev.senseflash');
|
||||
const flashEl = sr().querySelector('.dev.activity-event');
|
||||
out.motionTripFlashes = !!flashEl;
|
||||
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
|
||||
const after = flashEl ? getComputedStyle(flashEl, '::after') : null;
|
||||
out.flashAnimated = !!after && after.animationName === 'hp-sense';
|
||||
out.flashFinite = !!after && after.animationIterationCount === '3'; // не infinite!
|
||||
const waves = flashEl ? [...flashEl.querySelectorAll('.activity-ring.event i')] : [];
|
||||
const firstWave = waves[0] ? getComputedStyle(waves[0]) : null;
|
||||
out.flashAnimated = !!firstWave && firstWave.animationName.startsWith('hp-activity-event');
|
||||
out.flashFinite = waves.length === 3
|
||||
&& waves.every((w) => getComputedStyle(w).animationIterationCount === '1');
|
||||
// подложка нейтральная — как у обычного .dev, не как у .dev.on
|
||||
const neutral = [...sr().querySelectorAll('.dev')].find((e) =>
|
||||
!e.classList.contains('senseflash') && !e.classList.contains('sensehold')
|
||||
!e.classList.contains('activity-event') && !e.classList.contains('activity-presence')
|
||||
&& !e.classList.contains('on') && !e.classList.contains('open')
|
||||
&& !e.classList.contains('alarm') && !e.classList.contains('unavail') && !e.classList.contains('valonly'));
|
||||
const bg = flashEl ? getComputedStyle(flashEl).backgroundColor : '';
|
||||
@@ -46,76 +59,79 @@ const res = await page.evaluate(async () => {
|
||||
await sleep(3600);
|
||||
await c.updateComplete;
|
||||
out.stateStillOn = c.hass.states['binary_sensor.hall_motion'].state === 'on';
|
||||
out.flashEndsWhileOn = sr().querySelectorAll('.dev.senseflash').length === 0;
|
||||
out.flashEndsWhileOn = sr().querySelectorAll('.dev.activity-event').length === 0;
|
||||
|
||||
// --- повторный цикл off→on: новая вспышка --------------------------------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
out.retripFlashesAgain = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
|
||||
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
|
||||
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс senseflash
|
||||
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс event
|
||||
// не снимался и animation-name не менялся — браузер продолжал старый
|
||||
// timeline, и после его конца (base opacity 0) второй trip был невидим.
|
||||
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
|
||||
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
|
||||
.find((a) => a.animationName && a.animationName.startsWith('hp-sense'));
|
||||
.find((a) => a.animationName && a.animationName.startsWith('hp-activity-event') && a.playState === 'running');
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №1
|
||||
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
await sleep(1200); // середина первой вспышки (< 3.3s)
|
||||
const a1 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||||
const a1 = senseAnim(sr().querySelector('.dev.activity-event'));
|
||||
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
|
||||
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
const a2 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||||
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
const a2 = senseAnim(sr().querySelector('.dev.activity-event'));
|
||||
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
|
||||
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
|
||||
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
|
||||
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
|
||||
await sleep(2300);
|
||||
const el3 = sr().querySelector('.dev.senseflash');
|
||||
const el3 = sr().querySelector('.dev.activity-event');
|
||||
const a3 = senseAnim(el3);
|
||||
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
|
||||
out.rapidRingVisibleAfterFirstWindow = !!el3
|
||||
&& parseFloat(getComputedStyle(el3, '::after').opacity) > 0.03;
|
||||
&& [...el3.querySelectorAll('.activity-ring.event i')]
|
||||
.some((w) => parseFloat(getComputedStyle(w).opacity) > 0.03);
|
||||
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
|
||||
await sleep(1400); await c.updateComplete;
|
||||
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.senseflash').length === 0;
|
||||
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.activity-event').length === 0;
|
||||
|
||||
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
|
||||
// --- occupancy: статичное кольцо presence, без анимации -----------------
|
||||
await setMotion('on', 'occupancy');
|
||||
const holdEl = sr().querySelector('.dev.sensehold');
|
||||
const holdEl = sr().querySelector('.dev.activity-presence');
|
||||
out.occupancyHolds = !!holdEl;
|
||||
const hAfter = holdEl ? getComputedStyle(holdEl, '::after') : null;
|
||||
const holdRing = holdEl?.querySelector('.activity-ring.presence i:first-child');
|
||||
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
|
||||
out.holdNotAnimated = !!hAfter && hAfter.animationName === 'none';
|
||||
out.holdRingFaint = !!hAfter && Math.abs(parseFloat(hAfter.opacity) - 0.4) < 0.01;
|
||||
out.holdBgNeutral = !!holdEl && !!neutral
|
||||
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
|
||||
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('senseflash');
|
||||
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('activity-event');
|
||||
await setMotion('off', 'occupancy');
|
||||
out.occupancyOffClears = sr().querySelectorAll('.dev.sensehold').length === 0;
|
||||
out.occupancyOffClears = sr().querySelectorAll('.dev.activity-presence').length === 0;
|
||||
|
||||
// --- presence аналогично -------------------------------------------------
|
||||
await setMotion('on', 'presence');
|
||||
out.presenceHolds = sr().querySelectorAll('.dev.sensehold').length === 1;
|
||||
out.presenceHolds = sr().querySelectorAll('.dev.activity-presence').length === 1;
|
||||
// и это именно sense-состояние, не тревога и не «открыто»
|
||||
out.noAlarmNoOpen = !sr().querySelector('.dev.sensehold.alarm') && !sr().querySelector('.dev.sensehold.open');
|
||||
out.noAlarmNoOpen = !sr().querySelector('.dev.activity-presence.alarm') && !sr().querySelector('.dev.activity-presence.open');
|
||||
|
||||
// --- ghost не рисует ничего (призрак — конфигурация, не статус) ---------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
const d = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
|
||||
c._serverCfg.markers = (c._serverCfg.markers || [])
|
||||
.filter((m) => m.binding !== 'device:' + d.bindingRef);
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, display: 'icon_ripple', hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
out.hiddenNotInView = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
|
||||
out.hiddenNotInView = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
|
||||
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
const ghost = sr().querySelector('.dev.ghost');
|
||||
out.ghostNoSense = !!ghost && !ghost.classList.contains('senseflash') && !ghost.classList.contains('sensehold')
|
||||
&& getComputedStyle(ghost, '::after').animationName !== 'hp-sense';
|
||||
out.ghostNoSense = !!ghost && !ghost.classList.contains('activity-event') && !ghost.classList.contains('activity-presence')
|
||||
&& !ghost.querySelector('.activity-ring');
|
||||
return out;
|
||||
});
|
||||
checkAll(res, {});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Regression: resize a shared wall whose middle is virtual and whose solid
|
||||
* remainders carry thickness. The live overlay, commit and Undo must move one
|
||||
* geometry transaction; virtual drawing must paint above the real wall body.
|
||||
* geometry transaction. Virtual drawing paints above the real wall body in
|
||||
* editors, but below it in View so thick-wall jambs mask the dash ends.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
@@ -47,6 +48,18 @@ const res = await page.evaluate(async () => {
|
||||
c._cursorPt = null;
|
||||
await upd();
|
||||
out.partialSpanCreated = (sp().open_spans || []).length === 1;
|
||||
const editorBody = sr().querySelector('.wallbodies');
|
||||
const editorVirtual = sr().querySelector('.openwalls');
|
||||
out.savedVirtualAboveRealInEditor = !!editorBody && !!editorVirtual
|
||||
&& !!(editorBody.compareDocumentPosition(editorVirtual) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
const viewBody = sr().querySelector('.wallbodies');
|
||||
const viewVirtual = sr().querySelector('.openwalls');
|
||||
out.savedVirtualBelowRealInView = !!viewBody && !!viewVirtual
|
||||
&& !!(viewVirtual.compareDocumentPosition(viewBody) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
c._setMode('plan');
|
||||
await upd();
|
||||
out.atomicThicknessBefore = c._intervalCm([550, 145, 550, 195]) === 20
|
||||
&& c._intervalCm([550, 205, 550, 295]) === 0
|
||||
&& c._intervalCm([550, 305, 550, 455]) === 20;
|
||||
@@ -113,6 +126,30 @@ const res = await page.evaluate(async () => {
|
||||
}) === before;
|
||||
}
|
||||
|
||||
// Canonical storage: legacy/transactional fragments on one room pair become
|
||||
// one selectable span, and closing it restores the uniformly thick parent
|
||||
// wall instead of leaving atomic debris in `walls`.
|
||||
const restoredCut = c._openCuts()[0];
|
||||
if (restoredCut) {
|
||||
const mx = (restoredCut[0] + restoredCut[2]) / 2;
|
||||
const my = (restoredCut[1] + restoredCut[3]) / 2;
|
||||
c._persistOpenCuts([
|
||||
[restoredCut[0], restoredCut[1], mx, my],
|
||||
[mx, my, restoredCut[2], restoredCut[3]],
|
||||
]);
|
||||
await upd();
|
||||
out.adjacentSpansPersistAsOne = (sp().open_spans || []).length === 1
|
||||
&& c._openCuts().length === 1;
|
||||
c._closeOpenSpan(c._openCuts()[0]);
|
||||
await upd();
|
||||
out.closeRejoinsUniformWall = !sp().open_spans
|
||||
&& (sp().walls || []).length === 1
|
||||
&& c._intervalCm(restoredCut) === 20;
|
||||
} else {
|
||||
out.adjacentSpansPersistAsOne = false;
|
||||
out.closeRejoinsUniformWall = false;
|
||||
}
|
||||
|
||||
// The screenshot topology: the two real arms belong to different room
|
||||
// contours and only meet at the endpoint of B↔C's virtual boundary. Their
|
||||
// per-room butt caps must be completed into one clean outer mitre.
|
||||
|
||||
@@ -9,10 +9,10 @@ const res = await page.evaluate(async () => {
|
||||
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
|
||||
await c.updateComplete;
|
||||
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
|
||||
// живёт только в glow-пятне и в фолбэке цвета пульсации
|
||||
// живёт только в glow-пятне и в фолбэке цвета activity-эффекта
|
||||
out.rgbClassGone = !sr().querySelector('.dev.rgb');
|
||||
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
|
||||
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
|
||||
// фолбэк activity сохраняет цвет свечения: маркер icon_ripple на лампе
|
||||
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
|
||||
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
|
||||
|
||||
@@ -33,9 +33,10 @@ const out = await page.evaluate(() => {
|
||||
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
|
||||
}) === '';
|
||||
|
||||
// без hvac_action фолбэк на state
|
||||
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
|
||||
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
|
||||
// без hvac_action выбранный режим heat не доказывает реальную работу
|
||||
o.modeWithoutActionStaysDark = cls(trv, {
|
||||
'climate.trv': { state: 'heat', attributes: {} },
|
||||
}) === '';
|
||||
|
||||
// горящая лампа устройства желтит значок, даже если primary — не она
|
||||
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
|
||||
|
||||
+188
-245
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user