A curtain marker shows the cover it opens

Owner, 2026-08-04, on his own curtains: «нет ни дышащего кольца во время хода,
ни рамки "открыто", ни морфинга иконки». Same device and the same cause as the
tap fix two commits before this branch: his Aqara «Roller shade driver E1»
ships the `cover.*` hidden by the integration and a visible
`switch.*_reverse_direction`, so `primaryEntity` picks the service switch —
and `_stateClass`, the state-morphed icon and the ripple all read `d.primary`.
The plan reported the state of the reverse-direction option: a yellow
«включено» plate whenever it was on, and nothing at all while the curtain
actually travelled.

`coverEntityOf` already knew where the cover was; the indication now asks it
through one helper, `_coverIndicator` — the device's cover when the marker's
tap action is explicitly «Открыть/закрыть», null otherwise — and `_actEntity`
(`_coverIndicator || primary`) is what the tap path and the marker
presentation now share. Same entity offered in the dialog, driven by the tap
and shown on the plan.

THE RULE, and why it is the least surprising one (docs/FILTERING.md «What a
marker SHOWS»): picking «Открыть/закрыть» is the only statement the card has
that means «this marker IS the curtain», and the dialog offers it exactly for
the devices that own a cover. Hanging the indication on «the device has a
cover somewhere» would have re-decided, silently, what a mixed marker is — a
lamp that also owns a blind would stop showing the lamp. The precedence in
`_stateClass` is unchanged above it: bound controls first, then a lit light
(the glow spot and the badge may never disagree), then the cover, then the
primary — so even with the action chosen a shining lamp keeps its yellow. The
price is that a curtain left on «Инфо-карточка» still speaks for its primary;
that is one click in the dialog, and it is the honest reading of what the
marker has been told it is.

