mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
feat: discovery filters become visible, explained and previewable (#44)
Light grouping and the excluded-integrations list move from hidden keys to a Discovery-filters section on the catalog's Available tab: a toggle (unset = on, the legacy behaviour), searchable integration chips with a Restore-recommended reset (defaults stored as key absence), and appear/disappear counters computed by diffing the REAL seedHiddenBindings/buildDevices outputs — no second copy of the filter. Saving writes settings once over the ordinary expected_rev path. Every excluded candidate now names its integration in the catalog. Room climate follows the same user exclusions through the single effectiveExcludedIntegrations resolver (spec H2); explicit climate opt-in stays stronger. The field registry passports both keys as current supported settings. User-Visible: yes Issue: #44
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// #44: the Discovery-filters section makes group_lights and
|
||||
// exclude_integrations visible, previewable and saved in one write.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 980, height: 820 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = () => c.renderRoot || c.shadowRoot;
|
||||
const writes = [];
|
||||
const base = c.hass.callWS;
|
||||
let rev = 70;
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
writes.push(JSON.parse(JSON.stringify({
|
||||
group_lights: m.config.settings?.group_lights ?? null,
|
||||
exclude_integrations: m.config.settings?.exclude_integrations ?? null,
|
||||
})));
|
||||
rev += 1;
|
||||
return { ok: true, rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
c._cfgRev = rev;
|
||||
const result = {};
|
||||
|
||||
c._setMode('devices');
|
||||
await c.updateComplete;
|
||||
c._openDeviceInbox();
|
||||
await c.updateComplete;
|
||||
const tabs = [...root().querySelectorAll('.device-inbox-tabs [role="tab"]')];
|
||||
tabs[1]?.click(); // «Доступны»
|
||||
await c.updateComplete;
|
||||
const section = root().querySelector('.device-inbox-discovery');
|
||||
result.sectionOnAvailable = !!section;
|
||||
section?.querySelector('summary')?.click();
|
||||
await c.updateComplete;
|
||||
|
||||
// AC1: тумблер группировки
|
||||
const runtime = c._editorRuntime;
|
||||
const dialog = () => c._deviceInbox;
|
||||
const toggle = root().querySelector('.device-inbox-discovery .srcrow input');
|
||||
result.toggleDefaultOn = !!toggle?.checked;
|
||||
toggle.checked = false;
|
||||
toggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
|
||||
// AC2: добавить исключение — превью-счётчик «скроется» ненулевой
|
||||
// демо: все устройства платформы 'demo' — исключение честно всё материализует
|
||||
const anyIntegration = 'demo';
|
||||
// AC3: явный маркер той же интеграции обязан пережить запись фильтров
|
||||
c._serverCfg = { ...c._serverCfg, markers: [...(c._serverCfg.markers || []), {
|
||||
id: 'explicit-44', binding: 'device:d_lamp', space: c._serverCfg.spaces[0].id,
|
||||
}] };
|
||||
const beforeExcluded = runtime._discoveryFilterState(dialog()).excluded.length;
|
||||
c._deviceInbox = { ...dialog(), draftExcluded: [
|
||||
...runtime._discoveryFilterState(dialog()).excluded, anyIntegration,
|
||||
] };
|
||||
await c.updateComplete;
|
||||
const preview = runtime._discoveryFilterPreview(dialog());
|
||||
result.previewHides = preview.hide > 0;
|
||||
result.previewLightsCounted = preview.lights >= 0;
|
||||
|
||||
// Save: одна запись, ключи в конфиге
|
||||
await runtime._saveDiscoveryFilters(dialog());
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
result.oneWrite = writes.length === 1;
|
||||
result.writeCarriesBoth = writes[0]
|
||||
&& writes[0].group_lights === false
|
||||
&& Array.isArray(writes[0].exclude_integrations)
|
||||
&& writes[0].exclude_integrations.includes(anyIntegration)
|
||||
&& writes[0].exclude_integrations.length === beforeExcluded + 1;
|
||||
|
||||
// AC3: явный маркер пережил запись фильтров (в т.ч. в конфиге)
|
||||
result.markersUntouched = (c._serverCfg.markers || [])
|
||||
.some((marker) => marker.id === 'explicit-44');
|
||||
|
||||
// AC1-обратно: вернуть группировку и рекомендуемые → ключи удаляются
|
||||
const d2 = c._deviceInbox;
|
||||
c._deviceInbox = { ...d2, draftGroupLights: true, draftExcluded: null };
|
||||
await c.updateComplete;
|
||||
await runtime._saveDiscoveryFilters(c._deviceInbox);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
result.secondWrite = writes.length === 2;
|
||||
result.defaultsRemoveKeys = writes[1]
|
||||
&& writes[1].group_lights === null && writes[1].exclude_integrations === null;
|
||||
|
||||
// AC4: причина с именем интеграции — строим строку и проверяем текст
|
||||
const excludedNow = new Set([anyIntegration]);
|
||||
c._serverCfg = { ...c._serverCfg, settings: {
|
||||
...c._serverCfg.settings, exclude_integrations: [...excludedNow],
|
||||
} };
|
||||
c._deviceInboxMemo = null; c._regSignature = '';
|
||||
c._maybeRebuildDevices?.(); c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await c.updateComplete;
|
||||
// причина с именем интеграции живёт на строках reason=excluded_integration
|
||||
// (категория строк — какой была до задачи; вкладку не навязываем)
|
||||
const tabsNow = [...root().querySelectorAll('.device-inbox-tabs [role="tab"]')];
|
||||
let reasonNames = false;
|
||||
for (const tab of tabsNow) {
|
||||
tab.click();
|
||||
await c.updateComplete;
|
||||
const reasons = [...root().querySelectorAll('.device-inbox-reason')]
|
||||
.map((node) => node.textContent || '');
|
||||
if (reasons.some((text) => text.includes(anyIntegration))) { reasonNames = true; break; }
|
||||
}
|
||||
result.reasonNamesIntegration = reasonNames;
|
||||
|
||||
c._closeDeviceInbox?.();
|
||||
return result;
|
||||
});
|
||||
checkAll(out, ['sectionOnAvailable', 'toggleDefaultOn', 'previewHides', 'previewLightsCounted',
|
||||
'oneWrite', 'writeCarriesBoth', 'markersUntouched', 'secondWrite', 'defaultsRemoveKeys',
|
||||
'reasonNamesIntegration']);
|
||||
await finish(browser);
|
||||
@@ -1523,3 +1523,14 @@ other world honest against it:
|
||||
`enforcedBy`. The lifecycle fixtures in `test/fixtures/config-lifecycle/`
|
||||
pin the load contract: oldest-supported and future-field configs pass the
|
||||
schema losslessly.
|
||||
|
||||
## No hidden discovery knobs (#44, 2026-08-30)
|
||||
|
||||
Every stored key that shapes device discovery is a visible, supported setting
|
||||
or does not exist. `settings.group_lights` and `settings.exclude_integrations`
|
||||
are edited in the device catalog's Discovery-filters section; the ONE resolver
|
||||
`effectiveExcludedIntegrations()` (devices.ts) feeds discovery, the
|
||||
materialisation seed and room climate alike, and the preview in the dialog
|
||||
diffs the real `seedHiddenBindings`/`buildDevices` outputs — there is no
|
||||
second copy of the filter logic to drift. The field registry (#33) carries
|
||||
their passports; `scripts/config-audit.mjs` treats both as `current`.
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Device discovery lost its last hidden knobs: light grouping and the
|
||||
excluded-integrations list are now visible **Discovery filters** on the
|
||||
catalog's Available tab, with appear/disappear counters before saving and
|
||||
the integration named on every excluded candidate. Room climate now follows
|
||||
the same user-configured exclusions as discovery
|
||||
([#44](https://github.com/Matysh/houseplan-card/issues/44)).
|
||||
|
||||
- Old and future plan configs are now guarded by machinery instead of habit:
|
||||
the backend schema is dumped into a committed manifest, frontend and
|
||||
backend value lists are compared in CI with an explicit allow-list for the
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- У обнаружения устройств не осталось скрытых ручек: группировка света и
|
||||
список исключённых интеграций стали видимыми «Фильтрами обнаружения» на
|
||||
вкладке «Доступны» каталога — со счётчиками «появится/скроется» до записи
|
||||
и именем интеграции у каждого исключённого кандидата. Климат комнаты
|
||||
теперь следует тем же пользовательским исключениям, что и обнаружение
|
||||
([#44](https://github.com/Matysh/houseplan-card/issues/44)).
|
||||
|
||||
- Старые и будущие конфигурации плана теперь защищены механикой, а не
|
||||
привычкой: схема бэкенда выгружается в закоммиченный манифест, списки
|
||||
значений фронта и бэкенда сверяются в CI с явным allow-list'ом немногих
|
||||
|
||||
@@ -527,6 +527,13 @@ and its other entities remain deleted. The complete device stays available in
|
||||
- **Devices** opens one searchable lifecycle catalog. Its **On plan**,
|
||||
**Available**, **Hidden** and **Available again** tabs explain where every
|
||||
exact HA binding is and offer the next valid action;
|
||||
- **Discovery filters** (#44) live on the **Available** tab: a switch that
|
||||
groups room lights into one marker (on by default) and the list of excluded
|
||||
integrations with search and a "Restore recommended" reset. Changes show
|
||||
appear/disappear counters before anything is written; Save stores the
|
||||
settings once. Filters only affect automatic candidates — a device you
|
||||
placed explicitly never disappears because of them, and an excluded
|
||||
candidate names its integration in the catalog;
|
||||
- **Add virtual device** lives at the top of that catalog. Enable **Show
|
||||
entities** in **Available** to place an individual entity;
|
||||
- **Show hidden on plan** is a local catalog switch. It reveals user-hidden
|
||||
|
||||
@@ -783,6 +783,7 @@ binding tombstone не вырезает её из живого устройст
|
||||
| Клик по маркеру | Открывает диалог настройки |
|
||||
| **Добавить** | Сразу открывает диалог нового устройства, без перехода в каталог |
|
||||
| **Устройства** | Открывает единый каталог с поиском и вкладками «На плане», «Доступны», «Скрытые» и «Доступны снова»; у каждой привязки показаны причина, статус HA и допустимое следующее действие |
|
||||
| **Фильтры обнаружения** (#44) | Живут на вкладке «Доступны»: тумблер «Объединять светильники комнаты» (включён по умолчанию) и список исключённых интеграций с поиском и кнопкой «Вернуть рекомендуемые». Изменения показывают счётчики «появится/скроется» до записи; Сохранить пишет настройки один раз. Фильтры влияют только на автоматических кандидатов — явно размещённое устройство из-за них не исчезает, а исключённый кандидат называет свою интеграцию в каталоге |
|
||||
| **Добавить виртуальное устройство** | Находится вверху каталога и создаёт маркер без HA-привязки |
|
||||
| **Показывать скрытые на плане** | Локальный переключатель каталога: до выхода из редактора показывает скрытые и деактивированные маркеры служебными призраками, не меняя сохранённый флаг «Скрыть» |
|
||||
| **Правила иконок** | Открывает приоритетный список регулярных выражений для «имя + модель» |
|
||||
|
||||
@@ -44,14 +44,15 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([
|
||||
selector: { path: ['settings', 'group_lights'] },
|
||||
storage: 'House Plan server config',
|
||||
type: 'boolean',
|
||||
default: 'unset',
|
||||
default: 'unset (= true: room lights grouped)',
|
||||
level: 'global',
|
||||
ui: 'hidden',
|
||||
ui: 'device inbox → Discovery filters (#44)',
|
||||
runtime: 'device discovery/materialisation',
|
||||
introduced: 'legacy',
|
||||
status: 'decision-required',
|
||||
migration: 'decide supported UI versus fixed discovery rule before removal',
|
||||
compatibility: 'preserve unknown owner intent; never delete automatically yet',
|
||||
introduced: 'legacy; supported UI since #44',
|
||||
status: 'current',
|
||||
migration: 'none — a supported setting; true is stored as key absence',
|
||||
compatibility: 'unset keeps grouping on, exactly the legacy behaviour',
|
||||
enforcedBy: 'demo/smoke_discovery_filters.mjs (#44 AC1)',
|
||||
},
|
||||
{
|
||||
id: 'settings.exclude_integrations',
|
||||
@@ -59,14 +60,15 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([
|
||||
selector: { path: ['settings', 'exclude_integrations'] },
|
||||
storage: 'House Plan server config',
|
||||
type: 'string[]',
|
||||
default: 'unset',
|
||||
default: 'unset (= the product EXCLUDED_DOMAINS list)',
|
||||
level: 'global',
|
||||
ui: 'hidden',
|
||||
runtime: 'device discovery filter',
|
||||
introduced: 'legacy',
|
||||
status: 'decision-required',
|
||||
migration: 'decide supported UI versus fixed exclusion rules before removal',
|
||||
compatibility: 'preserve and continue applying',
|
||||
ui: 'device inbox → Discovery filters (#44)',
|
||||
runtime: 'device discovery, materialisation seed, room climate (#44 H2) — one resolver',
|
||||
introduced: 'legacy; supported UI since #44',
|
||||
status: 'current',
|
||||
migration: 'none — a supported setting; a present list REPLACES the product list wholesale',
|
||||
compatibility: 'unset keeps the product list; [] is a valid "exclude nothing"',
|
||||
enforcedBy: 'effectiveExcludedIntegrations (devices.ts) + demo/smoke_discovery_filters.mjs (#44 AC2)',
|
||||
},
|
||||
{
|
||||
id: 'settings.weather_entity',
|
||||
|
||||
@@ -735,6 +735,28 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " this._persistDecorStyle();\n }, 0);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'discovery-preview-copies-the-filter',
|
||||
guard: 'node --test test/devices.test.mjs',
|
||||
because: 'the preview must diff the REAL seedHiddenBindings/buildDevices outputs — a '
|
||||
+ 'hand-rolled platform check would drift from the production filter (#44 AC6)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: " const seededOf = (settings: object, excluded: ReadonlySet<string>) => new Set(\n seedHiddenBindings({ ...ctx, settings, excluded } as never));",
|
||||
replace: " const seededOf = (settings: object, excluded: ReadonlySet<string>) => new Set(\n [...excluded].filter((platform) => platform && EXCLUDED_DOMAINS.has(platform)));",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'discovery-reset-writes-a-copy',
|
||||
guard: 'node demo/smoke_discovery_filters.mjs',
|
||||
because: 'Restore recommended must store the default as ABSENCE of the key — a written '
|
||||
+ 'copy of the product list would freeze it against future product updates (#44 AC2)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: " if (draft.usesProductList) delete settings.exclude_integrations;\n else settings.exclude_integrations = draft.excluded;",
|
||||
replace: " settings.exclude_integrations = draft.excluded;",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'schema-manifest-enum-drift',
|
||||
guard: 'node --test test/config-schema-parity.test.mjs',
|
||||
|
||||
+16
-2
@@ -28,7 +28,7 @@ export interface BuildCtx {
|
||||
areaToSpace: Record<string, string>;
|
||||
markers: Marker[];
|
||||
settings: ServerConfig['settings'];
|
||||
excluded: Set<string>;
|
||||
excluded: ReadonlySet<string>;
|
||||
/** LEGACY only: honoured while the config has no settings.filter_seeded.
|
||||
* Seeded configs hide by explicit marker flags (docs/FILTERING.md). */
|
||||
showAll: boolean;
|
||||
@@ -1432,8 +1432,22 @@ function markerClimateTarget(marker: Marker): string | null {
|
||||
* render — an entire frame spent re-reading metadata that did not change. The
|
||||
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
|
||||
*/
|
||||
/** #44: THE resolver of the effective excluded-integration set. Absence of
|
||||
* the key means the product list; a present list REPLACES it wholesale
|
||||
* (including the valid empty list = "exclude nothing"). Every consumer —
|
||||
* discovery, static render, room climate — must read through here. */
|
||||
export function effectiveExcludedIntegrations(
|
||||
settings: { exclude_integrations?: string[] } | null | undefined,
|
||||
): ReadonlySet<string> {
|
||||
const list = settings?.exclude_integrations;
|
||||
return list ? new Set(list) : EXCLUDED_DOMAINS;
|
||||
}
|
||||
|
||||
export function roomClimateMap(
|
||||
hass: BuildCtx['hass'], rules?: CompiledIconRule[], markers?: Marker[] | null,
|
||||
// #44 H2: room climate follows the SAME user-configurable exclusion set as
|
||||
// discovery; the default keeps today's behaviour byte-for-byte.
|
||||
excluded: ReadonlySet<string> = EXCLUDED_DOMAINS,
|
||||
): Map<string, AreaClimate> {
|
||||
const out = new Map<string, AreaClimate>();
|
||||
if (!hass?.entities) return out;
|
||||
@@ -1488,7 +1502,7 @@ export function roomClimateMap(
|
||||
const optClimate = climOpt.size > 0 && eid.startsWith('climate.')
|
||||
&& (climOpt.has(eid) || (reg.device_id && climOpt.has(reg.device_id)));
|
||||
if (!optClimate) {
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
|
||||
if (excluded.has(reg.platform)) continue; // filtered-out integrations (#44: user-configurable)
|
||||
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||
}
|
||||
let groups = byTarget.get(target);
|
||||
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
} from './vacuum';
|
||||
import {
|
||||
buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
|
||||
areaTemp, areaHum, sourceValue, roomClimateKey, roomClimateMap,
|
||||
areaTemp, areaHum, effectiveExcludedIntegrations, sourceValue, roomClimateKey, roomClimateMap,
|
||||
resolvedLightSources, resolvedLightState, resolvedLightStats,
|
||||
hasOwnSpatialSource, hasOwnStatefulLightSource, ownControllableEntities,
|
||||
forcedLightEntityOf,
|
||||
@@ -3920,9 +3920,8 @@ export class HouseplanCard extends LitElement {
|
||||
return fillColorsOf(this._settings);
|
||||
}
|
||||
|
||||
private get _excluded(): Set<string> {
|
||||
const list = this._settings.exclude_integrations;
|
||||
return list ? new Set(list) : EXCLUDED_DOMAINS;
|
||||
private get _excluded(): ReadonlySet<string> {
|
||||
return effectiveExcludedIntegrations(this._settings); // #44: single resolver
|
||||
}
|
||||
|
||||
protected willUpdate(changed: PropertyValues): void {
|
||||
@@ -11867,7 +11866,7 @@ export class HouseplanCard extends LitElement {
|
||||
const planHass = this._renderPlanHass;
|
||||
const c = this._climateCache;
|
||||
if (c && c.h === planHass && c.r === this._iconRules && c.mk === mk) return c.m;
|
||||
const m = roomClimateMap(planHass, this._iconRules, mk);
|
||||
const m = roomClimateMap(planHass, this._iconRules, mk, this._excluded);
|
||||
this._climateCache = { h: planHass, r: this._iconRules, mk, m };
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ import {
|
||||
type VacSourceResolution, type VacSourceStatus,
|
||||
} from './vacuum';
|
||||
import {
|
||||
buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
|
||||
buildDevices, deviceFromMarkerDraft, effectiveExcludedIntegrations, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
|
||||
areaTemp, areaHum, sourceValue, areaClimateMap,
|
||||
resolvedLightSources, resolvedLightState, resolvedLightStats,
|
||||
hasOwnSpatialSource, hasOwnStatefulLightSource, ownControllableEntities,
|
||||
@@ -801,6 +801,11 @@ interface DeviceInboxDialogState {
|
||||
showEntities: boolean;
|
||||
onlyNew: boolean;
|
||||
limit: number;
|
||||
/** #44: Discovery-filters drafts. `undefined` = untouched (mirror the
|
||||
* stored settings); draftExcluded `null` = "recommended" (no stored key). */
|
||||
filtersOpen?: boolean;
|
||||
draftGroupLights?: boolean;
|
||||
draftExcluded?: string[] | null;
|
||||
/** Logical row restored after a nested marker dialog closes. */
|
||||
anchor?: string;
|
||||
busy?: string;
|
||||
@@ -11754,6 +11759,167 @@ public _renderDevicesBar(): TemplateResult {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** #44: drafts merged over the stored settings — the single truth the
|
||||
* preview, the chips and Save all read. */
|
||||
public _discoveryFilterState(dialog: DeviceInboxDialogState): {
|
||||
groupLights: boolean; excluded: string[]; usesProductList: boolean; dirty: boolean;
|
||||
} {
|
||||
const settings = this.host._settings as { group_lights?: boolean; exclude_integrations?: string[] };
|
||||
const groupLights = dialog.draftGroupLights !== undefined
|
||||
? dialog.draftGroupLights : settings.group_lights !== false;
|
||||
const stored = settings.exclude_integrations;
|
||||
const excluded = dialog.draftExcluded !== undefined
|
||||
? (dialog.draftExcluded === null ? [...EXCLUDED_DOMAINS] : dialog.draftExcluded)
|
||||
: (stored ? [...stored] : [...EXCLUDED_DOMAINS]);
|
||||
const usesProductList = dialog.draftExcluded !== undefined
|
||||
? dialog.draftExcluded === null : !stored;
|
||||
const dirty = dialog.draftGroupLights !== undefined || dialog.draftExcluded !== undefined;
|
||||
return { groupLights, excluded: excluded.sort(), usesProductList, dirty };
|
||||
}
|
||||
|
||||
/** Integrations really present in the HA registry (platforms + device
|
||||
* identifier domains), for the exclusion search. */
|
||||
private _registryIntegrations(): string[] {
|
||||
const found = new Set<string>();
|
||||
const full = this.host._fullRegistryHass;
|
||||
for (const reg of Object.values<any>(full?.entities || {})) {
|
||||
if (reg?.platform) found.add(String(reg.platform));
|
||||
}
|
||||
for (const device of Object.values<any>(full?.devices || {})) {
|
||||
const domain = Array.isArray(device?.identifiers?.[0]) ? device.identifiers[0][0] : null;
|
||||
if (domain) found.add(String(domain));
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
private _discoveryPreviewMemo: { key: string; value: { appear: number; hide: number; lights: number } } | null = null;
|
||||
|
||||
/** #44 AC6: the preview runs the SAME buildDevices the plan runs — draft
|
||||
* settings in, binding sets compared. Memoised so hass ticks while the
|
||||
* section is open do not redo discovery. */
|
||||
public _discoveryFilterPreview(dialog: DeviceInboxDialogState): { appear: number; hide: number; lights: number } {
|
||||
const draft = this._discoveryFilterState(dialog);
|
||||
const key = [
|
||||
draft.groupLights ? 1 : 0, draft.usesProductList ? 'p' : draft.excluded.join(','),
|
||||
this.host._cfgEpoch, this.host._regSignature,
|
||||
].join('|');
|
||||
if (this._discoveryPreviewMemo?.key === key) return this._discoveryPreviewMemo.value;
|
||||
const ctx = {
|
||||
hass: this.host.hass,
|
||||
registry: this.host._haRegistry,
|
||||
areaToSpace: Object.fromEntries(
|
||||
Object.entries(this.host._areaToSpace).map(([a, v]) => [a, v.space]),
|
||||
),
|
||||
markers: this.host._markers,
|
||||
showAll: this.host._showAll,
|
||||
firstSpaceId: this.host._model[0]?.id || '',
|
||||
loc: (k: string) => this.host._t(k as never),
|
||||
iconRules: this.host._iconRules,
|
||||
};
|
||||
// Two shared builders, zero copies of the filter logic: the exclusion
|
||||
// materialises through seedHiddenBindings (hidden markers), the grouping
|
||||
// changes the buildDevices candidate set — the preview diffs BOTH.
|
||||
const seededOf = (settings: object, excluded: ReadonlySet<string>) => new Set(
|
||||
seedHiddenBindings({ ...ctx, settings, excluded } as never));
|
||||
const candidatesOf = (settings: object, excluded: ReadonlySet<string>) => new Set(
|
||||
buildDevices({ ...ctx, settings, excluded } as never)
|
||||
.map((device) => device.bindingRef || device.id));
|
||||
const currentExcluded = effectiveExcludedIntegrations(this.host._settings);
|
||||
const draftSettings = { ...this.host._settings, group_lights: draft.groupLights };
|
||||
const draftExcluded = new Set(draft.excluded);
|
||||
const seededNow = seededOf(this.host._settings, currentExcluded);
|
||||
const seededNext = seededOf(draftSettings, draftExcluded);
|
||||
const candidatesNow = candidatesOf(this.host._settings, currentExcluded);
|
||||
const candidatesNext = candidatesOf(draftSettings, draftExcluded);
|
||||
let appear = 0, hide = 0, lights = 0;
|
||||
const isLight = (binding: string) => binding.includes('light.');
|
||||
for (const binding of seededNow) if (!seededNext.has(binding)) { appear++; if (isLight(binding)) lights++; }
|
||||
for (const binding of seededNext) if (!seededNow.has(binding)) { hide++; if (isLight(binding)) lights++; }
|
||||
for (const binding of candidatesNext) if (!candidatesNow.has(binding)) { appear++; if (isLight(binding)) lights++; }
|
||||
for (const binding of candidatesNow) if (!candidatesNext.has(binding)) { hide++; if (isLight(binding)) lights++; }
|
||||
const value = { appear, hide, lights };
|
||||
this._discoveryPreviewMemo = { key, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
/** #44: one settings write per Save; defaults are stored as ABSENCE. */
|
||||
public async _saveDiscoveryFilters(dialog: DeviceInboxDialogState): Promise<void> {
|
||||
const cfg = this.host._serverCfg;
|
||||
if (!cfg) return;
|
||||
const draft = this._discoveryFilterState(dialog);
|
||||
const settings: Record<string, unknown> = { ...(cfg.settings as object) };
|
||||
if (draft.groupLights) delete settings.group_lights;
|
||||
else settings.group_lights = false;
|
||||
if (draft.usesProductList) delete settings.exclude_integrations;
|
||||
else settings.exclude_integrations = draft.excluded;
|
||||
this.host._serverCfg = { ...cfg, settings } as typeof cfg;
|
||||
this.host._deviceInboxMemo = null;
|
||||
this._discoveryPreviewMemo = null;
|
||||
this.host._regSignature = ''; // force the device rebuild to see new filters
|
||||
if (this.host._deviceInbox) {
|
||||
this.host._deviceInbox = {
|
||||
...dialog, draftGroupLights: undefined, draftExcluded: undefined,
|
||||
};
|
||||
}
|
||||
this._saveConfig();
|
||||
this.host._maybeRebuildDevices();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
private _renderDiscoveryFilters(dialog: DeviceInboxDialogState): TemplateResult {
|
||||
const draft = this._discoveryFilterState(dialog);
|
||||
const patch = (delta: Partial<DeviceInboxDialogState>) =>
|
||||
(this.host._deviceInbox = { ...dialog, ...delta });
|
||||
const preview = dialog.filtersOpen && draft.dirty ? this._discoveryFilterPreview(dialog) : null;
|
||||
const known = this._registryIntegrations().filter((name) => !draft.excluded.includes(name));
|
||||
return html`<details class="device-inbox-discovery" ?open=${dialog.filtersOpen}
|
||||
@toggle=${(event: Event) => patch({ filtersOpen: (event.target as HTMLDetailsElement).open })}>
|
||||
<summary>${this.host._t('device_inbox.filters_title' as never)}</summary>
|
||||
<label class="srcrow">
|
||||
<input type="checkbox" .checked=${draft.groupLights}
|
||||
@change=${(event: Event) => patch({
|
||||
draftGroupLights: (event.target as HTMLInputElement).checked,
|
||||
})} />
|
||||
${this.host._t('device_inbox.filters_group_lights' as never)}
|
||||
</label>
|
||||
<div class="device-inbox-excluded">
|
||||
<span>${this.host._t('device_inbox.filters_excluded' as never)}</span>
|
||||
<div class="device-inbox-chips">
|
||||
${draft.excluded.map((name) => html`<span class="chip">${name}
|
||||
<button type="button" aria-label="×"
|
||||
@click=${() => patch({ draftExcluded: draft.excluded.filter((x) => x !== name) })}>×</button>
|
||||
</span>`)}
|
||||
</div>
|
||||
<input type="text" list="hp-discovery-integrations"
|
||||
placeholder=${this.host._t('device_inbox.filters_search_ph' as never)}
|
||||
@change=${(event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const name = input.value.trim();
|
||||
if (name && !draft.excluded.includes(name)) {
|
||||
patch({ draftExcluded: [...draft.excluded, name] });
|
||||
}
|
||||
input.value = '';
|
||||
}} />
|
||||
<datalist id="hp-discovery-integrations">
|
||||
${known.map((name) => html`<option value=${name}></option>`)}
|
||||
</datalist>
|
||||
<button type="button" class="btn ghost" ?disabled=${draft.usesProductList}
|
||||
@click=${() => patch({ draftExcluded: null })}>
|
||||
${this.host._t('device_inbox.filters_reset' as never)}
|
||||
</button>
|
||||
</div>
|
||||
${preview ? html`<div class="device-inbox-preview">
|
||||
${this.host._t('device_inbox.filters_preview_appear' as never, { count: String(preview.appear) })}
|
||||
· ${this.host._t('device_inbox.filters_preview_hide' as never, { count: String(preview.hide) })}
|
||||
· ${this.host._t('device_inbox.filters_preview_lights' as never, { count: String(preview.lights) })}
|
||||
</div>` : nothing}
|
||||
<button type="button" class="btn on" ?disabled=${!draft.dirty}
|
||||
@click=${() => this._saveDiscoveryFilters(dialog)}>
|
||||
${this.host._t('device_inbox.filters_save' as never)}
|
||||
</button>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
public _renderDeviceInbox(): TemplateResult {
|
||||
const dialog = this.host._deviceInbox!;
|
||||
const rows = this._deviceInboxRows();
|
||||
@@ -11819,6 +11985,7 @@ public _renderDeviceInbox(): TemplateResult {
|
||||
}} />${this.host._t('device_inbox.show_hidden' as any)}
|
||||
</label>
|
||||
</div>
|
||||
${dialog.tab === 'available' ? this._renderDiscoveryFilters(dialog) : nothing}
|
||||
<div class="device-inbox-results" aria-live="polite">
|
||||
${visible.length ? visible.map((row) => {
|
||||
const primary = row.category === 'on_plan'
|
||||
@@ -11846,7 +12013,10 @@ public _renderDeviceInbox(): TemplateResult {
|
||||
${[row.model, row.integration, row.spaceName, row.areaName].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
<div class="device-inbox-reason">
|
||||
${this.host._t(`device_inbox.reason_${row.reason}` as any)}
|
||||
${row.reason === 'excluded_integration' && row.integration
|
||||
? this.host._t('device_inbox.reason_excluded_integration' as any,
|
||||
{ integration: row.integration })
|
||||
: this.host._t(`device_inbox.reason_${row.reason}` as any)}
|
||||
${status ? html`<span class="device-inbox-status">${status}</span>` : nothing}
|
||||
</div>
|
||||
<code>${row.binding}</code>
|
||||
|
||||
+10
-1
@@ -481,7 +481,7 @@
|
||||
"device_inbox.reason_manual_hidden": "Vom Benutzer versteckt",
|
||||
"device_inbox.reason_automatic_hidden": "Automatisch versteckt",
|
||||
"device_inbox.reason_service_entry": "Nur Service-Registry-Eintrag",
|
||||
"device_inbox.reason_excluded_integration": "Integration durch Gerätefilter ausgeschlossen",
|
||||
"device_inbox.reason_excluded_integration": "Integration „{integration}“ durch Erkennungsfilter ausgeschlossen",
|
||||
"device_inbox.reason_excluded_domain": "Nicht-räumlicher Entitätstyp",
|
||||
"device_inbox.reason_grouped_light": "Dargestellt durch eine Raumlichtgruppe",
|
||||
"device_inbox.reason_represented_by_parent": "Dargestellt durch sein übergeordnetes Gerät",
|
||||
@@ -492,6 +492,15 @@
|
||||
"device_inbox.status_orphaned": "Bindung ist in Home Assistant nicht mehr vorhanden",
|
||||
"device_inbox.status_unverified": "Home Assistant-Register vorübergehend nicht verfügbar",
|
||||
"device_inbox.saved": "Geräteliste aktualisiert",
|
||||
"device_inbox.filters_excluded": "Ausgeschlossene Integrationen",
|
||||
"device_inbox.filters_group_lights": "Raumleuchten zu einem Marker gruppieren",
|
||||
"device_inbox.filters_preview_appear": "erscheint: {count}",
|
||||
"device_inbox.filters_preview_hide": "verschwindet: {count}",
|
||||
"device_inbox.filters_preview_lights": "betroffene Leuchten: {count}",
|
||||
"device_inbox.filters_reset": "Empfohlene wiederherstellen",
|
||||
"device_inbox.filters_save": "Filter speichern",
|
||||
"device_inbox.filters_search_ph": "Integration hinzufügen…",
|
||||
"device_inbox.filters_title": "Erkennungsfilter",
|
||||
"space.roomcard_section": "Raumkarte zeigt:",
|
||||
"space.label_temp": "Temperatur",
|
||||
"space.label_hum": "Luftfeuchtigkeit",
|
||||
|
||||
+10
-1
@@ -481,7 +481,7 @@
|
||||
"device_inbox.reason_manual_hidden": "Hidden by user",
|
||||
"device_inbox.reason_automatic_hidden": "Hidden automatically",
|
||||
"device_inbox.reason_service_entry": "Service-only registry entry",
|
||||
"device_inbox.reason_excluded_integration": "Integration excluded by device filters",
|
||||
"device_inbox.reason_excluded_integration": "Integration “{integration}” excluded by discovery filters",
|
||||
"device_inbox.reason_excluded_domain": "Non-spatial entity type",
|
||||
"device_inbox.reason_grouped_light": "Represented by a room light group",
|
||||
"device_inbox.reason_represented_by_parent": "Represented by its parent device",
|
||||
@@ -492,6 +492,15 @@
|
||||
"device_inbox.status_orphaned": "Binding is no longer present in Home Assistant",
|
||||
"device_inbox.status_unverified": "Home Assistant registry is temporarily unavailable",
|
||||
"device_inbox.saved": "Device list updated",
|
||||
"device_inbox.filters_excluded": "Excluded integrations",
|
||||
"device_inbox.filters_group_lights": "Group room lights into one marker",
|
||||
"device_inbox.filters_preview_appear": "will appear: {count}",
|
||||
"device_inbox.filters_preview_hide": "will disappear: {count}",
|
||||
"device_inbox.filters_preview_lights": "lights affected: {count}",
|
||||
"device_inbox.filters_reset": "Restore recommended",
|
||||
"device_inbox.filters_save": "Save filters",
|
||||
"device_inbox.filters_search_ph": "Add an integration…",
|
||||
"device_inbox.filters_title": "Discovery filters",
|
||||
"space.roomcard_section": "Room card shows:",
|
||||
"space.label_temp": "Temperature",
|
||||
"space.label_hum": "Humidity",
|
||||
|
||||
+10
-1
@@ -481,7 +481,7 @@
|
||||
"device_inbox.reason_manual_hidden": "Masqué par l'utilisateur",
|
||||
"device_inbox.reason_automatic_hidden": "Masqué automatiquement",
|
||||
"device_inbox.reason_service_entry": "Entrée de registre de service uniquement",
|
||||
"device_inbox.reason_excluded_integration": "Intégration exclue par les filtres d'appareils",
|
||||
"device_inbox.reason_excluded_integration": "Intégration « {integration} » exclue par les filtres de découverte",
|
||||
"device_inbox.reason_excluded_domain": "Type d'entité non spatial",
|
||||
"device_inbox.reason_grouped_light": "Représenté par un groupe de lumières de la pièce",
|
||||
"device_inbox.reason_represented_by_parent": "Représenté par son appareil parent",
|
||||
@@ -492,6 +492,15 @@
|
||||
"device_inbox.status_orphaned": "La liaison n'existe plus dans Home Assistant",
|
||||
"device_inbox.status_unverified": "Le registre Home Assistant est temporairement indisponible",
|
||||
"device_inbox.saved": "Liste des appareils mise à jour",
|
||||
"device_inbox.filters_excluded": "Intégrations exclues",
|
||||
"device_inbox.filters_group_lights": "Regrouper les lumières de la pièce en un marqueur",
|
||||
"device_inbox.filters_preview_appear": "apparaîtra : {count}",
|
||||
"device_inbox.filters_preview_hide": "disparaîtra : {count}",
|
||||
"device_inbox.filters_preview_lights": "lumières concernées : {count}",
|
||||
"device_inbox.filters_reset": "Rétablir les recommandées",
|
||||
"device_inbox.filters_save": "Enregistrer les filtres",
|
||||
"device_inbox.filters_search_ph": "Ajouter une intégration…",
|
||||
"device_inbox.filters_title": "Filtres de découverte",
|
||||
"space.roomcard_section": "La carte de pièce affiche :",
|
||||
"space.label_temp": "Température",
|
||||
"space.label_hum": "Humidité",
|
||||
|
||||
+10
-1
@@ -481,7 +481,7 @@
|
||||
"device_inbox.reason_manual_hidden": "Скрыто пользователем",
|
||||
"device_inbox.reason_automatic_hidden": "Скрыто автоматически",
|
||||
"device_inbox.reason_service_entry": "Служебная запись реестра",
|
||||
"device_inbox.reason_excluded_integration": "Интеграция исключена фильтрами устройств",
|
||||
"device_inbox.reason_excluded_integration": "Исключена интеграция «{integration}»",
|
||||
"device_inbox.reason_excluded_domain": "Непространственный тип сущности",
|
||||
"device_inbox.reason_grouped_light": "Представлено группой света комнаты",
|
||||
"device_inbox.reason_represented_by_parent": "Представлено родительским устройством",
|
||||
@@ -492,6 +492,15 @@
|
||||
"device_inbox.status_orphaned": "Привязка больше не найдена в Home Assistant",
|
||||
"device_inbox.status_unverified": "Реестр Home Assistant временно недоступен",
|
||||
"device_inbox.saved": "Список устройств обновлён",
|
||||
"device_inbox.filters_excluded": "Исключённые интеграции",
|
||||
"device_inbox.filters_group_lights": "Объединять светильники комнаты в один маркер",
|
||||
"device_inbox.filters_preview_appear": "появится: {count}",
|
||||
"device_inbox.filters_preview_hide": "скроется: {count}",
|
||||
"device_inbox.filters_preview_lights": "затронет светильников: {count}",
|
||||
"device_inbox.filters_reset": "Вернуть рекомендуемые",
|
||||
"device_inbox.filters_save": "Сохранить фильтры",
|
||||
"device_inbox.filters_search_ph": "Добавить интеграцию…",
|
||||
"device_inbox.filters_title": "Фильтры обнаружения",
|
||||
"space.roomcard_section": "В карточке комнаты:",
|
||||
"space.label_temp": "Температура",
|
||||
"space.label_hum": "Влажность",
|
||||
|
||||
+5
-4
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { html, svg, nothing, type TemplateResult } from 'lit';
|
||||
import {
|
||||
buildDevices, areaLqi, roomClimateKey, roomClimateMap, sourceValue,
|
||||
buildDevices, areaLqi, effectiveExcludedIntegrations, roomClimateKey, roomClimateMap, sourceValue,
|
||||
resolvedLightSources, resolvedLightState,
|
||||
} from './devices';
|
||||
import {
|
||||
@@ -242,8 +242,7 @@ export function buildSpaceDevices(o: StaticDeviceBuildOpts): DevItem[] {
|
||||
if (room.area) areaToSpace[room.area] = (space as any).id;
|
||||
}
|
||||
}
|
||||
const excluded = o.cfg.settings?.exclude_integrations
|
||||
? new Set(o.cfg.settings.exclude_integrations) : EXCLUDED_DOMAINS;
|
||||
const excluded = effectiveExcludedIntegrations(o.cfg.settings);
|
||||
const iconRules = compileIconRules(
|
||||
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
|
||||
);
|
||||
@@ -298,7 +297,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const iconRules = compileIconRules(
|
||||
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
|
||||
);
|
||||
const roomClimate = roomClimateMap(planHass, iconRules, o.cfg.markers || []);
|
||||
const roomClimate = roomClimateMap(
|
||||
planHass, iconRules, o.cfg.markers || [], effectiveExcludedIntegrations(o.cfg.settings),
|
||||
);
|
||||
const roomTemperature = (room: typeof space.rooms[number]): number | null => {
|
||||
const source = room.settings?.temp_source;
|
||||
if (source) return sourceValue(planHass, source, 'temp', o.cfg.markers || []);
|
||||
|
||||
@@ -1285,6 +1285,33 @@ export const dialogsStyles = css`
|
||||
background: color-mix(in srgb, var(--hp-accent) 18%, transparent);
|
||||
}
|
||||
.device-inbox-tabs button span { color: var(--hp-muted); margin-inline-start: 4px; }
|
||||
/* #44: discovery-filters section on the Available tab */
|
||||
.device-inbox-discovery {
|
||||
margin: 8px 0; padding: 8px 10px;
|
||||
border: 1px solid var(--divider-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.device-inbox-discovery summary { cursor: pointer; font-weight: 600; }
|
||||
.device-inbox-discovery .srcrow { display: flex; gap: 6px; align-items: center; margin: 8px 0; }
|
||||
.device-inbox-excluded { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.device-inbox-excluded > span { font-weight: 500; }
|
||||
.device-inbox-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.device-inbox-chips .chip {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
padding: 1px 6px; border-radius: 10px;
|
||||
background: var(--secondary-background-color, #f0f0f0); font-size: 12px;
|
||||
}
|
||||
.device-inbox-chips .chip button {
|
||||
border: none; background: none; cursor: pointer; padding: 0 2px;
|
||||
color: var(--secondary-text-color, #666);
|
||||
}
|
||||
.device-inbox-excluded input[type="text"] {
|
||||
flex: 1 1 140px; min-width: 120px; padding: 4px 6px;
|
||||
border: 1px solid var(--divider-color, #e0e0e0); border-radius: 6px;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color, #212121);
|
||||
}
|
||||
.device-inbox-preview { margin: 8px 0 4px; font-size: 13px; opacity: 0.85; }
|
||||
.device-inbox-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
+61
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, climateTempFor,
|
||||
areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate, areaClimateMap,
|
||||
roomClimateKey, roomClimateMap,
|
||||
roomClimateKey, roomClimateMap, effectiveExcludedIntegrations,
|
||||
litLightEntity, resolvedDeviceStateEntities, resolvedLightSources, resolvedLightState,
|
||||
resolvedLightStats, hasOwnSpatialSource, selectSpatialGlowSource, seedHiddenBindings,
|
||||
deletePlanMarkerRecords, effectiveMarkerControls, persistedExternalControls,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
removedPlanBindings, isRemovedPlanEntity, isRemovedPlanSource,
|
||||
} from '../test-build/devices.js';
|
||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) {
|
||||
@@ -2166,3 +2167,62 @@ test('issue 369в: легаси-маркер без ключа area размещ
|
||||
assert.deepEqual(roomClimateMap(hass, undefined, areaMarker).get('kitchen'),
|
||||
{ temp: 19, hum: null });
|
||||
});
|
||||
|
||||
|
||||
// #44 AC5: THE resolver of the effective exclusion set and the grouping flag.
|
||||
test('#44 AC5: effectiveExcludedIntegrations resolves unset/list/empty honestly', () => {
|
||||
const product = effectiveExcludedIntegrations(undefined);
|
||||
assert.ok(product.has('hacs') && product.has('sun'),
|
||||
'no settings -> the product EXCLUDED_DOMAINS list');
|
||||
assert.equal(effectiveExcludedIntegrations({}), product,
|
||||
'settings without the key -> the SAME product set (identity, no copies)');
|
||||
const custom = effectiveExcludedIntegrations({ exclude_integrations: ['demo_x'] });
|
||||
assert.ok(custom.has('demo_x') && !custom.has('hacs'),
|
||||
'a present list REPLACES the product list wholesale');
|
||||
const nothing = effectiveExcludedIntegrations({ exclude_integrations: [] });
|
||||
assert.equal(nothing.size, 0, 'the empty list is a valid "exclude nothing"');
|
||||
});
|
||||
|
||||
// #44 AC4b (H2): room climate follows the SAME user-configurable exclusion.
|
||||
test('#44 AC4b: roomClimateMap filters by the configured exclusion set', () => {
|
||||
const hass = {
|
||||
areas: { old: { temperature_entity_id: null, humidity_entity_id: null } },
|
||||
devices: { thermo: { area_id: 'old' } },
|
||||
entities: {
|
||||
'sensor.thermo_temperature': { device_id: 'thermo', platform: 'demo_x' },
|
||||
},
|
||||
states: {
|
||||
'sensor.thermo_temperature': { state: '21', attributes: { device_class: 'temperature' } },
|
||||
},
|
||||
};
|
||||
// default (no user key): demo_x is not in the product list -> climate counts
|
||||
assert.deepEqual(roomClimateMap(hass, undefined, []).get('old'), { temp: 21, hum: null });
|
||||
// the user excludes demo_x -> the SAME reading disappears from room climate
|
||||
const excluded = effectiveExcludedIntegrations({ exclude_integrations: ['demo_x'] });
|
||||
assert.equal(roomClimateMap(hass, undefined, [], excluded).get('old'), undefined,
|
||||
'a user-excluded integration must not feed room climate (the hidden third variant of #44)');
|
||||
// regression: explicit climate opt-in beats the exclusion (existing optClimate branch)
|
||||
const opted = [{
|
||||
id: 'thermo', binding: 'device:thermo', space: 'f1', area: 'old', use_climate_temp: true,
|
||||
}];
|
||||
const hassClimate = {
|
||||
...hass,
|
||||
entities: { 'climate.thermo': { device_id: 'thermo', platform: 'demo_x' } },
|
||||
states: { 'climate.thermo': { state: 'heat', attributes: { current_temperature: 23 } } },
|
||||
};
|
||||
assert.deepEqual(roomClimateMap(hassClimate, undefined, opted, excluded).get('old'),
|
||||
{ temp: 23, hum: null }, 'explicit opt-in stays stronger than the exclusion');
|
||||
});
|
||||
|
||||
// #44 AC6: the preview must run the real discovery, not a copy of the filter.
|
||||
test('#44 AC6: the filters preview calls the shared buildDevices', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8');
|
||||
const preview = source.slice(
|
||||
source.indexOf('public _discoveryFilterPreview('),
|
||||
source.indexOf('public async _saveDiscoveryFilters('),
|
||||
);
|
||||
assert.match(preview, /buildDevices\(\{ \.\.\.ctx, settings, excluded \}/,
|
||||
'both sides of the diff must come from the one production builder');
|
||||
assert.doesNotMatch(preview, /EXCLUDED_DOMAINS\.has|\.platform/,
|
||||
'no re-implementation of the filter inside the preview');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user