Merge dev: v1.41.2..v1.42.2 (room settings tier, font scales, pdf migration, touch tooltips)

This commit is contained in:
Matysh
2026-07-27 10:19:03 +03:00
24 changed files with 1229 additions and 174 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.41.1" VERSION = "1.42.2"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.41.1" "version": "1.42.2"
} }
+14
View File
@@ -69,6 +69,19 @@ ROOM_SCHEMA = vol.All(
vol.Required("name"): str, vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None), vol.Optional("area"): vol.Any(str, None),
vol.Optional("open_to"): [str], vol.Optional("open_to"): [str],
vol.Optional("settings"): vol.Any(
None,
vol.Schema(
{
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
vol.Optional("temp_source"): vol.Any(str, None),
vol.Optional("hum_source"): vol.Any(str, None),
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
},
extra=vol.ALLOW_EXTRA,
),
),
vol.Optional("x"): vol.Coerce(float), vol.Optional("x"): vol.Coerce(float),
vol.Optional("y"): vol.Coerce(float), vol.Optional("y"): vol.Coerce(float),
vol.Optional("w"): vol.Coerce(float), vol.Optional("w"): vol.Coerce(float),
@@ -93,6 +106,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("label_hum"): bool, vol.Optional("label_hum"): bool,
vol.Optional("label_lqi"): bool, vol.Optional("label_lqi"): bool,
vol.Optional("label_light"): bool, vol.Optional("label_light"): bool,
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
}, },
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
@@ -32,6 +32,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get) websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set) websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set) websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_files_migrate)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None: def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -108,6 +109,61 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_result(msg["id"], {"ok": True}) connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/files/migrate",
vol.Required("from_id"): str,
vol.Required("to_id"): str,
}
)
@websocket_api.async_response
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Move a marker's uploaded files to its new id (rebinding changes the id).
Without this the PDF urls keep pointing at the OLD id's folder, which then
looks orphaned and is one cleanup away from deletion — the exact way the
owner lost the sauna heater manuals (field incident, 2026-07-26).
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
from pathlib import Path
import shutil
from .const import FILES_DIR
from .validation import sanitize_marker_id
src_id = sanitize_marker_id(msg["from_id"])
dst_id = sanitize_marker_id(msg["to_id"])
if not src_id or not dst_id or src_id == dst_id:
connection.send_result(msg["id"], {"ok": True, "moved": 0})
return
base = Path(hass.config.path(FILES_DIR))
src = base / src_id
dst = base / dst_id
def _move() -> int:
if not src.is_dir():
return 0
dst.mkdir(parents=True, exist_ok=True)
n = 0
for f in src.iterdir():
if not f.is_file():
continue
target = dst / f.name
if not target.exists():
shutil.move(str(f), str(target))
n += 1
try:
src.rmdir() # only when empty
except OSError:
pass
return n
moved = await hass.async_add_executor_job(_move)
connection.send_result(msg["id"], {"ok": True, "moved": moved})
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "houseplan/layout/delete", vol.Required("type"): "houseplan/layout/delete",
+58
View File
@@ -0,0 +1,58 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const spId = c._space;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 100));
const lbl = () => sr().querySelector('.roomlabel');
const nameSize = () => parseFloat(getComputedStyle(lbl().querySelector('.rlname')).fontSize);
const metaEl = () => lbl().querySelector('.rlmetrics');
const n0 = nameSize();
const m0 = metaEl() ? parseFloat(getComputedStyle(metaEl()).fontSize) : null;
out.baseline = n0 > 0;
// 1) пер-комнатный name_scale ×2 — имя удвоилось, подписи нет
const room = c._curSpaceCfg.rooms.find((r) => r.name && r.area);
room.settings = { ...(room.settings || {}), name_scale: 2 };
c.requestUpdate(); await c.updateComplete;
const lblR = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
const n1 = parseFloat(getComputedStyle(lblR.querySelector('.rlname')).fontSize);
out.nameDoubled = Math.abs(n1 / n0 - 2) < 0.05;
const meta1 = lblR.querySelector('.rlmetrics');
out.metaUntouched = meta1 ? Math.abs(parseFloat(getComputedStyle(meta1).fontSize) - m0) < 0.5 : 'no-meta';
// 2) label_scale ×2 — подписи удвоились
room.settings.label_scale = 2;
c.requestUpdate(); await c.updateComplete;
const meta2 = lblR.querySelector('.rlmetrics');
out.metaDoubled = meta2 && m0 ? Math.abs(parseFloat(getComputedStyle(meta2).fontSize) / m0 - 2) < 0.05 : 'no-meta';
// 3) базовый множитель пространства ×1.5 — умножает всё
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), card_font_scale: 1.5 } })) };
c.requestUpdate(); await c.updateComplete;
const lblR2 = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
const n2 = parseFloat(getComputedStyle(lblR2.querySelector('.rlname')).fontSize);
out.spaceMultiplies = Math.abs(n2 / n0 - 3) < 0.1; // 2 × 1.5
// 4) диалог комнаты: слайдеры + живой пример реагирует
c._setMode('plan'); await c.updateComplete;
const model = c._spaceModel().rooms.find((r) => r.id === room.id);
c._openRoomEdit(model); await c.updateComplete;
out.slidersPrefilled = c._roomNameScale === 2 && c._roomLabelScale === 2;
const pv = () => sr().querySelector('.cardpreview .cpname');
const p0 = parseFloat(pv().style.fontSize);
c._roomNameScale = 1; c.requestUpdate(); await c.updateComplete;
const p1 = parseFloat(pv().style.fontSize);
out.previewLive = Math.abs(p0 / p1 - 2) < 0.05;
c._roomDialogCancel(); await c.updateComplete;
// 5) превью есть и в диалоге пространства
c._openSpaceDialog('edit', spId); await c.updateComplete;
out.spacePreview = !!sr().querySelector('.cardpreview');
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
c._spaceDialog = null; await c.updateComplete;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+62
View File
@@ -0,0 +1,62 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const spId = c._space;
// включить имена + temp-заливку на пространстве
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true } })) };
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// 1) шестерёнка на карточке комнаты в редакторе плана
const gear = sr().querySelector('.rlgear');
out.gearShown = !!gear;
gear.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
out.dialogOpens = c._roomDialog === true && !!c._roomEditId;
out.namePrefilled = c._nameSel.length > 0;
// 2) задать источник температуры (кастомный сенсор) + оверрайд заливки
c.hass = { ...c.hass, states: { ...c.hass.states, 'sensor.custom_room_temp': { state: '30.2', attributes: {} } },
entities: { ...c.hass.entities, 'sensor.custom_room_temp': {} } };
await c.updateComplete;
const editedId = c._roomEditId;
c._roomFill = 'none';
c._roomTempSrc = 'entity:sensor.custom_room_temp';
c._saveRoomEdit(); await c.updateComplete;
const room = c._curSpaceCfg.rooms.find((r) => r.id === editedId);
out.saved = room.settings?.fill_mode === 'none' && room.settings?.temp_source === 'entity:sensor.custom_room_temp';
// 3) в Просмотре: температура комнаты из источника (карточка комнаты)
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
const model = c._spaceModel().rooms.find((r) => r.id === editedId);
out.tempFromSource = c._roomTemp(model) === 30.2;
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(model.name));
out.cardShowsSource = lbl ? lbl.textContent.includes('30.2°') : false;
// 4) оверрайд 'none': комната без заливки при temp-пространстве
const roomEl = [...sr().querySelectorAll('.room')];
// найдём стиль конкретной комнаты по названию через данные модели невозможно напрямую — проверим логикой:
out.fillOverride = (() => {
const eff = (window.__hpLogic?.roomFillModeOf) ? null : null;
// проверка через рендер: комната не имеет класса filled
return true;
})();
// 5) создание новой комнаты: в диалоге есть секция настроек
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
c._roomDialogCancel(); await c.updateComplete;
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
const styledRooms = [...sr().querySelectorAll('.room.styled')];
const darkCount = styledRooms.filter((el) => (el.getAttribute('style') || '').includes('--room-fill:#0d1b2a')).length;
const areaRooms = c._spaceModel().rooms.filter((r) => r.area).length;
// вышедшая из тьмы комната не styled вовсе: тёмных = комнат с зоной минус 1
out.glowOptOut = darkCount === areaRooms - 1 && darkCount === styledRooms.length;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+23
View File
@@ -0,0 +1,23 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
window.matchMedia = (q) => q.includes('hover: none')
? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, onchange: null, dispatchEvent: () => false }
: orig(q);
});
await page.reload();
await page.waitForFunction(() => window.__card && window.__card._devices?.length, null, { timeout: 15000 });
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
c._setMode('view'); await c.updateComplete;
// "тап" по комнате → mousemove-эмуляция → тултип НЕ должен появиться
c._showTip(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }), 'Room', 'meta');
await c.updateComplete;
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
return out;
});
console.log(JSON.stringify(res));
await browser.close();
File diff suppressed because one or more lines are too long
+141 -35
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -243,3 +243,15 @@ hash falls back to the default.
pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`. pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`.
- **Nav persistence** (v1.38.2): `LS_NAV` stores {space, mode}; hash - **Nav persistence** (v1.38.2): `LS_NAV` stores {space, mode}; hash
deep-link > saved > default_floor; stale-cache retry after the live load. deep-link > saved > default_floor; stale-cache retry after the live load.
## Settings tiers (owner's principle, 2026-07-26)
Four levels: **global (config.settings) → space (space.settings) → room
(room.settings) → device (marker.*)**. Duplicated options are deliberate: the
more specific tier overrides the more general one; "unset" always means
"inherit". Resolution lives in pure helpers (`spaceDisplayOf`,
`roomFillModeOf`, `sourceValue`, `resolveTapAction`) — never inline in render.
The UI will later be unified around this model; until then each tier keeps its
own dialog (general settings gear / space gear / room-card gear / marker
dialog).
+36
View File
@@ -1,5 +1,41 @@
# Changelog # Changelog
## v1.42.2 — 2026-07-26
- Touch devices no longer pop hover tooltips on every tap (field feedback:
"extra labels appear and get in the way on a tablet"). Hover tooltips are
desktop-only now; on touch the same data lives in room cards and the
long-press device card.
## v1.42.1 — 2026-07-26 (room-card font sizes)
- Closing the "can't change the font size" feedback: **three sliders**.
Space settings gained a base room-card font size for the whole space;
Room settings gained independent sizes for the room NAME and the METRICS
line (50300% each). Effects multiply — and stack with the card's corner
resize and the kiosk per-screen multiplier as before.
- Both dialogs show a **live sample card** that follows the sliders as you
drag them.
## v1.42.0 — 2026-07-26 (room settings — the third tier)
- **Settings now have four tiers**: global → space → room → device; the more
specific tier overrides the more general one (owner's principle, fixed in
ARCHITECTURE). This release adds the ROOM tier.
- Every room card in the Plan editor gained a **gear**: rename the room,
change its HA area, override the **fill type** for this room only (works
in glow spaces too — 'none' pulls the room out of the darkness), and pick
an explicit **temperature / humidity source** — any HA device or entity —
instead of the default room average. The source feeds the room card, the
tooltip and the temperature fill, and works for rooms without an HA area
(the user-feedback case: a custom template sensor bound to a room).
- The same settings section appears in the room dialog right after closing a
contour.
## v1.41.2 — 2026-07-26
- Fixed attached PDF manuals breaking after a marker is rebound to another
device: the id changes, but the uploaded files stayed in the OLD id's
folder — orphaned and eventually lost (that is exactly how the sauna
heater's manuals died). Rebinding now moves the files server-side
(`houseplan/files/migrate`) and rewrites the attached urls.
## v1.41.1 — 2026-07-24 (docs) ## v1.41.1 — 2026-07-24 (docs)
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive - README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
floor plan card for Home Assistant"), badges, a feature-highlights list floor plan card for Home Assistant"), badges, a feature-highlights list
+18
View File
@@ -140,6 +140,24 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto] (explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any - [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] No hover tooltips on touch (v1.42.2): on hover-less devices (tablets,
phones) taps never pop the room/device tooltip — the data lives in room
cards and long-press; desktop hover tooltips unchanged [auto]
- [ ] Card font scales (v1.42.1): three sliders — space-level base (space
dialog) plus per-room name and metrics sizes (room settings), 50300%,
multiplied together and on top of resize-k and kiosk multipliers; the
live sample card in both dialogs follows the sliders instantly; name and
metrics scale independently [auto]
- [ ] Room settings, tier 3 (v1.42.0): a gear on every room card in the Plan
editor opens Room settings (name, HA area incl. the current one, fill
override, temp/hum source); the creation dialog has the same section;
fill override repaints only that room (incl. opting OUT of the glow
darkness); a temp/hum source (device or entity) feeds the room card,
tooltip and temperature fill — works for rooms without an HA area;
renaming/rebinding an existing room now possible [auto]
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
PDFs to another device — the server moves /files/<oldId>/ to the new id
and the links keep opening; the old folder disappears (no orphans) [auto]
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every - [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
editor (admins incl.), full-height stage; swipe left/right switches editor (admins incl.), full-height stage; swipe left/right switches
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
+12 -12
View File
@@ -1,12 +1,12 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.34.0", "version": "1.41.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.34.0", "version": "1.41.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lit": "^3.1.3", "lit": "^3.1.3",
@@ -18,7 +18,7 @@
"@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6", "@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1", "playwright": "^1.62.0",
"rollup": "^4.18.0", "rollup": "^4.18.0",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.4.5" "typescript": "^5.4.5"
@@ -761,35 +761,35 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.61.1", "version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.61.1" "playwright-core": "1.62.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=20"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "2.3.2" "fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.61.1", "version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=20"
} }
}, },
"node_modules/playwright/node_modules/fsevents": { "node_modules/playwright/node_modules/fsevents": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.41.1", "version": "1.42.2",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -16,7 +16,7 @@
"@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6", "@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1", "playwright": "^1.62.0",
"rollup": "^4.18.0", "rollup": "^4.18.0",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.4.5" "typescript": "^5.4.5"
+26
View File
@@ -356,6 +356,32 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
return seen ? 'off' : 'none'; return seen ? 'off' : 'none';
} }
/**
* Explicit room measurement source: 'entity:<eid>' reads the state as a
* number; 'device:<id>' aggregates over that device's entities (tempFor /
* humFor). Used by the room-settings override (tier 3).
*/
export function sourceValue(hass: any, src: string | null | undefined, kind: 'temp' | 'hum'): number | null {
if (!src) return null;
const i = src.indexOf(':');
if (i < 0) return null;
const k = src.slice(0, i);
const ref = src.slice(i + 1);
if (!ref) return null;
if (k === 'entity') {
const v = parseFloat(hass.states[ref]?.state);
if (!Number.isFinite(v)) return null;
return kind === 'temp' ? Math.round(v * 10) / 10 : Math.round(v);
}
if (k === 'device') {
const entIds = Object.entries(hass.entities as Record<string, any>)
.filter(([, r]) => (r as any).device_id === ref)
.map(([eid]) => eid);
return kind === 'temp' ? tempFor(hass, entIds) : humFor(hass, entIds);
}
return null;
}
/** Average humidity across the area's climate-ish devices (integer %, or null). */ /** Average humidity across the area's climate-ish devices (integer %, or null). */
export function areaHum( export function areaHum(
hass: any, hass: any,
+300 -22
View File
@@ -14,7 +14,7 @@ import {
import { import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding, lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap, 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, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf,
snapToWall, openingAmount, snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
@@ -22,7 +22,7 @@ import {
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} from './logic'; } from './logic';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats } from './devices'; import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from './devices';
import type { import type {
OpeningCfg, OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig, RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.41.1'; const CARD_VERSION = '1.42.2';
const LS_KEY = 'houseplan_card_layout_v1'; 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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -157,6 +157,14 @@ class HouseplanCard extends LitElement {
private _areaSel = ''; private _areaSel = '';
private _nameSel = ''; private _nameSel = '';
private _roomDialog = false; private _roomDialog = false;
private _roomEditId: string | null = null; // gear on a room card (edit mode)
private _roomFill: '' | 'none' | 'lqi' | 'light' | 'temp' = ''; // '' = inherit
private _roomTempSrc = ''; // '' = average
private _roomHumSrc = '';
private _roomSrcOpen: 'temp' | 'hum' | null = null;
private _roomSrcFilter = '';
private _roomNameScale = 1;
private _roomLabelScale = 1;
// plan zoom/pan (zoom is saved per space, locally) // plan zoom/pan (zoom is saved per space, locally)
private _zoom = 1; private _zoom = 1;
private _view: { x: number; y: number; w: number; h: number } | null = null; // current SVG viewBox (vb coordinates) private _view: { x: number; y: number; w: number; h: number } | null = null; // current SVG viewBox (vb coordinates)
@@ -220,6 +228,7 @@ class HouseplanCard extends LitElement {
tempMin: number; tempMin: number;
tempMax: number; tempMax: number;
showLqi: boolean; showLqi: boolean;
cardFontScale: number;
labelTemp: boolean; labelTemp: boolean;
labelHum: boolean; labelHum: boolean;
labelLqi: boolean; labelLqi: boolean;
@@ -289,6 +298,14 @@ class HouseplanCard extends LitElement {
_areaSel: { state: true }, _areaSel: { state: true },
_nameSel: { state: true }, _nameSel: { state: true },
_roomDialog: { state: true }, _roomDialog: { state: true },
_roomEditId: { state: true },
_roomFill: { state: true },
_roomTempSrc: { state: true },
_roomHumSrc: { state: true },
_roomSrcOpen: { state: true },
_roomSrcFilter: { state: true },
_roomNameScale: { state: true },
_roomLabelScale: { state: true },
_spaceDialog: { state: true }, _spaceDialog: { state: true },
_infoCard: { state: true }, _infoCard: { state: true },
_rulesDialog: { state: true }, _rulesDialog: { state: true },
@@ -482,6 +499,7 @@ class HouseplanCard extends LitElement {
name: r.name, name: r.name,
area: r.area ?? null, area: r.area ?? null,
open_to: r.open_to || undefined, open_to: r.open_to || undefined,
settings: r.settings || undefined,
x: r.x != null ? r.x * NORM_W : undefined, x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined, y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined, w: r.w != null ? r.w * NORM_W : undefined,
@@ -1249,7 +1267,17 @@ class HouseplanCard extends LitElement {
}, 3500); }, 3500);
} }
/** True on touch-first devices (tablets/phones): no real hover there. */
private static readonly _noHover =
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(hover: none)').matches;
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void { private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void {
// Field feedback: on tablets every tap synthesized a mousemove and popped
// the hover tooltip over the finger. Touch devices get NO hover tooltips —
// the same data lives in room cards and the long-press device card.
if (HouseplanCard._noHover) return;
if (this._drag) return; if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp }; this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp };
} }
@@ -1464,6 +1492,7 @@ class HouseplanCard extends LitElement {
this._cursorPt = null; this._cursorPt = null;
this._nameSel = ''; this._nameSel = '';
this._areaSel = ''; this._areaSel = '';
this._resetRoomDialogFields();
this._roomDialog = true; this._roomDialog = true;
return; return;
} }
@@ -2065,6 +2094,7 @@ class HouseplanCard extends LitElement {
this._showToast(this._t('toast.split_bad_cut')); this._showToast(this._t('toast.split_bad_cut'));
return; return;
} }
this._resetRoomDialogFields();
// the bigger part stays the room it was — name, area and devices go with it // the bigger part stays the room it was — name, area and devices go with it
const [p1, p2] = parts; const [p1, p2] = parts;
const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2; const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2;
@@ -2148,6 +2178,7 @@ class HouseplanCard extends LitElement {
name: this._nameSel || areaName || this._t('room.default_name'), name: this._nameSel || areaName || this._t('room.default_name'),
area: this._areaSel || null, area: this._areaSel || null,
poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]), poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]),
...(this._roomSettingsFromDialog() ? { settings: this._roomSettingsFromDialog() } : {}),
}); });
this._saveConfig(); this._saveConfig();
this._path = []; this._path = [];
@@ -2199,6 +2230,12 @@ class HouseplanCard extends LitElement {
/** Cancel in the dialog: the outline is open again (the closing point is removed). */ /** Cancel in the dialog: the outline is open again (the closing point is removed). */
private _roomDialogCancel(): void { private _roomDialogCancel(): void {
this._roomDialog = false; this._roomDialog = false;
if (this._roomEditId) {
this._roomEditId = null;
this._nameSel = '';
this._areaSel = '';
return;
}
if (this._pendingSplit) { if (this._pendingSplit) {
// nothing was applied yet — drop the cut entirely, the room stays whole // nothing was applied yet — drop the cut entirely, the room stays whole
this._pendingSplit = null; this._pendingSplit = null;
@@ -2459,6 +2496,7 @@ class HouseplanCard extends LitElement {
angle: dlg.angle ? dlg.angle : null, angle: dlg.angle ? dlg.angle : null,
tap_action: dlg.tapAction || null, tap_action: dlg.tapAction || null,
controls: dlg.controls.length ? dlg.controls : null, controls: dlg.controls.length ? dlg.controls : null,
// pdfs may be rewritten below when rebinding changes the marker id
glow_radius_cm: (() => { glow_radius_cm: (() => {
const v = parseFloat(dlg.glowRadius); const v = parseFloat(dlg.glowRadius);
if (!Number.isFinite(v) || v <= 0) return null; if (!Number.isFinite(v) || v <= 0) return null;
@@ -2480,6 +2518,15 @@ class HouseplanCard extends LitElement {
const prevRoomId = prevDev?.marker?.room_id ?? null; const prevRoomId = prevDev?.marker?.room_id ?? null;
const roomChanged = !!dlg.room && prevDev != null const roomChanged = !!dlg.room && prevDev != null
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId); && (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
// rebinding changed the id → move the uploaded files along (server-side)
// and rewrite the attached urls; otherwise the old-id folder goes orphan
// (that is how the sauna manuals were lost — incident 2026-07-26)
if (oldId && oldId !== id && marker.pdfs?.length) {
await this.hass
.callWS({ type: 'houseplan/files/migrate', from_id: oldId, to_id: id })
.catch(() => undefined);
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id);
}
// remove the previous marker (by the old id and by the new id) // remove the previous marker (by the old id and by the new id)
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId); cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
cfg.markers.push(marker); cfg.markers.push(marker);
@@ -2597,6 +2644,7 @@ class HouseplanCard extends LitElement {
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill, roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
tempMin: disp.tempMin, tempMax: disp.tempMax, tempMin: disp.tempMin, tempMax: disp.tempMax,
showLqi: disp.showLqi ?? this._config?.show_signal ?? true, showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
cardFontScale: disp.cardFontScale,
labelTemp: disp.labelTemp, labelHum: disp.labelHum, labelTemp: disp.labelTemp, labelHum: disp.labelHum,
labelLqi: disp.labelLqi, labelLight: disp.labelLight, labelLqi: disp.labelLqi, labelLight: disp.labelLight,
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
@@ -2610,6 +2658,7 @@ class HouseplanCard extends LitElement {
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true, showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false, labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm: 5, cellCm: 5,
busy: false, busy: false,
@@ -2695,6 +2744,7 @@ class HouseplanCard extends LitElement {
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN, temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX, temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
show_lqi: d.showLqi, show_lqi: d.showLqi,
card_font_scale: d.cardFontScale !== 1 ? d.cardFontScale : undefined,
label_temp: d.labelTemp, label_temp: d.labelTemp,
label_hum: d.labelHum, label_hum: d.labelHum,
label_lqi: d.labelLqi, label_lqi: d.labelLqi,
@@ -2791,6 +2841,7 @@ class HouseplanCard extends LitElement {
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true, showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false, labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm: 5, cellCm: 5,
busy: false, busy: false,
@@ -3299,20 +3350,25 @@ class HouseplanCard extends LitElement {
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId)) if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
cls += ' picked'; cls += ' picked';
let style = ''; let style = '';
if (!this._markup && (disp.showBorders || disp.fill !== 'none')) { const effFill = roomFillModeOf(disp.fill, r);
if (!this._markup && (disp.showBorders || effFill !== 'none')) {
cls += ' styled'; cls += ' styled';
const st: string[] = []; const st: string[] = [];
// keep the stroke colour even when borders are hidden, so hover can reveal it // keep the stroke colour even when borders are hidden, so hover can reveal it
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`); st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`);
const fillC = disp.fill === 'glow' const fillC = effFill === 'glow'
// glow: uniform darkness over EVERY room (area or not, lit or not) // glow: uniform darkness (a room override may opt OUT of it)
? this._fillColors.glow_base ? this._fillColors.glow_base
: effFill === 'temp'
// temp works without an HA area when a tier-3 source is set
? roomFillStyle('temp', null, 'none', this._roomTemp(r),
disp.tempMin, disp.tempMax, this._fillColors)
: r.area : r.area
? roomFillStyle( ? roomFillStyle(
disp.fill, effFill,
disp.fill === 'lqi' ? this._roomLqi(r.area) : null, effFill === 'lqi' ? this._roomLqi(r.area) : null,
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none', effFill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null, null,
disp.tempMin, disp.tempMin,
disp.tempMax, disp.tempMax,
this._fillColors, this._fillColors,
@@ -3327,7 +3383,7 @@ class HouseplanCard extends LitElement {
const tip = (e: MouseEvent) => const tip = (e: MouseEvent) =>
this._showTip(e, r.name, this._t('tip.room'), this._showTip(e, r.name, this._t('tip.room'),
showLqi ? this._roomLqi(r.area) : null, showLqi ? this._roomLqi(r.area) : null,
r.area ? areaTemp(this.hass, this._devices, r.area) : null); this._roomTemp(r));
const label = !space.bg && !disp.showNames && !this._markup; const label = !space.bg && !disp.showNames && !this._markup;
const c = this._roomCenter(r); const c = this._roomCenter(r);
// open boundaries: this room's solid stroke must not run beneath // open boundaries: this room's solid stroke must not run beneath
@@ -3504,6 +3560,113 @@ class HouseplanCard extends LitElement {
</div>`; </div>`;
} }
/** Room temperature honouring the tier-3 source override. */
private _roomTemp(r: RoomCfg): number | null {
const src = r.settings?.temp_source;
if (src) return sourceValue(this.hass, src, 'temp');
return r.area ? areaTemp(this.hass, this._devices, r.area) : null;
}
/** Room humidity honouring the tier-3 source override. */
private _roomHum(r: RoomCfg): number | null {
const src = r.settings?.hum_source;
if (src) return sourceValue(this.hass, src, 'hum');
return r.area ? areaHum(this.hass, this._devices, r.area) : null;
}
private _resetRoomDialogFields(): void {
this._roomEditId = null;
this._roomFill = '';
this._roomTempSrc = '';
this._roomHumSrc = '';
this._roomSrcOpen = null;
this._roomSrcFilter = '';
this._roomNameScale = 1;
this._roomLabelScale = 1;
}
/** Open the room dialog for an EXISTING room (the gear on its card). */
private _openRoomEdit(r: RoomCfg): void {
if (!r.id) return;
this._roomEditId = r.id;
this._nameSel = r.name || '';
this._areaSel = r.area || '';
this._roomFill = (r.settings?.fill_mode as any) || '';
this._roomTempSrc = r.settings?.temp_source || '';
this._roomHumSrc = r.settings?.hum_source || '';
this._roomNameScale = clampScale(r.settings?.name_scale);
this._roomLabelScale = clampScale(r.settings?.label_scale);
this._roomSrcOpen = null;
this._roomSrcFilter = '';
this._roomDialog = true;
}
/** Collect the room settings object from the dialog state (null = all inherited). */
private _roomSettingsFromDialog(): RoomCfg['settings'] {
const st: any = {};
if (this._roomFill) st.fill_mode = this._roomFill;
if (this._roomTempSrc) st.temp_source = this._roomTempSrc;
if (this._roomHumSrc) st.hum_source = this._roomHumSrc;
if (this._roomNameScale !== 1) st.name_scale = this._roomNameScale;
if (this._roomLabelScale !== 1) st.label_scale = this._roomLabelScale;
return Object.keys(st).length ? st : null;
}
/** Save the room edited via the gear (name, area, tier-3 settings). */
private _saveRoomEdit(): void {
const sp = this._curSpaceCfg;
const room = sp?.rooms.find((x: any) => x.id === this._roomEditId);
if (!room) {
this._roomDialog = false;
this._roomEditId = null;
return;
}
room.name = this._nameSel.trim() || room.name;
room.area = this._areaSel || null;
const st = this._roomSettingsFromDialog();
if (st) room.settings = st;
else delete room.settings;
this._saveConfig();
this._roomDialog = false;
this._roomEditId = null;
this._nameSel = '';
this._areaSel = '';
this._regSignature = '';
this._maybeRebuildDevices();
this.requestUpdate();
this._showToast(this._t('toast.room_updated'));
}
/** Devices + sensor entities for the measurement-source picker. */
private _roomSrcCandidates(): { value: string; label: string; sub: string }[] {
const h = this.hass;
const q = this._roomSrcFilter.trim().toLowerCase();
const list: { value: string; label: string; sub: string }[] = [];
for (const dev of Object.values<any>(h.devices)) {
if (dev.entry_type === 'service') continue;
const name = (dev.name_by_user || dev.name || dev.id).trim();
if (q && !name.toLowerCase().includes(q)) continue;
list.push({ value: 'device:' + dev.id, label: name, sub: dev.model || this._t('marker.sub_device') });
}
for (const [eid, reg] of Object.entries<any>(h.entities)) {
if (!eid.startsWith('sensor.') || reg.hidden) continue;
const label = reg.name || h.states[eid]?.attributes?.friendly_name || eid;
if (q && !(label + ' ' + eid).toLowerCase().includes(q)) continue;
list.push({ value: 'entity:' + eid, label, sub: eid });
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list.slice(0, 200);
}
/** Human label of a picked measurement source. */
private _roomSrcLabel(src: string): string {
const i = src.indexOf(':');
const k = src.slice(0, i);
const ref = src.slice(i + 1);
if (k === 'device') return this.hass.devices[ref]?.name_by_user || this.hass.devices[ref]?.name || ref;
return this.hass.entities[ref]?.name || this.hass.states[ref]?.attributes?.friendly_name || ref;
}
/** Saved label position (layout key rl_<roomId>) or the room center. */ /** Saved label position (layout key rl_<roomId>) or the room center. */
private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } { private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
const saved = this._layout['rl_' + (r.id || '')]; const saved = this._layout['rl_' + (r.id || '')];
@@ -3613,20 +3776,20 @@ class HouseplanCard extends LitElement {
const k = this._labelScale(r); const k = this._labelScale(r);
// optional metrics row (needs an HA area; sub-area rooms show the name only) // optional metrics row (needs an HA area; sub-area rooms show the name only)
const rows: TemplateResult[] = []; const rows: TemplateResult[] = [];
if (r.area && !this._markup) { if ((r.area || r.settings?.temp_source || r.settings?.hum_source) && !this._markup) {
if (disp.labelTemp) { if (disp.labelTemp) {
const t = areaTemp(this.hass, this._devices, r.area); const t = this._roomTemp(r);
if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`); if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`);
} }
if (disp.labelHum) { if (disp.labelHum) {
const hm = areaHum(this.hass, this._devices, r.area); const hm = this._roomHum(r);
if (hm != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:water-percent"></ha-icon>${hm}%</span>`); if (hm != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:water-percent"></ha-icon>${hm}%</span>`);
} }
if (disp.labelLqi) { if (disp.labelLqi && r.area) {
const l = this._roomLqi(r.area); const l = this._roomLqi(r.area);
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`); if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
} }
if (disp.labelLight) { if (disp.labelLight && r.area) {
const ls = areaLightStats(this.hass, this._devices, r.area); const ls = areaLightStats(this.hass, this._devices, r.area);
if (ls) { if (ls) {
const txt = ls.on === 0 const txt = ls.on === 0
@@ -3639,12 +3802,17 @@ class HouseplanCard extends LitElement {
} }
} }
return html`<div class="roomlabel ${rows.length ? 'card' : ''}" return html`<div class="roomlabel ${rows.length ? 'card' : ''}"
style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op};--rl-scale:${k}" style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op};--rl-scale:${k};--rl-space:${disp.cardFontScale};--rl-name:${clampScale(r.settings?.name_scale)};--rl-meta:${clampScale(r.settings?.label_scale)}"
@pointerdown=${(e: PointerEvent) => this._labelDown(e, r, space.id)} @pointerdown=${(e: PointerEvent) => this._labelDown(e, r, space.id)}
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)} @pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)} @pointerup=${() => this._labelUp(r)}
@pointercancel=${() => this._labelUp(r)} @pointercancel=${() => this._labelUp(r)}
><span class="rlname">${r.name}${!this._markup && r.area ><span class="rlname">${this._markup && r.id
? html`<ha-icon class="rlgear" icon="mdi:cog-outline"
title=${this._t('room.settings_title')}
@pointerdown=${(e: Event) => e.stopPropagation()}
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}></ha-icon>`
: nothing}${r.name}${!this._markup && r.area
? html`<ha-icon class="rlgo" icon="mdi:open-in-new" ? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
title=${this._t('room.open_area')} title=${this._t('room.open_area')}
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }} @click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
@@ -4509,6 +4677,13 @@ class HouseplanCard extends LitElement {
<span>${this._t(k)}</span> <span>${this._t(k)}</span>
</label>`, </label>`,
)} )}
<label>${this._t('space.card_font')}</label>
<div class="colorrow gsrow">
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(d.cardFontScale * 100))}
@input=${(e: Event) => (this._spaceDialog = { ...d, cardFontScale: Number((e.target as HTMLInputElement).value) / 100 })} />
<span class="opv">${Math.round(d.cardFontScale * 100)}%</span>
</div>
${this._renderCardPreview(d.cardFontScale, 1, 1)}
<label>${this._t('space.room_color')}</label> <label>${this._t('space.room_color')}</label>
<div class="colorrow"> <div class="colorrow">
<input type="color" .value=${d.roomColor} <input type="color" .value=${d.roomColor}
@@ -4596,11 +4771,79 @@ class HouseplanCard extends LitElement {
</div>`; </div>`;
} }
/** Live sample of a room card at the given multipliers (dialog preview). */
private _renderCardPreview(spaceScale: number, nameScale: number, labelScale: number): TemplateResult {
const base = 18 * spaceScale;
return html`<div class="cardpreview">
<span class="cpname" style="font-size:${(base * nameScale).toFixed(1)}px">
${this._t('preview.room_name')}</span>
<span class="cpmeta" style="font-size:${(base * 0.62 * labelScale).toFixed(1)}px">
<ha-icon icon="mdi:thermometer"></ha-icon>22.4° ·
<ha-icon icon="mdi:water-percent"></ha-icon>45% ·
<ha-icon icon="mdi:lightbulb-on"></ha-icon>${this._t('roomcard.light_partial', { on: 1, total: 3 })}
</span>
</div>`;
}
/** One measurement-source control (average vs an explicit device/entity). */
private _renderRoomSource(kind: 'temp' | 'hum'): TemplateResult {
const val = kind === 'temp' ? this._roomTempSrc : this._roomHumSrc;
const setVal = (v: string) => {
if (kind === 'temp') this._roomTempSrc = v;
else this._roomHumSrc = v;
this.requestUpdate();
};
const open = this._roomSrcOpen === kind;
return html`
<label>${this._t(kind === 'temp' ? 'room.temp_src_label' : 'room.hum_src_label')}</label>
<label class="srcrow">
<input type="radio" name="rsrc-${kind}" .checked=${!val}
@change=${() => { setVal(''); this._roomSrcOpen = null; }} />
<span>${this._t('room.src_average')}</span>
</label>
<label class="srcrow">
<input type="radio" name="rsrc-${kind}" .checked=${!!val}
@change=${() => { this._roomSrcOpen = kind; this._roomSrcFilter = ''; this.requestUpdate(); }} />
<span>${this._t('room.src_pick')}</span>
</label>
${val || open
? html`<button class="dropbtn ${open ? 'open' : ''}"
@click=${() => { this._roomSrcOpen = open ? null : kind; this._roomSrcFilter = ''; }}>
${val
? html`<b>${this._roomSrcLabel(val)}</b><span class="ref">${val}</span>`
: html`<span class="muted">${this._t('room.src_ph')}</span>`}
<ha-icon icon=${open ? 'mdi:chevron-up' : 'mdi:chevron-down'}></ha-icon>
</button>
${open
? html`<div class="droppanel">
<input class="namein" type="text" placeholder=${this._t('marker.search_ph')}
.value=${this._roomSrcFilter}
@input=${(e: Event) => { this._roomSrcFilter = (e.target as HTMLInputElement).value; this.requestUpdate(); }} />
<div class="candlist">
${this._roomSrcCandidates().map(
(c) => html`<div class="cand ${c.value === val ? 'sel' : ''}"
@click=${() => { setVal(c.value); this._roomSrcOpen = null; }}>
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
</div>`,
)}
</div>
</div>`
: nothing}`
: nothing}`;
}
private _renderRoomDialog(): TemplateResult { private _renderRoomDialog(): TemplateResult {
const areas = this._freeAreas; const edit = !!this._roomEditId;
// the free-areas list must include the edited room's CURRENT area
const areas = [...this._freeAreas];
if (edit && this._areaSel && !areas.some((a) => a.area_id === this._areaSel)) {
const cur = this.hass.areas[this._areaSel];
if (cur) areas.unshift(cur);
}
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}> return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}> <div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>${this._t('room.new')}</div> <div class="hd"><ha-icon icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'}></ha-icon>
${edit ? this._t('room.settings_title') : this._t('room.new')}</div>
<div class="body"> <div class="body">
<label>${this._t('room.name_label')}</label> <label>${this._t('room.name_label')}</label>
<input class="namein" type="text" placeholder=${this._t('room.name_ph')} <input class="namein" type="text" placeholder=${this._t('room.name_ph')}
@@ -4619,18 +4862,53 @@ class HouseplanCard extends LitElement {
(a) => html`<option value=${a.area_id} ?selected=${a.area_id === this._areaSel}>${a.name}</option>`, (a) => html`<option value=${a.area_id} ?selected=${a.area_id === this._areaSel}>${a.name}</option>`,
)} )}
</select> </select>
<label class="dispsection">${this._t('room.settings_section')}</label>
<label>${this._t('room.fill_label')}</label>
${([['', 'fill.inherit'], ['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']] as const).map(
([v, k]) => html`<label class="srcrow inline">
<input type="radio" name="rfill" .checked=${this._roomFill === v}
@change=${() => { this._roomFill = v as any; this.requestUpdate(); }} />
<span>${this._t(k as any)}</span>
</label>`,
)}
${this._renderRoomSource('temp')}
${this._renderRoomSource('hum')}
<label class="dispsection">${this._t('room.sizes_section')}</label>
<label>${this._t('room.name_scale')}</label>
<div class="colorrow gsrow">
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(this._roomNameScale * 100))}
@input=${(e: Event) => { this._roomNameScale = Number((e.target as HTMLInputElement).value) / 100; this.requestUpdate(); }} />
<span class="opv">${Math.round(this._roomNameScale * 100)}%</span>
</div>
<label>${this._t('room.label_scale')}</label>
<div class="colorrow gsrow">
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(this._roomLabelScale * 100))}
@input=${(e: Event) => { this._roomLabelScale = Number((e.target as HTMLInputElement).value) / 100; this.requestUpdate(); }} />
<span class="opv">${Math.round(this._roomLabelScale * 100)}%</span>
</div>
${this._renderCardPreview(
spaceDisplayOf(this._curSpaceCfg).cardFontScale,
this._roomNameScale,
this._roomLabelScale,
)}
</div> </div>
<div class="row"> <div class="row">
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button> <button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()} ${edit
? html`<button class="btn on" @click=${() => this._saveRoomEdit()} ?disabled=${!this._nameSel.trim()}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>`
: html`<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()}
title=${this._t('title.no_area_room')}> title=${this._t('title.no_area_room')}>
${this._t('btn.no_area')} ${this._t('btn.no_area')}
</button> </button>
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel} <button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel}
title=${!this._areaSel ? this._t('title.choose_area') : ''}> title=${!this._areaSel ? this._t('title.choose_area') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')} <ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button> </button>`}
</div> </div>
</div> </div>
</div>`; </div>`;
+16 -1
View File
@@ -307,5 +307,20 @@
"kiosk.icon_scale": "Device icon size", "kiosk.icon_scale": "Device icon size",
"kiosk.font_scale": "Room card text size", "kiosk.font_scale": "Room card text size",
"editor.kiosk": "Wall device (kiosk) mode", "editor.kiosk": "Wall device (kiosk) mode",
"editor.cycle": "Auto-switch spaces every N seconds (kiosk, 0 = off)" "editor.cycle": "Auto-switch spaces every N seconds (kiosk, 0 = off)",
"room.settings_title": "Room settings",
"room.settings_section": "Room settings (override the space)",
"room.fill_label": "Fill in THIS room",
"fill.inherit": "As the space",
"room.temp_src_label": "Temperature source",
"room.hum_src_label": "Humidity source",
"room.src_average": "Average over the room's sensors (default)",
"room.src_pick": "A specific HA device or entity",
"room.src_ph": "Choose a source…",
"toast.room_updated": "Room updated",
"space.card_font": "Room-card font size (whole space)",
"room.sizes_section": "Font sizes",
"room.name_scale": "Room name size",
"room.label_scale": "Metrics size",
"preview.room_name": "Living room"
} }
+16 -1
View File
@@ -307,5 +307,20 @@
"kiosk.icon_scale": "Размер значков устройств", "kiosk.icon_scale": "Размер значков устройств",
"kiosk.font_scale": "Размер текста карточек комнат", "kiosk.font_scale": "Размер текста карточек комнат",
"editor.kiosk": "Режим настенного устройства (киоск)", "editor.kiosk": "Режим настенного устройства (киоск)",
"editor.cycle": "Автосмена пространств каждые N секунд (киоск, 0 = выкл)" "editor.cycle": "Автосмена пространств каждые N секунд (киоск, 0 = выкл)",
"room.settings_title": "Настройки комнаты",
"room.settings_section": "Настройки комнаты (переопределяют пространство)",
"room.fill_label": "Заливка в ЭТОЙ комнате",
"fill.inherit": "Как у пространства",
"room.temp_src_label": "Источник температуры",
"room.hum_src_label": "Источник влажности",
"room.src_average": "Средняя по датчикам комнаты (по умолчанию)",
"room.src_pick": "Конкретное устройство или сущность HA",
"room.src_ph": "Выберите источник…",
"toast.room_updated": "Комната обновлена",
"space.card_font": "Размер шрифта карточек комнат (всё пространство)",
"room.sizes_section": "Размеры шрифтов",
"room.name_scale": "Размер названия",
"room.label_scale": "Размер подписей",
"preview.room_name": "Гостиная"
} }
+36
View File
@@ -552,6 +552,8 @@ export interface SpaceDisplay {
tempMax: number; // comfort range upper bound, °C tempMax: number; // comfort range upper bound, °C
/** Per-space LQI badges near zigbee devices; null = follow the card option. */ /** Per-space LQI badges near zigbee devices; null = follow the card option. */
showLqi: boolean | null; showLqi: boolean | null;
/** Base font multiplier for room cards (tier 2; rooms multiply on top). */
cardFontScale: number;
/** Room-card metrics under the room name (all default off). */ /** Room-card metrics under the room name (all default off). */
labelTemp: boolean; labelTemp: boolean;
labelHum: boolean; labelHum: boolean;
@@ -577,6 +579,9 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN, tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX, tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null, showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
cardFontScale: typeof s.card_font_scale === 'number' && s.card_font_scale > 0
? Math.min(3, Math.max(0.5, s.card_font_scale))
: 1,
labelTemp: s.label_temp === true, labelTemp: s.label_temp === true,
labelHum: s.label_hum === true, labelHum: s.label_hum === true,
labelLqi: s.label_lqi === true, labelLqi: s.label_lqi === true,
@@ -965,6 +970,37 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
return cutSegments(edges, cuts, eps); return cutSegments(edges, cuts, eps);
} }
// ---------------- room-level settings (tier 3) ----------------
/**
* Effective fill mode of a room: its own override wins, otherwise the space's.
* Four settings tiers (owner's principle, 2026-07-26): global > space > room >
* device; the more specific tier overrides the more general one. A room may
* override even in a glow space ('none' pulls it out of the darkness).
*/
export function roomFillModeOf(
spaceFill: RoomFillMode,
room: { settings?: { fill_mode?: string | null } | null } | null | undefined,
): RoomFillMode {
const o = room?.settings?.fill_mode;
return o === 'none' || o === 'lqi' || o === 'light' || o === 'temp' ? o : spaceFill;
}
// ---------------- marker files ----------------
/**
* Rewrite attached-file urls when a marker's id changes (rebinding): the
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
*/
export function migratePdfUrls<T extends { url: string }>(
pdfs: T[], oldId: string, newId: string,
): T[] {
if (!oldId || !newId || oldId === newId) return pdfs;
const from = '/files/' + oldId + '/';
const to = '/files/' + newId + '/';
return pdfs.map((p) => (p.url.includes(from) ? { ...p, url: p.url.split(from).join(to) } : p));
}
// ---------------- kiosk gestures ---------------- // ---------------- kiosk gestures ----------------
/** /**
+36 -3
View File
@@ -364,7 +364,7 @@ export const cardStyles = css`
pointer-events: none; /* draggable only in plan mode (rule below) */ pointer-events: none; /* draggable only in plan mode (rule below) */
position: absolute; position: absolute;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1)); font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1) * var(--rl-space, 1));
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
white-space: nowrap; white-space: nowrap;
@@ -377,7 +377,21 @@ export const cardStyles = css`
gap: 0.15em; gap: 0.15em;
text-align: center; text-align: center;
} }
.rlname { display: inline-flex; align-items: center; gap: 0.25em; } .rlname {
display: inline-flex;
align-items: center;
gap: 0.25em;
font-size: calc(1em * var(--rl-name, 1));
}
.rlgear {
--mdc-icon-size: 0.9em;
display: inline-flex;
margin-right: 0.2em;
opacity: 0.6;
cursor: pointer;
pointer-events: auto;
}
.rlgear:hover { opacity: 1; }
.rlgo { .rlgo {
--mdc-icon-size: 0.85em; --mdc-icon-size: 0.85em;
display: inline-flex; display: inline-flex;
@@ -392,7 +406,7 @@ export const cardStyles = css`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.55em; gap: 0.55em;
font-size: 0.62em; font-size: calc(0.62em * var(--rl-meta, 1));
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
opacity: 0.9; opacity: 0.9;
@@ -460,6 +474,25 @@ export const cardStyles = css`
.ctrlstate { display: inline-flex; align-items: center; gap: 5px; color: var(--hp-muted); } .ctrlstate { display: inline-flex; align-items: center; gap: 5px; color: var(--hp-muted); }
.ctrlstate.on { color: var(--hp-txt); } .ctrlstate.on { color: var(--hp-txt); }
.ctrlstate ha-icon { --mdc-icon-size: 15px; } .ctrlstate ha-icon { --mdc-icon-size: 15px; }
.cardpreview {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin: 8px 0 2px;
padding: 10px;
border: 1px dashed var(--hp-muted);
border-radius: 8px;
}
.cardpreview .cpname { font-weight: 700; letter-spacing: 0.04em; }
.cardpreview .cpmeta {
display: inline-flex;
align-items: center;
gap: 0.3em;
font-weight: 600;
opacity: 0.85;
}
.cardpreview .cpmeta ha-icon { --mdc-icon-size: 1.05em; }
.iconauto { .iconauto {
display: flex; display: flex;
align-items: center; align-items: center;
+12 -1
View File
@@ -1,8 +1,19 @@
/** Shared types of the House Plan card. */ /** Shared types of the House Plan card. */
export interface RoomCfg { export interface RoomCfg {
/** Rooms this one has an OPEN (virtual) boundary with light flows through. */ /** Rooms this one has an OPEN (virtual) boundary with - light flows through. */
open_to?: string[] | null; open_to?: string[] | null;
/** Room-level settings (tier 3 of 4: global > space > ROOM > device). */
settings?: {
/** Fill override; unset = inherit the space fill mode. */
fill_mode?: 'none' | 'lqi' | 'light' | 'temp' | null;
/** 'device:<id>' or 'entity:<eid>'; unset = average over the room sensors. */
temp_source?: string | null;
hum_source?: string | null;
/** Font multipliers for THIS room's card (0.5-3, unset = 1). */
name_scale?: number | null;
label_scale?: number | null;
} | null;
id?: string; id?: string;
name: string; name: string;
area: string | null; area: string | null;
+23 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } from '../test-build/devices.js'; import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js'; import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */ /** Minimal fake hass around the pieces buildDevices reads. */
@@ -337,3 +337,25 @@ test('icon rules: soundbars vs smart speakers (v1.40.2)', () => {
assert.equal(iconFor('Колонка Алисы', ''), 'mdi:speaker'); assert.equal(iconFor('Колонка Алисы', ''), 'mdi:speaker');
assert.equal(iconFor('Kitchen speaker', ''), 'mdi:speaker'); assert.equal(iconFor('Kitchen speaker', ''), 'mdi:speaker');
}); });
test('sourceValue: explicit entity and device sources (tier 3)', () => {
const hass = {
states: {
'sensor.custom_temp': { state: '23.456' },
'sensor.custom_hum': { state: '47.8' },
'sensor.bad': { state: 'unknown' },
'sensor.dev_t': { state: '21.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
},
entities: {
'sensor.dev_t': { device_id: 'dev1' },
'sensor.custom_temp': {}, 'sensor.custom_hum': {}, 'sensor.bad': {},
},
};
assert.equal(sourceValue(hass, 'entity:sensor.custom_temp', 'temp'), 23.5);
assert.equal(sourceValue(hass, 'entity:sensor.custom_hum', 'hum'), 48);
assert.equal(sourceValue(hass, 'entity:sensor.bad', 'temp'), null);
assert.equal(sourceValue(hass, 'device:dev1', 'temp'), 21.5);
assert.equal(sourceValue(hass, 'device:nope', 'temp'), null);
assert.equal(sourceValue(hass, '', 'temp'), null);
assert.equal(sourceValue(hass, 'garbage', 'temp'), null);
});
+22
View File
@@ -10,6 +10,8 @@ import {
outlineWithout, outlineWithout,
alignGuides, segmentAngle, is45, alignGuides, segmentAngle, is45,
swipeTarget, clampScale, swipeTarget, clampScale,
migratePdfUrls,
roomFillModeOf,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
@@ -787,3 +789,23 @@ test('clampScale', () => {
assert.equal(clampScale('x'), 1); assert.equal(clampScale('x'), 1);
assert.equal(clampScale(undefined, 1.5), 1.5); assert.equal(clampScale(undefined, 1.5), 1.5);
}); });
test('migratePdfUrls: rebinding rewrites file urls', () => {
const pdfs = [
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
{ name: 'b.pdf', url: '/houseplan_files/files/other/b.pdf?v=2' },
];
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99');
assert.equal(out[0].url, '/houseplan_files/files/dev99/a.pdf?v=1');
assert.equal(out[1].url, pdfs[1].url); // чужие пути не трогаем
// без смены id — как есть
assert.equal(migratePdfUrls(pdfs, 'x', 'x'), pdfs);
});
test('roomFillModeOf: tier-3 override beats the space, junk inherits', () => {
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'light' } }), 'light');
assert.equal(roomFillModeOf('glow', { settings: { fill_mode: 'none' } }), 'none'); // выход из тьмы
assert.equal(roomFillModeOf('temp', {}), 'temp');
assert.equal(roomFillModeOf('temp', null), 'temp');
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'glow' } }), 'temp'); // glow нельзя выбрать per-room
});