feat v1.22.0: presence ripples, per-device icon size/rotation, one-click HACS badge

Visuals borrowed from easy-floorplan (github.com/nicosandller/easy-floorplan), model kept ours.
isActiveState is independent of the live_states toggle; unavailable/unknown count as idle.
Honours prefers-reduced-motion. Badges scale with --dev-size. +1 test (74).
This commit is contained in:
Matysh
2026-07-17 07:02:22 +03:00
parent 921facf1ac
commit 7c9731655e
17 changed files with 377 additions and 43 deletions
+5
View File
@@ -51,6 +51,11 @@ Key advantages in short:
## Installation ## Installation
One click if you already run HACS:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
### Via HACS (recommended) ### Via HACS (recommended)
1. Open **HACS → menu (⋮) → Custom repositories**. 1. Open **HACS → menu (⋮) → Custom repositories**.
+5
View File
@@ -51,6 +51,11 @@ House Plan показывает ваш умный дом так, как он в
## Установка ## Установка
В один клик, если у вас уже есть HACS:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
### Через HACS (рекомендуется) ### Через HACS (рекомендуется)
1. Откройте **HACS → меню (⋮) → Custom repositories**. 1. Откройте **HACS → меню (⋮) → Custom repositories**.
+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.21.3" VERSION = "1.22.0"
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.21.3" "version": "1.22.0"
} }
@@ -121,6 +121,11 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("link"): vol.Any(str, None), vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None), vol.Optional("description"): vol.Any(str, None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None), vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
vol.Optional("ripple_color"): vol.Any(str, None),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
vol.Optional("pdfs"): [ vol.Optional("pdfs"): [
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA) vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
], ],
+89 -13
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## v1.22.0 — 2026-07-17 (presence ripples, per-device icon size/rotation, one-click install)
Ideas borrowed from [easy-floorplan](https://github.com/nicosandller/easy-floorplan) — the visuals,
not the model.
- **Presence ripples**: `display: badge | ripple | icon_ripple` per marker, with `ripple_color`
and `ripple_size`. Active → pulsing rings; idle → a faint dot. Gated by the pure
`isActiveState` — independent of the card-wide live_states toggle, and `unavailable`/`unknown`
count as idle so an outage never leaves a ring pulsing. Honours `prefers-reduced-motion`.
- **Per-device icon `size` (×0.53) and `angle`** — sizing now hangs off `--dev-size`, so value
badges scale with the device.
- **One-click install badge** (My Home Assistant → HACS) in both READMEs.
- (+1 test.)
## v1.21.3 — 2026-07-16 (room labels: no text shadow) ## v1.21.3 — 2026-07-16 (room labels: no text shadow)
- Room name labels no longer carry a text shadow — crisper look on both the white - Room name labels no longer carry a text shadow — crisper look on both the white
hand-drawn canvas and plan images. hand-drawn canvas and plan images.
+2 -2
View File
@@ -13,14 +13,14 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.21.3** everywhere (manifest, const.py, package.json, CARD_VERSION) | | Version | **v1.22.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 | | GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
| 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) | | 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) |
| 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 | | 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 |
| 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 | | 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 |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
| 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.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
| Tests | 73 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 | 74 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) |
## Recent milestones (details in CHANGELOG.md) ## Recent milestones (details in CHANGELOG.md)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.21.3", "version": "1.22.0",
"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",
+70 -6
View File
@@ -16,7 +16,7 @@ import {
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, spaceDisplayOf, roomFillColor, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} from './logic'; } from './logic';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices'; import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
@@ -28,7 +28,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.21.3'; const CARD_VERSION = '1.22.0';
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';
@@ -115,6 +115,11 @@ class HouseplanCard extends LitElement {
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
bindingFilter: string; bindingFilter: string;
icon: string; // '' = auto icon: string; // '' = auto
display: 'badge' | 'ripple' | 'icon_ripple';
rippleColor: string; // '' = accent
rippleSize: number; // in icon diameters
size: number; // icon size multiplier
angle: number; // icon rotation, degrees
tapAction: string; // '' = card default tapAction: string; // '' = card default
model: string; model: string;
link: string; link: string;
@@ -1341,6 +1346,11 @@ class HouseplanCard extends LitElement {
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef, binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
bindingFilter: '', bindingFilter: '',
icon: d.marker?.icon || '', icon: d.marker?.icon || '',
display: d.marker?.display || 'badge',
rippleColor: d.marker?.ripple_color || '',
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3,
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
angle: Number(d.marker?.angle) || 0,
tapAction: d.marker?.tap_action || '', tapAction: d.marker?.tap_action || '',
model: d.model || '', model: d.model || '',
link: d.link || '', link: d.link || '',
@@ -1351,7 +1361,9 @@ class HouseplanCard extends LitElement {
}; };
} else { } else {
this._markerDialog = { this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', tapAction: '', model: '', name: '', binding: 'virtual', bindingFilter: '', icon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false, link: '', description: '', pdfs: [], room: '', busy: false,
}; };
} }
@@ -1531,6 +1543,11 @@ class HouseplanCard extends LitElement {
binding: dlg.binding, binding: dlg.binding,
name: dlg.name.trim() || null, name: dlg.name.trim() || null,
icon: dlg.icon || null, icon: dlg.icon || null,
display: dlg.display !== 'badge' ? dlg.display : null,
ripple_color: dlg.display !== 'badge' && dlg.rippleColor ? dlg.rippleColor : null,
ripple_size: dlg.display !== 'badge' && dlg.rippleSize !== 3 ? dlg.rippleSize : null,
size: dlg.size !== 1 ? dlg.size : null,
angle: dlg.angle ? dlg.angle : null,
tap_action: dlg.tapAction || null, tap_action: dlg.tapAction || null,
model: dlg.model.trim() || null, model: dlg.model.trim() || null,
link: dlg.link.trim() || null, link: dlg.link.trim() || null,
@@ -2187,9 +2204,22 @@ class HouseplanCard extends LitElement {
const temp = this._liveTemp(d); const temp = this._liveTemp(d);
const hum = this._liveHum(d); const hum = this._liveHum(d);
const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null; const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null;
const m = d.marker;
const disp = m?.display || 'badge';
const ripple = disp === 'ripple' || disp === 'icon_ripple';
const active = ripple && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
const angle = Number(m?.angle) || 0;
const rScale = Number(m?.ripple_size) > 0 ? Number(m!.ripple_size) : 3;
const st = [`left:${left}%`, `top:${top}%`];
if (scale !== 1) st.push(`--dev-scale:${scale}`);
if (ripple) {
st.push(`--ripple-scale:${rScale}`);
if (m?.ripple_color) st.push(`--ripple-color:${m.ripple_color}`);
}
return html`<div return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''}" class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''}"
style="left:${left}%;top:${top}%" style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)} @click=${(e: MouseEvent) => this._clickDevice(e, d)}
@mousemove=${(e: MouseEvent) => @mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name, this._showTip(e, d.name,
@@ -2200,7 +2230,12 @@ class HouseplanCard extends LitElement {
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)} @pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)} @pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}
> >
<ha-icon icon="${d.icon}"></ha-icon> ${ripple
? html`<span class="ripple ${active ? 'active' : ''}"><i></i><i></i><i></i></span>`
: nothing}
${disp !== 'ripple'
? html`<ha-icon icon="${d.icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>`
: nothing}
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing} ${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
${hum != null ? html`<span class="hval">${hum}%</span>` : nothing} ${hum != null ? html`<span class="hval">${hum}%</span>` : nothing}
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing} ${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
@@ -2478,6 +2513,35 @@ class HouseplanCard extends LitElement {
.value=${d.icon} .value=${d.icon}
@input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`} @input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`}
<label>${this._t('marker.display_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, display: (e.target as HTMLSelectElement).value as any })}>
${[['badge', 'display.badge'], ['ripple', 'display.ripple'], ['icon_ripple', 'display.icon_ripple']].map(
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
)}
</select>
${d.display !== 'badge'
? html`<div class="colorrow">
<input type="color" .value=${d.rippleColor || '#3ea6ff'}
@input=${(e: Event) => (this._markerDialog = { ...d, rippleColor: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._t('marker.ripple_size')}</span>
<input type="range" min="2" max="8" step="0.5" .value=${String(d.rippleSize)}
@input=${(e: Event) => (this._markerDialog = { ...d, rippleSize: Number((e.target as HTMLInputElement).value) })} />
<span class="opv">×${d.rippleSize}</span>
</div>`
: nothing}
<label>${this._t('marker.size_label')}</label>
<div class="colorrow">
<input type="range" min="0.5" max="3" step="0.1" .value=${String(d.size)}
@input=${(e: Event) => (this._markerDialog = { ...d, size: Number((e.target as HTMLInputElement).value) })} />
<span class="opv">×${d.size.toFixed(1)}</span>
<span class="opl">${this._t('marker.angle_label')}</span>
<input type="range" min="0" max="350" step="10" .value=${String(d.angle)}
@input=${(e: Event) => (this._markerDialog = { ...d, angle: Number((e.target as HTMLInputElement).value) })} />
<span class="opv">${d.angle}°</span>
</div>
<label>${this._t('marker.model_label')}</label> <label>${this._t('marker.model_label')}</label>
<input class="namein" type="text" placeholder=${this._t('marker.model_ph')} <input class="namein" type="text" placeholder=${this._t('marker.model_ph')}
.value=${d.model} .value=${d.model}
+7
View File
@@ -60,6 +60,13 @@
"marker.room_auto": "— by device area (auto) —", "marker.room_auto": "— by device area (auto) —",
"marker.icon_label": "Icon", "marker.icon_label": "Icon",
"marker.icon_ph": "mdi:… (empty = auto)", "marker.icon_ph": "mdi:… (empty = auto)",
"marker.display_label": "Display",
"display.badge": "Icon badge",
"display.ripple": "Ripple only",
"display.icon_ripple": "Icon + ripple",
"marker.ripple_size": "Ripple size",
"marker.size_label": "Icon size / rotation",
"marker.angle_label": "Rotate",
"marker.model_label": "Model", "marker.model_label": "Model",
"marker.model_ph": "e.g. Aqara T&H", "marker.model_ph": "e.g. Aqara T&H",
"marker.link_label": "Link", "marker.link_label": "Link",
+7
View File
@@ -60,6 +60,13 @@
"marker.room_auto": "— по зоне устройства (авто) —", "marker.room_auto": "— по зоне устройства (авто) —",
"marker.icon_label": "Иконка", "marker.icon_label": "Иконка",
"marker.icon_ph": "mdi:… (пусто = авто)", "marker.icon_ph": "mdi:… (пусто = авто)",
"marker.display_label": "Отображение",
"display.badge": "Значок",
"display.ripple": "Только пульсация",
"display.icon_ripple": "Значок + пульсация",
"marker.ripple_size": "Размер пульсации",
"marker.size_label": "Размер / поворот значка",
"marker.angle_label": "Поворот",
"marker.model_label": "Модель", "marker.model_label": "Модель",
"marker.model_ph": "напр. Aqara T&H", "marker.model_ph": "напр. Aqara T&H",
"marker.link_label": "Ссылка", "marker.link_label": "Ссылка",
+10
View File
@@ -74,6 +74,16 @@ export function roomEdges(rooms: any[]): number[][] {
return out; return out;
} }
/**
* Is an entity "active / detected"? Used by presence ripples, which are opted into per
* device and therefore must not depend on the card-wide live_states toggle.
* Anything unknown including `unavailable` counts as idle: a sensor outage should
* calm the plan down, never leave a ring pulsing forever.
*/
export function isActiveState(state?: string | null): boolean {
return ['on', 'open', 'home', 'detected', 'playing', 'cleaning'].includes(String(state));
}
/** Point equality within a tolerance. */ /** Point equality within a tolerance. */
export function samePoint(a: number[], b: number[], eps = 0.001): boolean { export function samePoint(a: number[], b: number[], eps = 0.001): boolean {
return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps; return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
+55 -5
View File
@@ -225,6 +225,54 @@ export const cardStyles = css`
fill-opacity: 0.22; fill-opacity: 0.22;
stroke-opacity: 1; stroke-opacity: 1;
} }
/* presence ripples: opted into per device, drawn around the anchor point */
.dev.noicon {
background: transparent;
border-color: transparent;
box-shadow: none;
}
.dev ha-icon {
position: relative;
z-index: 1;
}
.ripple {
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--dev-size) * var(--ripple-scale, 3));
height: calc(var(--dev-size) * var(--ripple-scale, 3));
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 0;
}
.ripple i {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--ripple-color, var(--hp-accent));
opacity: 0;
}
.ripple.active i {
animation: hp-ripple 2.4s ease-out infinite;
}
.ripple.active i:nth-child(2) { animation-delay: 0.8s; }
.ripple.active i:nth-child(3) { animation-delay: 1.6s; }
/* idle: a faint dot keeps the spot marked without pulling the eye */
.ripple:not(.active) i:nth-child(n + 2) { display: none; }
.ripple:not(.active) i {
inset: calc(50% - 0.15 * var(--dev-size));
opacity: 0.3;
animation: none;
}
@keyframes hp-ripple {
0% { transform: scale(0.18); opacity: 0.7; }
70% { opacity: 0.22; }
100% { transform: scale(1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ripple.active i { animation: none; opacity: 0.3; }
.ripple.active i:nth-child(n + 2) { display: none; }
}
.roomlabel { .roomlabel {
position: absolute; position: absolute;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
@@ -338,10 +386,12 @@ export const cardStyles = css`
} }
.dev { .dev {
position: absolute; position: absolute;
/* per-device multiplier on top of the card-wide icon size */
--dev-size: calc(var(--icon-size, 2.5cqw) * var(--dev-scale, 1));
/* центр квадрата (включая рамку 1px) точно на точке привязки: -(size/2 + border) */ /* центр квадрата (включая рамку 1px) точно на точке привязки: -(size/2 + border) */
width: var(--icon-size, 2.5cqw); width: var(--dev-size);
height: var(--icon-size, 2.5cqw); height: var(--dev-size);
margin: calc(var(--icon-size, 2.5cqw) / -2 - 1px) 0 0 calc(var(--icon-size, 2.5cqw) / -2 - 1px); margin: calc(var(--dev-size) / -2 - 1px) 0 0 calc(var(--dev-size) / -2 - 1px);
border-radius: 22%; border-radius: 22%;
background: var(--hp-bg); background: var(--hp-bg);
border: 1px solid var(--hp-line); border: 1px solid var(--hp-line);
@@ -402,7 +452,7 @@ export const cardStyles = css`
border: 1px solid var(--hp-accent); border: 1px solid var(--hp-accent);
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18); border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14); padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
font-size: calc(var(--icon-size, 2.5cqw) * 0.45); font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700; font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68); line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
color: var(--hp-txt); color: var(--hp-txt);
@@ -419,7 +469,7 @@ export const cardStyles = css`
border: 1px solid #4fc3f7; border: 1px solid #4fc3f7;
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18); border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14); padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
font-size: calc(var(--icon-size, 2.5cqw) * 0.45); font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700; font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68); line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
color: var(--hp-txt); color: var(--hp-txt);
+5
View File
@@ -38,6 +38,11 @@ export interface Marker {
description?: string | null; description?: string | null;
pdfs?: PdfRef[]; pdfs?: PdfRef[];
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle' tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
display?: 'badge' | 'ripple' | 'icon_ripple' | null; // how the device is drawn
ripple_color?: string | null;
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
size?: number | null; // icon size multiplier (default 1)
angle?: number | null; // icon rotation, degrees
} }
export interface ServerConfig { export interface ServerConfig {
+13 -1
View File
@@ -4,7 +4,7 @@ import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi, lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor, fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
import { import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses, iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -395,3 +395,15 @@ test('closestPointOnBoundary: projects a click onto the nearest wall', () => {
assert.deepEqual(closestPointOnBoundary([5, 4], sq), [5, 0]); // inside → nearest edge (bottom) assert.deepEqual(closestPointOnBoundary([5, 4], sq), [5, 0]); // inside → nearest edge (bottom)
assert.equal(closestPointOnBoundary([0, 0], [[0, 0]]), null); // no edges assert.equal(closestPointOnBoundary([0, 0], [[0, 0]]), null); // no edges
}); });
test('isActiveState: a sensor outage calms the plan down, it never pulses forever', () => {
assert.ok(isActiveState('on'));
assert.ok(isActiveState('open'));
assert.ok(isActiveState('home'));
assert.ok(isActiveState('detected'));
assert.ok(!isActiveState('off'));
assert.ok(!isActiveState('unavailable'));
assert.ok(!isActiveState('unknown'));
assert.ok(!isActiveState(undefined));
assert.ok(!isActiveState(null));
});