Compare commits

...
4 Commits
Author SHA1 Message Date
Matysh 68aa1f04ba Release v1.45.3
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 5s
Validate / frontend (push) Successful in 1m34s
Validate / backend (push) Failing after 6m18s
Validate / smoke (push) Failing after 10m27s
issue #3: display='value' was offered by the editor since v1.26.0 but rejected
by the schema, which blocked saving the configuration entirely. Option lists
are now shared and checked across languages.
2026-07-28 00:26:27 +03:00
Matysh 3d41fe16b8 v1.45.3: 'value instead of an icon' could never be saved (issue #3)
The device editor has offered display='value' since v1.26.0; MARKER_SCHEMA
accepted only badge/ripple/icon_ripple. Picking it produced

  not a valid value for dictionary value @ data['config']['markers'][n]['display']

and since one rejected marker fails the whole config write, the user could not
save the plan at all until the setting was undone. Reported by @RemyRoux with
the exact error text, 2026-07-27 — a year and a half after the feature shipped.

The schema now accepts it, and the class of bug is closed rather than the
instance: DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES and ROOM_FILL_MODES are
exported from src/logic.ts, the editors render their options from them, and a
backend test parses those lists out of the TypeScript source and asserts the
schema accepts every one (and rejects a bogus value). Reverting the one-word
schema fix fails that test, which is the check that was missing.

Plus an HA-harness test saving a config that contains a value-display marker —
the exact call the user's card was making.
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 00:23:37 +03:00
Matysh ac734688d4 Release v1.45.2
Hardening from the v1.45.1 review: R4-1 a failed cleanup no longer reports an
accepted save as an error, R4-2 one signing request per url instead of one per
render.
2026-07-28 00:16:34 +03:00
Matysh 2e2d353b04 v1.45.2: hardening from the v1.45.1 review — R4-1, R4-2
R4-1: collecting superseded plan files runs after the configuration is already
durable, but an error listing the directory propagated out of config/set. The
client saw a failure for a revision the server had committed, and its retry
came back as a conflict. collect_plans now reports 0 instead of raising, and
config/set logs and proceeds — the event fires, the revision is returned.

R4-2: the pending set was cleared when a batch went out, not when it came back,
so every render during an in-flight content/sign queued another request: six
calls where one was needed, and unbounded on a socket that is slow rather than
busy. Queued and in-flight are separate states now; a failure backs off (2 s
doubling to 60 s) instead of retrying on the next frame; an in-flight entry
expires after 15 s so a promise that never settles cannot wedge retries; a late
answer after dispose() no longer renders.

Tests: test/signing.test.mjs — eight cases with hand-settled promises, verified
against a v1.45.1 checkout where four of them fail (2 sign calls instead of 1,
no backoff, a late answer rendering after teardown). Backend: a broken
collector still yields a successful save whose revision the next CAS accepts.
Pure collector: a disappearing directory returns 0.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-28 00:13:45 +03:00
23 changed files with 648 additions and 128 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
PLAN_ORPHAN_TTL_S = 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.45.1"
VERSION = "1.45.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.45.1"
"version": "1.45.3"
}
+12 -3
View File
@@ -63,14 +63,23 @@ def collect_plans(
* any other unreferenced plan file is a rejected or abandoned upload, and
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
belong to a transaction that has not committed yet.
Never raises: the configuration is already stored by the time this runs, so
a file-system problem must not turn a durable commit into a failed call.
"""
if not plans_dir.is_dir():
return 0
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
removed = 0
for item in sorted(plans_dir.iterdir()):
try:
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
except OSError as err:
# The directory can vanish or turn unreadable between the check and the
# walk. This is housekeeping running behind a commit that is already
# durable, so it reports "nothing collected" instead of failing (R4-1).
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
return 0
for item in items:
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
superseded = item.name in old_refs
+3 -1
View File
@@ -207,7 +207,9 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
# asserts every option the editor offers is accepted here (issue #3)
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("ripple_color"): vol.Any(str, None),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
+12 -5
View File
@@ -363,11 +363,18 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
# still holding the lock: the file system is not part of the store's
# transaction, so collection has to be pinned to this commit (R3-1)
await hass.async_add_executor_job(
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
)
# Still holding the lock: the file system is not part of the store's
# transaction, so collection has to be pinned to this commit (R3-1).
# It is best-effort housekeeping behind an already durable write — a
# failure here must not withhold the event and the success response,
# or the client retries an edit the server has already accepted and
# gets a conflict for its trouble (R4-1).
try:
await hass.async_add_executor_job(
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
)
except Exception: # noqa: BLE001 — see above: the commit stands regardless
_LOGGER.exception("House Plan: collecting superseded plan files failed")
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)
+12 -4
View File
@@ -51,19 +51,26 @@ const res = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 120));
out.hrefAfterFailedSign = await href();
// 2) повтор после ошибки: pending освобождён, вторая попытка проходит
// 2) сразу повтора нет: после ошибки подпись уходит в backoff (ревью R4-2),
// иначе нестабильный сокет получал бы по запросу на каждый рендер
for (let i = 0; i < 5; i++) { card.requestUpdate(); await card.updateComplete; }
await new Promise((r) => setTimeout(r, 150));
out.noRetryStorm = signCalls === 1;
// 3) после выдержки повтор проходит
await new Promise((r) => setTimeout(r, 2100));
card.requestUpdate(); await card.updateComplete;
await new Promise((r) => setTimeout(r, 150));
out.hrefAfterRetry = await href();
out.retried = signCalls >= 2;
out.retried = signCalls === 2;
// 3) повторный рендер не теряет подпись и не просит её заново
// 4) повторный рендер не теряет подпись и не просит её заново
const before = signCalls;
card.requestUpdate(); await card.updateComplete;
out.hrefStable = await href();
out.noExtraSignOnRerender = signCalls === before;
// 4) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
const ent = card._signer.entries;
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
card.requestUpdate(); await card.updateComplete;
@@ -80,6 +87,7 @@ const res = await page.evaluate(async () => {
// зафиксировано прогоном на v1.45.1 и сверено с кодом
checkAll(res, {
hrefAfterFailedSign: null,
noRetryStorm: true,
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
retried: true,
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
File diff suppressed because one or more lines are too long
+30 -30
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -194,7 +194,7 @@ to a transaction still in flight. The `.` between id and token is load-bearing
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
with the files of a space whose name merely starts the same way.
**Signed content urls are batched and aged** (reviews R2-2, R3-2). `ContentSigner`
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
in `src/signing.ts` is the single implementation, used by both cards; the
duplicate inside houseplan-space-card signed correctly and never handed the
result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
@@ -204,6 +204,10 @@ signatures carry the time they were issued — an aging one keeps rendering whil
its replacement is fetched, an expired one is dropped rather than served (it
would 401 and raise a failed-login warning). The cache is pruned to the urls the
live config references, so it cannot grow past the cap through history alone.
Queued and in-flight are distinct states: a render happening while a request is
out must not queue the same url again, a failure backs off rather than retrying
on the next frame, and an in-flight entry expires after `SIGN_INFLIGHT_MS` so a
promise that never settles cannot block retries forever.
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
+37
View File
@@ -1,5 +1,42 @@
# Changelog
## v1.45.3 — 2026-07-27
- **"Value instead of an icon" could not be saved (issue #3).** The option was
added to the device editor in v1.26.0, but the server-side schema only ever
accepted `badge`, `ripple` and `icon_ripple`. Choosing it produced
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`
— and because a single rejected marker fails the whole configuration write,
the plan could not be saved at all until the setting was undone. Thanks to
@RemyRoux for the report and the exact error text.
- **The option lists now live in one place and are checked across languages.**
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` and `ROOM_FILL_MODES` are
exported from the card and read by a backend test that asserts the schema
accepts every value a user can actually pick. Adding an option to an editor
and forgetting the schema now fails the test suite instead of surfacing a year
later through somebody's error message.
## v1.45.2 — 2026-07-27 (hardening from the v1.45.1 review: R4-1, R4-2)
- **A failed cleanup no longer reports an accepted save as an error (R4-1).**
Collecting superseded plan files runs after the configuration is already
stored, but an error while listing the directory — it can vanish or turn
unreadable between the check and the walk — propagated out of `config/set`.
The client then saw a failure for a revision the server had committed, and its
retry came back as a conflict. The collector now reports "nothing collected"
instead of raising, and `config/set` logs and proceeds: the event fires and
the new revision is returned.
- **One signing request per url instead of one per render (R4-2).** The pending
set was cleared when the batch went out rather than when it came back, so
while a `content/sign` call was in flight every re-render queued another one —
six calls where one was needed, and far worse on a socket that is slow rather
than merely busy. Queued and in-flight are now separate states, a failure
backs off (2 s doubling to 60 s) instead of retrying on the next frame, and a
request that never settles stops blocking retries after 15 s. A late answer
arriving after the card was torn down no longer triggers a render.
- Tests: eight unit tests for the signer with hand-settled promises (four fail
on v1.45.1), a backend test asserting a broken collector still yields a
successful save with a usable revision, and the pure-collector test extended
to a disappearing directory.
## v1.45.1 — 2026-07-27 (follow-up review of v1.45.0: R3-1, R3-2)
- **Collecting old plan files moved into the config transaction (R3-1, high).**
v1.45.0 made the upload safe but handed the deletion to the client: after a
+39
View File
@@ -6,6 +6,45 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.45.3 — 2026-07-27
- **«Значение вместо иконки» невозможно было сохранить (issue #3).** Опция
появилась в редакторе устройств ещё в v1.26.0, но серверная схема всё это
время принимала только `badge`, `ripple` и `icon_ripple`. При её выборе
сохранение падало с
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`,
а поскольку один отвергнутый маркер валит всю запись конфигурации, план не
сохранялся вообще, пока настройку не отменишь. Спасибо @RemyRoux за отчёт и
точный текст ошибки.
- **Списки опций теперь в одном месте и сверяются между языками.**
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` и `ROOM_FILL_MODES`
экспортируются из карточки, и backend-тест читает их, проверяя, что схема
принимает каждое значение, которое пользователь реально может выбрать.
Теперь добавить опцию в редактор и забыть про схему — значит уронить тесты, а
не узнать об этом через полтора года из чужого сообщения об ошибке.
## v1.45.2 — 2026-07-27 (закалка по ревью v1.45.1: R4-1, R4-2)
- **Сбой уборки больше не превращает принятое сохранение в ошибку (R4-1).**
Сборка вытесненных файлов плана идёт уже после того, как конфигурация
сохранена, но ошибка при обходе каталога — он может исчезнуть или стать
недоступным между проверкой и обходом — вылетала наружу из `config/set`.
Клиент видел неудачу для ревизии, которую сервер закоммитил, а его повтор
возвращался с конфликтом. Теперь сборщик сообщает «ничего не убрано» вместо
исключения, а `config/set` пишет в лог и продолжает: событие уходит, новая
ревизия возвращается.
- **Один запрос подписи на ссылку вместо одного на рендер (R4-2).** Множество
ожидающих очищалось в момент отправки батча, а не по возвращении, поэтому
пока запрос `content/sign` был в полёте, каждая перерисовка ставила ещё
один — шесть вызовов там, где нужен один, и куда хуже на медленном (а не
просто занятом) сокете. Состояния «в очереди» и «в полёте» теперь разделены,
после ошибки включается выдержка (2 с с удвоением до 60 с) вместо повтора на
следующем кадре, а запрос, который так и не завершился, перестаёт блокировать
повторы через 15 с. Поздний ответ, пришедший после размонтирования карточки,
больше не вызывает перерисовку.
- Тесты: восемь юнит-тестов подписывателя с ручным разрешением promise (четыре
падают на v1.45.1), backend-тест на то, что сломанный сборщик оставляет
сохранение успешным с рабочей ревизией, и проверка исчезнувшего каталога в
тестах чистого сборщика.
## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.45.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.45.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` = 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.45.1** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.3** 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 |
+14
View File
@@ -234,6 +234,20 @@ 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]
- [ ] Every editor option is storable (v1.45.3, issue #3): set a sensor to
"value instead of an icon" and save — no validation error, the value shows
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]
- [ ] Signing does not amplify on a bad connection (v1.45.2, review R4-2): with
the WebSocket slow or refusing, the card issues ONE sign request per url
and backs off after a failure instead of asking again on every render; a
request that never answers stops blocking retries after 15 s
[auto: unit: signing.test + smoke_space_card_bg]
- [ ] A broken plans directory does not fail a save (v1.45.2, review R4-1): make
the plans folder unreadable and save the configuration — the save
succeeds, the revision is usable, and the next save does not conflict
[auto: backend test_a_failing_collector_does_not_undo_an_accepted_save]
- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
two tabs, attach a background in each in turn — the plan last saved is the
one served, and neither commit deletes the other's file. A rejected upload
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.45.1",
"version": "1.45.3",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+6 -5
View File
@@ -22,6 +22,7 @@ import {
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
} from './logic';
import { ContentSigner } from './signing';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
@@ -34,7 +35,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.45.1';
const CARD_VERSION = '1.45.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';
@@ -4824,7 +4825,7 @@ class HouseplanCard extends LitElement {
<label>${this._t('marker.tap_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
${[['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
${TAP_ACTIONS.map((v) => [v, 'tap.' + v.replace('-', '_')] as const).map(
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || d.defaultTap) === v}>${this._t(k as any)}</option>`,
)}
</select>
@@ -4895,7 +4896,7 @@ class HouseplanCard extends LitElement {
<label>${this._t('marker.display_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, display: (e.target as HTMLSelectElement).value as any })}>
${[['badge', 'display.badge'], ['ripple', 'display.ripple'], ['icon_ripple', 'display.icon_ripple'], ['value', 'display.value']].map(
${DISPLAY_MODES.map((v) => [v, 'display.' + v] as const).map(
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
)}
</select>
@@ -5072,7 +5073,7 @@ class HouseplanCard extends LitElement {
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
</div>
<label>${this._t('space.fill_label')}</label>
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp'], ['glow', 'fill.glow']].map(
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
([v, k]) => html`<label class="srcrow">
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
@@ -5243,7 +5244,7 @@ class HouseplanCard extends LitElement {
<label class="dispsection">${this._t('room.settings_section')}</label>
<label>${this._t('room.fill_label')}</label>
${([['', 'fill.inherit'], ['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']] as const).map(
${([['', 'fill.inherit'], ...ROOM_FILL_MODES.map((v) => [v, 'fill.' + v])] as const).map(
([v, k]) => html`<label class="srcrow inline">
<input type="radio" name="rfill" .checked=${this._roomFill === v}
@change=${() => { this._roomFill = v as any; this.requestUpdate(); }} />
+18
View File
@@ -531,6 +531,24 @@ export function safeUrl(url: string | null | undefined): string | null {
export type TapAction = 'info' | 'more-info' | 'toggle';
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
/**
* The option lists the editors offer, in one place — and the reason they are
* here rather than inline in the templates.
*
* `display` gained 'value' in v1.26.0 ("show the measurement instead of the
* icon") but the backend schema still only accepted badge/ripple/icon_ripple,
* so saving any marker configured that way was rejected outright — the feature
* was unusable for a year and a half and only surfaced through a user's error
* message (issue #3, 2026-07-27). The lists are exported so a backend test can
* read them and assert the schema accepts every value a user can pick.
* Adding an option here and forgetting the schema now fails the test suite.
*/
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
/**
+53 -13
View File
@@ -1,5 +1,11 @@
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
/** A request older than this is presumed lost, and the url may be asked again. */
export const SIGN_INFLIGHT_MS = 15000;
/** After a failure, wait before retrying; doubles up to the cap. */
export const SIGN_BACKOFF_MIN_MS = 2000;
export const SIGN_BACKOFF_MAX_MS = 60000;
/**
* Signed urls for the authenticated content endpoint, shared by both cards.
*
@@ -19,14 +25,23 @@ import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from
* replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
* the entry is dropped rather than served (it would 401 and raise a
* failed-login warning for the viewer's own IP);
* - `pending` is always released, so one failed request does not wedge a url
* forever.
* - a url that is queued or already in flight is not asked for again: renders
* are frequent and a slow socket used to turn every one of them into another
* `content/sign` call (review R4-2). An in-flight entry expires after
* SIGN_INFLIGHT_MS so a promise that never settles cannot block retries
* forever, and a failure backs off instead of retrying on the next frame.
*/
export class ContentSigner {
private signed: Record<string, { url: string; at: number }> = {};
private pending = new Set<string>();
/** Waiting for the batch timer to fire. */
private queued = new Set<string>();
/** Sent and not settled yet: url -> when the request went out. */
private inFlight = new Map<string, number>();
/** After a failure: when this url may be asked again, and the current delay. */
private retry = new Map<string, { notBefore: number; delay: number }>();
private batchTimer?: ReturnType<typeof setTimeout>;
private resignTimer?: ReturnType<typeof setInterval>;
private disposed = false;
/**
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
@@ -36,15 +51,18 @@ export class ContentSigner {
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
start(hass: () => any, referenced: () => Set<string>): void {
this.disposed = false; // an element can be reconnected after a disconnect
this.stopTimer();
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
}
/** Release every timer; the cache survives a reconnect, the timers must not. */
dispose(): void {
this.disposed = true;
this.stopTimer();
clearTimeout(this.batchTimer);
this.pending.clear();
this.queued.clear();
this.inFlight.clear();
}
private stopTimer(): void {
@@ -72,13 +90,18 @@ export class ContentSigner {
}
private request(hass: any, url: string): void {
if (this.pending.has(url) || !hass?.callWS) return;
this.pending.add(url);
if (!hass?.callWS || this.queued.has(url)) return;
const now = this.now();
const sent = this.inFlight.get(url);
if (sent !== undefined && now - sent < SIGN_INFLIGHT_MS) return; // already asking
const back = this.retry.get(url);
if (back && now < back.notBefore) return; // still backing off
this.queued.add(url);
clearTimeout(this.batchTimer);
// batch: switching space asks for several urls in the same tick
this.batchTimer = setTimeout(() => {
const paths = [...this.pending];
this.pending.clear();
const paths = [...this.queued];
this.queued.clear();
this.sign(hass, paths);
}, 30);
}
@@ -86,21 +109,32 @@ export class ContentSigner {
private sign(hass: any, paths: string[]): void {
if (!paths.length || !hass?.callWS) return;
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
const sentAt = this.now();
for (const p of batch) this.inFlight.set(p, sentAt);
hass
.callWS({ type: 'houseplan/content/sign', paths: batch })
.then((r: any) => {
if (!r?.urls) return;
for (const p of batch) this.retry.delete(p);
if (!r?.urls || this.disposed) return;
const at = this.now();
const next = { ...this.signed };
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
this.signed = next;
this.onUpdate();
})
.catch(() => undefined) // a retry happens on the next render
.catch(() => {
// back off rather than retry on the very next frame: a socket that
// is refusing sign requests would otherwise be hammered per render
const now = this.now();
for (const p of batch) {
const prev = this.retry.get(p)?.delay || 0;
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
this.retry.set(p, { notBefore: now + delay, delay });
}
})
.finally(() => {
// never leave a url wedged in `pending`: the first failure would
// otherwise make it unrequestable for the life of the page (R3-2)
for (const p of batch) this.pending.delete(p);
// release only our own attempt: a later one may have superseded it
for (const p of batch) if (this.inFlight.get(p) === sentAt) this.inFlight.delete(p);
});
}
}
@@ -117,6 +151,7 @@ export class ContentSigner {
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
}
this.signed = kept;
this.retry.clear(); // a scheduled refresh is a fresh chance for everything
this.sign(hass, Object.keys(kept));
}
@@ -124,4 +159,9 @@ export class ContentSigner {
get entries(): Record<string, { url: string; at: number }> {
return this.signed;
}
/** Test/debug view of what is currently being asked for. */
get inFlightUrls(): string[] {
return [...this.inFlight.keys()];
}
}
+185
View File
@@ -0,0 +1,185 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ContentSigner, SIGN_INFLIGHT_MS, SIGN_BACKOFF_MIN_MS } from '../test-build/signing.js';
import { SIGN_TTL_MS, SIGN_REFRESH_MS } from '../test-build/logic.js';
const URL_A = '/api/houseplan/content/plans/_/f1.tok.svg';
const URL_B = '/api/houseplan/content/files/m1/manual.pdf';
const tick = () => new Promise((r) => setTimeout(r, 45)); // > the 30 ms batch timer
/** hass whose sign call is resolved/rejected by hand. */
function makeHass() {
const calls = [];
const hass = {
callWS(m) {
let settle;
const p = new Promise((res, rej) => { settle = { res, rej }; });
calls.push({ paths: m.paths, ...settle });
return p;
},
};
return { hass, calls };
}
function signer(now = () => Date.now()) {
let updates = 0;
const s = new ContentSigner(() => { updates++; }, now);
return { s, updates: () => updates };
}
test('R4-2: renders during one unresolved request do not multiply it', async () => {
const { hass, calls } = makeHass();
const { s } = signer();
for (let i = 0; i < 6; i++) assert.equal(s.display(hass, URL_A), '');
await tick();
assert.equal(calls.length, 1, 'one batched request');
// more renders while it is still in flight
for (let i = 0; i < 5; i++) s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 1, 'still one: the url is in flight');
assert.deepEqual(s.inFlightUrls, [URL_A]);
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
await tick();
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
assert.equal(calls.length, 1, 'a resolved signature starts no extra request');
assert.deepEqual(s.inFlightUrls, []);
});
test('R4-2: a rejection frees the url but backs off before retrying', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s } = signer(() => t);
s.display(hass, URL_A);
await tick();
calls[0].rej(new Error('socket'));
await tick();
assert.deepEqual(s.inFlightUrls, [], 'released, not wedged');
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 1, 'the very next render must not retry');
t += SIGN_BACKOFF_MIN_MS + 1;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 2, 'retried once the backoff has passed');
// the second failure waits longer than the first
calls[1].rej(new Error('socket'));
await tick();
t += SIGN_BACKOFF_MIN_MS + 1;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 2, 'backoff doubled');
t += SIGN_BACKOFF_MIN_MS * 2;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 3);
calls[2].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
await tick();
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
});
test('R4-2: a request that never settles stops blocking after the timeout', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s } = signer(() => t);
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 1);
t += SIGN_INFLIGHT_MS - 1;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 1, 'still presumed in flight');
t += 2;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 2, 'presumed lost — asked again');
// the FIRST promise finally answers: it must not resurrect a stale in-flight
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
await tick();
assert.deepEqual(s.inFlightUrls, [URL_A], 'only the second attempt is in flight');
});
test('signatures age: fresh, aging, expired', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s } = signer(() => t);
s.display(hass, URL_A);
await tick();
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=ONE' } });
await tick();
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
assert.equal(calls.length, 1, 'a fresh signature asks for nothing');
t += SIGN_REFRESH_MS + 1; // aging: keep rendering, fetch a replacement
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
await tick();
assert.equal(calls.length, 2);
t += SIGN_TTL_MS; // expired: rendering it would 401
assert.equal(s.display(hass, URL_A), '');
assert.equal(s.entries[URL_A], undefined, 'the dead entry is dropped');
});
test('a non-content url is passed straight through, nothing is signed', async () => {
const { hass, calls } = makeHass();
const { s } = signer();
assert.equal(s.display(hass, '/local/plan.png'), '/local/plan.png');
assert.equal(s.display(hass, ''), '');
assert.equal(s.display(hass, null), '');
await tick();
assert.equal(calls.length, 0);
});
test('legacy urls are normalised before signing', async () => {
const { hass, calls } = makeHass();
const { s } = signer();
s.display(hass, '/houseplan_files/plans/f1.svg');
await tick();
assert.deepEqual(calls[0].paths, ['/api/houseplan/content/plans/_/f1.svg']);
});
test('resign prunes to the referenced set and gives everything a fresh chance', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s } = signer(() => t);
s.display(hass, URL_A);
s.display(hass, URL_B);
await tick();
calls[0].res({ urls: { [URL_A]: URL_A + '?s=1', [URL_B]: URL_B + '?s=1' } });
await tick();
assert.equal(Object.keys(s.entries).length, 2);
s.resign(hass, new Set([URL_A]));
await tick();
assert.deepEqual(Object.keys(s.entries), [URL_A], 'the unreferenced url is dropped');
assert.deepEqual(calls[1].paths, [URL_A]);
});
test('dispose(): a late answer neither renders nor throws, start() revives it', async () => {
const { hass, calls } = makeHass();
const { s, updates } = signer();
s.display(hass, URL_A);
await tick();
s.dispose();
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
await tick();
assert.equal(updates(), 0, 'no re-render after teardown');
s.start(() => hass, () => new Set([URL_A]));
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 2, 'a reconnected card asks again');
calls[1].res({ urls: { [URL_A]: URL_A + '?authSig=NEW' } });
await tick();
assert.equal(updates(), 1);
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=NEW');
s.dispose();
});
+66
View File
@@ -396,6 +396,72 @@ async def test_collection_ignores_files_that_are_not_plans(
assert (plans / "readme").is_file()
async def test_a_marker_showing_its_value_can_be_saved(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""issue #3: display='value' was rejected, and one bad marker fails the lot.
A user could not save the configuration at all after setting any sensor to
"value instead of an icon" — the editor offered the option, the schema had
never heard of it.
"""
await _setup(hass)
client = await hass_ws_client(hass)
cfg = await _cfg([{"id": "f1", "plan_url": None}])
cfg["markers"] = [
{"id": "sensor.t", "binding": "entity:sensor.t", "display": "value"},
{"id": "sensor.h", "binding": "entity:sensor.h", "display": "badge"},
]
ok = await _save(client, cfg, 0)
assert ok["success"], ok.get("error")
await client.send_json_auto_id({"type": "houseplan/config/get"})
got = await client.receive_json()
assert [m["display"] for m in got["result"]["config"]["markers"]] == ["value", "badge"]
async def test_a_failing_collector_does_not_undo_an_accepted_save(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""review R4-1: garbage collection runs behind an already durable write.
If it raised, the client got an error for a revision the store had already
accepted — and its retry then failed with `conflict`, because the server had
moved on. The commit stands and the event fires regardless.
"""
from custom_components.houseplan import websocket_api as wsapi
await _setup(hass)
client = await hass_ws_client(hass)
events = []
hass.bus.async_listen("houseplan_config_updated", lambda ev: events.append(ev.data))
def _boom(*_a, **_k):
raise OSError("the plans directory is on fire")
monkeypatch.setattr(wsapi, "collect_plans", _boom)
cfg = await _cfg([{"id": "r5", "plan_url": None}])
ok = await _save(client, cfg, 0)
assert ok["success"], "an accepted revision must be reported as accepted"
rev = ok["result"]["rev"]
await hass.async_block_till_done()
assert events and events[-1]["rev"] == rev, "the update event still fires"
# the store really holds the new revision, and the reported rev is usable
await client.send_json_auto_id({"type": "houseplan/config/get"})
got = await client.receive_json()
assert got["result"]["rev"] == rev
assert [sp["id"] for sp in got["result"]["config"]["spaces"]] == ["r5"]
monkeypatch.undo()
again = await _save(client, cfg, rev)
assert again["success"], "the next CAS on the reported revision goes through"
async def test_content_signed_path_opens_without_a_bearer_header(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
) -> None:
+89
View File
@@ -292,3 +292,92 @@ def test_collect_plans_survives_a_missing_directory(tmp_path):
collect_plans = plans.collect_plans
assert collect_plans(tmp_path / "nope", _cfg(), _cfg()) == 0
def test_collect_plans_never_raises_when_the_directory_disappears(tmp_path, monkeypatch):
"""review R4-1: it runs behind a durable commit, so it may only report 0."""
collect_plans = plans.collect_plans
d = tmp_path / "plans"
d.mkdir()
def _boom(self):
raise OSError("gone")
monkeypatch.setattr(type(d), "iterdir", _boom, raising=False)
assert collect_plans(d, _cfg("/p/a.png"), _cfg("/p/b.png")) == 0
# ---------- the editor's options must be storable (issue #3) ----------
def _ts_list(name):
"""Read one `export const NAME = [...] as const;` list out of src/logic.ts.
Deliberately reads the TypeScript source rather than duplicating the values:
a list that lives in two places drifts, which is exactly what happened here.
"""
import re
src = os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "logic.ts")
with open(src, encoding="utf-8") as fh:
text = fh.read()
m = re.search(rf"export const {name} = \[(.*?)\] as const;", text, re.S)
assert m, f"{name} not found in src/logic.ts"
return re.findall(r"'([^']+)'", m.group(1))
def _marker(**extra):
return {"id": "m1", "binding": "entity:sensor.x", **extra}
def test_every_display_mode_the_editor_offers_is_accepted():
"""issue #3: 'value' was added to the card in v1.26.0 and never to the schema.
Saving a sensor set to "value instead of an icon" failed with
"not a valid value for dictionary value @ data['config']['markers'][n]['display']",
and because one bad marker rejects the whole config, the user could not save
at all. Reported 2026-07-27.
"""
modes = _ts_list("DISPLAY_MODES")
assert "value" in modes, "the regression this test exists for"
for mode in modes:
v.MARKER_SCHEMA(_marker(display=mode))
v.MARKER_SCHEMA(_marker(display=None))
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA(_marker(display="wat"))
def test_every_tap_action_the_editor_offers_is_accepted():
for action in _ts_list("TAP_ACTIONS"):
v.MARKER_SCHEMA(_marker(tap_action=action))
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA(_marker(tap_action="launch-missiles"))
def _space(**settings):
return {
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
"rooms": [], "settings": settings,
}
def test_every_fill_mode_the_editor_offers_is_accepted():
for mode in _ts_list("SPACE_FILL_MODES"):
v.SPACE_SCHEMA(_space(fill_mode=mode))
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(_space(fill_mode="rainbow"))
def test_every_room_fill_mode_the_editor_offers_is_accepted():
def room(mode):
return {
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r1", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2,
"settings": {"fill_mode": mode}}],
}
for mode in _ts_list("ROOM_FILL_MODES"):
v.SPACE_SCHEMA(room(mode))
v.SPACE_SCHEMA(room(None)) # inherit from the space
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(room("rainbow"))
+2 -1
View File
@@ -12,6 +12,7 @@
"src/rules.ts",
"src/devices.ts",
"src/types.ts",
"src/space-geometry.ts"
"src/space-geometry.ts",
"src/signing.ts"
]
}