mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-08 07:29:13 +00:00
Release v1.60.2
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}$")
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
+164
-144
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
|
||||
Vendored
+164
-144
File diff suppressed because one or more lines are too long
+20
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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.*` и привязку маркера к нужной комнате |
|
||||
|
||||
### Геометрия выглядит неверно
|
||||
|
||||
@@ -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
@@ -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 и солнечные лучи остаются поверх подложки тоннеля без изменения геометрии, цвета, прозрачности или алгоритма распространения.
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ----------------
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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' : ''}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user