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:
Matysh
2026-08-03 10:24:51 +03:00
parent 3a6a819dec
commit 33a960031e
13 changed files with 387 additions and 62 deletions
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)
+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);
File diff suppressed because one or more lines are too long
+22 -17
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -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
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);
+38 -6
View File
@@ -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 })} />
+2
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}”?",
+2
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}»?",
+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' } },
+9
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"] == {}