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