fix v1.41.2: uploaded files survive marker rebinding
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 5s
Validate / frontend (push) Successful in 56s
Validate / backend (push) Failing after 6m31s

- new WS houseplan/files/migrate moves /files/<oldId>/ to the new id
  (admin-only, sanitized ids, merge-safe, removes the empty old dir)
- _saveMarker calls it and rewrites pdf urls (migratePdfUrls pure
  helper, +1 unit test, 112) when rebinding changes the id
- field incident: the sauna heater manuals pointed at an orphaned
  old-id folder that got cleaned up; data restored by hand on the
  home instance (official Harvia PDFs re-downloaded)
- TESTING/CHANGELOG same-commit
This commit is contained in:
Matysh
2026-07-26 17:42:28 +03:00
parent ad7e946e75
commit 88dc0d1de7
11 changed files with 113 additions and 9 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.41.1" VERSION = "1.41.2"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "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", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.41.1" "version": "1.41.2"
} }
@@ -32,6 +32,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get) websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set) websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set) websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_files_migrate)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None: def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -108,6 +109,61 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_result(msg["id"], {"ok": True}) connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/files/migrate",
vol.Required("from_id"): str,
vol.Required("to_id"): str,
}
)
@websocket_api.async_response
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Move a marker's uploaded files to its new id (rebinding changes the id).
Without this the PDF urls keep pointing at the OLD id's folder, which then
looks orphaned and is one cleanup away from deletion — the exact way the
owner lost the sauna heater manuals (field incident, 2026-07-26).
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
from pathlib import Path
import shutil
from .const import FILES_DIR
from .validation import sanitize_marker_id
src_id = sanitize_marker_id(msg["from_id"])
dst_id = sanitize_marker_id(msg["to_id"])
if not src_id or not dst_id or src_id == dst_id:
connection.send_result(msg["id"], {"ok": True, "moved": 0})
return
base = Path(hass.config.path(FILES_DIR))
src = base / src_id
dst = base / dst_id
def _move() -> int:
if not src.is_dir():
return 0
dst.mkdir(parents=True, exist_ok=True)
n = 0
for f in src.iterdir():
if not f.is_file():
continue
target = dst / f.name
if not target.exists():
shutil.move(str(f), str(target))
n += 1
try:
src.rmdir() # only when empty
except OSError:
pass
return n
moved = await hass.async_add_executor_job(_move)
connection.send_result(msg["id"], {"ok": True, "moved": moved})
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "houseplan/layout/delete", vol.Required("type"): "houseplan/layout/delete",
+2 -2
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## v1.41.2 — 2026-07-26
- Fixed attached PDF manuals breaking after a marker is rebound to another
device: the id changes, but the uploaded files stayed in the OLD id's
folder — orphaned and eventually lost (that is exactly how the sauna
heater's manuals died). Rebinding now moves the files server-side
(`houseplan/files/migrate`) and rewrites the attached urls.
## v1.41.1 — 2026-07-24 (docs) ## v1.41.1 — 2026-07-24 (docs)
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive - README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
floor plan card for Home Assistant"), badges, a feature-highlights list floor plan card for Home Assistant"), badges, a feature-highlights list
+3
View File
@@ -140,6 +140,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto] (explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any - [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
PDFs to another device — the server moves /files/<oldId>/ to the new id
and the links keep opening; the old folder disappears (no orphans) [auto]
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every - [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
editor (admins incl.), full-height stage; swipe left/right switches editor (admins incl.), full-height stage; swipe left/right switches
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.41.1", "version": "1.41.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",
+12 -2
View File
@@ -14,7 +14,7 @@ import {
import { import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding, lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls,
snapToWall, openingAmount, snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
@@ -32,7 +32,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.41.1'; const CARD_VERSION = '1.41.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';
@@ -2459,6 +2459,7 @@ class HouseplanCard extends LitElement {
angle: dlg.angle ? dlg.angle : null, angle: dlg.angle ? dlg.angle : null,
tap_action: dlg.tapAction || null, tap_action: dlg.tapAction || null,
controls: dlg.controls.length ? dlg.controls : null, controls: dlg.controls.length ? dlg.controls : null,
// pdfs may be rewritten below when rebinding changes the marker id
glow_radius_cm: (() => { glow_radius_cm: (() => {
const v = parseFloat(dlg.glowRadius); const v = parseFloat(dlg.glowRadius);
if (!Number.isFinite(v) || v <= 0) return null; if (!Number.isFinite(v) || v <= 0) return null;
@@ -2480,6 +2481,15 @@ class HouseplanCard extends LitElement {
const prevRoomId = prevDev?.marker?.room_id ?? null; const prevRoomId = prevDev?.marker?.room_id ?? null;
const roomChanged = !!dlg.room && prevDev != null const roomChanged = !!dlg.room && prevDev != null
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId); && (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
// rebinding changed the id → move the uploaded files along (server-side)
// and rewrite the attached urls; otherwise the old-id folder goes orphan
// (that is how the sauna manuals were lost — incident 2026-07-26)
if (oldId && oldId !== id && marker.pdfs?.length) {
await this.hass
.callWS({ type: 'houseplan/files/migrate', from_id: oldId, to_id: id })
.catch(() => undefined);
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id);
}
// remove the previous marker (by the old id and by the new id) // remove the previous marker (by the old id and by the new id)
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId); cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
cfg.markers.push(marker); cfg.markers.push(marker);
+15
View File
@@ -965,6 +965,21 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
return cutSegments(edges, cuts, eps); return cutSegments(edges, cuts, eps);
} }
// ---------------- marker files ----------------
/**
* Rewrite attached-file urls when a marker's id changes (rebinding): the
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
*/
export function migratePdfUrls<T extends { url: string }>(
pdfs: T[], oldId: string, newId: string,
): T[] {
if (!oldId || !newId || oldId === newId) return pdfs;
const from = '/files/' + oldId + '/';
const to = '/files/' + newId + '/';
return pdfs.map((p) => (p.url.includes(from) ? { ...p, url: p.url.split(from).join(to) } : p));
}
// ---------------- kiosk gestures ---------------- // ---------------- kiosk gestures ----------------
/** /**
+13
View File
@@ -10,6 +10,7 @@ import {
outlineWithout, outlineWithout,
alignGuides, segmentAngle, is45, alignGuides, segmentAngle, is45,
swipeTarget, clampScale, swipeTarget, clampScale,
migratePdfUrls,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
@@ -787,3 +788,15 @@ test('clampScale', () => {
assert.equal(clampScale('x'), 1); assert.equal(clampScale('x'), 1);
assert.equal(clampScale(undefined, 1.5), 1.5); assert.equal(clampScale(undefined, 1.5), 1.5);
}); });
test('migratePdfUrls: rebinding rewrites file urls', () => {
const pdfs = [
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
{ name: 'b.pdf', url: '/houseplan_files/files/other/b.pdf?v=2' },
];
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99');
assert.equal(out[0].url, '/houseplan_files/files/dev99/a.pdf?v=1');
assert.equal(out[1].url, pdfs[1].url); // чужие пути не трогаем
// без смены id — как есть
assert.equal(migratePdfUrls(pdfs, 'x', 'x'), pdfs);
});