mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.46.2: re-check of v1.46.1 — HP-1461-01, -02
HP-1461-01: collection was tied to config/set, which is the right scope for what a commit supersedes but leaves a file nobody references with no future write to notice it — cancel a dialog after the upload finished, drop the connection just after, or call the upload API directly. The daily sweep added in v1.46.1 only removed streaming temporaries, so the documented 'a cancelled attachment is collected an hour later' did not hold on an instance nobody edits. The scheduled pass now loads the stored config under the same write_lock a commit uses and runs collect_attachments/collect_plans with it as BOTH sides: nothing counts as superseded, referenced files are preserved, aged unreferenced ones go. Doing it under the lock keeps it from deciding on a snapshot a commit is about to replace. HP-1461-02: _reloadLayoutOnly captured the dirty set AFTER flushing the pending write, and the flush empties it first — so during a real drag (where a write is already scheduled) the snapshot was empty and the server's older position was merged over the user's move. The snapshot is taken before the flush, by value, and a _sentPos map now holds positions that are sent but unacknowledged, which closes the same window for a write that was already in flight. Tests: the upload test now cancels the request task for real (the previous one claimed to and only walked error paths); smoke_layout_sync schedules a genuine debounced write and delays it — verified failing on a v1.46.1 build with exactly the reported symptom; a new backend test reloads the entry and asserts the scheduled sweep takes an aged cancelled attachment and an orphan plan while keeping everything the config still references. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
@@ -20,9 +20,9 @@ from .const import (
|
|||||||
PLANS_URL,
|
PLANS_URL,
|
||||||
VERSION,
|
VERSION,
|
||||||
)
|
)
|
||||||
from .plans import sweep_upload_temps
|
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||||
from .repairs import async_check_plan_files
|
from .repairs import async_check_plan_files
|
||||||
from .store import HouseplanConfigEntry, create_data
|
from .store import HouseplanConfigEntry, create_data, get_data
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -101,19 +101,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
|||||||
|
|
||||||
await async_check_plan_files(hass, entry)
|
await async_check_plan_files(hass, entry)
|
||||||
|
|
||||||
# Abandoned upload temporaries (HP-1460-02). A request cleans up after
|
# Scheduled collection of everything nobody ended up referencing.
|
||||||
# itself; a hard kill mid-upload does not, and the commit-scoped collector
|
#
|
||||||
# only walks marker folders — so without this a `.upload-*` from a crashed
|
# A commit collects what that commit superseded, which is the right rule for
|
||||||
# transfer would sit there for good, and on an instance nobody edits, so
|
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
||||||
# would a cancelled attachment. Once at startup, then daily.
|
# after the file has already uploaded, lose the connection after the upload
|
||||||
|
# succeeded, or call the API directly, and the file is unreferenced with no
|
||||||
|
# future write to notice it (HP-1461-01). The earlier version of this sweep
|
||||||
|
# only removed streaming temporaries, which are a different, narrower case.
|
||||||
|
#
|
||||||
|
# Passing the CURRENT configuration as both sides means "nothing was
|
||||||
|
# superseded": every referenced file is preserved and only unreferenced ones
|
||||||
|
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
|
||||||
|
# so it cannot decide from a snapshot that a commit is about to replace.
|
||||||
async def _sweep(_now=None) -> None:
|
async def _sweep(_now=None) -> None:
|
||||||
files_dir = Path(hass.config.path(FILES_DIR))
|
files_dir = Path(hass.config.path(FILES_DIR))
|
||||||
|
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||||
try:
|
try:
|
||||||
n = await hass.async_add_executor_job(sweep_upload_temps, files_dir)
|
data = get_data(hass)
|
||||||
|
if data is None: # entry unloaded — nothing authoritative to compare against
|
||||||
|
await hass.async_add_executor_job(sweep_upload_temps, files_dir)
|
||||||
|
return
|
||||||
|
async with data.write_lock:
|
||||||
|
stored = await data.config_store.async_load() or {}
|
||||||
|
cfg = stored.get("config") or {}
|
||||||
|
|
||||||
|
def _collect() -> int:
|
||||||
|
n = sweep_upload_temps(files_dir)
|
||||||
|
n += collect_attachments(files_dir, cfg, cfg)
|
||||||
|
n += collect_plans(plans_dir, cfg, cfg)
|
||||||
|
return n
|
||||||
|
|
||||||
|
n = await hass.async_add_executor_job(_collect)
|
||||||
if n:
|
if n:
|
||||||
_LOGGER.info("House Plan: removed %s abandoned upload temporaries", n)
|
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
|
||||||
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||||
_LOGGER.exception("House Plan: sweeping upload temporaries failed")
|
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||||
|
|
||||||
await _sweep()
|
await _sweep()
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
|
|||||||
Binary file not shown.
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
|
|||||||
PLAN_ORPHAN_TTL_S = 3600
|
PLAN_ORPHAN_TTL_S = 3600
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.46.1"
|
VERSION = "1.46.2"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.46.1"
|
"version": "1.46.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,14 +56,35 @@ const res = await page.evaluate(async () => {
|
|||||||
out.ownWriteNoReload = gets === before;
|
out.ownWriteNoReload = gets === before;
|
||||||
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
|
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
|
||||||
|
|
||||||
// 3) чужая ревизия во время неотправленного перетаскивания не съедает его
|
// 3) НАСТОЯЩЕЕ перетаскивание: debounce запланирован, запись задержана, а
|
||||||
|
// layout/get отвечает мгновенно. Именно этот порядок и терял позицию:
|
||||||
|
// flush() внутри перечитывания опустошал _dirtyPos ДО снятия снимка.
|
||||||
|
let releaseUpdate;
|
||||||
|
const updateGate = new Promise((r) => { releaseUpdate = r; });
|
||||||
|
let delayUpdate = true;
|
||||||
|
const plain = hass.callWS;
|
||||||
|
c.hass = { ...hass, callWS: async (m) => {
|
||||||
|
if (m.type === 'houseplan/layout/update' && delayUpdate) {
|
||||||
|
delayUpdate = false;
|
||||||
|
await updateGate;
|
||||||
|
}
|
||||||
|
return plain(m);
|
||||||
|
} };
|
||||||
|
|
||||||
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
|
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
|
||||||
c._dirtyPos.add('dev_a'); // локально подвинули, ещё не отправили
|
c._dirtyPos.add('dev_a');
|
||||||
|
c._persistLayout(); // debounce запланирован, не сброшен вручную
|
||||||
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
|
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
|
||||||
subs.forEach((f) => f({ data: { rev } }));
|
subs.forEach((f) => f({ data: { rev } }));
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
out.dragKeptWhileWriteInFlight = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||||
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
|
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
|
||||||
|
|
||||||
|
releaseUpdate();
|
||||||
|
await new Promise((r) => setTimeout(r, 350));
|
||||||
|
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||||
|
out.serverAgrees = JSON.stringify(layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||||
|
out.sentPosDrained = c._sentPos.size === 0;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
// зафиксировано прогоном на v1.46.1 и сверено с кодом
|
// зафиксировано прогоном на v1.46.1 и сверено с кодом
|
||||||
@@ -73,7 +94,10 @@ checkAll(res, {
|
|||||||
revFollowed: true,
|
revFollowed: true,
|
||||||
ownWriteNoReload: true,
|
ownWriteNoReload: true,
|
||||||
ownWriteKept: true,
|
ownWriteKept: true,
|
||||||
localDragSurvived: true,
|
dragKeptWhileWriteInFlight: true,
|
||||||
remoteChangeApplied: true,
|
remoteChangeApplied: true,
|
||||||
|
localDragSurvived: true,
|
||||||
|
serverAgrees: true,
|
||||||
|
sentPosDrained: true,
|
||||||
});
|
});
|
||||||
await finish(browser);
|
await finish(browser);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -198,8 +198,13 @@ uploads agree on one name and quietly overwrite each other. It also budgets the
|
|||||||
length so the result survives the sanitiser the content view applies to the
|
length so the result survives the sanitiser the content view applies to the
|
||||||
request, since a name the view rewrites is a file written and never served.
|
request, since a name the view rewrites is a file written and never served.
|
||||||
Streaming temporaries live in the files root under `.upload-`, are removed on
|
Streaming temporaries live in the files root under `.upload-`, are removed on
|
||||||
every exit path of the request, and are swept at startup and daily for the
|
every exit path of the request (including cancellation, which is a
|
||||||
cases a process death leaves behind. A new icon has no id yet, so its
|
BaseException and slips past `except Exception`), and are swept at startup and
|
||||||
|
daily. That scheduled pass also runs the two collectors with the stored
|
||||||
|
configuration as *both* sides — nothing superseded, so every referenced file is
|
||||||
|
kept and only aged unreferenced ones go. Without it, collection would only ever
|
||||||
|
happen when somebody saves, and a file uploaded into a dialog that was then
|
||||||
|
cancelled would wait for a write that may never come. A new icon has no id yet, so its
|
||||||
files go to a per-dialog staging folder and move to the real id once the config
|
files go to a per-dialog staging folder and move to the real id once the config
|
||||||
write is accepted — the same copy → save → cleanup order as a rebind.
|
write is accepted — the same copy → save → cleanup order as a rebind.
|
||||||
`config/set` collects what its commit superseded, and anything unreferenced and
|
`config/set` collects what its commit superseded, and anything unreferenced and
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.46.2 — 2026-07-28 (re-check of v1.46.1: HP-1461-01, -02)
|
||||||
|
- **A file nobody ended up using is now cleaned up even if nothing is ever
|
||||||
|
saved again (HP-1461-01).** Collection is tied to a configuration write,
|
||||||
|
which is right for what a write supersedes but leaves a gap: cancel a dialog
|
||||||
|
after the file has already uploaded, lose the connection just after, or call
|
||||||
|
the upload API directly, and nothing references the file and no future write
|
||||||
|
notices it. The daily sweep added in v1.46.1 only removed half-finished
|
||||||
|
transfers, so the promise that a cancelled attachment disappears after an hour
|
||||||
|
did not hold on an instance nobody edits. The sweep now compares against the
|
||||||
|
stored configuration — under the same lock a write uses — and collects aged
|
||||||
|
unreferenced attachments and plans as well.
|
||||||
|
- **A drag is no longer undone by someone else's move (HP-1461-02).** When the
|
||||||
|
full card learned to follow position changes in v1.46.1, it protected the
|
||||||
|
positions you had moved but not yet sent — except it read that list *after*
|
||||||
|
flushing the pending write, and flushing empties it first. In a real drag,
|
||||||
|
where a write is already scheduled, the list was therefore empty and the
|
||||||
|
server's older position was painted over your move. The card now takes the
|
||||||
|
snapshot before flushing and also holds on to positions that are sent but not
|
||||||
|
yet acknowledged: until the server confirms a position, the card that moved it
|
||||||
|
is the authority on it.
|
||||||
|
- Two tests grew up to their docstrings: the upload test now actually cancels
|
||||||
|
the request task instead of only exercising error paths, and the position-sync
|
||||||
|
smoke schedules a real debounced write and delays it, which is the ordering
|
||||||
|
that lost the drag.
|
||||||
|
|
||||||
## v1.46.1 — 2026-07-28 (re-check of v1.46.0: HP-1460-01 … -03)
|
## v1.46.1 — 2026-07-28 (re-check of v1.46.0: HP-1460-01 … -03)
|
||||||
- **Two uploads of the same file name can no longer collide (HP-1460-01).**
|
- **Two uploads of the same file name can no longer collide (HP-1460-01).**
|
||||||
v1.46.0 stopped overwriting attachments, but choosing a free name and taking
|
v1.46.0 stopped overwriting attachments, but choosing a free name and taking
|
||||||
|
|||||||
@@ -6,6 +6,32 @@
|
|||||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||||
|
|
||||||
|
## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02)
|
||||||
|
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
|
||||||
|
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
|
||||||
|
это верно для того, что запись вытесняет, но оставляет зазор: отмените диалог
|
||||||
|
после того, как файл уже загрузился, потеряйте связь сразу после, или
|
||||||
|
вызовите API загрузки напрямую — и на файл никто не ссылается, а будущей
|
||||||
|
записи, которая бы это заметила, нет. Добавленное в v1.46.1 ежедневное
|
||||||
|
подметание убирало только незавершённые передачи, поэтому обещание «отменённое
|
||||||
|
вложение исчезнет через час» не выполнялось там, где никто ничего не правит.
|
||||||
|
Теперь подметание сверяется с сохранённой конфигурацией — под той же
|
||||||
|
блокировкой, что и запись, — и собирает устаревшие непривязанные вложения и
|
||||||
|
планы тоже.
|
||||||
|
- **Перетаскивание больше не отменяется чужим перемещением (HP-1461-02).** Когда
|
||||||
|
в v1.46.1 полная карточка научилась следить за позициями, она защищала те,
|
||||||
|
что вы подвинули, но ещё не отправили, — вот только читала этот список *после*
|
||||||
|
сброса отложенной записи, а сброс его первым делом опустошает. При настоящем
|
||||||
|
перетаскивании, когда запись уже запланирована, список оказывался пустым, и
|
||||||
|
старая серверная позиция закрашивала ваше движение. Теперь снимок снимается до
|
||||||
|
сброса, и вдобавок удерживаются позиции, отправленные, но ещё не
|
||||||
|
подтверждённые: пока сервер не подтвердил позицию, авторитет по ней — та
|
||||||
|
карточка, которая её подвинула.
|
||||||
|
- Два теста доросли до своих же описаний: тест загрузки теперь действительно
|
||||||
|
отменяет задачу запроса, а не только проходит по путям ошибок, а смоук
|
||||||
|
синхронизации позиций планирует настоящую отложенную запись и задерживает её —
|
||||||
|
именно этот порядок и терял перетаскивание.
|
||||||
|
|
||||||
## v1.46.1 — 2026-07-28 (перепроверка v1.46.0: HP-1460-01 … -03)
|
## v1.46.1 — 2026-07-28 (перепроверка v1.46.0: HP-1460-01 … -03)
|
||||||
- **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).**
|
- **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).**
|
||||||
v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие
|
v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие
|
||||||
|
|||||||
+2
-2
@@ -15,12 +15,12 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.46.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
| Version | **v1.46.2** 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) |
|
| 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) |
|
| 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 |
|
| 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) |
|
| 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.46.1** via direct copy (HACS custom repo also installed) |
|
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.2** via direct copy (HACS custom repo also installed) |
|
||||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -239,6 +239,15 @@ 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
|
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
|
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||||
|
- [ ] Nothing accumulates on an idle instance (v1.46.2, HP-1461-01): attach a
|
||||||
|
file, cancel the dialog, and do not save anything else — an hour later (or
|
||||||
|
after a restart) the file is gone, while every file the configuration
|
||||||
|
still references is untouched
|
||||||
|
[auto: backend test_scheduled_sweep_collects_what_no_commit_will]
|
||||||
|
- [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an
|
||||||
|
icon and, while the save is still in flight, have another window move a
|
||||||
|
different icon — your icon stays where you put it and the other one
|
||||||
|
updates [auto: smoke_layout_sync]
|
||||||
- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same
|
- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same
|
||||||
file from two browser tabs at once — two attachments, two sets of bytes,
|
file from two browser tabs at once — two attachments, two sets of bytes,
|
||||||
neither lost. A file whose name is at the length limit still downloads
|
neither lost. A file whose name is at the length limit still downloads
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.46.1",
|
"version": "1.46.2",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+20
-4
@@ -35,7 +35,7 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
import { langOf, t, type I18nKey } from './i18n';
|
||||||
|
|
||||||
const CARD_VERSION = '1.46.1';
|
const CARD_VERSION = '1.46.2';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
@@ -873,13 +873,23 @@ class HouseplanCard extends LitElement {
|
|||||||
*/
|
*/
|
||||||
private async _reloadLayoutOnly(): Promise<void> {
|
private async _reloadLayoutOnly(): Promise<void> {
|
||||||
if (!this._serverStorage || !this.hass?.callWS) return;
|
if (!this._serverStorage || !this.hass?.callWS) return;
|
||||||
|
// Snapshot BEFORE flushing. `flush()` runs the debounced writer
|
||||||
|
// synchronously, and the first thing that does is empty `_dirtyPos` — so
|
||||||
|
// reading the dirty set afterwards found nothing to protect and the server's
|
||||||
|
// older position was painted over the drag the user had just made
|
||||||
|
// (HP-1461-02). Positions are captured by value for the same reason.
|
||||||
|
const mine = new Map<string, any>();
|
||||||
|
for (const id of this._dirtyPos) if (this._layout[id]) mine.set(id, this._layout[id]);
|
||||||
if (this._persistLayout.pending()) this._persistLayout.flush();
|
if (this._persistLayout.pending()) this._persistLayout.flush();
|
||||||
const mine = new Set(this._dirtyPos);
|
// …and again after the flush: what was dirty is now in flight, and a write
|
||||||
|
// sent before this reload was even scheduled is in there too. Until the
|
||||||
|
// server acknowledges a position, this card is the authority on it.
|
||||||
|
for (const [id, pos] of this._sentPos) mine.set(id, pos);
|
||||||
try {
|
try {
|
||||||
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
|
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
|
||||||
const remote = resp?.layout || {};
|
const remote = resp?.layout || {};
|
||||||
const merged: Record<string, any> = { ...remote };
|
const merged: Record<string, any> = { ...remote };
|
||||||
for (const id of mine) if (this._layout[id]) merged[id] = this._layout[id];
|
for (const [id, pos] of mine) merged[id] = pos;
|
||||||
this._layout = merged;
|
this._layout = merged;
|
||||||
this._layoutRev = resp?.rev ?? this._layoutRev;
|
this._layoutRev = resp?.rev ?? this._layoutRev;
|
||||||
this._cacheSnapshot();
|
this._cacheSnapshot();
|
||||||
@@ -890,6 +900,8 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _dirtyPos = new Set<string>();
|
private _dirtyPos = new Set<string>();
|
||||||
|
/** Positions sent to the server and not acknowledged yet (HP-1461-02). */
|
||||||
|
private _sentPos = new Map<string, { s?: string; x: number; y: number }>();
|
||||||
|
|
||||||
private _persistLayout = debounce(() => {
|
private _persistLayout = debounce(() => {
|
||||||
if (this._serverStorage) {
|
if (this._serverStorage) {
|
||||||
@@ -899,10 +911,14 @@ class HouseplanCard extends LitElement {
|
|||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const pos = this._layout[id];
|
const pos = this._layout[id];
|
||||||
if (!pos) continue;
|
if (!pos) continue;
|
||||||
|
// in flight until the server answers: a layout reload triggered in the
|
||||||
|
// meantime must keep this position, not the one the server still has
|
||||||
|
this._sentPos.set(id, pos);
|
||||||
this.hass
|
this.hass
|
||||||
.callWS({ type: 'houseplan/layout/update', device_id: id, pos })
|
.callWS({ type: 'houseplan/layout/update', device_id: id, pos })
|
||||||
.then((r: any) => this._noteLayoutRev(r))
|
.then((r: any) => this._noteLayoutRev(r))
|
||||||
.catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) })));
|
.catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) })))
|
||||||
|
.finally(() => { if (this._sentPos.get(id) === pos) this._sentPos.delete(id); });
|
||||||
}
|
}
|
||||||
this._cacheSnapshot();
|
this._cacheSnapshot();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -783,3 +783,133 @@ async def test_repair_issue_goes_when_its_space_does(
|
|||||||
await _save(client, await _cfg([{"id": "other", "plan_url": None}]), rev)
|
await _save(client, await _cfg([{"id": "other", "plan_url": None}]), rev)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None
|
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancelling_an_upload_takes_its_temporary_with_it(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
"""HP-1460-02, properly this time: cancellation, not an ordinary error.
|
||||||
|
|
||||||
|
`asyncio.CancelledError` is a BaseException, so the old `except Exception`
|
||||||
|
never saw it and an aborted transfer stranded its `.upload-*`. The previous
|
||||||
|
test claimed to cover this and did not — it only exercised error paths.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
from custom_components.houseplan.const import FILES_DIR
|
||||||
|
from custom_components.houseplan.http_api import HouseplanUploadView
|
||||||
|
from custom_components.houseplan.plans import TMP_PREFIX
|
||||||
|
|
||||||
|
entry = await _setup(hass)
|
||||||
|
root = hass.config.path(FILES_DIR)
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
class _Part:
|
||||||
|
name = "file"
|
||||||
|
filename = "big.pdf"
|
||||||
|
|
||||||
|
async def read_chunk(self, _size):
|
||||||
|
started.set()
|
||||||
|
await asyncio.sleep(3600) # the client stopped sending; we wait
|
||||||
|
|
||||||
|
class _Reader:
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
if getattr(self, "_done", False):
|
||||||
|
raise StopAsyncIteration
|
||||||
|
self._done = True
|
||||||
|
return _Part()
|
||||||
|
|
||||||
|
from custom_components.houseplan import http_api as hp_http
|
||||||
|
|
||||||
|
class _User:
|
||||||
|
is_admin = True
|
||||||
|
|
||||||
|
class _Request:
|
||||||
|
app = {hp_http.KEY_HASS: hass}
|
||||||
|
|
||||||
|
def get(self, _key, default=None):
|
||||||
|
return _User()
|
||||||
|
|
||||||
|
async def multipart(self):
|
||||||
|
return _Reader()
|
||||||
|
|
||||||
|
task = hass.async_create_task(HouseplanUploadView().post(_Request()))
|
||||||
|
await started.wait()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)], "temp exists mid-upload"
|
||||||
|
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)] == [], (
|
||||||
|
"a cancelled upload must not leave its temporary behind"
|
||||||
|
)
|
||||||
|
assert entry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduled_sweep_collects_what_no_commit_will(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
"""HP-1461-01: a commit collects what it superseded — but only when it runs.
|
||||||
|
|
||||||
|
Cancel a dialog after the file uploaded, or lose the connection right after,
|
||||||
|
and nothing references the file and no future write notices it. The daily
|
||||||
|
sweep used to remove only streaming temporaries, so the promise that a
|
||||||
|
cancelled attachment goes after an hour did not hold on an instance nobody
|
||||||
|
edits.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR, PLAN_ORPHAN_TTL_S
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
files = hass.config.path(FILES_DIR)
|
||||||
|
plans = hass.config.path(PLANS_DIR)
|
||||||
|
old = time.time() - PLAN_ORPHAN_TTL_S - 60
|
||||||
|
|
||||||
|
def _seed() -> None:
|
||||||
|
os.makedirs(os.path.join(files, "m5"), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(files, "up_cancelled"), exist_ok=True)
|
||||||
|
os.makedirs(plans, exist_ok=True)
|
||||||
|
for path in (
|
||||||
|
os.path.join(files, "m5", "kept.pdf"),
|
||||||
|
os.path.join(files, "up_cancelled", "manual.pdf"),
|
||||||
|
os.path.join(plans, "s5.tok.png"),
|
||||||
|
os.path.join(plans, "s5.orphan.png"),
|
||||||
|
):
|
||||||
|
with open(path, "wb") as fh:
|
||||||
|
fh.write(b"x")
|
||||||
|
os.utime(path, (old, old))
|
||||||
|
|
||||||
|
await hass.async_add_executor_job(_seed)
|
||||||
|
|
||||||
|
cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}])
|
||||||
|
cfg["markers"] = [
|
||||||
|
{"id": "m5", "binding": "virtual",
|
||||||
|
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m5/kept.pdf"}]}
|
||||||
|
]
|
||||||
|
assert (await _save(client, cfg, 0))["success"]
|
||||||
|
|
||||||
|
# reload the entry: that is what runs the sweep at startup
|
||||||
|
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||||
|
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
exists = lambda p: os.path.isfile(p) # noqa: E731
|
||||||
|
assert await hass.async_add_executor_job(exists, os.path.join(files, "m5", "kept.pdf"))
|
||||||
|
assert await hass.async_add_executor_job(exists, os.path.join(plans, "s5.tok.png"))
|
||||||
|
assert not await hass.async_add_executor_job(
|
||||||
|
exists, os.path.join(files, "up_cancelled", "manual.pdf")
|
||||||
|
), "an aged cancelled attachment is collected without any further config write"
|
||||||
|
assert not await hass.async_add_executor_job(exists, os.path.join(plans, "s5.orphan.png"))
|
||||||
|
assert not await hass.async_add_executor_job(os.path.isdir, os.path.join(files, "up_cancelled"))
|
||||||
|
|||||||
Reference in New Issue
Block a user