This commit is contained in:
Matysh
2026-07-29 08:18:47 +03:00
17 changed files with 267 additions and 80 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.50.2"
VERSION = "1.50.3"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.50.2"
"version": "1.50.3"
}
+18 -3
View File
@@ -114,6 +114,21 @@ LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT
# the exact failure HP-1500-03 closed for layout positions, one schema over.
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
# client. The floor is one thousandth of the canvas (1 render unit): far below
# any real room, but keeps the maths finite.
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
def _view_box(value):
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
if not isinstance(value, (list, tuple)) or len(value) != 4:
raise vol.Invalid("view_box must be [x, y, w, h]")
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
@@ -145,8 +160,8 @@ ROOM_SCHEMA = vol.All(
),
vol.Optional("x"): _GEOM,
vol.Optional("y"): _GEOM,
vol.Optional("w"): _GEOM,
vol.Optional("h"): _GEOM,
vol.Optional("w"): _EXTENT,
vol.Optional("h"): _EXTENT,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
},
extra=vol.ALLOW_EXTRA,
@@ -210,7 +225,7 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
vol.Required("view_box"): vol.All([_GEOM], vol.Length(min=4, max=4)),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
vol.Optional("openings"): vol.All([
File diff suppressed because one or more lines are too long
+33 -19
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## v1.50.3 — 2026-07-29
**From the v1.50.2 review**
- **A size is not a coordinate (HP-1502-01).** The ±4 bound from v1.50.2
treated all four view_box elements and room w/h alike, so `[0, 0, 0, 0]`
and negative sizes still passed — and a zero axis serialises into
`viewBox="0 0 0 0"`, a blank plan on every client, with the static card
computing `aspect-ratio: 0 / 0` on top. Sizes now get their own validator:
strictly positive, floored at one thousandth of the canvas; coordinates may
still be negative, because a crop origin legitimately sits past the edge.
And since a store may already hold a broken viewport from before, both
cards fall back to the whole canvas instead of a blank screen, and a legacy
rectangle with a negative size is read as the same rectangle drawn from the
other corner.
**Also in this release**
- The room settings button moved to the bottom of the room card, and the room
name renders in exactly the same spot in view mode and in the plan editor —
the button and the metrics no longer take part in the label's centring.
## v1.50.2 — 2026-07-29
**From the v1.50.1 review**
+21
View File
@@ -6,6 +6,27 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.50.3 — 2026-07-29
**По ревью v1.50.2**
- **Размер — не координата (HP-1502-01).** Граница ±4 из v1.50.2 мерила все
четыре элемента view_box и w/h комнат одинаково, поэтому `[0, 0, 0, 0]` и
отрицательные размеры всё ещё проходили — а нулевая ось сериализуется в
`viewBox="0 0 0 0"`: пустой план у всех клиентов, и `aspect-ratio: 0 / 0` у
статичной карточки сверху. У размеров теперь свой validator: строго
положительные, пол — одна тысячная холста; координаты по-прежнему могут
быть отрицательными — начало кропа законно выходит за край. А поскольку в
сторе битый viewport может уже лежать, обе карточки падают на полный холст
вместо пустого экрана, и legacy-прямоугольник с отрицательным размером
читается как тот же прямоугольник, нарисованный из другого угла.
**Также в этом релизе**
- Кнопка настроек комнаты переехала в самый низ карточки комнаты, а имя
комнаты отображается ровно в одном и том же месте в просмотре и в
редакторе плана — кнопка и метрики больше не участвуют в центрировании.
## v1.50.2 — 2026-07-29
**По ревью v1.50.1**
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.50.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.50.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.2** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.3** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
+7
View File
@@ -239,6 +239,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Sizes are positive (v1.50.3, HP-1502-01): view_box or room w/h of zero
or below is refused; a store that already holds one opens on the full
canvas, not a blank screen [auto: test_sizes_are_not_coordinates + unit
safeViewBox fallback]
- [ ] Room card layout (v1.50.3): the settings button is the bottom row of the
card and the room name sits in the same spot in view and plan modes
[manual; verified by vb-coordinate measurement]
- [ ] Geometry bounds (v1.50.2, HP-1501-01): a config with a 1e100 room
vertex is refused by the server; one already stored still renders with a
sane frame [auto: test_geometry_magnitudes_are_bounded + unit
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.50.2",
"version": "1.50.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.50.2",
"version": "1.50.3",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.50.2",
"version": "1.50.3",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+10 -9
View File
@@ -36,7 +36,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.50.2';
const CARD_VERSION = '1.50.3';
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';
@@ -4469,20 +4469,21 @@ class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)}
@pointercancel=${() => this._labelUp(r)}
>${this._markup && r.id
? html`<button class="rlgearbtn" title=${this._t('room.settings_title')}
@pointerdown=${(e: Event) => e.stopPropagation()}
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
<span class="rlgeartext">${this._t('room.settings_short')}</span>
</button>`
: nothing}<span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
><span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
title=${this._t('room.open_area')}
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
@pointerdown=${(e: Event) => e.stopPropagation()}></ha-icon>`
: nothing}</span>
${rows.length ? html`<span class="rlmetrics">${rows}</span>` : nothing}
${this._markup && r.id
? html`<button class="rlgearbtn" title=${this._t('room.settings_title')}
@pointerdown=${(e: Event) => e.stopPropagation()}
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
<span class="rlgeartext">${this._t('room.settings_short')}</span>
</button>`
: nothing}
${this._mode === 'plan'
? ['tl', 'tr', 'bl', 'br'].map(
(c) => html`<span class="rlhandle ${c}"
+30 -3
View File
@@ -28,11 +28,37 @@ export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
/** Build render-space models (NORM_W × NORM_W) from a server config. */
/** A stored view_box the render can trust: 4 finite numbers, positive sizes.
* The server refuses anything else NOW (HP-1502-01), but a store may already
* hold [0,0,0,0] or negative sizes from before — and a zero axis serialises
* into viewBox="0 0 0 0", which draws nothing on every client. Bad input
* falls back to the whole canvas rather than to a blank screen. */
function safeViewBox(vb: any): [number, number, number, number] {
if (
Array.isArray(vb) && vb.length === 4 && vb.every((n: any) => Number.isFinite(n))
&& vb[2] > 1e-6 && vb[3] > 1e-6
) return vb as [number, number, number, number];
return [0, 0, 1, 1];
}
/** Legacy rectangle rooms, normalised: a negative size is the same rectangle
* drawn from the other corner; the maths downstream assumes w/h >= 0. */
function normRect(r: any): { x?: number; y?: number; w?: number; h?: number } {
if (r.x == null || r.y == null) return { x: r.x, y: r.y, w: r.w, h: r.h };
const w = Number(r.w) || 0, h = Number(r.h) || 0;
return {
x: w < 0 ? r.x + w : r.x,
y: h < 0 ? r.y + h : r.y,
w: Math.abs(w),
h: Math.abs(h),
};
}
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
if (!cfg || !Array.isArray(cfg.spaces)) return [];
return cfg.spaces.map((s: any) => {
const H = NORM_W; // square canvas
const scale = (r: any): RoomCfg => ({
const scale = (raw: any): RoomCfg => { const r = { ...raw, ...normRect(raw) }; return {
id: r.id,
name: r.name,
area: r.area ?? null,
@@ -46,11 +72,12 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
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,
});
}; };
const vb = safeViewBox(s.view_box);
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],
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
+14 -1
View File
@@ -398,11 +398,19 @@ export const cardStyles = css`
@media (prefers-reduced-motion: reduce) {
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
}
/* The name is the anchor: the label box is centred on the room point, so
anything that takes part in its layout SHIFTS THE NAME. The gear button
and the metrics hang below as absolutes — the name renders in exactly
the same place in view mode and in the plan editor (owner's request),
and the button sits at the very bottom of the card. */
.rlgearbtn {
position: absolute;
top: calc(100% + 0.35em);
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
padding: 3px 8px;
border: 0;
border-radius: 999px;
@@ -440,6 +448,11 @@ export const cardStyles = css`
}
.stage.mode-view .rlgo:hover { opacity: 1; }
.roomlabel .rlmetrics {
position: absolute; /* below the name, outside the centring math */
top: calc(100% + 0.15em);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.55em;
+18
View File
@@ -165,3 +165,21 @@ test('contentBounds: never degenerate, never unbounded (HP-1500-03)', () => {
}], markers: [] })[0];
assert.equal(contentBounds(allBad), null, 'the caller keeps the full canvas');
});
test('spaceModels: a stored broken view_box falls back to the canvas (HP-1502-01)', () => {
// the server refuses these now, but a store may already hold one
for (const vb of [[0, 0, 0, 0], [0, 0, -1, -2], [0, 0, 1], null, [0, 0, NaN, 1]]) {
const m = spaceModels({ spaces: [{ id: 's', view_box: vb, rooms: [] }], markers: [] })[0];
assert.deepEqual(m.vb, [0, 0, 1000, 1000], JSON.stringify(vb) + ' falls back');
}
// a legitimate crop viewport is preserved
const crop = spaceModels({ spaces: [{ id: 's', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }], markers: [] })[0];
assert.deepEqual(crop.vb, [100, 200, 500, 400]);
// a legacy rect with a negative size is the same rectangle from the other corner
const m = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }],
}], markers: [] })[0];
const r = m.rooms[0];
assert.deepEqual([r.x, r.y, r.w, r.h].map(Math.round), [400, 400, 200, 300]);
});
+21
View File
@@ -236,6 +236,27 @@ def test_geometry_magnitudes_are_bounded():
{"id": "r", "name": "R", "poly": [[-0.2, 0], [1.3, 0], [1, 1]]}]}]})
def test_sizes_are_not_coordinates():
"""HP-1502-01: a size must be strictly positive — SVG refuses zero and
negative width/height, and the clients divide by these. view_box [0,0,0,0]
used to pass the shared validator and blank the plan on every client.
Coordinates may still be negative: a crop origin can sit past the edge."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
for cfg in (
{**base, "view_box": [0, 0, 0, 0]},
{**base, "view_box": [0, 0, -1, -2]},
{**base, "view_box": [0, 0, 1, 0.0001]}, # below the 0.001 floor
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0, "h": 0.5}]},
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0.5, "h": -1}]},
):
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [cfg]})
# negative COORDINATES stay legal, and a normal crop viewport passes
assert v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [-0.2, -0.1, 1.4, 1.2]}]})
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "x": -0.1, "y": -0.1, "w": 0.4, "h": 0.3}]}]})
def test_openings_cap_enforced():
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}