mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
HP-1454-01 (high, release blocker): an uploaded SVG plan opened directly is a top-level document of Home Assistant's own origin, so a <script> inside it reaches the session's localStorage and API. Uploading needs write access, which by default every authenticated user has. SVG responses now carry a sandbox CSP; only SVG, because a CSP on a PDF can break the browser's viewer and a raster image has nothing to disable. Verified in Chromium both ways: the script runs without the header and does not with it. HP-1454-02: attachment uploads wrote straight to <marker>/<filename>, outside the config transaction — a cancelled dialog or a rejected save left the stored url serving new bytes, and every new icon shared one 'new' folder, so two of them attaching manual.pdf pointed at one file. Uploads take a free name, a new icon gets a per-dialog staging folder promoted on an accepted save, and config/set collects superseded and aged-orphan attachments like it does plans. HP-1454-03: the debounce spaced out the starts of a write, not the writes. A save slower than 500 ms let the next edit go out with the same expected_rev; the server accepted the first, rejected the second, and the conflict handler reloaded over the local copy. Writes are chained now — one in flight, each with the revision the previous returned. HP-1454-04: _openPairsCache keyed on room ids and links only, so an aspect change or a dragged vertex left open boundaries and their glow cuts at old coordinates. It keys on the rendered model object now — the same invalidation the model cache already has, not a second strategy. The fingerprint also gained an O(1) geometry roll-up per room. HP-1454-05: outer collections were capped, inner ones were not. Limits for poly points, open_to, controls, pdfs, text and url lengths, plus a total serialized size cap; legacy is dropped server-side. HP-1454-06: upload streams to a temp file and downloads use FileResponse, so a 50 MB manual no longer costs ~100 MB of RSS per transfer. HP-1454-07: spaceModels() dropped room.settings, so the static card ignored the per-room fill override. HP-1454-08: layout had no revision on point-wise writes and no event, leaving static cards stale forever; it now keeps a revision, returns it and fires houseplan_layout_updated. HP-1454-09: repair cleanup only walked existing spaces, so a deleted space kept its warning. HP-1454-10: serialize-javascript pinned past two advisories. Tests: smoke_svg_sandbox (proves both directions), smoke_config_writer and smoke_render_parity (both verified failing against a v1.45.4 build), six pure tests for attachment collection and inner limits, four HA-harness tests for the CSP, non-overwriting uploads, the size cap and layout revisions. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
144 lines
6.1 KiB
TypeScript
144 lines
6.1 KiB
TypeScript
/**
|
|
* Shared STATIC renderer for a single houseplan space — used by the read-only
|
|
* `houseplan-space-card`. Draws exactly what is CONFIGURED (plan background,
|
|
* configured room borders/names, device markers at their saved positions), with NO interactivity
|
|
* (pointer-events:none) and NO state subscription; room fills are rendered exactly as
|
|
* configured on the full card (a snapshot of the current states passed via `hass`).
|
|
* Geometry/model math lives in space-geometry.ts (pure, unit-tested).
|
|
*/
|
|
import { html, svg, nothing, type TemplateResult } from 'lit';
|
|
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
|
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf } from './logic';
|
|
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
|
import { t, type Lang } from './i18n';
|
|
import type { ServerConfig } from './types';
|
|
import {
|
|
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
|
|
} from './space-geometry';
|
|
|
|
export { spaceModels } from './space-geometry';
|
|
|
|
export interface StaticRenderOpts {
|
|
hass: any;
|
|
cfg: ServerConfig;
|
|
layout: Layout;
|
|
spaceId: string;
|
|
iconSize?: number;
|
|
lang: Lang;
|
|
/**
|
|
* Resolve a stored content url to what the DOM may actually request — the
|
|
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
|
|
* Returning '' means "not signed yet": the caller must render no <image>
|
|
* rather than an unsigned one, which would 401 (review R3-2).
|
|
*/
|
|
displayUrl?: (raw: string) => string;
|
|
}
|
|
|
|
/**
|
|
* Static schematic of one space. Returns the inner stage template (svg + marker
|
|
* layer) or null when the space id is unknown (the caller renders an error card).
|
|
*/
|
|
export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
|
const models = spaceModels(o.cfg);
|
|
const space = models.find((s) => s.id === o.spaceId);
|
|
if (!space) return null;
|
|
const vb = space.vb;
|
|
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
|
|
const cfgSize = o.iconSize ?? 2.5;
|
|
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
|
|
|
const areaToSpace: Record<string, string> = {};
|
|
for (const s of o.cfg.spaces || []) for (const r of (s as any).rooms || []) if (r.area) areaToSpace[r.area] = (s as any).id;
|
|
const excluded = o.cfg.settings?.exclude_integrations ? new Set(o.cfg.settings.exclude_integrations) : EXCLUDED_DOMAINS;
|
|
const iconRules = compileIconRules(
|
|
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
|
|
);
|
|
const loc = (k: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => t(o.lang, k);
|
|
const all = buildDevices({
|
|
hass: o.hass,
|
|
areaToSpace,
|
|
markers: o.cfg.markers || [],
|
|
settings: o.cfg.settings || {},
|
|
excluded,
|
|
showAll: !!o.cfg.settings?.show_all,
|
|
firstSpaceId: models[0]?.id || '',
|
|
loc,
|
|
iconRules,
|
|
});
|
|
const devs = all.filter((d) => d.space === o.spaceId);
|
|
const defPos = defaultPositions(devs, space, iconPct);
|
|
|
|
const roomShapes = space.rooms
|
|
.filter((r) => r.area || disp.showBorders)
|
|
.map((r) => {
|
|
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
|
|
let style = '';
|
|
// tier 3 wins over the space, exactly as on the full card (HP-1454-07)
|
|
const fill = roomFillModeOf(disp.fill, r);
|
|
if (disp.showBorders || fill !== 'none') {
|
|
cls += ' styled';
|
|
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
|
|
// fill rendered exactly as configured on the full card (snapshot of current states)
|
|
const fillC = r.area
|
|
? roomFillStyle(
|
|
fill,
|
|
fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
|
fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
|
fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
|
disp.tempMin,
|
|
disp.tempMax,
|
|
fillColorsOf(o.cfg?.settings),
|
|
)
|
|
: null;
|
|
if (fillC) {
|
|
cls += ' filled';
|
|
parts.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
|
|
} else {
|
|
parts.push('--room-fill:transparent', '--room-fill-op:0');
|
|
}
|
|
style = parts.join(';');
|
|
}
|
|
const svgLabel = !space.bg && !disp.showNames;
|
|
const c = roomCenter(r);
|
|
const shape = r.poly
|
|
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"></polygon>`
|
|
: svg`<rect class="${cls}" style="${style}" x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"></rect>`;
|
|
return svg`${shape}${svgLabel ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
|
});
|
|
|
|
const markers = devs.map((d) => {
|
|
const p = markerPos(d, o.layout, o.cfg, defPos, space);
|
|
const left = ((p.x - vb[0]) / vb[2]) * 100;
|
|
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
|
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="left:${left}%;top:${top}%">
|
|
<ha-icon icon="${d.icon}"></ha-icon>
|
|
</div>`;
|
|
});
|
|
|
|
const labels = disp.showNames
|
|
? space.rooms
|
|
.filter((r) => r.name)
|
|
.map((r) => {
|
|
const p = labelPos(r, space.id, o.layout, o.cfg);
|
|
const left = ((p.x - vb[0]) / vb[2]) * 100;
|
|
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
|
const op = Math.min(1, disp.opacity + 0.25);
|
|
return html`<div class="roomlabel" style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op}">${r.name}</div>`;
|
|
})
|
|
: [];
|
|
|
|
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
|
|
|
|
return html`
|
|
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
|
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
|
${bgHref
|
|
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
|
|
: nothing}
|
|
${roomShapes}
|
|
</svg>
|
|
<div class="devlayer" style="--icon-size:${iconPct}cqw">${markers}${labels}</div>
|
|
</div>
|
|
`;
|
|
}
|