mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
Merge dev: v1.44.3..v1.44.5 (B1 regression fix, audit follow-up, room climate)
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
Thanks for your interest! The project is one HACS package: a storage **integration**
|
||||
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
|
||||
|
||||
## Changelog
|
||||
|
||||
User-visible changes go into **both** changelogs in the same commit:
|
||||
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
|
||||
older than v1.42.0 exist only in the English file — no need to backfill them.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
|
||||
@@ -268,6 +268,8 @@ turned the way it is mounted.
|
||||
reports and feature requests (please attach your House Plan version).
|
||||
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
|
||||
longer-form ideas.
|
||||
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
|
||||
([на русском](docs/CHANGELOG.ru.md)).
|
||||
|
||||
When reporting a problem, the version number helps a lot: it is shown in the
|
||||
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
|
||||
|
||||
@@ -271,6 +271,7 @@ title: План дома
|
||||
и запросы фич (пожалуйста, указывайте версию House Plan).
|
||||
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
|
||||
развёрнутых обсуждений.
|
||||
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
|
||||
|
||||
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
|
||||
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Single source of truth for the write-authorization policy.
|
||||
|
||||
The WS and HTTP paths used to duplicate this decision and drifted apart: the
|
||||
WS copy was fixed to fail closed while the upload view still failed OPEN when
|
||||
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
|
||||
one behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY
|
||||
from .store import get_entry
|
||||
|
||||
|
||||
def may_write(hass: HomeAssistant, user) -> bool:
|
||||
"""True when `user` may modify House Plan data.
|
||||
|
||||
Fails CLOSED: when the entry cannot be read — during a reload, or while the
|
||||
integration is disabled — the policy is unknown, and "unknown" is not the
|
||||
same as "permissive": only admins are allowed through.
|
||||
"""
|
||||
is_admin = bool(getattr(user, "is_admin", False))
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return is_admin if admin_only else True
|
||||
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.44.2"
|
||||
VERSION = "1.44.5"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ except ImportError: # older HA versions
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
from .store import get_entry
|
||||
from .auth import may_write
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -95,12 +95,8 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
entry = get_entry(hass)
|
||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
if admin_only:
|
||||
user = request.get("hass_user")
|
||||
if user is None or not user.is_admin:
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.44.2"
|
||||
"version": "1.44.5"
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ POS_SCHEMA = vol.Schema(
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||
POINT = vol.All([_finite], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
@@ -102,10 +102,10 @@ ROOM_SCHEMA = vol.All(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): vol.Coerce(float),
|
||||
vol.Optional("y"): vol.Coerce(float),
|
||||
vol.Optional("w"): vol.Coerce(float),
|
||||
vol.Optional("h"): vol.Coerce(float),
|
||||
vol.Optional("x"): _finite,
|
||||
vol.Optional("y"): _finite,
|
||||
vol.Optional("w"): _finite,
|
||||
vol.Optional("h"): _finite,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -161,17 +161,17 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): [
|
||||
vol.Optional("openings"): vol.All([
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): vol.Coerce(float),
|
||||
vol.Required("y"): vol.Coerce(float),
|
||||
vol.Required("angle"): vol.Coerce(float),
|
||||
vol.Required("x"): _finite,
|
||||
vol.Required("y"): _finite,
|
||||
vol.Required("angle"): _finite,
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
@@ -181,7 +181,7 @@ SPACE_SCHEMA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
],
|
||||
], vol.Length(max=MAX_OPENINGS)),
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
|
||||
@@ -17,6 +17,7 @@ from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
@@ -39,6 +40,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -55,18 +57,8 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
|
||||
|
||||
|
||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
"""May this connection write?
|
||||
|
||||
Fails CLOSED (audit B2): when the entry cannot be read — during a reload or
|
||||
while the integration is disabled — the policy is unknown, and "unknown" is
|
||||
not the same as "permissive". Previously this returned True and ws_plan_set,
|
||||
which never touches the runtime helper, accepted uploads in that window.
|
||||
"""
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return bool(getattr(connection.user, "is_admin", False))
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return connection.user.is_admin if admin_only else True
|
||||
"""May this connection write? Thin wrapper over the shared policy."""
|
||||
return may_write(hass, getattr(connection, "user", None))
|
||||
|
||||
|
||||
# ---------------- layout ----------------
|
||||
@@ -210,6 +202,46 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/content/sign",
|
||||
vol.Required("paths"): [str],
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Sign content paths so the BROWSER can fetch them.
|
||||
|
||||
Home Assistant authenticates HTTP requests by a Bearer header or an
|
||||
`authSig` signed path — there is no cookie auth. An <image href> inside SVG
|
||||
and a plain <a href> can send neither, so after the content endpoint became
|
||||
`requires_auth` the plan backgrounds and PDF links returned 401 (audit
|
||||
follow-up B1 regression, 2026-07-27 — reproduced live).
|
||||
|
||||
The card asks for signatures and uses the signed urls for display.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components.http.auth import async_sign_path
|
||||
|
||||
out: dict[str, str] = {}
|
||||
token_id = getattr(connection, "refresh_token_id", None)
|
||||
for path in msg["paths"][:200]:
|
||||
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
|
||||
continue # only ever sign our own content endpoint
|
||||
clean = path.split("?", 1)[0]
|
||||
try:
|
||||
try:
|
||||
signed = async_sign_path(hass, clean, timedelta(hours=24), refresh_token_id=token_id)
|
||||
except TypeError: # older HA signature: (hass, refresh_token_id, path, expiration)
|
||||
signed = async_sign_path(hass, token_id, clean, timedelta(hours=24))
|
||||
except Exception as err: # noqa: BLE001 — signing must never break the card
|
||||
_LOGGER.warning("House Plan: could not sign %s: %s", clean, err)
|
||||
continue
|
||||
out[path] = signed
|
||||
connection.send_result(msg["id"], {"urls": out})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/cleanup",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+19
-19
File diff suppressed because one or more lines are too long
@@ -1,5 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## v1.44.5 — 2026-07-27
|
||||
- **Room climate now counts every sensor in the area**, including devices that
|
||||
are not placed on the plan (hidden by curation or by you). Previously the
|
||||
average was taken over the visible icons only, so hiding a thermometer
|
||||
silently removed it from the room card, the tooltip and the temperature fill.
|
||||
Curation still applies (fridges, TRVs and chip-temperature plugs stay out),
|
||||
and an explicit per-room source still wins.
|
||||
- The room tooltip no longer says "open the area" — clicking a room stopped
|
||||
navigating in v1.40.1; the link icon on the room card does that.
|
||||
|
||||
|
||||
## v1.44.4 — 2026-07-27 (audit follow-up: B2, B5, L4)
|
||||
- **One authorization policy (B2).** The HTTP upload view still failed **open**
|
||||
when the config entry was unavailable while the WebSocket path failed closed —
|
||||
the two had drifted apart. Both now call the same `may_write` helper, which
|
||||
denies non-admins whenever the policy cannot be read.
|
||||
- **NaN/Infinity refused on every coordinate (B5).** The finite-number check
|
||||
guarded only layout positions; room rects, polygon vertices, `view_box` and
|
||||
opening coordinates accepted `"NaN"`, which serializes to `null` and corrupts
|
||||
the stored geometry permanently. The `MAX_OPENINGS` cap was defined but never
|
||||
wired in — the openings list was unbounded.
|
||||
- **Drag hardening (L4 sub-item).** The tolerant `setPointerCapture` wrapper is
|
||||
now used by every drag pipeline (device, label, resize), not just openings —
|
||||
an inactive pointer id could kill a drag outright. Decor shapes gained a
|
||||
bounds clamp: they can no longer be dragged far outside the plan and saved
|
||||
there.
|
||||
|
||||
## v1.44.3 — 2026-07-27 (fix: plans and manuals load again)
|
||||
- **The authenticated content endpoint had no working browser path.** v1.43.0
|
||||
closed the security hole correctly, but Home Assistant authenticates HTTP
|
||||
requests by a Bearer header or an `authSig` signed path — and an SVG
|
||||
`<image href>` or a plain `<a href>` sends neither. Plan backgrounds and PDF
|
||||
links returned **401** on a real dashboard (reproduced live before the fix).
|
||||
The card now asks the backend to sign what it displays
|
||||
(`houseplan/content/sign`, 24 h, bound to the session's refresh token, only
|
||||
for our own endpoint), re-renders when signatures arrive, and refreshes them
|
||||
every 12 hours so wall tablets keep working. A backend test fetches a signed
|
||||
url **without** an Authorization header and asserts 200, and 401 without the
|
||||
signature.
|
||||
|
||||
|
||||
> 🇷🇺 Русская версия: [CHANGELOG.ru.md](CHANGELOG.ru.md) (записи с v1.42.0).
|
||||
|
||||
## v1.44.2 — 2026-07-27 (external code review: CR-1…CR-3)
|
||||
|
||||
A second, adversarial review (of v1.44.0) produced three findings; all are
|
||||
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
# История изменений
|
||||
|
||||
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
||||
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
||||
>
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.44.5 — 2026-07-27
|
||||
- **Климат комнаты считается по всем датчикам зоны**, включая устройства,
|
||||
которых нет на плане (скрыты курированием или вами). Раньше среднее бралось
|
||||
только по видимым значкам, поэтому скрытый термометр молча выпадал из
|
||||
карточки комнаты, подсказки и температурной заливки. Курирование сохранено
|
||||
(холодильники, термоголовки и розетки с температурой чипа не считаются), а
|
||||
явно выбранный источник в настройках комнаты по-прежнему главнее.
|
||||
- Из подсказки к комнате убрана фраза «открыть зону» — клик по комнате перестал
|
||||
никуда вести ещё в v1.40.1, для перехода есть значок-ссылка у названия.
|
||||
|
||||
|
||||
## v1.44.4 — 2026-07-27 (доработка по аудиту: B2, B5, L4)
|
||||
- **Единая политика авторизации (B2).** HTTP-загрузка по-прежнему **разрешала**
|
||||
запись, когда запись о конфигурации недоступна, тогда как WebSocket-путь уже
|
||||
отказывал — они разошлись. Теперь оба вызывают общий помощник `may_write`,
|
||||
который в неопределённой ситуации пропускает только администраторов.
|
||||
- **NaN/Infinity отвергаются во всех координатах (B5).** Проверка на конечность
|
||||
числа стояла только у позиций раскладки; прямоугольники комнат, вершины
|
||||
полигонов, `view_box` и координаты проёмов принимали `"NaN"`, который при
|
||||
записи превращается в `null` и необратимо портит геометрию. Ограничение
|
||||
`MAX_OPENINGS` было объявлено, но нигде не использовалось — список проёмов
|
||||
оставался безразмерным.
|
||||
- **Укрепление перетаскивания (часть L4).** Безопасная обёртка над
|
||||
`setPointerCapture` теперь используется во всех сценариях перетаскивания
|
||||
(устройства, подписи, изменение размера), а не только у проёмов — «мёртвый»
|
||||
идентификатор указателя мог оборвать перетаскивание. Фигуры декора получили
|
||||
ограничение по границам: их больше нельзя утащить далеко за пределы плана и
|
||||
сохранить там.
|
||||
|
||||
## v1.44.3 — 2026-07-27 (исправление: планы и инструкции снова загружаются)
|
||||
- **У аутентифицированной выдачи контента не было рабочего пути для браузера.**
|
||||
Версия v1.43.0 закрыла дыру правильно, но Home Assistant аутентифицирует
|
||||
HTTP-запросы либо заголовком Bearer, либо подписанным путём `authSig` — а
|
||||
`<image href>` внутри SVG и обычная ссылка `<a href>` не отправляют ни того,
|
||||
ни другого. На настоящем дашборде фоны планов и ссылки на PDF отдавали
|
||||
**401** (воспроизведено вживую до исправления). Теперь карточка просит бэкенд
|
||||
подписать то, что собирается показать (`houseplan/content/sign`, 24 часа,
|
||||
привязано к токену сессии, только для нашего эндпоинта), перерисовывается,
|
||||
когда подписи приходят, и обновляет их каждые 12 часов, чтобы настенные
|
||||
планшеты продолжали работать. Тест бэкенда скачивает подписанный адрес **без**
|
||||
заголовка авторизации и проверяет 200, а без подписи — 401.
|
||||
|
||||
## v1.44.2 — 2026-07-27 (внешнее код-ревью: CR-1…CR-3)
|
||||
|
||||
Второе, состязательное ревью (версии v1.44.0) дало три находки — все закрыты.
|
||||
|
||||
- **Правило про замки теперь сформулировано точно и проверяется (CR-1).**
|
||||
Рецензент справедливо отметил, что утверждение «замок нельзя открыть с плана»
|
||||
было слишком абсолютным: кнопка в карточке двери действительно вызывает
|
||||
сервис. Эта кнопка — осознанное продуктовое решение, поэтому правило
|
||||
переписано там, где ему место («никогда случайным нажатием; ровно одна
|
||||
подписанная поверхность»), отпирание теперь **спрашивает подтверждение**, а
|
||||
новый смок-тест проверяет все пять путей управления и доказывает, что значки,
|
||||
`controls[]` и карточка устройства по-прежнему отказывают замкам.
|
||||
- **Перенос вложений стал транзакционным (CR-2).** При смене привязки маркера
|
||||
файлы раньше ПЕРЕМЕЩАЛИСЬ до сохранения конфига с проверкой ревизии: если
|
||||
сохранение отклонялось, на сервере оставались старые ссылки, а файлы уже
|
||||
уехали. Теперь сервер копирует, конфиг фиксируется, и только после этого
|
||||
старая папка удаляется (`houseplan/files/cleanup`).
|
||||
- **Неудачный или частичный перенос больше не переписывает ссылки (CR-3).**
|
||||
Копирование возвращает точное соответствие «исходное имя → записанное»;
|
||||
переписываются только подтверждённые копии, при совпадении имён файл получает
|
||||
уникальное имя вместо молчаливой ссылки на чужой файл, а ошибка переноса
|
||||
показывается тостом.
|
||||
|
||||
## v1.44.1 — 2026-07-27
|
||||
|
||||
- Ссылка на чат сообщества добавлена везде, где её ищут:
|
||||
**https://t.me/ha_houseplan** (бейдж и строка в шапке обоих README, раздел
|
||||
«Помощь и обмен опытом», контакт-ссылки в шаблонах issue, CONTRIBUTING,
|
||||
STATUS и SCOPE).
|
||||
|
||||
## v1.44.0 — 2026-07-27 (отзыв пользователя: сначала управление)
|
||||
|
||||
- **Карточка устройства стала поверхностью управления.** Она открывается со
|
||||
списка управляемых сущностей: лампы, розетки и вентиляторы переключаются
|
||||
прямо здесь кнопками под палец, шторы, замки и климат передают управление в
|
||||
штатный more-info Home Assistant. Модель, ссылки и PDF-инструкции ушли ниже —
|
||||
на настенном планшете эта карточка нужна для управления домом, а не для
|
||||
чтения документации (из полевого отзыва). Служебные (config/diagnostic)
|
||||
сущности в списке не показываются, замки по-прежнему не переключаются
|
||||
нажатием в карточке.
|
||||
- **«Это устройство — источник света»** — новый флаг у устройства. Умный
|
||||
выключатель с обычными (не умными) светильниками теперь даёт ореол в заливке
|
||||
«Свет по источникам» без создания хелпера-группы: свечение следует за самим
|
||||
выключателем либо за лампами, привязанными в «Управляет источниками света».
|
||||
|
||||
## v1.43.3 — 2026-07-27 (отзыв пользователя: обнаруживаемость и тач)
|
||||
|
||||
- **Настройки комнаты невозможно было найти.** Шестерёнка из v1.42.0 жила
|
||||
внутри подписи комнаты размером 0.9em от её шрифта и с прозрачностью 60% —
|
||||
несколько бледных пикселей на обычном плане. Теперь это кнопка-пилюля
|
||||
«⚙ Комната» фиксированного читаемого размера, не зависящая от масштаба
|
||||
карточки, и она появляется **даже у комнат без имени** (их там и называют).
|
||||
Заодно разблокировались слайдеры размеров шрифта, до которых никто не мог
|
||||
добраться.
|
||||
- **Строка показателей увеличена** с 0.62 до 0.75 от размера названия — автор
|
||||
отзыва мог увеличить название, но строка датчиков оставалась нечитаемой на
|
||||
планшете. Множители комнаты и пространства работают поверх.
|
||||
- **Тултипы на тач-устройствах, вторая попытка.** Проверки `(hover: none)`
|
||||
оказалось мало: некоторые устройства, оболочки, стилусы и подключённые мыши
|
||||
сообщают `hover: hover`, и подсказки продолжали висеть под пальцем. Теперь
|
||||
карточка запоминает первое же касание (touch/pen) и гасит открытую подсказку.
|
||||
|
||||
## v1.43.2 — 2026-07-27 (внешний аудит: слой тестов)
|
||||
|
||||
- **Смок-тесты наконец умеют падать (T1).** Все 48 браузерных смоков печатали
|
||||
булевы значения и всегда завершались с кодом 0 — регрессия была видна в их
|
||||
собственном выводе, а прогон считался успешным. `demo/serve.mjs` теперь
|
||||
экспортирует `check`/`checkAll`/`finish`: каждый факт проверяется по имени,
|
||||
несовпадения и необработанные исключения внутри карточки дают ненулевой код
|
||||
возврата. Проверено намеренной поломкой блокировки редакторов в киоске —
|
||||
соответствующий смок покраснел.
|
||||
- **Набор гоняется в CI (T2)** отдельной джобой `smoke` после `frontend`,
|
||||
против свежесобранного бандла (закоммиченная копия в `demo/srv/assets` —
|
||||
снимок, на нём легко получить «зелёный» отчёт о несуществующем коде), с
|
||||
выгрузкой логов при падении.
|
||||
- **`docs/TESTING.md` приведён в соответствие (T3).** `[auto]` теперь означает
|
||||
«существует именованная проверка, которая падает», и рядом написано, где она;
|
||||
72 пункта, где автоматизация была намерением, честно помечены `[manual]`.
|
||||
Исправлены два давних противоречия: строка про «ноль кнопок редактирования в
|
||||
Просмотре» (неверна с v1.30.1) и строка про клик по проёму (снова верна
|
||||
с v1.43.1).
|
||||
- Три смока проверяли поведение, которого уже нет (ожидания времён v1.39.0 и
|
||||
v1.25); теперь они тестируют текущий контракт.
|
||||
|
||||
## v1.43.1 — 2026-07-27 (внешний аудит: исправления P1)
|
||||
|
||||
- **Стоимость отрисовки (L1).** Home Assistant подменяет объект `hass` при
|
||||
любом изменении состояния в доме, и каждая такая отрисовка пересчитывала всю
|
||||
геометрию плана — `_openPairs()` вызывался по разу на комнату (кубическая
|
||||
математика коллинеарных наложений), модель пространства строилась дважды.
|
||||
Теперь и то, и другое мемоизируется по структурному отпечатку конфига и
|
||||
вынесено из цикла по комнатам; сброс кэша происходит синхронно в момент
|
||||
мутации, а не внутри дебаунса.
|
||||
- **Тап против перетаскивания у проёмов (L4).** У перетаскивания двери или окна
|
||||
не было порога движения, поэтому любое дрожание пальца считалось
|
||||
перетаскиванием: диалог свойств не открывался, а в конфиг писалось
|
||||
неизменённое состояние (что подпитывало гонку L2). Теперь порог 3 px, как во
|
||||
всех остальных сценариях перетаскивания, и запись только при реальном
|
||||
изменении геометрии.
|
||||
- **Вогнутые комнаты (G2).** Вложенность определялась через среднее арифметическое
|
||||
вершин — а оно лежит СНАРУЖИ U- и L-образных комнат, поэтому комнаты-острова
|
||||
в них отвергались как пересечение, а дырка в заливке не рисовалась. Теперь
|
||||
вычисляется настоящая внутренняя точка (`interiorPoint`).
|
||||
- **Дедупликация стен (G3).** `segKey` сортировал концы по сырым float, а
|
||||
печатал округлённые, поэтому одна общая стена могла дать два ключа и
|
||||
рисовалась дважды. Сначала округление, потом сортировка.
|
||||
- **Укрепление бэкенда (B2–B5).** Проверка прав на запись теперь **отказывает**,
|
||||
когда запись о конфигурации недоступна (раньше во время перезагрузки
|
||||
интеграции запись разрешалась); `layout/set` поддерживает `expected_rev` и
|
||||
сообщает о конфликте так же, как хранилище конфига; `config/set` без
|
||||
`expected_rev` поверх непустого хранилища пишет предупреждение в лог;
|
||||
координаты отвергают NaN/Infinity, а у пространств, комнат, маркеров, декора
|
||||
и раскладки появились щедрые ограничения размера.
|
||||
|
||||
## v1.43.0 — 2026-07-27 (внешний аудит: исправления P0)
|
||||
|
||||
Внешний аудит кода версии v1.41.1 нашёл четыре критические проблемы. Все четыре
|
||||
исправлены и покрыты регрессионными тестами.
|
||||
|
||||
- **Молчаливая потеря правок при сохранении (L2).** Отложенная запись конфига
|
||||
читала его в момент срабатывания, поэтому пришедшее в промежутке событие
|
||||
`houseplan_config_updated` подменяло конфиг, и правка пользователя исчезала
|
||||
без единой ошибки — воспроизводилось даже в одной вкладке. Теперь дебаунс
|
||||
умеет `flush()`/`pending()`, перезагрузка сперва дописывает отложенную
|
||||
запись и откладывается, пока запись в полёте, а неудачная перезагрузка
|
||||
наконец сообщает о себе вместо молчания.
|
||||
- **Разрез разрушал геометрию комнаты (G1).** Разрез, начинающийся и
|
||||
заканчивающийся на ОДНОЙ стене (вырезание ниши — совершенно естественное
|
||||
действие), давал две самопересекающиеся комнаты, суммарная площадь которых
|
||||
вдвое превышала исходную, и проверка пересечений это не ловила. Теперь такие
|
||||
разрезы корректно вырезают нишу, а инвариант разбиения (части в сумме дают
|
||||
исходную площадь) отклоняет всё остальное.
|
||||
- **Планы и загруженные файлы отдавались без авторизации (B1).** Любой, кто мог
|
||||
достучаться до вашего Home Assistant, скачивал планы этажей и вложенные
|
||||
инструкции без входа в систему. Теперь их отдаёт аутентифицированный
|
||||
обработчик; сохранённые старые адреса переписываются на чтении, так что
|
||||
ничего не ломается. **Старые публичные пути исчезают только после
|
||||
перезапуска Home Assistant.**
|
||||
- **Диалоги могли воскреснуть и обнулить карточку (L3).** Закрытие диалога во
|
||||
время неудачного сохранения превращало его состояние в пустую «оболочку»,
|
||||
отрисовщик падал, и карточка оставалась пустой до перезагрузки страницы.
|
||||
Защита добавлена во все четыре процедуры сохранения, тост об ошибке
|
||||
по-прежнему показывается.
|
||||
|
||||
## v1.42.2 — 2026-07-26
|
||||
|
||||
- На тач-устройствах подсказки при наведении больше не выскакивают при каждом
|
||||
касании (из отзыва: «на планшете при тапе вылезают доп. надписи — мешают»).
|
||||
Подсказки теперь только для мыши; на тач та же информация есть в карточках
|
||||
комнат и в карточке устройства по долгому нажатию.
|
||||
|
||||
## v1.42.1 — 2026-07-26 (размеры шрифтов карточек комнат)
|
||||
|
||||
- Закрываем отзыв «нельзя настроить размер шрифта»: **три слайдера**. В
|
||||
настройках пространства появился базовый размер шрифта карточек комнат для
|
||||
всего пространства; в настройках комнаты — независимые размеры **названия** и
|
||||
**строки показателей** (50–300% каждый). Эффекты перемножаются и складываются
|
||||
с растягиванием карточки за уголки и множителем экрана в киоск-режиме.
|
||||
- В обоих диалогах показывается **живой пример карточки**, который меняется
|
||||
прямо во время перетаскивания слайдеров.
|
||||
|
||||
## v1.42.0 — 2026-07-26 (настройки комнаты — третий уровень)
|
||||
|
||||
- **У настроек теперь четыре уровня**: общие → пространство → комната →
|
||||
устройство; более конкретный уровень переопределяет более общий (решение
|
||||
владельца, зафиксировано в ARCHITECTURE). В этом релизе добавлен уровень
|
||||
КОМНАТЫ.
|
||||
- У каждой карточки комнаты в редакторе плана появилась **шестерёнка**:
|
||||
переименовать комнату, сменить её зону HA, переопределить **тип заливки**
|
||||
только для этой комнаты (работает и в glow-пространствах — «без заливки»
|
||||
выводит комнату из темноты) и выбрать явный **источник температуры и
|
||||
влажности** — любое устройство или сущность HA вместо среднего по комнате.
|
||||
Источник питает карточку комнаты, всплывающую подсказку и температурную
|
||||
заливку и работает даже у комнат без зоны HA (случай из отзыва: собственный
|
||||
template-сенсор, привязанный к помещению).
|
||||
- Тот же раздел настроек появляется в диалоге комнаты сразу после замыкания
|
||||
контура.
|
||||
+3
-1
@@ -5,7 +5,9 @@
|
||||
> state, where everything lives, and how to continue safely.
|
||||
>
|
||||
> **Documentation policy (mandatory):** every change is documented *in the same
|
||||
> commit* — CHANGELOG entry for anything user-visible, STATUS.md for state changes
|
||||
> commit* — a CHANGELOG entry for anything user-visible **in BOTH
|
||||
> `docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian, since
|
||||
> v1.42.0 — the user base is largely Russian-speaking, see the Telegram chat)**, STATUS.md for state changes
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
|
||||
@@ -50,6 +50,29 @@
|
||||
never silently linked); urls are rewritten only for confirmed copies
|
||||
[auto: unit logic.test + tests_backend]
|
||||
|
||||
- [ ] Plans and PDFs load in a real browser (v1.44.3, B1 regression): open a
|
||||
dashboard with an uploaded plan — the background renders and a manual link
|
||||
opens; DevTools shows /api/houseplan/content/... returning 200 via a
|
||||
signed url, while the same url without authSig returns 401
|
||||
[auto: tests_backend + manual]
|
||||
- [ ] Auth policy is single-sourced (v1.44.4, B2): the HTTP upload and every WS
|
||||
write use the same `may_write`, which denies non-admins when the config
|
||||
entry is unavailable [auto: tests_backend]
|
||||
- [ ] Coordinates and caps (v1.44.4, B5): NaN/Infinity are refused on room
|
||||
rects, polygon vertices, view_box and openings — not only in layout; the
|
||||
openings list honours MAX_OPENINGS [auto: tests_backend]
|
||||
- [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the
|
||||
pointer through the tolerant helper; decor shapes cannot be dragged more
|
||||
than a quarter of the plan outside the viewBox [auto: smoke_decor]
|
||||
|
||||
- [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT
|
||||
placed on the plan (hidden by curation or by the user) still feeds the
|
||||
room card, the tooltip and the temperature fill; fridges/TRVs still do
|
||||
not; an explicit per-room source still wins [auto: unit devices.test]
|
||||
- [ ] Room tooltip wording (v1.44.5): hovering a room shows its name (plus
|
||||
temperature/signal when available) and no longer claims "open the area" —
|
||||
room clicks were removed in v1.40.1 [manual]
|
||||
|
||||
## Environments matrix
|
||||
|
||||
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.5",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+47
-1
@@ -162,7 +162,7 @@ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: s
|
||||
}
|
||||
|
||||
/** Icon with the full fallback chain: name rules → entity device_class → chip. */
|
||||
function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
|
||||
export function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
|
||||
const byRules = iconFor(name, model, rules);
|
||||
if (byRules !== FALLBACK_ICON) return byRules;
|
||||
const classes: string[] = [];
|
||||
@@ -400,6 +400,52 @@ export function areaHum(
|
||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Room climate from EVERY sensor of the area — including devices that are not
|
||||
* placed on the plan (hidden by curation or by the user). The old helpers read
|
||||
* the visible-icon list, so hiding a thermometer silently removed it from the
|
||||
* room card (field report, 2026-07-27).
|
||||
*
|
||||
* Curation is kept: only devices the card itself recognises as thermometers /
|
||||
* air monitors count, so fridges, TRVs and chip-temperature plugs stay out.
|
||||
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
||||
* a device measures.
|
||||
*/
|
||||
export function areaClimate(
|
||||
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
|
||||
): number | null {
|
||||
if (!area || !hass?.entities) return null;
|
||||
const groups = new Map<string, { name: string; model?: string; ents: string[] }>();
|
||||
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
|
||||
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
|
||||
const entArea = reg.area_id || dev?.area_id || null;
|
||||
if (entArea !== area) continue;
|
||||
const key = reg.device_id || eid;
|
||||
if (!groups.has(key)) {
|
||||
const st = hass.states?.[eid];
|
||||
groups.set(key, {
|
||||
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
|
||||
model: dev?.model,
|
||||
ents: [],
|
||||
});
|
||||
}
|
||||
groups.get(key)!.ents.push(eid);
|
||||
}
|
||||
const vals: number[] = [];
|
||||
for (const g of groups.values()) {
|
||||
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
||||
const ok = kind === 'temp'
|
||||
? icon === 'mdi:thermometer' || icon === 'mdi:air-filter'
|
||||
: icon === 'mdi:thermometer' || icon === 'mdi:air-filter' || icon === 'mdi:water-percent';
|
||||
if (!ok) continue;
|
||||
const v = kind === 'temp' ? tempFor(hass, g.ents) : humFor(hass, g.ents);
|
||||
if (v != null) vals.push(v);
|
||||
}
|
||||
if (!vals.length) return null;
|
||||
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
return kind === 'temp' ? Math.round(avg * 10) / 10 : Math.round(avg);
|
||||
}
|
||||
|
||||
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
||||
export function areaLightStats(
|
||||
hass: any,
|
||||
|
||||
+92
-16
@@ -22,7 +22,7 @@ import {
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from './devices';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate } from './devices';
|
||||
import type {
|
||||
OpeningCfg,
|
||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.44.2';
|
||||
const CARD_VERSION = '1.44.5';
|
||||
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';
|
||||
@@ -90,6 +90,22 @@ const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number): Debounced
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture the pointer for a drag, tolerating an inactive pointerId.
|
||||
*
|
||||
* `setPointerCapture` throws for synthetic events and for pointers some
|
||||
* browsers consider gone; that killed a drag outright. The opening pipeline
|
||||
* was hardened for this, the device/label/resize ones were not (audit
|
||||
* follow-up L4 sub-item) — now they all go through here.
|
||||
*/
|
||||
const capturePointer = (ev: PointerEvent): void => {
|
||||
try {
|
||||
(ev.target as Element | null)?.setPointerCapture?.(ev.pointerId);
|
||||
} catch {
|
||||
/* an inactive pointerId must never kill the drag */
|
||||
}
|
||||
};
|
||||
|
||||
class HouseplanCard extends LitElement {
|
||||
public hass?: any;
|
||||
private _config?: CardConfig;
|
||||
@@ -348,6 +364,12 @@ class HouseplanCard extends LitElement {
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this._keyHandler);
|
||||
// signatures expire (24 h); refresh well before that on long-lived screens
|
||||
clearInterval(this._resignTimer);
|
||||
this._resignTimer = window.setInterval(() => {
|
||||
this._signed = {};
|
||||
this.requestUpdate();
|
||||
}, 12 * 3600 * 1000);
|
||||
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
|
||||
clearInterval(this._cycleTimer);
|
||||
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
||||
@@ -361,6 +383,9 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._kioskDotsTimer);
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
clearTimeout(this._reloadRetry);
|
||||
clearTimeout(this._signTimer);
|
||||
clearInterval(this._resignTimer);
|
||||
clearTimeout(this._toastTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
clearTimeout(this._holdTimer);
|
||||
@@ -571,7 +596,7 @@ class HouseplanCard extends LitElement {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
bg: s.plan_url ? { href: this._display(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
rooms: s.rooms.map(scale),
|
||||
};
|
||||
});
|
||||
@@ -756,6 +781,46 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _reloadRetry?: number;
|
||||
/**
|
||||
* Signed urls for the content endpoint (audit follow-up B1 regression).
|
||||
* A browser cannot authenticate an <image href> or an <a href>: HA takes a
|
||||
* Bearer header or an `authSig` signed path, and an element sends neither.
|
||||
* So the card asks the backend to sign what it is about to display.
|
||||
*/
|
||||
private _signed: Record<string, string> = {};
|
||||
private _signPending = new Set<string>();
|
||||
private _signTimer?: number;
|
||||
|
||||
/** Display url: the signed variant when we have one, else the plain path. */
|
||||
private _display(url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
if (this._signed[u]) return this._signed[u];
|
||||
this._requestSignature(u);
|
||||
return u; // first paint may 401; the signature lands and re-renders
|
||||
}
|
||||
|
||||
private _requestSignature(url: string): void {
|
||||
if (this._signPending.has(url) || !this.hass?.callWS) return;
|
||||
this._signPending.add(url);
|
||||
clearTimeout(this._signTimer);
|
||||
// batch: a plan switch asks for several urls in the same tick
|
||||
this._signTimer = window.setTimeout(() => {
|
||||
const paths = [...this._signPending];
|
||||
this._signPending.clear();
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
this._signed = { ...this._signed, ...r.urls };
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined); // unsigned urls simply keep failing; no loop
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/** Re-sign everything periodically: a wall tablet outlives a signature. */
|
||||
private _resignTimer?: number;
|
||||
private _dirtyPos = new Set<string>();
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
@@ -1309,7 +1374,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.preventDefault();
|
||||
const p = this._pos(d);
|
||||
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
this._tip = null;
|
||||
}
|
||||
|
||||
@@ -1652,7 +1717,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.preventDefault();
|
||||
const p = this._snap(this._svgPoint(ev));
|
||||
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
|
||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
return true;
|
||||
}
|
||||
if (t === 'text') {
|
||||
@@ -1712,15 +1777,25 @@ class HouseplanCard extends LitElement {
|
||||
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
|
||||
pid: ev.pointerId, moved: false,
|
||||
};
|
||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
}
|
||||
|
||||
private _decorMoveUpdate(ev: PointerEvent): void {
|
||||
const m = this._decorMove!;
|
||||
const p = this._svgPoint(ev);
|
||||
const g = this._gridPitch;
|
||||
const dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
||||
const dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
||||
let dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
||||
let dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
||||
// audit follow-up L4: decor had neither a threshold nor a bounds clamp, so
|
||||
// a shape could be dragged far outside the viewBox and persisted there.
|
||||
const o = m.orig;
|
||||
const curX = o.kind === 'line' ? Math.min(o.x1, o.x2) : o.x;
|
||||
const curY = o.kind === 'line' ? Math.min(o.y1, o.y2) : o.y;
|
||||
const w = o.kind === 'line' ? Math.abs(o.x2 - o.x1) : (o.w || 0);
|
||||
const h = o.kind === 'line' ? Math.abs(o.y2 - o.y1) : (o.h || 0);
|
||||
const lim = 0.25; // a quarter of the plan may hang outside, no more
|
||||
dx = Math.max(-curX - lim, Math.min(1 + lim - curX - w, dx));
|
||||
dy = Math.max(-curY - lim, Math.min(1 + lim - curY - h, dy));
|
||||
if (dx || dy) m.moved = true;
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = this._decorList.map((x) => {
|
||||
@@ -2031,7 +2106,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
try {
|
||||
(ev.target as Element).setPointerCapture?.(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
} catch {
|
||||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||||
}
|
||||
@@ -3579,7 +3654,7 @@ class HouseplanCard extends LitElement {
|
||||
style = st.join(';');
|
||||
}
|
||||
const tip = (e: MouseEvent) =>
|
||||
this._showTip(e, r.name, this._t('tip.room'),
|
||||
this._showTip(e, r.name, '',
|
||||
showLqi ? this._roomLqi(r.area) : null,
|
||||
this._roomTemp(r));
|
||||
const label = !space.bg && !disp.showNames && !this._markup;
|
||||
@@ -3759,14 +3834,15 @@ class HouseplanCard extends LitElement {
|
||||
private _roomTemp(r: RoomCfg): number | null {
|
||||
const src = r.settings?.temp_source;
|
||||
if (src) return sourceValue(this.hass, src, 'temp');
|
||||
return r.area ? areaTemp(this.hass, this._devices, r.area) : null;
|
||||
// every sensor of the area, placed on the plan or not (field report)
|
||||
return r.area ? areaClimate(this.hass, r.area, 'temp', this._iconRules) : null;
|
||||
}
|
||||
|
||||
/** Room humidity honouring the tier-3 source override. */
|
||||
private _roomHum(r: RoomCfg): number | null {
|
||||
const src = r.settings?.hum_source;
|
||||
if (src) return sourceValue(this.hass, src, 'hum');
|
||||
return r.area ? areaHum(this.hass, this._devices, r.area) : null;
|
||||
return r.area ? areaClimate(this.hass, r.area, 'hum', this._iconRules) : null;
|
||||
}
|
||||
|
||||
private _resetRoomDialogFields(): void {
|
||||
@@ -3880,7 +3956,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const p = this._labelPos(r, spaceId);
|
||||
this._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
this._tip = null;
|
||||
}
|
||||
|
||||
@@ -3926,7 +4002,7 @@ class HouseplanCard extends LitElement {
|
||||
const cy = b.top + b.height / 2;
|
||||
const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy));
|
||||
this._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
}
|
||||
|
||||
private _rlResizeMove(ev: PointerEvent): void {
|
||||
@@ -4602,7 +4678,7 @@ class HouseplanCard extends LitElement {
|
||||
${d.pdfs && d.pdfs.length
|
||||
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
|
||||
${d.pdfs.map(
|
||||
(p) => html`<a class="pdf" href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
|
||||
(p) => html`<a class="pdf" href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
|
||||
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
|
||||
)}</span></div>`
|
||||
: nothing}
|
||||
@@ -4839,7 +4915,7 @@ class HouseplanCard extends LitElement {
|
||||
<div class="pdfedit">
|
||||
${d.pdfs.map(
|
||||
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
|
||||
<a href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
|
||||
<a href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
|
||||
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
|
||||
)}
|
||||
<label class="btn filebtn">
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"markup.delete": "Delete",
|
||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
|
||||
"markup.hint_start": "click a grid dot to start the outline",
|
||||
"tip.room": "room — open the area",
|
||||
"tip.lqi": "average zigbee signal:",
|
||||
"info.device_header": "Device on the plan",
|
||||
"info.model": "Model",
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||
"tip.room": "комната — открыть зону",
|
||||
"tip.lqi": "средний сигнал zigbee:",
|
||||
"info.device_header": "Устройство на плане",
|
||||
"info.model": "Модель",
|
||||
|
||||
@@ -109,6 +109,8 @@ class HouseplanSpaceCard extends LitElement {
|
||||
|
||||
public getCardSize(): number {
|
||||
const models = spaceModels(this._snap?.config || null);
|
||||
// B1 follow-up: the plan <image> needs a signed url in a browser session
|
||||
for (const m of models) if (m.bg?.href) this._signBg(m.bg);
|
||||
const sp = models.find((s) => s.id === this._config?.space);
|
||||
if (sp) {
|
||||
const ratio = sp.vb[3] / sp.vb[2]; // h/w
|
||||
@@ -121,6 +123,27 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
||||
}
|
||||
|
||||
private _signedBg: Record<string, string> = {};
|
||||
private _signBgPending = new Set<string>();
|
||||
|
||||
/** Ask the backend for a signed content url and swap it in when it arrives. */
|
||||
private _signBg(bg: { href: string }): void {
|
||||
const raw = bg.href.split('?authSig=')[0];
|
||||
if (!raw.startsWith('/api/houseplan/content/')) return;
|
||||
if (this._signedBg[raw]) { bg.href = this._signedBg[raw]; return; }
|
||||
if (this._signBgPending.has(raw) || !this.hass?.callWS) return;
|
||||
this._signBgPending.add(raw);
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: [raw] })
|
||||
.then((r: any) => {
|
||||
const url = r?.urls?.[raw];
|
||||
if (!url) return;
|
||||
this._signedBg = { ...this._signedBg, [raw]: url };
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._config) return nothing;
|
||||
const cfg = this._snap?.config;
|
||||
|
||||
+42
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from '../test-build/devices.js';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate } from '../test-build/devices.js';
|
||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -359,3 +359,44 @@ test('sourceValue: explicit entity and device sources (tier 3)', () => {
|
||||
assert.equal(sourceValue(hass, '', 'temp'), null);
|
||||
assert.equal(sourceValue(hass, 'garbage', 'temp'), null);
|
||||
});
|
||||
|
||||
test('areaClimate: counts sensors that are NOT on the plan (field report)', () => {
|
||||
const hass = {
|
||||
devices: {
|
||||
d1: { id: 'd1', name: 'Датчик температуры спальня', area_id: 'bed' },
|
||||
d2: { id: 'd2', name: 'Холодильник', model: 'LG', area_id: 'bed' },
|
||||
d3: { id: 'd3', name: 'Qingping air monitor', area_id: 'bed' },
|
||||
d4: { id: 'd4', name: 'Датчик температуры кухня', area_id: 'kitchen' },
|
||||
},
|
||||
entities: {
|
||||
'sensor.bed_t': { device_id: 'd1' },
|
||||
'sensor.bed_h': { device_id: 'd1' },
|
||||
'sensor.fridge_t': { device_id: 'd2' },
|
||||
'sensor.air_t': { device_id: 'd3' },
|
||||
'sensor.air_h': { device_id: 'd3' },
|
||||
'sensor.kitchen_t': { device_id: 'd4' },
|
||||
},
|
||||
states: {
|
||||
'sensor.bed_t': { state: '21.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.bed_h': { state: '40', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.fridge_t': { state: '4', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.air_t': { state: '23.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.air_h': { state: '50', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.kitchen_t': { state: '30.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
},
|
||||
};
|
||||
// среднее по двум термометрам зоны; ни одно устройство не «размещено» на плане
|
||||
assert.equal(areaClimate(hass, 'bed', 'temp'), 22);
|
||||
assert.equal(areaClimate(hass, 'bed', 'hum'), 45);
|
||||
// холодильник по-прежнему не считается климатом комнаты
|
||||
assert.notEqual(areaClimate(hass, 'bed', 'temp'), (21 + 4 + 23) / 3);
|
||||
// чужая зона не подмешивается
|
||||
assert.equal(areaClimate(hass, 'kitchen', 'temp'), 30);
|
||||
assert.equal(areaClimate(hass, 'nowhere', 'temp'), null);
|
||||
// сущность со своей area_id учитывается, даже если устройство в другой зоне
|
||||
const hass2 = {
|
||||
...hass,
|
||||
entities: { ...hass.entities, 'sensor.kitchen_t': { device_id: 'd4', area_id: 'bed' } },
|
||||
};
|
||||
assert.equal(areaClimate(hass2, 'kitchen', 'temp'), null);
|
||||
});
|
||||
|
||||
@@ -177,3 +177,47 @@ async def test_files_migrate_copies_and_reports_mapping(
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["removed"] is True
|
||||
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
|
||||
|
||||
|
||||
async def test_content_signed_path_opens_without_a_bearer_header(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
|
||||
) -> None:
|
||||
"""B1 follow-up: a browser <image>/<a> sends no Authorization header.
|
||||
|
||||
The unsigned url must be refused and the signed one must work — otherwise
|
||||
plan backgrounds and PDF links 401 on a real dashboard (reproduced live,
|
||||
2026-07-27).
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import CONTENT_URL, PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
|
||||
def _write() -> None:
|
||||
os.makedirs(plans, exist_ok=True)
|
||||
with open(os.path.join(plans, "s1.png"), "wb") as fh:
|
||||
fh.write(b"PNGDATA")
|
||||
|
||||
await hass.async_add_executor_job(_write)
|
||||
path = f"{CONTENT_URL}/plans/_/s1.png"
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "houseplan/content/sign", "paths": [path]})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"], resp
|
||||
signed = resp["result"]["urls"][path]
|
||||
assert "authSig=" in signed
|
||||
|
||||
http = await hass_client_no_auth()
|
||||
assert (await http.get(path)).status == 401 # unsigned: refused
|
||||
ok = await http.get(signed)
|
||||
assert ok.status == 200 and await ok.read() == b"PNGDATA"
|
||||
|
||||
# only our own endpoint may be signed
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/content/sign", "paths": ["/api/other/secret"]}
|
||||
)
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["urls"] == {}
|
||||
|
||||
@@ -140,3 +140,36 @@ def test_collection_caps():
|
||||
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA(big)
|
||||
|
||||
|
||||
def test_finite_on_every_coordinate():
|
||||
"""audit follow-up B5: NaN/Infinity must be refused everywhere, not only in layout."""
|
||||
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 100, 100], "rooms": []}
|
||||
# view_box
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, "NaN", 100]}]})
|
||||
# room rect coordinates
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "x": "Infinity", "y": 0, "w": 1, "h": 1}]}]})
|
||||
# polygon vertices
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[0, 0], [1, "NaN"], [1, 1]]}]}]})
|
||||
# opening coordinates
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
|
||||
{"id": "o", "type": "door", "x": "NaN", "y": 0.5, "angle": 0, "length": 0.1}]}]})
|
||||
# a sane config still validates
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[0, 0], [1, 0], [1, 1]]}]}]})
|
||||
|
||||
|
||||
def test_openings_cap_enforced():
|
||||
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
|
||||
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}
|
||||
for i in range(v.MAX_OPENINGS + 1)]
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
|
||||
"view_box": [0, 0, 100, 100], "rooms": [],
|
||||
"openings": many}]})
|
||||
|
||||
Reference in New Issue
Block a user