mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
Nothing paints over a marker that says it is a curtain
An explicit «Открыть/закрыть» marker is the strongest statement the card has about what a marker IS, so its cover now decides the plate BEFORE the bound `controls` and before a lit light of the same device — audit DEV-1DA1-01. Until now the cover came third, and the owner's contract «у штор не должно быть жёлтой подложки НИКОГДА» had two holes: a mixed device (a lamp that also ships a blind) told «Открыть/закрыть» went yellow off its own lit light, and a curtain marker with a bound wall switch went yellow off `controls`. The early `return 'on'` never reached the cover branch, so the travelling curtain lost its breathing ring as well — and in glow fill, where the renderer strips `on` from a shining source, it was left with no indicator at all, while the tap still drove the cover. Everything else keeps the old precedence: the same mixed device WITHOUT the explicit action is yellow again, a wall switch still mirrors its controls, and a «cover» marker whose device carries no cover.* at all falls back to its primary. docs/FILTERING.md «What a marker SHOWS» is renumbered accordingly. Regression: demo/smoke_cover_plate_precedence.mjs (the auditor's two markers, every cover state, class AND resolved plate colour).
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,207 @@
|
||||
// An EXPLICIT «Открыть/закрыть» marker is a curtain, and a curtain is never
|
||||
// painted — audit DEV-1DA1-01 (P2).
|
||||
//
|
||||
// `_stateClass` used to answer in this order: bound `controls` first, then any
|
||||
// lit `light.*` of the device, and only then the entity the tap acts on
|
||||
// (`_actEntity` → `_coverIndicator`). So the two combinations below still made
|
||||
// a curtain yellow, in flat contradiction with the owner's contract («у штор
|
||||
// не должно быть жёлтой подложки НИКОГДА») and with the promise that a marker
|
||||
// indicates the entity its tap drives:
|
||||
//
|
||||
// 1. a MIXED device (a lamp that also ships a blind) told `tap_action:
|
||||
// 'cover'` — the lit `light.mixed` returned 'on' before the cover branch
|
||||
// was ever reached, so the travelling blind lost its breathing ring too;
|
||||
// 2. a curtain marker with a bound `controls` target that happened to be on
|
||||
// — `controls` returned 'on' first, same result.
|
||||
//
|
||||
// The order now starts with the explicit cover: saying «this thing is a
|
||||
// curtain» in the dialog is the strongest statement the card has about what a
|
||||
// marker IS, so nothing may paint over it. Everything else keeps the old
|
||||
// precedence — a marker that was NOT told it is a curtain is untouched, and
|
||||
// that is re-checked at the bottom.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// ---- the plate colours, resolved the way the badge resolves them -------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)');
|
||||
const ORANGE = probe('var(--hp-open)');
|
||||
const NEUTRAL = probe('var(--hp-bg)');
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
|
||||
// =======================================================================
|
||||
// Part 1 — the class itself, straight from _stateClass, over every state
|
||||
// =======================================================================
|
||||
const withStates = (states, fn) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = fn();
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
const st = (state, attrs = {}) => ({ state, attributes: attrs });
|
||||
/** 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,
|
||||
};
|
||||
/** 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'] },
|
||||
};
|
||||
const CV = { closed: '', open: '', opening: 'covermove', closing: 'covermove' };
|
||||
for (const [state, want] of Object.entries(CV)) {
|
||||
const states = {
|
||||
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
|
||||
'switch.control': st('on', {}),
|
||||
'cover.mixed': st(state, { device_class: 'curtain' }),
|
||||
};
|
||||
o['litLamp_' + state] = withStates(states, () => c._stateClass(litLamp)) === want;
|
||||
o['controlled_' + state] = withStates(states, () => c._stateClass(controlled)) === want;
|
||||
}
|
||||
// an unreachable cover still only fades — never yellow, never orange
|
||||
o.litLampUnavailFades = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(litLamp)) === 'unavail';
|
||||
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({
|
||||
'light.mixed': st('on', {}),
|
||||
}, () => c._stateClass(litLamp)) === '';
|
||||
|
||||
// ---- 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 };
|
||||
o.mixedWithoutTheOptionStaysYellow = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === 'on';
|
||||
o.mixedWithoutTheOptionOffIsNeutral = withStates({
|
||||
'light.mixed': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === '';
|
||||
// a wall switch with controls and NO cover in sight still mirrors them
|
||||
const remote = {
|
||||
id: 'p_remote', primary: 'sensor.remote', entities: ['sensor.remote'], tapAction: null,
|
||||
marker: { id: 'p_remote', binding: 'device:y', controls: ['light.hall'] },
|
||||
};
|
||||
o.remoteMirrorsItsControls = withStates({ 'light.hall': st('on', {}) },
|
||||
() => c._stateClass(remote)) === 'on';
|
||||
o.remoteOffIsNeutral = withStates({ 'light.hall': st('off', {}) },
|
||||
() => c._stateClass(remote)) === '';
|
||||
// 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';
|
||||
// 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 = {
|
||||
id: 'p_nocover', primary: 'light.mixed', entities: ['light.mixed'], tapAction: 'cover', marker: null,
|
||||
};
|
||||
o.explicitCoverWithoutACoverKeepsThePrimary = withStates({ 'light.mixed': st('on', {}) },
|
||||
() => c._stateClass(noCover)) === 'on';
|
||||
|
||||
// =======================================================================
|
||||
// Part 2 — the real markers on the real plan: the PLATE, not just a class
|
||||
// =======================================================================
|
||||
const ENTS = {
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', device_id: 'd_ctrl', platform: 'demo' },
|
||||
'switch.control': { entity_id: 'switch.control', device_id: 'd_wall', platform: 'demo' },
|
||||
};
|
||||
const push = async (coverState) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null },
|
||||
d_ctrl: { id: 'd_ctrl', name: 'Office curtain', model: 'Curtain motor',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_ctrl']], entry_type: null, via_device_id: null },
|
||||
d_wall: { id: 'd_wall', name: 'Wall switch', model: 'Wall switch',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_wall']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: coverState, attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', state: coverState, attributes: { friendly_name: 'Office curtain', device_class: 'curtain' } },
|
||||
'switch.control': { entity_id: 'switch.control', state: 'on', attributes: { friendly_name: 'Wall switch' } },
|
||||
},
|
||||
};
|
||||
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'] },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
const ct = c._devices.find((x) => x.bindingRef === 'd_ctrl');
|
||||
if (mx && ct) {
|
||||
c._layout = { ...c._layout,
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 },
|
||||
[ct.id]: { s: ct.space, x: 0.06, y: 0.94 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — read a settled colour, not a fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await push('opening');
|
||||
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
const elOf = (d) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(d);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const report = (tag, ref) => {
|
||||
const d = dev(ref);
|
||||
const el = d && elOf(d);
|
||||
o[tag + 'MarkerFound'] = !!el;
|
||||
if (!el) return;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
|
||||
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 + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
|
||||
};
|
||||
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
|
||||
o.controlIsTrulyOn = c.hass.states['switch.control'].state === 'on';
|
||||
report('litLampDom', 'd_mixed');
|
||||
report('controlledDom', 'd_ctrl');
|
||||
|
||||
// …and at rest (closed) they are plain, with no ring
|
||||
await push('closed');
|
||||
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');
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+45
-45
File diff suppressed because one or more lines are too long
+27
-13
@@ -74,18 +74,18 @@ the old behaviour until an editing client materialises it.
|
||||
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:
|
||||
order (`_stateClass` / `_actEntity`):
|
||||
|
||||
1. the marker's bound **controls**, if it has any (a stateless remote or a
|
||||
virtual wall switch mirrors what it drives, not itself);
|
||||
2. a **lit light** among its entities (owner's principle 2026-07-29: the glow
|
||||
spot and the badge may never disagree);
|
||||
3. the device's **cover**, when the marker's tap action is explicitly
|
||||
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);
|
||||
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);
|
||||
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`).
|
||||
|
||||
Rule 3 was added 2026-08-04 on the owner's report: his Aqara «Roller shade
|
||||
Rule 1 was added 2026-08-04 on the owner's report: his Aqara «Roller shade
|
||||
driver E1» curtains ship the `cover.*` hidden by the integration and a visible
|
||||
`switch.*_reverse_direction`, so `primaryEntity` picked the service switch —
|
||||
the plan showed no ring while a curtain travelled, no `curtains` /
|
||||
@@ -94,6 +94,18 @@ reverse-direction option happened to be on. The tap had already been
|
||||
taught to find the cover among ALL the device's entities (2026-08-04, the
|
||||
same `coverEntityOf`); the indication now follows it.
|
||||
|
||||
**Why the cover is FIRST and not third** (audit DEV-1DA1-01, fixed the same
|
||||
day). It went in below `controls` and the lit light at first, and that left
|
||||
the contract below («у штор не должно быть жёлтой подложки НИКОГДА») with two
|
||||
holes big enough to walk through: a mixed device — a lamp that also ships a
|
||||
blind — told «Открыть/закрыть» went yellow off its own lit `light.*`, and a
|
||||
curtain marker with a bound wall switch went yellow off `controls`. In both
|
||||
the early `return 'on'` never reached the cover branch, so the travelling
|
||||
curtain also lost its breathing ring, and in glow fill (where the renderer
|
||||
strips `on` from a shining source) it was left with no indicator at all —
|
||||
while the tap still drove the cover. A rule that «шторы никогда не жёлтые»
|
||||
cannot have exceptions decided by the neighbours in the entity list.
|
||||
|
||||
**Why it hangs on the explicit action and not on «the device has a cover».**
|
||||
Choosing «Открыть/закрыть» in the marker dialog is the only statement the card
|
||||
has that means *this marker IS the curtain* — and the dialog offers that option
|
||||
@@ -101,11 +113,13 @@ for exactly the devices where a cover exists. Tying the indication to it keeps
|
||||
one answer to «what is this marker»: the option offered, the entity tapped and
|
||||
the state shown are the same entity, decided in one place. Nothing changes
|
||||
behind the user's back for a mixed device (a lamp that also owns a blind, a TRV
|
||||
with a service switch): those keep their primary until their owner says
|
||||
otherwise, and even with the action chosen a lit light still wins rule 2. The
|
||||
cost is that a curtain left on «Инфо-карточка» still indicates its primary —
|
||||
one click in the dialog away, and the honest reading of what the marker was
|
||||
told it is.
|
||||
with a service switch) that was NOT told it is a curtain: it keeps its primary
|
||||
and the old 2–3–4 precedence, controls and lit light included, until its owner
|
||||
says otherwise. Two edges follow from the wording: a marker set to
|
||||
«Открыть/закрыть» whose device carries no `cover.*` at all falls through to
|
||||
rules 2–4 (the statement is only as strong as the entity behind it), and a
|
||||
curtain left on «Инфо-карточка» still indicates its primary — one click in the
|
||||
dialog away, and the honest reading of what the marker was told it is.
|
||||
|
||||
### A cover is never painted (owner 2026-08-04)
|
||||
|
||||
|
||||
@@ -326,6 +326,18 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
action away and the marker speaks for its primary again; a lit lamp that
|
||||
also owns a cover keeps its yellow and its own icon
|
||||
[auto: smoke_cover_not_primary (the indication section)]
|
||||
- [ ] Nothing paints over an explicit curtain (dev, audit DEV-1DA1-01): the
|
||||
cover is the FIRST rule of «What a marker SHOWS», above `controls` and
|
||||
above a lit light. Two markers set to «Открыть/закрыть»: one on a mixed
|
||||
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
|
||||
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.*`
|
||||
at all falls back to its primary [auto: smoke_cover_plate_precedence]
|
||||
- [ ] 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
|
||||
|
||||
+25
-5
@@ -1586,6 +1586,24 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
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)
|
||||
const controls = (d.marker?.controls || []).filter(isControllable);
|
||||
@@ -1598,11 +1616,13 @@ class HouseplanCard extends LitElement {
|
||||
// stays yellow only where the spot is not drawn (other fills, the plan
|
||||
// editor).
|
||||
if (litLightEntity(this.hass, d)) return 'on';
|
||||
// Not always the primary: an «Open/close» marker speaks for its cover,
|
||||
// wherever that sits in the entity list (_coverIndicator). Before this an
|
||||
// Aqara curtain driver reported its `switch.*_reverse_direction` — no
|
||||
// breathing ring while it travelled, no «open» frame, and a yellow plate
|
||||
// whenever the reverse-direction option happened to be 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 '';
|
||||
|
||||
Reference in New Issue
Block a user