mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.45.0: external review of v1.44.8 — R2-1, R2-2, R2-3
R2-1 (high): plan replacement committed filesystem state before the config CAS.
The upload wrote the final name and unlinked the other extension, so a rejected
config write left the live plan already replaced — or the stored config
pointing at a deleted file. Uploads now go to <space>.<token>.<ext> and delete
nothing; houseplan/plan/cleanup runs only after the config write is accepted.
The '.' separator is load-bearing: a space id cannot contain one, so cleaning
'f1' can never reach the files of 'f1-attic'.
R2-2: the backend signs at most MAX_SIGN_PATHS (200) per request and ignores
the rest silently, while the card sent its whole cache in one call and trusted
any cached entry forever — past 200 attachments the later ones stopped being
refreshed and expired for good. Requests are chunked to the shared constant,
entries carry their issue time (aging urls keep rendering while a replacement
is fetched, expired ones are dropped), and the cache is pruned to urls the live
config still references.
R2-3: areaClimate() rescanned the whole registry per room and per measurement.
areaClimateMap() classifies once and returns Map<area,{temp,hum}>, memoized on
hass identity so fresh states are always observed. Smoke measurement: 133
registry scans per update with 44 rooms before, 2 after, flat in room count.
Also: smoke_ux_fixes wrote its screenshot to a hard-coded /tmp path and could
not run on Windows.
Tests: smoke_plan_upload_reject, smoke_sign_cap, smoke_climate_once (all fail
on v1.44.8), three backend tests for versioned plan names and cleanup scoping,
unit tests for chunk/referencedContentUrls and areaClimateMap.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
+56
-23
@@ -425,15 +425,27 @@ const NON_AIR_RE = new RegExp(
|
||||
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
||||
* a device measures.
|
||||
*/
|
||||
export function areaClimate(
|
||||
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
|
||||
): number | null {
|
||||
if (!area || !hass?.entities) return null;
|
||||
const groups = new Map<string, { name: string; model?: string; ents: string[] }>();
|
||||
export interface AreaClimate { temp: number | null; hum: number | null }
|
||||
|
||||
/**
|
||||
* Climate for EVERY area in one registry pass (review R2-3).
|
||||
*
|
||||
* The per-area version below rescanned the whole registry for each room and
|
||||
* each measurement: with 60 rooms and 2000 entities that is 120 traversals per
|
||||
* render — an entire frame spent re-reading metadata that did not change. The
|
||||
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
|
||||
*/
|
||||
export function areaClimateMap(
|
||||
hass: any, rules?: CompiledIconRule[],
|
||||
): Map<string, AreaClimate> {
|
||||
const out = new Map<string, AreaClimate>();
|
||||
if (!hass?.entities) return out;
|
||||
// area -> device (or lone entity) -> the entities that belong to it
|
||||
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
|
||||
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
|
||||
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
|
||||
const entArea = reg.area_id || dev?.area_id || null;
|
||||
if (entArea !== area) continue;
|
||||
const area = reg.area_id || dev?.area_id || null;
|
||||
if (!area) continue;
|
||||
// Not every "temperature" is room air. Real finds on a live install: the
|
||||
// NAS processor temperature, the water in a smart kettle, a sauna heater at
|
||||
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
||||
@@ -441,30 +453,51 @@ export function areaClimate(
|
||||
if (reg.entity_category) continue; // diagnostic/config readings
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
|
||||
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||
let groups = byArea.get(area);
|
||||
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
||||
const key = reg.device_id || eid;
|
||||
if (!groups.has(key)) {
|
||||
let g = groups.get(key);
|
||||
if (!g) {
|
||||
const st = hass.states?.[eid];
|
||||
groups.set(key, {
|
||||
g = {
|
||||
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
|
||||
model: dev?.model,
|
||||
ents: [],
|
||||
});
|
||||
};
|
||||
groups.set(key, g);
|
||||
}
|
||||
groups.get(key)!.ents.push(eid);
|
||||
g.ents.push(eid);
|
||||
}
|
||||
const vals: number[] = [];
|
||||
for (const g of groups.values()) {
|
||||
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
||||
const ok = kind === 'temp'
|
||||
? icon === 'mdi:thermometer' || icon === 'mdi:air-filter'
|
||||
: icon === 'mdi:thermometer' || icon === 'mdi:air-filter' || icon === 'mdi:water-percent';
|
||||
if (!ok) continue;
|
||||
const v = kind === 'temp' ? tempFor(hass, g.ents) : humFor(hass, g.ents);
|
||||
if (v != null) vals.push(v);
|
||||
for (const [area, groups] of byArea) {
|
||||
const temps: number[] = [];
|
||||
const hums: number[] = [];
|
||||
for (const g of groups.values()) {
|
||||
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
||||
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
|
||||
if (air) {
|
||||
const t = tempFor(hass, g.ents);
|
||||
if (t != null) temps.push(t);
|
||||
}
|
||||
if (air || icon === 'mdi:water-percent') {
|
||||
const h = humFor(hass, g.ents);
|
||||
if (h != null) hums.push(h);
|
||||
}
|
||||
}
|
||||
if (!temps.length && !hums.length) continue;
|
||||
out.set(area, {
|
||||
temp: temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null,
|
||||
hum: hums.length ? Math.round(hums.reduce((a, b) => a + b, 0) / hums.length) : null,
|
||||
});
|
||||
}
|
||||
if (!vals.length) return null;
|
||||
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
return kind === 'temp' ? Math.round(avg * 10) / 10 : Math.round(avg);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One area's reading. Convenience wrapper — prefer the map for many areas. */
|
||||
export function areaClimate(
|
||||
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
|
||||
): number | null {
|
||||
if (!area) return null;
|
||||
return areaClimateMap(hass, rules).get(area)?.[kind] ?? null;
|
||||
}
|
||||
|
||||
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
||||
|
||||
+86
-26
@@ -21,8 +21,9 @@ import {
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS, chunk, referencedContentUrls,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate } from './devices';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
|
||||
import type {
|
||||
OpeningCfg,
|
||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||
@@ -32,7 +33,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.44.8';
|
||||
const CARD_VERSION = '1.45.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';
|
||||
@@ -788,15 +789,25 @@ class HouseplanCard extends LitElement {
|
||||
* Bearer header or an `authSig` signed path, and an element sends neither.
|
||||
* So the card asks the backend to sign what it is about to display.
|
||||
*/
|
||||
private _signed: Record<string, string> = {};
|
||||
private _signed: Record<string, { url: string; at: number }> = {};
|
||||
private _signPending = new Set<string>();
|
||||
private _signTimer?: number;
|
||||
|
||||
/** Display url: the signed variant when we have one, else the plain path. */
|
||||
/** Display url: a signature we hold and still trust, else nothing. */
|
||||
private _display(url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
if (this._signed[u]) return this._signed[u];
|
||||
const hit = this._signed[u];
|
||||
const age = hit ? Date.now() - hit.at : Infinity;
|
||||
// Past its lifetime the signature is worthless: serving it would 401 and
|
||||
// raise a failed-login warning, exactly what an unsigned path does.
|
||||
if (age >= SIGN_TTL_MS) delete this._signed[u];
|
||||
else if (age < SIGN_REFRESH_MS) return hit.url;
|
||||
else {
|
||||
// aging but still valid: keep showing it while a fresh one is fetched
|
||||
this._requestSignature(u);
|
||||
return hit.url;
|
||||
}
|
||||
this._requestSignature(u);
|
||||
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
||||
// returns 401 and Home Assistant raises a "failed login attempt" for the
|
||||
@@ -812,35 +823,50 @@ class HouseplanCard extends LitElement {
|
||||
this._signTimer = window.setTimeout(() => {
|
||||
const paths = [...this._signPending];
|
||||
this._signPending.clear();
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
this._signed = { ...this._signed, ...r.urls };
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined); // unsigned urls simply keep failing; no loop
|
||||
this._signBatches(paths);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign everything periodically: a wall tablet outlives a signature.
|
||||
* Sign in batches the backend will actually answer in full. It caps a request
|
||||
* at MAX_SIGN_PATHS and drops the rest without saying so, so one oversized
|
||||
* call leaves entries that never get refreshed and silently expire (R2-2).
|
||||
*/
|
||||
private _signBatches(paths: string[]): void {
|
||||
if (!paths.length || !this.hass?.callWS) return;
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
const now = Date.now();
|
||||
const next = { ...this._signed };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at: now };
|
||||
this._signed = next;
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined); // unsigned urls simply keep failing; no loop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign periodically: a wall tablet outlives a signature.
|
||||
* The old urls are kept until the new ones arrive — dropping them first would
|
||||
* blank the plan for a round trip (and, if the socket is down, until it heals).
|
||||
*/
|
||||
private _resignTimer?: number;
|
||||
|
||||
private _resign(): void {
|
||||
const paths = Object.keys(this._signed);
|
||||
if (!paths.length || !this.hass?.callWS) return;
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
this._signed = { ...this._signed, ...r.urls };
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
// prune first: an entry for a plan that was replaced months ago must not
|
||||
// consume a slot in the (capped) signing request
|
||||
const live = referencedContentUrls(this._serverCfg);
|
||||
const now = Date.now();
|
||||
const kept: Record<string, { url: string; at: number }> = {};
|
||||
for (const [k, v] of Object.entries(this._signed)) {
|
||||
if (live.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this._signed = kept;
|
||||
this._signBatches(Object.keys(kept));
|
||||
}
|
||||
private _dirtyPos = new Set<string>();
|
||||
|
||||
@@ -3032,6 +3058,10 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
||||
await this._saveConfigNow();
|
||||
// Only now is the new file authoritative: the config write was accepted,
|
||||
// so the previous plan can go. Before this point nothing on disk was
|
||||
// touched, which is what makes a rejected save harmless (review R2-1).
|
||||
if (uploaded) this._cleanupPlanFiles(spaceId, uploaded.url);
|
||||
this._spaceDialog = null;
|
||||
if (d.mode === 'create') this._space = sp.id;
|
||||
this._regSignature = '';
|
||||
@@ -3108,6 +3138,19 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove plan files superseded by `keepUrl`. Fire-and-forget on purpose: the
|
||||
* user's edit is already saved, and a failed cleanup only leaves a stray file
|
||||
* that the next successful upload collects (review R2-1).
|
||||
*/
|
||||
private _cleanupPlanFiles(spaceId: string, keepUrl: string): void {
|
||||
const keep = keepUrl.split('?')[0].split('/').pop();
|
||||
if (!keep || !this.hass?.callWS) return;
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/plan/cleanup', space_id: spaceId, keep })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// ================= FLOORS IMPORT WIZARD =================
|
||||
|
||||
private _startImport(): void {
|
||||
@@ -3881,14 +3924,31 @@ class HouseplanCard extends LitElement {
|
||||
const src = r.settings?.temp_source;
|
||||
if (src) return sourceValue(this.hass, src, 'temp');
|
||||
// every sensor of the area, placed on the plan or not (field report)
|
||||
return r.area ? areaClimate(this.hass, r.area, 'temp', this._iconRules) : null;
|
||||
return r.area ? this._climate().get(r.area)?.temp ?? null : 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 ? areaClimate(this.hass, r.area, 'hum', this._iconRules) : null;
|
||||
return r.area ? this._climate().get(r.area)?.hum ?? null : null;
|
||||
}
|
||||
|
||||
private _climateCache: { h: any; r: any; m: Map<string, AreaClimate> } | null = null;
|
||||
|
||||
/**
|
||||
* Climate for every area, computed ONCE per hass snapshot (review R2-3).
|
||||
* Home Assistant hands out a new `hass` object on every state change, so
|
||||
* identity is exactly the right cache key: fresh states always recompute,
|
||||
* and the 60 rooms of one render share a single registry pass instead of
|
||||
* triggering one each (two, with humidity on).
|
||||
*/
|
||||
private _climate(): Map<string, AreaClimate> {
|
||||
const c = this._climateCache;
|
||||
if (c && c.h === this.hass && c.r === this._iconRules) return c.m;
|
||||
const m = areaClimateMap(this.hass, this._iconRules);
|
||||
this._climateCache = { h: this.hass, r: this._iconRules, m };
|
||||
return m;
|
||||
}
|
||||
|
||||
private _resetRoomDialogFields(): void {
|
||||
|
||||
@@ -1036,6 +1036,48 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
|
||||
* authenticated content endpoint (audit B1). Applied on READ, so stored
|
||||
* configs keep working without a migration.
|
||||
*/
|
||||
/**
|
||||
* How many paths one `houseplan/content/sign` call may carry. The backend caps
|
||||
* the request at the same number and silently ignores the rest, so a client
|
||||
* that sends more gets a partial answer with no way to tell which paths were
|
||||
* dropped — on a wall tablet those entries then expire for good (review R2-2).
|
||||
* Keep in sync with MAX_SIGN_PATHS in custom_components/houseplan/const.py.
|
||||
*/
|
||||
export const MAX_SIGN_PATHS = 200;
|
||||
|
||||
/** A signature is valid for 24 h; refresh once two thirds of it is gone. */
|
||||
export const SIGN_TTL_MS = 24 * 3600 * 1000;
|
||||
export const SIGN_REFRESH_MS = 16 * 3600 * 1000;
|
||||
|
||||
/** Split a list into chunks of at most `size` (used for signing batches). */
|
||||
export function chunk<T>(items: T[], size: number): T[][] {
|
||||
const n = Math.max(1, Math.floor(size));
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += n) out.push(items.slice(i, i + n));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every content url the given config still refers to, normalised through
|
||||
* `contentUrl`. The signature cache is pruned to this set: without it the cache
|
||||
* only grows — replaced plans and deleted attachments keep their entries, and
|
||||
* the total can cross the per-request cap even when the live config is small.
|
||||
*/
|
||||
export function referencedContentUrls(cfg: any): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const add = (u: unknown) => {
|
||||
if (typeof u !== 'string' || !u) return;
|
||||
const c = contentUrl(u);
|
||||
if (c.startsWith('/api/houseplan/content/')) out.add(c);
|
||||
};
|
||||
for (const sp of cfg?.spaces || []) {
|
||||
add(sp?.plan_url);
|
||||
for (const m of sp?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
|
||||
}
|
||||
for (const m of cfg?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function contentUrl(url: string | null | undefined): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('/houseplan_files/plans/')) {
|
||||
|
||||
Reference in New Issue
Block a user