feat v1.23.0: doors & windows with live open/lock state

New markup tool 'Opening': click a wall — the opening snaps onto the nearest DERIVED
room wall (v1.19.0 model: openings keep absolute coords, so room edits never break
them) and takes its angle. Dialog: type, length in real cm (door 90 / window 120 —
per-space scale makes it honest), contact sensor (invertible), lock (doors).
Rendering after easy-floorplan (MIT): hinged leaf + swing arc drawing on via
stroke-dashoffset; window = two casements. No sensor → static plan defaults (door
open, window closed); unavailable freezes the default (pure openingAmount, tested).
Lock: padlock badge (green locked / orange unlocked / grey unknown); the lock is
never toggled from the plan — clicking shows an info card with both states.
snapToWall + openingAmount in logic.ts; space.openings validated server-side.
+2 tests (76). Verified live on the dacha (real TTLock + TRV window sensor).
This commit is contained in:
Matysh
2026-07-17 07:21:20 +03:00
parent 7c9731655e
commit ff7c1b883c
16 changed files with 1063 additions and 197 deletions
+330 -3
View File
@@ -15,12 +15,14 @@ import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
spaceDisplayOf, roomFillColor, 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 } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
} from './types';
import './editor';
@@ -28,14 +30,14 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.22.0';
const CARD_VERSION = '1.23.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';
const NORM_W = 1000; // width of the render space for normalized configs
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
type MarkupTool = 'draw' | 'merge' | 'split' | 'delroom';
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'delroom';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
@@ -82,7 +84,19 @@ class HouseplanCard extends LitElement {
private _tool: MarkupTool = 'draw';
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _cursorPt: number[] | null = null;
private _mergeSel: string | null = null; // first room picked for a merge
private _mergeSel: string | null = null;
private _openingDialog: {
id?: string; // editing an existing opening
type: 'door' | 'window';
lengthCm: number;
contact: string;
lock: string;
invert: boolean;
flipH: boolean;
flipV: boolean;
x: number; y: number; angle: number; // render units (from the wall snap)
} | null = null;
private _openingInfo: OpeningCfg | null = null; // first room picked for a merge
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
private _splitSel: { roomId: string; a: number[] | null } | null = null; // room being cut + first wall point
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
@@ -182,6 +196,8 @@ class HouseplanCard extends LitElement {
_path: { state: true },
_cursorPt: { state: true },
_mergeSel: { state: true },
_openingDialog: { state: true },
_openingInfo: { state: true },
_mergeDialog: { state: true },
_splitSel: { state: true },
_areaSel: { state: true },
@@ -1070,6 +1086,10 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
return;
}
if (this._tool === 'opening') {
this._openingClick(raw);
return;
}
if (this._tool === 'merge') {
this._mergeClick(raw);
return;
@@ -1114,6 +1134,113 @@ class HouseplanCard extends LitElement {
this._path = [...this._path, pt];
}
/** Openings of the current space in render units. */
private get _openingsR(): (OpeningCfg & { rx: number; ry: number; rlen: number })[] {
const sp = this._curSpaceCfg;
const H = this._spaceH;
return (sp?.openings || []).map((o: OpeningCfg) => ({
...o, rx: o.x * NORM_W, ry: o.y * H, rlen: o.length * NORM_W,
}));
}
/** cm → render units via the space scale (cm per grid cell). */
private _cmToUnits(cm: number): number {
return (cm / this._cellCm) * this._gridPitch;
}
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
private _openingClick(raw: number[]): void {
const eps = this._gridPitch * 1.5;
const hit = this._openingsR.find(
(o) => Math.hypot(raw[0] - o.rx, raw[1] - o.ry) <= Math.max(o.rlen / 2, eps),
);
if (hit) {
this._openingDialog = {
id: hit.id,
type: hit.type,
lengthCm: Math.round((hit.rlen / this._gridPitch) * this._cellCm),
contact: hit.contact || '',
lock: hit.lock || '',
invert: !!hit.invert,
flipH: !!hit.flip_h,
flipV: !!hit.flip_v,
x: hit.rx, y: hit.ry, angle: hit.angle,
};
return;
}
const snap = snapToWall(raw, this._spaceModel().rooms, eps);
if (!snap) {
this._showToast(this._t('toast.opening_no_wall'));
return;
}
this._openingDialog = {
type: 'door', lengthCm: 90, contact: '', lock: '',
invert: false, flipH: false, flipV: false,
x: snap.x, y: snap.y, angle: snap.angle,
};
}
private _saveOpening(): void {
const d = this._openingDialog;
const sp = this._curSpaceCfg;
if (!d || !sp) return;
const H = this._spaceH;
const o: OpeningCfg = {
id: d.id || 'o' + Date.now().toString(36),
type: d.type,
x: d.x / NORM_W,
y: d.y / H,
angle: d.angle,
length: this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
contact: d.contact || null,
lock: d.type === 'door' ? d.lock || null : null,
invert: d.invert || undefined,
flip_h: d.flipH || undefined,
flip_v: d.flipV || undefined,
};
sp.openings = sp.openings || [];
const i = sp.openings.findIndex((x: OpeningCfg) => x.id === o.id);
if (i >= 0) sp.openings[i] = o;
else sp.openings.push(o);
this._saveConfig();
this._openingDialog = null;
this.requestUpdate();
}
private _deleteOpening(): void {
const d = this._openingDialog;
const sp = this._curSpaceCfg;
if (!d?.id || !sp?.openings) return;
sp.openings = sp.openings.filter((x: OpeningCfg) => x.id !== d.id);
this._saveConfig();
this._openingDialog = null;
this.requestUpdate();
}
/** Contact-sensor candidates: door/window-like classes first, then the rest. */
private _contactCandidates(): { value: string; label: string }[] {
const out: [string, string, number][] = [];
for (const eid of Object.keys(this.hass.states)) {
const dom = eid.split('.')[0];
if (dom !== 'binary_sensor' && dom !== 'cover') continue;
const st = this.hass.states[eid];
const dc = st?.attributes?.device_class || '';
const doorish = ['door', 'window', 'opening', 'garage_door', 'garage'].includes(dc);
if (dom === 'cover' && !doorish) continue;
out.push([eid, st?.attributes?.friendly_name || eid, doorish ? 0 : 1]);
}
return out
.sort((a, b) => a[2] - b[2] || a[1].localeCompare(b[1]))
.map(([value, label]) => ({ value, label }));
}
private _lockCandidates(): { value: string; label: string }[] {
return Object.keys(this.hass.states)
.filter((eid) => eid.startsWith('lock.'))
.map((eid) => ({ value: eid, label: this.hass.states[eid]?.attributes?.friendly_name || eid }))
.sort((a, b) => a.label.localeCompare(b.label));
}
/** Merge: first click picks a room, second picks the room to merge it with. */
private _mergeClick(raw: number[]): void {
const rooms = this._spaceModel().rooms;
@@ -2156,9 +2283,11 @@ class HouseplanCard extends LitElement {
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
})}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)}
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
${devs.map((d) => this._renderDevice(d, view))}
${this._renderOpeningLocks(view)}
${disp.showNames && !this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
: nothing}
@@ -2174,6 +2303,8 @@ class HouseplanCard extends LitElement {
${this._roomDialog ? this._renderRoomDialog() : nothing}
${this._mergeDialog ? this._renderMergeDialog() : nothing}
${this._openingDialog ? this._renderOpeningDialog() : nothing}
${this._openingInfo ? this._renderOpeningInfoCard() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}
@@ -2331,6 +2462,197 @@ class HouseplanCard extends LitElement {
return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1];
}
/** Live state of an opening's contact, 0..1 drawn amount. */
private _openingAmt(o: OpeningCfg): number {
const st = o.contact ? this.hass.states[o.contact]?.state : null;
return openingAmount(o.type, st, !!o.invert);
}
/**
* Doors and windows, drawn in plan (SVG) coordinates so they scale and pan with
* the plan. Symbol geometry after easy-floorplan (MIT): jambs, a leaf that swings
* around its hinge, and a quarter-circle arc that "draws on" via stroke-dashoffset.
*/
private _renderOpenings(disp: SpaceDisplay): TemplateResult {
const items = this._openingsR;
if (!items.length) return svg``;
const base = disp.color;
return svg`${items.map((o) => {
const half = o.rlen / 2;
const amt = this._openingAmt(o);
const active = amt > 0 && !!o.contact;
const tone = active ? 'var(--hp-open)' : base;
const jamb = 8;
const sx = o.flip_h ? -1 : 1;
const sy = o.flip_v ? -1 : 1;
let body;
if (o.type === 'window') {
// two casement leaves hinged at the jambs, meeting in the middle
const arcLen = (Math.PI / 2) * half;
body = svg`
<path class="op-arc" d="M 0 0 A ${half} ${half} 0 0 0 ${-half} ${-half}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<path class="op-arc" d="M 0 0 A ${half} ${half} 0 0 1 ${half} ${-half}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${-90 * amt}deg)">
<rect x="0" y="-1.5" width="${half}" height="3" fill="${tone}"></rect>
</g>
</g>
<g transform="translate(${half} 0)">
<g class="op-leaf" style="transform:rotate(${90 * amt}deg)">
<rect x="${-half}" y="-1.5" width="${half}" height="3" fill="${tone}"></rect>
</g>
</g>`;
} else {
// door leaf hinged at the left jamb, swinging up; arc from tip to tip
const L = o.rlen;
const arcLen = (Math.PI / 2) * L;
body = svg`
<path class="op-arc" d="M ${half} 0 A ${L} ${L} 0 0 0 ${-half} ${-L}" fill="none"
stroke="${tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amt)}"></path>
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${-90 * amt}deg)">
<rect x="0" y="-1.75" width="${L}" height="3.5" fill="${tone}"></rect>
</g>
</g>`;
}
return svg`<g transform="translate(${o.rx} ${o.ry}) rotate(${o.angle})">
<g transform="scale(${sx} ${sy})">
<line x1="${-half}" y1="${-jamb / 2}" x2="${-half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
<line x1="${half}" y1="${-jamb / 2}" x2="${half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
${body}
</g>
<rect class="op-hit" x="${-half - 6}" y="${-o.rlen - 8}" width="${o.rlen + 12}" height="${o.rlen * 2 + 16}"
@click=${(e: MouseEvent) => { e.stopPropagation(); if (!this._markup) this._openingInfo = o; }}></rect>
</g>`;
})}`;
}
/** Padlock badges for doors with a lock entity (HTML, so ha-icon just works). */
private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult {
const items = this._openingsR.filter((o) => o.type === 'door' && o.lock);
if (!items.length) return html``;
return html`${items.map((o) => {
const st = this.hass.states[o.lock!]?.state;
const locked = st === 'locked';
const known = locked || ['unlocked', 'open', 'opening', 'unlocking', 'locking'].includes(String(st));
// perpendicular offset from the opening center, away from the swing side
const rad = ((o.angle + 90) * Math.PI) / 180;
const off = 16 * (o.flip_v ? -1 : 1);
const px = o.rx + Math.cos(rad) * off;
const py = o.ry + Math.sin(rad) * off;
const left = ((px - view.x) / view.w) * 100;
const top = ((py - view.y) / view.h) * 100;
return html`<div class="oplock ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => { e.stopPropagation(); if (!this._markup) this._openingInfo = o; }}>
<ha-icon icon="${locked ? 'mdi:lock' : known ? 'mdi:lock-open-variant' : 'mdi:lock-question'}"></ha-icon>
</div>`;
})}`;
}
private _renderOpeningInfoCard(): TemplateResult {
const o = this._openingInfo!;
const cSt = o.contact ? this.hass.states[o.contact]?.state : null;
const amt = this._openingAmt(o);
const lSt = o.lock ? this.hass.states[o.lock]?.state : null;
const row = (icon: string, label: string, value: string, cls = '') =>
html`<div class="oprow ${cls}"><ha-icon icon=${icon}></ha-icon><span>${label}</span><b>${value}</b></div>`;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._openingInfo = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon=${o.type === 'door' ? 'mdi:door' : 'mdi:window-closed-variant'}></ha-icon>
${this._t(o.type === 'door' ? 'opening.door' : 'opening.window')}</div>
<div class="body">
${o.contact
? row(amt > 0 ? 'mdi:door-open' : 'mdi:door-closed',
this._t('opening.contact_label'),
cSt === 'unavailable' || cSt == null
? this._t('opening.state_unknown')
: this._t(amt > 0 ? 'opening.open' : 'opening.closed'),
amt > 0 ? 'warn' : 'ok')
: nothing}
${o.lock
? row(lSt === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant',
this._t('opening.lock_label'),
lSt === 'locked' ? this._t('opening.locked')
: ['unlocked', 'open'].includes(String(lSt)) ? this._t('opening.unlocked')
: this._t('opening.state_unknown'),
lSt === 'locked' ? 'ok' : 'warn')
: nothing}
${!o.contact && !o.lock ? html`<p class="muted">${this._t('opening.no_entities')}</p>` : nothing}
</div>
<div class="row">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._openingInfo = null)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
}
private _renderOpeningDialog(): TemplateResult {
const d = this._openingDialog!;
const opt = (list: { value: string; label: string }[], cur: string, set: (v: string) => void) =>
html`<select class="areasel" @change=${(e: Event) => set((e.target as HTMLSelectElement).value)}>
<option value="" ?selected=${!cur}>${this._t('opening.none')}</option>
${list.map((c) => html`<option value=${c.value} ?selected=${c.value === cur}>${c.label}</option>`)}
</select>`;
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:door"></ha-icon>
${d.id ? this._t('opening.edit') : this._t('opening.new')}</div>
<div class="body">
<label>${this._t('opening.type_label')}</label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'door'}
@change=${() => (this._openingDialog = { ...d, type: 'door', lengthCm: d.id ? d.lengthCm : 90 })} />
<span>${this._t('opening.door')}</span></label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'window'}
@change=${() => (this._openingDialog = { ...d, type: 'window', lengthCm: d.id ? d.lengthCm : 120 })} />
<span>${this._t('opening.window')}</span></label>
<label>${this._t('opening.length_label')}</label>
<input class="namein tempin" type="number" min="20" max="600" step="5" .value=${String(d.lengthCm)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) this._openingDialog = { ...d, lengthCm: n };
}} />
<label>${this._t('opening.contact_label')}</label>
${opt(this._contactCandidates(), d.contact, (v) => (this._openingDialog = { ...d, contact: v }))}
${d.contact
? html`<label class="srcrow"><input type="checkbox" .checked=${d.invert}
@change=${(e: Event) => (this._openingDialog = { ...d, invert: (e.target as HTMLInputElement).checked })} />
<span>${this._t('opening.invert')}</span></label>`
: nothing}
${d.type === 'door'
? html`<label>${this._t('opening.lock_label')}</label>
${opt(this._lockCandidates(), d.lock, (v) => (this._openingDialog = { ...d, lock: v }))}`
: nothing}
<label class="srcrow"><input type="checkbox" .checked=${d.flipH}
@change=${(e: Event) => (this._openingDialog = { ...d, flipH: (e.target as HTMLInputElement).checked })} />
<span>${this._t('opening.flip_h')}</span></label>
<label class="srcrow"><input type="checkbox" .checked=${d.flipV}
@change=${(e: Event) => (this._openingDialog = { ...d, flipV: (e.target as HTMLInputElement).checked })} />
<span>${this._t('opening.flip_v')}</span></label>
</div>
<div class="row">
${d.id
? html`<button class="btn danger" @click=${this._deleteOpening}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.delete')}
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._openingDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveOpening}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
private _renderMarkupDefs(_vb: number[]): TemplateResult {
const g = this._gridPitch;
const dotR = g * 0.14;
@@ -2386,6 +2708,11 @@ class HouseplanCard extends LitElement {
title=${this._t('title.markup_split')}>
<ha-icon icon="mdi:vector-polyline-remove"></ha-icon>${this._t('markup.split')}
</button>
<button class="btn ${this._tool === 'opening' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'opening'; }}
title=${this._t('title.markup_opening')}>
<ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')}
</button>
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
title=${this._t('title.markup_delroom')}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}