mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9be44dab1e | ||
|
|
ba88782ce1 | ||
|
|
a20b73621f | ||
|
|
b70153769a | ||
|
|
5e5c06f126 | ||
|
|
c4a80bcb9f | ||
|
|
9ccc3831d1 | ||
|
|
33a960031e | ||
|
|
3a6a819dec | ||
|
|
c024c6d75a | ||
|
|
a8bb145ffb | ||
|
|
1e0295a370 | ||
|
|
c74ce39eb3 | ||
|
|
ee0ee9a1d3 | ||
|
|
a8d40e4d99 | ||
|
|
63a8274623 |
@@ -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
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
|
||||
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
weather-сущности.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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, чистая шапка
|
||||
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
|
||||
+527
-305
File diff suppressed because one or more lines are too long
Vendored
+527
-305
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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)]
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+24
-1
@@ -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
@@ -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": "Выкл"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+298
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/logic.ts", "src/vacuum.ts",
|
||||
"src/sun.ts",
|
||||
"src/resize.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
|
||||
Reference in New Issue
Block a user