Merge dev: v1.40.2..v1.41.1 (kiosk mode, speaker icons, SEO README, new demo GIF)
Validate / backend (push) Failing after 6m2s
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m21s

This commit is contained in:
Matysh
2026-07-23 21:49:55 +03:00
26 changed files with 705 additions and 89 deletions
+49 -3
View File
@@ -1,11 +1,40 @@
# 🏠 House Plan — an interactive house plan for Home Assistant # 🏠 House Plan — interactive floor plan card for Home Assistant
**A live map of your home right inside Home Assistant: floors, rooms and devices on a real floor plan — with live states, temperature and signal strength. Everything is configured with the mouse, without a single line of YAML.** [![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
![House Plan demo](docs/images/demo.gif) **Turn Home Assistant into a live, interactive map of your home.** Upload or draw
a floor plan, outline the rooms with your mouse — and every smart device appears
in its real place: live states, tap-to-toggle lights, temperature and humidity per
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
🇷🇺 [Документация на русском](README.ru.md) 🇷🇺 [Документация на русском](README.ru.md)
**Feature highlights**
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
virtual walls and a visual decor layer, all drawn with clicks; smart
alignment guides and a live ruler in real meters/feet.
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
of its own color that spills through doorways and open zone boundaries.
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
comfort-range temperature fills, per-room signal heatmap.
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
explicit button, never an accidental tap.
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
auto-carousel, per-screen icon sizes.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
--- ---
## What it is and why ## What it is and why
@@ -49,6 +78,23 @@ Key advantages in short:
--- ---
## Wall tablet / TV (kiosk mode)
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
(or tick "Wall device (kiosk) mode" in the card editor):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
```
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
tune icon and text sizes for THIS screen (saved per device). To hide Home
Assistant's own header use the companion app's kiosk settings or the
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
## Installation ## Installation
One click if you already run HACS: One click if you already run HACS:
+49 -4
View File
@@ -1,13 +1,41 @@
# 🏠 House Plan — интерактивный план дома для Home Assistant # 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
**Живая карта вашего дома прямо в Home Assistant: этажи, комнаты и устройства на настоящем плане — с реальными состояниями, температурой и уровнем сигнала. Всё настраивается мышкой, без единой строчки YAML.** [![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
![House Plan demo](docs/images/demo.gif) **Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
своих местах: живые состояния, свет по клику, температура и влажность по
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
🇬🇧 [English documentation](README.md) ![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
🇬🇧 [Documentation in English](README.md)
**Главное**
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
--- ---
## Что это и зачем ## Что это и зачем
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота. House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
@@ -49,6 +77,23 @@ House Plan показывает ваш умный дом так, как он в
--- ---
## Настенный планшет / ТВ (киоск-режим)
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
галочка «Режим настенного устройства» в редакторе карточки):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
```
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
настройка размеров значков и текста для ЭТОГО экрана (хранится на
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
## Установка ## Установка
В один клик, если у вас уже есть HACS: В один клик, если у вас уже есть HACS:
+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.40.1" VERSION = "1.41.1"
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.40.1" "version": "1.41.1"
} }
+73
View File
@@ -0,0 +1,73 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c0 = window.__card;
// создать киоск-экземпляр
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = c0.hass;
document.body.appendChild(c);
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 400));
c.hass = { ...c0.hass };
await c.updateComplete;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) шапка скрыта
const hdr = sr().querySelector('.hdr');
out.headerHidden = !hdr || getComputedStyle(hdr).display === 'none';
// 2) редакторы заблокированы
c._setMode('plan'); await c.updateComplete;
out.editorsBlocked = c._mode === 'view';
// 3) свайп при 1:1 переключает пространство (и точки показываются)
const s0 = c._space;
const stage = sr().querySelector('.stage');
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.swipeSwitches = c._space !== s0;
out.dotsShown = !!sr().querySelector('.kioskdots');
// 4) при зуме свайп не переключает
c._zoom = 2; const s1 = c._space;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.noSwipeZoomed = c._space === s1;
// 5) двойной тап сбрасывает зум
c._lastTap = Date.now() - 100;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
await c.updateComplete;
out.dblTapResets = c._zoom === 1;
// 6) локальный множитель иконок применяется
c._saveKioskScale({ icon: 2 }); await c.updateComplete;
const dl = sr().querySelector('.devlayer');
const size1 = dl.getAttribute('style');
c._saveKioskScale({ icon: 1 }); await c.updateComplete;
const size2 = sr().querySelector('.devlayer').getAttribute('style');
const v1 = parseFloat(size1.match(/--icon-size:([\d.]+)/)[1]);
const v2 = parseFloat(size2.match(/--icon-size:([\d.]+)/)[1]);
out.iconScaleWorks = Math.abs(v1 / v2 - 2) < 0.01;
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
c._kioskDialog = true; await c.updateComplete;
out.dialogRenders = !!sr().querySelector('.dialog input[type="range"]');
c._kioskDialog = false;
// 8) карусель: тик двигает пространство, пауза после касания работает
const cs = c._space;
c._config = { ...c._config, cycle: 1 };
c._cyclePausedUntil = 0;
c._cycleTick(); await c.updateComplete;
out.cycleAdvances = c._space !== cs;
c._cyclePausedUntil = Date.now() + 60000;
const cs2 = c._space;
c._cycleTick(); await c.updateComplete;
out.cyclePaused = c._space === cs2;
// 9) обычная карточка (не киоск): шапка на месте, свайпа нет
const sr0 = c0.shadowRoot || c0.renderRoot;
out.normalHeader = !!sr0.querySelector('.hdr') && getComputedStyle(sr0.querySelector('.hdr')).display !== 'none';
c.remove();
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+61 -19
View File
File diff suppressed because one or more lines are too long
+31
View File
@@ -212,3 +212,34 @@ footer button lives outside it and stays clickable.
(default target `/plan-doma`). The full card reads `#space=<id>` on load (a valid id wins over (default target `/plan-doma`). The full card reads `#space=<id>` on load (a valid id wins over
`default_floor`) and on `hashchange`, without blocking manual space switching; an invalid/absent `default_floor`) and on `hashchange`, without blocking manual space switching; an invalid/absent
hash falls back to the default. hash falls back to the default.
## Additions v1.28v1.41 (2026-07-24)
- **Decor layer** (`space.decor[]`, v1.33): purely visual shapes
(line/rect/ellipse/text, normalized coords, per-shape style) drawn in the
SVG right above the background image and BELOW rooms; pointer-events only
inside the Background editor. Validated by `DECOR_SCHEMA` (vol.Any of the
four kinds).
- **Glow fill** (v1.35+): `fill_mode: 'glow'` paints every room with
`fill_colors.glow_base` and renders per-source radial gradients clipped by
a per-light `clipPath` = zone polygons + doorway sectors, each contour a
SEPARATE clipPath child (children union; subpaths of one nonzero path
cancel on opposite windings — field bug v1.36.3). Radius: global
`settings.glow_radius_cm` + per-marker `glow_radius_cm`.
- **Open boundaries** (v1.37): `room.open_to: [roomId]` symmetric links;
light zones are connected components (`openZoneOf`); shared stretches
computed by collinear-overlap math (`sharedBoundary`) and drawn as a TRUE
dash — both room outlines and derived wall segments are trimmed
(`outlineWithout`/`cutSegments`).
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
toggled as one HA-group-semantics service call on the marker's EXPLICIT
tap_action=toggle; icon state/tint mirrors the targets. `primaryEntity`
picks in tiers so domain priority beats registry `hidden` (grouped lamps).
- **Island rooms** (v1.34): full nesting is legal (`polyContainsPoly`);
parents render as evenodd paths with holes (`islandsOf`).
- **Kiosk mode** (v1.41): a card-config flag, not a mode — `_setMode` is
hard-blocked, header hidden, swipe/carousel handled in the stage pointer
pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`.
- **Nav persistence** (v1.38.2): `LS_NAV` stores {space, mode}; hash
deep-link > saved > default_floor; stale-cache retry after the live load.
+25
View File
@@ -1,5 +1,30 @@
# Changelog # Changelog
## v1.41.1 — 2026-07-24 (docs)
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
floor plan card for Home Assistant"), badges, a feature-highlights list
covering glow, controls, kiosk, virtual walls and room cards; the kiosk
recipe moved next to Installation. Demo GIF slot ready for the new capture.
## v1.41.0 — 2026-07-24 (kiosk mode for wall devices)
- New card option **`kiosk: true`** (also in the GUI editor): the full View
experience — live states, glow, lamp taps, info cards, locks — with no
header and no editors at all, sized for a wall tablet or TV.
- **Swipe** left/right switches spaces at 1:1 zoom (wrap-around, dots
indicator); while zoomed the gesture pans as usual; **double tap** resets
zoom. **`cycle: N`** auto-advances spaces every N seconds (pauses for a
minute after any touch) — for TVs and against OLED burn-in.
- **Per-screen sizes**: long-press an empty spot (3 s) to open a popover with
icon and room-card-font multipliers, stored in this device's localStorage —
every tablet/TV tunes itself once.
- Recipe for a full-screen wall dashboard (panel view + kiosk-mode/companion
settings) added to the README.
## v1.40.2 — 2026-07-24
- Default icon rules: smart speakers (Yandex/Alice stations, «колонка»,
generic speakers) now get **mdi:speaker**; mdi:soundbar stays for actual
soundbars. Applies where the icon rules haven't been customized.
## v1.40.1 — 2026-07-23 ## v1.40.1 — 2026-07-23
- Rooms are no longer clickable in View (default cursor, empty space does - Rooms are no longer clickable in View (default cursor, empty space does
nothing). Instead the room card shows a small **open-in-new icon** after nothing). Instead the room card shows a small **open-in-new icon** after
+33 -9
View File
@@ -9,18 +9,42 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas, > (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move. > ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-07-17) ## Snapshot (2026-07-24)
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.27.0** everywhere (manifest, const.py, package.json, CARD_VERSION) | | Version | **v1.41.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) | | 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) |
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) | | GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion | | CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations | | HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 13 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.23.1**, installed *via HACS* (custom repo). The user has real rooms, 12 openings (doors/windows with TTLock locks and contact sensors) and live fills configured | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.41.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) | | Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | 77 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) | | Tests | 111 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
## Current feature surface (since the 2026-07-17 snapshot)
- **Three editors + View**: Plan / Devices / Background (decor layer v1.33) as
tabs with an X to close; View is the default; nav (space+mode) persists
across reloads (v1.38.2). **Kiosk mode** (v1.41.0): `kiosk: true` — no
header/editors, swipe between spaces, double-tap zoom reset, `cycle: N`
carousel, per-screen size multipliers in localStorage.
- **Glow fill** (v1.35v1.37): dark house + per-source light pools (rgb/color
temp/default; per-source radius), door sectors, open boundaries
(`room.open_to`, virtual walls, dashed, transitive light zones).
- **Real switches** (v1.36): `marker.controls[]` group-toggle with HA-group
semantics; icon mirrors targets; hidden grouped lamps fixed (tiered
primaryEntity). **Lights toggle by default** (v1.39): primary domain light
→ tap toggles, kettle-style devices keep the card default.
- **Plan geometry**: polyline split (v1.32), island rooms w/ evenodd holes
(v1.34), smart guides + 45° angle badge (v1.40), opening hover preview.
- **Rooms**: room cards with metrics (temp/hum/lqi/light "1 of 3") and
proportional resize (v1.31); link icon to the HA area (v1.40.1, room taps
removed). **New-device red dot** (v1.29), lock action button (v1.30).
- **Dialog UX**: binding radios + entities checkbox + search dropdown
(v1.38.0); tap actions simplified to Device card / more-info / Toggle,
right-click → more-info (v1.38.1); Esc closes every dialog (v1.30.4).
## Recent milestones (details in CHANGELOG.md) ## Recent milestones (details in CHANGELOG.md)
+7
View File
@@ -140,6 +140,13 @@ 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
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
editor (admins incl.), full-height stage; swipe left/right switches
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
resets zoom; long-press (3 s) on empty plan opens the per-screen size
popover (icon ×0.53, room-card font; localStorage per device);
cycle: N auto-advances spaces with a 60 s pause after any touch;
manual: walk the real wall tablet [auto+manual]
- [ ] Room link icon (v1.40.1): clicking empty room space in View does - [ ] Room link icon (v1.40.1): clicking empty room space in View does
nothing (default cursor); an open-in-new icon after the room name (rooms nothing (default cursor); an open-in-new icon after the room name (rooms
with an HA area, View only) navigates to the area; no icon in editors or with an HA area, View only) navigates to the area; no icon in editors or
+10
View File
@@ -87,3 +87,13 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
editing under Plan; colored frame indicator. editing under Plan; colored frame indicator.
- **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices. - **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices.
- **It.4+:** follow-up features 15 above, each its own release. - **It.4+:** follow-up features 15 above, each its own release.
## Kiosk mode (v1.41.0)
`kiosk: true` on the card is the fourth interaction surface: the full View
experience with the header removed and editors hard-blocked (even for
admins). Swipe switches spaces at 1:1 zoom only (zoomed gestures pan;
double tap resets zoom); `cycle: N` auto-advances spaces with a 60 s pause
after any touch; a 3 s long-press on empty plan opens the per-screen size
popover (localStorage). Nav persistence never restores an editor here.
Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 853 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.40.1", "version": "1.41.1",
"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",
+4
View File
@@ -67,6 +67,8 @@ class HouseplanCardEditor extends LitElement {
{ name: 'show_temperature', selector: { boolean: {} } }, { name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } }, { name: 'live_states', selector: { boolean: {} } },
{ name: 'show_signal', selector: { boolean: {} } }, { name: 'show_signal', selector: { boolean: {} } },
{ name: 'kiosk', selector: { boolean: {} } },
{ name: 'cycle', selector: { number: { min: 0, max: 3600, step: 5, mode: 'box' } } },
]; ];
} }
@@ -82,6 +84,8 @@ class HouseplanCardEditor extends LitElement {
show_temperature: t(L, 'editor.show_temperature'), show_temperature: t(L, 'editor.show_temperature'),
live_states: t(L, 'editor.live_states'), live_states: t(L, 'editor.live_states'),
show_signal: t(L, 'editor.show_signal'), show_signal: t(L, 'editor.show_signal'),
kiosk: t(L, 'editor.kiosk'),
cycle: t(L, 'editor.cycle'),
}; };
return html`<ha-form return html`<ha-form
.hass=${this.hass} .hass=${this.hass}
+141 -7
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, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale,
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,11 +32,12 @@ 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.40.1'; const CARD_VERSION = '1.41.1';
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';
const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were) const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were)
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
const NORM_W = 1000; // width of the render space for normalized configs const NORM_W = 1000; // width of the render space for normalized configs
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved) const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
@@ -102,6 +103,28 @@ class HouseplanCard extends LitElement {
} }
/** Legacy alias: markup machinery is active exactly in plan mode. */ /** Legacy alias: markup machinery is active exactly in plan mode. */
private get _kiosk(): boolean {
return !!this._config?.kiosk;
}
private _showKioskDots(): void {
this._kioskDots = true;
clearTimeout(this._kioskDotsTimer);
this._kioskDotsTimer = window.setTimeout(() => (this._kioskDots = false), 2500);
}
/** Kiosk auto-carousel: advance to the next space every `cycle` seconds. */
private _cycleTick(): void {
if (!this._kiosk || !(Number(this._config?.cycle) > 0)) return;
if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) {
const ids = this._model.map((m) => m.id);
const i = ids.indexOf(this._space);
this._space = ids[(i + 1) % ids.length];
this._restoreZoom();
this._showKioskDots();
}
}
/** Any edit mode is active (plan / devices / decor). */ /** Any edit mode is active (plan / devices / decor). */
private get _editing(): boolean { private get _editing(): boolean {
return this._mode === 'plan' || this._mode === 'devices' || this._mode === 'decor'; return this._mode === 'plan' || this._mode === 'devices' || this._mode === 'decor';
@@ -207,6 +230,16 @@ class HouseplanCard extends LitElement {
private _keyHandler = (e: KeyboardEvent) => this._onKey(e); private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
private _hashApplied = false; private _hashApplied = false;
private _navApplied = false; // the saved space was restored (or the user navigated) private _navApplied = false; // the saved space was restored (or the user navigated)
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 };
private _kioskDialog = false;
private _kioskDots = false;
private _kioskDotsTimer?: number;
private _kioskHoldTimer?: number;
private _cycleTimer?: number;
private _cyclePausedUntil = 0;
private _swipeStart: { x: number; y: number; id: number } | null = null;
private _lastTap = 0;
/** Deep-link: read `#space=<id>` from the URL (used by embedded houseplan-space-card). */ /** Deep-link: read `#space=<id>` from the URL (used by embedded houseplan-space-card). */
private _hashSpace(): string { private _hashSpace(): string {
const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || ''); const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || '');
@@ -251,6 +284,8 @@ class HouseplanCard extends LitElement {
_decorDraft: { state: true }, _decorDraft: { state: true },
_decorSel: { state: true }, _decorSel: { state: true },
_decorTextDialog: { state: true }, _decorTextDialog: { state: true },
_kioskDialog: { state: true },
_kioskDots: { state: true },
_areaSel: { state: true }, _areaSel: { state: true },
_nameSel: { state: true }, _nameSel: { state: true },
_roomDialog: { state: true }, _roomDialog: { state: true },
@@ -267,11 +302,18 @@ class HouseplanCard extends LitElement {
public connectedCallback(): void { public connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
window.addEventListener('keydown', this._keyHandler); window.addEventListener('keydown', this._keyHandler);
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
clearInterval(this._cycleTimer);
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
}
window.addEventListener('hashchange', this._onHashChange); window.addEventListener('hashchange', this._onHashChange);
} }
public disconnectedCallback(): void { public disconnectedCallback(): void {
window.removeEventListener('keydown', this._keyHandler); window.removeEventListener('keydown', this._keyHandler);
clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer);
window.removeEventListener('hashchange', this._onHashChange); window.removeEventListener('hashchange', this._onHashChange);
clearTimeout(this._holdTimer); clearTimeout(this._holdTimer);
this._roViewport?.disconnect(); this._roViewport?.disconnect();
@@ -379,6 +421,12 @@ class HouseplanCard extends LitElement {
} catch { } catch {
this._zoomBySpace = {}; this._zoomBySpace = {};
} }
try {
const ks = JSON.parse(localStorage.getItem(LS_KIOSK) || 'null');
this._kioskScale = { icon: clampScale(ks?.icon), font: clampScale(ks?.font) };
} catch {
/* defaults */
}
// instant render from cache (stale-while-revalidate): show the plan and icons // instant render from cache (stale-while-revalidate): show the plan and icons
// right away, without waiting for the server response — fresh data will load in the background. // right away, without waiting for the server response — fresh data will load in the background.
try { try {
@@ -394,8 +442,9 @@ class HouseplanCard extends LitElement {
else if (nav?.space && this._model.find((sp) => sp.id === nav.space)) { this._space = nav.space; this._navApplied = true; } else if (nav?.space && this._model.find((sp) => sp.id === nav.space)) { this._space = nav.space; this._navApplied = true; }
else if (config.default_floor) this._space = config.default_floor; else if (config.default_floor) this._space = config.default_floor;
else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space; else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space;
// reopenning the tab lands you in the same editor you left (admins only) // reopenning the tab lands you in the same editor you left (admins only);
if (nav?.mode && nav.mode !== 'view' && this._canEdit) this._mode = nav.mode; // kiosk screens always stay in View
if (nav?.mode && nav.mode !== 'view' && this._canEdit && !config.kiosk) this._mode = nav.mode;
} }
} catch { } catch {
/* ignore */ /* ignore */
@@ -1011,6 +1060,23 @@ class HouseplanCard extends LitElement {
} }
private _stagePointerDown(ev: PointerEvent): void { private _stagePointerDown(ev: PointerEvent): void {
if (this._kiosk) {
this._cyclePausedUntil = Date.now() + 60000;
if (this._pointers.size === 0) {
this._swipeStart = { x: ev.clientX, y: ev.clientY, id: ev.pointerId };
// long-press on EMPTY stage opens the per-screen size popover
if (!(ev.target as HTMLElement).closest?.('.dev, .roomlabel, .oplock')) {
clearTimeout(this._kioskHoldTimer);
this._kioskHoldTimer = window.setTimeout(() => {
this._kioskDialog = true;
this._swipeStart = null;
}, 3000);
}
} else {
this._swipeStart = null; // second finger = pinch, not a swipe
clearTimeout(this._kioskHoldTimer);
}
}
// do not interfere with icon dragging and markup drawing // do not interfere with icon dragging and markup drawing
if (this._drag || this._markup) return; if (this._drag || this._markup) return;
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return; if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
@@ -1077,6 +1143,31 @@ class HouseplanCard extends LitElement {
} }
private _stagePointerUp(ev: PointerEvent): void { private _stagePointerUp(ev: PointerEvent): void {
if (this._kiosk) {
clearTimeout(this._kioskHoldTimer);
const ss = this._swipeStart;
this._swipeStart = null;
if (ss && ss.id === ev.pointerId) {
const dx = ev.clientX - ss.x;
const dy = ev.clientY - ss.y;
// double tap (no movement) resets the zoom to 1:1
if (Math.abs(dx) + Math.abs(dy) < 8) {
const now = Date.now();
if (now - this._lastTap < 350) this._resetZoom();
this._lastTap = now;
}
const target = swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
if (target) {
this._space = target;
this._selId = null;
this._restoreZoom();
this._saveNav();
this._suppressClick = true;
setTimeout(() => (this._suppressClick = false), 0);
this._showKioskDots();
}
}
}
if (this._decorDraft?.pid === ev.pointerId) { if (this._decorDraft?.pid === ev.pointerId) {
this._decorCommitDraft(); this._decorCommitDraft();
return; return;
@@ -1218,6 +1309,7 @@ class HouseplanCard extends LitElement {
} }
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void { private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
if (this._kiosk && mode !== 'view') return; // wall devices never edit
if (this._mode === mode) return; if (this._mode === mode) return;
if ((mode === 'plan' || mode === 'decor') && !this._norm) { if ((mode === 'plan' || mode === 'decor') && !this._norm) {
this._showToast(this._t('toast.markup_needs_server')); this._showToast(this._t('toast.markup_needs_server'));
@@ -3045,6 +3137,42 @@ class HouseplanCard extends LitElement {
</div>`; </div>`;
} }
private _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void {
this._kioskScale = { ...this._kioskScale, ...patch };
try {
localStorage.setItem(LS_KIOSK, JSON.stringify(this._kioskScale));
} catch {
/* ignore */
}
this.requestUpdate();
}
/** Per-SCREEN size settings (wall tablets differ) — stored locally. */
private _renderKioskDialog(): TemplateResult {
const k = this._kioskScale;
const row = (key: 'icon' | 'font', label: string) => html`<label>${label}</label>
<div class="colorrow">
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(k[key] * 100))}
@input=${(e: Event) => this._saveKioskScale({ [key]: Number((e.target as HTMLInputElement).value) / 100 })} />
<span class="opv">${Math.round(k[key] * 100)}%</span>
</div>`;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._kioskDialog = false)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:tablet"></ha-icon>${this._t('kiosk.title')}</div>
<div class="body">
<div class="rhint">${this._t('kiosk.hint')}</div>
${row('icon', this._t('kiosk.icon_scale'))}
${row('font', this._t('kiosk.font_scale'))}
</div>
<div class="row">
<button class="btn ghost" @click=${() => this._saveKioskScale({ icon: 1, font: 1 })}>${this._t('gs.reset')}</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => (this._kioskDialog = false)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
}
// ================= render ================= // ================= render =================
protected render(): TemplateResult | typeof nothing { protected render(): TemplateResult | typeof nothing {
@@ -3081,7 +3209,7 @@ class HouseplanCard extends LitElement {
return html` return html`
<ha-card> <ha-card>
<div class="hdr"> <div class="hdr ${this._kiosk ? 'kioskhide' : ''}">
<div class="head"> <div class="head">
<div class="title"> <div class="title">
<ha-icon icon="mdi:home-city"></ha-icon> <ha-icon icon="mdi:home-city"></ha-icon>
@@ -3149,7 +3277,7 @@ class HouseplanCard extends LitElement {
</div> </div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}" <div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
style="height:calc(100dvh - 118px)" style="height:${this._kiosk ? '100dvh' : 'calc(100dvh - 118px)'}"
@click=${(e: MouseEvent) => this._markupClick(e)} @click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)} @wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)} @pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)}
@@ -3249,7 +3377,7 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupLayer(vb) : nothing} ${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)} ${this._renderOpenings(disp)}
</svg> </svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw"> <div class="devlayer" style="--icon-size:${((iconPct * vb[2] * (this._kiosk ? this._kioskScale.icon : 1)) / view.w).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi))} ${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderOpeningLocks(view)} ${this._renderOpeningLocks(view)}
${disp.showNames || this._markup ${disp.showNames || this._markup
@@ -3288,6 +3416,12 @@ class HouseplanCard extends LitElement {
: nothing} : nothing}
</div>` </div>`
: nothing} : nothing}
${this._kiosk && this._kioskDots && this._model.length > 1
? html`<div class="kioskdots">
${this._model.map((m) => html`<span class="kdot ${m.id === this._space ? 'on' : ''}"></span>`)}
</div>`
: nothing}
${this._kioskDialog ? this._renderKioskDialog() : nothing}
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing} ${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
</ha-card> </ha-card>
`; `;
+7 -1
View File
@@ -301,5 +301,11 @@
"marker.show_entities": "Show entities", "marker.show_entities": "Show entities",
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too", "marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
"marker.pick_ph": "Choose a device…", "marker.pick_ph": "Choose a device…",
"room.open_area": "Open the HA area" "room.open_area": "Open the HA area",
"kiosk.title": "This screen's sizes",
"kiosk.hint": "Stored on this device only — every wall tablet or TV can have its own comfortable sizes.",
"kiosk.icon_scale": "Device icon size",
"kiosk.font_scale": "Room card text size",
"editor.kiosk": "Wall device (kiosk) mode",
"editor.cycle": "Auto-switch spaces every N seconds (kiosk, 0 = off)"
} }
+7 -1
View File
@@ -301,5 +301,11 @@
"marker.show_entities": "Отображать сущности", "marker.show_entities": "Отображать сущности",
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности", "marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
"marker.pick_ph": "Выберите устройство…", "marker.pick_ph": "Выберите устройство…",
"room.open_area": "Открыть зону в HA" "room.open_area": "Открыть зону в HA",
"kiosk.title": "Размеры на этом экране",
"kiosk.hint": "Хранится только на этом устройстве — у каждого настенного планшета или ТВ свои удобные размеры.",
"kiosk.icon_scale": "Размер значков устройств",
"kiosk.font_scale": "Размер текста карточек комнат",
"editor.kiosk": "Режим настенного устройства (киоск)",
"editor.cycle": "Автосмена пространств каждые N секунд (киоск, 0 = выкл)"
} }
+24
View File
@@ -965,6 +965,30 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
return cutSegments(edges, cuts, eps); return cutSegments(edges, cuts, eps);
} }
// ---------------- kiosk gestures ----------------
/**
* Kiosk swipe: which neighbouring space a horizontal gesture selects.
* Only fires at 1:1 zoom (owner's decision when zoomed the gesture pans),
* needs a mostly-horizontal move of at least minPx. Wraps around.
*/
export function swipeTarget(
dx: number, dy: number, zoom: number, spaceIds: string[], current: string, minPx = 60,
): string | null {
if (zoom > 1.001 || spaceIds.length < 2) return null;
if (Math.abs(dx) < minPx || Math.abs(dx) < Math.abs(dy) * 1.5) return null;
const i = spaceIds.indexOf(current);
if (i < 0) return null;
const n = spaceIds.length;
return dx < 0 ? spaceIds[(i + 1) % n] : spaceIds[(i - 1 + n) % n];
}
/** Clamp a per-screen size multiplier (icons / room-card font). */
export function clampScale(v: unknown, def = 1): number {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.min(3, Math.max(0.5, n)) : def;
}
// ---------------- alignment guides ---------------- // ---------------- alignment guides ----------------
export interface AlignGuide { export interface AlignGuide {
+2 -1
View File
@@ -46,7 +46,8 @@ export const DEFAULT_ICON_RULES: IconRule[] = [
{ pattern: 'стиральн|washer|washing', icon: 'mdi:washing-machine' }, { pattern: 'стиральн|washer|washing', icon: 'mdi:washing-machine' },
{ pattern: 'сушилк|dryer', icon: 'mdi:tumble-dryer' }, { pattern: 'сушилк|dryer', icon: 'mdi:tumble-dryer' },
{ pattern: 'пылесос|vacuum|dreame|roborock', icon: 'mdi:robot-vacuum' }, { pattern: 'пылесос|vacuum|dreame|roborock', icon: 'mdi:robot-vacuum' },
{ pattern: 'soundbar|колонк|станц|speaker', icon: 'mdi:soundbar' }, { pattern: 'soundbar', icon: 'mdi:soundbar' },
{ pattern: 'колонк|станц|speaker|яндекс|yandex|алиса|alice', icon: 'mdi:speaker' },
{ pattern: 'tv|телевизор|hyundaitv|mitv|television', icon: 'mdi:television' }, { pattern: 'tv|телевизор|hyundaitv|mitv|television', icon: 'mdi:television' },
{ pattern: 'keenetic|роутер|router|mesh|access point', icon: 'mdi:router-wireless' }, { pattern: 'keenetic|роутер|router|mesh|access point', icon: 'mdi:router-wireless' },
{ pattern: 'ибп|ups|kirpich', icon: 'mdi:battery-charging-high' }, { pattern: 'ибп|ups|kirpich', icon: 'mdi:battery-charging-high' },
+20 -1
View File
@@ -364,7 +364,7 @@ export const cardStyles = css`
pointer-events: none; /* draggable only in plan mode (rule below) */ pointer-events: none; /* draggable only in plan mode (rule below) */
position: absolute; position: absolute;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1)); font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1));
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
white-space: nowrap; white-space: nowrap;
@@ -505,6 +505,25 @@ export const cardStyles = css`
color: #4bd28f; color: #4bd28f;
border-color: #4bd28f; border-color: #4bd28f;
} }
.hdr.kioskhide { display: none; }
.kioskdots {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 5;
pointer-events: none;
}
.kdot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--hp-muted);
opacity: 0.55;
}
.kdot.on { background: var(--hp-accent); opacity: 1; }
.measurelabel { .measurelabel {
position: absolute; position: absolute;
transform: translate(12px, -150%); transform: translate(12px, -150%);
+5 -1
View File
@@ -108,5 +108,9 @@ export interface CardConfig {
live_states?: boolean; live_states?: boolean;
show_signal?: boolean; show_signal?: boolean;
language?: string; // 'en' | 'ru' | '' (auto — HA profile) language?: string; // 'en' | 'ru' | '' (auto — HA profile)
tap_action?: string; // 'info' (default) | 'more-info' | 'toggle' tap_action?: string; // legacy, ignored since v1.38.1
/** Wall-device (kiosk) mode: no header, no editors, swipe between spaces. */
kiosk?: boolean;
/** Kiosk auto-carousel: seconds between space switches, 0/undefined = off. */
cycle?: number;
} }
+8 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } from '../test-build/devices.js'; import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } from '../test-build/devices.js';
import { compileIconRules } from '../test-build/rules.js'; import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */ /** Minimal fake hass around the pieces buildDevices reads. */
function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) { function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) {
@@ -330,3 +330,10 @@ test('primaryEntity: hidden light beats visible config switch (grouped lamps)',
}; };
assert.equal(primaryEntity(hass2, ['light.a', 'light.b'], 'mdi:lightbulb'), 'light.b'); assert.equal(primaryEntity(hass2, ['light.a', 'light.b'], 'mdi:lightbulb'), 'light.b');
}); });
test('icon rules: soundbars vs smart speakers (v1.40.2)', () => {
assert.equal(iconFor('Sony soundbar', ''), 'mdi:soundbar');
assert.equal(iconFor('Яндекс Станция Мини', ''), 'mdi:speaker');
assert.equal(iconFor('Колонка Алисы', ''), 'mdi:speaker');
assert.equal(iconFor('Kitchen speaker', ''), 'mdi:speaker');
});
+24
View File
@@ -9,6 +9,7 @@ import {
sharedBoundary, openZoneOf, distToSegment, sharedBoundary, openZoneOf, distToSegment,
outlineWithout, outlineWithout,
alignGuides, segmentAngle, is45, alignGuides, segmentAngle, is45,
swipeTarget, clampScale,
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';
@@ -763,3 +764,26 @@ test('segmentAngle & is45', () => {
assert.ok(is45(44.8) && is45(45.3)); assert.ok(is45(44.8) && is45(45.3));
assert.ok(!is45(30) && !is45(52)); assert.ok(!is45(30) && !is45(52));
}); });
test('swipeTarget: kiosk swipe rules', () => {
const ids = ['f1', 'f2', 'garden'];
// влево → следующее, вправо → предыдущее, по кругу
assert.equal(swipeTarget(-100, 5, 1, ids, 'f1'), 'f2');
assert.equal(swipeTarget(100, 5, 1, ids, 'f1'), 'garden');
assert.equal(swipeTarget(-100, 0, 1, ids, 'garden'), 'f1');
// при зуме — не свайп
assert.equal(swipeTarget(-100, 0, 2, ids, 'f1'), null);
// короткий или диагональный жест — не свайп
assert.equal(swipeTarget(-30, 0, 1, ids, 'f1'), null);
assert.equal(swipeTarget(-80, 70, 1, ids, 'f1'), null);
// одно пространство — некуда
assert.equal(swipeTarget(-100, 0, 1, ['f1'], 'f1'), null);
});
test('clampScale', () => {
assert.equal(clampScale(2), 2);
assert.equal(clampScale(9), 3);
assert.equal(clampScale(0.1), 0.5);
assert.equal(clampScale('x'), 1);
assert.equal(clampScale(undefined, 1.5), 1.5);
});