filtering: hiding is an explicit per-device flag (docs/FILTERING.md)

Agreed with the owner: whether a device is on the plan is a CHECKBOX
('Hide device from plan', every kind incl. virtual), not a runtime
algorithm. The old filter survives only as the SEEDER of those flags.

- marker.hidden is the flag; hidden devices are BUILT (room LQI counts
  them — owner's decision) but rendered only in the device editor with
  'Show hidden' on, ghosted. They cast no glow and no light fill: an
  invisible device casts no visible light (owner's decision).
- seedHiddenBindings(): non-physical devices (excluded domains, Group,
  scene, bridge, myheat children, grouped lamps) in bound areas WITHOUT a
  marker. The editing client materialises them into hidden:true stub
  markers, sets settings.filter_seeded, retires settings.show_all, and
  strips fresh-hidden ids from the red-dot list. Unticking the checkbox
  keeps a hidden:false marker — the seeder never revisits a marked device,
  so the user's decision is final. New non-physical devices hide silently;
  physical ones keep the red-dot flow.
- legacy configs (no filter_seeded) keep the OLD behaviour verbatim —
  runtime filter, shared show_all, hidden-means-gone — until an editing
  client materialises them, so a read-only tablet never sees a half-state.
- 'Show all' is renamed 'Show hidden' and is LOCAL to the tab; the shared
  settings.show_all retires with the runtime filter.
- 'Remove from plan' disappears for auto/entity devices (the checkbox is
  the way); a virtual device's Delete remains a real deletion.
- docs/FILTERING.md is the source of truth for the mechanism.

Tests: seeder/seeded/legacy/lights units (146), smoke_hidden_flag with 12
assertions (68 smokes). Inventory: 146 / 51 / 43 / 68.
This commit is contained in:
Matysh
2026-07-29 11:35:35 +03:00
parent 996a7442ec
commit 694e1e9a3b
13 changed files with 457 additions and 101 deletions
+69
View File
@@ -0,0 +1,69 @@
// docs/FILTERING.md: скрытие — явная галка. Конфиг материализуется сеятелем
// (filter_seeded), галка в диалоге у всех устройств, «Показать скрытые» —
// локальный режим редактора, скрытые рисуются призраками и только там.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// --- сеятель материализует конфиг при загрузке ---------------------------
out.configSeeded = await page.evaluate(async () => {
const c = window.__card;
const t0 = Date.now();
while (!c._serverCfg?.settings?.filter_seeded && Date.now() - t0 < 3000) {
await new Promise((r) => setTimeout(r, 60));
}
return c._serverCfg?.settings?.filter_seeded === true;
});
// --- галка прячет, счётчик не считает, призрак только в редакторе --------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const visibleIds = () => [...sr().querySelectorAll('.dev')].length;
const before = visibleIds();
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
o.hiddenNotRendered = visibleIds() === before - 1;
const built = c._devices.find((x) => x.id === d.id);
o.stillBuilt = !!built && built.hidden === true;
o.countExcludesHidden = (sr().querySelector('.count')?.textContent || '').includes(String(before - 1));
// в просмотре тумблера нет эффекта — призраки только в редакторе устройств
c._showHidden = true; c.requestUpdate(); await c.updateComplete;
o.noGhostsInView = !sr().querySelector('.dev.ghost');
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
o.ghostInEditor = !!sr().querySelector('.dev.ghost');
// тумблер локальный: конфиг не трогается
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
// --- галка в диалоге у авто-устройства, кнопки «Удалить» нет -----------
const ghost = c._devices.find((x) => x.id === d.id);
c._openMarkerDialog(ghost); await c.updateComplete;
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
o.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
await c._saveMarker(); await c.updateComplete;
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:' + d.bindingRef);
o.untickKeepsMarker = !!m && m.hidden === false;
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
o.deviceVisibleAgain = !!back && !back.hidden;
// --- у виртуального кнопка «Удалить» есть -------------------------------
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virt = c._devices.find((x) => x.virtual);
c._openMarkerDialog(virt); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
c._markerDialog = null; c._setMode('view');
return o;
}));
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+48 -36
View File
File diff suppressed because one or more lines are too long
+61
View File
@@ -0,0 +1,61 @@
# Filtering: the explicit "hide from plan" flag
Agreed with the owner 2026-07-29. This document is the source of truth for the
mechanism; the code follows it.
## Principle
Whether a device is on the plan is an EXPLICIT, per-device fact: the
"Hide from plan" checkbox, stored as `marker.hidden`. The old on-the-fly
filtering algorithm survives only as the SEEDER of those flags — it decides
the initial value once, and the user owns the flag from then on.
## Data model
- `marker.hidden: true` — hidden from the plan. For an auto device without a
marker, hiding creates a stub marker (this mechanism predates this spec).
- `marker.hidden: false` (marker present) — explicitly VISIBLE: the seeder
never touches a device that has any marker, so unhiding must KEEP the stub
marker. That is the re-seed protection.
- No marker — never evaluated by the seeder yet, or a plain physical device.
- `settings.filter_seeded: true` — this config has been materialised.
- `settings.show_all` — removed (deleted during materialisation). The old
toggle was shared config state; the new one is a local editor tool.
## Seeding
"Non-physical" = the old filter rules: excluded integration domains, model
"Group", scene-like models, bridges, myheat children, and individual lamp
devices in an area covered by a light group (when group folding is on).
The seeder runs on the editing client (write permission required) whenever
devices rebuild, and creates `hidden: true` stub markers for non-physical
devices in BOUND areas that have NO marker. It is idempotent: marked devices
are never revisited. It fires on:
1. first load of a config without `filter_seeded` (materialises the current
behaviour; nothing changes visually, the flags become real and editable);
2. an area newly bound to the plan;
3. a new device appearing in a bound area — non-physical ones are hidden
silently (no red dot); physical ones keep the red-dot flow.
Until a config is seeded (`filter_seeded` absent), `buildDevices` applies the
LEGACY runtime filter, so a read-only client on an old config sees exactly
the old behaviour until an editing client materialises it.
## Behaviour
- Hidden devices ARE built (flagged `hidden`), but not rendered in any mode,
except the device editor with "Show hidden devices" on — there they render
ghosted (translucent, dashed) and clicking opens the dialog to untick.
- "Show hidden devices" (rename of "Show all") is LOCAL, ephemeral state of
the current tab.
- Room LQI counts hidden devices (owner's decision).
- Light fill and glow do NOT count hidden devices — an invisible device casts
no visible light (owner's decision). Room climate is registry-wide and
unaffected, as before.
- The checkbox appears in the dialog of EVERY device kind, virtual included.
- "Remove from plan" disappears for auto/entity devices (the checkbox is the
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.
+8
View File
@@ -239,6 +239,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and [auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved] neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Hide-from-plan flag (dev, docs/FILTERING.md): every device dialog has
the checkbox, incl. virtual; hidden devices vanish from every mode and
the count, still count toward room LQI, cast no glow/light fill; the
device editor's "Show hidden" (local, per tab) shows them ghosted;
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
[auto: smoke_hidden_flag + unit seedHiddenBindings/seeded/legacy]
- [ ] Yellow means working (dev): a TRV whose hvac_action is heating glows - [ ] Yellow means working (dev): a TRV whose hvac_action is heating glows
yellow; one that is merely enabled (idle) or has a service switch on yellow; one that is merely enabled (idle) or has a service switch on
(anti-scaling, child lock) stays dark; a lit light yellows its icon in (anti-scaling, child lock) stays dark; a lit light yellows its icon in
+54 -9
View File
@@ -14,6 +14,8 @@ export interface BuildCtx {
markers: Marker[]; markers: Marker[];
settings: ServerConfig['settings']; settings: ServerConfig['settings'];
excluded: Set<string>; excluded: Set<string>;
/** LEGACY only: honoured while the config has no settings.filter_seeded.
* Seeded configs hide by explicit marker flags (docs/FILTERING.md). */
showAll: boolean; showAll: boolean;
firstSpaceId: string; firstSpaceId: string;
/** Localized display strings for generated device names. */ /** Localized display strings for generated device names. */
@@ -202,6 +204,7 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
function applyMarker(item: DevItem, m: Marker): void { function applyMarker(item: DevItem, m: Marker): void {
item.marker = m; item.marker = m;
if (m.hidden) item.hidden = true;
if (m.name) item.name = m.name; if (m.name) item.name = m.name;
if (m.icon) item.icon = m.icon; if (m.icon) item.icon = m.icon;
if (m.model != null) item.model = m.model; if (m.model != null) item.model = m.model;
@@ -211,6 +214,43 @@ function applyMarker(item: DevItem, m: Marker): void {
item.tapAction = m.tap_action ?? null; item.tapAction = m.tap_action ?? null;
} }
/**
* The SEEDER (docs/FILTERING.md): bindings of non-physical devices in bound
* areas that have no marker yet. The editing client turns each into a
* `hidden: true` stub marker — after that the flag belongs to the user, and a
* marker of ANY kind (even `hidden: false`) makes the device invisible to
* this function forever. Idempotent by construction.
*/
export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): string[] {
const { hass: h, areaToSpace, markers, settings, excluded, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const groupedAreas = new Set(groups.map((g) => g.area));
const entsBy = entitiesByDevice(h);
const marked = new Set(markers.map((m) => m.binding));
const out: string[] = [];
for (const dev of Object.values<any>(h.devices)) {
const area = dev.area_id;
if (!area || !areaToSpace[area]) continue;
if (dev.entry_type === 'service') continue;
if (marked.has('device:' + dev.id)) continue;
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
let nonPhysical =
excluded.has(dom)
|| dev.model === 'Group'
|| /scene/i.test(dev.model || '')
|| /bridge/i.test((dev.model || '') + (dev.name || ''))
|| (dom === 'myheat' && !!dev.via_device_id);
if (!nonPhysical && groupLights && groupedAreas.has(area)) {
const name = (dev.name_by_user || dev.name || '').trim();
if (resolveIcon(h, name, dev.model, entIds, iconRules) === 'mdi:lightbulb') nonPhysical = true;
}
if (nonPhysical) out.push('device:' + dev.id);
}
return out;
}
/** Filtering + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */ /** Filtering + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
export function buildDevices(ctx: BuildCtx): DevItem[] { export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx; const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
@@ -234,11 +274,13 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
if (dev.entry_type === 'service') continue; if (dev.entry_type === 'service') continue;
if (claimed.has('device:' + dev.id)) continue; // a marker will take over below if (claimed.has('device:' + dev.id)) continue; // a marker will take over below
const marker = markerFor('device', dev.id); const marker = markerFor('device', dev.id);
if (marker && marker.hidden) continue; if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely
const entIds = entsBy[dev.id] || []; const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds); const dom = domainOfDevice(h, dev, entIds);
// filtering (can be turned off with the “show all” toggle) // LEGACY runtime filter: only while the config is not yet materialised
if (!showAll) { // (docs/FILTERING.md). A seeded config hides by explicit marker flags.
const legacy = !settings.filter_seeded;
if (legacy && !showAll) {
if (excluded.has(dom)) continue; if (excluded.has(dom)) continue;
if (dev.model === 'Group') continue; if (dev.model === 'Group') continue;
if (/scene/i.test(dev.model || '')) continue; if (/scene/i.test(dev.model || '')) continue;
@@ -249,7 +291,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const key = name + '|' + area; const key = name + '|' + area;
let icon = resolveIcon(h, name, dev.model, entIds, iconRules); let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock'; if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue; if (legacy && !showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
// duplicates by “name|zone” are numbered rather than hidden // duplicates by “name|zone” are numbered rather than hidden
seen[key] = (seen[key] || 0) + 1; seen[key] = (seen[key] || 0) + 1;
const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name; const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name;
@@ -292,7 +334,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
// 3) explicit markers (rebinding/metadata/virtual) // 3) explicit markers (rebinding/metadata/virtual)
for (const m of markers) { for (const m of markers) {
if (m.hidden) continue; // Hidden is a FLAG now, not an absence: the device is built (room LQI
// still counts it) and the renderer decides. Legacy configs keep the old
// "hidden = gone" until they are seeded (docs/FILTERING.md).
if (m.hidden && !settings.filter_seeded) continue;
const [kind, ref] = m.binding.split(':'); const [kind, ref] = m.binding.split(':');
if (kind === 'device') { if (kind === 'device') {
const dev = h.devices[ref]; const dev = h.devices[ref];
@@ -370,10 +415,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
* Light situation of an area: 'on' if any light entity of the area's devices is on, * Light situation of an area: 'on' if any light entity of the area's devices is on,
* 'off' if lights exist but none is on, 'none' when the area has no lights at all. * 'off' if lights exist but none is on, 'none' when the area has no lights at all.
*/ */
export function areaLights(hass: any, devices: { area: string; entities: string[] }[], area: string): 'on' | 'off' | 'none' { export function areaLights(hass: any, devices: { area: string; entities: string[]; hidden?: boolean }[], area: string): 'on' | 'off' | 'none' {
let seen = false; let seen = false;
for (const d of devices) { for (const d of devices) {
if (d.area !== area) continue; if (d.area !== area || d.hidden) continue; // an invisible device casts no visible light
for (const eid of d.entities) { for (const eid of d.entities) {
if (!eid.startsWith('light.')) continue; if (!eid.startsWith('light.')) continue;
seen = true; seen = true;
@@ -530,13 +575,13 @@ export function areaClimate(
/** How many of the area's lights are on: {on, total}, or null without lights. */ /** How many of the area's lights are on: {on, total}, or null without lights. */
export function areaLightStats( export function areaLightStats(
hass: any, hass: any,
devices: { area: string; entities: string[] }[], devices: { area: string; entities: string[]; hidden?: boolean }[],
area: string, area: string,
): { on: number; total: number } | null { ): { on: number; total: number } | null {
const seen = new Set<string>(); const seen = new Set<string>();
let on = 0; let on = 0;
for (const dv of devices) { for (const dv of devices) {
if (dv.area !== area) continue; if (dv.area !== area || dv.hidden) continue; // hidden lights are not part of the picture
for (const eid of dv.entities) { for (const eid of dv.entities) {
if (!eid.startsWith('light.') || seen.has(eid)) continue; if (!eid.startsWith('light.') || seen.has(eid)) continue;
seen.add(eid); seen.add(eid);
+80 -9
View File
@@ -25,7 +25,7 @@ import {
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES, DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
} from './logic'; } from './logic';
import { ContentSigner } from './signing'; import { ContentSigner } from './signing';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices'; import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import type { import type {
OpeningCfg, OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig, RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -293,7 +293,8 @@ class HouseplanCard extends LitElement {
link: string; link: string;
description: string; description: string;
pdfs: PdfRef[]; pdfs: PdfRef[];
room: string; // 'space#area' for a virtual one room: string;
hideFromPlan: boolean; // 'space#area' for a virtual one
busy: boolean; busy: boolean;
} | null = null; } | null = null;
private _spaceDialog: { private _spaceDialog: {
@@ -671,13 +672,67 @@ class HouseplanCard extends LitElement {
return this._serverCfg?.settings || {}; return this._serverCfg?.settings || {};
} }
/** LOCAL editor tool (docs/FILTERING.md): show the hidden devices ghosted.
* The old toggle wrote settings.show_all shared state that flipped the
* plan for every wall tablet at once. Legacy configs still honour it
* through buildDevices until they are seeded. */
private _showHidden = false;
private get _showAll(): boolean { private get _showAll(): boolean {
return !!this._settings.show_all; return this._settings.filter_seeded ? this._showHidden : !!this._settings.show_all;
} }
private _toggleShowAll(): void { private _toggleShowAll(): void {
if (!this._serverCfg) return; if (!this._serverCfg) return;
this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._showAll } }; if (this._settings.filter_seeded) {
this._showHidden = !this._showHidden;
this.requestUpdate();
return;
}
// legacy config: the old shared behaviour until an editor materialises it
this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._settings.show_all } };
this._regSignature = '';
this._maybeRebuildDevices();
this._saveConfig();
this.requestUpdate();
}
/**
* The seeder (docs/FILTERING.md): materialise the filter into explicit
* hidden flags. Runs after every device rebuild on a client that may write;
* idempotent a device with ANY marker is never revisited, so an unticked
* checkbox (a marker with hidden: false) is re-seed protection. Also
* removes freshly hidden ids from the red-dot list: a dot on an invisible
* device would wait forever.
*/
private _seedHiddenDevices(): void {
if (!this._serverCfg || !this._norm || !this._canEdit) return;
const cfg = this._serverCfg;
const bindings = seedHiddenBindings({
hass: this.hass,
areaToSpace: Object.fromEntries(
Object.entries(this._areaToSpace).map(([a, v]) => [a, v.space]),
),
markers: this._markers,
settings: this._settings,
excluded: this._excluded,
firstSpaceId: this._model[0]?.id || '',
iconRules: this._iconRules,
});
if (!bindings.length && cfg.settings?.filter_seeded) return;
cfg.markers = cfg.markers || [];
const hiddenIds: string[] = [];
for (const b of bindings) {
const id = 'h' + b.slice(b.indexOf(':') + 1);
cfg.markers.push({ id, binding: b, hidden: true });
hiddenIds.push(b.slice(b.indexOf(':') + 1));
}
const st = { ...(cfg.settings || {}), filter_seeded: true } as any;
delete st.show_all; // the shared toggle retires with the runtime filter
if (hiddenIds.length && Array.isArray(st.new_device_ids)) {
st.new_device_ids = st.new_device_ids.filter((x: string) => !hiddenIds.includes(x));
}
cfg.settings = st;
this._regSignature = ''; this._regSignature = '';
this._maybeRebuildDevices(); this._maybeRebuildDevices();
this._saveConfig(); this._saveConfig();
@@ -1007,6 +1062,7 @@ class HouseplanCard extends LitElement {
}); });
this._defPos = this._defaultPositions(); this._defPos = this._defaultPositions();
this._syncNewDevices(); this._syncNewDevices();
this._seedHiddenDevices();
} }
/** /**
@@ -2753,6 +2809,7 @@ class HouseplanCard extends LitElement {
room: d.marker?.room_id room: d.marker?.room_id
? d.space + '#@' + d.marker.room_id ? d.space + '#@' + d.marker.room_id
: d.space && d.area ? d.space + '#' + d.area : '', : d.space && d.area ? d.space + '#' + d.area : '',
hideFromPlan: d.marker?.hidden === true,
busy: false, busy: false,
}; };
} else { } else {
@@ -2762,7 +2819,7 @@ class HouseplanCard extends LitElement {
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0, display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false, tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
glowRadius: '', model: '', glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false, link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false,
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
}; };
} }
@@ -2966,6 +3023,10 @@ class HouseplanCard extends LitElement {
link: dlg.link.trim() || null, link: dlg.link.trim() || null,
description: dlg.description.trim() || null, description: dlg.description.trim() || null,
pdfs: dlg.pdfs, pdfs: dlg.pdfs,
// Explicit false, not absence: a marker of any kind is what tells the
// seeder "the user decided" — unticking must not invite a re-hide
// (docs/FILTERING.md).
hidden: dlg.hideFromPlan ? true : false,
}; };
// save the room choice (always for virtual ones; for bound ones — if chosen) // save the room choice (always for virtual ones; for bound ones — if chosen)
if (dlg.binding === 'virtual' || dlg.room) { if (dlg.binding === 'virtual' || dlg.room) {
@@ -3612,6 +3673,7 @@ class HouseplanCard extends LitElement {
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = []; const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
for (const d of this._devices) { for (const d of this._devices) {
if (d.space !== space.id) continue; if (d.space !== space.id) continue;
if (d.hidden) continue; // an invisible device casts no visible light (docs/FILTERING.md)
// A light source is normally a device with a lit light.* entity. With the // A light source is normally a device with a lit light.* entity. With the
// "is a light source" flag (field request: a smart SWITCH driving dumb // "is a light source" flag (field request: a smart SWITCH driving dumb
// fixtures) any lit entity counts — the switch itself, or the lights it // fixtures) any lit entity counts — the switch itself, or the lights it
@@ -3895,7 +3957,11 @@ class HouseplanCard extends LitElement {
} }
const space = this._spaceModel(); const space = this._spaceModel();
const vb = space.vb; const vb = space.vb;
const devs = this._devices.filter((d) => d.space === space.id); // hidden devices render ONLY in the device editor with "show hidden" on
// (ghosted); everywhere else the flag removes them from sight — but not
// from the build, so room LQI still counts them (docs/FILTERING.md)
const showGhosts = this._mode === 'devices' && this._showAll;
const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
const disp = spaceDisplayOf(this._curSpaceCfg); const disp = spaceDisplayOf(this._curSpaceCfg);
const showLqi = disp.showLqi ?? this._config.show_signal ?? true; const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5; const cfgSize = this._config.icon_size ?? 2.5;
@@ -3954,7 +4020,7 @@ class HouseplanCard extends LitElement {
)} )}
</div>` </div>`
: nothing} : nothing}
<span class="count">${this._t('count.devices', { n: devs.length })}</span> <span class="count">${this._t('count.devices', { n: devs.filter((d) => !d.hidden).length })}</span>
<span class="spacer"></span> <span class="spacer"></span>
<div class="zoomctl"> <div class="zoomctl">
<button class="btn zb" @click=${() => this._stepZoom(-1)} title=${this._t('title.zoom_out')}><ha-icon icon="mdi:minus"></ha-icon></button> <button class="btn zb" @click=${() => this._stepZoom(-1)} title=${this._t('title.zoom_out')}><ha-icon icon="mdi:minus"></ha-icon></button>
@@ -4184,7 +4250,7 @@ class HouseplanCard extends LitElement {
} }
if (lightC) st.push(`--light-color:${lightC}`); if (lightC) st.push(`--light-color:${lightC}`);
return html`<div return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${lightC ? 'rgb' : ''} ${alarm ? 'alarm' : ''}" class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${d.hidden ? 'ghost' : ''} ${disp === 'ripple' ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${lightC ? 'rgb' : ''} ${alarm ? 'alarm' : ''}"
style="${st.join(';')}" style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)} @click=${(e: MouseEvent) => this._clickDevice(e, d)}
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)} @contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
@@ -5241,6 +5307,11 @@ class HouseplanCard extends LitElement {
@change=${(e: Event) => (this._markerDialog = { ...d, isLight: (e.target as HTMLInputElement).checked })} /> @change=${(e: Event) => (this._markerDialog = { ...d, isLight: (e.target as HTMLInputElement).checked })} />
<span>${this._t('marker.is_light')}</span> <span>${this._t('marker.is_light')}</span>
</label> </label>
<label class="srcrow" title=${this._t('marker.hide_tip')}>
<input type="checkbox" .checked=${d.hideFromPlan}
@change=${(e: Event) => (this._markerDialog = { ...d, hideFromPlan: (e.target as HTMLInputElement).checked })} />
<span>${this._t('marker.hide')}</span>
</label>
<label>${this._t('marker.glow_radius_label')}</label> <label>${this._t('marker.glow_radius_label')}</label>
<div class="colorrow"> <div class="colorrow">
<input class="tempin" type="number" min="0.5" step="0.5" <input class="tempin" type="number" min="0.5" step="0.5"
@@ -5325,7 +5396,7 @@ class HouseplanCard extends LitElement {
</div> </div>
</div> </div>
<div class="row"> <div class="row">
${d.devId ${d.devId && d.binding === 'virtual'
? html`<button class="btn danger" @click=${this._deleteMarker}> ? html`<button class="btn danger" @click=${this._deleteMarker}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.remove')} <ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.remove')}
</button>` </button>`
+5 -3
View File
@@ -21,7 +21,7 @@
"title.zoom_out": "Zoom out", "title.zoom_out": "Zoom out",
"title.zoom_reset": "Reset zoom", "title.zoom_reset": "Reset zoom",
"title.add_device": "Add a device to the plan", "title.add_device": "Add a device to the plan",
"title.show_all": "Show all area devices (no filtering)", "title.show_all": "Show hidden devices (ghosted, this tab only)",
"title.markup": "Room markup: grid, lines, outlines", "title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space", "title.configure_space": "Configure space",
"title.add_space": "Add space", "title.add_space": "Add space",
@@ -244,7 +244,7 @@
"opening.lock_pending": "Working…", "opening.lock_pending": "Working…",
"title.close_editor": "Close editor (back to view)", "title.close_editor": "Close editor (back to view)",
"devbar.add": "Add", "devbar.add": "Add",
"devbar.show_all": "Show all", "devbar.show_all": "Show hidden",
"devbar.rules": "Icon rules", "devbar.rules": "Icon rules",
"space.roomcard_section": "Room card shows:", "space.roomcard_section": "Room card shows:",
"space.label_temp": "Temperature", "space.label_temp": "Temperature",
@@ -338,5 +338,7 @@
"btn.use": "Use", "btn.use": "Use",
"confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.", "confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.",
"toast.plans_list_failed": "Could not list the stored plans: {err}", "toast.plans_list_failed": "Could not list the stored plans: {err}",
"toast.plan_delete_failed": "Could not delete the plan: {err}" "toast.plan_delete_failed": "Could not delete the plan: {err}",
"marker.hide": "Hide device from plan",
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor."
} }
+5 -3
View File
@@ -21,7 +21,7 @@
"title.zoom_out": "Отдалить", "title.zoom_out": "Отдалить",
"title.zoom_reset": "Сбросить масштаб", "title.zoom_reset": "Сбросить масштаб",
"title.add_device": "Добавить устройство на план", "title.add_device": "Добавить устройство на план",
"title.show_all": "Показывать все устройства зоны (без фильтрации)", "title.show_all": "Показать скрытые устройства (полупрозрачными, только в этой вкладке)",
"title.markup": "Разметка комнат: сетка, линии, контуры", "title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство", "title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство", "title.add_space": "Добавить пространство",
@@ -244,7 +244,7 @@
"opening.lock_pending": "Выполняется…", "opening.lock_pending": "Выполняется…",
"title.close_editor": "Закрыть редактор (вернуться к просмотру)", "title.close_editor": "Закрыть редактор (вернуться к просмотру)",
"devbar.add": "Добавить", "devbar.add": "Добавить",
"devbar.show_all": "Показать все", "devbar.show_all": "Показать скрытые",
"devbar.rules": "Правила иконок", "devbar.rules": "Правила иконок",
"space.roomcard_section": "В карточке комнаты:", "space.roomcard_section": "В карточке комнаты:",
"space.label_temp": "Температура", "space.label_temp": "Температура",
@@ -338,5 +338,7 @@
"btn.use": "Выбрать", "btn.use": "Выбрать",
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.", "confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
"toast.plans_list_failed": "Не удалось получить список планов: {err}", "toast.plans_list_failed": "Не удалось получить список планов: {err}",
"toast.plan_delete_failed": "Не удалось удалить план: {err}" "toast.plan_delete_failed": "Не удалось удалить план: {err}",
"marker.hide": "Скрыть устройство с плана",
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств."
} }
+2 -1
View File
@@ -65,7 +65,8 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
loc, loc,
iconRules, iconRules,
}); });
const devs = all.filter((d) => d.space === o.spaceId); // the static card never shows hidden devices — there is no editor here
const devs = all.filter((d) => d.space === o.spaceId && !d.hidden);
const defPos = defaultPositions(devs, space, iconPct); const defPos = defaultPositions(devs, space, iconPct);
const roomShapes = space.rooms const roomShapes = space.rooms
+7
View File
@@ -963,6 +963,13 @@ export const cardStyles = css`
.dev.virtual { .dev.virtual {
border-style: dashed; border-style: dashed;
} }
/* "hide from plan" flag, shown only in the device editor with the
"show hidden devices" toggle on (docs/FILTERING.md) */
.dev.ghost {
opacity: 0.4;
border-style: dashed;
filter: saturate(0.4);
}
.dev.sel { .dev.sel {
border-color: #ffc14d; border-color: #ffc14d;
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35); box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
+6 -1
View File
@@ -90,7 +90,9 @@ export interface ServerConfig {
settings: { settings: {
exclude_integrations?: string[]; exclude_integrations?: string[];
group_lights?: boolean; group_lights?: boolean;
show_all?: boolean; show_all?: boolean; // legacy: removed when the config is materialised
/** The filter flags are materialised into markers (docs/FILTERING.md). */
filter_seeded?: boolean;
icon_rules?: { pattern: string; icon: string }[]; icon_rules?: { pattern: string; icon: string }[];
}; };
} }
@@ -101,6 +103,9 @@ export interface DevItem {
model: string; model: string;
area: string; area: string;
space: string; space: string;
/** "Hide from plan": built (so room LQI still counts it) but not rendered,
* except ghosted in the device editor with "show hidden" on. */
hidden?: boolean;
icon: string; icon: string;
entities: string[]; entities: string[];
primary?: string; primary?: string;
+62 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity } from '../test-build/devices.js'; import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js'; import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */ /** Minimal fake hass around the pieces buildDevices reads. */
@@ -524,3 +524,64 @@ test('litLightEntity: one truth for "this thing is shining"', () => {
'switch.wall', 'switch.wall',
); );
}); });
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
test('seedHiddenBindings: non-physical devices in bound areas, unmarked only', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
bridge: dev('bridge', 'Z2M Bridge', 'Bridge', 'living'),
grp: dev('grp', 'All lights', 'Group', 'living'),
scn: dev('scn', 'Evening', 'Scene switch', 'living'),
faraway: dev('faraway', 'Bridge 2', 'Bridge', 'garage'), // unbound area
}});
const ctx = { hass: h, areaToSpace: { living: 'f1' }, markers: [], settings: {},
excluded: new Set(['hacs']), firstSpaceId: 'f1' };
assert.deepEqual(seedHiddenBindings(ctx).sort(), ['device:bridge', 'device:grp', 'device:scn']);
// a marker of ANY kind — even hidden: false — is the user's decision
const marked = { ...ctx, markers: [
{ id: 'x', binding: 'device:bridge', hidden: false },
{ id: 'y', binding: 'device:grp', hidden: true },
] };
assert.deepEqual(seedHiddenBindings(marked), ['device:scn'], 'marked devices are never revisited');
});
test('seeded config: hidden is a flag, not an absence', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
plug: dev('plug', 'Plug', 'Socket', 'living'),
}});
const res = buildDevices(baseCtx(h, {
settings: { filter_seeded: true },
markers: [{ id: 'hplug', binding: 'device:plug', hidden: true }],
}));
const plug = res.find((d) => d.bindingRef === 'plug');
assert.ok(plug, 'the hidden device is BUILT — room LQI still counts it');
assert.equal(plug.hidden, true, 'and flagged for the renderer');
assert.ok(!res.find((d) => d.id === 'lamp').hidden);
});
test('legacy config (no filter_seeded): the runtime filter still applies', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
bridge: dev('bridge', 'Hub', 'Bridge', 'living'),
}});
const res = buildDevices(baseCtx(h));
assert.deepEqual(res.map((d) => d.id), ['lamp'], 'a read-only client on an old config sees the old behaviour');
// and a hidden marker still removes the device entirely, as before
const res2 = buildDevices(baseCtx(h, { markers: [{ id: 'x', binding: 'device:lamp', hidden: true }] }));
assert.deepEqual(res2.map((d) => d.id), []);
});
test('hidden lights cast no visible light, but count toward LQI', () => {
const h = mkHass({
devices: {},
states: { 'light.a': { state: 'on', attributes: {} } },
});
const devices = [
{ area: 'living', entities: ['light.a'], hidden: true },
];
assert.equal(areaLights(h, devices, 'living'), 'none', 'an invisible device casts no visible light');
assert.equal(areaLightStats(h, devices, 'living'), null);
});