Compare commits

...
16 Commits
Author SHA1 Message Date
Matysh 9be44dab1e v1.56.0
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 7m51s
Validate / smoke (push) Failing after 9m38s
2026-08-03 13:51:04 +03:00
Matysh ba88782ce1 v1.56.0 2026-08-03 13:46:24 +03:00
Matysh a20b73621f DEV-B701-01: sun-ray memo keyed by _cfgEpoch, not _cfgRev (stale wedge after local geometry edits)
The wedge cache key carried the SERVER revision, which only moves after the
debounced houseplan/config/set is acked. Every local mutation path ends in
_saveConfig(), which bumps _cfgEpoch synchronously — so a dragged window or
an edited room kept its old wedge for the whole write window (forever on a
failed write). The memo now uses the epoch, the same signal the model/
geometry caches key on (audit L1).

smoke_sun.mjs no longer masks the defect: touchCfg() bumps _cfgEpoch (what
production does) instead of faking a server rev, and a new regression drives
the REAL path — a pointer drag of the east window, exit from the editor
inside the debounce window (rev untouched), a room shrink through
_saveConfig() that must re-clip the wedge, and a late-ack survival check.
Fails on b701537, green with the fix. 218 unit + 79 backend + 85/85 smokes.
2026-08-03 13:28:57 +03:00
Matysh b70153769a OPENING DRAG: shoulders measure the ONE room edge under the opening, no collinear merge (owner 2026-08-03)
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 1m24s
Validate / backend (push) Failing after 5m25s
Validate / smoke (push) Failing after 7m57s
Owner: 'not like that — the whole wall is counted now, only the wall of ONE
room must count'. openingShoulders no longer merges collinear touching edges
of neighbouring rooms into a physical run: the wall is exactly the room-
polygon edge the opening is snapped to. Selection mirrors snapToWall
(nearest collinear edge, first in roomEdges order on a tie), so the ruler
always measures the same edge the drag snapped to; the center tick/magnet
now targets that edge's middle. Unit tests flipped to the new contract plus
a staggered shared-wall case; smoke_opening_measure recalculated for r1's
own edge 40..550 and grew a shared-wall scenario (both failed on the old
build, green now); docs/TESTING.md wording updated.
2026-08-03 12:21:02 +03:00
Matysh 5e5c06f126 OPENING DRAG: shoulder rulers + center tick with a soft magnet (owner 2026-08-03)
While an opening is dragged along a wall, a measure badge sits on the middle
of EACH shoulder: the along-the-wall distance from the wall end to the nearest
opening edge, live (segmentCm/formatLength, so metric/imperial and cell_cm are
honoured). Collinear touching room edges count as ONE physical wall — a user
thinks in whole walls, not the fragments roomEdges derives. When the opening's
center reaches the wall's center (±half a grid step) a perpendicular dashed
tick (alignment-guide look) appears through the wall center and the center
magnet-snaps; Shift disables the magnet. Everything vanishes on release.
Angled walls work: distances run along the wall, the tick is perpendicular.

- src/logic.ts: openingShoulders() — pure shoulder/centered math (unit-tested)
- src/houseplan-card.ts: _opMeasure state fed by _opPointerMove, badge layer
  next to the resize badges, _renderOpeningCenterTick in the SVG
- demo/smoke_opening_measure.mjs: real-pointer drag; numbers checked against
  the demo geometry (4.56/5.52 m, 5.04/5.04 m at center), magnet == 0.5,
  Shift keeps 0.4987, everything gone after drop (was red without the feature)
- docs/TESTING.md: checklist line
2026-08-03 11:59:57 +03:00
Matysh c4a80bcb9f DIALOGS: native ha-switch / ha-slider with a hard fallback to plain inputs
Boolean .srcrow checkboxes (14 rows: sun rays, vacuum live position,
opening invert/flipH/flipV, marker show-entities/tap-confirm/climate-temp/
is-light/hide-from-plan, space borders/names/lqi + the 4 room-card label
flags) and every dialog range slider (9: fill opacities, kiosk icon/font,
ripple size, marker size/angle, card font, room opacity, room name/label
scale) render through _boolInput/_rangeInput. Each helper picks the native
HA element via customElements.get(...) at render time - the ha-* API is
undocumented and drifts between HA releases, so the presence check is the
only coupling; without the element (old HA, smoke env) the EXISTING input
renders unchanged, and both branches feed one handler (change/.checked,
input+change/.value). Radios, selects, the decorbar fill flag and the
import-dialog floor rows stay native on purpose. New smoke_ha_controls
covers both branches with ha-* stubs (two-way value flow); the other 83
smokes keep exercising the fallback, which stays pixel-identical.
2026-08-03 11:09:00 +03:00
Matysh 9ccc3831d1 STYLES: design-token pass — spacing/radius/font/shadow scales in :host
209 hardcoded px values in styles.ts now resolve through design tokens
(--sp-1..6: 2/4/6/8/12/16, --rad-s/m/l: 6/8/12, --fs-s/m/l: 12/13/15,
--shadow-1/2/3). 151 swaps are value-identical; 58 stray values (3/5/7/9/
13/14px paddings, 11/12.5/13.5px fonts, 4/5/10/14px radii, two odd shadows)
are unified onto the nearest step, max +-2px by design. Untouched: %, all
calc() off --icon-size/--dev-size/--puck-size, viewBox units (compass text,
.rlabel, .vacfit), z-index, animation timings, colors. The phantom
--hp-panel/--hp-fg vars in .vaccalbar (defined nowhere) fold into
--hp-bg/--hp-txt. .modetab keeps its 10px h-padding: +2px wrapped the
header modes row at ~900px. 10px spacings stay literal for now - 10 is
equidistant from --sp-4/--sp-5 and moving it either way shifts the header;
candidate for its own step in a future pass.
2026-08-03 11:01:13 +03:00
Matysh 33a960031e CLIMATE TEMP: opt-in room temperature from climate devices (owner 2026-08-03)
Marker dialog grows a checkbox (climate devices only, default OFF):
'Use the device's temperature sensor'. When ticked, the AC/thermostat's
attributes.current_temperature shows as the standard .tval badge next to
the icon (scales with --dev-size, honours show_temperature) and joins the
room average like a thermometer. Unavailable / missing attribute = no
badge, no vote; several climate entities - the first valid one wins;
hidden devices keep voting (room climate stays registry-wide, exactly
like hidden thermometers). Stored as marker.use_climate_temp (bool|None,
validated in MARKER_SCHEMA). Units in test/devices.test.mjs, smoke
demo/smoke_climate_temp.mjs (real checkbox click, 20 + 23.5 -> 21.8 on
the room card), backend test, docs/TESTING.md.
2026-08-03 10:24:51 +03:00
Matysh 3a6a819dec SUN: white day — the brightest moment of the day is white (owner 2026-08-03)
- BG_STOPS: +10deg #e8ddcf (morning light), +30..90deg #ffffff; night half
  of the scale untouched (-4 #131a28, -12..-90 #070c14)
- drawn-plan paper vs white sky: all paper shapes now sit in one
  .hp-paperg group; in daynight mode the group gets a subtle
  drop-shadow so the sheet contour stays readable at high sun
  (static mode and night unaffected)
- pinned colors updated: test/sun.test.mjs, demo/smoke_sun.mjs;
  smoke_bg_color paperUnderneath follows the .hp-paperg wrapper
- docs/SUN.md: explicit BG_STOPS table
- demo/shot_daynight.mjs: noon/sunset/night stills of the scale
2026-08-03 09:56:36 +03:00
Matysh c024c6d75a BG: drawn-plan paper follows the room contours, not their bounding box
Owner: the white backing must hug the ROOMS — an L-shaped house or detached
buildings grew a white square around the plan. Drawn plans now paper one
opaque shape per room (paperRoomShapes in logic.ts) in exactly the room's own
geometry — polygon points / rounded rect verbatim — so the union of the stack
is the paper: islands paint over their parent, open (virtual) boundaries
change nothing, and the scene bg_color / daynight sky reaches the exterior
walls, shows in the L's pocket and between buildings. Image plans keep the
backdrop-image rect (the canvas IS the paper). A live resize preview
(_rszPreview) feeds _renderCfg, so the paper moves WITH a dragged wall.
Static space-card follows the same contract. Paper is fill-only (stroke:none).

smoke_bg_color §11–13 rewritten: L-shaped + detached test rooms, paper-per-
room DOM checks, resize-preview wiring, pixel probes (acid in the pocket and
between buildings, none inside rooms); §13 injects the snapshot directly —
the module-level config-store cache made the old WS mock a no-op. Was 8 red
on the previous build, green now. docs/SUN.md + docs/TESTING.md contract
updated; unit test for paperRoomShapes.
2026-08-03 08:38:48 +03:00
Matysh a8bb145ffb BG: opaque plan paper — the scene background never bleeds through the plan
Owner request 2026-08-03: bg_color (and the daynight sky) used to shine
through the plan itself — a hand-drawn plan's translucent room fills sat
directly on the scene colour, and a transparent backdrop image let it
through too. An opaque rect.hp-paper now sits under everything the plan
draws and hugs the plan's extents (the backdrop image rect, or the drawn
content bounds the opening view fits). Its colour is the pre-bg_color
canvas: white for drawn plans (.stage.noplan), the theme card background
under an image and on the static space-card. The daynight night keeps
dimming the plan via the zoomwrap brightness filter ONLY — the paper's
alpha never changes. The scene colour is visible strictly AROUND the
plan, in view/kiosk/editors and the static card alike.

smoke_bg_color grew the contract (sections 11–13): paper presence,
geometry and opacity in view/editors/night, the white drawn-plan paper,
the static card's paper, plus a pixel proof against an acid #ff00ff
background (screenshot → canvas: no acid admixture inside the plan, acid
right outside it). The suite fails on the previous build.

docs: SUN.md background contract + TESTING.md checklist item.
2026-08-03 08:16:48 +03:00
Matysh 1e0295a370 SUN: adjust pinned dialog inventories + screenshot script
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m22s
Validate / backend (push) Failing after 4m10s
Validate / hacs (push) Failing after 7s
Validate / smoke (push) Failing after 12m53s
smoke_general_settings: the settings dialog now has 14 gsrows and a Sun
group; smoke_temp_fill: the space dialog gained the per-space compass
field. demo/shot_sun.mjs renders the evening-wedges and compass shots.
2026-08-03 01:41:26 +03:00
Matysh c74ce39eb3 SUN: backend validation + card render, dialogs, i18n, smoke (docs/SUN.md)
- validation.py: north_deg (strict int 0-359), bg_mode, sun_rays at both
  levels + weather_entity (global); tests_backend coverage
- card: memoised window-wedge layer (recomputes only on sun/config change),
  day/night stage background with slow CSS transition and ~10% plan dim,
  compass dial + number input in the general settings, per-space
  bg_mode/north_deg/sun_rays overrides (empty = inherit), weather datalist
- static space-card: background honours bg_mode (wedges full-card-only)
- i18n en/ru, styles, docs/TESTING.md checklist
- demo/smoke_sun.mjs: 4-wall windows, compass rotation, night, clipping,
  inheritance, clouds, memo, editors clean, real compass drag; verified to
  FAIL against the pre-feature build (16 named failures)
2026-08-03 01:36:03 +03:00
Matysh ee0ee9a1d3 SUN: spec (docs/SUN.md) + pure logic src/sun.ts with unit tests
planSunAngle/sunDirOnPlan (compass wrap), dayPhase palette, exterior-wall
probing, window wedges (rayQuad + polyclip room clipping), cloudFactor map,
north_deg/bg_mode/sun_rays inheritance. 26 new unit tests.
2026-08-03 01:26:38 +03:00
Matysh a8d40e4d99 v1.55.3
Validate / smoke (push) Failing after 12m47s
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 2m24s
Validate / backend (push) Failing after 7m57s
2026-08-02 15:01:37 +03:00
Matysh 63a8274623 AUD-1552-01/02: the v1.55.2 boot-veil audit findings, fixed with regressions
The v1.55.2 recheck (verdict NEEDS FIX) found two lifecycle holes in the
first-open boot veil (HP-1552); both are closed here and covered by
demo/smoke_preloader_lifecycle.mjs, which fails on v1.55.2.

AUD-1552-01 (high): disconnect/reconnect while booting hid the plan
FOREVER. disconnectedCallback cleared the boot timer but kept its
truthy id, so 'updated()' never restarted the watcher. Now the id is
nulled on disconnect and connectedCallback restarts the whole veil
lifecycle: a fresh watch (fresh clock, BOOT_MAX_MS hard cap) while
booting, the tail timers when detached mid-fade or mid-grace.

AUD-1552-02 (medium): two equal reads at 200/400 ms revealed the plan
at ~400 ms, so HA chrome landing at 450+ ms jumped on a VISIBLE plan.
The veil now holds a full protective window (BOOT_MIN_MS=700 — the old
600 ms plus a frame-latency margin: a shift applied at ~590 ms only
materializes in the stage height a couple frames later) with 100 ms
sampling and trailing quiescence (BOOT_QUIET_MS=250 restarts on every
height change), capped by BOOT_MAX_MS=1200. After the reveal a short
soft grace (BOOT_SOFT_MS=1500, .stage.hpsettle) turns later passive
shifts into a 0.25 s height glide — the viewport ResizeObserver refits
the plan along the transition; deliberate height changes (_setMode into
an editor) cancel the grace so the plan never drifts under the pointer.
Reduced motion disables the glide.

Regressions (demo/smoke_preloader_lifecycle.mjs, all FAIL on v1.55.2):
- A: detach before the first tick -> reattach -> veil lifts within the
  cap, plan visible, no hpboot class, no zombie veil after a mid-fade
  remount either;
- B: parameterized layout shifts at 150/300/450/590 ms -> not a single
  frame shows the plan at a non-final stage height;
- C: a shift after the reveal glides (>=3 intermediate frames), no snap.

