mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.4.3+v1.4.4: icon centers snap to markup grid (drag + one-time DB snap); CRITICAL: optimistic locking for config (rev/expected_rev/conflict), houseplan_config_updated event with live client resync, per-device layout updates instead of full set
This commit is contained in:
Binary file not shown.
@@ -8,7 +8,7 @@ FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # относительно каталога конфигурации HA
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.4.2"
|
||||
VERSION = "1.4.4"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -166,23 +166,43 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Вернуть конфигурацию (пространства, оверрайды, виртуальные устройства, настройки)."""
|
||||
"""Вернуть конфигурацию и её ревизию."""
|
||||
data = await _config_store(hass).async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
connection.send_result(msg["id"], {"config": config})
|
||||
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "houseplan/config/set", vol.Required("config"): CONFIG_SCHEMA}
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
vol.Required("config"): CONFIG_SCHEMA,
|
||||
vol.Optional("expected_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Полностью заменить конфигурацию."""
|
||||
"""Заменить конфигурацию с оптимистичной блокировкой (expected_rev).
|
||||
|
||||
Защита от гонки нескольких открытых клиентов: если конфиг менялся с момента
|
||||
последнего чтения клиентом — возвращается ошибка conflict, клиент обязан
|
||||
перечитать конфиг и повторить правку поверх свежей версии.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Правка конфигурации разрешена только администраторам")
|
||||
return
|
||||
await _config_store(hass).async_save({"config": msg["config"]})
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
store = _config_store(hass)
|
||||
data = await store.async_load() or {}
|
||||
current_rev = data.get("rev", 0)
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Конфигурация изменена в другом окне (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
# ---------------- загрузка планов ----------------
|
||||
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -113,6 +113,6 @@ more-info по приоритету доменов), temp, members[] (групп
|
||||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
|
||||
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only опционально) |
|
||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
|
||||
| `houseplan/config/get` | — | `{config}` |
|
||||
| `houseplan/config/set` | `config` (валидация vol) | `{ok}` |
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; событие `houseplan_config_updated` |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8МБ) | `{ok, url}` |
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v1.4.4 — 2026-07-04 (КРИТИЧЕСКИЙ фикс: гонка конфигураций)
|
||||
- ИНЦИДЕНТ: config сохранялся целиком по принципу last-writer-wins — открытый клиент со
|
||||
старой копией затирал чужие правки (потеряна первая итерация пользовательской разметки).
|
||||
- Фикс: оптимистичная блокировка — конфиг несёт `rev`; `config/set` принимает `expected_rev`
|
||||
и возвращает ошибку `conflict` при несовпадении; клиент перечитывает конфиг.
|
||||
- Live-синхронизация: `config/set` рассылает событие `houseplan_config_updated`; все открытые
|
||||
карточки подписаны и перечитывают конфиг автоматически (окна больше не расходятся).
|
||||
- Позиции при drag сохраняются точечно через `layout/update` (dirty-set), а не полным
|
||||
`layout/set` — раскладка тоже больше не затирается между окнами.
|
||||
- Бэкап конфига перед деплоем: .storage/houseplan.config.bak-*.
|
||||
|
||||
## v1.4.3 — 2026-07-04
|
||||
- Иконки устройств привязываются к той же сетке, что и разметка (центр иконки = узел, снап
|
||||
при перетаскивании и вводе X/Y). Существующие 57 позиций в БД дачи снапнуты к узлам.
|
||||
|
||||
## v1.4.2 — 2026-07-04 (сетка разметки)
|
||||
- Шаг сетки уменьшен в 2 раза: GRID_N 60 → 120 (шаг 8.33 рендер-ед. ≈ 0.83% ширины плана).
|
||||
- Точки сетки рендерятся ПОВЕРХ плана и комнат (grid-rect перенесён из под-плана в верхний
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.4",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+59
-7
@@ -12,7 +12,7 @@ import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds';
|
||||
import { EXCLUDED_DOMAINS, GROUP_TITLES, iconFor, DOMAIN_PRIORITY } from './rules';
|
||||
import './editor';
|
||||
|
||||
const CARD_VERSION = '1.4.2';
|
||||
const CARD_VERSION = '1.4.4';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
|
||||
|
||||
@@ -124,6 +124,8 @@ class HouseplanCard extends LitElement {
|
||||
private _serverStorage = false;
|
||||
private _loaded = false;
|
||||
private _serverCfg: ServerConfig | null = null;
|
||||
private _cfgRev = 0;
|
||||
private _unsubCfg: (() => void) | null = null;
|
||||
private _devices: DevItem[] = [];
|
||||
private _regSignature = '';
|
||||
private _defPos: Record<string, { x: number; y: number }> = {};
|
||||
@@ -177,6 +179,10 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
window.removeEventListener('keydown', this._keyHandler);
|
||||
if (this._unsubCfg) {
|
||||
this._unsubCfg();
|
||||
this._unsubCfg = null;
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -326,7 +332,14 @@ class HouseplanCard extends LitElement {
|
||||
this._serverStorage = true;
|
||||
const cfg = cfgResp?.config;
|
||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||
this._cfgRev = cfgResp?.rev || 0;
|
||||
this._layout = layResp?.layout || {};
|
||||
// live-синхронизация: конфиг изменён в другом окне → перечитать
|
||||
if (!this._unsubCfg) {
|
||||
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
|
||||
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
|
||||
}, 'houseplan_config_updated');
|
||||
}
|
||||
if (this._norm && !this._model.find((s) => s.id === this._space)) {
|
||||
this._space = this._model[0]?.id || this._space;
|
||||
}
|
||||
@@ -344,11 +357,34 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private async _reloadConfigOnly(): Promise<void> {
|
||||
try {
|
||||
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
|
||||
const cfg = resp?.config;
|
||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||
this._cfgRev = resp?.rev || 0;
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this.requestUpdate();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
private _dirtyPos = new Set<string>();
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
if (this._serverStorage) {
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/layout/set', layout: this._layout })
|
||||
.catch((e: any) => this._showToast('Не удалось сохранить на сервере: ' + (e?.message || e)));
|
||||
// точечные обновления: не затираем позиции, изменённые в других окнах
|
||||
const ids = [...this._dirtyPos];
|
||||
this._dirtyPos.clear();
|
||||
for (const id of ids) {
|
||||
const pos = this._layout[id];
|
||||
if (!pos) continue;
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/layout/update', device_id: id, pos })
|
||||
.catch((e: any) => this._showToast('Не удалось сохранить позицию: ' + (e?.message || e)));
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
|
||||
}
|
||||
@@ -598,14 +634,19 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _savePos(d: DevItem, x: number, y: number): void {
|
||||
if (this._norm) {
|
||||
// центр иконки привязывается к узлам той же сетки, что и разметка комнат
|
||||
const g = this._gridPitch;
|
||||
const gx = Math.round(x / g) * g;
|
||||
const gy = Math.round(y / g) * g;
|
||||
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === d.space)?.aspect || 1;
|
||||
this._layout = {
|
||||
...this._layout,
|
||||
[d.id]: { s: d.space, x: x / NORM_W, y: y / (NORM_W / aspect) },
|
||||
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / (NORM_W / aspect) },
|
||||
};
|
||||
} else {
|
||||
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
|
||||
}
|
||||
this._dirtyPos.add(d.id);
|
||||
this._persistLayout();
|
||||
}
|
||||
|
||||
@@ -797,8 +838,19 @@ class HouseplanCard extends LitElement {
|
||||
private _saveConfig = debounce(() => {
|
||||
if (!this._serverCfg) return;
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/config/set', config: this._serverCfg })
|
||||
.catch((e: any) => this._showToast('Не удалось сохранить конфиг: ' + (e?.message || e)));
|
||||
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
|
||||
.then((r: any) => {
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
})
|
||||
.catch((e: any) => {
|
||||
if (e?.code === 'conflict') {
|
||||
this._showToast('Конфиг изменён в другом окне — данные обновлены, повторите последнее действие');
|
||||
this._cancelPath();
|
||||
this._reloadConfigOnly();
|
||||
} else {
|
||||
this._showToast('Не удалось сохранить конфиг: ' + (e?.message || e));
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
|
||||
/** Добавить сегмент (рендер-единицы) в каркас пространства (без дублей). true = новый. */
|
||||
|
||||
Reference in New Issue
Block a user