/** * House Plan Card — an interactive house plan as a native Lovelace card. * Configuration sources: * 1) SERVER (the houseplan integration, WS houseplan/config/get) — spaces, plans, * rooms, device overrides, virtual devices. Coordinates are NORMALIZED (0..1). * 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas. * The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage. */ import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit'; import { EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor, type IconRule, type CompiledIconRule, } from './rules'; import { lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding, segmentCm, formatLength, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, spaceDisplayOf, roomFillColor, 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 { RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig, } from './types'; import './editor'; import './space-card'; import { cardStyles } from './styles'; import { langOf, t, type I18nKey } from './i18n'; const CARD_VERSION = '1.18.1'; 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' | 'erase' | 'delroom'; const fireEvent = (node: EventTarget, type: string, detail?: unknown) => { const ev = new Event(type, { bubbles: true, composed: true }) as any; ev.detail = detail ?? {}; node.dispatchEvent(ev); }; const navigate = (path: string) => { history.pushState(null, '', path); fireEvent(window, 'location-changed', { replace: false }); }; const debounce = void>(fn: T, ms: number) => { let t: number | undefined; return (...a: Parameters) => { clearTimeout(t); t = window.setTimeout(() => fn(...a), ms); }; }; class HouseplanCard extends LitElement { public hass?: any; private _config?: CardConfig; private _space = 'f1'; private _layout: Record = {}; private _serverStorage = false; private _loadOk = false; private _loading = false; private _loadTries = 0; private _serverCfg: ServerConfig | null = null; private _cfgRev = 0; private _unsubCfg: (() => void) | null = null; private _devices: DevItem[] = []; private _regSignature = ''; private _defPos: Record = {}; private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null } | null = null; private _selId: string | null = null; private _toast = ''; private _toastTimer?: number; // --- room markup editor --- private _markup = false; private _tool: MarkupTool = 'draw'; private _path: number[][] = []; // current outline (render units, vertices snapped to the grid) private _pathSegs: (string | null)[] = []; // keys of the segments added by outline steps private _cursorPt: number[] | null = null; private _areaSel = ''; private _nameSel = ''; private _roomDialog = false; // 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) private _zoomBySpace: Record = {}; private _pointers = new Map(); private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null; private _pinchStart: { dist: number; zoom: number } | null = null; private _suppressClick = false; private _roViewport?: ResizeObserver; private _onboardingShown = false; // the auto space dialog is shown once per session private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null; private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null; private _importQueue: string[] = []; // floor titles still to create private _importTotal = 0; private _rulesCompiledSrc = ''; private _rulesCompiled: CompiledIconRule[] | undefined; private _infoCard: DevItem | null = null; private _markerDialog: { devId?: string; // the icon being edited (if any) name: string; binding: string; // 'device:' | 'entity:' | 'virtual' bindingFilter: string; icon: string; // '' = auto tapAction: string; // '' = card default model: string; link: string; description: string; pdfs: PdfRef[]; room: string; // 'space#area' for a virtual one busy: boolean; } | null = null; private _spaceDialog: { mode: 'edit' | 'create'; spaceId?: string; title: string; planUrl: string | null; planFile: { ext: string; b64: string; aspect: number; name: string } | null; source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms orientation: 'landscape' | 'portrait' | 'square'; showBorders: boolean; showNames: boolean; roomColor: string; roomOpacity: number; // 0..1 fillMode: 'none' | 'lqi' | 'light' | 'temp'; tempMin: number; tempMax: number; cellCm: number; // real-world cm represented by one grid cell busy: boolean; } | null = null; private _keyHandler = (e: KeyboardEvent) => this._onKey(e); private _hashApplied = false; /** Deep-link: read `#space=` from the URL (used by embedded houseplan-space-card). */ private _hashSpace(): string { const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || ''); return m ? decodeURIComponent(m[1]) : ''; } private _onHashChange = (): void => { const id = this._hashSpace(); if (id && this._model.find((sp) => sp.id === id) && id !== this._space) { this._space = id; this._selId = null; this._restoreZoom(); this.requestUpdate(); } }; private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null; private _holdTimer?: number; private _holdFired = false; static properties = { hass: { attribute: false }, _config: { state: true }, _space: { state: true }, _layout: { state: true }, _devices: { state: true }, _tip: { state: true }, _selId: { state: true }, _toast: { state: true }, _serverCfg: { state: true }, _markup: { state: true }, _tool: { state: true }, _path: { state: true }, _cursorPt: { state: true }, _areaSel: { state: true }, _nameSel: { state: true }, _roomDialog: { state: true }, _spaceDialog: { state: true }, _infoCard: { state: true }, _rulesDialog: { state: true }, _importDialog: { state: true }, _markerDialog: { state: true }, _zoom: { state: true }, _view: { state: true }, }; public connectedCallback(): void { super.connectedCallback(); window.addEventListener('keydown', this._keyHandler); window.addEventListener('hashchange', this._onHashChange); } public disconnectedCallback(): void { window.removeEventListener('keydown', this._keyHandler); window.removeEventListener('hashchange', this._onHashChange); clearTimeout(this._holdTimer); this._roViewport?.disconnect(); this._roViewport = undefined; if (this._unsubCfg) { this._unsubCfg(); this._unsubCfg = null; } super.disconnectedCallback(); } private _onKey(e: KeyboardEvent): void { if (e.key === 'Escape') { if (this._infoCard) { this._infoCard = null; return; } if (this._markerDialog) { this._markerDialog = null; return; } } if (!this._markup) return; const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z'); if (!undo) return; if (this._roomDialog) { e.preventDefault(); this._roomDialogCancel(); return; } if (this._tool === 'draw' && this._path.length) { e.preventDefault(); this._undoPoint(); } } /** Remove the last placed point (and its line, if it was added by that step). */ private _undoPoint(): void { if (!this._path.length) return; if (this._path.length === 1) { this._path = []; this._pathSegs = []; return; } const segKey = this._pathSegs[this._pathSegs.length - 1]; this._pathSegs = this._pathSegs.slice(0, -1); if (segKey) this._removeSegmentByKey(segKey); this._path = this._path.slice(0, -1); } private _removeSegmentByKey(key: string): void { const sp = this._curSpaceCfg; if (!sp?.segments) return; const idx = this._segments.findIndex( (s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key, ); if (idx >= 0) { sp.segments.splice(idx, 1); this._saveConfig(); } } public static getConfigElement() { return document.createElement('houseplan-card-editor'); } public static getStubConfig(): Partial { return { type: 'custom:houseplan-card' }; } public setConfig(config: CardConfig): void { this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config }; if (config.default_floor) this._space = config.default_floor; try { this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {}; } catch { this._zoomBySpace = {}; } // instant render from cache (stale-while-revalidate): show the plan and icons // right away, without waiting for the server response — fresh data will load in the background. try { const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null'); if (c && c.config && Array.isArray(c.config.spaces)) { this._serverCfg = c.config; this._cfgRev = c.rev || 0; this._layout = c.layout || {}; this._serverStorage = true; const hs = this._hashSpace(); if (hs && this._model.find((sp) => sp.id === hs)) { this._space = hs; this._hashApplied = true; } else if (config.default_floor) this._space = config.default_floor; else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space; } } catch { /* ignore */ } } /** Save a snapshot of the config+layout to localStorage for an instant start. */ private _cacheSnapshot(): void { if (!this._serverCfg) return; try { localStorage.setItem(LS_CFG, JSON.stringify({ config: this._serverCfg, rev: this._cfgRev, layout: this._layout })); } catch { /* ignore */ } } public getCardSize(): number { return 12; } // ================= MODEL RESOLUTION (server configuration) ================= /** Whether a server configuration with spaces exists (otherwise — onboarding). */ private get _norm(): boolean { return !!(this._serverCfg && this._serverCfg.spaces.length); } /** Spaces in render units (NORM_W × NORM_W/aspect). */ private get _model(): SpaceModel[] { if (!this._serverCfg) return []; return this._serverCfg.spaces.map((s: any) => { const H = NORM_W / s.aspect; const scale = (r: any) => ({ id: r.id, name: r.name, area: r.area ?? null, 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, h: r.h != null ? r.h * H : undefined, poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined, }); return { id: s.id, title: s.title, vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H], bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null, rooms: s.rooms.map(scale), }; }); } private _spaceModel(id?: string): SpaceModel { const m = this._model; return m.find((s) => s.id === (id ?? this._space)) || m[0]; } private get _areaToSpace(): Record { const map: Record = {}; for (const s of this._model) for (const r of s.rooms) if (r.area) map[r.area] = { space: s.id, room: r }; return map; } private get _settings(): ServerConfig['settings'] { return this._serverCfg?.settings || {}; } private get _showAll(): boolean { return !!this._settings.show_all; } private _toggleShowAll(): void { if (!this._serverCfg) return; this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._showAll } }; this._regSignature = ''; this._maybeRebuildDevices(); this._saveConfig(); this.requestUpdate(); } /** Compiled icon rules: instance settings override the built-in defaults. */ private get _iconRules(): CompiledIconRule[] | undefined { const custom = this._settings.icon_rules; if (!custom || !Array.isArray(custom) || !custom.length) return undefined; const src = JSON.stringify(custom); if (src !== this._rulesCompiledSrc) { this._rulesCompiledSrc = src; this._rulesCompiled = compileIconRules(custom); } return this._rulesCompiled; } private get _excluded(): Set { const list = this._settings.exclude_integrations; return list ? new Set(list) : EXCLUDED_DOMAINS; } protected willUpdate(changed: PropertyValues): void { if (changed.has('hass') && this.hass) { if (!this._loadOk && !this._loading && this._loadTries < 8) { this._loadFromServer(); } this._maybeRebuildDevices(); } } protected updated(): void { const stage = this._stageEl; if (stage && !this._roViewport) { this._roViewport = new ResizeObserver(() => this._refitView()); this._roViewport.observe(stage); } if (stage && !this._view) this._refitView(); // onboarding: on an empty server config, open the space dialog right away if ( this._serverStorage && this._loadOk && this._model.length === 0 && !this._spaceDialog && !this._importDialog && !this._onboardingShown ) { this._onboardingShown = true; const floors = floorsOf(this.hass); if (floors.length) { this._importDialog = { floors: floors.map((f) => ({ ...f, checked: true })) }; } else { this._openSpaceDialog('create'); } } } // ================= server: config + layout ================= private async _loadFromServer(): Promise { this._loading = true; this._loadTries++; try { const [cfgResp, layResp] = await Promise.all([ this.hass.callWS({ type: 'houseplan/config/get' }), this.hass.callWS({ type: 'houseplan/layout/get' }), ]); this._loadOk = true; this._serverStorage = true; const cfg = cfgResp?.config; this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null; this._cfgRev = cfgResp?.rev || 0; this._layout = layResp?.layout || {}; // live sync: the config was changed in another window → re-read it if (!this._unsubCfg) { this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => { if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly(); }, 'houseplan_config_updated'); } const hs = this._hashSpace(); if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) { this._space = hs; this._hashApplied = true; } else if (this._norm && !this._model.find((s) => s.id === this._space)) { this._space = this._model[0]?.id || this._space; } this._cacheSnapshot(); this._restoreZoom(); } catch (e) { // not the last attempt — silently wait for the next hass update (WS warm-up) if (this._loadTries >= 8) { this._serverStorage = false; this._serverCfg = null; try { this._layout = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {}; } catch { this._layout = {}; } } } finally { this._loading = false; } this._regSignature = ''; this.requestUpdate(); } private async _reloadConfigOnly(): Promise { try { const resp = await this.hass.callWS({ type: 'houseplan/config/get' }); const cfg = resp?.config; this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null; this._cfgRev = resp?.rev || 0; this._cacheSnapshot(); this._regSignature = ''; this._maybeRebuildDevices(); this.requestUpdate(); } catch { /* ignore */ } } private _dirtyPos = new Set(); private _persistLayout = debounce(() => { if (this._serverStorage) { // point-wise updates: do not overwrite positions changed in other windows const ids = [...this._dirtyPos]; this._dirtyPos.clear(); for (const id of ids) { const pos = this._layout[id]; if (!pos) continue; this.hass .callWS({ type: 'houseplan/layout/update', device_id: id, pos }) .catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) }))); } this._cacheSnapshot(); } else { localStorage.setItem(LS_KEY, JSON.stringify(this._layout)); } }, 600); // ================= devices from the registries ================= private _maybeRebuildDevices(): void { const h = this.hass; if (!h?.devices || !h?.entities || !h?.areas) return; const sig = Object.keys(h.devices).length + ':' + Object.keys(h.entities).length + ':' + Object.keys(h.areas).length + ':' + (this._norm ? 'n' : 'l') + ':' + langOf(h, this._config?.language); if (sig === this._regSignature && this._devices.length) return; this._regSignature = sig; this._devices = buildDevices({ hass: h, areaToSpace: Object.fromEntries( Object.entries(this._areaToSpace).map(([a, v]) => [a, v.space]), ), markers: this._markers, settings: this._settings, excluded: this._excluded, showAll: this._showAll, firstSpaceId: this._model[0]?.id || '', loc: (k) => this._t(k), iconRules: this._iconRules, }); this._defPos = this._defaultPositions(); } /** Curation + light groups + overrides + virtual devices. */ private get _markers(): Marker[] { return this._serverCfg?.markers || []; } private _roomLqi(area: string | null): number | null { if (!area) return null; const vals: number[] = []; for (const d of this._devices) { if (d.area !== area || d.virtual) continue; const l = lqiFor(this.hass, d.entities); if (l != null) vals.push(l); } return averageLqi(vals); } // ================= positions ================= /** Bounding rectangle of a room (rect or polygon) in render units. */ private _roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } { if (r.poly && r.poly.length) { const xs = r.poly.map((p) => p[0]); const ys = r.poly.map((p) => p[1]); const x = Math.min(...xs); const y = Math.min(...ys); return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; } return { x: r.x ?? 0, y: r.y ?? 0, w: r.w ?? 0, h: r.h ?? 0 }; } private _defaultPositions(): Record { const map: Record = {}; const iconPct = this._config?.icon_size ?? 2.5; const minDist = (iconPct / 100) * NORM_W * 1.3; // no closer than the icon diameter + a margin for (const s of this._model) { for (const r of s.rooms) { if (!r.area) continue; const ds = this._devices.filter((d) => d.area === r.area && d.space === s.id); if (!ds.length) continue; const b = this._roomBounds(r); const pad = Math.min(b.w, b.h) * 0.1; const iw = b.w - pad * 2; const ih = b.h - pad * 2; const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1)))); const rows = Math.ceil(ds.length / cols); const cw = iw / cols; const ch = ih / Math.max(rows, 1); const pts = ds.map((_, i) => ({ x: b.x + pad + cw * ((i % cols) + 0.5), y: b.y + pad + ch * (Math.floor(i / cols) + 0.5), })); declump(pts, b, minDist, pad * 0.5); ds.forEach((d, i) => (map[d.id] = pts[i])); } } return map; } /** Device position in render units of the current space. */ private _pos(d: DevItem): { x: number; y: number } { const s = this._spaceModel(d.space); const saved = this._layout[d.id]; if (saved) { if (this._norm) { if (saved.s === d.space) { const aspect = this._serverCfg!.spaces.find((x: any) => x.id === d.space)?.aspect || 1; return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) }; } } else if (saved.s === undefined) { return { x: saved.x, y: saved.y }; } } if (this._defPos[d.id]) return this._defPos[d.id]; const vb = s.vb; return { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 }; } private _savePos(d: DevItem, x: number, y: number): void { if (this._norm) { // the icon center snaps to the nodes of the same grid as the room markup const g = this._gridPitch; const gx = Math.round(x / g) * g; const gy = Math.round(y / g) * g; const aspect = this._serverCfg!.spaces.find((s: any) => s.id === d.space)?.aspect || 1; this._layout = { ...this._layout, [d.id]: { s: d.space, x: gx / NORM_W, y: gy / (NORM_W / aspect) }, }; } else { this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } }; } this._dirtyPos.add(d.id); this._persistLayout(); } // ================= live states ================= private _stateClass(d: DevItem): string { if (!this._config?.live_states) return ''; const p = d.primary ? this.hass.states[d.primary] : undefined; if (!p) return ''; if (p.state === 'unavailable') return 'unavail'; // derive the domain from the entity id we looked the state up by — state // objects are not guaranteed to carry entity_id (defensive; found by the // TESTING.md edge-case run) const dom = d.primary!.split('.')[0]; if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : ''; if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : ''; if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : ''; if (dom === 'binary_sensor') { const dc = p.attributes?.device_class; if (['door', 'window', 'garage_door', 'opening', 'gas', 'smoke', 'moisture', 'problem'].includes(dc)) return p.state === 'on' ? 'open' : ''; } if (dom === 'media_player') return ['playing', 'on'].includes(p.state) ? 'on' : ''; if (dom === 'vacuum') return ['cleaning', 'returning'].includes(p.state) ? 'on' : ''; return ''; } private _liveTemp(d: DevItem): number | null { if (!this._config?.show_temperature) return null; if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null; return tempFor(this.hass, d.entities); } private _liveHum(d: DevItem): number | null { if (!this._config?.show_temperature) return null; // same "sensor values" toggle as temperature if (!d.primary || !isHumEntity(this.hass, d.primary)) return null; return humFor(this.hass, d.entities); } // ================= interaction ================= private _openMoreInfo(entityId?: string): void { if (!entityId) { this._showToast(this._t('toast.no_entity')); return; } fireEvent(this, 'hass-more-info', { entityId }); } private _clickDevice(ev: MouseEvent, d: DevItem): void { ev.stopPropagation(); if (this._drag?.moved || this._suppressClick || this._holdFired) return; if (this._markup) return; const domain = d.primary ? d.primary.split('.')[0] : null; const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain); if (action === 'toggle' && d.primary) { this.hass .callService('homeassistant', 'toggle', { entity_id: d.primary }) .catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) }))); return; } if (action === 'more-info' && d.primary) { this._openMoreInfo(d.primary); return; } this._infoCard = d; } /** Translate a key in the card's current language. */ private _t(key: I18nKey, vars?: Record): string { return t(langOf(this.hass, this._config?.language), key, vars); } private get _stageEl(): HTMLElement | null { return this.renderRoot.querySelector('.stage') as HTMLElement | null; } /** Aspect ratio of the scene (width/height, px). */ private _stageAspect(): number { const s = this._stageEl; const vb = this._spaceModel().vb; return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3]; } /** Current view with a fallback to the full fit. */ private _viewOr(vb: number[]): { x: number; y: number; w: number; h: number } { return this._view && this._view.w ? this._view : fitView(vb, this._stageAspect()); } /** Screen (sx,sy relative to the scene, px) → vb coordinates per the current view. */ private _screenToVb(sx: number, sy: number): number[] { const s = this._stageEl; const v = this._viewOr(this._spaceModel().vb); const w = s?.clientWidth || 1, h = s?.clientHeight || 1; return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h]; } /** Clamp the view to the fit bounds (the content always covers the scene). */ private _clampView( v: { x: number; y: number; w: number; h: number }, fit: { x: number; y: number; w: number; h: number }, ): { x: number; y: number; w: number; h: number } { return { w: v.w, h: v.h, x: Math.max(fit.x, Math.min(fit.x + fit.w - v.w, v.x)), y: Math.max(fit.y, Math.min(fit.y + fit.h - v.h, v.y)), }; } /** Set the zoom (centered on vb point cx,cy, or on the center of the current view). */ private _applyView(zoom: number, cx?: number, cy?: number): void { const vb = this._spaceModel().vb; const fit = fitView(vb, this._stageAspect()); const z = Math.min(8, Math.max(1, zoom)); const w = fit.w / z, h = fit.h / z; const cur = this._viewOr(vb); const ccx = cx ?? cur.x + cur.w / 2; const ccy = cy ?? cur.y + cur.h / 2; this._zoom = z; this._view = this._clampView({ x: ccx - w / 2, y: ccy - h / 2, w, h }, fit); } /** Recompute the view for a new scene size, preserving zoom and center. */ private _refitView(): void { if (!this._stageEl) return; const cur = this._view; this._applyView(this._zoom, cur ? cur.x + cur.w / 2 : undefined, cur ? cur.y + cur.h / 2 : undefined); this.requestUpdate(); } /** Change the zoom while keeping the point (sx,sy relative to the scene) in place. */ private _zoomAt(sx: number, sy: number, newZoom: number): void { const stage = this._stageEl; if (!stage) return; const vb = this._spaceModel().vb; const fit = fitView(vb, this._stageAspect()); const z = Math.min(8, Math.max(1, newZoom)); const w = stage.clientWidth, h = stage.clientHeight; const pt = this._screenToVb(sx, sy); const nw = fit.w / z, nh = fit.h / z; this._zoom = z; this._view = this._clampView({ x: pt[0] - (sx / w) * nw, y: pt[1] - (sy / h) * nh, w: nw, h: nh }, fit); } private _onWheel(ev: WheelEvent): void { const stage = this._stageEl; if (!stage) return; ev.preventDefault(); const r = stage.getBoundingClientRect(); const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15; this._zoomAt(ev.clientX - r.left, ev.clientY - r.top, this._zoom * factor); this._saveZoom(); } private _stepZoom(delta: number): void { const stage = this._stageEl; if (!stage) return; this._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, this._zoom * (delta > 0 ? 1.4 : 1 / 1.4)); this._saveZoom(); } private _resetZoom(): void { const vb = this._spaceModel().vb; this._zoom = 1; this._view = fitView(vb, this._stageAspect()); this._saveZoom(); } /** Save the current space zoom to localStorage. */ private _saveZoom(): void { this._zoomBySpace = { ...this._zoomBySpace, [this._space]: this._zoom }; try { localStorage.setItem(LS_ZOOM, JSON.stringify(this._zoomBySpace)); } catch { /* ignore */ } } /** Restore the saved space zoom and center the plan. */ private _restoreZoom(): void { const z = this._zoomBySpace[this._space] || 1; this._zoom = z; this._view = null; requestAnimationFrame(() => { if (!this._stageEl) return; const vb = this._spaceModel().vb; this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); this.requestUpdate(); }); } private _stagePointerDown(ev: PointerEvent): void { // do not interfere with icon dragging and markup drawing if (this._drag || this._markup) return; if ((ev.target as HTMLElement).closest('.dev')) return; this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); const v = this._viewOr(this._spaceModel().vb); if (this._pointers.size === 1) { this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y }; this._suppressClick = false; } else if (this._pointers.size === 2) { const pts = [...this._pointers.values()]; const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); this._pinchStart = { dist, zoom: this._zoom }; this._panStart = null; } } private _stagePointerMove(ev: PointerEvent): void { if (!this._pointers.has(ev.pointerId)) { this._markupMove(ev); return; } this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); if (this._pinchStart && this._pointers.size >= 2) { const pts = [...this._pointers.values()]; const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); const scale = dist / (this._pinchStart.dist || 1); const r = this._stageEl!.getBoundingClientRect(); const cx = (pts[0].x + pts[1].x) / 2 - r.left; const cy = (pts[0].y + pts[1].y) / 2 - r.top; this._zoomAt(cx, cy, this._pinchStart.zoom * scale); this._suppressClick = true; this._saveZoom(); } else if (this._panStart) { const ddx = ev.clientX - this._panStart.sx; const ddy = ev.clientY - this._panStart.sy; if (Math.abs(ddx) + Math.abs(ddy) > 4) this._suppressClick = true; if (this._zoom > 1 && this._view) { const stage = this._stageEl!; const v = this._view; const fit = fitView(this._spaceModel().vb, this._stageAspect()); this._view = this._clampView( { x: this._panStart.vx - (ddx / stage.clientWidth) * v.w, y: this._panStart.vy - (ddy / stage.clientHeight) * v.h, w: v.w, h: v.h, }, fit, ); } } } private _stagePointerUp(ev: PointerEvent): void { this._pointers.delete(ev.pointerId); if (this._pointers.size < 2) this._pinchStart = null; if (this._pointers.size === 0) { this._panStart = null; // reset click suppression on the next tick (so that a click right after a pan does not fire) setTimeout(() => (this._suppressClick = false), 0); } } private _clickRoom(r: RoomCfg): void { if (this._suppressClick || !r.area) return; navigate('/config/areas/area/' + r.area); } private _pointerDown(ev: PointerEvent, d: DevItem): void { if (this._markup) return; // in markup mode icons are not dragged ev.preventDefault(); const p = this._pos(d); this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false }; (ev.target as HTMLElement).setPointerCapture(ev.pointerId); this._tip = null; // long-press always opens the info card — keeps metadata reachable // even when the tap action is set to toggle this._holdFired = false; clearTimeout(this._holdTimer); this._holdTimer = window.setTimeout(() => { if (this._drag && this._drag.id === d.id && !this._drag.moved) { this._holdFired = true; this._drag = null; this._infoCard = d; } }, 600); } private _pointerMove(ev: PointerEvent, d: DevItem): void { if (!this._drag || this._drag.id !== d.id) return; const stage = this.renderRoot.querySelector('.stage') as HTMLElement; if (!stage) return; const vb = this._spaceModel().vb; const rect = stage.getBoundingClientRect(); const v = this._viewOr(vb); const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w; const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h; if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) { this._drag.moved = true; clearTimeout(this._holdTimer); } const m = Math.min(vb[2], vb[3]) * 0.008; const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx)); const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, this._drag.oy + dy)); this._savePos(d, nx, ny); } private _pointerUp(_ev: PointerEvent, d: DevItem): void { clearTimeout(this._holdTimer); if (!this._drag || this._drag.id !== d.id) return; const moved = this._drag.moved; this._drag = moved ? this._drag : null; if (moved) { this._selId = d.id; window.setTimeout(() => (this._drag = null), 0); } } private _resetLayout(): void { if (!confirm(this._t('confirm.reset_layout'))) return; this._layout = {}; this._persistLayout(); } private _showToast(msg: string): void { this._toast = msg; clearTimeout(this._toastTimer); this._toastTimer = window.setTimeout(() => { this._toast = ''; }, 3500); } private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void { if (this._drag) return; this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp }; } // ================= ROOM MARKUP EDITOR ================= private get _gridPitch(): number { return NORM_W / GRID_N; } /** cm represented by one grid cell for the current space (default 5). */ private get _cellCm(): number { const v = Number(this._curSpaceCfg?.cell_cm); return Number.isFinite(v) && v > 0 ? v : 5; } /** Human-readable length of a segment (render units) using the HA unit system. */ private _fmtLen(a: number[], b: number[]): string { const cm = segmentCm(a, b, this._gridPitch, this._cellCm); return formatLength(cm, this.hass?.config?.unit_system?.length === 'mi'); } private get _curSpaceCfg(): any { return this._serverCfg?.spaces.find((s: any) => s.id === this._space); } private get _spaceH(): number { const sp = this._curSpaceCfg; return sp ? NORM_W / sp.aspect : NORM_W; } /** Segments of the current space in render units. */ private get _segments(): number[][] { const sp = this._curSpaceCfg; const H = this._spaceH; return (sp?.segments || []).map((s: number[]) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]); } private _toggleMarkup(): void { if (!this._norm) { this._showToast(this._t('toast.markup_needs_server')); return; } this._markup = !this._markup; this._path = []; this._cursorPt = null; this._tool = 'draw'; } private _svgPoint(ev: MouseEvent): number[] { const stage = this.renderRoot.querySelector('.stage') as HTMLElement; const r = stage.getBoundingClientRect(); return this._screenToVb(ev.clientX - r.left, ev.clientY - r.top); } private _snap(p: number[]): number[] { const g = this._gridPitch; return [snapToGrid(p[0], g), snapToGrid(p[1], g)]; } private _samePt(a: number[], b: number[]): boolean { return samePoint(a, b); } private _segKey(a: number[], b: number[]): string { return segKeyOf(a, b); } private _saveConfig = debounce(() => { if (!this._serverCfg) return; this.hass .callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev }) .then((r: any) => { this._cfgRev = r?.rev ?? this._cfgRev + 1; }) .catch((e: any) => { if (e?.code === 'conflict') { this._showToast(this._t('toast.conflict')); this._cancelPath(); this._reloadConfigOnly(); } else { this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) })); } }); }, 500); /** Add a segment (render units) to the space skeleton (no duplicates). true = new. */ private _addSegment(a: number[], b: number[]): boolean { const sp = this._curSpaceCfg; if (!sp) return false; const H = this._spaceH; const key = this._segKey(a, b); const exists = this._segments.some((s) => this._segKey([s[0], s[1]], [s[2], s[3]]) === key); if (exists) return false; sp.segments = sp.segments || []; sp.segments.push([a[0] / NORM_W, a[1] / H, b[0] / NORM_W, b[1] / H]); this._saveConfig(); return true; } private _distToSeg(p: number[], s: number[]): number { const [x, y] = p; const [x1, y1, x2, y2] = s; const dx = x2 - x1; const dy = y2 - y1; const len2 = dx * dx + dy * dy || 1; let t = ((x - x1) * dx + (y - y1) * dy) / len2; t = Math.max(0, Math.min(1, t)); const px = x1 + t * dx; const py = y1 + t * dy; return Math.hypot(x - px, y - py); } private _pointInRoom(p: number[], r: RoomCfg): boolean { if (r.poly) return pointInPolygon(p, r.poly); return ( r.x != null && p[0] >= r.x! && p[0] <= r.x! + r.w! && p[1] >= r.y! && p[1] <= r.y! + r.h! ); } private _markupClick(ev: MouseEvent): void { if (!this._markup) return; const raw = this._svgPoint(ev); if (this._tool === 'erase') { const sp = this._curSpaceCfg; if (!sp?.segments?.length) return; const segs = this._segments; let best = -1; let bestD = this._gridPitch * 0.5; segs.forEach((s, i) => { const d = this._distToSeg(raw, s); if (d < bestD) { bestD = d; best = i; } }); if (best >= 0) { sp.segments.splice(best, 1); this._saveConfig(); this.requestUpdate(); } return; } if (this._tool === 'delroom') { const space = this._spaceModel(); const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r)); if (!room) return; if (!confirm(this._t('confirm.delete_room', { name: room.name }))) return; const sp = this._curSpaceCfg; sp.rooms = sp.rooms.filter((r: any) => r.id !== room.id); this._saveConfig(); this._regSignature = ''; this._maybeRebuildDevices(); this.requestUpdate(); return; } // draw: clicks on grid points, pairs of points get connected with a line const pt = this._snap(raw); if (!this._path.length) { this._path = [pt]; this._pathSegs = []; return; } const last = this._path[this._path.length - 1]; if (this._samePt(pt, last)) return; // repeated click on the same point const added = this._addSegment(last, pt); this._pathSegs = [...this._pathSegs, added ? this._segKey(last, pt) : null]; this._path = [...this._path, pt]; // closing the outline: a click on the first vertex → the save dialog if (this._path.length >= 4 && this._samePt(pt, this._path[0])) { this._cursorPt = null; this._nameSel = ''; this._areaSel = ''; this._roomDialog = true; } } private get _contourClosed(): boolean { return this._path.length >= 4 && this._samePt(this._path[0], this._path[this._path.length - 1]); } private _markupMove(ev: MouseEvent): void { if (!this._markup || this._tool !== 'draw' || !this._path.length || this._contourClosed) { return; } this._cursorPt = this._snap(this._svgPoint(ev)); } /** Save a room with a mandatory binding to an HA area. */ private _saveRoom(): void { if (!this._areaSel) return; this._commitRoom(); } /** Save a decorative room without an area (only a name is required). */ private _saveRoomNoArea(): void { if (!this._nameSel.trim()) return; this._areaSel = ''; this._commitRoom(); } private _commitRoom(): void { if (!this._contourClosed) return; const sp = this._curSpaceCfg; if (!sp) return; const H = this._spaceH; const verts = this._path.slice(0, -1); // without the duplicated closing vertex const areaName = this._areaSel ? this.hass.areas[this._areaSel]?.name : ''; sp.rooms.push({ id: 'r' + Date.now().toString(36), 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._saveConfig(); this._path = []; this._pathSegs = []; const boundArea = this._areaSel; this._areaSel = ''; this._nameSel = ''; this._roomDialog = false; this._regSignature = ''; this._maybeRebuildDevices(); // auto-add the area's device icons + PIN their positions in the layout, // so that icons do not get reshuffled when the order in the HA registry changes. let added = 0; if (boundArea) { const aspect = this._serverCfg?.spaces.find((x: any) => x.id === this._space)?.aspect || 1; const H2 = NORM_W / aspect; const next = { ...this._layout }; for (const d of this._devices) { if (d.area !== boundArea || d.space !== this._space) continue; added++; if (this._layout[d.id]) continue; // placed manually — leave it alone const dp = this._defPos[d.id]; if (!dp) continue; next[d.id] = { s: this._space, x: dp.x / NORM_W, y: dp.y / H2 }; this._dirtyPos.add(d.id); } this._layout = next; this._persistLayout(); } const roomsN = this._model.find((s) => s.id === this._space)?.rooms.length || 0; this._showToast( boundArea ? this._t('toast.room_saved', { n: roomsN, added }) : this._t('toast.room_saved_no_area', { n: roomsN }), ); } private _cancelPath(): void { this._path = []; this._pathSegs = []; this._cursorPt = null; this._roomDialog = false; } /** Cancel in the dialog: the outline is open again (the closing point is removed). */ private _roomDialogCancel(): void { this._roomDialog = false; this._undoPoint(); } /** HA areas not yet assigned to any room in the config. */ private get _freeAreas(): any[] { const used = new Set(); for (const sp of this._serverCfg?.spaces || []) for (const r of sp.rooms || []) if (r.area) used.add(r.area); return Object.values(this.hass?.areas || {}) .filter((a) => !used.has(a.area_id)) .sort((a, b) => (a.name || '').localeCompare(b.name || '')); } // ================= DEVICE EDITOR (markers) ================= private _openMarkerDialog(d?: DevItem): void { if (!this._norm) { this._showToast(this._t('toast.marker_needs_server')); return; } if (d) { this._markerDialog = { devId: d.id, name: d.name, binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef, bindingFilter: '', icon: d.marker?.icon || '', tapAction: d.marker?.tap_action || '', model: d.model || '', link: d.link || '', description: d.description || '', pdfs: [...(d.pdfs || [])], room: d.space && d.area ? d.space + '#' + d.area : '', busy: false, }; } else { this._markerDialog = { name: '', binding: 'virtual', bindingFilter: '', icon: '', tapAction: '', model: '', link: '', description: '', pdfs: [], room: '', busy: false, }; } } /** Binding candidates: HA devices + group/helper entities, minus the ones already placed. */ private _bindingCandidates(): { value: string; label: string; sub: string }[] { const h = this.hass; const taken = new Set(); for (const dev of this._devices) { if (dev.id === this._markerDialog?.devId) continue; if (dev.bindingKind === 'device' && dev.bindingRef) taken.add('device:' + dev.bindingRef); if (dev.bindingKind === 'entity' && dev.bindingRef) taken.add('entity:' + dev.bindingRef); } // dedup as on the plan: hide devices with the same “name|area” as already shown ones (Tuya duplicates) const shownKeys = new Set(); for (const dev of this._devices) { if (dev.bindingKind === 'device' && dev.name) shownKeys.add(dev.name.trim() + '|' + (dev.area || '')); } const list: { value: string; label: string; sub: string }[] = []; // devices (incl. Z2M groups with model=Group) for (const dev of Object.values(h.devices)) { if (dev.entry_type === 'service') continue; const v = 'device:' + dev.id; if (taken.has(v)) continue; const name = (dev.name_by_user || dev.name || dev.id).trim(); if (v !== this._markerDialog?.binding && shownKeys.has(name + '|' + (dev.area_id || ''))) continue; list.push({ value: v, label: name, sub: (dev.model || this._t('marker.sub_device')) + (dev.model === 'Group' ? this._t('marker.sub_z2m_group') : '') }); } // group/helper entities without a physical device of their own const helperPlatforms = new Set([ 'group', 'template', 'derivative', 'min_max', 'threshold', 'integration', 'statistics', 'trend', 'utility_meter', 'tod', 'switch_as_x', 'schedule', ]); for (const [eid, reg] of Object.entries(h.entities)) { const v = 'entity:' + eid; if (taken.has(v)) continue; const isHelper = helperPlatforms.has(reg.platform); const isGroupEntity = reg.platform === 'group'; if (!isHelper && !isGroupEntity) continue; if (reg.hidden) continue; const st = h.states[eid]; list.push({ value: v, label: reg.name || st?.attributes?.friendly_name || eid, sub: eid.split('.')[0] + ' · ' + (reg.platform === 'group' ? this._t('marker.sub_group') : this._t('marker.sub_helper')), }); } // Individual entities — surfaced only while searching (avoids a huge default list); lets you // place a single entity of a multi-entity device (e.g. the humidity of a climate sensor) as // its own icon. Uses the same entity: binding as helpers/groups. const q = (this._markerDialog?.bindingFilter || '').toLowerCase().trim(); if (q) { const seen = new Set(list.map((o) => o.value)); for (const [eid, reg] of Object.entries(h.entities)) { const v = 'entity:' + eid; if (taken.has(v) || seen.has(v) || reg.hidden) continue; const stt = h.states[eid]; const label = reg.name || stt?.attributes?.friendly_name || eid; const dev = reg.device_id ? h.devices[reg.device_id] : null; const devName = dev ? (dev.name_by_user || dev.name || '') : ''; if (!(label + ' ' + eid + ' ' + devName).toLowerCase().includes(q)) continue; list.push({ value: v, label, sub: eid.split('.')[0] + ' · ' + this._t('marker.sub_entity') + (devName ? ' · ' + devName : '') }); } } const f = (this._markerDialog?.bindingFilter || '').toLowerCase().trim(); const filtered = f ? list.filter((o) => (o.label + ' ' + o.sub + ' ' + o.value).toLowerCase().includes(f)) : list; filtered.sort((a, b) => a.label.localeCompare(b.label)); return filtered.slice(0, 200); } /** List of rooms across all spaces for a virtual device. */ private _allRoomsFlat(): { value: string; label: string }[] { const res: { value: string; label: string }[] = []; for (const sp of this._serverCfg?.spaces || []) { for (const r of sp.rooms || []) { if (!r.area) continue; res.push({ value: sp.id + '#' + r.area, label: (sp.title || sp.id) + ' · ' + r.name }); } } return res; } /** Readable error text (never “[object Object]”). */ private _errText(e: any): string { if (!e) return this._t('err.unknown'); if (typeof e === 'string') return e; if (e.message) return e.message; if (e.error) return e.error; if (e.code != null) return this._t('err.code', { code: e.code }); try { return JSON.stringify(e); } catch { return String(e); } } /** * Manual files are uploaded via HTTP (multipart) — not via WebSocket, whose message size * limit breaks the connection on large PDFs. */ private async _pickMarkerFiles(ev: Event): Promise { const input = ev.target as HTMLInputElement; const files = input.files ? [...input.files] : []; input.value = ''; if (!files.length || !this._markerDialog) return; const mid = this._markerDialog.devId || 'new'; const uploaded: PdfRef[] = []; for (const file of files) { try { const fd = new FormData(); fd.append('marker_id', mid); fd.append('file', file, file.name); // fetchWithAuth refreshes a stale access_token itself; the fallback is the raw token const resp: Response = this.hass?.fetchWithAuth ? await this.hass.fetchWithAuth('/api/houseplan/upload', { method: 'POST', body: fd }) : await fetch('/api/houseplan/upload', { method: 'POST', body: fd, headers: this.hass?.auth?.data?.access_token ? { authorization: `Bearer ${this.hass.auth.data.access_token}` } : {}, }); const json = await resp.json().catch(() => ({})); if (!resp.ok || json.error) { const map: Record = { too_large: this._t('err.too_large', { mb: json.max_mb || 25 }), bad_ext: this._t('err.bad_ext'), unauthorized: this._t('err.unauthorized'), }; throw new Error(map[json.error] || json.error || 'HTTP ' + resp.status); } uploaded.push({ name: json.name || file.name, url: json.url }); } catch (e: any) { this._showToast(this._t('toast.file_failed', { name: file.name, err: this._errText(e) })); } } // the dialog might have closed during the upload — add only if it is still open if (uploaded.length && this._markerDialog) { this._markerDialog = { ...this._markerDialog, pdfs: [...this._markerDialog.pdfs, ...uploaded] }; this._showToast(this._t('toast.files_attached', { n: uploaded.length })); } } private _removeMarkerPdf(url: string): void { if (!this._markerDialog) return; this._markerDialog = { ...this._markerDialog, pdfs: this._markerDialog.pdfs.filter((p) => p.url !== url), }; } private async _saveMarker(): Promise { const dlg = this._markerDialog; if (!dlg || dlg.busy) return; if (dlg.binding === 'virtual' && !dlg.name.trim()) { this._showToast(this._t('toast.virtual_name_required')); return; } this._markerDialog = { ...dlg, busy: true }; try { const cfg = this._serverCfg!; cfg.markers = cfg.markers || []; // determine the marker id let id: string; // a manually chosen room overrides the space/area for any icon const [roomSp, roomAr] = dlg.room ? dlg.room.split('#') : ['', '']; let space: string | null = roomSp || null; let area: string | null = roomAr || null; if (dlg.binding === 'virtual' && !space) space = this._space; id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36)); const oldId = dlg.devId; const marker: Marker = { id, binding: dlg.binding, name: dlg.name.trim() || null, icon: dlg.icon || null, tap_action: dlg.tapAction || null, model: dlg.model.trim() || null, link: dlg.link.trim() || null, description: dlg.description.trim() || null, pdfs: dlg.pdfs, }; // save the room choice (always for virtual ones; for bound ones — if chosen) if (dlg.binding === 'virtual' || dlg.room) { marker.space = space; marker.area = area; } // the room changed → move the icon to its center const prevDev = oldId ? this._devices.find((x) => x.id === oldId) : null; const roomChanged = !!dlg.room && prevDev != null && (prevDev.space !== space || prevDev.area !== area); // 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.push(marker); // position: a new icon OR the room changed → place it at the center of the room/space. // Write POINT-WISE (layout/update), not the whole layout — a full layout/set overwrites // positions changed in other windows (the v1.4.4 incident). let newPos: { s: string; x: number; y: number } | null = null; if (!this._layout[id] || roomChanged) { const spm = this._spaceModel(space || undefined); let cx = spm.vb[0] + spm.vb[2] / 2; let cy = spm.vb[1] + spm.vb[3] / 2; if (area) { const room = spm.rooms.find((r) => r.area === area); if (room) [cx, cy] = this._roomCenter(room); } newPos = this._normPos(space || this._space, cx, cy); this._layout = { ...this._layout, [id]: newPos }; } await this._saveConfigNow(); if (newPos) await this.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos: newPos }); if (oldId && oldId !== id) { // rebinding changed the icon id — clean up the old position delete this._layout[oldId]; await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId }).catch(() => undefined); } this._markerDialog = null; this._regSignature = ''; this._maybeRebuildDevices(); this._showToast(this._t('toast.marker_saved')); } catch (e: any) { this._markerDialog = { ...this._markerDialog!, busy: false }; this._showToast(this._t('toast.error', { err: this._errText(e) })); } } private async _deleteMarker(): Promise { const dlg = this._markerDialog; if (!dlg) return; const d = dlg.devId ? this._devices.find((x) => x.id === dlg.devId) : null; const label = dlg.name || this._t('device.fallback'); if (!confirm(this._t('confirm.remove_marker', { name: label }))) return; const cfg = this._serverCfg!; cfg.markers = cfg.markers || []; if (d && d.bindingKind === 'virtual') { cfg.markers = cfg.markers.filter((m) => m.id !== d.id); } else if (d && d.marker) { // there was an explicit marker → either hide or delete: we hide (the auto entry comes back if it is an auto device) cfg.markers = cfg.markers.filter((m) => m.id !== d.id); if (d.bindingKind === 'device' && d.bindingRef) { cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true }); } else if (d.bindingKind === 'entity' && d.bindingRef) { cfg.markers.push({ id: d.id, binding: 'entity:' + d.bindingRef, hidden: true }); } } else if (d && d.bindingKind === 'device' && d.bindingRef) { cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true }); } else if (d && d.bindingKind === 'entity' && d.bindingRef) { cfg.markers.push({ id: d.id, binding: 'entity:' + d.bindingRef, hidden: true }); } try { await this._saveConfigNow(); if (d && d.bindingKind === 'virtual' && this._layout[d.id]) { // the virtual one is deleted for good → its position is no longer needed delete this._layout[d.id]; await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: d.id }).catch(() => undefined); } this._markerDialog = null; this._regSignature = ''; this._maybeRebuildDevices(); this._showToast(this._t('toast.marker_removed')); } catch (e: any) { this._showToast(this._t('toast.error', { err: this._errText(e) })); } } private _normPos(space: string, x: number, y: number): { s: string; x: number; y: number } { const aspect = this._serverCfg!.spaces.find((s: any) => s.id === space)?.aspect || 1; return { s: space, x: x / NORM_W, y: y / (NORM_W / aspect) }; } // ================= SPACE MANAGEMENT ================= private _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void { if (!this._serverStorage || !this._serverCfg) { this._showToast(this._t('toast.integration_missing')); return; } if (mode === 'edit') { const sp = this._serverCfg!.spaces.find((x: any) => x.id === spaceId); if (!sp) return; const disp = spaceDisplayOf(sp); this._spaceDialog = { mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null, source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape', showBorders: disp.showBorders, showNames: disp.showNames, roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill, tempMin: disp.tempMin, tempMax: disp.tempMax, cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, busy: false, }; } else { this._spaceDialog = { mode, title: '', planUrl: null, planFile: null, source: 'file', orientation: 'landscape', showBorders: false, showNames: false, roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, cellCm: 5, busy: false, }; } } /** Background file selection: read base64 and determine the aspect ratio. */ private async _pickPlanFile(ev: Event): Promise { const input = ev.target as HTMLInputElement; const file = input.files?.[0]; if (!file || !this._spaceDialog) return; const extMap: Record = { 'image/svg+xml': 'svg', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', }; const ext = extMap[file.type] || (file.name.toLowerCase().endsWith('.svg') ? 'svg' : ''); if (!ext) { this._showToast(this._t('toast.plan_formats')); return; } const buf = new Uint8Array(await file.arrayBuffer()); let bin = ''; for (let i = 0; i < buf.length; i += 32768) bin += String.fromCharCode(...buf.subarray(i, i + 32768)); const b64 = btoa(bin); // aspect ratio: render into an Image const url = URL.createObjectURL(file); const aspect = await new Promise((res) => { const img = new Image(); img.onload = () => res(img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1.414); img.onerror = () => res(1.414); img.src = url; }); URL.revokeObjectURL(url); this._spaceDialog = { ...this._spaceDialog, planFile: { ext, b64, aspect, name: file.name } }; } private async _saveSpaceDialog(): Promise { const d = this._spaceDialog; if (!d || d.busy || !d.title.trim()) return; if (d.source === 'file' && !d.planFile && !d.planUrl) { this._showToast(this._t('toast.plan_required')); return; } const wasFirst = d.mode === 'create' && (this._serverCfg?.spaces.length || 0) === 0; this._spaceDialog = { ...d, busy: true }; try { const cfg = this._serverCfg!; let sp: any; const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414; if (d.mode === 'create') { sp = { id: 's' + Date.now().toString(36), title: d.title.trim(), plan_url: null, aspect: d.source === 'draw' ? drawAspect : 1.414, view_box: [0, 0, 1, 1], rooms: [], segments: [], }; cfg.spaces.push(sp); } else { sp = cfg.spaces.find((x: any) => x.id === d.spaceId); sp.title = d.title.trim(); } if (d.source === 'file' && d.planFile) { const resp = await this.hass.callWS({ type: 'houseplan/plan/set', space_id: sp.id, ext: d.planFile.ext, data: d.planFile.b64, }); sp.plan_url = resp.url; sp.aspect = d.planFile.aspect; } // switching an existing space to "draw" detaches its background image // (the uploaded file stays on disk; only the reference is cleared) if (d.source === 'draw') sp.plan_url = null; // per-space display settings; hand-drawn spaces get borders+names on by default const draw = d.source === 'draw'; sp.settings = { ...(sp.settings || {}), show_borders: draw && d.mode === 'create' ? true : d.showBorders, show_names: draw && d.mode === 'create' ? true : d.showNames, room_color: d.roomColor, room_opacity: d.roomOpacity, fill_mode: d.fillMode, temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN, temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX, }; sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5; await this._saveConfigNow(); this._spaceDialog = null; if (d.mode === 'create') this._space = sp.id; this._regSignature = ''; this._maybeRebuildDevices(); if (this._importQueue.length) { // floors-import wizard: proceed to the next floor this._openNextImport(); } else if (wasFirst || this._importTotal > 0) { // guide the user onward: straight into room markup mode this._importTotal = 0; this._space = this._serverCfg!.spaces[0]?.id || this._space; this._markup = true; this._tool = 'draw'; this._path = []; this._cursorPt = null; this._showToast(this._t(wasFirst && !this._importTotal ? 'toast.space_added_onboard' : 'import.done')); } else { this._showToast(d.mode === 'create' ? this._t('toast.space_added') : this._t('toast.space_saved')); } } catch (e: any) { this._spaceDialog = { ...this._spaceDialog!, busy: false }; this._showToast(this._t('toast.error', { err: this._errText(e) })); } } private async _deleteSpace(): Promise { const d = this._spaceDialog; if (!d || d.mode !== 'edit') return; const sp = this._serverCfg!.spaces.find((x: any) => x.id === d.spaceId); if (!confirm(this._t('confirm.delete_space', { title: sp.title }))) return; this._serverCfg!.spaces = this._serverCfg!.spaces.filter((x: any) => x.id !== d.spaceId); try { await this._saveConfigNow(); this._spaceDialog = null; if (this._space === d.spaceId) this._space = this._serverCfg!.spaces[0]?.id || ''; this._regSignature = ''; this._maybeRebuildDevices(); this._showToast(this._t('toast.space_deleted')); } catch (e: any) { this._showToast(this._t('toast.delete_failed', { err: this._errText(e) })); } } /** Immediate config save with a revision bump (no debounce). On a rev conflict the local copy is refreshed before rethrowing, so the user's retry starts from the fresh config instead of hitting the same conflict again. */ private async _saveConfigNow(): Promise { try { const r = await this.hass.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev, }); this._cfgRev = r?.rev ?? this._cfgRev + 1; } catch (e: any) { if (e?.code === 'conflict') await this._reloadConfigOnly(); throw e; } } // ================= FLOORS IMPORT WIZARD ================= private _startImport(): void { const dlg = this._importDialog; if (!dlg) return; const titles = dlg.floors.filter((f) => f.checked).map((f) => f.name); this._importDialog = null; if (!titles.length) { this._openSpaceDialog('create'); return; } this._importQueue = titles; this._importTotal = titles.length; this._openNextImport(); } /** Open the space dialog for the next queued floor (title prefilled, plan required). */ private _openNextImport(): void { const title = this._importQueue.shift(); if (title === undefined) return; this._spaceDialog = { mode: 'create', title, planUrl: null, planFile: null, source: 'file', orientation: 'landscape', showBorders: false, showNames: false, roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, cellCm: 5, busy: false, }; } /** Skip the current floor of the wizard without creating a space. */ private _skipImport(): void { this._spaceDialog = null; if (this._importQueue.length) this._openNextImport(); else if (this._importTotal > 0 && this._model.length) { this._importTotal = 0; this._space = this._serverCfg!.spaces[0]?.id || this._space; this._markup = true; this._showToast(this._t('import.done')); } } private _renderImportDialog(): TemplateResult { const d = this._importDialog!; const n = d.floors.filter((f) => f.checked).length; return html``; } // ================= ICON RULES EDITOR ================= private _openRulesDialog = (): void => { if (!this._norm) return; const custom = this._settings.icon_rules; const rules = (custom && custom.length ? custom : DEFAULT_ICON_RULES).map((r) => ({ ...r })); this._rulesDialog = { rules, test: '', busy: false }; }; private _rulesSet(rules: IconRule[]): void { this._rulesDialog = { ...this._rulesDialog!, rules }; } private async _saveRules(): Promise { const dlg = this._rulesDialog; if (!dlg || dlg.busy) return; const cleaned = dlg.rules.filter((r) => r.pattern.trim() && r.icon.trim()); this._rulesDialog = { ...dlg, busy: true }; try { const cfg = this._serverCfg!; const isDefault = JSON.stringify(cleaned) === JSON.stringify(DEFAULT_ICON_RULES); const settings: any = { ...cfg.settings }; if (isDefault) delete settings.icon_rules; else settings.icon_rules = cleaned; this._serverCfg = { ...cfg, settings }; await this._saveConfigNow(); this._rulesDialog = null; this._regSignature = ''; this._maybeRebuildDevices(); this._showToast(this._t('rules.saved')); } catch (e: any) { this._rulesDialog = { ...this._rulesDialog!, busy: false }; this._showToast(this._t('toast.error', { err: this._errText(e) })); } } private _renderRulesDialog(): TemplateResult { const d = this._rulesDialog!; const compiled = compileIconRules(d.rules); const testIcon = d.test.trim() ? iconFor(d.test, '', compiled) : null; const move = (i: number, delta: number) => { const r = [...d.rules]; const j = i + delta; if (j < 0 || j >= r.length) return; [r[i], r[j]] = [r[j], r[i]]; this._rulesSet(r); }; return html``; } // ================= render ================= protected render(): TemplateResult | typeof nothing { if (!this._config || !this.hass) return nothing; const model = this._model; if (!model.length) { return html`
${this._config.title || this._t('card.title')}

