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:
Matysh
2026-07-27 09:58:21 +03:00
parent 88dc0d1de7
commit 19e19b5c5f
21 changed files with 721 additions and 146 deletions
+230 -27
View File
@@ -14,7 +14,7 @@ import {
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls,
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,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
@@ -22,7 +22,7 @@ import {
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} 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 {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -157,6 +157,12 @@ class HouseplanCard extends LitElement {
private _areaSel = '';
private _nameSel = '';
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)
private _zoom = 1;
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 },
_nameSel: { 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 },
_infoCard: { state: true },
_rulesDialog: { state: true },
@@ -482,6 +494,7 @@ class HouseplanCard extends LitElement {
name: r.name,
area: r.area ?? null,
open_to: r.open_to || undefined,
settings: r.settings || undefined,
x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined,
@@ -1464,6 +1477,7 @@ class HouseplanCard extends LitElement {
this._cursorPt = null;
this._nameSel = '';
this._areaSel = '';
this._resetRoomDialogFields();
this._roomDialog = true;
return;
}
@@ -2065,6 +2079,7 @@ class HouseplanCard extends LitElement {
this._showToast(this._t('toast.split_bad_cut'));
return;
}
this._resetRoomDialogFields();
// the bigger part stays the room it was — name, area and devices go with it
const [p1, p2] = parts;
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'),
area: this._areaSel || null,
poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]),
...(this._roomSettingsFromDialog() ? { settings: this._roomSettingsFromDialog() } : {}),
});
this._saveConfig();
this._path = [];
@@ -2199,6 +2215,12 @@ class HouseplanCard extends LitElement {
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
private _roomDialogCancel(): void {
this._roomDialog = false;
if (this._roomEditId) {
this._roomEditId = null;
this._nameSel = '';
this._areaSel = '';
return;
}
if (this._pendingSplit) {
// nothing was applied yet — drop the cut entirely, the room stays whole
this._pendingSplit = null;
@@ -3309,20 +3331,25 @@ class HouseplanCard extends LitElement {
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
cls += ' picked';
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';
const st: string[] = [];
// 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}`);
const fillC = disp.fill === 'glow'
// glow: uniform darkness over EVERY room (area or not, lit or not)
const fillC = effFill === 'glow'
// glow: uniform darkness (a room override may opt OUT of it)
? 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
? roomFillStyle(
disp.fill,
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null,
effFill,
effFill === 'lqi' ? this._roomLqi(r.area) : null,
effFill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
null,
disp.tempMin,
disp.tempMax,
this._fillColors,
@@ -3337,7 +3364,7 @@ class HouseplanCard extends LitElement {
const tip = (e: MouseEvent) =>
this._showTip(e, r.name, this._t('tip.room'),
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 c = this._roomCenter(r);
// open boundaries: this room's solid stroke must not run beneath
@@ -3514,6 +3541,107 @@ class HouseplanCard extends LitElement {
</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. */
private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
const saved = this._layout['rl_' + (r.id || '')];
@@ -3623,20 +3751,20 @@ class HouseplanCard extends LitElement {
const k = this._labelScale(r);
// optional metrics row (needs an HA area; sub-area rooms show the name only)
const rows: TemplateResult[] = [];
if (r.area && !this._markup) {
if ((r.area || r.settings?.temp_source || r.settings?.hum_source) && !this._markup) {
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 (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 (disp.labelLqi) {
if (disp.labelLqi && 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 (disp.labelLight) {
if (disp.labelLight && r.area) {
const ls = areaLightStats(this.hass, this._devices, r.area);
if (ls) {
const txt = ls.on === 0
@@ -3654,7 +3782,12 @@ class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => 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"
title=${this._t('room.open_area')}
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
@@ -4606,11 +4739,65 @@ class HouseplanCard extends LitElement {
</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 {
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()}>
<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">
<label>${this._t('room.name_label')}</label>
<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>`,
)}
</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 class="row">
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
<span class="spacer"></span>
<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()}
title=${this._t('title.no_area_room')}>
${this._t('btn.no_area')}
</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>
${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')}>
${this._t('btn.no_area')}
</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>`;