mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
@@ -51,6 +51,11 @@ Key advantages in short:
|
||||
|
||||
## Installation
|
||||
|
||||
One click if you already run HACS:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
|
||||
### Via HACS (recommended)
|
||||
|
||||
1. Open **HACS → menu (⋮) → Custom repositories**.
|
||||
|
||||
@@ -51,6 +51,11 @@ House Plan показывает ваш умный дом так, как он в
|
||||
|
||||
## Установка
|
||||
|
||||
В один клик, если у вас уже есть HACS:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
|
||||
### Через HACS (рекомендуется)
|
||||
|
||||
1. Откройте **HACS → меню (⋮) → Custom repositories**.
|
||||
|
||||
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.21.3"
|
||||
VERSION = "1.22.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"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("description"): vol.Any(str, 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.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
|
||||
Vendored
+89
-13
File diff suppressed because one or more lines are too long
@@ -1,5 +1,17 @@
|
||||
# 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.5–3) 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)
|
||||
- Room name labels no longer carry a text shadow — crisper look on both the white
|
||||
hand-drawn canvas and plan images.
|
||||
|
||||
+2
-2
@@ -13,14 +13,14 @@
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.21.3",
|
||||
"version": "1.22.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+70
-6
@@ -16,7 +16,7 @@ import {
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||||
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,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
|
||||
@@ -28,7 +28,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -115,6 +115,11 @@ class HouseplanCard extends LitElement {
|
||||
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
|
||||
bindingFilter: string;
|
||||
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
|
||||
model: string;
|
||||
link: string;
|
||||
@@ -1341,6 +1346,11 @@ class HouseplanCard extends LitElement {
|
||||
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
|
||||
bindingFilter: '',
|
||||
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 || '',
|
||||
model: d.model || '',
|
||||
link: d.link || '',
|
||||
@@ -1351,7 +1361,9 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
} else {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1531,6 +1543,11 @@ class HouseplanCard extends LitElement {
|
||||
binding: dlg.binding,
|
||||
name: dlg.name.trim() || 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,
|
||||
model: dlg.model.trim() || null,
|
||||
link: dlg.link.trim() || null,
|
||||
@@ -2187,9 +2204,22 @@ class HouseplanCard extends LitElement {
|
||||
const temp = this._liveTemp(d);
|
||||
const hum = this._liveHum(d);
|
||||
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
|
||||
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''}"
|
||||
style="left:${left}%;top:${top}%"
|
||||
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''}"
|
||||
style="${st.join(';')}"
|
||||
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
|
||||
@mousemove=${(e: MouseEvent) =>
|
||||
this._showTip(e, d.name,
|
||||
@@ -2200,7 +2230,12 @@ class HouseplanCard extends LitElement {
|
||||
@pointerup=${(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}
|
||||
${hum != null ? html`<span class="hval">${hum}%</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}
|
||||
@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>
|
||||
<input class="namein" type="text" placeholder=${this._t('marker.model_ph')}
|
||||
.value=${d.model}
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
"marker.room_auto": "— by device area (auto) —",
|
||||
"marker.icon_label": "Icon",
|
||||
"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_ph": "e.g. Aqara T&H",
|
||||
"marker.link_label": "Link",
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
"marker.room_auto": "— по зоне устройства (авто) —",
|
||||
"marker.icon_label": "Иконка",
|
||||
"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_ph": "напр. Aqara T&H",
|
||||
"marker.link_label": "Ссылка",
|
||||
|
||||
@@ -74,6 +74,16 @@ export function roomEdges(rooms: any[]): number[][] {
|
||||
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. */
|
||||
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;
|
||||
|
||||
+55
-5
@@ -225,6 +225,54 @@ export const cardStyles = css`
|
||||
fill-opacity: 0.22;
|
||||
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 {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
@@ -338,10 +386,12 @@ export const cardStyles = css`
|
||||
}
|
||||
.dev {
|
||||
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) */
|
||||
width: var(--icon-size, 2.5cqw);
|
||||
height: var(--icon-size, 2.5cqw);
|
||||
margin: calc(var(--icon-size, 2.5cqw) / -2 - 1px) 0 0 calc(var(--icon-size, 2.5cqw) / -2 - 1px);
|
||||
width: var(--dev-size);
|
||||
height: var(--dev-size);
|
||||
margin: calc(var(--dev-size) / -2 - 1px) 0 0 calc(var(--dev-size) / -2 - 1px);
|
||||
border-radius: 22%;
|
||||
background: var(--hp-bg);
|
||||
border: 1px solid var(--hp-line);
|
||||
@@ -402,7 +452,7 @@ export const cardStyles = css`
|
||||
border: 1px solid var(--hp-accent);
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
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;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
@@ -419,7 +469,7 @@ export const cardStyles = css`
|
||||
border: 1px solid #4fc3f7;
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
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;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
|
||||
@@ -38,6 +38,11 @@ export interface Marker {
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
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 {
|
||||
|
||||
+13
-1
@@ -4,7 +4,7 @@ import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState,
|
||||
} from '../test-build/logic.js';
|
||||
import {
|
||||
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.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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user