mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
v1.51.0
This commit is contained in:
@@ -75,6 +75,11 @@ Key advantages in short:
|
||||
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
|
||||
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
|
||||
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
|
||||
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
|
||||
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
|
||||
A pulsing red ring = an emergency (leak, smoke, gas). RGB bulbs color their icon with the real
|
||||
light color. A translucent icon = unavailable. Dark = idle.
|
||||
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
|
||||
|
||||
---
|
||||
|
||||
@@ -74,6 +74,11 @@ House Plan показывает ваш умный дом так, как он в
|
||||
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). RGB-лампы окрашивают значок реальным цветом света.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
|
||||
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.50.4"
|
||||
VERSION = "1.51.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.50.4"
|
||||
"version": "1.51.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// На телефоне в редакторах не работали зум и навигация жестами: pointerdown
|
||||
// сцены выходил сразу при _markup, так что пинч и пан не начинались вовсе.
|
||||
// Рисование кликается, жесты двигаются — они совместимы; палец с движением
|
||||
// панорамирует, два пальца зумируют, отпускание после жеста не рисует точку.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
const pd = (c, id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (c, id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (c, id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
|
||||
// --- пинч-зум в редакторе плана -----------------------------------------
|
||||
out.pinchZoomsInPlanEditor = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan');
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
const z0 = c._zoom;
|
||||
pd(1, 300, 300); pd(2, 400, 300); // два пальца
|
||||
pm(1, 250, 300); pm(2, 450, 300); // разводим
|
||||
const zoomed = c._zoom > z0 * 1.5;
|
||||
pu(1, 250, 300); pu(2, 450, 300);
|
||||
return zoomed && c._path.length === 0;
|
||||
});
|
||||
|
||||
// --- пан одним пальцем в редакторе, точка не рисуется --------------------
|
||||
out.panWorksAndDoesNotDraw = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
const st = c._stageEl;
|
||||
c._zoomAt(st.clientWidth / 2, st.clientHeight / 2, 3); // есть куда панорамировать
|
||||
const v0 = { ...c._view };
|
||||
pd(3, 300, 300);
|
||||
pm(3, 380, 340); pm(3, 420, 360);
|
||||
const panned = Math.abs(c._view.x - v0.x) > 1 || Math.abs(c._view.y - v0.y) > 1;
|
||||
const suppressed = c._suppressClick === true;
|
||||
c._markupClick({ composedPath: () => [], clientX: 420, clientY: 360 }); // синтезированный click после пана
|
||||
const noDot = c._path.length === 0;
|
||||
pu(3, 420, 360);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return panned && suppressed && noDot;
|
||||
});
|
||||
|
||||
// --- обычный клик без движения по-прежнему рисует ------------------------
|
||||
out.tapStillDraws = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
c._tool = 'draw';
|
||||
const before = c._path.length;
|
||||
pd(4, 300, 300); pu(4, 300, 300);
|
||||
const st = c._stageEl.getBoundingClientRect();
|
||||
c._markupClick({ composedPath: () => [], clientX: st.left + 200, clientY: st.top + 200 });
|
||||
const drew = c._path.length > before;
|
||||
c._path = []; c._setMode('view');
|
||||
return drew;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -9,10 +9,13 @@ const res = await page.evaluate(async () => {
|
||||
const btn = sr().querySelector('.rlgearbtn');
|
||||
out.gearButtonShown = !!btn;
|
||||
const cs = btn ? getComputedStyle(btn) : null;
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) >= 10 && cs.pointerEvents === 'auto' : null;
|
||||
// 2026-07-29: владелец вдвое уменьшил кнопку — контракт теперь не «не
|
||||
// меньше N px», а «размер от иконки устройства и кликабельность»
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) > 0 && cs.pointerEvents === 'auto' : null;
|
||||
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
|
||||
// (в редакторе плана .dev скрыты display:none — сравнивать не с чем)
|
||||
out.gearTapTarget = box ? box.height > 6 && box.height < 40 : null;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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');
|
||||
// призрак — конфигурация, не статус: ни жёлтого, ни unavail, ни тревоги
|
||||
const g = sr().querySelector('.dev.ghost');
|
||||
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
|
||||
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
|
||||
// и он синий, а не тёмный — отличим от недоступного устройства
|
||||
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
|
||||
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
|
||||
// тумблер локальный: конфиг не трогается
|
||||
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));
|
||||
@@ -36,8 +36,14 @@ const res = await page.evaluate(async () => {
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const planLbl = sr().querySelector('.roomlabel');
|
||||
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
|
||||
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
|
||||
// метрики видны и в редакторе плана (решение владельца, 2026-07-29)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length > 0;
|
||||
// кнопка настроек — отдельно от подписи, в центре комнаты, с размером от иконки
|
||||
out.gearDetached = (() => {
|
||||
const g = sr().querySelector('.rlgearbtn');
|
||||
if (!g || g.closest('.roomlabel')) return false;
|
||||
return getComputedStyle(g).height !== 'auto';
|
||||
})();
|
||||
// масштаб: сымитируем resize через прямой вызов
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Единый принцип жёлтого (2026-07-29): жёлтый = устройство прямо сейчас
|
||||
// выполняет основную функцию. Термоголовка желтеет от реального нагрева
|
||||
// (hvac_action), а не от служебного свитча защиты от накипи; горящая лампа
|
||||
// желтит значок в любом режиме заливки тем же условием, что зажигает пятно.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(() => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const cls = (d, states) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
const trv = { id: 't', primary: 'climate.trv', entities: ['climate.trv', 'switch.trv_anti_scaling'], marker: null };
|
||||
|
||||
// реально греет → жёлтая
|
||||
o.heatingIsYellow = cls(trv, {
|
||||
'climate.trv': { state: 'heat', attributes: { hvac_action: 'heating' } },
|
||||
'switch.trv_anti_scaling': { state: 'on' },
|
||||
}) === 'on';
|
||||
|
||||
// включена, но не греет (idle) → чёрная, даже со включённой защитой от накипи
|
||||
o.idleIsDark = cls(trv, {
|
||||
'climate.trv': { state: 'heat', attributes: { hvac_action: 'idle' } },
|
||||
'switch.trv_anti_scaling': { state: 'on' },
|
||||
}) === '';
|
||||
|
||||
// выключена → чёрная
|
||||
o.offIsDark = cls(trv, {
|
||||
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
|
||||
}) === '';
|
||||
|
||||
// без hvac_action фолбэк на state
|
||||
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
|
||||
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
|
||||
|
||||
// горящая лампа устройства желтит значок, даже если primary — не она
|
||||
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
|
||||
o.litLightWins = cls(lamp, {
|
||||
'sensor.lamp_power': { state: '5' },
|
||||
'light.lamp': { state: 'on' },
|
||||
}) === 'on';
|
||||
o.darkLampIdle = cls(lamp, {
|
||||
'sensor.lamp_power': { state: '5' },
|
||||
'light.lamp': { state: 'off' },
|
||||
}) === '';
|
||||
|
||||
// «источник света»: умный выключатель с глупыми светильниками — жёлтый от
|
||||
// того же условия, что и пятно glow
|
||||
const wall = { id: 'w', primary: 'sensor.w', entities: ['sensor.w'],
|
||||
marker: { is_light: true, controls: ['switch.fixtures'] } };
|
||||
o.forcedSourceYellow = cls(wall, {
|
||||
'sensor.w': { state: '1' }, 'switch.fixtures': { state: 'on' },
|
||||
}) === 'on';
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
File diff suppressed because one or more lines are too long
Vendored
+91
-65
File diff suppressed because one or more lines are too long
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## v1.51.0 — 2026-07-29
|
||||
|
||||
**Hiding is an explicit flag now** (docs/FILTERING.md)
|
||||
|
||||
- **Every device dialog — virtual ones included — has a "Hide device from
|
||||
plan" checkbox.** The old on-the-fly filter survives only as the SEEDER of
|
||||
those flags: on first load by an editing client the config is materialised
|
||||
once — non-physical devices (bridges, scenes, service integrations, lamps
|
||||
folded into a light group) get the flag, and from then on the flag belongs
|
||||
to you. Unticking it is final: the seeder never revisits a device you have
|
||||
decided about. New non-physical devices hide silently; physical ones keep
|
||||
the red-dot flow.
|
||||
- **"Show all" became "Show hidden"** — a local tool of the device editor
|
||||
(nothing flips on the wall tablets), showing hidden devices as translucent
|
||||
BLUE dashed ghosts: clearly apart from a grey unavailable icon, and with no
|
||||
live-state paint at all — a ghost is configuration, not status. Click one
|
||||
to untick the box. "Remove from plan" is gone for bound devices (the
|
||||
checkbox is the way); a virtual device's Delete still deletes.
|
||||
- Hidden devices still count toward the room's Zigbee signal, but cast no
|
||||
glow and no light fill — an invisible device casts no visible light. Room
|
||||
climate is unchanged. Old configs behave exactly as before until an
|
||||
editing client materialises them.
|
||||
|
||||
**Yellow means working right now**
|
||||
|
||||
- One principle for the glowing icon: a light is shining, a socket is
|
||||
powering, a fan is spinning, media is playing, a vacuum is cleaning — or a
|
||||
radiator valve is ACTUALLY heating (hvac_action), not merely enabled for
|
||||
the winter. Previously a TRV could glow yellow because its anti-scaling
|
||||
service switch was on while the actually-heating one stayed dark: the
|
||||
primary-entity search let a vendor's config switch outrank the visible
|
||||
climate entity. Fixed — a service entity never beats the device's visible
|
||||
main function (this also fixes tap-toggle and icon morphing on such
|
||||
devices).
|
||||
- The glow pool and the icon color now ask the same question: a lit light
|
||||
yellows its icon in every fill mode, by exactly the condition that lights
|
||||
its glow spot. The README (en+ru) documents the color language.
|
||||
|
||||
**The editors, on a phone**
|
||||
|
||||
- Pinch zoom and pan gestures now work in every editor: drawing is
|
||||
click-based, so the two coexist — a moving finger pans, two fingers pinch,
|
||||
releasing after a gesture never draws a point, a clean tap still does.
|
||||
|
||||
**The room settings button**
|
||||
|
||||
- Detached from the (movable) room name: it sits at the VISUAL centre of the
|
||||
room — the centre of the largest inscribed circle with a pull toward the
|
||||
area centroid, so an elongated room centres it on both axes and an L-shaped
|
||||
one keeps it in the middle of its widest part, never down a thin limb.
|
||||
- Half its former size, sized from the device icon (70% of the icon box) and
|
||||
zooming WITH the plan instead of keeping a constant screen size.
|
||||
- The small metric rows under the room name (temperature, humidity, signal,
|
||||
lights) now show in the plan editor too, and the name renders in exactly
|
||||
the same spot in view mode and in the editor.
|
||||
|
||||
## v1.50.4 — 2026-07-29
|
||||
|
||||
**From the v1.50.3 review**
|
||||
|
||||
@@ -6,6 +6,63 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.51.0 — 2026-07-29
|
||||
|
||||
**Скрытие — теперь явная галка** (docs/FILTERING.md)
|
||||
|
||||
- **В диалоге каждого устройства — включая виртуальные — есть галка «Скрыть
|
||||
устройство с плана».** Старый фильтр «на лету» остался только сеятелем
|
||||
этих галок: при первом открытии редактором конфиг один раз
|
||||
материализуется — нефизические устройства (мосты, сцены, служебные
|
||||
интеграции, лампы, свёрнутые в световую группу) получают галку, и дальше
|
||||
она принадлежит вам. Снятая галка — навсегда: сеятель не возвращается к
|
||||
устройству, о котором вы уже решили. Новые нефизические прячутся молча;
|
||||
физические — с красной точкой, как раньше.
|
||||
- **«Показать все» стала «Показать скрытые»** — локальный инструмент
|
||||
редактора устройств (на настенных планшетах ничего не мигает): скрытые
|
||||
рисуются полупрозрачными СИНИМИ пунктирными призраками — не спутать с
|
||||
серым недоступным — и совсем без живой индикации: призрак — это
|
||||
конфигурация, а не статус. Клик — и галку можно снять. «Удалить с плана»
|
||||
у привязанных устройств убрана (галка — единственный путь); «Удалить» у
|
||||
виртуального по-прежнему удаляет.
|
||||
- Скрытые устройства учитываются в Zigbee-сигнале комнаты, но не дают ни
|
||||
пятна света, ни заливки — не видно устройства, не видно и света. Климат
|
||||
комнат без изменений. Старые конфиги ведут себя по-старому, пока их не
|
||||
материализует клиент с правом записи.
|
||||
|
||||
**Жёлтый значит «работает прямо сейчас»**
|
||||
|
||||
- Один принцип для светящегося значка: лампа светит, розетка подаёт,
|
||||
вентилятор крутится, медиа играет, пылесос убирает — или термоголовка
|
||||
РЕАЛЬНО греет (hvac_action), а не просто включена на зиму. Раньше
|
||||
термоголовка могла светиться жёлтым из-за включённой защиты от накипи, а
|
||||
реально греющая оставалась тёмной: выбор главной сущности пропускал
|
||||
служебный свитч производителя вперёд видимого climate. Исправлено —
|
||||
служебная сущность больше никогда не побеждает видимую основную функцию
|
||||
(это чинит и tap-переключение, и морфинг иконок у таких устройств).
|
||||
- Пятно света и цвет значка задают один и тот же вопрос: горящая лампа
|
||||
желтит значок в любом режиме заливки ровно тем условием, что зажигает её
|
||||
пятно. README (en+ru) описывает язык цветов.
|
||||
|
||||
**Редакторы на телефоне**
|
||||
|
||||
- Пинч-зум и панорамирование жестами работают во всех редакторах: рисование
|
||||
кликовое, так что они совместимы — палец с движением панорамирует, два
|
||||
пальца зумируют, отпускание после жеста не ставит точку, чистый тап —
|
||||
ставит.
|
||||
|
||||
**Кнопка настроек комнаты**
|
||||
|
||||
- Отвязана от (передвигаемого) названия: стоит в ВИЗУАЛЬНОМ центре комнаты —
|
||||
центр наибольшей вписанной окружности с тяготением к центроиду площади,
|
||||
поэтому вытянутая комната центрирует её по обеим осям, а Г-образная
|
||||
держит в середине широкой части и не утаскивает в узкую.
|
||||
- Вдвое меньше прежней, размер от иконки устройства (70% бокса) и зумится
|
||||
ВМЕСТЕ с планом, а не держит постоянный экранный размер.
|
||||
- Мелкие подписи под названием комнаты (температура, влажность, сигнал,
|
||||
свет) теперь видны и в редакторе плана, а название рендерится ровно в том
|
||||
же месте в просмотре и в редакторе.
|
||||
|
||||
## v1.50.4 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.3**
|
||||
|
||||
@@ -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.
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.50.4** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.51.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.4** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.51.0** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||
|
||||
@@ -239,6 +239,29 @@ 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
|
||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||
- [ ] Room settings button (dev): detached from the (movable) name label —
|
||||
always at the room's geometric centre, one button-height below it; sized
|
||||
at 70% of a device icon and zooming WITH the plan; the small metric rows
|
||||
under the room name now show in the plan editor too
|
||||
[auto: smoke_room_cards gearDetached/plainInPlan]
|
||||
- [ ] 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 as BLUE
|
||||
dashed ghosts — distinct from a grey unavailable icon — with NO live
|
||||
state paint (no yellow, no alarm, no ripple);
|
||||
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; 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
|
||||
every fill mode by the same condition that lights the glow pool
|
||||
[auto: smoke_yellow_principle + unit primaryEntity/litLightEntity]
|
||||
- [ ] Editor gestures on touch (dev): in the plan editor on a phone, pinch
|
||||
zooms and a moving finger pans; releasing after a gesture does not draw
|
||||
a point, a clean tap still does [auto: smoke_editor_gestures]
|
||||
- [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero
|
||||
viewport and a negative rect renders identically sane in BOTH cards —
|
||||
full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry]
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.50.4",
|
||||
"version": "1.51.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.50.4",
|
||||
"version": "1.51.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.50.4",
|
||||
"version": "1.51.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+88
-16
@@ -14,6 +14,8 @@ export interface BuildCtx {
|
||||
markers: Marker[];
|
||||
settings: ServerConfig['settings'];
|
||||
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;
|
||||
firstSpaceId: string;
|
||||
/** Localized display strings for generated device names. */
|
||||
@@ -56,14 +58,22 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
|
||||
const all = entIds
|
||||
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
|
||||
.filter((e) => e.reg);
|
||||
// Tiers: visible primary entities first, but a HIDDEN light still beats a
|
||||
// visible config switch. Real case: individual lamps folded into a light
|
||||
// group get hidden in the registry — the device's main function is still
|
||||
// the lamp, and tap-toggle must flip IT, not the do-not-disturb switch.
|
||||
// Tiers, in order: functional and visible; functional but hidden (lamps
|
||||
// folded into a light group get hidden in the registry — the device's main
|
||||
// function is still the lamp, and tap-toggle must flip IT, not the
|
||||
// do-not-disturb switch); config/diagnostic but visible; everything.
|
||||
//
|
||||
// The TIER loop is the OUTER one. It used to be inner, which made the
|
||||
// domain priority stronger than visibility — and `switch` outranks
|
||||
// `climate`, so a TRV's primary was whatever service switch the vendor
|
||||
// ships (child lock, anti-scaling): the icon glowed yellow because scale
|
||||
// protection was on, while the head that was actually HEATING stayed dark
|
||||
// (owner's install, verified live 2026-07-29). A service entity must never
|
||||
// beat the device's visible main function.
|
||||
const tiers = [
|
||||
all.filter((e) => !e.reg.hidden && !e.reg.entity_category),
|
||||
all.filter((e) => !e.reg.hidden),
|
||||
all.filter((e) => !e.reg.entity_category),
|
||||
all.filter((e) => !e.reg.hidden),
|
||||
all,
|
||||
];
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
|
||||
@@ -72,16 +82,35 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
|
||||
if (t) return t.eid;
|
||||
}
|
||||
}
|
||||
for (const dom of DOMAIN_PRIORITY) {
|
||||
for (const tier of tiers) {
|
||||
for (const tier of tiers) {
|
||||
for (const dom of DOMAIN_PRIORITY) {
|
||||
const found = tier.find((e) => e.eid.split('.')[0] === dom);
|
||||
if (found) return found.eid;
|
||||
}
|
||||
}
|
||||
// no known domain anywhere — first entity of the best non-empty tier
|
||||
for (const tier of tiers) if (tier.length) return tier[0].eid;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lit light entity of a device, or null — ONE truth for "this thing is
|
||||
* shining": the glow-mode pool and the yellow icon both ask here, so the
|
||||
* light pool and the icon can never disagree (owner's principle, 2026-07-29).
|
||||
* Normally that is a lit `light.*`; with the "is a light source" flag (a smart
|
||||
* switch driving dumb fixtures) any lit entity counts — the switch itself, or
|
||||
* the lights it controls when they are bound.
|
||||
*/
|
||||
export function litLightEntity(
|
||||
hass: any, d: { entities: string[]; marker?: { is_light?: boolean | null; controls?: string[] | null } | null },
|
||||
): string | null {
|
||||
const forced = d.marker?.is_light === true;
|
||||
const pool = forced
|
||||
? [...(d.marker?.controls || []), ...d.entities]
|
||||
: d.entities.filter((e) => e.startsWith('light.'));
|
||||
return pool.find((e) => hass.states[e]?.state === 'on') || null;
|
||||
}
|
||||
|
||||
/** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
|
||||
export function lqiFor(hass: any, entIds: string[]): number | null {
|
||||
const vals: number[] = [];
|
||||
@@ -175,6 +204,7 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
|
||||
|
||||
function applyMarker(item: DevItem, m: Marker): void {
|
||||
item.marker = m;
|
||||
if (m.hidden) item.hidden = true;
|
||||
if (m.name) item.name = m.name;
|
||||
if (m.icon) item.icon = m.icon;
|
||||
if (m.model != null) item.model = m.model;
|
||||
@@ -184,6 +214,43 @@ function applyMarker(item: DevItem, m: Marker): void {
|
||||
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. */
|
||||
export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
|
||||
@@ -207,11 +274,13 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
if (dev.entry_type === 'service') continue;
|
||||
if (claimed.has('device:' + dev.id)) continue; // a marker will take over below
|
||||
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 dom = domainOfDevice(h, dev, entIds);
|
||||
// filtering (can be turned off with the “show all” toggle)
|
||||
if (!showAll) {
|
||||
// LEGACY runtime filter: only while the config is not yet materialised
|
||||
// (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 (dev.model === 'Group') continue;
|
||||
if (/scene/i.test(dev.model || '')) continue;
|
||||
@@ -222,7 +291,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const key = name + '|' + area;
|
||||
let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
|
||||
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
|
||||
seen[key] = (seen[key] || 0) + 1;
|
||||
const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name;
|
||||
@@ -265,7 +334,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
|
||||
// 3) explicit markers (rebinding/metadata/virtual)
|
||||
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(':');
|
||||
if (kind === 'device') {
|
||||
const dev = h.devices[ref];
|
||||
@@ -343,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,
|
||||
* '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;
|
||||
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) {
|
||||
if (!eid.startsWith('light.')) continue;
|
||||
seen = true;
|
||||
@@ -503,13 +575,13 @@ export function areaClimate(
|
||||
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
||||
export function areaLightStats(
|
||||
hass: any,
|
||||
devices: { area: string; entities: string[] }[],
|
||||
devices: { area: string; entities: string[]; hidden?: boolean }[],
|
||||
area: string,
|
||||
): { on: number; total: number } | null {
|
||||
const seen = new Set<string>();
|
||||
let on = 0;
|
||||
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) {
|
||||
if (!eid.startsWith('light.') || seen.has(eid)) continue;
|
||||
seen.add(eid);
|
||||
|
||||
+149
-31
@@ -15,7 +15,7 @@ import {
|
||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
|
||||
snapToWall, openingAmount,
|
||||
snapToWall, openingAmount, interiorPoint, poleOfInaccessibility,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
|
||||
} from './logic';
|
||||
import { ContentSigner } from './signing';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
|
||||
import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
|
||||
import type {
|
||||
OpeningCfg,
|
||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||
@@ -36,7 +36,7 @@ import { cardStyles } from './styles';
|
||||
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.50.4';
|
||||
const CARD_VERSION = '1.51.0';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -293,7 +293,8 @@ class HouseplanCard extends LitElement {
|
||||
link: string;
|
||||
description: string;
|
||||
pdfs: PdfRef[];
|
||||
room: string; // 'space#area' for a virtual one
|
||||
room: string;
|
||||
hideFromPlan: boolean; // 'space#area' for a virtual one
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
private _spaceDialog: {
|
||||
@@ -671,13 +672,67 @@ class HouseplanCard extends LitElement {
|
||||
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 {
|
||||
return !!this._settings.show_all;
|
||||
return this._settings.filter_seeded ? this._showHidden : !!this._settings.show_all;
|
||||
}
|
||||
|
||||
private _toggleShowAll(): void {
|
||||
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._maybeRebuildDevices();
|
||||
this._saveConfig();
|
||||
@@ -1007,6 +1062,7 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
this._defPos = this._defaultPositions();
|
||||
this._syncNewDevices();
|
||||
this._seedHiddenDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1157,6 +1213,10 @@ class HouseplanCard extends LitElement {
|
||||
const controls = (d.marker?.controls || []).filter(isControllable);
|
||||
if (controls.length)
|
||||
return controls.some((e) => this.hass.states[e]?.state === 'on') ? 'on' : '';
|
||||
// A shining light makes its icon yellow in EVERY fill mode — and it is
|
||||
// the same condition that lights the glow pool, so the pool and the icon
|
||||
// cannot disagree (they used to read different entities).
|
||||
if (litLightEntity(this.hass, d)) return 'on';
|
||||
const p = d.primary ? this.hass.states[d.primary] : undefined;
|
||||
if (!p) return '';
|
||||
if (p.state === 'unavailable') return 'unavail';
|
||||
@@ -1165,6 +1225,14 @@ class HouseplanCard extends LitElement {
|
||||
// TESTING.md edge-case run)
|
||||
const dom = d.primary!.split('.')[0];
|
||||
if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : '';
|
||||
if (dom === 'climate') {
|
||||
// yellow = actually working right now ("which radiators are heating"),
|
||||
// not "enabled for the winter": hvac_action when the integration
|
||||
// reports one, the coarser state only as a fallback
|
||||
const act = p.attributes?.hvac_action;
|
||||
if (act != null) return ['heating', 'cooling', 'drying', 'fan'].includes(act) ? 'on' : '';
|
||||
return ['off', 'unknown'].includes(p.state) ? '' : 'on';
|
||||
}
|
||||
if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
|
||||
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
|
||||
if (dom === 'binary_sensor') {
|
||||
@@ -1416,8 +1484,17 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
}
|
||||
}
|
||||
// do not interfere with icon dragging and markup drawing
|
||||
if (this._drag || this._markup) return;
|
||||
// do not interfere with icon and label dragging
|
||||
if (this._drag) return;
|
||||
if (this._markup) {
|
||||
// Drawing is CLICK-based, so gestures coexist with it: a finger that
|
||||
// MOVES pans, two fingers pinch, and _suppressClick keeps the release
|
||||
// from feeding the active tool. The editor used to opt out of gestures
|
||||
// entirely, which left a phone with no way to zoom or pan the plan
|
||||
// (owner's report). Pointers that begin on interactive children still
|
||||
// stay out — labels and handles run their own drags.
|
||||
if ((ev.target as HTMLElement).closest?.('.roomlabel, .rlhandle, .dev, .oplock, .op-hit, button')) return;
|
||||
}
|
||||
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
|
||||
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
|
||||
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||
@@ -1447,6 +1524,8 @@ class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||
// the tool preview (snap dot, measure label) keeps following the finger
|
||||
if (this._markup && this._pointers.size === 1) this._markupMove(ev);
|
||||
if (this._pinchStart && this._pointers.size >= 2) {
|
||||
const pts = [...this._pointers.values()];
|
||||
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
@@ -1822,6 +1901,8 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _markupClick(ev: MouseEvent): void {
|
||||
if (!this._markup) return;
|
||||
// a pan or pinch just happened — the synthesized click is not a draw
|
||||
if (this._suppressClick) return;
|
||||
// Room cards swallow markup clicks: dragging, resizing or just clicking a
|
||||
// card must not feed the active tool (draw point, delete room, merge/split
|
||||
// pick, opening placement). The drag itself already stops pointer events,
|
||||
@@ -2728,6 +2809,7 @@ class HouseplanCard extends LitElement {
|
||||
room: d.marker?.room_id
|
||||
? d.space + '#@' + d.marker.room_id
|
||||
: d.space && d.area ? d.space + '#' + d.area : '',
|
||||
hideFromPlan: d.marker?.hidden === true,
|
||||
busy: false,
|
||||
};
|
||||
} else {
|
||||
@@ -2737,7 +2819,7 @@ class HouseplanCard extends LitElement {
|
||||
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
|
||||
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -2941,6 +3023,10 @@ class HouseplanCard extends LitElement {
|
||||
link: dlg.link.trim() || null,
|
||||
description: dlg.description.trim() || null,
|
||||
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)
|
||||
if (dlg.binding === 'virtual' || dlg.room) {
|
||||
@@ -3587,15 +3673,13 @@ class HouseplanCard extends LitElement {
|
||||
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
|
||||
for (const d of this._devices) {
|
||||
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
|
||||
// "is a light source" flag (field request: a smart SWITCH driving dumb
|
||||
// fixtures) any lit entity counts — the switch itself, or the lights it
|
||||
// controls when they are bound.
|
||||
const forced = d.marker?.is_light === true;
|
||||
const pool = forced
|
||||
? [...(d.marker?.controls || []), ...d.entities]
|
||||
: d.entities.filter((e) => e.startsWith('light.'));
|
||||
const lightEid = pool.find((e) => this.hass.states[e]?.state === 'on');
|
||||
// the SAME condition that turns the icon yellow (devices.ts)
|
||||
const lightEid = litLightEntity(this.hass, d);
|
||||
if (!lightEid) continue;
|
||||
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
|
||||
if (!glow) continue;
|
||||
@@ -3873,7 +3957,11 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
const space = this._spaceModel();
|
||||
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 showLqi = disp.showLqi ?? this._config.show_signal ?? true;
|
||||
const cfgSize = this._config.icon_size ?? 2.5;
|
||||
@@ -3932,7 +4020,7 @@ class HouseplanCard extends LitElement {
|
||||
)}
|
||||
</div>`
|
||||
: 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>
|
||||
<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>
|
||||
@@ -4069,6 +4157,7 @@ class HouseplanCard extends LitElement {
|
||||
${disp.showNames || this._markup
|
||||
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||||
: nothing}
|
||||
${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing}
|
||||
</div>
|
||||
${this._measureAnchor
|
||||
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
|
||||
@@ -4117,7 +4206,8 @@ class HouseplanCard extends LitElement {
|
||||
const p = this._pos(d);
|
||||
const left = ((p.x - view.x) / view.w) * 100;
|
||||
const top = ((p.y - view.y) / view.h) * 100;
|
||||
const cls = this._stateClass(d);
|
||||
// a ghost is configuration, not status: no yellow, no open, no unavail
|
||||
const cls = d.hidden ? '' : this._stateClass(d);
|
||||
const temp = this._liveTemp(d);
|
||||
const hum = this._liveHum(d);
|
||||
const lqi = showLqi && !d.virtual ? lqiFor(this.hass, d.entities) : null;
|
||||
@@ -4141,15 +4231,15 @@ class HouseplanCard extends LitElement {
|
||||
// RGB lights color the icon (and the ripple, unless a custom ripple color is set);
|
||||
// an icon with controlled targets takes the color of its first lit RGB target
|
||||
const ctrl = (m?.controls || []).filter(isControllable);
|
||||
const lightC = this._config?.live_states
|
||||
const lightC = this._config?.live_states && !d.hidden
|
||||
? ctrl.length
|
||||
? ctrl.map((e) => lightColorOf(this.hass.states[e])).find((v) => v) || null
|
||||
: domain === 'light' ? lightColorOf(primarySt) : null
|
||||
: null;
|
||||
// emergencies (leak/smoke/gas/CO/siren) pulse red regardless of display mode
|
||||
const alarm = this._config?.live_states
|
||||
const alarm = this._config?.live_states && !d.hidden
|
||||
&& isAlarmState(domain, primarySt?.attributes?.device_class, primarySt?.state);
|
||||
const active = ripple && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
|
||||
const active = ripple && !d.hidden && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
|
||||
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
|
||||
const angle = Number(m?.angle) || 0;
|
||||
const rScale = Number(m?.ripple_size) > 0 ? Number(m!.ripple_size) : 3;
|
||||
@@ -4162,7 +4252,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
if (lightC) st.push(`--light-color:${lightC}`);
|
||||
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(';')}"
|
||||
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
|
||||
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
|
||||
@@ -4412,6 +4502,37 @@ class HouseplanCard extends LitElement {
|
||||
this._persistLayout();
|
||||
}
|
||||
|
||||
/** The room-settings button: detached from the (movable) label, always at
|
||||
* the geometric CENTRE of the room, sized at 70% of a device icon and
|
||||
* therefore zooming with the plan (owner's spec, 2026-07-29). */
|
||||
private _gearPtCache = new WeakMap<number[][], number[]>();
|
||||
|
||||
private _renderRoomGear(
|
||||
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number },
|
||||
): TemplateResult | typeof nothing {
|
||||
if (!r.id) return nothing;
|
||||
let c: number[] | null = null;
|
||||
if (r.poly) {
|
||||
// the VISUAL centre (largest inscribed circle) — interiorPoint only
|
||||
// promises "inside", which sat visibly off-centre on an L-shaped room.
|
||||
// The model is memoized, so the poly array is a stable cache key.
|
||||
c = this._gearPtCache.get(r.poly) || null;
|
||||
if (!c) { c = poleOfInaccessibility(r.poly); this._gearPtCache.set(r.poly, c); }
|
||||
} else if (r.x != null && r.y != null) {
|
||||
c = [r.x + (r.w || 0) / 2, r.y + (r.h || 0) / 2];
|
||||
}
|
||||
if (!c) return nothing;
|
||||
const left = ((c[0] - view.x) / view.w) * 100;
|
||||
const top = ((c[1] - view.y) / view.h) * 100;
|
||||
return html`<button class="rlgearbtn" style="left:${left}%;top:${top}%"
|
||||
title=${this._t('room.settings_title')}
|
||||
@pointerdown=${(e: Event) => e.stopPropagation()}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
|
||||
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
||||
<span class="rlgeartext">${this._t('room.settings_short')}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private _renderRoomLabel(
|
||||
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay,
|
||||
): TemplateResult | typeof nothing {
|
||||
@@ -4425,7 +4546,7 @@ class HouseplanCard extends LitElement {
|
||||
const k = this._labelScale(r);
|
||||
// optional metrics row (needs an HA area; sub-area rooms show the name only)
|
||||
const rows: TemplateResult[] = [];
|
||||
if ((r.area || r.settings?.temp_source || r.settings?.hum_source) && !this._markup) {
|
||||
if (r.area || r.settings?.temp_source || r.settings?.hum_source) {
|
||||
if (disp.labelTemp) {
|
||||
const t = this._roomTemp(r);
|
||||
if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`);
|
||||
@@ -4463,14 +4584,6 @@ class HouseplanCard extends LitElement {
|
||||
@pointerdown=${(e: Event) => e.stopPropagation()}></ha-icon>`
|
||||
: nothing}</span>
|
||||
${rows.length ? html`<span class="rlmetrics">${rows}</span>` : nothing}
|
||||
${this._markup && r.id
|
||||
? html`<button class="rlgearbtn" title=${this._t('room.settings_title')}
|
||||
@pointerdown=${(e: Event) => e.stopPropagation()}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
|
||||
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
||||
<span class="rlgeartext">${this._t('room.settings_short')}</span>
|
||||
</button>`
|
||||
: nothing}
|
||||
${this._mode === 'plan'
|
||||
? ['tl', 'tr', 'bl', 'br'].map(
|
||||
(c) => html`<span class="rlhandle ${c}"
|
||||
@@ -5219,6 +5332,11 @@ class HouseplanCard extends LitElement {
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, isLight: (e.target as HTMLInputElement).checked })} />
|
||||
<span>${this._t('marker.is_light')}</span>
|
||||
</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>
|
||||
<div class="colorrow">
|
||||
<input class="tempin" type="number" min="0.5" step="0.5"
|
||||
@@ -5303,7 +5421,7 @@ class HouseplanCard extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
${d.devId
|
||||
${d.devId && d.binding === 'virtual'
|
||||
? html`<button class="btn danger" @click=${this._deleteMarker}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.remove')}
|
||||
</button>`
|
||||
|
||||
+6
-4
@@ -21,7 +21,7 @@
|
||||
"title.zoom_out": "Zoom out",
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"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.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
@@ -244,7 +244,7 @@
|
||||
"opening.lock_pending": "Working…",
|
||||
"title.close_editor": "Close editor (back to view)",
|
||||
"devbar.add": "Add",
|
||||
"devbar.show_all": "Show all",
|
||||
"devbar.show_all": "Show hidden",
|
||||
"devbar.rules": "Icon rules",
|
||||
"space.roomcard_section": "Room card shows:",
|
||||
"space.label_temp": "Temperature",
|
||||
@@ -338,5 +338,7 @@
|
||||
"btn.use": "Use",
|
||||
"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.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_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без фильтрации)",
|
||||
"title.show_all": "Показать скрытые устройства (полупрозрачными, только в этой вкладке)",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -244,7 +244,7 @@
|
||||
"opening.lock_pending": "Выполняется…",
|
||||
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
|
||||
"devbar.add": "Добавить",
|
||||
"devbar.show_all": "Показать все",
|
||||
"devbar.show_all": "Показать скрытые",
|
||||
"devbar.rules": "Правила иконок",
|
||||
"space.roomcard_section": "В карточке комнаты:",
|
||||
"space.label_temp": "Температура",
|
||||
@@ -338,5 +338,7 @@
|
||||
"btn.use": "Выбрать",
|
||||
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
|
||||
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}"
|
||||
}
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}",
|
||||
"marker.hide": "Скрыть устройство с плана",
|
||||
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств."
|
||||
}
|
||||
@@ -238,6 +238,74 @@ export function segmentsProperlyCross(
|
||||
* triangle centroid of consecutive vertices — the first point that passes
|
||||
* pointStrictlyInside wins.
|
||||
*/
|
||||
/**
|
||||
* The visual centre of a polygon: the centre of the largest inscribed circle
|
||||
* (pole of inaccessibility). `interiorPoint` only promises "inside", and for
|
||||
* an L-shaped room it lands near the seam — the owner's kitchen-living room
|
||||
* got its settings button visibly off-centre (2026-07-29). Grid search with
|
||||
* one refinement pass; exact enough for a button, cheap enough to cache.
|
||||
*/
|
||||
export function poleOfInaccessibility(poly: number[][], steps = 24): number[] {
|
||||
const xs = poly.map((p) => p[0]);
|
||||
const ys = poly.map((p) => p[1]);
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
const span = Math.max(maxX - minX, maxY - minY) || 1;
|
||||
// Area centroid (shoelace-weighted). Clearance alone has a PLATEAU on any
|
||||
// elongated room — every point of the long midline fits the same circle —
|
||||
// and a plain argmax took the first plateau point: left of centre on the
|
||||
// owner's kitchen, above centre in the sauna. A soft pull toward the
|
||||
// centroid breaks the tie along the plateau without ever dragging the
|
||||
// point into a thinner limb (clearance differences dominate the score).
|
||||
let a2 = 0, cx = 0, cy = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const p = poly[i], q = poly[(i + 1) % poly.length];
|
||||
const cross = p[0] * q[1] - q[0] * p[1];
|
||||
a2 += cross;
|
||||
cx += (p[0] + q[0]) * cross;
|
||||
cy += (p[1] + q[1]) * cross;
|
||||
}
|
||||
const centroid = Math.abs(a2) > 1e-9
|
||||
? [cx / (3 * a2), cy / (3 * a2)]
|
||||
: [(minX + maxX) / 2, (minY + maxY) / 2];
|
||||
const clearance = (x: number, y: number): number => {
|
||||
if (!pointInPolygon([x, y], poly)) return -Infinity;
|
||||
let d = Infinity;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
d = Math.min(d, distToSegment([x, y], [a[0], a[1], b[0], b[1]]));
|
||||
}
|
||||
return d;
|
||||
};
|
||||
const score = (x: number, y: number): number => {
|
||||
const d = clearance(x, y);
|
||||
if (d === -Infinity) return d;
|
||||
return d - 0.08 * Math.hypot(x - centroid[0], y - centroid[1]) - 0.0001 * span;
|
||||
};
|
||||
let best: number[] | null = null;
|
||||
let bestS = -Infinity;
|
||||
for (let i = 1; i < steps; i++) {
|
||||
for (let j = 1; j < steps; j++) {
|
||||
const x = minX + ((maxX - minX) * i) / steps;
|
||||
const y = minY + ((maxY - minY) * j) / steps;
|
||||
const sc = score(x, y);
|
||||
if (sc > bestS) { bestS = sc; best = [x, y]; }
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const [bx, by] = best;
|
||||
const cw = (maxX - minX) / steps, ch = (maxY - minY) / steps;
|
||||
for (let i = -4; i <= 4; i++) {
|
||||
for (let j = -4; j <= 4; j++) {
|
||||
const x = bx + (cw * i) / 4, y = by + (ch * j) / 4;
|
||||
const sc = score(x, y);
|
||||
if (sc > bestS) { bestS = sc; best = [x, y]; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return best || interiorPoint(poly) || poly[0];
|
||||
}
|
||||
|
||||
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
|
||||
if (!poly || poly.length < 3) return null;
|
||||
const n = poly.length;
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
loc,
|
||||
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 roomShapes = space.rooms
|
||||
|
||||
+27
-7
@@ -403,31 +403,37 @@ export const cardStyles = css`
|
||||
and the metrics hang below as absolutes — the name renders in exactly
|
||||
the same place in view mode and in the plan editor (owner's request),
|
||||
and the button sits at the very bottom of the card. */
|
||||
/* Standalone, centred on the room, sized from the device icon: icon-size
|
||||
already rescales with the view, so the button zooms WITH the plan
|
||||
instead of keeping a constant screen size (owner's spec). */
|
||||
.rlgearbtn {
|
||||
--gear-h: calc(var(--icon-size, 2.5cqw) * 0.77); /* owner: half the previous size */
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35em);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
/* dead-centred on the room, both axes (owner's spec) */
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
gap: 0.35em;
|
||||
height: var(--gear-h);
|
||||
padding: 0 calc(var(--gear-h) * 0.38);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-size: calc(var(--gear-h) * 0.42);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
z-index: 2;
|
||||
}
|
||||
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
|
||||
.rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: calc(var(--gear-h) * 0.55); display: inline-flex; }
|
||||
.rlgear {
|
||||
--mdc-icon-size: 0.9em;
|
||||
display: inline-flex;
|
||||
@@ -963,6 +969,20 @@ export const cardStyles = css`
|
||||
.dev.virtual {
|
||||
border-style: dashed;
|
||||
}
|
||||
/* "hide from plan" flag, shown only in the device editor with the
|
||||
"show hidden devices" toggle on (docs/FILTERING.md). BLUE, so a hidden
|
||||
device cannot be mistaken for an unavailable one (translucent dark) —
|
||||
and no live-state paint at all: a ghost is configuration, not status
|
||||
(owner's request). */
|
||||
.dev.ghost {
|
||||
opacity: 0.6;
|
||||
border-style: dashed;
|
||||
border-color: var(--hp-accent);
|
||||
background: rgba(62, 166, 255, 0.22); /* fallback for old WebViews */
|
||||
background: color-mix(in srgb, var(--hp-accent) 30%, var(--card-background-color, #1c2530));
|
||||
color: var(--hp-accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
.dev.sel {
|
||||
border-color: #ffc14d;
|
||||
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
|
||||
|
||||
+6
-1
@@ -90,7 +90,9 @@ export interface ServerConfig {
|
||||
settings: {
|
||||
exclude_integrations?: string[];
|
||||
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 }[];
|
||||
};
|
||||
}
|
||||
@@ -101,6 +103,9 @@ export interface DevItem {
|
||||
model: string;
|
||||
area: 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;
|
||||
entities: string[];
|
||||
primary?: string;
|
||||
|
||||
+103
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap } 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';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -483,3 +483,105 @@ test('areaClimateMap: температура и влажность живут в
|
||||
};
|
||||
assert.deepEqual(areaClimateMap(hass).get('hall'), { temp: 21.4, hum: 48 });
|
||||
});
|
||||
|
||||
|
||||
test('primaryEntity: a service switch never beats the visible main function (TRV)', () => {
|
||||
// the owner's radiator heads, verified live: switch outranks climate in the
|
||||
// domain list, and the old inner-tier loop let a CONFIG switch (anti
|
||||
// scaling, child lock) become primary — the icon glowed yellow because
|
||||
// scale protection was on, while the head actually heating stayed dark
|
||||
const hass = {
|
||||
entities: {
|
||||
'climate.trv': {},
|
||||
'sensor.trv_temp': {},
|
||||
'switch.trv_anti_scaling': { entity_category: 'config' },
|
||||
'switch.trv_child_lock': { entity_category: 'config' },
|
||||
},
|
||||
states: {},
|
||||
};
|
||||
assert.equal(
|
||||
primaryEntity(hass, ['switch.trv_anti_scaling', 'switch.trv_child_lock', 'climate.trv', 'sensor.trv_temp'], 'mdi:thermostat'),
|
||||
'climate.trv',
|
||||
);
|
||||
// but a device whose only functional entity IS a switch keeps it
|
||||
const plug = { entities: { 'switch.plug': {}, 'sensor.plug_power': {} }, states: {} };
|
||||
assert.equal(primaryEntity(plug, ['sensor.plug_power', 'switch.plug'], 'mdi:power-socket'), 'switch.plug');
|
||||
});
|
||||
|
||||
test('litLightEntity: one truth for "this thing is shining"', () => {
|
||||
const hass = { states: {
|
||||
'light.a': { state: 'off' }, 'light.b': { state: 'on' },
|
||||
'switch.wall': { state: 'on' }, 'sensor.x': { state: '5' },
|
||||
} };
|
||||
// a lit light.* anywhere in the device
|
||||
assert.equal(litLightEntity(hass, { entities: ['light.a', 'light.b'] }), 'light.b');
|
||||
assert.equal(litLightEntity(hass, { entities: ['light.a'] }), null);
|
||||
// without the flag a lit switch is NOT a light
|
||||
assert.equal(litLightEntity(hass, { entities: ['switch.wall'] }), null);
|
||||
// with "is a light source" any lit entity counts, controls first
|
||||
assert.equal(
|
||||
litLightEntity(hass, { entities: ['sensor.x'], marker: { is_light: true, controls: ['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);
|
||||
});
|
||||
|
||||
+28
-2
@@ -15,8 +15,7 @@ import {
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
} from '../test-build/logic.js';
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility } from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
} from '../test-build/rules.js';
|
||||
@@ -910,3 +909,30 @@ test('chunk / referencedContentUrls: signing batches and cache pruning (review R
|
||||
assert.equal(referencedContentUrls(null).size, 0);
|
||||
assert.equal(referencedContentUrls({}).size, 0);
|
||||
});
|
||||
|
||||
|
||||
test('poleOfInaccessibility: the VISUAL centre, not just any interior point', () => {
|
||||
// a square: the pole is the centre
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const p = poleOfInaccessibility(sq);
|
||||
assert.ok(Math.abs(p[0] - 5) < 0.6 && Math.abs(p[1] - 5) < 0.6);
|
||||
|
||||
// an ELONGATED rectangle: clearance is a plateau along the midline, and a
|
||||
// plain argmax used to take its first point — left of centre on the
|
||||
// owner's kitchen, above centre in the sauna. The centroid pull centres it.
|
||||
const wide = [[0, 0], [30, 0], [30, 10], [0, 10]];
|
||||
const w = poleOfInaccessibility(wide);
|
||||
assert.ok(Math.abs(w[0] - 15) < 1.2 && Math.abs(w[1] - 5) < 1.2, 'centred both axes: ' + w);
|
||||
const tall = [[0, 0], [8, 0], [8, 28], [0, 28]];
|
||||
const tl = poleOfInaccessibility(tall);
|
||||
assert.ok(Math.abs(tl[0] - 4) < 1 && Math.abs(tl[1] - 14) < 1.2, 'sauna case, centred vertically: ' + tl);
|
||||
|
||||
// the owner's kitchen-living room shape: a THICK top slab with a narrower
|
||||
// column hanging off the right side. The button belongs near the middle of
|
||||
// the slab — pulled toward the area centroid, never down the thin column.
|
||||
const L = [[0, 0], [20, 0], [20, 16], [16, 16], [16, 8], [0, 8]];
|
||||
const q = poleOfInaccessibility(L);
|
||||
assert.ok(pointInPolygon(q, L), 'inside, always');
|
||||
assert.ok(q[1] < 8, 'in the thick slab (y < 8), not down the column: ' + q);
|
||||
assert.ok(Math.abs(q[0] - 11.3) < 2.5, 'near the centroid x within the slab: ' + q);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user