mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
feat v1.42.0: room settings — the third settings tier
- four-tier principle fixed in ARCHITECTURE: global > space > room >
device, specific overrides general, unset inherits
- room.settings { fill_mode, temp_source, hum_source } with backend
schema; pure roomFillModeOf + sourceValue (+2 test suites, 114)
- gear on room cards in the Plan editor opens Room settings: rename,
re-area (current area included in the list), fill override (may opt
out of glow darkness), explicit temp/hum source with a searchable
device+entity dropdown; the same section in the creation dialog
- source feeds the room card, tooltip and temp fill; works for rooms
without an HA area (user-feedback case #1)
- smoke_room_settings.mjs (10 checks); TESTING/CHANGELOG/ARCHITECTURE
same-commit
This commit is contained in:
@@ -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.2"
|
VERSION = "1.42.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"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",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.41.2"
|
"version": "1.42.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ 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),
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
|||||||
@@ -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();
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+85
-33
File diff suppressed because one or more lines are too long
@@ -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).
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## v1.41.2 — 2026-07-26
|
||||||
- Fixed attached PDF manuals breaking after a marker is rebound to another
|
- 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
|
device: the id changes, but the uploaded files stayed in the OLD id's
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ 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
|
||||||
|
- [ ] 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
|
- [ ] 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
|
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]
|
and the links keep opening; the old folder disappears (no orphans) [auto]
|
||||||
|
|||||||
Generated
+12
-12
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.41.2",
|
"version": "1.42.0",
|
||||||
"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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+230
-27
@@ -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, migratePdfUrls,
|
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.2';
|
const CARD_VERSION = '1.42.0';
|
||||||
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,12 @@ 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 = '';
|
||||||
// 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)
|
||||||
@@ -289,6 +295,12 @@ 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 },
|
||||||
_spaceDialog: { state: true },
|
_spaceDialog: { state: true },
|
||||||
_infoCard: { state: true },
|
_infoCard: { state: true },
|
||||||
_rulesDialog: { state: true },
|
_rulesDialog: { state: true },
|
||||||
@@ -482,6 +494,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,
|
||||||
@@ -1464,6 +1477,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 +2079,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 +2163,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 +2215,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;
|
||||||
@@ -3309,20 +3331,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,
|
||||||
@@ -3337,7 +3364,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
|
||||||
@@ -3514,6 +3541,107 @@ 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 = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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._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;
|
||||||
|
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 || '')];
|
||||||
@@ -3623,20 +3751,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
|
||||||
@@ -3654,7 +3782,12 @@ class HouseplanCard extends LitElement {
|
|||||||
@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); }}
|
||||||
@@ -4606,11 +4739,65 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>`;
|
</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')}
|
||||||
@@ -4629,18 +4816,34 @@ 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')}
|
||||||
</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
|
||||||
title=${this._t('title.no_area_room')}>
|
? html`<button class="btn on" @click=${() => this._saveRoomEdit()} ?disabled=${!this._nameSel.trim()}>
|
||||||
${this._t('btn.no_area')}
|
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||||
</button>
|
</button>`
|
||||||
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel}
|
: html`<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()}
|
||||||
title=${!this._areaSel ? this._t('title.choose_area') : ''}>
|
title=${this._t('title.no_area_room')}>
|
||||||
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
${this._t('btn.no_area')}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel}
|
||||||
|
title=${!this._areaSel ? this._t('title.choose_area') : ''}>
|
||||||
|
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||||
|
</button>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
+11
-1
@@ -307,5 +307,15 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -307,5 +307,15 @@
|
|||||||
"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": "Комната обновлена"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -965,6 +965,22 @@ 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 ----------------
|
// ---------------- marker files ----------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -378,6 +378,15 @@ export const cardStyles = css`
|
|||||||
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; }
|
||||||
|
.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;
|
||||||
|
|||||||
+9
-1
@@ -1,8 +1,16 @@
|
|||||||
/** 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;
|
||||||
|
} | null;
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
area: string | null;
|
area: string | null;
|
||||||
|
|||||||
+23
-1
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } 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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
alignGuides, segmentAngle, is45,
|
alignGuides, segmentAngle, is45,
|
||||||
swipeTarget, clampScale,
|
swipeTarget, clampScale,
|
||||||
migratePdfUrls,
|
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';
|
||||||
@@ -800,3 +801,11 @@ test('migratePdfUrls: rebinding rewrites file urls', () => {
|
|||||||
// без смены id — как есть
|
// без смены id — как есть
|
||||||
assert.equal(migratePdfUrls(pdfs, 'x', 'x'), pdfs);
|
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
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user