mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
feat: add contextual Zigbee topology hover
Issue: #54 User-Visible: yes
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+5
-5
@@ -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);
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+39
-38
File diff suppressed because one or more lines are too long
+230
-169
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
+36
@@ -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
+1
@@ -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)}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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);
|
||||
Vendored
+100
-56
@@ -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": []
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+5
-5
@@ -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);
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+39
-38
File diff suppressed because one or more lines are too long
+230
-169
File diff suppressed because one or more lines are too long
+3
-3
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};
|
||||
Vendored
+1
-1
@@ -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)}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Администратор теперь может явно загрузить снимок топологии ZHA или
|
||||
Zigbee2MQTT и при наведении реальной мыши видеть только наблюдаемых прямых
|
||||
соседей выбранного устройства. Диагностика по умолчанию выключена, никогда не
|
||||
запускает сканирование от наведения, считает вместо отрисовки связи с другими
|
||||
пространствами и не появляется на touch, в киоске, редакторах и статической
|
||||
карточке ([#54](https://github.com/Matysh/houseplan-card/issues/54)).
|
||||
- В Просмотре и киоске клик или тап по комнате теперь центрирует и вписывает
|
||||
её видимый пол и ограничивающие стены с полями 10%; видимая подпись комнаты
|
||||
даёт то же действие с клавиатуры
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -212,6 +212,21 @@ title: План дома
|
||||
Показывать окно с информацией при наведении на комнату**. По умолчанию опция
|
||||
включена и не влияет на подсказки устройств.
|
||||
|
||||
Для разовой проверки размещения Zigbee администратор может включить **Общие
|
||||
настройки → Показывать связи Zigbee при наведении на устройство**. По умолчанию
|
||||
опция выключена. Там же надо явно получить снимок: **Прочитать данные ZHA**
|
||||
читает уже существующий кеш ZHA, а **Обновить карту** запускает raw-сканирование
|
||||
Zigbee2MQTT для каждого указанного базового топика (по умолчанию
|
||||
`zigbee2mqtt`). Сканирование занимает от 10 секунд до 2 минут и временно может
|
||||
снизить отзывчивость Zigbee-сети.
|
||||
|
||||
После загрузки данных наведение реальной мыши на сопоставленный Zigbee-маркер
|
||||
показывает только его наблюдаемых прямых соседей. Соседи в текущем пространстве
|
||||
соединяются линиями, а доступные маркеры в других пространствах учитываются во
|
||||
временном счётчике. Это снимок соседства, а не маршрут каждого текущего пакета.
|
||||
На touch/pen, в киоске, редакторах и статической карточке слой не появляется;
|
||||
само наведение никогда не запускает сканирование.
|
||||
|
||||
В редакторах сетка продолжается по всему рабочему холсту. В режиме просмотра сетка не показывается.
|
||||
|
||||
Основная панель каждого редактора содержит только постоянные инструменты, а
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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} в других пространствах"
|
||||
}
|
||||
@@ -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[] };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user