v1.56.0
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 7m51s
Validate / smoke (push) Failing after 9m38s

This commit is contained in:
Matysh
2026-08-03 13:51:04 +03:00
39 changed files with 4708 additions and 1209 deletions
+4
View File
@@ -35,6 +35,10 @@ right on your Lovelace dashboard.
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
of its own color that spills through doorways and open zone boundaries.
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
the day (white noon → golden hour → deep night), while windows on exterior
walls cast real wedges of sunlight into the rooms; optional cloud cover
from a weather entity.
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
comfort-range temperature fills, per-room signal heatmap.
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
+4
View File
@@ -33,6 +33,10 @@
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
weather-сущности.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.55.3"
VERSION = "1.56.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.55.3"
"version": "1.56.0"
}
+27
View File
@@ -168,6 +168,22 @@ ROOM_SCHEMA = vol.All(
),
_require_geometry,
)
def _north_deg(value):
"""Compass (docs/SUN.md): strict integer degrees, 0..359.
Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
int in Python — none of those is a compass reading a client stored.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise vol.Invalid("north_deg must be an integer in 0..359")
if not 0 <= value <= 359:
raise vol.Invalid("north_deg must be an integer in 0..359")
return value
_BG_MODE = vol.In(["static", "daynight"])
SPACE_DISPLAY_SCHEMA = vol.Schema(
{
vol.Optional("show_borders"): bool,
@@ -185,6 +201,10 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("label_lqi"): bool,
vol.Optional("label_light"): bool,
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
# sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
vol.Optional("north_deg"): vol.Any(None, _north_deg),
vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
vol.Optional("sun_rays"): vol.Any(None, bool),
},
extra=vol.ALLOW_EXTRA,
)
@@ -299,6 +319,8 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
# asserts every option the editor offers is accepted here (issue #3)
@@ -323,6 +345,11 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
+36
View File
@@ -0,0 +1,36 @@
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
];
sp.plan_url = ''; // hand-drawn plan: white paper — the risky case for a white day
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
c._cfgRev = (c._cfgRev || 0) + 1;
});
const shot = async (az, el, name) => {
await page.evaluate(async ([az, el]) => {
const c = window.__card;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth: az, elevation: el },
} } };
c.requestUpdate(); await c.updateComplete;
}, [az, el]);
await page.waitForTimeout(300);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
};
await shot(180, 60, 'dn_noon');
await shot(265, 3, 'dn_sunset');
await shot(0, -20, 'dn_night');
await browser.close();
console.log('shots done');
+52
View File
@@ -0,0 +1,52 @@
// Screenshots for docs/SUN.md: evening wedges over the glow fill + the compass
// dial in the general settings. Usage: node demo/shot_sun.mjs <outdir>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
// instant colors for the still shot: the day/night transition is tens of seconds
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
c._serverCfg.settings = {
...(c._serverCfg.settings || {}),
north_deg: 0, sun_rays: true, bg_mode: 'daynight',
};
c._cfgRev = (c._cfgRev || 0) + 1;
// a warm evening: the sun low in the west; the living-room light off so
// the glow pool does not swamp the west wedge
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 265, elevation: 3 },
},
} };
c.requestUpdate();
await c.updateComplete;
});
await page.waitForTimeout(400);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: outDir + '/sun_rays.png' });
await page.evaluate(async () => {
const c = window.__card;
c._openSettingsDialog();
c._settingsDialog = { ...c._settingsDialog, northDeg: 330 };
c.requestUpdate();
await c.updateComplete;
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
await page.waitForTimeout(200);
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
await browser.close();
console.log('shots written to ' + outDir);
+201
View File
@@ -141,4 +141,205 @@ const res = await page.evaluate(async () => {
return out;
});
checkAll(res);
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
// The scene bg_color must never bleed through the plan itself: opaque
// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
// The scene colour is visible ONLY around the paper; the 'daynight' night
// dims the paper via brightness only.
const paper = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
c._cfgRev = (c._cfgRev || 0) + 1;
await upd();
const m = c._spaceModel();
const p = sr().querySelector('.stage svg rect.hp-paper');
out.paperExists = !!p;
if (p) {
const a = (n) => Number(p.getAttribute(n));
out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
&& Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
const cs = getComputedStyle(p);
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
}
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
c._setMode('view');
await upd();
// daynight night: the paper dims via the brightness filter ONLY, never alpha
c._serverCfg.settings.bg_mode = 'daynight';
c._serverCfg.settings.north_deg = 0;
c._cfgRev++;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'below_horizon',
attributes: { azimuth: 180, elevation: -20 },
} } };
await upd();
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
const pn = sr().querySelector('.stage svg rect.hp-paper');
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
delete c._serverCfg.settings.bg_mode;
delete c._serverCfg.settings.north_deg;
c._cfgRev++;
await upd();
return out;
});
Object.assign(res, paper);
// ---- 12) drawn plan: the paper follows the ROOM CONTOURS ---------------
// Owner: «белая подложка должна быть по границам комнат» — an L-shaped house
// plus a detached building must NOT grow a white bounding rectangle. One
// paper shape per room, in exactly the room's own geometry; the acid scene
// colour shows in the L's pocket and between the buildings, right up to the
// exterior walls; inside the rooms it never shows. A live resize preview
// (_rszPreview) moves the paper together with the dragged wall.
const drawn = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
sp.plan_url = ''; // hand-drawn plan now
// binary-exact fractions -> exact render coords (0.125*1000 = 125 etc.)
sp.rooms = [
{ id: 'rL', name: 'L', poly: [[0.125, 0.125], [0.625, 0.125], [0.625, 0.375], [0.375, 0.375], [0.375, 0.625], [0.125, 0.625]] },
{ id: 'rQ', name: 'Q', x: 0.75, y: 0.75, w: 0.1875, h: 0.1875 },
];
sp.openings = [];
sp.decor = [];
c._devices = []; // no icons over the pixel probes below
c._cfgRev = (c._cfgRev || 0) + 1;
c._cfgEpoch++;
await upd();
const papers = () => [...sr().querySelectorAll('.stage.noplan svg .hp-paper')];
let ps = papers();
out.paperPerRoom = ps.length === sp.rooms.length;
out.paperShapesMatchRooms = ps.length === 2
&& ps[0].tagName.toLowerCase() === 'polygon'
&& ps[0].getAttribute('points') === sp.rooms[0].poly.map((p) => (p[0] * 1000) + ',' + (p[1] * 1000)).join(' ')
&& ps[1].tagName.toLowerCase() === 'rect';
out.drawnPaperWhite = ps.length > 0 && ps.every((p) => getComputedStyle(p).fill === 'rgb(255, 255, 255)');
out.paperNoStroke = ps.length > 0 && ps.every((p) => {
const cs = getComputedStyle(p);
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
});
// paper first, everything else on top of it (one .hp-paperg group wraps
// all paper shapes so the daynight drop shadow composites without seams)
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
&& !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
// must move WITH the room, not lag behind until the drop
const pv = JSON.parse(JSON.stringify(sp));
pv.rooms[0].poly = pv.rooms[0].poly.map(([x, y]) => [x === 0.625 ? 0.6875 : x, y]);
c._rszPreview = { space: c._space, sp: pv };
c._cfgEpoch++;
await upd();
out.previewMovesPaper = (papers()[0]?.getAttribute('points') || '').includes('687.5,125');
c._rszPreview = null;
c._cfgEpoch++;
await upd();
out.previewRestores = (papers()[0]?.getAttribute('points') || '').includes('625,125');
// screen coords for the pixel probes (svg user units -> viewport px)
const svgEl = sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => { const q = svgEl.createSVGPoint(); q.x = x; q.y = y; const r = q.matrixTransform(ctm); return [r.x, r.y]; };
out.probes = {
pocket: toScr(500, 500), // the L's notch — bg must show here
gap: toScr(687, 300), // between the two buildings
inside: [toScr(250, 250), toScr(500, 250), toScr(250, 500), toScr(843.75, 843.75)],
};
return out;
});
const { probes } = drawn;
delete drawn.probes;
Object.assign(res, drawn);
// pixel proof: the acid scene colour IS in the L's pocket and between the
// buildings (where the old bounding-box paper drew a white square), and is
// NOT inside any room
if (probes) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, probes }) => {
const img = new Image();
img.src = 'data:image/png;base64,' + shot;
await img.decode();
const cv = document.createElement('canvas');
cv.width = img.width;
cv.height = img.height;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const at = ([x, y]) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
return {
acidInPocket: acid(at(probes.pocket)),
acidBetweenBuildings: acid(at(probes.gap)),
noAcidInsideRooms: probes.inside.every((q) => !acid(at(q))),
};
}, { shot, probes });
Object.assign(res, px);
} else {
res.acidInPocket = false;
res.acidBetweenBuildings = false;
res.noAcidInsideRooms = false;
}
// ---- 13) the static space-card follows the same paper contract ----------
const stat = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
// NOTE: the config-store cache is module-level and already primed by the
// earlier static-card sections (with the PRE-§12 rooms), so a mocked
// callWS would never reach the card — inject the snapshot directly.
const mk = async (cfg) => {
const host = document.createElement('div');
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
el._snap = { config: cfg, rev: 1, layout: {} };
el._loadedOnce = true;
el.hass = c.hass;
host.appendChild(el);
const t0 = Date.now();
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000)
await new Promise((r) => setTimeout(r, 80));
await el.updateComplete;
return { el, host };
};
const base = JSON.parse(JSON.stringify(c._serverCfg));
base.spaces.find((s) => s.id === c._space).plan_url = '/assets/f1.svg';
base.settings = { ...(base.settings || {}), bg_color: '#ff00ff' };
const a = await mk(base);
const pa = a.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
out.staticPaperExists = !!pa;
out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
&& getComputedStyle(pa).fill !== 'none';
a.host.remove();
// drawn plan: per-room paper shapes (the L + the detached rect from §12)
const drawnCfg = JSON.parse(JSON.stringify(base));
const dsp = drawnCfg.spaces.find((s) => s.id === c._space);
dsp.plan_url = '';
const b = await mk(drawnCfg);
const pbs = [...b.el.renderRoot.querySelectorAll('.hp-static-stage svg .hp-paper')];
out.staticDrawnPaperPerRoom = pbs.length === dsp.rooms.length;
out.staticDrawnPaperShapes = pbs.length === 2
&& pbs[0].tagName.toLowerCase() === 'polygon'
&& pbs[1].tagName.toLowerCase() === 'rect';
b.host.remove();
return out;
});
Object.assign(res, stat);
checkAll({ ...paper, ...drawn, ...stat,
acidInPocket: res.acidInPocket,
acidBetweenBuildings: res.acidBetweenBuildings,
noAcidInsideRooms: res.noAcidInsideRooms });
await finish(browser, res);
+147
View File
@@ -0,0 +1,147 @@
// Опция «Использовать датчик температуры устройства» (marker.use_climate_temp):
// климат-устройства (кондиционеры, термостаты) знают температуру комнаты
// (attributes.current_temperature). По умолчанию ВЫКЛ — ни плашки, ни вклада
// в среднюю. Включена через диалог (реальный клик по чекбоксу): плашка .tval
// у значка + голос в средней комнаты (термометр 20 + климат 23.5 → 21.75,
// т.е. 21.8 по сетке карточки 0.1°). Unavailable-климат не ломает ничего.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// --- впрыскиваем климат-устройства в демо-hass ---------------------------
await page.evaluate(() => {
const c = window.__card;
const h = window.__mkHass();
const inject = (hass) => ({
...hass,
devices: {
...hass.devices,
d_ac: { id: 'd_ac', name: 'Air conditioner', model: 'AC-12', area_id: 'living_room', identifiers: [['demo', 'd_ac']], entry_type: null, via_device_id: null },
d_trv: { id: 'd_trv', name: 'Radiator valve', model: 'TRV-1', area_id: 'bedroom', identifiers: [['demo', 'd_trv']], entry_type: null, via_device_id: null },
},
entities: {
...hass.entities,
'climate.ac': { entity_id: 'climate.ac', device_id: 'd_ac', platform: 'demo' },
'climate.trv': { entity_id: 'climate.trv', device_id: 'd_trv', platform: 'demo' },
},
states: {
...hass.states,
// термометр гостиной — ровно 20, чтобы средняя с климатом была детерминированной
'sensor.living_temp': { ...hass.states['sensor.living_temp'], state: '20' },
'climate.ac': { entity_id: 'climate.ac', state: 'cool', attributes: { friendly_name: 'Air conditioner', current_temperature: 23.5, temperature: 24 } },
// мёртвый климат: state unavailable, атрибутов нет
'climate.trv': { entity_id: 'climate.trv', state: 'unavailable', attributes: { friendly_name: 'Radiator valve' } },
},
});
window.__injectClimate = () => { c.hass = inject(window.__mkHass()); };
window.__injectClimate();
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
});
await page.waitForFunction(() => window.__card._devices.some((d) => d.entities?.includes('climate.ac')));
// --- без опции: ни плашки, ни вклада в среднюю ---------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await c.updateComplete;
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
o.acBuilt = !!ac;
o.noBadgeByDefault = c._liveTemp(ac) === null;
o.avgWithoutOption = c._climate().get('living_room')?.temp; // === 20
return o;
}), { avgWithoutOption: 20 });
// --- диалог: чекбокс есть у климат-устройства, нет у обычного, клик мышью --
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// у обычного термометра чекбокса нет
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
c._openMarkerDialog(th); await c.updateComplete;
o.noCheckboxForThermometer = !sr().querySelector('.dialog .climrow');
c._markerDialog = null; await c.updateComplete;
// у кондиционера — есть и по умолчанию снят
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
c._openMarkerDialog(ac); await c.updateComplete;
const row = sr().querySelector('.dialog .climrow input');
o.checkboxForClimate = !!row;
o.uncheckedByDefault = !!row && !row.checked;
if (row) row.scrollIntoView({ block: 'center' });
return o;
}));
// реальный клик по чекбоксу — координатами, через весь стек событий
const box = await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const r = sr.querySelector('.dialog .climrow input')?.getBoundingClientRect();
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
});
check('checkboxClickable', !!box);
if (box) await page.mouse.click(box.x, box.y);
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await c.updateComplete;
o.clickTicksState = c._markerDialog?.useClimateTemp === true;
await c._saveMarker(); await c.updateComplete;
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:d_ac');
o.savedInMarker = m?.use_climate_temp === true;
return o;
}));
// --- с опцией: плашка «23.5°», средняя пересчиталась ----------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
await c.updateComplete;
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
o.liveTempFromClimate = c._liveTemp(ac); // === 23.5
const badges = [...sr().querySelectorAll('.dev .tval')].map((el) => el.textContent.trim());
o.badgeVisible = badges.includes('23.5°');
// средняя гостиной: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
o.avgWithOption = c._climate().get('living_room')?.temp;
// и на КАРТОЧКЕ КОМНАТЫ: включаем метрику температуры и читаем текст
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.settings = { ...(sp.settings || {}), label_temp: true, show_names: true };
c.requestUpdate(); await c.updateComplete;
const labels = [...sr().querySelectorAll('.roomlabel .rlm')].map((el) => el.textContent.trim());
o.roomCardShowsAvg = labels.some((t) => t.includes('21.8°'));
return o;
}), { liveTempFromClimate: 23.5, avgWithOption: 21.8 });
// --- unavailable-климат: опция включена, но ни плашки, ни вклада ----------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
c._serverCfg.markers = [...c._serverCfg.markers,
{ id: 'd_trv', binding: 'device:d_trv', use_climate_temp: true, hidden: false }];
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const trv = c._devices.find((d) => d.entities?.includes('climate.trv'));
o.unavailNoBadge = !!trv && c._liveTemp(trv) === null;
o.unavailNoVote = c._climate().get('bedroom')?.temp ?? null; // в спальне нет термометров
// а гостиная как была 21.8
o.avgStillCorrect = c._climate().get('living_room')?.temp;
return o;
}), { unavailNoVote: null, avgStillCorrect: 21.8 });
// --- чекбокс переживает пересоздание диалога ------------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
c._openMarkerDialog(ac); await c.updateComplete;
o.survivesReopen = c._markerDialog?.useClimateTemp === true;
o.checkboxChecked = sr().querySelector('.dialog .climrow input')?.checked === true;
c._markerDialog = null;
return o;
}));
checkAll(out, { avgWithoutOption: 20, liveTempFromClimate: 23.5, avgWithOption: 21.8, unavailNoVote: null, avgStillCorrect: 21.8 });
await finish(browser, out);
+3 -3
View File
@@ -4,7 +4,7 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
@@ -28,8 +28,8 @@ const res = await page.evaluate(async () => {
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 12, // 10 цветов + радиус свечения + фон вокруг плана
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background"],
"rows": 14, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun"],
"saved": {"c": "#ff00ff", "a": 0.5},
"lqiBefore": 7,
"lqiAfter": 0,
+86
View File
@@ -0,0 +1,86 @@
// Native HA controls in dialogs (ha-switch / ha-slider) + the fallback.
// The demo env has no HA frontend, so every OTHER smoke already runs the
// fallback branch (plain input[type=checkbox|range]). Here we check both:
// 1) fallback: without ha-* the dialogs render the classic inputs;
// 2) ha branch: after registering lightweight ha-switch/ha-slider stubs
// (checked/value + `change`/`input`, the real contract of the HA ones)
// a re-opened dialog renders them, values flow BOTH ways.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// --- 1) fallback branch: no ha-* registered -> classic inputs ----------
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
out.fallbackCheckbox = !!sr().querySelector('.dialog .srcrow input[type=checkbox]');
out.fallbackRange = !!sr().querySelector('.dialog input[type=range]');
out.fallbackNoHaSwitch = !sr().querySelector('.dialog ha-switch');
c._markerDialog = null; await c.updateComplete;
// --- 2) register stubs mimicking the HA contract -----------------------
customElements.define('ha-switch', class extends HTMLElement {
#on = false;
get checked() { return this.#on; }
set checked(v) { this.#on = !!v; }
get disabled() { return this.hasAttribute('disabled'); }
set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
});
customElements.define('ha-slider', class extends HTMLElement {
#v = 0;
get value() { return this.#v; }
set value(v) { this.#v = Number(v); }
set min(_) {} set max(_) {} set step(_) {}
});
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
const switches = [...sr().querySelectorAll('.dialog ha-switch')];
out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
out.haNoPlainCheckbox = !sr().querySelector('.dialog .srcrow input[type=checkbox]');
const sliders = [...sr().querySelectorAll('.dialog ha-slider')];
out.haSliderRendered = sliders.length >= 2; // size + angle
// --- 3) card -> control: state is pushed into the element --------------
c._markerDialog = { ...c._markerDialog, isLight: true, hideFromPlan: false };
await c.updateComplete;
out.downstreamChecked = [...sr().querySelectorAll('.dialog ha-switch')].some((el) => el.checked === true);
// --- 4) control -> card: change with .checked lands in the dialog ------
const before = c._markerDialog.hideFromPlan;
const sw = [...sr().querySelectorAll('.dialog ha-switch')].find((el) => el.checked === before);
sw.checked = !before;
sw.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.upstreamChecked = c._markerDialog.hideFromPlan === !before || c._markerDialog.isLight === !before
|| c._markerDialog.useClimateTemp === !before || c._markerDialog.tapConfirm === !before;
// --- 5) slider both ways ----------------------------------------------
const sizeBefore = c._markerDialog.size;
const sl = [...sr().querySelectorAll('.dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
out.downstreamValue = !!sl;
sl.value = 2;
sl.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
out.upstreamValue = c._markerDialog.size === 2;
// `change` (fired on release, HA-version dependent) reaches the same handler
sl.value = 2.5;
sl.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.upstreamValueChangeEvt = c._markerDialog.size === 2.5;
c._markerDialog = null; await c.updateComplete;
// --- 6) general settings: opacity sliders take the ha branch too -------
c._openSettingsDialog(); await c.updateComplete;
out.gsHaSliders = sr().querySelectorAll('.dialog ha-slider').length >= 9;
const gsl = sr().querySelector('.dialog ha-slider');
gsl.value = 55;
gsl.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
out.gsUpstream = Math.round((Object.values(c._settingsDialog.colors)[0].a) * 100) === 55;
c._settingsDialog = null; await c.updateComplete;
return out;
});
checkAll(res, {});
await finish(browser, res);
+172
View File
@@ -0,0 +1,172 @@
// Opening drag rulers (owner 2026-08-03): while a door/window is dragged along
// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
// along the wall), and when the opening's center hits the wall's center a
// perpendicular dashed tick appears and the center magnet-snaps (Shift opts
// out). The wall is ONE room's edge (owner: "only the wall of one room") —
// r2's collinear top edge never merges into r1's, so the ruler runs 40..550
// (center 295), not the old merged 40..960. Real pointer events; the numbers
// are checked against the geometry: cell = 5 cm, grid pitch = 1000/240, so
// cm = units * 1.2 and r1's top wall (510 u) is 6.12 m.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
// 80-unit door on r1's top wall (y = 0.14), off-center
sp.openings = [{ id: 'op1', type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08 }];
c._setMode('plan'); c._tool = 'opening';
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const badges = () => page.evaluate(() =>
[...window.__card.renderRoot.querySelectorAll('.measurelabel.opshoulder')].map((el) => el.textContent.trim()).sort());
const nums = async () => (await badges()).map((t) => parseFloat(t));
const tick = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opcentertick').length);
const near = (a, b, tol) => Math.abs(a - b) <= tol;
// ---------- drag to an off-center spot: two live badges, no tick ----------
const [sx, sy] = await screenPt(200, 140);
const [t1x] = await screenPt(460, 140);
await page.mouse.move(sx, sy);
await page.mouse.down();
await page.mouse.move(t1x, sy, { steps: 5 });
await settle();
// center 460 -> edges 420/500; shoulders measured on r1's OWN edge 40..550:
// 420-40=380 u (4.56 m) and 550-500=50 u (0.60 m) — the old merged build
// showed 960-500=460 u (5.52 m) on the right
{
const [a, b] = await nums();
check('two_badges_visible', (await badges()).length, 2);
check('badge_right_0_60', near(a, 0.6, 0.04));
check('badge_left_4_56', near(b, 4.56, 0.04));
// exact invariant, immune to pointer rounding: shoulders + opening = wall
check('badges_sum_to_wall', near(a + b, 6.12 - 0.96, 0.021));
}
check('no_tick_off_center', await tick(), 0);
// ---------- live update on the way ----------
const [t2x] = await screenPt(360, 140);
await page.mouse.move(t2x, sy, { steps: 3 });
await settle();
// center 360 -> shoulders 320-40=280 u (3.36 m) and 550-400=150 u (1.80 m)
{
const [a, b] = await nums();
check('badge_right_updates_1_80', near(a, 1.8, 0.04));
check('badge_left_updates_3_36', near(b, 3.36, 0.04));
}
// ---------- near the OWN edge center (40+550)/2 = 295: tick + magnet ----------
const [t3x] = await screenPt(293.7, 140);
await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('tick_at_center', await tick(), 1);
check('magnet_snaps_center', near(await page.evaluate(() =>
window.__card._curSpaceCfg.openings[0].x), 0.295, 1e-6));
// both shoulders equal after the snap: (510 - 80) / 2 = 215 u -> 2.58 m
check('badges_equal_at_center', await badges(), ['2.58 m', '2.58 m']);
// the tick is perpendicular to the wall (horizontal wall -> a vertical dash)
const geom = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opcentertick');
if (!el) return null;
const dashed = getComputedStyle(el).strokeDasharray;
return { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2'),
dashed: dashed !== 'none' && dashed !== '' };
});
check('tick_perpendicular_at_wall_center', !!geom
&& near(geom.x1, 295, 1e-6) && near(geom.x2, 295, 1e-6) && near(geom.y2 - geom.y1, 30, 1e-6));
check('tick_dashed', !!geom && geom.dashed);
// ---------- release: everything disappears, the snapped x is committed ----------
await page.mouse.up();
await settle();
check('badges_gone_after_drop', (await badges()).length, 0);
check('tick_gone_after_drop', await tick(), 0);
check('committed_x_center', near(await page.evaluate(() =>
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
// ---------- Shift disables the magnet (badges stay) ----------
// park the opening off-center first: a drag that returns to within 3 px of its
// own starting point is filtered by the tap threshold, so the Shift approach
// must start away from the center
const [cx0] = await screenPt(295, 140);
await page.mouse.move(cx0, sy);
await page.mouse.down();
await page.mouse.move(t2x, sy, { steps: 3 });
await page.mouse.up();
await settle();
await page.keyboard.down('Shift');
await page.mouse.down();
await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('no_tick_with_shift', await tick(), 0);
const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
check('no_magnet_with_shift', near(freeX, 0.2937, 0.004) && freeX !== 0.295);
check('badges_still_there_with_shift', (await badges()).length, 2);
await page.mouse.up();
await page.keyboard.up('Shift');
await settle();
check('badges_gone_final', (await badges()).length, 0);
// ---------- opening on a SHARED wall of two rooms: the own room's edge ----------
// staggered fragments on the boundary x=550: r1's right edge 140..460 (its
// room is made the short one), r2's left edge 140..580. The snap resolves to
// r1's edge (nearest, first in roomEdges order on the tie), so the shoulders
// and the center come from 140..460 — the old build measured the merged
// 140..580 union
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.rooms = [
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.46], [0.04, 0.46]] },
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.58], [0.55, 0.58]] },
];
sp.openings = [{ id: 'op1', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await settle();
const [vx0, vy0] = await screenPt(550, 300);
const [, vy1] = await screenPt(550, 360);
await page.mouse.move(vx0, vy0);
await page.mouse.down();
await page.mouse.move(vx0, vy1, { steps: 3 });
await settle();
// center 360 -> edges 320/400; r1's edge 140..460: 320-140=180 u (2.16 m) and
// 460-400=60 u (0.72 m); the merged build showed 580-400=180 u (2.16 m) below
{
const [a, b] = await nums();
check('shared_wall_two_badges', (await badges()).length, 2);
check('shared_wall_bottom_0_72', near(a, 0.72, 0.04));
check('shared_wall_top_2_16', near(b, 2.16, 0.04));
}
check('shared_wall_no_tick_off_center', await tick(), 0);
await page.mouse.up();
await settle();
// approach the OWN edge center (140+460)/2 = 300 — NOT the merged 360
const [, vy2] = await screenPt(550, 301.3);
await page.mouse.move(vx0, vy1);
await page.mouse.down();
await page.mouse.move(vx0, vy2, { steps: 3 });
await settle();
check('shared_wall_tick_at_own_center', await tick(), 1);
check('shared_wall_magnet_y_0_3', near(await page.evaluate(() =>
window.__card._curSpaceCfg.openings[0].y), 0.3, 1e-6));
// equal shoulders on the own edge: (320 - 80) / 2 = 120 u -> 1.44 m
check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
await page.mouse.up();
await settle();
await finish(browser, { done: true });
+293
View File
@@ -0,0 +1,293 @@
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
// day/night background vs static, per-space inheritance, cloud fading,
// memoisation across hass ticks, editors stay clean.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (azimuth, elevation) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth, elevation },
} } };
await upd();
};
const setWeather = async (state) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'weather.home': {
entity_id: 'weather.home', state, attributes: {},
} } };
await upd();
};
const cfg = () => c._serverCfg;
// DEV-B701-01: local mutations bump _cfgEpoch (what _saveConfig() does
// synchronously). The old helper bumped _cfgRev — a server ack that the
// production save path does NOT produce until the debounced write lands —
// and thereby masked the stale sun-ray cache.
const touchCfg = () => { c._cfgEpoch++; };
const litIds = () => (c._sunRaysCache?.rays || []).map((r) => r.openingId).sort();
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
const stageStyle = () => sr().querySelector('.stage').getAttribute('style') || '';
// ---- the test plan: windows on all four OUTER walls + one interior ----
const sp = cfg().spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 }, // top of r1
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 }, // right of r3
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 }, // bottom of r4
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }, // left of r1
{ id: 'wI', type: 'window', x: 0.55, y: 0.30, angle: 90, length: 0.08 }, // r1|r2 wall
{ id: 'dS', type: 'door', x: 0.20, y: 0.86, angle: 0, length: 0.1 }, // doors never glow
];
touchCfg();
// 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
touchCfg();
await setSun(90, 5);
out.silentWithoutNorth = domPolys().length === 0;
out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
// 2) compass set → morning east sun lights ONLY the east window
cfg().settings.north_deg = 0;
touchCfg();
await setSun(90, 5);
out.morningEast = JSON.stringify(litIds()) === '["wE"]';
out.morningDomDrawn = domPolys().length > 0;
// 3) noon south → only the south window, wedge shorter than the morning one
const eastLen = c._sunRaysCache?.rays?.[0]?.len ?? 0;
await setSun(180, 60);
out.noonSouth = JSON.stringify(litIds()) === '["wS"]';
out.noonShorter = (c._sunRaysCache?.rays?.[0]?.len ?? 1e9) < eastLen;
// 4) evening west → only the west window; wedge stays inside its room r1
await setSun(270, 4);
out.eveningWest = JSON.stringify(litIds()) === '["wW"]';
const ray = c._sunRaysCache?.rays?.[0] || { polys: [] };
out.wedgeClippedToRoom = ray.polys.length > 0 && ray.polys.every((poly) => poly.every(([x, y]) =>
x >= 40 - 0.5 && x <= 550 + 0.5 && y >= 140 - 0.5 && y <= 580 + 0.5));
// 5) night → nothing
await setSun(90, -10);
out.nightEmpty = domPolys().length === 0;
// 6) rotated compass: the same morning east sun now lights the NORTH window
cfg().settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
cfg().settings.north_deg = 0;
touchCfg();
// 7) memoisation: an unrelated hass tick must NOT recompute the wedges
await setSun(90, 5);
const cacheBefore = c._sunRaysCache;
c.hass = { ...c.hass, states: { ...c.hass.states } }; // plain tick, sun unchanged
await upd();
out.memoSurvivesTick = c._sunRaysCache === cacheBefore;
await setSun(91, 5);
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
// 8) day/night background: daynight breathes, static does not
await setSun(180, 60);
out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
cfg().settings.bg_mode = 'daynight';
touchCfg();
await setSun(180, 60);
out.daynightNoonBg = stageStyle().includes('background:#ffffff');
out.daynightClass = !!sr().querySelector('.stage.daynight');
await setSun(180, -20);
out.daynightNightBg = stageStyle().includes('background:#070c14');
out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
cfg().settings.bg_mode = 'static';
touchCfg();
await setSun(180, -20);
out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
delete cfg().settings.bg_mode;
touchCfg();
// 9) per-space inheritance: the space override wins over the global flag
sp.settings = { ...(sp.settings || {}), sun_rays: false };
touchCfg();
await setSun(90, 5);
out.spaceOverridesOff = domPolys().length === 0;
delete sp.settings.sun_rays;
// and a per-space compass override redirects the light
sp.settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
delete sp.settings.north_deg;
touchCfg();
// 10) clouds: rain kills the wedges, cloudy only fades them
cfg().settings.weather_entity = 'weather.home';
touchCfg();
await setWeather('pouring');
await setSun(90, 5);
out.rainKillsRays = domPolys().length === 0;
await setWeather('cloudy');
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.18 * 0.4) < 1e-6;
await setWeather('sunny');
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.clearFullAlpha = Math.abs(stopClear - 0.18) < 1e-6;
delete cfg().settings.weather_entity;
touchCfg();
// 11) feature off: without the flag nothing is drawn even with sun+north
delete cfg().settings.sun_rays;
touchCfg();
await setSun(90, 5);
out.offDrawsNothing = domPolys().length === 0;
cfg().settings.sun_rays = true;
cfg().settings.bg_mode = 'daynight';
touchCfg();
// 12) editors stay clean: no wedges, no day/night canvas
await setSun(90, 5);
c._setMode('plan');
await upd();
out.editorNoRays = domPolys().length === 0;
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
c._setMode('view');
await upd();
out.viewRaysBack = domPolys().length > 0;
return out;
});
// 13) the general-settings dialog: a REAL drag on the compass dial sets degrees
await page.evaluate(async () => {
const c = window.__card;
c._openSettingsDialog();
c.requestUpdate();
await c.updateComplete;
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
const box = await page.evaluate(() => {
const r = window.__card.shadowRoot.querySelector('.compass').getBoundingClientRect();
return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
});
await page.mouse.move(box.cx + 45, box.cy); // on the ring, due right = 90°
await page.mouse.down();
await page.mouse.move(box.cx, box.cy + 45); // drag on to due down = 180°
await page.mouse.up();
const dlg = await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot;
const out = {
compassDragSets180: c._settingsDialog?.northDeg === 180,
dialogHasModeSelect: !!sr.querySelector('.dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('.dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
};
c._settingsDialog = null;
return out;
});
Object.assign(res, dlg);
// 14) DEV-B701-01 regression: the PRODUCTION local-save path (no touchCfg, no
// fake rev/epoch bump from the test) must move the wedge. A REAL pointer drag
// of the east window ends in _opPointerUp -> _saveConfig(); the WS write is
// debounced 500 ms, so at check time _cfgRev is still the boot revision — the
// ray must follow the window anyway, and again after the late server ack.
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svgEl = stage.querySelector('svg');
const [vx, vy, vw, vh] = svgEl.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const rayInfo = () => page.evaluate(() => {
const c = window.__card;
const rays = c._sunRaysCache?.rays || [];
const r = rays.find((q) => q.openingId === 'wE');
return {
ids: rays.map((q) => q.openingId).sort(),
midY: r ? (r.a[1] + r.b[1]) / 2 : NaN,
minX: r ? Math.min(...r.polys.flat().map((p) => p[0])) : NaN,
rev: c._cfgRev,
wEy: c._serverCfg.spaces.find((s) => s.id === 'f1').openings.find((o) => o.id === 'wE').y,
};
});
// baseline: morning east sun, only wE glows, window sits at y = 600
await page.evaluate(async () => {
const c = window.__card;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 90, elevation: 5 },
} } };
c.requestUpdate(); await c.updateComplete;
});
const base = await rayInfo();
check('b701_baseline_east_only', base.ids, ['wE']);
check('b701_baseline_mid_600', Math.abs(base.midY - 600) < 1, true);
// the real drag: enter the opening editor and pull wE down the east wall
await page.evaluate(async () => {
const c = window.__card;
c._setMode('plan'); c._tool = 'opening';
c.requestUpdate(); await c.updateComplete;
});
await settle();
const [dx, dy] = await screenPt(960, 600);
const [, dty] = await screenPt(960, 700);
await page.mouse.move(dx, dy);
await page.mouse.down();
await page.mouse.move(dx, dty, { steps: 6 });
await page.mouse.up();
await settle();
// leave the editor IMMEDIATELY — well inside the 500 ms debounce window
const after = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view');
c.requestUpdate(); await c.updateComplete;
return true;
});
check('b701_left_editor', after);
const moved = await rayInfo();
check('b701_window_really_moved', Math.abs(moved.wEy * 1000 - 700) < 15, true);
check('b701_rev_still_boot', moved.rev, base.rev); // the write has NOT landed yet
check('b701_ray_follows_window', Math.abs(moved.midY - moved.wEy * 1000) < 1, true);
check('b701_ray_not_stale', Math.abs(moved.midY - 600) > 50, true);
// same defect class for ROOM geometry: r3 shrinks to a thin strip along its
// east wall through the real mutation endpoint (_saveConfig, still no ack).
// The wedge reaches ~188 units into the room (minX ~772), so the new west
// boundary at x = 900 MUST cut it — a stale clip keeps points near x = 772.
const clipped = await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.rooms.find((r) => r.id === 'r3').poly = [[0.90, 0.46], [0.96, 0.46], [0.96, 0.86], [0.90, 0.86]];
c._saveConfig(); // the one true local-save entry point
c.requestUpdate(); await c.updateComplete;
return true;
});
check('b701_room_mutated', clipped);
const reclipped = await rayInfo();
check('b701_room_rev_still_boot', reclipped.rev, base.rev);
check('b701_room_reclips_ray', reclipped.minX >= 900 - 1, true);
// let the debounced write land: the ray must survive the late server ack
await page.waitForTimeout(800);
const acked = await page.evaluate(async () => {
const c = window.__card;
c.requestUpdate(); await c.updateComplete;
return true;
});
check('b701_acked', acked);
const final = await rayInfo();
check('b701_write_landed', final.rev !== base.rev, true);
check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true);
await finish(browser, checkAll(res));
+2 -2
View File
@@ -32,7 +32,7 @@ checkAll(res, {
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
"dialogTempFields": 3,
"dialogHiddenWhenNone": 1,
"dialogTempFields": 4, // масштаб + мин/макс + компас пространства (docs/SUN.md)
"dialogHiddenWhenNone": 2, // мин/макс скрыты; масштаб и компас остаются
});
await finish(browser, res);
File diff suppressed because one or more lines are too long
+518 -305
View File
File diff suppressed because one or more lines are too long
+44
View File
@@ -1,5 +1,49 @@
# 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
+44
View File
@@ -6,6 +6,50 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. 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,
+3 -3
View File
@@ -11,16 +11,16 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-08-02)
## Snapshot (2026-08-03)
| Item | State |
|---|---|
| Version | **v1.55.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance. Closes the v1.55.2 audit findings AUD-1552-01/02 (boot-veil lifecycle: reconnect restart + full protective window with trailing quiescence + post-reveal soft grace; regressions in `demo/smoke_preloader_lifecycle.mjs`) |
| 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.3** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.56.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
+167
View File
@@ -0,0 +1,167 @@
# Sun on the plan — the spec (source of truth)
Status: approved by the owner 2026-08-03. Shipped in v1.56.0.
Scope decisions final: the compass lives in the GENERAL settings with a
per-space override, the feature is silent until `north_deg` is set
anywhere, wedges ship for the FULL card only in v1, and mutual shading
of the building's wings is explicitly NOT computed.
## Principle
The plan learns where north is, and from that single number plus HA's
own `sun.sun` the card knows where the sun stands relative to every
wall. Two visuals follow: the stage background can breathe with the
day (day → golden hour → dusk → night), and windows on exterior walls
cast soft wedges of light into their rooms. Everything is display
only — no entities are created, no services are called.
## Data
- Source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north,
clockwise; `attributes.elevation` in degrees, negative below the
horizon). No `sun.sun` in the install → the whole feature stays
silent and the settings dialog says why.
- Sun attributes update rarely (~30–120 s). Sun geometry is recomputed
ONLY when (azimuth, elevation) or the config change — never on every
`hass` tick. The wedge layer memoises on
`(azimuth, elevation, config rev, space id, weather state)`.
- Angle on the plan: `plan_angle = azimuth − north_deg` (normalised to
0–360). With `north_deg = 0` the top of the canvas is north; the
direction TOWARD the sun on the canvas is
`(sin(plan_angle), −cos(plan_angle))` (y grows downward).
## Compass — `settings.north_deg`
- Integer 0–359, degrees clockwise from "up on the canvas" to true
north. Lives in the GENERAL settings (⚙) as a circular dial: drag
the «N» arrow around the ring, 1° steps, 15° with Shift held; a
plain number input sits next to it for accessibility and precision.
- Per-space override in the space settings (empty = inherit), the same
pattern as `show_lqi` / `fill_mode`.
- While `north_deg` is null at BOTH levels the whole sun feature is
inert: static background, no wedges, nothing computed. The settings
dialogs show a hint.
- Backend validation: integer in 0–359 at both levels.
## Plan background — `settings.bg_mode: 'static' | 'daynight'`
- Global default in the general settings, per-space override (null =
inherit). Default `'static'`.
- `'static'` — the existing `bg_color` behaviour, color picker and
all. Nothing changes for existing installs.
- `'daynight'` — the stage background follows the sun's elevation:
WHITE at full day (the brightest moment of the day is white — owner,
2026-08-03), a warm bright shift in the golden hour (elevation below
~10°), cooling through dusk, deep darkening at night. The scale
(piecewise-linear between stops, `BG_STOPS` in `src/sun.ts`):
| elevation | color | phase |
| --- | --- | --- |
| −90°…−12° | `#070c14` | deep night |
| −4° | `#131a28` | dusk cools down |
| 0° | `#4a3527` | warm band right at the horizon |
| +10° | `#e8ddcf` | morning light — warm and bright |
| +30°…+90° | `#ffffff` | plain day, white |
The PLAN
itself dims only ~10% at night (`filter: brightness(.9)`), so the
daytime room fills stay readable. Transitions are a CSS
background/filter transition tens of seconds long;
`prefers-reduced-motion` gets the current colors statically.
- The UI is a two-option selector; the color picker shows only for
`'static'`.
- Backend validation: `In(['static', 'daynight'])` at both levels.
- `'daynight'` follows the general gate: without `north_deg` (or
without `sun.sun`) it behaves as `'static'`.
- **The scene background never bleeds through the plan** (owner,
2026-08-03). In BOTH modes the background — `bg_color` or the
daynight sky — is visible only AROUND the plan: opaque `.hp-paper`
shapes sit under everything the plan draws. An image plan papers the
backdrop image rect (the canvas IS the paper); a hand-drawn plan
papers the ROOM CONTOURS — one shape per room in exactly the room's
own geometry (fill only, no stroke), never their bounding box, so
the background reaches the exterior walls of an L-shaped house and
fills the gaps between detached buildings (an empty drawn space has
no paper). Open (virtual) boundaries do not affect the paper; a live
resize preview moves it together with the rooms. Its colour is the
pre-bg_color canvas — white for hand-drawn plans, the theme card
background under an image. The night dimming above is the
`brightness` filter on the zoomwrap ONLY; the paper's alpha never
changes. Applies to view/kiosk/editors and the static space-card
alike (smoke_bg_color).
## Window light wedges — `settings.sun_rays`
Boolean, global + per-space (null = inherit), default OFF.
For every opening of type «window» sitting on an EXTERIOR wall — a
wall stretch with no other room on its outer side, decided by probing
the existing room geometry just off both sides of the window; windows
on interior walls do not participate, and open (virtual) boundaries
never qualify because both sides are rooms — the card draws a wedge
when BOTH hold:
- the sun is above the horizon (`elevation > 0`), and
- the dot product of the wall's outward normal with the direction
toward the sun is positive (the sun actually faces this window).
The wedge is a quadrilateral cast from the window's span along the
direction AWAY from the sun (light falls inward), clipped by the
room's polygon (`polyclip` intersection, the same dependency
`src/resize.ts` already uses). Its length is `k(elevation)` in window
lengths: ~2.5 at sunrise/sunset tapering to ~0.8 at the zenith
(`0.8 + 1.7·(1 − elevation/90)^1.6`). A linear gradient runs bright at
the window and dissolves inward; the color is warm orange while
`elevation < 10°` and neutral by day; peak opacity is modest (~0.18 —
two overlapping wedges never exceed a readable ceiling). Near the
horizon the opacity ramps in over the first ~2° so wedges never pop.
Layer order: ABOVE room fills (and the glow layer), BELOW devices and
labels (those live in the HTML `devlayer` anyway). Night
(`elevation ≤ 0`) → no wedges. Wedges work under BOTH `bg_mode`s.
## Cloud cover — `settings.weather_entity` (optional)
String entity id or null; GLOBAL settings only. When set and the
entity's state reads overcast, the wedges fade by an opacity
multiplier — ~0.25 fully overcast, 0 (gone) in rain/snow:
| states | factor |
| --- | --- |
| clear, sunny, clear-night, windy, exceptional, unset entity | 1.0 |
| partlycloudy, windy-variant | 0.7 |
| cloudy | 0.4 |
| overcast, fog | 0.25 |
| rainy, pouring, snowy, snowy-rainy, hail, lightning, lightning-rainy | 0.0 |
| unknown, unavailable | 1.0 (a dead sensor must not kill the sun) |
Backend validation: string or null.
## Edge cases and limits
- No `sun.sun` → silent feature + a hint in the settings dialog.
- `north_deg` unset everywhere → silent feature + a hint.
- `prefers-reduced-motion` → no transitions; colors and wedges render
statically for the current sun position.
- Kiosk mode → works (same view path).
- Static `houseplan-space-card` → the background honours the effective
`bg_mode`/color; wedges are v1 FULL-CARD ONLY (documented limit).
- Editors (plan/devices/decor) → no wedges and no day/night: the
editor canvases render exactly as before.
- Mutual shading of the building's own wings (an L-shaped house
shadowing its inner corner) is NOT computed — a lit window casts its
wedge even when another wing geometrically blocks the sun. Accepted
v1 limit.
- Wedges of windows on all four wall orientations are unit-tested,
including the 359→0 azimuth wrap.
## Files
- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge
quads + clipping, cloud factor, settings inheritance); unit-tested
in `test/sun.test.mjs`.
- `src/houseplan-card.ts` — the memoised wedge layer, the day/night
stage background, both settings dialogs (compass dial included).
- `src/space-render.ts` — the static card's background only.
- `custom_components/houseplan/validation.py` — the four settings at
both levels; tests in `tests_backend/test_validation.py`.
- `demo/smoke_sun.mjs` — end-to-end behaviour against the demo rig.
+64
View File
@@ -120,6 +120,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
walls, click (any tool) opens its properties — with a 3 px drag threshold
since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings]
- [ ] Opening drag rulers (2026-08-03): while an opening is dragged, a measure
badge on EACH shoulder shows the along-the-wall distance from the wall end
to the nearest opening edge, live; the wall is ONE room's edge — the edge
the opening is snapped to — so a neighbouring room's collinear edge is
never merged in; at that edge's center (±half a grid step) a
perpendicular dashed tick appears and the center magnet-snaps — Shift
disables the magnet; badges and tick vanish on release
[auto: smoke_opening_measure + unit openingShoulders]
## Onboarding ★
@@ -866,3 +874,59 @@ require hands on real hardware — they remain for the human pass.
one release = one undo step (rooms AND openings)
- [ ] Device markers do not move; the room settings gear re-centres itself
- [ ] Smoke: `node demo/smoke_room_resize.mjs`
## Sun on the plan (docs/SUN.md)
- [ ] Everything below is INERT until `north_deg` is set (general ⚙ or the
space settings) AND the install has `sun.sun`; both dialogs hint at
whichever is missing
- [ ] The ⚙ compass: dragging the «N» arrow turns it in 1° steps (15° with
Shift); the number field mirrors the dial and accepts 0–359; «Clear»
returns the unset state
- [ ] «Plan background» selector: `static` keeps the existing color picker
and behaviour byte-for-byte; `daynight` hides the picker and the stage
follows the sun — neutral day, warm golden hour, dark night — with a
slow (tens of seconds) transition; the PLAN dims only ~10% at night
- [ ] Opaque plan paper (2026-08-03, owner): the scene background —
`bg_color` or the daynight sky — is visible ONLY around the plan and
NEVER bleeds through it, in view/kiosk/editors and on the static
space-card. An image plan papers the backdrop image rect
(`rect.hp-paper`). A hand-drawn plan papers the ROOM CONTOURS: one
`.hp-paper` shape per room in exactly the room's own geometry (fill
only, no stroke), so an L-shaped house or detached buildings never
grow a white bounding rectangle — the scene colour reaches the
exterior walls, shows in the L's pocket and between buildings, and an
empty drawn space has no paper at all. A live resize preview
(`_rszPreview`) moves the paper together with the dragged wall.
Colours: historical white for drawn plans, the theme card background
under an image. At night (`daynight`) the paper dims via the zoomwrap
`brightness` filter only — its alpha stays 1. Pixel-proofed against
an acid `#ff00ff` background [auto: smoke_bg_color]
- [ ] Per-space overrides (background mode, north, sun-in-windows) inherit
when empty, exactly like show_lqi/fill_mode
- [ ] «Sunlight through windows» (default OFF): wedges appear only from
windows on EXTERIOR walls facing the sun; interior windows, open
(virtual) boundaries and doors never light up
- [ ] Wedge direction follows the compass; length grows toward
sunrise/sunset and shrinks toward noon; every wedge is clipped by its
room polygon; night = no wedges at all
- [ ] Weather entity (optional, global): cloudy fades the wedges, rain/snow
removes them, a dead/unknown weather sensor changes nothing
- [ ] Sun geometry recomputes ONLY when the sun attributes or the config
change — an unrelated `hass` tick reuses the memo
- [ ] Editors (plan/devices/decor) render with NO wedges and NO day/night;
kiosk works; the static space-card honours the background mode
(wedges are full-card-only in v1)
- [ ] `prefers-reduced-motion` → no transitions, static colors
- [ ] Smoke: `node demo/smoke_sun.mjs`; units: `test/sun.test.mjs`;
backend: `tests_backend/test_validation.py` (sun settings)
## Climate temperature opt-in (dev)
- [ ] «Use the device's temperature sensor» (marker dialog, climate devices
only, default OFF): current_temperature shows as the standard `.tval`
badge and joins the room average like a thermometer; unavailable /
missing attribute = no badge, no vote; hidden devices keep voting
(registry-wide climate, like hidden thermometers); the tick survives
dialog recreation [auto: smoke_climate_temp; units: test/devices.test.mjs;
backend: tests_backend/test_validation.py (use_climate_temp)]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.55.3",
"version": "1.56.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.55.3",
"version": "1.56.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.55.3",
"version": "1.56.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+44 -4
View File
@@ -146,6 +146,23 @@ export function tempFor(hass: any, entIds: string[]): number | null {
return null;
}
/**
* Room temperature as a CLIMATE device reports it: the first climate.* entity
* with a finite attributes.current_temperature (owner's spec: several climate
* entities -> the first valid one wins). Unavailable/unknown entities and a
* missing attribute yield null - no badge, no vote in the room average.
*/
export function climateTempFor(hass: any, entIds: string[]): number | null {
for (const eid of entIds) {
if (!eid.startsWith('climate.')) continue;
const st = hass.states[eid];
if (!st || st.state === 'unavailable' || st.state === 'unknown') continue;
const v = parseFloat(st.attributes?.current_temperature);
if (Number.isFinite(v)) return Math.round(v * 10) / 10;
}
return null;
}
/** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */
export function isHumEntity(hass: any, eid: string): boolean {
if (hass.entities?.[eid]?.entity_category) return false;
@@ -508,10 +525,21 @@ export interface AreaClimate { temp: number | null; hum: number | null }
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
*/
export function areaClimateMap(
hass: any, rules?: CompiledIconRule[],
hass: any, rules?: CompiledIconRule[], markers?: Marker[] | null,
): Map<string, AreaClimate> {
const out = new Map<string, AreaClimate>();
if (!hass?.entities) return out;
// Opt-in climate sources (marker.use_climate_temp): an AC or thermostat
// knows the room temperature (current_temperature) - when the user ticks
// the option, that reading votes in the room average like any thermometer.
// The set holds binding refs (device ids and entity ids); with no opted
// markers the pass below is byte-for-byte the old one.
const climOpt = new Set<string>();
for (const m of markers || []) {
if (m?.use_climate_temp !== true) continue;
const i = (m.binding || '').indexOf(':');
if (i > 0) climOpt.add(m.binding.slice(i + 1));
}
// area -> device (or lone entity) -> the entities that belong to it
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
@@ -523,8 +551,15 @@ export function areaClimateMap(
// 90 C and a virtual better_thermostat duplicating the real sensor (field
// question, 2026-07-27). Three guards, cheapest first:
if (reg.entity_category) continue; // diagnostic/config readings
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
// An explicitly opted climate entity skips the platform/name filters:
// the tick is the user saying "this one DOES measure room air" - even on
// an excluded platform (better_thermostat) or a suspicious entity id.
const optClimate = climOpt.size > 0 && eid.startsWith('climate.')
&& (climOpt.has(eid) || (reg.device_id && climOpt.has(reg.device_id)));
if (!optClimate) {
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
}
let groups = byArea.get(area);
if (!groups) { groups = new Map(); byArea.set(area, groups); }
const key = reg.device_id || eid;
@@ -543,13 +578,18 @@ export function areaClimateMap(
for (const [area, groups] of byArea) {
const temps: number[] = [];
const hums: number[] = [];
for (const g of groups.values()) {
for (const [key, g] of groups) {
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
if (air) {
const t = tempFor(hass, g.ents);
if (t != null) temps.push(t);
}
// opted climate device/entity: current_temperature joins the average
if (climOpt.size > 0 && (climOpt.has(key) || g.ents.some((e) => climOpt.has(e)))) {
const t = climateTempFor(hass, g.ents);
if (t != null) temps.push(t);
}
if (air || icon === 'mdi:water-percent') {
const h = humFor(hass, g.ents);
if (h != null) hums.push(h);
+475 -73
View File
@@ -13,9 +13,9 @@ import {
} from './rules';
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
snapToWall, openingAmount, interiorPoint, poleOfInaccessibility, subst,
snapToWall, openingAmount, openingShoulders, interiorPoint, poleOfInaccessibility, subst,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
@@ -28,6 +28,10 @@ import {
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
} from './resize';
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
sunStateOf, cloudFactor, rayAlpha, rayColor, type SunRay,
} from './sun';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
import {
@@ -36,7 +40,7 @@ import {
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
VAC_TRAIL_LINGER_MS, Pt as VacPt,
} from './vacuum';
import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -47,7 +51,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.55.3';
const CARD_VERSION = '1.56.0';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -270,6 +274,11 @@ class HouseplanCard extends LitElement {
} | null = null;
private _openingInfo: OpeningCfg | null = null;
private _opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean } | null = null;
// live ruler badges + the "centered on the wall" tick while an opening is dragged
private _opMeasure: {
labels: { x: number; y: number; text: string }[];
guide: { x: number; y: number; angle: number } | null;
} | null = null;
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
@@ -326,7 +335,15 @@ class HouseplanCard extends LitElement {
private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
private _settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; busy: boolean } | null = null;
private _settingsDialog: {
colors: FillColors; glowRadius: number; bgColor: string | null;
/** sun on the plan (docs/SUN.md) */
northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean; weatherEntity: string;
busy: boolean;
} | null = null;
/** Wedge memo: recomputed only when (azimuth, elevation, north, cfg rev) change (docs/SUN.md). */
private _sunRaysCache: { key: string; rays: SunRay[] } | null = null;
private _compassDrag = false;
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
private _importQueue: string[] = []; // floor titles still to create
private _importTotal = 0;
@@ -367,6 +384,7 @@ class HouseplanCard extends LitElement {
controlsFilter: string;
glowRadius: string; // per-device glow radius in display units; '' = global default
isLight: boolean; // force this marker to glow (dumb fixtures behind a switch)
useClimateTemp: boolean; // badge + room-average vote from climate current_temperature
model: string;
link: string;
description: string;
@@ -396,6 +414,9 @@ class HouseplanCard extends LitElement {
roomColor: string;
roomOpacity: number; // 0..1
bgColor: string | null; // background around the plan; null = inherit general
bgMode: 'static' | 'daynight' | null; // plan background mode; null = inherit (docs/SUN.md)
northDeg: number | null; // per-space compass override; null = inherit
sunRays: boolean | null; // per-space wedges override; null = inherit
fillMode: 'none' | 'lqi' | 'light' | 'temp' | 'glow';
tempMin: number;
tempMax: number;
@@ -489,6 +510,7 @@ class HouseplanCard extends LitElement {
_tool: { state: true },
_rszSel: { state: true },
_rszLive: { state: true },
_opMeasure: { state: true },
_path: { state: true },
_cursorPt: { state: true },
_mergeSel: { state: true },
@@ -1457,10 +1479,29 @@ class HouseplanCard extends LitElement {
private _liveTemp(d: DevItem): number | null {
if (!this._config?.show_temperature) return null;
// Opt-in climate source (marker.use_climate_temp): the AC/thermostat's
// current_temperature gets the same badge a thermometer has. No valid
// reading (missing attribute, unavailable) -> no badge at all.
if (d.marker?.use_climate_temp === true) {
const t = climateTempFor(this.hass, d.entities);
if (t != null) return t;
}
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null;
return tempFor(this.hass, d.entities);
}
/** Does the dialog's binding carry a climate entity? Gates the opt-in checkbox. */
private _bindingHasClimate(binding: string): boolean {
if (binding.startsWith('entity:')) return binding.slice(7).startsWith('climate.');
if (binding.startsWith('device:')) {
const ref = binding.slice(7);
for (const [eid, reg] of Object.entries<any>(this.hass?.entities || {})) {
if (reg?.device_id === ref && eid.startsWith('climate.')) return true;
}
}
return false;
}
private _liveHum(d: DevItem): number | null {
if (!this._config?.show_temperature) return null; // same "sensor values" toggle as temperature
if (!d.primary || !isHumEntity(this.hass, d.primary)) return null;
@@ -1581,6 +1622,24 @@ class HouseplanCard extends LitElement {
return b ? [b.x, b.y, b.w, b.h] : m.vb;
}
/**
* Opaque plan "paper" (owner 2026-08-03): the scene background — bg_color or
* the 'daynight' sky — must never bleed through the plan itself, so opaque
* shapes sit under everything the plan draws. With a backdrop image the
* paper is the image rect (the canvas IS the paper) — this helper returns
* it. For a hand-drawn plan the paper follows the ROOM CONTOURS instead
* (paperRoomShapes in logic.ts): one shape per room in exactly the room's
* own geometry, so an L-shaped house or detached buildings never grow a
* white bounding rectangle — the scene colour is visible right up to the
* exterior walls, and an empty drawn space has no paper at all. Its colour
* is the pre-bg_color canvas (styles.ts .hp-paper): white for drawn plans,
* the theme card background under an image. At night 'daynight' dims it via
* the zoomwrap brightness filter ONLY — its alpha stays 1 (docs/SUN.md).
*/
private _paperRect(m: SpaceModel): { x: number; y: number; w: number; h: number } | null {
return m.bg ? { x: m.bg.x, y: m.bg.y, w: m.bg.w, h: m.bg.h } : null;
}
/** Aspect ratio of the scene (width/height, px). */
private _stageAspect(): number {
const s = this._stageEl;
@@ -3099,8 +3158,30 @@ class HouseplanCard extends LitElement {
const sp = this._curSpaceCfg;
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
if (!cfg) return;
const nx = snap.x / NORM_W;
const ny = snap.y / this._spaceH;
// ruler badges on both shoulders + soft magnet to the wall's center
// (owner 2026-08-03); tol = half a grid step, Shift opts out of the magnet
// (same convention as the coarse-angle Shift elsewhere in the editor)
const rooms = this._spaceModel().rooms;
const tol = this._gridPitch / 2;
let cx = snap.x, cy = snap.y;
let sh = openingShoulders([cx, cy], snap.angle, cfg.length * NORM_W, rooms, tol);
if (sh && sh.centered && !ev.shiftKey && (cx !== sh.wallCenter[0] || cy !== sh.wallCenter[1])) {
[cx, cy] = sh.wallCenter;
sh = openingShoulders([cx, cy], snap.angle, cfg.length * NORM_W, rooms, tol);
}
if (sh) {
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const lbl = (d: number, m: number[]) =>
({ x: m[0], y: m[1], text: formatLength((d / this._gridPitch) * this._cellCm, imperial) });
this._opMeasure = {
labels: [lbl(sh.sideA, sh.midA), lbl(sh.sideB, sh.midB)],
guide: sh.centered && !ev.shiftKey
? { x: sh.wallCenter[0], y: sh.wallCenter[1], angle: snap.angle }
: null,
};
} else this._opMeasure = null;
const nx = cx / NORM_W;
const ny = cy / this._spaceH;
if (cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle) this._opDrag.dirty = true;
cfg.x = nx;
cfg.y = ny;
@@ -3111,6 +3192,7 @@ class HouseplanCard extends LitElement {
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
if (!this._opDrag || this._opDrag.id !== o.id) return;
const moved = this._opDrag.moved;
this._opMeasure = null; // badges and the center tick live only through the drag
// only write when the geometry actually changed (audit L4)
if (moved && this._opDrag.dirty) this._saveConfig();
// keep the flag until the click event that follows pointerup, then let it go
@@ -3483,6 +3565,7 @@ class HouseplanCard extends LitElement {
controls: [...(d.marker?.controls || [])],
controlsFilter: '',
isLight: d.marker?.is_light === true,
useClimateTemp: d.marker?.use_climate_temp === true,
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this._imperial
? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10
@@ -3505,7 +3588,7 @@ class HouseplanCard extends LitElement {
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', tapTarget: '', tapConfirm: false, runFilter: '',
defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
glowRadius: '', model: '',
useClimateTemp: false, glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false,
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
};
@@ -3727,6 +3810,7 @@ class HouseplanCard extends LitElement {
controls: dlg.controls.length ? dlg.controls : null,
// pdfs may be rewritten below when rebinding changes the marker id
is_light: dlg.isLight ? true : null,
use_climate_temp: dlg.useClimateTemp ? true : null,
glow_radius_cm: (() => {
const v = parseFloat(dlg.glowRadius);
if (!Number.isFinite(v) || v <= 0) return null;
@@ -3900,6 +3984,9 @@ class HouseplanCard extends LitElement {
showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
bgColor: disp.bgColor,
bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight' ? sp.settings.bg_mode : null,
northDeg: northDegOf({}, sp.settings),
sunRays: typeof sp.settings?.sun_rays === 'boolean' ? sp.settings.sun_rays : null,
tempMin: disp.tempMin, tempMax: disp.tempMax,
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
cardFontScale: disp.cardFontScale,
@@ -3915,6 +4002,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
bgColor: null,
bgMode: null, northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
@@ -4143,6 +4231,9 @@ class HouseplanCard extends LitElement {
room_color: d.roomColor,
room_opacity: d.roomOpacity,
bg_color: d.bgColor || undefined, // empty = inherit the general setting
bg_mode: d.bgMode || undefined, // sun on the plan (docs/SUN.md); empty = inherit
north_deg: d.northDeg ?? undefined,
sun_rays: d.sunRays ?? undefined,
fill_mode: d.fillMode,
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
@@ -4250,6 +4341,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
bgColor: null,
bgMode: null, northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
@@ -4305,12 +4397,153 @@ class HouseplanCard extends LitElement {
</div>`;
}
// ================= SUN ON THE PLAN (docs/SUN.md) =================
/** Global settings with the general-settings dialog's pending values. */
private _sunGlobal(): any {
const gd = this._settingsDialog;
if (!gd) return this._settings;
return {
...this._settings,
north_deg: gd.northDeg ?? undefined,
bg_mode: gd.bgMode,
sun_rays: gd.sunRays,
weather_entity: (gd.weatherEntity || '').trim() || undefined,
};
}
/** Current space settings with the space dialog's pending values. */
private _sunSpace(): any {
const sd = this._spaceDialog;
const saved = this._curSpaceCfg?.settings || {};
if (!sd || sd.mode !== 'edit' || sd.spaceId !== this._space) return saved;
return {
...saved,
north_deg: sd.northDeg ?? undefined,
bg_mode: sd.bgMode ?? undefined,
sun_rays: sd.sunRays ?? undefined,
};
}
/** Effective compass; null = the whole sun feature is inert (docs/SUN.md). */
private _effNorth(): number | null {
return northDegOf(this._sunGlobal(), this._sunSpace());
}
private _effBgMode(): 'static' | 'daynight' {
return bgModeOf(this._sunGlobal(), this._sunSpace());
}
private _effSunRays(): boolean {
return sunRaysOn(this._sunGlobal(), this._sunSpace());
}
/** sun.sun, but only when the feature is armed (north_deg set somewhere). */
private _sunNow(): { azimuth: number; elevation: number } | null {
return this._effNorth() !== null ? sunStateOf(this.hass) : null;
}
/**
* Window light wedges (docs/SUN.md): view/kiosk only. Geometry recomputes
* ONLY when the memo key changes — sun attributes tick every ~30-120 s and
* everything else in `hass` must not trigger the polygon clipping.
*/
private _renderSunRays(space: SpaceModel): TemplateResult {
const empty = svg`` as unknown as TemplateResult;
if (this._editing || !this._effSunRays()) return empty;
const north = this._effNorth();
const sun = north !== null ? sunStateOf(this.hass) : null;
if (!sun || sun.elevation <= 0) return empty;
const weather = weatherEntityOf(this._sunGlobal());
const cloud = cloudFactor(weather ? this.hass?.states?.[weather]?.state : null);
const alpha = rayAlpha(sun.elevation, cloud);
if (alpha <= 0) return empty;
// DEV-B701-01: the geometry signal must be _cfgEpoch, not _cfgRev.
// Every local mutation ends in _saveConfig(), which bumps the epoch
// SYNCHRONOUSLY; _cfgRev only moves after the debounced WS write is
// acked, so a rev-keyed memo served wedges for the OLD window position
// during the whole write window (and forever if the write failed).
const key = `${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${this._cfgEpoch}`;
if (!this._sunRaysCache || this._sunRaysCache.key !== key) {
const rooms = space.rooms
.map((r) => ({ id: r.id || '', poly: roomPoly(r) }))
.filter((r): r is { id: string; poly: number[][] } => !!r.id && !!r.poly);
const windows = this._openingsR
.filter((o) => o.type === 'window')
.map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
this._sunRaysCache = { key, rays: computeSunRays(rooms, windows, sun.azimuth, sun.elevation, north!) };
}
const rays = this._sunRaysCache.rays;
if (!rays.length) return empty;
const color = rayColor(dayPhase(sun.elevation).warmth);
return svg`<defs>
${rays.map((r, i) => {
const mx = (r.a[0] + r.b[0]) / 2;
const my = (r.a[1] + r.b[1]) / 2;
return svg`<linearGradient id="hp-sun-${i}" gradientUnits="userSpaceOnUse"
x1="${mx}" y1="${my}" x2="${mx + r.dir[0] * r.len}" y2="${my + r.dir[1] * r.len}">
<stop offset="0%" stop-color="${color}" stop-opacity="${alpha.toFixed(3)}"></stop>
<stop offset="100%" stop-color="${color}" stop-opacity="0"></stop>
</linearGradient>`;
})}
</defs>
<g class="sunlayer">
${rays.map((r, i) => r.polys.map((p) => svg`<polygon
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`))}
</g>` as unknown as TemplateResult;
}
/** One drag/click sample on the compass dial → dialog north_deg. */
private _compassPoint(ev: PointerEvent): void {
const el = ev.currentTarget as SVGSVGElement;
const r = el.getBoundingClientRect();
const dx = ev.clientX - (r.left + r.width / 2);
const dy = ev.clientY - (r.top + r.height / 2);
if (Math.hypot(dx, dy) < 5) return; // the dead centre says nothing about angle
let a = Math.round((Math.atan2(dx, -dy) * 180) / Math.PI);
if (ev.shiftKey) a = Math.round(a / 15) * 15; // coarse 15° steps with Shift
a = ((a % 360) + 360) % 360;
this._settingsDialog = { ...this._settingsDialog!, northDeg: a };
}
/** The compass dial: drag the «N» arrow around the ring (docs/SUN.md). */
private _renderCompass(): TemplateResult {
const d = this._settingsDialog!;
const deg = d.northDeg;
return html`<svg class="compass ${deg === null ? 'unset' : ''}" viewBox="-60 -60 120 120"
@pointerdown=${(e: PointerEvent) => {
(e.currentTarget as Element).setPointerCapture(e.pointerId);
this._compassDrag = true;
this._compassPoint(e);
}}
@pointermove=${(e: PointerEvent) => { if (this._compassDrag) this._compassPoint(e); }}
@pointerup=${() => (this._compassDrag = false)}
@pointercancel=${() => (this._compassDrag = false)}>
<circle class="cring" r="50"></circle>
${[0, 45, 90, 135, 180, 225, 270, 315].map(
(t) => svg`<line class="ctick ${t % 90 ? 'minor' : ''}" x1="0" y1="-50" x2="0" y2="${t % 90 ? -46 : -43}"
transform="rotate(${t})"></line>`,
)}
<g class="cneedle" transform="rotate(${deg ?? 0})">
<line x1="0" y1="34" x2="0" y2="-28"></line>
<path d="M -7 -24 L 0 -42 L 7 -24 Z"></path>
<text x="0" y="-12" text-anchor="middle">${this._t('gs.north_letter')}</text>
</g>
<text class="cdeg" x="0" y="26" text-anchor="middle">${deg === null ? '—' : deg + '°'}</text>
</svg>`;
}
/**
* Effective stage background: per-space override → global setting → '' (the
* theme default from the stylesheet). An open dialog previews its pending
* value, so the color can be picked against the live plan.
* value, so the color can be picked against the live plan. In 'daynight'
* mode (docs/SUN.md) the sun's elevation paints the stage instead.
*/
private _stageBg(disp: SpaceDisplay): string {
if (this._effBgMode() === 'daynight') {
const sun = this._sunNow();
if (sun) return dayPhase(sun.elevation).bg;
}
const gd = this._settingsDialog;
const sd = this._spaceDialog;
const globalBg = gd ? gd.bgColor || '' : stageBgOf(this._settings, { bgColor: null });
@@ -4343,6 +4576,10 @@ class HouseplanCard extends LitElement {
colors: JSON.parse(JSON.stringify(this._fillColors)),
glowRadius,
bgColor: stageBgOf(this._settings, { bgColor: null }) || null,
northDeg: northDegOf(this._settings, {}),
bgMode: bgModeOf(this._settings, {}),
sunRays: sunRaysOn(this._settings, {}),
weatherEntity: weatherEntityOf(this._settings) || '',
busy: false,
};
};
@@ -4367,6 +4604,17 @@ class HouseplanCard extends LitElement {
else delete settings.glow_radius_cm;
if (d.bgColor) settings.bg_color = d.bgColor;
else delete settings.bg_color;
// sun on the plan (docs/SUN.md): defaults are never stored
if (d.northDeg !== null && Number.isInteger(d.northDeg) && d.northDeg >= 0 && d.northDeg <= 359)
settings.north_deg = d.northDeg;
else delete settings.north_deg;
if (d.bgMode === 'daynight') settings.bg_mode = 'daynight';
else delete settings.bg_mode;
if (d.sunRays) settings.sun_rays = true;
else delete settings.sun_rays;
const we = (d.weatherEntity || '').trim();
if (we) settings.weather_entity = we;
else delete settings.weather_entity;
this._serverCfg = { ...cfg, settings };
await this._saveConfigNow();
this._settingsDialog = null;
@@ -4382,6 +4630,34 @@ class HouseplanCard extends LitElement {
}
}
/** Boolean toggle for dialog rows: the native ha-switch when the HA
* frontend provides it, the classic checkbox otherwise (older HA, the
* smoke env). The ha-* API is undocumented and shifts between HA
* releases, so the presence check is the ONLY coupling: both branches
* fire `change` and both are read back via `.checked` off the event
* target - one handler, two renderers. */
private _boolInput(checked: boolean, onChange: (v: boolean) => void, disabled = false): TemplateResult {
const h = (e: Event) => onChange(!!(e.target as HTMLInputElement).checked);
return customElements.get('ha-switch')
? html`<ha-switch .checked=${checked} .disabled=${disabled} @change=${h}></ha-switch>`
: html`<input type="checkbox" .checked=${checked} ?disabled=${disabled} @change=${h} />`;
}
/** Range slider for dialog rows: ha-slider when available, plain
* input[type=range] otherwise. Same fallback contract as _boolInput;
* ha-slider emits `input` while dragging and `change` on release
* (which of the two carries the final value differs between HA
* versions - listen to both, the handler is idempotent). */
private _rangeInput(min: number, max: number, step: number, value: number, onInput: (v: number) => void): TemplateResult {
const h = (e: Event) => {
const n = Number((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) onInput(n);
};
return customElements.get('ha-slider')
? html`<ha-slider .min=${min} .max=${max} .step=${step} .value=${value} @input=${h} @change=${h}></ha-slider>`
: html`<input type="range" min=${min} max=${max} step=${step} .value=${String(value)} @input=${h} />`;
}
private _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
const d = this._settingsDialog!;
const v = d.colors[key];
@@ -4389,8 +4665,7 @@ class HouseplanCard extends LitElement {
<span class="gsl">${this._t(labelKey as any)}</span>
<input type="color" .value=${v.c}
@input=${(e: Event) => this._setFillColor(key, { c: (e.target as HTMLInputElement).value })} />
<input type="range" min="0" max="100" .value=${String(Math.round(v.a * 100))}
@input=${(e: Event) => this._setFillColor(key, { a: Number((e.target as HTMLInputElement).value) / 100 })} />
${this._rangeInput(0, 100, 1, Math.round(v.a * 100), (n) => this._setFillColor(key, { a: n / 100 }))}
<span class="opv">${Math.round(v.a * 100)}%</span>
</div>`;
}
@@ -4521,19 +4796,77 @@ class HouseplanCard extends LitElement {
</div>
<label class="dispsection">${this._t('gs.bg_group')}</label>
<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.bg_color')}</span>
<input type="color" .value=${this._settingsDialog!.bgColor || this._stageBgHex()}
<span class="gsl">${this._t('gs.bg_mode')}</span>
<select class="areasel"
@change=${(e: Event) =>
(this._settingsDialog = { ...this._settingsDialog!, bgMode: (e.target as HTMLSelectElement).value === 'daynight' ? 'daynight' : 'static' })}>
<option value="static" ?selected=${this._settingsDialog!.bgMode === 'static'}>${this._t('gs.bg_static')}</option>
<option value="daynight" ?selected=${this._settingsDialog!.bgMode === 'daynight'}>${this._t('gs.bg_daynight')}</option>
</select>
</div>
${this._settingsDialog!.bgMode === 'static'
? html`<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.bg_color')}</span>
<input type="color" .value=${this._settingsDialog!.bgColor || this._stageBgHex()}
@input=${(e: Event) =>
(this._settingsDialog = { ...this._settingsDialog!, bgColor: (e.target as HTMLInputElement).value })} />
${this._settingsDialog!.bgColor
? html`<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, bgColor: null })}>${this._t('gs.bg_default')}</button>`
: html`<span class="opl">${this._t('gs.bg_theme')}</span>`}
</div>`
: html`<div class="rhint">${this._t('gs.bg_daynight_hint')}</div>`}
<label class="dispsection">${this._t('gs.sun_group')}</label>
${!sunStateOf(this.hass)
? html`<div class="rhint">${this._t('gs.sun_missing')}</div>`
: nothing}
<div class="sunrow">
${this._renderCompass()}
<div class="suncol">
<span class="gsl">${this._t('gs.north')}</span>
<div class="colorrow">
<input class="namein tempin" type="number" min="0" max="359" step="1"
placeholder=${this._t('gs.north_ph')}
.value=${this._settingsDialog!.northDeg === null ? '' : String(this._settingsDialog!.northDeg)}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value.trim();
const n = raw === '' ? null : Math.round(Number(raw));
this._settingsDialog = {
...this._settingsDialog!,
northDeg: n !== null && Number.isFinite(n) ? Math.min(359, Math.max(0, n)) : null,
};
}} />
${this._settingsDialog!.northDeg !== null
? html`<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, northDeg: null })}>${this._t('gs.north_clear')}</button>`
: nothing}
</div>
${this._settingsDialog!.northDeg === null
? html`<div class="rhint">${this._t('gs.north_hint')}</div>`
: nothing}
</div>
</div>
<label class="srcrow">
${this._boolInput(this._settingsDialog!.sunRays, (v) =>
(this._settingsDialog = { ...this._settingsDialog!, sunRays: v }))}
<span>${this._t('gs.sun_rays')}</span>
</label>
<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.weather')}</span>
<input class="namein" type="text" list="hp-weather-list" placeholder=${this._t('gs.weather_ph')}
.value=${this._settingsDialog!.weatherEntity}
@input=${(e: Event) =>
(this._settingsDialog = { ...this._settingsDialog!, bgColor: (e.target as HTMLInputElement).value })} />
${this._settingsDialog!.bgColor
? html`<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, bgColor: null })}>${this._t('gs.bg_default')}</button>`
: html`<span class="opl">${this._t('gs.bg_theme')}</span>`}
(this._settingsDialog = { ...this._settingsDialog!, weatherEntity: (e.target as HTMLInputElement).value })} />
<datalist id="hp-weather-list">
${Object.keys(this.hass?.states || {}).filter((id) => id.startsWith('weather.')).map(
(id) => html`<option value=${id}></option>`,
)}
</datalist>
</div>
</div>
<div class="row">
<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3, bgColor: null })}>
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'static', sunRays: false, weatherEntity: '' })}>
${this._t('gs.reset')}
</button>
<span class="spacer"></span>
@@ -4669,8 +5002,7 @@ class HouseplanCard extends LitElement {
const k = this._kioskScale;
const row = (key: 'icon' | 'font', label: string) => html`<label>${label}</label>
<div class="colorrow">
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(k[key] * 100))}
@input=${(e: Event) => this._saveKioskScale({ [key]: Number((e.target as HTMLInputElement).value) / 100 })} />
${this._rangeInput(50, 300, 5, Math.round(k[key] * 100), (n) => this._saveKioskScale({ [key]: n / 100 }))}
<span class="opv">${Math.round(k[key] * 100)}%</span>
</div>`;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._kioskDialog = false)}>
@@ -4730,6 +5062,9 @@ class HouseplanCard extends LitElement {
// Background around the plan (view/kiosk; editors keep their own canvas).
// Both settings dialogs preview their pending value live.
const stageBg = this._editing ? '' : this._stageBg(disp);
// day/night breathing: armed only with a compass AND sun.sun (docs/SUN.md)
const dayNight = !this._editing && this._effBgMode() === 'daynight' ? this._sunNow() : null;
const planDim = dayNight ? dayPhase(dayNight.elevation).planDim : 0;
return html`
<ha-card>
@@ -4800,7 +5135,7 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${dayNight ? ' daynight' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -4808,8 +5143,28 @@ class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}">
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
style="${dayNight ? `filter:brightness(${(1 - planDim).toFixed(3)})` : ''}">
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
${(() => {
// opaque paper: the image rect for image plans, the room
// contours for drawn ones (owner: no white square around an
// L-shaped house — the scene bg reaches the exterior walls).
// `space` comes from _renderCfg, so a live resize preview
// (_rszPreview) moves the paper together with the rooms.
// One <g> around ALL paper shapes: the daynight drop shadow
// (styles.ts) is composited once for the whole sheet, so
// adjacent rooms never cast seams onto each other's paper.
if (space.bg) {
const pp = this._paperRect(space)!;
return svg`<g class="hp-paperg"><rect class="hp-paper" x="${pp.x}" y="${pp.y}" width="${pp.w}" height="${pp.h}" pointer-events="none"></rect></g>`;
}
return svg`<g class="hp-paperg">${paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></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}" pointer-events="none"></rect>`,
)}</g>`;
})()}
${this._editing ? this._renderMarkupDefs(vb) : nothing}
${this._editing && !this._markup
? svg`<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>`
@@ -4909,8 +5264,10 @@ class HouseplanCard extends LitElement {
});
})()}
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._renderSunRays(space)}
${this._renderOpenWalls(disp)}
${this._editing ? this._renderAlignGuides() : nothing}
${this._opMeasure?.guide ? this._renderOpeningCenterTick() : nothing}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
@@ -4933,6 +5290,11 @@ class HouseplanCard extends LitElement {
class="measurelabel ${l.area ? 'rszarea' : ''}"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
: nothing}
${this._opMeasure
? html`<div class="measurelayer">${this._opMeasure.labels.map((l) => html`<div
class="measurelabel opshoulder"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
: nothing}
</div>
${this._zoom > 1
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
@@ -5169,8 +5531,7 @@ class HouseplanCard extends LitElement {
<button class="btn ghostbtn" @click=${() => this._vacStartFit(dev)}>${this._t('vac.fit')}</button>
</div>
<label class="srcrow">
<input type="checkbox" .checked=${v.live !== false}
@change=${(e: Event) => setVac({ live: (e.target as HTMLInputElement).checked ? null : false })} />
${this._boolInput(v.live !== false, (on) => setVac({ live: on ? null : false }))}
<span>${this._t('vac.live')}</span>
</label>
<label>${this._t('vac.trail')}</label>
@@ -5619,7 +5980,7 @@ class HouseplanCard extends LitElement {
return r.area ? this._climate().get(r.area)?.hum ?? null : null;
}
private _climateCache: { h: any; r: any; m: Map<string, AreaClimate> } | null = null;
private _climateCache: { h: any; r: any; mk: any; m: Map<string, AreaClimate> } | null = null;
/**
* Climate for every area, computed ONCE per hass snapshot (review R2-3).
@@ -5629,10 +5990,13 @@ class HouseplanCard extends LitElement {
* triggering one each (two, with humidity on).
*/
private _climate(): Map<string, AreaClimate> {
// markers are part of the key: ticking "use the device's temperature
// sensor" replaces the markers array, so the average recomputes at once
const mk = this._serverCfg?.markers;
const c = this._climateCache;
if (c && c.h === this.hass && c.r === this._iconRules) return c.m;
const m = areaClimateMap(this.hass, this._iconRules);
this._climateCache = { h: this.hass, r: this._iconRules, m };
if (c && c.h === this.hass && c.r === this._iconRules && c.mk === mk) return c.m;
const m = areaClimateMap(this.hass, this._iconRules, mk);
this._climateCache = { h: this.hass, r: this._iconRules, mk, m };
return m;
}
@@ -6053,6 +6417,18 @@ class HouseplanCard extends LitElement {
</g>` as unknown as TemplateResult;
}
/** Perpendicular dashed tick through the wall's center while a dragged opening
* sits exactly in the middle — same look as the alignment guides. Length is
* about the wall stroke (2.5) × 6 to each side. */
private _renderOpeningCenterTick(): TemplateResult {
const gd = this._opMeasure!.guide!;
const rad = ((gd.angle + 90) * Math.PI) / 180;
const half = 2.5 * 6;
return svg`<line class="alignline opcentertick"
x1="${gd.x - Math.cos(rad) * half}" y1="${gd.y - Math.sin(rad) * half}"
x2="${gd.x + Math.cos(rad) * half}" y2="${gd.y + Math.sin(rad) * half}"></line>` as unknown as TemplateResult;
}
private _roomCenter(r: RoomCfg): number[] {
if (r.poly) {
const n = r.poly.length;
@@ -6256,8 +6632,7 @@ class HouseplanCard extends LitElement {
<label>${this._t('opening.contact_label')}</label>
${opt(this._contactCandidates(), d.contact, (v) => (this._openingDialog = { ...d, contact: v }))}
${d.contact
? html`<label class="srcrow"><input type="checkbox" .checked=${d.invert}
@change=${(e: Event) => (this._openingDialog = { ...d, invert: (e.target as HTMLInputElement).checked })} />
? html`<label class="srcrow">${this._boolInput(d.invert, (v) => (this._openingDialog = { ...d, invert: v }))}
<span>${this._t('opening.invert')}</span></label>`
: nothing}
@@ -6266,11 +6641,9 @@ class HouseplanCard extends LitElement {
${opt(this._lockCandidates(), d.lock, (v) => (this._openingDialog = { ...d, lock: v }))}`
: nothing}
<label class="srcrow"><input type="checkbox" .checked=${d.flipH}
@change=${(e: Event) => (this._openingDialog = { ...d, flipH: (e.target as HTMLInputElement).checked })} />
<label class="srcrow">${this._boolInput(d.flipH, (v) => (this._openingDialog = { ...d, flipH: v }))}
<span>${this._t('opening.flip_h')}</span></label>
<label class="srcrow"><input type="checkbox" .checked=${d.flipV}
@change=${(e: Event) => (this._openingDialog = { ...d, flipV: (e.target as HTMLInputElement).checked })} />
<label class="srcrow">${this._boolInput(d.flipV, (v) => (this._openingDialog = { ...d, flipV: v }))}
<span>${this._t('opening.flip_v')}</span></label>
</div>
<div class="row">
@@ -6574,9 +6947,8 @@ class HouseplanCard extends LitElement {
<span>${this._t('marker.from_ha_option')}</span>
</label>
<label class="srcrow inline entcheck" title=${this._t('marker.show_entities_tip')}>
<input type="checkbox" .checked=${d.showEntities}
?disabled=${d.bindingMode !== 'ha'}
@change=${(e: Event) => (this._markerDialog = { ...d, showEntities: (e.target as HTMLInputElement).checked })} />
${this._boolInput(d.showEntities, (v) => (this._markerDialog = { ...d, showEntities: v }),
d.bindingMode !== 'ha')}
<span>${this._t('marker.show_entities')}</span>
</label>
</div>
@@ -6656,8 +7028,7 @@ class HouseplanCard extends LitElement {
: nothing}
${d.tapAction === 'run' || d.tapAction === 'toggle' || (!d.tapAction && d.defaultTap === 'toggle')
? html`<label class="srcrow" title=${this._t('marker.tap_confirm_tip')}>
<input type="checkbox" .checked=${d.tapConfirm}
@change=${(e: Event) => (this._markerDialog = { ...d, tapConfirm: (e.target as HTMLInputElement).checked })} />
${this._boolInput(d.tapConfirm, (v) => (this._markerDialog = { ...d, tapConfirm: v }))}
<span>${this._t('marker.tap_confirm')}</span>
</label>`
: nothing}
@@ -6695,14 +7066,18 @@ class HouseplanCard extends LitElement {
</div>`
: nothing}
${this._bindingHasClimate(d.binding)
? html`<label class="srcrow climrow" title=${this._t('marker.use_climate_temp_tip')}>
${this._boolInput(d.useClimateTemp, (v) => (this._markerDialog = { ...d, useClimateTemp: v }))}
<span>${this._t('marker.use_climate_temp')}</span>
</label>`
: nothing}
<label class="srcrow" title=${this._t('marker.is_light_tip')}>
<input type="checkbox" .checked=${d.isLight}
@change=${(e: Event) => (this._markerDialog = { ...d, isLight: (e.target as HTMLInputElement).checked })} />
${this._boolInput(d.isLight, (v) => (this._markerDialog = { ...d, isLight: v }))}
<span>${this._t('marker.is_light')}</span>
</label>
<label class="srcrow" title=${this._t('marker.hide_tip')}>
<input type="checkbox" .checked=${d.hideFromPlan}
@change=${(e: Event) => (this._markerDialog = { ...d, hideFromPlan: (e.target as HTMLInputElement).checked })} />
${this._boolInput(d.hideFromPlan, (v) => (this._markerDialog = { ...d, hideFromPlan: v }))}
<span>${this._t('marker.hide')}</span>
</label>
<label>${this._t('marker.glow_radius_label')}</label>
@@ -6742,20 +7117,17 @@ class HouseplanCard extends LitElement {
<input type="color" .value=${d.rippleColor || '#3ea6ff'}
@input=${(e: Event) => (this._markerDialog = { ...d, rippleColor: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._t('marker.ripple_size')}</span>
<input type="range" min="2" max="8" step="0.5" .value=${String(d.rippleSize)}
@input=${(e: Event) => (this._markerDialog = { ...d, rippleSize: Number((e.target as HTMLInputElement).value) })} />
${this._rangeInput(2, 8, 0.5, d.rippleSize, (n) => (this._markerDialog = { ...d, rippleSize: n }))}
<span class="opv">×${d.rippleSize}</span>
</div>`
: nothing}
<label>${this._t('marker.size_label')}</label>
<div class="colorrow">
<input type="range" min="0.5" max="3" step="0.1" .value=${String(d.size)}
@input=${(e: Event) => (this._markerDialog = { ...d, size: Number((e.target as HTMLInputElement).value) })} />
${this._rangeInput(0.5, 3, 0.1, d.size, (n) => (this._markerDialog = { ...d, size: n }))}
<span class="opv">×${d.size.toFixed(1)}</span>
<span class="opl">${this._t('marker.angle_label')}</span>
<input type="range" min="0" max="350" step="10" .value=${String(d.angle)}
@input=${(e: Event) => (this._markerDialog = { ...d, angle: Number((e.target as HTMLInputElement).value) })} />
${this._rangeInput(0, 350, 10, d.angle, (n) => (this._markerDialog = { ...d, angle: n }))}
<span class="opv">${d.angle}°</span>
</div>
@@ -6866,33 +7238,28 @@ class HouseplanCard extends LitElement {
<label class="dispsection">${this._t('space.display_section')}</label>
<label class="srcrow">
<input type="checkbox" .checked=${d.showBorders}
@change=${(e: Event) => (this._spaceDialog = { ...d, showBorders: (e.target as HTMLInputElement).checked })} />
${this._boolInput(d.showBorders, (v) => (this._spaceDialog = { ...d, showBorders: v }))}
<span>${this._t('space.show_borders')}</span>
</label>
<label class="srcrow">
<input type="checkbox" .checked=${d.showNames}
@change=${(e: Event) => (this._spaceDialog = { ...d, showNames: (e.target as HTMLInputElement).checked })} />
${this._boolInput(d.showNames, (v) => (this._spaceDialog = { ...d, showNames: v }))}
<span>${this._t('space.show_names')}</span>
</label>
<label class="srcrow">
<input type="checkbox" .checked=${d.showLqi}
@change=${(e: Event) => (this._spaceDialog = { ...d, showLqi: (e.target as HTMLInputElement).checked })} />
${this._boolInput(d.showLqi, (v) => (this._spaceDialog = { ...d, showLqi: v }))}
<span>${this._t('space.show_lqi')}</span>
</label>
<label class="dispsection">${this._t('space.roomcard_section')}</label>
${([['labelTemp', 'space.label_temp'], ['labelHum', 'space.label_hum'],
['labelLqi', 'space.label_lqi'], ['labelLight', 'space.label_light']] as const).map(
([f, k]) => html`<label class="srcrow">
<input type="checkbox" .checked=${d[f]}
@change=${(e: Event) => (this._spaceDialog = { ...d, [f]: (e.target as HTMLInputElement).checked })} />
${this._boolInput(d[f], (v) => (this._spaceDialog = { ...d, [f]: v }))}
<span>${this._t(k)}</span>
</label>`,
)}
<label>${this._t('space.card_font')}</label>
<div class="colorrow gsrow">
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(d.cardFontScale * 100))}
@input=${(e: Event) => (this._spaceDialog = { ...d, cardFontScale: Number((e.target as HTMLInputElement).value) / 100 })} />
${this._rangeInput(50, 300, 5, Math.round(d.cardFontScale * 100), (n) => (this._spaceDialog = { ...d, cardFontScale: n / 100 }))}
<span class="opv">${Math.round(d.cardFontScale * 100)}%</span>
</div>
${this._renderCardPreview(d.cardFontScale, 1, 1)}
@@ -6901,19 +7268,56 @@ class HouseplanCard extends LitElement {
<input type="color" .value=${d.roomColor}
@input=${(e: Event) => (this._spaceDialog = { ...d, roomColor: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._t('space.opacity')}</span>
<input type="range" min="0" max="100" .value=${String(Math.round(d.roomOpacity * 100))}
@input=${(e: Event) => (this._spaceDialog = { ...d, roomOpacity: Number((e.target as HTMLInputElement).value) / 100 })} />
${this._rangeInput(0, 100, 1, Math.round(d.roomOpacity * 100), (n) => (this._spaceDialog = { ...d, roomOpacity: n / 100 }))}
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
</div>
<label>${this._t('space.bg_color')}</label>
<label>${this._t('space.bg_mode')}</label>
<select class="areasel"
@change=${(e: Event) => {
const v = (e.target as HTMLSelectElement).value;
this._spaceDialog = { ...d, bgMode: v === 'static' || v === 'daynight' ? (v as any) : null };
}}>
<option value="" ?selected=${d.bgMode === null}>${this._t('space.sun_inherit')}</option>
<option value="static" ?selected=${d.bgMode === 'static'}>${this._t('gs.bg_static')}</option>
<option value="daynight" ?selected=${d.bgMode === 'daynight'}>${this._t('gs.bg_daynight')}</option>
</select>
${(d.bgMode ?? bgModeOf(this._settings, {})) === 'static'
? html`<label>${this._t('space.bg_color')}</label>
<div class="colorrow">
<input type="color" .value=${d.bgColor || stageBgOf(this._settings, { bgColor: null }) || this._stageBgHex()}
@input=${(e: Event) => (this._spaceDialog = { ...d, bgColor: (e.target as HTMLInputElement).value })} />
${d.bgColor
? html`<button class="btn ghost" @click=${() => (this._spaceDialog = { ...d, bgColor: null })}>
${this._t('space.bg_inherit')}</button>`
: html`<span class="opl">${this._t('space.bg_inherited')}</span>`}
</div>`
: nothing}
<label>${this._t('space.north')}</label>
<div class="colorrow">
<input type="color" .value=${d.bgColor || stageBgOf(this._settings, { bgColor: null }) || this._stageBgHex()}
@input=${(e: Event) => (this._spaceDialog = { ...d, bgColor: (e.target as HTMLInputElement).value })} />
${d.bgColor
? html`<button class="btn ghost" @click=${() => (this._spaceDialog = { ...d, bgColor: null })}>
${this._t('space.bg_inherit')}</button>`
: html`<span class="opl">${this._t('space.bg_inherited')}</span>`}
<input class="namein tempin" type="number" min="0" max="359" step="1"
placeholder=${this._t('space.sun_inherit')}
.value=${d.northDeg === null ? '' : String(d.northDeg)}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value.trim();
const n = raw === '' ? null : Math.round(Number(raw));
this._spaceDialog = { ...d, northDeg: n !== null && Number.isFinite(n) ? Math.min(359, Math.max(0, n)) : null };
}} />
<span class="opl">${d.northDeg === null
? this._t('space.north_inherited', {
v: northDegOf(this._settings, {}) === null ? '—' : String(northDegOf(this._settings, {})) + '°',
})
: '°'}</span>
</div>
<label>${this._t('space.sun_rays')}</label>
<select class="areasel"
@change=${(e: Event) => {
const v = (e.target as HTMLSelectElement).value;
this._spaceDialog = { ...d, sunRays: v === '' ? null : v === '1' };
}}>
<option value="" ?selected=${d.sunRays === null}>${this._t('space.sun_inherit')}</option>
<option value="1" ?selected=${d.sunRays === true}>${this._t('space.sun_on')}</option>
<option value="0" ?selected=${d.sunRays === false}>${this._t('space.sun_off')}</option>
</select>
<label>${this._t('space.fill_label')}</label>
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
([v, k]) => html`<label class="srcrow">
@@ -7099,14 +7503,12 @@ class HouseplanCard extends LitElement {
<label class="dispsection">${this._t('room.sizes_section')}</label>
<label>${this._t('room.name_scale')}</label>
<div class="colorrow gsrow">
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(this._roomNameScale * 100))}
@input=${(e: Event) => { this._roomNameScale = Number((e.target as HTMLInputElement).value) / 100; this.requestUpdate(); }} />
${this._rangeInput(50, 300, 5, Math.round(this._roomNameScale * 100), (n) => { this._roomNameScale = n / 100; this.requestUpdate(); })}
<span class="opv">${Math.round(this._roomNameScale * 100)}%</span>
</div>
<label>${this._t('room.label_scale')}</label>
<div class="colorrow gsrow">
<input type="range" min="50" max="300" step="5" .value=${String(Math.round(this._roomLabelScale * 100))}
@input=${(e: Event) => { this._roomLabelScale = Number((e.target as HTMLInputElement).value) / 100; this.requestUpdate(); }} />
${this._rangeInput(50, 300, 5, Math.round(this._roomLabelScale * 100), (n) => { this._roomLabelScale = n / 100; this.requestUpdate(); })}
<span class="opv">${Math.round(this._roomLabelScale * 100)}%</span>
</div>
${this._renderCardPreview(
+24 -1
View File
@@ -328,6 +328,8 @@
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
"room.settings_short": "Room settings",
"room.unnamed": "Unnamed room",
"marker.use_climate_temp": "Use the device's temperature sensor",
"marker.use_climate_temp_tip": "Air conditioners and thermostats know the room temperature (current_temperature). Shows it as a badge next to the icon and counts it in the room average — like a regular thermometer.",
"marker.is_light": "This device is a light source",
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
"confirm.unlock": "Unlock “{name}”?",
@@ -384,7 +386,28 @@
"gs.bg_color": "Background around the plan",
"gs.bg_default": "Theme default",
"gs.bg_theme": "theme default",
"gs.bg_mode": "Plan background",
"gs.bg_static": "Static color",
"gs.bg_daynight": "Follows the sun (day/night)",
"gs.bg_daynight_hint": "The stage follows the sun: neutral day, warm golden hour, deep night. Needs the compass below.",
"gs.sun_group": "Sun",
"gs.sun_missing": "The sun.sun entity was not found — the sun features stay off.",
"gs.north": "North on the plan",
"gs.north_ph": "not set",
"gs.north_hint": "Point the arrow at north (1° steps, 15° with Shift) or type the degrees — until then the sun features stay off.",
"gs.north_clear": "Clear",
"gs.north_letter": "N",
"gs.sun_rays": "Sunlight through windows",
"gs.weather": "Weather entity",
"gs.weather_ph": "weather.home (optional)",
"space.bg_color": "Background around the plan",
"space.bg_inherit": "Inherit general",
"space.bg_inherited": "inherits general settings"
"space.bg_inherited": "inherits general settings",
"space.bg_mode": "Plan background",
"space.north": "North on the plan (override)",
"space.north_inherited": "inherited: {v}",
"space.sun_rays": "Sunlight through windows",
"space.sun_inherit": "Inherit general",
"space.sun_on": "On",
"space.sun_off": "Off"
}
+24 -1
View File
@@ -328,6 +328,8 @@
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
"room.settings_short": "Настройки комнаты",
"room.unnamed": "Комната без имени",
"marker.use_climate_temp": "Использовать датчик температуры устройства",
"marker.use_climate_temp_tip": "Кондиционеры и термостаты знают температуру комнаты (current_temperature). Показывает её плашкой у значка и учитывает в средней температуре комнаты — как обычный термометр.",
"marker.is_light": "Это устройство — источник света",
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
"confirm.unlock": "Открыть замок «{name}»?",
@@ -384,7 +386,28 @@
"gs.bg_color": "Цвет фона вокруг плана",
"gs.bg_default": "Как в теме",
"gs.bg_theme": "по умолчанию — из темы",
"gs.bg_mode": "Фон плана",
"gs.bg_static": "Статичный цвет",
"gs.bg_daynight": "Следует за солнцем (день/ночь)",
"gs.bg_daynight_hint": "Фон следует за солнцем: нейтральный день, тёплый золотой час, глубокая ночь. Нужен компас ниже.",
"gs.sun_group": "Солнце",
"gs.sun_missing": "Сущность sun.sun не найдена — солнечные функции выключены.",
"gs.north": "Север на плане",
"gs.north_ph": "не задан",
"gs.north_hint": "Направьте стрелку на север (шаг 1°, с Shift 15°) или введите градусы — до этого солнечные функции молчат.",
"gs.north_clear": "Сбросить",
"gs.north_letter": "С",
"gs.sun_rays": "Солнце в окнах",
"gs.weather": "Сущность погоды",
"gs.weather_ph": "weather.home (необязательно)",
"space.bg_color": "Цвет фона вокруг плана",
"space.bg_inherit": "Наследовать общий",
"space.bg_inherited": "наследуется из общих настроек"
"space.bg_inherited": "наследуется из общих настроек",
"space.bg_mode": "Фон плана",
"space.north": "Север на плане (переопределение)",
"space.north_inherited": "наследуется: {v}",
"space.sun_rays": "Солнце в окнах",
"space.sun_inherit": "Наследовать общий",
"space.sun_on": "Вкл",
"space.sun_off": "Выкл"
}
+94
View File
@@ -62,6 +62,30 @@ export function roomPoly(r: any): number[][] | null {
return null;
}
/**
* Paper shapes for a hand-drawn plan (owner 2026-08-03): the opaque paper
* follows the ROOM CONTOURS, not their bounding box — an L-shaped house or a
* detached building must not grow a white rectangle around itself. One shape
* per room, in EXACTLY the geometry the room itself renders (same polygon
* points string, same rounded rect), so the paper never peeks past a wall;
* the union of the stack is the paper. Islands simply paint over their
* parent; open (virtual) boundaries do not affect the paper at all.
*/
export function paperRoomShapes(rooms: any[]): Array<
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
for (const r of rooms || []) {
if (r?.poly?.length >= 3) {
out.push({ poly: r.poly.map((p: number[]) => p.join(',')).join(' ') });
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
}
}
return out;
}
export function roomEdges(rooms: any[]): number[][] {
const out: number[][] = [];
const seen = new Set<string>();
@@ -115,6 +139,76 @@ export function snapToWall(
return best;
}
export interface OpeningShoulders {
/** Endpoints of the wall edge — the ONE room-polygon edge the opening sits on. */
wallA: number[];
wallB: number[];
/** Distance (render units) from each wall end to the NEAREST opening edge, >= 0. */
sideA: number;
sideB: number;
/** Midpoint of each shoulder — where a measure badge sits. */
midA: number[];
midB: number[];
/** Center of the wall edge. */
wallCenter: number[];
/** The opening's center coincides with the wall center (within tol). */
centered: boolean;
}
/**
* Shoulders of an opening being dragged along a wall: distances from the
* opening's edges to the ends of the wall it sits on, plus the "centered on the
* wall" flag (owner 2026-08-03: live ruler badges + a perpendicular dashed tick
* when the opening is exactly in the middle). The wall is ONE room-polygon
* edge — exactly the edge the opening is snapped to (owner 2026-08-03:
* «only the wall of one room», collinear edges of a NEIGHBOURING room are
* never merged in). Selection mirrors snapToWall: among the edges collinear
* with the opening's line, the one nearest to its center wins, first in
* roomEdges order on a tie — so the ruler always measures the same edge the
* drag snapped to. Works for angled walls too: everything is measured along
* the wall's direction. Returns null when the center does not lie on any wall.
*/
export function openingShoulders(
center: number[], angleDeg: number, lengthPx: number, rooms: any[], tol: number, eps = 1.0,
): OpeningShoulders | null {
const rad = (angleDeg * Math.PI) / 180;
const dir = [Math.cos(rad), Math.sin(rad)];
// edges lying on the opening's wall line → pick the ONE nearest to the
// center along that line (t=0 at the center). Same tie-break as snapToWall:
// strictly-nearer wins, so on a tie (shared-boundary fragments, a junction
// vertex) the first edge in roomEdges order is used — the very edge the
// drag snapped to. NO merging of collinear runs across rooms.
let wall: [number, number] | null = null;
let bestD = eps;
for (const e of roomEdges(rooms)) {
const pts = [[e[0], e[1]], [e[2], e[3]]];
const off = (p: number[]) => Math.abs(dir[0] * (p[1] - center[1]) - dir[1] * (p[0] - center[0]));
if (off(pts[0]) > eps || off(pts[1]) > eps) continue; // not collinear with the wall line
const t0 = (pts[0][0] - center[0]) * dir[0] + (pts[0][1] - center[1]) * dir[1];
const t1 = (pts[1][0] - center[0]) * dir[0] + (pts[1][1] - center[1]) * dir[1];
const lo = Math.min(t0, t1), hi = Math.max(t0, t1);
const d = lo > 0 ? lo : hi < 0 ? -hi : 0; // distance from the center to the edge, along the line
if (d < bestD) { bestD = d; wall = [lo, hi]; }
}
if (!wall) return null; // center is not on any edge of this direction
const [tMin, tMax] = wall;
const half = lengthPx / 2;
const sideA = Math.max(0, -half - tMin);
const sideB = Math.max(0, tMax - half);
const at = (t: number) => [center[0] + dir[0] * t, center[1] + dir[1] * t];
const tMid = (tMin + tMax) / 2;
return {
wallA: at(tMin),
wallB: at(tMax),
sideA,
sideB,
midA: at((tMin - half) / 2),
midB: at((half + tMax) / 2),
wallCenter: at(tMid),
centered: Math.abs(tMid) <= tol,
};
}
/**
* How far open an opening is drawn, 0..1, from its contact sensor state.
* No sensor bound → doors default to open (the familiar swing symbol), windows to
+9
View File
@@ -205,6 +205,15 @@ class HouseplanSpaceCard extends LitElement {
height: 100%;
display: block;
}
/* Opaque plan paper — the scene bg_color/daynight sky shows only AROUND
the plan (owner 2026-08-03). The static card keeps its historical
canvas colour: the theme card background. Drawn plans paper the room
contours (per-room shapes, fill only, no stroke) — same contract as
the full card. */
.hp-paper {
fill: var(--ha-card-background, var(--card-background-color, #111));
stroke: none;
}
.hp-static-stage .devlayer {
position: absolute;
inset: 0;
+29 -3
View File
@@ -8,9 +8,10 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf, paperRoomShapes } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
@@ -146,12 +147,37 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: [];
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
// the static card paints the same background around the plan as the full one
const stageBg = stageBgOf(o.cfg?.settings, disp);
// The static card paints the same background around the plan as the full
// one. In 'daynight' mode (docs/SUN.md) the sun's elevation paints it —
// wedges stay full-card-only in v1, the background alone follows the sky.
const spaceSettings = (o.cfg.spaces.find((sp: any) => sp.id === o.spaceId) as any)?.settings || {};
let sunBg = '';
if (bgModeOf(o.cfg?.settings, spaceSettings) === 'daynight' && northDegOf(o.cfg?.settings, spaceSettings) !== null) {
const sun = sunStateOf(o.hass);
if (sun) sunBg = dayPhase(sun.elevation).bg;
}
const stageBg = sunBg || stageBgOf(o.cfg?.settings, disp);
// Opaque plan paper (owner 2026-08-03), same contract as the full card: the
// scene background never bleeds through the plan. With a backdrop image the
// paper is the image rect; a hand-drawn plan papers the ROOM CONTOURS
// (paperRoomShapes) — never their bounding box, so the scene colour reaches
// the exterior walls of an L-shaped house and fills the gaps between
// detached buildings. An empty drawn space has no paper at all.
const paper = space.bg
? { x: space.bg.x, y: space.bg.y, w: space.bg.w, h: space.bg.h }
: null;
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${paper
? svg`<rect class="hp-paper" x="${paper.x}" y="${paper.y}" width="${paper.w}" height="${paper.h}"></rect>`
: paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
: nothing}
+325 -196
View File
File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
/**
* Sun on the plan — pure logic only (docs/SUN.md).
*
* Angles, the day phase palette, exterior-wall detection, window light
* wedges and their clipping, the cloud factor and the settings
* inheritance. Coordinates are render units (NORM_W-scaled canvas,
* y grows DOWNWARD), same as the card's space model. Nothing here
* touches Lit, the DOM or `hass` beyond a plain state object.
*/
import { intersection } from 'polyclip-ts';
import { pointInPolygon, lerpColor } from './logic';
// ---------------- angles ----------------
/** Normalise any angle in degrees to [0, 360). */
export function norm360(deg: number): number {
const d = deg % 360;
return d < 0 ? d + 360 : d;
}
/**
* The sun's bearing on the CANVAS: 0 = up, clockwise (docs/SUN.md).
* `north_deg` is how far true north is rotated clockwise from "canvas up".
*/
export function planSunAngle(azimuth: number, northDeg: number): number {
return norm360(azimuth - northDeg);
}
/** Unit vector TOWARD the sun on the canvas (x right, y down). */
export function sunDirOnPlan(azimuth: number, northDeg: number): [number, number] {
const a = (planSunAngle(azimuth, northDeg) * Math.PI) / 180;
return [Math.sin(a), -Math.cos(a)];
}
// ---------------- day phase (bg_mode: 'daynight') ----------------
export interface DayPhase {
/** Stage background color for the current elevation. */
bg: string;
/** How much the PLAN itself dims (0..0.1 — readability first). */
planDim: number;
/** 1 = golden hour / horizon, 0 = plain daylight. Drives wedge color. */
warmth: number;
}
/** elevation° → color stops; piecewise-linear between neighbours. */
const BG_STOPS: [number, string][] = [
[-90, '#070c14'], // deep night
[-12, '#070c14'],
[-4, '#131a28'], // dusk cools down
[0, '#4a3527'], // warm band right at the horizon
[10, '#e8ddcf'], // morning light — warm and bright on the way to white
[30, '#ffffff'], // plain day: the brightest moment is white (owner 2026-08-03)
[90, '#ffffff'],
];
const clamp01 = (t: number) => Math.min(1, Math.max(0, t));
/** Background, plan dim and warmth for a sun elevation (docs/SUN.md). */
export function dayPhase(elevation: number): DayPhase {
const e = Math.min(90, Math.max(-90, Number(elevation) || 0));
let bg = BG_STOPS[BG_STOPS.length - 1][1];
for (let i = 1; i < BG_STOPS.length; i++) {
const [e0, c0] = BG_STOPS[i - 1];
const [e1, c1] = BG_STOPS[i];
if (e <= e1) {
bg = lerpColor(c0, c1, (e - e0) / (e1 - e0));
break;
}
}
return {
bg,
// full 10% below ~-6°, gone above +10° — a slow dusk, not a switch
planDim: clamp01((10 - e) / 16) * 0.1,
warmth: e < 0 ? 1 : clamp01(1 - e / 10),
};
}
// ---------------- exterior walls & windows ----------------
export interface SunRoom { id: string; poly: number[][] }
/** A window opening in render units: centre, wall angle°, full length. */
export interface SunWindow { id: string; x: number; y: number; angle: number; length: number }
/**
* Is the wall stretch at `mid` with outward normal `n` exterior — i.e. is
* there NO room just outside it? Probes one point `probe` units out.
*/
export function isExteriorWall(mid: number[], n: number[], rooms: SunRoom[], probe = 6): boolean {
const p = [mid[0] + n[0] * probe, mid[1] + n[1] * probe];
return !rooms.some((r) => r.poly.length >= 3 && pointInPolygon(p, r.poly));
}
/**
* The wall a window sits on: probe both sides of the window centre. Exactly
* one side inside a room → exterior wall; the outward normal points to the
* empty side and the room on the other side hosts the wedge. Both sides in
* rooms (interior walls, open/virtual boundaries) or neither (a window not
* on any boundary) → null: this window never casts light (docs/SUN.md).
*/
export function windowWallInfo(
win: { x: number; y: number; angle: number },
rooms: SunRoom[],
probe = 6,
): { normal: [number, number]; roomId: string } | null {
const rad = (win.angle * Math.PI) / 180;
// perpendicular to the wall (the wall runs along `angle`)
const n: [number, number] = [Math.sin(rad), -Math.cos(rad)];
const roomAt = (side: 1 | -1): SunRoom | null => {
const p = [win.x + n[0] * probe * side, win.y + n[1] * probe * side];
return rooms.find((r) => r.poly.length >= 3 && pointInPolygon(p, r.poly)) || null;
};
const plus = roomAt(1);
const minus = roomAt(-1);
if (plus && minus) return null; // interior wall (incl. open boundaries)
if (!plus && !minus) return null; // not on any room's wall
return plus
? { normal: [-n[0], -n[1]], roomId: plus.id! }
: { normal: n, roomId: minus!.id };
}
/** Does the sun actually shine INTO this window right now? (A grazing sun
* exactly along the wall does not count — hence the epsilon, which also
* swallows the sin/cos float dust of the right-angle directions.) */
export function windowLit(normal: number[], sunDir: number[], elevation: number): boolean {
return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > 1e-9;
}
// ---------------- wedge geometry ----------------
/**
* Wedge length in WINDOW LENGTHS: longest (~2.5) at sunrise/sunset, shortest
* (~0.8) at the zenith. `0.8 + 1.7·(1 − e/90)^1.6` — long low shafts, short
* noon pools, smooth in between (docs/SUN.md).
*/
export function rayLength(elevation: number): number {
const e = Math.min(90, Math.max(0, elevation));
return 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
}
/** The unclipped wedge: window span a-b extruded by `len` along `dir`. */
export function rayQuad(a: number[], b: number[], dir: number[], len: number): number[][] {
return [
[a[0], a[1]],
[b[0], b[1]],
[b[0] + dir[0] * len, b[1] + dir[1] * len],
[a[0] + dir[0] * len, a[1] + dir[1] * len],
];
}
/** Clip a wedge by the room outline. Returns outer rings (may be several). */
export function clipToRoom(quad: number[][], room: number[][]): number[][][] {
try {
const res = intersection(
[[...quad.map((p) => [p[0], p[1]]), [quad[0][0], quad[0][1]]]] as any,
[[...room.map((p) => [p[0], p[1]]), [room[0][0], room[0][1]]]] as any,
);
const out: number[][][] = [];
for (const poly of res as any) {
const ring = poly?.[0];
if (!Array.isArray(ring) || ring.length < 4) continue;
out.push(ring.slice(0, ring.length - 1).map((p: number[]) => [p[0], p[1]]));
}
return out;
} catch {
return []; // a degenerate clip draws nothing rather than everything
}
}
export interface SunRay {
openingId: string;
roomId: string;
/** Clipped wedge outline(s), render units. */
polys: number[][][];
/** Window span endpoints (the bright end of the gradient). */
a: number[];
b: number[];
/** Direction the light travels (AWAY from the sun), unit vector. */
dir: [number, number];
/** Wedge reach in render units (the gradient's fade distance). */
len: number;
}
/**
* All wedges of a space for one sun position. Pure and deterministic — the
* card memoises the result on (azimuth, elevation, config rev) and reuses
* it across hass ticks (docs/SUN.md). Mutual shading of building wings is
* NOT considered (documented limit).
*/
export function computeSunRays(
rooms: SunRoom[],
windows: SunWindow[],
azimuth: number,
elevation: number,
northDeg: number,
): SunRay[] {
if (!(elevation > 0)) return [];
const toSun = sunDirOnPlan(azimuth, northDeg);
const away: [number, number] = [-toSun[0], -toSun[1]];
const k = rayLength(elevation);
const out: SunRay[] = [];
for (const w of windows) {
if (!(w.length > 0)) continue;
const info = windowWallInfo(w, rooms);
if (!info || !windowLit(info.normal, toSun, elevation)) continue;
const room = rooms.find((r) => r.id === info.roomId);
if (!room) continue;
const rad = (w.angle * Math.PI) / 180;
const hx = (Math.cos(rad) * w.length) / 2;
const hy = (Math.sin(rad) * w.length) / 2;
const a = [w.x - hx, w.y - hy];
const b = [w.x + hx, w.y + hy];
const len = k * w.length;
const polys = clipToRoom(rayQuad(a, b, away, len), room.poly);
if (!polys.length) continue;
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len });
}
return out;
}
// ---------------- wedge dressing ----------------
/** Peak wedge opacity; two overlapping wedges stay readable (docs/SUN.md). */
export const RAY_MAX_ALPHA = 0.18;
/** Wedge opacity: ramps in over the first ~2° so sunrise never pops. */
export function rayAlpha(elevation: number, cloud = 1): number {
if (!(elevation > 0)) return 0;
return RAY_MAX_ALPHA * Math.min(1, elevation / 2) * clamp01(cloud);
}
/** Wedge color: warm orange at the horizon → neutral daylight. */
export function rayColor(warmth: number): string {
return lerpColor('#ffe9c2', '#ff9a45', clamp01(warmth));
}
// ---------------- cloud cover ----------------
/** weather.* state → wedge opacity multiplier (docs/SUN.md table). */
const CLOUD_FACTORS: Record<string, number> = {
'clear': 1, 'sunny': 1, 'clear-night': 1, 'windy': 1, 'exceptional': 1,
'partlycloudy': 0.7, 'windy-variant': 0.7,
'cloudy': 0.4,
'overcast': 0.25, 'fog': 0.25,
'rainy': 0, 'pouring': 0, 'snowy': 0, 'snowy-rainy': 0,
'hail': 0, 'lightning': 0, 'lightning-rainy': 0,
};
/**
* Cloud multiplier for a weather entity state. Unset entity, unknown state
* or a dead sensor → 1: a broken weather sensor must not kill the sun.
*/
export function cloudFactor(state: string | null | undefined): number {
if (!state) return 1;
const f = CLOUD_FACTORS[String(state).toLowerCase()];
return f === undefined ? 1 : f;
}
// ---------------- settings inheritance (global → space) ----------------
const intDeg = (v: any): number | null =>
typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 359 ? v : null;
/** Effective compass: the space override wins, null = feature inert. */
export function northDegOf(settings: any, spaceSettings: any): number | null {
const sp = intDeg(spaceSettings?.north_deg);
if (sp !== null) return sp;
return intDeg(settings?.north_deg);
}
export type BgMode = 'static' | 'daynight';
/** Effective background mode; anything unknown falls back to 'static'. */
export function bgModeOf(settings: any, spaceSettings: any): BgMode {
const pick = (v: any): BgMode | null => (v === 'static' || v === 'daynight' ? v : null);
return pick(spaceSettings?.bg_mode) ?? pick(settings?.bg_mode) ?? 'static';
}
/** Effective «sun in the windows» flag; default OFF (docs/SUN.md). */
export function sunRaysOn(settings: any, spaceSettings: any): boolean {
const sp = spaceSettings?.sun_rays;
if (typeof sp === 'boolean') return sp;
return settings?.sun_rays === true;
}
/** The optional weather entity (GLOBAL settings only). */
export function weatherEntityOf(settings: any): string | null {
const v = settings?.weather_entity;
return typeof v === 'string' && v.trim() ? v.trim() : null;
}
/** Read sun.sun out of a hass-like object; null when absent/garbage. */
export function sunStateOf(hass: any): { azimuth: number; elevation: number } | null {
const attrs = hass?.states?.['sun.sun']?.attributes;
const az = Number(attrs?.azimuth);
const el = Number(attrs?.elevation);
return Number.isFinite(az) && Number.isFinite(el) ? { azimuth: az, elevation: el } : null;
}
+7
View File
@@ -81,6 +81,13 @@ export interface Marker {
* null/undefined = auto: any light.* entity of the device.
*/
is_light?: boolean | null;
/**
* Climate devices (AC, thermostat) know the room temperature
* (attributes.current_temperature). Opt-in per marker: show it as a badge
* next to the icon and include it in the room average. null/absent = off —
* nothing changes without an explicit tick (owner's spec, 2026-08-03).
*/
use_climate_temp?: boolean | null;
}
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
+60 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, climateTempFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -469,6 +469,65 @@ test('areaClimateMap: one registry pass for all areas (review R2-3)', () => {
assert.equal(scans, 120, 'wrapper считает по одной зоне — им нельзя пользоваться в рендере');
});
test('climateTempFor: первая climate-сущность с валидной current_temperature', () => {
const h = {
states: {
'climate.dead': { state: 'unavailable', attributes: {} },
'climate.mute': { state: 'heat', attributes: {} }, // атрибута нет — мимо
'climate.ac': { state: 'cool', attributes: { current_temperature: 23.46 } },
'climate.second': { state: 'heat', attributes: { current_temperature: 30 } },
'sensor.t': { state: '21', attributes: { device_class: 'temperature' } },
},
};
// несколько climate — берётся ПЕРВАЯ с валидным значением, с округлением до 0.1
assert.equal(climateTempFor(h, ['climate.dead', 'climate.mute', 'climate.ac', 'climate.second']), 23.5);
// не-climate сущности не участвуют вовсе
assert.equal(climateTempFor(h, ['sensor.t']), null);
// unavailable/unknown/без атрибута — null: ни плашки, ни голоса в средней
assert.equal(climateTempFor(h, ['climate.dead']), null);
assert.equal(climateTempFor(h, ['climate.mute']), null);
});
test('areaClimateMap: климат с опцией участвует в средней (термометр 20 + кондиционер 23.5)', () => {
const hass = {
devices: {
dt: { id: 'dt', name: 'Датчик температуры', area_id: 'living' },
dac: { id: 'dac', name: 'Кондиционер', model: 'AC-12', area_id: 'living' },
},
entities: {
'sensor.t': { device_id: 'dt', platform: 'mqtt' },
'climate.ac': { device_id: 'dac', platform: 'demo' },
},
states: {
'sensor.t': { state: '20', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'climate.ac': { state: 'cool', attributes: { current_temperature: 23.5 } },
},
};
// без опции — как раньше: только термометр
assert.equal(areaClimateMap(hass).get('living').temp, 20);
assert.equal(areaClimateMap(hass, undefined, [{ id: 'm1', binding: 'device:dac' }]).get('living').temp, 20);
// опция включена: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
const markers = [{ id: 'm1', binding: 'device:dac', use_climate_temp: true }];
assert.equal(areaClimateMap(hass, undefined, markers).get('living').temp, 21.8);
// entity-привязка работает так же
const entMarkers = [{ id: 'm1', binding: 'entity:climate.ac', use_climate_temp: true }];
assert.equal(areaClimateMap(hass, undefined, entMarkers).get('living').temp, 21.8);
// unavailable климат не ломает и не участвует
const broken = { ...hass, states: { ...hass.states, 'climate.ac': { state: 'unavailable', attributes: {} } } };
assert.equal(areaClimateMap(broken, undefined, markers).get('living').temp, 20);
});
test('areaClimateMap: комната только с климат-источником появляется в карте', () => {
const hass = {
devices: { dac: { id: 'dac', name: 'Кондиционер', area_id: 'bed' } },
entities: { 'climate.bed': { device_id: 'dac', platform: 'demo' } },
states: { 'climate.bed': { state: 'heat', attributes: { current_temperature: 24.2 } } },
};
assert.equal(areaClimateMap(hass).get('bed'), undefined);
const markers = [{ id: 'm1', binding: 'device:dac', use_climate_temp: true }];
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('bed'), { temp: 24.2, hum: null });
});
test('areaClimateMap: температура и влажность живут в одной записи', () => {
const hass = {
devices: { q: { id: 'q', name: 'Qingping Air Monitor', area_id: 'hall' } },
+129 -2
View File
@@ -14,8 +14,8 @@ import {
roomFillModeOf,
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
@@ -959,3 +959,130 @@ test('tap action run: explicit-only, with runnable targets (owner spec 2026-07-2
// locks and alarms stay forbidden, run or not
assert.equal(resolveTapAction('toggle', undefined, 'lock'), 'info');
});
test('paperRoomShapes: paper follows room contours, one shape per room (owner 2026-08-03)', () => {
const L = [[100, 100], [600, 100], [600, 350], [350, 350], [350, 600], [100, 600]];
const rooms = [
{ id: 'L', poly: L }, // L-shaped: polygon, verbatim
{ id: 'q', x: 650, y: 650, w: 200, h: 100 }, // legacy rect: same rounded rect the room draws
{ id: 'bad' }, // no geometry -> no paper
];
const shapes = paperRoomShapes(rooms);
assert.equal(shapes.length, 2, 'one shape per room WITH geometry');
// the polygon uses exactly the room's own points string — same coordinates,
// so the paper can never peek past a wall
assert.deepEqual(shapes[0], { poly: L.map((p) => p.join(',')).join(' ') });
// the rect mirrors the room shape incl. its corner rounding rx
assert.deepEqual(shapes[1], { rect: { x: 650, y: 650, w: 200, h: 100, rx: 100 * 0.03 } });
// degenerate inputs never throw and produce no paper
assert.deepEqual(paperRoomShapes([]), []);
assert.deepEqual(paperRoomShapes(null), []);
assert.deepEqual(paperRoomShapes([{ poly: [[0, 0], [1, 1]] }]), [], 'a 2-point poly is not a surface');
});
// ---------------- opening drag: shoulder rulers + "centered on the wall" ----------------
// (owner 2026-08-03) render units; demo-like geometry ×1000
test('openingShoulders: distances from the opening edges to the wall ends', () => {
const rooms = [{ id: 'r1', poly: [[40, 140], [550, 140], [550, 580], [40, 580]] }];
// 80-long opening centered at x=200 on the top wall (40..550)
const sh = openingShoulders([200, 140], 0, 80, rooms, 2);
assert.ok(sh);
assert.deepEqual(sh.wallA, [40, 140]);
assert.deepEqual(sh.wallB, [550, 140]);
assert.equal(sh.sideA, 120, 'left: 160 - 40');
assert.equal(sh.sideB, 310, 'right: 550 - 240');
// badges sit on the middle of each shoulder
assert.deepEqual(sh.midA, [100, 140]);
assert.deepEqual(sh.midB, [395, 140]);
assert.deepEqual(sh.wallCenter, [295, 140]);
assert.equal(sh.centered, false);
// at the wall end one shoulder collapses to zero, never negative
const end = openingShoulders([80, 140], 0, 80, rooms, 2);
assert.equal(end.sideA, 0);
assert.equal(end.sideB, 430);
// off every wall -> null
assert.equal(openingShoulders([300, 300], 0, 80, rooms, 2), null);
});
test('openingShoulders: centered flag obeys the tolerance', () => {
const rooms = [{ id: 'r1', poly: [[40, 140], [550, 140], [550, 580], [40, 580]] }];
const mid = openingShoulders([295, 140], 0, 80, rooms, 2);
assert.equal(mid.centered, true);
assert.equal(mid.sideA, mid.sideB);
assert.equal(mid.sideA, 215, '(510 - 80) / 2');
assert.equal(openingShoulders([296.5, 140], 0, 80, rooms, 2).centered, true, 'inside tol');
assert.equal(openingShoulders([298, 140], 0, 80, rooms, 2).centered, false, 'outside tol');
});
test('openingShoulders: only the OWN room edge counts, no merging with a neighbour', () => {
// two rooms side by side: the top edges 40..550 (r1) and 550..960 (r2) are
// collinear and touch at x=550, but they belong to DIFFERENT rooms — the
// ruler measures ONLY the edge the opening sits on (owner 2026-08-03:
// «only the wall of one room»)
const rooms = [
{ id: 'r1', poly: [[40, 140], [550, 140], [550, 580], [40, 580]] },
{ id: 'r2', poly: [[550, 140], [960, 140], [960, 580], [550, 580]] },
];
const sh = openingShoulders([200, 140], 0, 80, rooms, 2);
assert.deepEqual(sh.wallA, [40, 140]);
assert.deepEqual(sh.wallB, [550, 140], 'stops at the own corner, never runs into r2');
assert.equal(sh.sideB, 550 - 240);
// the middle of r1's OWN edge: (40+550)/2 = 295
assert.equal(openingShoulders([295, 140], 0, 80, rooms, 2).centered, true,
'centered on the own edge');
assert.equal(openingShoulders([500, 140], 0, 80, rooms, 2).centered, false,
'the center of the old merged run means nothing now');
// an opening on r2's fragment measures r2's edge
const far = openingShoulders([700, 140], 0, 80, rooms, 2);
assert.deepEqual(far.wallA, [550, 140]);
assert.deepEqual(far.wallB, [960, 140]);
assert.equal(openingShoulders([755, 140], 0, 80, rooms, 2).centered, true);
// the interior shared wall x=550 is ONE deduped edge shared by both rooms
const v = openingShoulders([550, 300], 90, 80, rooms, 2);
assert.deepEqual(v.wallA, [550, 140]);
assert.deepEqual(v.wallB, [550, 580]);
assert.equal(v.centered, false);
assert.equal(openingShoulders([550, 360], 90, 80, rooms, 2).centered, true);
});
test('openingShoulders: shared wall of two rooms uses the snapped room edge', () => {
// staggered fragments on the shared line x=550: r1's right edge 140..400,
// r2's left edge 300..580. The snap tie-break (nearest edge, first in
// roomEdges order on a tie — exactly what snapToWall picks during the
// drag) resolves to r1's edge, so shoulders and center come from 140..400,
// never from the merged 140..580 union
const rooms = [
{ id: 'r1', poly: [[40, 140], [550, 140], [550, 400], [40, 400]] },
{ id: 'r2', poly: [[550, 300], [960, 300], [960, 580], [550, 580]] },
];
const sh = openingShoulders([550, 350], 90, 80, rooms, 2);
assert.deepEqual(sh.wallA, [550, 140]);
assert.deepEqual(sh.wallB, [550, 400], "r1's own end, not r2's 580");
assert.equal(sh.sideA, 310 - 140);
assert.equal(sh.sideB, 400 - 390);
assert.deepEqual(sh.wallCenter, [550, 270]);
assert.equal(sh.centered, false);
// below r1's edge the same line belongs to r2 alone
const low = openingShoulders([550, 500], 90, 80, rooms, 2);
assert.deepEqual(low.wallA, [550, 300]);
assert.deepEqual(low.wallB, [550, 580]);
assert.equal(openingShoulders([550, 440], 90, 80, rooms, 2).centered, true,
'centered on r2 own edge (300+580)/2');
});
test('openingShoulders: angled wall measures along the wall direction', () => {
// 3-4-5 triangle: hypotenuse-ish edge (0,0)->(300,400), length 500, angle 53.13°
const rooms = [{ id: 't', poly: [[0, 0], [300, 400], [0, 400]] }];
const ang = (Math.atan2(400, 300) * 180) / Math.PI;
const mid = openingShoulders([150, 200], ang, 100, rooms, 2);
assert.ok(mid);
assert.ok(Math.abs(mid.sideA - 200) < 1e-9 && Math.abs(mid.sideB - 200) < 1e-9);
assert.equal(mid.centered, true);
// slide 30 units towards the far end (dir = [0.6, 0.8])
const off = openingShoulders([150 + 30 * 0.6, 200 + 30 * 0.8], ang, 100, rooms, 2);
assert.ok(Math.abs(off.sideA - 230) < 1e-9, 'near shoulder grows by 30');
assert.ok(Math.abs(off.sideB - 170) < 1e-9, 'far shoulder shrinks by 30');
assert.equal(off.centered, false);
assert.ok(Math.abs(off.wallCenter[0] - 150) < 1e-9 && Math.abs(off.wallCenter[1] - 200) < 1e-9);
});
+240
View File
@@ -0,0 +1,240 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
norm360, planSunAngle, sunDirOnPlan, dayPhase,
isExteriorWall, windowWallInfo, windowLit,
rayLength, rayQuad, clipToRoom, computeSunRays,
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
} from '../test-build/sun.js';
const near = (a, b, eps = 1e-9) => Math.abs(a - b) < eps;
// ---- the test house: two rooms sharing the x=500 wall, windows on all four
// outer walls plus one on the shared (interior) wall --------------------
const ROOMS = [
{ id: 'r1', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] },
{ id: 'r2', poly: [[500, 100], [800, 100], [800, 500], [500, 500]] },
];
const WIN = {
north: { id: 'wN', x: 300, y: 100, angle: 0, length: 60 },
south: { id: 'wS', x: 300, y: 500, angle: 0, length: 60 },
west: { id: 'wW', x: 100, y: 300, angle: 90, length: 60 },
east: { id: 'wE', x: 800, y: 300, angle: 90, length: 60 },
inner: { id: 'wI', x: 500, y: 300, angle: 90, length: 60 },
};
const ALL = Object.values(WIN);
test('planSunAngle: plain subtraction, wraps around the circle (359→0)', () => {
assert.equal(planSunAngle(180, 0), 180);
assert.equal(planSunAngle(0, 1), 359);
assert.equal(planSunAngle(359, 359), 0);
assert.equal(planSunAngle(10, 350), 20);
assert.equal(norm360(-90), 270);
assert.equal(norm360(720), 0);
});
test('sunDirOnPlan: compass points map to canvas vectors (y grows down)', () => {
const cases = [
[0, [0, -1]], // north = canvas up
[90, [1, 0]], // east = right
[180, [0, 1]], // south = down
[270, [-1, 0]], // west = left
];
for (const [az, [x, y]] of cases) {
const d = sunDirOnPlan(az, 0);
assert.ok(near(d[0], x, 1e-12) && near(d[1], y, 1e-12), `az ${az}`);
}
// rotating the compass rotates the whole sky: east sun, north_deg=90 → up
const d = sunDirOnPlan(90, 90);
assert.ok(near(d[0], 0, 1e-12) && near(d[1], -1, 1e-12));
});
test('dayPhase: night is dark and dim, noon is white, sunrise is warm', () => {
const night = dayPhase(-20);
const dawn = dayPhase(2);
const noon = dayPhase(60);
assert.equal(night.bg, '#070c14');
assert.equal(noon.bg, '#ffffff');
assert.notEqual(dawn.bg, night.bg);
assert.notEqual(dawn.bg, noon.bg);
assert.ok(near(night.planDim, 0.1));
assert.equal(noon.planDim, 0);
assert.ok(dawn.planDim > 0 && dawn.planDim < 0.1);
assert.equal(night.warmth, 1);
assert.equal(noon.warmth, 0);
assert.ok(near(dawn.warmth, 0.8));
// garbage elevation never throws and stays inside the palette
assert.ok(dayPhase(NaN).bg.startsWith('#'));
});
test('windowWallInfo: exterior windows on all four sides get outward normals', () => {
const n = windowWallInfo(WIN.north, ROOMS);
const s = windowWallInfo(WIN.south, ROOMS);
const w = windowWallInfo(WIN.west, ROOMS);
const e = windowWallInfo(WIN.east, ROOMS);
assert.deepEqual(n.roomId, 'r1');
assert.ok(near(n.normal[0], 0, 1e-12) && near(n.normal[1], -1, 1e-12));
assert.ok(near(s.normal[0], 0, 1e-12) && near(s.normal[1], 1, 1e-12));
assert.ok(near(w.normal[0], -1, 1e-12) && near(w.normal[1], 0, 1e-12));
assert.equal(w.roomId, 'r1');
assert.ok(near(e.normal[0], 1, 1e-12) && near(e.normal[1], 0, 1e-12));
assert.equal(e.roomId, 'r2');
});
test('windowWallInfo: interior and orphan windows never participate', () => {
assert.equal(windowWallInfo(WIN.inner, ROOMS), null); // shared wall
assert.equal(windowWallInfo({ x: 300, y: 300, angle: 0 }, ROOMS), null); // mid-room
assert.equal(windowWallInfo({ x: 950, y: 950, angle: 0 }, ROOMS), null); // nowhere
});
test('isExteriorWall probes the outer side', () => {
assert.ok(isExteriorWall([300, 100], [0, -1], ROOMS));
assert.ok(!isExteriorWall([500, 300], [1, 0], ROOMS)); // r2 is outside r1 here
});
test('windowLit: above the horizon AND facing the sun', () => {
const east = [1, 0];
assert.ok(windowLit(east, sunDirOnPlan(90, 0), 10));
assert.ok(!windowLit(east, sunDirOnPlan(270, 0), 10)); // sun behind the house
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), 0)); // sunset moment
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), -5)); // night
});
test('rayLength: longest at the horizon, shortest at noon, monotonic', () => {
assert.ok(near(rayLength(0), 2.5, 1e-9));
assert.ok(near(rayLength(90), 0.8, 1e-9));
assert.ok(rayLength(10) > rayLength(30));
assert.ok(rayLength(30) > rayLength(60));
assert.ok(near(rayLength(-5), 2.5, 1e-9)); // clamped
});
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
const quad = rayQuad([100, 270], [100, 330], [1, 0], 1000); // way past the wall
const clipped = clipToRoom(quad, ROOMS[0].poly);
assert.equal(clipped.length, 1);
for (const [x, y] of clipped[0]) {
assert.ok(x >= 100 - 1e-6 && x <= 500 + 1e-6, 'x inside the room');
assert.ok(y >= 100 - 1e-6 && y <= 500 + 1e-6, 'y inside the room');
}
assert.ok(clipped[0].some(([x]) => near(x, 500, 1e-6)), 'reaches the far wall, not past it');
// a wedge fully outside the room clips to nothing
assert.equal(clipToRoom(rayQuad([900, 900], [960, 900], [0, 1], 50), ROOMS[0].poly).length, 0);
});
test('computeSunRays: morning east sun lights ONLY the east window', () => {
const rays = computeSunRays(ROOMS, ALL, 90, 5, 0);
assert.deepEqual(rays.map((r) => r.openingId), ['wE']);
assert.equal(rays[0].roomId, 'r2');
// light travels AWAY from the sun: westward into the room
assert.ok(near(rays[0].dir[0], -1, 1e-12) && near(rays[0].dir[1], 0, 1e-12));
for (const [x, y] of rays[0].polys[0]) {
assert.ok(x >= 500 - 1e-6 && x <= 800 + 1e-6 && y >= 100 - 1e-6 && y <= 500 + 1e-6);
}
});
test('computeSunRays: noon south sun → south window, short wedge', () => {
const rays = computeSunRays(ROOMS, ALL, 180, 60, 0);
assert.deepEqual(rays.map((r) => r.openingId), ['wS']);
assert.ok(near(rays[0].len, rayLength(60) * 60, 1e-9));
assert.ok(rays[0].len < computeSunRays(ROOMS, ALL, 90, 5, 0)[0].len);
});
test('computeSunRays: evening west sun → west window', () => {
const rays = computeSunRays(ROOMS, ALL, 270, 4, 0);
assert.deepEqual(rays.map((r) => r.openingId), ['wW']);
});
test('computeSunRays: night → nothing at all', () => {
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, 0, 0), []);
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, -10, 0), []);
});
test('computeSunRays: rotating the compass swings the light to another window', () => {
// the same morning east sun, but the plan is rotated 90°: what the canvas
// shows as "up" is now east → the NORTH-drawn window faces the sun
const rays = computeSunRays(ROOMS, ALL, 90, 5, 90);
assert.deepEqual(rays.map((r) => r.openingId), ['wN']);
// and the interior window still never lights up whatever the compass says
for (const nd of [0, 45, 90, 180, 270]) {
for (const az of [0, 90, 180, 270]) {
assert.ok(!computeSunRays(ROOMS, ALL, az, 5, nd).some((r) => r.openingId === 'wI'));
}
}
});
test('rayAlpha: capped, ramps in near the horizon, scaled by clouds', () => {
assert.equal(rayAlpha(0), 0);
assert.equal(rayAlpha(-3), 0);
assert.ok(near(rayAlpha(1), RAY_MAX_ALPHA / 2));
assert.ok(near(rayAlpha(30), RAY_MAX_ALPHA));
assert.ok(near(rayAlpha(30, 0.25), RAY_MAX_ALPHA * 0.25));
assert.equal(rayAlpha(30, 0), 0);
});
test('rayColor: warm at the horizon, neutral by day', () => {
assert.equal(rayColor(1), '#ff9a45');
assert.equal(rayColor(0), '#ffe9c2');
assert.notEqual(rayColor(0.5), rayColor(0));
});
test('cloudFactor: the state map, garbage-safe', () => {
assert.equal(cloudFactor('sunny'), 1);
assert.equal(cloudFactor('clear-night'), 1);
assert.equal(cloudFactor('partlycloudy'), 0.7);
assert.equal(cloudFactor('cloudy'), 0.4);
assert.equal(cloudFactor('overcast'), 0.25);
assert.equal(cloudFactor('fog'), 0.25);
assert.equal(cloudFactor('rainy'), 0);
assert.equal(cloudFactor('pouring'), 0);
assert.equal(cloudFactor('snowy'), 0);
assert.equal(cloudFactor('lightning-rainy'), 0);
assert.equal(cloudFactor('unknown'), 1);
assert.equal(cloudFactor('unavailable'), 1);
assert.equal(cloudFactor(null), 1);
assert.equal(cloudFactor(undefined), 1);
assert.equal(cloudFactor('CLOUDY'), 0.4);
});
test('northDegOf: space override wins, strict int 0–359, null = inert', () => {
assert.equal(northDegOf({ north_deg: 90 }, {}), 90);
assert.equal(northDegOf({ north_deg: 90 }, { north_deg: 0 }), 0); // 0 is a value, not "unset"
assert.equal(northDegOf({}, { north_deg: 359 }), 359);
assert.equal(northDegOf({}, {}), null);
assert.equal(northDegOf(null, undefined), null);
for (const bad of [360, -1, 1.5, '90', true, NaN]) {
assert.equal(northDegOf({ north_deg: bad }, {}), null, String(bad));
}
// a garbage override falls back to the valid global
assert.equal(northDegOf({ north_deg: 45 }, { north_deg: 999 }), 45);
});
test('bgModeOf: inherit chain with a static fallback', () => {
assert.equal(bgModeOf({}, {}), 'static');
assert.equal(bgModeOf({ bg_mode: 'daynight' }, {}), 'daynight');
assert.equal(bgModeOf({ bg_mode: 'daynight' }, { bg_mode: 'static' }), 'static');
assert.equal(bgModeOf({}, { bg_mode: 'daynight' }), 'daynight');
assert.equal(bgModeOf({ bg_mode: 'disco' }, {}), 'static');
});
test('sunRaysOn: default OFF, per-space tri-state inherit', () => {
assert.equal(sunRaysOn({}, {}), false);
assert.equal(sunRaysOn({ sun_rays: true }, {}), true);
assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: false }), false);
assert.equal(sunRaysOn({}, { sun_rays: true }), true);
assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: null }), true); // null = inherit
assert.equal(sunRaysOn({ sun_rays: 'yes' }, {}), false);
});
test('weatherEntityOf / sunStateOf: strings and hass shapes, garbage-safe', () => {
assert.equal(weatherEntityOf({ weather_entity: 'weather.home' }), 'weather.home');
assert.equal(weatherEntityOf({ weather_entity: ' ' }), null);
assert.equal(weatherEntityOf({}), null);
assert.deepEqual(
sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 120.5, elevation: -3 } } } }),
{ azimuth: 120.5, elevation: -3 },
);
assert.equal(sunStateOf({ states: {} }), null);
assert.equal(sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 'x', elevation: 1 } } } }), null);
assert.equal(sunStateOf(null), null);
});
+41
View File
@@ -115,6 +115,15 @@ def test_marker_schema():
v.MARKER_SCHEMA({"binding": "virtual"})
def test_marker_use_climate_temp_is_bool_or_none():
# opt-in room-temperature from climate devices (owner's feature 2026-08-03)
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "use_climate_temp": True})
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "use_climate_temp": False})
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "use_climate_temp": None})
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc", "use_climate_temp": "yes"})
def test_config_schema_defaults_and_extra():
out = v.CONFIG_SCHEMA({"spaces": []})
assert out["markers"] == [] and out["settings"] == {}
@@ -182,6 +191,38 @@ def test_bg_color_setting():
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#1234567"}})
def test_sun_settings_global():
"""docs/SUN.md: north_deg strict int 0..359, bg_mode, sun_rays, weather_entity."""
v.CONFIG_SCHEMA({"spaces": [], "settings": {
"north_deg": 0, "bg_mode": "daynight", "sun_rays": True, "weather_entity": "weather.home",
}})
v.CONFIG_SCHEMA({"spaces": [], "settings": {"north_deg": 359, "bg_mode": "static"}})
v.CONFIG_SCHEMA({"spaces": [], "settings": {"weather_entity": None}})
for bad in (360, -1, 1.5, "90", True, None):
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"north_deg": bad}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_mode": "disco"}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"sun_rays": "yes"}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"weather_entity": {"e": 1}}})
def test_sun_settings_per_space():
"""The same three at the space level; None/absent = inherit; no weather here."""
ok = {"id": "f1", "title": "F", "view_box": [0, 0, 1, 1], "rooms": []}
v.SPACE_SCHEMA(dict(ok, settings={"north_deg": 90, "bg_mode": "daynight", "sun_rays": False}))
v.SPACE_SCHEMA(dict(ok, settings={"north_deg": None, "bg_mode": None, "sun_rays": None}))
for bad in (360, -1, 2.5, "0", True):
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(dict(ok, settings={"north_deg": bad}))
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(dict(ok, settings={"bg_mode": "auto"}))
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(dict(ok, settings={"sun_rays": 1}))
def test_space_temp_bounds():
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
ok = {
+1
View File
@@ -9,6 +9,7 @@
},
"include": [
"src/logic.ts", "src/vacuum.ts",
"src/sun.ts",
"src/resize.ts",
"src/rules.ts",
"src/devices.ts",