mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
CLIMATE TEMP: opt-in room temperature from climate devices (owner 2026-08-03)
Marker dialog grows a checkbox (climate devices only, default OFF): 'Use the device's temperature sensor'. When ticked, the AC/thermostat's attributes.current_temperature shows as the standard .tval badge next to the icon (scales with --dev-size, honours show_temperature) and joins the room average like a thermometer. Unavailable / missing attribute = no badge, no vote; several climate entities - the first valid one wins; hidden devices keep voting (room climate stays registry-wide, exactly like hidden thermometers). Stored as marker.use_climate_temp (bool|None, validated in MARKER_SCHEMA). Units in test/devices.test.mjs, smoke demo/smoke_climate_temp.mjs (real checkbox click, 20 + 23.5 -> 21.8 on the room card), backend test, docs/TESTING.md.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -319,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)
|
||||
|
||||
@@ -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);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+22
-17
File diff suppressed because one or more lines are too long
@@ -912,3 +912,13 @@ require hands on real hardware — they remain for the human pass.
|
||||
- [ ] `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)]
|
||||
|
||||
+44
-4
@@ -146,6 +146,23 @@ export function tempFor(hass: any, entIds: string[]): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room temperature as a CLIMATE device reports it: the first climate.* entity
|
||||
* with a finite attributes.current_temperature (owner's spec: several climate
|
||||
* entities -> the first valid one wins). Unavailable/unknown entities and a
|
||||
* missing attribute yield null - no badge, no vote in the room average.
|
||||
*/
|
||||
export function climateTempFor(hass: any, entIds: string[]): number | null {
|
||||
for (const eid of entIds) {
|
||||
if (!eid.startsWith('climate.')) continue;
|
||||
const st = hass.states[eid];
|
||||
if (!st || st.state === 'unavailable' || st.state === 'unknown') continue;
|
||||
const v = parseFloat(st.attributes?.current_temperature);
|
||||
if (Number.isFinite(v)) return Math.round(v * 10) / 10;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */
|
||||
export function isHumEntity(hass: any, eid: string): boolean {
|
||||
if (hass.entities?.[eid]?.entity_category) return false;
|
||||
@@ -508,10 +525,21 @@ export interface AreaClimate { temp: number | null; hum: number | null }
|
||||
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
|
||||
*/
|
||||
export function areaClimateMap(
|
||||
hass: any, rules?: CompiledIconRule[],
|
||||
hass: any, rules?: CompiledIconRule[], markers?: Marker[] | null,
|
||||
): Map<string, AreaClimate> {
|
||||
const out = new Map<string, AreaClimate>();
|
||||
if (!hass?.entities) return out;
|
||||
// Opt-in climate sources (marker.use_climate_temp): an AC or thermostat
|
||||
// knows the room temperature (current_temperature) - when the user ticks
|
||||
// the option, that reading votes in the room average like any thermometer.
|
||||
// The set holds binding refs (device ids and entity ids); with no opted
|
||||
// markers the pass below is byte-for-byte the old one.
|
||||
const climOpt = new Set<string>();
|
||||
for (const m of markers || []) {
|
||||
if (m?.use_climate_temp !== true) continue;
|
||||
const i = (m.binding || '').indexOf(':');
|
||||
if (i > 0) climOpt.add(m.binding.slice(i + 1));
|
||||
}
|
||||
// area -> device (or lone entity) -> the entities that belong to it
|
||||
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
|
||||
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
|
||||
@@ -523,8 +551,15 @@ export function areaClimateMap(
|
||||
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
||||
// question, 2026-07-27). Three guards, cheapest first:
|
||||
if (reg.entity_category) continue; // diagnostic/config readings
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
|
||||
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||
// An explicitly opted climate entity skips the platform/name filters:
|
||||
// the tick is the user saying "this one DOES measure room air" - even on
|
||||
// an excluded platform (better_thermostat) or a suspicious entity id.
|
||||
const optClimate = climOpt.size > 0 && eid.startsWith('climate.')
|
||||
&& (climOpt.has(eid) || (reg.device_id && climOpt.has(reg.device_id)));
|
||||
if (!optClimate) {
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
|
||||
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||
}
|
||||
let groups = byArea.get(area);
|
||||
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
||||
const key = reg.device_id || eid;
|
||||
@@ -543,13 +578,18 @@ export function areaClimateMap(
|
||||
for (const [area, groups] of byArea) {
|
||||
const temps: number[] = [];
|
||||
const hums: number[] = [];
|
||||
for (const g of groups.values()) {
|
||||
for (const [key, g] of groups) {
|
||||
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
||||
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
|
||||
if (air) {
|
||||
const t = tempFor(hass, g.ents);
|
||||
if (t != null) temps.push(t);
|
||||
}
|
||||
// opted climate device/entity: current_temperature joins the average
|
||||
if (climOpt.size > 0 && (climOpt.has(key) || g.ents.some((e) => climOpt.has(e)))) {
|
||||
const t = climateTempFor(hass, g.ents);
|
||||
if (t != null) temps.push(t);
|
||||
}
|
||||
if (air || icon === 'mdi:water-percent') {
|
||||
const h = humFor(hass, g.ents);
|
||||
if (h != null) hums.push(h);
|
||||
|
||||
+38
-6
@@ -40,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,
|
||||
@@ -379,6 +379,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;
|
||||
@@ -1472,10 +1473,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;
|
||||
@@ -3516,6 +3536,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
|
||||
@@ -3538,7 +3559,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),
|
||||
};
|
||||
@@ -3760,6 +3781,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;
|
||||
@@ -5894,7 +5916,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).
|
||||
@@ -5904,10 +5926,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;
|
||||
}
|
||||
|
||||
@@ -6970,6 +6995,13 @@ class HouseplanCard extends LitElement {
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${this._bindingHasClimate(d.binding)
|
||||
? html`<label class="srcrow climrow" title=${this._t('marker.use_climate_temp_tip')}>
|
||||
<input type="checkbox" .checked=${d.useClimateTemp}
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, useClimateTemp: (e.target as HTMLInputElement).checked })} />
|
||||
<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 })} />
|
||||
|
||||
@@ -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}”?",
|
||||
|
||||
@@ -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}»?",
|
||||
|
||||
@@ -81,6 +81,13 @@ export interface Marker {
|
||||
* null/undefined = auto: any light.* entity of the device.
|
||||
*/
|
||||
is_light?: boolean | null;
|
||||
/**
|
||||
* Climate devices (AC, thermostat) know the room temperature
|
||||
* (attributes.current_temperature). Opt-in per marker: show it as a badge
|
||||
* next to the icon and include it in the room average. null/absent = off —
|
||||
* nothing changes without an explicit tick (owner's spec, 2026-08-03).
|
||||
*/
|
||||
use_climate_temp?: boolean | null;
|
||||
}
|
||||
|
||||
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
|
||||
|
||||
+60
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, climateTempFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
|
||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -469,6 +469,65 @@ test('areaClimateMap: one registry pass for all areas (review R2-3)', () => {
|
||||
assert.equal(scans, 120, 'wrapper считает по одной зоне — им нельзя пользоваться в рендере');
|
||||
});
|
||||
|
||||
test('climateTempFor: первая climate-сущность с валидной current_temperature', () => {
|
||||
const h = {
|
||||
states: {
|
||||
'climate.dead': { state: 'unavailable', attributes: {} },
|
||||
'climate.mute': { state: 'heat', attributes: {} }, // атрибута нет — мимо
|
||||
'climate.ac': { state: 'cool', attributes: { current_temperature: 23.46 } },
|
||||
'climate.second': { state: 'heat', attributes: { current_temperature: 30 } },
|
||||
'sensor.t': { state: '21', attributes: { device_class: 'temperature' } },
|
||||
},
|
||||
};
|
||||
// несколько climate — берётся ПЕРВАЯ с валидным значением, с округлением до 0.1
|
||||
assert.equal(climateTempFor(h, ['climate.dead', 'climate.mute', 'climate.ac', 'climate.second']), 23.5);
|
||||
// не-climate сущности не участвуют вовсе
|
||||
assert.equal(climateTempFor(h, ['sensor.t']), null);
|
||||
// unavailable/unknown/без атрибута — null: ни плашки, ни голоса в средней
|
||||
assert.equal(climateTempFor(h, ['climate.dead']), null);
|
||||
assert.equal(climateTempFor(h, ['climate.mute']), null);
|
||||
});
|
||||
|
||||
test('areaClimateMap: климат с опцией участвует в средней (термометр 20 + кондиционер 23.5)', () => {
|
||||
const hass = {
|
||||
devices: {
|
||||
dt: { id: 'dt', name: 'Датчик температуры', area_id: 'living' },
|
||||
dac: { id: 'dac', name: 'Кондиционер', model: 'AC-12', area_id: 'living' },
|
||||
},
|
||||
entities: {
|
||||
'sensor.t': { device_id: 'dt', platform: 'mqtt' },
|
||||
'climate.ac': { device_id: 'dac', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
'sensor.t': { state: '20', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'climate.ac': { state: 'cool', attributes: { current_temperature: 23.5 } },
|
||||
},
|
||||
};
|
||||
// без опции — как раньше: только термометр
|
||||
assert.equal(areaClimateMap(hass).get('living').temp, 20);
|
||||
assert.equal(areaClimateMap(hass, undefined, [{ id: 'm1', binding: 'device:dac' }]).get('living').temp, 20);
|
||||
// опция включена: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
|
||||
const markers = [{ id: 'm1', binding: 'device:dac', use_climate_temp: true }];
|
||||
assert.equal(areaClimateMap(hass, undefined, markers).get('living').temp, 21.8);
|
||||
// entity-привязка работает так же
|
||||
const entMarkers = [{ id: 'm1', binding: 'entity:climate.ac', use_climate_temp: true }];
|
||||
assert.equal(areaClimateMap(hass, undefined, entMarkers).get('living').temp, 21.8);
|
||||
// unavailable климат не ломает и не участвует
|
||||
const broken = { ...hass, states: { ...hass.states, 'climate.ac': { state: 'unavailable', attributes: {} } } };
|
||||
assert.equal(areaClimateMap(broken, undefined, markers).get('living').temp, 20);
|
||||
});
|
||||
|
||||
test('areaClimateMap: комната только с климат-источником появляется в карте', () => {
|
||||
const hass = {
|
||||
devices: { dac: { id: 'dac', name: 'Кондиционер', area_id: 'bed' } },
|
||||
entities: { 'climate.bed': { device_id: 'dac', platform: 'demo' } },
|
||||
states: { 'climate.bed': { state: 'heat', attributes: { current_temperature: 24.2 } } },
|
||||
};
|
||||
assert.equal(areaClimateMap(hass).get('bed'), undefined);
|
||||
const markers = [{ id: 'm1', binding: 'device:dac', use_climate_temp: true }];
|
||||
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('bed'), { temp: 24.2, hum: null });
|
||||
});
|
||||
|
||||
test('areaClimateMap: температура и влажность живут в одной записи', () => {
|
||||
const hass = {
|
||||
devices: { q: { id: 'q', name: 'Qingping Air Monitor', area_id: 'hall' } },
|
||||
|
||||
@@ -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"] == {}
|
||||
|
||||
Reference in New Issue
Block a user