Release v1.60.2
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 3m58s
Validate / backend (push) Failing after 8m11s
Validate / smoke (push) Failing after 22m39s

This commit is contained in:
Matysh
2026-08-08 17:39:25 +03:00
parent 854a6944a0
commit 2219700d63
41 changed files with 2383 additions and 631 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.60.2-beta.3"
VERSION = "1.60.2"
DEFAULT_CONFIG: dict = {
"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",
"requirements": [],
"single_config_entry": true,
"version": "1.60.2-beta.3"
"version": "1.60.2"
}
+1 -1
View File
@@ -632,7 +632,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("room_id"): vol.Any(str, None),
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
# offered, but remains accepted while old stores migrate to icon_ripple.
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", "static_icon", None),
vol.Optional("ripple_color"): vol.Any(
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
),
+113
View File
@@ -0,0 +1,113 @@
/**
* Room-coloured tunnels in thick walls: door/window/gate, outer and shared
* walls, a hard split on the wall axis, hide/show policies and editor opacity.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.shadowRoot || c.renderRoot;
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
const update = async () => {
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
};
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (v) => Math.round(v / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
dx /= len; dy /= len;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const wall = (a, b, cm = 20) => ({ key: wallKey(a, b), a, b, cm });
sp.rooms = [
{ id: 'tunnel-left', name: 'Left', area: 'tunnel_left_area',
poly: [[0.1, 0.2], [0.5, 0.2], [0.5, 0.7], [0.1, 0.7]],
settings: { fill_mode: 'light' } },
{ id: 'tunnel-right', name: 'Right', area: 'tunnel_right_area',
poly: [[0.5, 0.2], [0.9, 0.2], [0.9, 0.7], [0.5, 0.7]],
settings: {} },
];
sp.walls = [
wall([0.1, 0.2], [0.5, 0.2]),
wall([0.5, 0.2], [0.5, 0.7]),
wall([0.5, 0.7], [0.9, 0.7]),
];
sp.openings = [
{ id: 'tunnel-window', type: 'window', x: 0.3, y: 0.2, angle: 0, length: 0.1 },
{ id: 'tunnel-door', type: 'door', x: 0.5, y: 0.45, angle: 90, length: 0.1 },
{ id: 'tunnel-gate', type: 'gate', x: 0.7, y: 0.7, angle: 0, length: 0.16 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, hide_openings: false, fill_mode: 'glow' };
c._serverCfg.settings = {
...(c._serverCfg.settings || {}),
fill_colors: {
...(c._serverCfg.settings?.fill_colors || {}),
light_none: { c: '#112233', a: 0.21 },
glow_base: { c: '#445566', a: 0.42 },
},
};
c._setMode('view');
await update();
const tunnels = () => [...root().querySelectorAll('[data-hp="opening-tunnel"]')];
const byId = (id) => root().querySelector(`[data-hp="opening-tunnel"][data-id="${id}"]`);
const out = {};
out.allThreeTypes = tunnels().length === 3;
out.windowRepeatsColdRoom = byId('tunnel-window')?.getAttribute('fill') === '#112233'
&& (root().querySelector('[data-hp="room"][data-id="tunnel-left"]')?.getAttribute('style') || '')
.includes('--room-fill:#112233');
out.gateRepeatsHotRoom = byId('tunnel-gate')?.getAttribute('fill') === '#445566'
&& (root().querySelector('[data-hp="room"][data-id="tunnel-right"]')?.getAttribute('style') || '')
.includes('--room-fill:#445566');
const shared = byId('tunnel-door');
const stops = [...(shared?.querySelectorAll('stop') || [])].map((s) => ({
offset: s.getAttribute('offset'), color: s.getAttribute('stop-color'),
opacity: s.getAttribute('stop-opacity'),
}));
out.sharedUsesOneHardGradient = !!shared?.querySelector('linearGradient') && stops.length === 4;
out.sharedStopsOnWallAxis = stops[1]?.offset === '50.000000%'
&& stops[2]?.offset === '50.000000%';
out.sharedCarriesBothRooms = new Set(stops.map((s) => s.color)).has('#112233')
&& new Set(stops.map((s) => s.color)).has('#445566');
const svgEl = root().querySelector('.stage svg');
const tunnelGroup = root().querySelector('.opening-tunnels');
const wallGroup = root().querySelector('.wallbodies');
out.layerBeforeWall = !!svgEl && !!tunnelGroup && !!wallGroup
&& !!(tunnelGroup.compareDocumentPosition(wallGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
sp.settings.hide_openings = true;
await update();
out.hideSymbolKeepsTunnel = root().querySelectorAll('[data-hp="opening"]').length === 0
&& tunnels().length === 3;
sp.settings.show_borders = false;
await update();
out.hiddenBordersKeepTunnel = root().querySelectorAll('.wallbodies').length === 0
&& tunnels().length === 3;
c._setMode('decor');
await update();
out.backdropUsesSingleGroupOpacity = tunnels().length === 3
&& getComputedStyle(root().querySelector('.opening-tunnels')).opacity === '0.35'
&& byId('tunnel-window')?.getAttribute('fill-opacity') === '0.21';
c._setMode('plan');
await update();
out.planGestureCannotLeaveStalePatch = tunnels().length === 0;
return out;
});
checkAll(result);
await finish(browser, result);
+72
View File
@@ -0,0 +1,72 @@
// Always-static display contract: one neutral face across live states, while
// the existing dynamic display keeps reacting. Run only in the prerelease gate.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(() => {
const card = window.__card;
const saved = card.hass;
const show = (device, states) => {
card.hass = {
...saved,
entities: {
...saved.entities,
...Object.fromEntries(Object.keys(states).map((entity_id) => [entity_id, {
entity_id, device_id: device.id, platform: 'demo', disabled_by: null,
}])),
},
states: { ...saved.states, ...states },
};
const presentation = card._devicePresentation(device);
card.hass = saved;
return {
classes: presentation.classes,
icon: presentation.icon,
status: presentation.visual.status,
activity: presentation.activity,
temp: presentation.tempText,
lqi: presentation.lqiText,
vacuumLive: presentation.vacuumLive,
};
};
const smoke = {
id: 'static-smoke', name: 'Smoke', icon: 'mdi:smoke-detector',
entities: ['binary_sensor.static_smoke'], primary: 'binary_sensor.static_smoke',
marker: {
binding: 'device:static-smoke', display: 'static_icon',
vacuum: { live: true },
},
};
const relay = {
// Reuse the harness registry entry: `_planHass` deliberately resolves
// against the acquired HA registry, so an invented entity would correctly
// be absent even if it were injected into `hass.states` for this frame.
id: 'd_kettle', name: 'Relay', icon: 'mdi:toggle-switch',
entities: ['switch.kettle'], primary: 'switch.kettle',
marker: { binding: 'device:d_kettle', display: 'badge' },
};
const smokeOn = show(smoke, {
'binary_sensor.static_smoke': {
state: 'on', attributes: { device_class: 'smoke', linkquality: 180 },
},
});
const smokeUnavailable = show(smoke, {
'binary_sensor.static_smoke': {
state: 'unavailable', attributes: { device_class: 'smoke', linkquality: 180 },
},
});
const relayOn = show(relay, {
'switch.kettle': { state: 'on', attributes: {} },
});
return {
staticAlarmNeutral: smokeOn.status === 'neutral'
&& smokeOn.activity === 'none'
&& smokeOn.classes.join(' ') === 'static-icon',
staticUnavailableSame: JSON.stringify(smokeOn) === JSON.stringify(smokeUnavailable),
staticSuppressesVacuum: smokeOn.vacuumLive === false,
liveSwitchStillDynamic: relayOn.status === 'working' && relayOn.classes.includes('on'),
};
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+6 -7
View File
@@ -26,20 +26,19 @@ from homeassistant.helpers.typing import ConfigType
DOMAIN = "demo_robot"
TICK_S = 2 # telemetry cadence
SPEED_MM = 600 # per tick ≈ 0.3 m/s, a realistic robot pace
DOCK = (3900.0, 2600.0)
DOCK = (6100.0, 3600.0) # inside Under-Stairs Closet
# Robot-map rooms, mm, Y up (flipped vs screen). Bboxes mirror the plan's
# room polygons; the names MUST equal the plan room names — auto-calibration
# matches by name.
ROOMS: dict[str, tuple[str, float, float, float, float]] = {
"1": ("Гостиная", 480, 4320, 4160, 7360),
"2": ("Кухня", 4160, 5120, 7520, 7360),
"3": ("Спальня", 4160, 2400, 7520, 5120),
"4": ("Кабинет", 480, 2400, 2720, 4320),
"5": ("Прихожая", 2720, 2400, 4160, 4320),
"1": ("Kitchen & Living", 900, 3067, 6300, 6367),
"2": ("Hallway", 3200, 1033, 4533, 3800),
"3": ("Guest Bedroom", 4533, 1033, 6300, 3067),
"4": ("Under-Stairs Closet", 5300, 3067, 6933, 4467),
}
# cleaning order: start at the dock in the hallway, end next to it
CLEAN_ORDER = ("5", "4", "1", "2", "3")
CLEAN_ORDER = ("4", "2", "3", "1")
INSET = 350.0 # keep lanes away from walls
LANE_MM = 650.0 # serpentine lane spacing
+164 -144
View File
File diff suppressed because one or more lines are too long
+20 -5
View File
@@ -123,7 +123,7 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
## Device markers (v1.6.0+)
Per-marker appearance: `display: badge|icon_ripple|value`. Entity semantics originate in
Per-marker appearance: `display: badge|icon_ripple|value|static_icon`. Entity semantics originate in
`src/device-visual.ts`; `src/device-presentation.ts` resolves the complete renderer-ready
projection (sources, value, icon, classes, metrics and explanation), and
`src/device-face.ts` renders that projection on the full plan, device preview and static
@@ -132,13 +132,17 @@ shows the icon/morph and status plate; `icon_ripple` additionally shows a finite
static presence, mechanical transition or actual-work ring; `value` replaces the icon
with the HA-formatted numeric or text value. Ambiguous/missing/unavailable sources fall
back to the icon instead of selecting an arbitrary registry row. A critical alarm is red
in every presentation. The marker dialog builds its unsaved draft through `buildDevices`,
in every dynamic presentation. `static_icon` deliberately keeps the configured/automatic
base icon on one neutral dark plate: state morphing, work/open/alarm/unavailable paint,
activity, RGB, value, temperature/humidity/LQI badges and live vacuum overlays are all
suppressed. Hover/focus, taps, controls and light aggregation keep their normal behaviour.
`normalizeDeviceDisplay()` is the mandatory compatibility gate for every consumer and maps
legacy `ripple` to `icon_ripple`. The marker dialog builds its unsaved draft through `buildDevices`,
then `hp-device-preview` shows the actual projection, integration provenance from
registry/config-entry metadata and an isolated 3.3 s activity demonstration.
Runtime baselines are seeded as soon as a rebuilt registry becomes authoritative, before
the next HA snapshot is classified; source-key changes reset any finite effect immediately.
Legacy `display: ripple` is read as `icon_ripple` and rewritten on the next config save;
the backend accepts it only for compatibility. `ripple_color` and `ripple_size` remain the
The backend accepts legacy `display: ripple` only for compatibility. `ripple_color` and `ripple_size` remain the
stored names for the ordinary activity effect. `size` (icon multiplier via the
`--dev-size` CSS var — value badges scale along) and `angle` rotate/scale a single icon.
Room drawing shows a live **ruler** (`segmentCm` +
@@ -282,6 +286,13 @@ Its default width in the editor is 300 cm. `openingAmount` (pure) maps the conta
drag along walls (continuous re-snap, saved on release), click → status card (250 ms timer),
double click → properties dialog. In markup mode the "Opening" tool handles clicks instead.
For a wall with thickness, `openingTunnelGeometry()` resolves the atomic wall
interval and adjacent room on each side of the centreline. A base patch beneath
Glow/sun repeats the same frame-local effective fill as the room shape. Outer
openings give the one room both halves; shared openings use a local-coordinate
hard stop at `y=0`. The helper ignores virtual spans, unfinished drafts and
zero-thickness walls and clips mixed-thickness legacy spans per atomic body.
## Integration WS API
| Command | Parameters | Response |
@@ -499,7 +510,11 @@ hash falls back to the default.
cancel on opposite windings — field bug v1.36.3). Radius: global
`settings.glow_radius_cm` + per-marker `glow_radius_cm`. On a thick wall the
doorway sector is the angular intersection of its near- and far-face clear
spans, so the jamb returns clip the spill as a real opening tunnel.
spans, so the jamb returns clip the spill as a real opening tunnel. The
shared light resolver marks external `controls` as non-spatial: they still
vote in room state/statistics and drive group actions, but never place a Glow
pool at the controller. If the same entity also has a real lamp marker, that
physical marker owns its unique Glow position regardless of registry order.
- **Open boundaries** (v1.37, revised 2026-08): `space.open_spans` hold
geometric virtual stretches; `room.open_to` remains the light-zone index
derived from spans (legacy `open_to`-only configs expand to full
+18
View File
@@ -2,6 +2,24 @@
## Unreleased
## v1.60.2 — 2026-08-08
- Door, window and gate tunnels in thick walls now continue the effective room
fill instead of exposing a white paper strip. An exterior opening uses its
room colour through the full wall depth; a shared opening splits cleanly on
the wall centreline when its rooms use different fills. Glow, window sun
rays and opening symbols keep their existing behaviour.
- Device display now has an **Always static icon** option. It keeps the normal
dark icon unchanged by work, open, alarm, unavailable, RGB, values or sensor
badges, and hides live vacuum puck/trail overlays. Hover, actions, controls
and the device's separate contribution to room light remain unchanged. The
former **Icon** option is now named **Icon + dynamic plate**.
- A controller no longer becomes a physical Glow origin merely because it has
managed light targets. `controls` still drives group toggling, room light
state and statistics, while the actual lamp marker owns the light pool. A
marker's own legacy `switch.*` self-control is no longer inferred as a light;
relay-driven fixtures require the explicit “This device is a light source” flag.
## v1.60.2-beta.3 — 2026-08-08
- Saved devices disabled in Home Assistant are now forced off the plan and all
+19
View File
@@ -8,6 +8,25 @@
## В разработке
## v1.60.2 — 2026-08-08
- Тоннели дверей, окон и ворот в толстых стенах теперь продолжают фактическую
заливку комнаты вместо белой полосы подложки. Во внешней стене цвет комнаты
занимает всю глубину проёма, а в общей стене разные заливки ровно делятся по
её оси. Поведение Glow, солнечных лучей и символов проёмов не изменилось.
- В настройках отображения устройства появился режим **«Всегда статичный
значок»**. Он сохраняет обычный тёмный значок при работе, открытии, тревоге,
недоступности, RGB-цвете и любых значениях, скрывает дополнительные бейджи и
живой puck/след пылесоса. Hover, действия, controls и отдельное влияние
устройства на освещение комнаты не меняются. Прежний режим **«Значок»**
переименован в **«Значок + динамическая подложка»**.
- Выключатель больше не становится физической точкой Glow только из-за списка
управляемых источников: `controls` по-прежнему задаёт групповое управление,
состояние освещения и статистику комнаты, но световое пятно рисуется у
маркера самой лампы. Собственный `switch.*` в старом `controls` также больше
не включает скрытое legacy-поведение — для реле-светильника нужен явный флаг
«Это устройство — источник света».
## v1.60.2-beta.3 — 2026-08-08
- Сохранённые устройства, деактивированные в Home Assistant, теперь полностью
+19 -8
View File
@@ -128,6 +128,15 @@ A marker's live indication — status plate, state-morphed icon and semantic
activity — is derived by one resolver from one effective source set. Its
source precedence is (`_visualSamples` / `_actEntity`):
`display: static_icon` is the deliberate presentation exception to the matrix
below. The resolver still retains source metadata for preview diagnostics, but
the rendered marker always uses its base icon on a neutral dark plate: no state
morph, work/open/alarm/unavailable paint, activity, RGB, value or satellite
temperature/humidity/LQI badge. Live vacuum puck/trail/room-highlight overlays
are also suppressed. This changes presentation only: hover/focus, service-call
feedback, controls, Glow and room light aggregation keep using the real device.
Hidden, removed or HA-disabled lifecycle rules still outrank display mode.
1. the device's **cover**, when the marker's tap action is explicitly
«Открыть/закрыть» (`tap_action: 'cover'` — `coverEntityOf`, the same helper
and the same entity the tap drives). It wins over EVERYTHING below;
@@ -136,15 +145,17 @@ source precedence is (`_visualSamples` / `_actEntity`):
(an `entity:*` marker's bound entity and a `device:*` marker's child entities
are excluded from the external list), otherwise automatic `light.*` only when `light` is the
device's resolved functional role. An auxiliary LED/display light on a
media player or appliance does not turn the whole marker into a lamp. This exact set also feeds
Glow, Light fill, room light stats and group toggle;
media player or appliance does not turn the whole marker into a lamp. This
exact set feeds Light fill, room light stats, marker feedback and group
toggle. Glow additionally requires a spatial source: an external control
never places a pool at the controller, while a real lamp marker or explicit
`is_light` marker does. When both name the same entity, the physical marker
owns its one Glow position regardless of registry order;
For compatibility, a pre-v1.60 marker that lists its own `switch.*` in
`controls` is interpreted as `is_light`. Marker Save converts either binding;
Optimize Plans can convert the losslessly identifiable `entity:switch.*` case
(a `device:*` marker needs the HA registry and is therefore left to Save).
External controls remain additive, so migration does not change Glow, Light
fill or room statistics. The dialog preserves their ordered raw list,
A pre-v1.60 marker that lists its own `switch.*` in `controls` is ignored as
a self-reference; it is not interpreted as `is_light`. Marker Save removes
it, and Optimize Plans can remove the directly identifiable `entity:*` case.
The dialog preserves the ordered raw list of genuine external controls,
including duplicates and temporarily unknown targets; runtime consumers
separately de-duplicate and keep only currently controllable entities.
3. otherwise the device's **resolved state role**
+12 -7
View File
@@ -15,11 +15,11 @@
| Item | State |
|---|---|
| Version | **v1.60.2-beta.3** everywhere (manifest, const.py, package.json, CARD_VERSION) — pre-release with HA-disabled binding handling, live device preview, a unified Boundary tool, Gates, dashed decor lines and weather-independent window rays. The tag points at `dev`; `main` remains on v1.60.0 |
| Version | **v1.60.2** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release with HA-disabled binding handling, live device preview, a unified Boundary tool, Gates, dashed decor lines, weather-independent window rays, an always-static device display and room-coloured opening tunnels. Stable `main`, `dev` and tag point to the same tested release commit |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. 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) |
| CI | v1.60.2-beta.3 uses the targeted local pre-release gate; exact commands belong in the release handoff rather than this snapshot. The exact-SHA Ubuntu Validate remains mandatory and may run the broader CI matrix automatically: `release.yml` withholds the asset until every matching run finishes green. Stable releases additionally require the complete local frontend/backend/smoke gate before tagging |
| CI | v1.60.2 uses the complete local stable-release frontend/backend/browser-smoke gate. The exact-SHA Ubuntu Validate remains mandatory: `release.yml` withholds the asset until every matching run finishes green |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
@@ -80,11 +80,14 @@
the integration omits the action; service switches can no longer become
primary, and glow pool and icon share one condition. Editor gestures on touch
(pinch/pan) landed the same release.
- **Unified device status/activity** (v1.59.0-beta.10, 2026-08-05): three display
modes (Icon / Icon + activity / Value), one semantic resolver for yellow
- **Unified device status/activity** (v1.59.0-beta.10; extended 2026-08-08): four display
modes (Icon + dynamic plate / Icon + activity / Value / Always static icon),
one semantic resolver for yellow
actual work, orange open/unlocked, unavailable and always-red alarms;
activity distinguishes a short event, presence, mechanical travel and
running. Legacy Ripple-only migrates to Icon + activity on the next save.
running. Always-static deliberately suppresses every state-driven visual,
satellite badge and live vacuum overlay while leaving hover, actions, Glow and
controls intact. Legacy Ripple-only migrates to Icon + activity on the next save.
- **Passive media lifecycle** (v1.60.0-beta.1): every `media_player`,
regardless of model, stays neutral while powered or playing and fades to
the existing unavailable appearance on explicit `off`; transport playback
@@ -120,8 +123,10 @@
- **v1.59.1** (2026-08-06): device markers resolve a semantic entity role
instead of trusting registry order; one light-source resolver now drives
Glow, Light fill, room statistics, marker feedback and controls. Glow uses
0.7 opacity, and compacted real walls retain their correct inner face and
body at real/virtual T-junctions.
0.7 opacity. Current dev keeps external `controls` non-spatial: they still
drive group state, but only a real lamp marker or explicit `is_light` marker
can place a Glow pool. Compacted real walls retain their correct inner face
and body at real/virtual T-junctions.
- **v1.59.2** (2026-08-07): every modal uses the shared `hp-dialog` shell,
backed by Home Assistant's `ha-dialog` with a native demo fallback. Titles,
modal semantics, initial focus, focus trapping, Escape and restore focus are
+3 -1
View File
@@ -95,6 +95,7 @@ Everything in this table is **public API**.
| Room name (no metrics) | `text` (SVG) | `room-label` | `data-id` = room id, `data-area` | `.rlabel` |
| Room card (name + metrics) | `div` (HTML, marker layer) | `room-label` | `data-id` = room id, `data-area` | `.roomlabel` |
| Door / window / gate | `g` (SVG) | `opening` | `data-id` = opening id, `data-kind` = `door` \| `window` \| `gate` | `.opening` |
| Coloured tunnel inside a thick opening | `path` / `g` (SVG) | `opening-tunnel` | `data-id` = opening id, `data-kind` = `door` \| `window` \| `gate` | `.opening-tunnel` |
| Wall body (thickness) | `path` (SVG) | `wall` | `data-id` = segment key, `data-kind` = `shared` \| `outer` | `.wallbody` |
| Independent partition | `path` (SVG editor hit target) | `partition` | `data-id` = partition id, `data-kind` = `partition` | `.physical-hit` |
| Wall column | `path` / `circle` (SVG editor hit target) | `wall-column` | `data-id` = column id, `data-kind` = `square` \| `circle` | `.physical-hit` |
@@ -138,7 +139,8 @@ them:
`.on`, `.off`, `.unavail`, `.alarm`, `.ghost` (hidden device shown in the
device editor), `.virtual` (a marker with no entity), `.valonly` (value
instead of icon), `.noicon`, `.sel` (selected in an editor).
instead of icon), `.static-icon` (the always-static display mode), `.noicon`,
`.sel` (selected in an editor).
The full plan and `houseplan-space-card` emit the same binding-status data
attributes. A forced-hidden HA-disabled marker is absent from ordinary View;
+4 -2
View File
@@ -398,13 +398,15 @@ registry-сценарий. Для limited/read-only нужен локальны
## Состояние и активность устройства
- В списке отображения ровно три режима: «Значок», «Значок + активность», «Значение вместо иконки»; «Только пульсация» отсутствует.
- В списке отображения ровно четыре режима: «Значок + динамическая подложка», «Значок + активность», «Значение вместо иконки», «Всегда статичный значок»; «Только пульсация» отсутствует.
- «Всегда статичный значок»: включённое, выключенное, unavailable и тревожное устройство выглядят одинаково нейтрально, без °/%/LQI и activity; hover и клик работают как прежде. У alarm-capable привязки есть предупреждение.
- У пылесоса этот режим скрывает puck и след, а возврат к динамическому режиму снова показывает сохранённый след.
- Motion в «Значок + активность» даёт три волны только при `off → on` и затихает примерно через 3,3 с, даже если датчик ещё `on` — Стенд: датчики движения расставлены по Ground Floor.
- Occupancy/presence показывает спокойное статичное кольцо, пока присутствие активно — Стенд: датчики присутствия там же (контраст со вспышкой motion — рядом).
- Штора в `opening/closing` показывает дыхание только в «Значок + активность»; при прямом `closed ↔ open` без промежуточного состояния эффект держится примерно 3,3 с — Стенд: шторы в Study.
- Работающий свет, реле, вентилятор, climate с активным `hvac_action` и пылесос получают жёлтую подложку; медиаплееры при воспроизведении остаются нейтральными (media-правило); `automation = on` (включена) тоже нейтральна.
- Дверь/окно, разблокированный замок и открытый клапан — оранжевые, а не жёлтые.
- Тревога всегда красная и пульсирует во всех трёх режимах; unavailable приглушён и не получает обычной активности — Стенд: дым сам каждые 10 минут, протечка через `input_boolean.demo_leak`, unavailable — Pantry Light.
- Тревога всегда красная и пульсирует во всех динамических режимах; «Всегда статичный значок» — явное нейтральное исключение. Unavailable приглушён и не получает обычной активности — Стенд: дым сам каждые 10 минут, протечка через `input_boolean.demo_leak`, unavailable — Pantry Light.
- Цвет и размер ×2..×8 применяются к обычной активности; тревога остаётся красной.
- Размер ×0.5..×3 и поворот; бейджи масштабируются — Стенд: любой маркер.
- При выключенных live-состояниях обычные статус и активность выключены, критическая тревога остаётся.
+35 -14
View File
@@ -75,7 +75,7 @@ public binding-status hook.
- [ ] Every binding/display/icon/size/angle/control/temperature draft change
updates the preview before Save; Cancel writes neither config nor layout.
- [ ] Working, open, cover, presence, short event, transition, alarm,
- [ ] Working, open, cover, presence, short event, transition, alarm, static,
unavailable, media-neutral, composite-Power and `live_states: false`
explanations match the actual face.
- [ ] The local activity demo lasts 3.3 seconds, sends no service call, survives
@@ -124,8 +124,9 @@ missing destructive confirmation or an editor exception that breaks View.
(stylus, paired mouse, vendor skins) [auto: smoke_feedback_v2]
- [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb
fixtures glows in the "Light sources" fill once "This device is a light
source" is ticked (its own entity or the lights bound under "Controls");
fixtures glows in the "Light sources" fill only once "This device is a
light source" is ticked. External targets under "Controls" still feed
group state/statistics but never create a pool at the switch coordinates;
unticked devices without a light entity never glow [auto: smoke_glow]
- [ ] Device card controls (v1.44.0): the device card opens with its
controllable entities FIRST — toggles right there (≥30 px tap targets),
@@ -334,9 +335,10 @@ separately promised workflows:
[auto: smoke_light_badges + smoke_rgb_alarm]
- [ ] Alarm pulse (v1.27.0, unified dev): leak/smoke/gas/CO/siren in `on`
and an alarm control panel in `triggered`
get a red plate and red pulse over every display mode and even with
ordinary live-state dressing off; clears on 'off'; unavailable never
alarms [manual]; reduced-motion is static
get a red plate and red pulse over every dynamic display mode and even
with ordinary live-state dressing off; `static_icon` is the deliberate
neutral exception after an editor warning; clears on 'off'; unavailable
never alarms [manual]; reduced-motion is static
- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is
computed once per config change, not per HA state push — smoke asserts
zero recomputations across 10 state pushes and recomputation after an
@@ -797,7 +799,9 @@ separately promised workflows:
mirrors the targets, not the marker's own entity — the RGB tint is gone
since v1.52.0, target colours reach only the glow/activity effect; without explicit Toggle
the click opens info as usual; the info card lists targets with states;
locks/other domains are filtered out of controls [auto: smoke_controls]
locks/other domains are filtered out of controls. Glow is spatial: the
controller casts no pool and the real lamp marker owns it even when the
controller is encountered first [auto: smoke_controls; unit: devices.test.mjs]
- [ ] Marker controls are lossless across Open → Save: their stored order,
duplicates and temporarily unknown/vendor targets survive unchanged;
only the marker's own bound/device entities are removed, while runtime
@@ -1057,11 +1061,13 @@ require hands on real hardware — they remain for the human pass.
## Unified device status and activity (dev, owner 2026-08-05)
- [ ] The Display list contains exactly Icon, Icon + activity, Value instead
of an icon. Legacy `display: ripple` reads and saves back as `icon_ripple`
- [ ] Icon shows state plate/morph but no ordinary activity effect; Icon +
activity adds the semantic effect; Value keeps the state-coloured plate
and hides ordinary activity
- [ ] The Display list contains exactly Icon + dynamic plate, Icon + activity,
Value instead of an icon, Always static icon, in that order. Legacy
`display: ripple` reads and saves back as `icon_ripple`
- [ ] Icon + dynamic plate shows state plate/morph but no ordinary activity
effect; Icon + activity adds the semantic effect; Value keeps the
state-coloured plate and hides ordinary activity; Always static icon
keeps one neutral base icon and suppresses all state-driven visuals
- [ ] Motion/vibration/sound/contact rising edges render exactly three waves
for about 3.3 s; initial load and recovery from unknown/unavailable do
not fake an event; a rapid retrigger restarts it
@@ -1081,8 +1087,14 @@ require hands on real hardware — they remain for the human pass.
yellow plate and the running effect
- [ ] Open contact/unlocked lock/open valve are orange; an open cover stays
neutral because its icon morph carries that state
- [ ] Unavailable suppresses ordinary activity. Alarm outranks all of it,
remains red in every display mode and with ordinary live states off
- [ ] Unavailable suppresses ordinary activity. Alarm outranks all dynamic
presentation, including when ordinary live states are off; `static_icon`
deliberately hides alarm paint without suppressing service-call errors
- [ ] `static_icon` hides temperature/humidity/LQI, RGB, value, icon morph,
activity and live vacuum puck/trails/room highlight on the full and static
cards; preview still names the real HA state/source and explains the static result
- [ ] Switching a static vacuum back to a dynamic display restores applicable
live/server trails; choosing static never deletes stored trail history
- [ ] Activity colour and size (×2..×8) apply per device; alarm ignores them
- [ ] Icon size ×0.5..×3 and rotation 0..355° apply per device; the
temp/humidity badges scale with the icon
@@ -1720,6 +1732,15 @@ require hands on real hardware — they remain for the human pass.
toward the exterior face; with
`hide_openings` the symbols hide but the cut remains
[auto: smoke_wall_thickness]
- [ ] **Opening tunnel repeats the room fill**: check a door, window and gate on
outer and shared thick walls. An outer opening uses its room colour for
the complete wall depth; a shared opening with different fills has one
hard transition exactly on the wall axis and no white/alpha seam. Window
glass and all architectural symbols remain above it. Repeat with hidden
opening symbols, hidden wall borders and in the Background editor; Glow
and sun geometry must not change
[auto: smoke_opening_tunnel_fill + test/wall-thickness.test.mjs +
test/logic.test.mjs]
- [ ] **Door/gate light uses the clear tunnel**: place an off-centre light beside a
door or gate in a thick wall. In the neighbouring room the glow is limited by
sight lines through both the near and far inner-face corners; neither
+24 -10
View File
@@ -514,7 +514,7 @@ HA. «Показать» заблокировано до активации. П
## 12. Визуальные состояния устройств
Система состоит из трёх независимых слоёв:
В динамических режимах система состоит из трёх независимых слоёв:
1. **подложка маркера** сообщает устойчивый статус;
2. **иконка/число** сообщает тип или значение;
@@ -547,13 +547,14 @@ HA. «Показать» заблокировано до активации. П
Цвет и размер кольца можно изменить для конкретного маркера. Если цвет не задан, RGB-свет может передать кольцу свой текущий цвет.
### Три варианта «Отображение»
### Четыре варианта «Отображение»
| Опция | Иконка | Подложка статуса | Активность | Значение |
|---|---:|---:|---:|---:|
| Иконка | Да | Да | Нет | Отдельные компактные °/% и LQI возможны |
| Значок + динамическая подложка | Да | Да | Нет | Отдельные компактные °/% и LQI возможны |
| Иконка + активность | Да | Да | Да | Отдельные компактные °/% и LQI возможны |
| Значение вместо иконки | Если значение получить нельзя или источник неоднозначен | Да | Нет | Температура/влажность либо числовое или текстовое состояние, отформатированное HA |
| Всегда статичный значок | Да | Всегда нейтральная тёмная | Нет | Нет |
В режиме «Значение вместо иконки» поддерживаются и числа, и локализованные HA
текстовые состояния. Если источник отсутствует, недоступен или неоднозначен,
@@ -561,6 +562,15 @@ HA. «Показать» заблокировано до активации. П
сокращается многоточием, полный остаётся в подсказке и accessibility-описании.
LQI остаётся отдельным бейджем.
«Всегда статичный значок» — осознанное исключение из общей матрицы состояний.
Маркер не меняет подложку или иконку, не показывает тревогу, недоступность,
RGB-цвет, значение, температуру, влажность и LQI. Для пылесоса также скрываются
живой puck, след и подсветка убираемой комнаты, но сохранённая история не
удаляется. Hover/focus остаются такими же, как у других маркеров; клик, controls,
Glow и заливка «Свет» продолжают работать по фактическому состоянию и отдельным
настройкам устройства. Для привязок, способных сообщать о тревоге, редактор
показывает неблокирующее предупреждение перед сохранением этого режима.
Живой блок предпросмотра в настройках marker использует несохранённые поля
формы и текущее состояние HA. Он отдельно показывает интеграцию привязки и
фактическую сущность отображения. Для «Иконка + активность» можно безопасно
@@ -631,16 +641,20 @@ LQI остаётся отдельным бейджем.
| Видимое `light.* = on` | Да | Да | Да |
| `switch.* = on`, обычный | Нет | Нет | Да |
| `switch.* = on` + «Это источник света» | Да | Да | Да |
| Управляемая группа `controls` | Да, по состояниям группы | Да, по группе | Да, если работает любой источник |
| Управляемая группа `controls` | Да, по состояниям группы | Только у отдельных маркеров реальных источников; выключатель сам пятно не создаёт | Да, если работает любой источник |
| Скрытый маркер | Нет | Нет | Не рисуется |
Для всех четырёх потребителей — Glow, заливки «Свет», строки света в карточке
комнаты и группового toggle — набор вычисляется одинаково. Если заданы
`controls`, именно они описывают источники маркера. Иначе флаг «Это источник
света» превращает основной `light.*`/`switch.*` маркера в один источник; без
явных настроек автоматически учитываются его `light.*`. Одна сущность не
считается дважды. Ручная привязка маркера к комнате (`room_id`) точнее HA-зоны
и работает в том числе для комнаты без HA Area.
`controls`, они описывают управляемые источники и состояние группы, но не
назначают координаты выключателя координатами лампы. Пятно Glow строится только
от отдельного маркера реального `light.*` либо от маркера с явным флагом «Это
источник света». Без явных настроек автоматически учитываются собственные
`light.*` устройства. Одна сущность не считается дважды; при наличии и
контроллера, и лампы физическая точка принадлежит лампе независимо от порядка
реестра. Собственная `switch.*`-сущность внутри старого `controls` игнорируется,
пока пользователь явно не включит флаг источника. Ручная привязка маркера к
комнате (`room_id`) точнее HA-зоны и работает в том числе для комнаты без HA Area.
В режиме Glow свет ограничивается внутренним контуром комнаты, проходит в комнаты через двери и свободно объединяет комнаты через виртуальные границы. Наружная дверь не выпускает свет за пределы дома. У толстых стен сектор двери дополнительно обрезается внутренним тоннелем и откосами.
@@ -938,7 +952,7 @@ show_signal: true
| Нет температуры climate | В маркере включите использование `current_temperature` |
| Нет значения в value-режиме | Основное состояние нечисловое; используйте иконку или живой текст подложки |
| Нет LQI | Сущности устройства не отдают известный атрибут качества; проверьте локальную настройку пространства |
| Реле не светит в Glow | Включите «Это источник света» или свяжите реальные источники в `controls` |
| Реле не светит в Glow | Включите «Это источник света». `controls` управляет внешними лампами, но не переносит их Glow на координаты реле; реальные лампы должны иметь свои маркеры |
| Реле не влияет на заливку «Свет» | Включите «Это источник света», проверьте состояние основного `switch.*` и привязку маркера к нужной комнате |
### Геометрия выглядит неверно
+5
View File
@@ -139,6 +139,11 @@ previous run, at 40% opacity). The legacy boolean `trail` still maps in
(`false` → never). Recording is independent of the mode: the server always
records, the mode only decides what is drawn.
The device-level `display: static_icon` is a stronger visual override: it hides
the moving puck, current/previous trails and room highlight regardless of the
vacuum trail mode. It does not delete calibration or server history; changing
back to a dynamic device display restores the applicable live overlays.
The last segment is a rAF-driven tip line whose endpoint is glued to the
puck's animated centre every frame, so the path pours out from under the
icon instead of popping in when the next telemetry point lands.
+14 -2
View File
@@ -69,6 +69,15 @@ it after the body so its full stored extent and live preview stay visible.
- **Inner contour** = `inset(poly, half[])`.
- Room fills and glow are clipped to the inner contour (not painted into the
wall hatch).
- The free tunnel cut by a door, window or gate continues the effective room
fill through the wall body instead of exposing the neutral paper below the
plan. On an outer wall the one adjacent room owns the complete depth. On a
shared wall each room owns its half, with a hard colour change exactly on the
wall centreline. This is a base-fill layer only: Glow and sun remain above it
with their existing aperture, clipping, colour and opacity.
- A zero-thickness wall, virtual span, orphan opening or opening associated only
with an unfinished room draft has no coloured tunnel. Mixed-thickness legacy
spans are clipped to their actual atomic wall bodies.
- Glow leaving through a door uses the clear rectangular opening tunnel: its
sector is the intersection of the doorway spans at the near and far inner
faces. The two jamb returns therefore clip off-axis light; a zero-depth wall
@@ -105,13 +114,16 @@ Decor-line thickness, per-side finish, auto-from-backdrop, plan-wide default.
## 8. Testing
Unit: ring closed at corners; half-out; inner area; atomic partial shared;
virtual-T mitre; angle-aware opening; thick-door tunnel clipping; whole and
virtual-T mitre; angle-aware opening; thick-door tunnel clipping and room-side
colour ownership; whole and
atomic rekey after edge/scale.
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial
virtual stretch, its solid thick remainders and Undo move as one real resize;
the virtual rubber band paints above the real body; sun starts at the room-side
opening corners; nav mode restores after `can_write`; a 1 cm body uses
solid-only in both full and static cards while a 20 cm body keeps its hatch.
solid-only in both full and static cards while a 20 cm body keeps its hatch;
door/window/gate tunnels repeat outer/shared room fills without an axis seam
(`demo/smoke_opening_tunnel_fill.mjs`).
## 9. Independent partitions, drafts and columns
@@ -0,0 +1,481 @@
# Всегда статичный значок устройства
Статус: **ТЗ актуализировано по ревью, реализовано и проверено в v1.60.2**
Дата: **2026-08-08**
Область: **редактор устройств, все поверхности отображения marker, единый resolver визуального состояния, backend-валидация и пользовательская документация**
## 1. Краткое продуктовое решение
В поле **«Отображение»** добавляется четвёртый режим:
**«Всегда статичный значок»**.
В этом режиме видимый marker устройства всегда имеет один и тот же нейтральный вид:
- стандартная тёмная подложка marker;
- базовая выбранная или автоматически определённая иконка;
- заданные пользователем размер и угол поворота;
- без изменения подложки, прозрачности, цвета и иконки из-за состояния Home Assistant;
- без activity/pulse-эффекта;
- без RGB-окраски от источника света;
- без подмены иконки по состоянию;
- без живых дополнительных значений температуры, влажности и качества связи.
- без отдельного движущегося puck, следа и других state-driven overlays живого пылесоса.
Режим является **только способом отображения marker**. Он не изменяет привязку, действия по нажатию, принадлежность комнате, участие устройства в управлении, источниках света, Glow, заливке комнаты или расчётах плана.
Текущий режим **«Значок»** переименовывается в:
- RU: **«Значок + динамическая подложка»**;
- EN: **«Icon + dynamic plate»**.
Его внутренняя модель и текущее поведение не меняются. Он остаётся режимом по умолчанию.
## 2. Причина изменения
Сейчас минимальный режим `badge`, показанный пользователю как «Значок», всё равно является динамическим:
- работающие устройства получают жёлтую подложку;
- открытые объекты получают отдельное состояние;
- тревоги становятся красными;
- `off`, `unknown` и `unavailable` используют приглушённое представление согласно действующей общей логике;
- некоторые классы устройств меняют саму иконку по состоянию;
- активный источник света может передавать marker цвет;
- при включённых общих настройках рядом появляются температура, влажность или LQI.
Название «Значок» не объясняет эту динамику, а отдельного способа намеренно оставить устройство визуально нейтральным нет. Это особенно заметно у устройств с большим числом вспомогательных сущностей, у декоративных marker и у объектов, состояние которых не нужно акцентировать на плане.
## 3. Цели
1. Дать пользователю предсказуемый режим, в котором HA state не меняет вид marker.
2. Явно назвать существующий режим `badge` динамическим.
3. Сохранить текущий режим по умолчанию для новых и существующих устройств.
4. Обеспечить одинаковый результат на интерактивном плане, в карточке пространства и в предпросмотре настроек.
5. Не смешивать отображение marker с логикой света, управления и данных плана.
6. Не требовать миграции существующих конфигураций.
## 4. Не входит в задачу
- изменение правил, по которым определяется состояние устройства;
- изменение `resolvedLightSources(room)`;
- изменение Glow, заливки «Свет» или управления источниками света;
- изменение tap action, more-info или диалога устройства;
- создание отдельной пользовательской палитры статичного marker;
- фиксация автоматически выбранной MDI-иконки как ручного override;
- изменение правил скрытых, удалённых и деактивированных в HA устройств;
- изменение поведения остальных трёх режимов, кроме переименования `badge` в UI.
Touch editor: **best effort / intentionally degraded** согласно `docs/TOUCH-SUPPORT.md`. Native select обязан оставаться сохранённым и работоспособным на узком экране, но мобильная компоновка редактора не является отдельной feature-parity целью. View и kiosk полностью поддерживаются.
## 5. Список режимов и порядок в UI
Порядок в выпадающем списке **обязателен**:
| № | Persisted value | RU | EN | Краткое поведение |
|---:|---|---|---|---|
| 1 | `badge` или `null` | Значок + динамическая подложка | Icon + dynamic plate | Иконка и текущая динамическая подложка |
| 2 | `icon_ripple` | Значок + активность | Icon + activity | Динамическая подложка плюс activity-эффект |
| 3 | `value` | Значение вместо иконки | Value instead of an icon | Значение, когда его можно однозначно получить, иначе иконка |
| 4 | `static_icon` | Всегда статичный значок | Always static icon | Всегда нейтральная подложка и базовая иконка |
Legacy-значение `ripple` по-прежнему принимается только для чтения и нормализуется в `icon_ripple`; в UI оно не показывается.
## 6. Значение по умолчанию и совместимость
### 6.1. Новые устройства
Новый marker по умолчанию получает UI-режим `badge`. При сохранении значение может, как и сейчас, быть записано как `null`/отсутствующее поле. Это означает **«Значок + динамическая подложка»**, а не статичный значок.
### 6.2. Существующие устройства
- отсутствующий `display` → `badge`;
- `display: null` → `badge`;
- `display: "badge"` → `badge`;
- `display: "ripple"` → `icon_ripple`;
- `display: "icon_ripple"` → `icon_ripple`;
- `display: "value"` → `value`;
- `display: "static_icon"` → новый статичный режим;
- неизвестное значение во frontend должно безопасно давать `badge`, а backend продолжает отклонять его при записи.
Автоматически переводить существующие устройства в `static_icon` запрещено.
### 6.3. Изменение режима
Переключение режима в открытом диалоге сразу обновляет предпросмотр, но не меняет server config до нажатия **«Сохранить»**. Отмена диалога возвращает сохранённый режим.
## 7. Нормативный визуальный контракт `static_icon`
### 7.1. Что остаётся видимым
- базовая иконка `d.icon`;
- ручная иконка, если пользователь её выбрал;
- автоматически определённая иконка, если ручного override нет;
- пользовательский размер marker;
- пользовательский угол поворота;
- обычная рамка и тень нейтрального marker согласно теме House Plan.
«Чёрная подложка» в требованиях означает текущую **стандартную нейтральную подложку marker**, а не жёстко заданный `#000000`. Реализация обязана использовать действующие theme tokens (`--hp-bg`, `--hp-line`, `--hp-txt` или их актуальные преемники), чтобы сохранить контраст и совместимость с темами HA.
### 7.2. Что принудительно отключается
Для `static_icon` renderer-ready presentation всегда получает:
- `visual.availability = "available"`;
- `visual.status = "neutral"`;
- `visual.activity = "none"`;
- `activity = "none"`;
- `valueText = null`;
- `valueFullText = null`;
- `fallbackReason = null`;
- `tempText = null`;
- `humText = null`;
- `lqiText = null`;
- `lqiColor = null`;
- `lightColor = null`;
- базовую, а не state-morphed иконку;
- отсутствие классов `on`, `open`, `alarm`, `unavail` и `activity-*`.
Отдельный live-vacuum renderer также обязан скрыть движущийся puck, текущий/предыдущий след и room-highlight для этого marker. Runtime/серверная запись следа может продолжаться независимо: смена режима отображения не удаляет историю уборки.
Ручные `ripple_color` и `ripple_size`, оставшиеся в старой конфигурации, не используются и не показываются в форме, пока выбран `static_icon`. Их сохранённые значения не должны неожиданно влиять на статичный marker.
### 7.3. Состояния, которые не меняют marker
| Условие | Результат в `static_icon` |
|---|---|
| Устройство включено | Нейтральный marker |
| Устройство выключено | Тот же нейтральный marker |
| Устройство работает | Тот же нейтральный marker |
| Устройство остановлено | Тот же нейтральный marker |
| Движение/присутствие обнаружено | Тот же нейтральный marker |
| Cover открывается/закрывается | Тот же нейтральный marker и базовая иконка |
| Дверь/замок открыт | Тот же нейтральный marker |
| Сущность `unknown`/`unavailable` | Тот же нейтральный marker, без fade |
| Тревога/протечка/дым | Тот же нейтральный marker, без красной подложки |
| Свет включён | Тот же нейтральный marker, без жёлтой/RGB-подложки |
| Краткое event-событие | Тот же marker, без activity ring |
| Общая настройка live states включена/выключена | Тот же marker |
| Включены temperature/LQI badges | Для этого marker badges не показываются |
| Живой пылесос движется или имеет сохранённый след | Puck, след и room-highlight не рисуются |
Подавление тревоги является осознанным следствием режима. Предпросмотр и диалог Home Assistant всё ещё могут показывать фактическое тревожное состояние, но marker на плане не сигнализирует о нём.
## 8. Граница между отображением и поведением устройства
`static_icon` меняет только лицо marker. Следующие возможности продолжают работать без изменений:
- клик, more-info, toggle, cover action, run action и подтверждение действия;
- `controls` и управление связанными источниками света;
- `is_light` и автоматическое определение светового источника;
- Glow и заливка «Свет по источникам»;
- световой вклад устройства в карточку комнаты и controls;
- принадлежность пространству/комнате;
- температура устройства как источник комнатной температуры, если это настроено;
- данные в tooltip/more-info и предпросмотре настроек;
- автоматическое обновление HA state внутри модели данных.
Если статичный marker выглядит доступным, но его active binding фактически имеет state `unavailable`, действие по нажатию проходит по существующему безопасному пути. Ошибка service call обязательно показывается существующим toast/HA feedback и не должна превращаться в молчаливый no-op. Новый режим не симулирует доступность сервиса и не подавляет ошибки действий.
Следствие: источник света в режиме `static_icon` может освещать комнату и создавать Glow, оставаясь сам визуально чёрным. Если пользователь не хочет, чтобы устройство влияло на освещение, для этого используется существующая настройка **«Это устройство — источник света»** и/или список управляемых источников, а не режим отображения.
## 9. Приоритеты и исключения видимости
Режим `static_icon` действует только на marker, который по общим правилам разрешено показывать.
| Ситуация | Приоритет над `static_icon` |
|---|---|
| Marker удалён с плана | Не отображается и снова доступен к добавлению |
| Пользователь нажал «Скрыть» | Не отображается в просмотре |
| В редакторе включено «Показать скрытые» | Показывается как служебный blue ghost, а не как обычный чёрный marker |
| Устройство деактивировано в HA | Принудительно скрыто в просмотре; в разрешённом design-preview показывается HA-disabled ghost |
| Привязка orphaned | Обрабатывается по отдельному lifecycle-контракту disabled-devices; `static_icon` не возвращает marker на поверхность, где общий контракт запрещает его рендер, и не маскирует диагностику потерянной привязки |
| Marker выбран в редакторе | Допускается внешняя рамка выделения |
| Keyboard focus | Обязателен `focus-visible` outline для доступности |
| Новый marker | Допускается служебная точка «новое устройство» |
Служебные editor/diagnostic overlays не считаются реакцией на состояние устройства. Они не должны перекрашивать внутреннюю подложку обычного видимого статичного marker.
## 10. Hover, focus и действия
Интерактивные состояния интерфейса не входят в понятие статичности устройства и сохраняют текущее общее поведение marker:
- hover продолжает использовать существующую accent-подсветку;
- pointer cursor, tooltip и действие по клику сохраняются;
- keyboard focus обозначается существующим focus-состоянием;
- выбор marker в редакторе обозначается существующей рамкой/тенью;
- drag feedback сохраняется, но не должен имитировать HA-состояния `on`, `open`, `alarm` или `unavailable`.
После завершения hover/focus/selection/drag marker возвращается к своей нейтральной статичной подложке. Новый режим подавляет только визуальные реакции на данные и состояния устройства, но не обратную связь на действия пользователя.
## 11. Предпросмотр устройства
`hp-device-preview` обязан использовать тот же `ResolvedDevicePresentation`, что и план и `houseplan-space-card`.
При выборе `static_icon`:
- stage сразу показывает нейтральную подложку и базовую иконку;
- кнопка демонстрации activity не показывается;
- строка «Текущее состояние» продолжает показывать фактический state HA;
- строка «Источник отображения» продолжает показывать сущность/сущности;
- строка интеграции продолжает показывать provider;
- строка «Результат» показывает локализованное объяснение:
RU: **«Статичный режим: состояние устройства не меняет значок»**;
EN: **«Static mode: device state does not change the icon»**;
- тревога, недоступность и активность могут быть видны в фактах/технических подробностях, но не меняют stage;
- режим не создаёт и не изменяет activity runtime.
Если привязка содержит alarm-capable сущность (`smoke`, `gas`, `carbon_monoxide`, `moisture`, `safety`, `tamper`, `problem`, siren или alarm panel), под select показывается неблокирующее предупреждение: статичный режим скроет визуальную тревогу marker. Предупреждение не запрещает Save и не зависит от того, активна ли тревога прямо сейчас.
В `PresentationReason` добавляется отдельная причина `static_icon`. Она имеет приоритет после `ha_disabled`/`orphaned`, но до alarm/availability/state причин для обычного renderable marker.
## 12. Автоматическая и ручная иконка
Статичный режим использует **базовую effective icon**:
- ручной `marker.icon`, если он задан;
- иначе иконку, определённую текущими правилами автоматического выбора.
State morph запрещён: например, cover не меняет `mdi:...-closed` на `mdi:...-open` при изменении state.
Автоматическая иконка не превращается в ручной override только из-за выбора `static_icon`. Поэтому она может измениться после реального изменения метаданных устройства или правил иконок. Если пользователю нужна полностью зафиксированная MDI-иконка и при изменении конфигурации HA, он должен нажать существующее действие фиксации автоматической иконки.
## 13. Модель данных и backend
### 13.1. Frontend
Канонический тип:
```ts
type DeviceDisplayMode = 'badge' | 'icon_ripple' | 'value' | 'static_icon';
```
`ripple` остаётся только legacy input и нормализуется в `icon_ripple`.
`DISPLAY_MODES`:
```ts
export const DISPLAY_MODES = ['badge', 'icon_ripple', 'value', 'static_icon'] as const;
```
Обязательно использовать единый экспортируемый `DeviceDisplayMode` и normalizer вместо повторения union и локальных `_displayOf()` в нескольких файлах. Неизвестное runtime-значение нормализуется в `badge`; `ripple` нормализуется в `icon_ripple`.
### 13.2. Persisted marker
```yaml
display: static_icon
```
Поле `display` не переименовывается. Версия общей модели данных не меняется: это расширение уже существующего enum.
### 13.3. Backend
`MARKER_SCHEMA` принимает:
```text
badge | ripple | icon_ripple | value | static_icon | null
```
Cross-language test обязан подтверждать, что каждое значение из frontend `DISPLAY_MODES` принимается backend-схемой.
## 14. Архитектурный план реализации
### 14.1. Единая точка поведения
Основная логика реализуется в `resolveDevicePresentation()`, а не отдельно в каждом renderer.
Предпросмотр продолжает вычислять sources и фактические HA states для объяснения результата. Плановым поверхностям разрешён явный short-circuit source/value resolution для `static_icon`, поскольку этим данным нечего менять в renderer-ready лице. Short-circuit не должен затронуть отдельные агрегаты света/климата/controls, которые вызывают свои resolver независимо от marker presentation.
### 14.2. Поверхности-потребители
Должны быть проверены:
1. интерактивный план в `houseplan-card`;
2. `houseplan-space-card` через `space-render`;
3. `hp-device-preview`;
4. режим устройства в editor и design preview;
5. kiosk/view mode;
6. скрытые и HA-disabled ghosts.
Ни одна поверхность не должна самостоятельно повторно классифицировать состояние или добавлять live badges после resolver.
### 14.3. CSS
Renderer должен иметь стабильный hook для статичного режима, например класс `static-icon` или `data-display="static_icon"`, чтобы:
- отключить перекраску внутренней подложки от HA state, не изменяя общий hover;
- сохранить существующие hover, focus и editor selection;
- не зависеть от порядка классов `on/open/alarm/unavail`;
- дать одинаковый результат в основном плане, предпросмотре и статической карточке.
Не допускается решать задачу только более специфичным CSS поверх динамических классов: semantic projection тоже обязана быть нейтральной, чтобы tooltip, preview explanation и будущие renderers не получали ложное состояние.
### 14.4. Activity runtime
- `static_icon` никогда не отображает уже активный event window;
- preview demo останавливается при переключении на `static_icon`;
- накопленный runtime не должен менять статичный marker;
- после перехода обратно в `icon_ripple` не следует искусственно запускать activity только из-за смены режима;
- реальные последующие события после возврата в `icon_ripple` работают обычно.
## 15. Локализация и текстовая подсказка
Обновить RU/EN ключи:
```text
display.badge
display.static_icon
marker.display_hint
marker.preview.reason.static_icon
```
Рекомендуемая RU-подсказка:
> Динамическая подложка показывает работу, открытие, тревоги и недоступность. «Значок + активность» дополнительно показывает события и движение. «Значение вместо иконки» выводит однозначное состояние HA. Статичный значок не меняется от состояний устройства.
Рекомендуемая EN-подсказка:
> The dynamic plate shows work, open, alarm and unavailable states. Icon + activity also shows events and motion. Value instead of an icon shows one unambiguous HA state. A static icon does not react to device states.
Длинный ярлык `display.badge` проверяется в узком native select. Обрезка закрытого системного select допустима только если после открытия ОС показывает полный текст option; горизонтальный scroll или нарушение ширины диалога недопустимы.
## 16. Accessibility
- Названия режимов должны полностью озвучиваться native select.
- Контраст нейтральной иконки и подложки соответствует текущему marker contract.
- `static_icon` не отменяет `focus-visible`.
- Нельзя передавать состояние только через скрытую подложку: фактический state остаётся доступен в tooltip/more-info и в текстовом предпросмотре.
- Для тревожных устройств пользователь осознанно отказывается от визуальной тревоги на marker; это должно быть понятно из предпросмотра и подсказки режима.
- `prefers-reduced-motion` не требует отдельной ветки: в статичном режиме движения нет изначально.
## 17. Edge cases
### 17.1. Источник света
Свет включён, marker имеет `display: static_icon` и `is_light: true`: marker остаётся нейтральным, но Glow и заливка комнаты продолжают работать.
### 17.2. Critical alarm среди вторичных сущностей
Resolver продолжает находить critical source для диагностики, но лицо marker не становится красным. В предпросмотре current state/details тревога остаётся видна.
### 17.3. Нет HA state при загрузке
Marker сразу отображается нейтральным, без промежуточного `unavail` fade и без последующего визуального скачка после прихода states.
### 17.4. Виртуальное устройство
Виртуальный marker использует тот же статичный контракт. Его обычная dashed-рамка как признак типа объекта сохраняется, поскольку это не HA state.
### 17.5. Value → static
Текстовое значение немедленно заменяется базовой иконкой. Fallback notice value-режима исчезает.
### 17.6. Activity → static
Текущее кольцо исчезает немедленно. Возврат в activity-режим не создаёт новое событие сам по себе.
### 17.7. Автоматическая state icon
Открытие cover, lock или door не меняет glyph. При ручной смене icon в форме предпросмотр меняется — это конфигурационное действие пользователя, а не реакция на HA state.
### 17.8. Размер и поворот
Ручные size/angle применяются как обычно. «Статичный» не означает «не редактируемый».
### 17.9. Custom CSS/card-mod
Встроенный контракт гарантирует нейтральный вид без пользовательских overrides. Намеренный custom CSS может изменить marker; задача не должна пытаться перебить все внешние `!important`-правила.
## 18. Тестовый план для этапа реализации
По действующему правилу проекта тесты запускаются перед пре-релизом; ниже перечислен обязательный набор тестов, который должен быть добавлен вместе с реализацией.
### 18.1. Unit: presentation resolver
Для `static_icon` проверить минимум:
1. `on` и `off` дают одинаковую visual projection;
2. `unknown` и `unavailable` не дают `unavail`;
3. `alarm` не даёт `alarm`;
4. presence/event/transition/running не дают activity;
5. cover state не меняет базовую иконку;
6. RGB light не задаёт `lightColor`/динамическую окраску;
7. value/temp/hum/LQI отсутствуют;
8. size и angle сохраняются;
9. source facts/signature остаются доступными;
10. hidden/HA-disabled precedence соблюдается;
11. explanation reason равен `static_icon` для обычного marker;
12. переход между display modes не создаёт activity.
13. live vacuum projection помечается как подавленная, а plan renderer не рисует puck/след в `static_icon`.
### 18.2. Backend
- `static_icon` принимается `MARKER_SCHEMA`;
- неизвестное значение отклоняется;
- cross-language enum test проходит;
- legacy `ripple` и `null` по-прежнему принимаются.
### 18.3. UI/component
- в select четыре пункта в заданном порядке;
- `badge` выбран по умолчанию;
- новое название `badge` локализовано RU/EN;
- `static_icon` последним в RU/EN;
- activity settings скрыты для `static_icon`;
- preview сразу становится нейтральным;
- activity demo отсутствует;
- hover работает так же, как у остальных marker;
- focus-visible и editor selection остаются видимыми;
- сохранение/повторное открытие сохраняет `static_icon`.
- alarm-capable binding получает неблокирующее предупреждение;
- tap по `unavailable` binding в `static_icon` показывает видимую ошибку service call, а не молчит;
- длинный RU/EN ярлык не создаёт горизонтальный scroll на узком экране.
### 18.4. Поверхностный parity test
Один и тот же draft/config marker при одинаковых входных данных имеет одинаковые icon/classes/badges в:
- основном плане;
- `hp-device-preview`;
- `houseplan-space-card`.
Parity-набор включает отдельную vacuum-фикстуру: base marker статичен, а puck/след отсутствуют. Минимальный browser smoke проверяет alarm-устройство в `static_icon` (нет `alarm`, `unavail`, `activity-*`), мгновенное переключение режима туда и обратно, preview parity и отсутствие vacuum overlay.
## 19. Документация реализации
В той же локальной серии изменений обновляются:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` отдельной user-visible записью;
- `docs/USER-GUIDE.ru.md` — четыре режима и их матрица;
- `docs/ARCHITECTURE.md` и `docs/STATUS.md` — enum и единая static projection;
- `docs/FILTERING.md` — исключение из общей state-матрицы;
- `docs/VACUUM.md` — подавление puck/следа;
- `docs/TESTING.md` и `docs/TESTING-DEMO.md` — unit/backend/browser/manual сценарии;
- существующие скриншоты редактора с прежним списком режимов, если такие актуальные скриншоты обнаружены.
## 20. Критерии приёмки
1. В списке «Отображение» четыре режима, `static_icon` расположен последним.
2. Текущий «Значок» называется «Значок + динамическая подложка».
3. Новые и старые устройства остаются в динамическом `badge` по умолчанию.
4. Устройство в `static_icon` выглядит одинаково при `on`, `off`, working, open, alarm, unknown и unavailable.
5. Статичный marker не меняет иконку по state, не получает activity, RGB, value, temperature, humidity или LQI.
6. Размер, угол и базовая иконка применяются.
7. Световое и управляющее поведение устройства не меняется.
8. Скрытые, удалённые и HA-disabled устройства не возвращаются на план из-за `static_icon`.
9. Hover, focus и selection работают так же, как у остальных marker, и не изменяются этой задачей.
10. Предпросмотр точно объясняет статичный результат, сохраняя фактический HA state в текстовых фактах.
11. Backend принимает новый enum, существующие конфигурации не мигрируют и не ломаются.
12. Все три renderer-поверхности используют единый resolver и показывают одинаковый результат.
13. Живой пылесос в `static_icon` не показывает puck, след или room-highlight, но сохранённая история не удаляется.
14. Ошибка действия по недоступной сущности остаётся видимой пользователю.
15. Alarm-capable binding получает честное неблокирующее предупреждение до Save.
## 21. Подтверждённые продуктовые решения
Владелец продукта подтвердил 2026-08-08:
1. Статичность касается только внешнего вида marker. Устройство продолжает влиять на Glow, заливку «Свет» и controls согласно своим отдельным настройкам и фактическому состоянию.
2. У статичного marker полностью скрываются живые satellite badges температуры, влажности и LQI.
3. Hover не изменяется этой задачей и сохраняет текущее общее поведение marker, включая существующую accent-подсветку. Статичность относится к HA state и данным устройства, а не к интерактивной обратной связи UI.
Тревоги, `unknown`/`unavailable`, state icon morph и RGB-цвет также безусловно подавляются, поскольку статичный marker должен иметь одинаковый вид при любых состояниях устройства.
@@ -0,0 +1,364 @@
# Заливка проёмов в толстых стенах цветом комнаты
Статус: **ТЗ актуализировано по ревью, реализовано и проверено в v1.60.2**
Дата: **2026-08-08**
Область: **визуальный слой комнат и проёмов, толстые стены, все режимы заливки; без изменения модели данных и геометрии Glow**
## 1. Краткое продуктовое решение
Видимая внутренняя поверхность проёма в физической стене с толщиной — далее **тоннель проёма** — должна продолжать заливку примыкающей комнаты, а не показывать белую/нейтральную «бумагу» под планом.
Изменение относится только к фоновому цвету свободного прямоугольника внутри выреза стены. Оно не меняет:
- цвет и геометрию створки, дуги открытия, оконного стекла, косяков и hover-outline;
- состояние контактного датчика и существующую оранжевую индикацию открытого проёма;
- форму выреза в стене;
- распространение Glow через дверь или ворота;
- солнечные лучи через окно;
- влияние стен, перегородок и колонн на свет и площадь;
- сохранённую модель `openings`, `walls`, `open_spans`, rooms и fill settings.
## 2. Текущее поведение и причина дефекта
Сейчас физическое тело стены вырезается на полную глубину проёма. Заливка комнаты при наличии толщины стены, напротив, ограничивается внутренним контуром чистого пола и не входит в стену. Поэтому через вырез стены виден нижележащий слой `hp-paper`, обычно белый, даже когда примыкающая комната имеет серую, жёлтую, температурную или другую заливку.
Glow уже рисуется отдельным слоем после базовых заливок комнат и до тела стен. Стены затем маскируют Glow, оставляя видимым только рассчитанный тоннель. Этот порядок является правильным и должен сохраниться.
## 3. Термины
| Термин | Значение |
|---|---|
| Тело стены | Физическая полоса стены между внешней и внутренней гранями, включая hatch и solid fill |
| Вырез проёма | Часть тела стены, удалённая геометрией двери, окна или ворот |
| Тоннель проёма | Видимая свободная область внутри выреза между двумя гранями стены |
| Сторона тоннеля | Половина тоннеля от оси стены до внутренней грани примыкающей комнаты |
| Эффективная заливка комнаты | Фактические цвет и opacity после space fill, room override, текущих HA-данных и глобальной палитры |
| Символ проёма | Створка, дуга, стекло, линии косяков, hover/hit geometry и lock badge |
## 4. Границы задачи
### Входит
- визуальная подложка тоннеля физической стены с `cm > 0`;
- вычисление примыкающей комнаты с каждой стороны оси стены;
- все существующие режимы заливки: `none`, `lqi`, `light`, `temp`, `glow`;
- room-level override заливки;
- внешние и общие стены, прямые и наклонные сегменты;
- двери, окна и ворота без исключений;
- View, kiosk и те редакторы, в которых фактически видна заливка комнат;
- корректное совместное отображение с backdrop, day/night, hover и скрытыми символами проёмов.
### Не входит
- отдельная настройка цвета порога или откоса;
- выбор напольного покрытия в дверном проёме;
- изменение цвета створок, дуг, стекла, косяков или стен;
- анимация смешивания цветов между комнатами;
- изменение модели данных или добавление `room_id` в opening;
- изменение Glow, sun, area, wall-cut, resize, opening placement или contact logic;
- добавление проёмов в `houseplan-space-card`, который сейчас их отдельно не рисует;
- поддержка проёмов в независимых перегородках — текущая модель проёмов относится к стенам комнат.
## 5. Нормативный визуальный контракт
1. Если проём лежит на физической стене с ненулевой толщиной, свободная область тоннеля получает эффективную заливку примыкающей комнаты.
2. Цвет и opacity должны совпадать с заливкой комнаты в этом же кадре, без отдельного округления, осветления или принудительной непрозрачности.
3. Подложка ограничивается фактической шириной проёма и фактической глубиной тела стены.
4. Подложка не перекрывает hatch/solid тела стены, линии косяков, створки, дуги, стекло, hover-outline и hit target.
5. У стены нулевой толщины отдельная подложка тоннеля не создаётся: текущий line-wall render остаётся без изменений.
6. Если эффективной заливки у комнаты нет, её сторона тоннеля остаётся прозрачной и показывает тот же нижний слой, что комната.
7. Изменение HA-состояния, влияющего на room fill, одновременно обновляет и заливку тоннеля без отдельного кэша состояния.
8. Открытие/закрытие контакта меняет только существующий символ проёма. Цвет тоннеля не получает `--hp-open` и не зависит от `openingAmount`.
## 6. Общая стена и разные заливки
Обязательный контракт:
- если с обеих сторон стены находится по комнате, тоннель делится по оси стены на две половины;
- каждая половина повторяет эффективную заливку комнаты со своей стороны;
- если обе стороны имеют одинаковые RGBA, тоннель рисуется как единая одноцветная область;
- если RGBA различаются, используется один SVG hard-stop gradient с переходом строго на геометрической оси стены, без смешивания; процент stop вычисляется из локальной глубины результирующей геометрии и не считается автоматически равным 50% её bounding box;
- отсутствие комнаты с одной стороны внешней стены не создаёт прозрачную внешнюю половину: единственная примыкающая комната окрашивает тоннель на всю глубину;
- если на общей стене заливка определена только у одной комнаты, её половина окрашивается, вторая остаётся прозрачной.
Такой вариант не требует вводить понятие «кому принадлежит порог», не зависит от направления створки и честно продолжает обе комнаты.
## 7. Режимы заливки
| Эффективный режим комнаты | Цвет стороны тоннеля |
|---|---|
| `none` | Прозрачный; отдельного визуального цвета нет |
| `lqi` | Тот же интерполированный цвет и opacity, что у комнаты; при отсутствии LQI — прозрачный |
| `light` | `light_on`, `light_off` или `light_none` по тому же `resolvedLightState`; если результат комнаты `null` — прозрачный |
| `temp` | `temp_cold`, `temp_ok` или `temp_hot` по тому же источнику и порогам; при отсутствии температуры — прозрачный |
| `glow` | Только базовый `glow_base` комнаты. Радиальный световой Glow рисуется поверх этой базы существующим неизменённым слоем |
Room override вычисляется до таблицы: `room.settings.fill_mode` имеет тот же приоритет, что при обычной отрисовке комнаты.
## 8. Строгая неизменность Glow и солнечных лучей
Подложка тоннеля должна находиться в SVG-слоях:
1. после backdrop и вместе с/сразу после базовых room fills;
2. **до** `_renderGlowLayer()`;
3. **до** `_renderSunRays()`;
4. **до** `_renderWallBodies()`;
5. до символов `_renderOpenings()`.
Следствия:
- существующий Glow продолжает лежать поверх базовой заливки тоннеля;
- тело стены по-прежнему маскирует Glow и подложку вне фактического выреза;
- `doorSector`, near/far aperture, jamb clipping, радиус, цвет и opacity Glow не меняются;
- солнечный луч через окно продолжает лежать поверх базовой заливки и стартует из тех же внутренних углов;
- новая функция не должна вызываться из Glow resolver и не должна менять `passages`, `passageTunnelDepth`, `clipPath` или physical occluders;
- тесты должны сравнивать Glow geometry до/после изменения, а не только визуальный screenshot.
## 9. Геометрия тоннеля
### 9.1 Привязка к стене
Проём связывается с физическим span по тем же основным правилам, что текущие wall cuts и `openingInnerFaceOffset`:
1. направление стены совпадает с `opening.angle` modulo 180° в допустимом tolerance;
2. opening center проецируется на span;
3. ширина проёма должна находиться внутри span;
4. среди допустимых span выбирается ближайший;
5. перпендикулярная принимающая стена в T-стыке не может победить совпадающий по направлению span.
Нельзя вводить третий независимый алгоритм ассоциации. Следует выделить или расширить общий pure helper, используемый wall cut/inner-face/tunnel fill.
### 9.2 Размер
- длина локального прямоугольника по оси X равна clear width opening;
- глубина по локальной Y равна фактической толщине выбранного wall interval в render units;
- центр совпадает с осью стены и `opening.rx/ry`;
- поворот совпадает с нормализованным `opening.angle`;
- patch пересекается с фактическим телом выбранного wall interval и не выходит за него;
- если для защиты от субпиксельной щели всё же применяется технологический overhang, он обязан быть симметричным относительно оси стены, а hard-stop остаётся привязанным к локальной координате `y = 0`, а не к середине прямоугольника/bounding box;
- визуальный выход цвета за тело стены запрещён.
### 9.3 Комнаты по сторонам
Pure geometry helper возвращает:
```ts
interface OpeningTunnelSides {
depth: number;
negativeRoomId: string | null;
positiveRoomId: string | null;
wallKey: string | null;
}
```
Комната относится к стороне, если её boundary содержит совпадающий wall span, а тестовая точка от оси в сторону её внутренней грани попадает в room polygon/inner side. Порядок rooms в config не должен менять результат.
При нескольких кандидатах на одной стороне:
1. выигрывает комната, чей совпадающий edge содержит всю ширину opening;
2. затем ближайшая внутренняя грань;
3. затем меньшая геометрическая площадь как наиболее специфическая nested room;
4. затем стабильный `room.id` только как последний tie-breaker.
## 10. Сложная и повреждённая геометрия
| Ситуация | Поведение |
|---|---|
| Opening не найден на wall span | Подложка не рисуется; символ и сохранённые данные не меняются |
| Wall thickness равна 0/отсутствует | Подложка не рисуется |
| Opening попал на виртуальный `open_span` | Подложка не рисуется: физического тоннеля нет |
| Opening геометрически совпал только с `room_draft` | Подложка не рисуется: незавершённый контур имеет физическое тело, но ещё не является комнатой и не предоставляет цвет заливки |
| Opening частично выходит за конец стены | Patch должен быть пересечён с фактическим wall cut/body; запрещён выход цвета наружу. Валидация/Optimize остаются отдельной задачей |
| На ширине opening меняется толщина | Используется фактическая wall-cut geometry; нельзя заливать прямоугольник глубже тела стены. Неканоническая legacy-конфигурация деградирует без артефакта |
| T/X-перекрёсток | Выбирается только коллинеарный wall span; другие wall bodies маскируют patch |
| Совпадающая независимая перегородка | Opening её не прорезает; partition body, нарисованный позже, маскирует patch |
| Колонна перекрывает часть opening | Column body маскирует patch и остаётся физическим препятствием |
| Дублированные rooms/невозможное перекрытие | Детерминированный tie-breaker из §9.3; никакого изменения config |
| NaN/невалидные координаты | Никакого patch и исключения render pipeline |
## 11. Внешние стены
- Дверь, окно или ворота наружу имеют только одну примыкающую комнату.
- Эта комната окрашивает всю глубину тоннеля, а не только внутреннюю половину.
- Внешний stage/daynight background не считается второй «комнатой» и не смешивается с room fill.
- Направление створки, `flip_h`, `flip_v` и специальный наружный swing ворот не меняют выбор цвета.
## 12. Слои, режимы интерфейса и скрытие
| Поверхность/режим | Контракт |
|---|---|
| View | Patch виден и совпадает с room fill |
| Kiosk | То же, что View |
| Device editor | Patch виден там же и с той же opacity, где видна заливка комнаты |
| Backdrop editor | Patch входит в ту же визуально приглушённую группу/политику, что rooms/openings/walls. Его собственный fill-opacity не умножается отдельно второй раз |
| Plan editor | Patch рисуется только если сам room fill в этом режиме фактически виден; он не должен мешать выбору wall/opening и не получает pointer events |
| `houseplan-space-card` | Без изменений в этой задаче: карточка сейчас не рисует opening symbols/cuts. Общие pure helpers проектируются повторно используемыми |
`hide_openings` скрывает архитектурный символ, а не физический смысл проёма. Поэтому patch тоннеля остаётся видимым, как сохраняются wall cut, Glow и sun. У patch нет собственного hit target.
При `show_borders: false` физическая стена остаётся частью модели площади и света. Рекомендуется сохранять patch как часть room fill geometry; он не должен включать обратно hatch, outline или символ проёма.
## 13. Hover, selection и состояние проёма
- Room hover не входит в фактическую room fill и не меняет сохранённый/динамический цвет тоннеля.
- Если в будущем hover должен визуально включать тоннель, это отдельное UX-решение; текущая задача не расширяет hover path.
- Opening hover-outline в Plan editor остаётся поверх patch.
- Выбранный opening, ruler, center tick и курсор не меняются.
- Plan editor использует собственную голубую editing wash и не показывает динамические room fills, поэтому tunnel patch в нём также не рисуется. Во время resize комнаты, drag существующего opening и preview нового opening цветной patch отсутствует и не может остаться в старом проёме.
- Wall cut и архитектурный символ при этом продолжают следовать за `_rszPreview`/live-координатами существующим способом. После commit и возврата в режим, где room fill виден, tunnel строится сразу по новой сохранённой геометрии.
- Открытый contact продолжает окрашивать symbol в `--hp-open`; базовый patch остаётся цветом комнаты.
- Lock badge не зависит от patch.
## 14. Архитектурный план
### 14.1 Единый resolver эффективной заливки
Текущий расчёт room fill внутри render-map нельзя копировать в opening pipeline. Нужен общий resolver уровня карточки или чистой логики:
```ts
interface ResolvedRoomFill {
color: string;
opacity: number;
mode: RoomFillMode;
}
resolveEffectiveRoomFill(room, spaceDisplay, fillColors, liveAggregates): ResolvedRoomFill | null
```
Обычная room shape и tunnel patch обязаны получать один результат из одного вызова/мемоизированной карты `roomId → fill` на кадр.
Рефакторинг не имеет права менять существующую заливку самих комнат. Для `none`, `lqi`, `light`, `temp`, `glow`, room override и отсутствующих HA-данных итоговые `color` и `opacity` room shape после выноса resolver должны совпадать с дорефакторинговыми значениями.
### 14.2 Pure geometry helper
В `wall-thickness.ts` или соседнем geometry-модуле выделяется функция определения depth и room sides. Она не читает DOM, Lit state или HA.
### 14.3 Renderer
Рекомендуемый отдельный `_renderOpeningTunnelFills(space, resolvedRoomFills)`:
- возвращает pointer-inert SVG group;
- создаёт один oriented rect/path на opening;
- одинаковый RGBA рисует solid fill;
- разные стороны рисует один hard-stop linear gradient с переходом на локальной оси стены `y = 0`;
- для наклонной стены gradient задаётся в локальных координатах opening (`gradientUnits="userSpaceOnUse"` внутри повёрнутой группы либо эквивалентный `gradientTransform`), поэтому stop-линия всегда параллельна оси стены;
- использует стабильные, безопасные id gradient, не зависящие от room name;
- располагается в порядке слоёв из §8;
- имеет диагностический, но не обязательно публичный hook `data-hp="opening-tunnel" data-id=<opening id>`;
- не участвует в opening hit-testing.
### 14.4 Кэширование
- geometry cache key: space id + `_cfgEpoch` + wall/opening geometry;
- fill values не включаются в долгоживущий geometry cache, потому что LQI/light/temp меняются с HA state;
- на HA tick пересчитываются только RGBA примыкающих rooms, не wall union;
- patch count ограничен количеством openings; отдельные observers/timers запрещены.
В Plan editor tunnel geometry cache не читается и patch не создаётся. Live resize и opening drag обязаны инвалидировать geometry epoch wall cut, чтобы сам физический вырез следовал за preview; при следующем отображении room fill tunnel вычисляется из актуального `_renderCfg`.
## 15. Модель данных и совместимость
- Схема backend не меняется.
- Новых полей в config нет.
- Миграция и Optimize Plans не требуются.
- Старые планы автоматически получают новый render после обновления.
- Save без редактирования не должен переписывать openings/walls/rooms.
- Экспорт/импорт остаются байт-в-байт совместимыми по модели.
## 16. Accessibility и styling hooks
Patch декоративный и получает `aria-hidden="true"`/не участвует в accessibility tree.
Он не создаёт focus target и не меняет accessible name opening/room. Если hook `data-hp="opening-tunnel"` станет публичным, его нужно добавить в `docs/STYLING-HOOKS.md`; иначе явно оставить внутренним. Пользовательский CSS не должен быть обязан задавать цвет patch: inline resolved fill является продуктовой геометрией, как текущий inline room fill.
### 16.1 Документация и release notes
- обновить `docs/WALL-THICKNESS.md`: тоннель door/window/gate продолжает заливку примыкающей комнаты, а общая стена делится по оси;
- добавить проверку в `docs/TESTING.md` со ссылкой на manual matrix ниже;
- добавить пользовательские строки в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- если `data-hp="opening-tunnel"` объявляется публичным, документировать его в `docs/STYLING-HOOKS.md`; в противном случае оставить hook явно внутренним и не обещать CSS-совместимость.
## 17. Тестовый план будущей реализации
По действующему правилу проекта тесты запускаются перед пре-релизом; вместе с реализацией тесты добавляются, но обычная локальная правка не требует их немедленного прогона.
### Unit: geometry
1. Горизонтальная и вертикальная внешняя стена: одна room окрашивает всю глубину.
2. Наклонная 45° стена: patch имеет правильные width/depth/rotation.
3. Общая стена: комнаты правильно определены по обе стороны независимо от config order.
4. T-стык: перпендикулярная стена не перехватывает opening.
5. Разная толщина соседних spans и opening у границы.
6. Нулевая толщина, virtual span, orphan opening и opening только на draft wall — `null` geometry.
7. Nested/island rooms и стабильный tie-breaker.
8. Legacy opening, частично вышедший за span, не красит пространство вне wall cut.
### Unit: fill
1. `none` → прозрачный.
2. `lqi` low/middle/high и missing.
3. `light` on/off/none, включая room без HA area с `room_id` marker.
4. `temp` cold/ok/hot/missing и room-specific source.
5. `glow` → только `glow_base`.
6. Room override отличается от space fill.
7. Одинаковые RGBA → solid; разные → hard split на локальной оси стены, включая асимметричный bounding box/симметричный overhang.
8. Opacity 0, дробная opacity и 1 без удвоения alpha на оси.
9. Все пять режимов, room override и missing-data дают для обычной room shape те же `color`/`opacity`, что до выноса общего resolver.
### Browser smoke
1. Белый дефект в thick doorway исчезает при серой статической заливке.
2. Две rooms с разной заливкой дают ровный split без белой щели.
3. Light/temp/LQI state tick одновременно меняет room и tunnel.
4. Glow on/off: количество и geometry clip paths, sector points и wall masks совпадают до/после feature; меняется только базовый пиксель тоннеля.
5. Window sun ray имеет прежние source corners и gradient.
6. `hide_openings` скрывает symbol, но оставляет tunnel fill и световую семантику.
7. `show_borders: false` не возвращает wall body и не меняет light/area.
8. Backdrop/Plan/Device editor не получают pointer conflicts или лишнюю opacity.
9. В Plan editor live resize и drag существующего opening не рисуют tunnel patch; после commit в View patch появляется только в новой позиции, старый patch отсутствует.
### Manual visual matrix
| Ось | Значения |
|---|---|
| Opening type | door / window / gate |
| Wall | outer / shared / 45° / T-junction / mixed thickness |
| Fill | none / LQI / light / temp / glow / room override |
| Adjacent fills | same / different / one missing |
| Symbol | closed / open / hidden |
| Surface | View / kiosk / Plan / Devices / Backdrop |
| Background | plain paper / image backdrop / daynight |
Обязательные комбинированные проверки, не покрываемые простым проходом отдельных осей:
- `window + shared wall + different fills`: стекло остаётся поверх ровной stop-линии по оси стены;
- `gate + outer wall + daynight night`: тоннель получает цвет единственной room, а отсутствие нормированного контраста с наружным фоном считается допустимым;
- `opening drag + thick wall` и `room resize + thick opening`: во время жеста patch скрыт, после commit появляется только в новой геометрии.
## 18. Критерии приёмки
1. В проёме физической толстой стены больше не видна посторонняя белая полоса, если примыкающая room имеет заливку.
2. RGBA тоннеля совпадает с RGBA room fill в том же кадре.
3. Общая стена с разными заливками делит тоннель по оси стены: каждая половина продолжает заливку своей комнаты.
4. Внешний opening окрашен единственной примыкающей room на всю глубину.
5. `none` и отсутствие данных не изобретают цвет.
6. Открытие contact не перекрашивает tunnel в orange.
7. Створка, arc, glass, jamb, hover и lock badge визуально не изменились.
8. Glow sector, clip geometry, цвет, alpha, радиус и проникновение через opening не изменились.
9. Sun geometry через окно не изменилась.
10. Patch не выходит за opening/wall geometry на углах, T/X-стыках и legacy-конфигах.
11. Нет alpha seam или белой щели на оси общей стены.
12. `hide_openings` не удаляет tunnel fill; Plan editor остаётся редактируемым.
13. Модель config, backend schema и migrations не изменились.
14. Room shape и tunnel используют единый resolver эффективной заливки.
15. Существующий внешний вид room fill во всех режимах не изменился за пределами нового tunnel patch.
16. Live resize и opening drag не оставляют tunnel patch в старой геометрии: в Plan он скрыт, после commit отображается только в новом месте.
17. Документация и RU/EN changelog обновлены.
## 19. Зафиксированные продуктовые решения
1. Цветом заливки комнаты окрашиваются тоннели всех трёх типов проёмов: `door`, `window` и `gate`.
2. На общей стене тоннель делится по оси стены 50/50. Каждая половина продолжает эффективную заливку комнаты со своей стороны. Правило не зависит от направления створки, `flip_h` или `flip_v`.
3. Patch сохраняется при `hide_openings` и `show_borders: false`.
4. Room hover не перекрашивает тоннель.
5. Glow и солнечные лучи остаются поверх подложки тоннеля без изменения геометрии, цвета, прозрачности или алгоритма распространения.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.60.2-beta.3",
"version": "1.60.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.60.2-beta.3",
"version": "1.60.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.60.2-beta.3",
"version": "1.60.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+32 -20
View File
@@ -15,7 +15,8 @@ import {
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
coverEntityOf, hassValue, lightColorOf, lqiColor, stateIcon, valueWithUnit,
coverEntityOf, hassValue, lightColorOf, lqiColor, normalizeDeviceDisplay, stateIcon, valueWithUnit,
type DeviceDisplayMode,
} from './logic';
import type { DevItem } from './types';
@@ -49,6 +50,7 @@ export type PresentationReason =
| 'hidden_design_preview'
| 'composite_power_source'
| 'activity_display_disabled'
| 'static_icon'
| 'ha_disabled'
| 'orphaned';
@@ -88,6 +90,9 @@ export interface ResolvePresentationOptions {
/** User-hidden markers show their real design inside the editor preview. */
designPreview?: boolean;
activityRuntime?: PresentationActivityRuntime | null;
/** The preview needs real source facts even for a static face. Plan surfaces
* may skip that work because the static projection cannot consume it. */
sourceDetails?: boolean;
now?: number;
}
@@ -100,7 +105,7 @@ export interface ResolvedDevicePresentation {
sourceSignature: string;
visual: DeviceVisualState;
display: 'badge' | 'icon_ripple' | 'value';
display: DeviceDisplayMode;
icon: string;
valueText: string | null;
valueFullText: string | null;
@@ -136,11 +141,9 @@ export interface ResolvedPresentationSources {
}
const ACTIVITY_WINDOW_MS = 3300;
function displayOf(d: DevItem): ResolvedDevicePresentation['display'] {
const display = d.marker?.display;
return display === 'ripple' ? 'icon_ripple' : display || 'badge';
}
const EMPTY_SOURCES: ResolvedPresentationSources = {
sourceKind: 'none', visualSources: [], criticalSources: [], samples: [],
};
function sourceName(hass: any, eid: string): string {
const reg = hass?.entities?.[eid];
@@ -348,6 +351,7 @@ function explanationReason(
): PresentationReason {
if (haDisabled) return 'ha_disabled';
if (orphaned) return 'orphaned';
if (display === 'static_icon') return 'static_icon';
if (visual.status === 'alarm') return 'alarm';
if (!liveStates) return 'live_states_disabled';
if (visual.availability === 'unavailable') return 'unavailable';
@@ -368,6 +372,7 @@ export function presentationClasses(presentation: Pick<
'visual' | 'activity' | 'display' | 'effectiveHidden' | 'activityGeneration'
>): string[] {
if (presentation.effectiveHidden) return [];
if (presentation.display === 'static_icon') return ['static-icon'];
const classes: string[] = [];
const { visual } = presentation;
if (visual.status === 'alarm') classes.push('alarm');
@@ -390,8 +395,10 @@ export function resolveDevicePresentation(
d: DevItem,
options: ResolvePresentationOptions,
): ResolvedDevicePresentation {
const sources = resolvePresentationSources(hass, d);
const display = displayOf(d);
const display = normalizeDeviceDisplay(d.marker?.display);
const staticIcon = display === 'static_icon';
const sources = staticIcon && options.sourceDetails === false
? EMPTY_SOURCES : resolvePresentationSources(hass, d);
const status = d.bindingStatus;
const haDisabled = status?.kind === 'ha_disabled';
const orphaned = status?.kind === 'orphaned';
@@ -400,15 +407,18 @@ export function resolveDevicePresentation(
const combined = combineVisualSamples(sources.samples);
let visual = combined;
if (effectiveHidden) visual = { availability: 'available', status: 'neutral', activity: 'none' };
else if (staticIcon) visual = { availability: 'available', status: 'neutral', activity: 'none' };
else if (combined.status !== 'alarm' && !options.liveStates) {
visual = { availability: 'available', status: 'neutral', activity: 'none' };
}
const value = resolveValue(hass, d, sources, options.showTemperature);
const value = staticIcon && options.sourceDetails === false
? { source: null, text: null, fallback: null }
: resolveValue(hass, d, sources, options.showTemperature);
const sourceSignature = signatureOf(d, sources, value.source);
const rt = options.activityRuntime;
const now = options.now ?? Date.now();
if (!effectiveHidden && options.liveStates && combined.status !== 'alarm'
if (!staticIcon && !effectiveHidden && options.liveStates && combined.status !== 'alarm'
&& rt?.sources === sourceSignature && rt.flashTs && rt.flashKind
&& now - rt.flashTs < ACTIVITY_WINDOW_MS) {
visual = { ...visual, activity: rt.flashKind };
@@ -416,21 +426,21 @@ export function resolveDevicePresentation(
const activity = display === 'icon_ripple' && !effectiveHidden
&& options.liveStates && visual.status !== 'alarm' ? visual.activity : 'none';
const temp = !effectiveHidden && options.showTemperature
const temp = !staticIcon && !effectiveHidden && options.showTemperature
? (d.marker?.use_climate_temp === true ? climateTempFor(hass, d.entities)
: (d.icon === 'mdi:thermometer' || d.icon === 'mdi:air-filter') ? tempFor(hass, d.entities) : null)
: null;
const hum = !effectiveHidden && options.showTemperature && d.primary && isHumEntity(hass, d.primary)
const hum = !staticIcon && !effectiveHidden && options.showTemperature && d.primary && isHumEntity(hass, d.primary)
? humFor(hass, d.entities) : null;
const lqi = !effectiveHidden && options.showSignal && !d.virtual ? lqiFor(hass, d.entities) : null;
const lqi = !staticIcon && !effectiveHidden && options.showSignal && !d.virtual ? lqiFor(hass, d.entities) : null;
const actEid = sources.sourceKind === 'cover'
? sources.visualSources[0]?.eid
: d.primary || sources.visualSources[0]?.eid;
const state = actEid ? hass?.states?.[actEid] : null;
const icon = options.liveStates && !effectiveHidden
const icon = options.liveStates && !staticIcon && !effectiveHidden
? stateIcon(d.icon, actEid?.split('.')[0], state?.attributes?.device_class, state?.state, !!d.marker?.icon)
: d.icon;
const lightColor = options.liveStates && !effectiveHidden
const lightColor = options.liveStates && !staticIcon && !effectiveHidden
? resolvedLightSources(hass, [{ ...d, hidden: false }])
.map((source) => lightColorOf(hass?.states?.[source.eid]))
.find((color): color is string => !!color) || null
@@ -438,7 +448,7 @@ export function resolveDevicePresentation(
const scale = Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1;
const angle = Number(d.marker?.angle) || 0;
const rippleScale = Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3;
const rippleColor = d.marker?.ripple_color || lightColor || null;
const rippleColor = staticIcon ? null : d.marker?.ripple_color || lightColor || null;
const valueText = display === 'value' && !effectiveHidden ? value.text : null;
const disabledReason = status?.kind === 'ha_disabled' ? status.reason : null;
const reason = explanationReason(
@@ -446,14 +456,15 @@ export function resolveDevicePresentation(
);
const notices: PresentationReason[] = [];
if (options.designPreview && userHidden) notices.push('hidden_design_preview');
if (d.marker?.vacuum?.live === true) notices.push('vacuum_live_plan_only');
if (!staticIcon && d.marker?.vacuum?.live === true) notices.push('vacuum_live_plan_only');
const powerSource = sources.visualSources.length === 1
&& isDevicePowerSwitch(hass, sources.visualSources[0].eid);
const uncategorisedSwitches = d.entities.filter((eid) =>
eid.startsWith('switch.') && !hass?.entities?.[eid]?.entity_category,
).length;
if (powerSource && uncategorisedSwitches > 1) notices.push('composite_power_source');
if (display !== 'icon_ripple' && combined.status !== 'alarm' && combined.activity !== 'none') {
if (display !== 'icon_ripple' && display !== 'static_icon'
&& combined.status !== 'alarm' && combined.activity !== 'none') {
notices.push('activity_display_disabled');
}
@@ -488,7 +499,7 @@ export function resolveDevicePresentation(
haDisabled,
disabledReason,
orphaned,
vacuumLive: d.marker?.vacuum?.live === true,
vacuumLive: !staticIcon && d.marker?.vacuum?.live === true,
explanation: { reason, notices },
};
return { ...presentation, classes: presentationClasses(presentation) };
@@ -499,6 +510,7 @@ export function presentationWithDemoActivity(
source: ResolvedDevicePresentation,
generation: number,
): ResolvedDevicePresentation {
if (source.display !== 'icon_ripple') return source;
const out: ResolvedDevicePresentation = {
...source,
activity: 'event',
+26 -31
View File
@@ -191,10 +191,12 @@ export type LightSourceRoom = string | { id?: string | null; area?: string | nul
export interface ResolvedLightSource<D extends LightSourceDevice = LightSourceDevice> {
/** HA entity that carries the source's on/off state and accepts controls. */
eid: string;
/** Device/marker which places this source on the plan. */
/** Device/marker which declares or physically places this source on the plan. */
device: D;
/** Why this entity belongs to the light-source set. */
via: 'controls' | 'forced' | 'light';
/** False for remote controls: they describe room light but have no lamp position. */
castsGlow: boolean;
on: boolean;
}
@@ -220,7 +222,8 @@ export function effectiveMarkerControls(
* Runtime consumers deliberately discard unknown domains and duplicates, but
* opening and saving the dialog must not rewrite YAML-only targets or collapse
* repeated entries. Only the marker's own target is removed: it belongs to the
* normal tap action (or migrates to `is_light` for a legacy self-switch).
* normal tap action. Classifying that relay as a lamp is a separate, explicit
* `is_light` decision.
*/
export function persistedExternalControls(
binding: string | null | undefined,
@@ -236,22 +239,6 @@ export function persistedExternalControls(
return (controls || []).filter((eid) => typeof eid === 'string' && !own.has(eid));
}
/**
* Before `is_light` existed, putting a marker's own switch into `controls`
* was the supported way to say that the relay drives a real fixture. Keep that
* intent alive until Save/Optimize converts the legacy representation.
*/
export function hasLegacySelfLightIntent(
binding: string | null | undefined,
controls: readonly string[] | null | undefined,
ownEntities: readonly string[] = [],
): boolean {
const own = binding?.startsWith('entity:')
? new Set([binding.slice('entity:'.length)])
: new Set(ownEntities);
return (controls || []).some((eid) => eid.startsWith('switch.') && own.has(eid));
}
function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom): boolean {
if (typeof room === 'string') return d.area === room;
// An explicit marker-to-room binding is more precise than the HA area and
@@ -271,8 +258,8 @@ function forcedLightEntityOf(d: LightSourceDevice): string | null {
? d.marker.binding.slice('entity:'.length) : null;
const controllable = d.entities.filter(isControllable);
const primary = d.primary && isControllable(d.primary) ? d.primary : null;
// An entity-bound legacy self-control names the physical relay exactly.
// Do not let a different registry-derived primary entity steal that intent.
// An explicitly forced entity marker names the physical relay exactly. Do
// not let a different registry-derived primary entity steal that decision.
return (bound && isControllable(bound) ? bound : null) || primary || controllable[0] || null;
}
@@ -284,9 +271,7 @@ function lightEntitiesOf(hass: any, d: LightSourceDevice): LightEntityCandidate[
);
const out: LightEntityCandidate[] = controls.map((eid) => ({ eid, via: 'controls' }));
if (d.marker?.is_light === true || hasLegacySelfLightIntent(
d.marker?.binding, d.marker?.controls, d.entities,
)) {
if (d.marker?.is_light === true) {
// A forced source is a smart switch (or light) driving real fixtures.
// Prefer its primary entity, but never treat measurements or service
// entities as light switches merely because they belong to the device.
@@ -317,10 +302,11 @@ function lightEntitiesOf(hass: any, d: LightSourceDevice): LightEntityCandidate[
}
/**
* One source of truth for every light-related room feature. Explicit marker
* controls win, then `is_light`, then automatic `light.*` discovery. Passing
* a room scopes sources by room_id/area; omitting it resolves the supplied
* devices (used by Glow and per-marker controls).
* One source of truth for every light-related room feature. Within a marker,
* explicit controls win, then `is_light`, then automatic `light.*` discovery.
* Across markers the real/forced source owns spatial Glow over a controller
* naming the same entity. Passing a room scopes sources by room_id/area;
* omitting it resolves the supplied devices (used by Glow and controls).
*/
export function resolvedLightSources<D extends LightSourceDevice>(
hass: any,
@@ -328,14 +314,23 @@ export function resolvedLightSources<D extends LightSourceDevice>(
room?: LightSourceRoom | null,
): ResolvedLightSource<D>[] {
const out: ResolvedLightSource<D>[] = [];
const seen = new Set<string>();
const byEntity = new Map<string, number>();
for (const d of devices) {
if (d.hidden || (room != null && !lightSourceBelongsToRoom(d, room))) continue;
const candidates = lightEntitiesOf(hass, d);
for (const { eid, via } of candidates) {
if (!eid || seen.has(eid)) continue;
seen.add(eid);
out.push({ eid, device: d, via, on: hass.states[eid]?.state === 'on' });
if (!eid) continue;
const castsGlow = via !== 'controls';
const source = { eid, device: d, via, castsGlow, on: hass.states[eid]?.state === 'on' };
const previous = byEntity.get(eid);
if (previous == null) {
byEntity.set(eid, out.length);
out.push(source);
} else if (castsGlow && !out[previous].castsGlow) {
// A remote controller may be encountered before the real lamp marker.
// Keep one room/statistics vote, but let the physical marker own Glow.
out[previous] = source;
}
}
}
return out;
+152 -45
View File
@@ -22,11 +22,13 @@ import {
poleOfInaccessibility, subst,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
spaceDisplayOf, resolveEffectiveRoomFill, fillColorsOf, DEFAULT_FILL_COLORS,
type FillColors, type ResolvedRoomFill, runServiceFor, RUN_TARGET_DOMAINS,
DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
normalizeDeviceDisplay, isAlarmCapable, type DeviceDisplayMode,
coverService, coverEntityOf, COVER_GUARDED_CLASSES,
liveText, liveTextReference, liveTextToken, hassValue, valueWithUnit, decorTextScale, decorTextLines,
DECOR_TEXT_BASE,
@@ -51,7 +53,7 @@ import {
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
wallEdgeBodies, wallBodiesUnionPath, paperRoomShapesWithWalls,
innerContourForRoom, roomWallProfile, outsetContour,
openingInnerFaceOffset, applyWallThicknessToNewRoom,
openingInnerFaceOffset, openingTunnelGeometry, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, wallIntervals, normalizeWallIntervals,
intervalCmAt, wallBodyNeedsSolid, type WallEntry,
} from './wall-thickness';
@@ -77,7 +79,7 @@ import {
resolvedLightSources, resolvedLightState, resolvedLightStats,
resolvedDeviceStateEntities, removedPlanBindings, isRemovedPlanEntity,
deletePlanMarkerRecords, effectiveMarkerControls, persistedExternalControls,
hasLegacySelfLightIntent, resolveIcon,
resolveIcon,
type AreaClimate,
} from './devices';
import type {
@@ -128,7 +130,7 @@ import {
type DecorBox, type SnapGeometry,
} from './editors/decor/geometry';
const CARD_VERSION = '1.60.2-beta.3';
const CARD_VERSION = '1.60.2';
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
@@ -338,6 +340,10 @@ const unionRect = (a: Rect, b: Rect): Rect => {
};
type MarkupTool = 'select' | 'draw' | 'partition' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'boundary' | 'wallthick' | 'delroom';
type RoomFillFrame = {
byRoom: Map<RoomCfg, ResolvedRoomFill | null>;
byId: Map<string, ResolvedRoomFill | null>;
};
const MARKUP_TOOLS = new Set<MarkupTool>([
'select', 'draw', 'partition', 'column', 'merge', 'split', 'resize',
'opening', 'boundary', 'wallthick', 'delroom',
@@ -964,7 +970,7 @@ class HouseplanCard extends LitElement {
bindingFilter: string;
icon: string; // '' = auto
autoIcon: string; // the icon the rules would give — picker placeholder
display: 'badge' | 'icon_ripple' | 'value';
display: DeviceDisplayMode;
rippleColor: string; // '' = accent
rippleSize: number; // in icon diameters
size: number; // icon size multiplier
@@ -2941,12 +2947,6 @@ class HouseplanCard extends LitElement {
return this._coverIndicator(d) || d.primary;
}
/** Legacy `ripple` is read as the surviving icon+activity presentation. */
private _displayOf(d: DevItem): 'badge' | 'icon_ripple' | 'value' {
const display = d.marker?.display;
return display === 'ripple' ? 'icon_ripple' : display || 'badge';
}
/**
* Entities that jointly describe the marker. Explicit cover intent wins;
* otherwise the same resolved light set used by Glow/fill/controls wins;
@@ -2968,6 +2968,7 @@ class HouseplanCard extends LitElement {
showSignal: showLqi && this._config?.show_signal !== false,
designPreview,
activityRuntime: this._activityRt.get(d.id),
sourceDetails: false,
});
}
@@ -3011,6 +3012,20 @@ class HouseplanCard extends LitElement {
return this._bindingEntities(binding).some((eid) => eid.startsWith('climate.'));
}
/** Static display can deliberately hide this binding's alarm indication. */
private _bindingHasAlarm(binding: string): boolean {
return this._bindingEntities(binding).some((eid) => {
const state = this._planHass?.states?.[eid] || this.hass?.states?.[eid];
const registry = this._fullRegistryHass.entities[eid] || this.hass?.entities?.[eid];
return isAlarmCapable(
eid.split('.')[0],
state?.attributes?.device_class
|| registry?.device_class
|| registry?.original_device_class,
);
});
}
/** The cover entity behind the dialog's binding, or null. */
private _bindingCoverEntity(binding: string): string | null {
return coverEntityOf(this._bindingEntities(binding));
@@ -8008,6 +8023,99 @@ class HouseplanCard extends LitElement {
</defs>` as unknown as TemplateResult;
}
/**
* One authoritative room-fill projection per render frame. Room polygons and
* thick-wall opening tunnels consume the same object, so a live HA tick can
* never update one without the other or drift in palette semantics.
*/
private _resolvedRoomFills(space: SpaceModel, disp: SpaceDisplay): RoomFillFrame {
const byRoom = new Map<RoomCfg, ResolvedRoomFill | null>();
const byId = new Map<string, ResolvedRoomFill | null>();
for (const room of space.rooms) {
const mode = roomFillModeOf(disp.fill, room);
const resolved = resolveEffectiveRoomFill(
mode,
mode === 'lqi' && room.area ? this._roomLqi(room.area) : null,
mode === 'light'
? resolvedLightState(resolvedLightSources(this._planHass, this._devices, room))
: 'none',
mode === 'temp' ? this._roomTemp(room) : null,
disp.tempMin,
disp.tempMax,
this._fillColors,
);
byRoom.set(room, resolved);
if (room.id) byId.set(room.id, resolved);
}
return { byRoom, byId };
}
/**
* Room-coloured base beneath Glow/sun and beneath the architectural symbol.
* The pure helper returns exact atomic wall strips in opening-local coords;
* a hard-stop gradient changes side precisely at local y=0 (the wall axis),
* never at an assumed 50% of an asymmetric bounding box.
*/
private _renderOpeningTunnelFills(
space: SpaceModel,
roomFills: RoomFillFrame,
): TemplateResult {
// Plan mode replaces live fills with its blue editing wash. A coloured
// tunnel there would no longer repeat the room and could obstruct handles.
if (this._markup || !this._spaceWalls.length || !this._openingsR.length)
return svg`` as unknown as TemplateResult;
const openCuts = this._openPairs().flatMap((p) => p.segs);
const parts = this._openingsR.map((opening, index) => {
const geometry = openingTunnelGeometry(
space.rooms,
{ x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen },
this._spaceWalls,
openCuts,
this._wallKeyPitch,
this._cellCm,
this._gridPitch,
NORM_W,
);
if (!geometry) return nothing;
const negative = geometry.faces.find((f) => f.side === -1);
const positive = geometry.faces.find((f) => f.side === 1);
if (!negative || !positive) return nothing;
const negFill = roomFills.byId.get(negative.roomId) || null;
const posFill = roomFills.byId.get(positive.roomId) || null;
if (!negFill && !posFill) return nothing;
const d = `${negative.d} ${positive.d}`;
const same = !!negFill && !!posFill
&& negFill.color === posFill.color && negFill.opacity === posFill.opacity;
const transform = `translate(${opening.rx} ${opening.ry}) rotate(${opening.angle})`;
if (same) {
return svg`<path class="opening-tunnel" data-hp="opening-tunnel" data-id=${opening.id} data-kind=${opening.type}
data-wall-key=${geometry.wallKey} aria-hidden="true" pointer-events="none"
transform=${transform} d=${d} fill=${negFill!.color}
fill-opacity=${negFill!.opacity}></path>`;
}
const span = geometry.maxY - geometry.minY;
if (!(span > 0)) return nothing;
const axis = Math.max(0, Math.min(1, -geometry.minY / span));
const axisOffset = `${(axis * 100).toFixed(6)}%`;
const nf = negFill || { color: '#000000', opacity: 0, mode: 'none' as const };
const pf = posFill || { color: '#000000', opacity: 0, mode: 'none' as const };
const gradientId = `hp-opening-tunnel-${index}`;
return svg`<g class="opening-tunnel" data-hp="opening-tunnel" data-id=${opening.id} data-kind=${opening.type}
data-wall-key=${geometry.wallKey} aria-hidden="true" pointer-events="none"
transform=${transform}>
<defs><linearGradient id=${gradientId} gradientUnits="userSpaceOnUse"
x1="0" y1=${geometry.minY} x2="0" y2=${geometry.maxY}>
<stop offset="0%" stop-color=${nf.color} stop-opacity=${nf.opacity}></stop>
<stop offset=${axisOffset} stop-color=${nf.color} stop-opacity=${nf.opacity}></stop>
<stop offset=${axisOffset} stop-color=${pf.color} stop-opacity=${pf.opacity}></stop>
<stop offset="100%" stop-color=${pf.color} stop-opacity=${pf.opacity}></stop>
</linearGradient></defs>
<path d=${d} fill=${`url(#${gradientId})`} fill-rule="nonzero"></path>
</g>`;
});
return (svg`<g class="opening-tunnels" aria-hidden="true" pointer-events="none">${parts}</g>`) as unknown as TemplateResult;
}
private _renderWallBodies(disp: SpaceDisplay): TemplateResult {
if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices'))
return svg`` as unknown as TemplateResult;
@@ -8294,10 +8402,15 @@ class HouseplanCard extends LitElement {
this._opMeasure = r.measure;
const nx = r.x / NORM_W;
const ny = r.y / this._spaceH;
if (cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle) this._opDrag.dirty = true;
const geometryChanged = cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle;
if (geometryChanged) this._opDrag.dirty = true;
cfg.x = nx;
cfg.y = ny;
cfg.angle = snap.angle;
// The wall cut and room-coloured tunnel are geometry caches too. Advance
// the preview epoch so both follow the live opening instead of remaining at
// its pre-drag location until pointerup/save.
if (geometryChanged) this._cfgEpoch++;
this.requestUpdate();
}
@@ -8824,7 +8937,7 @@ class HouseplanCard extends LitElement {
bindingFilter: '',
icon: d.marker?.icon || '',
autoIcon: d.icon || '',
display: d.marker?.display === 'ripple' ? 'icon_ripple' : d.marker?.display || 'badge',
display: normalizeDeviceDisplay(d.marker?.display),
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,
@@ -8836,12 +8949,10 @@ class HouseplanCard extends LitElement {
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
// Keep unknown, temporarily inactive and duplicate external targets
// byte-for-byte across Open → Save. Runtime uses the filtered
// effective projection; a legacy self-reference becomes isLight.
// effective projection; a legacy self-reference is not a light source.
controls: persistedExternalControls(d.marker?.binding, d.marker?.controls, d.entities),
controlsFilter: '',
isLight: d.marker?.is_light === true || hasLegacySelfLightIntent(
d.marker?.binding, d.marker?.controls, d.entities,
),
isLight: d.marker?.is_light === true,
useClimateTemp: d.marker?.use_climate_temp === true,
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this._imperial
@@ -10449,7 +10560,7 @@ class HouseplanCard extends LitElement {
this._planHass,
this._devices.filter((d) => d.space === space.id),
)) {
if (source.on && source.device.id && !litByDevice.has(source.device.id))
if (source.on && source.castsGlow && source.device.id && !litByDevice.has(source.device.id))
litByDevice.set(source.device.id, source.eid);
}
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
@@ -10884,6 +10995,7 @@ class HouseplanCard extends LitElement {
const showGhosts = this._mode === 'devices' && this._showAll;
const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
const disp = spaceDisplayOf(this._curSpaceCfg);
const roomFills = this._resolvedRoomFills(space, disp);
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
@@ -11068,34 +11180,12 @@ class HouseplanCard extends LitElement {
const st: string[] = [];
// keep the stroke colour even when borders are hidden, so hover can reveal it
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`);
const fillC = effFill === 'glow'
// glow: uniform darkness (a room override may opt OUT of it)
? this._fillColors.glow_base
: effFill === 'temp'
// temp works without an HA area when a tier-3 source is set
? roomFillStyle('temp', null, 'none', this._roomTemp(r),
disp.tempMin, disp.tempMax, this._fillColors)
: effFill === 'light'
// marker.room_id also supports rooms without an HA area
? roomFillStyle(
'light', null,
resolvedLightState(resolvedLightSources(this._planHass, this._devices, r)),
null, disp.tempMin, disp.tempMax, this._fillColors,
)
: r.area
? roomFillStyle(
effFill,
effFill === 'lqi' ? this._roomLqi(r.area) : null,
'none',
null,
disp.tempMin,
disp.tempMax,
this._fillColors,
)
: null;
// One frame-local resolver drives both this room and every
// thick-wall opening tunnel that continues its floor colour.
const fillC = roomFills.byRoom.get(r) || null;
if (fillC) {
cls += ' filled';
st.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
st.push(`--room-fill:${fillC.color}`, `--room-fill-op:${fillC.opacity.toFixed(3)}`);
} else st.push('--room-fill:transparent', '--room-fill-op:0');
style = st.join(';');
}
@@ -11187,6 +11277,7 @@ class HouseplanCard extends LitElement {
x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
});
})()}
${this._renderOpeningTunnelFills(space, roomFills)}
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._renderSunRays(space)}
${this._editing ? this._renderAlignGuides() : nothing}
@@ -11410,6 +11501,7 @@ class HouseplanCard extends LitElement {
const live = new Set<string>();
for (const d of this._devices) {
if (d.hidden) continue;
if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') continue;
live.add(d.id);
const snapshot = this._activitySnapshot(d);
snapshots.set(d.id, snapshot);
@@ -11449,6 +11541,7 @@ class HouseplanCard extends LitElement {
const snapshots = this._syncActivityRuntime();
for (const d of this._devices) {
if (d.hidden) continue;
if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') continue;
const snapshot = snapshots.get(d.id) || this._activitySnapshot(d);
const { samples, sourceKey } = snapshot;
const rt = this._activityRt.get(d.id);
@@ -11480,6 +11573,10 @@ class HouseplanCard extends LitElement {
if (!this.hass) return;
for (const d of this._devices) {
if (d.hidden || !this._isVacDev(d)) continue;
if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') {
this._vacRt.delete(d.id);
continue;
}
const src = this._vacSource(d);
if (!src) continue;
const vacEnt = this._vacEntity(d);
@@ -11851,6 +11948,7 @@ class HouseplanCard extends LitElement {
const trails: TemplateResult[] = [];
for (const d of devs) {
if (d.hidden || !this._isVacDev(d)) continue;
if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') continue;
const src = this._vacSource(d);
if (!src) continue;
const tele = readVacTelemetry(this.hass?.states[src]?.attributes);
@@ -13701,12 +13799,21 @@ class HouseplanCard extends LitElement {
<label>${this._t('marker.display_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, display: (e.target as HTMLSelectElement).value as any })}>
@change=${(e: Event) => (this._markerDialog = {
...d,
display: normalizeDeviceDisplay((e.target as HTMLSelectElement).value),
})}>
${DISPLAY_MODES.map((v) => [v, 'display.' + v] as const).map(
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
)}
</select>
<p class="muted">${this._t('marker.display_hint')}</p>
${d.display === 'static_icon' && this._bindingHasAlarm(d.binding)
? html`<div class="habindingbanner" role="note">
<ha-icon icon="mdi:alert-outline"></ha-icon>
<span>${this._t('marker.static_alarm_warning')}</span>
</div>`
: nothing}
${previewPresentation
? html`<hp-device-preview
.hass=${this.hass}
+5 -2
View File
@@ -147,9 +147,11 @@
"marker.icon_label": "Icon",
"marker.icon_ph": "mdi:… (empty = auto)",
"marker.display_label": "Display",
"display.badge": "Icon",
"display.badge": "Icon + dynamic plate",
"display.icon_ripple": "Icon + activity",
"marker.display_hint": "A yellow plate means actual work. Activity adds events, presence and movement; alarms are always visible.",
"display.static_icon": "Always static icon",
"marker.display_hint": "The dynamic plate shows work, open, alarm and unavailable states. Icon + activity also shows events and motion. Value instead of an icon shows one unambiguous HA state. A static icon does not react to device states.",
"marker.static_alarm_warning": "This device can report alarms. Static display hides the marker's visual alarm indication.",
"marker.preview.title": "Display preview",
"marker.preview.actual": "Now",
"marker.preview.example": "Example",
@@ -193,6 +195,7 @@
"marker.preview.reason.activity_display_disabled": "The selected display mode does not show ordinary activity effects",
"marker.preview.reason.ha_disabled": "The Home Assistant binding is disabled and will be hidden on the plan",
"marker.preview.reason.orphaned": "The saved Home Assistant binding can no longer be found",
"marker.preview.reason.static_icon": "Static mode: device state does not change the icon",
"marker.activity_color": "Effect color",
"marker.ripple_size": "Effect size",
"marker.size_label": "Icon size / rotation",
+5 -2
View File
@@ -147,9 +147,11 @@
"marker.icon_label": "Иконка",
"marker.icon_ph": "mdi:… (пусто = авто)",
"marker.display_label": "Отображение",
"display.badge": "Значок",
"display.badge": "Значок + динамическая подложка",
"display.icon_ripple": "Значок + активность",
"marker.display_hint": "Жёлтая подложка означает фактическую работу. Активность добавляет события, присутствие и движение; тревога видна всегда.",
"display.static_icon": "Всегда статичный значок",
"marker.display_hint": "Динамическая подложка показывает работу, открытие, тревогу и недоступность. Значок + активность также показывает события и движение. Значение вместо иконки выводит одно однозначное состояние HA. Статичный значок не реагирует на состояния устройства.",
"marker.static_alarm_warning": "Это устройство может сообщать о тревогах. Статичный режим скрывает визуальную тревогу маркера.",
"marker.preview.title": "Предпросмотр отображения",
"marker.preview.actual": "Сейчас",
"marker.preview.example": "Пример",
@@ -193,6 +195,7 @@
"marker.preview.reason.activity_display_disabled": "Выбранный режим отображения не показывает обычные эффекты активности",
"marker.preview.reason.ha_disabled": "Привязка отключена в Home Assistant, поэтому устройство будет скрыто на плане",
"marker.preview.reason.orphaned": "Сохранённая привязка Home Assistant больше не найдена",
"marker.preview.reason.static_icon": "Статичный режим: состояние устройства не меняет значок",
"marker.activity_color": "Цвет эффекта",
"marker.ripple_size": "Размер эффекта",
"marker.size_label": "Размер / поворот значка",
+53 -4
View File
@@ -805,8 +805,18 @@ export type TapAction = 'info' | 'more-info' | 'toggle' | 'run' | 'cover';
* Adding an option here and forgetting the schema now fails the test suite.
*/
/** UI presentations. `ripple` remains a backend/read compatibility value only
* and is normalised to `icon_ripple` by the card. */
export const DISPLAY_MODES = ['badge', 'icon_ripple', 'value'] as const;
* and is normalised to `icon_ripple` by the shared normalizer. */
export const DISPLAY_MODES = ['badge', 'icon_ripple', 'value', 'static_icon'] as const;
export type DeviceDisplayMode = typeof DISPLAY_MODES[number];
/** One read boundary for persisted/legacy display values. Unknown manual data
* safely falls back to the default dynamic badge; the backend still rejects it
* on write. Keep every renderer and editor on this normalizer. */
export function normalizeDeviceDisplay(value: unknown): DeviceDisplayMode {
if (value === 'ripple') return 'icon_ripple';
return (DISPLAY_MODES as readonly unknown[]).includes(value)
? value as DeviceDisplayMode : 'badge';
}
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run', 'cover'] as const;
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
// 'glow' leads: it is the default for new spaces since v1.54 — the owner's
@@ -1408,6 +1418,36 @@ export function roomFillStyle(
return null;
}
/**
* Renderer-ready room fill shared by the room shape and every piece of floor
* that visually extends it (currently thick-wall opening tunnels).
*
* Keep live-data collection outside this pure helper. Callers resolve the
* room's LQI/light/temperature once per frame, then both render paths consume
* this exact color/opacity pair. This preserves the historical room palette
* while preventing a second, subtly different fill implementation.
*/
export interface ResolvedRoomFill {
color: string;
opacity: number;
mode: RoomFillMode;
}
export function resolveEffectiveRoomFill(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp: number | null | undefined,
tempMin: number,
tempMax: number,
colors: FillColors,
): ResolvedRoomFill | null {
const entry = mode === 'glow'
? colors.glow_base
: roomFillStyle(mode, lqi, lights, temp, tempMin, tempMax, colors);
return entry ? { color: entry.c, opacity: entry.a, mode } : null;
}
/**
* Room fill color for the selected mode, or null for "no fill".
* - lqi: red→green gradient by the room's average zigbee signal (null LQI → no fill)
@@ -1986,6 +2026,16 @@ export function distToSegment(p: number[], s: number[]): number {
/** Device classes whose active state is an emergency, not a status. */
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
/** Whether an entity can produce House Plan's critical alarm presentation,
* independent of its current state. Used for the static-icon safety notice. */
export function isAlarmCapable(
domain: string | null | undefined,
deviceClass: string | null | undefined,
): boolean {
if (domain === 'alarm_control_panel' || domain === 'siren') return true;
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
}
/**
* An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, a
* siren that is on, or an alarm panel in `triggered`. Unavailable/unknown
@@ -1998,8 +2048,7 @@ export function isAlarmState(
): boolean {
if (domain === 'alarm_control_panel') return state === 'triggered';
if (state !== 'on') return false;
if (domain === 'siren') return true;
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
return isAlarmCapable(domain, deviceClass);
}
// ---------------- room references ----------------
-7
View File
@@ -9,7 +9,6 @@
*/
import { alignAllToGrid, type AlignReport } from './align-grid';
import { hasLegacySelfLightIntent } from './devices';
import {
DECOR_TEXT_BASE, decorTextScale, liveTextReference, liveTextToken, roomPoly,
} from './logic';
@@ -88,12 +87,6 @@ const migrateLosslessly = (config: any): number => {
for (const marker of config.markers || []) {
if (marker.display === 'ripple') { marker.display = 'icon_ripple'; n++; }
if (Array.isArray(marker.controls)) {
// R2: preserve the old, once-supported declaration that an entity-bound
// switch is itself a light source before removing its redundant control.
if (hasLegacySelfLightIntent(marker.binding, marker.controls) && marker.is_light !== true) {
marker.is_light = true;
n++;
}
// R6: maintenance knows only the direct entity binding. Do not run the
// runtime filter here: YAML-only targets and duplicates are user data,
// not garbage that a "lossless" optimiser may silently discard.
+2 -1
View File
@@ -11,7 +11,7 @@ import { buildSpaceDevices, renderSpaceStatic, spaceModels } from './space-rende
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n';
import { ContentSigner } from './signing';
import { referencedContentUrls } from './logic';
import { normalizeDeviceDisplay, referencedContentUrls } from './logic';
import { acquireHaRegistries, activeRegistryHass, haRegistrySnapshot } from './ha-binding-status';
import { edgeActivity } from './device-visual';
import {
@@ -168,6 +168,7 @@ class HouseplanSpaceCard extends LitElement {
const live = new Set<string>();
for (const device of devices) {
if (device.hidden) continue;
if (normalizeDeviceDisplay(device.marker?.display) === 'static_icon') continue;
live.add(device.id);
const sources = resolvePresentationSources(planHass, device);
const samples = sources.samples;
+1
View File
@@ -208,6 +208,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
showTemperature: o.showTemperature !== false,
showSignal: disp.showLqi ?? (o.showSignal !== false),
activityRuntime: o.activityRuntime?.get(d.id),
sourceDetails: false,
});
const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)];
return html`<div class="dev ${presentation.classes.join(' ')} ${d.virtual ? 'virtual' : ''} ${presentation.valueText != null ? 'valonly' : ''}"
+1
View File
@@ -1144,6 +1144,7 @@ export const cardStyles = css`
.stage.mode-decor .rlabel,
.stage.mode-decor .room-outline,
.stage.mode-decor .wallbodies,
.stage.mode-decor .opening-tunnels,
.stage.mode-decor .openwalls {
opacity: 0.35;
}
+1 -1
View File
@@ -106,7 +106,7 @@ export interface Marker {
} | null;
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
/** `ripple` is legacy read compatibility; UI maps it to icon_ripple. */
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | 'static_icon' | 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)
+179 -1
View File
@@ -7,7 +7,7 @@
* centreline.
*/
import { union, difference } from 'polyclip-ts';
import { roomPoly, roomEdges, sharedBoundary } from './logic';
import { polygonArea, roomPoly, roomEdges, sharedBoundary } from './logic';
export interface WallEntry {
key: string;
@@ -1646,3 +1646,181 @@ export function openingInnerFaceOffset(
const along = depth / 2;
return { ox: inx * along * s, oy: iny * along * s, cm, side };
}
/** One half of a room-coloured opening tunnel, in opening-local coordinates. */
export interface OpeningTunnelFace {
side: -1 | 1;
roomId: string;
/** One SVG path, possibly containing several non-overlapping atomic strips. */
d: string;
}
/** Pure geometry consumed by the full-card opening-tunnel renderer. */
export interface OpeningTunnelGeometry {
faces: OpeningTunnelFace[];
/** Local Y bounds. The wall centreline is always y=0. */
minY: number;
maxY: number;
wallKey: string;
}
interface TunnelPiece {
x0: number;
x1: number;
half: number;
key: string;
}
interface TunnelCandidate {
roomId: string;
side: -1 | 1;
pieces: TunnelPiece[];
distance: number;
faceDistance: number;
area: number;
coverage: number;
full: boolean;
}
function tunnelCoverage(pieces: TunnelPiece[], lo: number, hi: number, eps: number): {
coverage: number; full: boolean;
} {
const spans = pieces
.map((p) => [Math.max(lo, p.x0), Math.min(hi, p.x1)] as [number, number])
.filter((p) => p[1] - p[0] > eps)
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (!spans.length) return { coverage: 0, full: false };
let start = spans[0][0], end = spans[0][1], coverage = 0;
let full = start <= lo + eps;
for (let i = 1; i < spans.length; i++) {
const [a, b] = spans[i];
if (a <= end + eps) {
end = Math.max(end, b);
continue;
}
coverage += end - start;
full = false;
start = a; end = b;
}
coverage += end - start;
full = full && end >= hi - eps;
return { coverage, full };
}
function tunnelFacePath(side: -1 | 1, pieces: TunnelPiece[]): string {
const parts: string[] = [];
for (const p of pieces) {
if (!(p.x1 > p.x0) || !(p.half > 0)) continue;
const y = side * p.half;
parts.push(`M ${p.x0} 0 L ${p.x1} 0 L ${p.x1} ${y} L ${p.x0} ${y} Z`);
}
return parts.join(' ');
}
/**
* Resolve the physical tunnel and its adjacent rooms without reading card or
* HA state. The opening-local X axis follows `angle`; local y=0 is the wall
* centreline. Atomic room-wall profiles provide exact mixed-thickness clips,
* so a legacy opening near a breakpoint cannot paint beyond the real body.
*
* A single adjacent room (outer wall) owns both halves. Two adjacent rooms own
* one half each. Draft walls, virtual spans and zero-thickness intervals never
* produce a face because they have no eligible room-wall interval.
*/
export function openingTunnelGeometry(
rooms: any[],
opening: { x: number; y: number; angle: number; length: number },
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): OpeningTunnelGeometry | null {
const x = Number(opening?.x), y = Number(opening?.y);
const angle = Number(opening?.angle), length = Number(opening?.length);
if (![x, y, angle, length, pitch, cellCm, gridPitch, coordScale].every(Number.isFinite)
|| !(length > 0) || !(pitch > 0) || !(cellCm > 0) || !(gridPitch > 0)
|| !(coordScale > 0) || !walls?.length) return null;
const rad = angle * Math.PI / 180;
const ux = Math.cos(rad), uy = Math.sin(rad);
const nx = -uy, ny = ux;
const openingHalf = length / 2;
const eps = Math.max(1e-7, pitch * coordScale * 0.02);
const maxDistance = pitch * coordScale;
const candidates = new Map<string, TunnelCandidate>();
for (const room of rooms || []) {
if (!room?.id) continue;
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
if (!pr) continue;
const area = Math.abs(polygonArea(pr.poly));
for (let i = 0; i < pr.poly.length; i++) {
if (!pr.kinds[i] || !(pr.offsets[i] > 0)) continue;
const a = pr.poly[i], b = pr.poly[(i + 1) % pr.poly.length];
if (!wallAngleMatches(a, b, angle)) continue;
const distance = distToSeg(x, y, a[0], a[1], b[0], b[1]);
if (distance > maxDistance) continue;
const ta = (a[0] - x) * ux + (a[1] - y) * uy;
const tb = (b[0] - x) * ux + (b[1] - y) * uy;
const x0 = Math.max(-openingHalf, Math.min(ta, tb));
const x1 = Math.min(openingHalf, Math.max(ta, tb));
if (x1 - x0 <= eps) continue;
const [inx, iny] = inwardNormal(pr.poly, i);
const side = (inx * nx + iny * ny >= 0 ? 1 : -1) as -1 | 1;
const key = `${side}|${room.id}`;
const half = pr.offsets[i];
const piece = { x0, x1, half, key: keyOf(a, b, pitch, coordScale) };
const prev = candidates.get(key);
if (prev) {
prev.pieces.push(piece);
prev.distance = Math.min(prev.distance, distance);
prev.faceDistance = Math.min(prev.faceDistance, distance + half);
} else {
candidates.set(key, {
roomId: room.id, side, pieces: [piece], distance,
faceDistance: distance + half, area, coverage: 0, full: false,
});
}
}
}
for (const c of candidates.values()) {
const coverage = tunnelCoverage(c.pieces, -openingHalf, openingHalf, eps);
c.coverage = coverage.coverage;
c.full = coverage.full;
}
const pick = (side: -1 | 1): TunnelCandidate | null => {
const list = [...candidates.values()].filter((c) => c.side === side && c.coverage > eps);
list.sort((a, b) => Number(b.full) - Number(a.full)
|| b.coverage - a.coverage
|| a.faceDistance - b.faceDistance
|| a.area - b.area
|| a.roomId.localeCompare(b.roomId));
return list[0] || null;
};
const negative = pick(-1), positive = pick(1);
if (!negative && !positive) return null;
let chosen: Array<{ candidate: TunnelCandidate; side: -1 | 1 }>;
if (negative && positive) {
chosen = [{ candidate: negative, side: -1 }, { candidate: positive, side: 1 }];
} else {
const only = (negative || positive)!;
// An outer wall has no second room, but the same floor continues through
// the complete wall depth to the exterior face.
chosen = [{ candidate: only, side: -1 }, { candidate: only, side: 1 }];
}
const faces = chosen.map(({ candidate, side }) => ({
side,
roomId: candidate.roomId,
d: tunnelFacePath(side, candidate.pieces),
})).filter((f) => !!f.d);
if (!faces.length) return null;
const allPieces = chosen.flatMap((x) => x.candidate.pieces);
const maxHalf = Math.max(...allPieces.map((p) => p.half));
const wallKey = [...new Set(allPieces.map((p) => p.key))].sort().join('|');
return { faces, minY: -maxHalf, maxY: maxHalf, wallKey };
}
+69
View File
@@ -40,6 +40,75 @@ const options = {
showSignal: true,
};
test('always-static display suppresses every live visual but keeps diagnostics', () => {
const h = hass({
'binary_sensor.smoke': state('binary_sensor.smoke', 'on', { device_class: 'smoke' }),
}, {
'binary_sensor.smoke': { entity_id: 'binary_sensor.smoke', device_id: 'd1', platform: 'demo' },
});
const marker = device({
icon: 'mdi:smoke-detector',
entities: ['binary_sensor.smoke'],
primary: 'binary_sensor.smoke',
marker: {
id: 'd1', binding: 'device:d1', display: 'static_icon',
ripple_color: '#00ff00', vacuum: { live: true },
},
});
const result = resolveDevicePresentation(h, marker, options);
assert.equal(result.display, 'static_icon');
assert.equal(result.visual.status, 'neutral');
assert.equal(result.visual.activity, 'none');
assert.equal(result.icon, 'mdi:smoke-detector');
assert.equal(result.valueText, null);
assert.equal(result.tempText, null);
assert.equal(result.humText, null);
assert.equal(result.lqiText, null);
assert.equal(result.lightColor, null);
assert.equal(result.rippleColor, null);
assert.equal(result.vacuumLive, false);
assert.deepEqual(result.classes, ['static-icon']);
assert.equal(result.explanation.reason, 'static_icon');
assert.equal(result.visualSources[0].eid, 'binary_sensor.smoke');
});
test('always-static source short-circuit is plan-only and lifecycle still wins', () => {
const h = hass(
{ 'switch.relay': state('switch.relay', 'unavailable') },
{ 'switch.relay': { entity_id: 'switch.relay', device_id: 'd1', platform: 'demo' } },
);
const marker = device({ marker: { id: 'd1', binding: 'device:d1', display: 'static_icon' } });
const plan = resolveDevicePresentation(h, marker, { ...options, sourceDetails: false });
assert.equal(plan.visual.status, 'neutral');
assert.deepEqual(plan.visualSources, []);
assert.deepEqual(plan.classes, ['static-icon']);
const hidden = resolveDevicePresentation(h, { ...marker, hidden: true, userHidden: true }, options);
assert.equal(hidden.effectiveHidden, true);
});
test('always-static vacuum keeps its dock face but exposes no live overlay', () => {
const h = hass(
{ 'vacuum.robot': state('vacuum.robot', 'cleaning') },
{ 'vacuum.robot': { entity_id: 'vacuum.robot', device_id: 'd1', platform: 'demo' } },
);
const base = device({
icon: 'mdi:robot-vacuum',
entities: ['vacuum.robot'],
primary: 'vacuum.robot',
marker: { id: 'd1', binding: 'device:d1', vacuum: { live: true } },
});
const dynamic = resolveDevicePresentation(h, base, options);
const fixed = resolveDevicePresentation(h, {
...base, marker: { ...base.marker, display: 'static_icon' },
}, options);
assert.equal(dynamic.visual.status, 'working');
assert.equal(dynamic.vacuumLive, true);
assert.equal(fixed.visual.status, 'neutral');
assert.equal(fixed.vacuumLive, false);
assert.equal(fixed.explanation.reason, 'static_icon');
});
test('one projection supplies working state and localized text value', () => {
const h = hass(
{ 'switch.relay': state('switch.relay', 'on') },
+31 -18
View File
@@ -909,12 +909,12 @@ test('resolvedLightSources: one source set feeds room fill, card, glow and contr
const sources = resolvedLightSources(hass, devices, { id: 'room-a', area: 'living' });
assert.deepEqual(
sources.map(({ eid, via, on }) => ({ eid, via, on })),
sources.map(({ eid, via, castsGlow, on }) => ({ eid, via, castsGlow, on })),
[
{ eid: 'light.auto', via: 'light', on: true },
{ eid: 'switch.relay', via: 'forced', on: true },
{ eid: 'light.bound', via: 'controls', on: false },
{ eid: 'switch.bound', via: 'controls', on: true },
{ eid: 'light.auto', via: 'light', castsGlow: true, on: true },
{ eid: 'switch.relay', via: 'forced', castsGlow: true, on: true },
{ eid: 'light.bound', via: 'controls', castsGlow: false, on: false },
{ eid: 'switch.bound', via: 'controls', castsGlow: false, on: true },
],
);
assert.equal(resolvedLightState(sources), 'on');
@@ -936,7 +936,7 @@ test('resolvedLightSources: marker room_id is more precise than a shared HA area
);
});
test('self-control is not external but preserves legacy switch-as-light intent', () => {
test('self-control is not external and only explicit is_light makes the switch shine', () => {
assert.deepEqual(
persistedExternalControls('entity:switch.hood', [
'switch.hood', 'input_boolean.yaml_only', 'light.mirror',
@@ -968,28 +968,22 @@ test('self-control is not external but preserves legacy switch-as-light intent',
};
assert.deepEqual(
resolvedLightSources(hass, [hood]).map(({ eid, via }) => ({ eid, via })),
[
{ eid: 'light.mirror', via: 'controls' },
{ eid: 'switch.hood', via: 'forced' },
],
'legacy entity self-control remains additive with external light targets',
[{ eid: 'light.mirror', via: 'controls' }],
'legacy entity self-control is ignored while external targets remain',
);
assert.deepEqual(
resolvedLightSources(hass, [{
...hood, primary: 'light.mirror', entities: ['light.mirror', 'switch.hood'],
}]).map(({ eid, via }) => ({ eid, via })),
[
{ eid: 'light.mirror', via: 'controls' },
{ eid: 'switch.hood', via: 'forced' },
],
'an entity binding preserves the exact legacy relay even if registry primary differs',
[{ eid: 'light.mirror', via: 'controls' }],
'a different registry primary cannot revive a legacy self-control',
);
assert.deepEqual(
resolvedLightSources(hass, [{
...hood, marker: { binding: 'device:hood', controls: ['switch.hood'] },
}]).map(({ eid, via }) => ({ eid, via })),
[{ eid: 'switch.hood', via: 'forced' }],
'legacy device-child self-control preserves the same intent',
[],
'a device-child self-control is ignored too',
);
assert.deepEqual(
resolvedLightSources(hass, [{ ...hood, marker: { ...hood.marker, is_light: true } }])
@@ -1002,6 +996,25 @@ test('self-control is not external but preserves legacy switch-as-light intent',
);
});
test('a real lamp marker owns Glow even when a controller names the same entity first', () => {
const hass = { states: { 'light.hall': { state: 'on' } } };
const controller = {
id: 'switch', area: 'hall', primary: 'switch.wall', entities: ['switch.wall'],
marker: { controls: ['light.hall'] },
};
const lamp = { id: 'lamp', area: 'hall', primary: 'light.hall', entities: ['light.hall'] };
const sources = resolvedLightSources(hass, [controller, lamp]);
assert.equal(sources.length, 1);
assert.equal(sources[0].device.id, 'lamp');
assert.equal(sources[0].via, 'light');
assert.equal(sources[0].castsGlow, true);
assert.equal(
resolvedLightSources(hass, [controller])[0].castsGlow,
false,
'a controller without a physical lamp marker never casts from the switch coordinates',
);
});
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
+41 -1
View File
@@ -15,7 +15,8 @@ import {
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, resolveEffectiveRoomFill, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf,
normalizeDeviceDisplay, isAlarmCapable,
liveText, liveTextValue, liveTextReference, liveTextToken,
hassValue, valueWithUnit, decorTextScale, decorTextLines,
LIVE_TEXT_DASH, LIVE_TEXT_VALUE_MAX, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX } from '../test-build/logic.js';
@@ -23,6 +24,17 @@ import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
test('display normalization and alarm-capable metadata share one contract', () => {
assert.equal(normalizeDeviceDisplay(undefined), 'badge');
assert.equal(normalizeDeviceDisplay('ripple'), 'icon_ripple');
assert.equal(normalizeDeviceDisplay('static_icon'), 'static_icon');
assert.equal(normalizeDeviceDisplay('not-a-mode'), 'badge');
assert.equal(isAlarmCapable('alarm_control_panel', ''), true);
assert.equal(isAlarmCapable('siren', ''), true);
assert.equal(isAlarmCapable('binary_sensor', 'smoke'), true);
assert.equal(isAlarmCapable('binary_sensor', 'motion'), false);
});
test('lqiColor: boundaries and midpoint', () => {
assert.equal(lqiColor(40), 'hsl(0, 85%, 55%)');
assert.equal(lqiColor(180), 'hsl(120, 85%, 55%)');
@@ -553,6 +565,34 @@ test('roomFillStyle light_none: alpha 0 keeps no-fill; a custom color fills ligh
assert.deepEqual(roomFillStyle('light', null, 'none', null, 20, 25, c), { c: '#123456', a: 0.2 });
});
test('resolveEffectiveRoomFill preserves the existing palette contract for room shapes and tunnels', () => {
const colors = fillColorsOf({ fill_colors: {
light_on: { c: '#111111', a: 0.11 },
light_off: { c: '#222222', a: 0.22 },
temp_cold: { c: '#333333', a: 0.33 },
temp_ok: { c: '#444444', a: 0.44 },
temp_hot: { c: '#555555', a: 0.55 },
lqi_low: { c: '#000000', a: 0.1 },
lqi_high: { c: '#ffffff', a: 0.3 },
glow_base: { c: '#666666', a: 0.66 },
} });
const cases = [
['none', null, 'none', null, null],
['lqi', 110, 'none', null, { color: '#808080', opacity: 0.2, mode: 'lqi' }],
['lqi', null, 'none', null, null],
['light', null, 'on', null, { color: '#111111', opacity: 0.11, mode: 'light' }],
['light', null, 'off', null, { color: '#222222', opacity: 0.22, mode: 'light' }],
['temp', null, 'none', 18, { color: '#333333', opacity: 0.33, mode: 'temp' }],
['temp', null, 'none', 22, { color: '#444444', opacity: 0.44, mode: 'temp' }],
['temp', null, 'none', 27, { color: '#555555', opacity: 0.55, mode: 'temp' }],
['temp', null, 'none', null, null],
['glow', null, 'none', null, { color: '#666666', opacity: 0.66, mode: 'glow' }],
];
for (const [mode, lqi, lights, temp, expected] of cases) {
assert.deepEqual(resolveEffectiveRoomFill(mode, lqi, lights, temp, 20, 25, colors), expected);
}
});
test('stateIcon: doors/locks/bulbs reflect state; custom icons and outages never morph', () => {
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'on', false), 'mdi:door-open');
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'off', false), 'mdi:door-closed');
+3 -3
View File
@@ -134,7 +134,7 @@ test('legacy filled shapes receive the canonical fill fields', () => {
assert.equal(result.config.spaces[0].decor[0].fill_opacity, 0.25);
});
test('optimizer migrates legacy self-light intent and preserves unknown controls losslessly', () => {
test('optimizer removes legacy self-control without inventing a light source', () => {
const result = optimizePlans({
model_version: PLAN_MODEL_VERSION - 1,
spaces: [],
@@ -147,9 +147,9 @@ test('optimizer migrates legacy self-light intent and preserves unknown controls
assert.deepEqual(result.config.markers[0].controls, [
'input_boolean.legacy', 'light.mirror', 'light.mirror',
]);
assert.equal(result.config.markers[0].is_light, true);
assert.equal(result.config.markers[0].is_light, undefined);
assert.equal(result.config.model_version, PLAN_MODEL_VERSION);
assert.equal(result.report.migrated, 2);
assert.equal(result.report.migrated, 1);
});
test('optimizer repairs legacy cell_cm values into the server schema range', () => {
+75 -1
View File
@@ -9,7 +9,8 @@ import {
wallBodyRings, wallBodiesUnionPath, innerContourForRoom,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, normalizeWallIntervals,
intervalCmAt, wallBodyNeedsSolid, openingInnerFaceOffset, WALL_HATCH_MIN_PX,
intervalCmAt, wallBodyNeedsSolid, openingInnerFaceOffset, openingTunnelGeometry,
WALL_HATCH_MIN_PX,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes } from '../test-build/logic.js';
import { GRID_PITCH } from '../test-build/space-geometry.js';
@@ -346,6 +347,79 @@ test('opening face side is known without wall thickness and can be inverted for
assert.equal(bottomInner.side, -1, 'the bottom wall room side is -Y');
});
test('openingTunnelGeometry: an outer thick wall gives the one room both tunnel halves', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 }];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.deepEqual(g.faces.map((f) => [f.side, f.roomId]), [[-1, 'r'], [1, 'r']]);
closeTo(g.minY, -2);
closeTo(g.maxY, 2);
});
test('openingTunnelGeometry: a shared wall is owned by the room on each local side', () => {
const rooms = [
{ id: 'south', poly: [[0, 0], [10, 0], [10, 5], [0, 5]] },
{ id: 'north', poly: [[0, -5], [10, -5], [10, 0], [0, 0]] },
];
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 15 }];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.equal(g.faces.find((f) => f.side === -1).roomId, 'north');
assert.equal(g.faces.find((f) => f.side === 1).roomId, 'south');
const reversed = openingTunnelGeometry(
[...rooms].reverse(), { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.deepEqual(reversed, g, 'config order must not change the selected rooms or paths');
});
test('openingTunnelGeometry: mixed atomic thickness clips each piece to its real depth', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [
{ key: wallKey([0, 0], [5, 0], pitch), a: [0, 0], b: [5, 0], cm: 10 },
{ key: wallKey([5, 0], [10, 0], pitch), a: [5, 0], b: [10, 0], cm: 20 },
];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 4 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.match(g.faces[0].d, /-1(?:\.0+)?\b/, '10 cm half-depth is present');
assert.match(g.faces[0].d, /-2(?:\.0+)?\b/, '20 cm half-depth is present');
closeTo(g.maxY, 2);
});
test('openingTunnelGeometry: virtual, zero-thickness, orphan and draft-only walls do not paint', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const opening = { x: 5, y: 0, angle: 0, length: 2 };
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 }];
assert.equal(openingTunnelGeometry(rooms, opening, [], [], pitch, 5, 1), null);
assert.equal(openingTunnelGeometry(rooms, opening, walls, [[0, 0, 10, 0]], pitch, 5, 1), null);
assert.equal(openingTunnelGeometry(rooms, { ...opening, y: 3 }, walls, [], pitch, 5, 1), null);
assert.equal(openingTunnelGeometry([], opening, walls, [], pitch, 5, 1), null,
'a physical room_draft body is not a room fill owner');
});
test('openingTunnelGeometry: angle match beats a perpendicular T-junction receiver', () => {
const rooms = [
{ id: 'horizontal', poly: [[0, 0], [10, 0], [10, 5], [0, 5]] },
{ id: 'vertical', poly: [[4, -5], [6, -5], [6, 0], [4, 0]] },
];
const walls = [
{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 },
{ key: wallKey([4, -5], [4, 0], pitch), cm: 30 },
];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.ok(g.faces.every((f) => f.roomId === 'horizontal' || f.roomId === 'vertical'));
closeTo(g.maxY, 2);
});
// ------------------------------- bodies / paper -----------------------------
test('wallEdgeBodies: shared and outer both grow ±½ from the centreline', () => {