feat v1.28.0: sub-area rooms — manual device placement without an HA area (issue #3)

- area-less rooms appear in the marker room list ('no area, manual'); markers
  store room_id and land at the room centre; dialog reopen restores the choice
- pure parseRoomRef (space#area / space#@roomId) + unit tests; backend schema
- ROADMAP phase 11 closed; TESTING.md row; smoke_subarea.mjs
This commit is contained in:
Matysh
2026-07-22 10:25:20 +03:00
parent a9346c9c14
commit 9fd36bd710
17 changed files with 134 additions and 33 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.27.0" VERSION = "1.28.0"
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.27.0" "version": "1.28.0"
} }
@@ -140,6 +140,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("link"): vol.Any(str, None), vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None), vol.Optional("description"): vol.Any(str, None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None), vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("room_id"): vol.Any(str, None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None), vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
vol.Optional("ripple_color"): vol.Any(str, None), vol.Optional("ripple_color"): vol.Any(str, None),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None), vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
+35
View File
@@ -0,0 +1,35 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
// комната без зоны на f1
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
...s, rooms: [...s.rooms, { id: 'rc', name: 'Cupboard', area: null, poly: [[0.05,0.62],[0.2,0.62],[0.2,0.9],[0.05,0.9]] }],
})) };
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
// список комнат в диалоге содержит подзону
c._setMode('devices');
c._openMarkerDialog(); await c.updateComplete;
const rooms = c._allRoomsFlat();
out.subareaListed = rooms.some((r) => r.value === 'f1#@rc');
// создать виртуальный маркер в подзону
c._markerDialog = { ...c._markerDialog, name: 'Vacuum dock', binding: 'virtual', room: 'f1#@rc' };
await c._saveMarker(); await c.updateComplete;
const m = c._serverCfg.markers.find((x) => x.name === 'Vacuum dock');
out.markerRoomId = m?.room_id;
out.markerAreaNull = m ? m.area === null : null;
// позиция — в центре подзоны (норм. центр rc: x=0.125, y=0.76)
const pos = c._layout[m.id];
out.posInRoom = pos && Math.abs(pos.x - 0.125) < 0.03 && Math.abs(pos.y - 0.76) < 0.03;
// девайс отрисован
const dev = c._devices.find((d) => d.id === m.id);
out.deviceBuilt = !!dev && dev.space === 'f1';
// reopen диалога восстанавливает выбор
c._openMarkerDialog(dev);
out.reopenRoom = c._markerDialog?.room;
c._markerDialog = null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## v1.28.0 — 2026-07-21 (sub-area rooms: manual placement without an HA area; issue #3)
- **Devices can now be placed into rooms that have no Home Assistant area.**
The marker dialog's room list includes area-less rooms (marked "no area,
manual"); pick one and the device/virtual marker lands at its centre. The
marker stores `room_id`, so the choice survives edits and re-opens. The
laundry-cupboard case from issue #3: a decorative room inside a larger area
can now hold its own door sensor and light.
- Room reference parsing extracted as pure `parseRoomRef` (`space#area` /
`space#@roomId`), unit-tested; backend schema for `room_id`.
## v1.27.0 — 2026-07-21 (RGB light colors + alarm pulse; issue #3) ## v1.27.0 — 2026-07-21 (RGB light colors + alarm pulse; issue #3)
- **RGB lights show their actual color**: an "on" light with a color tints its - **RGB lights show their actual color**: an "on" light with a color tints its
bulb icon, glow and — unless a custom ripple color is set — its presence ripple. bulb icon, glow and — unless a custom ripple color is set — its presence ripple.
+5 -5
View File
@@ -74,12 +74,12 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
- [ ] GitHub: issue templates, discussions on, CONTRIBUTING.md (build/test in 5 minutes). - [ ] GitHub: issue templates, discussions on, CONTRIBUTING.md (build/test in 5 minutes).
- [ ] Semantic versioning discipline; keep release notes user-facing (they show in HACS). - [ ] Semantic versioning discipline; keep release notes user-facing (they show in HACS).
## Phase 11 — UX modes redesign (approved, see docs/UX-MODES.md) ## Phase 11 — UX modes redesign ✅ DONE (v1.25.0v1.28.0, see docs/UX-MODES.md)
Three-tab interaction model (View / Plan / Devices): view mode is display-only — Shipped: the three-tab interaction model (View / Plan / Devices, v1.25.0),
no dragging or editing; all editing splits between a Plan tab and a Devices tab. state-reflecting icons + value display (v1.26.0), RGB light colors + alarm pulse
Deprecates "drag anywhere" and view-mode opening drag. Follow-ups from issue #3: (v1.27.0), sub-area rooms (v1.28.0). Remaining backlog from issue #3: music
state-reflecting icons, value display, RGB light colors, alarm visuals, sub-area rooms. notes for players, directional TV ripples (not planned).
## Explicit non-goals (for now) ## Explicit non-goals (for now)
+3
View File
@@ -139,6 +139,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Open via info card → Edit; all fields persist (name, icon, model, link, description) - [ ] Open via info card → Edit; all fields persist (name, icon, model, link, description)
- [ ] Rebind to another device/entity/helper: search filters; already-placed candidates excluded; old position cleaned up [auto backend] - [ ] Rebind to another device/entity/helper: search filters; already-placed candidates excluded; old position cleaned up [auto backend]
- [ ] Virtual device: requires name; room required; renders dashed - [ ] Virtual device: requires name; room required; renders dashed
- [ ] Sub-area rooms (v1.28.0): a room WITHOUT an HA area appears in the marker
room list ("no area, manual"); a device placed there lands at its centre,
the marker stores room_id, reopening the dialog restores the choice [auto]
- [ ] Room override moves the icon to the room center - [ ] Room override moves the icon to the room center
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies - [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend] - [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.27.0", "version": "1.28.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",
+28 -13
View File
@@ -17,7 +17,7 @@ import {
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
snapToWall, openingAmount, snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, stateIcon, lightColorOf, isAlarmState, parseRoomRef,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
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,
@@ -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.27.0'; const CARD_VERSION = '1.28.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';
@@ -1575,7 +1575,9 @@ class HouseplanCard extends LitElement {
link: d.link || '', link: d.link || '',
description: d.description || '', description: d.description || '',
pdfs: [...(d.pdfs || [])], pdfs: [...(d.pdfs || [])],
room: d.space && d.area ? d.space + '#' + d.area : '', room: d.marker?.room_id
? d.space + '#@' + d.marker.room_id
: d.space && d.area ? d.space + '#' + d.area : '',
busy: false, busy: false,
}; };
} else { } else {
@@ -1661,8 +1663,15 @@ class HouseplanCard extends LitElement {
const res: { value: string; label: string }[] = []; const res: { value: string; label: string }[] = [];
for (const sp of this._serverCfg?.spaces || []) { for (const sp of this._serverCfg?.spaces || []) {
for (const r of sp.rooms || []) { for (const r of sp.rooms || []) {
if (!r.area) continue; if (r.area) {
res.push({ value: sp.id + '#' + r.area, label: (sp.title || sp.id) + ' · ' + r.name }); res.push({ value: sp.id + '#' + r.area, label: (sp.title || sp.id) + ' · ' + r.name });
} else if (r.id) {
// sub-area room (no HA area): manual placement by room id — issue #3
res.push({
value: sp.id + '#@' + r.id,
label: (sp.title || sp.id) + ' · ' + r.name + ' · ' + this._t('marker.subarea'),
});
}
} }
} }
return res; return res;
@@ -1751,9 +1760,10 @@ class HouseplanCard extends LitElement {
// determine the marker id // determine the marker id
let id: string; let id: string;
// a manually chosen room overrides the space/area for any icon // a manually chosen room overrides the space/area for any icon
const [roomSp, roomAr] = dlg.room ? dlg.room.split('#') : ['', '']; const roomRef = parseRoomRef(dlg.room);
let space: string | null = roomSp || null; let space: string | null = roomRef?.space || null;
let area: string | null = roomAr || null; let area: string | null = roomRef?.area || null;
const roomId: string | null = roomRef?.roomId || null;
if (dlg.binding === 'virtual' && !space) space = this._space; if (dlg.binding === 'virtual' && !space) space = this._space;
id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36)); id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
const oldId = dlg.devId; const oldId = dlg.devId;
@@ -1777,10 +1787,13 @@ class HouseplanCard extends LitElement {
if (dlg.binding === 'virtual' || dlg.room) { if (dlg.binding === 'virtual' || dlg.room) {
marker.space = space; marker.space = space;
marker.area = area; marker.area = area;
marker.room_id = roomId;
} }
// the room changed → move the icon to its center // the room changed → move the icon to its center
const prevDev = oldId ? this._devices.find((x) => x.id === oldId) : null; const prevDev = oldId ? this._devices.find((x) => x.id === oldId) : null;
const roomChanged = !!dlg.room && prevDev != null && (prevDev.space !== space || prevDev.area !== area); const prevRoomId = prevDev?.marker?.room_id ?? null;
const roomChanged = !!dlg.room && prevDev != null
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
// 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);
@@ -1792,10 +1805,12 @@ class HouseplanCard extends LitElement {
const spm = this._spaceModel(space || undefined); const spm = this._spaceModel(space || undefined);
let cx = spm.vb[0] + spm.vb[2] / 2; let cx = spm.vb[0] + spm.vb[2] / 2;
let cy = spm.vb[1] + spm.vb[3] / 2; let cy = spm.vb[1] + spm.vb[3] / 2;
if (area) { const room = roomId
const room = spm.rooms.find((r) => r.area === area); ? spm.rooms.find((r) => r.id === roomId)
if (room) [cx, cy] = this._roomCenter(room); : area
} ? spm.rooms.find((r) => r.area === area)
: undefined;
if (room) [cx, cy] = this._roomCenter(room);
newPos = this._normPos(space || this._space, cx, cy); newPos = this._normPos(space || this._space, cx, cy);
this._layout = { ...this._layout, [id]: newPos }; this._layout = { ...this._layout, [id]: newPos };
} }
+2 -1
View File
@@ -243,5 +243,6 @@
"mode.view": "View", "mode.view": "View",
"mode.plan": "Plan", "mode.plan": "Plan",
"mode.devices": "Devices", "mode.devices": "Devices",
"display.value": "Value instead of an icon" "display.value": "Value instead of an icon",
"marker.subarea": "no area, manual"
} }
+2 -1
View File
@@ -243,5 +243,6 @@
"mode.view": "Просмотр", "mode.view": "Просмотр",
"mode.plan": "План", "mode.plan": "План",
"mode.devices": "Устройства", "mode.devices": "Устройства",
"display.value": "Значение вместо иконки" "display.value": "Значение вместо иконки",
"marker.subarea": "без зоны, вручную"
} }
+24
View File
@@ -708,3 +708,27 @@ export function isAlarmState(
if (domain === 'siren') return true; if (domain === 'siren') return true;
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass); return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
} }
// ---------------- room references ----------------
/**
* Parse a marker's room reference. Two shapes:
* - `space#area` a room bound to an HA area (historical form)
* - `space#@roomId` a room WITHOUT an area (sub-area rooms, issue #3):
* devices are placed into it manually, by room id.
*/
export function parseRoomRef(
v: string | null | undefined,
): { space: string; area: string | null; roomId: string | null } | null {
if (!v) return null;
const i = v.indexOf('#');
if (i <= 0) return null;
const space = v.slice(0, i);
const rest = v.slice(i + 1);
if (!rest) return null;
if (rest.startsWith('@')) {
const roomId = rest.slice(1);
return roomId ? { space, area: null, roomId } : null;
}
return { space, area: rest, roomId: null };
}
+1
View File
@@ -38,6 +38,7 @@ export interface Marker {
description?: string | null; description?: string | null;
pdfs?: PdfRef[]; pdfs?: PdfRef[];
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle' tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
ripple_color?: string | null; ripple_color?: string | null;
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3) ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
+11 -1
View File
@@ -4,7 +4,7 @@ import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi, lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor, fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
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, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
import { import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses, iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -513,3 +513,13 @@ test('isAlarmState: leak/smoke/gas/siren fire; doors and outages do not', () =>
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'off')); assert.ok(!isAlarmState('binary_sensor', 'smoke', 'off'));
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'unavailable')); assert.ok(!isAlarmState('binary_sensor', 'smoke', 'unavailable'));
}); });
test('parseRoomRef: area rooms, sub-area rooms by id, malformed refs', () => {
assert.deepEqual(parseRoomRef('f1#kitchen'), { space: 'f1', area: 'kitchen', roomId: null });
assert.deepEqual(parseRoomRef('f1#@r7'), { space: 'f1', area: null, roomId: 'r7' });
assert.equal(parseRoomRef(''), null);
assert.equal(parseRoomRef('f1#'), null);
assert.equal(parseRoomRef('f1#@'), null);
assert.equal(parseRoomRef('#kitchen'), null);
assert.equal(parseRoomRef(null), null);
});