smoke_modes.mjs now strips the transient hpsettle class from its exact
stage-class assertions. Docs: CHANGELOG en+ru, STATUS.
2026-08-02 14:56:58 +03:00
41 changed files with 5044 additions and 1220 deletions
+4
View File
@@ -35,6 +35,10 @@ right on your Lovelace dashboard.
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
of its own color that spills through doorways and open zone boundaries.
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
the day (white noon → golden hour → deep night), while windows on exterior
walls cast real wedges of sunlight into the rooms; optional cloud cover
from a weather entity.
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
comfort-range temperature fills, per-room signal heatmap.
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
+4
View File
@@ -33,6 +33,10 @@
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
weather-сущности.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.55.2"
VERSION = "1.56.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.55.2"
"version": "1.56.0"
}
+27
View File
@@ -168,6 +168,22 @@ ROOM_SCHEMA = vol.All(
),
_require_geometry,
)
def _north_deg(value):
"""Compass (docs/SUN.md): strict integer degrees, 0..359.
Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
int in Python — none of those is a compass reading a client stored.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise vol.Invalid("north_deg must be an integer in 0..359")
if not 0 <= value <= 359:
raise vol.Invalid("north_deg must be an integer in 0..359")
return value
_BG_MODE = vol.In(["static", "daynight"])
SPACE_DISPLAY_SCHEMA = vol.Schema(
{
vol.Optional("show_borders"): bool,
@@ -185,6 +201,10 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("label_lqi"): bool,
vol.Optional("label_light"): bool,
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
# sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
vol.Optional("north_deg"): vol.Any(None, _north_deg),
vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
vol.Optional("sun_rays"): vol.Any(None, bool),
},
extra=vol.ALLOW_EXTRA,
)
@@ -299,6 +319,8 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
# asserts every option the editor offers is accepted here (issue #3)
@@ -323,6 +345,11 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
+36
View File
@@ -0,0 +1,36 @@
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
];
sp.plan_url = ''; // hand-drawn plan: white paper — the risky case for a white day
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
c._cfgRev = (c._cfgRev || 0) + 1;
});
const shot = async (az, el, name) => {
await page.evaluate(async ([az, el]) => {
const c = window.__card;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth: az, elevation: el },
} } };
c.requestUpdate(); await c.updateComplete;
}, [az, el]);
await page.waitForTimeout(300);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
};
await shot(180, 60, 'dn_noon');
await shot(265, 3, 'dn_sunset');
await shot(0, -20, 'dn_night');
await browser.close();
console.log('shots done');
+52
View File
@@ -0,0 +1,52 @@
// Screenshots for docs/SUN.md: evening wedges over the glow fill + the compass
// dial in the general settings. Usage: node demo/shot_sun.mjs <outdir>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
// instant colors for the still shot: the day/night transition is tens of seconds
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
c._serverCfg.settings = {
...(c._serverCfg.settings || {}),
north_deg: 0, sun_rays: true, bg_mode: 'daynight',
};
c._cfgRev = (c._cfgRev || 0) + 1;
// a warm evening: the sun low in the west; the living-room light off so
// the glow pool does not swamp the west wedge
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 265, elevation: 3 },
},
} };
c.requestUpdate();
await c.updateComplete;
});
await page.waitForTimeout(400);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: outDir + '/sun_rays.png' });
await page.evaluate(async () => {
const c = window.__card;
c._openSettingsDialog();
c._settingsDialog = { ...c._settingsDialog, northDeg: 330 };
c.requestUpdate();
await c.updateComplete;
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
await page.waitForTimeout(200);
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
await browser.close();
console.log('shots written to ' + outDir);
+201
View File
@@ -141,4 +141,205 @@ const res = await page.evaluate(async () => {
return out;
});
checkAll(res);
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
// The scene bg_color must never bleed through the plan itself: opaque
// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
// The scene colour is visible ONLY around the paper; the 'daynight' night
// dims the paper via brightness only.
const paper = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
c._cfgRev = (c._cfgRev || 0) + 1;
await upd();
const m = c._spaceModel();
const p = sr().querySelector('.stage svg rect.hp-paper');
out.paperExists = !!p;
if (p) {
const a = (n) => Number(p.getAttribute(n));
out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
&& Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
const cs = getComputedStyle(p);
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
}
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
c._setMode('view');
await upd();
// daynight night: the paper dims via the brightness filter ONLY, never alpha
c._serverCfg.settings.bg_mode = 'daynight';
c._serverCfg.settings.north_deg = 0;
c._cfgRev++;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'below_horizon',
attributes: { azimuth: 180, elevation: -20 },
} } };
await upd();
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
const pn = sr().querySelector('.stage svg rect.hp-paper');
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
delete c._serverCfg.settings.bg_mode;
delete c._serverCfg.settings.north_deg;
c._cfgRev++;
await upd();
return out;
});
Object.assign(res, paper);
// ---- 12) drawn plan: the paper follows the ROOM CONTOURS ---------------
// Owner: «белая подложка должна быть по границам комнат» — an L-shaped house
// plus a detached building must NOT grow a white bounding rectangle. One
// paper shape per room, in exactly the room's own geometry; the acid scene
// colour shows in the L's pocket and between the buildings, right up to the
// exterior walls; inside the rooms it never shows. A live resize preview
// (_rszPreview) moves the paper together with the dragged wall.
const drawn = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
sp.plan_url = ''; // hand-drawn plan now
// binary-exact fractions -> exact render coords (0.125*1000 = 125 etc.)
sp.rooms = [
{ id: 'rL', name: 'L', poly: [[0.125, 0.125], [0.625, 0.125], [0.625, 0.375], [0.375, 0.375], [0.375, 0.625], [0.125, 0.625]] },
{ id: 'rQ', name: 'Q', x: 0.75, y: 0.75, w: 0.1875, h: 0.1875 },
];
sp.openings = [];
sp.decor = [];
c._devices = []; // no icons over the pixel probes below
c._cfgRev = (c._cfgRev || 0) + 1;
c._cfgEpoch++;
await upd();
const papers = () => [...sr().querySelectorAll('.stage.noplan svg .hp-paper')];
let ps = papers();
out.paperPerRoom = ps.length === sp.rooms.length;
out.paperShapesMatchRooms = ps.length === 2
&& ps[0].tagName.toLowerCase() === 'polygon'
&& ps[0].getAttribute('points') === sp.rooms[0].poly.map((p) => (p[0] * 1000) + ',' + (p[1] * 1000)).join(' ')
&& ps[1].tagName.toLowerCase() === 'rect';
out.drawnPaperWhite = ps.length > 0 && ps.every((p) => getComputedStyle(p).fill === 'rgb(255, 255, 255)');
out.paperNoStroke = ps.length > 0 && ps.every((p) => {
const cs = getComputedStyle(p);
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
});
// paper first, everything else on top of it (one .hp-paperg group wraps
// all paper shapes so the daynight drop shadow composites without seams)
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
&& !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
// must move WITH the room, not lag behind until the drop
const pv = JSON.parse(JSON.stringify(sp));
pv.rooms[0].poly = pv.rooms[0].poly.map(([x, y]) => [x === 0.625 ? 0.6875 : x, y]);
c._rszPreview = { space: c._space, sp: pv };
c._cfgEpoch++;
await upd();
out.previewMovesPaper = (papers()[0]?.getAttribute('points') || '').includes('687.5,125');
c._rszPreview = null;
c._cfgEpoch++;
await upd();
out.previewRestores = (papers()[0]?.getAttribute('points') || '').includes('625,125');
// screen coords for the pixel probes (svg user units -> viewport px)
const svgEl = sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => { const q = svgEl.createSVGPoint(); q.x = x; q.y = y; const r = q.matrixTransform(ctm); return [r.x, r.y]; };
out.probes = {
pocket: toScr(500, 500), // the L's notch — bg must show here
gap: toScr(687, 300), // between the two buildings
inside: [toScr(250, 250), toScr(500, 250), toScr(250, 500), toScr(843.75, 843.75)],
};
return out;
});
const { probes } = drawn;
delete drawn.probes;
Object.assign(res, drawn);
// pixel proof: the acid scene colour IS in the L's pocket and between the
// buildings (where the old bounding-box paper drew a white square), and is
// NOT inside any room
if (probes) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, probes }) => {
const img = new Image();
img.src = 'data:image/png;base64,' + shot;
await img.decode();
const cv = document.createElement('canvas');
cv.width = img.width;
cv.height = img.height;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const at = ([x, y]) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
return {
acidInPocket: acid(at(probes.pocket)),
acidBetweenBuildings: acid(at(probes.gap)),
noAcidInsideRooms: probes.inside.every((q) => !acid(at(q))),
};
}, { shot, probes });
Object.assign(res, px);
} else {
res.acidInPocket = false;
res.acidBetweenBuildings = false;
res.noAcidInsideRooms = false;
}
// ---- 13) the static space-card follows the same paper contract ----------
const stat = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
// NOTE: the config-store cache is module-level and already primed by the
// earlier static-card sections (with the PRE-§12 rooms), so a mocked
// callWS would never reach the card — inject the snapshot directly.
const mk = async (cfg) => {
const host = document.createElement('div');
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
el._snap = { config: cfg, rev: 1, layout: {} };
el._loadedOnce = true;
el.hass = c.hass;
host.appendChild(el);
const t0 = Date.now();
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000)
await new Promise((r) => setTimeout(r, 80));
await el.updateComplete;
return { el, host };
};
const base = JSON.parse(JSON.stringify(c._serverCfg));
base.spaces.find((s) => s.id === c._space).plan_url = '/assets/f1.svg';
base.settings = { ...(base.settings || {}), bg_color: '#ff00ff' };
const a = await mk(base);
const pa = a.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
out.staticPaperExists = !!pa;
out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
&& getComputedStyle(pa).fill !== 'none';
a.host.remove();
// drawn plan: per-room paper shapes (the L + the detached rect from §12)
const drawnCfg = JSON.parse(JSON.stringify(base));
const dsp = drawnCfg.spaces.find((s) => s.id === c._space);
dsp.plan_url = '';
const b = await mk(drawnCfg);
const pbs = [...b.el.renderRoot.querySelectorAll('.hp-static-stage svg .hp-paper')];
out.staticDrawnPaperPerRoom = pbs.length === dsp.rooms.length;
out.staticDrawnPaperShapes = pbs.length === 2
&& pbs[0].tagName.toLowerCase() === 'polygon'
&& pbs[1].tagName.toLowerCase() === 'rect';
b.host.remove();
return out;
});
Object.assign(res, stat);
checkAll({ ...paper, ...drawn, ...stat,
acidInPocket: res.acidInPocket,
acidBetweenBuildings: res.acidBetweenBuildings,
noAcidInsideRooms: res.noAcidInsideRooms });
await finish(browser, res);
+147
View File
@@ -0,0 +1,147 @@
// Опция «Использовать датчик температуры устройства» (marker.use_climate_temp):
// климат-устройства (кондиционеры, термостаты) знают температуру комнаты
// (attributes.current_temperature). По умолчанию ВЫКЛ — ни плашки, ни вклада
// в среднюю. Включена через диалог (реальный клик по чекбоксу): плашка .tval
// у значка + голос в средней комнаты (термометр 20 + климат 23.5 → 21.75,
// т.е. 21.8 по сетке карточки 0.1°). Unavailable-климат не ломает ничего.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// --- впрыскиваем климат-устройства в демо-hass ---------------------------
await page.evaluate(() => {
const c = window.__card;
const h = window.__mkHass();
const inject = (hass) => ({
...hass,
devices: {
...hass.devices,
d_ac: { id: 'd_ac', name: 'Air conditioner', model: 'AC-12', area_id: 'living_room', identifiers: [['demo', 'd_ac']], entry_type: null, via_device_id: null },
d_trv: { id: 'd_trv', name: 'Radiator valve', model: 'TRV-1', area_id: 'bedroom', identifiers: [['demo', 'd_trv']], entry_type: null, via_device_id: null },
},
entities: {
...hass.entities,
'climate.ac': { entity_id: 'climate.ac', device_id: 'd_ac', platform: 'demo' },
'climate.trv': { entity_id: 'climate.trv', device_id: 'd_trv', platform: 'demo' },
},
states: {
...hass.states,
// термометр гостиной — ровно 20, чтобы средняя с климатом была детерминированной
'sensor.living_temp': { ...hass.states['sensor.living_temp'], state: '20' },
'climate.ac': { entity_id: 'climate.ac', state: 'cool', attributes: { friendly_name: 'Air conditioner', current_temperature: 23.5, temperature: 24 } },
// мёртвый климат: state unavailable, атрибутов нет
'climate.trv': { entity_id: 'climate.trv', state: 'unavailable', attributes: { friendly_name: 'Radiator valve' } },
},
});
window.__injectClimate = () => { c.hass = inject(window.__mkHass()); };
window.__injectClimate();
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
});
await page.waitForFunction(() => window.__card._devices.some((d) => d.entities?.includes('climate.ac')));
// --- без опции: ни плашки, ни вклада в среднюю ---------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await c.updateComplete;
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
o.acBuilt = !!ac;
o.noBadgeByDefault = c._liveTemp(ac) === null;
o.avgWithoutOption = c._climate().get('living_room')?.temp; // === 20
return o;
}), { avgWithoutOption: 20 });
// --- диалог: чекбокс есть у климат-устройства, нет у обычного, клик мышью --
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// у обычного термометра чекбокса нет
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
c._openMarkerDialog(th); await c.updateComplete;
o.noCheckboxForThermometer = !sr().querySelector('.dialog .climrow');
c._markerDialog = null; await c.updateComplete;
// у кондиционера — есть и по умолчанию снят
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
c._openMarkerDialog(ac); await c.updateComplete;
const row = sr().querySelector('.dialog .climrow input');
o.checkboxForClimate = !!row;
o.uncheckedByDefault = !!row && !row.checked;
if (row) row.scrollIntoView({ block: 'center' });
return o;
}));
// реальный клик по чекбоксу — координатами, через весь стек событий
const box = await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const r = sr.querySelector('.dialog .climrow input')?.getBoundingClientRect();
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
});
check('checkboxClickable', !!box);
if (box) await page.mouse.click(box.x, box.y);
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await c.updateComplete;
o.clickTicksState = c._markerDialog?.useClimateTemp === true;
await c._saveMarker(); await c.updateComplete;
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:d_ac');
o.savedInMarker = m?.use_climate_temp === true;
return o;
}));
// --- с опцией: плашка «23.5°», средняя пересчиталась ----------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
await c.updateComplete;
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
o.liveTempFromClimate = c._liveTemp(ac); // === 23.5
const badges = [...sr().querySelectorAll('.dev .tval')].map((el) => el.textContent.trim());
o.badgeVisible = badges.includes('23.5°');
// средняя гостиной: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
o.avgWithOption = c._climate().get('living_room')?.temp;
// и на КАРТОЧКЕ КОМНАТЫ: включаем метрику температуры и читаем текст
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.settings = { ...(sp.settings || {}), label_temp: true, show_names: true };
c.requestUpdate(); await c.updateComplete;
const labels = [...sr().querySelectorAll('.roomlabel .rlm')].map((el) => el.textContent.trim());
o.roomCardShowsAvg = labels.some((t) => t.includes('21.8°'));
return o;
}), { liveTempFromClimate: 23.5, avgWithOption: 21.8 });
// --- unavailable-климат: опция включена, но ни плашки, ни вклада ----------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
c._serverCfg.markers = [...c._serverCfg.markers,
{ id: 'd_trv', binding: 'device:d_trv', use_climate_temp: true, hidden: false }];
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const trv = c._devices.find((d) => d.entities?.includes('climate.trv'));
o.unavailNoBadge = !!trv && c._liveTemp(trv) === null;
o.unavailNoVote = c._climate().get('bedroom')?.temp ?? null; // в спальне нет термометров
// а гостиная как была 21.8
o.avgStillCorrect = c._climate().get('living_room')?.temp;
return o;
}), { unavailNoVote: null, avgStillCorrect: 21.8 });
// --- чекбокс переживает пересоздание диалога ------------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
c._openMarkerDialog(ac); await c.updateComplete;
o.survivesReopen = c._markerDialog?.useClimateTemp === true;
o.checkboxChecked = sr().querySelector('.dialog .climrow input')?.checked === true;
c._markerDialog = null;
return o;
}));
checkAll(out, { avgWithoutOption: 20, liveTempFromClimate: 23.5, avgWithOption: 21.8, unavailNoVote: null, avgStillCorrect: 21.8 });
await finish(browser, out);
+3 -3
View File
@@ -4,7 +4,7 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
@@ -28,8 +28,8 @@ const res = await page.evaluate(async () => {
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 12, // 10 цветов + радиус свечения + фон вокруг плана
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background"],
"rows": 14, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun"],
"saved": {"c": "#ff00ff", "a": 0.5},
"lqiBefore": 7,
"lqiAfter": 0,
+86
View File
@@ -0,0 +1,86 @@
// Native HA controls in dialogs (ha-switch / ha-slider) + the fallback.
// The demo env has no HA frontend, so every OTHER smoke already runs the
// fallback branch (plain input[type=checkbox|range]). Here we check both:
// 1) fallback: without ha-* the dialogs render the classic inputs;
// 2) ha branch: after registering lightweight ha-switch/ha-slider stubs
// (checked/value + `change`/`input`, the real contract of the HA ones)
// a re-opened dialog renders them, values flow BOTH ways.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// --- 1) fallback branch: no ha-* registered -> classic inputs ----------
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
out.fallbackCheckbox = !!sr().querySelector('.dialog .srcrow input[type=checkbox]');
out.fallbackRange = !!sr().querySelector('.dialog input[type=range]');
out.fallbackNoHaSwitch = !sr().querySelector('.dialog ha-switch');
c._markerDialog = null; await c.updateComplete;
// --- 2) register stubs mimicking the HA contract -----------------------
customElements.define('ha-switch', class extends HTMLElement {
#on = false;
get checked() { return this.#on; }
set checked(v) { this.#on = !!v; }
get disabled() { return this.hasAttribute('disabled'); }
set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
});
customElements.define('ha-slider', class extends HTMLElement {
#v = 0;
get value() { return this.#v; }
set value(v) { this.#v = Number(v); }
set min(_) {} set max(_) {} set step(_) {}
});
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
const switches = [...sr().querySelectorAll('.dialog ha-switch')];
out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
out.haNoPlainCheckbox = !sr().querySelector('.dialog .srcrow input[type=checkbox]');
const sliders = [...sr().querySelectorAll('.dialog ha-slider')];
out.haSliderRendered = sliders.length >= 2; // size + angle
// --- 3) card -> control: state is pushed into the element --------------
c._markerDialog = { ...c._markerDialog, isLight: true, hideFromPlan: false };
await c.updateComplete;
out.downstreamChecked = [...sr().querySelectorAll('.dialog ha-switch')].some((el) => el.checked === true);
// --- 4) control -> card: change with .checked lands in the dialog ------
const before = c._markerDialog.hideFromPlan;
const sw = [...sr().querySelectorAll('.dialog ha-switch')].find((el) => el.checked === before);
sw.checked = !before;
sw.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.upstreamChecked = c._markerDialog.hideFromPlan === !before || c._markerDialog.isLight === !before
|| c._markerDialog.useClimateTemp === !before || c._markerDialog.tapConfirm === !before;
// --- 5) slider both ways ----------------------------------------------
const sizeBefore = c._markerDialog.size;
const sl = [...sr().querySelectorAll('.dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
out.downstreamValue = !!sl;
sl.value = 2;
sl.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
out.upstreamValue = c._markerDialog.size === 2;
// `change` (fired on release, HA-version dependent) reaches the same handler
sl.value = 2.5;
sl.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.upstreamValueChangeEvt = c._markerDialog.size === 2.5;
c._markerDialog = null; await c.updateComplete;
// --- 6) general settings: opacity sliders take the ha branch too -------
c._openSettingsDialog(); await c.updateComplete;
out.gsHaSliders = sr().querySelectorAll('.dialog ha-slider').length >= 9;
const gsl = sr().querySelector('.dialog ha-slider');
gsl.value = 55;
gsl.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
out.gsUpstream = Math.round((Object.values(c._settingsDialog.colors)[0].a) * 100) === 55;
c._settingsDialog = null; await c.updateComplete;
return out;
});
checkAll(res, {});
await finish(browser, res);
+2 -1
View File
@@ -13,7 +13,8 @@ const st = () => page.evaluate(() => {
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
gears: sr.querySelectorAll('.tabedit').length,
markupBar: !!sr.querySelector('.editbar'),
stageClass: sr.querySelector('.stage').className,
// hpsettle is the transient post-boot grace (AUD-1552-02), not a mode class
stageClass: sr.querySelector('.stage').className.replace(/ ?\bhpsettle\b/, ''),
};
});
// 1) старт: view, чистая шапка
+172
View File
@@ -0,0 +1,172 @@
// Opening drag rulers (owner 2026-08-03): while a door/window is dragged along
// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
// along the wall), and when the opening's center hits the wall's center a
// perpendicular dashed tick appears and the center magnet-snaps (Shift opts
// out). The wall is ONE room's edge (owner: "only the wall of one room") —
// r2's collinear top edge never merges into r1's, so the ruler runs 40..550
// (center 295), not the old merged 40..960. Real pointer events; the numbers
// are checked against the geometry: cell = 5 cm, grid pitch = 1000/240, so
// cm = units * 1.2 and r1's top wall (510 u) is 6.12 m.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
// 80-unit door on r1's top wall (y = 0.14), off-center
sp.openings = [{ id: 'op1', type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08 }];
c._setMode('plan'); c._tool = 'opening';
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const badges = () => page.evaluate(() =>
[...window.__card.renderRoot.querySelectorAll('.measurelabel.opshoulder')].map((el) => el.textContent.trim()).sort());
const nums = async () => (await badges()).map((t) => parseFloat(t));
const tick = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opcentertick').length);
const near = (a, b, tol) => Math.abs(a - b) <= tol;
// ---------- drag to an off-center spot: two live badges, no tick ----------
const [sx, sy] = await screenPt(200, 140);
const [t1x] = await screenPt(460, 140);
await page.mouse.move(sx, sy);
await page.mouse.down();
await page.mouse.move(t1x, sy, { steps: 5 });
await settle();
// center 460 -> edges 420/500; shoulders measured on r1's OWN edge 40..550:
// 420-40=380 u (4.56 m) and 550-500=50 u (0.60 m) — the old merged build
// showed 960-500=460 u (5.52 m) on the right
{
const [a, b] = await nums();
check('two_badges_visible', (await badges()).length, 2);
check('badge_right_0_60', near(a, 0.6, 0.04));
check('badge_left_4_56', near(b, 4.56, 0.04));
// exact invariant, immune to pointer rounding: shoulders + opening = wall
check('badges_sum_to_wall', near(a + b, 6.12 - 0.96, 0.021));
}
check('no_tick_off_center', await tick(), 0);
// ---------- live update on the way ----------
const [t2x] = await screenPt(360, 140);
await page.mouse.move(t2x, sy, { steps: 3 });
await settle();
// center 360 -> shoulders 320-40=280 u (3.36 m) and 550-400=150 u (1.80 m)
{
const [a, b] = await nums();
check('badge_right_updates_1_80', near(a, 1.8, 0.04));
check('badge_left_updates_3_36', near(b, 3.36, 0.04));
}
// ---------- near the OWN edge center (40+550)/2 = 295: tick + magnet ----------
const [t3x] = await screenPt(293.7, 140);
await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('tick_at_center', await tick(), 1);
check('magnet_snaps_center', near(await page.evaluate(() =>
window.__card._curSpaceCfg.openings[0].x), 0.295, 1e-6));
// both shoulders equal after the snap: (510 - 80) / 2 = 215 u -> 2.58 m
check('badges_equal_at_center', await badges(), ['2.58 m', '2.58 m']);
// the tick is perpendicular to the wall (horizontal wall -> a vertical dash)
const geom = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opcentertick');
if (!el) return null;
const dashed = getComputedStyle(el).strokeDasharray;
return { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2'),
dashed: dashed !== 'none' && dashed !== '' };
});
check('tick_perpendicular_at_wall_center', !!geom
&& near(geom.x1, 295, 1e-6) && near(geom.x2, 295, 1e-6) && near(geom.y2 - geom.y1, 30, 1e-6));
check('tick_dashed', !!geom && geom.dashed);
// ---------- release: everything disappears, the snapped x is committed ----------
await page.mouse.up();
await settle();
check('badges_gone_after_drop', (await badges()).length, 0);
check('tick_gone_after_drop', await tick(), 0);
check('committed_x_center', near(await page.evaluate(() =>
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
// ---------- Shift disables the magnet (badges stay) ----------
// park the opening off-center first: a drag that returns to within 3 px of its
// own starting point is filtered by the tap threshold, so the Shift approach
// must start away from the center
const [cx0] = await screenPt(295, 140);
await page.mouse.move(cx0, sy);
await page.mouse.down();
await page.mouse.move(t2x, sy, { steps: 3 });
await page.mouse.up();
await settle();
await page.keyboard.down('Shift');
await page.mouse.down();
await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('no_tick_with_shift', await tick(), 0);
const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
check('no_magnet_with_shift', near(freeX, 0.2937, 0.004) && freeX !== 0.295);
check('badges_still_there_with_shift', (await badges()).length, 2);
await page.mouse.up();
await page.keyboard.up('Shift');
await settle();
check('badges_gone_final', (await badges()).length, 0);
// ---------- opening on a SHARED wall of two rooms: the own room's edge ----------
// staggered fragments on the boundary x=550: r1's right edge 140..460 (its
// room is made the short one), r2's left edge 140..580. The snap resolves to
// r1's edge (nearest, first in roomEdges order on the tie), so the shoulders
// and the center come from 140..460 — the old build measured the merged
// 140..580 union
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.rooms = [
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.46], [0.04, 0.46]] },
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.58], [0.55, 0.58]] },
];
sp.openings = [{ id: 'op1', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await settle();
const [vx0, vy0] = await screenPt(550, 300);
const [, vy1] = await screenPt(550, 360);
await page.mouse.move(vx0, vy0);
await page.mouse.down();
await page.mouse.move(vx0, vy1, { steps: 3 });
await settle();
// center 360 -> edges 320/400; r1's edge 140..460: 320-140=180 u (2.16 m) and
// 460-400=60 u (0.72 m); the merged build showed 580-400=180 u (2.16 m) below
{
const [a, b] = await nums();
check('shared_wall_two_badges', (await badges()).length, 2);
check('shared_wall_bottom_0_72', near(a, 0.72, 0.04));
check('shared_wall_top_2_16', near(b, 2.16, 0.04));
}
check('shared_wall_no_tick_off_center', await tick(), 0);
await page.mouse.up();
await settle();
// approach the OWN edge center (140+460)/2 = 300 — NOT the merged 360
const [, vy2] = await screenPt(550, 301.3);
await page.mouse.move(vx0, vy1);
await page.mouse.down();
await page.mouse.move(vx0, vy2, { steps: 3 });
await settle();
check('shared_wall_tick_at_own_center', await tick(), 1);
check('shared_wall_magnet_y_0_3', near(await page.evaluate(() =>
window.__card._curSpaceCfg.openings[0].y), 0.3, 1e-6));
// equal shoulders on the own edge: (320 - 80) / 2 = 120 u -> 1.44 m
check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
await page.mouse.up();
await settle();
await finish(browser, { done: true });
+180
View File
@@ -0,0 +1,180 @@
// AUD-1552-01 + AUD-1552-02: adversarial lifecycle regressions for the
// first-open boot veil (HP-1552). Both scenarios FAIL on v1.55.2.
//
// A. disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
// switch remounts the card). v1.55.2 cleared the boot timer on disconnect
// but kept its truthy id, so the reconnect never restarted the watcher and
// the plan stayed hidden FOREVER. Now the veil lifecycle restarts from
// connectedCallback: the veil must lift no later than the hard cap.
// B. layout shifts before, between and after the old poll ticks (150, 300,
// 450, 590 ms). v1.55.2 revealed the plan after two equal reads at
// 200/400 ms, so a panel landing at 450+ ms jumped on a VISIBLE plan. Now
// the veil holds for the full protective window with trailing quiescence:
// not a single frame may show the plan at a non-final stage height.
// C. a shift AFTER the reveal (slower than the cap) must glide, not snap:
// for a short grace the stage height transitions and the plan refits
// along with it (no transition at all on v1.55.2 — instant jump).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- A: detach before the first tick, reattach, veil must lift ----------
const rec = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.appendChild(c);
document.body.appendChild(wrap);
await sleep(50); // the first boot tick has not fired yet
out.bootingWhenDetached = c._booting === true;
c.remove(); // disconnect: timers die here
await sleep(30);
wrap.appendChild(c); // reconnect — the veil lifecycle must restart
const t0 = performance.now();
const sr = () => c.shadowRoot || c.renderRoot;
while (c._booting && performance.now() - t0 < 2000) await sleep(50);
out.revealMs = Math.round(performance.now() - t0);
out.bootSettledAfterReconnect = c._booting === false;
out.settledWithinCap = performance.now() - t0 < 1500; // hard cap 1200 + slack
await sleep(300); // let the fade finish
const stage = sr().querySelector('.stage');
const wrapEl = stage && stage.querySelector(':scope > .zoomwrap');
out.planVisibleAfterReconnect = !!wrapEl && getComputedStyle(wrapEl).visibility === 'visible';
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
out.veilGone = !sr().querySelector('.bootveil');
// A2: a second detach DURING the fade must not leave a zombie veil either
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
c2.hass = window.__card.hass;
wrap.appendChild(c2);
const t1 = performance.now();
while (c2._booting && performance.now() - t1 < 2000) await sleep(50);
// _bootFading is true for ~220 ms after the reveal — detach right inside it
c2.remove();
await sleep(30);
wrap.appendChild(c2);
await sleep(400);
const sr2 = c2.shadowRoot || c2.renderRoot;
out.fadeVeilGoneAfterReattach = !sr2.querySelector('.bootveil') && c2._bootFading === false;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(rec)) check(k, v, k === 'revealMs' ? rec.revealMs : true);
// ---------- B: shifts at 150/300/450/590 ms — never a visible stale frame ----------
const shifts = [150, 300, 450, 590];
const outB = {};
for (const shiftMs of shifts) {
const res = await page.evaluate(async (shiftAt) => {
const out = {};
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const frames = [];
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let grown = false;
await new Promise((done) => {
const sample = () => {
const t = performance.now() - t0;
if (!grown && t >= shiftAt) {
grown = true; // the "HA panel" arrives
spacer.style.height = '56px';
window.dispatchEvent(new Event('resize'));
}
const stage = sr().querySelector('.stage');
if (stage) {
const veil = sr().querySelector('.bootveil');
const wrapEl = stage.querySelector(':scope > .zoomwrap');
const planHidden =
!wrapEl ||
getComputedStyle(wrapEl).visibility === 'hidden' ||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
frames.push({ t: Math.round(t), planVisible: !planHidden, stageH: stage.clientHeight });
}
if (t < 1900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const seen = frames;
const finalH = seen[seen.length - 1].stageH;
// THE assertion: no frame may show the plan at a stale (pre-panel) height
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
out.noStaleFrame = bad.length === 0
? true
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms`;
const shown = seen.filter((f) => f.planVisible);
out.planShown = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
out.heightReacted = finalH !== seen[0].stageH;
wrap.remove();
return out;
}, shiftMs);
outB[`shift${shiftMs}_noStaleFrame`] = res.noStaleFrame;
outB[`shift${shiftMs}_planShown`] = res.planShown;
outB[`shift${shiftMs}_heightReacted`] = res.heightReacted;
check(`shift${shiftMs}_noStaleFrame`, res.noStaleFrame);
check(`shift${shiftMs}_planShown`, res.planShown);
check(`shift${shiftMs}_heightReacted`, res.heightReacted);
}
// ---------- C: shift AFTER the reveal glides instead of snapping ----------
const late = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(25);
out.revealed = c._booting === false;
await sleep(120); // clearly after the reveal, still inside the soft grace
const stage = sr().querySelector('.stage');
const h0 = stage.clientHeight;
spacer.style.height = '56px'; // VERY late panel — slower than the cap
window.dispatchEvent(new Event('resize'));
const heights = [];
await new Promise((done) => {
const t1 = performance.now();
const sample = () => {
heights.push(stage.clientHeight);
if (performance.now() - t1 < 700) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const hEnd = heights[heights.length - 1];
out.heightMoved = Math.abs(hEnd - h0) > 30;
// the glide: intermediate frames strictly between the endpoints
const mid = heights.filter((h) => Math.abs(h - h0) > 4 && Math.abs(h - hEnd) > 4);
out.lateShiftGlides = mid.length >= 3
? true
: `only ${mid.length} intermediate frame(s): ${h0} -> ${hEnd} snapped`;
out.settledAtFinal = Math.abs(hEnd - (h0 - 56)) <= 2;
wrap.remove();
return out;
});
check('late_revealed', late.revealed);
check('late_heightMoved', late.heightMoved);
check('late_lateShiftGlides', late.lateShiftGlides);
check('late_settledAtFinal', late.settledAtFinal);
await finish(browser, { ...rec, ...outB, ...late });
+293
View File
@@ -0,0 +1,293 @@
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
// day/night background vs static, per-space inheritance, cloud fading,
// memoisation across hass ticks, editors stay clean.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (azimuth, elevation) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth, elevation },
} } };
await upd();
};
const setWeather = async (state) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'weather.home': {
entity_id: 'weather.home', state, attributes: {},
} } };
await upd();
};
const cfg = () => c._serverCfg;
// DEV-B701-01: local mutations bump _cfgEpoch (what _saveConfig() does
// synchronously). The old helper bumped _cfgRev — a server ack that the
// production save path does NOT produce until the debounced write lands —
// and thereby masked the stale sun-ray cache.
const touchCfg = () => { c._cfgEpoch++; };
const litIds = () => (c._sunRaysCache?.rays || []).map((r) => r.openingId).sort();
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
const stageStyle = () => sr().querySelector('.stage').getAttribute('style') || '';
// ---- the test plan: windows on all four OUTER walls + one interior ----
const sp = cfg().spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 }, // top of r1
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 }, // right of r3
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 }, // bottom of r4
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }, // left of r1
{ id: 'wI', type: 'window', x: 0.55, y: 0.30, angle: 90, length: 0.08 }, // r1|r2 wall
{ id: 'dS', type: 'door', x: 0.20, y: 0.86, angle: 0, length: 0.1 }, // doors never glow
];
touchCfg();
// 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
touchCfg();
await setSun(90, 5);
out.silentWithoutNorth = domPolys().length === 0;
out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
// 2) compass set → morning east sun lights ONLY the east window
cfg().settings.north_deg = 0;
touchCfg();
await setSun(90, 5);
out.morningEast = JSON.stringify(litIds()) === '["wE"]';
out.morningDomDrawn = domPolys().length > 0;
// 3) noon south → only the south window, wedge shorter than the morning one
const eastLen = c._sunRaysCache?.rays?.[0]?.len ?? 0;
await setSun(180, 60);
out.noonSouth = JSON.stringify(litIds()) === '["wS"]';
out.noonShorter = (c._sunRaysCache?.rays?.[0]?.len ?? 1e9) < eastLen;
// 4) evening west → only the west window; wedge stays inside its room r1
await setSun(270, 4);
out.eveningWest = JSON.stringify(litIds()) === '["wW"]';
const ray = c._sunRaysCache?.rays?.[0] || { polys: [] };
out.wedgeClippedToRoom = ray.polys.length > 0 && ray.polys.every((poly) => poly.every(([x, y]) =>
x >= 40 - 0.5 && x <= 550 + 0.5 && y >= 140 - 0.5 && y <= 580 + 0.5));
// 5) night → nothing
await setSun(90, -10);
out.nightEmpty = domPolys().length === 0;
// 6) rotated compass: the same morning east sun now lights the NORTH window
cfg().settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
cfg().settings.north_deg = 0;
touchCfg();
// 7) memoisation: an unrelated hass tick must NOT recompute the wedges
await setSun(90, 5);
const cacheBefore = c._sunRaysCache;
c.hass = { ...c.hass, states: { ...c.hass.states } }; // plain tick, sun unchanged
await upd();
out.memoSurvivesTick = c._sunRaysCache === cacheBefore;
await setSun(91, 5);
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
// 8) day/night background: daynight breathes, static does not
await setSun(180, 60);
out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
cfg().settings.bg_mode = 'daynight';
touchCfg();
await setSun(180, 60);
out.daynightNoonBg = stageStyle().includes('background:#ffffff');
out.daynightClass = !!sr().querySelector('.stage.daynight');
await setSun(180, -20);
out.daynightNightBg = stageStyle().includes('background:#070c14');
out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
cfg().settings.bg_mode = 'static';
touchCfg();
await setSun(180, -20);
out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
delete cfg().settings.bg_mode;
touchCfg();
// 9) per-space inheritance: the space override wins over the global flag
sp.settings = { ...(sp.settings || {}), sun_rays: false };
touchCfg();
await setSun(90, 5);
out.spaceOverridesOff = domPolys().length === 0;
delete sp.settings.sun_rays;
// and a per-space compass override redirects the light
sp.settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
delete sp.settings.north_deg;
touchCfg();
// 10) clouds: rain kills the wedges, cloudy only fades them
cfg().settings.weather_entity = 'weather.home';
touchCfg();
await setWeather('pouring');
await setSun(90, 5);
out.rainKillsRays = domPolys().length === 0;
await setWeather('cloudy');
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.18 * 0.4) < 1e-6;
await setWeather('sunny');
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.clearFullAlpha = Math.abs(stopClear - 0.18) < 1e-6;
delete cfg().settings.weather_entity;
touchCfg();
// 11) feature off: without the flag nothing is drawn even with sun+north
delete cfg().settings.sun_rays;
touchCfg();
await setSun(90, 5);
out.offDrawsNothing = domPolys().length === 0;
cfg().settings.sun_rays = true;
cfg().settings.bg_mode = 'daynight';
touchCfg();
// 12) editors stay clean: no wedges, no day/night canvas
await setSun(90, 5);
c._setMode('plan');
await upd();
out.editorNoRays = domPolys().length === 0;
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
c._setMode('view');
await upd();
out.viewRaysBack = domPolys().length > 0;
return out;
});
// 13) the general-settings dialog: a REAL drag on the compass dial sets degrees
await page.evaluate(async () => {
const c = window.__card;
c._openSettingsDialog();
c.requestUpdate();
await c.updateComplete;
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
const box = await page.evaluate(() => {
const r = window.__card.shadowRoot.querySelector('.compass').getBoundingClientRect();
return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
});
await page.mouse.move(box.cx + 45, box.cy); // on the ring, due right = 90°
await page.mouse.down();
await page.mouse.move(box.cx, box.cy + 45); // drag on to due down = 180°
await page.mouse.up();
const dlg = await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot;
const out = {
compassDragSets180: c._settingsDialog?.northDeg === 180,
dialogHasModeSelect: !!sr.querySelector('.dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('.dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
};
c._settingsDialog = null;
return out;
});
Object.assign(res, dlg);
// 14) DEV-B701-01 regression: the PRODUCTION local-save path (no touchCfg, no
// fake rev/epoch bump from the test) must move the wedge. A REAL pointer drag
// of the east window ends in _opPointerUp -> _saveConfig(); the WS write is
// debounced 500 ms, so at check time _cfgRev is still the boot revision — the
// ray must follow the window anyway, and again after the late server ack.
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svgEl = stage.querySelector('svg');
const [vx, vy, vw, vh] = svgEl.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const rayInfo = () => page.evaluate(() => {
const c = window.__card;
const rays = c._sunRaysCache?.rays || [];
const r = rays.find((q) => q.openingId === 'wE');
return {
ids: rays.map((q) => q.openingId).sort(),
midY: r ? (r.a[1] + r.b[1]) / 2 : NaN,
minX: r ? Math.min(...r.polys.flat().map((p) => p[0])) : NaN,
rev: c._cfgRev,
wEy: c._serverCfg.spaces.find((s) => s.id === 'f1').openings.find((o) => o.id === 'wE').y,
};
});
// baseline: morning east sun, only wE glows, window sits at y = 600
await page.evaluate(async () => {
const c = window.__card;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 90, elevation: 5 },
} } };
c.requestUpdate(); await c.updateComplete;
});
const base = await rayInfo();
check('b701_baseline_east_only', base.ids, ['wE']);
check('b701_baseline_mid_600', Math.abs(base.midY - 600) < 1, true);
// the real drag: enter the opening editor and pull wE down the east wall
await page.evaluate(async () => {
const c = window.__card;
c._setMode('plan'); c._tool = 'opening';
c.requestUpdate(); await c.updateComplete;
});
await settle();
const [dx, dy] = await screenPt(960, 600);
const [, dty] = await screenPt(960, 700);
await page.mouse.move(dx, dy);
await page.mouse.down();
await page.mouse.move(dx, dty, { steps: 6 });
await page.mouse.up();
await settle();
// leave the editor IMMEDIATELY — well inside the 500 ms debounce window
const after = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view');
c.requestUpdate(); await c.updateComplete;
return true;
});
check('b701_left_editor', after);
const moved = await rayInfo();
check('b701_window_really_moved', Math.abs(moved.wEy * 1000 - 700) < 15, true);
check('b701_rev_still_boot', moved.rev, base.rev); // the write has NOT landed yet
check('b701_ray_follows_window', Math.abs(moved.midY - moved.wEy * 1000) < 1, true);
check('b701_ray_not_stale', Math.abs(moved.midY - 600) > 50, true);
// same defect class for ROOM geometry: r3 shrinks to a thin strip along its
// east wall through the real mutation endpoint (_saveConfig, still no ack).
// The wedge reaches ~188 units into the room (minX ~772), so the new west
// boundary at x = 900 MUST cut it — a stale clip keeps points near x = 772.
const clipped = await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.rooms.find((r) => r.id === 'r3').poly = [[0.90, 0.46], [0.96, 0.46], [0.96, 0.86], [0.90, 0.86]];
c._saveConfig(); // the one true local-save entry point
c.requestUpdate(); await c.updateComplete;
return true;
});
check('b701_room_mutated', clipped);
const reclipped = await rayInfo();
check('b701_room_rev_still_boot', reclipped.rev, base.rev);
check('b701_room_reclips_ray', reclipped.minX >= 900 - 1, true);
// let the debounced write land: the ray must survive the late server ack
await page.waitForTimeout(800);
const acked = await page.evaluate(async () => {
const c = window.__card;
c.requestUpdate(); await c.updateComplete;
return true;
});
check('b701_acked', acked);
const final = await rayInfo();
check('b701_write_landed', final.rev !== base.rev, true);
check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true);
await finish(browser, checkAll(res));
+2 -2
View File
@@ -32,7 +32,7 @@ checkAll(res, {
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
"dialogTempFields": 3,
"dialogHiddenWhenNone": 1,
"dialogTempFields": 4, // масштаб + мин/макс + компас пространства (docs/SUN.md)
"dialogHiddenWhenNone": 2, // мин/макс скрыты; масштаб и компас остаются
});
await finish(browser, res);
File diff suppressed because one or more lines are too long
+527 -305
View File
File diff suppressed because one or more lines are too long
+66
View File
@@ -1,5 +1,71 @@
# Changelog
## v1.56.0 — 2026-08-03
Minor release: the sun comes to the floor plan — a compass, a day/night
backdrop and real sunlight through the windows — plus room temperature
from climate devices, a design-token pass over the UI, live rulers for
opening drags, and a sun-cache fix.
- **The sun on your floor plan** (docs/SUN.md). Tell the plan where
north is — a compass dial in the general settings, with a per-space
override — and the card starts living by Home Assistant's own
`sun.sun`:
- *Day/night backdrop* (`bg_mode: daynight`): the scene behind the
plan follows the sun's elevation — white at full day, warm gold
through the golden hour, cooling dusk, deep night. The plan itself
dims only ~10% at night, so the rooms stay readable.
- *Light through the windows* (`sun_rays`): every window on an
exterior wall casts a soft wedge of light into its room — clipped
by the room's own contour, warmer near the horizon, stretching
long at sunrise and sunset, short at noon, gone after dark.
- *Clouds, if you want them* (`weather_entity`): point at a weather
entity and an overcast sky mutes the wedges; rain or snow puts
them out. A dead sensor never kills the sun.
- The plan now sits on an *opaque sheet of paper* traced along the
room contours: the scene background — `bg_color` or the daynight
sky — is visible only around the walls and never bleeds through
the rooms; in day/night mode the sheet casts a soft shadow.
- The whole feature is silent until the compass is set; without
`sun.sun` the settings dialog explains why.
- **Room temperature from climate devices.** A new checkbox in the
device dialog puts the AC's or thermostat's `current_temperature` on
the badge next to its icon and into the room's average temperature.
- **UI modernization.** A design-token pass over `styles.ts` — 209
hard-coded values unified into spacing/radius/font/shadow scales —
and native `ha-switch` / `ha-slider` controls in the dialogs, with a
hard fallback to the old plain inputs where HA components are
unavailable.
- **Live rulers for opening drags.** Dragging a door or window now
shows the live distances to both ends of its wall, measured along
the owning room's edge; a dashed guide and a soft magnet snap the
opening to the wall's center (hold Shift to disable the magnet).
- **Fix DEV-B701-01** — the sun-ray cache is now invalidated by local
geometry edits too, so a wedge can no longer go stale after you move
a wall locally before the server confirms the change.
## v1.55.3 — 2026-08-02
Patch release: two fixes for the v1.55.2 first-open veil, found by an
adversarial lifecycle audit (AUD-1552-01/02).
- **A dashboard rebuild during the first open can no longer leave the
plan hidden forever** (AUD-1552-01). Disconnecting the card while the
boot veil was up (Lovelace recreates its DOM, a view switch remounts
the card) killed the settle timers but kept their ids, so nothing ever
lifted the veil again. The veil lifecycle now restarts from every
reconnect — with a fresh clock and an unconditional hard cap — and the
fade-out also survives a mid-fade remount.
- **The veil no longer opens early, right before a late panel lands**
(AUD-1552-02). Two equal height reads at 200/400 ms used to reveal the
plan at ~400 ms, so Home Assistant chrome arriving at 450+ ms jumped
on a visible plan — exactly what the veil was meant to prevent. The
veil now holds for a full protective window with trailing quiescence
(height changes near the cap extend the wait), and for a short grace
after the reveal any later shift glides via a height transition
instead of snapping. Deliberate height changes (entering an editor)
still apply instantly.
## v1.55.2 — 2026-08-02
Patch release: a calmer first open — no zoom flash and no layout jumps
+67
View File
@@ -6,6 +6,73 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.56.0 — 2026-08-03
Минорный релиз: на план приходит солнце — компас, фон «день/ночь» и
настоящий свет из окон — плюс температура с климатических устройств,
дизайн-токены в UI, живые линейки при перетаскивании проёмов и фикс
кэша солнечных лучей.
- **Солнце на плане дома** (docs/SUN.md). Скажите плану, где север —
круговой компас в общих настройках с переопределением в каждом
пространстве — и карточка начинает жить по `sun.sun` самого
Home Assistant:
- *Фон «день/ночь»* (`bg_mode: daynight`): сцена за планом следует
за высотой солнца — белый ясный день, тёплое золото золотого
часа, остывающие сумерки, глубокая ночь. Сам план ночью
приглушается лишь на ~10%, комнаты остаются читаемыми.
- *Свет из окон* (`sun_rays`): каждое окно на внешней стене пускает
в свою комнату мягкий клин света — обрезанный контуром комнаты,
теплее у горизонта, длинный на рассвете и закате, короткий в
полдень и гаснущий ночью.
- *Облачность — по желанию* (`weather_entity`): укажите
weather-сущность, и пасмурное небо приглушит клинья, а дождь или
снег погасит совсем. Мёртвый датчик солнце не выключает.
- План теперь лежит на *непрозрачном листе бумаги*, повторяющем
контуры комнат: фон сцены — `bg_color` или небо «дня/ночи» —
виден только вокруг стен и не просвечивает сквозь комнаты; в
режиме «день/ночь» у листа мягкая тень.
- Пока компас не задан, фича полностью молчит; без `sun.sun`
диалог настроек объясняет, почему.
- **Температура с климатических устройств.** Новый чекбокс в диалоге
устройства выводит `current_temperature` кондиционера или
термостата на плашку у значка и учитывает её в средней температуре
комнаты.
- **Модернизация UI.** Токен-проход по `styles.ts` — 209 «зашитых»
значений сведены в шкалы отступов/радиусов/шрифтов/теней — и
родные `ha-switch` / `ha-slider` в диалогах с жёстким фолбэком на
старые инпуты там, где компоненты HA недоступны.
- **Живые линейки проёмов.** При перетаскивании двери или окна теперь
видны живые расстояния до обоих концов стены — по ребру своей
комнаты; пунктирная направляющая и мягкий магнит центрируют проём
на стене (Shift отключает магнит).
- **Фикс DEV-B701-01** — кэш солнечных лучей теперь сбрасывается и
локальными правками геометрии: клин больше не «застревает», если
подвинуть стену до подтверждения сервера.
## v1.55.3 — 2026-08-02
Патч-релиз: два исправления вуали первого открытия из v1.55.2,
найденные adversarial-аудитом жизненного цикла (AUD-1552-01/02).
- **Пересборка дашборда во время первого открытия больше не оставляет
план скрытым навсегда** (AUD-1552-01). Отключение карточки при
поднятой вуали (Lovelace пересоздаёт DOM, переключение вью
перемонтирует карточку) убивало таймеры стабилизации, но сохраняло их
id — и вуаль уже никогда не снималась. Теперь жизненный цикл вуали
перезапускается при каждом повторном подключении — с новым отсчётом и
безусловным жёстким таймаутом; затухание тоже переживает размонтирование
посреди fade.
- **Вуаль больше не открывается рано, прямо перед поздней панелью**
(AUD-1552-02). Два одинаковых замера высоты на 200/400 мс открывали
план уже на ~400 мс, и хром Home Assistant, пришедший на 450+ мс,
прыгал на видимом плане — ровно то, от чего вуаль должна защищать.
Теперь вуаль держится полное защитное окно с trailing quiescence
(изменения высоты у края окна продлевают ожидание), а короткое время
после открытия поздний сдвиг применяется плавно (transition высоты),
а не скачком. Намеренные изменения высоты (вход в редактор)
по-прежнему применяются мгновенно.
## v1.55.2 — 2026-08-02
Патч-релиз: спокойное первое открытие — без вспышки зума и прыжков
+3 -3
View File
@@ -11,16 +11,16 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-08-01)
## Snapshot (2026-08-03)
| Item | State |
|---|---|
| Version | **v1.55.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.56.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance. Minor release: the Sun feature (docs/SUN.md — north compass, day/night backdrop, window light wedges, optional weather cloud cover, opaque room-contour paper), opt-in room temperature from climate devices, design-token pass over styles.ts + native ha-switch/ha-slider, opening-drag shoulder rulers with a center magnet, and the DEV-B701-01 sun-ray cache fix |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (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 | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| 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), deployed **v1.55.2** via direct copy (HACS custom repo also installed) |
| 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), deployed **v1.56.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
+167
View File
@@ -0,0 +1,167 @@
# Sun on the plan — the spec (source of truth)
Status: approved by the owner 2026-08-03. Shipped in v1.56.0.
Scope decisions final: the compass lives in the GENERAL settings with a
per-space override, the feature is silent until `north_deg` is set
anywhere, wedges ship for the FULL card only in v1, and mutual shading
of the building's wings is explicitly NOT computed.
## Principle
The plan learns where north is, and from that single number plus HA's
own `sun.sun` the card knows where the sun stands relative to every
wall. Two visuals follow: the stage background can breathe with the
day (day → golden hour → dusk → night), and windows on exterior walls
cast soft wedges of light into their rooms. Everything is display
only — no entities are created, no services are called.
## Data
- Source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north,
clockwise; `attributes.elevation` in degrees, negative below the
horizon). No `sun.sun` in the install → the whole feature stays
silent and the settings dialog says why.
- Sun attributes update rarely (~30–120 s). Sun geometry is recomputed
ONLY when (azimuth, elevation) or the config change — never on every
`hass` tick. The wedge layer memoises on
`(azimuth, elevation, config rev, space id, weather state)`.
- Angle on the plan: `plan_angle = azimuth − north_deg` (normalised to
0–360). With `north_deg = 0` the top of the canvas is north; the
direction TOWARD the sun on the canvas is
`(sin(plan_angle), −cos(plan_angle))` (y grows downward).
## Compass — `settings.north_deg`
- Integer 0–359, degrees clockwise from "up on the canvas" to true
north. Lives in the GENERAL settings (⚙) as a circular dial: drag
the «N» arrow around the ring, 1° steps, 15° with Shift held; a
plain number input sits next to it for accessibility and precision.
- Per-space override in the space settings (empty = inherit), the same
pattern as `show_lqi` / `fill_mode`.
- While `north_deg` is null at BOTH levels the whole sun feature is
inert: static background, no wedges, nothing computed. The settings
dialogs show a hint.
- Backend validation: integer in 0–359 at both levels.
## Plan background — `settings.bg_mode: 'static' | 'daynight'`
- Global default in the general settings, per-space override (null =
inherit). Default `'static'`.
- `'static'` — the existing `bg_color` behaviour, color picker and
all. Nothing changes for existing installs.
- `'daynight'` — the stage background follows the sun's elevation:
WHITE at full day (the brightest moment of the day is white — owner,
2026-08-03), a warm bright shift in the golden hour (elevation below
~10°), cooling through dusk, deep darkening at night. The scale
(piecewise-linear between stops, `BG_STOPS` in `src/sun.ts`):
| elevation | color | phase |
| --- | --- | --- |
| −90°…−12° | `#070c14` | deep night |
| −4° | `#131a28` | dusk cools down |
| 0° | `#4a3527` | warm band right at the horizon |
| +10° | `#e8ddcf` | morning light — warm and bright |
| +30°…+90° | `#ffffff` | plain day, white |
The PLAN
itself dims only ~10% at night (`filter: brightness(.9)`), so the
daytime room fills stay readable. Transitions are a CSS
background/filter transition tens of seconds long;
`prefers-reduced-motion` gets the current colors statically.
- The UI is a two-option selector; the color picker shows only for
`'static'`.
- Backend validation: `In(['static', 'daynight'])` at both levels.
- `'daynight'` follows the general gate: without `north_deg` (or
without `sun.sun`) it behaves as `'static'`.
- **The scene background never bleeds through the plan** (owner,
2026-08-03). In BOTH modes the background — `bg_color` or the
daynight sky — is visible only AROUND the plan: opaque `.hp-paper`
shapes sit under everything the plan draws. An image plan papers the
backdrop image rect (the canvas IS the paper); a hand-drawn plan
papers the ROOM CONTOURS — one shape per room in exactly the room's
own geometry (fill only, no stroke), never their bounding box, so
the background reaches the exterior walls of an L-shaped house and
fills the gaps between detached buildings (an empty drawn space has
no paper). Open (virtual) boundaries do not affect the paper; a live
resize preview moves it together with the rooms. Its colour is the
pre-bg_color canvas — white for hand-drawn plans, the theme card
background under an image. The night dimming above is the
`brightness` filter on the zoomwrap ONLY; the paper's alpha never
changes. Applies to view/kiosk/editors and the static space-card
alike (smoke_bg_color).
## Window light wedges — `settings.sun_rays`
Boolean, global + per-space (null = inherit), default OFF.
For every opening of type «window» sitting on an EXTERIOR wall — a
wall stretch with no other room on its outer side, decided by probing
the existing room geometry just off both sides of the window; windows
on interior walls do not participate, and open (virtual) boundaries
never qualify because both sides are rooms — the card draws a wedge
when BOTH hold:
- the sun is above the horizon (`elevation > 0`), and
- the dot product of the wall's outward normal with the direction
toward the sun is positive (the sun actually faces this window).
The wedge is a quadrilateral cast from the window's span along the
direction AWAY from the sun (light falls inward), clipped by the
room's polygon (`polyclip` intersection, the same dependency
`src/resize.ts` already uses). Its length is `k(elevation)` in window
lengths: ~2.5 at sunrise/sunset tapering to ~0.8 at the zenith
(`0.8 + 1.7·(1 − elevation/90)^1.6`). A linear gradient runs bright at
the window and dissolves inward; the color is warm orange while
`elevation < 10°` and neutral by day; peak opacity is modest (~0.18 —
two overlapping wedges never exceed a readable ceiling). Near the
horizon the opacity ramps in over the first ~2° so wedges never pop.
Layer order: ABOVE room fills (and the glow layer), BELOW devices and
labels (those live in the HTML `devlayer` anyway). Night
(`elevation ≤ 0`) → no wedges. Wedges work under BOTH `bg_mode`s.
## Cloud cover — `settings.weather_entity` (optional)
String entity id or null; GLOBAL settings only. When set and the
entity's state reads overcast, the wedges fade by an opacity
multiplier — ~0.25 fully overcast, 0 (gone) in rain/snow:
| states | factor |
| --- | --- |
| clear, sunny, clear-night, windy, exceptional, unset entity | 1.0 |
| partlycloudy, windy-variant | 0.7 |
| cloudy | 0.4 |
| overcast, fog | 0.25 |
| rainy, pouring, snowy, snowy-rainy, hail, lightning, lightning-rainy | 0.0 |
| unknown, unavailable | 1.0 (a dead sensor must not kill the sun) |
Backend validation: string or null.
## Edge cases and limits
- No `sun.sun` → silent feature + a hint in the settings dialog.
- `north_deg` unset everywhere → silent feature + a hint.
- `prefers-reduced-motion` → no transitions; colors and wedges render
statically for the current sun position.
- Kiosk mode → works (same view path).
- Static `houseplan-space-card` → the background honours the effective
`bg_mode`/color; wedges are v1 FULL-CARD ONLY (documented limit).
- Editors (plan/devices/decor) → no wedges and no day/night: the
editor canvases render exactly as before.
- Mutual shading of the building's own wings (an L-shaped house
shadowing its inner corner) is NOT computed — a lit window casts its
wedge even when another wing geometrically blocks the sun. Accepted
v1 limit.
- Wedges of windows on all four wall orientations are unit-tested,
including the 359→0 azimuth wrap.
## Files
- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge
quads + clipping, cloud factor, settings inheritance); unit-tested
in `test/sun.test.mjs`.
- `src/houseplan-card.ts` — the memoised wedge layer, the day/night
stage background, both settings dialogs (compass dial included).
- `src/space-render.ts` — the static card's background only.
- `custom_components/houseplan/validation.py` — the four settings at
both levels; tests in `tests_backend/test_validation.py`.
- `demo/smoke_sun.mjs` — end-to-end behaviour against the demo rig.
+64
View File
@@ -120,6 +120,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
walls, click (any tool) opens its properties — with a 3 px drag threshold
since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings]
- [ ] Opening drag rulers (2026-08-03): while an opening is dragged, a measure
badge on EACH shoulder shows the along-the-wall distance from the wall end
to the nearest opening edge, live; the wall is ONE room's edge — the edge
the opening is snapped to — so a neighbouring room's collinear edge is
never merged in; at that edge's center (±half a grid step) a
perpendicular dashed tick appears and the center magnet-snaps — Shift
disables the magnet; badges and tick vanish on release
[auto: smoke_opening_measure + unit openingShoulders]
## Onboarding ★
@@ -866,3 +874,59 @@ require hands on real hardware — they remain for the human pass.
one release = one undo step (rooms AND openings)
- [ ] Device markers do not move; the room settings gear re-centres itself
- [ ] Smoke: `node demo/smoke_room_resize.mjs`
## Sun on the plan (docs/SUN.md)
- [ ] Everything below is INERT until `north_deg` is set (general ⚙ or the
space settings) AND the install has `sun.sun`; both dialogs hint at
whichever is missing
- [ ] The ⚙ compass: dragging the «N» arrow turns it in 1° steps (15° with
Shift); the number field mirrors the dial and accepts 0–359; «Clear»
returns the unset state
- [ ] «Plan background» selector: `static` keeps the existing color picker
and behaviour byte-for-byte; `daynight` hides the picker and the stage
follows the sun — neutral day, warm golden hour, dark night — with a
slow (tens of seconds) transition; the PLAN dims only ~10% at night
- [ ] Opaque plan paper (2026-08-03, owner): the scene background —
`bg_color` or the daynight sky — is visible ONLY around the plan and
NEVER bleeds through it, in view/kiosk/editors and on the static
space-card. An image plan papers the backdrop image rect
(`rect.hp-paper`). A hand-drawn plan papers the ROOM CONTOURS: one
`.hp-paper` shape per room in exactly the room's own geometry (fill
only, no stroke), so an L-shaped house or detached buildings never
grow a white bounding rectangle — the scene colour reaches the
exterior walls, shows in the L's pocket and between buildings, and an
empty drawn space has no paper at all. A live resize preview
(`_rszPreview`) moves the paper together with the dragged wall.
Colours: historical white for drawn plans, the theme card background
under an image. At night (`daynight`) the paper dims via the zoomwrap
`brightness` filter only — its alpha stays 1. Pixel-proofed against
an acid `#ff00ff` background [auto: smoke_bg_color]
- [ ] Per-space overrides (background mode, north, sun-in-windows) inherit
when empty, exactly like show_lqi/fill_mode
- [ ] «Sunlight through windows» (default OFF): wedges appear only from
windows on EXTERIOR walls facing the sun; interior windows, open
(virtual) boundaries and doors never light up
- [ ] Wedge direction follows the compass; length grows toward
sunrise/sunset and shrinks toward noon; every wedge is clipped by its
room polygon; night = no wedges at all
- [ ] Weather entity (optional, global): cloudy fades the wedges, rain/snow
removes them, a dead/unknown weather sensor changes nothing
- [ ] Sun geometry recomputes ONLY when the sun attributes or the config
change — an unrelated `hass` tick reuses the memo
- [ ] Editors (plan/devices/decor) render with NO wedges and NO day/night;
kiosk works; the static space-card honours the background mode
(wedges are full-card-only in v1)
- [ ] `prefers-reduced-motion` → no transitions, static colors
- [ ] Smoke: `node demo/smoke_sun.mjs`; units: `test/sun.test.mjs`;
backend: `tests_backend/test_validation.py` (sun settings)
## Climate temperature opt-in (dev)
- [ ] «Use the device's temperature sensor» (marker dialog, climate devices
only, default OFF): current_temperature shows as the standard `.tval`
badge and joins the room average like a thermometer; unavailable /
missing attribute = no badge, no vote; hidden devices keep voting
(registry-wide climate, like hidden thermometers); the tick survives
dialog recreation [auto: smoke_climate_temp; units: test/devices.test.mjs;
backend: tests_backend/test_validation.py (use_climate_temp)]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.55.2",
"version": "1.56.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.55.2",
"version": "1.56.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.55.2",
"version": "1.56.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+44 -4
View File
@@ -146,6 +146,23 @@ export function tempFor(hass: any, entIds: string[]): number | null {
return null;
}
/**
* Room temperature as a CLIMATE device reports it: the first climate.* entity
* with a finite attributes.current_temperature (owner's spec: several climate
* entities -> the first valid one wins). Unavailable/unknown entities and a
* missing attribute yield null - no badge, no vote in the room average.
*/
export function climateTempFor(hass: any, entIds: string[]): number | null {
for (const eid of entIds) {
if (!eid.startsWith('climate.')) continue;
const st = hass.states[eid];
if (!st || st.state === 'unavailable' || st.state === 'unknown') continue;
const v = parseFloat(st.attributes?.current_temperature);
if (Number.isFinite(v)) return Math.round(v * 10) / 10;
}
return null;
}
/** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */
export function isHumEntity(hass: any, eid: string): boolean {
if (hass.entities?.[eid]?.entity_category) return false;
@@ -508,10 +525,21 @@ export interface AreaClimate { temp: number | null; hum: number | null }
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
*/
export function areaClimateMap(
hass: any, rules?: CompiledIconRule[],
hass: any, rules?: CompiledIconRule[], markers?: Marker[] | null,
): Map<string, AreaClimate> {
const out = new Map<string, AreaClimate>();
if (!hass?.entities) return out;
// Opt-in climate sources (marker.use_climate_temp): an AC or thermostat
// knows the room temperature (current_temperature) - when the user ticks
// the option, that reading votes in the room average like any thermometer.
// The set holds binding refs (device ids and entity ids); with no opted
// markers the pass below is byte-for-byte the old one.
const climOpt = new Set<string>();
for (const m of markers || []) {
if (m?.use_climate_temp !== true) continue;
const i = (m.binding || '').indexOf(':');
if (i > 0) climOpt.add(m.binding.slice(i + 1));
}
// area -> device (or lone entity) -> the entities that belong to it
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
@@ -523,8 +551,15 @@ export function areaClimateMap(
// 90 C and a virtual better_thermostat duplicating the real sensor (field
// question, 2026-07-27). Three guards, cheapest first:
if (reg.entity_category) continue; // diagnostic/config readings
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
// An explicitly opted climate entity skips the platform/name filters:
// the tick is the user saying "this one DOES measure room air" - even on
// an excluded platform (better_thermostat) or a suspicious entity id.
const optClimate = climOpt.size > 0 && eid.startsWith('climate.')
&& (climOpt.has(eid) || (reg.device_id && climOpt.has(reg.device_id)));
if (!optClimate) {
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
}
let groups = byArea.get(area);
if (!groups) { groups = new Map(); byArea.set(area, groups); }
const key = reg.device_id || eid;
@@ -543,13 +578,18 @@ export function areaClimateMap(
for (const [area, groups] of byArea) {
const temps: number[] = [];
const hums: number[] = [];
for (const g of groups.values()) {
for (const [key, g] of groups) {
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
if (air) {
const t = tempFor(hass, g.ents);
if (t != null) temps.push(t);
}
// opted climate device/entity: current_temperature joins the average
if (climOpt.size > 0 && (climOpt.has(key) || g.ents.some((e) => climOpt.has(e)))) {
const t = climateTempFor(hass, g.ents);
if (t != null) temps.push(t);
}
if (air || icon === 'mdi:water-percent') {
const h = humFor(hass, g.ents);
if (h != null) hums.push(h);
+548 -83
View File
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -328,6 +328,8 @@
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
"room.settings_short": "Room settings",
"room.unnamed": "Unnamed room",
"marker.use_climate_temp": "Use the device's temperature sensor",
"marker.use_climate_temp_tip": "Air conditioners and thermostats know the room temperature (current_temperature). Shows it as a badge next to the icon and counts it in the room average — like a regular thermometer.",
"marker.is_light": "This device is a light source",
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
"confirm.unlock": "Unlock “{name}”?",
@@ -384,7 +386,28 @@
"gs.bg_color": "Background around the plan",
"gs.bg_default": "Theme default",
"gs.bg_theme": "theme default",
"gs.bg_mode": "Plan background",
"gs.bg_static": "Static color",
"gs.bg_daynight": "Follows the sun (day/night)",
"gs.bg_daynight_hint": "The stage follows the sun: neutral day, warm golden hour, deep night. Needs the compass below.",
"gs.sun_group": "Sun",
"gs.sun_missing": "The sun.sun entity was not found — the sun features stay off.",
"gs.north": "North on the plan",
"gs.north_ph": "not set",
"gs.north_hint": "Point the arrow at north (1° steps, 15° with Shift) or type the degrees — until then the sun features stay off.",
"gs.north_clear": "Clear",
"gs.north_letter": "N",
"gs.sun_rays": "Sunlight through windows",
"gs.weather": "Weather entity",
"gs.weather_ph": "weather.home (optional)",
"space.bg_color": "Background around the plan",
"space.bg_inherit": "Inherit general",
"space.bg_inherited": "inherits general settings"
"space.bg_inherited": "inherits general settings",
"space.bg_mode": "Plan background",
"space.north": "North on the plan (override)",
"space.north_inherited": "inherited: {v}",
"space.sun_rays": "Sunlight through windows",
"space.sun_inherit": "Inherit general",
"space.sun_on": "On",
"space.sun_off": "Off"
}
+24 -1
View File
@@ -328,6 +328,8 @@
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
"room.settings_short": "Настройки комнаты",
"room.unnamed": "Комната без имени",
"marker.use_climate_temp": "Использовать датчик температуры устройства",
"marker.use_climate_temp_tip": "Кондиционеры и термостаты знают температуру комнаты (current_temperature). Показывает её плашкой у значка и учитывает в средней температуре комнаты — как обычный термометр.",
"marker.is_light": "Это устройство — источник света",
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
"confirm.unlock": "Открыть замок «{name}»?",
@@ -384,7 +386,28 @@
"gs.bg_color": "Цвет фона вокруг плана",
"gs.bg_default": "Как в теме",
"gs.bg_theme": "по умолчанию — из темы",
"gs.bg_mode": "Фон плана",
"gs.bg_static": "Статичный цвет",
"gs.bg_daynight": "Следует за солнцем (день/ночь)",
"gs.bg_daynight_hint": "Фон следует за солнцем: нейтральный день, тёплый золотой час, глубокая ночь. Нужен компас ниже.",
"gs.sun_group": "Солнце",
"gs.sun_missing": "Сущность sun.sun не найдена — солнечные функции выключены.",
"gs.north": "Север на плане",
"gs.north_ph": "не задан",
"gs.north_hint": "Направьте стрелку на север (шаг 1°, с Shift 15°) или введите градусы — до этого солнечные функции молчат.",
"gs.north_clear": "Сбросить",
"gs.north_letter": "С",
"gs.sun_rays": "Солнце в окнах",
"gs.weather": "Сущность погоды",
"gs.weather_ph": "weather.home (необязательно)",
"space.bg_color": "Цвет фона вокруг плана",
"space.bg_inherit": "Наследовать общий",
"space.bg_inherited": "наследуется из общих настроек"
"space.bg_inherited": "наследуется из общих настроек",
"space.bg_mode": "Фон плана",
"space.north": "Север на плане (переопределение)",
"space.north_inherited": "наследуется: {v}",
"space.sun_rays": "Солнце в окнах",
"space.sun_inherit": "Наследовать общий",
"space.sun_on": "Вкл",
"space.sun_off": "Выкл"
}
+94
View File
@@ -62,6 +62,30 @@ export function roomPoly(r: any): number[][] | null {
return null;
}
/**
* Paper shapes for a hand-drawn plan (owner 2026-08-03): the opaque paper
* follows the ROOM CONTOURS, not their bounding box — an L-shaped house or a
* detached building must not grow a white rectangle around itself. One shape
* per room, in EXACTLY the geometry the room itself renders (same polygon
* points string, same rounded rect), so the paper never peeks past a wall;
* the union of the stack is the paper. Islands simply paint over their
* parent; open (virtual) boundaries do not affect the paper at all.
*/
export function paperRoomShapes(rooms: any[]): Array<
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
for (const r of rooms || []) {
if (r?.poly?.length >= 3) {
out.push({ poly: r.poly.map((p: number[]) => p.join(',')).join(' ') });
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
}
}
return out;
}
export function roomEdges(rooms: any[]): number[][] {
const out: number[][] = [];
const seen = new Set<string>();
@@ -115,6 +139,76 @@ export function snapToWall(
return best;
}
export interface OpeningShoulders {
/** Endpoints of the wall edge — the ONE room-polygon edge the opening sits on. */
wallA: number[];
wallB: number[];
/** Distance (render units) from each wall end to the NEAREST opening edge, >= 0. */
sideA: number;
sideB: number;
/** Midpoint of each shoulder — where a measure badge sits. */
midA: number[];
midB: number[];
/** Center of the wall edge. */
wallCenter: number[];
/** The opening's center coincides with the wall center (within tol). */
centered: boolean;
}
/**
* Shoulders of an opening being dragged along a wall: distances from the
* opening's edges to the ends of the wall it sits on, plus the "centered on the
* wall" flag (owner 2026-08-03: live ruler badges + a perpendicular dashed tick
* when the opening is exactly in the middle). The wall is ONE room-polygon
* edge — exactly the edge the opening is snapped to (owner 2026-08-03:
* «only the wall of one room», collinear edges of a NEIGHBOURING room are
* never merged in). Selection mirrors snapToWall: among the edges collinear
* with the opening's line, the one nearest to its center wins, first in
* roomEdges order on a tie — so the ruler always measures the same edge the
* drag snapped to. Works for angled walls too: everything is measured along
* the wall's direction. Returns null when the center does not lie on any wall.
*/
export function openingShoulders(
center: number[], angleDeg: number, lengthPx: number, rooms: any[], tol: number, eps = 1.0,
): OpeningShoulders | null {
const rad = (angleDeg * Math.PI) / 180;
const dir = [Math.cos(rad), Math.sin(rad)];
// edges lying on the opening's wall line → pick the ONE nearest to the
// center along that line (t=0 at the center). Same tie-break as snapToWall:
// strictly-nearer wins, so on a tie (shared-boundary fragments, a junction
// vertex) the first edge in roomEdges order is used — the very edge the
// drag snapped to. NO merging of collinear runs across rooms.
let wall: [number, number] | null = null;
let bestD = eps;
for (const e of roomEdges(rooms)) {
const pts = [[e[0], e[1]], [e[2], e[3]]];
const off = (p: number[]) => Math.abs(dir[0] * (p[1] - center[1]) - dir[1] * (p[0] - center[0]));
if (off(pts[0]) > eps || off(pts[1]) > eps) continue; // not collinear with the wall line
const t0 = (pts[0][0] - center[0]) * dir[0] + (pts[0][1] - center[1]) * dir[1];
const t1 = (pts[1][0] - center[0]) * dir[0] + (pts[1][1] - center[1]) * dir[1];
const lo = Math.min(t0, t1), hi = Math.max(t0, t1);
const d = lo > 0 ? lo : hi < 0 ? -hi : 0; // distance from the center to the edge, along the line
if (d < bestD) { bestD = d; wall = [lo, hi]; }
}
if (!wall) return null; // center is not on any edge of this direction
const [tMin, tMax] = wall;
const half = lengthPx / 2;
const sideA = Math.max(0, -half - tMin);
const sideB = Math.max(0, tMax - half);
const at = (t: number) => [center[0] + dir[0] * t, center[1] + dir[1] * t];
const tMid = (tMin + tMax) / 2;
return {
wallA: at(tMin),
wallB: at(tMax),
sideA,
sideB,
midA: at((tMin - half) / 2),
midB: at((half + tMax) / 2),
wallCenter: at(tMid),
centered: Math.abs(tMid) <= tol,
};
}
/**
* How far open an opening is drawn, 0..1, from its contact sensor state.
* No sensor bound → doors default to open (the familiar swing symbol), windows to
+9
View File
@@ -205,6 +205,15 @@ class HouseplanSpaceCard extends LitElement {
height: 100%;
display: block;
}
/* Opaque plan paper — the scene bg_color/daynight sky shows only AROUND
the plan (owner 2026-08-03). The static card keeps its historical
canvas colour: the theme card background. Drawn plans paper the room
contours (per-room shapes, fill only, no stroke) — same contract as
the full card. */
.hp-paper {
fill: var(--ha-card-background, var(--card-background-color, #111));
stroke: none;
}
.hp-static-stage .devlayer {
position: absolute;
inset: 0;
+29 -3
View File
@@ -8,9 +8,10 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf, paperRoomShapes } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
@@ -146,12 +147,37 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: [];
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
// the static card paints the same background around the plan as the full one
const stageBg = stageBgOf(o.cfg?.settings, disp);
// The static card paints the same background around the plan as the full
// one. In 'daynight' mode (docs/SUN.md) the sun's elevation paints it —
// wedges stay full-card-only in v1, the background alone follows the sky.
const spaceSettings = (o.cfg.spaces.find((sp: any) => sp.id === o.spaceId) as any)?.settings || {};
let sunBg = '';
if (bgModeOf(o.cfg?.settings, spaceSettings) === 'daynight' && northDegOf(o.cfg?.settings, spaceSettings) !== null) {
const sun = sunStateOf(o.hass);
if (sun) sunBg = dayPhase(sun.elevation).bg;
}
const stageBg = sunBg || stageBgOf(o.cfg?.settings, disp);
// Opaque plan paper (owner 2026-08-03), same contract as the full card: the
// scene background never bleeds through the plan. With a backdrop image the
// paper is the image rect; a hand-drawn plan papers the ROOM CONTOURS
// (paperRoomShapes) — never their bounding box, so the scene colour reaches
// the exterior walls of an L-shaped house and fills the gaps between
// detached buildings. An empty drawn space has no paper at all.
const paper = space.bg
? { x: space.bg.x, y: space.bg.y, w: space.bg.w, h: space.bg.h }
: null;
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${paper
? svg`<rect class="hp-paper" x="${paper.x}" y="${paper.y}" width="${paper.w}" height="${paper.h}"></rect>`
: paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
: nothing}
+334 -196
View File
File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
/**
* Sun on the plan — pure logic only (docs/SUN.md).
*
* Angles, the day phase palette, exterior-wall detection, window light
* wedges and their clipping, the cloud factor and the settings
* inheritance. Coordinates are render units (NORM_W-scaled canvas,
* y grows DOWNWARD), same as the card's space model. Nothing here
* touches Lit, the DOM or `hass` beyond a plain state object.
*/
import { intersection } from 'polyclip-ts';
import { pointInPolygon, lerpColor } from './logic';
// ---------------- angles ----------------
/** Normalise any angle in degrees to [0, 360). */
export function norm360(deg: number): number {
const d = deg % 360;
return d < 0 ? d + 360 : d;
}
/**
* The sun's bearing on the CANVAS: 0 = up, clockwise (docs/SUN.md).
* `north_deg` is how far true north is rotated clockwise from "canvas up".
*/
export function planSunAngle(azimuth: number, northDeg: number): number {
return norm360(azimuth - northDeg);
}
/** Unit vector TOWARD the sun on the canvas (x right, y down). */
export function sunDirOnPlan(azimuth: number, northDeg: number): [number, number] {
const a = (planSunAngle(azimuth, northDeg) * Math.PI) / 180;
return [Math.sin(a), -Math.cos(a)];
}
// ---------------- day phase (bg_mode: 'daynight') ----------------
export interface DayPhase {
/** Stage background color for the current elevation. */
bg: string;
/** How much the PLAN itself dims (0..0.1 — readability first). */
planDim: number;
/** 1 = golden hour / horizon, 0 = plain daylight. Drives wedge color. */
warmth: number;
}
/** elevation° → color stops; piecewise-linear between neighbours. */
const BG_STOPS: [number, string][] = [
[-90, '#070c14'], // deep night
[-12, '#070c14'],
[-4, '#131a28'], // dusk cools down
[0, '#4a3527'], // warm band right at the horizon
[10, '#e8ddcf'], // morning light — warm and bright on the way to white
[30, '#ffffff'], // plain day: the brightest moment is white (owner 2026-08-03)
[90, '#ffffff'],
];
const clamp01 = (t: number) => Math.min(1, Math.max(0, t));
/** Background, plan dim and warmth for a sun elevation (docs/SUN.md). */
export function dayPhase(elevation: number): DayPhase {
const e = Math.min(90, Math.max(-90, Number(elevation) || 0));
let bg = BG_STOPS[BG_STOPS.length - 1][1];
for (let i = 1; i < BG_STOPS.length; i++) {
const [e0, c0] = BG_STOPS[i - 1];
const [e1, c1] = BG_STOPS[i];
if (e <= e1) {
bg = lerpColor(c0, c1, (e - e0) / (e1 - e0));
break;
}
}
return {
bg,
// full 10% below ~-6°, gone above +10° — a slow dusk, not a switch
planDim: clamp01((10 - e) / 16) * 0.1,
warmth: e < 0 ? 1 : clamp01(1 - e / 10),
};
}
// ---------------- exterior walls & windows ----------------
export interface SunRoom { id: string; poly: number[][] }
/** A window opening in render units: centre, wall angle°, full length. */
export interface SunWindow { id: string; x: number; y: number; angle: number; length: number }
/**
* Is the wall stretch at `mid` with outward normal `n` exterior — i.e. is
* there NO room just outside it? Probes one point `probe` units out.
*/
export function isExteriorWall(mid: number[], n: number[], rooms: SunRoom[], probe = 6): boolean {
const p = [mid[0] + n[0] * probe, mid[1] + n[1] * probe];
return !rooms.some((r) => r.poly.length >= 3 && pointInPolygon(p, r.poly));
}
/**
* The wall a window sits on: probe both sides of the window centre. Exactly
* one side inside a room → exterior wall; the outward normal points to the
* empty side and the room on the other side hosts the wedge. Both sides in
* rooms (interior walls, open/virtual boundaries) or neither (a window not
* on any boundary) → null: this window never casts light (docs/SUN.md).
*/
export function windowWallInfo(
win: { x: number; y: number; angle: number },
rooms: SunRoom[],
probe = 6,
): { normal: [number, number]; roomId: string } | null {
const rad = (win.angle * Math.PI) / 180;
// perpendicular to the wall (the wall runs along `angle`)
const n: [number, number] = [Math.sin(rad), -Math.cos(rad)];
const roomAt = (side: 1 | -1): SunRoom | null => {
const p = [win.x + n[0] * probe * side, win.y + n[1] * probe * side];
return rooms.find((r) => r.poly.length >= 3 && pointInPolygon(p, r.poly)) || null;
};
const plus = roomAt(1);
const minus = roomAt(-1);
if (plus && minus) return null; // interior wall (incl. open boundaries)
if (!plus && !minus) return null; // not on any room's wall
return plus
? { normal: [-n[0], -n[1]], roomId: plus.id! }
: { normal: n, roomId: minus!.id };
}
/** Does the sun actually shine INTO this window right now? (A grazing sun
* exactly along the wall does not count — hence the epsilon, which also
* swallows the sin/cos float dust of the right-angle directions.) */
export function windowLit(normal: number[], sunDir: number[], elevation: number): boolean {
return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > 1e-9;
}
// ---------------- wedge geometry ----------------
/**
* Wedge length in WINDOW LENGTHS: longest (~2.5) at sunrise/sunset, shortest
* (~0.8) at the zenith. `0.8 + 1.7·(1 − e/90)^1.6` — long low shafts, short
* noon pools, smooth in between (docs/SUN.md).
*/
export function rayLength(elevation: number): number {
const e = Math.min(90, Math.max(0, elevation));
return 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
}
/** The unclipped wedge: window span a-b extruded by `len` along `dir`. */
export function rayQuad(a: number[], b: number[], dir: number[], len: number): number[][] {
return [
[a[0], a[1]],
[b[0], b[1]],
[b[0] + dir[0] * len, b[1] + dir[1] * len],
[a[0] + dir[0] * len, a[1] + dir[1] * len],
];
}
/** Clip a wedge by the room outline. Returns outer rings (may be several). */
export function clipToRoom(quad: number[][], room: number[][]): number[][][] {
try {
const res = intersection(
[[...quad.map((p) => [p[0], p[1]]), [quad[0][0], quad[0][1]]]] as any,
[[...room.map((p) => [p[0], p[1]]), [room[0][0], room[0][1]]]] as any,
);
const out: number[][][] = [];
for (const poly of res as any) {
const ring = poly?.[0];
if (!Array.isArray(ring) || ring.length < 4) continue;
out.push(ring.slice(0, ring.length - 1).map((p: number[]) => [p[0], p[1]]));
}
return out;
} catch {
return []; // a degenerate clip draws nothing rather than everything
}
}
export interface SunRay {
openingId: string;
roomId: string;
/** Clipped wedge outline(s), render units. */
polys: number[][][];
/** Window span endpoints (the bright end of the gradient). */
a: number[];
b: number[];
/** Direction the light travels (AWAY from the sun), unit vector. */
dir: [number, number];
/** Wedge reach in render units (the gradient's fade distance). */
len: number;
}
/**
* All wedges of a space for one sun position. Pure and deterministic — the
* card memoises the result on (azimuth, elevation, config rev) and reuses
* it across hass ticks (docs/SUN.md). Mutual shading of building wings is
* NOT considered (documented limit).
*/
export function computeSunRays(
rooms: SunRoom[],
windows: SunWindow[],
azimuth: number,
elevation: number,
northDeg: number,
): SunRay[] {
if (!(elevation > 0)) return [];
const toSun = sunDirOnPlan(azimuth, northDeg);
const away: [number, number] = [-toSun[0], -toSun[1]];
const k = rayLength(elevation);
const out: SunRay[] = [];
for (const w of windows) {
if (!(w.length > 0)) continue;
const info = windowWallInfo(w, rooms);
if (!info || !windowLit(info.normal, toSun, elevation)) continue;
const room = rooms.find((r) => r.id === info.roomId);
if (!room) continue;
const rad = (w.angle * Math.PI) / 180;
const hx = (Math.cos(rad) * w.length) / 2;
const hy = (Math.sin(rad) * w.length) / 2;
const a = [w.x - hx, w.y - hy];
const b = [w.x + hx, w.y + hy];
const len = k * w.length;
const polys = clipToRoom(rayQuad(a, b, away, len), room.poly);
if (!polys.length) continue;
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len });
}
return out;
}
// ---------------- wedge dressing ----------------
/** Peak wedge opacity; two overlapping wedges stay readable (docs/SUN.md). */
export const RAY_MAX_ALPHA = 0.18;
/** Wedge opacity: ramps in over the first ~2° so sunrise never pops. */
export function rayAlpha(elevation: number, cloud = 1): number {
if (!(elevation > 0)) return 0;
return RAY_MAX_ALPHA * Math.min(1, elevation / 2) * clamp01(cloud);
}
/** Wedge color: warm orange at the horizon → neutral daylight. */
export function rayColor(warmth: number): string {
return lerpColor('#ffe9c2', '#ff9a45', clamp01(warmth));
}
// ---------------- cloud cover ----------------
/** weather.* state → wedge opacity multiplier (docs/SUN.md table). */
const CLOUD_FACTORS: Record<string, number> = {
'clear': 1, 'sunny': 1, 'clear-night': 1, 'windy': 1, 'exceptional': 1,
'partlycloudy': 0.7, 'windy-variant': 0.7,
'cloudy': 0.4,
'overcast': 0.25, 'fog': 0.25,
'rainy': 0, 'pouring': 0, 'snowy': 0, 'snowy-rainy': 0,
'hail': 0, 'lightning': 0, 'lightning-rainy': 0,
};
/**
* Cloud multiplier for a weather entity state. Unset entity, unknown state
* or a dead sensor → 1: a broken weather sensor must not kill the sun.
*/
export function cloudFactor(state: string | null | undefined): number {
if (!state) return 1;
const f = CLOUD_FACTORS[String(state).toLowerCase()];
return f === undefined ? 1 : f;
}
// ---------------- settings inheritance (global → space) ----------------
const intDeg = (v: any): number | null =>
typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 359 ? v : null;
/** Effective compass: the space override wins, null = feature inert. */
export function northDegOf(settings: any, spaceSettings: any): number | null {
const sp = intDeg(spaceSettings?.north_deg);
if (sp !== null) return sp;
return intDeg(settings?.north_deg);
}
export type BgMode = 'static' | 'daynight';
/** Effective background mode; anything unknown falls back to 'static'. */
export function bgModeOf(settings: any, spaceSettings: any): BgMode {
const pick = (v: any): BgMode | null => (v === 'static' || v === 'daynight' ? v : null);
return pick(spaceSettings?.bg_mode) ?? pick(settings?.bg_mode) ?? 'static';
}
/** Effective «sun in the windows» flag; default OFF (docs/SUN.md). */
export function sunRaysOn(settings: any, spaceSettings: any): boolean {
const sp = spaceSettings?.sun_rays;
if (typeof sp === 'boolean') return sp;
return settings?.sun_rays === true;
}
/** The optional weather entity (GLOBAL settings only). */
export function weatherEntityOf(settings: any): string | null {
const v = settings?.weather_entity;
return typeof v === 'string' && v.trim() ? v.trim() : null;
}
/** Read sun.sun out of a hass-like object; null when absent/garbage. */
export function sunStateOf(hass: any): { azimuth: number; elevation: number } | null {
const attrs = hass?.states?.['sun.sun']?.attributes;
const az = Number(attrs?.azimuth);
const el = Number(attrs?.elevation);
return Number.isFinite(az) && Number.isFinite(el) ? { azimuth: az, elevation: el } : null;
}
+7
View File
@@ -81,6 +81,13 @@ export interface Marker {
* null/undefined = auto: any light.* entity of the device.
*/
is_light?: boolean | null;
/**
* Climate devices (AC, thermostat) know the room temperature
* (attributes.current_temperature). Opt-in per marker: show it as a badge
* next to the icon and include it in the room average. null/absent = off —
* nothing changes without an explicit tick (owner's spec, 2026-08-03).
*/
use_climate_temp?: boolean | null;
}
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
+60 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, climateTempFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -469,6 +469,65 @@ test('areaClimateMap: one registry pass for all areas (review R2-3)', () => {
assert.equal(scans, 120, 'wrapper считает по одной зоне — им нельзя пользоваться в рендере');
});
test('climateTempFor: первая climate-сущность с валидной current_temperature', () => {
const h = {
states: {
'climate.dead': { state: 'unavailable', attributes: {} },
'climate.mute': { state: 'heat', attributes: {} }, // атрибута нет — мимо
'climate.ac': { state: 'cool', attributes: { current_temperature: 23.46 } },
'climate.second': { state: 'heat', attributes: { current_temperature: 30 } },
'sensor.t': { state: '21', attributes: { device_class: 'temperature' } },
},
};
// несколько climate — берётся ПЕРВАЯ с валидным значением, с округлением до 0.1
assert.equal(climateTempFor(h, ['climate.dead', 'climate.mute', 'climate.ac', 'climate.second']), 23.5);
// не-climate сущности не участвуют вовсе
assert.equal(climateTempFor(h, ['sensor.t']), null);
// unavailable/unknown/без атрибута — null: ни плашки, ни голоса в средней
assert.equal(climateTempFor(h, ['climate.dead']), null);
assert.equal(climateTempFor(h, ['climate.mute']), null);
});
test('areaClimateMap: климат с опцией участвует в средней (термометр 20 + кондиционер 23.5)', () => {
const hass = {
devices: {
dt: { id: 'dt', name: 'Датчик температуры', area_id: 'living' },
dac: { id: 'dac', name: 'Кондиционер', model: 'AC-12', area_id: 'living' },
},
entities: {
'sensor.t': { device_id: 'dt', platform: 'mqtt' },
'climate.ac': { device_id: 'dac', platform: 'demo' },
},
states: {
'sensor.t': { state: '20', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'climate.ac': { state: 'cool', attributes: { current_temperature: 23.5 } },
},
};
// без опции — как раньше: только термометр
assert.equal(areaClimateMap(hass).get('living').temp, 20);
assert.equal(areaClimateMap(hass, undefined, [{ id: 'm1', binding: 'device:dac' }]).get('living').temp, 20);
// опция включена: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
const markers = [{ id: 'm1', binding: 'device:dac', use_climate_temp: true }];
assert.equal(areaClimateMap(hass, undefined, markers).get('living').temp, 21.8);
// entity-привязка работает так же
const entMarkers = [{ id: 'm1', binding: 'entity:climate.ac', use_climate_temp: true }];
assert.equal(areaClimateMap(hass, undefined, entMarkers).get('living').temp, 21.8);
// unavailable климат не ломает и не участвует
const broken = { ...hass, states: { ...hass.states, 'climate.ac': { state: 'unavailable', attributes: {} } } };
assert.equal(areaClimateMap(broken, undefined, markers).get('living').temp, 20);
});
test('areaClimateMap: комната только с климат-источником появляется в карте', () => {
const hass = {
devices: { dac: { id: 'dac', name: 'Кондиционер', area_id: 'bed' } },
entities: { 'climate.bed': { device_id: 'dac', platform: 'demo' } },
states: { 'climate.bed': { state: 'heat', attributes: { current_temperature: 24.2 } } },
};
assert.equal(areaClimateMap(hass).get('bed'), undefined);
const markers = [{ id: 'm1', binding: 'device:dac', use_climate_temp: true }];
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('bed'), { temp: 24.2, hum: null });
});
test('areaClimateMap: температура и влажность живут в одной записи', () => {
const hass = {
devices: { q: { id: 'q', name: 'Qingping Air Monitor', area_id: 'hall' } },
+129 -2
View File
@@ -14,8 +14,8 @@ import {
roomFillModeOf,
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
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 } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
@@ -959,3 +959,130 @@ test('tap action run: explicit-only, with runnable targets (owner spec 2026-07-2
// locks and alarms stay forbidden, run or not
assert.equal(resolveTapAction('toggle', undefined, 'lock'), 'info');
});
test('paperRoomShapes: paper follows room contours, one shape per room (owner 2026-08-03)', () => {
const L = [[100, 100], [600, 100], [600, 350], [350, 350], [350, 600], [100, 600]];
const rooms = [
{ id: 'L', poly: L }, // L-shaped: polygon, verbatim
{ id: 'q', x: 650, y: 650, w: 200, h: 100 }, // legacy rect: same rounded rect the room draws
{ id: 'bad' }, // no geometry -> no paper
];
const shapes = paperRoomShapes(rooms);
assert.equal(shapes.length, 2, 'one shape per room WITH geometry');
// the polygon uses exactly the room's own points string — same coordinates,
// so the paper can never peek past a wall
assert.deepEqual(shapes[0], { poly: L.map((p) => p.join(',')).join(' ') });
// the rect mirrors the room shape incl. its corner rounding rx
assert.deepEqual(shapes[1], { rect: { x: 650, y: 650, w: 200, h: 100, rx: 100 * 0.03 } });
// degenerate inputs never throw and produce no paper
assert.deepEqual(paperRoomShapes([]), []);
assert.deepEqual(paperRoomShapes(null), []);
assert.deepEqual(paperRoomShapes([{ poly: [[0, 0], [1, 1]] }]), [], 'a 2-point poly is not a surface');
});
// ---------------- opening drag: shoulder rulers + "centered on the wall" ----------------
// (owner 2026-08-03) render units; demo-like geometry ×1000
test('openingShoulders: distances from the opening edges to the wall ends', () => {
const rooms = [{ id: 'r1', poly: [[40, 140], [550, 140], [550, 580], [40, 580]] }];
// 80-long opening centered at x=200 on the top wall (40..550)
const sh = openingShoulders([200, 140], 0, 80, rooms, 2);
assert.ok(sh);
assert.deepEqual(sh.wallA, [40, 140]);
assert.deepEqual(sh.wallB, [550, 140]);
assert.equal(sh.sideA, 120, 'left: 160 - 40');
assert.equal(sh.sideB, 310, 'right: 550 - 240');
// badges sit on the middle of each shoulder
assert.deepEqual(sh.midA, [100, 140]);
assert.deepEqual(sh.midB, [395, 140]);
assert.deepEqual(sh.wallCenter, [295, 140]);
assert.equal(sh.centered, false);
// at the wall end one shoulder collapses to zero, never negative
const end = openingShoulders([80, 140], 0, 80, rooms, 2);
assert.equal(end.sideA, 0);
assert.equal(end.sideB, 430);
// off every wall -> null
assert.equal(openingShoulders([300, 300], 0, 80, rooms, 2), null);
});
test('openingShoulders: centered flag obeys the tolerance', () => {
const rooms = [{ id: 'r1', poly: [[40, 140], [550, 140], [550, 580], [40, 580]] }];
const mid = openingShoulders([295, 140], 0, 80, rooms, 2);
assert.equal(mid.centered, true);
assert.equal(mid.sideA, mid.sideB);
assert.equal(mid.sideA, 215, '(510 - 80) / 2');
assert.equal(openingShoulders([296.5, 140], 0, 80, rooms, 2).centered, true, 'inside tol');
assert.equal(openingShoulders([298, 140], 0, 80, rooms, 2).centered, false, 'outside tol');
});
test('openingShoulders: only the OWN room edge counts, no merging with a neighbour', () => {
// two rooms side by side: the top edges 40..550 (r1) and 550..960 (r2) are
// collinear and touch at x=550, but they belong to DIFFERENT rooms — the
// ruler measures ONLY the edge the opening sits on (owner 2026-08-03:
// «only the wall of one room»)
const rooms = [
{ id: 'r1', poly: [[40, 140], [550, 140], [550, 580], [40, 580]] },
{ id: 'r2', poly: [[550, 140], [960, 140], [960, 580], [550, 580]] },
];
const sh = openingShoulders([200, 140], 0, 80, rooms, 2);
assert.deepEqual(sh.wallA, [40, 140]);
assert.deepEqual(sh.wallB, [550, 140], 'stops at the own corner, never runs into r2');
assert.equal(sh.sideB, 550 - 240);
// the middle of r1's OWN edge: (40+550)/2 = 295
assert.equal(openingShoulders([295, 140], 0, 80, rooms, 2).centered, true,
'centered on the own edge');
assert.equal(openingShoulders([500, 140], 0, 80, rooms, 2).centered, false,
'the center of the old merged run means nothing now');
// an opening on r2's fragment measures r2's edge
const far = openingShoulders([700, 140], 0, 80, rooms, 2);
assert.deepEqual(far.wallA, [550, 140]);
assert.deepEqual(far.wallB, [960, 140]);
assert.equal(openingShoulders([755, 140], 0, 80, rooms, 2).centered, true);
// the interior shared wall x=550 is ONE deduped edge shared by both rooms
const v = openingShoulders([550, 300], 90, 80, rooms, 2);
assert.deepEqual(v.wallA, [550, 140]);
assert.deepEqual(v.wallB, [550, 580]);
assert.equal(v.centered, false);
assert.equal(openingShoulders([550, 360], 90, 80, rooms, 2).centered, true);
});
test('openingShoulders: shared wall of two rooms uses the snapped room edge', () => {
// staggered fragments on the shared line x=550: r1's right edge 140..400,
// r2's left edge 300..580. The snap tie-break (nearest edge, first in
// roomEdges order on a tie — exactly what snapToWall picks during the
// drag) resolves to r1's edge, so shoulders and center come from 140..400,
// never from the merged 140..580 union
const rooms = [
{ id: 'r1', poly: [[40, 140], [550, 140], [550, 400], [40, 400]] },
{ id: 'r2', poly: [[550, 300], [960, 300], [960, 580], [550, 580]] },
];
const sh = openingShoulders([550, 350], 90, 80, rooms, 2);
assert.deepEqual(sh.wallA, [550, 140]);
assert.deepEqual(sh.wallB, [550, 400], "r1's own end, not r2's 580");
assert.equal(sh.sideA, 310 - 140);
assert.equal(sh.sideB, 400 - 390);
assert.deepEqual(sh.wallCenter, [550, 270]);
assert.equal(sh.centered, false);
// below r1's edge the same line belongs to r2 alone
const low = openingShoulders([550, 500], 90, 80, rooms, 2);
assert.deepEqual(low.wallA, [550, 300]);
assert.deepEqual(low.wallB, [550, 580]);
assert.equal(openingShoulders([550, 440], 90, 80, rooms, 2).centered, true,
'centered on r2 own edge (300+580)/2');
});
test('openingShoulders: angled wall measures along the wall direction', () => {
// 3-4-5 triangle: hypotenuse-ish edge (0,0)->(300,400), length 500, angle 53.13°
const rooms = [{ id: 't', poly: [[0, 0], [300, 400], [0, 400]] }];
const ang = (Math.atan2(400, 300) * 180) / Math.PI;
const mid = openingShoulders([150, 200], ang, 100, rooms, 2);
assert.ok(mid);
assert.ok(Math.abs(mid.sideA - 200) < 1e-9 && Math.abs(mid.sideB - 200) < 1e-9);
assert.equal(mid.centered, true);
// slide 30 units towards the far end (dir = [0.6, 0.8])
const off = openingShoulders([150 + 30 * 0.6, 200 + 30 * 0.8], ang, 100, rooms, 2);
assert.ok(Math.abs(off.sideA - 230) < 1e-9, 'near shoulder grows by 30');
assert.ok(Math.abs(off.sideB - 170) < 1e-9, 'far shoulder shrinks by 30');
assert.equal(off.centered, false);
assert.ok(Math.abs(off.wallCenter[0] - 150) < 1e-9 && Math.abs(off.wallCenter[1] - 200) < 1e-9);
});
+240
View File
@@ -0,0 +1,240 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
norm360, planSunAngle, sunDirOnPlan, dayPhase,
isExteriorWall, windowWallInfo, windowLit,
rayLength, rayQuad, clipToRoom, computeSunRays,
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
} from '../test-build/sun.js';
const near = (a, b, eps = 1e-9) => Math.abs(a - b) < eps;
// ---- the test house: two rooms sharing the x=500 wall, windows on all four
// outer walls plus one on the shared (interior) wall --------------------
const ROOMS = [
{ id: 'r1', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] },
{ id: 'r2', poly: [[500, 100], [800, 100], [800, 500], [500, 500]] },
];
const WIN = {
north: { id: 'wN', x: 300, y: 100, angle: 0, length: 60 },
south: { id: 'wS', x: 300, y: 500, angle: 0, length: 60 },
west: { id: 'wW', x: 100, y: 300, angle: 90, length: 60 },
east: { id: 'wE', x: 800, y: 300, angle: 90, length: 60 },
inner: { id: 'wI', x: 500, y: 300, angle: 90, length: 60 },
};
const ALL = Object.values(WIN);
test('planSunAngle: plain subtraction, wraps around the circle (359→0)', () => {
assert.equal(planSunAngle(180, 0), 180);
assert.equal(planSunAngle(0, 1), 359);
assert.equal(planSunAngle(359, 359), 0);
assert.equal(planSunAngle(10, 350), 20);
assert.equal(norm360(-90), 270);
assert.equal(norm360(720), 0);
});
test('sunDirOnPlan: compass points map to canvas vectors (y grows down)', () => {
const cases = [
[0, [0, -1]], // north = canvas up
[90, [1, 0]], // east = right
[180, [0, 1]], // south = down
[270, [-1, 0]], // west = left
];
for (const [az, [x, y]] of cases) {
const d = sunDirOnPlan(az, 0);
assert.ok(near(d[0], x, 1e-12) && near(d[1], y, 1e-12), `az ${az}`);
}
// rotating the compass rotates the whole sky: east sun, north_deg=90 → up
const d = sunDirOnPlan(90, 90);
assert.ok(near(d[0], 0, 1e-12) && near(d[1], -1, 1e-12));
});
test('dayPhase: night is dark and dim, noon is white, sunrise is warm', () => {
const night = dayPhase(-20);
const dawn = dayPhase(2);
const noon = dayPhase(60);
assert.equal(night.bg, '#070c14');
assert.equal(noon.bg, '#ffffff');
assert.notEqual(dawn.bg, night.bg);
assert.notEqual(dawn.bg, noon.bg);
assert.ok(near(night.planDim, 0.1));
assert.equal(noon.planDim, 0);
assert.ok(dawn.planDim > 0 && dawn.planDim < 0.1);
assert.equal(night.warmth, 1);
assert.equal(noon.warmth, 0);
assert.ok(near(dawn.warmth, 0.8));
// garbage elevation never throws and stays inside the palette
assert.ok(dayPhase(NaN).bg.startsWith('#'));
});
test('windowWallInfo: exterior windows on all four sides get outward normals', () => {
const n = windowWallInfo(WIN.north, ROOMS);
const s = windowWallInfo(WIN.south, ROOMS);
const w = windowWallInfo(WIN.west, ROOMS);
const e = windowWallInfo(WIN.east, ROOMS);
assert.deepEqual(n.roomId, 'r1');
assert.ok(near(n.normal[0], 0, 1e-12) && near(n.normal[1], -1, 1e-12));
assert.ok(near(s.normal[0], 0, 1e-12) && near(s.normal[1], 1, 1e-12));
assert.ok(near(w.normal[0], -1, 1e-12) && near(w.normal[1], 0, 1e-12));
assert.equal(w.roomId, 'r1');
assert.ok(near(e.normal[0], 1, 1e-12) && near(e.normal[1], 0, 1e-12));
assert.equal(e.roomId, 'r2');
});
test('windowWallInfo: interior and orphan windows never participate', () => {
assert.equal(windowWallInfo(WIN.inner, ROOMS), null); // shared wall
assert.equal(windowWallInfo({ x: 300, y: 300, angle: 0 }, ROOMS), null); // mid-room
assert.equal(windowWallInfo({ x: 950, y: 950, angle: 0 }, ROOMS), null); // nowhere
});
test('isExteriorWall probes the outer side', () => {
assert.ok(isExteriorWall([300, 100], [0, -1], ROOMS));
assert.ok(!isExteriorWall([500, 300], [1, 0], ROOMS)); // r2 is outside r1 here
});
test('windowLit: above the horizon AND facing the sun', () => {
const east = [1, 0];
assert.ok(windowLit(east, sunDirOnPlan(90, 0), 10));
assert.ok(!windowLit(east, sunDirOnPlan(270, 0), 10)); // sun behind the house
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), 0)); // sunset moment
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), -5)); // night
});
test('rayLength: longest at the horizon, shortest at noon, monotonic', () => {
assert.ok(near(rayLength(0), 2.5, 1e-9));
assert.ok(near(rayLength(90), 0.8, 1e-9));
assert.ok(rayLength(10) > rayLength(30));
assert.ok(rayLength(30) > rayLength(60));
assert.ok(near(rayLength(-5), 2.5, 1e-9)); // clamped
});
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
const quad = rayQuad([100, 270], [100, 330], [1, 0], 1000); // way past the wall
const clipped = clipToRoom(quad, ROOMS[0].poly);
assert.equal(clipped.length, 1);
for (const [x, y] of clipped[0]) {
assert.ok(x >= 100 - 1e-6 && x <= 500 + 1e-6, 'x inside the room');
assert.ok(y >= 100 - 1e-6 && y <= 500 + 1e-6, 'y inside the room');
}
assert.ok(clipped[0].some(([x]) => near(x, 500, 1e-6)), 'reaches the far wall, not past it');
// a wedge fully outside the room clips to nothing
assert.equal(clipToRoom(rayQuad([900, 900], [960, 900], [0, 1], 50), ROOMS[0].poly).length, 0);
});
test('computeSunRays: morning east sun lights ONLY the east window', () => {
const rays = computeSunRays(ROOMS, ALL, 90, 5, 0);
assert.deepEqual(rays.map((r) => r.openingId), ['wE']);
assert.equal(rays[0].roomId, 'r2');
// light travels AWAY from the sun: westward into the room
assert.ok(near(rays[0].dir[0], -1, 1e-12) && near(rays[0].dir[1], 0, 1e-12));
for (const [x, y] of rays[0].polys[0]) {
assert.ok(x >= 500 - 1e-6 && x <= 800 + 1e-6 && y >= 100 - 1e-6 && y <= 500 + 1e-6);
}
});
test('computeSunRays: noon south sun → south window, short wedge', () => {
const rays = computeSunRays(ROOMS, ALL, 180, 60, 0);
assert.deepEqual(rays.map((r) => r.openingId), ['wS']);
assert.ok(near(rays[0].len, rayLength(60) * 60, 1e-9));
assert.ok(rays[0].len < computeSunRays(ROOMS, ALL, 90, 5, 0)[0].len);
});
test('computeSunRays: evening west sun → west window', () => {
const rays = computeSunRays(ROOMS, ALL, 270, 4, 0);
assert.deepEqual(rays.map((r) => r.openingId), ['wW']);
});
test('computeSunRays: night → nothing at all', () => {
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, 0, 0), []);
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, -10, 0), []);
});
test('computeSunRays: rotating the compass swings the light to another window', () => {
// the same morning east sun, but the plan is rotated 90°: what the canvas
// shows as "up" is now east → the NORTH-drawn window faces the sun
const rays = computeSunRays(ROOMS, ALL, 90, 5, 90);
assert.deepEqual(rays.map((r) => r.openingId), ['wN']);
// and the interior window still never lights up whatever the compass says
for (const nd of [0, 45, 90, 180, 270]) {
for (const az of [0, 90, 180, 270]) {
assert.ok(!computeSunRays(ROOMS, ALL, az, 5, nd).some((r) => r.openingId === 'wI'));
}
}
});
test('rayAlpha: capped, ramps in near the horizon, scaled by clouds', () => {
assert.equal(rayAlpha(0), 0);
assert.equal(rayAlpha(-3), 0);
assert.ok(near(rayAlpha(1), RAY_MAX_ALPHA / 2));
assert.ok(near(rayAlpha(30), RAY_MAX_ALPHA));
assert.ok(near(rayAlpha(30, 0.25), RAY_MAX_ALPHA * 0.25));
assert.equal(rayAlpha(30, 0), 0);
});
test('rayColor: warm at the horizon, neutral by day', () => {
assert.equal(rayColor(1), '#ff9a45');
assert.equal(rayColor(0), '#ffe9c2');
assert.notEqual(rayColor(0.5), rayColor(0));
});
test('cloudFactor: the state map, garbage-safe', () => {
assert.equal(cloudFactor('sunny'), 1);
assert.equal(cloudFactor('clear-night'), 1);
assert.equal(cloudFactor('partlycloudy'), 0.7);
assert.equal(cloudFactor('cloudy'), 0.4);
assert.equal(cloudFactor('overcast'), 0.25);
assert.equal(cloudFactor('fog'), 0.25);
assert.equal(cloudFactor('rainy'), 0);
assert.equal(cloudFactor('pouring'), 0);
assert.equal(cloudFactor('snowy'), 0);
assert.equal(cloudFactor('lightning-rainy'), 0);
assert.equal(cloudFactor('unknown'), 1);
assert.equal(cloudFactor('unavailable'), 1);
assert.equal(cloudFactor(null), 1);
assert.equal(cloudFactor(undefined), 1);
assert.equal(cloudFactor('CLOUDY'), 0.4);
});
test('northDegOf: space override wins, strict int 0–359, null = inert', () => {
assert.equal(northDegOf({ north_deg: 90 }, {}), 90);
assert.equal(northDegOf({ north_deg: 90 }, { north_deg: 0 }), 0); // 0 is a value, not "unset"
assert.equal(northDegOf({}, { north_deg: 359 }), 359);
assert.equal(northDegOf({}, {}), null);
assert.equal(northDegOf(null, undefined), null);
for (const bad of [360, -1, 1.5, '90', true, NaN]) {
assert.equal(northDegOf({ north_deg: bad }, {}), null, String(bad));
}
// a garbage override falls back to the valid global
assert.equal(northDegOf({ north_deg: 45 }, { north_deg: 999 }), 45);
});
test('bgModeOf: inherit chain with a static fallback', () => {
assert.equal(bgModeOf({}, {}), 'static');
assert.equal(bgModeOf({ bg_mode: 'daynight' }, {}), 'daynight');
assert.equal(bgModeOf({ bg_mode: 'daynight' }, { bg_mode: 'static' }), 'static');
assert.equal(bgModeOf({}, { bg_mode: 'daynight' }), 'daynight');
assert.equal(bgModeOf({ bg_mode: 'disco' }, {}), 'static');
});
test('sunRaysOn: default OFF, per-space tri-state inherit', () => {
assert.equal(sunRaysOn({}, {}), false);
assert.equal(sunRaysOn({ sun_rays: true }, {}), true);
assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: false }), false);
assert.equal(sunRaysOn({}, { sun_rays: true }), true);
assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: null }), true); // null = inherit
assert.equal(sunRaysOn({ sun_rays: 'yes' }, {}), false);
});
test('weatherEntityOf / sunStateOf: strings and hass shapes, garbage-safe', () => {
assert.equal(weatherEntityOf({ weather_entity: 'weather.home' }), 'weather.home');
assert.equal(weatherEntityOf({ weather_entity: ' ' }), null);
assert.equal(weatherEntityOf({}), null);
assert.deepEqual(
sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 120.5, elevation: -3 } } } }),
{ azimuth: 120.5, elevation: -3 },
);
assert.equal(sunStateOf({ states: {} }), null);
assert.equal(sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 'x', elevation: 1 } } } }), null);
assert.equal(sunStateOf(null), null);
});
+41
View File
@@ -115,6 +115,15 @@ def test_marker_schema():
v.MARKER_SCHEMA({"binding": "virtual"})
def test_marker_use_climate_temp_is_bool_or_none():
# opt-in room-temperature from climate devices (owner's feature 2026-08-03)
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "use_climate_temp": True})
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "use_climate_temp": False})
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "use_climate_temp": None})
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "use_climate_temp": "yes"})
def test_config_schema_defaults_and_extra():
out = v.CONFIG_SCHEMA({"spaces": []})
assert out["markers"] == [] and out["settings"] == {}
@@ -182,6 +191,38 @@ def test_bg_color_setting():
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#1234567"}})
def test_sun_settings_global():
"""docs/SUN.md: north_deg strict int 0..359, bg_mode, sun_rays, weather_entity."""
v.CONFIG_SCHEMA({"spaces": [], "settings": {
"north_deg": 0, "bg_mode": "daynight", "sun_rays": True, "weather_entity": "weather.home",
}})
v.CONFIG_SCHEMA({"spaces": [], "settings": {"north_deg": 359, "bg_mode": "static"}})
v.CONFIG_SCHEMA({"spaces": [], "settings": {"weather_entity": None}})
for bad in (360, -1, 1.5, "90", True, None):
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"north_deg": bad}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_mode": "disco"}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"sun_rays": "yes"}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"weather_entity": {"e": 1}}})
def test_sun_settings_per_space():
"""The same three at the space level; None/absent = inherit; no weather here."""
ok = {"id": "f1", "title": "F", "view_box": [0, 0, 1, 1], "rooms": []}
v.SPACE_SCHEMA(dict(ok, settings={"north_deg": 90, "bg_mode": "daynight", "sun_rays": False}))
v.SPACE_SCHEMA(dict(ok, settings={"north_deg": None, "bg_mode": None, "sun_rays": None}))
for bad in (360, -1, 2.5, "0", True):
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(dict(ok, settings={"north_deg": bad}))
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(dict(ok, settings={"bg_mode": "auto"}))
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(dict(ok, settings={"sun_rays": 1}))
def test_space_temp_bounds():
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
ok = {
+1
View File
@@ -9,6 +9,7 @@
},
"include": [
"src/logic.ts", "src/vacuum.ts",
"src/sun.ts",
"src/resize.ts",
"src/rules.ts",
"src/devices.ts",