feat: add contextual Zigbee topology hover

Issue: #54
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-04 14:40:09 +03:00
parent e5587aed7c
commit 85702afc0c
56 changed files with 2481 additions and 564 deletions
@@ -1,125 +1,169 @@
{
"schema": 1,
"fingerprint": "3b3266f5c1877e9391391f251beca97edd3e900ec4e8b5c76e1104f30c9bc546",
"fingerprint": "355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-C8w37GDk.js",
"houseplan-assets/houseplan-card-BcCJlm2v.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 294865,
"initialViewGzipBytes": 295363,
"lazyFiles": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/de-BjgDA_-V.js",
"houseplan-assets/editor-DE8oZkN6.js",
"houseplan-assets/fr-BJ_QuGU7.js",
"houseplan-assets/houseplan-editor-runtime--Pbh6-7a.js",
"houseplan-assets/houseplan-onboarding-runtime-krqWbC8A.js"
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/de-vj2e8G-y.js",
"houseplan-assets/editor-B4uO_Hit.js",
"houseplan-assets/fr-B2dlk0Va.js",
"houseplan-assets/houseplan-editor-runtime-Bya6M8Zb.js",
"houseplan-assets/houseplan-onboarding-runtime-BZHqs9mL.js",
"houseplan-assets/hp-zigbee-topology-overlay-rOIQtlN5.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js",
"houseplan-assets/zigbee-topology-runtime-DHrStvLy.js"
],
"lazyGzipBytes": 223581,
"lazyGzipBytes": 234005,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/editor-DE8oZkN6.js",
"houseplan-assets/houseplan-editor-runtime--Pbh6-7a.js"
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/editor-B4uO_Hit.js",
"houseplan-assets/houseplan-editor-runtime-Bya6M8Zb.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js"
],
"lazyEditorGzipBytes": 167513,
"lazyEditorGzipBytes": 173855,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/houseplan-onboarding-runtime-krqWbC8A.js"
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/houseplan-onboarding-runtime-BZHqs9mL.js"
],
"lazyOnboardingGzipBytes": 14236,
"lazyOnboardingGzipBytes": 14239,
"lazyLocaleFiles": [
"houseplan-assets/de-BjgDA_-V.js",
"houseplan-assets/fr-BJ_QuGU7.js"
"houseplan-assets/de-vj2e8G-y.js",
"houseplan-assets/fr-B2dlk0Va.js"
],
"lazyLocaleGzipBytes": 49112,
"lazyLocaleGzipBytes": 49119,
"files": [
{
"path": "houseplan-assets/backdrop-pick-vUmKfDDT.js",
"sha256": "f789275a2e1a8044a4edd4f18470514139f3e7d8f6828605dc37811ca84a714e",
"path": "houseplan-assets/backdrop-pick-DVnyEcZa.js",
"sha256": "bfb00d8595149d7468a205ee5fc178a499257afd609f96ae7f3894c2ba4d6ce5",
"rawBytes": 21418,
"gzipBytes": 7280,
"gzipBytes": 7284,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BjgDA_-V.js",
"sha256": "f7a91fd53b06aa187f70988f89bed5e3431d4bb544b51346f9db7912826fa534",
"path": "houseplan-assets/de-vj2e8G-y.js",
"sha256": "b59b4bd44e1c546448137f6d8f1b6e5c6d0e06a51d9886749ceb8ac15473c07a",
"rawBytes": 82182,
"gzipBytes": 24826,
"gzipBytes": 24830,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DE8oZkN6.js",
"sha256": "6d8d8b1f353f6747801909b0221de10847036a7a077a1b45453b122f010bf03e",
"path": "houseplan-assets/editor-B4uO_Hit.js",
"sha256": "b484a2f0ec27ce089a214d1c399d3d293e0d34cb0f9fc82a7543ea43a0481503",
"rawBytes": 3826,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BJ_QuGU7.js",
"sha256": "5164aed80408d9ea4cc81ae467221f991b517a4d65efb74afd73499fa163f32e",
"path": "houseplan-assets/fr-B2dlk0Va.js",
"sha256": "cadea9f03538e80cd8d72268adfb6865437e573a69725fd3d8c004174e634672",
"rawBytes": 84312,
"gzipBytes": 24286,
"gzipBytes": 24289,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-C8w37GDk.js",
"sha256": "cac6e48f53e8a00900cfca465d7d1692b44a700a0438075e83f669d5fc367089",
"rawBytes": 1047374,
"gzipBytes": 294069,
"path": "houseplan-assets/houseplan-card-BcCJlm2v.js",
"sha256": "6ddaa80e5d1eadb3c1b75850367413559a9852048d0b272116b65bc54647baa6",
"rawBytes": 1048696,
"gzipBytes": 294567,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-BjgDA_-V.js",
"houseplan-assets/editor-DE8oZkN6.js",
"houseplan-assets/fr-BJ_QuGU7.js",
"houseplan-assets/houseplan-editor-runtime--Pbh6-7a.js",
"houseplan-assets/houseplan-onboarding-runtime-krqWbC8A.js"
"houseplan-assets/de-vj2e8G-y.js",
"houseplan-assets/editor-B4uO_Hit.js",
"houseplan-assets/fr-B2dlk0Va.js",
"houseplan-assets/houseplan-editor-runtime-Bya6M8Zb.js",
"houseplan-assets/houseplan-onboarding-runtime-BZHqs9mL.js",
"houseplan-assets/hp-zigbee-topology-overlay-rOIQtlN5.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime--Pbh6-7a.js",
"sha256": "605aa48f413ea7e97c30019d7da80c8b4048f2758c1afb8180f337475842772c",
"rawBytes": 601994,
"gzipBytes": 158652,
"path": "houseplan-assets/houseplan-editor-runtime-Bya6M8Zb.js",
"sha256": "066fb86f20424f03b246d5ae8055c9e0ee282c103ae5a13339a95450a52a7e49",
"rawBytes": 608944,
"gzipBytes": 160244,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/houseplan-card-BcCJlm2v.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-DHrStvLy.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-BZHqs9mL.js",
"sha256": "01fa5e1aa7ed3a37a62183d62b79b99b43572e5ec81eb5873fab4ca1ebfa0413",
"rawBytes": 28088,
"gzipBytes": 6955,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-krqWbC8A.js",
"sha256": "c8e02dc7958910578b39c227a1adcce9f6b272a22e0e403ea587859fc679067d",
"rawBytes": 28088,
"gzipBytes": 6956,
"path": "houseplan-assets/hp-zigbee-topology-overlay-rOIQtlN5.js",
"sha256": "14e0452ab14bdd3bf27e1f8194825faacd7c5919aa1975cb887a7c3db20eb6a4",
"rawBytes": 6007,
"gzipBytes": 2380,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/houseplan-card-BcCJlm2v.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js",
"houseplan-assets/zigbee-topology-runtime-DHrStvLy.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-BoNFdBaW.js",
"sha256": "ab6139986f9dcc63cd9ca62fac1f31bc19622d47e0131f0a9453812870702d97",
"rawBytes": 12072,
"gzipBytes": 4746,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-DHrStvLy.js",
"sha256": "0c061e55b92f9a73ebf8c97da4dad736d9408eecaba503a11abcac01ae1d8d69",
"rawBytes": 3817,
"gzipBytes": 1696,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BcCJlm2v.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "4182a1debed64bb2c2b337fc8ca10aa0cd3ea4c6530c597404505c277104a623",
"sha256": "3d510618b0952ac50f9ac166f4751b604cd0a719fba428dd9eed771feecdb097",
"rawBytes": 1183,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3b3266f5c1877e9391391f251beca97edd3e900ec4e8b5c76e1104f30c9bc546";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-C8w37GDk.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__="355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c";import{k as e,l as o,t,dq as s,A as a,dr as i,ds as l,dt as r,E as n,c}from"./houseplan-card-BcCJlm2v.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 r="string"==typeof this._config?.default_floor?this._config.default_floor:"",n=[...e];return r&&!e.some(e=>e.value===r)&&n.unshift({value:r,label:r}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:n}}}:{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 r();if("warm"===e)return n;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);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c";import{k as e,A as t,du as i,aw as s,c as o,l as r,j as n}from"./houseplan-card-BcCJlm2v.js";import{a,r as h,t as d}from"./zigbee-topology-BoNFdBaW.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-DHrStvLy.js";const l={revision:0,topologies:[],states:{}};class v extends e{constructor(){super(...arguments),this.devices=[],this.currentSpace="",this._runtime=l,this._hovered="",this._pointerOver=e=>{if(!this._mouseAllowed(e))return void this._clear();const t=this._deviceFromEvent(e),i=t?.dataset.id||"";i&&i!==this._hovered&&(this._hovered=i,this.requestUpdate())},this._pointerOut=e=>{const t=this._deviceFromEvent(e);if(!t||t.dataset.id!==this._hovered)return;const i=e.relatedTarget instanceof Element?e.relatedTarget.closest('[data-hp="device"]'):null;i?.dataset.id!==this._hovered&&this._clear()},this._pointerDown=e=>{"mouse"!==e.pointerType&&this._clear()}}connectedCallback(){super.connectedCallback();const e=this.getRootNode();e.host&&"undefined"!=typeof MutationObserver&&(this._hoverGateObserver=new MutationObserver(()=>{e.host.hasAttribute("data-pointer-hover")||this._clear()}),this._hoverGateObserver.observe(e.host,{attributes:!0,attributeFilter:["data-pointer-hover"]})),queueMicrotask(()=>this._connectParent())}disconnectedCallback(){this._release?.(),this._release=void 0,this._disconnectParent(),this._hoverGateObserver?.disconnect(),this._hoverGateObserver=void 0,this._hovered="",super.disconnectedCallback()}updated(e){e.has("hass")&&(this._release?.(),this._runtime=p(this.hass),this._mappedMemo=void 0,this._release=c(this.hass,()=>{this._runtime=p(this.hass),this._mappedMemo=void 0,this.requestUpdate()})),(e.has("currentSpace")||e.has("devices")||e.has("registry"))&&(this._mappedMemo=void 0,this.devices.some(e=>e.id===this._hovered&&e.space===this.currentSpace)||(this._hovered=""))}_connectParent(){const e=this.parentElement;e&&e!==this._parent&&(this._disconnectParent(),this._parent=e,e.addEventListener("pointerover",this._pointerOver),e.addEventListener("pointerout",this._pointerOut),e.addEventListener("pointerdown",this._pointerDown,!0))}_disconnectParent(){this._parent?.removeEventListener("pointerover",this._pointerOver),this._parent?.removeEventListener("pointerout",this._pointerOut),this._parent?.removeEventListener("pointerdown",this._pointerDown,!0),this._parent=void 0}_deviceFromEvent(e){for(const t of e.composedPath())if(t instanceof HTMLElement&&"device"===t.dataset.hp)return t;return null}_mouseAllowed(e){const t=this.getRootNode();return"mouse"===e.pointerType&&t.host?.hasAttribute?.("data-pointer-hover")}_clear(){this._hovered&&(this._hovered="",this.requestUpdate())}_position(e){const t=[...this._parent?.querySelectorAll(".dev[data-id]")||[]].find(t=>t.dataset.id===e);if(!t)return null;const i=Number.parseFloat(t.style.left),s=Number.parseFloat(t.style.top),o=t.getBoundingClientRect();return Number.isFinite(i)&&Number.isFinite(s)?{x:i,y:s,width:o.width,height:o.height}:null}render(){if(!this._hovered||!this.registry||!this._runtime.topologies.length)return t;const e=this._mappedMemo,n=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:a(this._runtime.topologies,this.devices,this.registry);e&&n===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:n});const p=h(n,this.currentSpace,this._hovered),c=this._position(this._hovered);if(!c)return t;const l=p.lines.map(e=>({...e,point:this._position(e.neighborMarkerId)})).filter(e=>!!e.point);return o`
${l.length?s`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
aria-hidden="true" data-hp="zigbee-topology-lines">
${l.map(e=>s`<line x1=${c.x} y1=${c.y}
x2=${e.point.x} y2=${e.point.y}
stroke=${void 0===e.lqi?"rgba(145,155,165,.85)":i(e.lqi)}
stroke-width=${void 0===e.lqi?2:2.2}
stroke-dasharray=${void 0===e.lqi?"5 5":t} opacity=".9"></line>`)}
</svg>`:t}
${l.map(e=>o`<div class="halo" data-hp="zigbee-topology-neighbor"
data-id=${e.neighborMarkerId} style="left:${e.point.x}%;top:${e.point.y}%;width:${1.22*e.point.width}px;height:${1.22*e.point.height}px"></div>`)}
${p.remoteCount?o`<div class="remote" data-hp="zigbee-topology-remote"
style="left:${c.x}%;top:${c.y}%">${d(r(this.hass),"remote_count",{n:p.remoteCount})}</div>`:t}
`}}v.properties={hass:{attribute:!1},devices:{attribute:!1},registry:{attribute:!1},currentSpace:{type:String,attribute:"current-space"},viewKey:{attribute:!1}},v.styles=n`
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
.halo {
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
}
.remote {
position: absolute; transform: translate(12px, calc(-100% - 12px));
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
font: 600 11px/1.2 system-ui, sans-serif;
}
@media (forced-colors: active) {
line { stroke: Highlight !important; }
.halo { border-color: Highlight; box-shadow: none; }
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
}
`,customElements.get("hp-zigbee-topology-overlay")||customElements.define("hp-zigbee-topology-overlay",v);export{v as HpZigbeeTopologyOverlay};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c";import{n as t,b as n}from"./zigbee-topology-BoNFdBaW.js";import{y as e}from"./houseplan-card-BcCJlm2v.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function u(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function l(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){const n=t?.payload??t;if("string"!=typeof n)return n;try{return JSON.parse(n)}catch{return null}}async function h(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const u=e(o);return c(s,`z2m:${u||String(o)}`,async()=>{if(f(t),!u)throw a("invalid_topic");const e=t?.connection?.subscribeMessage;if("function"!=typeof e||"function"!=typeof t?.callService)throw a("unsupported");const o=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),i=Date.now()+Math.max(1,r);let s=null,c=null;const l=new Promise(t=>{s=t}),p=new Promise(t=>{c=t}),m=[];try{const r=await e.call(t.connection,t=>{!0===t?.retain&&d(t)&&s?.()},{type:"mqtt/subscribe",topic:`${u}/bridge/info`});"function"==typeof r&&m.push(r);const f=await e.call(t.connection,t=>{if(!0===t?.retain)return;const n=d(t);n&&function(t){const n=t?.transaction??t?.data?.transaction;return"string"==typeof n||"number"==typeof n?String(n):null}(n)===o&&c?.(n)},{type:"mqtt/subscribe",topic:`${u}/bridge/response/networkmap`});"function"==typeof f&&m.push(f),await h(l,Math.min(4e3,Math.max(1,i-Date.now()))),await t.callService("mqtt","publish",{topic:`${u}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:o}),qos:0,retain:!1});const g=await h(p,i-Date.now());if(g?.status&&"ok"!==g.status)throw a("provider");return n(g,u)}finally{for(const t of m)try{t()}catch{}}})}export{p as readZhaTopology,m as refreshZ2mTopology,l as subscribeZigbeeTopology,u as zigbeeTopologyRuntimeSnapshot};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3b3266f5c1877e9391391f251beca97edd3e900ec4e8b5c76e1104f30c9bc546";try{await import("./houseplan-assets/houseplan-card-C8w37GDk.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c";try{await import("./houseplan-assets/houseplan-card-BcCJlm2v.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+47
View File
@@ -0,0 +1,47 @@
/** #54: bounded provider normalization and incident-only hover performance. */
import { performance } from 'node:perf_hooks';
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js';
const NODES = 500;
const SAMPLES = 9;
const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 };
const hex = (index) => index.toString(16).padStart(16, '0');
const rows = Array.from({ length: NODES }, (_, index) => ({
ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => {
const other = index + offset;
return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : [];
}),
}));
const registry = {
revision: 1, authoritative: true, access: 'full', lastSuccess: Date.now(), entities: {},
devices: Object.fromEntries(rows.map((_, index) => [`d${index}`, { id: `d${index}` }])),
};
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
const devices = rows.map((_, index) => ({
id: `m${index}`, name: '', model: '', area: '', space: index % 5 ? 'main' : 'remote',
icon: '', entities: [], bindingKind: 'device', bindingRef: `d${index}`, bindingStatus: active,
}));
const measured = (fn) => {
const samples = [];
for (let i = 0; i < SAMPLES; i++) {
const started = performance.now(); fn(); samples.push(performance.now() - started);
}
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length * .9)];
};
let topology; let mapped;
const normalizeMs = measured(() => { topology = normalizeZhaTopology(rows); });
const mapMs = measured(() => { mapped = mapTopologies([topology], devices, registry); });
const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', 'm1'));
const repeatedHoverMs = measured(() => {
for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`);
});
const result = { nodes: topology.nodes.length, links: topology.links.length,
normalizeMs, mapMs, firstHoverMs, repeatedHoverMs };
console.log(JSON.stringify(result, null, 2));
for (const [name, budget] of Object.entries(BUDGETS)) {
if (result[name] > budget) throw new Error(`${name} ${result[name].toFixed(2)} ms > ${budget} ms`);
}
+102
View File
@@ -0,0 +1,102 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const wait = (predicate, timeout = 3000) => new Promise((resolve, reject) => {
const started = performance.now();
const tick = () => {
if (predicate()) return resolve();
if (performance.now() - started > timeout) return reject(new Error('topology smoke timeout'));
setTimeout(tick, 20);
};
tick();
});
const mouse = (type, relatedTarget = null) => new PointerEvent(type, {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, relatedTarget,
});
const out = {};
await card.updateComplete;
out.defaultOff = !root().querySelector('hp-zigbee-topology-overlay');
out.offHasNoLazyChunk = !performance.getEntriesByType('resource')
.some((entry) => /zigbee-topology-runtime|hp-zigbee-topology-overlay/.test(entry.name));
let zhaCalls = 0;
const originalHass = card.hass;
card.hass = { ...originalHass, callWS: async (message) => {
if (message.type !== 'zha/devices') return originalHass.callWS(message);
zhaCalls++;
return [
{ ieee: '00124b0000000001', device_reg_id: 'd_light1', neighbors: [
{ ieee: '00124b0000000002', lqi: 180 },
{ ieee: '00124b0000000003', lqi: 80 },
] },
{ ieee: '00124b0000000002', device_reg_id: 'd_lamp', neighbors: [] },
{ ieee: '00124b0000000003', device_reg_id: 'd_mower', neighbors: [] },
{ ieee: '00124b0000000004', device_reg_id: 'd_temp', neighbors: [
{ ieee: '00124b0000000005', lqi: 20 },
] },
{ ieee: '00124b0000000005', device_reg_id: 'd_tv', neighbors: [] },
];
} };
await card._ensureEditorRuntime();
card._openSettingsDialog();
await card.updateComplete;
let settings = root().querySelector('hp-zigbee-topology-settings');
await wait(() => !!settings?.shadowRoot);
settings._emit({ enabled: true, z2mBaseTopics: [] });
await card.updateComplete;
await card._saveSettingsDialog();
await card.updateComplete;
await wait(() => !!root().querySelector('hp-zigbee-topology-overlay'));
out.settingPersists = card._serverCfg.settings.zigbee_topology?.enabled === true;
out.enableDoesNotFetch = zhaCalls === 0;
card._openSettingsDialog();
await card.updateComplete;
settings = root().querySelector('hp-zigbee-topology-settings');
await wait(() => !!settings?.shadowRoot?.querySelector('button'));
await settings._readZha();
await wait(() => settings._snapshot?.states?.zha?.phase === 'ready');
out.explicitZhaRead = zhaCalls === 1;
card._settingsDialog = null;
card.requestUpdate();
await card.updateComplete;
const source = root().querySelector('.dev[data-id="d_light1"]');
source.dispatchEvent(mouse('pointerover'));
await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot
?.querySelector('[data-hp="zigbee-topology-lines"]'));
const overlay = root().querySelector('hp-zigbee-topology-overlay');
out.incidentOnly = overlay.shadowRoot.querySelectorAll('line').length === 1
&& overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-neighbor"]').length === 1;
out.crossSpaceCount = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]')
?.textContent.trim() === '+1 in other spaces';
out.pointerTransparent = getComputedStyle(overlay).pointerEvents === 'none'
&& getComputedStyle(overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]')).pointerEvents === 'none';
source.dispatchEvent(mouse('pointerout', root().querySelector('.stage')));
await card.updateComplete;
out.leaveClears = !overlay.shadowRoot.querySelector('line');
source.dispatchEvent(mouse('pointerover'));
await wait(() => overlay.shadowRoot.querySelector('line'));
root().querySelector('.stage').dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'touch', bubbles: true, composed: true, pointerId: 7,
}));
await wait(() => !overlay.shadowRoot.querySelector('line'));
out.touchClears = !overlay.shadowRoot.querySelector('line');
card._setMode('plan');
await card.updateComplete;
out.editorHasNoOverlay = !root().querySelector('hp-zigbee-topology-overlay');
card._setMode('view');
card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: false } };
await card.updateComplete;
out.nonAdminHasNoOverlay = !root().querySelector('hp-zigbee-topology-overlay') && zhaCalls === 1;
return out;
});
checkAll(result);
await finish(browser, result);
+100 -56
View File
@@ -1,125 +1,169 @@
{
"schema": 1,
"fingerprint": "3b3266f5c1877e9391391f251beca97edd3e900ec4e8b5c76e1104f30c9bc546",
"fingerprint": "355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-C8w37GDk.js",
"houseplan-assets/houseplan-card-BcCJlm2v.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 294865,
"initialViewGzipBytes": 295363,
"lazyFiles": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/de-BjgDA_-V.js",
"houseplan-assets/editor-DE8oZkN6.js",
"houseplan-assets/fr-BJ_QuGU7.js",
"houseplan-assets/houseplan-editor-runtime--Pbh6-7a.js",
"houseplan-assets/houseplan-onboarding-runtime-krqWbC8A.js"
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/de-vj2e8G-y.js",
"houseplan-assets/editor-B4uO_Hit.js",
"houseplan-assets/fr-B2dlk0Va.js",
"houseplan-assets/houseplan-editor-runtime-Bya6M8Zb.js",
"houseplan-assets/houseplan-onboarding-runtime-BZHqs9mL.js",
"houseplan-assets/hp-zigbee-topology-overlay-rOIQtlN5.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js",
"houseplan-assets/zigbee-topology-runtime-DHrStvLy.js"
],
"lazyGzipBytes": 223581,
"lazyGzipBytes": 234005,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/editor-DE8oZkN6.js",
"houseplan-assets/houseplan-editor-runtime--Pbh6-7a.js"
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/editor-B4uO_Hit.js",
"houseplan-assets/houseplan-editor-runtime-Bya6M8Zb.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js"
],
"lazyEditorGzipBytes": 167513,
"lazyEditorGzipBytes": 173855,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/houseplan-onboarding-runtime-krqWbC8A.js"
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/houseplan-onboarding-runtime-BZHqs9mL.js"
],
"lazyOnboardingGzipBytes": 14236,
"lazyOnboardingGzipBytes": 14239,
"lazyLocaleFiles": [
"houseplan-assets/de-BjgDA_-V.js",
"houseplan-assets/fr-BJ_QuGU7.js"
"houseplan-assets/de-vj2e8G-y.js",
"houseplan-assets/fr-B2dlk0Va.js"
],
"lazyLocaleGzipBytes": 49112,
"lazyLocaleGzipBytes": 49119,
"files": [
{
"path": "houseplan-assets/backdrop-pick-vUmKfDDT.js",
"sha256": "f789275a2e1a8044a4edd4f18470514139f3e7d8f6828605dc37811ca84a714e",
"path": "houseplan-assets/backdrop-pick-DVnyEcZa.js",
"sha256": "bfb00d8595149d7468a205ee5fc178a499257afd609f96ae7f3894c2ba4d6ce5",
"rawBytes": 21418,
"gzipBytes": 7280,
"gzipBytes": 7284,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BjgDA_-V.js",
"sha256": "f7a91fd53b06aa187f70988f89bed5e3431d4bb544b51346f9db7912826fa534",
"path": "houseplan-assets/de-vj2e8G-y.js",
"sha256": "b59b4bd44e1c546448137f6d8f1b6e5c6d0e06a51d9886749ceb8ac15473c07a",
"rawBytes": 82182,
"gzipBytes": 24826,
"gzipBytes": 24830,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DE8oZkN6.js",
"sha256": "6d8d8b1f353f6747801909b0221de10847036a7a077a1b45453b122f010bf03e",
"path": "houseplan-assets/editor-B4uO_Hit.js",
"sha256": "b484a2f0ec27ce089a214d1c399d3d293e0d34cb0f9fc82a7543ea43a0481503",
"rawBytes": 3826,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BJ_QuGU7.js",
"sha256": "5164aed80408d9ea4cc81ae467221f991b517a4d65efb74afd73499fa163f32e",
"path": "houseplan-assets/fr-B2dlk0Va.js",
"sha256": "cadea9f03538e80cd8d72268adfb6865437e573a69725fd3d8c004174e634672",
"rawBytes": 84312,
"gzipBytes": 24286,
"gzipBytes": 24289,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-C8w37GDk.js",
"sha256": "cac6e48f53e8a00900cfca465d7d1692b44a700a0438075e83f669d5fc367089",
"rawBytes": 1047374,
"gzipBytes": 294069,
"path": "houseplan-assets/houseplan-card-BcCJlm2v.js",
"sha256": "6ddaa80e5d1eadb3c1b75850367413559a9852048d0b272116b65bc54647baa6",
"rawBytes": 1048696,
"gzipBytes": 294567,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-BjgDA_-V.js",
"houseplan-assets/editor-DE8oZkN6.js",
"houseplan-assets/fr-BJ_QuGU7.js",
"houseplan-assets/houseplan-editor-runtime--Pbh6-7a.js",
"houseplan-assets/houseplan-onboarding-runtime-krqWbC8A.js"
"houseplan-assets/de-vj2e8G-y.js",
"houseplan-assets/editor-B4uO_Hit.js",
"houseplan-assets/fr-B2dlk0Va.js",
"houseplan-assets/houseplan-editor-runtime-Bya6M8Zb.js",
"houseplan-assets/houseplan-onboarding-runtime-BZHqs9mL.js",
"houseplan-assets/hp-zigbee-topology-overlay-rOIQtlN5.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime--Pbh6-7a.js",
"sha256": "605aa48f413ea7e97c30019d7da80c8b4048f2758c1afb8180f337475842772c",
"rawBytes": 601994,
"gzipBytes": 158652,
"path": "houseplan-assets/houseplan-editor-runtime-Bya6M8Zb.js",
"sha256": "066fb86f20424f03b246d5ae8055c9e0ee282c103ae5a13339a95450a52a7e49",
"rawBytes": 608944,
"gzipBytes": 160244,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/houseplan-card-BcCJlm2v.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-DHrStvLy.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-BZHqs9mL.js",
"sha256": "01fa5e1aa7ed3a37a62183d62b79b99b43572e5ec81eb5873fab4ca1ebfa0413",
"rawBytes": 28088,
"gzipBytes": 6955,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DVnyEcZa.js",
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-krqWbC8A.js",
"sha256": "c8e02dc7958910578b39c227a1adcce9f6b272a22e0e403ea587859fc679067d",
"rawBytes": 28088,
"gzipBytes": 6956,
"path": "houseplan-assets/hp-zigbee-topology-overlay-rOIQtlN5.js",
"sha256": "14e0452ab14bdd3bf27e1f8194825faacd7c5919aa1975cb887a7c3db20eb6a4",
"rawBytes": 6007,
"gzipBytes": 2380,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-vUmKfDDT.js",
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/houseplan-card-BcCJlm2v.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js",
"houseplan-assets/zigbee-topology-runtime-DHrStvLy.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-BoNFdBaW.js",
"sha256": "ab6139986f9dcc63cd9ca62fac1f31bc19622d47e0131f0a9453812870702d97",
"rawBytes": 12072,
"gzipBytes": 4746,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-DHrStvLy.js",
"sha256": "0c061e55b92f9a73ebf8c97da4dad736d9408eecaba503a11abcac01ae1d8d69",
"rawBytes": 3817,
"gzipBytes": 1696,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BcCJlm2v.js",
"houseplan-assets/zigbee-topology-BoNFdBaW.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "4182a1debed64bb2c2b337fc8ca10aa0cd3ea4c6530c597404505c277104a623",
"sha256": "3d510618b0952ac50f9ac166f4751b604cd0a719fba428dd9eed771feecdb097",
"rawBytes": 1183,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-C8w37GDk.js"
"houseplan-assets/houseplan-card-BcCJlm2v.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3b3266f5c1877e9391391f251beca97edd3e900ec4e8b5c76e1104f30c9bc546";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-C8w37GDk.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__="355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c";import{k as e,l as o,t,dq as s,A as a,dr as i,ds as l,dt as r,E as n,c}from"./houseplan-card-BcCJlm2v.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 r="string"==typeof this._config?.default_floor?this._config.default_floor:"",n=[...e];return r&&!e.some(e=>e.value===r)&&n.unshift({value:r,label:r}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:n}}}:{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 r();if("warm"===e)return n;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);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c";import{k as e,A as t,du as i,aw as s,c as o,l as r,j as n}from"./houseplan-card-BcCJlm2v.js";import{a,r as h,t as d}from"./zigbee-topology-BoNFdBaW.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-DHrStvLy.js";const l={revision:0,topologies:[],states:{}};class v extends e{constructor(){super(...arguments),this.devices=[],this.currentSpace="",this._runtime=l,this._hovered="",this._pointerOver=e=>{if(!this._mouseAllowed(e))return void this._clear();const t=this._deviceFromEvent(e),i=t?.dataset.id||"";i&&i!==this._hovered&&(this._hovered=i,this.requestUpdate())},this._pointerOut=e=>{const t=this._deviceFromEvent(e);if(!t||t.dataset.id!==this._hovered)return;const i=e.relatedTarget instanceof Element?e.relatedTarget.closest('[data-hp="device"]'):null;i?.dataset.id!==this._hovered&&this._clear()},this._pointerDown=e=>{"mouse"!==e.pointerType&&this._clear()}}connectedCallback(){super.connectedCallback();const e=this.getRootNode();e.host&&"undefined"!=typeof MutationObserver&&(this._hoverGateObserver=new MutationObserver(()=>{e.host.hasAttribute("data-pointer-hover")||this._clear()}),this._hoverGateObserver.observe(e.host,{attributes:!0,attributeFilter:["data-pointer-hover"]})),queueMicrotask(()=>this._connectParent())}disconnectedCallback(){this._release?.(),this._release=void 0,this._disconnectParent(),this._hoverGateObserver?.disconnect(),this._hoverGateObserver=void 0,this._hovered="",super.disconnectedCallback()}updated(e){e.has("hass")&&(this._release?.(),this._runtime=p(this.hass),this._mappedMemo=void 0,this._release=c(this.hass,()=>{this._runtime=p(this.hass),this._mappedMemo=void 0,this.requestUpdate()})),(e.has("currentSpace")||e.has("devices")||e.has("registry"))&&(this._mappedMemo=void 0,this.devices.some(e=>e.id===this._hovered&&e.space===this.currentSpace)||(this._hovered=""))}_connectParent(){const e=this.parentElement;e&&e!==this._parent&&(this._disconnectParent(),this._parent=e,e.addEventListener("pointerover",this._pointerOver),e.addEventListener("pointerout",this._pointerOut),e.addEventListener("pointerdown",this._pointerDown,!0))}_disconnectParent(){this._parent?.removeEventListener("pointerover",this._pointerOver),this._parent?.removeEventListener("pointerout",this._pointerOut),this._parent?.removeEventListener("pointerdown",this._pointerDown,!0),this._parent=void 0}_deviceFromEvent(e){for(const t of e.composedPath())if(t instanceof HTMLElement&&"device"===t.dataset.hp)return t;return null}_mouseAllowed(e){const t=this.getRootNode();return"mouse"===e.pointerType&&t.host?.hasAttribute?.("data-pointer-hover")}_clear(){this._hovered&&(this._hovered="",this.requestUpdate())}_position(e){const t=[...this._parent?.querySelectorAll(".dev[data-id]")||[]].find(t=>t.dataset.id===e);if(!t)return null;const i=Number.parseFloat(t.style.left),s=Number.parseFloat(t.style.top),o=t.getBoundingClientRect();return Number.isFinite(i)&&Number.isFinite(s)?{x:i,y:s,width:o.width,height:o.height}:null}render(){if(!this._hovered||!this.registry||!this._runtime.topologies.length)return t;const e=this._mappedMemo,n=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:a(this._runtime.topologies,this.devices,this.registry);e&&n===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:n});const p=h(n,this.currentSpace,this._hovered),c=this._position(this._hovered);if(!c)return t;const l=p.lines.map(e=>({...e,point:this._position(e.neighborMarkerId)})).filter(e=>!!e.point);return o`
${l.length?s`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
aria-hidden="true" data-hp="zigbee-topology-lines">
${l.map(e=>s`<line x1=${c.x} y1=${c.y}
x2=${e.point.x} y2=${e.point.y}
stroke=${void 0===e.lqi?"rgba(145,155,165,.85)":i(e.lqi)}
stroke-width=${void 0===e.lqi?2:2.2}
stroke-dasharray=${void 0===e.lqi?"5 5":t} opacity=".9"></line>`)}
</svg>`:t}
${l.map(e=>o`<div class="halo" data-hp="zigbee-topology-neighbor"
data-id=${e.neighborMarkerId} style="left:${e.point.x}%;top:${e.point.y}%;width:${1.22*e.point.width}px;height:${1.22*e.point.height}px"></div>`)}
${p.remoteCount?o`<div class="remote" data-hp="zigbee-topology-remote"
style="left:${c.x}%;top:${c.y}%">${d(r(this.hass),"remote_count",{n:p.remoteCount})}</div>`:t}
`}}v.properties={hass:{attribute:!1},devices:{attribute:!1},registry:{attribute:!1},currentSpace:{type:String,attribute:"current-space"},viewKey:{attribute:!1}},v.styles=n`
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
.halo {
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
}
.remote {
position: absolute; transform: translate(12px, calc(-100% - 12px));
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
font: 600 11px/1.2 system-ui, sans-serif;
}
@media (forced-colors: active) {
line { stroke: Highlight !important; }
.halo { border-color: Highlight; box-shadow: none; }
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
}
`,customElements.get("hp-zigbee-topology-overlay")||customElements.define("hp-zigbee-topology-overlay",v);export{v as HpZigbeeTopologyOverlay};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c";import{n as t,b as n}from"./zigbee-topology-BoNFdBaW.js";import{y as e}from"./houseplan-card-BcCJlm2v.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function u(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function l(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){const n=t?.payload??t;if("string"!=typeof n)return n;try{return JSON.parse(n)}catch{return null}}async function h(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const u=e(o);return c(s,`z2m:${u||String(o)}`,async()=>{if(f(t),!u)throw a("invalid_topic");const e=t?.connection?.subscribeMessage;if("function"!=typeof e||"function"!=typeof t?.callService)throw a("unsupported");const o=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),i=Date.now()+Math.max(1,r);let s=null,c=null;const l=new Promise(t=>{s=t}),p=new Promise(t=>{c=t}),m=[];try{const r=await e.call(t.connection,t=>{!0===t?.retain&&d(t)&&s?.()},{type:"mqtt/subscribe",topic:`${u}/bridge/info`});"function"==typeof r&&m.push(r);const f=await e.call(t.connection,t=>{if(!0===t?.retain)return;const n=d(t);n&&function(t){const n=t?.transaction??t?.data?.transaction;return"string"==typeof n||"number"==typeof n?String(n):null}(n)===o&&c?.(n)},{type:"mqtt/subscribe",topic:`${u}/bridge/response/networkmap`});"function"==typeof f&&m.push(f),await h(l,Math.min(4e3,Math.max(1,i-Date.now()))),await t.callService("mqtt","publish",{topic:`${u}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:o}),qos:0,retain:!1});const g=await h(p,i-Date.now());if(g?.status&&"ok"!==g.status)throw a("provider");return n(g,u)}finally{for(const t of m)try{t()}catch{}}})}export{p as readZhaTopology,m as refreshZ2mTopology,l as subscribeZigbeeTopology,u as zigbeeTopologyRuntimeSnapshot};
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3b3266f5c1877e9391391f251beca97edd3e900ec4e8b5c76e1104f30c9bc546";try{await import("./houseplan-assets/houseplan-card-C8w37GDk.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="355345b81df5df7c2cef0804f6844712b1f27064f0ac17c14c8255e18c05c73c";try{await import("./houseplan-assets/houseplan-card-BcCJlm2v.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+24
View File
@@ -1636,6 +1636,30 @@ diffs the real `seedHiddenBindings`/`buildDevices` outputs — there is no
second copy of the filter logic to drift. The field registry (#33) carries
their passports; `scripts/config-audit.mjs` treats both as `current`.
## Contextual Zigbee topology (#54, 2026-09-04)
The initial View graph owns only the fail-closed settings reader and a dynamic
overlay bridge. A saved `settings.zigbee_topology.enabled === true`, an actual
HA admin, full-card View and non-kiosk surface are all required before the
topology overlay chunk is requested. Opening the lazy General Settings runtime
does not load provider transport until an enabled saved setting needs status or
the admin presses a provider action.
`zigbee-topology.ts` normalizes ZHA and Zigbee2MQTT into unordered edge pairs
with separate directional observations, maps IEEE nodes through exact HA
registry ownership and resolves only edges incident to the hovered marker.
Routes never invent neighbor edges. `zigbee-topology-runtime.ts` owns a
per-connection memory cache and in-flight dedupe: ZHA reads `zha/devices`
without requesting a scan; Z2M verifies the retained bridge-info topic, sends
one correlated raw `routes:false` request through `mqtt.publish`, rejects
retained/foreign/late replies and always releases subscriptions.
The pointer-transparent overlay is stacked above architectural/isometric
layers and below device markers. It reads the already projected marker centres,
so flat/isometric camera and zoom remain single-source. Cache data, IEEE
addresses and raw payloads are never persisted, logged, exported or admitted
to support diagnostics.
## The initial bundle carries English and Russian whole (#400, 2026-08-31)
`en` and `ru` are synchronous dictionaries in the initial chunk; `de` and `fr`
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Administrators can optionally load a ZHA or Zigbee2MQTT topology snapshot and
see only the observed direct neighbours of the device currently under a real
mouse pointer. The diagnostic layer is off by default, never scans on hover,
counts rather than draws cross-space links, and stays absent from touch,
kiosk, editors and the static card
([#54](https://github.com/Matysh/houseplan-card/issues/54)).
- In View and kiosk, clicking or tapping a room now centres it and fits its
visible floor and boundary walls with 10% screen margins; a visible room
label offers the same action from the keyboard
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Администратор теперь может явно загрузить снимок топологии ZHA или
Zigbee2MQTT и при наведении реальной мыши видеть только наблюдаемых прямых
соседей выбранного устройства. Диагностика по умолчанию выключена, никогда не
запускает сканирование от наведения, считает вместо отрисовки связи с другими
пространствами и не появляется на touch, в киоске, редакторах и статической
карточке ([#54](https://github.com/Matysh/houseplan-card/issues/54)).
- В Просмотре и киоске клик или тап по комнате теперь центрирует и вписывает
её видимый пол и ограничивающие стены с полями 10%; видимая подпись комнаты
даёт то же действие с клавиатуры
+15
View File
@@ -77,6 +77,21 @@ new frontend restores the disabled behavior after upgrade. Full backup/import
preserves the setting and the privacy-safe support projection includes only a
validated boolean.
## Contextual Zigbee topology (#54)
`settings.zigbee_topology` is an optional global object. Only exact
`enabled: true` activates the admin-only full-card View layer; absence,
malformed values and older configs are off. `z2m_base_topics` is a bounded list
of exact MQTT base topics: empty entries, wildcards, duplicates and control
characters are dropped. Disabling the feature retains valid topics; resetting
General settings removes the object.
Provider snapshots, IEEE addresses, links, errors and timestamps are runtime
memory only. They are not config fields, do not enter backup/export or support
diagnostics, and disappear with the HA connection/page. Older frontends ignore
the settings object; the backend's unknown-settings policy preserves it. No
model or store version migration is required.
## Vacuum map routes (#162)
`marker.vacuum.map_routes` is an optional array of
+1 -1
View File
@@ -46,7 +46,7 @@ deliberate degradation is allowed under `TOUCH-SUPPORT.md`.
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, filtered auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average/fill and opt-in direct-neighbour links for one hovered device | **Closed** (spatial diagnostics; no persistent full-mesh graph) |
## Partially covered — improvement backlog stays inside these
+5
View File
@@ -25,6 +25,11 @@ URLs, paths, filenames, plan/backdrop/manual bytes, vacuum calibration and
trails, backup history, message and contact. Unknown fields are dropped rather
than copied and redacted later.
The Zigbee topology snapshot is never offered to this package: raw provider
payloads, IEEE addresses, neighbor links, base topics, timestamps and provider
errors stay out of support preview/download/submission and out of browser
storage and logs.
## Preview and authorization
Only a Home Assistant user allowed to write House Plan can build or send a
+14
View File
@@ -201,6 +201,20 @@ but hide the floating room summary, turn off **General settings → Show the roo
information window on hover**. The option is on by default and does not affect
device tooltips.
For an occasional Zigbee placement check, an administrator can enable
**General settings → Show Zigbee links when hovering over a device**. The option
is off by default. Load the provider snapshot there: **Read ZHA data** reads
ZHA's existing cache, while **Update map** starts an explicit Zigbee2MQTT raw
network-map scan for each entered base topic (default `zigbee2mqtt`). The latter
may take 10 seconds to 2 minutes and can temporarily slow the Zigbee network.
After data is loaded, moving a real mouse over a mapped Zigbee marker shows
only its observed direct neighbours. Links to markers on the current space are
lines; drawable neighbours on other spaces are summarized as a temporary
count. This is a snapshot, not the route currently used by every packet. The
layer does not appear on touch/pen, in kiosk, in editors or in the static card,
and hovering never starts a scan.
Each editor has a stable primary toolbar. Tool parameters and selected-object
actions appear in a context tray over the top of the canvas. On a narrow screen
the tray scrolls horizontally instead of shrinking the plan.
+15
View File
@@ -212,6 +212,21 @@ title: План дома
Показывать окно с информацией при наведении на комнату**. По умолчанию опция
включена и не влияет на подсказки устройств.
Для разовой проверки размещения Zigbee администратор может включить **Общие
настройки → Показывать связи Zigbee при наведении на устройство**. По умолчанию
опция выключена. Там же надо явно получить снимок: **Прочитать данные ZHA**
читает уже существующий кеш ZHA, а **Обновить карту** запускает raw-сканирование
Zigbee2MQTT для каждого указанного базового топика (по умолчанию
`zigbee2mqtt`). Сканирование занимает от 10 секунд до 2 минут и временно может
снизить отзывчивость Zigbee-сети.
После загрузки данных наведение реальной мыши на сопоставленный Zigbee-маркер
показывает только его наблюдаемых прямых соседей. Соседи в текущем пространстве
соединяются линиями, а доступные маркеры в других пространствах учитываются во
временном счётчике. Это снимок соседства, а не маршрут каждого текущего пакета.
На touch/pen, в киоске, редакторах и статической карточке слой не появляется;
само наведение никогда не запускает сканирование.
В редакторах сетка продолжается по всему рабочему холсту. В режиме просмотра сетка не показывается.
Основная панель каждого редактора содержит только постоянные инструменты, а
+8
View File
@@ -83,6 +83,14 @@ The global `settings.show_room_tooltip` preference controls only the floating
room information window. Missing or invalid values mean enabled; exact `false`
hides the window while room highlight and every device tooltip remain active.
The admin-only **General settings → Show Zigbee links on device hover** option
is off by default. Once an admin explicitly reads cached ZHA data or updates a
configured Zigbee2MQTT map, mouse hover temporarily draws only the observed
direct links incident to that device. A linked marker in another space is
counted beside the source without drawing an inter-space line. Hover never
fetches data; touch, pen, keyboard focus, kiosk, editors and the static card do
not expose this diagnostic layer.
Removed from this mode (they move, not die):
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
- room-label dragging,
+1
View File
@@ -36,6 +36,7 @@
"release:notes": "node scripts/release-notes.mjs",
"prepare": "node scripts/install-hooks.mjs",
"benchmark:junction-limits": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node demo/benchmark_junction_limits.mjs",
"benchmark:zigbee-topology": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node demo/benchmark_zigbee_topology.mjs",
"furniture:generate": "node scripts/generate-furniture-assets.mjs",
"furniture:check": "node scripts/generate-furniture-assets.mjs --check"
},
+16
View File
@@ -100,6 +100,22 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([
migration: 'safe point-in-room backfill; cross-source full import drops it',
compatibility: 'older frontends ignore it; same-source backup preserves it',
},
{
id: 'settings.zigbee_topology',
schema: 'allow-extra',
enforcedBy: 'zigbee-topology-settings.ts + explicit admin provider controls; #54',
selector: { path: ['settings', 'zigbee_topology'] },
storage: 'House Plan server config',
type: '{ enabled?: boolean, z2m_base_topics?: string[] }',
default: 'unset (= disabled; default Zigbee2MQTT base topic is presented in UI)',
level: 'global',
ui: 'General settings → Zigbee links',
runtime: 'allows a mouse-only incident-neighbour overlay after explicit provider refresh',
introduced: '#54',
status: 'current',
migration: 'none — topology snapshots remain memory-only',
compatibility: 'older frontends ignore it; backend preserves it through allow-extra',
},
{
id: 'markers[].display=ripple',
enforcedBy: 'logic.ts normalizeDeviceDisplay (read) + editor-runtime _dropLegacySegments rewrites on every write',
+44
View File
@@ -5607,6 +5607,50 @@ const MUTANT_DEFINITIONS = [
+ ' && fingerprint(current) !== attempt.attemptedFingerprint)) return false;',
}],
},
{
id: 'zigbee-topology-zha-read-starts-scan',
guard: 'node --test test/zigbee-topology.test.mjs',
because: 'hover diagnostics may read the existing ZHA snapshot but must never turn a read '
+ 'into an implicit radio scan (#54 AC4)',
patches: [{
file: 'src/zigbee-topology-runtime.ts',
find: " return normalizeZhaTopology(await hass.callWS({ type: 'zha/devices' }));",
replace: " return normalizeZhaTopology(await hass.callWS({ type: 'zha/topology/update' }));",
}],
},
{
id: 'zigbee-topology-ambiguous-marker-selected',
guard: 'node --test test/zigbee-topology.test.mjs',
because: 'a Zigbee node with multiple drawable placements must fail closed instead of '
+ 'drawing a plausible but false neighbour line (#54 AC8)',
patches: [{
file: 'src/zigbee-topology.ts',
find: ' if (candidates.length === 1) placements.set(node.key, {',
replace: ' if (candidates.length >= 1) placements.set(node.key, {',
}],
},
{
id: 'zigbee-topology-z2m-foreign-response-accepted',
guard: 'node --test test/zigbee-topology.test.mjs',
because: 'parallel Zigbee2MQTT requests share one response topic; only the matching '
+ 'transaction may complete this snapshot (#54 AC6)',
patches: [{
file: 'src/zigbee-topology-runtime.ts',
find: ' if (value && transactionOf(value) === transaction) responseResolve?.(value);',
replace: ' if (value) responseResolve?.(value);',
}],
},
{
id: 'zigbee-topology-z2m-subscriptions-leak',
guard: 'node --test test/zigbee-topology.test.mjs',
because: 'both MQTT subscriptions must be released after success or failure so one manual '
+ 'refresh cannot leave listeners processing later payloads (#54 AC6)',
patches: [{
file: 'src/zigbee-topology-runtime.ts',
find: ' try { unsubscribe(); } catch { /* cleanup is best effort */ }',
replace: ' try { void unsubscribe; } catch { /* cleanup is best effort */ }',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+10
View File
@@ -27,6 +27,16 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'renderZigbeeTopologyOverlay', 'resolveTopologyHover', 'mapTopologyNodes',
'readZhaTopology', 'refreshZ2mTopology', 'zigbeeTopologySettingsOf',
],
smokes: ['smoke_zigbee_topology_hover.mjs'],
because: 'the #54 production-bundle scenario proves default-off lazy loading, explicit provider '
+ 'refresh, incident-only mouse hover, cross-space counting, pointer transparency and immediate '
+ 'cleanup across leave, touch, editor and non-admin boundaries',
},
{
symbols: [
'beginVacuumRouteDraft', 'chooseVacuumRouteSpace', 'commitVacuumRouteDraft',
+3 -3
View File
@@ -306,7 +306,7 @@ import {
type RenderDeviceSnapshot,
} from './render-device-snapshot';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import { effectiveDeviceBaseSize } from './device-marker-geometry';
import { effectiveDeviceBaseSize } from './device-marker-geometry'; import { renderZigbeeTopologyOverlay } from './zigbee-topology-overlay-bridge';
import {
ModeTransitionController, viewportFromViewBox,
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
@@ -2074,7 +2074,7 @@ export class HouseplanCard extends LitElement {
colors: FillColors; glowRadius: number; bgColor: string | null;
/** sun on the plan (docs/SUN.md) */
northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean;
showRoomTooltip: boolean;
showRoomTooltip: boolean; zigbeeTopology: import('./zigbee-topology-settings').ZigbeeTopologySettings;
busy: boolean;
} | null = null;
private _supportDialog: SupportDialogState | null = null;
@@ -11813,7 +11813,7 @@ export class HouseplanCard extends LitElement {
<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoWalls(isoLayers, isoPanels)}
</svg>` : nothing}
</svg>` : nothing}${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, viewKey: view, view: this._mode === 'view', kiosk: this._kiosk })}
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
had, restored by the owner. `iconCqw` is `iconPct * iconUnit
+9 -10
View File
@@ -17,7 +17,7 @@ import type { HpConfirmRequest } from './danger-confirm';
import './hp-color-opacity';
import type { ColorPickerLabels } from './hp-color-opacity';
import './hp-help';
import './hp-device-preview';
import './hp-device-preview'; import './hp-zigbee-topology-settings';
import {
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
type IconRule, type CompiledIconRule,
@@ -250,7 +250,7 @@ import { enqueueSerializedWrite, optimisticAttempt, rollbackOptimistic, type Opt
import { applyCalibrationProposal, saveAutomaticCalibration, saveManualCalibration, saveVacuumMatrix,
type CalibrationProposal, type VacuumFit } from './vacuum-calibration-write';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { supportT, type SupportI18nKey } from './i18n/support';
import { supportT, type SupportI18nKey } from './i18n/support'; import { writeZigbeeTopologySettings, zigbeeTopologySettingsOf, type ZigbeeTopologySettings } from './zigbee-topology-settings';
import {
newSupportDialogState,
supportApiCompatible,
@@ -1118,8 +1118,8 @@ export interface HouseplanEditorHostPort {
_sentPos: Map<string, DeviceLayout[string] | null>;
_serverCfg: ServerConfig | null;
_serverStorage: boolean;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; };
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; showRoomTooltip: boolean; busy: boolean; } | null;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; };
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; busy: boolean; } | null;
_supportDialog: SupportDialogState | null;
_showAll: boolean;
_showHidden: boolean;
@@ -9067,8 +9067,7 @@ public _openSettingsDialog = (): void => {
northDeg: northDegOf(this.host._settings, {}),
bgMode: bgModeOf(this.host._settings, {}),
sunRays: sunRaysOn(this.host._settings, {}),
showRoomTooltip: showRoomTooltipOf(this.host._settings),
busy: false,
showRoomTooltip: showRoomTooltipOf(this.host._settings), zigbeeTopology: zigbeeTopologySettingsOf(this.host._settings), busy: false,
};
};
@@ -10265,7 +10264,7 @@ public _updateDecorStyle(next: DecorStyle): void {
try {
const cfg = this.host._serverCfg!;
const isDefault = JSON.stringify(d.colors) === JSON.stringify(DEFAULT_FILL_COLORS);
const settings: any = { ...cfg.settings };
let settings: any = { ...cfg.settings };
if (isDefault) delete settings.fill_colors;
else settings.fill_colors = d.colors;
const cm = this.host._imperial ? d.glowRadius * 30.48 : d.glowRadius * 100;
@@ -10281,7 +10280,7 @@ public _updateDecorStyle(next: DecorStyle): void {
if (d.sunRays) settings.sun_rays = true;
else delete settings.sun_rays;
if (d.showRoomTooltip) delete settings.show_room_tooltip;
else settings.show_room_tooltip = false;
else settings.show_room_tooltip = false; settings = writeZigbeeTopologySettings(settings, d.zigbeeTopology);
// Old configs may still contain this accepted field, but weather no
// longer affects sunlight; saving general settings cleans it up.
delete settings.weather_entity;
@@ -10594,7 +10593,7 @@ public _renderSettingsDialog(): TemplateResult {
<span>${supportT(
langOf(this.host.hass, this.host._config?.language), 'gs.show_room_tooltip',
)}</span>
</label>
</label><hp-zigbee-topology-settings .hass=${this.host.hass} .value=${this.host._settingsDialog!.zigbeeTopology} .savedEnabled=${zigbeeTopologySettingsOf(this.host._settings).enabled} .devices=${this.host._devices} .registry=${this.host._haRegistry} @hp-topology-settings-change=${(event: CustomEvent<ZigbeeTopologySettings>) => (this.host._settingsDialog = { ...this.host._settingsDialog!, zigbeeTopology: event.detail })}></hp-zigbee-topology-settings>
<label class="dispsection">${this.host._t('gs.light_group')}</label>
${this._renderColorRow('light_on', 'gs.light_on')}
${this._renderColorRow('light_off', 'gs.light_off')}
@@ -10720,7 +10719,7 @@ public _renderSettingsDialog(): TemplateResult {
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, showRoomTooltip: true })}>
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, showRoomTooltip: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] } })}>
${this.host._t('gs.reset')}
</button>
<span class="spacer"></span>
+203
View File
@@ -0,0 +1,203 @@
import { LitElement, css, html, svg, nothing, type PropertyValues } from 'lit';
import { langOf } from './i18n';
import { lqiColor } from './logic';
import { topologyT } from './i18n/topology';
import {
mapTopologies, resolveMappedTopologyHover, type ZigbeeHoverResolution, type ZigbeeMappedTopology,
} from './zigbee-topology';
import {
subscribeZigbeeTopology, zigbeeTopologyRuntimeSnapshot,
type ZigbeeTopologyRuntimeSnapshot,
} from './zigbee-topology-runtime';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type { DevItem } from './types';
const EMPTY_RUNTIME: ZigbeeTopologyRuntimeSnapshot = { revision: 0, topologies: [], states: {} };
const EMPTY_HOVER: ZigbeeHoverResolution = { lines: [], remoteCount: 0, omittedCount: 0 };
export class HpZigbeeTopologyOverlay extends LitElement {
static properties = {
hass: { attribute: false },
devices: { attribute: false },
registry: { attribute: false },
currentSpace: { type: String, attribute: 'current-space' },
viewKey: { attribute: false },
};
hass: any;
devices: readonly DevItem[] = [];
registry!: HaRegistrySnapshot;
currentSpace = '';
viewKey: unknown;
private _runtime = EMPTY_RUNTIME;
private _hovered = '';
private _release?: () => void;
private _parent?: HTMLElement;
private _hoverGateObserver?: MutationObserver;
private _mappedMemo?: { runtime: ZigbeeTopologyRuntimeSnapshot; devices: readonly DevItem[];
registry: HaRegistrySnapshot; mapped: ZigbeeMappedTopology[] };
static styles = css`
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
.halo {
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
}
.remote {
position: absolute; transform: translate(12px, calc(-100% - 12px));
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
font: 600 11px/1.2 system-ui, sans-serif;
}
@media (forced-colors: active) {
line { stroke: Highlight !important; }
.halo { border-color: Highlight; box-shadow: none; }
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
}
`;
connectedCallback(): void {
super.connectedCallback();
const root = this.getRootNode() as ShadowRoot;
if (root.host && typeof MutationObserver !== 'undefined') {
this._hoverGateObserver = new MutationObserver(() => {
if (!root.host.hasAttribute('data-pointer-hover')) this._clear();
});
this._hoverGateObserver.observe(root.host, { attributes: true, attributeFilter: ['data-pointer-hover'] });
}
queueMicrotask(() => this._connectParent());
}
disconnectedCallback(): void {
this._release?.();
this._release = undefined;
this._disconnectParent();
this._hoverGateObserver?.disconnect();
this._hoverGateObserver = undefined;
this._hovered = '';
super.disconnectedCallback();
}
protected updated(changed: PropertyValues<this>): void {
if (changed.has('hass')) {
this._release?.();
this._runtime = zigbeeTopologyRuntimeSnapshot(this.hass);
this._mappedMemo = undefined;
this._release = subscribeZigbeeTopology(this.hass, () => {
this._runtime = zigbeeTopologyRuntimeSnapshot(this.hass);
this._mappedMemo = undefined;
this.requestUpdate();
});
}
if (changed.has('currentSpace') || changed.has('devices') || changed.has('registry')) {
this._mappedMemo = undefined;
if (!this.devices.some((item) => item.id === this._hovered && item.space === this.currentSpace)) {
this._hovered = '';
}
}
}
private _connectParent(): void {
const parent = this.parentElement;
if (!parent || parent === this._parent) return;
this._disconnectParent();
this._parent = parent;
parent.addEventListener('pointerover', this._pointerOver);
parent.addEventListener('pointerout', this._pointerOut);
parent.addEventListener('pointerdown', this._pointerDown, true);
}
private _disconnectParent(): void {
this._parent?.removeEventListener('pointerover', this._pointerOver);
this._parent?.removeEventListener('pointerout', this._pointerOut);
this._parent?.removeEventListener('pointerdown', this._pointerDown, true);
this._parent = undefined;
}
private _deviceFromEvent(event: Event): HTMLElement | null {
for (const value of event.composedPath()) {
if (value instanceof HTMLElement && value.dataset.hp === 'device') return value;
}
return null;
}
private _mouseAllowed(event: PointerEvent): boolean {
const root = this.getRootNode() as ShadowRoot;
return event.pointerType === 'mouse' && root.host?.hasAttribute?.('data-pointer-hover');
}
private _pointerOver = (event: PointerEvent): void => {
if (!this._mouseAllowed(event)) { this._clear(); return; }
const device = this._deviceFromEvent(event);
const id = device?.dataset.id || '';
if (id && id !== this._hovered) { this._hovered = id; this.requestUpdate(); }
};
private _pointerOut = (event: PointerEvent): void => {
const from = this._deviceFromEvent(event);
if (!from || from.dataset.id !== this._hovered) return;
const related = event.relatedTarget instanceof Element
? event.relatedTarget.closest<HTMLElement>('[data-hp="device"]') : null;
if (related?.dataset.id === this._hovered) return;
this._clear();
};
private _pointerDown = (event: PointerEvent): void => {
if (event.pointerType !== 'mouse') this._clear();
};
private _clear(): void {
if (!this._hovered) return;
this._hovered = '';
this.requestUpdate();
}
private _position(id: string): { x: number; y: number; width: number; height: number } | null {
const marker = [...(this._parent?.querySelectorAll<HTMLElement>('.dev[data-id]') || [])]
.find((item) => item.dataset.id === id);
if (!marker) return null;
const x = Number.parseFloat(marker.style.left);
const y = Number.parseFloat(marker.style.top);
const rect = marker.getBoundingClientRect();
return Number.isFinite(x) && Number.isFinite(y)
? { x, y, width: rect.width, height: rect.height } : null;
}
protected render() {
if (!this._hovered || !this.registry || !this._runtime.topologies.length) return nothing;
const memo = this._mappedMemo;
const mapped = memo && memo.runtime === this._runtime && memo.devices === this.devices
&& memo.registry === this.registry ? memo.mapped : mapTopologies(this._runtime.topologies, this.devices, this.registry);
if (!memo || mapped !== memo.mapped) this._mappedMemo = { runtime: this._runtime, devices: this.devices, registry: this.registry, mapped };
const hover = resolveMappedTopologyHover(mapped, this.currentSpace, this._hovered);
const origin = this._position(this._hovered);
if (!origin) return nothing;
const lines = hover.lines.map((line) => ({ ...line, point: this._position(line.neighborMarkerId) }))
.filter((line) => !!line.point);
return html`
${lines.length ? svg`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
aria-hidden="true" data-hp="zigbee-topology-lines">
${lines.map((line) => svg`<line x1=${origin.x} y1=${origin.y}
x2=${line.point!.x} y2=${line.point!.y}
stroke=${line.lqi === undefined ? 'rgba(145,155,165,.85)' : lqiColor(line.lqi)}
stroke-width=${line.lqi === undefined ? 2 : 2.2}
stroke-dasharray=${line.lqi === undefined ? '5 5' : nothing} opacity=".9"></line>`)}
</svg>` : nothing}
${lines.map((line) => html`<div class="halo" data-hp="zigbee-topology-neighbor"
data-id=${line.neighborMarkerId} style="left:${line.point!.x}%;top:${line.point!.y}%;width:${line.point!.width * 1.22}px;height:${line.point!.height * 1.22}px"></div>`)}
${hover.remoteCount ? html`<div class="remote" data-hp="zigbee-topology-remote"
style="left:${origin.x}%;top:${origin.y}%">${topologyT(
langOf(this.hass), 'remote_count', { n: hover.remoteCount },
)}</div>` : nothing}
`;
}
}
if (!customElements.get('hp-zigbee-topology-overlay')) {
customElements.define('hp-zigbee-topology-overlay', HpZigbeeTopologyOverlay);
}
+208
View File
@@ -0,0 +1,208 @@
import { LitElement, css, html, nothing, type PropertyValues } from 'lit';
import { langOf } from './i18n';
import { topologyT, type TopologyI18nKey } from './i18n/topology';
import {
normalizeZ2mBaseTopic, type ZigbeeTopologySettings,
} from './zigbee-topology-settings';
import { mapTopologyNodes, TOPOLOGY_STALE_MS } from './zigbee-topology';
import type { DevItem } from './types';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type {
ZigbeeProviderState, ZigbeeTopologyRuntimeSnapshot,
} from './zigbee-topology-runtime';
const EMPTY_RUNTIME: ZigbeeTopologyRuntimeSnapshot = { revision: 0, topologies: [], states: {} };
export class HpZigbeeTopologySettings extends LitElement {
static properties = {
hass: { attribute: false },
value: { attribute: false },
savedEnabled: { type: Boolean, attribute: 'saved-enabled' },
devices: { attribute: false },
registry: { attribute: false },
};
hass: any;
value: ZigbeeTopologySettings = { enabled: false, z2mBaseTopics: [] };
savedEnabled = false;
devices: readonly DevItem[] = [];
registry?: HaRegistrySnapshot;
private _runtime: typeof import('./zigbee-topology-runtime') | null = null;
private _snapshot = EMPTY_RUNTIME;
private _release?: () => void;
private _topicText = 'zigbee2mqtt';
private _invalidTopic = false;
static styles = css`
:host { display: block; color: inherit; font: inherit; }
.section { margin-top: 18px; font-weight: 700; }
.toggle { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.hint, .status { color: var(--secondary-text-color, #9aa0aa); font-size: 12px; line-height: 1.45; }
.hint { margin-top: 6px; }
.providers { display: grid; gap: 14px; margin-top: 12px; padding-left: 34px; }
.provider { display: grid; gap: 7px; }
.provider-title { font-weight: 650; }
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
textarea {
box-sizing: border-box; width: 100%; min-height: 58px; resize: vertical;
border: 1px solid var(--divider-color, #666); border-radius: 8px; padding: 8px;
color: var(--primary-text-color, inherit); background: var(--card-background-color, #202126);
font: inherit;
}
button {
display: inline-flex; align-items: center; gap: 6px; min-height: 36px;
border: 1px solid var(--divider-color, #666); border-radius: 9px; padding: 7px 11px;
color: inherit; background: transparent; cursor: pointer; font: inherit; font-weight: 600;
}
button:disabled, textarea:disabled { opacity: .5; cursor: default; }
.warning { color: var(--warning-color, #d89300); font-size: 12px; line-height: 1.45; }
`;
disconnectedCallback(): void {
this._release?.();
this._release = undefined;
super.disconnectedCallback();
}
protected updated(changed: PropertyValues<this>): void {
if (changed.has('hass')) {
this._release?.();
this._release = undefined;
this._runtime = null;
this._snapshot = EMPTY_RUNTIME;
}
if (this.savedEnabled && this._admin) void this._ensureRuntime();
}
protected willUpdate(changed: PropertyValues<this>): void {
if (changed.has('value')) {
this._topicText = (this.value.z2mBaseTopics.length ? this.value.z2mBaseTopics : ['zigbee2mqtt']).join('\n');
this._invalidTopic = false;
}
}
private get _admin(): boolean { return this.hass?.user?.is_admin === true; }
private _t(key: TopologyI18nKey, vars?: Record<string, string | number>): string {
return topologyT(langOf(this.hass), key, vars);
}
private async _ensureRuntime(): Promise<typeof import('./zigbee-topology-runtime')> {
if (!this._runtime) {
this._runtime = await import('./zigbee-topology-runtime');
this._snapshot = this._runtime.zigbeeTopologyRuntimeSnapshot(this.hass);
this._release = this._runtime.subscribeZigbeeTopology(this.hass, () => {
this._snapshot = this._runtime!.zigbeeTopologyRuntimeSnapshot(this.hass);
this.requestUpdate();
});
this.requestUpdate();
}
return this._runtime;
}
private _emit(value: ZigbeeTopologySettings): void {
if (!this._admin) return;
this.dispatchEvent(new CustomEvent('hp-topology-settings-change', {
detail: value, bubbles: true, composed: true,
}));
}
private _topics(): string[] {
return this.value.z2mBaseTopics.length ? this.value.z2mBaseTopics : ['zigbee2mqtt'];
}
private _editTopics(raw: string): void {
this._topicText = raw;
const values = raw.split(/[\r\n,]+/).map((value) => value.trim()).filter(Boolean);
const topics = values.map(normalizeZ2mBaseTopic).filter(Boolean) as string[];
this._invalidTopic = topics.length !== values.length;
if (this._invalidTopic) { this.requestUpdate(); return; }
this._emit({ ...this.value, z2mBaseTopics: [...new Set(topics)].slice(0, 8) });
}
private _status(key: string): string {
const current = this._snapshot.states[key];
if (!current) return this._t('status_idle');
if (current.phase === 'loading') return this._t('status_loading');
if (current.phase === 'error') return this._t((`error_${current.error || 'provider'}`) as TopologyI18nKey);
if (current.phase !== 'ready' || !current.obtainedAt) return this._t('status_idle');
const time = new Date(current.obtainedAt).toLocaleTimeString(langOf(this.hass), {
hour: '2-digit', minute: '2-digit',
});
const topology = this._snapshot.topologies.find((item) =>
(key === 'zha' ? item.provider === 'zha' : `z2m:${item.instanceId}` === key));
const mappingPartial = !!topology && !!this.registry
&& mapTopologyNodes(topology, this.devices, this.registry).warnings.length > 0;
if (current.partial || mappingPartial) return this._t('status_partial', { time });
if (topology && !topology.links.length) return this._t('status_no_links', { time });
return this._t(Date.now() - current.obtainedAt > TOPOLOGY_STALE_MS ? 'status_stale' : 'status_ready', { time });
}
private _busy(key: string): boolean {
return this._snapshot.states[key]?.phase === 'loading';
}
private async _readZha(): Promise<void> {
if (!this.savedEnabled || !this._admin) return;
(await this._ensureRuntime()).readZhaTopology(this.hass);
}
private async _refreshZ2m(topic: string): Promise<void> {
if (!this.savedEnabled || !this._admin) return;
(await this._ensureRuntime()).refreshZ2mTopology(this.hass, topic);
}
protected render() {
const admin = this._admin;
const enabled = this.value.enabled;
const mayLoad = admin && this.savedEnabled;
const topics = this._topics();
return html`
<div class="section">${this._t('title')}</div>
<label class="toggle">
${customElements.get('ha-switch')
? html`<ha-switch .checked=${enabled} .disabled=${!admin}
@change=${(event: Event) => this._emit({ ...this.value,
enabled: !!(event.target as HTMLInputElement).checked })}></ha-switch>`
: html`<input type="checkbox" .checked=${enabled} ?disabled=${!admin}
@change=${(event: Event) => this._emit({ ...this.value,
enabled: (event.target as HTMLInputElement).checked })} />`}
<span>${this._t('toggle')}</span>
</label>
<div class="hint">${this._t('hint')}</div>
${!admin ? html`<div class="hint">${this._t('admin_only')}</div>` : nothing}
${enabled ? html`<div class="providers">
${!this.savedEnabled ? html`<div class="hint">${this._t('save_first')}</div>` : nothing}
<div class="provider">
<div class="provider-title">${this._t('zha')}</div>
<div class="hint">${this._t('zha_hint')}</div>
<div class="actions">
<button ?disabled=${!mayLoad || this._busy('zha')} @click=${this._readZha}>
<ha-icon icon="mdi:access-point-network"></ha-icon>${this._t('zha_read')}
</button>
<span class="status">${this._status('zha')}</span>
</div>
</div>
<div class="provider">
<div class="provider-title">${this._t('z2m')}</div>
<label class="hint" for="z2m-topics">${this._t('z2m_topics')}</label>
<textarea id="z2m-topics" ?disabled=${!admin}
.value=${this._topicText} @input=${(event: Event) =>
this._editTopics((event.target as HTMLTextAreaElement).value)}></textarea>
${this._invalidTopic ? html`<div class="warning">${this._t('error_invalid_topic')}</div>` : nothing}
<div class="warning">${this._t('z2m_warning')}</div>
${topics.map((topic) => html`<div class="actions">
<button ?disabled=${!mayLoad || this._invalidTopic || this._busy(`z2m:${topic}`)}
@click=${() => this._refreshZ2m(topic)}>
<ha-icon icon="mdi:refresh"></ha-icon>${this._t('z2m_update')} · ${topic}
</button>
<span class="status">${this._status(`z2m:${topic}`)}</span>
</div>`)}
</div>
</div>` : nothing}
`;
}
}
if (!customElements.get('hp-zigbee-topology-settings')) {
customElements.define('hp-zigbee-topology-settings', HpZigbeeTopologySettings);
}
+16
View File
@@ -0,0 +1,16 @@
import { subst } from '../logic';
import type { Lang } from './registry';
import de from './topology/de.json' with { type: 'json' };
import en from './topology/en.json' with { type: 'json' };
import fr from './topology/fr.json' with { type: 'json' };
import ru from './topology/ru.json' with { type: 'json' };
export type TopologyI18nKey = keyof typeof en;
const DICTIONARIES: Record<Lang, Record<TopologyI18nKey, string>> = { en, ru, de, fr };
export function topologyT(
lang: Lang, key: TopologyI18nKey, vars?: Record<string, string | number>,
): string {
return subst(DICTIONARIES[lang]?.[key] ?? en[key] ?? key, vars);
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Zigbee-Verbindungen",
"toggle": "Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",
"hint": "Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",
"admin_only": "Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",
"save_first": "Speichere diese Einstellung, bevor Topologiedaten geladen werden.",
"zha": "ZHA",
"zha_read": "ZHA-Daten lesen",
"zha_hint": "Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",
"z2m": "Zigbee2MQTT",
"z2m_topics": "Basistopics (eines pro Zeile)",
"z2m_update": "Karte aktualisieren",
"z2m_warning": "Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",
"status_idle": "Daten nicht geladen",
"status_loading": "Wird geladen…",
"status_ready": "Empfangen: {time}",
"status_stale": "Empfangen: {time} · veraltet",
"status_partial": "Empfangen: {time} · einige Knoten fehlen",
"status_no_links": "Empfangen: {time} · keine beobachteten Verbindungen gefunden",
"error_permission": "Administratorrechte sind erforderlich.",
"error_unsupported": "Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",
"error_timeout": "Der Anbieter hat nicht rechtzeitig geantwortet.",
"error_invalid_topic": "Prüfe das Zigbee2MQTT-Basistopic.",
"error_invalid_payload": "Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",
"error_provider": "Topologiedaten konnten nicht geladen werden.",
"remote_count": "+{n} in anderen Bereichen"
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Zigbee links",
"toggle": "Show Zigbee links when hovering over a device",
"hint": "Shows observed direct neighbours only. Nothing is fetched while you hover.",
"admin_only": "Topology data is available to Home Assistant administrators only.",
"save_first": "Save this setting before loading topology data.",
"zha": "ZHA",
"zha_read": "Read ZHA data",
"zha_hint": "Reads ZHA's cached topology; it does not start a radio scan.",
"z2m": "Zigbee2MQTT",
"z2m_topics": "Base topics (one per line)",
"z2m_update": "Update map",
"z2m_warning": "A network-map scan can take 10 seconds to 2 minutes and may temporarily reduce Zigbee responsiveness.",
"status_idle": "Data not loaded",
"status_loading": "Loading…",
"status_ready": "Received {time}",
"status_stale": "Received {time} · stale",
"status_partial": "Received {time} · some nodes were omitted",
"status_no_links": "Received {time} · no observed links found",
"error_permission": "Administrator permission is required.",
"error_unsupported": "This provider or Home Assistant API is unavailable.",
"error_timeout": "The provider did not respond in time.",
"error_invalid_topic": "Check the Zigbee2MQTT base topic.",
"error_invalid_payload": "The provider returned unsupported topology data.",
"error_provider": "Topology data could not be loaded.",
"remote_count": "+{n} in other spaces"
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Liens Zigbee",
"toggle": "Afficher les liens Zigbee au survol d’un appareil",
"hint": "Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",
"admin_only": "Les données de topologie sont réservées aux administrateurs Home Assistant.",
"save_first": "Enregistrez ce réglage avant de charger les données de topologie.",
"zha": "ZHA",
"zha_read": "Lire les données ZHA",
"zha_hint": "Lit la topologie en cache de ZHA sans lancer d’analyse radio.",
"z2m": "Zigbee2MQTT",
"z2m_topics": "Topics de base (un par ligne)",
"z2m_update": "Actualiser la carte",
"z2m_warning": "L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",
"status_idle": "Données non chargées",
"status_loading": "Chargement…",
"status_ready": "Reçu : {time}",
"status_stale": "Reçu : {time} · obsolète",
"status_partial": "Reçu : {time} · certains nœuds sont omis",
"status_no_links": "Reçu : {time} · aucun lien observé trouvé",
"error_permission": "Les droits administrateur sont requis.",
"error_unsupported": "Ce fournisseur ou l’API Home Assistant n’est pas disponible.",
"error_timeout": "Le fournisseur n’a pas répondu à temps.",
"error_invalid_topic": "Vérifiez le topic de base Zigbee2MQTT.",
"error_invalid_payload": "Le fournisseur a renvoyé des données de topologie non prises en charge.",
"error_provider": "Impossible de charger les données de topologie.",
"remote_count": "+{n} dans d’autres espaces"
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Связи Zigbee",
"toggle": "Показывать связи Zigbee при наведении на устройство",
"hint": "Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",
"admin_only": "Данные топологии доступны только администраторам Home Assistant.",
"save_first": "Сохраните настройку перед загрузкой данных топологии.",
"zha": "ZHA",
"zha_read": "Прочитать данные ZHA",
"zha_hint": "Читает сохранённую топологию ZHA и не запускает радио-сканирование.",
"z2m": "Zigbee2MQTT",
"z2m_topics": "Базовые топики (по одному в строке)",
"z2m_update": "Обновить карту",
"z2m_warning": "Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",
"status_idle": "Данные не загружены",
"status_loading": "Загрузка…",
"status_ready": "Получено: {time}",
"status_stale": "Получено: {time} · данные устарели",
"status_partial": "Получено: {time} · часть узлов не показана",
"status_no_links": "Получено: {time} · наблюдаемые связи не найдены",
"error_permission": "Нужны права администратора.",
"error_unsupported": "Провайдер или API Home Assistant недоступен.",
"error_timeout": "Провайдер не ответил вовремя.",
"error_invalid_topic": "Проверьте базовый топик Zigbee2MQTT.",
"error_invalid_payload": "Провайдер вернул неподдерживаемые данные топологии.",
"error_provider": "Не удалось загрузить данные топологии.",
"remote_count": "+{n} в других пространствах"
}
+2
View File
@@ -261,6 +261,8 @@ export interface ServerConfig {
icon_rules?: { pattern: string; icon: string }[];
/** Only an explicit false hides the room hover information window. */
show_room_tooltip?: boolean;
/** Opt-in, admin-only contextual Zigbee diagnostics (#54). */
zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] };
};
}
+21
View File
@@ -0,0 +1,21 @@
import { html, nothing, type TemplateResult } from 'lit';
import { zigbeeTopologySettingsOf } from './zigbee-topology-settings';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type { DevItem } from './types';
export function renderZigbeeTopologyOverlay(input: {
hass: any;
settings: unknown;
devices: readonly DevItem[];
registry: HaRegistrySnapshot;
currentSpace: string;
viewKey: unknown;
view: boolean;
kiosk: boolean;
}): TemplateResult | typeof nothing {
if (!input.view || input.kiosk || input.hass?.user?.is_admin !== true
|| !zigbeeTopologySettingsOf(input.settings).enabled) return nothing;
void import('./hp-zigbee-topology-overlay');
return html`<hp-zigbee-topology-overlay .hass=${input.hass} .devices=${input.devices}
.registry=${input.registry} .currentSpace=${input.currentSpace} .viewKey=${input.viewKey}></hp-zigbee-topology-overlay>`;
}
+207
View File
@@ -0,0 +1,207 @@
import { normalizeZ2mTopology, normalizeZhaTopology, type ZigbeeTopology } from './zigbee-topology';
import { normalizeZ2mBaseTopic } from './zigbee-topology-settings';
export type ZigbeeTopologyErrorCode =
| 'permission'
| 'unsupported'
| 'timeout'
| 'invalid_topic'
| 'invalid_payload'
| 'provider';
export type ZigbeeProviderState = {
phase: 'idle' | 'loading' | 'ready' | 'error';
obtainedAt?: number;
partial?: boolean;
error?: ZigbeeTopologyErrorCode;
};
export interface ZigbeeTopologyRuntimeSnapshot {
revision: number;
topologies: ZigbeeTopology[];
states: Record<string, ZigbeeProviderState>;
}
type Cache = ZigbeeTopologyRuntimeSnapshot & {
listeners: Set<() => void>;
inflight: Map<string, Promise<void>>;
};
const caches = new WeakMap<object, Cache>();
function keyOf(hass: any): object | null {
const key = hass?.connection || hass;
return key && (typeof key === 'object' || typeof key === 'function') ? key : null;
}
function cacheOf(hass: any): Cache | null {
const key = keyOf(hass);
if (!key) return null;
let cache = caches.get(key);
if (!cache) {
cache = { revision: 0, topologies: [], states: {}, listeners: new Set(), inflight: new Map() };
caches.set(key, cache);
}
return cache;
}
function notify(cache: Cache): void {
cache.revision++;
for (const listener of cache.listeners) listener();
}
function state(cache: Cache, key: string, value: ZigbeeProviderState): void {
cache.states = { ...cache.states, [key]: value };
notify(cache);
}
function store(cache: Cache, topology: ZigbeeTopology): void {
cache.topologies = [
...cache.topologies.filter((item) => !(item.provider === topology.provider
&& item.instanceId === topology.instanceId)),
topology,
];
state(cache, topology.provider === 'zha' ? 'zha' : `z2m:${topology.instanceId}`, {
phase: 'ready', obtainedAt: topology.obtainedAt, partial: topology.warnings.length > 0,
});
}
function errorCode(error: unknown): ZigbeeTopologyErrorCode {
const code = (error as { code?: unknown } | null)?.code;
if (code === 'permission' || code === 'unsupported' || code === 'timeout'
|| code === 'invalid_topic' || code === 'invalid_payload') return code;
const message = String((error as any)?.message || '').toLowerCase();
if (message.includes('unauthor') || message.includes('permission')) return 'permission';
if (message.includes('unknown_command') || message.includes('not found')) return 'unsupported';
return 'provider';
}
function fail(code: ZigbeeTopologyErrorCode): Error & { code: ZigbeeTopologyErrorCode } {
return Object.assign(new Error(code), { code });
}
function run(cache: Cache, key: string, task: () => Promise<ZigbeeTopology>): Promise<void> {
const existing = cache.inflight.get(key);
if (existing) return existing;
state(cache, key, { phase: 'loading' });
const promise = task().then((topology) => {
if (!topology.nodes.length && topology.warnings.some((item) => item.code === 'invalid_payload')) {
throw fail('invalid_payload');
}
store(cache, topology);
}).catch((error) => {
state(cache, key, { phase: 'error', error: errorCode(error) });
}).finally(() => cache.inflight.delete(key));
cache.inflight.set(key, promise);
return promise;
}
export function zigbeeTopologyRuntimeSnapshot(hass: any): ZigbeeTopologyRuntimeSnapshot {
const cache = cacheOf(hass);
return cache
? { revision: cache.revision, topologies: cache.topologies, states: cache.states }
: { revision: 0, topologies: [], states: {} };
}
export function subscribeZigbeeTopology(hass: any, listener: () => void): () => void {
const cache = cacheOf(hass);
if (!cache) return () => undefined;
cache.listeners.add(listener);
return () => cache.listeners.delete(listener);
}
function requireAdmin(hass: any): void {
if (hass?.user?.is_admin !== true) throw fail('permission');
}
/** Read the cached ZHA graph. This must never call zha/topology/update. */
export function readZhaTopology(hass: any): Promise<void> {
const cache = cacheOf(hass);
if (!cache) return Promise.resolve();
return run(cache, 'zha', async () => {
requireAdmin(hass);
if (typeof hass?.callWS !== 'function') throw fail('unsupported');
return normalizeZhaTopology(await hass.callWS({ type: 'zha/devices' }));
});
}
function parseMessage(message: any): any {
const raw = message?.payload ?? message;
if (typeof raw !== 'string') return raw;
try { return JSON.parse(raw); } catch { return null; }
}
function transactionOf(value: any): string | null {
const transaction = value?.transaction ?? value?.data?.transaction;
return typeof transaction === 'string' || typeof transaction === 'number'
? String(transaction) : null;
}
function randomTransaction(): string {
const cryptoObj = globalThis.crypto;
if (typeof cryptoObj?.randomUUID === 'function') return `houseplan-${cryptoObj.randomUUID()}`;
return `houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let id: ReturnType<typeof globalThis.setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
id = globalThis.setTimeout(() => reject(fail('timeout')), Math.max(1, ms));
}),
]);
} finally {
if (id !== undefined) globalThis.clearTimeout(id);
}
}
/** Explicit, completion-aware Z2M raw network-map request through HA MQTT. */
export function refreshZ2mTopology(
hass: any, baseTopic: string, timeoutMs = 150_000,
): Promise<void> {
const cache = cacheOf(hass);
if (!cache) return Promise.resolve();
const topic = normalizeZ2mBaseTopic(baseTopic);
const cacheKey = `z2m:${topic || String(baseTopic)}`;
return run(cache, cacheKey, async () => {
requireAdmin(hass);
if (!topic) throw fail('invalid_topic');
const subscribe = hass?.connection?.subscribeMessage;
if (typeof subscribe !== 'function' || typeof hass?.callService !== 'function') throw fail('unsupported');
const transaction = randomTransaction();
const deadline = Date.now() + Math.max(1, timeoutMs);
let infoResolve: (() => void) | null = null;
let responseResolve: ((value: any) => void) | null = null;
const info = new Promise<void>((resolve) => { infoResolve = resolve; });
const response = new Promise<any>((resolve) => { responseResolve = resolve; });
const unsubscribers: Array<() => void> = [];
try {
const unsubInfo = await subscribe.call(hass.connection, (message: any) => {
if (message?.retain === true && parseMessage(message)) infoResolve?.();
}, { type: 'mqtt/subscribe', topic: `${topic}/bridge/info` });
if (typeof unsubInfo === 'function') unsubscribers.push(unsubInfo);
const unsubResponse = await subscribe.call(hass.connection, (message: any) => {
if (message?.retain === true) return;
const value = parseMessage(message);
if (value && transactionOf(value) === transaction) responseResolve?.(value);
}, { type: 'mqtt/subscribe', topic: `${topic}/bridge/response/networkmap` });
if (typeof unsubResponse === 'function') unsubscribers.push(unsubResponse);
await withTimeout(info, Math.min(4000, Math.max(1, deadline - Date.now())));
await hass.callService('mqtt', 'publish', {
topic: `${topic}/bridge/request/networkmap`,
payload: JSON.stringify({ type: 'raw', routes: false, transaction }),
qos: 0,
retain: false,
});
const value = await withTimeout(response, deadline - Date.now());
if (value?.status && value.status !== 'ok') throw fail('provider');
return normalizeZ2mTopology(value, topic);
} finally {
for (const unsubscribe of unsubscribers) {
try { unsubscribe(); } catch { /* cleanup is best effort */ }
}
}
});
}
+45
View File
@@ -0,0 +1,45 @@
export interface ZigbeeTopologySettings {
enabled: boolean;
z2mBaseTopics: string[];
}
const EMPTY_SETTINGS: ZigbeeTopologySettings = Object.freeze({
enabled: false,
z2mBaseTopics: Object.freeze([]) as unknown as string[],
});
/** MQTT topic names are exact paths. Wildcards/control characters are unsafe here. */
export function normalizeZ2mBaseTopic(value: unknown): string | null {
if (typeof value !== 'string') return null;
const topic = value.trim().replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/');
if (!topic || topic.length > 180 || /[#+\u0000-\u001f\u007f]/.test(topic)) return null;
return topic;
}
export function zigbeeTopologySettingsOf(settings: unknown): ZigbeeTopologySettings {
const raw = (settings as { zigbee_topology?: unknown } | null | undefined)?.zigbee_topology;
if (!raw || typeof raw !== 'object') return EMPTY_SETTINGS;
const source = raw as { enabled?: unknown; z2m_base_topics?: unknown; z2mBaseTopics?: unknown };
const values = Array.isArray(source.z2m_base_topics)
? source.z2m_base_topics : Array.isArray(source.z2mBaseTopics) ? source.z2mBaseTopics : [];
const seen = new Set<string>();
const z2mBaseTopics: string[] = [];
for (const value of values) {
const topic = normalizeZ2mBaseTopic(value);
if (!topic || seen.has(topic)) continue;
seen.add(topic);
z2mBaseTopics.push(topic);
if (z2mBaseTopics.length === 8) break;
}
return { enabled: source.enabled === true, z2mBaseTopics };
}
export function writeZigbeeTopologySettings(
settings: Record<string, unknown>, value: ZigbeeTopologySettings,
): Record<string, unknown> {
const next = { ...settings };
const topics = value.z2mBaseTopics.map(normalizeZ2mBaseTopic).filter(Boolean) as string[];
if (!value.enabled && !topics.length) delete next.zigbee_topology;
else next.zigbee_topology = { enabled: value.enabled === true, z2m_base_topics: [...new Set(topics)].slice(0, 8) };
return next;
}
+316
View File
@@ -0,0 +1,316 @@
import type { DevItem } from './types';
import type { HaRegistrySnapshot } from './ha-binding-status';
import { normalizeZ2mBaseTopic } from './zigbee-topology-settings';
export type ZigbeeProvider = 'zha' | 'z2m';
export type ZigbeeRole = 'coordinator' | 'router' | 'end' | 'unknown';
export interface ZigbeeDirectionalObservation {
lqi?: number;
relationship?: string;
activeRoute?: boolean;
}
export interface ZigbeeTopologyNode {
key: string;
ieee: string;
deviceId?: string;
role: ZigbeeRole;
available?: boolean;
}
export interface ZigbeeTopologyLink {
a: string;
b: string;
aToB?: ZigbeeDirectionalObservation;
bToA?: ZigbeeDirectionalObservation;
}
export type ZigbeeTopologyWarningCode =
| 'invalid_payload'
| 'duplicate_link'
| 'self_link'
| 'unmatched_device'
| 'ambiguous_placement'
| 'provider_scan_failure';
export interface ZigbeeTopologyWarning {
code: ZigbeeTopologyWarningCode;
nodeKey?: string;
}
export interface ZigbeeTopology {
provider: ZigbeeProvider;
instanceId: string;
obtainedAt: number;
freshness: 'provider-cache' | 'fresh-scan';
nodes: ZigbeeTopologyNode[];
links: ZigbeeTopologyLink[];
warnings: ZigbeeTopologyWarning[];
}
export interface ZigbeeHoverLine {
neighborMarkerId: string;
lqi?: number;
}
export interface ZigbeeHoverResolution {
lines: ZigbeeHoverLine[];
remoteCount: number;
omittedCount: number;
}
export const TOPOLOGY_STALE_MS = 5 * 60 * 1000;
export const TOPOLOGY_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024;
export const TOPOLOGY_MAX_NODES = 1000;
export const TOPOLOGY_MAX_LINKS = 6000;
export function normalizeIeee(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
let text = String(value).trim().toLowerCase();
if (/^0x[0-9a-f]{16}$/.test(text)) text = text.slice(2);
else text = text.replace(/[:-]/g, '');
return /^[0-9a-f]{16}$/.test(text) ? text : null;
}
function nodeKey(provider: ZigbeeProvider, instanceId: string, ieee: string): string {
return `${provider}:${instanceId}:${ieee}`;
}
function roleOf(value: unknown): ZigbeeRole {
const role = String(value || '').toLowerCase().replace(/[ _-]/g, '');
if (role.includes('coordinator')) return 'coordinator';
if (role.includes('router')) return 'router';
if (role.includes('enddevice') || role === 'end') return 'end';
return 'unknown';
}
function lqiOf(value: unknown): number | undefined {
const n = Number(value);
return Number.isFinite(n) && n >= 0 && n <= 255 ? Math.round(n) : undefined;
}
function observation(value: any): ZigbeeDirectionalObservation {
const lqi = lqiOf(value?.lqi ?? value?.linkquality ?? value?.link_quality);
const relationship = typeof value?.relationship === 'string'
? value.relationship.slice(0, 40) : undefined;
return { ...(lqi === undefined ? {} : { lqi }), ...(relationship ? { relationship } : {}) };
}
function boundedArray(value: unknown, max: number): any[] | null {
return Array.isArray(value) && value.length <= max ? value : null;
}
function safePayload(value: unknown): boolean {
if (typeof value === 'string') return value.length <= TOPOLOGY_MAX_PAYLOAD_BYTES;
try { return JSON.stringify(value).length <= TOPOLOGY_MAX_PAYLOAD_BYTES; } catch { return false; }
}
function pushDirectionalLink(
links: Map<string, ZigbeeTopologyLink>, warnings: ZigbeeTopologyWarning[],
from: string, to: string, value: any,
): void {
if (from === to) { warnings.push({ code: 'self_link', nodeKey: from }); return; }
const forward = from < to;
const pair = forward ? `${from}|${to}` : `${to}|${from}`;
const current = links.get(pair) || { a: forward ? from : to, b: forward ? to : from };
const field = forward ? 'aToB' : 'bToA';
if (current[field]) warnings.push({ code: 'duplicate_link', nodeKey: from });
else current[field] = observation(value);
links.set(pair, current);
}
/** Normalize cached ZHA neighbors. Route destinations deliberately do not create edges. */
export function normalizeZhaTopology(payload: unknown, now = Date.now()): ZigbeeTopology {
const warnings: ZigbeeTopologyWarning[] = [];
const rows = safePayload(payload) ? boundedArray(payload, TOPOLOGY_MAX_NODES) : null;
if (!rows) return { provider: 'zha', instanceId: 'zha', obtainedAt: now,
freshness: 'provider-cache', nodes: [], links: [], warnings: [{ code: 'invalid_payload' }] };
const nodes = new Map<string, ZigbeeTopologyNode>();
const links = new Map<string, ZigbeeTopologyLink>();
for (const row of rows) {
const ieee = normalizeIeee(row?.ieee ?? row?.ieee_address);
if (!ieee) { warnings.push({ code: 'invalid_payload' }); continue; }
const key = nodeKey('zha', 'zha', ieee);
nodes.set(key, {
key, ieee,
...(typeof row?.device_reg_id === 'string' ? { deviceId: row.device_reg_id } : {}),
role: roleOf(row?.device_type ?? row?.type),
...(typeof row?.available === 'boolean' ? { available: row.available } : {}),
});
}
for (const row of rows) {
const ieee = normalizeIeee(row?.ieee ?? row?.ieee_address);
if (!ieee) continue;
const from = nodeKey('zha', 'zha', ieee);
const neighbors = boundedArray(row?.neighbors, TOPOLOGY_MAX_LINKS);
if (!neighbors) { if (row?.neighbors != null) warnings.push({ code: 'invalid_payload', nodeKey: from }); continue; }
for (const neighbor of neighbors) {
if (links.size >= TOPOLOGY_MAX_LINKS) { warnings.push({ code: 'invalid_payload' }); break; }
const otherIeee = normalizeIeee(neighbor?.ieee ?? neighbor?.ieee_address);
if (!otherIeee) { warnings.push({ code: 'invalid_payload', nodeKey: from }); continue; }
const to = nodeKey('zha', 'zha', otherIeee);
if (!nodes.has(to)) nodes.set(to, { key: to, ieee: otherIeee, role: roleOf(neighbor?.device_type) });
pushDirectionalLink(links, warnings, from, to, neighbor);
}
}
return { provider: 'zha', instanceId: 'zha', obtainedAt: now,
freshness: 'provider-cache', nodes: [...nodes.values()], links: [...links.values()], warnings };
}
function z2mValue(payload: any): any {
let value = payload;
if (typeof value === 'string') {
if (value.length > TOPOLOGY_MAX_PAYLOAD_BYTES) return null;
try { value = JSON.parse(value); } catch { return null; }
}
value = value?.data?.value ?? value?.value ?? value?.data ?? value;
if (typeof value === 'string') {
if (value.length > TOPOLOGY_MAX_PAYLOAD_BYTES) return null;
try { value = JSON.parse(value); } catch { return null; }
}
return value;
}
function endpointIeee(value: any, nodeById: Map<string, string>): string | null {
const direct = normalizeIeee(value?.ieee_address ?? value?.ieee ?? value);
if (direct) return direct;
const id = value?.id ?? value?.network_address ?? value;
return id == null ? null : nodeById.get(String(id)) || null;
}
/** Normalize one explicit Zigbee2MQTT raw network-map response. */
export function normalizeZ2mTopology(payload: unknown, baseTopic: string, now = Date.now()): ZigbeeTopology {
const warnings: ZigbeeTopologyWarning[] = [];
const topic = normalizeZ2mBaseTopic(baseTopic) || 'invalid';
const raw = safePayload(payload) ? z2mValue(payload) : null;
const rows = boundedArray(raw?.nodes, TOPOLOGY_MAX_NODES);
const rawLinks = boundedArray(raw?.links, TOPOLOGY_MAX_LINKS);
if (!rows || !rawLinks) return { provider: 'z2m', instanceId: topic, obtainedAt: now,
freshness: 'fresh-scan', nodes: [], links: [], warnings: [{ code: 'invalid_payload' }] };
const nodes = new Map<string, ZigbeeTopologyNode>();
const nodeById = new Map<string, string>();
for (const row of rows) {
const ieee = normalizeIeee(row?.ieee_address ?? row?.ieee);
if (!ieee) { warnings.push({ code: 'invalid_payload' }); continue; }
const key = nodeKey('z2m', topic, ieee);
nodes.set(key, { key, ieee, role: roleOf(row?.type ?? row?.device_type),
...(typeof row?.failed === 'boolean' ? { available: !row.failed } : {}) });
for (const id of [row?.id, row?.network_address]) if (id != null) nodeById.set(String(id), ieee);
}
const links = new Map<string, ZigbeeTopologyLink>();
for (const row of rawLinks) {
const fromIeee = endpointIeee(row?.source, nodeById);
const toIeee = endpointIeee(row?.target, nodeById);
if (!fromIeee || !toIeee) { warnings.push({ code: 'invalid_payload' }); continue; }
const from = nodeKey('z2m', topic, fromIeee);
const to = nodeKey('z2m', topic, toIeee);
if (!nodes.has(from)) nodes.set(from, { key: from, ieee: fromIeee, role: 'unknown' });
if (!nodes.has(to)) nodes.set(to, { key: to, ieee: toIeee, role: 'unknown' });
pushDirectionalLink(links, warnings, from, to, row);
}
return { provider: 'z2m', instanceId: topic, obtainedAt: now,
freshness: 'fresh-scan', nodes: [...nodes.values()], links: [...links.values()], warnings };
}
function ieeeFromRegistryIdentifier(value: unknown): string | null {
if (typeof value !== 'string') return null;
const direct = normalizeIeee(value);
if (direct) return direct;
const match = value.toLowerCase().match(/^zigbee2mqtt(?:_bridge)?_(.+)$/);
return match ? normalizeIeee(match[1]) : null;
}
function deviceIdsForNode(node: ZigbeeTopologyNode, registry: HaRegistrySnapshot): string[] {
if (node.deviceId && registry.devices[node.deviceId]) return [node.deviceId];
const ids = new Set<string>();
for (const [id, device] of Object.entries<any>(registry.devices)) {
const identifiers = Array.isArray(device?.identifiers) ? device.identifiers : [];
if (identifiers.some((pair: any) => Array.isArray(pair)
&& ieeeFromRegistryIdentifier(pair[1]) === node.ieee)) ids.add(id);
}
if (ids.size) return [...ids];
for (const entity of Object.values<any>(registry.entities)) {
if (!entity?.device_id || typeof entity?.unique_id !== 'string') continue;
if (ieeeFromRegistryIdentifier(entity.unique_id) === node.ieee) ids.add(entity.device_id);
}
return [...ids];
}
function drawable(device: DevItem): boolean {
return !device.hidden && !device.virtual && device.bindingStatus?.kind !== 'ha_disabled'
&& device.bindingStatus?.kind !== 'orphaned' && device.bindingStatus?.kind !== 'unverified';
}
export type ZigbeeNodePlacement = { markerId: string; space: string };
export type ZigbeeMappedTopology = { topology: ZigbeeTopology; placements: Map<string, ZigbeeNodePlacement> };
/** Exact registry binding only; names, models and friendly names are never identities. */
export function mapTopologyNodes(
topology: ZigbeeTopology, devices: readonly DevItem[], registry: HaRegistrySnapshot,
): { placements: Map<string, ZigbeeNodePlacement>; warnings: ZigbeeTopologyWarning[] } {
const placements = new Map<string, ZigbeeNodePlacement>();
const warnings: ZigbeeTopologyWarning[] = [];
for (const node of topology.nodes) {
if (node.available === false) { warnings.push({ code: 'provider_scan_failure', nodeKey: node.key }); continue; }
const deviceIds = deviceIdsForNode(node, registry);
if (deviceIds.length !== 1) { warnings.push({ code: 'unmatched_device', nodeKey: node.key }); continue; }
const deviceId = deviceIds[0];
let candidates = devices.filter((item) => drawable(item)
&& item.bindingKind === 'device' && item.bindingRef === deviceId);
if (!candidates.length) candidates = devices.filter((item) => drawable(item)
&& item.bindingKind === 'entity' && !!item.bindingRef
&& registry.entities[item.bindingRef]?.device_id === deviceId);
if (candidates.length === 1) placements.set(node.key, {
markerId: candidates[0].id, space: candidates[0].space,
});
else warnings.push({ code: candidates.length > 1 ? 'ambiguous_placement' : 'unmatched_device', nodeKey: node.key });
}
return { placements, warnings };
}
export function mapTopologies(
topologies: readonly ZigbeeTopology[], devices: readonly DevItem[], registry: HaRegistrySnapshot,
): ZigbeeMappedTopology[] {
return topologies.map((topology) => ({
topology, placements: mapTopologyNodes(topology, devices, registry).placements,
}));
}
export function resolveMappedTopologyHover(
mappedTopologies: readonly ZigbeeMappedTopology[], currentSpace: string, hoveredMarkerId: string,
): ZigbeeHoverResolution {
const lines = new Map<string, ZigbeeHoverLine>();
const remote = new Set<string>();
let omittedCount = 0;
for (const { topology, placements } of mappedTopologies) {
const hoveredNodes = new Set([...placements]
.filter(([, placement]) => placement.markerId === hoveredMarkerId && placement.space === currentSpace)
.map(([key]) => key));
if (!hoveredNodes.size) continue;
for (const link of topology.links) {
const fromA = hoveredNodes.has(link.a);
const fromB = hoveredNodes.has(link.b);
if (!fromA && !fromB) continue;
const other = placements.get(fromA ? link.b : link.a);
if (!other || other.markerId === hoveredMarkerId) { omittedCount++; continue; }
if (other.space !== currentSpace) { remote.add(other.markerId); continue; }
const obs = fromA ? link.aToB : link.bToA;
const existing = lines.get(other.markerId);
if (!existing || (existing.lqi === undefined && obs?.lqi !== undefined)) {
lines.set(other.markerId, { neighborMarkerId: other.markerId, lqi: obs?.lqi });
}
}
}
return { lines: [...lines.values()], remoteCount: remote.size, omittedCount };
}
/** Resolve only the edges incident to the marker currently under the mouse. */
export function resolveTopologyHover(
topologies: readonly ZigbeeTopology[], devices: readonly DevItem[],
registry: HaRegistrySnapshot, currentSpace: string, hoveredMarkerId: string,
): ZigbeeHoverResolution {
return resolveMappedTopologyHover(
mapTopologies(topologies, devices, registry), currentSpace, hoveredMarkerId,
);
}
+184
View File
@@ -0,0 +1,184 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
mapTopologyNodes, normalizeIeee, normalizeZ2mTopology,
normalizeZhaTopology, resolveTopologyHover,
} from '../test-build/zigbee-topology.js';
import {
normalizeZ2mBaseTopic, writeZigbeeTopologySettings, zigbeeTopologySettingsOf,
} from '../test-build/zigbee-topology-settings.js';
import {
readZhaTopology, refreshZ2mTopology, zigbeeTopologyRuntimeSnapshot,
} from '../test-build/zigbee-topology-runtime.js';
const registry = {
revision: 1, authoritative: true, access: 'full', lastSuccess: 1,
devices: {
da: { id: 'da', identifiers: [['zha', '00:12:4b:00:00:00:00:01']] },
db: { id: 'db', identifiers: [['mqtt', 'zigbee2mqtt_0x00124b0000000002']] },
dc: { id: 'dc', identifiers: [['mqtt', 'zigbee2mqtt_bridge_00124b0000000003']] },
},
entities: {
'sensor.b': { entity_id: 'sensor.b', device_id: 'db', unique_id: 'b_lqi' },
},
};
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
const devices = [
{ id: 'ma', name: 'A', model: '', area: 'a', space: 'one', icon: '', entities: [], bindingKind: 'device', bindingRef: 'da', bindingStatus: active },
{ id: 'mb', name: 'B', model: '', area: 'b', space: 'one', icon: '', entities: [], bindingKind: 'entity', bindingRef: 'sensor.b', bindingStatus: active },
{ id: 'mc', name: 'C', model: '', area: 'c', space: 'two', icon: '', entities: [], bindingKind: 'device', bindingRef: 'dc', bindingStatus: active },
];
test('topology settings are default-off, bounded, normalized and preserved independently', () => {
assert.deepEqual(zigbeeTopologySettingsOf(undefined), { enabled: false, z2mBaseTopics: [] });
assert.deepEqual(zigbeeTopologySettingsOf({ zigbee_topology: {
enabled: true, z2m_base_topics: [' zigbee2mqtt/ ', 'zigbee2mqtt', 'bad/#'],
} }), { enabled: true, z2mBaseTopics: ['zigbee2mqtt'] });
assert.equal(normalizeZ2mBaseTopic('/house//z2m/'), 'house/z2m');
const saved = writeZigbeeTopologySettings({ keep: 7 }, {
enabled: true, z2mBaseTopics: ['zigbee2mqtt'],
});
assert.deepEqual(saved, { keep: 7, zigbee_topology: { enabled: true, z2m_base_topics: ['zigbee2mqtt'] } });
assert.deepEqual(writeZigbeeTopologySettings(saved, { enabled: false, z2mBaseTopics: [] }), { keep: 7 });
});
test('IEEE normalization is exact and rejects partial identifiers', () => {
assert.equal(normalizeIeee('0x00124B0000000001'), '00124b0000000001');
assert.equal(normalizeIeee('00:12:4b:00:00:00:00:01'), '00124b0000000001');
assert.equal(normalizeIeee('124b1'), null);
});
test('ZHA normalization keeps directional observations and never infers route edges', () => {
const topology = normalizeZhaTopology([
{ ieee: '00124b0000000001', device_reg_id: 'da', device_type: 'Router',
neighbors: [{ ieee: '00124b0000000002', lqi: 170 }],
routes: [{ dest_nwk: 77, next_hop: 88 }] },
{ ieee: '00124b0000000002', device_reg_id: 'db', device_type: 'EndDevice',
neighbors: [{ ieee: '00124b0000000001', lqi: 90 }] },
], 123);
assert.equal(topology.links.length, 1);
assert.equal(topology.links[0].aToB.lqi, 170);
assert.equal(topology.links[0].bToA.lqi, 90);
assert.equal(topology.obtainedAt, 123);
});
test('Z2M normalization accepts a nested raw JSON value, drops self links and preserves unknown LQI', () => {
const topology = normalizeZ2mTopology({ data: { value: JSON.stringify({
nodes: [
{ ieee_address: '00124b0000000001', type: 'Coordinator' },
{ ieee_address: '00124b0000000002', type: 'Router' },
],
links: [
{ source: { ieee_address: '00124b0000000001' }, target: { ieee_address: '00124b0000000002' }, linkquality: 'bad' },
{ source: { ieee_address: '00124b0000000001' }, target: { ieee_address: '00124b0000000001' }, linkquality: 255 },
],
}) } }, 'zigbee2mqtt', 456);
assert.equal(topology.links.length, 1);
assert.equal(topology.links[0].aToB.lqi, undefined);
assert.ok(topology.warnings.some((item) => item.code === 'self_link'));
});
test('exact device/entity mapping yields local lines and a deduplicated remote count', () => {
const topology = normalizeZhaTopology([
{ ieee: '00124b0000000001', device_reg_id: 'da', neighbors: [
{ ieee: '00124b0000000002', lqi: 180 },
{ ieee: '00124b0000000003', lqi: 70 },
] },
{ ieee: '00124b0000000002', device_reg_id: 'db', neighbors: [] },
{ ieee: '00124b0000000003', device_reg_id: 'dc', neighbors: [] },
]);
const mapped = mapTopologyNodes(topology, devices, registry);
assert.deepEqual([...mapped.placements.values()], [
{ markerId: 'ma', space: 'one' }, { markerId: 'mb', space: 'one' }, { markerId: 'mc', space: 'two' },
]);
assert.deepEqual(resolveTopologyHover([topology], devices, registry, 'one', 'ma'), {
lines: [{ neighborMarkerId: 'mb', lqi: 180 }], remoteCount: 1, omittedCount: 0,
});
assert.deepEqual(resolveTopologyHover([topology], devices, registry, 'one', 'mb'), {
lines: [{ neighborMarkerId: 'ma', lqi: undefined }], remoteCount: 0, omittedCount: 0,
});
});
test('hidden and ambiguous placements fail closed', () => {
const topology = normalizeZhaTopology([
{ ieee: '00124b0000000001', device_reg_id: 'da', neighbors: [{ ieee: '00124b0000000002', lqi: 100 }] },
{ ieee: '00124b0000000002', device_reg_id: 'db', neighbors: [] },
]);
const ambiguous = [...devices, { ...devices[0], id: 'ma2' }];
assert.equal(mapTopologyNodes(topology, ambiguous, registry).placements.has(topology.nodes[0].key), false);
const hidden = devices.map((item) => item.id === 'mb' ? { ...item, hidden: true } : item);
assert.deepEqual(resolveTopologyHover([topology], hidden, registry, 'one', 'ma'), {
lines: [], remoteCount: 0, omittedCount: 1,
});
});
test('ZHA runtime is explicit, admin-only and deduplicates concurrent reads', async () => {
let calls = 0;
let release;
const pending = new Promise((resolve) => { release = resolve; });
const hass = { user: { is_admin: true }, connection: {}, callWS: async (message) => {
calls++;
assert.deepEqual(message, { type: 'zha/devices' });
await pending;
return [{ ieee: '00124b0000000001', device_reg_id: 'da', neighbors: [] }];
} };
const a = readZhaTopology(hass);
const b = readZhaTopology(hass);
assert.equal(calls, 1);
release();
await Promise.all([a, b]);
assert.equal(zigbeeTopologyRuntimeSnapshot(hass).topologies.length, 1);
const denied = { user: { is_admin: false }, connection: {}, callWS: async () => assert.fail('must not call') };
await readZhaTopology(denied);
assert.equal(zigbeeTopologyRuntimeSnapshot(denied).states.zha.error, 'permission');
});
test('Z2M runtime verifies retained bridge info, correlates transaction and cleans subscriptions', async () => {
const listeners = new Map();
let cleanups = 0;
const hass = {
user: { is_admin: true },
connection: {
async subscribeMessage(callback, message) {
listeners.set(message.topic, callback);
if (message.topic.endsWith('/bridge/info')) queueMicrotask(() => callback({ retain: true, payload: '{}' }));
return () => { cleanups++; listeners.delete(message.topic); };
},
},
async callService(domain, service, data) {
assert.equal(`${domain}.${service}`, 'mqtt.publish');
const request = JSON.parse(data.payload);
assert.equal(request.type, 'raw');
assert.equal(request.routes, false);
listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({ retain: true,
payload: JSON.stringify({ status: 'ok', transaction: request.transaction }) });
listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({ retain: false,
payload: JSON.stringify({ status: 'ok', transaction: 'foreign' }) });
queueMicrotask(() => listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({
retain: false,
payload: JSON.stringify({ status: 'ok', transaction: request.transaction, data: { value: {
nodes: [{ ieee_address: '00124b0000000001', type: 'Coordinator' }], links: [],
} } }),
}));
},
};
await refreshZ2mTopology(hass, 'zigbee2mqtt', 100);
assert.equal(cleanups, 2);
assert.equal(zigbeeTopologyRuntimeSnapshot(hass).states['z2m:zigbee2mqtt'].phase, 'ready');
});
test('Z2M runtime refuses an unconfirmed base topic without publishing and still cleans up', async () => {
let publishes = 0;
let cleanups = 0;
const hass = {
user: { is_admin: true },
connection: { async subscribeMessage() { return () => { cleanups++; }; } },
async callService() { publishes++; },
};
await refreshZ2mTopology(hass, 'zigbee2mqtt', 10);
assert.equal(publishes, 0);
assert.equal(cleanups, 2);
assert.equal(zigbeeTopologyRuntimeSnapshot(hass).states['z2m:zigbee2mqtt'].error, 'timeout');
});
+6
View File
@@ -201,6 +201,10 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(
"settings": {
"north_deg": 30,
"show_room_tooltip": False,
"zigbee_topology": {
"enabled": True,
"z2m_base_topics": ["private/site/zigbee2mqtt"],
},
"fill_colors": {"warm": {"c": "#ffaa00", "a": 0.5, "secret": "drop"}},
"decor_default_style": {
"color": "#123456", "width_cm": 2, "secret": "drop",
@@ -292,6 +296,8 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(
assert package["versions"]["card"] == "unknown"
assert plan["settings"]["fill_colors"] == {"warm": {"a": 0.5, "c": "#ffaa00"}}
assert plan["settings"]["show_room_tooltip"] is False
assert "zigbee_topology" not in plan["settings"]
assert b"private/site/zigbee2mqtt" not in raw
assert plan["settings"]["decor_default_style"] == {"color": "#123456", "width_cm": 2}
assert space["rooms"][0]["poly"] == [[0, 0], [2, 0]]
assert space["rooms"][0]["settings"]["temp_source_kind"] == "entity"
+1
View File
@@ -13,6 +13,7 @@
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts", "src/device-area-relocation.ts",
"src/ha-binding-status.ts",
"src/zigbee-topology-settings.ts", "src/zigbee-topology.ts", "src/zigbee-topology-runtime.ts",
"src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/vacuum-route-edit.ts",
"src/sun.ts",
"src/light-visibility.ts", "src/glow-scene.ts", "src/space-render.ts",