mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
@@ -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
Vendored
+48
-36
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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>`
|
||||||
|
|||||||
+6
-4
@@ -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."
|
||||||
|
}
|
||||||
+6
-4
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user