smoke_cover_not_primary.mjs grows an indication section on the owner's device:
closed / open / opening / closing give no class, `open`, `covermove`,
`covermove`, the icon morphs `mdi:curtains-closed` <-> `mdi:curtains`, and
reverse-direction ON never lights the marker again. The rule's boundary is
asserted from both sides (take the action away — the primary speaks again;
give it back — the cover does), a lit lamp with a travelling cover keeps its
yellow and its own icon, and the auditor's own DEV-2C947-04 shape (both
entities VISIBLE) is pinned for the tap as well. Eight checks are red on the
parent commit.
This commit is contained in:
Matysh
2026-08-04 01:45:05 +03:00
parent ade8daab16
commit de53d530fa
7 changed files with 217 additions and 32 deletions
File diff suppressed because one or more lines are too long
+106 -1
View File
@@ -29,6 +29,10 @@ const out = await page.evaluate(async () => {
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
// a MIXED device: a lamp that also happens to ship a cover. Its primary is
// meaningfully the light, and nothing below may take that away from it.
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
};
const coverState = (state, attrs = {}) => ({
entity_id: 'cover.office_curtain', state,
@@ -39,24 +43,39 @@ const out = await page.evaluate(async () => {
...c.hass,
devices: { ...c.hass.devices,
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null } },
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null },
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null } },
entities: { ...c.hass.entities, ...ENTS },
states: { ...c.hass.states,
'cover.office_curtain': coverState(state, attrs),
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
'cover.mixed': { entity_id: 'cover.mixed', state: 'opening', attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
},
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
};
c._regSignature = '';
c._maybeRebuildDevices();
// park both extra markers in their own corners: the DOM assertions below
// find a marker by its position, and the auto grid would sit them on top
// of the demo house's own bedroom devices
const cu = c._devices.find((x) => x.bindingRef === 'd_curtain');
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
if (cu && mx) {
c._layout = { ...c._layout,
[cu.id]: { s: cu.space, x: 0.06, y: 0.94 },
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 } };
}
c.requestUpdate();
await c.updateComplete;
};
await push('closed', { device_class: 'curtain' });
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
o.deviceIsOnThePlan = !!curtain();
// the premise of the whole report: the cover is NOT the primary entity
o.primaryIsNotTheCover = curtain()?.primary === 'switch.office_curtain_reverse';
@@ -117,6 +136,92 @@ const out = await page.evaluate(async () => {
c._markerDialog = null;
c._setMode('view'); await c.updateComplete;
// ---- and the marker SHOWS that cover, not the service switch -----------
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
// "открыто", ни морфинга иконки». Same cause, same helper: _stateClass and
// the icon morph read d.primary, so the plan reported the state of
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
// exactly when the owner has explicitly chosen «Открыть/закрыть».
const devEl = (dev) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(dev);
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 clsOf = (dev) => [...(devEl(dev)?.classList || [])];
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
const setSwitch = async (state) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state, attributes: {} } } };
c.requestUpdate(); await c.updateComplete;
};
await push('closed', { device_class: 'curtain' });
o.markerIsOnScreen = !!devEl(curtain());
o.closedIsNeitherOpenNorMoving =
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('covermove');
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
await push('open', { device_class: 'curtain' });
o.openWearsTheOpenFrame = clsOf(curtain()).includes('open');
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
await push('opening', { device_class: 'curtain' });
o.travellingBreathes = clsOf(curtain()).includes('covermove');
await push('closing', { device_class: 'curtain' });
o.closingBreathesToo = clsOf(curtain()).includes('covermove');
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('covermove');
// the service switch never speaks for the marker again: reverse-direction
// ON used to paint the curtain yellow («включено») while it stood still
await push('closed', { device_class: 'curtain' });
await setSwitch('on');
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
// THE BOUNDARY of the rule: take the explicit action away and the marker
// goes back to its primary entity — no cover indication is invented
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
savedMarker.tap_action = 'info';
await push('opening', { device_class: 'curtain' });
await setSwitch('on');
o.withoutTheActionThePrimarySpeaks = clsOf(curtain()).includes('on')
&& !clsOf(curtain()).includes('covermove');
savedMarker.tap_action = 'cover';
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
// ---- the auditor's own probe: BOTH entities visible ---------------------
// DEV-2C947-04 was written up with a plain visible `cover.*` beside a
// visible `switch.*`: no hidden tier involved, `switch` simply outranks
// `cover` inside DOMAIN_PRIORITY. Same premise, same cure — pinned here so
// the finding cannot come back through the other door.
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
o.visibleCoverIsStillNotThePrimary = curtain()?.primary === 'switch.office_curtain_reverse';
calls.length = 0;
await tap();
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
o.visibleCoverAlsoIndicates = iconOf(curtain()) === 'mdi:curtains-closed';
o.noInfoCardForTheVisibleCover = !c._infoCard;
// ---- a MIXED device is not hijacked ------------------------------------
// A lit lamp that also owns a cover keeps the lamp: no explicit «Open/close»
// on this marker, so nothing changes for it — and even if there were one,
// a shining light still wins the yellow plate (the glow and the badge may
// never disagree, owner's principle 2026-07-29).
o.mixedPrimaryIsTheLight = mixed()?.primary === 'light.mixed';
o.mixedCoverIsTravelling = c.hass.states['cover.mixed'].state === 'opening';
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('covermove');
// its icon is the device's own auto icon, NOT the cover's open/closed pair
o.mixedIconIsNotMorphedByTheCover =
iconOf(mixed()) === mixed()?.icon && !iconOf(mixed()).includes('curtains');
return o;
});
checkAll(out);
File diff suppressed because one or more lines are too long
+8 -8
View File
File diff suppressed because one or more lines are too long
+37
View File
@@ -68,3 +68,40 @@ the old behaviour until an editing client materialises it.
one way to hide); a virtual device's "Delete" remains a real deletion.
- Duplicate names are still numbered, light groups still fold — those are
aggregation, not hiding.
## What a marker SHOWS
A marker's live indication — the yellow «on» plate, the «open» frame, the
breathing `covermove` ring, the state-morphed icon, the ripple — speaks for
ONE entity of the device, resolved in this order:
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
«Открыть/закрыть» (`tap_action: 'cover'` — `coverEntityOf`, the same helper
and the same entity the tap drives);
4. otherwise the **primary** entity (`primaryEntity`).
Rule 3 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 «open» frame, no
`curtains` / `curtains-closed` morph, and a yellow «включено» plate whenever
the 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 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
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.
+10
View File
@@ -299,6 +299,16 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
card. The guarded class is read off that same cover, so a garage still
degrades to info and is still not offered in the dialog
[auto: smoke_cover_not_primary + unit coverEntityOf]
- [ ] 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 «open» frame when it is open,
the `mdi:curtains-closed` / `mdi:curtains` morph, and no yellow plate
when `switch.*_reverse_direction` happens to be on. The rule is exactly
the explicit action (docs/FILTERING.md «What a marker SHOWS»): take the
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)]
- [ ] 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
+40 -7
View File
@@ -1548,6 +1548,29 @@ class HouseplanCard extends LitElement {
// ================= live states =================
/**
* The device's own cover, when the marker is EXPLICITLY «Open/close»
* (`tap_action: 'cover'`) — otherwise null. One helper, one answer: the tap
* acts on it, the badge speaks for it, the icon morphs with it.
*
* The rule and why it is the least surprising one (owner, 2026-08-04 —
* docs/FILTERING.md «What a marker SHOWS»): a marker keeps indicating its
* PRIMARY entity, unless its owner has said, in the marker dialog, that
* this thing is a curtain. Saying so is the only statement the card has
* that means «the cover is what this device does»; a mixed marker (a lamp
* with a sensor, a TRV with a service switch) is never touched behind the
* user's back, and there is no third notion of «what this marker is»
* besides the option the dialog offers and the entity the tap drives.
*/
private _coverIndicator(d: DevItem): string | null {
return d.tapAction === 'cover' ? coverEntityOf(d.entities) : null;
}
/** The entity a marker's tap acts on and its indication speaks for. */
private _actEntity(d: DevItem): string | undefined {
return this._coverIndicator(d) || d.primary;
}
private _stateClass(d: DevItem): string {
if (!this._config?.live_states) return '';
// an icon with controlled targets mirrors THEM, not its own entity
@@ -1562,13 +1585,19 @@ 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';
const p = d.primary ? this.hass.states[d.primary] : undefined;
// 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.
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 = d.primary!.split('.')[0];
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"),
@@ -1711,8 +1740,8 @@ class HouseplanCard extends LitElement {
// switch used to resolve on the domain `switch` and fall back to the info
// card, which is exactly what the owner saw (2026-08-04). The guarded
// class is read off that same cover, so a garage still degrades.
const coverEid = d.tapAction === 'cover' ? coverEntityOf(d.entities) : null;
const actEid = coverEid || d.primary;
const coverEid = this._coverIndicator(d);
const actEid = this._actEntity(d);
const domain = actEid ? actEid.split('.')[0] : null;
// the accidental-tap guard (owner's spec 2026-07-29): any state-changing
// action — toggle or run — may ask first. The dialog is ours, not the
@@ -6353,7 +6382,11 @@ class HouseplanCard extends LitElement {
// ghost keeps the base icon and name — display modes are status dressing
const ripple = (disp === 'ripple' || disp === 'icon_ripple') && !d.hidden;
// value-only display: the measurement IS the marker
const primarySt = d.primary ? this.hass.states[d.primary] : undefined;
// 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.
const actEid = this._actEntity(d);
const primarySt = actEid ? this.hass.states[actEid] : undefined;
const valText = disp === 'value' && !d.hidden
? (temp != null ? temp + '°'
: hum != null ? hum + '%'
@@ -6362,7 +6395,7 @@ class HouseplanCard extends LitElement {
: null)
: null;
// live state variants of the auto icon (doors, locks, bulbs), like core HA
const domain = d.primary ? d.primary.split('.')[0] : null;
const domain = actEid ? actEid.split('.')[0] : null;
const icon = this._config?.live_states && !d.hidden
? stateIcon(d.icon, domain, primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
: d.icon;
@@ -6379,7 +6412,7 @@ class HouseplanCard extends LitElement {
// 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 && !!d.primary && isActiveState(this.hass.states[d.primary]?.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;