fix: follow marker placement for room climate

Issue: #317
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 16:48:10 +03:00
parent b3df25ab85
commit 6c9752f5e6
32 changed files with 708 additions and 337 deletions
@@ -1,39 +1,39 @@
{
"schema": 1,
"fingerprint": "0c61742c98d55aec077478fcee7b8acf52e2ac1b70e2ed23532483b845ca884f",
"fingerprint": "a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BU843YpJ.js",
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 256091,
"initialViewGzipBytes": 256127,
"lazyFiles": [
"houseplan-assets/de-SftP4Auy.js",
"houseplan-assets/editor-DuOmG3Hn.js",
"houseplan-assets/houseplan-editor-runtime-pSbu_R5t.js",
"houseplan-assets/houseplan-onboarding-runtime-DXJhYLas.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/de-AOEY0-Iu.js",
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"lazyGzipBytes": 160351,
"lazyGzipBytes": 160345,
"lazyEditorFiles": [
"houseplan-assets/editor-DuOmG3Hn.js",
"houseplan-assets/houseplan-editor-runtime-pSbu_R5t.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"lazyEditorGzipBytes": 131833,
"lazyEditorGzipBytes": 131827,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-DXJhYLas.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"lazyOnboardingGzipBytes": 7198,
"lazyOnboardingGzipBytes": 7197,
"lazyLocaleFiles": [
"houseplan-assets/de-SftP4Auy.js"
"houseplan-assets/de-AOEY0-Iu.js"
],
"lazyLocaleGzipBytes": 21873,
"files": [
{
"path": "houseplan-assets/de-SftP4Auy.js",
"sha256": "3bee20ff5a52b6c6726cf857545bc3e1061a1249a515df5660491fd662218c94",
"path": "houseplan-assets/de-AOEY0-Iu.js",
"sha256": "af2926bc08c1d4d617b844f53b7aa8ef772b9751c15570b78b2fb24ab1109c20",
"rawBytes": 71477,
"gzipBytes": 21873,
"isEntry": false,
@@ -41,71 +41,71 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DuOmG3Hn.js",
"sha256": "d90a38e511769dc68d69081375ffb5067b196d429ab59fc4de06068baeafcac8",
"path": "houseplan-assets/editor-2znZ5Si4.js",
"sha256": "f701b93ee31570464f43993fb38d74f142f7e2ff1033e38f6ba79a8c17b6e858",
"rawBytes": 3831,
"gzipBytes": 1584,
"gzipBytes": 1582,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BU843YpJ.js"
"houseplan-assets/houseplan-card-BsbMwnTm.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BU843YpJ.js",
"sha256": "1937126237e02356adad8b8c34b4adb29892bc183d6bcaa17e2542404907c811",
"rawBytes": 910424,
"gzipBytes": 255907,
"path": "houseplan-assets/houseplan-card-BsbMwnTm.js",
"sha256": "6d133886e636a0cd8a38a321f4bec4eac300bd7191631494e895c299b2d9bb6e",
"rawBytes": 910936,
"gzipBytes": 255944,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-SftP4Auy.js",
"houseplan-assets/editor-DuOmG3Hn.js",
"houseplan-assets/houseplan-editor-runtime-pSbu_R5t.js",
"houseplan-assets/houseplan-onboarding-runtime-DXJhYLas.js"
"houseplan-assets/de-AOEY0-Iu.js",
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-pSbu_R5t.js",
"sha256": "51fdd8f9766569a3b1af3b04d91dea81e6b555d9784bf5be53c6f82a42fbe51e",
"path": "houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"sha256": "a644ece8d0cb490b6c41949e25fa80e64580be2d0d4a0e3fc4e1669b33777f9e",
"rawBytes": 504959,
"gzipBytes": 129696,
"gzipBytes": 129693,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BU843YpJ.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DXJhYLas.js",
"sha256": "19299e14098c6e413ca410d7b2c3715d57c945fdb690991f92fa2dc769d9c84d",
"path": "houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"sha256": "ad84c9b77ef8b3920a69ba80700c97d558c2f6229d2cb589e9548451ef3328c9",
"rawBytes": 26356,
"gzipBytes": 6645,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BU843YpJ.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-DIa5HjfJ.js",
"sha256": "93f68c225dc00086ea86ac8a97ddfdf49b99ff17a8221f3cc3796f92d5e9f3cb",
"path": "houseplan-assets/space-deletion-B9Tp5xY6.js",
"sha256": "00bb44aff178ffb82ec71bcd59b67d1a12137ecb4379ec0d606bd613b4447c1b",
"rawBytes": 911,
"gzipBytes": 553,
"gzipBytes": 552,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "823dc598d07959ff800a942ca1b705bd54b94dd4def67d66668f2a5552cc271f",
"sha256": "6c720bd98beee922433c17737fe95dc4c98c69ab182ddcddd05c42a82d50f968",
"rawBytes": 190,
"gzipBytes": 184,
"gzipBytes": 183,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BU843YpJ.js"
"houseplan-assets/houseplan-card-BsbMwnTm.js"
],
"dynamicImports": []
}
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0c61742c98d55aec077478fcee7b8acf52e2ac1b70e2ed23532483b845ca884f";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-BU843YpJ.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-BsbMwnTm.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
@@ -1 +1 @@
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0c61742c98d55aec077478fcee7b8acf52e2ac1b70e2ed23532483b845ca884f";export{t as a,e as c,o as i,r as s,s as t};
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";export{t as a,e as c,o as i,r as s,s as t};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0c61742c98d55aec077478fcee7b8acf52e2ac1b70e2ed23532483b845ca884f";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-BU843YpJ.js";
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-BsbMwnTm.js";
+112
View File
@@ -0,0 +1,112 @@
// Issue #317: a real sensor explicitly placed into a House Plan room owns the
// automatic room-climate vote, including when that room has no HA Area. The
// same frame feeds label, tooltip, temperature fill and hosted Static.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const baseHass = window.__mkHass();
const localRoom = {
id: 'local-climate', name: 'Local climate', area: null,
poly: [[0.08, 0.62], [0.38, 0.62], [0.38, 0.82], [0.08, 0.82]],
};
const config = JSON.parse(JSON.stringify(card._serverCfg));
const floor = config.spaces.find((space) => space.id === 'f1');
floor.rooms.push(localRoom);
floor.settings = {
...(floor.settings || {}), show_names: true, label_temp: true,
label_hum: true, fill_mode: 'temp',
};
config.markers = [
...(config.markers || []).filter((marker) => marker.binding !== 'device:d_temp'),
{
id: 'd_temp', binding: 'device:d_temp', space: 'f1', area: null,
room_id: localRoom.id, hidden: true,
},
];
card._serverCfg = config;
card._cfgEpoch++;
card._regSignature = '';
card._maybeRebuildDevices();
card.hass = { ...baseHass };
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const localKey = '@room/f1/local-climate';
result.localAutomatic = card._climate().get(localKey)?.temp === 22.4;
result.registryRoomReleased = card._climate().get('living_room')?.temp == null;
const localLabel = root().querySelector('[data-hp="room-label"][data-id="local-climate"]');
result.labelShowsAutomatic = localLabel?.textContent.includes('22.4°') === true;
result.oldLabelDropsMovedValue = root()
.querySelector('[data-hp="room-label"][data-id="r1"]')?.textContent.includes('22.4°') === false;
const localShape = root().querySelector('[data-hp="room"][data-id="local-climate"]');
const oldShape = root().querySelector('[data-hp="room"][data-id="r1"]');
result.localTempFill = localShape?.classList.contains('filled') === true;
result.oldTempFillRemoved = oldShape?.classList.contains('filled') === false;
localShape?.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, pointerType: 'mouse', clientX: 120, clientY: 180,
}));
await card.updateComplete;
result.tooltipSharesAutomatic = card._tip?.title === 'Local climate'
&& card._tip?.temp === 22.4;
// Hosted Static receives the same config and active HA snapshot. It has no
// tooltip/metric row by design, but its temperature fill must use the same
// room target rather than the old visible-device/HA-Area helper.
await customElements.whenDefined('houseplan-space-card');
const staticHass = {
...baseHass,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config, rev: 317, can_write: true };
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 317 };
return baseHass.callWS(message);
},
};
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', show_button: false });
staticCard.hass = staticHass;
document.body.appendChild(staticCard);
const deadline = Date.now() + 6000;
while (!staticCard.renderRoot?.querySelector('[data-hp="room"][data-id="local-climate"]')
&& Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 60));
await staticCard.updateComplete;
const staticRoot = staticCard.shadowRoot || staticCard.renderRoot;
result.staticLocalTempFill = staticRoot
?.querySelector('[data-hp="room"][data-id="local-climate"]')
?.classList.contains('filled') === true;
result.staticOldFillRemoved = staticRoot
?.querySelector('[data-hp="room"][data-id="r1"]')
?.classList.contains('filled') === false;
staticCard.remove();
// A configured source remains an override, not another vote or a fallback.
const configuredRoom = floor.rooms.find((room) => room.id === localRoom.id);
configuredRoom.settings = { temp_source: 'entity:sensor.override_temperature' };
card._serverCfg = { ...config, spaces: [...config.spaces] };
card._cfgEpoch++;
card.hass = {
...baseHass,
states: {
...baseHass.states,
'sensor.override_temperature': {
entity_id: 'sensor.override_temperature', state: '17',
attributes: { device_class: 'temperature', unit_of_measurement: '°C' },
},
},
};
card.requestUpdate();
await card.updateComplete;
result.explicitSourceWins = card._roomTemp(card._spaceModel().rooms
.find((room) => room.id === localRoom.id)) === 17;
return result;
});
checkAll(out);
await finish(browser, out);
+46 -46
View File
@@ -1,39 +1,39 @@
{
"schema": 1,
"fingerprint": "0c61742c98d55aec077478fcee7b8acf52e2ac1b70e2ed23532483b845ca884f",
"fingerprint": "a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BU843YpJ.js",
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 256091,
"initialViewGzipBytes": 256127,
"lazyFiles": [
"houseplan-assets/de-SftP4Auy.js",
"houseplan-assets/editor-DuOmG3Hn.js",
"houseplan-assets/houseplan-editor-runtime-pSbu_R5t.js",
"houseplan-assets/houseplan-onboarding-runtime-DXJhYLas.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/de-AOEY0-Iu.js",
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"lazyGzipBytes": 160351,
"lazyGzipBytes": 160345,
"lazyEditorFiles": [
"houseplan-assets/editor-DuOmG3Hn.js",
"houseplan-assets/houseplan-editor-runtime-pSbu_R5t.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"lazyEditorGzipBytes": 131833,
"lazyEditorGzipBytes": 131827,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-DXJhYLas.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"lazyOnboardingGzipBytes": 7198,
"lazyOnboardingGzipBytes": 7197,
"lazyLocaleFiles": [
"houseplan-assets/de-SftP4Auy.js"
"houseplan-assets/de-AOEY0-Iu.js"
],
"lazyLocaleGzipBytes": 21873,
"files": [
{
"path": "houseplan-assets/de-SftP4Auy.js",
"sha256": "3bee20ff5a52b6c6726cf857545bc3e1061a1249a515df5660491fd662218c94",
"path": "houseplan-assets/de-AOEY0-Iu.js",
"sha256": "af2926bc08c1d4d617b844f53b7aa8ef772b9751c15570b78b2fb24ab1109c20",
"rawBytes": 71477,
"gzipBytes": 21873,
"isEntry": false,
@@ -41,71 +41,71 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DuOmG3Hn.js",
"sha256": "d90a38e511769dc68d69081375ffb5067b196d429ab59fc4de06068baeafcac8",
"path": "houseplan-assets/editor-2znZ5Si4.js",
"sha256": "f701b93ee31570464f43993fb38d74f142f7e2ff1033e38f6ba79a8c17b6e858",
"rawBytes": 3831,
"gzipBytes": 1584,
"gzipBytes": 1582,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BU843YpJ.js"
"houseplan-assets/houseplan-card-BsbMwnTm.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BU843YpJ.js",
"sha256": "1937126237e02356adad8b8c34b4adb29892bc183d6bcaa17e2542404907c811",
"rawBytes": 910424,
"gzipBytes": 255907,
"path": "houseplan-assets/houseplan-card-BsbMwnTm.js",
"sha256": "6d133886e636a0cd8a38a321f4bec4eac300bd7191631494e895c299b2d9bb6e",
"rawBytes": 910936,
"gzipBytes": 255944,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-SftP4Auy.js",
"houseplan-assets/editor-DuOmG3Hn.js",
"houseplan-assets/houseplan-editor-runtime-pSbu_R5t.js",
"houseplan-assets/houseplan-onboarding-runtime-DXJhYLas.js"
"houseplan-assets/de-AOEY0-Iu.js",
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-pSbu_R5t.js",
"sha256": "51fdd8f9766569a3b1af3b04d91dea81e6b555d9784bf5be53c6f82a42fbe51e",
"path": "houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"sha256": "a644ece8d0cb490b6c41949e25fa80e64580be2d0d4a0e3fc4e1669b33777f9e",
"rawBytes": 504959,
"gzipBytes": 129696,
"gzipBytes": 129693,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BU843YpJ.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DXJhYLas.js",
"sha256": "19299e14098c6e413ca410d7b2c3715d57c945fdb690991f92fa2dc769d9c84d",
"path": "houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"sha256": "ad84c9b77ef8b3920a69ba80700c97d558c2f6229d2cb589e9548451ef3328c9",
"rawBytes": 26356,
"gzipBytes": 6645,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BU843YpJ.js",
"houseplan-assets/space-deletion-DIa5HjfJ.js"
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-DIa5HjfJ.js",
"sha256": "93f68c225dc00086ea86ac8a97ddfdf49b99ff17a8221f3cc3796f92d5e9f3cb",
"path": "houseplan-assets/space-deletion-B9Tp5xY6.js",
"sha256": "00bb44aff178ffb82ec71bcd59b67d1a12137ecb4379ec0d606bd613b4447c1b",
"rawBytes": 911,
"gzipBytes": 553,
"gzipBytes": 552,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "823dc598d07959ff800a942ca1b705bd54b94dd4def67d66668f2a5552cc271f",
"sha256": "6c720bd98beee922433c17737fe95dc4c98c69ab182ddcddd05c42a82d50f968",
"rawBytes": 190,
"gzipBytes": 184,
"gzipBytes": 183,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BU843YpJ.js"
"houseplan-assets/houseplan-card-BsbMwnTm.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0c61742c98d55aec077478fcee7b8acf52e2ac1b70e2ed23532483b845ca884f";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-BU843YpJ.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-BsbMwnTm.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
@@ -1 +1 @@
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0c61742c98d55aec077478fcee7b8acf52e2ac1b70e2ed23532483b845ca884f";export{t as a,e as c,o as i,r as s,s as t};
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";export{t as a,e as c,o as i,r as s,s as t};
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0c61742c98d55aec077478fcee7b8acf52e2ac1b70e2ed23532483b845ca884f";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-BU843YpJ.js";
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-BsbMwnTm.js";
+16 -6
View File
@@ -1145,12 +1145,22 @@ preserved instead of repeatedly snapping back to that hash. The legacy field
initializer is never a cold-start choice by itself. A plan with no spaces keeps
`null` authority and does not invent an id.
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
card memoizes it on `hass` identity, which Home Assistant replaces on every
state change. Per-room lookups are O(1). `areaClimate()` survives as a
single-area wrapper for tests — using it in a render reintroduces the
O(rooms × entities) cost it was extracted from.
**Room climate is one pass per hass snapshot** (review R2-3, issue #317).
`roomClimateMap()` classifies the whole active registry once and returns one
`{temp, hum}` aggregate per effective room target. HA-area rooms keep their
area key. An explicitly placed marker overrides registry placement; an
area-less room uses a collision-safe `space + room_id` key. Exact `entity:`
placement wins over its parent `device:` placement for that entity, so a
reading cannot remain in the old Area and vote twice. The card memoizes the map
on `hass`, rules and markers; per-room lookups are O(1). `areaClimateMap()` and
`areaClimate()` survive as compatibility wrappers — using the single-area
wrapper in a render reintroduces the O(rooms × entities) cost the map removed.
Explicit room `temp_source`/`hum_source` remains above the automatic aggregate.
Hidden live markers still contribute; removed and HA-disabled bindings do not.
Full View and hosted Static use the same resolver and bounded active render
snapshot, so a state tick cannot update a temperature fill through a different
membership rule.
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Room temperature and humidity now follow where a real sensor is placed in
House Plan instead of remaining in its Home Assistant Area. This also gives
automatic climate values to rooms without an HA Area; labels, tooltips and
temperature fills stay in sync ([#317](https://github.com/Matysh/houseplan-card/issues/317)).
- Active physical wall controllers that Home Assistant exposes without any own
entities no longer look permanently unavailable: they now stay opaque and
follow their controlled target as yellow when working or neutral otherwise
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Температура и влажность комнаты теперь следуют фактическому размещению
реального датчика в House Plan, а не остаются в его HA-зоне. Автоматические
климатические показатели работают и в комнатах без HA-зоны; подпись,
подсказка и температурная заливка согласованы
([#317](https://github.com/Matysh/houseplan-card/issues/317)).
- Активные физические настенные выключатели, для которых Home Assistant не
предоставляет собственных сущностей, больше не выглядят постоянно
недоступными: теперь они остаются непрозрачными и становятся жёлтыми при
+11 -1
View File
@@ -704,6 +704,14 @@ missing destructive confirmation or an editor exception that breaks View.
placed on the plan (hidden by filtering or by the user) still feeds the
room card, the tooltip and the temperature fill; fridges/TRVs still do
not; an explicit per-room source still wins [auto: unit devices.test]
- [ ] Room climate follows explicit House Plan placement (#317): a real
temperature/humidity sensor moved away from registry Area A votes exactly
once in its marker target, including an area-less `space + room_id` room;
hidden markers still vote, while removed/HA-disabled ones do not. Exact
entity placement wins over its parent device only for that entity, and
explicit room sources remain authoritative [auto:
smoke_room_climate_placement; units: test/devices.test.mjs; mutation:
room-climate-ignores-marker-placement]
- [ ] Room hover + tooltip: in View, hovering any room visibly highlights it
(filled, transparent and area-less alike) and shows its name plus clean-
floor area; temperature/signal follow when available. Thick walls reduce
@@ -1441,7 +1449,9 @@ separately promised workflows:
- [ ] Climate cost does not grow with rooms (v1.45.0, review R2-3): on a plan
with dozens of rooms an unrelated HA state update triggers ONE registry
pass, repeated renders on the same snapshot trigger none, and a changed
sensor value is still visible immediately [auto: smoke_climate_once]
sensor value is still visible immediately. Explicit marker targets and
area-less rooms are indexed before this same pass; they do not add a pass
per room [auto: smoke_climate_once + smoke_room_climate_placement]
- [ ] Plan upload survives a concurrent config revision (v1.44.8): with a second
tab open on the same plan, attach a background image in space settings —
the plan shows immediately, `plan_url` is in `.storage/houseplan.config`,
+6 -2
View File
@@ -428,8 +428,12 @@ per gesture, names the rule the next step would break.
### HA area binding
One HA area may be bound to one room. The binding drives automatic device
placement, room LQI, light and aggregate climate. A room without an area may
still contain manually assigned devices and explicit measurement sources.
placement, room LQI, light and aggregate climate. A real temperature or
humidity sensor manually assigned to another House Plan room follows that
placement for the automatic room average instead of remaining in its registry
HA area. This also works for a room without an area; its manually assigned real
sensors provide the automatic average, while an explicit room measurement
source still takes priority.
## 9. Doors, windows, gates and locks
+4 -4
View File
@@ -465,7 +465,7 @@ Undo оптимизации.
### Связь с HA-зоной
Одна HA-зона может быть назначена только одной комнате. Связь нужна для автоматического размещения устройств, LQI, света и средних климатических показателей. Комната без HA-зоны всё равно может иметь вручную назначенные устройства и отдельные источники температуры/влажности.
Одна HA-зона может быть назначена только одной комнате. Связь нужна для автоматического размещения устройств, LQI, света и средних климатических показателей. Если реальный датчик температуры или влажности вручную назначен другой комнате House Plan, в автоматическом среднем он следует этому размещению и больше не учитывается в исходной HA-зоне. Это работает и для комнаты без HA-зоны: вручную назначенные реальные датчики автоматически дают ей среднее, а отдельно выбранный источник температуры/влажности по-прежнему имеет высший приоритет.
### Инструменты плана
@@ -798,8 +798,8 @@ binding tombstone не вырезает её из живого устройст
| Является источником света | **Авто** использует роль устройства, **Всегда** создаёт собственный источник даже без entity HA, **Никогда** исключает только собственный источник. Внешние `controls` продолжают учитываться в свете комнаты |
| Ведущая сущность | Появляется для роли **Всегда**, если у устройства несколько `light.*`/`switch.*`. Выбранная сущность определяет состояние и service call; исчезнувший выбор не стирается и временно заменяется безопасным fallback |
| Цвет и яркость свечения | **Из источника** использует живые данные HA; **Задать цвет** сохраняет живую яркость; **Задать цвет и яркость** фиксирует оба параметра (1–100%) |
| Использовать температуру climate | Добавляет `current_temperature` термостата/кондиционера в маркер и среднее комнаты |
| Скрыть / Показать | Кнопка в левом нижнем углу диалога. После сохранения убирает или возвращает значок; LQI скрытого устройства всё ещё может участвовать в среднем комнаты |
| Использовать температуру climate | Добавляет `current_temperature` термостата/кондиционера в маркер и среднее назначенной ему комнаты |
| Скрыть / Показать | Кнопка в левом нижнем углу диалога. После сохранения убирает или возвращает значок; LQI и климатические данные скрытого устройства всё ещё могут участвовать в среднем назначенной комнаты |
| Удалить | Кнопка рядом со «Скрыть». После подтверждения сразу полностью убирает устройство с плана и позволяет позднее добавить его заново |
| Радиус свечения | Локально переопределяет общий радиус, 0.1–100 м в хранилище |
| Иконка | Ручная `mdi:*`; пусто — автоматический подбор. Автовариант показывается в поле как значок с именем `mdi:*`, но не сохраняется как ручная замена |
@@ -861,7 +861,7 @@ binding tombstone не вырезает её из живого устройст
- в редакторе виден только при включённом в каталоге **Показывать скрытые на плане**;
- не показывает цвет состояния, число, температуру, влажность и LQI;
- не создаёт световое пятно и не считается источником для заливки «Свет»;
- может продолжать участвовать в среднем LQI комнаты, потому что оно считается по реестру устройств зоны.
- может продолжать участвовать в среднем LQI и климатических показателях назначенной комнаты; скрытие значка не меняет принадлежность данных.
### Деактивированные в Home Assistant
+11 -11
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": null,
"sourceFingerprint": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceFingerprint": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"captureScriptSha256": "fe76c8e2e27501e9a377d7aa15dd6cba6a018d55c07deb602dbee097f0a0a5ba",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "a4aa3af48578ee41594a9b1a6e98b4d39f3324a949a9fd9e63d5854576d5db85"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19a7251bbfeac721a2d2e08aa441e00c2bd751924b56cd5fa3d223b4b023b745",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+13
View File
@@ -105,6 +105,19 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
const MUTANT_DEFINITIONS = [
{
id: 'room-climate-ignores-marker-placement',
guard: 'node demo/smoke_room_climate_placement.mjs',
because: 'a real sensor manually placed into a House Plan room must leave its registry HA '
+ 'Area and feed the local room label, tooltip and temperature fill exactly once (#317)',
patches: [{
file: 'src/devices.ts',
find: ' const target = entityTargets.get(eid)\n'
+ ' || (reg.device_id ? deviceTargets.get(reg.device_id) : null)\n'
+ ' || reg.area_id || dev?.area_id || null;',
replace: ' const target = reg.area_id || dev?.area_id || null;',
}],
},
{
id: 'editor-runtime-fingerprint-handshake',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+7
View File
@@ -27,6 +27,13 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: ['roomClimateKey', 'roomClimateMap'],
smokes: ['smoke_room_climate_placement.mjs'],
because: 'the #317 production-bundle scenario proves one manually placed sensor through '
+ 'room label, tooltip, temperature fill and hosted Static; those DOM surfaces cannot call '
+ 'the pure room-target helpers directly',
},
{
symbols: [
'LanguageRuntime', 'LanguageRuntimeContract', 'languageRenderGate',
+66 -12
View File
@@ -1383,6 +1383,32 @@ const NON_AIR_RE = new RegExp(
*/
export interface AreaClimate { temp: number | null; hum: number | null }
/**
* Stable lookup key for one room's automatic climate aggregate.
*
* HA-area rooms deliberately keep their historical raw area id. A local room
* has no HA identity, so its key is namespaced by both space and room id; room
* ids alone are not globally unique across imported spaces.
*/
export function roomClimateKey(
spaceId: string | null | undefined,
room: { id?: string | null; area?: string | null },
): string | null {
if (room.area) return room.area;
if (!spaceId || !room.id) return null;
return `@room/${encodeURIComponent(spaceId)}/${encodeURIComponent(room.id)}`;
}
function markerClimateTarget(marker: Marker): string | null {
// Persisted local-room placement uses an explicit null Area. Do not treat a
// stale room_id without its space as a valid target or let it collide with a
// room imported into another space.
if (marker.area === null && marker.space && marker.room_id) {
return roomClimateKey(marker.space, { id: marker.room_id, area: null });
}
return marker.area || null;
}
/**
* Climate for EVERY area in one registry pass (review R2-3).
*
@@ -1391,8 +1417,8 @@ export interface AreaClimate { temp: number | null; hum: number | null }
* render — an entire frame spent re-reading metadata that did not change. The
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
*/
export function areaClimateMap(
hass: any, rules?: CompiledIconRule[], markers?: Marker[] | null,
export function roomClimateMap(
hass: BuildCtx['hass'], rules?: CompiledIconRule[], markers?: Marker[] | null,
): Map<string, AreaClimate> {
const out = new Map<string, AreaClimate>();
if (!hass?.entities) return out;
@@ -1403,13 +1429,27 @@ export function areaClimateMap(
// markers the pass below is byte-for-byte the old one.
const removed = removedPlanBindings(markers);
const climOpt = new Set<string>();
const entityTargets = new Map<string, string>();
const deviceTargets = new Map<string, string>();
for (const m of markers || []) {
if (m?.removed || m?.use_climate_temp !== true) continue;
if (m?.removed) continue;
const i = (m.binding || '').indexOf(':');
if (i > 0) climOpt.add(m.binding.slice(i + 1));
if (i <= 0) continue;
const kind = m.binding.slice(0, i);
const ref = m.binding.slice(i + 1);
if (!ref) continue;
if (m.use_climate_temp === true) climOpt.add(ref);
const target = markerClimateTarget(m);
if (!target) continue;
// Match buildDevices(): malformed duplicate live bindings resolve to the
// first saved marker, never to two rooms and never to two votes.
if (kind === 'entity' && !entityTargets.has(ref)) entityTargets.set(ref, target);
else if (kind === 'device' && !deviceTargets.has(ref)) deviceTargets.set(ref, target);
}
// area -> device (or lone entity) -> the entities that belong to it
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
// effective room target -> physical device (or lone entity) -> entities.
// Choosing the target before grouping guarantees that a manually moved
// reading cannot remain in its registry Area at the same time (#317).
const byTarget = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
// A device tombstone suppresses all of its data. An entity tombstone only
// suppresses a standalone entity; inside its live parent device it remains
@@ -1417,8 +1457,11 @@ export function areaClimateMap(
if ((reg.device_id && removed.devices.has(reg.device_id) && !removed.liveEntities.has(eid))
|| (!reg.device_id && removed.entities.has(eid) && !removed.liveEntities.has(eid))) continue;
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
const area = reg.area_id || dev?.area_id || null;
if (!area) continue;
if (!isRegistryEntryEnabled(reg) || (dev && !isRegistryEntryEnabled(dev))) continue;
const target = entityTargets.get(eid)
|| (reg.device_id ? deviceTargets.get(reg.device_id) : null)
|| reg.area_id || dev?.area_id || null;
if (!target) continue;
// Not every "temperature" is room air. Real finds on a live install: the
// NAS processor temperature, the water in a smart kettle, a sauna heater at
// 90 C and a virtual better_thermostat duplicating the real sensor (field
@@ -1433,8 +1476,8 @@ export function areaClimateMap(
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); }
let groups = byTarget.get(target);
if (!groups) { groups = new Map(); byTarget.set(target, groups); }
const key = reg.device_id || eid;
let g = groups.get(key);
if (!g) {
@@ -1448,7 +1491,7 @@ export function areaClimateMap(
}
g.ents.push(eid);
}
for (const [area, groups] of byArea) {
for (const [target, groups] of byTarget) {
const temps: number[] = [];
const hums: number[] = [];
for (const [key, g] of groups) {
@@ -1469,7 +1512,7 @@ export function areaClimateMap(
}
}
if (!temps.length && !hums.length) continue;
out.set(area, {
out.set(target, {
temp: temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null,
hum: hums.length ? Math.round(hums.reduce((a, b) => a + b, 0) / hums.length) : null,
});
@@ -1477,6 +1520,17 @@ export function areaClimateMap(
return out;
}
/**
* Historical area-only name retained for callers and integrations. The map
* still uses raw HA area ids; when explicit local-room markers are supplied it
* may additionally contain namespaced room keys returned by roomClimateKey().
*/
export function areaClimateMap(
hass: BuildCtx['hass'], rules?: CompiledIconRule[], markers?: Marker[] | null,
): Map<string, AreaClimate> {
return roomClimateMap(hass, rules, markers);
}
/** One area's reading. Convenience wrapper — prefer the map for many areas. */
export function areaClimate(
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
+11 -9
View File
@@ -112,7 +112,7 @@ import {
} from './vacuum';
import {
buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
areaTemp, areaHum, sourceValue, areaClimateMap,
areaTemp, areaHum, sourceValue, roomClimateKey, roomClimateMap,
resolvedLightSources, resolvedLightState, resolvedLightStats,
hasOwnSpatialSource, hasOwnStatefulLightSource, ownControllableEntities,
forcedLightEntityOf,
@@ -11956,21 +11956,23 @@ export class HouseplanCard extends LitElement {
private _roomTemp(r: RoomCfg): number | null {
const src = r.settings?.temp_source;
if (src) return sourceValue(this._renderPlanHass, src, 'temp', this._markers);
// every sensor of the area, placed on the plan or not (field report)
return r.area ? this._climate().get(r.area)?.temp ?? null : null;
const key = roomClimateKey(this._spaceModel()?.id, r);
return key ? this._climate().get(key)?.temp ?? null : null;
}
/** Room humidity honouring the tier-3 source override. */
private _roomHum(r: RoomCfg): number | null {
const src = r.settings?.hum_source;
if (src) return sourceValue(this._renderPlanHass, src, 'hum', this._markers);
return r.area ? this._climate().get(r.area)?.hum ?? null : null;
const key = roomClimateKey(this._spaceModel()?.id, r);
return key ? this._climate().get(key)?.hum ?? 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).
* Climate for every HA Area and explicitly placed local room, computed ONCE
* per hass snapshot (review R2-3, issue #317).
* Home Assistant hands out a new `hass` object on every state change, so
* identity is exactly the right cache key: fresh states always recompute,
* and the 60 rooms of one render share a single registry pass instead of
@@ -11983,7 +11985,7 @@ export class HouseplanCard extends LitElement {
const planHass = this._renderPlanHass;
const c = this._climateCache;
if (c && c.h === planHass && c.r === this._iconRules && c.mk === mk) return c.m;
const m = areaClimateMap(planHass, this._iconRules, mk);
const m = roomClimateMap(planHass, this._iconRules, mk);
this._climateCache = { h: planHass, r: this._iconRules, mk, m };
return m;
}
@@ -12087,10 +12089,10 @@ export class HouseplanCard extends LitElement {
const top = ((point[1] - view.y) / view.h) * 100;
const op = Math.min(1, disp.opacity + 0.25);
const k = this._labelScale(r);
// Optional metrics row. Light sources may use an explicit room_id even
// when this room has no HA area; the other aggregate metrics still need it.
// Optional metrics row. Temperature/humidity and light sources may use an
// explicit room_id even when this room has no HA Area. LQI remains area-only.
const rows: TemplateResult[] = [];
if (r.area || r.settings?.temp_source || r.settings?.hum_source || disp.labelLight) {
if (disp.labelTemp || disp.labelHum || (disp.labelLqi && r.area) || disp.labelLight) {
if (disp.labelTemp) {
const t = this._roomTemp(r);
if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`);
+1 -1
View File
@@ -2011,7 +2011,7 @@ export function isAlarmState(
* Parse a marker's room reference. Two shapes:
* - `space#area` — a room bound to an HA area (historical form)
* - `space#@roomId` — a room WITHOUT an area (sub-area rooms, issue #3):
* devices are placed into it manually, by room id.
* devices and their room-aware data are assigned manually, by room id.
*/
export function parseRoomRef(
v: string | null | undefined,
+19 -2
View File
@@ -7,7 +7,10 @@
* Geometry/model math lives in space-geometry.ts (pure, unit-tested).
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaTemp, resolvedLightSources, resolvedLightState } from './devices';
import {
buildDevices, areaLqi, roomClimateKey, roomClimateMap, sourceValue,
resolvedLightSources, resolvedLightState,
} from './devices';
import {
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
@@ -197,6 +200,20 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// two cards (HP-1511-01). Rendering still draws `devs` only.
const defPos = defaultPositions(spaceDevs, space, iconPct);
// Hosted Static uses the same room-aware automatic aggregate as the full
// card. Build it once for the whole render: room labels/fills must never
// rescan the registry or fall back to visible markers only (#317).
const iconRules = compileIconRules(
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
);
const roomClimate = roomClimateMap(planHass, iconRules, o.cfg.markers || []);
const roomTemperature = (room: typeof space.rooms[number]): number | null => {
const source = room.settings?.temp_source;
if (source) return sourceValue(planHass, source, 'temp', o.cfg.markers || []);
const key = roomClimateKey(space.id, room);
return key ? roomClimate.get(key)?.temp ?? null : null;
};
// docs/CANVAS.md §4: the static card frames the CONTENT, exactly like the
// full one — `space.vb` is only the stored hint now, and on a plan drawn
// past the old unit square it framed empty canvas with the house off-screen.
@@ -268,7 +285,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
fill === 'light'
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, room, o.virtualLights))
: 'none',
fill === 'temp' && room.area ? areaTemp(planHass, spaceDevs, room.area) : null,
fill === 'temp' ? roomTemperature(room) : null,
disp.tempMin,
disp.tempMax,
colors,
+4 -1
View File
@@ -142,7 +142,10 @@ export interface Marker {
calibration?: Record<string, number[]>;
segment_map?: Record<string, string>;
} | null;
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
/** Manual placement into a House Plan room without an HA Area. Room-aware
* consumers resolve it together with `space`; visual placement is not its
* only use. */
room_id?: string | null;
/** `ripple` is legacy read compatibility; UI maps it to icon_ripple. */
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | 'static_icon' | null; // how the device is drawn
ripple_color?: string | null;
+128
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, climateTempFor,
areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate, areaClimateMap,
roomClimateKey, roomClimateMap,
litLightEntity, resolvedDeviceStateEntities, resolvedLightSources, resolvedLightState,
resolvedLightStats, hasOwnSpatialSource, selectSpatialGlowSource, seedHiddenBindings,
deletePlanMarkerRecords, effectiveMarkerControls, persistedExternalControls,
@@ -1300,6 +1301,133 @@ test('areaClimateMap: температура и влажность живут в
assert.deepEqual(areaClimateMap(hass).get('hall'), { temp: 21.4, hum: 48 });
});
test('issue 317: explicit device placement moves one climate vote between HA areas', () => {
const hass = {
devices: {
moved: { id: 'moved', name: 'Moved thermometer', area_id: 'old' },
stayed: { id: 'stayed', name: 'Stayed thermometer', area_id: 'old' },
},
entities: {
'sensor.moved_temperature': { device_id: 'moved', platform: 'demo' },
'sensor.stayed_temperature': { device_id: 'stayed', platform: 'demo' },
},
states: {
'sensor.moved_temperature': { state: '20', attributes: { device_class: 'temperature' } },
'sensor.stayed_temperature': { state: '30', attributes: { device_class: 'temperature' } },
},
};
const moved = [{
id: 'moved', binding: 'device:moved', space: 'f1', area: 'new', hidden: true,
}];
const map = roomClimateMap(hass, undefined, moved);
assert.deepEqual(map.get('old'), { temp: 30, hum: null });
assert.deepEqual(map.get('new'), { temp: 20, hum: null });
// Explicit and registry placement agree: the physical device still votes once.
const same = [{ id: 'moved', binding: 'device:moved', space: 'f1', area: 'old' }];
assert.deepEqual(roomClimateMap(hass, undefined, same).get('old'), { temp: 25, hum: null });
});
test('issue 317: a hidden real sensor supplies an area-less House Plan room', () => {
const hass = {
devices: { q: { id: 'q', name: 'Qingping Air Monitor', area_id: 'registry-room' } },
entities: {
'sensor.q_temperature': { device_id: 'q', platform: 'xiaomi' },
'sensor.q_humidity': { device_id: 'q', platform: 'xiaomi' },
},
states: {
'sensor.q_temperature': { state: '21.4', attributes: { device_class: 'temperature' } },
'sensor.q_humidity': { state: '48', attributes: { device_class: 'humidity' } },
},
};
const marker = {
id: 'q', binding: 'device:q', space: 'garden', area: null, room_id: 'shed', hidden: true,
};
const key = roomClimateKey('garden', { id: 'shed', area: null });
assert.equal(key, '@room/garden/shed');
const map = roomClimateMap(hass, undefined, [marker]);
assert.deepEqual(map.get(key), { temp: 21.4, hum: 48 });
assert.equal(map.get('registry-room'), undefined, 'manual placement owns the readings');
assert.equal(roomClimateKey('other', { id: 'shed', area: null }), '@room/other/shed');
assert.equal(roomClimateKey('', { id: 'shed', area: null }), null);
});
test('issue 317: exact entity placement wins over its parent marker only for that entity', () => {
const hass = {
devices: { hub: { id: 'hub', name: 'Climate hub', area_id: 'registry-room' } },
entities: {
'sensor.hub_a_temperature': { device_id: 'hub', platform: 'demo' },
'sensor.hub_b_temperature': { device_id: 'hub', platform: 'demo' },
},
states: {
'sensor.hub_a_temperature': { state: '20', attributes: { device_class: 'temperature' } },
'sensor.hub_b_temperature': { state: '40', attributes: { device_class: 'temperature' } },
},
};
const markers = [
{ id: 'hub', binding: 'device:hub', space: 'f1', area: 'parent-room' },
{
id: 'hub-a', binding: 'entity:sensor.hub_a_temperature',
space: 'f1', area: null, room_id: 'local-room',
},
];
const map = roomClimateMap(hass, undefined, markers);
assert.deepEqual(map.get('parent-room'), { temp: 40, hum: null });
assert.deepEqual(map.get(roomClimateKey('f1', { id: 'local-room', area: null })), {
temp: 20, hum: null,
});
assert.equal(map.get('registry-room'), undefined);
});
test('issue 317: removed and HA-disabled bindings cannot feed a local room', () => {
const hass = {
devices: {
hub: { id: 'hub', name: 'Climate hub', area_id: 'old' },
disabled: { id: 'disabled', name: 'Disabled thermometer', area_id: 'old', disabled_by: 'user' },
},
entities: {
'sensor.kept_temperature': { device_id: 'hub', platform: 'demo' },
'sensor.removed_temperature': { device_id: 'hub', platform: 'demo' },
'sensor.disabled_temperature': { device_id: 'disabled', platform: 'demo' },
},
states: {
'sensor.kept_temperature': { state: '20', attributes: { device_class: 'temperature' } },
'sensor.removed_temperature': { state: '40', attributes: { device_class: 'temperature' } },
'sensor.disabled_temperature': { state: '90', attributes: { device_class: 'temperature' } },
},
};
const markers = [
{ id: 'hub', binding: 'device:hub', removed: true },
{
id: 'kept', binding: 'entity:sensor.kept_temperature',
space: 'f1', area: null, room_id: 'local-room',
},
{
id: 'disabled', binding: 'device:disabled',
space: 'f1', area: null, room_id: 'local-room',
},
];
assert.deepEqual(
roomClimateMap(hass, undefined, markers).get('@room/f1/local-room'),
{ temp: 20, hum: null },
);
});
test('issue 317: opted climate current_temperature follows manual placement', () => {
const hass = {
devices: { ac: { id: 'ac', name: 'Air conditioner', area_id: 'old' } },
entities: { 'climate.ac': { device_id: 'ac', platform: 'demo' } },
states: { 'climate.ac': { state: 'cool', attributes: { current_temperature: 23.5 } } },
};
const marker = {
id: 'ac', binding: 'device:ac', space: 'f2', area: null, room_id: 'bed',
use_climate_temp: true,
};
const map = roomClimateMap(hass, undefined, [marker]);
assert.deepEqual(map.get('@room/f2/bed'), { temp: 23.5, hum: null });
assert.equal(map.get('old'), undefined);
});
test('primaryEntity: a service switch never beats the visible main function (TRV)', () => {
// the owner's radiator heads, verified live: switch outranks climate in the