${this._t('empty.no_spaces')}

${this._serverStorage ? html`

${this._t('empty.add_first')}

` : html`

${this._t('empty.install')}

`}
${this._spaceDialog ? this._renderSpaceDialog() : nothing} ${this._importDialog ? this._renderImportDialog() : nothing} ${this._toast ? html`
${this._toast}
` : nothing}
`; } const space = this._spaceModel(); const vb = space.vb; const devs = this._devices.filter((d) => d.space === space.id); const disp = spaceDisplayOf(this._curSpaceCfg); const cfgSize = this._config.icon_size ?? 2.5; const iconPct = cfgSize > 8 ? 2.5 : cfgSize; const view = this._viewOr(vb); return html`
${this._config.title || this._t('card.title')}
${model.map( (s) => html``, )} ${this._norm ? html`` : nothing}
${this._t('count.devices', { n: devs.length })}
${this._norm ? html`` : nothing} ${this._norm ? html` ` : nothing}
${this._markup ? this._renderMarkupBar() : nothing}
this._markupClick(e)} @wheel=${(e: WheelEvent) => this._onWheel(e)} @pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)} @pointermove=${(e: PointerEvent) => this._stagePointerMove(e)} @pointerup=${(e: PointerEvent) => this._stagePointerUp(e)} @pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
${this._markup ? this._renderMarkupDefs(vb) : nothing} ${space.bg ? svg`` : nothing} ${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => { let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : ''); let style = ''; if (!this._markup && (disp.showBorders || disp.fill !== '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 = r.area ? roomFillColor( 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, disp.tempMin, disp.tempMax, ) : null; if (fillC) { cls += ' filled'; st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`); } else st.push('--room-fill:transparent', '--room-fill-op:0'); style = st.join(';'); } const tip = (e: MouseEvent) => this._showTip(e, r.name, this._t('tip.room'), this._config?.show_signal ? this._roomLqi(r.area) : null, r.area ? areaTemp(this.hass, this._devices, r.area) : null); const label = (!space.bg && !disp.showNames) || this._markup; const c = this._roomCenter(r); const shape = r.poly ? svg` this._clickRoom(r)} @mousemove=${tip} @mouseleave=${() => (this._tip = null)}>` : svg` this._clickRoom(r)} @mousemove=${tip} @mouseleave=${() => (this._tip = null)}>`; return svg`${shape}${label ? svg`${r.name}` : nothing}`; })} ${this._markup ? this._renderMarkupLayer(vb) : nothing}
${devs.map((d) => this._renderDevice(d, view))} ${disp.showNames && !this._markup ? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp)) : nothing}
${this._markup && this._tool === 'draw' && this._path.length && this._cursorPt && !this._contourClosed ? html`
${this._renderMeasureLabel(view)}
` : nothing}
${this._zoom > 1 ? html`
${Math.round(this._zoom * 100)}%
` : nothing}
${this._roomDialog ? this._renderRoomDialog() : nothing} ${this._spaceDialog ? this._renderSpaceDialog() : nothing} ${this._markerDialog ? this._renderMarkerDialog() : nothing} ${this._infoCard ? this._renderInfoCard() : nothing} ${this._rulesDialog ? this._renderRulesDialog() : nothing} ${this._importDialog ? this._renderImportDialog() : nothing} ${this._tip ? html`
${this._tip.title}${this._tip.meta ? html`${this._tip.meta}` : nothing} ${this._tip.temp != null ? html`${this._t('tip.temp_avg')} ${this._tip.temp}°` : nothing} ${this._tip.lqi != null ? html`${this._t('tip.lqi')} ${this._tip.lqi}` : nothing}
` : nothing} ${this._toast ? html`
${this._toast}
` : nothing}
`; } private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }): TemplateResult { const p = this._pos(d); const left = ((p.x - view.x) / view.w) * 100; const top = ((p.y - view.y) / view.h) * 100; const cls = this._stateClass(d); const temp = this._liveTemp(d); const hum = this._liveHum(d); const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null; return html`
this._clickDevice(e, d)} @mousemove=${(e: MouseEvent) => this._showTip(e, d.name, d.model + (temp != null ? ' · ' + temp + '°' : '') + (hum != null ? ' · ' + hum + '%' : '') + (lqi != null ? ' · LQI ' + lqi : ''))} @mouseleave=${() => (this._tip = null)} @pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)} @pointermove=${(e: PointerEvent) => this._pointerMove(e, d)} @pointerup=${(e: PointerEvent) => this._pointerUp(e, d)} @pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)} > ${temp != null ? html`${temp}°` : nothing} ${hum != null ? html`${hum}%` : nothing} ${lqi != null ? html`${lqi}` : nothing}
`; } /** Saved label position (layout key rl_) or the room center. */ private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } { const saved = this._layout['rl_' + (r.id || '')]; if (saved && saved.s === spaceId) { const aspect = this._serverCfg!.spaces.find((x: any) => x.id === spaceId)?.aspect || 1; return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) }; } const c = this._roomCenter(r); return { x: c[0], y: c[1] }; } /** Room-name labels are dragged exactly like device icons (same layout store). */ private _labelDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void { if (this._markup) return; ev.preventDefault(); ev.stopPropagation(); const p = this._labelPos(r, spaceId); this._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false }; (ev.target as HTMLElement).setPointerCapture(ev.pointerId); this._tip = null; } private _labelMove(ev: PointerEvent, r: RoomCfg, spaceId: string): void { const id = 'rl_' + (r.id || ''); if (!this._drag || this._drag.id !== id) return; const stage = this._stageEl; if (!stage) return; const vb = this._spaceModel(spaceId).vb; const rect = stage.getBoundingClientRect(); const v = this._viewOr(vb); const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w; const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h; if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true; const m = Math.min(vb[2], vb[3]) * 0.008; const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx)); const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, this._drag.oy + dy)); this._savePos({ id, space: spaceId } as DevItem, nx, ny); } private _labelUp(r: RoomCfg): void { const id = 'rl_' + (r.id || ''); if (!this._drag || this._drag.id !== id) return; const moved = this._drag.moved; this._drag = moved ? this._drag : null; if (moved) window.setTimeout(() => (this._drag = null), 0); } private _renderRoomLabel( r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay, ): TemplateResult | typeof nothing { if (!r.name) return nothing; const p = this._labelPos(r, space.id); const left = ((p.x - view.x) / view.w) * 100; const top = ((p.y - view.y) / view.h) * 100; const op = Math.min(1, disp.opacity + 0.25); return html`
this._labelDown(e, r, space.id)} @pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)} @pointerup=${() => this._labelUp(r)} @pointercancel=${() => this._labelUp(r)} >${r.name}
`; } /** Length badge that follows the cursor while drawing the current segment. */ private _renderMeasureLabel(view: { x: number; y: number; w: number; h: number }): TemplateResult { const a = this._path[this._path.length - 1]; const b = this._cursorPt!; const left = ((b[0] - view.x) / view.w) * 100; const top = ((b[1] - view.y) / view.h) * 100; return html`
${this._fmtLen(a, b)}
`; } private _roomCenter(r: RoomCfg): number[] { if (r.poly) { const n = r.poly.length; return [r.poly.reduce((a, p) => a + p[0], 0) / n, r.poly.reduce((a, p) => a + p[1], 0) / n]; } return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1]; } private _renderMarkupDefs(_vb: number[]): TemplateResult { const g = this._gridPitch; const dotR = g * 0.14; return svg` `; } private _renderMarkupLayer(vb: number[]): TemplateResult { const segs = this._segments; const path = this._path; const g = this._gridPitch; return svg` ${segs.map((s) => svg``)} ${path.length > 1 ? svg`` : nothing} ${path.length && this._cursorPt && this._tool === 'draw' && !this._contourClosed ? svg`` : nothing} ${path.map((p, i) => svg``)} `; } private _renderMarkupBar(): TemplateResult { return html`
${this._tool === 'draw' ? html`${this._path.length ? this._t('markup.hint_points', { n: this._path.length }) : this._t('markup.hint_start')} ${this._path.length ? html`` : nothing}` : nothing}
`; } private _renderInfoCard(): TemplateResult { const d = this._infoCard!; const st = d.primary ? this.hass.states[d.primary] : undefined; const stateTxt = st ? this.hass.formatEntityState?.(st) ?? st.state : null; return html``; } private _renderMarkerDialog(): TemplateResult { const d = this._markerDialog!; const isVirtual = d.binding === 'virtual'; const cands = this._bindingCandidates(); const curLabel = (() => { if (isVirtual) return null; const found = cands.find((c) => c.value === d.binding); if (found) return found.label; const [k, ref] = d.binding.split(':'); if (k === 'device') return this.hass.devices[ref]?.name_by_user || this.hass.devices[ref]?.name || ref; return this.hass.states[ref]?.attributes?.friendly_name || ref; })(); return html``; } private _renderSpaceDialog(): TemplateResult { const d = this._spaceDialog!; return html``; } private _renderRoomDialog(): TemplateResult { const areas = this._freeAreas; return html``; } static styles = cardStyles; } if (!customElements.get('houseplan-card')) { customElements.define('houseplan-card', HouseplanCard); } (window as any).customCards = (window as any).customCards || []; if (!(window as any).customCards.find((c: any) => c.type === 'houseplan-card')) { (window as any).customCards.push({ type: 'houseplan-card', name: 'House Plan Card', description: 'Interactive house plan: spaces, rooms and devices with live states and drag layout.', }); } // eslint-disable-next-line no-console console.info(`%c HOUSEPLAN-CARD %c v${CARD_VERSION} `, 'background:#3ea6ff;color:#04121f;font-weight:700', '');