v1.59.0-beta.10: unify device visuals and wall refinements
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 3m19s
Validate / smoke (push) Failing after 1m12s
Validate / backend (push) Failing after 7m45s

This commit is contained in:
Matysh
2026-08-05 23:38:01 +03:00
parent 4868cc0786
commit d2bec266ed
50 changed files with 2749 additions and 1462 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.59.0-beta.9"
VERSION = "1.59.0-beta.10"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.59.0-beta.9"
"version": "1.59.0-beta.10"
}
+8 -7
View File
@@ -273,8 +273,9 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
MAX_ENTITY_ID = 255
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
# A caption is a caption: the template is bounded, the attribute name is a flat
# name (no dots needed), and a unit is a couple of characters, not a sentence.
# A caption is a caption: the inline-reference template is bounded. Attribute
# and unit bounds below apply only to legacy beta.9 link fields, which remain
# accepted so an older saved plan can reach the frontend and migrate on edit.
MAX_DECOR_TEXT = 200
MAX_DECOR_ATTR = 64
MAX_DECOR_UNIT = 16
@@ -330,9 +331,9 @@ DECOR_SCHEMA = vol.Any(
vol.Optional("scale"): vol.All(
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
# live text: the label may show one entity's value. All three
# optional, so every existing plan validates unchanged and no
# migration runs.
# Legacy one-value link (beta.9 and earlier). New labels store
# every `{entity[:attribute]}` reference directly in `text`;
# these stay accepted solely for backward compatibility.
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
@@ -475,8 +476,8 @@ MARKER_SCHEMA = vol.Schema(
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
# asserts every option the editor offers is accepted here (issue #3)
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
# offered, but remains accepted while old stores migrate to icon_ripple.
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("ripple_color"): vol.Any(
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
+7 -4
View File
@@ -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;
+4 -3
View File
@@ -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);
+5 -2
View File
@@ -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 };
});
+3 -1
View File
@@ -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 клик НЕ переключает (инфо)
+16 -13
View File
@@ -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;
});
+7 -7
View File
@@ -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');
+13 -12
View File
@@ -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
View File
@@ -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'));
+3
View File
@@ -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
+2 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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, {});
+38 -1
View File
@@ -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.
+2 -2
View File
@@ -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' });
+4 -3
View File
@@ -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 };
File diff suppressed because one or more lines are too long
+188 -245
View File
File diff suppressed because one or more lines are too long
+17 -7
View File
@@ -87,11 +87,16 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
## Device markers (v1.6.0+)
Per-marker appearance (v1.22.0): `display: badge|ripple|icon_ripple` (+`ripple_color`,
`ripple_size`) draws presence-style pulsing rings gated by the pure `isActiveState` —
deliberately independent of the card-wide `live_states` toggle, with `unavailable` counting as
idle. `size` (icon multiplier via the `--dev-size` CSS var — value badges scale along) and
`angle` rotate/scale a single icon. Room drawing shows a live **ruler** (`segmentCm` +
Per-marker appearance: `display: badge|icon_ripple|value`. All three use one semantic
resolver (`src/device-visual.ts`) for availability, steady status and activity. `badge`
shows the icon/morph and status plate; `icon_ripple` additionally shows a finite event,
static presence, mechanical transition or actual-work ring; `value` replaces the icon
with the HA-formatted numeric value. A critical alarm is red in every presentation.
Legacy `display: ripple` is read as `icon_ripple` and rewritten on the next config save;
the backend accepts it only for compatibility. `ripple_color` and `ripple_size` remain the
stored names for the ordinary activity effect. `size` (icon multiplier via the
`--dev-size` CSS var — value badges scale along) and `angle` rotate/scale a single icon.
Room drawing shows a live **ruler** (`segmentCm` +
`formatLength`, metres or feet+inches by `hass.config.unit_system`); the scale is per-space
`cell_cm` (default 5 cm per grid cell).
@@ -386,12 +391,17 @@ hash falls back to the default.
a per-light `clipPath` = zone polygons + doorway sectors, each contour a
SEPARATE clipPath child (children union; subpaths of one nonzero path
cancel on opposite windings — field bug v1.36.3). Radius: global
`settings.glow_radius_cm` + per-marker `glow_radius_cm`.
`settings.glow_radius_cm` + per-marker `glow_radius_cm`. On a thick wall the
doorway sector is the angular intersection of its near- and far-face clear
spans, so the jamb returns clip the spill as a real opening tunnel.
- **Open boundaries** (v1.37, revised 2026-08): `space.open_spans` hold
geometric virtual stretches; `room.open_to` remains the light-zone index
derived from spans (legacy `open_to`-only configs expand to full
`sharedBoundary` on read). Shared stretches drawn as a TRUE dash; outlines
trimmed (`outlineWithout`/`cutSegments`). Geometry mutations clip one stored
trimmed (`outlineWithout`/`cutSegments`). In View the dash group is painted
before thick wall bodies, letting real jambs mask its centreline ends; in all
editors it is painted after the bodies so saved spans and previews remain
fully visible. Geometry mutations clip one stored
span to **every** surviving shared segment. Adjacent pieces owned by different
room pairs stay separate: their midpoints are the source of the corresponding
`open_to` links after Split (`AUD-159B7-01`).
+38
View File
@@ -2,6 +2,44 @@
## Unreleased (dev)
## v1.59.0-beta.10 — 2026-08-05
Tenth pre-release of the 1.59 line: one coherent device-state language and a
focused set of wall, doorway-light and background-label refinements.
- **One device-state language.** Marker plates and effects now come from one
semantic resolver: yellow means actual work, orange means open/unlocked,
unavailable is faded, and alarms are always red. The display list is reduced
to Icon, Icon + activity and Value; the removed Ripple-only value migrates to
Icon + activity on the next save. Activity distinguishes short events,
persistent presence, mechanical travel and actual running, with
reduced-motion fallbacks and no false event on first load or reconnect.
Short effects also reset when a marker's effective source changes, while a
cover's real opening/closing state takes precedence over the tap fallback.
- **Clearer wall-drawing toolbar.** The Plan editor's former “Add” tool is now
labelled “Walls”, and its new-wall thickness field sits immediately to the
right of that button instead of after the rest of the toolbar.
- **View-mode virtual walls sit behind thick wall bodies.** Their stored
geometry still reaches the physical centreline, but in View the hatch masks
the dash ends inside adjoining thick walls. All three editors keep saved
dashes and live previews above the wall body so the complete span remains
visible while editing.
- **Door light respects thick-wall reveals.** Light now crosses a doorway only
through the clear width of its physical wall tunnel. The near and far inner
face spans jointly clip the spill, so the two jamb returns cast the expected
cut-offs for an off-centre source; zero-thickness walls retain the previous
doorway sector.
- **Inline HA variables in decor text.** A label can now mix ordinary copy and
multiple `{entity}` / `{entity:attribute}` references. Choosing a state or
attribute inserts its token at the textarea caret. The separate unit field,
one-slot hint, and preview are removed; old linked labels remain readable and
migrate to inline tokens when edited.
- **Canonical wall fragments.** Touching or overlapping virtual stretches on
the same boundary and room pair now collapse into one `open_span`; Split
pieces belonging to different room pairs remain separate. When removing the
last virtual stretches leaves an original wall solid and uniformly thick,
its atomic thickness entries collapse back to one whole-wall key.
## v1.59.0-beta.9 — 2026-08-05
Ninth pre-release of the 1.59 line: mixed virtual/thick resize integrity and
+42
View File
@@ -8,6 +8,48 @@
## Не выпущено (dev)
## v1.59.0-beta.10 — 2026-08-05
Десятый pre-release линейки 1.59: единая система состояния устройств и набор
точечных улучшений стен, света через дверные проёмы и надписей подложки.
- **Единый язык состояния устройств.** Подложка и эффекты маркера теперь
вычисляются одним семантическим resolver: жёлтый означает фактическую работу,
оранжевый — открыто/разблокировано, недоступное устройство приглушается, а
тревога всегда остаётся красной. В списке отображения остались «Значок»,
«Значок + активность» и «Значение вместо иконки»; удалённый режим «Только
пульсация» при следующем сохранении переходит в «Значок + активность».
Активность различает короткое событие, постоянное присутствие, механическое
движение и фактическую работу, не создавая ложного события при загрузке или
восстановлении связи. Короткие эффекты также сбрасываются при смене
фактического источника маркера, а реальное открытие/закрытие шторы имеет
приоритет над временной реакцией на нажатие.
- **Более понятная панель рисования стен.** Бывшая кнопка «Добавить» в
редакторе плана теперь называется «Стены», а поле толщины новых стен стоит
сразу справа от неё, а не после остальных инструментов панели.
- **В режиме просмотра виртуальные стены находятся под телом толстой стены.**
Их сохранённая геометрия по-прежнему доходит до физической осевой линии, но в
View штриховка скрывает концы пунктира внутри примыкающих толстых стен. Во
всех трёх редакторах сохранённый пунктир и живой preview остаются поверх тела
стены, чтобы весь отрезок был виден во время правки.
- **Свет из двери учитывает откосы толстой стены.** Теперь свет проходит через
дверной проём только по свободной ширине его физического тоннеля. Ближний и
дальний внутренние срезы совместно ограничивают пучок, поэтому оба стеновых
откоса корректно отсекают лучи смещённого источника; для стены без толщины
сохраняется прежний сектор проёма.
- **Inline-переменные HA в тексте подложки.** Одна надпись теперь может
сочетать обычный текст и несколько ссылок `{entity}` / `{entity:attribute}`.
Выбор состояния или атрибута вставляет токен в позицию курсора textarea.
Отдельное поле единицы, подсказка про один слот и предпросмотр удалены;
старые связанные надписи продолжают читаться и при редактировании переходят
на inline-токены.
- **Канонические фрагменты стен.** Соседние или перекрывающиеся виртуальные
отрезки на одной границе одной пары комнат теперь объединяются в один
`open_span`; части Split, принадлежащие разным парам комнат, остаются
раздельными. Если после удаления последних виртуальных участков исходная
стена снова целиком реальная и имеет одну толщину, её атомарные ключи
схлопываются обратно в один ключ цельной стены.
## v1.59.0-beta.9 — 2026-08-05
Девятый pre-release линейки 1.59: целостный resize смешанных
+8 -8
View File
@@ -71,16 +71,16 @@ the old behaviour until an editing client materialises it.
## What a marker SHOWS
A marker's live indication — the yellow «on» plate, the «open» frame (never
on a cover, see below), the breathing `covermove` ring, the state-morphed
icon, the ripple — speaks for ONE entity of the device, resolved in this
order (`_stateClass` / `_actEntity`):
A marker's live indication — status plate, state-morphed icon and semantic
activity — is derived by one resolver from one effective source set. Its
source precedence is (`_visualSamples` / `_actEntity`):
1. the device's **cover**, when the marker's tap action is explicitly
«Открыть/закрыть» (`tap_action: 'cover'` — `coverEntityOf`, the same helper
and the same entity the tap drives). It wins over EVERYTHING below;
2. the marker's bound **controls**, if it has any (a stateless remote or a
virtual wall switch mirrors what it drives, not itself);
virtual wall switch aggregates what it drives, not itself; any working
target drives both the yellow plate and running activity);
3. a **lit light** among its entities (owner's principle 2026-07-29: the glow
spot and the badge may never disagree);
4. otherwise the **primary** entity (`primaryEntity`).
@@ -125,14 +125,14 @@ dialog away, and the honest reading of what the marker was told it is.
«У штор не должно быть жёлтой подложки НИКОГДА, индикация открыто/закрыто за
счёт морфинга иконки.» For the `cover` domain — and for the cover an
«Открыть/закрыть» marker indicates, rule 3 above — `_stateClass` returns no
plate class in any state:
«Открыть/закрыть» marker indicates, rule 1 above — the visual resolver returns
no working/open plate in any state:
| cover state | plate | ring | icon |
|---|---|---|---|
| `closed` | neutral | — | closed glyph |
| `open`, ajar (`open` + position) | neutral | — | open glyph |
| `opening`, `closing` | neutral | `.covermove` breathes | open glyph |
| `opening`, `closing` | neutral | `.activity-transition` breathes in Icon + activity | open glyph |
| `unknown` / no state | neutral | — | base icon, no morph |
| `unavailable` | neutral, faded (`.unavail`) | — | base icon |
+45 -64
View File
@@ -1,17 +1,18 @@
# The text block — a decor label that can show an entity's state
Status: **implemented (dev, unreleased).** Code: `src/logic.ts`
(`liveText`, `liveTextValue`, `decorTextScale`, `decorTextLines` — pure,
unit-tested), `src/houseplan-card.ts` (`_renderDecorLayer`,
(`liveText`, `liveTextReference`, `liveTextToken`, `liveTextValue`,
`decorTextScale`, `decorTextLines` — pure, unit-tested),
`src/houseplan-card.ts` (`_renderDecorLayer`,
`_renderTextFrame`, `_renderDecorTextDialog`, the `_dt*` gestures),
`custom_components/houseplan/validation.py` (`DECOR_SCHEMA`, text branch).
Smokes: `demo/smoke_live_text.mjs`, `demo/smoke_decor_text.mjs`;
`demo/smoke_decor.mjs` keeps the surrounding decor contract.
The shape: `{kind:'text', x, y, text, color, scale?, angle?, entity?, attr?,
unit?}` — plus the legacy `size?` that older plans still carry. Everything
after `color` is optional, so **every existing plan validates and renders
unchanged and no migration runs**.
The shape: `{kind:'text', x, y, text, color, scale?, angle?}` — plus legacy
`size?`, `entity?`, `attr?`, and `unit?` fields that older plans may still
carry. New live references are stored only inside `text`; existing linked
labels render unchanged and migrate to inline references when edited.
## 1. Why a live label
@@ -27,28 +28,26 @@ happens to have a live number in it.
The competing card (ha-floorplan) covers this with `text_set` and it is one of
its most used features; our decor text was one field away from it.
## 2. The live value
## 2. Inline HA variables
A decor text shape has three optional fields:
`text` is both the visible copy and the complete template. It may contain any
number of HA references mixed with ordinary text and line breaks:
- `entity` — an entity id whose state is substituted; absent = today's plain
static label, byte-for-byte unchanged;
- `attr` — an attribute name to read instead of `state` (battery level,
current temperature of a climate, position of a cover); absent = the state;
- `unit` — a suffix; absent = the entity's own `unit_of_measurement`, so
`sensor.tank` needs no configuration to read *68 %*.
- `{sensor.water_tank}` — the entity state;
- `{climate.hall:current_temperature}` — one attribute;
- `Бак {sensor.water_tank}, зал {climate.hall:current_temperature}` — several
independent values in one label.
The `text` field keeps its present meaning and becomes the **template**: the
placeholder `{}` is where the value lands. `Бак {}` → *Бак 68 %*. A template
without a placeholder gets the value appended after a space, so a user who
only picks an entity and types nothing sensible still sees something useful.
**Only the first `{}` is replaced** — one label, one value.
The editor writes the colon form because the boundary between entity and
attribute is unambiguous. Hand-written `{climate.hall.current_temperature}` is
accepted too: the first two dot-separated parts form the entity id and the
rest is the attribute. Invalid brace contents stay literal, while a valid but
missing entity or attribute renders as a dash.
`{}` was chosen over `{{ }}` deliberately: this is a substitution, not a
template language. There is no expression, no condition, no arithmetic — the
moment we accept `{{ states('x') | round(1) }}` we have signed up for the
class of support load that fills the competitor's thread (138 posts of "my
CSS/template does not work"). One value, one place, no syntax to get wrong.
This remains substitution, not a template language: no expressions,
conditions, arithmetic, Jinja, or nested braces. The 200-character limit is
the limit of the saved template; each resolved value is still clipped to 60
characters.
### 2.1. Rendering rules
@@ -77,17 +76,10 @@ CSS/template does not work"). One value, one place, no syntax to get wrong.
formatter falls back to the raw state, byte-for-byte the pre-2026-08-05
behaviour. Imperial/metric is not our business either — the value and the
unit come from HA (docs/STYLING-HOOKS.md §6).
- **The unit is inherited only for the STATE.** With an `attr` the entity's
`unit_of_measurement` is not applied: it describes the state, and a
`battery_level` read off a °C sensor must not come out as «73 °C». An
explicit `unit` always wins; an empty one inherits.
- **The unit appears exactly once.** HA's formatter normally appends the
entity's unit itself, so the inherited one is not added a second time — and
where it does not append it, ours still is, so the unit never silently
disappears either. The rule: strip the entity's own `unit_of_measurement`
if it is already the tail (exact trailing match, nothing else in the string
is touched), then append the unit actually wanted — the user's explicit one,
or the entity's own.
- **Units belong to Home Assistant.** State variables use HA's formatted state,
including its unit. Attribute variables use HA's attribute formatter and do
not inherit the entity state's unit. There is no separate unit override in
the new editor; a literal suffix can be typed immediately after the token.
- The value is clipped to `LIVE_TEXT_VALUE_MAX` (60) characters: a caption is
a caption, and an attribute that turns out to be a 4 KB string must not
become the plan's wallpaper.
@@ -119,7 +111,7 @@ size is not one of three opinions; it is whatever fits the place it is put in.
newline is stored and rendered as a newline (one `<tspan>` per line, line
height 1.2 em). The label **never wraps by itself** — a caption that reflows
on every state change is a caption that jumps around the plan. A
300-character line stays one line.
200-character line stays one line.
- **Multi-line blocks are centred**, horizontally (the decor layer's
`text-anchor: middle`, which single-line labels already used) and
vertically: the anchor `x/y` sits in the middle of the block, so adding a
@@ -159,34 +151,24 @@ click opens its editor, and the corner/rotate handles appear.
## 5. The dialog
Under the text field:
- a **textarea** (line breaks are content now), saved with the button or
Ctrl/⌘+Enter — plain Enter is a new line;
- a hint that mentions `{}` **only when an entity is chosen** — an unlinked
label must not be burdened with syntax it does not need;
- an **entity picker** with a datalist of all entities — the same control
style the vacuum source and the weather field use;
- an **attribute field**, shown only once an entity is chosen, with a datalist
of that entity's actual attribute names — the user should not have to know
that a climate keeps `current_temperature`;
- a **unit field** whose placeholder is the entity's own unit, so leaving it
empty is the obvious right answer (the placeholder disappears once an
attribute is chosen, because an attribute does not inherit it);
- a **live preview** of the resulting label, rendered through the same
`liveText` the plan uses: one substitution, one truth.
Clearing the entity clears the attribute and the unit with it — a save never
leaves orphan fields in the config.
- The textarea is the sole source of the label. It accepts ordinary copy,
line breaks, and manually typed references in any order; Ctrl/⌘+Enter saves.
- «Insert HA variable» contains an entity picker. After an entity is selected,
the second control offers its state and actual attribute names.
- Choosing the state or an attribute immediately inserts the complete token at
the textarea's current selection/caret, then returns focus after the token.
The user can continue typing or insert another variable, including one from
another entity, until the 200-character field limit is reached.
- There is no unit field, single-slot hint, or separate preview. The label on
the plan is already the live preview and uses the same text template.
## 6. Backend
`DECOR_SCHEMA`, text branch: `entity` optional, `None` or an entity id
(`^[a-z0-9_]+\.[a-z0-9_]+$`, ≤ 255); `attr` optional, `None` or a flat name
≤ 64; `unit` optional, `None` or ≤ 16 characters; `scale` optional, finite,
`0.15…20`; `angle` optional, finite, `-360…360`; `text` ≤ 200 characters,
newlines included. Everything optional, so every existing plan validates
unchanged. Tests: `tests_backend/test_validation.py`
`DECOR_SCHEMA`, text branch: `text` ≤ 200 characters, newlines and inline
references included; `scale` optional, finite, `0.15…20`; `angle` optional,
finite, `-360…360`. Legacy `entity`/`attr`/`unit` remain accepted and bounded
so old saved plans continue to validate and render. The frontend writes none
of them after the label has been edited. Tests: `tests_backend/test_validation.py`
(`test_decor_text_live_fields`, `test_decor_text_block_scale_and_angle`).
## 7. What this is not
@@ -194,8 +176,7 @@ unchanged. Tests: `tests_backend/test_validation.py`
- **Not a second device marker.** No tap action, no icon, no state class, no
participation in room aggregation (LQI, climate averages) — it is a caption,
not a device. A user who wants an interactive thing puts a marker.
- **Not a template engine** (see §2).
- **Not a multi-entity widget.** One label, one value. Two values are two
labels; that stays honest and costs the user one drag.
- **Not a template engine** (see §2). Multiple substitutions do not introduce
expressions, conditions, or formatting rules.
- **Not an auto-layout.** No wrapping, no shrink-to-fit: the size is set with
the corners and the lines with the Enter key.
+8 -3
View File
@@ -15,11 +15,11 @@
| Item | State |
|---|---|
| Version | **v1.59.0-beta.9** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.9` on **`dev`**, GitHub Release with `prerelease=true`; `main` is not touched and nothing is copied to the home instance by hand — HACS delivers it on the beta channel. On top of beta.8: resize keeps partial `open_spans` and the atomic thickness keys of their solid remainders together in live preview, commit and Undo; real arms at virtual T-junctions receive the missing mitre, and the virtual dash/preview renders above thick real walls. Previous pre-release: v1.59.0-beta.8; previous stable: v1.58.0 |
| Version | **v1.59.0-beta.10** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.10` on **`dev`**, GitHub Release with `prerelease=true`; `main` is not touched and nothing is copied to the home instance by hand — HACS delivers it on the beta channel. On top of beta.9: unified device status/activity visuals; inline HA variables in decor text; doorway-light clipping by thick-wall reveals; view-only virtual-wall masking; a clearer wall toolbar; and canonical merging/compaction of wall fragments. Previous pre-release: v1.59.0-beta.9; previous stable: v1.58.0 |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
| Workflow | Owner's rule since 2026-08-05: ordinary fixes/features are made **locally, without tests and without commits**. Tests/build/smokes run only when the owner asks for a pre-release; then bump to `X.Y.Z-beta.N`, commit/tag the tested `dev` state and publish a GitHub Release with `prerelease=true`. `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
| GitHub | https://github.com/Matysh/houseplan-card — `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | beta.8 repairs the stale general-settings smoke that left beta.7 Validate red (`111/112`) and adds exact-SHA release gating: `release.yml` withholds the asset until every matching Validate run finishes green. The full local gate is green; GitHub Validate and the release asset are verified after the tag is pushed |
| CI | beta.10 passes the full local frontend, pure-backend and browser-smoke gate. Exact-SHA release gating remains active: `release.yml` withholds the asset until every matching Validate run finishes green; GitHub Validate and the release asset are verified after the tag is pushed |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
@@ -69,6 +69,11 @@
- **Yellow = working right now** (v1.51.0): climate by hvac_action, service
switches can no longer become primary, glow pool and icon share one
condition. Editor gestures on touch (pinch/pan) landed the same release.
- **Unified device status/activity** (v1.59.0-beta.10, 2026-08-05): three display
modes (Icon / Icon + activity / Value), one semantic resolver for yellow
actual work, orange open/unlocked, unavailable and always-red alarms;
activity distinguishes a short event, presence, mechanical travel and
running. Legacy Ripple-only migrates to Icon + activity on the next save.
## Recent milestones (details in CHANGELOG.md)
+14 -10
View File
@@ -136,10 +136,10 @@
- Drag иконок, снап к сетке, позиция per-space — Стенд: редактор устройств: перетащить иконку, F5 — позиция жива; у «Двора» своя раскладка.
- ↺ сброс автолейаута — Стенд: не проверяется: кнопка Reset убрана в v1.33.2 (пункт в TESTING.md исторический).
- Бейдж температуры, LQI с цветом — Стенд: под «Zigbee»-термометрами цифра LQI (цвет от красного к зелёному: Кабинет красный, Гостиная зелёный), температура на бейдже.
- Живые состояния: свет жёлтый, открытая штора оранжевая, unavailable блёклый — Стенд: пощёлкать свет; открыть штору («Открыть шторы» или карточка); unavailable в демо нет — эмулировать нечем, но у робота после паузы всё живое.
- Живые состояния: фактическая работа жёлтая, дверь/разблокированный замок/клапан оранжевые, штора нейтральная и меняет иконку, unavailable блёклый — Стенд: пощёлкать свет; открыть штору («Открыть шторы» или карточка); unavailable в демо нет — smoke.
- Морфинг иконок по состоянию — Стенд: дверь/замок: открыть lock.front_door из карточки двери — иконка меняется; гаражные ворота во «Дворе».
- «Значение вместо иконки» — Стенд: у термометра в диалоге выбрать display «значение» — на плане цифра.
- RGB только в glow/ripple — Стенд: включить RGBWW-лампу в Гостиной цветом: бейдж жёлтый стандартный, цвет только в пятне glow.
- RGB только в glow/activity — Стенд: включить RGBWW-лампу в Гостиной цветом: бейдж жёлтый стандартный, цвет только в пятне glow и activity-эффекте.
- Красная пульсация тревоги — Стенд: включить `input_boolean.demo_leak` (Настройки → Устройства и службы → Помощники) — датчик на Кухне пульсирует красным; дым в Спальне сам вспыхивает каждые 10 минут на 30 с.
- Стоимость рендера (geometry once) — Стенд: не проверяется руками — smoke_render_perf.
- Тап vs drag открывания — Стенд: редактор плана, тап по двери = свойства, drag на место = ничего не записано.
@@ -155,7 +155,7 @@
- Tap запускает автоматизацию — Стенд: тап по «Открыть шторы» (Гостиная) → диалог подтверждения → штора едет; в диалоге маркера видно «Run» + поиск по automation/script/scene (есть скрипт «Открыть шторы», автоматизация «Вечерний сценарий», сцены «Кино», «Вечер в гостиной»); Esc = не выполнять.
- Бейджи light-source в glow — Стенд: заливка glow (по умолчанию): у горящей лампы бейдж стандартный (индикатор — пятно), у розетки-«источника света» жёлтый.
- Множитель размера иконки растит глиф — Стенд: size 3 у любого маркера.
- Auto-grid parity, ripple ghost, hidden LQI parity, ghost без цифр — Стенд: скрыть «Zigbee»-термометр: LQI комнаты не меняется, «Схема» совпадает с планом; «Show hidden» в редакторе устройств показывает синий пунктирный призрак без значений.
- Auto-grid parity, activity ghost, hidden LQI parity, ghost без цифр — Стенд: скрыть «Zigbee»-термометр: LQI комнаты не меняется, «Схема» совпадает с планом; «Show hidden» в редакторе устройств показывает синий пунктирный призрак без значений и эффектов.
- Флаг «скрыть с плана» — Стенд: галка в любом диалоге устройства; скрытый исчезает из всех режимов и счётчика, но LQI комнаты держит.
- «Жёлтый = работает» — Стенд: «Тёплый пол» в Кабинете: hvac_action=heating → жёлтая подложка. (Выключить нагрев можно, поставив target ниже 20° в карточке — бейдж гаснет.)
- Жесты редактора на touch — Стенд: с телефона в редакторе плана: pinch = zoom, палец = pan, отпускание не ставит точку.
@@ -316,15 +316,19 @@
- show_button: false — Стенд: правкой YAML вида.
- #space= диплинк — Стенд: открыть `…/plan#space=yard`.
## Presence ripples / per-device иконка
## Состояние и активность устройства
- «Только пульсация»: бейдж исчезает, кольца при on — Стенд: у датчика движения «Movement Backyard» (Кухня) поставить display «пульсация» — он периодически on/off сам.
- «Иконка + пульсация» — Стенд: там же.
- Цвет и размер ×2..×8 — Стенд: там же.
- unavailable останавливает пульс — Стенд: не проверяется (нет unavailable-сущностей в демо) — smoke.
- В списке отображения ровно три режима: «Значок», «Значок + активность», «Значение вместо иконки»; «Только пульсация» отсутствует.
- Motion в «Значок + активность» даёт три волны только при `off → on` и затихает примерно через 3,3 с, даже если датчик ещё `on`.
- Occupancy/presence показывает спокойное статичное кольцо, пока присутствие активно.
- Штора в `opening/closing` показывает дыхание только в «Значок + активность»; при прямом `closed ↔ open` без промежуточного состояния эффект держится примерно 3,3 с.
- Работающий свет, реле, вентилятор, climate с активным `hvac_action`, плеер и пылесос получают жёлтую подложку; `automation = on` (включена) остаётся нейтральной.
- Дверь/окно, разблокированный замок и открытый клапан — оранжевые, а не жёлтые.
- Тревога всегда красная и пульсирует во всех трёх режимах; unavailable приглушён и не получает обычной активности.
- Цвет и размер ×2..×8 применяются к обычной активности; тревога остаётся красной.
- Размер ×0.5..×3 и поворот; бейджи масштабируются — Стенд: любой маркер.
- Риплы при выключенных live-состояниях — Стенд: выключить «живые состояния» в конфиге карточки (правка вида) — риплы живут.
- reduce motion — Стенд: включить в ОС тестера.
- При выключенных live-состояниях обычные статус и активность выключены, критическая тревога остаётся.
- reduce motion — Стенд: включить в ОС тестера; вместо анимации остаётся статичное кольцо.
## Двери и окна
+94 -45
View File
@@ -232,17 +232,23 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
- [ ] ↺ reset restores auto layout after confirm
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
- [ ] Unified live states (dev, owner 2026-08-05): actual work is yellow;
open door/window, unlocked lock and open valve are orange; covers stay
neutral and morph their icon; unavailable is faded. The plate and the
activity effect come from the same semantic resolver
- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed,
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [manual]
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
as its body, small badges hidden; non-numeric fallback keeps the icon [manual]
- [ ] RGB lights (v1.27.0, contract changed in v1.52.0): a lamp's colour lives
in its glow spot and the ripple fallback ONLY — the icon/badge/border get
no RGB tint; explicit ripple color still wins; off lights unchanged
in its glow spot and the activity-effect fallback ONLY — the icon/badge/border get
no RGB tint; explicit activity color still wins; off lights unchanged
[auto: smoke_light_badges + smoke_rgb_alarm]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [manual]; reduced-motion static
- [ ] Alarm pulse (v1.27.0, unified dev): leak/smoke/gas/CO/siren in `on`
and an alarm control panel in `triggered`
get a red plate and red pulse over every display mode and even with
ordinary live-state dressing off; clears on 'off'; unavailable never
alarms [manual]; reduced-motion is static
- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is
computed once per config change, not per HA state push — smoke asserts
zero recomputations across 10 state pushes and recomputation after an
@@ -299,9 +305,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
closed -> cover.open_cover, open (incl. ajar) -> cover.close_cover,
opening/closing -> cover.stop_cover, no readable state -> cover.toggle;
the «ask for confirmation» checkbox guards it like toggle/run.
Indication: while travelling the icon breathes a soft yellow ring
(.covermove, 2.2s, static under prefers-reduced-motion) and the plate
stays NEUTRAL in EVERY state (2026-08-04, see the next item); the icon
Indication: while travelling the icon breathes a soft activity ring
(`.activity-transition`, 2.2s, static under prefers-reduced-motion) in
display «Icon + activity» and the plate stays NEUTRAL in EVERY state
(2026-08-04, see the next item); the icon
morphs by state + device_class (blinds/shutter/curtain…), unknown state
morphs nothing; no position percentages anywhere
[auto: smoke_cover_tap + units resolveTapAction/coverService/stateIcon +
@@ -312,7 +319,8 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
closing: the plate is the plain neutral badge every time — never the
yellow «включено» one, never the orange «открыто» frame it used to wear
while open — the icon is the only open/closed signal, and the breathing
.covermove ring appears in the two travelling states and nowhere else.
`.activity-transition` ring appears in the two travelling states and
nowhere else when «Icon + activity» is selected.
The morph is exhaustive: every device class gives two DIFFERENT glyphs
(awning included), a cover with no device_class morphs within its own
auto-icon family (mdi:roller-shade, mdi:garage-variant), a hand-picked
@@ -334,7 +342,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Curtain INDICATION follows the same cover (dev, owner 2026-08-04): with
«Open/close» chosen on that same Aqara marker, the plan shows the cover
and not the service switch — the breathing ring while it travels
(`covermove`, opening AND closing), the `mdi:curtains-closed` /
(`activity-transition`, opening AND closing), the `mdi:curtains-closed` /
`mdi:curtains` morph, a neutral plate throughout (the «открыто» frame
was retired for covers later the same day), and no yellow plate when
`switch.*_reverse_direction` happens to be on. The rule is exactly
@@ -348,8 +356,8 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
device whose own `light.*` is ON, one whose bound `controls` switch is
ON. In every cover state (closed / open / opening / closing) the plate
stays neutral — never the yellow «включено», never the orange «открыто»
— the travelling ring breathes, the icon morphs with the cover, and in
glow fill the ring is still there (that is where a yellow-plated curtain
— with «Icon + activity» the travelling ring breathes, the icon morphs
with the cover, and in glow fill the ring is still there (that is where a yellow-plated curtain
used to lose BOTH indicators). Untouched: the same mixed device without
the explicit action is yellow again, a wall switch still mirrors its
controls, and an «Открыть/закрыть» marker whose device has no `cover.*`
@@ -357,7 +365,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
standard (the spot is the indicator) and a lit socket stays yellow; in
other fills a lit lamp is plain yellow with no RGB tint; morphing and
the ripple colour fallback survive [auto: smoke_light_badges +
the activity colour fallback survive [auto: smoke_light_badges +
smoke_rgb_alarm]
- [ ] Icon size multiplier scales the glyph (dev): set a marker's size to 3 —
the icon inside grows with the badge instead of staying default
@@ -365,8 +373,8 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Auto-grid parity (v1.51.2, HP-1511-01): with an empty layout, a visible
device among hidden ones sits at the same spot on both cards
[auto: smoke_hidden_flag autoGridParity]
- [ ] Ripple ghost (v1.51.2, HP-1511-02): a hidden ripple-display marker shows
its base icon, no pulse [auto: smoke_hidden_flag rippleGhost*]
- [ ] Activity ghost (v1.51.2, unified dev): a hidden icon+activity marker
shows its base icon and no effect [auto: smoke_hidden_flag rippleGhost*]
- [ ] Hidden LQI parity (v1.51.1, HP-1510-01): a room whose only Zigbee
devices are hidden paints the same lqi fill on the full and the static
card [auto: smoke_hidden_flag lqiParity]
@@ -378,7 +386,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
the count, still count toward room LQI, cast no glow/light fill; the
device editor's "Show hidden" (local, per tab) shows them as BLUE
dashed ghosts — distinct from a grey unavailable icon — with NO live
state paint (no yellow, no alarm, no ripple);
state paint (no yellow, no alarm, no activity);
unticking keeps a hidden:false marker (re-seed protection); an old
config materialises on first load by an editing client and legacy
clients keep the old behaviour until then
@@ -679,7 +687,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
tap action Toggle flips all bound lights/switches at once (any on → all
off, all off → all on, one service call); the icon state (yellow badge)
mirrors the targets, not the marker's own entity — the RGB tint is gone
since v1.52.0, target colours reach only the glow/ripple; without explicit Toggle
since v1.52.0, target colours reach only the glow/activity effect; without explicit Toggle
the click opens info as usual; the info card lists targets with states;
locks/other domains are filtered out of controls [auto: smoke_controls]
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
@@ -869,17 +877,34 @@ require hands on real hardware — they remain for the human pass.
- [ ] `show_button: false` hides the footer
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [manual]
## Presence ripples / per-device icon (v1.22.0)
## Unified device status and activity (dev, owner 2026-08-05)
- [ ] Marker dialog → Display = "Ripple only": the icon badge disappears, rings pulse while the
entity is on, and collapse to a faint dot when it goes off
- [ ] Display = "Icon + ripple": both the icon and the rings are drawn
- [ ] Ripple colour and size (×2..×8) apply per device
- [ ] An entity going `unavailable` stops the pulsing (idle dot), never leaves it running
- [ ] Icon size ×0.5..×3 and rotation 0..350° apply per device; the temp/humidity badges
scale with the icon
- [ ] Ripples still work with the card-wide "live states" toggle OFF (they are opt-in per device)
- [ ] With OS "reduce motion" enabled, rings do not animate
- [ ] The Display list contains exactly Icon, Icon + activity, Value instead
of an icon. Legacy `display: ripple` reads and saves back as `icon_ripple`
- [ ] Icon shows state plate/morph but no ordinary activity effect; Icon +
activity adds the semantic effect; Value keeps the state-coloured plate
and hides ordinary activity
- [ ] Motion/vibration/sound/contact rising edges render exactly three waves
for about 3.3 s; initial load and recovery from unknown/unavailable do
not fake an event; a rapid retrigger restarts it
- [ ] Occupancy/presence is one calm static ring for the whole active state
- [ ] Cover/lock/valve movement breathes until the travelling state ends;
direct terminal `closed ↔ open` / `locked ↔ unlocked` without an
intermediate state breathes for about 3.3 s
- [ ] Actual work (light/switch/fan/humidifier on, active climate action,
media playing, vacuum cleaning, script running) is yellow and slowly
breathes in Icon + activity; `automation = on` is merely enabled and
remains neutral
- [ ] Controls aggregate their targets: any working target drives both the
yellow plate and the running effect
- [ ] Open contact/unlocked lock/open valve are orange; an open cover stays
neutral because its icon morph carries that state
- [ ] Unavailable suppresses ordinary activity. Alarm outranks all of it,
remains red in every display mode and with ordinary live states off
- [ ] Activity colour and size (×2..×8) apply per device; alarm ignores them
- [ ] Icon size ×0.5..×3 and rotation 0..355° apply per device; the
temp/humidity badges scale with the icon
- [ ] With OS "reduce motion" enabled, activity/alarm rings are static
## Doors & windows (v1.23.0)
@@ -1132,25 +1157,30 @@ require hands on real hardware — they remain for the human pass.
## The text block on the plan (docs/LIVE-TEXT.md, dev, unreleased)
- [ ] **A label can show a live value**: in the Background editor place a text,
pick an entity in the dialog and write `Бак {}` — the plan shows
«Бак 68 %». Change the sensor: the label follows without any reload. A
label with no entity is untouched, byte for byte
[auto: smoke_live_text + unit logic.test]
- [ ] **The unit comes from the entity**: leave the unit field empty and the
entity's own `unit_of_measurement` is used (its placeholder shows which);
type your own and it wins. Choose an ATTRIBUTE (say `battery_level` on a
°C sensor) and the entity's unit is NOT inherited — an attribute is not
the state [auto: smoke_live_text]
- [ ] **One text, many HA variables**: write `Бак {sensor.tank}, зал
{climate.hall:current_temperature}` — both values render and update
independently. The hand-written dotted attribute form
`{climate.hall.current_temperature}` works too; ordinary/invalid braces
stay literal [auto: smoke_live_text + unit logic.test]
- [ ] **Picker inserts at the caret**: put the cursor between two words, choose
an entity and then its state/attribute — the full token appears exactly
at that selection and focus returns immediately after it. Continue typing
and insert another variable until the textarea's 200-character limit
[auto: smoke_live_text]
- [ ] **No separate unit/preview/single-slot UI**: the dialog has none of the
old unit field, `{}` hint, or preview block. State units come from HA;
attributes do not inherit the state unit, and a custom suffix is ordinary
text after the token [manual]
- [ ] **A dead sensor says so**: make the entity unavailable (or delete it) —
the value becomes «—» and the rest of the caption stays. The dash carries
no unit [auto: smoke_live_text]
- [ ] **Nothing is rounded**: a sensor reporting `23.94781` shows `23.94781`.
Rounding is the sensor's `display_precision`, not ours [auto:
smoke_live_text]
- [ ] **The preview is the truth**: the line under the dialog's fields is
rendered by the same substitution the plan uses, so what it shows is what
the plan will show [manual]
- [ ] **Old links migrate on edit**: a stored beta.9 `text + entity/attr/unit`
label still renders unchanged. Open it: the inline token is visible in
the textarea; save it and only the new text template remains
[auto: smoke_live_text]
- [ ] **The same everywhere**: the label reads identically in View, in the
editors and on a kiosk screen [auto: smoke_live_text]
- [ ] **No font-size choice any more**: the dialog has no Small/Medium/Large.
@@ -1427,9 +1457,9 @@ require hands on real hardware — they remain for the human pass.
attribute goes through the attribute formatter (a climate's
`current_temperature` is a number, not the climate's state)
[auto: smoke_value_format + unit logic.test]
- [ ] **Your own unit replaces the entity's**: type `проц.` in a label's unit
field — the result is «68,4 проц.», never «68,4 % проц.»
[auto: smoke_value_format]
- [ ] **A literal suffix stays literal**: write an attribute token followed by
` проц.` — the suffix is part of the text, with no hidden unit override
and no duplicate appended by the label renderer [auto: smoke_live_text]
- [ ] **An older Home Assistant is unchanged**: on an HA without
`formatEntityState` the badge and the label print the raw state with the
entity's unit appended, exactly as before — nothing is blank and nothing
@@ -1451,6 +1481,11 @@ require hands on real hardware — they remain for the human pass.
## Wall thickness (docs/WALL-THICKNESS.md, Unreleased)
- [ ] **Walls + adjacent draw thickness**: the first Plan-editor tool is named
“Walls” / «Стены» rather than “Add”; while it is active, its thickness
field is the immediately following toolbar element, before Merge. The
field still defaults to 15 cm and disappears when another tool is selected
[auto: smoke_draw_wall_thickness]
- [ ] **Tool + hover + input**: Plan editor → Wall thickness. Hover highlights
the whole wall; click opens the cm/in field; empty/0 clears; Esc closes
without applying; «Apply to all walls of this room» fills every allowed
@@ -1462,6 +1497,11 @@ require hands on real hardware — they remain for the human pass.
the body; the door swing is offset toward the inner face; with
`hide_openings` the symbols hide but the cut remains
[auto: smoke_wall_thickness]
- [ ] **Door light uses the clear tunnel**: place an off-centre light beside a
door in a thick wall. In the neighbouring room the glow is limited by
sight lines through both the near and far inner-face corners; neither
side crosses a solid jamb return. Clearing wall thickness restores the
wider centreline-based sector [auto: test/logic.test.mjs; manual visual]
- [ ] **Shared once / clear → line / resize re-keys**: one body for a shared
wall; clearing thickness restores the centreline; resizing a thick wall
keeps the thickness on the moved stretch, including both atomic solid
@@ -1469,9 +1509,18 @@ require hands on real hardware — they remain for the human pass.
[auto: smoke_wall_thickness + smoke_resize_virtual_thick]
- [ ] **Virtual T-junction**: when two real thick arms from different room
contours meet at an `open_span` endpoint, the outside corner is a clean
mitre with no stair-step; the saved dash and the two-click rubber band
paint above the real hatch right up to the centreline
mitre with no stair-step. In every editor the saved dash and the two-click
rubber band paint above the real hatch right up to the centreline; in
View the same saved dash paints below the body, so each thick jamb masks
its centreline end without shortening the stored span
[auto: test/wall-thickness.test.mjs + smoke_resize_virtual_thick]
- [ ] **Fragment normalisation**: draw adjacent/overlapping virtual stretches
along the same pair of rooms — they persist and select as one span. Close
the last virtual stretch on a uniformly thick wall — its saved `walls`
data returns to one whole-edge key. Repeat across a Split boundary: spans
owned by different room pairs must stay separate
[auto: test/open-spans.test.mjs + test/wall-thickness.test.mjs +
smoke_resize_virtual_thick]
- [ ] **Unit + backend**: inset/mitre/bevel, key from either end, degrade,
rekey, cm↔inches; `walls` schema bounds
[auto: test/wall-thickness.test.mjs + tests_backend/test_validation.py]
+12 -6
View File
@@ -48,8 +48,9 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
## Plan — geometry and appearance of the space
- Toolbar tools: Outline room (with a session wall-thickness field, default
15 cm — docs/WALL-THICKNESS.md §6), Delete room, Merge, Split, Resize,
- Toolbar tools: **Walls** / room outline (with its session wall-thickness field
immediately on the right, default 15 cm — docs/WALL-THICKNESS.md §6), Delete
room, Merge, Split, Resize,
Opening (place / drag along walls / properties), Open boundary, Wall thickness
(docs/WALL-THICKNESS.md — click a wall, set cm/inches from HA's unit system;
empty/0 clears), Room labels (drag positions — labels are part of the plan).
@@ -74,6 +75,10 @@ layer you cannot see is a layer you cannot edit.
- **Virtual walls follow `show_borders`** (owner, 2026-08-05). They are walls —
dashed ones. Drawing them on a space with no borders left a plan whose only
walls were a few floating dashed stretches.
- **Their geometry and their presentation are separate.** Every virtual span
still ends on the real wall centreline. In View the thick wall body is
painted over the dash ends, so they visually stop at its faces; editors paint
the full dash (and live preview) over the body for unambiguous editing.
- **`hide_openings` hides the symbol, not the opening.** Light still spills
through it, the sun still enters at a window, a contact sensor still opens
it, and the resize tool still anchors to it. Anything else would be a second
@@ -85,7 +90,8 @@ layer you cannot see is a layer you cannot edit.
## Devices — placement and marker configuration
- Icon dragging (ONLY here). Click on a device opens the **edit dialog directly**
(binding, name, icon, size/angle, display badge/ripple + colors, tap override,
(binding, name, icon, size/angle, display as icon / icon + activity / value,
activity color and size, tap override,
model/link/description/PDFs, room).
- + add device/entity/virtual, the "Hide device from plan" checkbox (since
v1.51.0 the one hiding mechanism, docs/FILTERING.md), ↺ reset layout,
@@ -117,12 +123,12 @@ layer you cannot see is a layer you cannot edit.
1. State-reflecting icons (open/closed door variants etc., like core HA).
2. `display: value` — show the measurement instead of an icon.
3. Light color in the icon/ripple (RGB lights). *(Shipped in v1.27.0;
superseded in v1.52.0: the colour lives in the glow spot and the ripple
3. Light color in the activity effect (RGB lights). *(Shipped in v1.27.0;
superseded in v1.52.0: the colour lives in the glow spot and the activity
fallback only, the icon tint was removed by the owner's rule.)*
4. Alarm visual (leak/smoke/doorbell): red pulse overlay.
5. Rooms as sub-areas without an HA area + manual device placement by room id.
6. Backlog (not planned): music notes for players, directional TV ripples.
6. Backlog (not planned): music notes for players, directional TV effects.
## Implementation iterations
+17 -4
View File
@@ -18,7 +18,11 @@ Code: `src/wall-thickness.ts`, render in `src/houseplan-card.ts` /
Per space: `walls: [{ key, cm }]`. Key = quantised midpoint + direction
(modulo 180°). Config always stores centimetres. Open boundaries refuse
thickness. One physical stretch has one thickness (atomic collinear spans when
neighbours overlap only partially).
neighbours overlap only partially). Atomic keys are storage only while a real
geometric break requires them: once an original edge is entirely solid with
one thickness, normalisation writes its single whole-edge key again. Likewise,
touching/overlapping `open_spans` of the same room pair are stored as one span;
pair ownership remains a hard boundary so Split can derive exact `open_to` links.
Degrade unmatched keys silently on write. Resize / undo / scale re-key all
touched spans in the same transaction and keep `walls` in the resize snapshot.
@@ -47,12 +51,19 @@ angle (mod 180°), then nearest span — never a perpendicular neighbour at a T.
At an `open_span` endpoint, real arms owned by different room contours receive
the same bounded mitre patch as arms from one contour; a virtual T therefore
has one clean outer corner rather than two butt caps forming a step.
The virtual segment itself remains centreline-based. View paints it before the
wall body, which masks the part inside each adjoining thick jamb; editors paint
it after the body so its full stored extent and live preview stay visible.
## 4. Floor, fills, light, area
- **Inner contour** = `inset(poly, half[])`.
- Room fills and glow are clipped to the inner contour (not painted into the
wall hatch).
- Glow leaving through a door uses the clear rectangular opening tunnel: its
sector is the intersection of the doorway spans at the near and far inner
faces. The two jamb returns therefore clip off-axis light; a zero-depth wall
keeps the original centreline sector.
- Displayed **m²** = area of the inner contour (clean floor). Wall-length
rulers and opening anchors stay on the centreline.
- With no thickness, inner = poly (parity with pre-thickness behaviour).
@@ -70,8 +81,9 @@ centreline/full-span wedge. `hide_openings` hides the symbol only.
Plan-editor tool «Wall thickness»; hover whole wall; cm/in from HA; empty/0
clears; apply-to-room. Hooks: `data-hp="wall"`. i18n en/ru.
**Draw with thickness.** The Draw toolbar carries a session thickness field
(default **15 cm**, or inches when HA is imperial). Closing a new room outline
**Draw with thickness.** The Plan toolbar's **Walls** button carries its session
thickness field immediately on the right (default **15 cm**, or inches when HA
is imperial). Closing a new room outline
writes that cm onto every new edge that does not already have one; shared
stretches that already carry a neighbour's thickness are left alone. Empty / 0
leaves the room thin. Live thick preview follows the rubber-band while drawing.
@@ -84,7 +96,8 @@ Decor-line thickness, per-side finish, auto-from-backdrop, plan-wide default.
## 8. Testing
Unit: ring closed at corners; half-out; inner area; atomic partial shared;
virtual-T mitre; angle-aware opening; whole and atomic rekey after edge/scale.
virtual-T mitre; angle-aware opening; thick-door tunnel clipping; whole and
atomic rekey after edge/scale.
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial
virtual stretch, its solid thick remainders and Undo move as one real resize;
the virtual rubber band paints above the real body; sun starts at the room-side
@@ -0,0 +1,155 @@
# Единая индикация состояния и активности устройств
Статус: **утверждено владельцем и реализовано локально 2026-08-05**.
## Цель
Заменить четыре пересекающиеся механики — цвет подложки, пользовательскую
пульсацию, motion/presence-эффекты и движение штор — одной семантической
системой. Визуал должен отвечать на три разных вопроса:
1. Доступно ли устройство.
2. В каком устойчивом состоянии оно находится.
3. Что оно делает прямо сейчас или что только что произошло.
## Режимы отображения
В UI остаются:
1. **Значок** — иконка, морфинг и статусная подложка без обычных эффектов
активности.
2. **Значок + активность** — то же плюс семантический эффект активности.
3. **Значение вместо значка** — числовая плашка со статусным цветом; обычная
активность не рисуется.
Опция **«Только пульсация» удаляется**. Legacy `display: ripple` читается как
`icon_ripple`, а при следующем сохранении конфигурации переписывается в
`icon_ripple`. Backend временно продолжает принимать старое значение.
## Модель визуального состояния
Для маркера вычисляется один результат:
```ts
availability: 'available' | 'unavailable'
status: 'neutral' | 'open' | 'working' | 'alarm'
activity: 'none' | 'event' | 'presence' | 'transition' | 'running'
```
- `availability` отвечает за приглушение недоступного устройства;
- `status` управляет подложкой и морфингом;
- `activity` управляет кольцом или волнами;
- тревога имеет высший приоритет;
- недоступность подавляет обычную активность.
## Визуальный язык
| Смысл | Подложка | Эффект в «Значок + активность» |
|---|---|---|
| Доступно, простаивает | нейтральная | нет |
| Выполняет основную функцию | жёлтая | медленное дыхание |
| Открыто / разблокировано | оранжевая | только событие/переход, не постоянный эффект |
| Разовое срабатывание | текущая статусная | три расходящиеся волны, 3,3 с |
| Присутствие | нейтральная | спокойное статичное кольцо |
| Механическое движение | текущая статусная | дыхание до конца движения |
| Недоступно | приглушённая | нет |
| Тревога (протечка, дым, газ, CO, safety/tamper/problem, сирена, сработавшая охрана) | красная | быстрая красная пульсация при любом display |
`prefers-reduced-motion` заменяет анимацию статичным кольцом.
## Значение жёлтой подложки
Жёлтый означает только **фактическое выполнение основной функции**:
- `light/switch/fan/humidifier = on`;
- climate: `hvac_action = heating/cooling/drying/fan`, но не просто выбранный
режим `heat/cool/auto`;
- media player: `playing`;
- vacuum: `cleaning` (и работающий механизм при `returning`);
- бытовая техника: `running/washing/rinsing/spinning/drying/heating/cooking`;
- маркер с `controls`: работает хотя бы одна управляемая цель.
Не являются работой: открытая дверь, разблокированный замок, открытые шторы,
presence/motion, `enabled`, `home`, `unknown`, `unavailable`, climate `idle`.
В glow-режиме у реального источника света жёлтая подложка может быть скрыта:
его устойчивым индикатором служит световое пятно. Эффект активности остаётся.
## Семантика активности
### Event, 3,3 секунды
- motion: только засвидетельствованный `off -> on`, не весь cooldown;
- vibration/shock/sound;
- открытие двери/окна (`off -> on`), но не всё время открытого состояния;
- button/event;
- успешный запуск script/scene/automation без устойчивого состояния.
Первое состояние после загрузки и восстановление из `unknown/unavailable` не
создают ложного события. Повторный детект перезапускает окно и анимацию.
### Presence
`occupancy/presence = on` даёт статичное кольцо до исчезновения присутствия.
### Transition
- cover: `opening/closing`;
- lock: `locking/unlocking`;
- valve: `opening/closing`;
- vacuum: `returning`.
Если промежуточного состояния нет, прямой конечный переход
`closed <-> open` / `locked <-> unlocked` показывает transition 3,3 секунды.
### Running
Пока устройство фактически работает, его подложка жёлтая, а в режиме
«Значок + активность» вокруг неё медленно дышит кольцо.
## Источник состояния
Приоритет:
1. Явный специализированный источник (в будущей расширенной настройке).
2. Cover, когда tap action явно задан как «Открыть/закрыть».
3. `controls` — агрегированное состояние всех управляемых целей.
4. Функциональная сущность устройства / включённый light.
5. Primary entity.
Критические сущности устройства проверяются независимо и не могут быть
скрыты менее важным primary state.
## Приоритет вывода
1. alarm;
2. unavailable;
3. event;
4. transition;
5. presence;
6. running;
7. open;
8. neutral.
## Инварианты
- Одна функция вычисляет подложку и activity; они не смотрят на разные
сущности.
- Обычная активность появляется только в `icon_ripple`.
- Alarm отображается при любом display и не зависит от пользовательского цвета.
- Hidden/ghost не получает live-чисел, морфинга, подложки или активности.
- Static `houseplan-space-card` остаётся статической и не рисует live effects.
- Пользовательские цвет и размер применяются к обычной активности, но не к
красной тревоге.
## Реализация
- Чистая классификация сущностей, агрегация и распознавание переходов:
`src/device-visual.ts`.
- Выбор эффективных источников маркера, runtime-окно 3,3 с, миграция legacy
display и рендер: `src/houseplan-card.ts`.
- Общий слой колец и reduced-motion варианты: `src/styles.ts`.
- UI оставляет три режима; backend сохраняет `ripple` только как входное
legacy-значение до завершения миграционного периода.
- Unit-спецификация классификатора подготовлена в
`test/device-visual.test.mjs`; браузерные smokes переведены на новые классы.
@@ -0,0 +1,465 @@
# Свободные цепочки стен и разная толщина при рисовании — ТЗ
Статус: **согласовано и отложено** (2026-08-05).
> Реализацию не начинать до отдельного прямого запроса владельца проекта.
> Этот документ фиксирует требования и принятые продуктовые решения для
> будущей реализации.
## 1. Цель
Добавить возможность:
1. Рисовать одну цепочку стен, задавая отдельную толщину каждому следующему
сегменту.
2. Оставлять на плане незамкнутые стены, не принадлежащие комнате или контуру.
3. Переключаться на другие инструменты без удаления уже нарисованной
незамкнутой цепочки.
4. После возвращения в инструмент или перезагрузки продолжать сохранённую
цепочку с одного из её концов.
5. Замыкать цепочку с выбором результата:
- создать комнату;
- оставить замкнутую цепочку самостоятельными стенами.
## 2. Текущее состояние и архитектурная причина изменения
Сейчас комната является единственным первичным источником геометрии стен:
- контур хранится в `rooms[].poly`;
- стены вычисляются из рёбер комнат;
- `walls[]` хранит толщину рёбер, но не самостоятельную геометрию;
- незаконченный `_path` является временным состоянием редактора;
- смена инструмента очищает `_path`;
- сервер удаляет старое legacy-поле `segments`;
- статическая карточка, проёмы, привязки и расчёт видимой области получают
геометрию стен преимущественно из комнат.
Поэтому функция должна вводить новую сохраняемую сущность. Простое сохранение
текущего `_path` не решает рендер, восстановление после перезагрузки,
посегментную толщину и интеграцию с остальными инструментами.
## 3. Термины
- **Свободная цепочка стен** — упорядоченная открытая или замкнутая ломаная,
которая не принадлежит комнате.
- **Сегмент** — отрезок между двумя соседними вершинами; у замкнутой цепочки
последний сегмент соединяет последнюю вершину с первой.
- **Активная цепочка** — цепочка, которую пользователь сейчас продолжает.
- **Линейная стена** — существующий вариант стены без объёмного тела.
- **Толстая стена** — стена с положительной толщиной в сантиметрах.
- **Виртуальный участок** — существующий `open_span`. Отсутствие толщины само
по себе не превращает стену в виртуальную.
## 4. Модель данных
В конфигурацию пространства добавить необязательное поле `wall_paths`:
```ts
interface WallPathCfg {
id: string;
points: Array<[number, number]>;
closed?: boolean;
segments: Array<{
cm?: number;
}>;
}
```
Пример открытой цепочки:
```json
{
"id": "wp_01",
"points": [[0.10, 0.20], [0.40, 0.20], [0.40, 0.55]],
"segments": [{ "cm": 15 }, { "cm": 25 }]
}
```
Пример замкнутой самостоятельной цепочки:
```json
{
"id": "wp_02",
"closed": true,
"points": [[0.10, 0.20], [0.40, 0.20], [0.40, 0.55]],
"segments": [{ "cm": 15 }, { "cm": 20 }, { "cm": 25 }]
}
```
Инварианты:
- открытая цепочка: `points.length >= 2` и
`segments.length === points.length - 1`;
- замкнутая цепочка: минимум три пригодные вершины и
`segments.length === points.length`;
- у замкнутой цепочки последний сегмент идёт от последней вершины к первой;
- первая точка не дублируется в конце массива;
- `cm` отсутствует для линейной стены без объёмного тела;
- отсутствие `cm` не означает `open_span`;
- соседние точки не совпадают;
- `id` уникален в пределах пространства;
- координаты нормализованы по тем же правилам, что и `rooms[].poly`;
- старое корневое поле `segments` не возвращается и продолжает считаться
legacy.
Толщина хранится внутри `wall_paths`, потому что существующий `walls[]`
привязан к геометрии комнат и удаляет осиротевшие ключи при нормализации.
## 5. Рисование и посегментная толщина
### 5.1 Начало цепочки
1. Пользователь выбирает инструмент «Стены».
2. Первый клик создаёт временную начальную точку.
3. Второй клик создаёт первый сегмент и постоянную запись в `wall_paths`.
4. Одиночная точка без сегмента не сохраняется и исчезает при смене
инструмента или перезагрузке.
### 5.2 Значение поля толщины
Поле толщины означает **толщину следующего сегмента**:
- значение фиксируется при клике второй точки сегмента;
- изменение поля не меняет уже созданные сегменты;
- live-preview использует текущее значение;
- пустое значение создаёт линейную стену;
- конфиг всегда хранит сантиметры независимо от выбранной системы единиц;
- допустимая толщина — 1–100 см;
- нечисловое значение и значение вне диапазона показывают ошибку и не дают
создать сегмент.
Пример: пользователь может последовательно создать сегменты 15 см, 25 см,
линейный сегмент и снова 15 см в пределах одной цепочки.
## 6. Сохранение при смене инструмента
Как только создан первый сегмент:
- цепочка становится частью конфигурации пространства;
- каждый следующий законченный сегмент сразу добавляется в локальную
конфигурацию;
- запись на сервер может оставаться debounce-записью;
- перед сменой этажа, режима, пространства или размонтированием карточки
ожидающая запись принудительно завершается;
- переключение на другой инструмент прекращает активное рисование, но не
удаляет цепочку;
- цепочка остаётся видимой во всех предусмотренных режимах.
Активное состояние редактора не требуется хранить на сервере.
## 7. Продолжение цепочки
Принятое поведение:
- в той же сессии после возвращения в инструмент «Стены» автоматически
продолжается последняя активная цепочка;
- после перезагрузки пользователь выбирает конечную точку кликом;
- конечные точки доступных цепочек подсвечиваются при наведении;
- клик по последней точке продолжает цепочку в текущем направлении;
- клик по первой точке разворачивает порядок точек и сегментных параметров,
после чего продолжает цепочку с этого конца;
- при продолжении поле толщины принимает значение крайнего сегмента выбранного
конца, после чего пользователь может его изменить;
- из середины сегмента и из внутренней вершины нельзя создавать ответвление.
### 7.1 Соединение двух цепочек
Разрешается соединение **конец с концом**:
- при необходимости одна или обе цепочки разворачиваются;
- точки и параметры сегментов объединяются без потери соответствия;
- одна исходная запись сохраняет `id`, вторая удаляется;
- нулевой соединительный сегмент не создаётся;
- соединение через середину сегмента не поддерживается;
- если соединение приводит к замыканию, открывается диалог завершения,
описанный ниже.
## 8. Замыкание цепочки
При клике по начальной точке активной цепочки с минимум тремя пригодными
вершинами:
1. Проверяется замыкающий сегмент.
2. Проверяются нулевая длина, самопересечения и недопустимые наложения.
3. Открывается существующий диалог создания комнаты.
4. Внизу диалога добавляется второе действие
**«Оставить замкнутыми стенами»**.
До выбора результата исходная открытая цепочка остаётся в конфигурации, а
замыкающий сегмент хранится как ожидающее локальное изменение. Это исключает
потерю стен при отмене диалога или ошибке сохранения.
### 8.1 Основное действие: создать комнату
При обычном сохранении комнаты одной транзакцией:
- создаётся `room.poly`;
- свободная цепочка удаляется из `wall_paths`;
- толщина каждого сегмента переносится в существующую модель `walls[]`;
- замыкающий сегмент получает толщину, выбранную перед замыканием;
- если новый контур разделяет физическую стену с существующей комнатой,
побеждает толщина уже существующей стены.
Одна физическая стена не должна иметь две конкурирующие толщины.
### 8.2 Второе действие: оставить замкнутыми стенами
При выборе «Оставить замкнутыми стенами»:
- комната не создаётся;
- заливка, название и площадь не появляются;
- замыкающий сегмент добавляется в `segments`;
- цепочка сохраняется с `closed: true`;
- активное рисование этой цепочки завершается.
Для продолжения замкнутой цепочки сначала потребуется удалить один из её
сегментов. Перемещение вершин в первой версии не предусмотрено.
### 8.3 Отмена и ошибки
- отмена диалога возвращает пользователя к открытой сохранённой цепочке;
- ошибка записи не удаляет исходный `wall_path`;
- удаление `wall_path` и добавление комнаты выполняются атомарно в одном
изменении локальной конфигурации.
## 9. Рендер
Свободные стены отображаются:
- в редакторе плана — всегда;
- в остальных редакторах — по действующим правилам отображения границ;
- в режиме просмотра — при включённом `show_borders`;
- в основной и статической карточке.
### 9.1 Толстые стены
Для сегментов с толщиной необходимо:
- строить тело относительно центральной линии;
- объединять тела свободных и комнатных стен перед отрисовкой;
- использовать существующие заливку и штриховку стен;
- корректно формировать L- и T-образные стыки;
- объединять сегменты разной толщины без щелей;
- ограничивать miter на острых углах и переходить к bevel;
- завершать действительно свободный конец ровным поперечным срезом;
- прятать окончание линейного сегмента под телом примыкающей толстой стены.
### 9.2 Виртуальные участки
Действующее правило сохраняется:
- в режиме просмотра виртуальный пунктир рисуется под телом стены;
- в редакторах пунктир рисуется поверх тела полностью от центральной линии;
- свободная линейная стена не является виртуальным участком.
### 9.3 Границы содержимого
`wall_paths` участвуют в расчёте видимой области с учётом половины толщины.
План, состоящий только из свободных стен, должен корректно центрироваться и
масштабироваться.
## 10. Влияние на комнаты, площадь и свет
До преобразования в комнату свободная цепочка:
- не создаёт заливку пола;
- не создаёт площадь и подпись м²;
- не создаёт название комнаты;
- не связывается с HA area;
- не изменяет внутренний контур существующей комнаты;
- не вычитает площадь из комнаты;
- не разделяет комнату на световые зоны;
- не участвует в распространении света между комнатами;
- не создаёт солнечные лучи или дверной тоннель.
Свободная стена является отображаемой физической геометрией, но не
топологической границей помещения.
Автоматическое распознавание комнат из произвольного графа пересекающихся стен
не входит в задачу. Комната создаётся только явным замыканием цепочки и выбором
основного действия в диалоге.
## 11. Работа инструментов
### 11.1 Толщина стены
Инструмент позволяет выбрать сегмент свободной цепочки и изменить только его
толщину. Действие «Применить ко всей цепочке» можно добавить позднее как
необязательное улучшение.
### 11.2 Удаление
- единственный сегмент: удалить всю цепочку;
- крайний сегмент: укоротить цепочку;
- средний сегмент: разбить цепочку на две независимые цепочки;
- одна часть сохраняет старый `id`, вторая получает новый;
- часть без сегментов не сохраняется;
- удаление сегмента замкнутой цепочки превращает её в открытую цепочку с
`closed: false`.
### 11.3 Resize и перемещение вершин
Не входят в первую версию. Resize продолжает работать только с комнатами.
Перемещение вершин свободных цепочек вынесено в следующий этап.
### 11.4 Merge и Split
Не считают свободные цепочки комнатами и не изменяют их автоматически.
### 11.5 Выравнивание
Глобальное выравнивание плана по сетке должно включать точки `wall_paths`, не
нарушая соответствие сегментов и их толщин.
### 11.6 Привязка мебели
Свободные физические стены рекомендуется включить в привязку мебели, поскольку
для пользователя они являются обычными стенами.
### 11.7 Двери и окна
Двери и окна устанавливаются **только в стены комнат**.
- свободные открытые и замкнутые цепочки не являются целью инструмента
проёмов;
- при наведении на них инструмент не показывает доступную привязку;
- проёмы становятся доступны только после преобразования цепочки в комнату.
## 12. Undo, Escape и Reset
Рекомендуемая безопасная модель:
- `Ctrl+Z` или `Escape` удаляет последний сегмент, добавленный в текущей
сессии редактирования;
- при продолжении старой цепочки Undo не удаляет части, существовавшие до её
выбора;
- когда добавленных в сессии сегментов больше нет, следующее действие снимает
активность с цепочки;
- для новой цепочки отмена первого сегмента удаляет `wall_path`, оставляя
временную стартовую точку;
- следующая отмена удаляет стартовую точку.
Reset следует трактовать как «Отменить текущие изменения цепочки»:
- новая цепочка возвращается к состоянию до текущего рисования;
- у продолженной цепочки удаляются только добавленные в этой сессии сегменты;
- ранее сохранённая часть не удаляется;
- полное удаление выполняется инструментом удаления.
## 13. Сохранение и конкурентные изменения
- каждый завершённый сегмент сразу попадает в локальную конфигурацию;
- запись на сервер выполняется существующей сериализованной очередью;
- ожидающий debounce принудительно завершается при уходе из контекста;
- `activeWallPathId` является только состоянием редактора;
- после перезагрузки продолжение начинается выбором конечной точки;
- преобразование в комнату не должно сначала удалить цепочку и только потом
пытаться сохранить комнату;
- конфликты ревизий обрабатываются действующим механизмом конфигурации.
## 14. Серверная валидация и ограничения
Предлагаемые пределы:
- не более 500 цепочек на пространство;
- не более 500 точек в одной цепочке;
- не более 2000 свободных сегментов суммарно;
- только конечные числовые координаты в допустимых границах холста;
- толщина 1–100 см;
- уникальные идентификаторы;
- отсутствие совпадающих соседних точек;
- точное соответствие количества сегментов типу цепочки;
- минимум три пригодные вершины у замкнутой цепочки;
- лишние или повреждённые поля отклоняются либо нормализуются единообразно.
Существующие конфигурации миграции не требуют: `wall_paths` является
необязательным полем.
## 15. Геометрические и UX edge cases
Обязательно учесть:
- смену инструмента после единственной точки;
- двойной клик в одной координате;
- нулевой и очень короткий сегмент;
- возврат в предыдущую точку;
- попытку замкнуть цепочку с недостаточным количеством вершин;
- самопересечение замыкающего или обычного сегмента;
- прохождение свободной стены через существующую комнату;
- полное или частичное совпадение с комнатной стеной;
- полное или частичное совпадение двух свободных цепочек;
- пересечение двух цепочек без общей вершины;
- соединение двух цепочек с необходимостью развернуть обе;
- попытку соединиться с серединой сегмента;
- T-образный стык стен разной толщины;
- переход толстая → линейная → толстая;
- острый угол и ограничение miter;
- удаление среднего сегмента открытой и замкнутой цепочки;
- отмену диалога комнаты;
- ошибку сервера при завершении комнаты;
- изменение единиц измерения во время рисования;
- координаты вне сетки;
- достижение лимита точек или общего размера конфигурации;
- план без комнат, содержащий только свободные стены;
- скрытые границы в режиме просмотра;
- конкурентное редактирование в другой вкладке.
## 16. Критерии готовности первой версии
1. Незамкнутая цепочка с хотя бы одним сегментом не исчезает при смене
инструмента.
2. Она сохраняется после перезагрузки карточки и Home Assistant.
3. В той же сессии рисование продолжается автоматически.
4. После перезагрузки цепочку можно продолжить с любого конца.
5. Две цепочки можно соединить конец с концом.
6. Ответвление из середины сегмента невозможно.
7. Толщина задаётся отдельно каждому следующему сегменту.
8. Сегменты разной толщины образуют корректное тело без щелей.
9. При замыкании доступны «Создать комнату» и
«Оставить замкнутыми стенами».
10. Создание комнаты переносит толщины и атомарно удаляет свободную цепочку.
11. При общей стене сохраняется ранее существовавшая толщина.
12. Отмена диалога не удаляет свободную цепочку.
13. Замкнутая самостоятельная цепочка сохраняется без комнаты, заливки и
площади.
14. Свободные стены видны в основной и статической карточке.
15. Они учитываются при масштабировании и центрировании.
16. Удаление среднего сегмента корректно разделяет цепочку.
17. Двери и окна нельзя устанавливать в свободные стены.
18. Старые конфигурации продолжают работать без миграции.
## 17. Не входит в первую версию
- перемещение вершин свободных стен;
- Resize свободных цепочек;
- ответвление из середины сегмента;
- автоматическое распознавание комнат из сети стен;
- установка дверей и окон в свободные стены;
- участие свободных стен в комнатной топологии, площади и распространении
света;
- отдельные материалы, стили и свойства сторон свободной стены.
## 18. Предлагаемый порядок будущей реализации
1. Схема `wall_paths`, серверная валидация и чистые операции над цепочками.
2. Посегментная толщина в инструменте «Стены».
3. Сохранение, автоматическое продолжение и выбор конца после перезагрузки.
4. Соединение цепочек конец с концом.
5. Диалог замыкания и два варианта завершения.
6. Геометрия тел и стыков стен разной толщины.
7. Основная и статическая карточки, расчёт видимой области.
8. Инструменты толщины, удаления, привязки и выравнивания.
9. Документация и тестовый чек-лист.
10. Прогон тестов только перед пре-релизом согласно принятому процессу.
## 19. Зафиксированные ответы владельца
| Вопрос | Решение |
|---|---|
| Что делать при замыкании? | Открывать диалог комнаты; внизу сразу добавить второе действие «Оставить замкнутыми стенами». |
| Как продолжать цепочку? | В той же сессии продолжать автоматически; после перезагрузки выбирать конец кликом. |
| Как соединять цепочки? | Соединять конец с концом; ответвление из середины сегмента не поддерживать. |
| Разрешать двери и окна? | Нет, проёмы устанавливаются только в стены комнат. |
| Нужны ли перемещение вершин и Resize? | Вынести в следующий этап; в первой версии оставить продолжение, изменение толщины и удаление сегментов. |
| Какая толщина побеждает у общей стены? | Уже существующая физическая стена определяет толщину. |
| Сохранять ли одиночную точку? | Нет, точка без сегмента остаётся временной. |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.59.0-beta.9",
"version": "1.59.0-beta.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.59.0-beta.9",
"version": "1.59.0-beta.10",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.59.0-beta.9",
"version": "1.59.0-beta.10",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+205
View File
@@ -0,0 +1,205 @@
/**
* One semantic visual-state vocabulary for device markers.
*
* The renderer must never decide the yellow plate, the orange plate and the
* activity ring from unrelated entities. This module classifies individual HA
* entities and combines them; transition timing stays in the card because it
* needs the previous hass snapshot and repaint timers.
*/
import { isAlarmState } from './logic';
export type DeviceAvailability = 'available' | 'unavailable';
export type DeviceStatus = 'neutral' | 'open' | 'working' | 'alarm';
export type DeviceActivity = 'none' | 'event' | 'presence' | 'transition' | 'running';
export type ActivityEdge = 'none' | 'rising' | 'change' | 'terminal_transition';
export interface EntityVisualSample {
eid: string;
state: string;
availability: DeviceAvailability;
status: DeviceStatus;
activity: DeviceActivity;
edge: ActivityEdge;
}
export interface DeviceVisualState {
availability: DeviceAvailability;
status: DeviceStatus;
activity: DeviceActivity;
}
const ALARM_STATUS: DeviceVisualState = {
availability: 'available', status: 'alarm', activity: 'none',
};
const EVENT_BINARY_CLASSES = new Set(['motion', 'vibration', 'sound']);
const PRESENCE_BINARY_CLASSES = new Set(['occupancy', 'presence']);
const CONTACT_BINARY_CLASSES = new Set(['door', 'window', 'garage_door', 'opening']);
const RUNNING_BINARY_CLASSES = new Set(['running', 'power']);
/** States that describe real work, not a selected mode or mere availability. */
const WORKING_STATES = new Set([
'running', 'working', 'washing', 'rinsing', 'spinning', 'drying', 'heating',
'cooling', 'cleaning', 'cooking', 'playing', 'recording', 'pumping',
'irrigating', 'humidifying', 'dehumidifying', 'fan',
]);
const IDLE_STATES = new Set([
'off', 'idle', 'paused', 'standby', 'docked', 'finished', 'complete',
'completed', 'stopped', 'ready', 'sleeping',
]);
const unavailable = (state: string): boolean =>
state === '' || state === 'unknown' || state === 'unavailable' || state === '__missing__';
const lower = (v: unknown): string => String(v ?? '').trim().toLowerCase();
/** Best actual-work attribute exposed by climate/appliance integrations. */
function workAction(attrs: any): string {
for (const key of ['hvac_action', 'action', 'current_operation', 'run_state', 'job_state', 'operation', 'activity']) {
const v = lower(attrs?.[key]);
if (v) return v;
}
return '';
}
/** Classify one entity without looking at previous state. */
export function entityVisualSample(hass: any, eid: string): EntityVisualSample {
const st = hass?.states?.[eid];
const state = st ? lower(st.state) : '__missing__';
const domain = String(eid || '').split('.')[0];
const dc = lower(st?.attributes?.device_class);
const base: EntityVisualSample = {
eid, state,
availability: unavailable(state) ? 'unavailable' : 'available',
status: 'neutral', activity: 'none', edge: 'none',
};
if (base.availability === 'unavailable') return base;
if (isAlarmState(domain, dc, state)) {
return { ...base, status: 'alarm' };
}
if (domain === 'binary_sensor') {
if (EVENT_BINARY_CLASSES.has(dc)) return { ...base, edge: 'rising' };
if (PRESENCE_BINARY_CLASSES.has(dc))
return { ...base, activity: state === 'on' ? 'presence' : 'none' };
if (CONTACT_BINARY_CLASSES.has(dc))
return { ...base, status: state === 'on' ? 'open' : 'neutral', edge: 'rising' };
if (dc === 'moving')
return { ...base, activity: state === 'on' ? 'transition' : 'none' };
if (RUNNING_BINARY_CLASSES.has(dc))
return state === 'on'
? { ...base, status: 'working', activity: 'running' }
: base;
return base;
}
if (domain === 'cover') {
return {
...base,
// Covers deliberately stay neutral: open/closed is the icon morph's job.
activity: state === 'opening' || state === 'closing' ? 'transition' : 'none',
edge: 'terminal_transition',
};
}
if (domain === 'lock') {
return {
...base,
status: state === 'unlocked' || state === 'open' ? 'open' : 'neutral',
activity: state === 'locking' || state === 'unlocking' ? 'transition' : 'none',
edge: 'terminal_transition',
};
}
if (domain === 'valve') {
return {
...base,
status: ['open', 'opening', 'closing'].includes(state) ? 'open' : 'neutral',
activity: state === 'opening' || state === 'closing' ? 'transition' : 'none',
edge: 'terminal_transition',
};
}
if (domain === 'climate') {
const action = workAction(st.attributes);
return WORKING_STATES.has(action)
? { ...base, status: 'working', activity: 'running' }
: base;
}
if (['light', 'switch', 'fan', 'humidifier'].includes(domain)) {
return state === 'on'
? { ...base, status: 'working', activity: 'running' }
: base;
}
if (domain === 'media_player') {
return state === 'playing'
? { ...base, status: 'working', activity: 'running' }
: base;
}
if (domain === 'vacuum') {
if (state === 'cleaning') return { ...base, status: 'working', activity: 'running' };
if (state === 'returning') return { ...base, status: 'working', activity: 'transition' };
return base;
}
// A script stays `on` while it is executing. An automation's `on` means
// enabled, not running, so it must remain neutral (a witnessed manual run is
// surfaced as a short event by the card's service-call path instead).
if (domain === 'script') {
return state === 'on'
? { ...base, status: 'working', activity: 'running' }
: base;
}
if (domain === 'automation') return base;
if (domain === 'button' || domain === 'event') return { ...base, edge: 'change' };
const action = workAction(st.attributes);
if (WORKING_STATES.has(action) || (WORKING_STATES.has(state) && !IDLE_STATES.has(state))) {
return { ...base, status: 'working', activity: 'running' };
}
return base;
}
/** Combine the entities that jointly describe one marker. */
export function combineVisualSamples(samples: EntityVisualSample[]): DeviceVisualState {
if (!samples.length) return { availability: 'available', status: 'neutral', activity: 'none' };
const available = samples.filter((s) => s.availability === 'available');
if (!available.length) return { availability: 'unavailable', status: 'neutral', activity: 'none' };
if (available.some((s) => s.status === 'alarm')) return ALARM_STATUS;
const status: DeviceStatus = available.some((s) => s.status === 'working')
? 'working'
: available.some((s) => s.status === 'open') ? 'open' : 'neutral';
const activity: DeviceActivity = available.some((s) => s.activity === 'transition')
? 'transition'
: available.some((s) => s.activity === 'presence')
? 'presence'
: available.some((s) => s.activity === 'running') ? 'running' : 'none';
return { availability: 'available', status, activity };
}
const validEdgeState = (s: string | undefined): boolean =>
!!s && !unavailable(s);
/** Translate a witnessed state edge into the short-lived activity it starts. */
export function edgeActivity(
previous: string | undefined,
current: EntityVisualSample,
): 'event' | 'transition' | null {
if (!validEdgeState(previous) || current.availability === 'unavailable' || previous === current.state) return null;
if (current.edge === 'rising') return previous === 'off' && current.state === 'on' ? 'event' : null;
if (current.edge === 'change') return 'event';
if (current.edge === 'terminal_transition') {
const pair = new Set([previous, current.state]);
if ((pair.has('closed') && pair.has('open')) || (pair.has('locked') && pair.has('unlocked')))
return 'transition';
}
return null;
}
+321 -238
View File
@@ -18,14 +18,14 @@ import {
snapToWall, snapPointAlongPoly, openingAmount, openingShoulders, interiorPoint,
poleOfInaccessibility, subst,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
stateIcon, lightColorOf, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
coverService, coverMoving, coverEntityOf, COVER_GUARDED_CLASSES,
liveText, hassValue, valueWithUnit, decorTextScale, decorTextLines,
coverService, coverEntityOf, COVER_GUARDED_CLASSES,
liveText, liveTextToken, hassValue, valueWithUnit, decorTextScale, decorTextLines,
DECOR_TEXT_BASE, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX,
} from './logic';
import {
@@ -84,8 +84,12 @@ import {
} from './space-geometry';
import { alignAllToGrid, type AlignReport } from './align-grid';
import { langOf, t, type I18nKey } from './i18n';
import {
combineVisualSamples, edgeActivity, entityVisualSample,
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
const CARD_VERSION = '1.59.0-beta.9';
const CARD_VERSION = '1.59.0-beta.10';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -240,10 +244,9 @@ const LS_ZOOM = 'houseplan_card_zoom_v1';
const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were)
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
/** motion flash window: 3 beats of the hp-sense ring (3 × 1.1s in styles.ts).
Owner's rule (2026-08-01): «движение = разовая вспышка в момент
обнаружения; cool-down не пульсирует». */
const SENSE_FLASH_MS = 3300;
/** Short semantic-event / direct-terminal-transition window. Event uses
three sequential 1.1 s waves; motion cool-down itself never animates. */
const ACTIVITY_WINDOW_MS = 3300;
/** Smallest rectangle holding both (docs/CANVAS.md §4). */
const unionRect = (a: Rect, b: Rect): Rect => {
@@ -369,12 +372,14 @@ class HouseplanCard extends LitElement {
private _decorDraft: { kind: 'line' | 'rect' | 'ellipse'; a: number[]; b: number[]; pid: number } | null = null;
private _decorMove: { id: string; start: number[]; orig: any; pid: number; moved: boolean } | null = null;
private _decorSel: string | null = null;
/** The text dialog. `size` is gone from the UI (the block is scaled by its
* corners now); `entity`/`attr`/`unit` are the live link (docs/LIVE-TEXT.md). */
/** The text dialog. Live references are part of `text`; `pickerEntity` is
* only transient UI state and is never persisted (docs/LIVE-TEXT.md). */
private _decorTextDialog: {
id?: string; x: number; y: number; text: string; color: string;
entity?: string; attr?: string; unit?: string;
pickerEntity?: string;
} | null = null;
/** Last textarea selection survives moving focus to the HA pickers. */
private _decorTextSelection: { start: number; end: number } = { start: 0, end: 0 };
/**
* The furniture palette (docs/FURNITURE.md §3): which symbol is armed and at
* what REAL size it will be placed. `w`/`h` are centimetres — the fields show
@@ -661,7 +666,7 @@ class HouseplanCard extends LitElement {
bindingFilter: string;
icon: string; // '' = auto
autoIcon: string; // the icon the rules would give — picker placeholder
display: 'badge' | 'ripple' | 'icon_ripple' | 'value';
display: 'badge' | 'icon_ripple' | 'value';
rippleColor: string; // '' = accent
rippleSize: number; // in icon diameters
size: number; // icon size multiplier
@@ -740,13 +745,20 @@ class HouseplanCard extends LitElement {
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 };
private _kioskDialog = false;
/** motion-sensor runtime per marker: the last seen primary state and the
ts of the last off→on trip. Owner's rule (2026-08-01): «движение =
разовая вспышка в момент обнаружения; cool-down не пульсирует» —
the flash is keyed to the TRANSITION, not to the 'on' state. `timer`
is the one setTimeout per entry that repaints the card when the flash
window closes (cleared in disconnectedCallback). */
private _senseRt = new Map<string, { last: string; flashTs: number; timer: number; gen: number }>();
/**
* Previous entity states + the short event/terminal-transition window for
* every marker. The map lives outside Lit state: hass ticks update it, one
* timer repaints when the 3.3 s window closes, and the generation bit forces
* a CSS event animation to restart on a rapid retrigger.
*/
private _activityRt = new Map<string, {
sources: string;
last: Record<string, string>;
flashTs: number;
flashKind: 'event' | 'transition' | null;
timer: number;
gen: number;
}>();
/** live-vacuum runtime per marker: RAW robot coords (matrix applied at render) */
private _vacRt = new Map<string, { trail: VacPt[]; lastKey: string; lastTs: number;
moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | null }>();
@@ -907,7 +919,7 @@ class HouseplanCard extends LitElement {
document.removeEventListener('visibilitychange', this._vacVisHandler);
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
if (this._skySnapRaf) { cancelAnimationFrame(this._skySnapRaf); this._skySnapRaf = 0; }
for (const rt of this._senseRt.values()) clearTimeout(rt.timer); // pending flash-window repaints
for (const rt of this._activityRt.values()) clearTimeout(rt.timer); // pending activity-window repaints
window.removeEventListener('keydown', this._keyHandler);
clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer);
@@ -1449,7 +1461,12 @@ class HouseplanCard extends LitElement {
case 'settings': this._settingsDialog = { ...d.data, busy: false }; break;
case 'rules': this._rulesDialog = { ...d.data, busy: false }; break;
case 'opening': this._openingDialog = { ...d.data }; break;
case 'decorText': this._decorTextDialog = { ...d.data }; break;
case 'decorText': {
this._decorTextDialog = { ...d.data };
const end = String(this._decorTextDialog?.text ?? '').length;
this._decorTextSelection = { start: end, end };
break;
}
case 'room': {
const r = d.data;
this._roomEditId = r.editId; this._roomFill = r.fill; this._roomTempSrc = r.tempSrc;
@@ -1662,7 +1679,7 @@ class HouseplanCard extends LitElement {
}
protected willUpdate(changed: PropertyValues): void {
if (changed?.has?.('hass')) { this._vacTick(); this._senseTick(); }
if (changed?.has?.('hass')) { this._vacTick(); this._activityTick(); }
this._skyPlan();
if (changed.has('hass') && this.hass) {
this._hookConnection();
@@ -2230,107 +2247,70 @@ class HouseplanCard extends LitElement {
return this._coverIndicator(d) || d.primary;
}
private _stateClass(d: DevItem): string {
if (!this._config?.live_states) return '';
// FIRST of all: is this marker a CURTAIN? Choosing «Открыть/закрыть» in
// the dialog is the strongest thing its owner can say about what the
// marker IS (see _coverIndicator), so it outranks every other source of
// indication — and the owner's contract for that answer is absolute: «у
// штор не должно быть жёлтой подложки НИКОГДА». Deciding this after
// `controls` / a lit light (as it was until 2026-08-04) meant a curtain on
// a mixed device — a lamp that also ships a blind, a marker with a bound
// wall switch — went yellow anyway and lost its breathing ring while it
// travelled, so the tap, the icon and the plate each told a different
// story. The plate class of a cover is only ever '' , 'covermove' or
// 'unavail'; the open/closed state is the icon's job (COVER_ICONS).
const cov = this._coverIndicator(d);
if (cov) {
const cs = this.hass.states[cov];
if (!cs) return '';
if (cs.state === 'unavailable') return 'unavail';
return coverMoving(cs.state) ? 'covermove' : '';
}
// an icon with controlled targets mirrors THEM, not its own entity
// (stateless remotes and virtual wall switches have nothing else to show)
/** Legacy `ripple` is read as the surviving icon+activity presentation. */
private _displayOf(d: DevItem): 'badge' | 'icon_ripple' | 'value' {
const display = d.marker?.display;
return display === 'ripple' ? 'icon_ripple' : display || 'badge';
}
/**
* Entities that jointly describe the marker. Explicit cover intent wins;
* controls are an aggregate; otherwise a lit light and the primary entity
* keep the glow plate, icon and activity on the same story. Critical
* secondary entities are appended so an alarm cannot hide behind a less
* important primary sensor.
*/
private _visualSamples(d: DevItem): EntityVisualSample[] {
const ids: string[] = [];
const cover = this._coverIndicator(d);
const controls = (d.marker?.controls || []).filter(isControllable);
if (controls.length)
return controls.some((e) => this.hass.states[e]?.state === 'on') ? 'on' : '';
// A shining light yields 'on' here by the SAME condition that lights the
// glow pool, so the pool and the state cannot disagree. Note the renderer
// then STRIPS the class wherever the glow layer is actually visible —
// there the spot is the one indicator (v1.52.0, owner's rule); the badge
// stays yellow only where the spot is not drawn (other fills, the plan
// editor).
if (litLightEntity(this.hass, d)) return 'on';
// The explicit cover was answered above; what is left here is the primary
// entity — an «Open/close» marker only ever reaches this line when its
// device carries no `cover.*` at all, and then it has nothing else to
// speak for. (Before 2026-08-04 an Aqara curtain driver reported its
// `switch.*_reverse_direction` from here: no breathing ring while it
// travelled, no morph, and a yellow plate whenever the reverse-direction
// option happened to be on.)
const eid = this._actEntity(d);
const p = eid ? this.hass.states[eid] : undefined;
if (!p) return '';
if (p.state === 'unavailable') return 'unavail';
// derive the domain from the entity id we looked the state up by — state
// objects are not guaranteed to carry entity_id (defensive; found by the
// TESTING.md edge-case run)
const dom = eid!.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : '';
if (dom === 'climate') {
// yellow = actually working right now ("which radiators are heating"),
// not "enabled for the winter": hvac_action when the integration
// reports one, the coarser state only as a fallback
const act = p.attributes?.hvac_action;
if (act != null) return ['heating', 'cooling', 'drying', 'fan'].includes(act) ? 'on' : '';
return ['off', 'unknown'].includes(p.state) ? '' : 'on';
if (cover) ids.push(cover);
else if (controls.length) ids.push(...controls);
else {
const lit = litLightEntity(this.hass, d);
if (lit) ids.push(lit);
if (d.primary && !ids.includes(d.primary)) ids.push(d.primary);
}
// COVERS (owner's contract 2026-08-04): «у штор не должно быть жёлтой
// подложки НИКОГДА, индикация открыто/закрыто за счёт морфинга иконки».
// So a cover returns NO plate class in any state — not the yellow «on»
// one, not the orange 'open' frame it used to wear while open/opening.
// Its whole open/closed story is told by the icon (stateIcon/COVER_ICONS,
// ajar counts as open because HA reports 'open' for it), and its motion by
// the breathing ring alone (2026-08-03, the vacuum puck's language).
if (dom === 'cover') return coverMoving(p.state) ? 'covermove' : '';
// A VALVE is deliberately NOT swept along: no icon pair morphs for it, so
// the frame is the only thing that says «открыт» — taking it away would
// leave the marker mute. Owner's rule names the curtains, and the two
// domains part ways here.
if (dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
if (dom === 'binary_sensor') {
const dc = p.attributes?.device_class;
if (['door', 'window', 'garage_door', 'opening', 'gas', 'smoke', 'moisture', 'problem'].includes(dc))
return p.state === 'on' ? 'open' : '';
// Owner's rule (2026-08-01, вариант «б»): «движение = разовая
// вспышка в момент обнаружения; cool-down не пульсирует; присутствие =
// статичное кольцо пока обитаемо». Neither takes the yellow 'on'
// fill — that stays reserved for «включено».
// MOTION flashes once per off→on trip: 'senseflash' lives only for
// the ~3.3s window after the transition (stamped by _senseTick on
// the hass tick) — a sensor still 'on' after that is in its
// cool-down, not seeing motion, so the class goes away even though
// the state has not changed. A new off→on trip re-arms the flash.
if (dc === 'motion') {
const rt = this._senseRt.get(d.id);
if (!(rt && rt.flashTs && Date.now() - rt.flashTs < SENSE_FLASH_MS)) return '';
// HP-1543-02: alternate the animation identity per trip (see
// _senseTick) — odd generations ride the base hp-sense keyframes,
// even ones the identical hp-sense-b twin, so a rapid re-trip
// restarts the flash instead of silently inheriting the old timeline
return rt.gen % 2 === 0 ? 'senseflash sf2' : 'senseflash';
// Safety wins independently of the presentation source.
for (const eid of d.entities || []) {
const sample = entityVisualSample(this.hass, eid);
if (sample.status === 'alarm' && !ids.includes(eid)) ids.push(eid);
}
return ids.map((eid) => entityVisualSample(this.hass, eid));
}
/** One semantic result feeds the plate and every non-critical activity effect. */
private _deviceVisual(d: DevItem): DeviceVisualState {
if (d.hidden) return { availability: 'available', status: 'neutral', activity: 'none' };
const combined = combineVisualSamples(this._visualSamples(d));
// A critical state is never a decorative preference and stays visible
// even when the card-wide live-state dressing is disabled.
if (combined.status === 'alarm') return combined;
if (!this._config?.live_states) return { availability: 'available', status: 'neutral', activity: 'none' };
if (combined.availability === 'unavailable') return combined;
const rt = this._activityRt.get(d.id);
if (rt?.flashTs && rt.flashKind && Date.now() - rt.flashTs < ACTIVITY_WINDOW_MS)
return { ...combined, activity: rt.flashKind };
return combined;
}
/** CSS classes retained behind the old helper name for smoke/debug callers. */
private _stateClass(d: DevItem, visual = this._deviceVisual(d)): string {
if (d.hidden) return '';
const cls: string[] = [];
if (visual.status === 'alarm') cls.push('alarm');
else if (visual.availability === 'unavailable') cls.push('unavail');
else if (visual.status === 'working') cls.push('on');
else if (visual.status === 'open') cls.push('open');
if (this._displayOf(d) === 'icon_ripple' && this._config?.live_states && visual.status !== 'alarm') {
if (visual.activity !== 'none') cls.push('activity-' + visual.activity);
if (visual.activity === 'event') {
const rt = this._activityRt.get(d.id);
if (rt && rt.gen % 2 === 0) cls.push('activity-gen2');
}
// OCCUPANCY/PRESENCE while 'on': a calm STATIC ring ('sensehold',
// no animation in styles.ts) — «комната обитаема» is a state, not
// an event, so it must not blink.
if (['occupancy', 'presence'].includes(dc))
return p.state === 'on' ? 'sensehold' : '';
}
if (dom === 'media_player') return ['playing', 'on'].includes(p.state) ? 'on' : '';
if (dom === 'vacuum') return ['cleaning', 'returning'].includes(p.state) ? 'on' : '';
return '';
return cls.join(' ');
}
private _liveTemp(d: DevItem): number | null {
@@ -2462,7 +2442,11 @@ class HouseplanCard extends LitElement {
guarded(this._t('confirm.tap_run', { name }), () => {
this.hass
.callService(svc.domain, svc.service, { entity_id: target })
.then(() => this._showToast(this._t('toast.run_started', { name })))
.then(() => {
this._stampActivity(d.id, 'event', this._activitySourceKey(this._visualSamples(d)));
this.requestUpdate();
this._showToast(this._t('toast.run_started', { name }));
})
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
});
return;
@@ -3375,6 +3359,11 @@ class HouseplanCard extends LitElement {
* weight: drop it on every save. Configs written before v1.19.0 shed it on first write.
*/
private _dropLegacySegments(): void {
// «Ripple only» was removed from the UI: keep old configs readable, then
// materialise the recognisable icon+activity presentation on any write.
for (const marker of this._serverCfg?.markers || []) {
if (marker.display === 'ripple') marker.display = 'icon_ripple';
}
for (const sp of this._serverCfg?.spaces || []) {
delete (sp as any).segments;
if (Array.isArray(sp.walls)) {
@@ -3980,6 +3969,7 @@ class HouseplanCard extends LitElement {
x: clampCanvasN(p[0] / NORM_W), y: clampCanvasN(p[1] / this._decorH),
text: '', color: this._decorStyle.color,
};
this._decorTextSelection = { start: 0, end: 0 };
return true;
}
if (t === 'furniture') {
@@ -4126,24 +4116,48 @@ class HouseplanCard extends LitElement {
/** Open the editor of an existing label (double click, or the text tool). */
private _decorOpenText(shape: any): void {
let text = String(shape.text ?? '');
const legacyToken = liveTextToken(shape.entity, shape.attr);
if (legacyToken) {
const slot = text.indexOf('{}');
text = slot >= 0
? text.slice(0, slot) + legacyToken + text.slice(slot + 2)
: `${text}${text ? ' ' : ''}${legacyToken}`;
}
this._decorTextDialog = {
id: shape.id, x: shape.x, y: shape.y, text: shape.text, color: shape.color,
entity: shape.entity || '', attr: shape.attr || '', unit: shape.unit || '',
id: shape.id, x: shape.x, y: shape.y, text, color: shape.color,
pickerEntity: shape.entity || '',
};
this._decorTextSelection = { start: text.length, end: text.length };
}
private _decorRememberTextSelection(el: HTMLTextAreaElement): void {
this._decorTextSelection = {
start: el.selectionStart ?? el.value.length,
end: el.selectionEnd ?? el.value.length,
};
}
/** The link fields as they are STORED: only what the user actually filled in
* ends up in the config, so a plain label stays byte-for-byte a plain one. */
private _decorTextLink(d: { entity?: string; attr?: string; unit?: string }): Record<string, string> {
const out: Record<string, string> = {};
const ent = (d.entity || '').trim();
if (!ent) return out;
out.entity = ent;
const attr = (d.attr || '').trim();
if (attr) out.attr = attr;
const unit = (d.unit || '').trim();
if (unit) out.unit = unit;
return out;
/** Insert a complete reference without ever truncating it into broken text. */
private _decorInsertLiveVariable(attr: string | null): void {
const d = this._decorTextDialog;
if (!d) return;
const token = liveTextToken(d.pickerEntity, attr);
if (!token) return;
const old = d.text;
const start = Math.max(0, Math.min(old.length, this._decorTextSelection.start));
const end = Math.max(start, Math.min(old.length, this._decorTextSelection.end));
if (old.length - (end - start) + token.length > 200) return;
const text = old.slice(0, start) + token + old.slice(end);
const caret = start + token.length;
this._decorTextSelection = { start: caret, end: caret };
this._decorTextDialog = { ...d, text };
this.updateComplete.then(() => {
const el = this.renderRoot.querySelector<HTMLTextAreaElement>('textarea.dtarea');
if (!el) return;
el.focus();
el.setSelectionRange(caret, caret);
});
}
private _decorSaveText(): void {
@@ -4152,19 +4166,18 @@ class HouseplanCard extends LitElement {
const text = String(d?.text ?? '').replace(/\r\n?/g, '\n').trim();
if (!d || !text) { this._decorTextDialog = null; return; }
const sp = this._curSpaceCfg;
const link = this._decorTextLink(d);
if (d.id) {
sp.decor = this._decorList.map((x) => {
if (x.id !== d.id) return x;
// the OLD link is dropped wholesale: clearing the entity in the dialog
// must clear the attribute and the unit with it, not leave orphans
// beta.9 and earlier stored one live link beside the text. Saving in
// the new editor migrates it to inline tokens and drops the old fields.
const { entity, attr, unit, ...rest } = x;
return { ...rest, text, color: d.color, ...link };
return { ...rest, text, color: d.color };
});
} else {
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
sp.decor = [...this._decorList, { id, kind: 'text', x: d.x, y: d.y,
text, color: d.color, ...link }];
text, color: d.color }];
this._decorSel = id;
}
this._decorTextDialog = null;
@@ -4961,11 +4974,8 @@ class HouseplanCard extends LitElement {
private _renderDecorTextDialog(): TemplateResult {
const d = this._decorTextDialog!;
const ent = (d.entity || '').trim();
const ent = (d.pickerEntity || '').trim();
const st = ent ? this.hass?.states?.[ent] : null;
const ownUnit = String(st?.attributes?.unit_of_measurement ?? '');
// the preview is the REAL renderer: one substitution, one truth
const preview = liveText(d.text, d, this.hass);
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorTextDialog = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:format-text"></ha-icon>${this._t('decor.text_title')}</div>
@@ -4975,38 +4985,43 @@ class HouseplanCard extends LitElement {
and rendered (centred). Enter is a NEW LINE here, so saving
moved to Ctrl/Cmd+Enter and the button. */}
<textarea class="namein dtarea" rows="3" maxlength="200" .value=${d.text} autofocus
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLTextAreaElement).value })}
@input=${(e: Event) => {
const el = e.target as HTMLTextAreaElement;
this._decorRememberTextSelection(el);
this._decorTextDialog = { ...d, text: el.value };
}}
@click=${(e: Event) => this._decorRememberTextSelection(e.target as HTMLTextAreaElement)}
@keyup=${(e: Event) => this._decorRememberTextSelection(e.target as HTMLTextAreaElement)}
@select=${(e: Event) => this._decorRememberTextSelection(e.target as HTMLTextAreaElement)}
@blur=${(e: Event) => this._decorRememberTextSelection(e.target as HTMLTextAreaElement)}
@keydown=${(e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) this._decorSaveText();
}}></textarea>
<div class="rhint">${this._t(ent ? 'decor.text_hint_live' : 'decor.text_hint')}</div>
<label>${this._t('decor.color')}</label>
<input type="color" .value=${d.color}
@input=${(e: Event) => (this._decorTextDialog = { ...d, color: (e.target as HTMLInputElement).value })} />
<label class="dispsection">${this._t('decor.live_group')}</label>
<div class="rhint">${this._t('decor.live_hint')}</div>
<label>${this._t('decor.live_entity')}</label>
<input class="namein" type="text" list="hp-dtext-ents" placeholder=${this._t('decor.live_entity_ph')}
.value=${d.entity || ''}
@input=${(e: Event) => (this._decorTextDialog = { ...d, entity: (e.target as HTMLInputElement).value })} />
.value=${d.pickerEntity || ''}
@input=${(e: Event) => (this._decorTextDialog = {
...d, pickerEntity: (e.target as HTMLInputElement).value,
})} />
<datalist id="hp-dtext-ents">
${Object.keys(this.hass?.states || {}).map((id) => html`<option value=${id}></option>`)}
</datalist>
${ent ? html`
<label>${this._t('decor.live_attr')}</label>
<input class="namein" type="text" list="hp-dtext-attrs" placeholder=${this._t('decor.live_attr_ph')}
.value=${d.attr || ''}
@input=${(e: Event) => (this._decorTextDialog = { ...d, attr: (e.target as HTMLInputElement).value })} />
<datalist id="hp-dtext-attrs">
${Object.keys(st?.attributes || {}).map((a) => html`<option value=${a}></option>`)}
</datalist>
<label>${this._t('decor.live_unit')}</label>
<input class="namein" type="text" .value=${d.unit || ''}
placeholder=${(d.attr || '').trim() ? '' : ownUnit}
@input=${(e: Event) => (this._decorTextDialog = { ...d, unit: (e.target as HTMLInputElement).value })} />
<label>${this._t('decor.live_preview')}</label>
<div class="dtpreview">${decorTextLines(preview).map((ln) => html`<div>${ln}</div>`)}</div>
<select class="namein" .value=${''}
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
if (value) this._decorInsertLiveVariable(value === '__state__' ? null : value);
}}>
<option value="">${this._t('decor.live_attr_ph')}</option>
<option value="__state__">${this._t('decor.live_state')}</option>
${Object.keys(st?.attributes || {}).map((a) => html`<option value=${a}>${a}</option>`)}
</select>
` : nothing}
</div>
<div class="row">
@@ -5117,10 +5132,14 @@ class HouseplanCard extends LitElement {
private _persistOpenCuts(cuts: number[][]): void {
const sp = this._curSpaceCfg;
if (!sp) return;
const entries = cutsToSpanEntries(cuts, NORM_W);
const eps = this._gridPitch * 0.02;
const entries = clipOpenSpansToShared(
cutsToSpanEntries(cuts, NORM_W), this._spaceModel().rooms, NORM_W, eps,
);
const canonicalCuts = entries.map((e) => entryToSeg(e, NORM_W));
if (entries.length) (sp as any).open_spans = entries;
else delete (sp as any).open_spans;
syncOpenToFromCuts(sp.rooms || [], this._spaceModel().rooms, cuts, this._gridPitch * 0.02);
syncOpenToFromCuts(sp.rooms || [], this._spaceModel().rooms, canonicalCuts, eps);
}
private _closeOpenSpan(sg: number[]): void {
@@ -6060,7 +6079,7 @@ class HouseplanCard extends LitElement {
bindingFilter: '',
icon: d.marker?.icon || '',
autoIcon: d.icon || '',
display: d.marker?.display || 'badge',
display: d.marker?.display === 'ripple' ? 'icon_ripple' : d.marker?.display || 'badge',
rippleColor: d.marker?.ripple_color || '',
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3,
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
@@ -6308,8 +6327,8 @@ class HouseplanCard extends LitElement {
name: dlg.name.trim() || null,
icon: dlg.icon || null,
display: dlg.display !== 'badge' ? dlg.display : null,
ripple_color: dlg.display !== 'badge' && dlg.rippleColor ? dlg.rippleColor : null,
ripple_size: dlg.display !== 'badge' && dlg.rippleSize !== 3 ? dlg.rippleSize : null,
ripple_color: dlg.display === 'icon_ripple' && dlg.rippleColor ? dlg.rippleColor : null,
ripple_size: dlg.display === 'icon_ripple' && dlg.rippleSize !== 3 ? dlg.rippleSize : null,
size: dlg.size !== 1 ? dlg.size : null,
angle: dlg.angle ? dlg.angle : null,
tap_action: dlg.tapAction || null,
@@ -7434,6 +7453,22 @@ class HouseplanCard extends LitElement {
.map((r) => ({ r, poly: roomPoly(r) }))
.filter((x): x is { r: RoomCfg; poly: number[][] } => !!x.poly);
const doors = this._openingsR.filter((o) => o.type === 'door');
const walls = this._spaceWalls;
const openCuts = this._openPairs().flatMap((p) => p.segs);
const doorTunnelDepth = new Map<string, number>();
if (walls.length) {
for (const o of doors) {
const rad = (o.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * o.rlen) / 2;
const dy = (Math.sin(rad) * o.rlen) / 2;
const cm = intervalCmAt(
space.rooms, walls, openCuts,
[o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy],
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
if (cm > 0) doorTunnelDepth.set(o.id, wallCmToUnits(cm, this._cellCm, this._gridPitch));
}
}
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
for (const d of this._devices) {
if (d.space !== space.id) continue;
@@ -7460,8 +7495,6 @@ class HouseplanCard extends LitElement {
const zoneIds = home.r.id ? openZoneOf(home.r.id, space.rooms) : new Set([home.r.id]);
const zone = polys.filter((x) => x.r.id && zoneIds.has(x.r.id));
const zoneList = zone.length ? zone : [home];
const walls = this._spaceWalls;
const openCuts = this._openPairs().flatMap((p) => p.segs);
const shapes: string[] = zoneList.map((z) => {
const poly = (walls.length && z.r.id)
? (innerContourForRoom(
@@ -7483,7 +7516,10 @@ class HouseplanCard extends LitElement {
const dx = (Math.cos(rad) * o.rlen) / 2;
const dy = (Math.sin(rad) * o.rlen) / 2;
if (!hasRoomBehind([o.rx, o.ry], o.angle, [pos.x, pos.y], others, g * 0.6)) continue;
const sector = doorSector([pos.x, pos.y], [o.rx - dx, o.ry - dy], [o.rx + dx, o.ry + dy], R);
const sector = doorSector(
[pos.x, pos.y], [o.rx - dx, o.ry - dy], [o.rx + dx, o.ry + dy],
R, 170, doorTunnelDepth.get(o.id) || 0,
);
if (sector) shapes.push('M ' + sector.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z');
}
// IMPORTANT: separate <path> children — clipPath children always
@@ -8129,12 +8165,16 @@ class HouseplanCard extends LitElement {
an opening MEANS is untouched: light still spills through
it, the sun still comes in at its window, and the contact
sensor still opens it. */}
${''/* View: virtual geometry still meets real walls on their
centreline, but paints BELOW the physical wall body. The
hatch therefore masks the visually awkward half-dashes
inside thick jambs without changing the stored span. */}
${!this._editing ? this._renderOpenWalls(disp) : nothing}
${this._renderWallBodies(disp)}
${''/* Virtual boundaries (including the live two-click preview)
deliberately paint AFTER real wall bodies. At a T-junction
their first dash must remain visible all the way to the
centreline instead of disappearing underneath the hatch. */}
${this._renderOpenWalls(disp)}
${''/* Editors: saved virtual boundaries and the live two-click
preview deliberately paint AFTER real wall bodies. Their
full centreline geometry remains visible for editing. */}
${this._editing ? this._renderOpenWalls(disp) : nothing}
${disp.hideOpenings && !this._markup ? nothing : this._renderOpenings(disp)}
${this._renderWallThickUi()}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
@@ -8288,40 +8328,80 @@ class HouseplanCard extends LitElement {
return !!this._vacEntity(d);
}
/** Start/restart a short semantic event or direct-terminal transition. */
private _activitySourceKey(samples: EntityVisualSample[]): string {
return samples.map((sample) => sample.eid).sort().join('\n');
}
private _stampActivity(id: string, kind: 'event' | 'transition', sources?: string): void {
let rt = this._activityRt.get(id);
if (!rt) {
rt = { sources: sources || '', last: {}, flashTs: 0, flashKind: null, timer: 0, gen: 0 };
this._activityRt.set(id, rt);
}
if (sources != null) rt.sources = sources;
// Event outranks a transition when two sources change in the same hass tick.
if (rt.flashTs && Date.now() - rt.flashTs < ACTIVITY_WINDOW_MS && rt.flashKind === 'event' && kind === 'transition') return;
rt.flashTs = Date.now();
rt.flashKind = kind;
rt.gen++;
clearTimeout(rt.timer);
rt.timer = window.setTimeout(() => this.requestUpdate(), ACTIVITY_WINDOW_MS + 60);
}
/**
* Motion-flash bookkeeping, one pass per hass tick (the _vacTick pattern):
* remembers each motion marker's last primary state and stamps flashTs on
* an off→on transition. Only a WITNESSED 'off' → 'on' step counts — a
* sensor first seen already 'on' (page load mid cool-down) or coming back
* from 'unavailable' is a reconnect, not a fresh detection, and must not
* flash. Each stamp (re)arms ONE setTimeout that calls requestUpdate when
* the ~3.3s window closes, so _stateClass drops 'senseflash' even though
* no new hass tick arrives; disconnectedCallback clears the timers.
* One pass per hass tick records every effective source. Only a witnessed,
* meaningful edge starts a short effect: first load and recovery from
* unknown/unavailable establish a baseline and never fake a detection.
*/
private _senseTick(): void {
private _activityTick(): void {
if (!this.hass) return;
const live = new Set<string>();
for (const d of this._devices) {
if (d.hidden || !d.primary || !d.primary.startsWith('binary_sensor.')) continue;
const p = this.hass.states[d.primary];
if (p?.attributes?.device_class !== 'motion') continue;
const rt = this._senseRt.get(d.id);
if (!rt) { this._senseRt.set(d.id, { last: p.state, flashTs: 0, timer: 0, gen: 0 }); continue; }
if (p.state === 'on' && rt.last === 'off') {
rt.flashTs = Date.now();
// HP-1543-02: a retrip BEFORE the previous flash finished kept the
// same 'senseflash' class and the same animation-name on the same
// pseudo-element — browsers never restart such a CSS animation, so
// the second detection played nothing once the first one-shot had
// ended (base opacity 0). Every trip bumps the generation;
// _stateClass maps its parity to alternating keyframe names
// (hp-sense / hp-sense-b in styles.ts), which is a NEW animation
// identity and forces a fresh timeline per detection. No class is
// ever removed mid-flash, so a lone first flash still plays whole.
rt.gen++;
clearTimeout(rt.timer);
rt.timer = window.setTimeout(() => this.requestUpdate(), SENSE_FLASH_MS + 60);
if (d.hidden) continue;
live.add(d.id);
const samples = this._visualSamples(d);
const sourceKey = this._activitySourceKey(samples);
let rt = this._activityRt.get(d.id);
if (!rt) {
rt = { sources: sourceKey, last: {}, flashTs: 0, flashKind: null, timer: 0, gen: 0 };
for (const sample of samples) rt.last[sample.eid] = sample.state;
this._activityRt.set(d.id, rt);
continue;
}
rt.last = p.state;
// A marker changed what it represents (cover action removed, controls
// rebound, primary replaced): the old entity's short effect must never
// leak onto the new source. Establish a fresh baseline instead.
if (rt.sources !== sourceKey) {
clearTimeout(rt.timer);
rt.sources = sourceKey;
rt.last = {};
rt.flashTs = 0;
rt.flashKind = null;
for (const sample of samples) rt.last[sample.eid] = sample.state;
continue;
}
// A direct closed↔open fallback is only a substitute for integrations
// that omit opening/closing. Once a real travelling state is observed,
// it owns the ring and the old 3.3 s fallback is discarded.
if (rt.flashKind === 'transition' && samples.some((sample) => sample.activity === 'transition')) {
clearTimeout(rt.timer);
rt.flashTs = 0;
rt.flashKind = null;
}
let edge: 'event' | 'transition' | null = null;
for (const sample of samples) {
const found = edgeActivity(rt.last[sample.eid], sample);
if (found === 'event' || (!edge && found)) edge = found;
rt.last[sample.eid] = sample.state;
}
if (edge) this._stampActivity(d.id, edge, sourceKey);
}
// Config/registry churn must not leave timers or stale edge baselines alive.
for (const [id, rt] of this._activityRt) {
if (live.has(id)) continue;
clearTimeout(rt.timer);
this._activityRt.delete(id);
}
}
@@ -8794,21 +8874,24 @@ class HouseplanCard extends LitElement {
// must equal the layer's visibility, or a mode ends up with neither
// indicator (HP-1520-01). "Source" is exactly the litLightEntity
// condition that casts the spot, so a lit socket keeps its yellow.
let cls = d.hidden ? '' : this._stateClass(d);
if (glowFill && cls === 'on' && litLightEntity(this.hass, d)) cls = '';
const visual = this._deviceVisual(d);
let cls = d.hidden ? '' : this._stateClass(d, visual);
if (glowFill && visual.status === 'working' && litLightEntity(this.hass, d))
cls = cls.split(/\s+/).filter((c) => c && c !== 'on').join(' ');
const temp = d.hidden ? null : this._liveTemp(d);
const hum = d.hidden ? null : this._liveHum(d);
const lqi = showLqi && !d.virtual && !d.hidden ? lqiFor(this.hass, d.entities) : null;
const m = d.marker;
const disp = m?.display || 'badge';
// a ghost drops the ripple presentation entirely (HP-1511-02): an
// icon-less pulse is unrecognisable, and the release contract says a
// ghost keeps the base icon and name — display modes are status dressing
const ripple = (disp === 'ripple' || disp === 'icon_ripple') && !d.hidden;
const disp = this._displayOf(d);
// Ordinary activity is an explicit presentation. Alarm is already a dev
// class and remains visible in every presentation; ghosts get neither.
const activity: DeviceActivity = disp === 'icon_ripple' && !d.hidden
&& this._config?.live_states && visual.status !== 'alarm'
? visual.activity : 'none';
// value-only display: the measurement IS the marker
// The state the marker PRESENTS — the primary one, or the device's cover
// when the marker is explicitly «Open/close» (_coverIndicator): the badge,
// the icon morph and the ripple all read the same entity the tap drives.
// the icon morph and the value all read the same entity the tap drives.
const actEid = this._actEntity(d);
const primarySt = actEid ? this.hass.states[actEid] : undefined;
// The °/% plates come first and keep their own compact form — they are a
@@ -8840,26 +8923,22 @@ class HouseplanCard extends LitElement {
const icon = this._config?.live_states && !d.hidden
? stateIcon(d.icon, domain, primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
: d.icon;
// v1.52.0: a lamp's colour lives in its GLOW and in the ripple fallback
// v1.52.0: a lamp's colour lives in its GLOW and in the activity fallback
// only — the icon/border tint is gone. lightC is still computed (from the
// marker's first lit RGB target, else the primary light) purely to feed
// --ripple-color when no explicit ripple colour is set.
// --ripple-color (legacy storage/CSS name) when no explicit effect colour is set.
const ctrl = (m?.controls || []).filter(isControllable);
const lightC = this._config?.live_states && !d.hidden
? ctrl.length
? ctrl.map((e) => lightColorOf(this.hass.states[e])).find((v) => v) || null
: domain === 'light' ? lightColorOf(primarySt) : null
: null;
// emergencies (leak/smoke/gas/CO/siren) pulse red regardless of display mode
const alarm = this._config?.live_states && !d.hidden
&& isAlarmState(domain, primarySt?.attributes?.device_class, primarySt?.state);
const active = ripple && !d.hidden && !!actEid && isActiveState(this.hass.states[actEid]?.state);
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
const angle = Number(m?.angle) || 0;
const rScale = Number(m?.ripple_size) > 0 ? Number(m!.ripple_size) : 3;
const st = [`left:${left}%`, `top:${top}%`];
if (scale !== 1) st.push(`--dev-scale:${scale}`);
if (ripple) {
if (disp === 'icon_ripple') {
st.push(`--ripple-scale:${rScale}`);
if (m?.ripple_color) st.push(`--ripple-color:${m.ripple_color}`);
else if (lightC) st.push(`--ripple-color:${lightC}`);
@@ -8873,7 +8952,7 @@ class HouseplanCard extends LitElement {
data-id="${d.id}"
data-entity=${d.primary || nothing}
data-area=${d.area || nothing}
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${d.hidden ? 'ghost' : ''} ${disp === 'ripple' && !d.hidden ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${alarm ? 'alarm' : ''}"
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${d.hidden ? 'ghost' : ''} ${valText != null ? 'valonly' : ''}"
style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
@@ -8886,15 +8965,13 @@ class HouseplanCard extends LitElement {
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}
>
${ripple
? html`<span class="ripple ${active ? 'active' : ''}"><i></i><i></i><i></i></span>`
${activity !== 'none'
? html`<span class="activity-ring ${activity} ${cls.includes('activity-gen2') ? 'gen2' : ''}"><i></i><i></i><i></i></span>`
: nothing}
${this._newIds.has(d.id) ? html`<span class="newdot" title=${this._t('device.new')}></span>` : nothing}
${valText != null
? html`<span class="valtext">${valText}</span>`
: disp !== 'ripple' || d.hidden
? html`<ha-icon icon="${icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>`
: nothing}
: html`<ha-icon icon="${icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>`}
${temp != null && valText == null ? html`<span class="tval">${temp}°</span>` : nothing}
${hum != null && valText == null ? html`<span class="hval">${hum}%</span>` : nothing}
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
@@ -9772,10 +9849,23 @@ class HouseplanCard extends LitElement {
private _renderMarkupBar(): TemplateResult {
return html`<div class="editbar">
<ha-icon icon="mdi:vector-square-edit" class="warn"></ha-icon>
<button class="btn ${this._tool === 'draw' ? 'on' : ''}" @click=${() => (this._tool = 'draw')}
title=${this._t('title.markup_add')}>
<ha-icon icon="mdi:vector-polyline-plus"></ha-icon>${this._t('markup.add')}
</button>
<span class="wallsgroup">
<button class="btn ${this._tool === 'draw' ? 'on' : ''}" @click=${() => (this._tool = 'draw')}
title=${this._t('title.markup_add')}>
<ha-icon icon="mdi:vector-polyline-plus"></ha-icon>${this._t('markup.add')}
</button>
${this._tool === 'draw'
? html`<label class="drawwall">${this._t('wallthick.field')}
<input type="number" min="0" max="100" step="any"
.value=${this._drawWallFieldValue}
@input=${(e: Event) => {
this._drawWallField = (e.target as HTMLInputElement).value;
}}
title=${this._t('markup.draw_wall_title')} />
<span class="opl">${this._t(this._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}</span>
</label>`
: nothing}
</span>
<button class="btn ${this._tool === 'merge' ? 'on' : ''}"
@click=${() => { this._tool = 'merge'; this._cancelPath(); this._tool = 'merge'; }}
title=${this._t('title.markup_merge')}>
@@ -9815,15 +9905,6 @@ class HouseplanCard extends LitElement {
? html`<span class="hint">${this._path.length
? this._t('markup.hint_points', { n: this._path.length })
: this._t('markup.hint_start')}</span>
<label class="drawwall">${this._t('wallthick.field')}
<input type="number" min="0" max="100" step="any"
.value=${this._drawWallFieldValue}
@input=${(e: Event) => {
this._drawWallField = (e.target as HTMLInputElement).value;
}}
title=${this._t('markup.draw_wall_title')} />
<span class="opl">${this._t(this._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}</span>
</label>
${this._path.length ? html`<button class="btn ghost" @click=${this._cancelPath}>${this._t('btn.reset')}</button>` : nothing}`
: nothing}
${this._tool === 'resize' ? html`<span class="hint">${this._t('markup.hint_resize')}</span>` : nothing}
@@ -10180,8 +10261,10 @@ class HouseplanCard extends LitElement {
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
)}
</select>
${d.display === 'ripple' || d.display === 'icon_ripple'
<p class="muted">${this._t('marker.display_hint')}</p>
${d.display === 'icon_ripple'
? html`<div class="colorrow">
<span class="opl">${this._t('marker.activity_color')}</span>
<input type="color" .value=${d.rippleColor || '#3ea6ff'}
@input=${(e: Event) => (this._markerDialog = { ...d, rippleColor: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._t('marker.ripple_size')}</span>
+12 -15
View File
@@ -25,14 +25,14 @@
"title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space",
"title.add_space": "Add space",
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes",
"title.markup_add": "Walls: connect grid dots with lines until the room outline closes",
"title.markup_merge": "Merge rooms: click one room, then the neighbour it shares a wall with",
"title.markup_split": "Split a room: click the room, then two points on its walls",
"title.markup_delroom": "Delete: click a virtual wall to make it solid; a shared solid wall merges the two rooms; an outer wall or inside a room deletes the room (with confirm).",
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
"title.choose_area": "Select a Home Assistant area",
"title.need_plan": "Upload a floor-plan image",
"markup.add": "Add",
"markup.add": "Walls",
"markup.merge": "Merge",
"markup.split": "Split",
"markup.resize": "Resize",
@@ -83,10 +83,11 @@
"marker.icon_label": "Icon",
"marker.icon_ph": "mdi:… (empty = auto)",
"marker.display_label": "Display",
"display.badge": "Icon badge",
"display.ripple": "Ripple only",
"display.icon_ripple": "Icon + ripple",
"marker.ripple_size": "Ripple size",
"display.badge": "Icon",
"display.icon_ripple": "Icon + activity",
"marker.display_hint": "A yellow plate means actual work. Activity adds events, presence and movement; alarms are always visible.",
"marker.activity_color": "Effect color",
"marker.ripple_size": "Effect size",
"marker.size_label": "Icon size / rotation",
"marker.angle_label": "Rotate",
"marker.model_label": "Model",
@@ -278,16 +279,12 @@
"decor.fill": "Fill",
"decor.text_title": "Text label",
"decor.text_label": "Text",
"decor.text_hint": "A new line here is a new line on the plan; the block is centred. Drag a corner to resize it, the handle above it to turn it.",
"decor.text_hint_live": "{} is where the value lands. Without it the value is added at the end. A new line here is a new line on the plan.",
"decor.live_group": "Live value",
"decor.live_hint": "The label may show one entity's value — as Home Assistant reports it, with no rounding. A dead entity shows a dash.",
"decor.live_group": "Insert HA variable",
"decor.live_entity": "Entity",
"decor.live_entity_ph": "none — a plain label",
"decor.live_attr": "Attribute",
"decor.live_attr_ph": "the state",
"decor.live_unit": "Unit",
"decor.live_preview": "Preview",
"decor.live_entity_ph": "choose an entity",
"decor.live_attr": "Value",
"decor.live_attr_ph": "choose state or attribute",
"decor.live_state": "State",
"decor.backdrop": "Backdrop image",
"decor.backdrop_hint": "Drag the picture to move it, pull a corner to resize it. Shift — off the grid.",
"decor.backdrop_reset": "Reset the picture",
+11 -14
View File
@@ -25,14 +25,14 @@
"title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
"title.markup_add": "Стены: соединяйте точки сетки линиями до замкнутого контура комнаты",
"title.markup_merge": "Объединить комнаты: клик по одной, затем по соседней с общей стеной",
"title.markup_split": "Разделить комнату: клик по комнате, затем две точки на её стенах",
"title.markup_delroom": "Удалить: виртуальная стена → обычная; общая глухая → слияние комнат; внешняя или клик внутрь → удалить комнату (с подтверждением)",
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
"title.choose_area": "Выберите зону Home Assistant",
"title.need_plan": "Загрузите подложку (план этажа)",
"markup.add": "Добавить",
"markup.add": "Стены",
"markup.merge": "Объединить",
"markup.split": "Разделить",
"markup.resize": "Размер",
@@ -84,9 +84,10 @@
"marker.icon_ph": "mdi:… (пусто = авто)",
"marker.display_label": "Отображение",
"display.badge": "Значок",
"display.ripple": "Только пульсация",
"display.icon_ripple": "Значок + пульсация",
"marker.ripple_size": "Размер пульсации",
"display.icon_ripple": "Значок + активность",
"marker.display_hint": "Жёлтая подложка означает фактическую работу. Активность добавляет события, присутствие и движение; тревога видна всегда.",
"marker.activity_color": "Цвет эффекта",
"marker.ripple_size": "Размер эффекта",
"marker.size_label": "Размер / поворот значка",
"marker.angle_label": "Поворот",
"marker.model_label": "Модель",
@@ -278,16 +279,12 @@
"decor.fill": "Залить",
"decor.text_title": "Надпись",
"decor.text_label": "Текст",
"decor.text_hint": "Перевод строки здесь — перевод строки на плане; блок выравнивается по центру. Тяните угол — меняет размер, ручка сверху — поворот.",
"decor.text_hint_live": "{} — место, куда встанет значение. Без него значение добавится в конец. Перевод строки здесь — перевод строки на плане.",
"decor.live_group": "Живое значение",
"decor.live_hint": "Надпись может показывать значение одной сущности — так, как его отдаёт Home Assistant, без округления. Мёртвая сущность — прочерк.",
"decor.live_group": "Вставить переменную HA",
"decor.live_entity": "Сущность",
"decor.live_entity_ph": "нет — обычная надпись",
"decor.live_attr": "Атрибут",
"decor.live_attr_ph": "состояние",
"decor.live_unit": "Единица",
"decor.live_preview": "Предпросмотр",
"decor.live_entity_ph": "выберите сущность",
"decor.live_attr": "Значение",
"decor.live_attr_ph": "выберите состояние или атрибут",
"decor.live_state": "Состояние",
"decor.backdrop": "Картинка-подложка",
"decor.backdrop_hint": "Тяните картинку — двигается, тяните угол — меняет размер. Shift — мимо сетки.",
"decor.backdrop_reset": "Вернуть картинку",
+117 -23
View File
@@ -283,10 +283,9 @@ export function openingAmount(
}
/**
* Is an entity "active / detected"? Used by presence ripples, which are opted into per
* device and therefore must not depend on the card-wide live_states toggle.
* Anything unknown — including `unavailable` — counts as idle: a sensor outage should
* calm the plan down, never leave a ring pulsing forever.
* Coarse active/open predicate used by bound architectural openings and
* compatibility helpers. Device activity itself is classified semantically
* in device-visual.ts; it must not use this broad list.
*/
export function isActiveState(state?: string | null): boolean {
return ['on', 'open', 'home', 'detected', 'playing', 'cleaning'].includes(String(state));
@@ -766,7 +765,9 @@ export type TapAction = 'info' | 'more-info' | 'toggle' | 'run' | 'cover';
* can read them and assert the schema accepts every value a user can pick.
* Adding an option here and forgetting the schema now fails the test suite.
*/
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
/** UI presentations. `ripple` remains a backend/read compatibility value only
* and is normalised to `icon_ripple` by the card. */
export const DISPLAY_MODES = ['badge', 'icon_ripple', 'value'] as const;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run', 'cover'] as const;
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
// 'glow' leads: it is the default for new spaces since v1.54 — the owner's
@@ -918,8 +919,8 @@ export const LIVE_TEXT_DASH = '—';
/** A caption is a caption: an attribute that turns out to be a 4 KB string
* must not become the plan's wallpaper. */
export const LIVE_TEXT_VALUE_MAX = 60;
/** The one placeholder. Not `{{ }}`: this is a substitution, not a template
* language — see docs/LIVE-TEXT.md for why that line is where it is. */
/** Legacy one-value placeholder. New labels store complete `{entity[:attr]}`
* references in `text`; this stays readable for existing configurations. */
export const LIVE_TEXT_SLOT = '{}';
export interface LiveTextLink {
@@ -931,6 +932,45 @@ export interface LiveTextLink {
unit?: string | null;
}
/**
* A reference written directly in decor text.
*
* Canonical form is `{domain.object_id}` for state and
* `{domain.object_id:attribute}` for an attribute. For hand-written labels we
* also accept `{domain.object_id.attribute}`: the first two dot-separated
* parts are the entity id and the rest is the flat attribute name.
*/
export function liveTextReference(raw: string | null | undefined): LiveTextLink | null {
const ref = String(raw ?? '').trim();
if (!ref) return null;
let entity = ref;
let attr = '';
const colon = ref.indexOf(':');
if (colon >= 0) {
entity = ref.slice(0, colon).trim();
attr = ref.slice(colon + 1).trim();
} else {
const parts = ref.split('.');
if (parts.length > 2) {
entity = parts.slice(0, 2).join('.');
attr = parts.slice(2).join('.');
}
}
if (!/^[a-z0-9_]+\.[a-z0-9_]+$/.test(entity)) return null;
if (colon >= 0 && !attr) return null;
if (attr && !/^[a-zA-Z0-9_.-]+$/.test(attr)) return null;
return attr ? { entity, attr } : { entity };
}
/** Canonical token inserted by the text editor. */
export function liveTextToken(entity: string | null | undefined, attr?: string | null): string {
const id = String(entity ?? '').trim();
const name = String(attr ?? '').trim();
const ref = liveTextReference(name ? `${id}:${name}` : id);
if (!ref) return '';
return `{${ref.entity}${ref.attr ? `:${ref.attr}` : ''}}`;
}
/** One attribute/state value as text, or null when there is nothing to show. */
function liveRaw(raw: unknown): string | null {
if (raw === undefined || raw === null) return null;
@@ -1070,15 +1110,26 @@ export function liveTextValue(hass: any, link: LiveTextLink | null | undefined):
}
/**
* The label as it must be painted: the template with its first `{}` replaced
* by the live value. A template without the placeholder gets the value
* appended after a space, so picking an entity and typing nothing sensible
* still shows something useful. Only the FIRST `{}` is substituted — one
* label, one value (docs/LIVE-TEXT.md «not a multi-entity widget»).
* Without an entity the text is returned byte-for-byte.
* The label as it must be painted. Every valid `{entity}` / `{entity:attr}`
* reference is resolved independently, so ordinary text and any number of HA
* values may be mixed in one caption. Invalid brace contents stay literal.
*
* `link` is the pre-template storage format. It is intentionally evaluated
* only when the text has no new references, keeping old saved labels working
* until the user edits and saves them in the new UI.
*/
export function liveText(text: string | null | undefined, link: LiveTextLink | null | undefined, hass: any): string {
const tpl = text ?? '';
let referenced = false;
const rendered = tpl.replace(/\{([^{}\r\n]+)\}/g, (whole, raw: string) => {
const ref = liveTextReference(raw);
if (!ref) return whole;
referenced = true;
return liveTextValue(hass, ref);
});
if (referenced) return rendered;
// Backward compatibility for shapes saved by beta.9 and earlier.
if (!(link?.entity || '').trim()) return tpl;
const v = liveTextValue(hass, link);
const i = tpl.indexOf(LIVE_TEXT_SLOT);
@@ -1487,21 +1538,62 @@ export function glowColorOf(state: any, fallback: string): { c: string; bri: num
/**
* Light spilling through a doorway: the sector of the glow circle between the
* rays source→A and source→B (door edge points), out to radius r. This part of
* the circle is intentionally NOT clipped by the room (owner's spec: no shadow
* casting — just the unclipped sector). Null when the door is out of reach or
* the source sits on a door edge; the sweep is clamped to maxDeg.
* rays source→A and source→B (door edge points), out to radius r.
*
* With `tunnelDepth > 0`, A/B are the centreline opening corners and the wall
* is an actual rectangular tunnel. A valid ray must cross BOTH its source-side
* and far-side spans, so the outgoing angle is the intersection of the two
* spans' angular intervals. That is the shadow cast by the two jamb returns;
* an off-centre lamp no longer shines through the solid corner of a thick wall.
* Zero depth is byte-for-byte the old centreline sector.
*
* This part of the circle is intentionally NOT clipped by the room (owner's
* spec: no general shadow casting — only the opening tunnel constrains it).
* Null when the door is out of reach/source is on an edge, or when the two
* tunnel apertures have no common sight line. Sweep is clamped to maxDeg.
*/
export function doorSector(
src: number[], a: number[], b: number[], r: number, maxDeg = 170,
tunnelDepth = 0,
): number[][] | null {
const la = Math.hypot(a[0] - src[0], a[1] - src[1]);
const lb = Math.hypot(b[0] - src[0], b[1] - src[1]);
if (la < 1e-6 || lb < 1e-6 || Math.min(la, lb) >= r) return null;
let aa = Math.atan2(a[1] - src[1], a[0] - src[0]);
let sweep = Math.atan2(b[1] - src[1], b[0] - src[0]) - aa;
while (sweep > Math.PI) sweep -= 2 * Math.PI;
while (sweep < -Math.PI) sweep += 2 * Math.PI;
let aa: number;
let sweep: number;
if (Number.isFinite(tunnelDepth) && tunnelDepth > 1e-6) {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy);
if (len < 1e-6) return null;
const ux = dx / len, uy = dy / len;
const nx = -uy, ny = ux;
const cx = (a[0] + b[0]) / 2, cy = (a[1] + b[1]) / 2;
const side = ((src[0] - cx) * nx + (src[1] - cy) * ny) >= 0 ? 1 : -1;
const off = tunnelDepth / 2;
const nearA = [a[0] + nx * off * side, a[1] + ny * off * side];
const nearB = [b[0] + nx * off * side, b[1] + ny * off * side];
const farA = [a[0] - nx * off * side, a[1] - ny * off * side];
const farB = [b[0] - nx * off * side, b[1] - ny * off * side];
const base = Math.atan2(cy - src[1], cx - src[0]);
const unwrap = (p: number[]) => {
let ang = Math.atan2(p[1] - src[1], p[0] - src[0]);
while (ang - base > Math.PI) ang -= 2 * Math.PI;
while (ang - base < -Math.PI) ang += 2 * Math.PI;
return ang;
};
const na = unwrap(nearA), nb = unwrap(nearB);
const fa = unwrap(farA), fb = unwrap(farB);
const lo = Math.max(Math.min(na, nb), Math.min(fa, fb));
const hi = Math.min(Math.max(na, nb), Math.max(fa, fb));
if (!(hi - lo > 1e-9)) return null;
aa = lo;
sweep = hi - lo;
} else {
aa = Math.atan2(a[1] - src[1], a[0] - src[0]);
sweep = Math.atan2(b[1] - src[1], b[0] - src[0]) - aa;
while (sweep > Math.PI) sweep -= 2 * Math.PI;
while (sweep < -Math.PI) sweep += 2 * Math.PI;
}
const max = (maxDeg * Math.PI) / 180;
if (Math.abs(sweep) > max) {
const mid = aa + sweep / 2;
@@ -1844,14 +1936,16 @@ export function distToSegment(p: number[], s: number[]): number {
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
/**
* An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, or a
* siren that is on. Unavailable/unknown never alarm (an outage is not a fire).
* An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, a
* siren that is on, or an alarm panel in `triggered`. Unavailable/unknown
* never alarm (an outage is not a fire).
*/
export function isAlarmState(
domain: string | null | undefined,
deviceClass: string | null | undefined,
state: string | null | undefined,
): boolean {
if (domain === 'alarm_control_panel') return state === 'triggered';
if (state !== 'on') return false;
if (domain === 'siren') return true;
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
+69 -4
View File
@@ -257,7 +257,10 @@ export function resolveOpenCuts(
): number[][] {
const list = (rooms || []).filter((r) => r?.id);
const clean = sanitizeOpenSpans(spans);
if (clean.length) return clean.map((e) => entryToSeg(e, coordScale));
if (clean.length) {
return clipOpenSpansToShared(clean, rooms, coordScale, eps)
.map((e) => entryToSeg(e, coordScale));
}
// Legacy `open_to`-only configuration. NEVER read in the middle of a geometry
// transaction (AUD-159B6-02): once explicit spans have been removed the index
// is stale by construction and would resurrect a different stretch.
@@ -499,7 +502,7 @@ export function clipOpenSpansToShared(
if (!clean.length) return [];
const shared = sharedSegsWithPairs(roomsModel, eps);
if (!shared.length) return [];
const out: OpenSpanEntry[] = [];
const pieces: Array<{ pair: string; seg: number[] }> = [];
const minLen = Math.max(eps * 4, 1e-6);
for (const e of clean) {
const sg = entryToSeg(e, coordScale);
@@ -523,7 +526,7 @@ export function clipOpenSpansToShared(
ranges.push({ lo, hi });
byPair.set(pair, ranges);
}
for (const ranges of byPair.values()) {
for (const [pair, ranges] of byPair) {
ranges.sort((a, b) => a.lo - b.lo || a.hi - b.hi);
const merged: { lo: number; hi: number }[] = [];
for (const range of ranges) {
@@ -535,7 +538,69 @@ export function clipOpenSpansToShared(
const na = [ax + ux * range.lo, ay + uy * range.lo];
const nb = [ax + ux * range.hi, ay + uy * range.hi];
if (Math.hypot(nb[0] - na[0], nb[1] - na[1]) < minLen) continue;
out.push(spanToEntry(na, nb, coordScale));
pieces.push({ pair, seg: [na[0], na[1], nb[0], nb[1]] });
}
}
}
// Canonicalise storage globally, not only inside each source entry. Drawing
// two touching pieces on the same physical boundary must not leave two dash
// phases / two selectable fragments behind. Pair identity is deliberately
// part of the group: Split can leave touching pieces owned by DIFFERENT room
// pairs and those must stay separate for an exact `open_to` index.
interface LineGroup {
pair: string;
origin: number[];
ux: number;
uy: number;
ranges: Array<{ lo: number; hi: number }>;
}
const groups: LineGroup[] = [];
const lineTol = Math.max(eps * 4, 1e-6);
for (const { pair, seg } of pieces) {
const dx = seg[2] - seg[0], dy = seg[3] - seg[1];
const len = Math.hypot(dx, dy);
if (len < minLen) continue;
let ux = dx / len, uy = dy / len;
if (ux < -1e-12 || (Math.abs(ux) <= 1e-12 && uy < 0)) {
ux = -ux;
uy = -uy;
}
let group = groups.find((g) => (
g.pair === pair &&
Math.abs(g.ux * uy - g.uy * ux) <= 1e-6 &&
Math.abs((seg[0] - g.origin[0]) * g.uy - (seg[1] - g.origin[1]) * g.ux) <= lineTol &&
Math.abs((seg[2] - g.origin[0]) * g.uy - (seg[3] - g.origin[1]) * g.ux) <= lineTol
));
if (!group) {
group = { pair, origin: [seg[0], seg[1]], ux, uy, ranges: [] };
groups.push(group);
}
const t1 = (seg[0] - group.origin[0]) * group.ux + (seg[1] - group.origin[1]) * group.uy;
const t2 = (seg[2] - group.origin[0]) * group.ux + (seg[3] - group.origin[1]) * group.uy;
group.ranges.push({ lo: Math.min(t1, t2), hi: Math.max(t1, t2) });
}
const out: OpenSpanEntry[] = [];
for (const group of groups) {
group.ranges.sort((a, b) => a.lo - b.lo || a.hi - b.hi);
const merged: Array<{ lo: number; hi: number }> = [];
for (const range of group.ranges) {
const tail = merged[merged.length - 1];
if (tail && range.lo <= tail.hi + minLen) tail.hi = Math.max(tail.hi, range.hi);
else merged.push({ ...range });
}
for (const range of merged) {
const a = [
group.origin[0] + group.ux * range.lo,
group.origin[1] + group.uy * range.lo,
];
const b = [
group.origin[0] + group.ux * range.hi,
group.origin[1] + group.uy * range.hi,
];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) >= minLen) {
out.push(spanToEntry(a, b, coordScale));
}
}
}
+54 -113
View File
@@ -576,17 +576,13 @@ export const cardStyles = css`
@media (prefers-reduced-motion: reduce) {
.op-leaf, .op-arc { transition: none; }
}
/* presence ripples: opted into per device, drawn around the anchor point */
.dev.noicon {
background: transparent;
border-color: transparent;
box-shadow: none;
}
/* Semantic activity: event / presence / transition / actual running all
share one ring layer and one colour/size override. */
.dev ha-icon {
position: relative;
z-index: 1;
}
.ripple {
.activity-ring {
position: absolute;
left: 50%;
top: 50%;
@@ -596,33 +592,57 @@ export const cardStyles = css`
pointer-events: none;
z-index: 0;
}
.ripple i {
.activity-ring i {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--ripple-color, var(--hp-accent));
opacity: 0;
}
.ripple.active i {
animation: hp-ripple 2.4s ease-out infinite;
/* A witnessed edge: exactly three waves over the 3.3 s runtime window. */
.activity-ring.event i {
animation: hp-activity-event 1.1s ease-out 1 forwards;
}
.ripple.active i:nth-child(2) { animation-delay: 0.8s; }
.ripple.active i:nth-child(3) { animation-delay: 1.6s; }
/* idle: a faint dot keeps the spot marked without pulling the eye */
.ripple:not(.active) i:nth-child(n + 2) { display: none; }
.ripple:not(.active) i {
inset: calc(50% - 0.15 * var(--dev-size));
opacity: 0.3;
animation: none;
.activity-ring.event i:nth-child(2) { animation-delay: 1.1s; }
.activity-ring.event i:nth-child(3) { animation-delay: 2.2s; }
.activity-ring.event.gen2 i { animation-name: hp-activity-event-b; }
/* Presence is a state, not an event: calm and deliberately static. */
.activity-ring.presence i:first-child { opacity: 0.4; }
.activity-ring.presence i:nth-child(n + 2),
.activity-ring.transition i:nth-child(n + 2),
.activity-ring.running i:nth-child(n + 2) { display: none; }
/* Physical travel and actual work use related but distinct tempos. */
.activity-ring.transition i:first-child {
animation: hp-activity-breathe 2.2s ease-in-out infinite;
}
@keyframes hp-ripple {
.activity-ring.running i:first-child {
animation: hp-activity-running 2.8s ease-in-out infinite;
}
@keyframes hp-activity-event {
0% { transform: scale(0.18); opacity: 0.7; }
70% { opacity: 0.22; }
100% { transform: scale(1); opacity: 0; }
}
/* Alternate identity: a rapid retrigger restarts the browser timeline. */
@keyframes hp-activity-event-b {
0% { transform: scale(0.18); opacity: 0.7; }
70% { opacity: 0.22; }
100% { transform: scale(1); opacity: 0; }
}
@keyframes hp-activity-breathe {
0% { transform: scale(0.92); opacity: 0.16; }
50% { transform: scale(1.04); opacity: 0.5; }
100% { transform: scale(0.92); opacity: 0.16; }
}
@keyframes hp-activity-running {
0% { transform: scale(0.94); opacity: 0.18; }
50% { transform: scale(1.02); opacity: 0.42; }
100% { transform: scale(0.94); opacity: 0.18; }
}
@media (prefers-reduced-motion: reduce) {
.ripple.active i { animation: none; opacity: 0.3; }
.ripple.active i:nth-child(n + 2) { display: none; }
.activity-ring i { animation: none !important; }
.activity-ring i:first-child { opacity: 0.4; }
.activity-ring i:nth-child(n + 2) { display: none; }
}
.roomlabel {
pointer-events: none; /* draggable only in plan mode (rule below) */
@@ -1024,15 +1044,6 @@ export const cardStyles = css`
font: inherit;
line-height: 1.35;
}
.dtpreview {
padding: 6px 8px;
border-radius: 8px;
background: rgba(127, 127, 127, 0.12);
text-align: center;
white-space: pre-wrap;
word-break: break-word;
font-weight: 600;
}
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;
@@ -1368,54 +1379,6 @@ export const cardStyles = css`
/* v1.52.0: the RGB tint of the icon/border is gone — a lamp's colour
lives ONLY in its glow spot (owner's rule). The ripple-color fallback
keeps using the light colour; that is set inline via --ripple-color. */
/* Owner's rule (2026-08-01, вариант «б»): «движение = разовая вспышка
в момент обнаружения; cool-down не пульсирует; присутствие =
статичное кольцо пока обитаемо». The yellow FILL stays reserved for
«включено» — both sense states keep the neutral badge. Both rings
are declared BEFORE .dev.alarm on purpose: equal specificity on the
shared ::after means the later alarm rule wins if a device ever
carries both — red beats yellow. */
/* MOTION tripped: a short ONE-SHOT flash — three beats of the yellow
ring (3 × 1.1s), then silence, even while the entity still reports
'on' (that tail is the sensor's cool-down, not motion). The
iteration count is FINITE, and the card itself drops the class
~3.3s after the off→on transition (_senseTick/_stateClass in
houseplan-card.ts); base opacity 0 keeps the ring invisible should
the class outlive the animation by a frame. */
.dev.senseflash::after {
content: '';
position: absolute;
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 2px solid var(--hp-on);
border-radius: 50%;
opacity: 0;
animation: hp-sense 1.1s ease-in-out 3;
pointer-events: none;
}
/* HP-1543-02: a rapid off→on retrip must RESTART the flash. The card
flips the .sf2 marker on every trip (_senseTick generation parity);
hp-sense-b duplicates hp-sense exactly — switching the animation-name
is what makes the browser abandon the old timeline and start a fresh
one on the same pseudo-element (a same-name animation never restarts
while the class stays on). */
.dev.senseflash.sf2::after { animation-name: hp-sense-b; }
/* OCCUPANCY/PRESENCE while 'on': a calm STATIC ring, no animation —
«комната обитаема» is a state, not an event, so it must not blink.
Brightness matches the reduced-motion variant of the flash. */
.dev.sensehold::after {
content: '';
position: absolute;
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 2px solid var(--hp-on);
border-radius: 50%;
opacity: 0.4;
pointer-events: none;
}
/* COVER ON THE MOVE (owner 2026-08-03): «не жёлтая подложка, а лёгкая
пульсация вокруг значка в стиле шайбы пылесоса». Same yellow as the
sense rings, the vacuum puck's 2.2s period, and a moderate opacity so
two curtains travelling at once never turn into a strobe. The plate
itself stays neutral — yellow means «включено», nothing else. */
/* Sun wedges (docs/SUN.md). The layer is present ONLY above the 3°
threshold; crossing it fades the whole layer in or out over EXACTLY
2 s (owner 2026-08-03 — RAY_FADE_MS in src/sun.ts must match). The
@@ -1440,34 +1403,14 @@ export const cardStyles = css`
.sunlayer, .sunlayer.out { animation: none; }
.sunlayer.out { opacity: 0; }
}
.dev.covermove::after {
content: '';
position: absolute;
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 2px solid var(--hp-on);
border-radius: 50%;
opacity: 0.45;
animation: hp-covermove 2.2s ease-in-out infinite;
pointer-events: none;
}
@keyframes hp-covermove {
0% { transform: scale(0.92); opacity: 0.16; }
50% { transform: scale(1.12); opacity: 0.5; }
100% { transform: scale(0.92); opacity: 0.16; }
}
@keyframes hp-sense {
0% { transform: scale(0.9); opacity: 0.5; }
60% { transform: scale(1.12); opacity: 0.12; }
100% { transform: scale(1.12); opacity: 0; }
}
/* identical twin of hp-sense — exists ONLY as the alternate animation
identity for the retrip restart (HP-1543-02), keep the two in sync */
@keyframes hp-sense-b {
0% { transform: scale(0.9); opacity: 0.5; }
60% { transform: scale(1.12); opacity: 0.12; }
100% { transform: scale(1.12); opacity: 0; }
}
/* alarms pulse red over everything */
.dev.alarm,
.dev.alarm:hover {
background: #6f2325;
border-color: #f25a4a;
color: #fff;
box-shadow: 0 0 10px rgba(242, 90, 74, 0.65);
}
.dev.alarm::after {
content: '';
position: absolute;
@@ -1483,14 +1426,6 @@ export const cardStyles = css`
}
@media (prefers-reduced-motion: reduce) {
.dev.alarm::after { animation: none; opacity: 0.9; }
/* the motion flash becomes a static ring for the same ~3.3s class
window — consistent with alarm's treatment; sensehold is static
by design already */
.dev.senseflash::after,
.dev.senseflash.sf2::after { animation: none; opacity: 0.4; } /* HP-1543-02: retrip re-arms the window, ring stays static */
/* a travelling cover keeps a STATIC ring — the movement is still shown,
it just stops breathing */
.dev.covermove::after { animation: none; opacity: 0.4; }
}
.dev .newdot {
position: absolute;
@@ -2416,6 +2351,12 @@ export const cardStyles = css`
gap: var(--sp-3);
white-space: nowrap;
}
.editbar .wallsgroup {
display: inline-flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.editbar .drawwall input {
width: 4.2em;
}
+1
View File
@@ -72,6 +72,7 @@ export interface Marker {
segment_map?: Record<string, string>;
} | null;
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
/** `ripple` is legacy read compatibility; UI maps it to icon_ripple. */
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
ripple_color?: string | null;
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
+58 -4
View File
@@ -875,8 +875,10 @@ export function wallIntervals(
}
/**
* Rewrite `walls` so every entry names an atomic interval of the CURRENT
* geometry, and no entry survives under an open span.
* Rewrite `walls` so every entry names the smallest necessary interval of the
* CURRENT geometry, and no entry survives under an open span. Atomic entries
* are compacted back to the original polygon edge when all of its children
* are solid and have the same thickness.
*
* This is the single place where the spec invariant "an open span and a
* positive thickness never share a key" is enforced: opening a stretch splits
@@ -893,10 +895,62 @@ export function normalizeWallIntervals(
coordScale = 1,
): WallEntry[] {
if (!walls?.length) return [];
const atomic: WallInterval[] = [];
const atomicKeys = new Set<string>();
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
if (iv.open || !(iv.cm > 0) || atomicKeys.has(iv.key)) continue;
atomicKeys.add(iv.key);
atomic.push(iv);
}
// A partial shared boundary or a former open span may have split one
// original edge into several storage keys. Once every child is solid and
// carries the same cm value, the parent key is the canonical representation
// again. Prefer the longest valid parent first so a room whose edge contains
// a shorter neighbour edge also removes that redundant shorter key.
const parents: Array<{ a: number[]; b: number[]; key: string; cm: number; len: number }> = [];
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr) continue;
for (let pi = 0; pi < pr.orig.length; pi++) {
const children: number[] = [];
for (let i = 0; i < pr.parent.length; i++) {
if (pr.parent[i] === pi) children.push(i);
}
if (!children.length) continue;
const cm = pr.cms[children[0]];
if (!(cm > 0) || children.some((i) => pr.kinds[i] === null || pr.cms[i] !== cm)) continue;
const a = pr.orig[pi], b = pr.orig[(pi + 1) % pr.orig.length];
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (len <= 0) continue;
parents.push({
a: [a[0], a[1]], b: [b[0], b[1]],
key: keyOf(a, b, pitch, coordScale), cm, len,
});
}
}
parents.sort((a, b) => b.len - a.len || a.key.localeCompare(b.key));
const out: WallEntry[] = [];
const seen = new Set<string>();
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
if (iv.open || !(iv.cm > 0) || seen.has(iv.key)) continue;
const covered = new Set<string>();
const tol = openEps(pitch, coordScale) * 4;
for (const parent of parents) {
const matches = atomic.filter((iv) => (
!covered.has(iv.key) && iv.cm === parent.cm &&
angleClose(segAngle(iv.a, iv.b), segAngle(parent.a, parent.b)) &&
distToSeg(iv.a[0], iv.a[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol &&
distToSeg(iv.b[0], iv.b[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol
));
if (!matches.length) continue;
for (const iv of matches) covered.add(iv.key);
if (seen.has(parent.key)) continue;
seen.add(parent.key);
out.push({ key: parent.key, cm: parent.cm });
}
for (const iv of atomic) {
if (covered.has(iv.key) || seen.has(iv.key)) continue;
seen.add(iv.key);
out.push({ key: iv.key, cm: iv.cm });
}
+91
View File
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
combineVisualSamples,
edgeActivity,
entityVisualSample,
} from '../test-build/device-visual.js';
const hass = (states) => ({
states: Object.fromEntries(Object.entries(states).map(([eid, value]) => [eid, {
state: value.state,
attributes: value.attributes || {},
}])),
});
test('actual work is yellow/running, but an enabled automation is neutral', () => {
const h = hass({
'switch.relay': { state: 'on' },
'climate.office': { state: 'heat', attributes: { hvac_action: 'idle' } },
'climate.bedroom': { state: 'heat', attributes: { hvac_action: 'heating' } },
'automation.night': { state: 'on' },
'script.scene': { state: 'on' },
});
assert.deepEqual(
entityVisualSample(h, 'switch.relay'),
{ eid: 'switch.relay', state: 'on', availability: 'available', status: 'working', activity: 'running', edge: 'none' },
);
assert.equal(entityVisualSample(h, 'climate.office').status, 'neutral');
assert.equal(entityVisualSample(h, 'climate.bedroom').status, 'working');
assert.equal(entityVisualSample(h, 'automation.night').status, 'neutral');
assert.equal(entityVisualSample(h, 'script.scene').status, 'working');
});
test('open state, cover travel and presence are separate meanings', () => {
const h = hass({
'binary_sensor.window': { state: 'on', attributes: { device_class: 'window' } },
'binary_sensor.room': { state: 'on', attributes: { device_class: 'occupancy' } },
'cover.curtain': { state: 'opening' },
'lock.front': { state: 'unlocked' },
});
assert.equal(entityVisualSample(h, 'binary_sensor.window').status, 'open');
assert.equal(entityVisualSample(h, 'binary_sensor.room').activity, 'presence');
assert.deepEqual(
(({ status, activity }) => ({ status, activity }))(entityVisualSample(h, 'cover.curtain')),
{ status: 'neutral', activity: 'transition' },
);
assert.equal(entityVisualSample(h, 'lock.front').status, 'open');
});
test('alarm outranks work and unavailable suppresses unavailable-only markers', () => {
const h = hass({
'switch.pump': { state: 'on' },
'binary_sensor.leak': { state: 'on', attributes: { device_class: 'moisture' } },
'alarm_control_panel.home': { state: 'triggered' },
'sensor.dead': { state: 'unavailable' },
});
const alarm = combineVisualSamples([
entityVisualSample(h, 'switch.pump'),
entityVisualSample(h, 'binary_sensor.leak'),
]);
assert.deepEqual(alarm, { availability: 'available', status: 'alarm', activity: 'none' });
assert.equal(entityVisualSample(h, 'alarm_control_panel.home').status, 'alarm');
assert.deepEqual(
combineVisualSamples([entityVisualSample(h, 'sensor.dead')]),
{ availability: 'unavailable', status: 'neutral', activity: 'none' },
);
});
test('edges suppress first load/recovery and recognise semantic events', () => {
const motionOn = entityVisualSample(
hass({ 'binary_sensor.motion': { state: 'on', attributes: { device_class: 'motion' } } }),
'binary_sensor.motion',
);
assert.equal(edgeActivity(undefined, motionOn), null);
assert.equal(edgeActivity('unavailable', motionOn), null);
assert.equal(edgeActivity('off', motionOn), 'event');
assert.equal(edgeActivity('on', motionOn), null);
const coverOpen = entityVisualSample(
hass({ 'cover.curtain': { state: 'open' } }),
'cover.curtain',
);
assert.equal(edgeActivity('closed', coverOpen), 'transition');
assert.equal(edgeActivity('opening', coverOpen), null);
const button = entityVisualSample(
hass({ 'event.doorbell': { state: '2026-08-05T18:00:00+00:00' } }),
'event.doorbell',
);
assert.equal(edgeActivity('2026-08-05T17:59:00+00:00', button), 'event');
});
+52 -2
View File
@@ -16,7 +16,8 @@ import {
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf,
liveText, liveTextValue, hassValue, valueWithUnit, decorTextScale, decorTextLines,
liveText, liveTextValue, liveTextReference, liveTextToken,
hassValue, valueWithUnit, decorTextScale, decorTextLines,
LIVE_TEXT_DASH, LIVE_TEXT_VALUE_MAX, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -663,10 +664,12 @@ test('lightColorOf: rgb of an on light; off/unavailable/no-color → null', () =
assert.equal(lightColorOf(undefined), null);
});
test('isAlarmState: leak/smoke/gas/siren fire; doors and outages do not', () => {
test('isAlarmState: safety sensors, siren and triggered alarm panel fire; doors and outages do not', () => {
assert.ok(isAlarmState('binary_sensor', 'moisture', 'on'));
assert.ok(isAlarmState('binary_sensor', 'smoke', 'on'));
assert.ok(isAlarmState('siren', undefined, 'on'));
assert.ok(isAlarmState('alarm_control_panel', undefined, 'triggered'));
assert.ok(!isAlarmState('alarm_control_panel', undefined, 'armed_away'));
assert.ok(!isAlarmState('binary_sensor', 'door', 'on'));
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'off'));
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'unavailable'));
@@ -779,6 +782,26 @@ test('doorSector: sector through a door, clamped and guarded', () => {
assert.equal(doorSector(s, [0, 0], [10, 2], 50), null);
});
test('doorSector: thick doorway is clipped by both tunnel jamb faces', () => {
const src = [0, 3];
const a = [10, -2];
const b = [10, 2];
const thin = doorSector(src, a, b, 50);
const thick = doorSector(src, a, b, 50, 170, 4);
assert.ok(thin && thick);
assert.deepEqual(doorSector(src, a, b, 50, 170, 0), thin);
// The vertical wall occupies x=8..12. Every limiting ray must remain inside
// the clear opening y=-2..2 at BOTH faces, not only at the centreline.
const yAtX = (p, x) => src[1] + (p[1] - src[1]) * ((x - src[0]) / (p[0] - src[0]));
for (const p of [thick[1], thick.at(-1)]) {
for (const x of [8, 12]) assert.ok(Math.abs(yAtX(p, x)) <= 2 + 1e-9);
}
const spreadAtFarFace = (sector) => Math.abs(yAtX(sector[1], 12) - yAtX(sector.at(-1), 12));
assert.ok(spreadAtFarFace(thick) < spreadAtFarFace(thin));
});
test('hasRoomBehind: neighbour room yes, street no', () => {
const neighbour = [[10, -5], [20, -5], [20, 5], [10, 5]];
// дверь в стене x=10 (стена вертикальна: угол 90°), источник слева в (5,0)
@@ -1232,6 +1255,33 @@ test('liveText: no entity = the text is untouched, byte for byte', () => {
assert.equal(liveText('', null, hassLive), '');
});
test('liveText: inline variables resolve every state and attribute in the text', () => {
assert.equal(
liveText('Бак {sensor.tank}; климат {climate.hall:current_temperature}', null, hassLive),
'Бак 68 %; климат 21.5',
);
assert.equal(
liveText('{sensor.plain} / {sensor.tank} / {sensor.plain}', null, hassLive),
'17.4 / 68 % / 17.4',
);
});
test('liveText: hand-written dotted attributes work; invalid braces stay literal', () => {
assert.equal(liveText('{climate.hall.current_temperature}', null, hassLive), '21.5');
assert.equal(liveText('Обычный {текст}', null, hassLive), 'Обычный {текст}');
assert.equal(liveText('{sensor.nope}', null, hassLive), LIVE_TEXT_DASH);
});
test('liveTextToken writes the canonical editor syntax', () => {
assert.deepEqual(liveTextReference('climate.hall.current_temperature'), {
entity: 'climate.hall', attr: 'current_temperature',
});
assert.equal(liveTextToken('sensor.tank'), '{sensor.tank}');
assert.equal(liveTextToken('climate.hall', 'current_temperature'),
'{climate.hall:current_temperature}');
assert.equal(liveTextToken('not-an-entity'), '');
});
test('liveText: the placeholder is where the value lands', () => {
assert.equal(liveText('Бак {}', { entity: 'sensor.tank' }, hassLive), 'Бак 68 %');
assert.equal(liveText('{} в баке', { entity: 'sensor.tank' }, hassLive), '68 % в баке');
+20
View File
@@ -141,6 +141,26 @@ describe('open-spans', () => {
assert.ok(Math.max(sg[1], sg[3]) <= 4 + 1e-6, JSON.stringify(sg));
});
it('adjacent virtual pieces of the same room pair collapse to one span', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },
{ id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] },
];
const spans = [
spanToEntry([5, 0], [5, 2], 1),
spanToEntry([5, 4], [5, 2], 1),
];
const clipped = clipOpenSpansToShared(spans, rooms, 1, eps);
assert.equal(clipped.length, 1);
const sg = entryToSeg(clipped[0], 1);
assert.deepEqual(
[Math.min(sg[1], sg[3]), Math.max(sg[1], sg[3])],
[0, 4],
);
assert.equal(resolveOpenCuts(rooms, spans, 1, eps).length, 1,
'legacy fragmented storage must render as one selectable span too');
});
it('clipOpenSpansToShared keeps both room-pair pieces after Split', () => {
const rooms = [
{ id: 'left_top', poly: [[0, 0], [5, 0], [5, 2], [0, 2]] },
+22
View File
@@ -169,6 +169,28 @@ test('partial shared wall: a pre-atomic whole-edge key still covers both pieces'
assert.ok(ivs.every((iv) => iv.cm === 30), 'an existing plan must not lose thickness');
});
test('equal solid atomic pieces compact back to one whole-wall key', () => {
const rooms = partialRooms();
const walls = [
{ key: wallKey([5, 0], [5, 4], pitch), cm: 30 },
{ key: wallKey([5, 4], [5, 10], pitch), cm: 30 },
];
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
assert.deepEqual(next, [{ key: wallKey([5, 0], [5, 10], pitch), cm: 30 }]);
});
test('different solid thicknesses remain separate atomic keys', () => {
const rooms = partialRooms();
const walls = [
{ key: wallKey([5, 0], [5, 4], pitch), cm: 30 },
{ key: wallKey([5, 4], [5, 10], pitch), cm: 20 },
];
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
assert.equal(next.length, 2);
assert.deepEqual(new Set(next.map((w) => w.cm)), new Set([20, 30]));
assert.ok(!next.some((w) => w.key === wallKey([5, 0], [5, 10], pitch)));
});
// An open span that does NOT contain the parent edge's midpoint used to leave
// the key in place, so the wall body stayed solid straight across the passage.
test('open span away from the edge midpoint clears only its own interval', () => {
+6 -4
View File
@@ -592,6 +592,8 @@ def test_every_display_mode_the_editor_offers_is_accepted():
assert "value" in modes, "the regression this test exists for"
for mode in modes:
v.MARKER_SCHEMA(_marker(display=mode))
# Read/write compatibility for stores created before the unified activity UI.
v.MARKER_SCHEMA(_marker(display="ripple"))
v.MARKER_SCHEMA(_marker(display=None))
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA(_marker(display="wat"))
@@ -1183,11 +1185,11 @@ class TestVacuum:
def test_decor_text_live_fields():
"""docs/LIVE-TEXT.md: a text label may carry an entity, an attribute and a
unit. All three optional and BOUNDED; junk is refused; a label without them
stays valid, so no plan needs a migration."""
"""docs/LIVE-TEXT.md: new references live in `text`; the bounded legacy
entity/attribute/unit fields remain valid until an old label is edited."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5, "text": "Бак {}"}
txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5,
"text": "Бак {sensor.water_tank}"}
def cfg(extra):
return v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [{**txt, **extra}]}]})
+2 -2
View File
@@ -8,7 +8,7 @@
"resolveJsonModule": true
},
"include": [
"src/logic.ts", "src/vacuum.ts",
"src/logic.ts", "src/device-visual.ts", "src/vacuum.ts",
"src/sun.ts",
"src/resize.ts",
"src/rules.ts",
@@ -21,4 +21,4 @@
"src/wall-thickness.ts",
"src/open-spans.ts"
]
}
}