mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.44.7: plan backgrounds never displayed (regression from v1.44.5)
The card signs content urls because a browser cannot authenticate an <image href>. But _display() was called inside _buildModel(), and the space model is memoized on the config fingerprint — so the UNSIGNED url froze in the cache and the signature, which did arrive, never reached the element. The plan never loaded, and the browser kept hitting the unsigned path: 401, which Home Assistant reports as a failed login attempt from the viewer's own IP (that is how the owner spotted it). PDF links were unaffected: they already resolved at render time. - _buildModel() keeps the raw plan_url; the render pass calls _display(). - _display() returns '' for an unsigned content url instead of the plain path, and the <image> is not emitted at all until the signature lands — no 401, no spurious login-attempt warning. - _resign() replaces 'drop everything and re-request': the previous urls are kept until the new ones arrive, so a wall tablet never blanks. - demo/smoke_plan_signed.mjs: reproduces on v1.44.6 (href stays ?v=..., never ?authSig=), passes here. TESTING.md row added. - docs: CHANGELOG.md + CHANGELOG.ru.md + STATUS.md.
This commit is contained in:
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.44.6"
|
||||
VERSION = "1.44.7"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.44.6"
|
||||
"version": "1.44.7"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Подложка (фон плана) лежит за requires_auth-эндпоинтом: браузер не умеет
|
||||
// авторизовать <image href>, поэтому карточка просит бэкенд подписать путь.
|
||||
// Регрессия 2026-07-27: _display() вызывался внутри _buildModel(), а модель
|
||||
// мемоизируется по отпечатку конфига — неподписанный url «замерзал» в кэше,
|
||||
// подпись до <image> не доезжала. План не отображался никогда, а браузер
|
||||
// продолжал дёргать неподписанный путь → 401 → HA писал «неудачный вход»
|
||||
// с собственного IP пользователя.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const bgHref = () => {
|
||||
const im = sr().querySelector('.stage svg image');
|
||||
return im ? im.getAttribute('href') : null;
|
||||
};
|
||||
|
||||
let signCalls = 0;
|
||||
let release;
|
||||
const gate = new Promise((r) => { release = r; });
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
signCalls++;
|
||||
const n = signCalls;
|
||||
if (n === 1) await gate;
|
||||
const urls = {};
|
||||
for (const p of m.paths) urls[p] = p.split('?')[0] + '?authSig=SIG' + n;
|
||||
return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, plan_url: '/api/houseplan/content/plans/_/f1.svg?v=17831509',
|
||||
})};
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// до подписи ничего не рисуем: неподписанный запрос вернул бы 401
|
||||
out.hrefBeforeSign = bgHref();
|
||||
|
||||
release();
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
await c.updateComplete;
|
||||
|
||||
// подпись доехала до атрибута, а не осела в кэше модели
|
||||
out.signRequested = signCalls;
|
||||
out.hrefSigned = bgHref();
|
||||
|
||||
// перерисовка по состоянию HA не теряет подпись и не просит её заново
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.hrefAfterRerender = bgHref();
|
||||
out.signRequestedAfterRerender = signCalls;
|
||||
|
||||
// ре-подпись на долгоживущем экране: старый url держится до нового ответа
|
||||
const before = bgHref();
|
||||
c._resign();
|
||||
out.resignKeepsPlan = bgHref() === before;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
out.hrefAfterResign = bgHref();
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.7 и сверено с кодом
|
||||
checkAll(res, {
|
||||
hrefBeforeSign: null,
|
||||
signRequested: 1,
|
||||
hrefSigned: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
|
||||
hrefAfterRerender: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
|
||||
signRequestedAfterRerender: 1,
|
||||
resignKeepsPlan: true,
|
||||
hrefAfterResign: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG2',
|
||||
});
|
||||
await finish(browser);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v1.44.7 — 2026-07-27
|
||||
- **Plan backgrounds are visible again (regression from v1.44.5).** Since the
|
||||
content endpoint requires authentication, the card asks the backend to sign
|
||||
the plan's url — but the signing happened inside the *memoized* space model,
|
||||
which is cached on the config fingerprint. The unsigned url froze in that
|
||||
cache, so the signature never reached the `<image>` element and the plan never
|
||||
loaded. The url is now resolved at render time, outside the cache. (PDF links
|
||||
were unaffected — they already resolved at render time.)
|
||||
- **No more "failed login attempt" from your own IP.** While the plan was
|
||||
broken the browser kept requesting the unsigned path, which returns 401 and
|
||||
makes Home Assistant raise a login-attempt warning for the viewer's own
|
||||
address. The card now renders nothing until the signature is in hand, so an
|
||||
unsigned request is never made.
|
||||
- **Long-lived screens no longer blink.** The 12-hour re-signing used to drop
|
||||
every signature and wait for new ones; it now keeps the current urls until the
|
||||
replacements arrive, so a wall tablet never shows an empty plan.
|
||||
- Regression test `demo/smoke_plan_signed.mjs` fails on v1.44.6 and passes here.
|
||||
|
||||
## v1.44.6 — 2026-07-27
|
||||
- **Only room *air* counts as room climate.** After v1.44.5 started reading the
|
||||
area registry instead of the visible icons, every hidden temperature entity in
|
||||
|
||||
@@ -6,6 +6,25 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.44.7 — 2026-07-27
|
||||
- **Подложки снова отображаются (регрессия с v1.44.5).** Эндпоинт с файлами
|
||||
требует авторизации, поэтому карточка просит бэкенд подписать ссылку на план —
|
||||
но подпись подставлялась внутри *мемоизированной* модели пространства, а она
|
||||
кэшируется по отпечатку конфига. Неподписанная ссылка «замерзала» в кэше,
|
||||
подпись до элемента `<image>` не доезжала, и план не грузился никогда. Теперь
|
||||
ссылка вычисляется в момент отрисовки, вне кэша. (Ссылки на PDF не страдали —
|
||||
там она и так вычислялась при отрисовке.)
|
||||
- **Больше нет «неудачной попытки входа» с собственного IP.** Пока подложка была
|
||||
сломана, браузер продолжал дёргать неподписанный путь, тот отвечал 401, и
|
||||
Home Assistant поднимал предупреждение о неудачном входе с адреса самого
|
||||
зрителя. Теперь до получения подписи не рисуется ничего, и неподписанный
|
||||
запрос не уходит вовсе.
|
||||
- **Долгоживущие экраны не моргают.** Переподписывание раз в 12 часов раньше
|
||||
сбрасывало все подписи и ждало новые; теперь текущие ссылки держатся до
|
||||
прихода замены, так что настенный планшет не показывает пустой план.
|
||||
- Регрессионный тест `demo/smoke_plan_signed.mjs` падает на v1.44.6 и проходит
|
||||
здесь.
|
||||
|
||||
## v1.44.6 — 2026-07-27
|
||||
- **Климатом комнаты считается только температура *воздуха*.** После v1.44.5,
|
||||
когда данные стали браться из реестра зон, а не с видимых значков,
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.44.6** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.44.7** 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` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). 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.44.6** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.44.7** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
|
||||
| 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 |
|
||||
|
||||
@@ -234,6 +234,12 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
are only reachable through /api/houseplan/content/… with a session; the
|
||||
old /houseplan_files/plans|files paths return 404 after a restart; old
|
||||
stored URLs keep working (rewritten on read) [auto+manual]
|
||||
- [ ] Signed plan background (v1.44.7): a space whose plan lives on the content
|
||||
endpoint renders its background image with an `authSig` query — the plan is
|
||||
visible after a plain page load, and Home Assistant logs NO failed-login
|
||||
attempt from the viewer's own IP. Nothing is requested before the signature
|
||||
arrives; a 12 h re-sign keeps the previous url until the new one lands
|
||||
[auto: smoke_plan_signed]
|
||||
- [ ] Dialog zombies (v1.43.0, audit L3): close a dialog (Esc) while its save is
|
||||
in flight and let the save fail — the dialog stays closed, the card keeps
|
||||
rendering, the error toast still fires [auto: unit: logic.test + manual]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.44.6",
|
||||
"version": "1.44.7",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+31
-10
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.44.6';
|
||||
const CARD_VERSION = '1.44.7';
|
||||
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';
|
||||
@@ -366,10 +366,7 @@ class HouseplanCard extends LitElement {
|
||||
window.addEventListener('keydown', this._keyHandler);
|
||||
// signatures expire (24 h); refresh well before that on long-lived screens
|
||||
clearInterval(this._resignTimer);
|
||||
this._resignTimer = window.setInterval(() => {
|
||||
this._signed = {};
|
||||
this.requestUpdate();
|
||||
}, 12 * 3600 * 1000);
|
||||
this._resignTimer = window.setInterval(() => this._resign(), 12 * 3600 * 1000);
|
||||
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
|
||||
clearInterval(this._cycleTimer);
|
||||
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
||||
@@ -596,7 +593,11 @@ class HouseplanCard extends LitElement {
|
||||
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: this._display(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
// raw url on purpose: the model is memoized on the config fingerprint,
|
||||
// so a signed url baked in here would freeze BEFORE the signature
|
||||
// arrives and the plan would never load (bug found 2026-07-27).
|
||||
// _display() is called at render time instead.
|
||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
rooms: s.rooms.map(scale),
|
||||
};
|
||||
});
|
||||
@@ -797,7 +798,10 @@ class HouseplanCard extends LitElement {
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
if (this._signed[u]) return this._signed[u];
|
||||
this._requestSignature(u);
|
||||
return u; // first paint may 401; the signature lands and re-renders
|
||||
// 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
|
||||
// viewer's own IP. Callers skip rendering until the signature lands.
|
||||
return '';
|
||||
}
|
||||
|
||||
private _requestSignature(url: string): void {
|
||||
@@ -819,8 +823,25 @@ class HouseplanCard extends LitElement {
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/** Re-sign everything periodically: a wall tablet outlives a signature. */
|
||||
/**
|
||||
* Re-sign everything 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);
|
||||
}
|
||||
private _dirtyPos = new Set<string>();
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
@@ -3603,8 +3624,8 @@ class HouseplanCard extends LitElement {
|
||||
${this._editing && !this._markup
|
||||
? svg`<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>`
|
||||
: nothing}
|
||||
${space.bg
|
||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
${space.bg && this._display(space.bg.href)
|
||||
? svg`<image href="${this._display(space.bg.href)}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${this._renderDecorLayer()}
|
||||
${(() => {
|
||||
|
||||
Reference in New Issue
Block a user