From e7cf0416be0ef7f45eb18b42a7faccb5407ea7fd Mon Sep 17 00:00:00 2001 From: Matysh Date: Sun, 5 Jul 2026 20:58:33 +0300 Subject: [PATCH] =?UTF-8?q?refactor+fix=20v1.10.0:=20=D0=B0=D1=83=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=20=E2=80=94=20=D0=B3=D0=BE=D0=BD=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20(asyncio.Lock,=20=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=D0=B0=D1=80=D0=BD=D1=8B=D0=B9=20rev),=20?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=B5=D1=87=D0=BD=D1=8B=D0=B9=20layout/updat?= =?UTF-8?q?e=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20layout/set=20(=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=20last-writer-wins),=20layout/delete,=20safe?= =?UTF-8?q?Url=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20XSS=20=D0=B2=20lin?= =?UTF-8?q?k/pdfs,=20fetchWithAuth,=20KEY=5FHASS,=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D0=BD=D0=B3=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BB?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D1=82=20upload,=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20(styles/types/devi?= =?UTF-8?q?ces),=20=D0=B4=D0=B8=D0=BD=D0=B0=D0=BC=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=81=D1=82=D0=B2=D0=B0=20=D0=B2=20GUI-=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5,=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=B4=D0=B0=20(file/set,=20GROUP=5F?= =?UTF-8?q?TITLES)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/houseplan/const.py | 2 +- .../houseplan/frontend/houseplan-card.js | 632 ++++----- custom_components/houseplan/http_api.py | 37 +- custom_components/houseplan/manifest.json | 2 +- custom_components/houseplan/websocket_api.py | 123 +- dist/houseplan-card.js | 632 ++++----- docs/CHANGELOG.md | 29 + package.json | 4 +- src/devices.ts | 275 ++++ src/editor.ts | 63 +- src/houseplan-card.ts | 1169 +---------------- src/logic.ts | 14 + src/rules.ts | 64 +- src/styles.ts | 727 ++++++++++ src/types.ts | 75 ++ test/logic.test.mjs | 17 +- 16 files changed, 1991 insertions(+), 1874 deletions(-) create mode 100644 src/devices.ts create mode 100644 src/styles.ts create mode 100644 src/types.ts diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index 6cb47b7..f7c3d43 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -10,7 +10,7 @@ PLANS_DIR = "houseplan/plans" # относительно каталога ко FILES_URL = "/houseplan_files/files" FILES_DIR = "houseplan/files" CONF_ADMIN_ONLY = "admin_only" -VERSION = "1.9.3" +VERSION = "1.10.0" DEFAULT_CONFIG: dict = { "spaces": [], diff --git a/custom_components/houseplan/frontend/houseplan-card.js b/custom_components/houseplan/frontend/houseplan-card.js index dc54475..5499d40 100755 --- a/custom_components/houseplan/frontend/houseplan-card.js +++ b/custom_components/houseplan/frontend/houseplan-card.js @@ -1,321 +1,10 @@ -const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let a=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const o=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new a("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:n,defineProperty:r,getOwnPropertyDescriptor:l,getOwnPropertyNames:h,getOwnPropertySymbols:c,getPrototypeOf:d}=Object,p=globalThis,_=p.trustedTypes,u=_?_.emptyScript:"",g=p.reactiveElementPolyfillSupport,m=(t,e)=>t,f={toAttribute(t,e){switch(e){case Boolean:t=t?u:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},v=(t,e)=>!n(t,e),b={attribute:!0,type:String,converter:f,reflect:!1,useDefault:!1,hasChanged:v};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let y=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&r(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:a}=l(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);a?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(m("elementProperties")))return;const t=d(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(m("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(m("properties"))){const t=this.properties,e=[...h(t),...c(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(o(t))}else void 0!==t&&e.push(o(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),a=t.litNonce;void 0!==a&&s.setAttribute("nonce",a),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const a=(void 0!==i.converter?.toAttribute?i.converter:f).toAttribute(e,i.type);this._$Em=t,null==a?this.removeAttribute(s):this.setAttribute(s,a),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),a="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:f;this._$Em=s;const o=a.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,a){if(void 0!==t){const o=this.constructor;if(!1===s&&(a=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??v)(a,e)||i.useDefault&&i.reflect&&a===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:a},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==a||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};y.elementStyles=[],y.shadowRootOptions={mode:"open"},y[m("elementProperties")]=new Map,y[m("finalized")]=new Map,g?.({ReactiveElement:y}),(p.reactiveElementVersions??=[]).push("2.1.2");const x=globalThis,$=t=>t,w=x.trustedTypes,k=w?w.createPolicy("lit-html",{createHTML:t=>t}):void 0,S="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,A="?"+C,z=`<${A}>`,D=document,M=()=>D.createComment(""),P=t=>null===t||"object"!=typeof t&&"function"!=typeof t,E=Array.isArray,R="[ \t\n\f\r]",T=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,O=/-->/g,U=/>/g,H=RegExp(`>|${R}(?:([^\\s"'>=/]+)(${R}*=${R}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),N=/'/g,I=/"/g,q=/^(?:script|style|textarea|title)$/i,F=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),L=F(1),j=F(2),B=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),K=new WeakMap,V=D.createTreeWalker(D,129);function Z(t,e){if(!E(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==k?k.createHTML(e):e}const Y=(t,e)=>{const i=t.length-1,s=[];let a,o=2===e?"":3===e?"":"",n=T;for(let e=0;e"===l[0]?(n=a??T,h=-1):void 0===l[1]?h=-2:(h=n.lastIndex-l[2].length,r=l[1],n=void 0===l[3]?H:'"'===l[3]?I:N):n===I||n===N?n=H:n===O||n===U?n=T:(n=H,a=void 0);const d=n===H&&t[e+1].startsWith("/>")?" ":"";o+=n===T?i+z:h>=0?(s.push(r),i.slice(0,h)+S+i.slice(h)+C+d):i+C+(-2===h?e:d)}return[Z(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class X{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let a=0,o=0;const n=t.length-1,r=this.parts,[l,h]=Y(t,e);if(this.el=X.createElement(l,i),V.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=V.nextNode())&&r.length0){s.textContent=w?w.emptyScript:"";for(let i=0;iE(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==W&&P(this._$AH)?this._$AA.nextSibling.data=t:this.T(D.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=X.createElement(Z(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new G(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=K.get(t.strings);return void 0===e&&K.set(t.strings,e=new X(t)),e}k(t){E(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const a of t)s===e.length?e.push(i=new Q(this.O(M()),this.O(M()),this,this.options)):i=e[s],i._$AI(a),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=W}_$AI(t,e=this,i,s){const a=this.strings;let o=!1;if(void 0===a)t=J(this,t,e,0),o=!P(t)||t!==this._$AH&&t!==B,o&&(this._$AH=t);else{const s=t;let n,r;for(t=a[0],n=0;n{const s=i?.renderBefore??e;let a=s._$litPart$;if(void 0===a){const t=i?.renderBefore??null;s._$litPart$=a=new Q(e.insertBefore(M(),t),t,void 0,i??{})}return a._$AI(t),a})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return B}}rt._$litElement$=!0,rt.finalized=!0,nt.litElementHydrateSupport?.({LitElement:rt});const lt=nt.litElementPolyfillSupport;lt?.({LitElement:rt}),(nt.litElementVersions??=[]).push("4.2.2");const ht=new Set(["hacs","sun","backup","hassio","met","telegram_bot","mobile_app","systemmonitor","better_thermostat","adaptive_lighting","yandex_pogoda","upnp_serial_number"]),ct=[["протечк","mdi:water-alert"],["клапан","mdi:pipe-valve"],["дым","mdi:smoke-detector"],["термоголов","mdi:radiator"],["температ","mdi:thermometer"],["qingping|air monitor|молекул","mdi:air-filter"],["штор","mdi:roller-shade"],["розетк|plug","mdi:power-socket-de"],["выключат|switch","mdi:light-switch"],["лампа|лампочк|bulb|gx53|светильник|rgb","mdi:lightbulb"],["камер|camera","mdi:cctv"],["замок|ttlock|lock|sn609|sn9161","mdi:lock"],["ворота|garage","mdi:garage-variant"],["калитк|door|открыт","mdi:door"],["счётчик|счетчик|kws|meter","mdi:meter-electric"],["вводный автомат|breaker|wifimcbn","mdi:electric-switch"],["myheat|котёл|котел|boiler|отоплен","mdi:water-boiler"],["холодильник|fridge","mdi:fridge"],["стиральн|washer","mdi:washing-machine"],["сушилк|dryer","mdi:tumble-dryer"],["пылесос|vacuum|dreame","mdi:robot-vacuum"],["soundbar|колонк|станц","mdi:soundbar"],["tv|телевизор|hyundaitv|mitv","mdi:television"],["keenetic|роутер|router","mdi:router-wireless"],["ибп|ups|kirpich","mdi:battery-charging-high"],["slzb|координат|zigbee","mdi:zigbee"]];function dt(t,e){const i=((t||"")+" "+(e||"")).toLowerCase();for(const[t,e]of ct)if(new RegExp(t).test(i))return e;return"mdi:chip"}const pt=["light","switch","cover","valve","lock","climate","fan","media_player","camera","vacuum","humidifier","water_heater","alarm_control_panel","sensor","binary_sensor","event","button","number","select","update"];function _t(t){const e=Math.max(0,Math.min(120,(t-40)/140*120));return`hsl(${Math.round(e)}, 85%, 55%)`}function ut(t,e){return Math.round(t/e)*e}function gt(t){return t.length?Math.round(t.reduce((t,e)=>t+e,0)/t.length):null}function mt(t,e){if(e>t[2]/t[3]){const i=t[3],s=t[3]*e;return{x:t[0]-(s-t[2])/2,y:t[1],w:s,h:i}}const i=t[2],s=t[2]/e;return{x:t[0],y:t[1]-(s-t[3])/2,w:i,h:s}}function ft(t,e,i,s){if(t.length<2)return;const a=e.x+s,o=e.x+e.w-s,n=e.y+s,r=e.y+e.h-s;for(let e=0;e<60;e++){let e=!1;for(let s=0;st:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new a("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:n,defineProperty:r,getOwnPropertyDescriptor:l,getOwnPropertyNames:c,getOwnPropertySymbols:h,getPrototypeOf:d}=Object,p=globalThis,u=p.trustedTypes,_=u?u.emptyScript:"",g=p.reactiveElementPolyfillSupport,m=(t,e)=>t,f={toAttribute(t,e){switch(e){case Boolean:t=t?_:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},v=(t,e)=>!n(t,e),b={attribute:!0,type:String,converter:f,reflect:!1,useDefault:!1,hasChanged:v};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let y=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&r(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:a}=l(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);a?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(m("elementProperties")))return;const t=d(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(m("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(m("properties"))){const t=this.properties,e=[...c(t),...h(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(o(t))}else void 0!==t&&e.push(o(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),a=t.litNonce;void 0!==a&&s.setAttribute("nonce",a),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const a=(void 0!==i.converter?.toAttribute?i.converter:f).toAttribute(e,i.type);this._$Em=t,null==a?this.removeAttribute(s):this.setAttribute(s,a),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),a="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:f;this._$Em=s;const o=a.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,a){if(void 0!==t){const o=this.constructor;if(!1===s&&(a=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??v)(a,e)||i.useDefault&&i.reflect&&a===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:a},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==a||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};y.elementStyles=[],y.shadowRootOptions={mode:"open"},y[m("elementProperties")]=new Map,y[m("finalized")]=new Map,g?.({ReactiveElement:y}),(p.reactiveElementVersions??=[]).push("2.1.2");const x=globalThis,$=t=>t,w=x.trustedTypes,k=w?w.createPolicy("lit-html",{createHTML:t=>t}):void 0,S="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,A="?"+C,z=`<${A}>`,M=document,D=()=>M.createComment(""),P=t=>null===t||"object"!=typeof t&&"function"!=typeof t,E=Array.isArray,R="[ \t\n\f\r]",T=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,O=/-->/g,U=/>/g,H=RegExp(`>|${R}(?:([^\\s"'>=/]+)(${R}*=${R}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),I=/'/g,N=/"/g,q=/^(?:script|style|textarea|title)$/i,L=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=L(1),F=L(2),W=Symbol.for("lit-noChange"),B=Symbol.for("lit-nothing"),K=new WeakMap,V=M.createTreeWalker(M,129);function Z(t,e){if(!E(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==k?k.createHTML(e):e}const Y=(t,e)=>{const i=t.length-1,s=[];let a,o=2===e?"":3===e?"":"",n=T;for(let e=0;e"===l[0]?(n=a??T,c=-1):void 0===l[1]?c=-2:(c=n.lastIndex-l[2].length,r=l[1],n=void 0===l[3]?H:'"'===l[3]?N:I):n===N||n===I?n=H:n===O||n===U?n=T:(n=H,a=void 0);const d=n===H&&t[e+1].startsWith("/>")?" ":"";o+=n===T?i+z:c>=0?(s.push(r),i.slice(0,c)+S+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Z(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class X{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let a=0,o=0;const n=t.length-1,r=this.parts,[l,c]=Y(t,e);if(this.el=X.createElement(l,i),V.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=V.nextNode())&&r.length0){s.textContent=w?w.emptyScript:"";for(let i=0;iE(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==B&&P(this._$AH)?this._$AA.nextSibling.data=t:this.T(M.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=X.createElement(Z(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new G(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=K.get(t.strings);return void 0===e&&K.set(t.strings,e=new X(t)),e}k(t){E(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const a of t)s===e.length?e.push(i=new Q(this.O(D()),this.O(D()),this,this.options)):i=e[s],i._$AI(a),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=B}_$AI(t,e=this,i,s){const a=this.strings;let o=!1;if(void 0===a)t=J(this,t,e,0),o=!P(t)||t!==this._$AH&&t!==W,o&&(this._$AH=t);else{const s=t;let n,r;for(t=a[0],n=0;n{const s=i?.renderBefore??e;let a=s._$litPart$;if(void 0===a){const t=i?.renderBefore??null;s._$litPart$=a=new Q(e.insertBefore(D(),t),t,void 0,i??{})}return a._$AI(t),a})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return W}}rt._$litElement$=!0,rt.finalized=!0,nt.litElementHydrateSupport?.({LitElement:rt});const lt=nt.litElementPolyfillSupport;lt?.({LitElement:rt}),(nt.litElementVersions??=[]).push("4.2.2");const ct=new Set(["hacs","sun","backup","hassio","met","telegram_bot","mobile_app","systemmonitor","better_thermostat","adaptive_lighting","yandex_pogoda","upnp_serial_number"]),ht=[[/протечк/,"mdi:water-alert"],[/клапан/,"mdi:pipe-valve"],[/дым/,"mdi:smoke-detector"],[/термоголов/,"mdi:radiator"],[/температ/,"mdi:thermometer"],[/qingping|air monitor|молекул/,"mdi:air-filter"],[/штор/,"mdi:roller-shade"],[/розетк|plug/,"mdi:power-socket-de"],[/выключат|switch/,"mdi:light-switch"],[/лампа|лампочк|bulb|gx53|светильник|rgb/,"mdi:lightbulb"],[/камер|camera/,"mdi:cctv"],[/замок|ttlock|lock|sn609|sn9161/,"mdi:lock"],[/ворота|garage/,"mdi:garage-variant"],[/калитк|door|открыт/,"mdi:door"],[/счётчик|счетчик|kws|meter/,"mdi:meter-electric"],[/вводный автомат|breaker|wifimcbn/,"mdi:electric-switch"],[/myheat|котёл|котел|boiler|отоплен/,"mdi:water-boiler"],[/холодильник|fridge/,"mdi:fridge"],[/стиральн|washer/,"mdi:washing-machine"],[/сушилк|dryer/,"mdi:tumble-dryer"],[/пылесос|vacuum|dreame/,"mdi:robot-vacuum"],[/soundbar|колонк|станц/,"mdi:soundbar"],[/tv|телевизор|hyundaitv|mitv/,"mdi:television"],[/keenetic|роутер|router/,"mdi:router-wireless"],[/ибп|ups|kirpich/,"mdi:battery-charging-high"],[/slzb|координат|zigbee/,"mdi:zigbee"]];function dt(t,e){const i=((t||"")+" "+(e||"")).toLowerCase();for(const[t,e]of ht)if(t.test(i))return e;return"mdi:chip"}const pt=["light","switch","cover","valve","lock","climate","fan","media_player","camera","vacuum","humidifier","water_heater","alarm_control_panel","sensor","binary_sensor","event","button","number","select","update"];function ut(t){const e=Math.max(0,Math.min(120,(t-40)/140*120));return`hsl(${Math.round(e)}, 85%, 55%)`}function _t(t,e){return Math.round(t/e)*e}function gt(t){return t.length?Math.round(t.reduce((t,e)=>t+e,0)/t.length):null}function mt(t,e){if(e>t[2]/t[3]){const i=t[3],s=t[3]*e;return{x:t[0]-(s-t[2])/2,y:t[1],w:s,h:i}}const i=t[2],s=t[2]/e;return{x:t[0],y:t[1]-(s-t[3])/2,w:i,h:s}}function ft(t,e,i,s){if(t.length<2)return;const a=e.x+s,o=e.x+e.w-s,n=e.y+s,r=e.y+e.h-s;for(let e=0;e<60;e++){let e=!1;for(let s=0;s({eid:e,reg:t.entities[e],st:t.states[e]})).filter(t=>t.reg&&!t.reg.hidden),a=s.filter(t=>!t.reg.entity_category),o=a.length?a:s;if("mdi:thermometer"===i||"mdi:air-filter"===i){const e=o.find(e=>yt(t,e.eid));if(e)return e.eid}for(const t of pt){const e=o.find(e=>e.eid.split(".")[0]===t);if(e)return e.eid}return o[0]?.eid}function $t(t,e){const i=[];for(const s of e){const e=t.states[s];if(!e)continue;const a=(e.attributes?.unit_of_measurement||"").toLowerCase();if(/_(linkquality|lqi)$/.test(s)||"lqi"===a){const t=parseFloat(e.state);isNaN(t)||i.push(t);continue}const o=e.attributes?.linkquality??e.attributes?.lqi;if(null!=o){const t=parseFloat(o);isNaN(t)||i.push(t)}}return gt(i)}function wt(t,e){for(const i of e){if(!yt(t,i))continue;const e=t.states[i];if(!e)continue;const s=parseFloat(e.state);if(!isNaN(s))return Math.round(10*s)/10}return null}function kt(t,e){t.marker=e,e.name&&(t.name=e.name),e.icon&&(t.icon=e.icon),null!=e.model&&(t.model=e.model),t.link=e.link??null,t.description=e.description??null,t.pdfs=e.pdfs||[]}function St(t){const{hass:e,areaToSpace:i,markers:s,settings:a,excluded:o,showAll:n,firstSpaceId:r}=t,l=!1!==a.group_lights,c=function(t,e){if(!e)return[];const i=[];for(const[e,s]of Object.entries(t.entities)){if(!e.startsWith("light.")||s.hidden)continue;let a=null;if("group"===s.platform)a=s.area_id||null;else{if(!s.device_id)continue;{const e=t.devices[s.device_id];if("Group"!==e?.model)continue;a=e.area_id||s.area_id||null}}if(!a)continue;const o=t.states[e];i.push({eid:e,name:s.name||o?.attributes?.friendly_name||e,area:a})}return i}(e,l),h=new Set(c.map(t=>t.area)),d=function(t){const e={};for(const[i,s]of Object.entries(t.entities))s?.device_id&&(e[s.device_id]=e[s.device_id]||[]).push(i);return e}(e),p=new Set;for(const t of s){const[e,i]=t.binding.split(":");"device"!==e&&"entity"!==e||!i||p.add(t.binding)}const u=(t,e)=>s.find(i=>i.binding===t+":"+e),_={},g=[];for(const t of Object.values(e.devices)){const s=t.area_id;if(!s||!i[s])continue;if("service"===t.entry_type)continue;if(p.has("device:"+t.id))continue;const a=u("device",t.id);if(a&&a.hidden)continue;const r=d[t.id]||[],c=bt(e,t,r);if(!n){if(o.has(c))continue;if("Group"===t.model)continue;if(/scene/i.test(t.model||""))continue;if(/bridge/i.test((t.model||"")+(t.name||"")))continue;if("myheat"===c&&t.via_device_id)continue}const m=(t.name_by_user||t.name||"без имени").trim(),f=m+"|"+s;let v=dt(m,t.model);if(r.some(t=>t.startsWith("lock."))&&(v="mdi:lock"),!n&&l&&"mdi:lightbulb"===v&&h.has(s))continue;_[f]=(_[f]||0)+1;const b=_[f]>1?m+" "+_[f]:m,y={id:t.id,name:b,model:t.model||"",area:s,space:i[s],icon:v,entities:r,bindingKind:"device",bindingRef:t.id,pdfs:[]};y.primary=xt(e,r,v),"mdi:thermometer"!==v&&"mdi:air-filter"!==v||(y.temp=wt(e,r)),g.push(y)}for(const t of c)i[t.area]&&(p.has("entity:"+t.eid)||g.push({id:"lg_"+t.eid,name:t.name,model:"группа света",area:t.area,space:i[t.area],icon:"mdi:lightbulb-group",entities:[t.eid],primary:t.eid,bindingKind:"entity",bindingRef:t.eid,pdfs:[]}));for(const t of s){if(t.hidden)continue;const[s,a]=t.binding.split(":");if("device"===s){const s=e.devices[a],o=t.area||s?.area_id||"",n=o&&i[o]||t.space||r,l=s&&d[s.id]||[];let c=s?dt(s.name_by_user||s.name||"",s.model):"mdi:help-circle";l.some(t=>t.startsWith("lock."))&&(c="mdi:lock");const h={id:t.id,name:s?.name_by_user||s?.name||"устройство",model:s?.model||"",area:o,space:n,icon:c,entities:l,bindingKind:"device",bindingRef:a};h.primary=xt(e,l,c),"mdi:thermometer"!==c&&"mdi:air-filter"!==c||(h.temp=wt(e,l)),kt(h,t),g.push(h)}else if("entity"===s){const s=e.entities[a],o=t.area||s?.area_id||s?.device_id&&e.devices[s.device_id]?.area_id||"",n=o&&i[o]||t.space||r,l=e.states[a],c={id:t.id,name:s?.name||l?.attributes?.friendly_name||a,model:"",area:o,space:n,icon:"mdi:shape-outline",entities:[a],primary:a,bindingKind:"entity",bindingRef:a};kt(c,t),g.push(c)}else{const e=t.area||"",s=t.space||e&&i[e]||r,a={id:t.id,name:t.name||"виртуальное устройство",model:t.model||"",area:e,space:s,icon:t.icon||"mdi:map-marker",entities:[],bindingKind:"virtual",virtual:!0};kt(a,t),g.push(a)}}return g}const Ct={title:"Заголовок",default_floor:"Пространство по умолчанию",icon_size:"Размер иконок, % ширины плана",show_temperature:"Показывать температуру",live_states:"Живые состояния (вкл/выкл, открыто…)",show_signal:"Показывать сигнал zigbee (LQI)"};class At extends rt{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(t){this._config=t}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const t=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(t?.config?.spaces||[]).map(t=>({value:t.id,label:t.title||t.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _schema(){const t=this._spaces||[];return[{name:"title",selector:{text:{}}},t.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:t}}}:{name:"default_floor",selector:{text:{}}},{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:{}}}]}render(){return this.hass&&this._config?(this._loadSpaces(),j`bt[t.name]||t.name} + .schema=${this._schema} + .computeLabel=${t=>Ct[t.name]||t.name} @value-changed=${this._valueChanged} - >`:W}_valueChanged(t){const e={...this._config,...t.detail.value},i=new Event("config-changed",{bubbles:!0,composed:!0});i.detail={config:e},this.dispatchEvent(i)}}yt.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",yt);const xt="houseplan_card_layout_v1",$t="houseplan_card_cfg_v1",wt="houseplan_card_zoom_v1",kt=1e3,St=(t,e,i)=>{const s=new Event(e,{bubbles:!0,composed:!0});s.detail=i??{},t.dispatchEvent(s)},Ct=(t,e)=>{let i;return(...s)=>{clearTimeout(i),i=window.setTimeout(()=>t(...s),e)}};class At extends rt{constructor(){super(...arguments),this._space="f1",this._layout={},this._serverStorage=!1,this._loadOk=!1,this._loading=!1,this._loadTries=0,this._serverCfg=null,this._cfgRev=0,this._unsubCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._tip=null,this._selId=null,this._toast="",this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._zoom=1,this._view=null,this._zoomBySpace={},this._pointers=new Map,this._panStart=null,this._pinchStart=null,this._suppressClick=!1,this._onboardingShown=!1,this._infoCard=null,this._markerDialog=null,this._spaceDialog=null,this._keyHandler=t=>this._onKey(t),this._drag=null,this._dirtyPos=new Set,this._persistLayout=Ct(()=>{if(this._serverStorage){const t=[...this._dirtyPos];this._dirtyPos.clear();for(const e of t){const t=this._layout[e];t&&this.hass.callWS({type:"houseplan/layout/update",device_id:e,pos:t}).catch(t=>this._showToast("Не удалось сохранить позицию: "+(t?.message||t)))}this._cacheSnapshot()}else localStorage.setItem(xt,JSON.stringify(this._layout))},600),this._saveConfig=Ct(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev}).then(t=>{this._cfgRev=t?.rev??this._cfgRev+1}).catch(t=>{"conflict"===t?.code?(this._showToast("Конфиг изменён в другом окне — данные обновлены, повторите последнее действие"),this._cancelPath(),this._reloadConfigOnly()):this._showToast("Не удалось сохранить конфиг: "+(t?.message||t))})},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),this._roViewport?.disconnect(),this._roViewport=void 0,this._unsubCfg&&(this._unsubCfg(),this._unsubCfg=null),super.disconnectedCallback()}_onKey(t){if("Escape"===t.key){if(this._infoCard)return void(this._infoCard=null);if(this._markerDialog)return void(this._markerDialog=null)}if(!this._markup)return;return"Escape"===t.key||(t.ctrlKey||t.metaKey)&&"z"===t.key.toLowerCase()?this._roomDialog?(t.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(t.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const t=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),t&&this._removeSegmentByKey(t),this._path=this._path.slice(0,-1)}_removeSegmentByKey(t){const e=this._curSpaceCfg;if(!e?.segments)return;const i=this._segments.findIndex(e=>this._segKey([e[0],e[1]],[e[2],e[3]])===t);i>=0&&(e.segments.splice(i,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(t){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...t},t.default_floor&&(this._space=t.default_floor);try{this._zoomBySpace=JSON.parse(localStorage.getItem(wt)||"{}")||{}}catch{this._zoomBySpace={}}try{const e=JSON.parse(localStorage.getItem($t)||"null");e&&e.config&&Array.isArray(e.config.spaces)&&(this._serverCfg=e.config,this._cfgRev=e.rev||0,this._layout=e.layout||{},this._serverStorage=!0,t.default_floor?this._space=t.default_floor:this._model.find(t=>t.id===this._space)||(this._space=this._model[0]?.id||this._space))}catch{}}_cacheSnapshot(){if(this._serverCfg)try{localStorage.setItem($t,JSON.stringify({config:this._serverCfg,rev:this._cfgRev,layout:this._layout}))}catch{}}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){return this._serverCfg?this._serverCfg.spaces.map(t=>{const e=kt/t.aspect;return{id:t.id,title:t.title,vb:[t.view_box[0]*kt,t.view_box[1]*e,t.view_box[2]*kt,t.view_box[3]*e],bg:t.plan_url?{href:t.plan_url,x:0,y:0,w:kt,h:e}:null,rooms:t.rooms.map(t=>({id:t.id,name:t.name,area:t.area??null,x:null!=t.x?t.x*kt:void 0,y:null!=t.y?t.y*e:void 0,w:null!=t.w?t.w*kt:void 0,h:null!=t.h?t.h*e:void 0,poly:t.poly?t.poly.map(t=>[t[0]*kt,t[1]*e]):void 0}))}}):[]}_spaceModel(t){const e=this._model;return e.find(e=>e.id===(t??this._space))||e[0]}get _areaToSpace(){const t={};for(const e of this._model)for(const i of e.rooms)i.area&&(t[i.area]={space:e.id,room:i});return t}get _settings(){return this._serverCfg?.settings||{}}get _showAll(){return!!this._settings.show_all}_toggleShowAll(){this._serverCfg&&(this._serverCfg={...this._serverCfg,settings:{...this._serverCfg.settings,show_all:!this._showAll}},this._regSignature="",this._maybeRebuildDevices(),this._saveConfig(),this.requestUpdate())}get _excluded(){const t=this._settings.exclude_integrations;return t?new Set(t):ht}willUpdate(t){t.has("hass")&&this.hass&&(!this._loadOk&&!this._loading&&this._loadTries<8&&this._loadFromServer(),this._maybeRebuildDevices())}updated(){const t=this._stageEl;t&&!this._roViewport&&(this._roViewport=new ResizeObserver(()=>this._refitView()),this._roViewport.observe(t)),t&&!this._view&&this._refitView(),this._serverStorage&&this._loadOk&&0===this._model.length&&!this._spaceDialog&&!this._onboardingShown&&(this._onboardingShown=!0,this._openSpaceDialog("create"))}async _loadFromServer(){this._loading=!0,this._loadTries++;try{const[t,e]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._loadOk=!0,this._serverStorage=!0;const i=t?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._cfgRev=t?.rev||0,this._layout=e?.layout||{},this._unsubCfg||(this._unsubCfg=await this.hass.connection.subscribeEvents(t=>{(t?.data?.rev??-1)!==this._cfgRev&&this._reloadConfigOnly()},"houseplan_config_updated")),this._norm&&!this._model.find(t=>t.id===this._space)&&(this._space=this._model[0]?.id||this._space),this._cacheSnapshot(),this._restoreZoom()}catch(t){if(this._loadTries>=8){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(xt)||"{}")||{}}catch{this._layout={}}}}finally{this._loading=!1}this._regSignature="",this.requestUpdate()}async _reloadConfigOnly(){try{const t=await this.hass.callWS({type:"houseplan/config/get"}),e=t?.config;this._serverCfg=e&&Array.isArray(e.spaces)?e:null,this._cfgRev=t?.rev||0,this._cacheSnapshot(),this._regSignature="",this._maybeRebuildDevices(),this.requestUpdate()}catch{}}_maybeRebuildDevices(){const t=this.hass;if(!t?.devices||!t?.entities||!t?.areas)return;const e=Object.keys(t.devices).length+":"+Object.keys(t.entities).length+":"+Object.keys(t.areas).length+":"+(this._norm?"n":"l");e===this._regSignature&&this._devices.length||(this._regSignature=e,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const t={};for(const[e,i]of Object.entries(this.hass.entities))i?.device_id&&(t[i.device_id]=t[i.device_id]||[]).push(e);return t}_domainOfDevice(t,e){if(t.identifiers?.[0]?.[0])return t.identifiers[0][0];for(const t of e){const e=this.hass.entities[t]?.platform;if(e)return e}return""}_primaryEntity(t,e){const i=t.map(t=>({eid:t,reg:this.hass.entities[t],st:this.hass.states[t]})).filter(t=>t.reg&&!t.reg.hidden),s=i.filter(t=>!t.reg.entity_category),a=s.length?s:i;if("mdi:thermometer"===e||"mdi:air-filter"===e){const t=a.find(t=>this._isTempEntity(t.eid));if(t)return t.eid}for(const t of pt){const e=a.find(e=>e.eid.split(".")[0]===t);if(e)return e.eid}return a[0]?.eid}_isTempEntity(t){const e=this.hass.states[t];if(!e)return/_temperature$/.test(t);const i=e.attributes||{};return"temperature"===i.device_class||/°C|°F/.test(i.unit_of_measurement||"")||/_temperature$/.test(t)}_lqiFor(t){const e=[];for(const i of t){const t=this.hass.states[i];if(!t)continue;const s=(t.attributes?.unit_of_measurement||"").toLowerCase();if(/_(linkquality|lqi)$/.test(i)||"lqi"===s){const i=parseFloat(t.state);isNaN(i)||e.push(i);continue}const a=t.attributes?.linkquality??t.attributes?.lqi;if(null!=a){const t=parseFloat(a);isNaN(t)||e.push(t)}}return gt(e)}_roomLqi(t){if(!t)return null;const e=[];for(const i of this._devices){if(i.area!==t||i.virtual)continue;const s=this._lqiFor(i.entities);null!=s&&e.push(s)}return gt(e)}_tempFor(t){for(const e of t){if(!this._isTempEntity(e))continue;const t=this.hass.states[e];if(!t)continue;const i=parseFloat(t.state);if(!isNaN(i))return Math.round(10*i)/10}return null}_lightGroups(){if(!1===this._settings.group_lights)return[];const t=this.hass,e=[];for(const[i,s]of Object.entries(t.entities)){if(!i.startsWith("light.")||s.hidden)continue;let a=null;if("group"===s.platform)a=s.area_id||null;else{if(!s.device_id)continue;{const e=t.devices[s.device_id];if("Group"!==e?.model)continue;a=e.area_id||s.area_id||null}}if(!a)continue;const o=t.states[i];e.push({eid:i,name:s.name||o?.attributes?.friendly_name||i,area:a})}return e}_groupedLightAreas(){return new Set(this._lightGroups().map(t=>t.area))}get _markers(){return this._serverCfg?.markers||[]}_markerFor(t,e){return this._markers.find(i=>i.binding===t+":"+e)}get _claimed(){const t=new Set;for(const e of this._markers){const[i,s]=e.binding.split(":");"device"!==i&&"entity"!==i||!s||t.add(e.binding)}return t}_applyMarker(t,e){t.marker=e,e.name&&(t.name=e.name),e.icon&&(t.icon=e.icon),null!=e.model&&(t.model=e.model),t.link=e.link??null,t.description=e.description??null,t.pdfs=e.pdfs||[]}_buildDevices(){const t=this.hass,e=this._areaToSpace,i=!1!==this._settings.group_lights,s=this._groupedLightAreas(),a=this._excluded,o=this._showAll,n=this._entitiesByDevice(),r=this._claimed,l={},h=[];for(const c of Object.values(t.devices)){const t=c.area_id;if(!t||!e[t])continue;if("service"===c.entry_type)continue;if(r.has("device:"+c.id))continue;const d=this._markerFor("device",c.id);if(d&&d.hidden)continue;const p=n[c.id]||[],_=this._domainOfDevice(c,p);if(!o){if(a.has(_))continue;if("Group"===c.model)continue;if(/scene/i.test(c.model||""))continue;if(/bridge/i.test((c.model||"")+(c.name||"")))continue;if("myheat"===_&&c.via_device_id)continue}const u=(c.name_by_user||c.name||"без имени").trim(),g=u+"|"+t;let m=dt(u,c.model);if(p.some(t=>t.startsWith("lock."))&&(m="mdi:lock"),!o&&i&&"mdi:lightbulb"===m&&s.has(t))continue;l[g]=(l[g]||0)+1;const f=l[g]>1?u+" "+l[g]:u,v={id:c.id,name:f,model:c.model||"",area:t,space:e[t].space,icon:m,entities:p,bindingKind:"device",bindingRef:c.id,pdfs:[]};v.primary=this._primaryEntity(p,m),"mdi:thermometer"!==m&&"mdi:air-filter"!==m||(v.temp=this._tempFor(p)),h.push(v)}for(const t of this._lightGroups())e[t.area]&&(r.has("entity:"+t.eid)||h.push({id:"lg_"+t.eid,name:t.name,model:"группа света",area:t.area,space:e[t.area].space,icon:"mdi:lightbulb-group",entities:[t.eid],primary:t.eid,bindingKind:"entity",bindingRef:t.eid,pdfs:[]}));for(const i of this._markers){if(i.hidden)continue;const[s,a]=i.binding.split(":");if("device"===s){const s=t.devices[a],o=i.area||s?.area_id||"",r=o&&e[o]?.space||i.space||this._model[0]?.id||"",l=s&&n[s.id]||[];let c=s?dt(s.name_by_user||s.name||"",s.model):"mdi:help-circle";l.some(t=>t.startsWith("lock."))&&(c="mdi:lock");const d={id:i.id,name:s?.name_by_user||s?.name||"устройство",model:s?.model||"",area:o,space:r,icon:c,entities:l,bindingKind:"device",bindingRef:a};d.primary=this._primaryEntity(l,c),"mdi:thermometer"!==c&&"mdi:air-filter"!==c||(d.temp=this._tempFor(l)),this._applyMarker(d,i),h.push(d)}else if("entity"===s){const s=t.entities[a],o=i.area||s?.area_id||s?.device_id&&t.devices[s.device_id]?.area_id||"",n=o&&e[o]?.space||i.space||this._model[0]?.id||"",r=t.states[a],l={id:i.id,name:s?.name||r?.attributes?.friendly_name||a,model:"",area:o,space:n,icon:"mdi:shape-outline",entities:[a],primary:a,bindingKind:"entity",bindingRef:a};this._applyMarker(l,i),h.push(l)}else{const t=i.area||"",s=i.space||t&&e[t]?.space||this._model[0]?.id||"",a={id:i.id,name:i.name||"виртуальное устройство",model:i.model||"",area:t,space:s,icon:i.icon||"mdi:map-marker",entities:[],bindingKind:"virtual",virtual:!0};this._applyMarker(a,i),h.push(a)}}return h}_roomBounds(t){if(t.poly&&t.poly.length){const e=t.poly.map(t=>t[0]),i=t.poly.map(t=>t[1]),s=Math.min(...e),a=Math.min(...i);return{x:s,y:a,w:Math.max(...e)-s,h:Math.max(...i)-a}}return{x:t.x??0,y:t.y??0,w:t.w??0,h:t.h??0}}_defaultPositions(){const t={},e=(this._config?.icon_size??2.5)/100*kt*1.3;for(const i of this._model)for(const s of i.rooms){if(!s.area)continue;const a=this._devices.filter(t=>t.area===s.area&&t.space===i.id);if(!a.length)continue;const o=this._roomBounds(s),n=.1*Math.min(o.w,o.h),r=o.w-2*n,l=o.h-2*n,h=Math.max(1,Math.round(Math.sqrt(a.length*r/Math.max(l,1)))),c=Math.ceil(a.length/h),d=r/h,p=l/Math.max(c,1),_=a.map((t,e)=>({x:o.x+n+d*(e%h+.5),y:o.y+n+p*(Math.floor(e/h)+.5)}));ft(_,o,e,.5*n),a.forEach((e,i)=>t[e.id]=_[i])}return t}_pos(t){const e=this._spaceModel(t.space),i=this._layout[t.id];if(i)if(this._norm){if(i.s===t.space){const e=this._serverCfg.spaces.find(e=>e.id===t.space)?.aspect||1;return{x:i.x*kt,y:i.y*(kt/e)}}}else if(void 0===i.s)return{x:i.x,y:i.y};if(this._defPos[t.id])return this._defPos[t.id];const s=e.vb;return{x:s[0]+s[2]/2,y:s[1]+s[3]/2}}_savePos(t,e,i){if(this._norm){const s=this._gridPitch,a=Math.round(e/s)*s,o=Math.round(i/s)*s,n=this._serverCfg.spaces.find(e=>e.id===t.space)?.aspect||1;this._layout={...this._layout,[t.id]:{s:t.space,x:a/kt,y:o/(kt/n)}}}else this._layout={...this._layout,[t.id]:{x:Math.round(e),y:Math.round(i)}};this._dirtyPos.add(t.id),this._persistLayout()}_stateClass(t){if(!this._config?.live_states)return"";const e=t.primary?this.hass.states[t.primary]:void 0;if(!e)return"";if("unavailable"===e.state)return"unavail";const i=e.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(i))return"on"===e.state?"on":"";if("cover"===i||"valve"===i)return["open","opening"].includes(e.state)?"open":"";if("lock"===i)return["unlocked","open"].includes(e.state)?"open":"";if("binary_sensor"===i){const t=e.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(t))return"on"===e.state?"open":""}return"media_player"===i?["playing","on"].includes(e.state)?"on":"":"vacuum"===i&&["cleaning","returning"].includes(e.state)?"on":""}_liveTemp(t){return this._config?.show_temperature?"mdi:thermometer"!==t.icon&&"mdi:air-filter"!==t.icon?null:this._tempFor(t.entities):null}_openMoreInfo(t){t?St(this,"hass-more-info",{entityId:t}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(t,e){t.stopPropagation(),this._drag?.moved||this._suppressClick||this._markup||(this._infoCard=e)}get _stageEl(){return this.renderRoot.querySelector(".stage")}_stageAspect(){const t=this._stageEl,e=this._spaceModel().vb;return t&&t.clientHeight?t.clientWidth/t.clientHeight:e[2]/e[3]}_viewOr(t){return this._view&&this._view.w?this._view:mt(t,this._stageAspect())}_screenToVb(t,e){const i=this._stageEl,s=this._viewOr(this._spaceModel().vb),a=i?.clientWidth||1,o=i?.clientHeight||1;return[s.x+t/a*s.w,s.y+e/o*s.h]}_clampView(t,e){return{w:t.w,h:t.h,x:Math.max(e.x,Math.min(e.x+e.w-t.w,t.x)),y:Math.max(e.y,Math.min(e.y+e.h-t.h,t.y))}}_applyView(t,e,i){const s=this._spaceModel().vb,a=mt(s,this._stageAspect()),o=Math.min(8,Math.max(1,t)),n=a.w/o,r=a.h/o,l=this._viewOr(s),h=e??l.x+l.w/2,c=i??l.y+l.h/2;this._zoom=o,this._view=this._clampView({x:h-n/2,y:c-r/2,w:n,h:r},a)}_refitView(){if(!this._stageEl)return;const t=this._view;this._applyView(this._zoom,t?t.x+t.w/2:void 0,t?t.y+t.h/2:void 0),this.requestUpdate()}_zoomAt(t,e,i){const s=this._stageEl;if(!s)return;const a=mt(this._spaceModel().vb,this._stageAspect()),o=Math.min(8,Math.max(1,i)),n=s.clientWidth,r=s.clientHeight,l=this._screenToVb(t,e),h=a.w/o,c=a.h/o;this._zoom=o,this._view=this._clampView({x:l[0]-t/n*h,y:l[1]-e/r*c,w:h,h:c},a)}_onWheel(t){const e=this._stageEl;if(!e)return;t.preventDefault();const i=e.getBoundingClientRect(),s=t.deltaY<0?1.15:1/1.15;this._zoomAt(t.clientX-i.left,t.clientY-i.top,this._zoom*s),this._saveZoom()}_stepZoom(t){const e=this._stageEl;e&&(this._zoomAt(e.clientWidth/2,e.clientHeight/2,this._zoom*(t>0?1.4:1/1.4)),this._saveZoom())}_resetZoom(){const t=this._spaceModel().vb;this._zoom=1,this._view=mt(t,this._stageAspect()),this._saveZoom()}_saveZoom(){this._zoomBySpace={...this._zoomBySpace,[this._space]:this._zoom};try{localStorage.setItem(wt,JSON.stringify(this._zoomBySpace))}catch{}}_restoreZoom(){const t=this._zoomBySpace[this._space]||1;this._zoom=t,this._view=null,requestAnimationFrame(()=>{if(!this._stageEl)return;const e=this._spaceModel().vb;this._applyView(t,e[0]+e[2]/2,e[1]+e[3]/2),this.requestUpdate()})}_stagePointerDown(t){if(this._drag||this._markup)return;if(t.target.closest(".dev"))return;this._pointers.set(t.pointerId,{x:t.clientX,y:t.clientY});const e=this._viewOr(this._spaceModel().vb);if(1===this._pointers.size)this._panStart={sx:t.clientX,sy:t.clientY,vx:e.x,vy:e.y},this._suppressClick=!1;else if(2===this._pointers.size){const t=[...this._pointers.values()],e=Math.hypot(t[0].x-t[1].x,t[0].y-t[1].y);this._pinchStart={dist:e,zoom:this._zoom},this._panStart=null}}_stagePointerMove(t){if(this._pointers.has(t.pointerId)){if(this._pointers.set(t.pointerId,{x:t.clientX,y:t.clientY}),this._pinchStart&&this._pointers.size>=2){const t=[...this._pointers.values()],e=Math.hypot(t[0].x-t[1].x,t[0].y-t[1].y)/(this._pinchStart.dist||1),i=this._stageEl.getBoundingClientRect(),s=(t[0].x+t[1].x)/2-i.left,a=(t[0].y+t[1].y)/2-i.top;this._zoomAt(s,a,this._pinchStart.zoom*e),this._suppressClick=!0,this._saveZoom()}else if(this._panStart){const e=t.clientX-this._panStart.sx,i=t.clientY-this._panStart.sy;if(Math.abs(e)+Math.abs(i)>4&&(this._suppressClick=!0),this._zoom>1&&this._view){const t=this._stageEl,s=this._view,a=mt(this._spaceModel().vb,this._stageAspect());this._view=this._clampView({x:this._panStart.vx-e/t.clientWidth*s.w,y:this._panStart.vy-i/t.clientHeight*s.h,w:s.w,h:s.h},a)}}}else this._markupMove(t)}_stagePointerUp(t){this._pointers.delete(t.pointerId),this._pointers.size<2&&(this._pinchStart=null),0===this._pointers.size&&(this._panStart=null,setTimeout(()=>this._suppressClick=!1,0))}_clickRoom(t){var e;!this._suppressClick&&t.area&&(e="/config/areas/area/"+t.area,history.pushState(null,"",e),St(window,"location-changed",{replace:!1}))}_pointerDown(t,e){if(this._markup)return;t.preventDefault();const i=this._pos(e);this._drag={id:e.id,sx:t.clientX,sy:t.clientY,ox:i.x,oy:i.y,moved:!1},t.target.setPointerCapture(t.pointerId),this._tip=null}_pointerMove(t,e){if(!this._drag||this._drag.id!==e.id)return;const i=this.renderRoot.querySelector(".stage");if(!i)return;const s=this._spaceModel().vb,a=i.getBoundingClientRect(),o=this._viewOr(s),n=(t.clientX-this._drag.sx)/a.width*o.w,r=(t.clientY-this._drag.sy)/a.height*o.h;Math.abs(t.clientX-this._drag.sx)+Math.abs(t.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const l=.008*Math.min(s[2],s[3]),h=Math.max(s[0]+l,Math.min(s[0]+s[2]-l,this._drag.ox+n)),c=Math.max(s[1]+l,Math.min(s[1]+s[3]-l,this._drag.oy+r));this._savePos(e,h,c)}_pointerUp(t,e){if(!this._drag||this._drag.id!==e.id)return;const i=this._drag.moved;this._drag=i?this._drag:null,i&&(this._selId=e.id,window.setTimeout(()=>this._drag=null,0))}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(t){this._toast=t,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(t,e,i,s){this._drag||(this._tip={x:t.clientX,y:t.clientY,title:e,meta:i,lqi:s})}get _gridPitch(){return kt/240}get _curSpaceCfg(){return this._serverCfg?.spaces.find(t=>t.id===this._space)}get _spaceH(){const t=this._curSpaceCfg;return t?kt/t.aspect:kt}get _segments(){const t=this._curSpaceCfg,e=this._spaceH;return(t?.segments||[]).map(t=>[t[0]*kt,t[1]*e,t[2]*kt,t[3]*e])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(t){const e=this.renderRoot.querySelector(".stage").getBoundingClientRect();return this._screenToVb(t.clientX-e.left,t.clientY-e.top)}_snap(t){const e=this._gridPitch;return[ut(t[0],e),ut(t[1],e)]}_samePt(t,e){return function(t,e,i=.001){return Math.abs(t[0]-e[0])this._segKey([t[0],t[1]],[t[2],t[3]])===a);return!o&&(i.segments=i.segments||[],i.segments.push([t[0]/kt,t[1]/s,e[0]/kt,e[1]/s]),this._saveConfig(),!0)}_distToSeg(t,e){const[i,s]=t,[a,o,n,r]=e,l=n-a,h=r-o;let c=((i-a)*l+(s-o)*h)/(l*l+h*h||1);c=Math.max(0,Math.min(1,c));const d=a+c*l,p=o+c*h;return Math.hypot(i-d,s-p)}_pointInRoom(t,e){return e.poly?function(t,e){let i=!1;for(let s=0,a=e.length-1;st[1]!=l>t[1]&&t[0]<(r-o)*(t[1]-n)/(l-n)+o&&(i=!i)}return i}(t,e.poly):null!=e.x&&t[0]>=e.x&&t[0]<=e.x+e.w&&t[1]>=e.y&&t[1]<=e.y+e.h}_markupClick(t){if(!this._markup)return;const e=this._svgPoint(t);if("erase"===this._tool){const t=this._curSpaceCfg;if(!t?.segments?.length)return;const i=this._segments;let s=-1,a=.5*this._gridPitch;return i.forEach((t,i)=>{const o=this._distToSeg(e,t);o=0&&(t.segments.splice(s,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const t=[...this._spaceModel().rooms].reverse().find(t=>this._pointInRoom(e,t));if(!t)return;if(!confirm(`Удалить комнату «${t.name}»?`))return;const i=this._curSpaceCfg;return i.rooms=i.rooms.filter(e=>e.id!==t.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const i=this._snap(e);if(!this._path.length)return this._path=[i],void(this._pathSegs=[]);const s=this._path[this._path.length-1];if(this._samePt(i,s))return;const a=this._addSegment(s,i);this._pathSegs=[...this._pathSegs,a?this._segKey(s,i):null],this._path=[...this._path,i],this._path.length>=4&&this._samePt(i,this._path[0])&&(this._cursorPt=null,this._nameSel="",this._areaSel="",this._roomDialog=!0)}get _contourClosed(){return this._path.length>=4&&this._samePt(this._path[0],this._path[this._path.length-1])}_markupMove(t){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(t)))}_saveRoom(){this._areaSel&&this._commitRoom()}_saveRoomNoArea(){this._nameSel.trim()&&(this._areaSel="",this._commitRoom())}_commitRoom(){if(!this._contourClosed)return;const t=this._curSpaceCfg;if(!t)return;const e=this._spaceH,i=this._path.slice(0,-1),s=this._areaSel?this.hass.areas[this._areaSel]?.name:"";t.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||s||"Комната",area:this._areaSel||null,poly:i.map(t=>[t[0]/kt,t[1]/e])}),this._saveConfig(),this._path=[],this._pathSegs=[];const a=this._areaSel;this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices();let o=0;if(a){const t=this._serverCfg?.spaces.find(t=>t.id===this._space)?.aspect||1,e=kt/t,i={...this._layout};for(const t of this._devices){if(t.area!==a||t.space!==this._space)continue;if(o++,this._layout[t.id])continue;const s=this._defPos[t.id];s&&(i[t.id]={s:this._space,x:s.x/kt,y:s.y/e},this._dirtyPos.add(t.id))}this._layout=i,this._persistLayout()}const n=this._model.find(t=>t.id===this._space)?.rooms.length||0;this._showToast(a?`Комната сохранена (${n}). Устройств добавлено: ${o}. Обведите следующую или выйдите из разметки.`:`Комната сохранена (${n}, без зоны). Обведите следующую или выйдите из разметки.`)}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const t=new Set;for(const e of this._serverCfg?.spaces||[])for(const i of e.rooms||[])i.area&&t.add(i.area);return Object.values(this.hass?.areas||{}).filter(e=>!t.has(e.area_id)).sort((t,e)=>(t.name||"").localeCompare(e.name||""))}_openMarkerDialog(t){this._norm?this._markerDialog=t?{devId:t.id,name:t.name,binding:"virtual"===t.bindingKind?"virtual":t.bindingKind+":"+t.bindingRef,bindingFilter:"",icon:t.marker?.icon||"",model:t.model||"",link:t.link||"",description:t.description||"",pdfs:[...t.pdfs||[]],room:t.space&&t.area?t.space+"#"+t.area:"",busy:!1}:{name:"",binding:"virtual",bindingFilter:"",icon:"",model:"",link:"",description:"",pdfs:[],room:"",busy:!1}:this._showToast("Редактирование устройств доступно после переноса конфига на сервер")}_bindingCandidates(){const t=this.hass,e=new Set;for(const t of this._devices)t.id!==this._markerDialog?.devId&&("device"===t.bindingKind&&t.bindingRef&&e.add("device:"+t.bindingRef),"entity"===t.bindingKind&&t.bindingRef&&e.add("entity:"+t.bindingRef));const i=new Set;for(const t of this._devices)"device"===t.bindingKind&&t.name&&i.add(t.name.trim()+"|"+(t.area||""));const s=[];for(const a of Object.values(t.devices)){if("service"===a.entry_type)continue;const t="device:"+a.id;if(e.has(t))continue;const o=(a.name_by_user||a.name||a.id).trim();t!==this._markerDialog?.binding&&i.has(o+"|"+(a.area_id||""))||s.push({value:t,label:o,sub:(a.model||"устройство")+("Group"===a.model?" · Z2M-группа":"")})}const a=new Set(["group","template","derivative","min_max","threshold","integration","statistics","trend","utility_meter","tod","switch_as_x","schedule"]);for(const[i,o]of Object.entries(t.entities)){const n="entity:"+i;if(e.has(n))continue;const r=a.has(o.platform),l="group"===o.platform;if(!r&&!l)continue;if(o.hidden)continue;const h=t.states[i];s.push({value:n,label:o.name||h?.attributes?.friendly_name||i,sub:i.split(".")[0]+" · "+("group"===o.platform?"группа":"хелпер")})}const o=(this._markerDialog?.bindingFilter||"").toLowerCase().trim(),n=o?s.filter(t=>(t.label+" "+t.sub+" "+t.value).toLowerCase().includes(o)):s;return n.sort((t,e)=>t.label.localeCompare(e.label)),n.slice(0,200)}_allRoomsFlat(){const t=[];for(const e of this._serverCfg?.spaces||[])for(const i of e.rooms||[])i.area&&t.push({value:e.id+"#"+i.area,label:(e.title||e.id)+" · "+i.name});return t}_errText(t){if(!t)return"неизвестная ошибка";if("string"==typeof t)return t;if(t.message)return t.message;if(t.error)return t.error;if(null!=t.code)return"код "+t.code;try{return JSON.stringify(t)}catch{return String(t)}}async _pickMarkerFiles(t){const e=t.target,i=e.files?[...e.files]:[];if(e.value="",!i.length||!this._markerDialog)return;const s=this._markerDialog.devId||"new",a=this.hass?.auth?.data?.access_token,o=[];for(const t of i)try{const e=new FormData;e.append("marker_id",s),e.append("file",t,t.name);const i=await fetch("/api/houseplan/upload",{method:"POST",body:e,headers:a?{authorization:`Bearer ${a}`}:{}}),n=await i.json().catch(()=>({}));if(!i.ok||n.error){const t={too_large:"файл больше "+(n.max_mb||25)+" МБ",bad_ext:"недопустимый тип (нужен PDF/изображение)",unauthorized:"нужны права администратора"};throw new Error(t[n.error]||n.error||"HTTP "+i.status)}o.push({name:n.name||t.name,url:n.url})}catch(e){this._showToast("Файл «"+t.name+"» не загружен: "+this._errText(e))}o.length&&this._markerDialog&&(this._markerDialog={...this._markerDialog,pdfs:[...this._markerDialog.pdfs,...o]},this._showToast("Прикреплено файлов: "+o.length))}_removeMarkerPdf(t){this._markerDialog&&(this._markerDialog={...this._markerDialog,pdfs:this._markerDialog.pdfs.filter(e=>e.url!==t)})}async _saveMarker(){const t=this._markerDialog;if(t&&!t.busy)if("virtual"!==t.binding||t.name.trim()){this._markerDialog={...t,busy:!0};try{const e=this._serverCfg;let i;e.markers=e.markers||[];const[s,a]=t.room?t.room.split("#"):["",""];let o=s||null,n=a||null;"virtual"!==t.binding||o||(o=this._space),i=function(t,e,i){const[s,a]=t.split(":");return"device"===s?a:"entity"===s?"lg_"+a:e&&e.startsWith("v_")?e:i()}(t.binding,t.devId,()=>"v_"+Date.now().toString(36));const r=t.devId,l={id:i,binding:t.binding,name:t.name.trim()||null,icon:t.icon||null,model:t.model.trim()||null,link:t.link.trim()||null,description:t.description.trim()||null,pdfs:t.pdfs};("virtual"===t.binding||t.room)&&(l.space=o,l.area=n);const h=r?this._devices.find(t=>t.id===r):null,c=!!t.room&&null!=h&&(h.space!==o||h.area!==n);if(e.markers=e.markers.filter(t=>t.id!==i&&t.id!==r),e.markers.push(l),!this._layout[i]||c){const t=this._spaceModel(o||void 0);let e=t.vb[0]+t.vb[2]/2,s=t.vb[1]+t.vb[3]/2;if(n){const i=t.rooms.find(t=>t.area===n);i&&([e,s]=this._roomCenter(i))}this._layout[i]=this._normPos(o||this._space,e,s)}await this._saveConfigNow(),await this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство сохранено")}catch(t){this._markerDialog={...this._markerDialog,busy:!1},this._showToast("Ошибка: "+(t?.message||t))}}else this._showToast("Укажите имя виртуального устройства")}async _deleteMarker(){const t=this._markerDialog;if(!t)return;const e=t.devId?this._devices.find(e=>e.id===t.devId):null,i=t.name||"устройство";if(!confirm(`Убрать «${i}» с плана?`))return;const s=this._serverCfg;s.markers=s.markers||[],e&&"virtual"===e.bindingKind?s.markers=s.markers.filter(t=>t.id!==e.id):e&&e.marker?(s.markers=s.markers.filter(t=>t.id!==e.id),"device"===e.bindingKind&&e.bindingRef?s.markers.push({id:e.id,binding:"device:"+e.bindingRef,hidden:!0}):"entity"===e.bindingKind&&e.bindingRef&&s.markers.push({id:e.id,binding:"entity:"+e.bindingRef,hidden:!0})):e&&"device"===e.bindingKind&&e.bindingRef?s.markers.push({id:e.id,binding:"device:"+e.bindingRef,hidden:!0}):e&&"entity"===e.bindingKind&&e.bindingRef&&s.markers.push({id:e.id,binding:"entity:"+e.bindingRef,hidden:!0});try{await this._saveConfigNow(),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство убрано с плана")}catch(t){this._showToast("Ошибка: "+(t?.message||t))}}_normPos(t,e,i){const s=this._serverCfg.spaces.find(e=>e.id===t)?.aspect||1;return{s:t,x:e/kt,y:i/(kt/s)}}_openSpaceDialog(t,e){if(this._serverStorage&&this._serverCfg)if("edit"===t){const i=this._serverCfg.spaces.find(t=>t.id===e);if(!i)return;this._spaceDialog={mode:t,spaceId:e,title:i.title,planUrl:i.plan_url||null,planFile:null,busy:!1}}else this._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,busy:!1};else this._showToast("Интеграция House Plan не установлена — управление недоступно")}async _pickPlanFile(t){const e=t.target,i=e.files?.[0];if(!i||!this._spaceDialog)return;const s={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[i.type]||(i.name.toLowerCase().endsWith(".svg")?"svg":"");if(!s)return void this._showToast("Поддерживаются SVG, PNG, JPG, WebP");const a=new Uint8Array(await i.arrayBuffer());let o="";for(let t=0;t{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:1.414),e.onerror=()=>t(1.414),e.src=r});URL.revokeObjectURL(r),this._spaceDialog={...this._spaceDialog,planFile:{ext:s,b64:n,aspect:l,name:i.name}}}async _saveSpaceDialog(){const t=this._spaceDialog;if(!t||t.busy||!t.title.trim())return;if(!t.planFile&&!t.planUrl)return void this._showToast("Загрузите подложку — план этажа обязателен");const e="create"===t.mode&&0===(this._serverCfg?.spaces.length||0);this._spaceDialog={...t,busy:!0};try{const i=this._serverCfg;let s;if("create"===t.mode?(s={id:"s"+Date.now().toString(36),title:t.title.trim(),plan_url:null,aspect:1.414,view_box:[0,0,1,1],rooms:[],segments:[]},i.spaces.push(s)):(s=i.spaces.find(e=>e.id===t.spaceId),s.title=t.title.trim()),t.planFile){const e=await this.hass.callWS({type:"houseplan/plan/set",space_id:s.id,ext:t.planFile.ext,data:t.planFile.b64});s.plan_url=e.url,s.aspect=t.planFile.aspect}await this._saveConfigNow(),this._spaceDialog=null,"create"===t.mode&&(this._space=s.id),this._regSignature="",this._maybeRebuildDevices(),e?(this._markup=!0,this._tool="draw",this._path=[],this._cursorPt=null,this._showToast("Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.")):this._showToast("create"===t.mode?"Пространство добавлено":"Пространство сохранено")}catch(t){this._spaceDialog={...this._spaceDialog,busy:!1},this._showToast("Ошибка: "+(t?.message||t))}}async _deleteSpace(){const t=this._spaceDialog;if(!t||"edit"!==t.mode)return;const e=this._serverCfg.spaces.find(e=>e.id===t.spaceId);if(confirm(`Удалить пространство «${e.title}» со всеми комнатами и разметкой?`)){this._serverCfg.spaces=this._serverCfg.spaces.filter(e=>e.id!==t.spaceId);try{await this._saveConfigNow(),this._spaceDialog=null,this._space===t.spaceId&&(this._space=this._serverCfg.spaces[0]?.id||""),this._regSignature="",this._maybeRebuildDevices(),this._showToast("Пространство удалено")}catch(t){this._showToast("Ошибка удаления: "+(t?.message||t))}}}async _saveConfigNow(){const t=await this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev});this._cfgRev=t?.rev??this._cfgRev+1}render(){if(!this._config||!this.hass)return W;const t=this._model;if(!t.length)return L` -
-
${this._config.title||"План дома"}
-
-
- -

Пространств пока нет.

- ${this._serverStorage?L`

Добавьте первое пространство и загрузите план этажа.

- `:L`

Установите интеграцию House Plan и добавьте запись в «Устройства и службы».

`} -
- ${this._spaceDialog?this._renderSpaceDialog():W} - ${this._toast?L`
${this._toast}
`:W} -
`;const e=this._spaceModel(),i=e.vb,s=this._devices.filter(t=>t.space===e.id),a=this._config.icon_size??2.5,o=a>8?2.5:a,n=this._viewOr(i);return L` - -
-
-
- - ${this._config.title||"План дома"} -
-
- ${t.map(t=>L``)} - ${this._norm?L``:W} -
- ${s.length} устр. - -
- - - -
- ${this._norm?L``:W} - ${this._norm?L` - `:W} - -
- ${this._markup?this._renderMarkupBar():W} -
- -
this._markupClick(t)} - @wheel=${t=>this._onWheel(t)} - @pointerdown=${t=>this._stagePointerDown(t)} - @pointermove=${t=>this._stagePointerMove(t)} - @pointerup=${t=>this._stagePointerUp(t)} - @pointercancel=${t=>this._stagePointerUp(t)}> -
- - ${this._markup?this._renderMarkupDefs(i):W} - ${e.bg?j``:W} - ${e.rooms.filter(t=>t.area||this._markup).map(t=>{const i="room "+(e.bg?"overlay":"yard")+(this._markup?" outlined":""),s=e=>this._showTip(e,t.name,"комната — открыть зону",this._config?.show_signal?this._roomLqi(t.area):null),a=!e.bg||this._markup,o=this._roomCenter(t),n=t.poly?j`this._clickRoom(t)} @mousemove=${s} - @mouseleave=${()=>this._tip=null}>`:j`this._clickRoom(t)} @mousemove=${s} - @mouseleave=${()=>this._tip=null}>`;return j`${n}${a?j`${t.name}`:W}`})} - ${this._markup?this._renderMarkupLayer(i):W} - -
- ${s.map(t=>this._renderDevice(t,n))} -
-
- ${this._zoom>1?L`
${Math.round(100*this._zoom)}%
`:W} -
- - ${this._roomDialog?this._renderRoomDialog():W} - ${this._spaceDialog?this._renderSpaceDialog():W} - ${this._markerDialog?this._renderMarkerDialog():W} - ${this._infoCard?this._renderInfoCard():W} - ${this._tip?L`
- ${this._tip.title}${this._tip.meta?L`${this._tip.meta}`:W} - ${null!=this._tip.lqi?L`средний сигнал zigbee: - ${this._tip.lqi}`:W} -
`:W} - ${this._toast?L`
${this._toast}
`:W} -
- `}_renderDevice(t,e){const i=this._pos(t),s=(i.x-e.x)/e.w*100,a=(i.y-e.y)/e.h*100,o=this._stateClass(t),n=this._liveTemp(t),r=this._config?.show_signal&&!t.virtual?this._lqiFor(t.entities):null;return L`
this._clickDevice(e,t)} - @mousemove=${e=>this._showTip(e,t.name,t.model+(null!=n?" · "+n+"°":"")+(null!=r?" · LQI "+r:""))} - @mouseleave=${()=>this._tip=null} - @pointerdown=${e=>this._pointerDown(e,t)} - @pointermove=${e=>this._pointerMove(e,t)} - @pointerup=${e=>this._pointerUp(e,t)} - > - - ${null!=n?L`${n}°`:W} - ${null!=r?L`${r}`:W} -
`}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_renderMarkupDefs(t){const e=this._gridPitch,i=.14*e;return j` - - - - - - - `}_renderMarkupLayer(t){const e=this._segments,i=this._path,s=this._gridPitch;return j` - - ${e.map(t=>j``)} - ${i.length>1?j``:W} - ${i.length&&this._cursorPt&&"draw"===this._tool&&!this._contourClosed?j``:W} - ${i.map((t,e)=>j``)} - `}_renderMarkupBar(){return L`
- - - - - - ${"draw"===this._tool?L`${this._path.length?"точек: "+this._path.length+" · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой":"кликните точку сетки, чтобы начать контур"} - ${this._path.length?L``:W}`:W} -
`}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null;return L``}_renderMarkerDialog(){const t=this._markerDialog,e="virtual"===t.binding,i=this._bindingCandidates(),s=(()=>{if(e)return null;const s=i.find(e=>e.value===t.binding);if(s)return s.label;const[a,o]=t.binding.split(":");return"device"===a?this.hass.devices[o]?.name_by_user||this.hass.devices[o]?.name||o:this.hass.states[o]?.attributes?.friendly_name||o})();return L``}_renderSpaceDialog(){const t=this._spaceDialog;return L``}_renderRoomDialog(){const t=this._freeAreas;return L``}}At.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_markup:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},At.styles=((t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new a(s,t,i)})` + >`):B}_valueChanged(t){const e={...this._config,...t.detail.value},i=new Event("config-changed",{bubbles:!0,composed:!0});i.detail={config:e},this.dispatchEvent(i)}}At.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",At);const zt=((t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new a(s,t,i)})` :host { --hp-bg: var(--card-background-color, #16212e); --hp-line: var(--divider-color, #2b3d4f); @@ -1038,4 +727,315 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow z-index: 120; max-width: 90vw; } - `,customElements.get("houseplan-card")||customElements.define("houseplan-card",At),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.9.3 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `,Mt="houseplan_card_layout_v1",Dt="houseplan_card_cfg_v1",Pt="houseplan_card_zoom_v1",Et=1e3,Rt=(t,e,i)=>{const s=new Event(e,{bubbles:!0,composed:!0});s.detail=i??{},t.dispatchEvent(s)},Tt=(t,e)=>{let i;return(...s)=>{clearTimeout(i),i=window.setTimeout(()=>t(...s),e)}};class Ot extends rt{constructor(){super(...arguments),this._space="f1",this._layout={},this._serverStorage=!1,this._loadOk=!1,this._loading=!1,this._loadTries=0,this._serverCfg=null,this._cfgRev=0,this._unsubCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._tip=null,this._selId=null,this._toast="",this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._zoom=1,this._view=null,this._zoomBySpace={},this._pointers=new Map,this._panStart=null,this._pinchStart=null,this._suppressClick=!1,this._onboardingShown=!1,this._infoCard=null,this._markerDialog=null,this._spaceDialog=null,this._keyHandler=t=>this._onKey(t),this._drag=null,this._dirtyPos=new Set,this._persistLayout=Tt(()=>{if(this._serverStorage){const t=[...this._dirtyPos];this._dirtyPos.clear();for(const e of t){const t=this._layout[e];t&&this.hass.callWS({type:"houseplan/layout/update",device_id:e,pos:t}).catch(t=>this._showToast("Не удалось сохранить позицию: "+(t?.message||t)))}this._cacheSnapshot()}else localStorage.setItem(Mt,JSON.stringify(this._layout))},600),this._saveConfig=Tt(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev}).then(t=>{this._cfgRev=t?.rev??this._cfgRev+1}).catch(t=>{"conflict"===t?.code?(this._showToast("Конфиг изменён в другом окне — данные обновлены, повторите последнее действие"),this._cancelPath(),this._reloadConfigOnly()):this._showToast("Не удалось сохранить конфиг: "+(t?.message||t))})},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),this._roViewport?.disconnect(),this._roViewport=void 0,this._unsubCfg&&(this._unsubCfg(),this._unsubCfg=null),super.disconnectedCallback()}_onKey(t){if("Escape"===t.key){if(this._infoCard)return void(this._infoCard=null);if(this._markerDialog)return void(this._markerDialog=null)}if(!this._markup)return;return"Escape"===t.key||(t.ctrlKey||t.metaKey)&&"z"===t.key.toLowerCase()?this._roomDialog?(t.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(t.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const t=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),t&&this._removeSegmentByKey(t),this._path=this._path.slice(0,-1)}_removeSegmentByKey(t){const e=this._curSpaceCfg;if(!e?.segments)return;const i=this._segments.findIndex(e=>this._segKey([e[0],e[1]],[e[2],e[3]])===t);i>=0&&(e.segments.splice(i,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(t){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...t},t.default_floor&&(this._space=t.default_floor);try{this._zoomBySpace=JSON.parse(localStorage.getItem(Pt)||"{}")||{}}catch{this._zoomBySpace={}}try{const e=JSON.parse(localStorage.getItem(Dt)||"null");e&&e.config&&Array.isArray(e.config.spaces)&&(this._serverCfg=e.config,this._cfgRev=e.rev||0,this._layout=e.layout||{},this._serverStorage=!0,t.default_floor?this._space=t.default_floor:this._model.find(t=>t.id===this._space)||(this._space=this._model[0]?.id||this._space))}catch{}}_cacheSnapshot(){if(this._serverCfg)try{localStorage.setItem(Dt,JSON.stringify({config:this._serverCfg,rev:this._cfgRev,layout:this._layout}))}catch{}}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){return this._serverCfg?this._serverCfg.spaces.map(t=>{const e=Et/t.aspect;return{id:t.id,title:t.title,vb:[t.view_box[0]*Et,t.view_box[1]*e,t.view_box[2]*Et,t.view_box[3]*e],bg:t.plan_url?{href:t.plan_url,x:0,y:0,w:Et,h:e}:null,rooms:t.rooms.map(t=>({id:t.id,name:t.name,area:t.area??null,x:null!=t.x?t.x*Et:void 0,y:null!=t.y?t.y*e:void 0,w:null!=t.w?t.w*Et:void 0,h:null!=t.h?t.h*e:void 0,poly:t.poly?t.poly.map(t=>[t[0]*Et,t[1]*e]):void 0}))}}):[]}_spaceModel(t){const e=this._model;return e.find(e=>e.id===(t??this._space))||e[0]}get _areaToSpace(){const t={};for(const e of this._model)for(const i of e.rooms)i.area&&(t[i.area]={space:e.id,room:i});return t}get _settings(){return this._serverCfg?.settings||{}}get _showAll(){return!!this._settings.show_all}_toggleShowAll(){this._serverCfg&&(this._serverCfg={...this._serverCfg,settings:{...this._serverCfg.settings,show_all:!this._showAll}},this._regSignature="",this._maybeRebuildDevices(),this._saveConfig(),this.requestUpdate())}get _excluded(){const t=this._settings.exclude_integrations;return t?new Set(t):ct}willUpdate(t){t.has("hass")&&this.hass&&(!this._loadOk&&!this._loading&&this._loadTries<8&&this._loadFromServer(),this._maybeRebuildDevices())}updated(){const t=this._stageEl;t&&!this._roViewport&&(this._roViewport=new ResizeObserver(()=>this._refitView()),this._roViewport.observe(t)),t&&!this._view&&this._refitView(),this._serverStorage&&this._loadOk&&0===this._model.length&&!this._spaceDialog&&!this._onboardingShown&&(this._onboardingShown=!0,this._openSpaceDialog("create"))}async _loadFromServer(){this._loading=!0,this._loadTries++;try{const[t,e]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._loadOk=!0,this._serverStorage=!0;const i=t?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._cfgRev=t?.rev||0,this._layout=e?.layout||{},this._unsubCfg||(this._unsubCfg=await this.hass.connection.subscribeEvents(t=>{(t?.data?.rev??-1)!==this._cfgRev&&this._reloadConfigOnly()},"houseplan_config_updated")),this._norm&&!this._model.find(t=>t.id===this._space)&&(this._space=this._model[0]?.id||this._space),this._cacheSnapshot(),this._restoreZoom()}catch(t){if(this._loadTries>=8){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(Mt)||"{}")||{}}catch{this._layout={}}}}finally{this._loading=!1}this._regSignature="",this.requestUpdate()}async _reloadConfigOnly(){try{const t=await this.hass.callWS({type:"houseplan/config/get"}),e=t?.config;this._serverCfg=e&&Array.isArray(e.spaces)?e:null,this._cfgRev=t?.rev||0,this._cacheSnapshot(),this._regSignature="",this._maybeRebuildDevices(),this.requestUpdate()}catch{}}_maybeRebuildDevices(){const t=this.hass;if(!t?.devices||!t?.entities||!t?.areas)return;const e=Object.keys(t.devices).length+":"+Object.keys(t.entities).length+":"+Object.keys(t.areas).length+":"+(this._norm?"n":"l");e===this._regSignature&&this._devices.length||(this._regSignature=e,this._devices=St({hass:t,areaToSpace:Object.fromEntries(Object.entries(this._areaToSpace).map(([t,e])=>[t,e.space])),markers:this._markers,settings:this._settings,excluded:this._excluded,showAll:this._showAll,firstSpaceId:this._model[0]?.id||""}),this._defPos=this._defaultPositions())}get _markers(){return this._serverCfg?.markers||[]}_roomLqi(t){if(!t)return null;const e=[];for(const i of this._devices){if(i.area!==t||i.virtual)continue;const s=$t(this.hass,i.entities);null!=s&&e.push(s)}return gt(e)}_roomBounds(t){if(t.poly&&t.poly.length){const e=t.poly.map(t=>t[0]),i=t.poly.map(t=>t[1]),s=Math.min(...e),a=Math.min(...i);return{x:s,y:a,w:Math.max(...e)-s,h:Math.max(...i)-a}}return{x:t.x??0,y:t.y??0,w:t.w??0,h:t.h??0}}_defaultPositions(){const t={},e=(this._config?.icon_size??2.5)/100*Et*1.3;for(const i of this._model)for(const s of i.rooms){if(!s.area)continue;const a=this._devices.filter(t=>t.area===s.area&&t.space===i.id);if(!a.length)continue;const o=this._roomBounds(s),n=.1*Math.min(o.w,o.h),r=o.w-2*n,l=o.h-2*n,c=Math.max(1,Math.round(Math.sqrt(a.length*r/Math.max(l,1)))),h=Math.ceil(a.length/c),d=r/c,p=l/Math.max(h,1),u=a.map((t,e)=>({x:o.x+n+d*(e%c+.5),y:o.y+n+p*(Math.floor(e/c)+.5)}));ft(u,o,e,.5*n),a.forEach((e,i)=>t[e.id]=u[i])}return t}_pos(t){const e=this._spaceModel(t.space),i=this._layout[t.id];if(i)if(this._norm){if(i.s===t.space){const e=this._serverCfg.spaces.find(e=>e.id===t.space)?.aspect||1;return{x:i.x*Et,y:i.y*(Et/e)}}}else if(void 0===i.s)return{x:i.x,y:i.y};if(this._defPos[t.id])return this._defPos[t.id];const s=e.vb;return{x:s[0]+s[2]/2,y:s[1]+s[3]/2}}_savePos(t,e,i){if(this._norm){const s=this._gridPitch,a=Math.round(e/s)*s,o=Math.round(i/s)*s,n=this._serverCfg.spaces.find(e=>e.id===t.space)?.aspect||1;this._layout={...this._layout,[t.id]:{s:t.space,x:a/Et,y:o/(Et/n)}}}else this._layout={...this._layout,[t.id]:{x:Math.round(e),y:Math.round(i)}};this._dirtyPos.add(t.id),this._persistLayout()}_stateClass(t){if(!this._config?.live_states)return"";const e=t.primary?this.hass.states[t.primary]:void 0;if(!e)return"";if("unavailable"===e.state)return"unavail";const i=e.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(i))return"on"===e.state?"on":"";if("cover"===i||"valve"===i)return["open","opening"].includes(e.state)?"open":"";if("lock"===i)return["unlocked","open"].includes(e.state)?"open":"";if("binary_sensor"===i){const t=e.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(t))return"on"===e.state?"open":""}return"media_player"===i?["playing","on"].includes(e.state)?"on":"":"vacuum"===i&&["cleaning","returning"].includes(e.state)?"on":""}_liveTemp(t){return this._config?.show_temperature?"mdi:thermometer"!==t.icon&&"mdi:air-filter"!==t.icon?null:wt(this.hass,t.entities):null}_openMoreInfo(t){t?Rt(this,"hass-more-info",{entityId:t}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(t,e){t.stopPropagation(),this._drag?.moved||this._suppressClick||this._markup||(this._infoCard=e)}get _stageEl(){return this.renderRoot.querySelector(".stage")}_stageAspect(){const t=this._stageEl,e=this._spaceModel().vb;return t&&t.clientHeight?t.clientWidth/t.clientHeight:e[2]/e[3]}_viewOr(t){return this._view&&this._view.w?this._view:mt(t,this._stageAspect())}_screenToVb(t,e){const i=this._stageEl,s=this._viewOr(this._spaceModel().vb),a=i?.clientWidth||1,o=i?.clientHeight||1;return[s.x+t/a*s.w,s.y+e/o*s.h]}_clampView(t,e){return{w:t.w,h:t.h,x:Math.max(e.x,Math.min(e.x+e.w-t.w,t.x)),y:Math.max(e.y,Math.min(e.y+e.h-t.h,t.y))}}_applyView(t,e,i){const s=this._spaceModel().vb,a=mt(s,this._stageAspect()),o=Math.min(8,Math.max(1,t)),n=a.w/o,r=a.h/o,l=this._viewOr(s),c=e??l.x+l.w/2,h=i??l.y+l.h/2;this._zoom=o,this._view=this._clampView({x:c-n/2,y:h-r/2,w:n,h:r},a)}_refitView(){if(!this._stageEl)return;const t=this._view;this._applyView(this._zoom,t?t.x+t.w/2:void 0,t?t.y+t.h/2:void 0),this.requestUpdate()}_zoomAt(t,e,i){const s=this._stageEl;if(!s)return;const a=mt(this._spaceModel().vb,this._stageAspect()),o=Math.min(8,Math.max(1,i)),n=s.clientWidth,r=s.clientHeight,l=this._screenToVb(t,e),c=a.w/o,h=a.h/o;this._zoom=o,this._view=this._clampView({x:l[0]-t/n*c,y:l[1]-e/r*h,w:c,h:h},a)}_onWheel(t){const e=this._stageEl;if(!e)return;t.preventDefault();const i=e.getBoundingClientRect(),s=t.deltaY<0?1.15:1/1.15;this._zoomAt(t.clientX-i.left,t.clientY-i.top,this._zoom*s),this._saveZoom()}_stepZoom(t){const e=this._stageEl;e&&(this._zoomAt(e.clientWidth/2,e.clientHeight/2,this._zoom*(t>0?1.4:1/1.4)),this._saveZoom())}_resetZoom(){const t=this._spaceModel().vb;this._zoom=1,this._view=mt(t,this._stageAspect()),this._saveZoom()}_saveZoom(){this._zoomBySpace={...this._zoomBySpace,[this._space]:this._zoom};try{localStorage.setItem(Pt,JSON.stringify(this._zoomBySpace))}catch{}}_restoreZoom(){const t=this._zoomBySpace[this._space]||1;this._zoom=t,this._view=null,requestAnimationFrame(()=>{if(!this._stageEl)return;const e=this._spaceModel().vb;this._applyView(t,e[0]+e[2]/2,e[1]+e[3]/2),this.requestUpdate()})}_stagePointerDown(t){if(this._drag||this._markup)return;if(t.target.closest(".dev"))return;this._pointers.set(t.pointerId,{x:t.clientX,y:t.clientY});const e=this._viewOr(this._spaceModel().vb);if(1===this._pointers.size)this._panStart={sx:t.clientX,sy:t.clientY,vx:e.x,vy:e.y},this._suppressClick=!1;else if(2===this._pointers.size){const t=[...this._pointers.values()],e=Math.hypot(t[0].x-t[1].x,t[0].y-t[1].y);this._pinchStart={dist:e,zoom:this._zoom},this._panStart=null}}_stagePointerMove(t){if(this._pointers.has(t.pointerId)){if(this._pointers.set(t.pointerId,{x:t.clientX,y:t.clientY}),this._pinchStart&&this._pointers.size>=2){const t=[...this._pointers.values()],e=Math.hypot(t[0].x-t[1].x,t[0].y-t[1].y)/(this._pinchStart.dist||1),i=this._stageEl.getBoundingClientRect(),s=(t[0].x+t[1].x)/2-i.left,a=(t[0].y+t[1].y)/2-i.top;this._zoomAt(s,a,this._pinchStart.zoom*e),this._suppressClick=!0,this._saveZoom()}else if(this._panStart){const e=t.clientX-this._panStart.sx,i=t.clientY-this._panStart.sy;if(Math.abs(e)+Math.abs(i)>4&&(this._suppressClick=!0),this._zoom>1&&this._view){const t=this._stageEl,s=this._view,a=mt(this._spaceModel().vb,this._stageAspect());this._view=this._clampView({x:this._panStart.vx-e/t.clientWidth*s.w,y:this._panStart.vy-i/t.clientHeight*s.h,w:s.w,h:s.h},a)}}}else this._markupMove(t)}_stagePointerUp(t){this._pointers.delete(t.pointerId),this._pointers.size<2&&(this._pinchStart=null),0===this._pointers.size&&(this._panStart=null,setTimeout(()=>this._suppressClick=!1,0))}_clickRoom(t){var e;!this._suppressClick&&t.area&&(e="/config/areas/area/"+t.area,history.pushState(null,"",e),Rt(window,"location-changed",{replace:!1}))}_pointerDown(t,e){if(this._markup)return;t.preventDefault();const i=this._pos(e);this._drag={id:e.id,sx:t.clientX,sy:t.clientY,ox:i.x,oy:i.y,moved:!1},t.target.setPointerCapture(t.pointerId),this._tip=null}_pointerMove(t,e){if(!this._drag||this._drag.id!==e.id)return;const i=this.renderRoot.querySelector(".stage");if(!i)return;const s=this._spaceModel().vb,a=i.getBoundingClientRect(),o=this._viewOr(s),n=(t.clientX-this._drag.sx)/a.width*o.w,r=(t.clientY-this._drag.sy)/a.height*o.h;Math.abs(t.clientX-this._drag.sx)+Math.abs(t.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const l=.008*Math.min(s[2],s[3]),c=Math.max(s[0]+l,Math.min(s[0]+s[2]-l,this._drag.ox+n)),h=Math.max(s[1]+l,Math.min(s[1]+s[3]-l,this._drag.oy+r));this._savePos(e,c,h)}_pointerUp(t,e){if(!this._drag||this._drag.id!==e.id)return;const i=this._drag.moved;this._drag=i?this._drag:null,i&&(this._selId=e.id,window.setTimeout(()=>this._drag=null,0))}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(t){this._toast=t,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(t,e,i,s){this._drag||(this._tip={x:t.clientX,y:t.clientY,title:e,meta:i,lqi:s})}get _gridPitch(){return Et/240}get _curSpaceCfg(){return this._serverCfg?.spaces.find(t=>t.id===this._space)}get _spaceH(){const t=this._curSpaceCfg;return t?Et/t.aspect:Et}get _segments(){const t=this._curSpaceCfg,e=this._spaceH;return(t?.segments||[]).map(t=>[t[0]*Et,t[1]*e,t[2]*Et,t[3]*e])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(t){const e=this.renderRoot.querySelector(".stage").getBoundingClientRect();return this._screenToVb(t.clientX-e.left,t.clientY-e.top)}_snap(t){const e=this._gridPitch;return[_t(t[0],e),_t(t[1],e)]}_samePt(t,e){return function(t,e,i=.001){return Math.abs(t[0]-e[0])this._segKey([t[0],t[1]],[t[2],t[3]])===a);return!o&&(i.segments=i.segments||[],i.segments.push([t[0]/Et,t[1]/s,e[0]/Et,e[1]/s]),this._saveConfig(),!0)}_distToSeg(t,e){const[i,s]=t,[a,o,n,r]=e,l=n-a,c=r-o;let h=((i-a)*l+(s-o)*c)/(l*l+c*c||1);h=Math.max(0,Math.min(1,h));const d=a+h*l,p=o+h*c;return Math.hypot(i-d,s-p)}_pointInRoom(t,e){return e.poly?function(t,e){let i=!1;for(let s=0,a=e.length-1;st[1]!=l>t[1]&&t[0]<(r-o)*(t[1]-n)/(l-n)+o&&(i=!i)}return i}(t,e.poly):null!=e.x&&t[0]>=e.x&&t[0]<=e.x+e.w&&t[1]>=e.y&&t[1]<=e.y+e.h}_markupClick(t){if(!this._markup)return;const e=this._svgPoint(t);if("erase"===this._tool){const t=this._curSpaceCfg;if(!t?.segments?.length)return;const i=this._segments;let s=-1,a=.5*this._gridPitch;return i.forEach((t,i)=>{const o=this._distToSeg(e,t);o=0&&(t.segments.splice(s,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const t=[...this._spaceModel().rooms].reverse().find(t=>this._pointInRoom(e,t));if(!t)return;if(!confirm(`Удалить комнату «${t.name}»?`))return;const i=this._curSpaceCfg;return i.rooms=i.rooms.filter(e=>e.id!==t.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const i=this._snap(e);if(!this._path.length)return this._path=[i],void(this._pathSegs=[]);const s=this._path[this._path.length-1];if(this._samePt(i,s))return;const a=this._addSegment(s,i);this._pathSegs=[...this._pathSegs,a?this._segKey(s,i):null],this._path=[...this._path,i],this._path.length>=4&&this._samePt(i,this._path[0])&&(this._cursorPt=null,this._nameSel="",this._areaSel="",this._roomDialog=!0)}get _contourClosed(){return this._path.length>=4&&this._samePt(this._path[0],this._path[this._path.length-1])}_markupMove(t){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(t)))}_saveRoom(){this._areaSel&&this._commitRoom()}_saveRoomNoArea(){this._nameSel.trim()&&(this._areaSel="",this._commitRoom())}_commitRoom(){if(!this._contourClosed)return;const t=this._curSpaceCfg;if(!t)return;const e=this._spaceH,i=this._path.slice(0,-1),s=this._areaSel?this.hass.areas[this._areaSel]?.name:"";t.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||s||"Комната",area:this._areaSel||null,poly:i.map(t=>[t[0]/Et,t[1]/e])}),this._saveConfig(),this._path=[],this._pathSegs=[];const a=this._areaSel;this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices();let o=0;if(a){const t=this._serverCfg?.spaces.find(t=>t.id===this._space)?.aspect||1,e=Et/t,i={...this._layout};for(const t of this._devices){if(t.area!==a||t.space!==this._space)continue;if(o++,this._layout[t.id])continue;const s=this._defPos[t.id];s&&(i[t.id]={s:this._space,x:s.x/Et,y:s.y/e},this._dirtyPos.add(t.id))}this._layout=i,this._persistLayout()}const n=this._model.find(t=>t.id===this._space)?.rooms.length||0;this._showToast(a?`Комната сохранена (${n}). Устройств добавлено: ${o}. Обведите следующую или выйдите из разметки.`:`Комната сохранена (${n}, без зоны). Обведите следующую или выйдите из разметки.`)}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const t=new Set;for(const e of this._serverCfg?.spaces||[])for(const i of e.rooms||[])i.area&&t.add(i.area);return Object.values(this.hass?.areas||{}).filter(e=>!t.has(e.area_id)).sort((t,e)=>(t.name||"").localeCompare(e.name||""))}_openMarkerDialog(t){this._norm?this._markerDialog=t?{devId:t.id,name:t.name,binding:"virtual"===t.bindingKind?"virtual":t.bindingKind+":"+t.bindingRef,bindingFilter:"",icon:t.marker?.icon||"",model:t.model||"",link:t.link||"",description:t.description||"",pdfs:[...t.pdfs||[]],room:t.space&&t.area?t.space+"#"+t.area:"",busy:!1}:{name:"",binding:"virtual",bindingFilter:"",icon:"",model:"",link:"",description:"",pdfs:[],room:"",busy:!1}:this._showToast("Редактирование устройств доступно после переноса конфига на сервер")}_bindingCandidates(){const t=this.hass,e=new Set;for(const t of this._devices)t.id!==this._markerDialog?.devId&&("device"===t.bindingKind&&t.bindingRef&&e.add("device:"+t.bindingRef),"entity"===t.bindingKind&&t.bindingRef&&e.add("entity:"+t.bindingRef));const i=new Set;for(const t of this._devices)"device"===t.bindingKind&&t.name&&i.add(t.name.trim()+"|"+(t.area||""));const s=[];for(const a of Object.values(t.devices)){if("service"===a.entry_type)continue;const t="device:"+a.id;if(e.has(t))continue;const o=(a.name_by_user||a.name||a.id).trim();t!==this._markerDialog?.binding&&i.has(o+"|"+(a.area_id||""))||s.push({value:t,label:o,sub:(a.model||"устройство")+("Group"===a.model?" · Z2M-группа":"")})}const a=new Set(["group","template","derivative","min_max","threshold","integration","statistics","trend","utility_meter","tod","switch_as_x","schedule"]);for(const[i,o]of Object.entries(t.entities)){const n="entity:"+i;if(e.has(n))continue;const r=a.has(o.platform),l="group"===o.platform;if(!r&&!l)continue;if(o.hidden)continue;const c=t.states[i];s.push({value:n,label:o.name||c?.attributes?.friendly_name||i,sub:i.split(".")[0]+" · "+("group"===o.platform?"группа":"хелпер")})}const o=(this._markerDialog?.bindingFilter||"").toLowerCase().trim(),n=o?s.filter(t=>(t.label+" "+t.sub+" "+t.value).toLowerCase().includes(o)):s;return n.sort((t,e)=>t.label.localeCompare(e.label)),n.slice(0,200)}_allRoomsFlat(){const t=[];for(const e of this._serverCfg?.spaces||[])for(const i of e.rooms||[])i.area&&t.push({value:e.id+"#"+i.area,label:(e.title||e.id)+" · "+i.name});return t}_errText(t){if(!t)return"неизвестная ошибка";if("string"==typeof t)return t;if(t.message)return t.message;if(t.error)return t.error;if(null!=t.code)return"код "+t.code;try{return JSON.stringify(t)}catch{return String(t)}}async _pickMarkerFiles(t){const e=t.target,i=e.files?[...e.files]:[];if(e.value="",!i.length||!this._markerDialog)return;const s=this._markerDialog.devId||"new",a=[];for(const t of i)try{const e=new FormData;e.append("marker_id",s),e.append("file",t,t.name);const i=this.hass?.fetchWithAuth?await this.hass.fetchWithAuth("/api/houseplan/upload",{method:"POST",body:e}):await fetch("/api/houseplan/upload",{method:"POST",body:e,headers:this.hass?.auth?.data?.access_token?{authorization:`Bearer ${this.hass.auth.data.access_token}`}:{}}),o=await i.json().catch(()=>({}));if(!i.ok||o.error){const t={too_large:"файл больше "+(o.max_mb||25)+" МБ",bad_ext:"недопустимый тип (нужен PDF/изображение)",unauthorized:"нужны права администратора"};throw new Error(t[o.error]||o.error||"HTTP "+i.status)}a.push({name:o.name||t.name,url:o.url})}catch(e){this._showToast("Файл «"+t.name+"» не загружен: "+this._errText(e))}a.length&&this._markerDialog&&(this._markerDialog={...this._markerDialog,pdfs:[...this._markerDialog.pdfs,...a]},this._showToast("Прикреплено файлов: "+a.length))}_removeMarkerPdf(t){this._markerDialog&&(this._markerDialog={...this._markerDialog,pdfs:this._markerDialog.pdfs.filter(e=>e.url!==t)})}async _saveMarker(){const t=this._markerDialog;if(t&&!t.busy)if("virtual"!==t.binding||t.name.trim()){this._markerDialog={...t,busy:!0};try{const e=this._serverCfg;let i;e.markers=e.markers||[];const[s,a]=t.room?t.room.split("#"):["",""];let o=s||null,n=a||null;"virtual"!==t.binding||o||(o=this._space),i=function(t,e,i){const[s,a]=t.split(":");return"device"===s?a:"entity"===s?"lg_"+a:e&&e.startsWith("v_")?e:i()}(t.binding,t.devId,()=>"v_"+Date.now().toString(36));const r=t.devId,l={id:i,binding:t.binding,name:t.name.trim()||null,icon:t.icon||null,model:t.model.trim()||null,link:t.link.trim()||null,description:t.description.trim()||null,pdfs:t.pdfs};("virtual"===t.binding||t.room)&&(l.space=o,l.area=n);const c=r?this._devices.find(t=>t.id===r):null,h=!!t.room&&null!=c&&(c.space!==o||c.area!==n);e.markers=e.markers.filter(t=>t.id!==i&&t.id!==r),e.markers.push(l);let d=null;if(!this._layout[i]||h){const t=this._spaceModel(o||void 0);let e=t.vb[0]+t.vb[2]/2,s=t.vb[1]+t.vb[3]/2;if(n){const i=t.rooms.find(t=>t.area===n);i&&([e,s]=this._roomCenter(i))}d=this._normPos(o||this._space,e,s),this._layout={...this._layout,[i]:d}}await this._saveConfigNow(),d&&await this.hass.callWS({type:"houseplan/layout/update",device_id:i,pos:d}),r&&r!==i&&(delete this._layout[r],await this.hass.callWS({type:"houseplan/layout/delete",device_id:r}).catch(()=>{})),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство сохранено")}catch(t){this._markerDialog={...this._markerDialog,busy:!1},this._showToast("Ошибка: "+this._errText(t))}}else this._showToast("Укажите имя виртуального устройства")}async _deleteMarker(){const t=this._markerDialog;if(!t)return;const e=t.devId?this._devices.find(e=>e.id===t.devId):null,i=t.name||"устройство";if(!confirm(`Убрать «${i}» с плана?`))return;const s=this._serverCfg;s.markers=s.markers||[],e&&"virtual"===e.bindingKind?s.markers=s.markers.filter(t=>t.id!==e.id):e&&e.marker?(s.markers=s.markers.filter(t=>t.id!==e.id),"device"===e.bindingKind&&e.bindingRef?s.markers.push({id:e.id,binding:"device:"+e.bindingRef,hidden:!0}):"entity"===e.bindingKind&&e.bindingRef&&s.markers.push({id:e.id,binding:"entity:"+e.bindingRef,hidden:!0})):e&&"device"===e.bindingKind&&e.bindingRef?s.markers.push({id:e.id,binding:"device:"+e.bindingRef,hidden:!0}):e&&"entity"===e.bindingKind&&e.bindingRef&&s.markers.push({id:e.id,binding:"entity:"+e.bindingRef,hidden:!0});try{await this._saveConfigNow(),e&&"virtual"===e.bindingKind&&this._layout[e.id]&&(delete this._layout[e.id],await this.hass.callWS({type:"houseplan/layout/delete",device_id:e.id}).catch(()=>{})),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство убрано с плана")}catch(t){this._showToast("Ошибка: "+this._errText(t))}}_normPos(t,e,i){const s=this._serverCfg.spaces.find(e=>e.id===t)?.aspect||1;return{s:t,x:e/Et,y:i/(Et/s)}}_openSpaceDialog(t,e){if(this._serverStorage&&this._serverCfg)if("edit"===t){const i=this._serverCfg.spaces.find(t=>t.id===e);if(!i)return;this._spaceDialog={mode:t,spaceId:e,title:i.title,planUrl:i.plan_url||null,planFile:null,busy:!1}}else this._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,busy:!1};else this._showToast("Интеграция House Plan не установлена — управление недоступно")}async _pickPlanFile(t){const e=t.target,i=e.files?.[0];if(!i||!this._spaceDialog)return;const s={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[i.type]||(i.name.toLowerCase().endsWith(".svg")?"svg":"");if(!s)return void this._showToast("Поддерживаются SVG, PNG, JPG, WebP");const a=new Uint8Array(await i.arrayBuffer());let o="";for(let t=0;t{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:1.414),e.onerror=()=>t(1.414),e.src=r});URL.revokeObjectURL(r),this._spaceDialog={...this._spaceDialog,planFile:{ext:s,b64:n,aspect:l,name:i.name}}}async _saveSpaceDialog(){const t=this._spaceDialog;if(!t||t.busy||!t.title.trim())return;if(!t.planFile&&!t.planUrl)return void this._showToast("Загрузите подложку — план этажа обязателен");const e="create"===t.mode&&0===(this._serverCfg?.spaces.length||0);this._spaceDialog={...t,busy:!0};try{const i=this._serverCfg;let s;if("create"===t.mode?(s={id:"s"+Date.now().toString(36),title:t.title.trim(),plan_url:null,aspect:1.414,view_box:[0,0,1,1],rooms:[],segments:[]},i.spaces.push(s)):(s=i.spaces.find(e=>e.id===t.spaceId),s.title=t.title.trim()),t.planFile){const e=await this.hass.callWS({type:"houseplan/plan/set",space_id:s.id,ext:t.planFile.ext,data:t.planFile.b64});s.plan_url=e.url,s.aspect=t.planFile.aspect}await this._saveConfigNow(),this._spaceDialog=null,"create"===t.mode&&(this._space=s.id),this._regSignature="",this._maybeRebuildDevices(),e?(this._markup=!0,this._tool="draw",this._path=[],this._cursorPt=null,this._showToast("Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.")):this._showToast("create"===t.mode?"Пространство добавлено":"Пространство сохранено")}catch(t){this._spaceDialog={...this._spaceDialog,busy:!1},this._showToast("Ошибка: "+this._errText(t))}}async _deleteSpace(){const t=this._spaceDialog;if(!t||"edit"!==t.mode)return;const e=this._serverCfg.spaces.find(e=>e.id===t.spaceId);if(confirm(`Удалить пространство «${e.title}» со всеми комнатами и разметкой?`)){this._serverCfg.spaces=this._serverCfg.spaces.filter(e=>e.id!==t.spaceId);try{await this._saveConfigNow(),this._spaceDialog=null,this._space===t.spaceId&&(this._space=this._serverCfg.spaces[0]?.id||""),this._regSignature="",this._maybeRebuildDevices(),this._showToast("Пространство удалено")}catch(t){this._showToast("Ошибка удаления: "+this._errText(t))}}}async _saveConfigNow(){const t=await this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev});this._cfgRev=t?.rev??this._cfgRev+1}render(){if(!this._config||!this.hass)return B;const t=this._model;if(!t.length)return j` +
+
${this._config.title||"План дома"}
+
+
+ +

Пространств пока нет.

+ ${this._serverStorage?j`

Добавьте первое пространство и загрузите план этажа.

+ `:j`

Установите интеграцию House Plan и добавьте запись в «Устройства и службы».

`} +
+ ${this._spaceDialog?this._renderSpaceDialog():B} + ${this._toast?j`
${this._toast}
`:B} +
`;const e=this._spaceModel(),i=e.vb,s=this._devices.filter(t=>t.space===e.id),a=this._config.icon_size??2.5,o=a>8?2.5:a,n=this._viewOr(i);return j` + +
+
+
+ + ${this._config.title||"План дома"} +
+
+ ${t.map(t=>j``)} + ${this._norm?j``:B} +
+ ${s.length} устр. + +
+ + + +
+ ${this._norm?j``:B} + ${this._norm?j` + `:B} + +
+ ${this._markup?this._renderMarkupBar():B} +
+ +
this._markupClick(t)} + @wheel=${t=>this._onWheel(t)} + @pointerdown=${t=>this._stagePointerDown(t)} + @pointermove=${t=>this._stagePointerMove(t)} + @pointerup=${t=>this._stagePointerUp(t)} + @pointercancel=${t=>this._stagePointerUp(t)}> +
+ + ${this._markup?this._renderMarkupDefs(i):B} + ${e.bg?F``:B} + ${e.rooms.filter(t=>t.area||this._markup).map(t=>{const i="room "+(e.bg?"overlay":"yard")+(this._markup?" outlined":""),s=e=>this._showTip(e,t.name,"комната — открыть зону",this._config?.show_signal?this._roomLqi(t.area):null),a=!e.bg||this._markup,o=this._roomCenter(t),n=t.poly?F`this._clickRoom(t)} @mousemove=${s} + @mouseleave=${()=>this._tip=null}>`:F`this._clickRoom(t)} @mousemove=${s} + @mouseleave=${()=>this._tip=null}>`;return F`${n}${a?F`${t.name}`:B}`})} + ${this._markup?this._renderMarkupLayer(i):B} + +
+ ${s.map(t=>this._renderDevice(t,n))} +
+
+ ${this._zoom>1?j`
${Math.round(100*this._zoom)}%
`:B} +
+ + ${this._roomDialog?this._renderRoomDialog():B} + ${this._spaceDialog?this._renderSpaceDialog():B} + ${this._markerDialog?this._renderMarkerDialog():B} + ${this._infoCard?this._renderInfoCard():B} + ${this._tip?j`
+ ${this._tip.title}${this._tip.meta?j`${this._tip.meta}`:B} + ${null!=this._tip.lqi?j`средний сигнал zigbee: + ${this._tip.lqi}`:B} +
`:B} + ${this._toast?j`
${this._toast}
`:B} +
+ `}_renderDevice(t,e){const i=this._pos(t),s=(i.x-e.x)/e.w*100,a=(i.y-e.y)/e.h*100,o=this._stateClass(t),n=this._liveTemp(t),r=this._config?.show_signal&&!t.virtual?$t(this.hass,t.entities):null;return j`
this._clickDevice(e,t)} + @mousemove=${e=>this._showTip(e,t.name,t.model+(null!=n?" · "+n+"°":"")+(null!=r?" · LQI "+r:""))} + @mouseleave=${()=>this._tip=null} + @pointerdown=${e=>this._pointerDown(e,t)} + @pointermove=${e=>this._pointerMove(e,t)} + @pointerup=${e=>this._pointerUp(e,t)} + > + + ${null!=n?j`${n}°`:B} + ${null!=r?j`${r}`:B} +
`}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_renderMarkupDefs(t){const e=this._gridPitch,i=.14*e;return F` + + + + + + + `}_renderMarkupLayer(t){const e=this._segments,i=this._path,s=this._gridPitch;return F` + + ${e.map(t=>F``)} + ${i.length>1?F``:B} + ${i.length&&this._cursorPt&&"draw"===this._tool&&!this._contourClosed?F``:B} + ${i.map((t,e)=>F``)} + `}_renderMarkupBar(){return j`
+ + + + + + ${"draw"===this._tool?j`${this._path.length?"точек: "+this._path.length+" · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой":"кликните точку сетки, чтобы начать контур"} + ${this._path.length?j``:B}`:B} +
`}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null;return j``}_renderMarkerDialog(){const t=this._markerDialog,e="virtual"===t.binding,i=this._bindingCandidates(),s=(()=>{if(e)return null;const s=i.find(e=>e.value===t.binding);if(s)return s.label;const[a,o]=t.binding.split(":");return"device"===a?this.hass.devices[o]?.name_by_user||this.hass.devices[o]?.name||o:this.hass.states[o]?.attributes?.friendly_name||o})();return j``}_renderSpaceDialog(){const t=this._spaceDialog;return j``}_renderRoomDialog(){const t=this._freeAreas;return j``}}Ot.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_markup:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},Ot.styles=zt,customElements.get("houseplan-card")||customElements.define("houseplan-card",Ot),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.10.0 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/custom_components/houseplan/http_api.py b/custom_components/houseplan/http_api.py index 61a5ec0..8c469bd 100644 --- a/custom_components/houseplan/http_api.py +++ b/custom_components/houseplan/http_api.py @@ -11,6 +11,11 @@ from pathlib import Path from aiohttp import web from homeassistant.components.http import HomeAssistantView + +try: # KEY_HASS — современный доступ к hass из aiohttp-приложения + from homeassistant.components.http import KEY_HASS +except ImportError: # старые версии HA + KEY_HASS = "hass" # type: ignore[assignment] from homeassistant.core import HomeAssistant from .const import CONF_ADMIN_ONLY, DOMAIN, FILES_DIR, FILES_URL @@ -24,6 +29,8 @@ from .validation import ( _LOGGER = logging.getLogger(__name__) +_CHUNK = 64 * 1024 + class HouseplanUploadView(HomeAssistantView): """POST /api/houseplan/upload — сохранить файл маркера, вернуть URL.""" @@ -33,7 +40,7 @@ class HouseplanUploadView(HomeAssistantView): requires_auth = True async def post(self, request: web.Request) -> web.Response: - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] entry = hass.data.get(DOMAIN, {}).get("entry") admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False)) if admin_only: @@ -44,6 +51,7 @@ class HouseplanUploadView(HomeAssistantView): marker_id = "misc" filename: str | None = None blob: bytes | None = None + too_large = False try: reader = await request.multipart() async for part in reader: @@ -51,11 +59,26 @@ class HouseplanUploadView(HomeAssistantView): marker_id = sanitize_marker_id(await part.text()) elif part.name == "file": filename = part.filename or "file" - blob = await part.read(decode=False) + # читаем чанками с обрывом по лимиту, а не весь файл в память + chunks: list[bytes] = [] + size = 0 + while chunk := await part.read_chunk(_CHUNK): + size += len(chunk) + if size > MAX_FILE_BYTES: + too_large = True + break + chunks.append(chunk) + if too_large: + break + blob = b"".join(chunks) except Exception as err: # noqa: BLE001 _LOGGER.warning("House Plan upload: ошибка чтения multipart: %s", err) return web.json_response({"error": "bad_request"}, status=400) + if too_large: + return web.json_response( + {"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413 + ) if blob is None or not filename: return web.json_response({"error": "no_file"}, status=400) ext = file_ext(filename) @@ -63,21 +86,17 @@ class HouseplanUploadView(HomeAssistantView): return web.json_response( {"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400 ) - if len(blob) > MAX_FILE_BYTES: - return web.json_response( - {"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413 - ) safe_name = sanitize_filename(filename) target_dir = Path(hass.config.path(FILES_DIR)) / marker_id path = target_dir / safe_name - def _write() -> None: + def _write() -> int: target_dir.mkdir(parents=True, exist_ok=True) path.write_bytes(blob) + return int(path.stat().st_mtime) - await hass.async_add_executor_job(_write) - mtime = int(path.stat().st_mtime) + mtime = await hass.async_add_executor_job(_write) return web.json_response( {"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename} ) diff --git a/custom_components/houseplan/manifest.json b/custom_components/houseplan/manifest.json index 170ebb3..62714e6 100755 --- a/custom_components/houseplan/manifest.json +++ b/custom_components/houseplan/manifest.json @@ -15,5 +15,5 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "requirements": [], - "version": "1.9.3" + "version": "1.10.0" } \ No newline at end of file diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index 060ef48..a2e7fa6 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -1,6 +1,7 @@ """WS-команды House Plan: раскладка, конфигурация пространств, загрузка планов.""" from __future__ import annotations +import asyncio import base64 import binascii from pathlib import Path @@ -13,25 +14,24 @@ from homeassistant.core import HomeAssistant, callback from .const import ( CONF_ADMIN_ONLY, DEFAULT_CONFIG, DOMAIN, - FILES_DIR, FILES_URL, PLANS_DIR, PLANS_URL, + PLANS_DIR, PLANS_URL, ) from .validation import ( - CONFIG_SCHEMA, FILE_EXTENSIONS, LAYOUT_SCHEMA, MAX_FILE_BYTES, MAX_PLAN_BYTES, - PLAN_EXTENSIONS, POS_SCHEMA, file_ext, sanitize_filename, sanitize_marker_id, valid_space_id, + CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES, + PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id, ) - @callback def async_register(hass: HomeAssistant) -> None: """Регистрация WS-команд.""" websocket_api.async_register_command(hass, ws_layout_get) websocket_api.async_register_command(hass, ws_layout_set) websocket_api.async_register_command(hass, ws_layout_update) + websocket_api.async_register_command(hass, ws_layout_delete) websocket_api.async_register_command(hass, ws_config_get) websocket_api.async_register_command(hass, ws_config_set) websocket_api.async_register_command(hass, ws_plan_set) - websocket_api.async_register_command(hass, ws_file_set) def _store(hass: HomeAssistant): @@ -42,6 +42,15 @@ def _config_store(hass: HomeAssistant): return hass.data[DOMAIN]["config_store"] +def _write_lock(hass: HomeAssistant) -> asyncio.Lock: + """Единый лок на цикл load→modify→save обоих хранилищ. + + Без него параллельные WS-вызовы теряют изменения (last-writer-wins), + а проверка expected_rev неатомарна. + """ + return hass.data[DOMAIN].setdefault("write_lock", asyncio.Lock()) + + def _check_write(hass: HomeAssistant, connection) -> bool: entry = hass.data[DOMAIN].get("entry") admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False)) @@ -68,7 +77,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> if not _check_write(hass, connection): connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам") return - await _store(hass).async_save({"layout": msg["layout"]}) + async with _write_lock(hass): + await _store(hass).async_save({"layout": msg["layout"]}) connection.send_result(msg["id"], {"ok": True}) @@ -86,10 +96,33 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any]) connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам") return store = _store(hass) - data = await store.async_load() or {} - layout = data.get("layout", {}) - layout[msg["device_id"]] = msg["pos"] - await store.async_save({"layout": layout}) + async with _write_lock(hass): + data = await store.async_load() or {} + layout = data.get("layout", {}) + layout[msg["device_id"]] = msg["pos"] + await store.async_save({"layout": layout}) + connection.send_result(msg["id"], {"ok": True}) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "houseplan/layout/delete", + vol.Required("device_id"): str, + } +) +@websocket_api.async_response +async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: + """Удалить позицию одного устройства (чистка при удалении маркера).""" + if not _check_write(hass, connection): + connection.send_error(msg["id"], "unauthorized", "Правка раскладки разрешена только администраторам") + return + store = _store(hass) + async with _write_lock(hass): + data = await store.async_load() or {} + layout = data.get("layout", {}) + if msg["device_id"] in layout: + del layout[msg["device_id"]] + await store.async_save({"layout": layout}) connection.send_result(msg["id"], {"ok": True}) @@ -124,16 +157,17 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> connection.send_error(msg["id"], "unauthorized", "Правка конфигурации разрешена только администраторам") return store = _config_store(hass) - data = await store.async_load() or {} - current_rev = data.get("rev", 0) - if "expected_rev" in msg and msg["expected_rev"] != current_rev: - connection.send_error( - msg["id"], "conflict", - f"Конфигурация изменена в другом окне (rev {current_rev} != {msg['expected_rev']})", - ) - return - new_rev = current_rev + 1 - await store.async_save({"config": msg["config"], "rev": new_rev}) + async with _write_lock(hass): + data = await store.async_load() or {} + current_rev = data.get("rev", 0) + if "expected_rev" in msg and msg["expected_rev"] != current_rev: + connection.send_error( + msg["id"], "conflict", + f"Конфигурация изменена в другом окне (rev {current_rev} != {msg['expected_rev']})", + ) + return + new_rev = current_rev + 1 + await store.async_save({"config": msg["config"], "rev": new_rev}) hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev}) connection.send_result(msg["id"], {"ok": True, "rev": new_rev}) @@ -171,7 +205,7 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N plans_dir = Path(hass.config.path(PLANS_DIR)) path = plans_dir / f"{space_id}.{msg['ext']}" - def _write() -> None: + def _write() -> int: plans_dir.mkdir(parents=True, exist_ok=True) # убрать старые варианты с другим расширением for old_ext in PLAN_EXTENSIONS: @@ -179,52 +213,9 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N if old_ext != msg["ext"] and old.exists(): old.unlink() path.write_bytes(raw) + return int(path.stat().st_mtime) - await hass.async_add_executor_job(_write) - mtime = int(path.stat().st_mtime) + mtime = await hass.async_add_executor_job(_write) connection.send_result( msg["id"], {"ok": True, "url": f"{PLANS_URL}/{space_id}.{msg['ext']}?v={mtime}"} ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "houseplan/file/set", - vol.Required("marker_id"): str, - vol.Required("filename"): str, - vol.Required("data"): str, # base64 - } -) -@websocket_api.async_response -async def ws_file_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: - """Загрузить файл-инструкцию (PDF и т.п.) для маркера; вернуть URL.""" - if not _check_write(hass, connection): - connection.send_error(msg["id"], "unauthorized", "Загрузка файлов разрешена только администраторам") - return - marker_id = sanitize_marker_id(msg["marker_id"]) - raw_name = msg["filename"].rsplit("/", 1)[-1].rsplit("\\", 1)[-1] - ext = file_ext(raw_name) - if ext not in FILE_EXTENSIONS: - connection.send_error(msg["id"], "bad_ext", f"Разрешены: {', '.join(sorted(FILE_EXTENSIONS))}") - return - safe_name = sanitize_filename(raw_name) - try: - blob = base64.b64decode(msg["data"], validate=True) - except (binascii.Error, ValueError): - connection.send_error(msg["id"], "invalid_data", "data должен быть корректным base64") - return - if len(blob) > MAX_FILE_BYTES: - connection.send_error(msg["id"], "too_large", f"Файл больше {MAX_FILE_BYTES // 1024 // 1024} МБ") - return - target_dir = Path(hass.config.path(FILES_DIR)) / marker_id - path = target_dir / safe_name - - def _write() -> None: - target_dir.mkdir(parents=True, exist_ok=True) - path.write_bytes(blob) - - await hass.async_add_executor_job(_write) - mtime = int(path.stat().st_mtime) - connection.send_result( - msg["id"], {"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": raw_name} - ) diff --git a/dist/houseplan-card.js b/dist/houseplan-card.js index dc54475..5499d40 100755 --- a/dist/houseplan-card.js +++ b/dist/houseplan-card.js @@ -1,321 +1,10 @@ -const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let a=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const o=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new a("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:n,defineProperty:r,getOwnPropertyDescriptor:l,getOwnPropertyNames:h,getOwnPropertySymbols:c,getPrototypeOf:d}=Object,p=globalThis,_=p.trustedTypes,u=_?_.emptyScript:"",g=p.reactiveElementPolyfillSupport,m=(t,e)=>t,f={toAttribute(t,e){switch(e){case Boolean:t=t?u:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},v=(t,e)=>!n(t,e),b={attribute:!0,type:String,converter:f,reflect:!1,useDefault:!1,hasChanged:v};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let y=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&r(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:a}=l(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);a?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(m("elementProperties")))return;const t=d(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(m("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(m("properties"))){const t=this.properties,e=[...h(t),...c(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(o(t))}else void 0!==t&&e.push(o(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),a=t.litNonce;void 0!==a&&s.setAttribute("nonce",a),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const a=(void 0!==i.converter?.toAttribute?i.converter:f).toAttribute(e,i.type);this._$Em=t,null==a?this.removeAttribute(s):this.setAttribute(s,a),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),a="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:f;this._$Em=s;const o=a.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,a){if(void 0!==t){const o=this.constructor;if(!1===s&&(a=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??v)(a,e)||i.useDefault&&i.reflect&&a===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:a},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==a||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};y.elementStyles=[],y.shadowRootOptions={mode:"open"},y[m("elementProperties")]=new Map,y[m("finalized")]=new Map,g?.({ReactiveElement:y}),(p.reactiveElementVersions??=[]).push("2.1.2");const x=globalThis,$=t=>t,w=x.trustedTypes,k=w?w.createPolicy("lit-html",{createHTML:t=>t}):void 0,S="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,A="?"+C,z=`<${A}>`,D=document,M=()=>D.createComment(""),P=t=>null===t||"object"!=typeof t&&"function"!=typeof t,E=Array.isArray,R="[ \t\n\f\r]",T=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,O=/-->/g,U=/>/g,H=RegExp(`>|${R}(?:([^\\s"'>=/]+)(${R}*=${R}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),N=/'/g,I=/"/g,q=/^(?:script|style|textarea|title)$/i,F=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),L=F(1),j=F(2),B=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),K=new WeakMap,V=D.createTreeWalker(D,129);function Z(t,e){if(!E(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==k?k.createHTML(e):e}const Y=(t,e)=>{const i=t.length-1,s=[];let a,o=2===e?"":3===e?"":"",n=T;for(let e=0;e"===l[0]?(n=a??T,h=-1):void 0===l[1]?h=-2:(h=n.lastIndex-l[2].length,r=l[1],n=void 0===l[3]?H:'"'===l[3]?I:N):n===I||n===N?n=H:n===O||n===U?n=T:(n=H,a=void 0);const d=n===H&&t[e+1].startsWith("/>")?" ":"";o+=n===T?i+z:h>=0?(s.push(r),i.slice(0,h)+S+i.slice(h)+C+d):i+C+(-2===h?e:d)}return[Z(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class X{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let a=0,o=0;const n=t.length-1,r=this.parts,[l,h]=Y(t,e);if(this.el=X.createElement(l,i),V.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=V.nextNode())&&r.length0){s.textContent=w?w.emptyScript:"";for(let i=0;iE(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==W&&P(this._$AH)?this._$AA.nextSibling.data=t:this.T(D.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=X.createElement(Z(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new G(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=K.get(t.strings);return void 0===e&&K.set(t.strings,e=new X(t)),e}k(t){E(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const a of t)s===e.length?e.push(i=new Q(this.O(M()),this.O(M()),this,this.options)):i=e[s],i._$AI(a),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=W}_$AI(t,e=this,i,s){const a=this.strings;let o=!1;if(void 0===a)t=J(this,t,e,0),o=!P(t)||t!==this._$AH&&t!==B,o&&(this._$AH=t);else{const s=t;let n,r;for(t=a[0],n=0;n{const s=i?.renderBefore??e;let a=s._$litPart$;if(void 0===a){const t=i?.renderBefore??null;s._$litPart$=a=new Q(e.insertBefore(M(),t),t,void 0,i??{})}return a._$AI(t),a})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return B}}rt._$litElement$=!0,rt.finalized=!0,nt.litElementHydrateSupport?.({LitElement:rt});const lt=nt.litElementPolyfillSupport;lt?.({LitElement:rt}),(nt.litElementVersions??=[]).push("4.2.2");const ht=new Set(["hacs","sun","backup","hassio","met","telegram_bot","mobile_app","systemmonitor","better_thermostat","adaptive_lighting","yandex_pogoda","upnp_serial_number"]),ct=[["протечк","mdi:water-alert"],["клапан","mdi:pipe-valve"],["дым","mdi:smoke-detector"],["термоголов","mdi:radiator"],["температ","mdi:thermometer"],["qingping|air monitor|молекул","mdi:air-filter"],["штор","mdi:roller-shade"],["розетк|plug","mdi:power-socket-de"],["выключат|switch","mdi:light-switch"],["лампа|лампочк|bulb|gx53|светильник|rgb","mdi:lightbulb"],["камер|camera","mdi:cctv"],["замок|ttlock|lock|sn609|sn9161","mdi:lock"],["ворота|garage","mdi:garage-variant"],["калитк|door|открыт","mdi:door"],["счётчик|счетчик|kws|meter","mdi:meter-electric"],["вводный автомат|breaker|wifimcbn","mdi:electric-switch"],["myheat|котёл|котел|boiler|отоплен","mdi:water-boiler"],["холодильник|fridge","mdi:fridge"],["стиральн|washer","mdi:washing-machine"],["сушилк|dryer","mdi:tumble-dryer"],["пылесос|vacuum|dreame","mdi:robot-vacuum"],["soundbar|колонк|станц","mdi:soundbar"],["tv|телевизор|hyundaitv|mitv","mdi:television"],["keenetic|роутер|router","mdi:router-wireless"],["ибп|ups|kirpich","mdi:battery-charging-high"],["slzb|координат|zigbee","mdi:zigbee"]];function dt(t,e){const i=((t||"")+" "+(e||"")).toLowerCase();for(const[t,e]of ct)if(new RegExp(t).test(i))return e;return"mdi:chip"}const pt=["light","switch","cover","valve","lock","climate","fan","media_player","camera","vacuum","humidifier","water_heater","alarm_control_panel","sensor","binary_sensor","event","button","number","select","update"];function _t(t){const e=Math.max(0,Math.min(120,(t-40)/140*120));return`hsl(${Math.round(e)}, 85%, 55%)`}function ut(t,e){return Math.round(t/e)*e}function gt(t){return t.length?Math.round(t.reduce((t,e)=>t+e,0)/t.length):null}function mt(t,e){if(e>t[2]/t[3]){const i=t[3],s=t[3]*e;return{x:t[0]-(s-t[2])/2,y:t[1],w:s,h:i}}const i=t[2],s=t[2]/e;return{x:t[0],y:t[1]-(s-t[3])/2,w:i,h:s}}function ft(t,e,i,s){if(t.length<2)return;const a=e.x+s,o=e.x+e.w-s,n=e.y+s,r=e.y+e.h-s;for(let e=0;e<60;e++){let e=!1;for(let s=0;st:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new a("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:n,defineProperty:r,getOwnPropertyDescriptor:l,getOwnPropertyNames:c,getOwnPropertySymbols:h,getPrototypeOf:d}=Object,p=globalThis,u=p.trustedTypes,_=u?u.emptyScript:"",g=p.reactiveElementPolyfillSupport,m=(t,e)=>t,f={toAttribute(t,e){switch(e){case Boolean:t=t?_:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},v=(t,e)=>!n(t,e),b={attribute:!0,type:String,converter:f,reflect:!1,useDefault:!1,hasChanged:v};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let y=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&r(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:a}=l(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);a?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(m("elementProperties")))return;const t=d(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(m("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(m("properties"))){const t=this.properties,e=[...c(t),...h(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(o(t))}else void 0!==t&&e.push(o(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),a=t.litNonce;void 0!==a&&s.setAttribute("nonce",a),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const a=(void 0!==i.converter?.toAttribute?i.converter:f).toAttribute(e,i.type);this._$Em=t,null==a?this.removeAttribute(s):this.setAttribute(s,a),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),a="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:f;this._$Em=s;const o=a.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,a){if(void 0!==t){const o=this.constructor;if(!1===s&&(a=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??v)(a,e)||i.useDefault&&i.reflect&&a===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:a},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==a||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};y.elementStyles=[],y.shadowRootOptions={mode:"open"},y[m("elementProperties")]=new Map,y[m("finalized")]=new Map,g?.({ReactiveElement:y}),(p.reactiveElementVersions??=[]).push("2.1.2");const x=globalThis,$=t=>t,w=x.trustedTypes,k=w?w.createPolicy("lit-html",{createHTML:t=>t}):void 0,S="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,A="?"+C,z=`<${A}>`,M=document,D=()=>M.createComment(""),P=t=>null===t||"object"!=typeof t&&"function"!=typeof t,E=Array.isArray,R="[ \t\n\f\r]",T=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,O=/-->/g,U=/>/g,H=RegExp(`>|${R}(?:([^\\s"'>=/]+)(${R}*=${R}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),I=/'/g,N=/"/g,q=/^(?:script|style|textarea|title)$/i,L=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=L(1),F=L(2),W=Symbol.for("lit-noChange"),B=Symbol.for("lit-nothing"),K=new WeakMap,V=M.createTreeWalker(M,129);function Z(t,e){if(!E(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==k?k.createHTML(e):e}const Y=(t,e)=>{const i=t.length-1,s=[];let a,o=2===e?"":3===e?"":"",n=T;for(let e=0;e"===l[0]?(n=a??T,c=-1):void 0===l[1]?c=-2:(c=n.lastIndex-l[2].length,r=l[1],n=void 0===l[3]?H:'"'===l[3]?N:I):n===N||n===I?n=H:n===O||n===U?n=T:(n=H,a=void 0);const d=n===H&&t[e+1].startsWith("/>")?" ":"";o+=n===T?i+z:c>=0?(s.push(r),i.slice(0,c)+S+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Z(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class X{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let a=0,o=0;const n=t.length-1,r=this.parts,[l,c]=Y(t,e);if(this.el=X.createElement(l,i),V.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=V.nextNode())&&r.length0){s.textContent=w?w.emptyScript:"";for(let i=0;iE(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==B&&P(this._$AH)?this._$AA.nextSibling.data=t:this.T(M.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=X.createElement(Z(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new G(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=K.get(t.strings);return void 0===e&&K.set(t.strings,e=new X(t)),e}k(t){E(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const a of t)s===e.length?e.push(i=new Q(this.O(D()),this.O(D()),this,this.options)):i=e[s],i._$AI(a),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=B}_$AI(t,e=this,i,s){const a=this.strings;let o=!1;if(void 0===a)t=J(this,t,e,0),o=!P(t)||t!==this._$AH&&t!==W,o&&(this._$AH=t);else{const s=t;let n,r;for(t=a[0],n=0;n{const s=i?.renderBefore??e;let a=s._$litPart$;if(void 0===a){const t=i?.renderBefore??null;s._$litPart$=a=new Q(e.insertBefore(D(),t),t,void 0,i??{})}return a._$AI(t),a})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return W}}rt._$litElement$=!0,rt.finalized=!0,nt.litElementHydrateSupport?.({LitElement:rt});const lt=nt.litElementPolyfillSupport;lt?.({LitElement:rt}),(nt.litElementVersions??=[]).push("4.2.2");const ct=new Set(["hacs","sun","backup","hassio","met","telegram_bot","mobile_app","systemmonitor","better_thermostat","adaptive_lighting","yandex_pogoda","upnp_serial_number"]),ht=[[/протечк/,"mdi:water-alert"],[/клапан/,"mdi:pipe-valve"],[/дым/,"mdi:smoke-detector"],[/термоголов/,"mdi:radiator"],[/температ/,"mdi:thermometer"],[/qingping|air monitor|молекул/,"mdi:air-filter"],[/штор/,"mdi:roller-shade"],[/розетк|plug/,"mdi:power-socket-de"],[/выключат|switch/,"mdi:light-switch"],[/лампа|лампочк|bulb|gx53|светильник|rgb/,"mdi:lightbulb"],[/камер|camera/,"mdi:cctv"],[/замок|ttlock|lock|sn609|sn9161/,"mdi:lock"],[/ворота|garage/,"mdi:garage-variant"],[/калитк|door|открыт/,"mdi:door"],[/счётчик|счетчик|kws|meter/,"mdi:meter-electric"],[/вводный автомат|breaker|wifimcbn/,"mdi:electric-switch"],[/myheat|котёл|котел|boiler|отоплен/,"mdi:water-boiler"],[/холодильник|fridge/,"mdi:fridge"],[/стиральн|washer/,"mdi:washing-machine"],[/сушилк|dryer/,"mdi:tumble-dryer"],[/пылесос|vacuum|dreame/,"mdi:robot-vacuum"],[/soundbar|колонк|станц/,"mdi:soundbar"],[/tv|телевизор|hyundaitv|mitv/,"mdi:television"],[/keenetic|роутер|router/,"mdi:router-wireless"],[/ибп|ups|kirpich/,"mdi:battery-charging-high"],[/slzb|координат|zigbee/,"mdi:zigbee"]];function dt(t,e){const i=((t||"")+" "+(e||"")).toLowerCase();for(const[t,e]of ht)if(t.test(i))return e;return"mdi:chip"}const pt=["light","switch","cover","valve","lock","climate","fan","media_player","camera","vacuum","humidifier","water_heater","alarm_control_panel","sensor","binary_sensor","event","button","number","select","update"];function ut(t){const e=Math.max(0,Math.min(120,(t-40)/140*120));return`hsl(${Math.round(e)}, 85%, 55%)`}function _t(t,e){return Math.round(t/e)*e}function gt(t){return t.length?Math.round(t.reduce((t,e)=>t+e,0)/t.length):null}function mt(t,e){if(e>t[2]/t[3]){const i=t[3],s=t[3]*e;return{x:t[0]-(s-t[2])/2,y:t[1],w:s,h:i}}const i=t[2],s=t[2]/e;return{x:t[0],y:t[1]-(s-t[3])/2,w:i,h:s}}function ft(t,e,i,s){if(t.length<2)return;const a=e.x+s,o=e.x+e.w-s,n=e.y+s,r=e.y+e.h-s;for(let e=0;e<60;e++){let e=!1;for(let s=0;s({eid:e,reg:t.entities[e],st:t.states[e]})).filter(t=>t.reg&&!t.reg.hidden),a=s.filter(t=>!t.reg.entity_category),o=a.length?a:s;if("mdi:thermometer"===i||"mdi:air-filter"===i){const e=o.find(e=>yt(t,e.eid));if(e)return e.eid}for(const t of pt){const e=o.find(e=>e.eid.split(".")[0]===t);if(e)return e.eid}return o[0]?.eid}function $t(t,e){const i=[];for(const s of e){const e=t.states[s];if(!e)continue;const a=(e.attributes?.unit_of_measurement||"").toLowerCase();if(/_(linkquality|lqi)$/.test(s)||"lqi"===a){const t=parseFloat(e.state);isNaN(t)||i.push(t);continue}const o=e.attributes?.linkquality??e.attributes?.lqi;if(null!=o){const t=parseFloat(o);isNaN(t)||i.push(t)}}return gt(i)}function wt(t,e){for(const i of e){if(!yt(t,i))continue;const e=t.states[i];if(!e)continue;const s=parseFloat(e.state);if(!isNaN(s))return Math.round(10*s)/10}return null}function kt(t,e){t.marker=e,e.name&&(t.name=e.name),e.icon&&(t.icon=e.icon),null!=e.model&&(t.model=e.model),t.link=e.link??null,t.description=e.description??null,t.pdfs=e.pdfs||[]}function St(t){const{hass:e,areaToSpace:i,markers:s,settings:a,excluded:o,showAll:n,firstSpaceId:r}=t,l=!1!==a.group_lights,c=function(t,e){if(!e)return[];const i=[];for(const[e,s]of Object.entries(t.entities)){if(!e.startsWith("light.")||s.hidden)continue;let a=null;if("group"===s.platform)a=s.area_id||null;else{if(!s.device_id)continue;{const e=t.devices[s.device_id];if("Group"!==e?.model)continue;a=e.area_id||s.area_id||null}}if(!a)continue;const o=t.states[e];i.push({eid:e,name:s.name||o?.attributes?.friendly_name||e,area:a})}return i}(e,l),h=new Set(c.map(t=>t.area)),d=function(t){const e={};for(const[i,s]of Object.entries(t.entities))s?.device_id&&(e[s.device_id]=e[s.device_id]||[]).push(i);return e}(e),p=new Set;for(const t of s){const[e,i]=t.binding.split(":");"device"!==e&&"entity"!==e||!i||p.add(t.binding)}const u=(t,e)=>s.find(i=>i.binding===t+":"+e),_={},g=[];for(const t of Object.values(e.devices)){const s=t.area_id;if(!s||!i[s])continue;if("service"===t.entry_type)continue;if(p.has("device:"+t.id))continue;const a=u("device",t.id);if(a&&a.hidden)continue;const r=d[t.id]||[],c=bt(e,t,r);if(!n){if(o.has(c))continue;if("Group"===t.model)continue;if(/scene/i.test(t.model||""))continue;if(/bridge/i.test((t.model||"")+(t.name||"")))continue;if("myheat"===c&&t.via_device_id)continue}const m=(t.name_by_user||t.name||"без имени").trim(),f=m+"|"+s;let v=dt(m,t.model);if(r.some(t=>t.startsWith("lock."))&&(v="mdi:lock"),!n&&l&&"mdi:lightbulb"===v&&h.has(s))continue;_[f]=(_[f]||0)+1;const b=_[f]>1?m+" "+_[f]:m,y={id:t.id,name:b,model:t.model||"",area:s,space:i[s],icon:v,entities:r,bindingKind:"device",bindingRef:t.id,pdfs:[]};y.primary=xt(e,r,v),"mdi:thermometer"!==v&&"mdi:air-filter"!==v||(y.temp=wt(e,r)),g.push(y)}for(const t of c)i[t.area]&&(p.has("entity:"+t.eid)||g.push({id:"lg_"+t.eid,name:t.name,model:"группа света",area:t.area,space:i[t.area],icon:"mdi:lightbulb-group",entities:[t.eid],primary:t.eid,bindingKind:"entity",bindingRef:t.eid,pdfs:[]}));for(const t of s){if(t.hidden)continue;const[s,a]=t.binding.split(":");if("device"===s){const s=e.devices[a],o=t.area||s?.area_id||"",n=o&&i[o]||t.space||r,l=s&&d[s.id]||[];let c=s?dt(s.name_by_user||s.name||"",s.model):"mdi:help-circle";l.some(t=>t.startsWith("lock."))&&(c="mdi:lock");const h={id:t.id,name:s?.name_by_user||s?.name||"устройство",model:s?.model||"",area:o,space:n,icon:c,entities:l,bindingKind:"device",bindingRef:a};h.primary=xt(e,l,c),"mdi:thermometer"!==c&&"mdi:air-filter"!==c||(h.temp=wt(e,l)),kt(h,t),g.push(h)}else if("entity"===s){const s=e.entities[a],o=t.area||s?.area_id||s?.device_id&&e.devices[s.device_id]?.area_id||"",n=o&&i[o]||t.space||r,l=e.states[a],c={id:t.id,name:s?.name||l?.attributes?.friendly_name||a,model:"",area:o,space:n,icon:"mdi:shape-outline",entities:[a],primary:a,bindingKind:"entity",bindingRef:a};kt(c,t),g.push(c)}else{const e=t.area||"",s=t.space||e&&i[e]||r,a={id:t.id,name:t.name||"виртуальное устройство",model:t.model||"",area:e,space:s,icon:t.icon||"mdi:map-marker",entities:[],bindingKind:"virtual",virtual:!0};kt(a,t),g.push(a)}}return g}const Ct={title:"Заголовок",default_floor:"Пространство по умолчанию",icon_size:"Размер иконок, % ширины плана",show_temperature:"Показывать температуру",live_states:"Живые состояния (вкл/выкл, открыто…)",show_signal:"Показывать сигнал zigbee (LQI)"};class At extends rt{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(t){this._config=t}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const t=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(t?.config?.spaces||[]).map(t=>({value:t.id,label:t.title||t.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _schema(){const t=this._spaces||[];return[{name:"title",selector:{text:{}}},t.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:t}}}:{name:"default_floor",selector:{text:{}}},{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:{}}}]}render(){return this.hass&&this._config?(this._loadSpaces(),j`bt[t.name]||t.name} + .schema=${this._schema} + .computeLabel=${t=>Ct[t.name]||t.name} @value-changed=${this._valueChanged} - >`:W}_valueChanged(t){const e={...this._config,...t.detail.value},i=new Event("config-changed",{bubbles:!0,composed:!0});i.detail={config:e},this.dispatchEvent(i)}}yt.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",yt);const xt="houseplan_card_layout_v1",$t="houseplan_card_cfg_v1",wt="houseplan_card_zoom_v1",kt=1e3,St=(t,e,i)=>{const s=new Event(e,{bubbles:!0,composed:!0});s.detail=i??{},t.dispatchEvent(s)},Ct=(t,e)=>{let i;return(...s)=>{clearTimeout(i),i=window.setTimeout(()=>t(...s),e)}};class At extends rt{constructor(){super(...arguments),this._space="f1",this._layout={},this._serverStorage=!1,this._loadOk=!1,this._loading=!1,this._loadTries=0,this._serverCfg=null,this._cfgRev=0,this._unsubCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._tip=null,this._selId=null,this._toast="",this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._zoom=1,this._view=null,this._zoomBySpace={},this._pointers=new Map,this._panStart=null,this._pinchStart=null,this._suppressClick=!1,this._onboardingShown=!1,this._infoCard=null,this._markerDialog=null,this._spaceDialog=null,this._keyHandler=t=>this._onKey(t),this._drag=null,this._dirtyPos=new Set,this._persistLayout=Ct(()=>{if(this._serverStorage){const t=[...this._dirtyPos];this._dirtyPos.clear();for(const e of t){const t=this._layout[e];t&&this.hass.callWS({type:"houseplan/layout/update",device_id:e,pos:t}).catch(t=>this._showToast("Не удалось сохранить позицию: "+(t?.message||t)))}this._cacheSnapshot()}else localStorage.setItem(xt,JSON.stringify(this._layout))},600),this._saveConfig=Ct(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev}).then(t=>{this._cfgRev=t?.rev??this._cfgRev+1}).catch(t=>{"conflict"===t?.code?(this._showToast("Конфиг изменён в другом окне — данные обновлены, повторите последнее действие"),this._cancelPath(),this._reloadConfigOnly()):this._showToast("Не удалось сохранить конфиг: "+(t?.message||t))})},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),this._roViewport?.disconnect(),this._roViewport=void 0,this._unsubCfg&&(this._unsubCfg(),this._unsubCfg=null),super.disconnectedCallback()}_onKey(t){if("Escape"===t.key){if(this._infoCard)return void(this._infoCard=null);if(this._markerDialog)return void(this._markerDialog=null)}if(!this._markup)return;return"Escape"===t.key||(t.ctrlKey||t.metaKey)&&"z"===t.key.toLowerCase()?this._roomDialog?(t.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(t.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const t=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),t&&this._removeSegmentByKey(t),this._path=this._path.slice(0,-1)}_removeSegmentByKey(t){const e=this._curSpaceCfg;if(!e?.segments)return;const i=this._segments.findIndex(e=>this._segKey([e[0],e[1]],[e[2],e[3]])===t);i>=0&&(e.segments.splice(i,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(t){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...t},t.default_floor&&(this._space=t.default_floor);try{this._zoomBySpace=JSON.parse(localStorage.getItem(wt)||"{}")||{}}catch{this._zoomBySpace={}}try{const e=JSON.parse(localStorage.getItem($t)||"null");e&&e.config&&Array.isArray(e.config.spaces)&&(this._serverCfg=e.config,this._cfgRev=e.rev||0,this._layout=e.layout||{},this._serverStorage=!0,t.default_floor?this._space=t.default_floor:this._model.find(t=>t.id===this._space)||(this._space=this._model[0]?.id||this._space))}catch{}}_cacheSnapshot(){if(this._serverCfg)try{localStorage.setItem($t,JSON.stringify({config:this._serverCfg,rev:this._cfgRev,layout:this._layout}))}catch{}}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){return this._serverCfg?this._serverCfg.spaces.map(t=>{const e=kt/t.aspect;return{id:t.id,title:t.title,vb:[t.view_box[0]*kt,t.view_box[1]*e,t.view_box[2]*kt,t.view_box[3]*e],bg:t.plan_url?{href:t.plan_url,x:0,y:0,w:kt,h:e}:null,rooms:t.rooms.map(t=>({id:t.id,name:t.name,area:t.area??null,x:null!=t.x?t.x*kt:void 0,y:null!=t.y?t.y*e:void 0,w:null!=t.w?t.w*kt:void 0,h:null!=t.h?t.h*e:void 0,poly:t.poly?t.poly.map(t=>[t[0]*kt,t[1]*e]):void 0}))}}):[]}_spaceModel(t){const e=this._model;return e.find(e=>e.id===(t??this._space))||e[0]}get _areaToSpace(){const t={};for(const e of this._model)for(const i of e.rooms)i.area&&(t[i.area]={space:e.id,room:i});return t}get _settings(){return this._serverCfg?.settings||{}}get _showAll(){return!!this._settings.show_all}_toggleShowAll(){this._serverCfg&&(this._serverCfg={...this._serverCfg,settings:{...this._serverCfg.settings,show_all:!this._showAll}},this._regSignature="",this._maybeRebuildDevices(),this._saveConfig(),this.requestUpdate())}get _excluded(){const t=this._settings.exclude_integrations;return t?new Set(t):ht}willUpdate(t){t.has("hass")&&this.hass&&(!this._loadOk&&!this._loading&&this._loadTries<8&&this._loadFromServer(),this._maybeRebuildDevices())}updated(){const t=this._stageEl;t&&!this._roViewport&&(this._roViewport=new ResizeObserver(()=>this._refitView()),this._roViewport.observe(t)),t&&!this._view&&this._refitView(),this._serverStorage&&this._loadOk&&0===this._model.length&&!this._spaceDialog&&!this._onboardingShown&&(this._onboardingShown=!0,this._openSpaceDialog("create"))}async _loadFromServer(){this._loading=!0,this._loadTries++;try{const[t,e]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._loadOk=!0,this._serverStorage=!0;const i=t?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._cfgRev=t?.rev||0,this._layout=e?.layout||{},this._unsubCfg||(this._unsubCfg=await this.hass.connection.subscribeEvents(t=>{(t?.data?.rev??-1)!==this._cfgRev&&this._reloadConfigOnly()},"houseplan_config_updated")),this._norm&&!this._model.find(t=>t.id===this._space)&&(this._space=this._model[0]?.id||this._space),this._cacheSnapshot(),this._restoreZoom()}catch(t){if(this._loadTries>=8){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(xt)||"{}")||{}}catch{this._layout={}}}}finally{this._loading=!1}this._regSignature="",this.requestUpdate()}async _reloadConfigOnly(){try{const t=await this.hass.callWS({type:"houseplan/config/get"}),e=t?.config;this._serverCfg=e&&Array.isArray(e.spaces)?e:null,this._cfgRev=t?.rev||0,this._cacheSnapshot(),this._regSignature="",this._maybeRebuildDevices(),this.requestUpdate()}catch{}}_maybeRebuildDevices(){const t=this.hass;if(!t?.devices||!t?.entities||!t?.areas)return;const e=Object.keys(t.devices).length+":"+Object.keys(t.entities).length+":"+Object.keys(t.areas).length+":"+(this._norm?"n":"l");e===this._regSignature&&this._devices.length||(this._regSignature=e,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const t={};for(const[e,i]of Object.entries(this.hass.entities))i?.device_id&&(t[i.device_id]=t[i.device_id]||[]).push(e);return t}_domainOfDevice(t,e){if(t.identifiers?.[0]?.[0])return t.identifiers[0][0];for(const t of e){const e=this.hass.entities[t]?.platform;if(e)return e}return""}_primaryEntity(t,e){const i=t.map(t=>({eid:t,reg:this.hass.entities[t],st:this.hass.states[t]})).filter(t=>t.reg&&!t.reg.hidden),s=i.filter(t=>!t.reg.entity_category),a=s.length?s:i;if("mdi:thermometer"===e||"mdi:air-filter"===e){const t=a.find(t=>this._isTempEntity(t.eid));if(t)return t.eid}for(const t of pt){const e=a.find(e=>e.eid.split(".")[0]===t);if(e)return e.eid}return a[0]?.eid}_isTempEntity(t){const e=this.hass.states[t];if(!e)return/_temperature$/.test(t);const i=e.attributes||{};return"temperature"===i.device_class||/°C|°F/.test(i.unit_of_measurement||"")||/_temperature$/.test(t)}_lqiFor(t){const e=[];for(const i of t){const t=this.hass.states[i];if(!t)continue;const s=(t.attributes?.unit_of_measurement||"").toLowerCase();if(/_(linkquality|lqi)$/.test(i)||"lqi"===s){const i=parseFloat(t.state);isNaN(i)||e.push(i);continue}const a=t.attributes?.linkquality??t.attributes?.lqi;if(null!=a){const t=parseFloat(a);isNaN(t)||e.push(t)}}return gt(e)}_roomLqi(t){if(!t)return null;const e=[];for(const i of this._devices){if(i.area!==t||i.virtual)continue;const s=this._lqiFor(i.entities);null!=s&&e.push(s)}return gt(e)}_tempFor(t){for(const e of t){if(!this._isTempEntity(e))continue;const t=this.hass.states[e];if(!t)continue;const i=parseFloat(t.state);if(!isNaN(i))return Math.round(10*i)/10}return null}_lightGroups(){if(!1===this._settings.group_lights)return[];const t=this.hass,e=[];for(const[i,s]of Object.entries(t.entities)){if(!i.startsWith("light.")||s.hidden)continue;let a=null;if("group"===s.platform)a=s.area_id||null;else{if(!s.device_id)continue;{const e=t.devices[s.device_id];if("Group"!==e?.model)continue;a=e.area_id||s.area_id||null}}if(!a)continue;const o=t.states[i];e.push({eid:i,name:s.name||o?.attributes?.friendly_name||i,area:a})}return e}_groupedLightAreas(){return new Set(this._lightGroups().map(t=>t.area))}get _markers(){return this._serverCfg?.markers||[]}_markerFor(t,e){return this._markers.find(i=>i.binding===t+":"+e)}get _claimed(){const t=new Set;for(const e of this._markers){const[i,s]=e.binding.split(":");"device"!==i&&"entity"!==i||!s||t.add(e.binding)}return t}_applyMarker(t,e){t.marker=e,e.name&&(t.name=e.name),e.icon&&(t.icon=e.icon),null!=e.model&&(t.model=e.model),t.link=e.link??null,t.description=e.description??null,t.pdfs=e.pdfs||[]}_buildDevices(){const t=this.hass,e=this._areaToSpace,i=!1!==this._settings.group_lights,s=this._groupedLightAreas(),a=this._excluded,o=this._showAll,n=this._entitiesByDevice(),r=this._claimed,l={},h=[];for(const c of Object.values(t.devices)){const t=c.area_id;if(!t||!e[t])continue;if("service"===c.entry_type)continue;if(r.has("device:"+c.id))continue;const d=this._markerFor("device",c.id);if(d&&d.hidden)continue;const p=n[c.id]||[],_=this._domainOfDevice(c,p);if(!o){if(a.has(_))continue;if("Group"===c.model)continue;if(/scene/i.test(c.model||""))continue;if(/bridge/i.test((c.model||"")+(c.name||"")))continue;if("myheat"===_&&c.via_device_id)continue}const u=(c.name_by_user||c.name||"без имени").trim(),g=u+"|"+t;let m=dt(u,c.model);if(p.some(t=>t.startsWith("lock."))&&(m="mdi:lock"),!o&&i&&"mdi:lightbulb"===m&&s.has(t))continue;l[g]=(l[g]||0)+1;const f=l[g]>1?u+" "+l[g]:u,v={id:c.id,name:f,model:c.model||"",area:t,space:e[t].space,icon:m,entities:p,bindingKind:"device",bindingRef:c.id,pdfs:[]};v.primary=this._primaryEntity(p,m),"mdi:thermometer"!==m&&"mdi:air-filter"!==m||(v.temp=this._tempFor(p)),h.push(v)}for(const t of this._lightGroups())e[t.area]&&(r.has("entity:"+t.eid)||h.push({id:"lg_"+t.eid,name:t.name,model:"группа света",area:t.area,space:e[t.area].space,icon:"mdi:lightbulb-group",entities:[t.eid],primary:t.eid,bindingKind:"entity",bindingRef:t.eid,pdfs:[]}));for(const i of this._markers){if(i.hidden)continue;const[s,a]=i.binding.split(":");if("device"===s){const s=t.devices[a],o=i.area||s?.area_id||"",r=o&&e[o]?.space||i.space||this._model[0]?.id||"",l=s&&n[s.id]||[];let c=s?dt(s.name_by_user||s.name||"",s.model):"mdi:help-circle";l.some(t=>t.startsWith("lock."))&&(c="mdi:lock");const d={id:i.id,name:s?.name_by_user||s?.name||"устройство",model:s?.model||"",area:o,space:r,icon:c,entities:l,bindingKind:"device",bindingRef:a};d.primary=this._primaryEntity(l,c),"mdi:thermometer"!==c&&"mdi:air-filter"!==c||(d.temp=this._tempFor(l)),this._applyMarker(d,i),h.push(d)}else if("entity"===s){const s=t.entities[a],o=i.area||s?.area_id||s?.device_id&&t.devices[s.device_id]?.area_id||"",n=o&&e[o]?.space||i.space||this._model[0]?.id||"",r=t.states[a],l={id:i.id,name:s?.name||r?.attributes?.friendly_name||a,model:"",area:o,space:n,icon:"mdi:shape-outline",entities:[a],primary:a,bindingKind:"entity",bindingRef:a};this._applyMarker(l,i),h.push(l)}else{const t=i.area||"",s=i.space||t&&e[t]?.space||this._model[0]?.id||"",a={id:i.id,name:i.name||"виртуальное устройство",model:i.model||"",area:t,space:s,icon:i.icon||"mdi:map-marker",entities:[],bindingKind:"virtual",virtual:!0};this._applyMarker(a,i),h.push(a)}}return h}_roomBounds(t){if(t.poly&&t.poly.length){const e=t.poly.map(t=>t[0]),i=t.poly.map(t=>t[1]),s=Math.min(...e),a=Math.min(...i);return{x:s,y:a,w:Math.max(...e)-s,h:Math.max(...i)-a}}return{x:t.x??0,y:t.y??0,w:t.w??0,h:t.h??0}}_defaultPositions(){const t={},e=(this._config?.icon_size??2.5)/100*kt*1.3;for(const i of this._model)for(const s of i.rooms){if(!s.area)continue;const a=this._devices.filter(t=>t.area===s.area&&t.space===i.id);if(!a.length)continue;const o=this._roomBounds(s),n=.1*Math.min(o.w,o.h),r=o.w-2*n,l=o.h-2*n,h=Math.max(1,Math.round(Math.sqrt(a.length*r/Math.max(l,1)))),c=Math.ceil(a.length/h),d=r/h,p=l/Math.max(c,1),_=a.map((t,e)=>({x:o.x+n+d*(e%h+.5),y:o.y+n+p*(Math.floor(e/h)+.5)}));ft(_,o,e,.5*n),a.forEach((e,i)=>t[e.id]=_[i])}return t}_pos(t){const e=this._spaceModel(t.space),i=this._layout[t.id];if(i)if(this._norm){if(i.s===t.space){const e=this._serverCfg.spaces.find(e=>e.id===t.space)?.aspect||1;return{x:i.x*kt,y:i.y*(kt/e)}}}else if(void 0===i.s)return{x:i.x,y:i.y};if(this._defPos[t.id])return this._defPos[t.id];const s=e.vb;return{x:s[0]+s[2]/2,y:s[1]+s[3]/2}}_savePos(t,e,i){if(this._norm){const s=this._gridPitch,a=Math.round(e/s)*s,o=Math.round(i/s)*s,n=this._serverCfg.spaces.find(e=>e.id===t.space)?.aspect||1;this._layout={...this._layout,[t.id]:{s:t.space,x:a/kt,y:o/(kt/n)}}}else this._layout={...this._layout,[t.id]:{x:Math.round(e),y:Math.round(i)}};this._dirtyPos.add(t.id),this._persistLayout()}_stateClass(t){if(!this._config?.live_states)return"";const e=t.primary?this.hass.states[t.primary]:void 0;if(!e)return"";if("unavailable"===e.state)return"unavail";const i=e.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(i))return"on"===e.state?"on":"";if("cover"===i||"valve"===i)return["open","opening"].includes(e.state)?"open":"";if("lock"===i)return["unlocked","open"].includes(e.state)?"open":"";if("binary_sensor"===i){const t=e.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(t))return"on"===e.state?"open":""}return"media_player"===i?["playing","on"].includes(e.state)?"on":"":"vacuum"===i&&["cleaning","returning"].includes(e.state)?"on":""}_liveTemp(t){return this._config?.show_temperature?"mdi:thermometer"!==t.icon&&"mdi:air-filter"!==t.icon?null:this._tempFor(t.entities):null}_openMoreInfo(t){t?St(this,"hass-more-info",{entityId:t}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(t,e){t.stopPropagation(),this._drag?.moved||this._suppressClick||this._markup||(this._infoCard=e)}get _stageEl(){return this.renderRoot.querySelector(".stage")}_stageAspect(){const t=this._stageEl,e=this._spaceModel().vb;return t&&t.clientHeight?t.clientWidth/t.clientHeight:e[2]/e[3]}_viewOr(t){return this._view&&this._view.w?this._view:mt(t,this._stageAspect())}_screenToVb(t,e){const i=this._stageEl,s=this._viewOr(this._spaceModel().vb),a=i?.clientWidth||1,o=i?.clientHeight||1;return[s.x+t/a*s.w,s.y+e/o*s.h]}_clampView(t,e){return{w:t.w,h:t.h,x:Math.max(e.x,Math.min(e.x+e.w-t.w,t.x)),y:Math.max(e.y,Math.min(e.y+e.h-t.h,t.y))}}_applyView(t,e,i){const s=this._spaceModel().vb,a=mt(s,this._stageAspect()),o=Math.min(8,Math.max(1,t)),n=a.w/o,r=a.h/o,l=this._viewOr(s),h=e??l.x+l.w/2,c=i??l.y+l.h/2;this._zoom=o,this._view=this._clampView({x:h-n/2,y:c-r/2,w:n,h:r},a)}_refitView(){if(!this._stageEl)return;const t=this._view;this._applyView(this._zoom,t?t.x+t.w/2:void 0,t?t.y+t.h/2:void 0),this.requestUpdate()}_zoomAt(t,e,i){const s=this._stageEl;if(!s)return;const a=mt(this._spaceModel().vb,this._stageAspect()),o=Math.min(8,Math.max(1,i)),n=s.clientWidth,r=s.clientHeight,l=this._screenToVb(t,e),h=a.w/o,c=a.h/o;this._zoom=o,this._view=this._clampView({x:l[0]-t/n*h,y:l[1]-e/r*c,w:h,h:c},a)}_onWheel(t){const e=this._stageEl;if(!e)return;t.preventDefault();const i=e.getBoundingClientRect(),s=t.deltaY<0?1.15:1/1.15;this._zoomAt(t.clientX-i.left,t.clientY-i.top,this._zoom*s),this._saveZoom()}_stepZoom(t){const e=this._stageEl;e&&(this._zoomAt(e.clientWidth/2,e.clientHeight/2,this._zoom*(t>0?1.4:1/1.4)),this._saveZoom())}_resetZoom(){const t=this._spaceModel().vb;this._zoom=1,this._view=mt(t,this._stageAspect()),this._saveZoom()}_saveZoom(){this._zoomBySpace={...this._zoomBySpace,[this._space]:this._zoom};try{localStorage.setItem(wt,JSON.stringify(this._zoomBySpace))}catch{}}_restoreZoom(){const t=this._zoomBySpace[this._space]||1;this._zoom=t,this._view=null,requestAnimationFrame(()=>{if(!this._stageEl)return;const e=this._spaceModel().vb;this._applyView(t,e[0]+e[2]/2,e[1]+e[3]/2),this.requestUpdate()})}_stagePointerDown(t){if(this._drag||this._markup)return;if(t.target.closest(".dev"))return;this._pointers.set(t.pointerId,{x:t.clientX,y:t.clientY});const e=this._viewOr(this._spaceModel().vb);if(1===this._pointers.size)this._panStart={sx:t.clientX,sy:t.clientY,vx:e.x,vy:e.y},this._suppressClick=!1;else if(2===this._pointers.size){const t=[...this._pointers.values()],e=Math.hypot(t[0].x-t[1].x,t[0].y-t[1].y);this._pinchStart={dist:e,zoom:this._zoom},this._panStart=null}}_stagePointerMove(t){if(this._pointers.has(t.pointerId)){if(this._pointers.set(t.pointerId,{x:t.clientX,y:t.clientY}),this._pinchStart&&this._pointers.size>=2){const t=[...this._pointers.values()],e=Math.hypot(t[0].x-t[1].x,t[0].y-t[1].y)/(this._pinchStart.dist||1),i=this._stageEl.getBoundingClientRect(),s=(t[0].x+t[1].x)/2-i.left,a=(t[0].y+t[1].y)/2-i.top;this._zoomAt(s,a,this._pinchStart.zoom*e),this._suppressClick=!0,this._saveZoom()}else if(this._panStart){const e=t.clientX-this._panStart.sx,i=t.clientY-this._panStart.sy;if(Math.abs(e)+Math.abs(i)>4&&(this._suppressClick=!0),this._zoom>1&&this._view){const t=this._stageEl,s=this._view,a=mt(this._spaceModel().vb,this._stageAspect());this._view=this._clampView({x:this._panStart.vx-e/t.clientWidth*s.w,y:this._panStart.vy-i/t.clientHeight*s.h,w:s.w,h:s.h},a)}}}else this._markupMove(t)}_stagePointerUp(t){this._pointers.delete(t.pointerId),this._pointers.size<2&&(this._pinchStart=null),0===this._pointers.size&&(this._panStart=null,setTimeout(()=>this._suppressClick=!1,0))}_clickRoom(t){var e;!this._suppressClick&&t.area&&(e="/config/areas/area/"+t.area,history.pushState(null,"",e),St(window,"location-changed",{replace:!1}))}_pointerDown(t,e){if(this._markup)return;t.preventDefault();const i=this._pos(e);this._drag={id:e.id,sx:t.clientX,sy:t.clientY,ox:i.x,oy:i.y,moved:!1},t.target.setPointerCapture(t.pointerId),this._tip=null}_pointerMove(t,e){if(!this._drag||this._drag.id!==e.id)return;const i=this.renderRoot.querySelector(".stage");if(!i)return;const s=this._spaceModel().vb,a=i.getBoundingClientRect(),o=this._viewOr(s),n=(t.clientX-this._drag.sx)/a.width*o.w,r=(t.clientY-this._drag.sy)/a.height*o.h;Math.abs(t.clientX-this._drag.sx)+Math.abs(t.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const l=.008*Math.min(s[2],s[3]),h=Math.max(s[0]+l,Math.min(s[0]+s[2]-l,this._drag.ox+n)),c=Math.max(s[1]+l,Math.min(s[1]+s[3]-l,this._drag.oy+r));this._savePos(e,h,c)}_pointerUp(t,e){if(!this._drag||this._drag.id!==e.id)return;const i=this._drag.moved;this._drag=i?this._drag:null,i&&(this._selId=e.id,window.setTimeout(()=>this._drag=null,0))}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(t){this._toast=t,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(t,e,i,s){this._drag||(this._tip={x:t.clientX,y:t.clientY,title:e,meta:i,lqi:s})}get _gridPitch(){return kt/240}get _curSpaceCfg(){return this._serverCfg?.spaces.find(t=>t.id===this._space)}get _spaceH(){const t=this._curSpaceCfg;return t?kt/t.aspect:kt}get _segments(){const t=this._curSpaceCfg,e=this._spaceH;return(t?.segments||[]).map(t=>[t[0]*kt,t[1]*e,t[2]*kt,t[3]*e])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(t){const e=this.renderRoot.querySelector(".stage").getBoundingClientRect();return this._screenToVb(t.clientX-e.left,t.clientY-e.top)}_snap(t){const e=this._gridPitch;return[ut(t[0],e),ut(t[1],e)]}_samePt(t,e){return function(t,e,i=.001){return Math.abs(t[0]-e[0])this._segKey([t[0],t[1]],[t[2],t[3]])===a);return!o&&(i.segments=i.segments||[],i.segments.push([t[0]/kt,t[1]/s,e[0]/kt,e[1]/s]),this._saveConfig(),!0)}_distToSeg(t,e){const[i,s]=t,[a,o,n,r]=e,l=n-a,h=r-o;let c=((i-a)*l+(s-o)*h)/(l*l+h*h||1);c=Math.max(0,Math.min(1,c));const d=a+c*l,p=o+c*h;return Math.hypot(i-d,s-p)}_pointInRoom(t,e){return e.poly?function(t,e){let i=!1;for(let s=0,a=e.length-1;st[1]!=l>t[1]&&t[0]<(r-o)*(t[1]-n)/(l-n)+o&&(i=!i)}return i}(t,e.poly):null!=e.x&&t[0]>=e.x&&t[0]<=e.x+e.w&&t[1]>=e.y&&t[1]<=e.y+e.h}_markupClick(t){if(!this._markup)return;const e=this._svgPoint(t);if("erase"===this._tool){const t=this._curSpaceCfg;if(!t?.segments?.length)return;const i=this._segments;let s=-1,a=.5*this._gridPitch;return i.forEach((t,i)=>{const o=this._distToSeg(e,t);o=0&&(t.segments.splice(s,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const t=[...this._spaceModel().rooms].reverse().find(t=>this._pointInRoom(e,t));if(!t)return;if(!confirm(`Удалить комнату «${t.name}»?`))return;const i=this._curSpaceCfg;return i.rooms=i.rooms.filter(e=>e.id!==t.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const i=this._snap(e);if(!this._path.length)return this._path=[i],void(this._pathSegs=[]);const s=this._path[this._path.length-1];if(this._samePt(i,s))return;const a=this._addSegment(s,i);this._pathSegs=[...this._pathSegs,a?this._segKey(s,i):null],this._path=[...this._path,i],this._path.length>=4&&this._samePt(i,this._path[0])&&(this._cursorPt=null,this._nameSel="",this._areaSel="",this._roomDialog=!0)}get _contourClosed(){return this._path.length>=4&&this._samePt(this._path[0],this._path[this._path.length-1])}_markupMove(t){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(t)))}_saveRoom(){this._areaSel&&this._commitRoom()}_saveRoomNoArea(){this._nameSel.trim()&&(this._areaSel="",this._commitRoom())}_commitRoom(){if(!this._contourClosed)return;const t=this._curSpaceCfg;if(!t)return;const e=this._spaceH,i=this._path.slice(0,-1),s=this._areaSel?this.hass.areas[this._areaSel]?.name:"";t.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||s||"Комната",area:this._areaSel||null,poly:i.map(t=>[t[0]/kt,t[1]/e])}),this._saveConfig(),this._path=[],this._pathSegs=[];const a=this._areaSel;this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices();let o=0;if(a){const t=this._serverCfg?.spaces.find(t=>t.id===this._space)?.aspect||1,e=kt/t,i={...this._layout};for(const t of this._devices){if(t.area!==a||t.space!==this._space)continue;if(o++,this._layout[t.id])continue;const s=this._defPos[t.id];s&&(i[t.id]={s:this._space,x:s.x/kt,y:s.y/e},this._dirtyPos.add(t.id))}this._layout=i,this._persistLayout()}const n=this._model.find(t=>t.id===this._space)?.rooms.length||0;this._showToast(a?`Комната сохранена (${n}). Устройств добавлено: ${o}. Обведите следующую или выйдите из разметки.`:`Комната сохранена (${n}, без зоны). Обведите следующую или выйдите из разметки.`)}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const t=new Set;for(const e of this._serverCfg?.spaces||[])for(const i of e.rooms||[])i.area&&t.add(i.area);return Object.values(this.hass?.areas||{}).filter(e=>!t.has(e.area_id)).sort((t,e)=>(t.name||"").localeCompare(e.name||""))}_openMarkerDialog(t){this._norm?this._markerDialog=t?{devId:t.id,name:t.name,binding:"virtual"===t.bindingKind?"virtual":t.bindingKind+":"+t.bindingRef,bindingFilter:"",icon:t.marker?.icon||"",model:t.model||"",link:t.link||"",description:t.description||"",pdfs:[...t.pdfs||[]],room:t.space&&t.area?t.space+"#"+t.area:"",busy:!1}:{name:"",binding:"virtual",bindingFilter:"",icon:"",model:"",link:"",description:"",pdfs:[],room:"",busy:!1}:this._showToast("Редактирование устройств доступно после переноса конфига на сервер")}_bindingCandidates(){const t=this.hass,e=new Set;for(const t of this._devices)t.id!==this._markerDialog?.devId&&("device"===t.bindingKind&&t.bindingRef&&e.add("device:"+t.bindingRef),"entity"===t.bindingKind&&t.bindingRef&&e.add("entity:"+t.bindingRef));const i=new Set;for(const t of this._devices)"device"===t.bindingKind&&t.name&&i.add(t.name.trim()+"|"+(t.area||""));const s=[];for(const a of Object.values(t.devices)){if("service"===a.entry_type)continue;const t="device:"+a.id;if(e.has(t))continue;const o=(a.name_by_user||a.name||a.id).trim();t!==this._markerDialog?.binding&&i.has(o+"|"+(a.area_id||""))||s.push({value:t,label:o,sub:(a.model||"устройство")+("Group"===a.model?" · Z2M-группа":"")})}const a=new Set(["group","template","derivative","min_max","threshold","integration","statistics","trend","utility_meter","tod","switch_as_x","schedule"]);for(const[i,o]of Object.entries(t.entities)){const n="entity:"+i;if(e.has(n))continue;const r=a.has(o.platform),l="group"===o.platform;if(!r&&!l)continue;if(o.hidden)continue;const h=t.states[i];s.push({value:n,label:o.name||h?.attributes?.friendly_name||i,sub:i.split(".")[0]+" · "+("group"===o.platform?"группа":"хелпер")})}const o=(this._markerDialog?.bindingFilter||"").toLowerCase().trim(),n=o?s.filter(t=>(t.label+" "+t.sub+" "+t.value).toLowerCase().includes(o)):s;return n.sort((t,e)=>t.label.localeCompare(e.label)),n.slice(0,200)}_allRoomsFlat(){const t=[];for(const e of this._serverCfg?.spaces||[])for(const i of e.rooms||[])i.area&&t.push({value:e.id+"#"+i.area,label:(e.title||e.id)+" · "+i.name});return t}_errText(t){if(!t)return"неизвестная ошибка";if("string"==typeof t)return t;if(t.message)return t.message;if(t.error)return t.error;if(null!=t.code)return"код "+t.code;try{return JSON.stringify(t)}catch{return String(t)}}async _pickMarkerFiles(t){const e=t.target,i=e.files?[...e.files]:[];if(e.value="",!i.length||!this._markerDialog)return;const s=this._markerDialog.devId||"new",a=this.hass?.auth?.data?.access_token,o=[];for(const t of i)try{const e=new FormData;e.append("marker_id",s),e.append("file",t,t.name);const i=await fetch("/api/houseplan/upload",{method:"POST",body:e,headers:a?{authorization:`Bearer ${a}`}:{}}),n=await i.json().catch(()=>({}));if(!i.ok||n.error){const t={too_large:"файл больше "+(n.max_mb||25)+" МБ",bad_ext:"недопустимый тип (нужен PDF/изображение)",unauthorized:"нужны права администратора"};throw new Error(t[n.error]||n.error||"HTTP "+i.status)}o.push({name:n.name||t.name,url:n.url})}catch(e){this._showToast("Файл «"+t.name+"» не загружен: "+this._errText(e))}o.length&&this._markerDialog&&(this._markerDialog={...this._markerDialog,pdfs:[...this._markerDialog.pdfs,...o]},this._showToast("Прикреплено файлов: "+o.length))}_removeMarkerPdf(t){this._markerDialog&&(this._markerDialog={...this._markerDialog,pdfs:this._markerDialog.pdfs.filter(e=>e.url!==t)})}async _saveMarker(){const t=this._markerDialog;if(t&&!t.busy)if("virtual"!==t.binding||t.name.trim()){this._markerDialog={...t,busy:!0};try{const e=this._serverCfg;let i;e.markers=e.markers||[];const[s,a]=t.room?t.room.split("#"):["",""];let o=s||null,n=a||null;"virtual"!==t.binding||o||(o=this._space),i=function(t,e,i){const[s,a]=t.split(":");return"device"===s?a:"entity"===s?"lg_"+a:e&&e.startsWith("v_")?e:i()}(t.binding,t.devId,()=>"v_"+Date.now().toString(36));const r=t.devId,l={id:i,binding:t.binding,name:t.name.trim()||null,icon:t.icon||null,model:t.model.trim()||null,link:t.link.trim()||null,description:t.description.trim()||null,pdfs:t.pdfs};("virtual"===t.binding||t.room)&&(l.space=o,l.area=n);const h=r?this._devices.find(t=>t.id===r):null,c=!!t.room&&null!=h&&(h.space!==o||h.area!==n);if(e.markers=e.markers.filter(t=>t.id!==i&&t.id!==r),e.markers.push(l),!this._layout[i]||c){const t=this._spaceModel(o||void 0);let e=t.vb[0]+t.vb[2]/2,s=t.vb[1]+t.vb[3]/2;if(n){const i=t.rooms.find(t=>t.area===n);i&&([e,s]=this._roomCenter(i))}this._layout[i]=this._normPos(o||this._space,e,s)}await this._saveConfigNow(),await this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство сохранено")}catch(t){this._markerDialog={...this._markerDialog,busy:!1},this._showToast("Ошибка: "+(t?.message||t))}}else this._showToast("Укажите имя виртуального устройства")}async _deleteMarker(){const t=this._markerDialog;if(!t)return;const e=t.devId?this._devices.find(e=>e.id===t.devId):null,i=t.name||"устройство";if(!confirm(`Убрать «${i}» с плана?`))return;const s=this._serverCfg;s.markers=s.markers||[],e&&"virtual"===e.bindingKind?s.markers=s.markers.filter(t=>t.id!==e.id):e&&e.marker?(s.markers=s.markers.filter(t=>t.id!==e.id),"device"===e.bindingKind&&e.bindingRef?s.markers.push({id:e.id,binding:"device:"+e.bindingRef,hidden:!0}):"entity"===e.bindingKind&&e.bindingRef&&s.markers.push({id:e.id,binding:"entity:"+e.bindingRef,hidden:!0})):e&&"device"===e.bindingKind&&e.bindingRef?s.markers.push({id:e.id,binding:"device:"+e.bindingRef,hidden:!0}):e&&"entity"===e.bindingKind&&e.bindingRef&&s.markers.push({id:e.id,binding:"entity:"+e.bindingRef,hidden:!0});try{await this._saveConfigNow(),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство убрано с плана")}catch(t){this._showToast("Ошибка: "+(t?.message||t))}}_normPos(t,e,i){const s=this._serverCfg.spaces.find(e=>e.id===t)?.aspect||1;return{s:t,x:e/kt,y:i/(kt/s)}}_openSpaceDialog(t,e){if(this._serverStorage&&this._serverCfg)if("edit"===t){const i=this._serverCfg.spaces.find(t=>t.id===e);if(!i)return;this._spaceDialog={mode:t,spaceId:e,title:i.title,planUrl:i.plan_url||null,planFile:null,busy:!1}}else this._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,busy:!1};else this._showToast("Интеграция House Plan не установлена — управление недоступно")}async _pickPlanFile(t){const e=t.target,i=e.files?.[0];if(!i||!this._spaceDialog)return;const s={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[i.type]||(i.name.toLowerCase().endsWith(".svg")?"svg":"");if(!s)return void this._showToast("Поддерживаются SVG, PNG, JPG, WebP");const a=new Uint8Array(await i.arrayBuffer());let o="";for(let t=0;t{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:1.414),e.onerror=()=>t(1.414),e.src=r});URL.revokeObjectURL(r),this._spaceDialog={...this._spaceDialog,planFile:{ext:s,b64:n,aspect:l,name:i.name}}}async _saveSpaceDialog(){const t=this._spaceDialog;if(!t||t.busy||!t.title.trim())return;if(!t.planFile&&!t.planUrl)return void this._showToast("Загрузите подложку — план этажа обязателен");const e="create"===t.mode&&0===(this._serverCfg?.spaces.length||0);this._spaceDialog={...t,busy:!0};try{const i=this._serverCfg;let s;if("create"===t.mode?(s={id:"s"+Date.now().toString(36),title:t.title.trim(),plan_url:null,aspect:1.414,view_box:[0,0,1,1],rooms:[],segments:[]},i.spaces.push(s)):(s=i.spaces.find(e=>e.id===t.spaceId),s.title=t.title.trim()),t.planFile){const e=await this.hass.callWS({type:"houseplan/plan/set",space_id:s.id,ext:t.planFile.ext,data:t.planFile.b64});s.plan_url=e.url,s.aspect=t.planFile.aspect}await this._saveConfigNow(),this._spaceDialog=null,"create"===t.mode&&(this._space=s.id),this._regSignature="",this._maybeRebuildDevices(),e?(this._markup=!0,this._tool="draw",this._path=[],this._cursorPt=null,this._showToast("Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.")):this._showToast("create"===t.mode?"Пространство добавлено":"Пространство сохранено")}catch(t){this._spaceDialog={...this._spaceDialog,busy:!1},this._showToast("Ошибка: "+(t?.message||t))}}async _deleteSpace(){const t=this._spaceDialog;if(!t||"edit"!==t.mode)return;const e=this._serverCfg.spaces.find(e=>e.id===t.spaceId);if(confirm(`Удалить пространство «${e.title}» со всеми комнатами и разметкой?`)){this._serverCfg.spaces=this._serverCfg.spaces.filter(e=>e.id!==t.spaceId);try{await this._saveConfigNow(),this._spaceDialog=null,this._space===t.spaceId&&(this._space=this._serverCfg.spaces[0]?.id||""),this._regSignature="",this._maybeRebuildDevices(),this._showToast("Пространство удалено")}catch(t){this._showToast("Ошибка удаления: "+(t?.message||t))}}}async _saveConfigNow(){const t=await this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev});this._cfgRev=t?.rev??this._cfgRev+1}render(){if(!this._config||!this.hass)return W;const t=this._model;if(!t.length)return L` -
-
${this._config.title||"План дома"}
-
-
- -

Пространств пока нет.

- ${this._serverStorage?L`

Добавьте первое пространство и загрузите план этажа.

- `:L`

Установите интеграцию House Plan и добавьте запись в «Устройства и службы».

`} -
- ${this._spaceDialog?this._renderSpaceDialog():W} - ${this._toast?L`
${this._toast}
`:W} -
`;const e=this._spaceModel(),i=e.vb,s=this._devices.filter(t=>t.space===e.id),a=this._config.icon_size??2.5,o=a>8?2.5:a,n=this._viewOr(i);return L` - -
-
-
- - ${this._config.title||"План дома"} -
-
- ${t.map(t=>L``)} - ${this._norm?L``:W} -
- ${s.length} устр. - -
- - - -
- ${this._norm?L``:W} - ${this._norm?L` - `:W} - -
- ${this._markup?this._renderMarkupBar():W} -
- -
this._markupClick(t)} - @wheel=${t=>this._onWheel(t)} - @pointerdown=${t=>this._stagePointerDown(t)} - @pointermove=${t=>this._stagePointerMove(t)} - @pointerup=${t=>this._stagePointerUp(t)} - @pointercancel=${t=>this._stagePointerUp(t)}> -
- - ${this._markup?this._renderMarkupDefs(i):W} - ${e.bg?j``:W} - ${e.rooms.filter(t=>t.area||this._markup).map(t=>{const i="room "+(e.bg?"overlay":"yard")+(this._markup?" outlined":""),s=e=>this._showTip(e,t.name,"комната — открыть зону",this._config?.show_signal?this._roomLqi(t.area):null),a=!e.bg||this._markup,o=this._roomCenter(t),n=t.poly?j`this._clickRoom(t)} @mousemove=${s} - @mouseleave=${()=>this._tip=null}>`:j`this._clickRoom(t)} @mousemove=${s} - @mouseleave=${()=>this._tip=null}>`;return j`${n}${a?j`${t.name}`:W}`})} - ${this._markup?this._renderMarkupLayer(i):W} - -
- ${s.map(t=>this._renderDevice(t,n))} -
-
- ${this._zoom>1?L`
${Math.round(100*this._zoom)}%
`:W} -
- - ${this._roomDialog?this._renderRoomDialog():W} - ${this._spaceDialog?this._renderSpaceDialog():W} - ${this._markerDialog?this._renderMarkerDialog():W} - ${this._infoCard?this._renderInfoCard():W} - ${this._tip?L`
- ${this._tip.title}${this._tip.meta?L`${this._tip.meta}`:W} - ${null!=this._tip.lqi?L`средний сигнал zigbee: - ${this._tip.lqi}`:W} -
`:W} - ${this._toast?L`
${this._toast}
`:W} -
- `}_renderDevice(t,e){const i=this._pos(t),s=(i.x-e.x)/e.w*100,a=(i.y-e.y)/e.h*100,o=this._stateClass(t),n=this._liveTemp(t),r=this._config?.show_signal&&!t.virtual?this._lqiFor(t.entities):null;return L`
this._clickDevice(e,t)} - @mousemove=${e=>this._showTip(e,t.name,t.model+(null!=n?" · "+n+"°":"")+(null!=r?" · LQI "+r:""))} - @mouseleave=${()=>this._tip=null} - @pointerdown=${e=>this._pointerDown(e,t)} - @pointermove=${e=>this._pointerMove(e,t)} - @pointerup=${e=>this._pointerUp(e,t)} - > - - ${null!=n?L`${n}°`:W} - ${null!=r?L`${r}`:W} -
`}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_renderMarkupDefs(t){const e=this._gridPitch,i=.14*e;return j` - - - - - - - `}_renderMarkupLayer(t){const e=this._segments,i=this._path,s=this._gridPitch;return j` - - ${e.map(t=>j``)} - ${i.length>1?j``:W} - ${i.length&&this._cursorPt&&"draw"===this._tool&&!this._contourClosed?j``:W} - ${i.map((t,e)=>j``)} - `}_renderMarkupBar(){return L`
- - - - - - ${"draw"===this._tool?L`${this._path.length?"точек: "+this._path.length+" · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой":"кликните точку сетки, чтобы начать контур"} - ${this._path.length?L``:W}`:W} -
`}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null;return L``}_renderMarkerDialog(){const t=this._markerDialog,e="virtual"===t.binding,i=this._bindingCandidates(),s=(()=>{if(e)return null;const s=i.find(e=>e.value===t.binding);if(s)return s.label;const[a,o]=t.binding.split(":");return"device"===a?this.hass.devices[o]?.name_by_user||this.hass.devices[o]?.name||o:this.hass.states[o]?.attributes?.friendly_name||o})();return L``}_renderSpaceDialog(){const t=this._spaceDialog;return L``}_renderRoomDialog(){const t=this._freeAreas;return L``}}At.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_markup:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},At.styles=((t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new a(s,t,i)})` + >`):B}_valueChanged(t){const e={...this._config,...t.detail.value},i=new Event("config-changed",{bubbles:!0,composed:!0});i.detail={config:e},this.dispatchEvent(i)}}At.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",At);const zt=((t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new a(s,t,i)})` :host { --hp-bg: var(--card-background-color, #16212e); --hp-line: var(--divider-color, #2b3d4f); @@ -1038,4 +727,315 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow z-index: 120; max-width: 90vw; } - `,customElements.get("houseplan-card")||customElements.define("houseplan-card",At),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.9.3 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `,Mt="houseplan_card_layout_v1",Dt="houseplan_card_cfg_v1",Pt="houseplan_card_zoom_v1",Et=1e3,Rt=(t,e,i)=>{const s=new Event(e,{bubbles:!0,composed:!0});s.detail=i??{},t.dispatchEvent(s)},Tt=(t,e)=>{let i;return(...s)=>{clearTimeout(i),i=window.setTimeout(()=>t(...s),e)}};class Ot extends rt{constructor(){super(...arguments),this._space="f1",this._layout={},this._serverStorage=!1,this._loadOk=!1,this._loading=!1,this._loadTries=0,this._serverCfg=null,this._cfgRev=0,this._unsubCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._tip=null,this._selId=null,this._toast="",this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._zoom=1,this._view=null,this._zoomBySpace={},this._pointers=new Map,this._panStart=null,this._pinchStart=null,this._suppressClick=!1,this._onboardingShown=!1,this._infoCard=null,this._markerDialog=null,this._spaceDialog=null,this._keyHandler=t=>this._onKey(t),this._drag=null,this._dirtyPos=new Set,this._persistLayout=Tt(()=>{if(this._serverStorage){const t=[...this._dirtyPos];this._dirtyPos.clear();for(const e of t){const t=this._layout[e];t&&this.hass.callWS({type:"houseplan/layout/update",device_id:e,pos:t}).catch(t=>this._showToast("Не удалось сохранить позицию: "+(t?.message||t)))}this._cacheSnapshot()}else localStorage.setItem(Mt,JSON.stringify(this._layout))},600),this._saveConfig=Tt(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev}).then(t=>{this._cfgRev=t?.rev??this._cfgRev+1}).catch(t=>{"conflict"===t?.code?(this._showToast("Конфиг изменён в другом окне — данные обновлены, повторите последнее действие"),this._cancelPath(),this._reloadConfigOnly()):this._showToast("Не удалось сохранить конфиг: "+(t?.message||t))})},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),this._roViewport?.disconnect(),this._roViewport=void 0,this._unsubCfg&&(this._unsubCfg(),this._unsubCfg=null),super.disconnectedCallback()}_onKey(t){if("Escape"===t.key){if(this._infoCard)return void(this._infoCard=null);if(this._markerDialog)return void(this._markerDialog=null)}if(!this._markup)return;return"Escape"===t.key||(t.ctrlKey||t.metaKey)&&"z"===t.key.toLowerCase()?this._roomDialog?(t.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(t.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const t=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),t&&this._removeSegmentByKey(t),this._path=this._path.slice(0,-1)}_removeSegmentByKey(t){const e=this._curSpaceCfg;if(!e?.segments)return;const i=this._segments.findIndex(e=>this._segKey([e[0],e[1]],[e[2],e[3]])===t);i>=0&&(e.segments.splice(i,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(t){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...t},t.default_floor&&(this._space=t.default_floor);try{this._zoomBySpace=JSON.parse(localStorage.getItem(Pt)||"{}")||{}}catch{this._zoomBySpace={}}try{const e=JSON.parse(localStorage.getItem(Dt)||"null");e&&e.config&&Array.isArray(e.config.spaces)&&(this._serverCfg=e.config,this._cfgRev=e.rev||0,this._layout=e.layout||{},this._serverStorage=!0,t.default_floor?this._space=t.default_floor:this._model.find(t=>t.id===this._space)||(this._space=this._model[0]?.id||this._space))}catch{}}_cacheSnapshot(){if(this._serverCfg)try{localStorage.setItem(Dt,JSON.stringify({config:this._serverCfg,rev:this._cfgRev,layout:this._layout}))}catch{}}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){return this._serverCfg?this._serverCfg.spaces.map(t=>{const e=Et/t.aspect;return{id:t.id,title:t.title,vb:[t.view_box[0]*Et,t.view_box[1]*e,t.view_box[2]*Et,t.view_box[3]*e],bg:t.plan_url?{href:t.plan_url,x:0,y:0,w:Et,h:e}:null,rooms:t.rooms.map(t=>({id:t.id,name:t.name,area:t.area??null,x:null!=t.x?t.x*Et:void 0,y:null!=t.y?t.y*e:void 0,w:null!=t.w?t.w*Et:void 0,h:null!=t.h?t.h*e:void 0,poly:t.poly?t.poly.map(t=>[t[0]*Et,t[1]*e]):void 0}))}}):[]}_spaceModel(t){const e=this._model;return e.find(e=>e.id===(t??this._space))||e[0]}get _areaToSpace(){const t={};for(const e of this._model)for(const i of e.rooms)i.area&&(t[i.area]={space:e.id,room:i});return t}get _settings(){return this._serverCfg?.settings||{}}get _showAll(){return!!this._settings.show_all}_toggleShowAll(){this._serverCfg&&(this._serverCfg={...this._serverCfg,settings:{...this._serverCfg.settings,show_all:!this._showAll}},this._regSignature="",this._maybeRebuildDevices(),this._saveConfig(),this.requestUpdate())}get _excluded(){const t=this._settings.exclude_integrations;return t?new Set(t):ct}willUpdate(t){t.has("hass")&&this.hass&&(!this._loadOk&&!this._loading&&this._loadTries<8&&this._loadFromServer(),this._maybeRebuildDevices())}updated(){const t=this._stageEl;t&&!this._roViewport&&(this._roViewport=new ResizeObserver(()=>this._refitView()),this._roViewport.observe(t)),t&&!this._view&&this._refitView(),this._serverStorage&&this._loadOk&&0===this._model.length&&!this._spaceDialog&&!this._onboardingShown&&(this._onboardingShown=!0,this._openSpaceDialog("create"))}async _loadFromServer(){this._loading=!0,this._loadTries++;try{const[t,e]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._loadOk=!0,this._serverStorage=!0;const i=t?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._cfgRev=t?.rev||0,this._layout=e?.layout||{},this._unsubCfg||(this._unsubCfg=await this.hass.connection.subscribeEvents(t=>{(t?.data?.rev??-1)!==this._cfgRev&&this._reloadConfigOnly()},"houseplan_config_updated")),this._norm&&!this._model.find(t=>t.id===this._space)&&(this._space=this._model[0]?.id||this._space),this._cacheSnapshot(),this._restoreZoom()}catch(t){if(this._loadTries>=8){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(Mt)||"{}")||{}}catch{this._layout={}}}}finally{this._loading=!1}this._regSignature="",this.requestUpdate()}async _reloadConfigOnly(){try{const t=await this.hass.callWS({type:"houseplan/config/get"}),e=t?.config;this._serverCfg=e&&Array.isArray(e.spaces)?e:null,this._cfgRev=t?.rev||0,this._cacheSnapshot(),this._regSignature="",this._maybeRebuildDevices(),this.requestUpdate()}catch{}}_maybeRebuildDevices(){const t=this.hass;if(!t?.devices||!t?.entities||!t?.areas)return;const e=Object.keys(t.devices).length+":"+Object.keys(t.entities).length+":"+Object.keys(t.areas).length+":"+(this._norm?"n":"l");e===this._regSignature&&this._devices.length||(this._regSignature=e,this._devices=St({hass:t,areaToSpace:Object.fromEntries(Object.entries(this._areaToSpace).map(([t,e])=>[t,e.space])),markers:this._markers,settings:this._settings,excluded:this._excluded,showAll:this._showAll,firstSpaceId:this._model[0]?.id||""}),this._defPos=this._defaultPositions())}get _markers(){return this._serverCfg?.markers||[]}_roomLqi(t){if(!t)return null;const e=[];for(const i of this._devices){if(i.area!==t||i.virtual)continue;const s=$t(this.hass,i.entities);null!=s&&e.push(s)}return gt(e)}_roomBounds(t){if(t.poly&&t.poly.length){const e=t.poly.map(t=>t[0]),i=t.poly.map(t=>t[1]),s=Math.min(...e),a=Math.min(...i);return{x:s,y:a,w:Math.max(...e)-s,h:Math.max(...i)-a}}return{x:t.x??0,y:t.y??0,w:t.w??0,h:t.h??0}}_defaultPositions(){const t={},e=(this._config?.icon_size??2.5)/100*Et*1.3;for(const i of this._model)for(const s of i.rooms){if(!s.area)continue;const a=this._devices.filter(t=>t.area===s.area&&t.space===i.id);if(!a.length)continue;const o=this._roomBounds(s),n=.1*Math.min(o.w,o.h),r=o.w-2*n,l=o.h-2*n,c=Math.max(1,Math.round(Math.sqrt(a.length*r/Math.max(l,1)))),h=Math.ceil(a.length/c),d=r/c,p=l/Math.max(h,1),u=a.map((t,e)=>({x:o.x+n+d*(e%c+.5),y:o.y+n+p*(Math.floor(e/c)+.5)}));ft(u,o,e,.5*n),a.forEach((e,i)=>t[e.id]=u[i])}return t}_pos(t){const e=this._spaceModel(t.space),i=this._layout[t.id];if(i)if(this._norm){if(i.s===t.space){const e=this._serverCfg.spaces.find(e=>e.id===t.space)?.aspect||1;return{x:i.x*Et,y:i.y*(Et/e)}}}else if(void 0===i.s)return{x:i.x,y:i.y};if(this._defPos[t.id])return this._defPos[t.id];const s=e.vb;return{x:s[0]+s[2]/2,y:s[1]+s[3]/2}}_savePos(t,e,i){if(this._norm){const s=this._gridPitch,a=Math.round(e/s)*s,o=Math.round(i/s)*s,n=this._serverCfg.spaces.find(e=>e.id===t.space)?.aspect||1;this._layout={...this._layout,[t.id]:{s:t.space,x:a/Et,y:o/(Et/n)}}}else this._layout={...this._layout,[t.id]:{x:Math.round(e),y:Math.round(i)}};this._dirtyPos.add(t.id),this._persistLayout()}_stateClass(t){if(!this._config?.live_states)return"";const e=t.primary?this.hass.states[t.primary]:void 0;if(!e)return"";if("unavailable"===e.state)return"unavail";const i=e.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(i))return"on"===e.state?"on":"";if("cover"===i||"valve"===i)return["open","opening"].includes(e.state)?"open":"";if("lock"===i)return["unlocked","open"].includes(e.state)?"open":"";if("binary_sensor"===i){const t=e.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(t))return"on"===e.state?"open":""}return"media_player"===i?["playing","on"].includes(e.state)?"on":"":"vacuum"===i&&["cleaning","returning"].includes(e.state)?"on":""}_liveTemp(t){return this._config?.show_temperature?"mdi:thermometer"!==t.icon&&"mdi:air-filter"!==t.icon?null:wt(this.hass,t.entities):null}_openMoreInfo(t){t?Rt(this,"hass-more-info",{entityId:t}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(t,e){t.stopPropagation(),this._drag?.moved||this._suppressClick||this._markup||(this._infoCard=e)}get _stageEl(){return this.renderRoot.querySelector(".stage")}_stageAspect(){const t=this._stageEl,e=this._spaceModel().vb;return t&&t.clientHeight?t.clientWidth/t.clientHeight:e[2]/e[3]}_viewOr(t){return this._view&&this._view.w?this._view:mt(t,this._stageAspect())}_screenToVb(t,e){const i=this._stageEl,s=this._viewOr(this._spaceModel().vb),a=i?.clientWidth||1,o=i?.clientHeight||1;return[s.x+t/a*s.w,s.y+e/o*s.h]}_clampView(t,e){return{w:t.w,h:t.h,x:Math.max(e.x,Math.min(e.x+e.w-t.w,t.x)),y:Math.max(e.y,Math.min(e.y+e.h-t.h,t.y))}}_applyView(t,e,i){const s=this._spaceModel().vb,a=mt(s,this._stageAspect()),o=Math.min(8,Math.max(1,t)),n=a.w/o,r=a.h/o,l=this._viewOr(s),c=e??l.x+l.w/2,h=i??l.y+l.h/2;this._zoom=o,this._view=this._clampView({x:c-n/2,y:h-r/2,w:n,h:r},a)}_refitView(){if(!this._stageEl)return;const t=this._view;this._applyView(this._zoom,t?t.x+t.w/2:void 0,t?t.y+t.h/2:void 0),this.requestUpdate()}_zoomAt(t,e,i){const s=this._stageEl;if(!s)return;const a=mt(this._spaceModel().vb,this._stageAspect()),o=Math.min(8,Math.max(1,i)),n=s.clientWidth,r=s.clientHeight,l=this._screenToVb(t,e),c=a.w/o,h=a.h/o;this._zoom=o,this._view=this._clampView({x:l[0]-t/n*c,y:l[1]-e/r*h,w:c,h:h},a)}_onWheel(t){const e=this._stageEl;if(!e)return;t.preventDefault();const i=e.getBoundingClientRect(),s=t.deltaY<0?1.15:1/1.15;this._zoomAt(t.clientX-i.left,t.clientY-i.top,this._zoom*s),this._saveZoom()}_stepZoom(t){const e=this._stageEl;e&&(this._zoomAt(e.clientWidth/2,e.clientHeight/2,this._zoom*(t>0?1.4:1/1.4)),this._saveZoom())}_resetZoom(){const t=this._spaceModel().vb;this._zoom=1,this._view=mt(t,this._stageAspect()),this._saveZoom()}_saveZoom(){this._zoomBySpace={...this._zoomBySpace,[this._space]:this._zoom};try{localStorage.setItem(Pt,JSON.stringify(this._zoomBySpace))}catch{}}_restoreZoom(){const t=this._zoomBySpace[this._space]||1;this._zoom=t,this._view=null,requestAnimationFrame(()=>{if(!this._stageEl)return;const e=this._spaceModel().vb;this._applyView(t,e[0]+e[2]/2,e[1]+e[3]/2),this.requestUpdate()})}_stagePointerDown(t){if(this._drag||this._markup)return;if(t.target.closest(".dev"))return;this._pointers.set(t.pointerId,{x:t.clientX,y:t.clientY});const e=this._viewOr(this._spaceModel().vb);if(1===this._pointers.size)this._panStart={sx:t.clientX,sy:t.clientY,vx:e.x,vy:e.y},this._suppressClick=!1;else if(2===this._pointers.size){const t=[...this._pointers.values()],e=Math.hypot(t[0].x-t[1].x,t[0].y-t[1].y);this._pinchStart={dist:e,zoom:this._zoom},this._panStart=null}}_stagePointerMove(t){if(this._pointers.has(t.pointerId)){if(this._pointers.set(t.pointerId,{x:t.clientX,y:t.clientY}),this._pinchStart&&this._pointers.size>=2){const t=[...this._pointers.values()],e=Math.hypot(t[0].x-t[1].x,t[0].y-t[1].y)/(this._pinchStart.dist||1),i=this._stageEl.getBoundingClientRect(),s=(t[0].x+t[1].x)/2-i.left,a=(t[0].y+t[1].y)/2-i.top;this._zoomAt(s,a,this._pinchStart.zoom*e),this._suppressClick=!0,this._saveZoom()}else if(this._panStart){const e=t.clientX-this._panStart.sx,i=t.clientY-this._panStart.sy;if(Math.abs(e)+Math.abs(i)>4&&(this._suppressClick=!0),this._zoom>1&&this._view){const t=this._stageEl,s=this._view,a=mt(this._spaceModel().vb,this._stageAspect());this._view=this._clampView({x:this._panStart.vx-e/t.clientWidth*s.w,y:this._panStart.vy-i/t.clientHeight*s.h,w:s.w,h:s.h},a)}}}else this._markupMove(t)}_stagePointerUp(t){this._pointers.delete(t.pointerId),this._pointers.size<2&&(this._pinchStart=null),0===this._pointers.size&&(this._panStart=null,setTimeout(()=>this._suppressClick=!1,0))}_clickRoom(t){var e;!this._suppressClick&&t.area&&(e="/config/areas/area/"+t.area,history.pushState(null,"",e),Rt(window,"location-changed",{replace:!1}))}_pointerDown(t,e){if(this._markup)return;t.preventDefault();const i=this._pos(e);this._drag={id:e.id,sx:t.clientX,sy:t.clientY,ox:i.x,oy:i.y,moved:!1},t.target.setPointerCapture(t.pointerId),this._tip=null}_pointerMove(t,e){if(!this._drag||this._drag.id!==e.id)return;const i=this.renderRoot.querySelector(".stage");if(!i)return;const s=this._spaceModel().vb,a=i.getBoundingClientRect(),o=this._viewOr(s),n=(t.clientX-this._drag.sx)/a.width*o.w,r=(t.clientY-this._drag.sy)/a.height*o.h;Math.abs(t.clientX-this._drag.sx)+Math.abs(t.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const l=.008*Math.min(s[2],s[3]),c=Math.max(s[0]+l,Math.min(s[0]+s[2]-l,this._drag.ox+n)),h=Math.max(s[1]+l,Math.min(s[1]+s[3]-l,this._drag.oy+r));this._savePos(e,c,h)}_pointerUp(t,e){if(!this._drag||this._drag.id!==e.id)return;const i=this._drag.moved;this._drag=i?this._drag:null,i&&(this._selId=e.id,window.setTimeout(()=>this._drag=null,0))}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(t){this._toast=t,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(t,e,i,s){this._drag||(this._tip={x:t.clientX,y:t.clientY,title:e,meta:i,lqi:s})}get _gridPitch(){return Et/240}get _curSpaceCfg(){return this._serverCfg?.spaces.find(t=>t.id===this._space)}get _spaceH(){const t=this._curSpaceCfg;return t?Et/t.aspect:Et}get _segments(){const t=this._curSpaceCfg,e=this._spaceH;return(t?.segments||[]).map(t=>[t[0]*Et,t[1]*e,t[2]*Et,t[3]*e])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(t){const e=this.renderRoot.querySelector(".stage").getBoundingClientRect();return this._screenToVb(t.clientX-e.left,t.clientY-e.top)}_snap(t){const e=this._gridPitch;return[_t(t[0],e),_t(t[1],e)]}_samePt(t,e){return function(t,e,i=.001){return Math.abs(t[0]-e[0])this._segKey([t[0],t[1]],[t[2],t[3]])===a);return!o&&(i.segments=i.segments||[],i.segments.push([t[0]/Et,t[1]/s,e[0]/Et,e[1]/s]),this._saveConfig(),!0)}_distToSeg(t,e){const[i,s]=t,[a,o,n,r]=e,l=n-a,c=r-o;let h=((i-a)*l+(s-o)*c)/(l*l+c*c||1);h=Math.max(0,Math.min(1,h));const d=a+h*l,p=o+h*c;return Math.hypot(i-d,s-p)}_pointInRoom(t,e){return e.poly?function(t,e){let i=!1;for(let s=0,a=e.length-1;st[1]!=l>t[1]&&t[0]<(r-o)*(t[1]-n)/(l-n)+o&&(i=!i)}return i}(t,e.poly):null!=e.x&&t[0]>=e.x&&t[0]<=e.x+e.w&&t[1]>=e.y&&t[1]<=e.y+e.h}_markupClick(t){if(!this._markup)return;const e=this._svgPoint(t);if("erase"===this._tool){const t=this._curSpaceCfg;if(!t?.segments?.length)return;const i=this._segments;let s=-1,a=.5*this._gridPitch;return i.forEach((t,i)=>{const o=this._distToSeg(e,t);o=0&&(t.segments.splice(s,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const t=[...this._spaceModel().rooms].reverse().find(t=>this._pointInRoom(e,t));if(!t)return;if(!confirm(`Удалить комнату «${t.name}»?`))return;const i=this._curSpaceCfg;return i.rooms=i.rooms.filter(e=>e.id!==t.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const i=this._snap(e);if(!this._path.length)return this._path=[i],void(this._pathSegs=[]);const s=this._path[this._path.length-1];if(this._samePt(i,s))return;const a=this._addSegment(s,i);this._pathSegs=[...this._pathSegs,a?this._segKey(s,i):null],this._path=[...this._path,i],this._path.length>=4&&this._samePt(i,this._path[0])&&(this._cursorPt=null,this._nameSel="",this._areaSel="",this._roomDialog=!0)}get _contourClosed(){return this._path.length>=4&&this._samePt(this._path[0],this._path[this._path.length-1])}_markupMove(t){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(t)))}_saveRoom(){this._areaSel&&this._commitRoom()}_saveRoomNoArea(){this._nameSel.trim()&&(this._areaSel="",this._commitRoom())}_commitRoom(){if(!this._contourClosed)return;const t=this._curSpaceCfg;if(!t)return;const e=this._spaceH,i=this._path.slice(0,-1),s=this._areaSel?this.hass.areas[this._areaSel]?.name:"";t.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||s||"Комната",area:this._areaSel||null,poly:i.map(t=>[t[0]/Et,t[1]/e])}),this._saveConfig(),this._path=[],this._pathSegs=[];const a=this._areaSel;this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices();let o=0;if(a){const t=this._serverCfg?.spaces.find(t=>t.id===this._space)?.aspect||1,e=Et/t,i={...this._layout};for(const t of this._devices){if(t.area!==a||t.space!==this._space)continue;if(o++,this._layout[t.id])continue;const s=this._defPos[t.id];s&&(i[t.id]={s:this._space,x:s.x/Et,y:s.y/e},this._dirtyPos.add(t.id))}this._layout=i,this._persistLayout()}const n=this._model.find(t=>t.id===this._space)?.rooms.length||0;this._showToast(a?`Комната сохранена (${n}). Устройств добавлено: ${o}. Обведите следующую или выйдите из разметки.`:`Комната сохранена (${n}, без зоны). Обведите следующую или выйдите из разметки.`)}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const t=new Set;for(const e of this._serverCfg?.spaces||[])for(const i of e.rooms||[])i.area&&t.add(i.area);return Object.values(this.hass?.areas||{}).filter(e=>!t.has(e.area_id)).sort((t,e)=>(t.name||"").localeCompare(e.name||""))}_openMarkerDialog(t){this._norm?this._markerDialog=t?{devId:t.id,name:t.name,binding:"virtual"===t.bindingKind?"virtual":t.bindingKind+":"+t.bindingRef,bindingFilter:"",icon:t.marker?.icon||"",model:t.model||"",link:t.link||"",description:t.description||"",pdfs:[...t.pdfs||[]],room:t.space&&t.area?t.space+"#"+t.area:"",busy:!1}:{name:"",binding:"virtual",bindingFilter:"",icon:"",model:"",link:"",description:"",pdfs:[],room:"",busy:!1}:this._showToast("Редактирование устройств доступно после переноса конфига на сервер")}_bindingCandidates(){const t=this.hass,e=new Set;for(const t of this._devices)t.id!==this._markerDialog?.devId&&("device"===t.bindingKind&&t.bindingRef&&e.add("device:"+t.bindingRef),"entity"===t.bindingKind&&t.bindingRef&&e.add("entity:"+t.bindingRef));const i=new Set;for(const t of this._devices)"device"===t.bindingKind&&t.name&&i.add(t.name.trim()+"|"+(t.area||""));const s=[];for(const a of Object.values(t.devices)){if("service"===a.entry_type)continue;const t="device:"+a.id;if(e.has(t))continue;const o=(a.name_by_user||a.name||a.id).trim();t!==this._markerDialog?.binding&&i.has(o+"|"+(a.area_id||""))||s.push({value:t,label:o,sub:(a.model||"устройство")+("Group"===a.model?" · Z2M-группа":"")})}const a=new Set(["group","template","derivative","min_max","threshold","integration","statistics","trend","utility_meter","tod","switch_as_x","schedule"]);for(const[i,o]of Object.entries(t.entities)){const n="entity:"+i;if(e.has(n))continue;const r=a.has(o.platform),l="group"===o.platform;if(!r&&!l)continue;if(o.hidden)continue;const c=t.states[i];s.push({value:n,label:o.name||c?.attributes?.friendly_name||i,sub:i.split(".")[0]+" · "+("group"===o.platform?"группа":"хелпер")})}const o=(this._markerDialog?.bindingFilter||"").toLowerCase().trim(),n=o?s.filter(t=>(t.label+" "+t.sub+" "+t.value).toLowerCase().includes(o)):s;return n.sort((t,e)=>t.label.localeCompare(e.label)),n.slice(0,200)}_allRoomsFlat(){const t=[];for(const e of this._serverCfg?.spaces||[])for(const i of e.rooms||[])i.area&&t.push({value:e.id+"#"+i.area,label:(e.title||e.id)+" · "+i.name});return t}_errText(t){if(!t)return"неизвестная ошибка";if("string"==typeof t)return t;if(t.message)return t.message;if(t.error)return t.error;if(null!=t.code)return"код "+t.code;try{return JSON.stringify(t)}catch{return String(t)}}async _pickMarkerFiles(t){const e=t.target,i=e.files?[...e.files]:[];if(e.value="",!i.length||!this._markerDialog)return;const s=this._markerDialog.devId||"new",a=[];for(const t of i)try{const e=new FormData;e.append("marker_id",s),e.append("file",t,t.name);const i=this.hass?.fetchWithAuth?await this.hass.fetchWithAuth("/api/houseplan/upload",{method:"POST",body:e}):await fetch("/api/houseplan/upload",{method:"POST",body:e,headers:this.hass?.auth?.data?.access_token?{authorization:`Bearer ${this.hass.auth.data.access_token}`}:{}}),o=await i.json().catch(()=>({}));if(!i.ok||o.error){const t={too_large:"файл больше "+(o.max_mb||25)+" МБ",bad_ext:"недопустимый тип (нужен PDF/изображение)",unauthorized:"нужны права администратора"};throw new Error(t[o.error]||o.error||"HTTP "+i.status)}a.push({name:o.name||t.name,url:o.url})}catch(e){this._showToast("Файл «"+t.name+"» не загружен: "+this._errText(e))}a.length&&this._markerDialog&&(this._markerDialog={...this._markerDialog,pdfs:[...this._markerDialog.pdfs,...a]},this._showToast("Прикреплено файлов: "+a.length))}_removeMarkerPdf(t){this._markerDialog&&(this._markerDialog={...this._markerDialog,pdfs:this._markerDialog.pdfs.filter(e=>e.url!==t)})}async _saveMarker(){const t=this._markerDialog;if(t&&!t.busy)if("virtual"!==t.binding||t.name.trim()){this._markerDialog={...t,busy:!0};try{const e=this._serverCfg;let i;e.markers=e.markers||[];const[s,a]=t.room?t.room.split("#"):["",""];let o=s||null,n=a||null;"virtual"!==t.binding||o||(o=this._space),i=function(t,e,i){const[s,a]=t.split(":");return"device"===s?a:"entity"===s?"lg_"+a:e&&e.startsWith("v_")?e:i()}(t.binding,t.devId,()=>"v_"+Date.now().toString(36));const r=t.devId,l={id:i,binding:t.binding,name:t.name.trim()||null,icon:t.icon||null,model:t.model.trim()||null,link:t.link.trim()||null,description:t.description.trim()||null,pdfs:t.pdfs};("virtual"===t.binding||t.room)&&(l.space=o,l.area=n);const c=r?this._devices.find(t=>t.id===r):null,h=!!t.room&&null!=c&&(c.space!==o||c.area!==n);e.markers=e.markers.filter(t=>t.id!==i&&t.id!==r),e.markers.push(l);let d=null;if(!this._layout[i]||h){const t=this._spaceModel(o||void 0);let e=t.vb[0]+t.vb[2]/2,s=t.vb[1]+t.vb[3]/2;if(n){const i=t.rooms.find(t=>t.area===n);i&&([e,s]=this._roomCenter(i))}d=this._normPos(o||this._space,e,s),this._layout={...this._layout,[i]:d}}await this._saveConfigNow(),d&&await this.hass.callWS({type:"houseplan/layout/update",device_id:i,pos:d}),r&&r!==i&&(delete this._layout[r],await this.hass.callWS({type:"houseplan/layout/delete",device_id:r}).catch(()=>{})),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство сохранено")}catch(t){this._markerDialog={...this._markerDialog,busy:!1},this._showToast("Ошибка: "+this._errText(t))}}else this._showToast("Укажите имя виртуального устройства")}async _deleteMarker(){const t=this._markerDialog;if(!t)return;const e=t.devId?this._devices.find(e=>e.id===t.devId):null,i=t.name||"устройство";if(!confirm(`Убрать «${i}» с плана?`))return;const s=this._serverCfg;s.markers=s.markers||[],e&&"virtual"===e.bindingKind?s.markers=s.markers.filter(t=>t.id!==e.id):e&&e.marker?(s.markers=s.markers.filter(t=>t.id!==e.id),"device"===e.bindingKind&&e.bindingRef?s.markers.push({id:e.id,binding:"device:"+e.bindingRef,hidden:!0}):"entity"===e.bindingKind&&e.bindingRef&&s.markers.push({id:e.id,binding:"entity:"+e.bindingRef,hidden:!0})):e&&"device"===e.bindingKind&&e.bindingRef?s.markers.push({id:e.id,binding:"device:"+e.bindingRef,hidden:!0}):e&&"entity"===e.bindingKind&&e.bindingRef&&s.markers.push({id:e.id,binding:"entity:"+e.bindingRef,hidden:!0});try{await this._saveConfigNow(),e&&"virtual"===e.bindingKind&&this._layout[e.id]&&(delete this._layout[e.id],await this.hass.callWS({type:"houseplan/layout/delete",device_id:e.id}).catch(()=>{})),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство убрано с плана")}catch(t){this._showToast("Ошибка: "+this._errText(t))}}_normPos(t,e,i){const s=this._serverCfg.spaces.find(e=>e.id===t)?.aspect||1;return{s:t,x:e/Et,y:i/(Et/s)}}_openSpaceDialog(t,e){if(this._serverStorage&&this._serverCfg)if("edit"===t){const i=this._serverCfg.spaces.find(t=>t.id===e);if(!i)return;this._spaceDialog={mode:t,spaceId:e,title:i.title,planUrl:i.plan_url||null,planFile:null,busy:!1}}else this._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,busy:!1};else this._showToast("Интеграция House Plan не установлена — управление недоступно")}async _pickPlanFile(t){const e=t.target,i=e.files?.[0];if(!i||!this._spaceDialog)return;const s={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[i.type]||(i.name.toLowerCase().endsWith(".svg")?"svg":"");if(!s)return void this._showToast("Поддерживаются SVG, PNG, JPG, WebP");const a=new Uint8Array(await i.arrayBuffer());let o="";for(let t=0;t{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:1.414),e.onerror=()=>t(1.414),e.src=r});URL.revokeObjectURL(r),this._spaceDialog={...this._spaceDialog,planFile:{ext:s,b64:n,aspect:l,name:i.name}}}async _saveSpaceDialog(){const t=this._spaceDialog;if(!t||t.busy||!t.title.trim())return;if(!t.planFile&&!t.planUrl)return void this._showToast("Загрузите подложку — план этажа обязателен");const e="create"===t.mode&&0===(this._serverCfg?.spaces.length||0);this._spaceDialog={...t,busy:!0};try{const i=this._serverCfg;let s;if("create"===t.mode?(s={id:"s"+Date.now().toString(36),title:t.title.trim(),plan_url:null,aspect:1.414,view_box:[0,0,1,1],rooms:[],segments:[]},i.spaces.push(s)):(s=i.spaces.find(e=>e.id===t.spaceId),s.title=t.title.trim()),t.planFile){const e=await this.hass.callWS({type:"houseplan/plan/set",space_id:s.id,ext:t.planFile.ext,data:t.planFile.b64});s.plan_url=e.url,s.aspect=t.planFile.aspect}await this._saveConfigNow(),this._spaceDialog=null,"create"===t.mode&&(this._space=s.id),this._regSignature="",this._maybeRebuildDevices(),e?(this._markup=!0,this._tool="draw",this._path=[],this._cursorPt=null,this._showToast("Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.")):this._showToast("create"===t.mode?"Пространство добавлено":"Пространство сохранено")}catch(t){this._spaceDialog={...this._spaceDialog,busy:!1},this._showToast("Ошибка: "+this._errText(t))}}async _deleteSpace(){const t=this._spaceDialog;if(!t||"edit"!==t.mode)return;const e=this._serverCfg.spaces.find(e=>e.id===t.spaceId);if(confirm(`Удалить пространство «${e.title}» со всеми комнатами и разметкой?`)){this._serverCfg.spaces=this._serverCfg.spaces.filter(e=>e.id!==t.spaceId);try{await this._saveConfigNow(),this._spaceDialog=null,this._space===t.spaceId&&(this._space=this._serverCfg.spaces[0]?.id||""),this._regSignature="",this._maybeRebuildDevices(),this._showToast("Пространство удалено")}catch(t){this._showToast("Ошибка удаления: "+this._errText(t))}}}async _saveConfigNow(){const t=await this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev});this._cfgRev=t?.rev??this._cfgRev+1}render(){if(!this._config||!this.hass)return B;const t=this._model;if(!t.length)return j` +
+
${this._config.title||"План дома"}
+
+
+ +

Пространств пока нет.

+ ${this._serverStorage?j`

Добавьте первое пространство и загрузите план этажа.

+ `:j`

Установите интеграцию House Plan и добавьте запись в «Устройства и службы».

`} +
+ ${this._spaceDialog?this._renderSpaceDialog():B} + ${this._toast?j`
${this._toast}
`:B} +
`;const e=this._spaceModel(),i=e.vb,s=this._devices.filter(t=>t.space===e.id),a=this._config.icon_size??2.5,o=a>8?2.5:a,n=this._viewOr(i);return j` + +
+
+
+ + ${this._config.title||"План дома"} +
+
+ ${t.map(t=>j``)} + ${this._norm?j``:B} +
+ ${s.length} устр. + +
+ + + +
+ ${this._norm?j``:B} + ${this._norm?j` + `:B} + +
+ ${this._markup?this._renderMarkupBar():B} +
+ +
this._markupClick(t)} + @wheel=${t=>this._onWheel(t)} + @pointerdown=${t=>this._stagePointerDown(t)} + @pointermove=${t=>this._stagePointerMove(t)} + @pointerup=${t=>this._stagePointerUp(t)} + @pointercancel=${t=>this._stagePointerUp(t)}> +
+ + ${this._markup?this._renderMarkupDefs(i):B} + ${e.bg?F``:B} + ${e.rooms.filter(t=>t.area||this._markup).map(t=>{const i="room "+(e.bg?"overlay":"yard")+(this._markup?" outlined":""),s=e=>this._showTip(e,t.name,"комната — открыть зону",this._config?.show_signal?this._roomLqi(t.area):null),a=!e.bg||this._markup,o=this._roomCenter(t),n=t.poly?F`this._clickRoom(t)} @mousemove=${s} + @mouseleave=${()=>this._tip=null}>`:F`this._clickRoom(t)} @mousemove=${s} + @mouseleave=${()=>this._tip=null}>`;return F`${n}${a?F`${t.name}`:B}`})} + ${this._markup?this._renderMarkupLayer(i):B} + +
+ ${s.map(t=>this._renderDevice(t,n))} +
+
+ ${this._zoom>1?j`
${Math.round(100*this._zoom)}%
`:B} +
+ + ${this._roomDialog?this._renderRoomDialog():B} + ${this._spaceDialog?this._renderSpaceDialog():B} + ${this._markerDialog?this._renderMarkerDialog():B} + ${this._infoCard?this._renderInfoCard():B} + ${this._tip?j`
+ ${this._tip.title}${this._tip.meta?j`${this._tip.meta}`:B} + ${null!=this._tip.lqi?j`средний сигнал zigbee: + ${this._tip.lqi}`:B} +
`:B} + ${this._toast?j`
${this._toast}
`:B} +
+ `}_renderDevice(t,e){const i=this._pos(t),s=(i.x-e.x)/e.w*100,a=(i.y-e.y)/e.h*100,o=this._stateClass(t),n=this._liveTemp(t),r=this._config?.show_signal&&!t.virtual?$t(this.hass,t.entities):null;return j`
this._clickDevice(e,t)} + @mousemove=${e=>this._showTip(e,t.name,t.model+(null!=n?" · "+n+"°":"")+(null!=r?" · LQI "+r:""))} + @mouseleave=${()=>this._tip=null} + @pointerdown=${e=>this._pointerDown(e,t)} + @pointermove=${e=>this._pointerMove(e,t)} + @pointerup=${e=>this._pointerUp(e,t)} + > + + ${null!=n?j`${n}°`:B} + ${null!=r?j`${r}`:B} +
`}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_renderMarkupDefs(t){const e=this._gridPitch,i=.14*e;return F` + + + + + + + `}_renderMarkupLayer(t){const e=this._segments,i=this._path,s=this._gridPitch;return F` + + ${e.map(t=>F``)} + ${i.length>1?F``:B} + ${i.length&&this._cursorPt&&"draw"===this._tool&&!this._contourClosed?F``:B} + ${i.map((t,e)=>F``)} + `}_renderMarkupBar(){return j`
+ + + + + + ${"draw"===this._tool?j`${this._path.length?"точек: "+this._path.length+" · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой":"кликните точку сетки, чтобы начать контур"} + ${this._path.length?j``:B}`:B} +
`}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null;return j``}_renderMarkerDialog(){const t=this._markerDialog,e="virtual"===t.binding,i=this._bindingCandidates(),s=(()=>{if(e)return null;const s=i.find(e=>e.value===t.binding);if(s)return s.label;const[a,o]=t.binding.split(":");return"device"===a?this.hass.devices[o]?.name_by_user||this.hass.devices[o]?.name||o:this.hass.states[o]?.attributes?.friendly_name||o})();return j``}_renderSpaceDialog(){const t=this._spaceDialog;return j``}_renderRoomDialog(){const t=this._freeAreas;return j``}}Ot.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_markup:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},Ot.styles=zt,customElements.get("houseplan-card")||customElements.define("houseplan-card",Ot),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.10.0 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f6f26b9..72dba0a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## v1.10.0 — 2026-07-05 (аудит: гонки записи, XSS, модульность) +**Бэкенд** +- **Гонка записи устранена**: все циклы load→modify→save (`layout/set|update|delete`, + `config/set`) сериализованы общим `asyncio.Lock` — параллельные WS-вызовы больше не теряют + изменения, проверка `expected_rev` стала атомарной. +- Новая WS-команда `houseplan/layout/delete` — чистка позиции при удалении маркера + (раньше в `.storage/houseplan.layout` копились сироты). +- Удалена мёртвая WS-команда `houseplan/file/set` — файлы грузятся только по HTTP + (`/api/houseplan/upload`), как и было в карточке. +- HTTP-загрузка: лимит 25 МБ применяется ПО МЕРЕ чтения multipart (обрыв на лимите), + а не после чтения всего файла в память; `stat()` файлов ушёл в executor. +- `request.app[KEY_HASS]` вместо устаревшего `request.app["hass"]` (с фолбэком для старых HA). + +**Фронтенд** +- **God-component разобран**: `houseplan-card.ts` 3023 → ~1990 строк. + Стили → `styles.ts`; типы → `types.ts`; построение устройств из реестров HA + (курирование, группы света, маркеры, LQI/температура) → `devices.ts` (чистые функции). +- **Last-writer-wins в раскладке устранён**: `_saveMarker` пишет позицию точечно + (`layout/update`), а не всей раскладкой (`layout/set`) — не затирает позиции из других + окон (регресс-класс инцидента v1.4.4). Перепривязка/удаление подчищают старую позицию. +- **XSS закрыт**: `link` и `pdfs[].url` маркеров проходят `safeUrl()` (только http(s) + и относительные пути; `javascript:`/`data:` отбрасываются). +12 тестов. +- Загрузка файлов через `hass.fetchWithAuth` (автообновление протухшего токена), + фолбэк на сырой токен. +- **Хардкод дачи убран из GUI-редактора**: список «пространство по умолчанию» строится + из серверного конфига (WS `config/get`), а не зашитых f1/f2/yard. +- `rules.ts`: удалён мёртвый экспорт `GROUP_TITLES`; регэкспы `iconFor` прекомпилированы. +- Единый `_errText()` во всех обработчиках ошибок (никогда «[object Object]»). + ## v1.9.3 — 2026-07-05 (аудит + рефакторинг + тесты) - Чистые функции `fitView` (contain-прямоугольник вьюпорта) и `declump` (расталкивание значков) вынесены из карточки в `logic.ts` — покрыты юнит-тестами (было 9 → стало 14 тестов). diff --git a/package.json b/package.json index 39c77f0..d56b8df 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "houseplan-card", - "version": "1.9.3", - "description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)", + "version": "1.10.0", + "description": "Interactive house plan Lovelace card for Home Assistant", "license": "MIT", "type": "module", "scripts": { diff --git a/src/devices.ts b/src/devices.ts new file mode 100644 index 0000000..1eb01f1 --- /dev/null +++ b/src/devices.ts @@ -0,0 +1,275 @@ +/** + * Построение списка устройств из реестров HA: курирование, группы света, + * маркеры (оверрайды/виртуальные). Без Lit/DOM — только hass-объект. + */ +import { iconFor, DOMAIN_PRIORITY } from './rules'; +import { averageLqi } from './logic'; +import type { DevItem, Marker, ServerConfig } from './types'; + +/** Контекст построения: срез hass + резолв конфига. */ +export interface BuildCtx { + hass: any; + /** area_id → space_id (только зоны, привязанные к комнатам). */ + areaToSpace: Record; + markers: Marker[]; + settings: ServerConfig['settings']; + excluded: Set; + showAll: boolean; + firstSpaceId: string; +} + +export function entitiesByDevice(hass: any): Record { + const map: Record = {}; + for (const [eid, ent] of Object.entries(hass.entities)) { + if (ent?.device_id) (map[ent.device_id] = map[ent.device_id] || []).push(eid); + } + return map; +} + +export function domainOfDevice(hass: any, dev: any, entIds: string[]): string { + if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0]; + for (const eid of entIds) { + const p = hass.entities[eid]?.platform; + if (p) return p; + } + return ''; +} + +export function isTempEntity(hass: any, eid: string): boolean { + const st = hass.states[eid]; + if (!st) return /_temperature$/.test(eid); + const a = st.attributes || {}; + return ( + a.device_class === 'temperature' || /°C|°F/.test(a.unit_of_measurement || '') || /_temperature$/.test(eid) + ); +} + +export function primaryEntity(hass: any, entIds: string[], icon: string): string | undefined { + const ents = entIds + .map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] })) + .filter((e) => e.reg && !e.reg.hidden); + const usable = ents.filter((e) => !e.reg.entity_category); + const pool = usable.length ? usable : ents; + if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') { + const t = pool.find((e) => isTempEntity(hass, e.eid)); + if (t) return t.eid; + } + for (const dom of DOMAIN_PRIORITY) { + const found = pool.find((e) => e.eid.split('.')[0] === dom); + if (found) return found.eid; + } + return pool[0]?.eid; +} + +/** Средний LQI zigbee по сущностям устройства (сенсоры *_linkquality/*_lqi либо атрибут). */ +export function lqiFor(hass: any, entIds: string[]): number | null { + const vals: number[] = []; + for (const eid of entIds) { + const st = hass.states[eid]; + if (!st) continue; + const unit = (st.attributes?.unit_of_measurement || '').toLowerCase(); + // 1) выделенный сенсор сигнала: Z2M *_linkquality, ZHA *_lqi, либо единицы «lqi» + if (/_(linkquality|lqi)$/.test(eid) || unit === 'lqi') { + const v = parseFloat(st.state); + if (!isNaN(v)) vals.push(v); + continue; + } + // 2) сигнал как АТРИБУТ на любой сущности устройства (Z2M linkquality / ZHA lqi) — + // покрывает устройства, у которых отдельный сенсор сигнала отключён + const av = st.attributes?.linkquality ?? st.attributes?.lqi; + if (av != null) { + const v = parseFloat(av); + if (!isNaN(v)) vals.push(v); + } + } + return averageLqi(vals); +} + +export function tempFor(hass: any, entIds: string[]): number | null { + for (const eid of entIds) { + if (!isTempEntity(hass, eid)) continue; + const st = hass.states[eid]; + if (!st) continue; + const v = parseFloat(st.state); + if (!isNaN(v)) return Math.round(v * 10) / 10; + } + return null; +} + +/** Групповые световые сущности: HA light-group (platform=group) и Z2M-группы (device model=Group). */ +export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] { + if (!enabled) return []; + const res: { eid: string; name: string; area: string }[] = []; + for (const [eid, reg] of Object.entries(hass.entities)) { + if (!eid.startsWith('light.') || reg.hidden) continue; + let area: string | null = null; + if (reg.platform === 'group') { + area = reg.area_id || null; + } else if (reg.device_id) { + const dev = hass.devices[reg.device_id]; + if (dev?.model === 'Group') area = dev.area_id || reg.area_id || null; + else continue; + } else { + continue; + } + if (!area) continue; + const st = hass.states[eid]; + res.push({ eid, name: reg.name || st?.attributes?.friendly_name || eid, area }); + } + return res; +} + +function applyMarker(item: DevItem, m: Marker): void { + item.marker = m; + if (m.name) item.name = m.name; + if (m.icon) item.icon = m.icon; + if (m.model != null) item.model = m.model; + item.link = m.link ?? null; + item.description = m.description ?? null; + item.pdfs = m.pdfs || []; +} + +/** Курирование + группы света + маркеры (метаданные/перепривязка) + виртуальные. Гибрид. */ +export function buildDevices(ctx: BuildCtx): DevItem[] { + const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId } = ctx; + const groupLights = settings.group_lights !== false; + const groups = lightGroups(h, groupLights); + const groupedAreas = new Set(groups.map((g) => g.area)); + const entsBy = entitiesByDevice(h); + const claimed = new Set(); + for (const m of markers) { + const [kind, ref] = m.binding.split(':'); + if ((kind === 'device' || kind === 'entity') && ref) claimed.add(m.binding); + } + const markerFor = (kind: string, ref: string) => markers.find((m) => m.binding === kind + ':' + ref); + const seen: Record = {}; + const rest: DevItem[] = []; + + // 1) авто-устройства HA (не занятые маркером, не скрытые) + for (const dev of Object.values(h.devices)) { + const area = dev.area_id; + if (!area || !areaToSpace[area]) continue; + if (dev.entry_type === 'service') continue; + if (claimed.has('device:' + dev.id)) continue; // маркер перекроет ниже + const marker = markerFor('device', dev.id); + if (marker && marker.hidden) continue; + const entIds = entsBy[dev.id] || []; + const dom = domainOfDevice(h, dev, entIds); + // курирование (можно отключить переключателем «показать все») + if (!showAll) { + if (excluded.has(dom)) continue; + if (dev.model === 'Group') continue; + if (/scene/i.test(dev.model || '')) continue; + if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue; + if (dom === 'myheat' && dev.via_device_id) continue; + } + const name = (dev.name_by_user || dev.name || 'без имени').trim(); + const key = name + '|' + area; + let icon = iconFor(name, dev.model); + if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock'; + if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue; + // дубли по «имя|зона» не скрываем, а нумеруем + seen[key] = (seen[key] || 0) + 1; + const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name; + const item: DevItem = { + id: dev.id, + name: dispName, + model: dev.model || '', + area, + space: areaToSpace[area], + icon, + entities: entIds, + bindingKind: 'device', + bindingRef: dev.id, + pdfs: [], + }; + item.primary = primaryEntity(h, entIds, icon); + if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds); + rest.push(item); + } + + // 2) группы света (не занятые маркером) + for (const g of groups) { + if (!areaToSpace[g.area]) continue; + if (claimed.has('entity:' + g.eid)) continue; + rest.push({ + id: 'lg_' + g.eid, + name: g.name, + model: 'группа света', + area: g.area, + space: areaToSpace[g.area], + icon: 'mdi:lightbulb-group', + entities: [g.eid], + primary: g.eid, + bindingKind: 'entity', + bindingRef: g.eid, + pdfs: [], + }); + } + + // 3) явные маркеры (перепривязка/метаданные/виртуальные) + for (const m of markers) { + if (m.hidden) continue; + const [kind, ref] = m.binding.split(':'); + if (kind === 'device') { + const dev = h.devices[ref]; + const area = m.area || dev?.area_id || ''; + const space = (area && areaToSpace[area]) || m.space || firstSpaceId; + const entIds = dev ? entsBy[dev.id] || [] : []; + let icon = dev ? iconFor(dev.name_by_user || dev.name || '', dev.model) : 'mdi:help-circle'; + if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock'; + const item: DevItem = { + id: m.id, + name: dev?.name_by_user || dev?.name || 'устройство', + model: dev?.model || '', + area, + space, + icon, + entities: entIds, + bindingKind: 'device', + bindingRef: ref, + }; + item.primary = primaryEntity(h, entIds, icon); + if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds); + applyMarker(item, m); + rest.push(item); + } else if (kind === 'entity') { + const reg = h.entities[ref]; + const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || ''; + const space = (area && areaToSpace[area]) || m.space || firstSpaceId; + const st = h.states[ref]; + const item: DevItem = { + id: m.id, + name: reg?.name || st?.attributes?.friendly_name || ref, + model: '', + area, + space, + icon: 'mdi:shape-outline', + entities: [ref], + primary: ref, + bindingKind: 'entity', + bindingRef: ref, + }; + applyMarker(item, m); + rest.push(item); + } else { + // виртуальный + const area = m.area || ''; + const space = m.space || (area && areaToSpace[area]) || firstSpaceId; + const item: DevItem = { + id: m.id, + name: m.name || 'виртуальное устройство', + model: m.model || '', + area, + space, + icon: m.icon || 'mdi:map-marker', + entities: [], + bindingKind: 'virtual', + virtual: true, + }; + applyMarker(item, m); + rest.push(item); + } + } + return rest; +} diff --git a/src/editor.ts b/src/editor.ts index 8fa2531..cbc29e1 100755 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,30 +1,9 @@ /** Редактор конфигурации карточки (GUI в Lovelace). */ import { LitElement, html, nothing } from 'lit'; -const SCHEMA = [ - { name: 'title', selector: { text: {} } }, - { - name: 'default_floor', - selector: { - select: { - mode: 'dropdown', - options: [ - { value: 'f1', label: '1 этаж' }, - { value: 'f2', label: '2 этаж' }, - { value: 'yard', label: 'Двор' }, - ], - }, - }, - }, - { name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } }, - { name: 'show_temperature', selector: { boolean: {} } }, - { name: 'live_states', selector: { boolean: {} } }, - { name: 'show_signal', selector: { boolean: {} } }, -]; - const LABELS: Record = { title: 'Заголовок', - default_floor: 'Этаж по умолчанию', + default_floor: 'Пространство по умолчанию', icon_size: 'Размер иконок, % ширины плана', show_temperature: 'Показывать температуру', live_states: 'Живые состояния (вкл/выкл, открыто…)', @@ -34,22 +13,60 @@ const LABELS: Record = { class HouseplanCardEditor extends LitElement { public hass?: any; private _config?: any; + private _spaces: { value: string; label: string }[] | null = null; + private _spacesLoading = false; static properties = { hass: { attribute: false }, _config: { state: true }, + _spaces: { state: true }, }; public setConfig(config: any): void { this._config = config; } + /** Пространства из серверного конфига интеграции — не хардкод. */ + private async _loadSpaces(): Promise { + if (this._spaces || this._spacesLoading || !this.hass) return; + this._spacesLoading = true; + try { + const resp = await this.hass.callWS({ type: 'houseplan/config/get' }); + this._spaces = (resp?.config?.spaces || []).map((s: any) => ({ + value: s.id, + label: s.title || s.id, + })); + } catch { + this._spaces = []; + } finally { + this._spacesLoading = false; + } + } + + private get _schema(): any[] { + const spaces = this._spaces || []; + return [ + { name: 'title', selector: { text: {} } }, + spaces.length + ? { + name: 'default_floor', + selector: { select: { mode: 'dropdown', options: spaces } }, + } + : { name: 'default_floor', selector: { text: {} } }, + { name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } }, + { name: 'show_temperature', selector: { boolean: {} } }, + { name: 'live_states', selector: { boolean: {} } }, + { name: 'show_signal', selector: { boolean: {} } }, + ]; + } + protected render() { if (!this.hass || !this._config) return nothing; + this._loadSpaces(); return html` LABELS[s.name] || s.name} @value-changed=${this._valueChanged} >`; diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 90aac01..bab6dcf 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -6,97 +6,28 @@ * 2) LEGACY-фолбэк — вшитые данные дачи (src/data/*), координаты в холсте 1489×1053. * Раскладка иконок хранится на сервере (houseplan/layout/*), fallback — localStorage. */ -import { LitElement, html, svg, css, nothing, TemplateResult, PropertyValues } from 'lit'; -import { EXCLUDED_DOMAINS, iconFor, DOMAIN_PRIORITY } from './rules'; +import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit'; +import { EXCLUDED_DOMAINS } from './rules'; import { lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding, - averageLqi, fitView, declump, + averageLqi, fitView, declump, safeUrl, } from './logic'; +import { buildDevices, lqiFor, tempFor } from './devices'; +import type { + RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig, +} from './types'; import './editor'; +import { cardStyles } from './styles'; -const CARD_VERSION = '1.9.3'; +const CARD_VERSION = '1.10.0'; const LS_KEY = 'houseplan_card_layout_v1'; const LS_CFG = 'houseplan_card_cfg_v1'; // кэш серверного конфига+раскладки для мгновенного рендера const LS_ZOOM = 'houseplan_card_zoom_v1'; const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов -interface RoomCfg { - id?: string; - name: string; - area: string | null; - x?: number; - y?: number; - w?: number; - h?: number; - poly?: number[][]; // полигон в рендер-единицах (модель) / нормированный (конфиг) -} - const GRID_N = 240; // точек сетки по ширине плана (шаг вдвое меньше; старые узлы — подмножество новых, позиции сохраняются) type MarkupTool = 'draw' | 'erase' | 'delroom'; -interface SpaceModel { - id: string; - title: string; - vb: number[]; // render units - bg: { href: string; x: number; y: number; w: number; h: number } | null; - rooms: RoomCfg[]; // render units -} - -interface PdfRef { - name: string; - url: string; -} - -/** Маркер конфига: правит/дополняет авто-устройство ИЛИ описывает ручной/виртуальный значок. */ -interface Marker { - id: string; - binding: string; // 'device:' | 'entity:' | 'virtual' - space?: string | null; - area?: string | null; - hidden?: boolean; - name?: string | null; - icon?: string | null; - model?: string | null; - link?: string | null; - description?: string | null; - pdfs?: PdfRef[]; -} - -interface ServerConfig { - spaces: any[]; - markers: Marker[]; - settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean }; -} - -interface DevItem { - id: string; - name: string; - model: string; - area: string; - space: string; - icon: string; - entities: string[]; - primary?: string; - temp?: number | null; - virtual?: boolean; - marker?: Marker; // связанный маркер конфига (метаданные, оверрайды) - bindingKind?: 'device' | 'entity' | 'virtual'; - bindingRef?: string; // device_id / entity_id - link?: string | null; - description?: string | null; - pdfs?: PdfRef[]; -} - -interface CardConfig { - type: string; - title?: string; - default_floor?: string; - icon_size?: number; - show_temperature?: boolean; - live_states?: boolean; - show_signal?: boolean; -} - const fireEvent = (node: EventTarget, type: string, detail?: unknown) => { const ev = new Event(type, { bubbles: true, composed: true }) as any; ev.detail = detail ?? {}; @@ -499,297 +430,34 @@ class HouseplanCard extends LitElement { Object.keys(h.areas).length + ':' + (this._norm ? 'n' : 'l'); if (sig === this._regSignature && this._devices.length) return; this._regSignature = sig; - this._devices = this._buildDevices(); + this._devices = buildDevices({ + hass: h, + areaToSpace: Object.fromEntries( + Object.entries(this._areaToSpace).map(([a, v]) => [a, v.space]), + ), + markers: this._markers, + settings: this._settings, + excluded: this._excluded, + showAll: this._showAll, + firstSpaceId: this._model[0]?.id || '', + }); this._defPos = this._defaultPositions(); } - private _entitiesByDevice(): Record { - const map: Record = {}; - for (const [eid, ent] of Object.entries(this.hass.entities)) { - if (ent?.device_id) (map[ent.device_id] = map[ent.device_id] || []).push(eid); - } - return map; - } - - private _domainOfDevice(dev: any, entIds: string[]): string { - if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0]; - for (const eid of entIds) { - const p = this.hass.entities[eid]?.platform; - if (p) return p; - } - return ''; - } - - private _primaryEntity(entIds: string[], icon: string): string | undefined { - const ents = entIds - .map((eid) => ({ eid, reg: this.hass.entities[eid], st: this.hass.states[eid] })) - .filter((e) => e.reg && !e.reg.hidden); - const usable = ents.filter((e) => !e.reg.entity_category); - const pool = usable.length ? usable : ents; - if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') { - const t = pool.find((e) => this._isTempEntity(e.eid)); - if (t) return t.eid; - } - for (const dom of DOMAIN_PRIORITY) { - const found = pool.find((e) => e.eid.split('.')[0] === dom); - if (found) return found.eid; - } - return pool[0]?.eid; - } - - private _isTempEntity(eid: string): boolean { - const st = this.hass.states[eid]; - if (!st) return /_temperature$/.test(eid); - const a = st.attributes || {}; - return ( - a.device_class === 'temperature' || /°C|°F/.test(a.unit_of_measurement || '') || /_temperature$/.test(eid) - ); - } - - private _lqiFor(entIds: string[]): number | null { - const vals: number[] = []; - for (const eid of entIds) { - const st = this.hass.states[eid]; - if (!st) continue; - const unit = (st.attributes?.unit_of_measurement || '').toLowerCase(); - // 1) выделенный сенсор сигнала: Z2M *_linkquality, ZHA *_lqi, либо единицы «lqi» - if (/_(linkquality|lqi)$/.test(eid) || unit === 'lqi') { - const v = parseFloat(st.state); - if (!isNaN(v)) vals.push(v); - continue; - } - // 2) сигнал как АТРИБУТ на любой сущности устройства (Z2M linkquality / ZHA lqi) — - // покрывает устройства, у которых отдельный сенсор сигнала отключён - const av = st.attributes?.linkquality ?? st.attributes?.lqi; - if (av != null) { - const v = parseFloat(av); - if (!isNaN(v)) vals.push(v); - } - } - return averageLqi(vals); - } - - private _roomLqi(area: string | null): number | null { - if (!area) return null; - const vals: number[] = []; - for (const d of this._devices) { - if (d.area !== area || d.virtual) continue; - const l = this._lqiFor(d.entities); - if (l != null) vals.push(l); - } - return averageLqi(vals); - } - - private _tempFor(entIds: string[]): number | null { - for (const eid of entIds) { - if (!this._isTempEntity(eid)) continue; - const st = this.hass.states[eid]; - if (!st) continue; - const v = parseFloat(st.state); - if (!isNaN(v)) return Math.round(v * 10) / 10; - } - return null; - } - - /** Групповые световые сущности: HA light-group (platform=group) и Z2M-группы (device model=Group). */ - private _lightGroups(): { eid: string; name: string; area: string }[] { - if (this._settings.group_lights === false) return []; - const h = this.hass; - const res: { eid: string; name: string; area: string }[] = []; - for (const [eid, reg] of Object.entries(h.entities)) { - if (!eid.startsWith('light.') || reg.hidden) continue; - let area: string | null = null; - if (reg.platform === 'group') { - area = reg.area_id || null; - } else if (reg.device_id) { - const dev = h.devices[reg.device_id]; - if (dev?.model === 'Group') area = dev.area_id || reg.area_id || null; - else continue; - } else { - continue; - } - if (!area) continue; - const st = h.states[eid]; - res.push({ eid, name: reg.name || st?.attributes?.friendly_name || eid, area }); - } - return res; - } - - /** Зоны, в которых есть группа света (одиночные лампы там скрываются). */ - private _groupedLightAreas(): Set { - return new Set(this._lightGroups().map((g) => g.area)); - } - /** Курирование + группы света + оверрайды + виртуальные устройства. */ private get _markers(): Marker[] { return this._serverCfg?.markers || []; } - /** Маркер по «claim key»: device: / entity:. */ - private _markerFor(kind: 'device' | 'entity', ref: string): Marker | undefined { - return this._markers.find((m) => m.binding === kind + ':' + ref); - } - - /** Множество HA-ссылок (device_id/entity_id), «занятых» маркерами. */ - private get _claimed(): Set { - const set = new Set(); - for (const m of this._markers) { - const [kind, ref] = m.binding.split(':'); - if ((kind === 'device' || kind === 'entity') && ref) set.add(m.binding); + private _roomLqi(area: string | null): number | null { + if (!area) return null; + const vals: number[] = []; + for (const d of this._devices) { + if (d.area !== area || d.virtual) continue; + const l = lqiFor(this.hass, d.entities); + if (l != null) vals.push(l); } - return set; - } - - private _applyMarker(item: DevItem, m: Marker): void { - item.marker = m; - if (m.name) item.name = m.name; - if (m.icon) item.icon = m.icon; - if (m.model != null) item.model = m.model; - item.link = m.link ?? null; - item.description = m.description ?? null; - item.pdfs = m.pdfs || []; - } - - /** Курирование + группы света + маркеры (метаданные/перепривязка) + виртуальные. Гибрид. */ - private _buildDevices(): DevItem[] { - const h = this.hass; - const areaMap = this._areaToSpace; - const groupLights = this._settings.group_lights !== false; - const groupedAreas = this._groupedLightAreas(); - const excluded = this._excluded; - const showAll = this._showAll; - const entsBy = this._entitiesByDevice(); - const claimed = this._claimed; - const seen: Record = {}; - const rest: DevItem[] = []; - - // 1) авто-устройства HA (не занятые маркером, не скрытые) - for (const dev of Object.values(h.devices)) { - const area = dev.area_id; - if (!area || !areaMap[area]) continue; - if (dev.entry_type === 'service') continue; - if (claimed.has('device:' + dev.id)) continue; // маркер перекроет ниже - const marker = this._markerFor('device', dev.id); - if (marker && marker.hidden) continue; - const entIds = entsBy[dev.id] || []; - const dom = this._domainOfDevice(dev, entIds); - // курирование (можно отключить переключателем «показать все») - if (!showAll) { - if (excluded.has(dom)) continue; - if (dev.model === 'Group') continue; - if (/scene/i.test(dev.model || '')) continue; - if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue; - if (dom === 'myheat' && dev.via_device_id) continue; - } - const name = (dev.name_by_user || dev.name || 'без имени').trim(); - const key = name + '|' + area; - let icon = iconFor(name, dev.model); - if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock'; - if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue; - // дубли по «имя|зона» не скрываем, а нумеруем - seen[key] = (seen[key] || 0) + 1; - const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name; - const item: DevItem = { - id: dev.id, - name: dispName, - model: dev.model || '', - area, - space: areaMap[area].space, - icon, - entities: entIds, - bindingKind: 'device', - bindingRef: dev.id, - pdfs: [], - }; - item.primary = this._primaryEntity(entIds, icon); - if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = this._tempFor(entIds); - rest.push(item); - } - - // 2) группы света (не занятые маркером) - for (const g of this._lightGroups()) { - if (!areaMap[g.area]) continue; - if (claimed.has('entity:' + g.eid)) continue; - rest.push({ - id: 'lg_' + g.eid, - name: g.name, - model: 'группа света', - area: g.area, - space: areaMap[g.area].space, - icon: 'mdi:lightbulb-group', - entities: [g.eid], - primary: g.eid, - bindingKind: 'entity', - bindingRef: g.eid, - pdfs: [], - }); - } - - // 3) явные маркеры (перепривязка/метаданные/виртуальные) - for (const m of this._markers) { - if (m.hidden) continue; - const [kind, ref] = m.binding.split(':'); - if (kind === 'device') { - const dev = h.devices[ref]; - const area = m.area || dev?.area_id || ''; - const space = (area && areaMap[area]?.space) || m.space || this._model[0]?.id || ''; - const entIds = dev ? entsBy[dev.id] || [] : []; - let icon = dev ? iconFor(dev.name_by_user || dev.name || '', dev.model) : 'mdi:help-circle'; - if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock'; - const item: DevItem = { - id: m.id, - name: dev?.name_by_user || dev?.name || 'устройство', - model: dev?.model || '', - area, - space, - icon, - entities: entIds, - bindingKind: 'device', - bindingRef: ref, - }; - item.primary = this._primaryEntity(entIds, icon); - if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = this._tempFor(entIds); - this._applyMarker(item, m); - rest.push(item); - } else if (kind === 'entity') { - const reg = h.entities[ref]; - const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || ''; - const space = (area && areaMap[area]?.space) || m.space || this._model[0]?.id || ''; - const st = h.states[ref]; - const item: DevItem = { - id: m.id, - name: reg?.name || st?.attributes?.friendly_name || ref, - model: '', - area, - space, - icon: 'mdi:shape-outline', - entities: [ref], - primary: ref, - bindingKind: 'entity', - bindingRef: ref, - }; - this._applyMarker(item, m); - rest.push(item); - } else { - // виртуальный - const area = m.area || ''; - const space = m.space || (area && areaMap[area]?.space) || this._model[0]?.id || ''; - const item: DevItem = { - id: m.id, - name: m.name || 'виртуальное устройство', - model: m.model || '', - area, - space, - icon: m.icon || 'mdi:map-marker', - entities: [], - bindingKind: 'virtual', - virtual: true, - }; - this._applyMarker(item, m); - rest.push(item); - } - } - return rest; + return averageLqi(vals); } // ================= позиции ================= @@ -895,7 +563,7 @@ class HouseplanCard extends LitElement { private _liveTemp(d: DevItem): number | null { if (!this._config?.show_temperature) return null; if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null; - return this._tempFor(d.entities); + return tempFor(this.hass, d.entities); } // ================= взаимодействие ================= @@ -1530,18 +1198,22 @@ class HouseplanCard extends LitElement { input.value = ''; if (!files.length || !this._markerDialog) return; const mid = this._markerDialog.devId || 'new'; - const token = this.hass?.auth?.data?.access_token; const uploaded: PdfRef[] = []; for (const file of files) { try { const fd = new FormData(); fd.append('marker_id', mid); fd.append('file', file, file.name); - const resp = await fetch('/api/houseplan/upload', { - method: 'POST', - body: fd, - headers: token ? { authorization: `Bearer ${token}` } : {}, - }); + // fetchWithAuth сам обновляет протухший access_token; фолбэк — сырой токен + const resp: Response = this.hass?.fetchWithAuth + ? await this.hass.fetchWithAuth('/api/houseplan/upload', { method: 'POST', body: fd }) + : await fetch('/api/houseplan/upload', { + method: 'POST', + body: fd, + headers: this.hass?.auth?.data?.access_token + ? { authorization: `Bearer ${this.hass.auth.data.access_token}` } + : {}, + }); const json = await resp.json().catch(() => ({})); if (!resp.ok || json.error) { const map: Record = { @@ -1612,7 +1284,10 @@ class HouseplanCard extends LitElement { // удалить прежний маркер (по старому id и по новому id) cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId); cfg.markers.push(marker); - // позиция: новый значок ИЛИ сменилась комната → поставить в центр комнаты/пространства + // позиция: новый значок ИЛИ сменилась комната → поставить в центр комнаты/пространства. + // Пишем ТОЧЕЧНО (layout/update), а не всей раскладкой — полный layout/set затирает + // позиции, изменённые в других окнах (инцидент v1.4.4). + let newPos: { s: string; x: number; y: number } | null = null; if (!this._layout[id] || roomChanged) { const spm = this._spaceModel(space || undefined); let cx = spm.vb[0] + spm.vb[2] / 2; @@ -1621,17 +1296,23 @@ class HouseplanCard extends LitElement { const room = spm.rooms.find((r) => r.area === area); if (room) [cx, cy] = this._roomCenter(room); } - this._layout[id] = this._normPos(space || this._space, cx, cy); + newPos = this._normPos(space || this._space, cx, cy); + this._layout = { ...this._layout, [id]: newPos }; } await this._saveConfigNow(); - await this.hass.callWS({ type: 'houseplan/layout/set', layout: this._layout }); + if (newPos) await this.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos: newPos }); + if (oldId && oldId !== id) { + // перепривязка сменила id значка — подчистить старую позицию + delete this._layout[oldId]; + await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId }).catch(() => undefined); + } this._markerDialog = null; this._regSignature = ''; this._maybeRebuildDevices(); this._showToast('Устройство сохранено'); } catch (e: any) { this._markerDialog = { ...this._markerDialog!, busy: false }; - this._showToast('Ошибка: ' + (e?.message || e)); + this._showToast('Ошибка: ' + this._errText(e)); } } @@ -1660,12 +1341,17 @@ class HouseplanCard extends LitElement { } try { await this._saveConfigNow(); + if (d && d.bindingKind === 'virtual' && this._layout[d.id]) { + // виртуальный удалён совсем → его позиция больше не нужна + delete this._layout[d.id]; + await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: d.id }).catch(() => undefined); + } this._markerDialog = null; this._regSignature = ''; this._maybeRebuildDevices(); this._showToast('Устройство убрано с плана'); } catch (e: any) { - this._showToast('Ошибка: ' + (e?.message || e)); + this._showToast('Ошибка: ' + this._errText(e)); } } @@ -1770,7 +1456,7 @@ class HouseplanCard extends LitElement { } } catch (e: any) { this._spaceDialog = { ...this._spaceDialog!, busy: false }; - this._showToast('Ошибка: ' + (e?.message || e)); + this._showToast('Ошибка: ' + this._errText(e)); } } @@ -1788,7 +1474,7 @@ class HouseplanCard extends LitElement { this._maybeRebuildDevices(); this._showToast('Пространство удалено'); } catch (e: any) { - this._showToast('Ошибка удаления: ' + (e?.message || e)); + this._showToast('Ошибка удаления: ' + this._errText(e)); } } @@ -1964,7 +1650,7 @@ class HouseplanCard extends LitElement { const top = ((p.y - view.y) / view.h) * 100; const cls = this._stateClass(d); const temp = this._liveTemp(d); - const lqi = this._config?.show_signal && !d.virtual ? this._lqiFor(d.entities) : null; + const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null; return html`
${d.model ? html`
Модель${d.model}
` : nothing} ${stateTxt ? html`
Состояние${stateTxt}
` : nothing} - ${d.link + ${safeUrl(d.link) ? html`
Ссылка - ${d.link}
` + ${d.link}
` : nothing} ${d.description ? html`
${d.description}
` : nothing} ${d.pdfs && d.pdfs.length ? html`
Инструкции ${d.pdfs.map( - (p) => html` + (p) => html` ${p.name}`, )}
` : nothing} @@ -2171,7 +1857,7 @@ class HouseplanCard extends LitElement {
${d.pdfs.map( (p) => html` - ${p.name} + ${p.name} this._removeMarkerPdf(p.url)}>`, )}
`; } - static styles = css` - :host { - --hp-bg: var(--card-background-color, #16212e); - --hp-line: var(--divider-color, #2b3d4f); - --hp-txt: var(--primary-text-color, #e6edf3); - --hp-muted: var(--secondary-text-color, #8aa0b3); - --hp-accent: var(--primary-color, #3ea6ff); - --hp-on: #ffd45c; - --hp-open: #ff9f43; - } - ha-card { - overflow: visible; /* overflow:hidden ломает position:sticky у шапки */ - } - .empty { - padding: 40px 24px; - color: var(--hp-txt); - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - } - .empty .big { - --mdc-icon-size: 56px; - color: var(--hp-accent); - opacity: 0.7; - } - .empty .muted { - color: var(--hp-muted); - font-size: 13px; - margin: 0; - } - .empty .btn { - margin-top: 8px; - } - .hdr { - position: sticky; - top: var(--header-height, 56px); - z-index: 20; - background: var(--card-background-color, var(--hp-bg)); - border-radius: var(--ha-card-border-radius, 12px) var(--ha-card-border-radius, 12px) 0 0; - } - .head { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-bottom: 1px solid var(--hp-line); - flex-wrap: wrap; - } - .title { - font-size: 15px; - font-weight: 600; - display: flex; - align-items: center; - gap: 6px; - white-space: nowrap; - } - .title ha-icon { - color: var(--hp-accent); - --mdc-icon-size: 18px; - } - .tabs { - display: flex; - gap: 4px; - background: rgba(127, 127, 127, 0.12); - padding: 3px; - border-radius: 10px; - flex-wrap: wrap; - } - @media (max-width: 620px) { - .head { gap: 6px; padding: 8px 10px; } - .head .count { display: none; } - .head .title { font-size: 14px; } - } - .tab { - border: 0; - background: transparent; - color: var(--hp-muted); - padding: 6px 13px; - border-radius: 8px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: 0.15s; - font-family: inherit; - } - .tab:hover { - color: var(--hp-txt); - } - .tab.active { - background: var(--hp-accent); - color: var(--text-primary-color, #fff); - } - .count { - font-size: 12px; - color: var(--hp-muted); - } - .spacer { - flex: 1; - } - .btn { - display: inline-flex; - align-items: center; - gap: 6px; - border: 1px solid var(--hp-line); - background: transparent; - color: var(--hp-txt); - padding: 6px 10px; - border-radius: 8px; - cursor: pointer; - transition: 0.15s; - font-family: inherit; - font-size: 12.5px; - } - .btn ha-icon { - --mdc-icon-size: 17px; - } - .btn:hover { - border-color: var(--hp-accent); - } - .btn.on { - background: var(--hp-accent); - color: var(--text-primary-color, #fff); - border-color: var(--hp-accent); - } - .btn.ghost { - border: none; - } - .btn[disabled] { - opacity: 0.5; - pointer-events: none; - } - .stage { - position: relative; - width: 100%; - container-type: inline-size; - overflow: hidden; - touch-action: none; /* свои жесты pinch/pan */ - background: var(--ha-card-background, var(--card-background-color, #111)); - } - .zoomwrap { - position: absolute; - inset: 0; - } - .zoomctl { - display: inline-flex; - gap: 2px; - background: rgba(127, 127, 127, 0.12); - border-radius: 8px; - padding: 2px; - } - .zoomctl .zb { - border: none; - padding: 5px 7px; - } - .zoomctl .zb[disabled] { - opacity: 0.4; - pointer-events: none; - } - .zoombadge { - position: absolute; - left: 8px; - bottom: 8px; - background: rgba(4, 18, 31, 0.8); - color: #dff1ff; - border: 1px solid var(--hp-accent); - border-radius: 8px; - padding: 2px 8px; - font-size: 12px; - font-weight: 600; - pointer-events: none; - } - .stage svg { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - display: block; - } - .room { - transition: 0.12s; - cursor: pointer; - } - .room.overlay { - fill: transparent; - stroke: transparent; - stroke-width: 2; - } - .room.overlay:hover { - fill: rgba(62, 166, 255, 0.18); - stroke: var(--hp-accent); - } - .room.yard { - fill: rgba(75, 140, 90, 0.14); - stroke: #4b8c5a; - stroke-width: 2; - } - .room.yard:hover { - fill: rgba(75, 140, 90, 0.24); - stroke: #6fbf86; - } - .rlabel { - fill: var(--hp-muted); - font-size: 15px; - font-weight: 600; - pointer-events: none; - text-anchor: middle; - } - .stage.edit .room { - pointer-events: none; - } - .stage.markup { - cursor: crosshair; - } - .stage.markup .room { - pointer-events: none; - } - .stage.markup .devlayer { - display: none; /* в разметке иконки не мешают */ - } - .room.outlined { - stroke: rgba(62, 166, 255, 0.55); - fill: rgba(62, 166, 255, 0.06); - } - .griddot { - fill: var(--hp-accent); - opacity: 0.75; - stroke: rgba(0, 0, 0, 0.35); - stroke-width: 0.4; - } - .seg { - stroke: var(--hp-accent); - stroke-width: 2.5; - stroke-linecap: round; - } - .pathline { - stroke: #ffc14d; - stroke-width: 3; - fill: none; - stroke-linecap: round; - stroke-linejoin: round; - } - .preview { - stroke: #ffc14d; - stroke-width: 2; - stroke-dasharray: 6 5; - opacity: 0.7; - } - .vertex { - fill: #ffc14d; - stroke: #4a2800; - stroke-width: 1; - } - .vertex.first { - fill: #4bd28f; - stroke: #04121f; - } - .areasel, - .namein { - background: var(--hp-bg); - border: 1px solid var(--hp-line); - color: var(--hp-txt); - border-radius: 6px; - padding: 6px 8px; - font-size: 13px; - font-family: inherit; - } - .namein { - width: 130px; - } - .devlayer { - position: absolute; - inset: 0; - pointer-events: none; - } - .dev { - position: absolute; - width: var(--icon-size, 2.5cqw); - height: var(--icon-size, 2.5cqw); - margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2); - border-radius: 22%; - background: var(--hp-bg); - border: 1px solid var(--hp-line); - display: flex; - align-items: center; - justify-content: center; - color: var(--hp-txt); - cursor: grab; - pointer-events: auto; - transition: background 0.15s, border-color 0.15s, opacity 0.2s; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); - z-index: 2; - } - .dev ha-icon { - --mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62); - } - .dev:active { - cursor: grabbing; - } - .dev:hover { - background: var(--hp-accent); - color: var(--text-primary-color, #fff); - z-index: 5; - } - .dev.on { - background: var(--hp-on); - border-color: var(--hp-on); - color: #503c00; - box-shadow: 0 0 8px rgba(255, 212, 92, 0.7); - } - .dev.open { - background: var(--hp-open); - border-color: var(--hp-open); - color: #4a2800; - } - .dev.unavail { - opacity: 0.35; - } - .dev.virtual { - border-style: dashed; - } - .dev.sel { - border-color: #ffc14d; - box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35); - } - - .dev .tval { - position: absolute; - left: 100%; - top: 50%; - transform: translateY(-50%); - margin-left: calc(var(--icon-size, 2.5cqw) * 0.1); - background: rgba(4, 18, 31, 0.9); - border: 1px solid var(--hp-accent); - border-radius: calc(var(--icon-size, 2.5cqw) * 0.18); - padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14); - font-size: calc(var(--icon-size, 2.5cqw) * 0.45); - font-weight: 700; - line-height: calc(var(--icon-size, 2.5cqw) * 0.68); - color: #dff1ff; - white-space: nowrap; - pointer-events: none; - } - .dev .lqi { - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - margin-top: calc(var(--icon-size, 2.5cqw) * 0.05); - font-size: calc(var(--icon-size, 2.5cqw) * 0.38); - font-weight: 700; - line-height: 1; - text-shadow: 0 0 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9); - white-space: nowrap; - pointer-events: none; - } - .editbar { - display: flex; - align-items: center; - gap: 10px; - padding: 9px 14px; - border-bottom: 1px solid var(--hp-line); - font-size: 13px; - flex-wrap: wrap; - } - .tab .tabedit { - --mdc-icon-size: 13px; - margin-left: 6px; - opacity: 0.4; - vertical-align: middle; - } - .tab:hover .tabedit { - opacity: 0.9; - } - .tab.tabadd { - padding: 6px 8px; - } - .tab.tabadd ha-icon { - --mdc-icon-size: 15px; - } - .planrow { - display: flex; - align-items: center; - gap: 10px; - } - .planprev { - max-width: 120px; - max-height: 70px; - border: 1px solid var(--hp-line); - border-radius: 6px; - background: #fff; - } - .planname { - font-size: 12.5px; - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .planname.muted { - color: var(--hp-muted); - } - .filebtn { - cursor: pointer; - } - .btn.danger { - border-color: #b3402a; - color: #ff7a5c; - } - .dialog .row .spacer { - flex: 1; - } - .dialog.wide { - width: min(440px, 94vw); - } - .dialog .body { - max-height: 66vh; - overflow-y: auto; - } - .descin { - width: 100%; - box-sizing: border-box; - background: var(--hp-bg); - border: 1px solid var(--hp-line); - color: var(--hp-txt); - border-radius: 6px; - padding: 6px 8px; - font-size: 13px; - font-family: inherit; - resize: vertical; - } - .bindsel { - display: flex; - flex-direction: column; - gap: 6px; - border: 1px solid var(--hp-line); - border-radius: 8px; - padding: 8px; - } - .bindsel .opt { - display: flex; - align-items: center; - gap: 6px; - border: 1px solid var(--hp-line); - background: transparent; - color: var(--hp-txt); - border-radius: 6px; - padding: 6px 8px; - cursor: pointer; - font-size: 12.5px; - font-family: inherit; - } - .bindsel .opt.on { - background: var(--hp-accent); - color: var(--text-primary-color, #fff); - border-color: var(--hp-accent); - } - .curbind { - display: flex; - align-items: center; - gap: 6px; - font-size: 12.5px; - color: var(--hp-txt); - flex-wrap: wrap; - } - .curbind .ref { - color: var(--hp-muted); - font-size: 11px; - } - .candlist { - max-height: 160px; - overflow-y: auto; - border-top: 1px solid var(--hp-line); - } - .cand { - display: flex; - justify-content: space-between; - gap: 8px; - padding: 6px 8px; - cursor: pointer; - border-radius: 6px; - font-size: 12.5px; - } - .cand:hover { - background: rgba(127, 127, 127, 0.15); - } - .cand.sel { - background: var(--hp-accent); - color: var(--text-primary-color, #fff); - } - .cand .cs { - color: var(--hp-muted); - font-size: 11px; - white-space: nowrap; - } - .cand.sel .cs { - color: var(--text-primary-color, #fff); - opacity: 0.85; - } - .cand.muted { - color: var(--hp-muted); - cursor: default; - } - .pdfedit { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; - } - .pdftag { - display: inline-flex; - align-items: center; - gap: 4px; - border: 1px solid var(--hp-line); - border-radius: 6px; - padding: 3px 6px; - font-size: 12px; - } - .pdftag a { - color: var(--hp-txt); - text-decoration: none; - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .pdftag .x { - --mdc-icon-size: 15px; - cursor: pointer; - color: var(--hp-muted); - } - .pdftag .x:hover { - color: #ff7a5c; - } - .inforow { - display: flex; - gap: 10px; - font-size: 13px; - margin: 3px 0; - } - .inforow .k { - color: var(--hp-muted); - min-width: 84px; - } - .inforow a { - color: var(--hp-accent); - word-break: break-all; - } - .infodesc { - font-size: 13px; - white-space: pre-wrap; - margin-top: 6px; - } - .infodesc.muted { - color: var(--hp-muted); - } - .pdflist { - display: flex; - flex-direction: column; - gap: 4px; - } - .pdf { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--hp-accent); - text-decoration: none; - } - ha-icon-picker { - display: block; - } - .dialogwrap { - background: rgba(0, 0, 0, 0.45); - display: flex; - align-items: center; - justify-content: center; - z-index: 90; - } - .dialog { - background: var(--card-background-color, var(--hp-bg)); - border: 1px solid var(--hp-accent); - border-radius: 14px; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); - width: min(360px, 92vw); - overflow: hidden; - } - .dialog .hd { - padding: 12px 16px; - font-weight: 600; - border-bottom: 1px solid var(--hp-line); - display: flex; - align-items: center; - gap: 8px; - } - .dialog .hd ha-icon { - color: var(--hp-accent); - } - .dialog .body { - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 6px; - } - .dialog .body label { - font-size: 12px; - color: var(--hp-muted); - margin-top: 6px; - } - .dialog .body .namein, - .dialog .body .areasel { - width: 100%; - box-sizing: border-box; - } - .dialog .row { - display: flex; - justify-content: flex-end; - gap: 8px; - padding: 12px 16px; - border-top: 1px solid var(--hp-line); - } - .editbar .warn { - color: #ffc14d; - } - .editbar .sname { - font-weight: 600; - } - .editbar input { - width: 74px; - background: transparent; - border: 1px solid var(--hp-line); - color: var(--hp-txt); - border-radius: 6px; - padding: 5px 7px; - font-size: 13px; - } - .editbar label, - .editbar .hint { - color: var(--hp-muted); - font-size: 12px; - } - .menuwrap { - position: fixed; - inset: 0; - z-index: 80; - } - .menu { - position: fixed; - background: var(--hp-bg); - border: 1px solid var(--hp-accent); - border-radius: 10px; - box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45); - min-width: 210px; - max-width: 300px; - overflow: hidden; - transform: translate(0, 8px); - } - .menu .hd { - padding: 8px 12px; - font-weight: 600; - font-size: 12.5px; - border-bottom: 1px solid var(--hp-line); - display: flex; - align-items: center; - gap: 6px; - } - .menu .hd ha-icon, - .menu .it.all ha-icon { - color: var(--hp-accent); - --mdc-icon-size: 16px; - } - .menu .it { - padding: 8px 12px; - font-size: 12.5px; - cursor: pointer; - display: flex; - align-items: center; - gap: 8px; - } - .menu .it ha-icon { - --mdc-icon-size: 16px; - color: var(--hp-muted); - } - .menu .it:hover { - background: rgba(127, 127, 127, 0.15); - } - .menu .it.all { - color: var(--hp-accent); - font-weight: 600; - } - .tip { - position: fixed; - pointer-events: none; - background: var(--hp-bg); - border: 1px solid var(--hp-accent); - color: var(--hp-txt); - padding: 6px 10px; - border-radius: 8px; - font-size: 12.5px; - box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45); - z-index: 99; - max-width: 260px; - } - .tip .m { - color: var(--hp-muted); - font-size: 11px; - display: block; - } - .toast { - position: fixed; - left: 50%; - bottom: 22px; - transform: translateX(-50%); - background: var(--hp-bg); - border: 1px solid var(--hp-accent); - color: var(--hp-txt); - padding: 9px 16px; - border-radius: 10px; - font-size: 13px; - box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45); - z-index: 120; - max-width: 90vw; - } - `; + static styles = cardStyles; } if (!customElements.get('houseplan-card')) { diff --git a/src/logic.ts b/src/logic.ts index a77a682..4ddde9d 100644 --- a/src/logic.ts +++ b/src/logic.ts @@ -98,3 +98,17 @@ export function declump( if (!moved) break; } } + +/** + * Безопасный URL для : допускаются только http(s) и относительные пути. + * Отсекает javascript:, data: и прочие опасные схемы (XSS через конфиг). + */ +export function safeUrl(url: string | null | undefined): string | null { + if (!url) return null; + const u = url.trim(); + if (/^(https?:)?\/\//i.test(u) || u.startsWith('/') || /^[\w./#?=&%~-]+$/i.test(u)) { + if (/^[a-z][\w+.-]*:/i.test(u) && !/^https?:/i.test(u)) return null; + return u; + } + return null; +} diff --git a/src/rules.ts b/src/rules.ts index 022aab3..6d8e7dd 100755 --- a/src/rules.ts +++ b/src/rules.ts @@ -9,48 +9,40 @@ export const EXCLUDED_DOMAINS = new Set([ 'upnp_serial_number', ]); -/** Заголовки групп ламп по area_id. */ -export const GROUP_TITLES: Record = { - gostinaia_na_dache: 'Настенные лампы', - prikhozhaia: 'Свет в прихожей', - detskaia_maiia: 'Свет у Майи', - detskaia_elina: 'Свет у Элины', -}; - -const ICON_RULES: Array<[string, string]> = [ - ['протечк', 'mdi:water-alert'], - ['клапан', 'mdi:pipe-valve'], - ['дым', 'mdi:smoke-detector'], - ['термоголов', 'mdi:radiator'], - ['температ', 'mdi:thermometer'], - ['qingping|air monitor|молекул', 'mdi:air-filter'], - ['штор', 'mdi:roller-shade'], - ['розетк|plug', 'mdi:power-socket-de'], - ['выключат|switch', 'mdi:light-switch'], - ['лампа|лампочк|bulb|gx53|светильник|rgb', 'mdi:lightbulb'], - ['камер|camera', 'mdi:cctv'], - ['замок|ttlock|lock|sn609|sn9161', 'mdi:lock'], - ['ворота|garage', 'mdi:garage-variant'], - ['калитк|door|открыт', 'mdi:door'], - ['счётчик|счетчик|kws|meter', 'mdi:meter-electric'], - ['вводный автомат|breaker|wifimcbn', 'mdi:electric-switch'], - ['myheat|котёл|котел|boiler|отоплен', 'mdi:water-boiler'], - ['холодильник|fridge', 'mdi:fridge'], - ['стиральн|washer', 'mdi:washing-machine'], - ['сушилк|dryer', 'mdi:tumble-dryer'], - ['пылесос|vacuum|dreame', 'mdi:robot-vacuum'], - ['soundbar|колонк|станц', 'mdi:soundbar'], - ['tv|телевизор|hyundaitv|mitv', 'mdi:television'], - ['keenetic|роутер|router', 'mdi:router-wireless'], - ['ибп|ups|kirpich', 'mdi:battery-charging-high'], - ['slzb|координат|zigbee', 'mdi:zigbee'], +const ICON_RULES: Array<[RegExp, string]> = [ + [/протечк/, 'mdi:water-alert'], + [/клапан/, 'mdi:pipe-valve'], + [/дым/, 'mdi:smoke-detector'], + [/термоголов/, 'mdi:radiator'], + [/температ/, 'mdi:thermometer'], + [/qingping|air monitor|молекул/, 'mdi:air-filter'], + [/штор/, 'mdi:roller-shade'], + [/розетк|plug/, 'mdi:power-socket-de'], + [/выключат|switch/, 'mdi:light-switch'], + [/лампа|лампочк|bulb|gx53|светильник|rgb/, 'mdi:lightbulb'], + [/камер|camera/, 'mdi:cctv'], + [/замок|ttlock|lock|sn609|sn9161/, 'mdi:lock'], + [/ворота|garage/, 'mdi:garage-variant'], + [/калитк|door|открыт/, 'mdi:door'], + [/счётчик|счетчик|kws|meter/, 'mdi:meter-electric'], + [/вводный автомат|breaker|wifimcbn/, 'mdi:electric-switch'], + [/myheat|котёл|котел|boiler|отоплен/, 'mdi:water-boiler'], + [/холодильник|fridge/, 'mdi:fridge'], + [/стиральн|washer/, 'mdi:washing-machine'], + [/сушилк|dryer/, 'mdi:tumble-dryer'], + [/пылесос|vacuum|dreame/, 'mdi:robot-vacuum'], + [/soundbar|колонк|станц/, 'mdi:soundbar'], + [/tv|телевизор|hyundaitv|mitv/, 'mdi:television'], + [/keenetic|роутер|router/, 'mdi:router-wireless'], + [/ибп|ups|kirpich/, 'mdi:battery-charging-high'], + [/slzb|координат|zigbee/, 'mdi:zigbee'], ]; /** Подбор MDI-иконки по имени/модели устройства. */ export function iconFor(name?: string, model?: string): string { const s = ((name || '') + ' ' + (model || '')).toLowerCase(); for (const [pat, icon] of ICON_RULES) { - if (new RegExp(pat).test(s)) return icon; + if (pat.test(s)) return icon; } return 'mdi:chip'; } diff --git a/src/styles.ts b/src/styles.ts new file mode 100644 index 0000000..5f2f575 --- /dev/null +++ b/src/styles.ts @@ -0,0 +1,727 @@ +/** Стили карточки House Plan (вынесены из компонента). */ +import { css } from 'lit'; + +export const cardStyles = css` + :host { + --hp-bg: var(--card-background-color, #16212e); + --hp-line: var(--divider-color, #2b3d4f); + --hp-txt: var(--primary-text-color, #e6edf3); + --hp-muted: var(--secondary-text-color, #8aa0b3); + --hp-accent: var(--primary-color, #3ea6ff); + --hp-on: #ffd45c; + --hp-open: #ff9f43; + } + ha-card { + overflow: visible; /* overflow:hidden ломает position:sticky у шапки */ + } + .empty { + padding: 40px 24px; + color: var(--hp-txt); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + } + .empty .big { + --mdc-icon-size: 56px; + color: var(--hp-accent); + opacity: 0.7; + } + .empty .muted { + color: var(--hp-muted); + font-size: 13px; + margin: 0; + } + .empty .btn { + margin-top: 8px; + } + .hdr { + position: sticky; + top: var(--header-height, 56px); + z-index: 20; + background: var(--card-background-color, var(--hp-bg)); + border-radius: var(--ha-card-border-radius, 12px) var(--ha-card-border-radius, 12px) 0 0; + } + .head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var(--hp-line); + flex-wrap: wrap; + } + .title { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } + .title ha-icon { + color: var(--hp-accent); + --mdc-icon-size: 18px; + } + .tabs { + display: flex; + gap: 4px; + background: rgba(127, 127, 127, 0.12); + padding: 3px; + border-radius: 10px; + flex-wrap: wrap; + } + @media (max-width: 620px) { + .head { gap: 6px; padding: 8px 10px; } + .head .count { display: none; } + .head .title { font-size: 14px; } + } + .tab { + border: 0; + background: transparent; + color: var(--hp-muted); + padding: 6px 13px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: 0.15s; + font-family: inherit; + } + .tab:hover { + color: var(--hp-txt); + } + .tab.active { + background: var(--hp-accent); + color: var(--text-primary-color, #fff); + } + .count { + font-size: 12px; + color: var(--hp-muted); + } + .spacer { + flex: 1; + } + .btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--hp-line); + background: transparent; + color: var(--hp-txt); + padding: 6px 10px; + border-radius: 8px; + cursor: pointer; + transition: 0.15s; + font-family: inherit; + font-size: 12.5px; + } + .btn ha-icon { + --mdc-icon-size: 17px; + } + .btn:hover { + border-color: var(--hp-accent); + } + .btn.on { + background: var(--hp-accent); + color: var(--text-primary-color, #fff); + border-color: var(--hp-accent); + } + .btn.ghost { + border: none; + } + .btn[disabled] { + opacity: 0.5; + pointer-events: none; + } + .stage { + position: relative; + width: 100%; + container-type: inline-size; + overflow: hidden; + touch-action: none; /* свои жесты pinch/pan */ + background: var(--ha-card-background, var(--card-background-color, #111)); + } + .zoomwrap { + position: absolute; + inset: 0; + } + .zoomctl { + display: inline-flex; + gap: 2px; + background: rgba(127, 127, 127, 0.12); + border-radius: 8px; + padding: 2px; + } + .zoomctl .zb { + border: none; + padding: 5px 7px; + } + .zoomctl .zb[disabled] { + opacity: 0.4; + pointer-events: none; + } + .zoombadge { + position: absolute; + left: 8px; + bottom: 8px; + background: rgba(4, 18, 31, 0.8); + color: #dff1ff; + border: 1px solid var(--hp-accent); + border-radius: 8px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; + pointer-events: none; + } + .stage svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + } + .room { + transition: 0.12s; + cursor: pointer; + } + .room.overlay { + fill: transparent; + stroke: transparent; + stroke-width: 2; + } + .room.overlay:hover { + fill: rgba(62, 166, 255, 0.18); + stroke: var(--hp-accent); + } + .room.yard { + fill: rgba(75, 140, 90, 0.14); + stroke: #4b8c5a; + stroke-width: 2; + } + .room.yard:hover { + fill: rgba(75, 140, 90, 0.24); + stroke: #6fbf86; + } + .rlabel { + fill: var(--hp-muted); + font-size: 15px; + font-weight: 600; + pointer-events: none; + text-anchor: middle; + } + .stage.edit .room { + pointer-events: none; + } + .stage.markup { + cursor: crosshair; + } + .stage.markup .room { + pointer-events: none; + } + .stage.markup .devlayer { + display: none; /* в разметке иконки не мешают */ + } + .room.outlined { + stroke: rgba(62, 166, 255, 0.55); + fill: rgba(62, 166, 255, 0.06); + } + .griddot { + fill: var(--hp-accent); + opacity: 0.75; + stroke: rgba(0, 0, 0, 0.35); + stroke-width: 0.4; + } + .seg { + stroke: var(--hp-accent); + stroke-width: 2.5; + stroke-linecap: round; + } + .pathline { + stroke: #ffc14d; + stroke-width: 3; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + } + .preview { + stroke: #ffc14d; + stroke-width: 2; + stroke-dasharray: 6 5; + opacity: 0.7; + } + .vertex { + fill: #ffc14d; + stroke: #4a2800; + stroke-width: 1; + } + .vertex.first { + fill: #4bd28f; + stroke: #04121f; + } + .areasel, + .namein { + background: var(--hp-bg); + border: 1px solid var(--hp-line); + color: var(--hp-txt); + border-radius: 6px; + padding: 6px 8px; + font-size: 13px; + font-family: inherit; + } + .namein { + width: 130px; + } + .devlayer { + position: absolute; + inset: 0; + pointer-events: none; + } + .dev { + position: absolute; + width: var(--icon-size, 2.5cqw); + height: var(--icon-size, 2.5cqw); + margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2); + border-radius: 22%; + background: var(--hp-bg); + border: 1px solid var(--hp-line); + display: flex; + align-items: center; + justify-content: center; + color: var(--hp-txt); + cursor: grab; + pointer-events: auto; + transition: background 0.15s, border-color 0.15s, opacity 0.2s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); + z-index: 2; + } + .dev ha-icon { + --mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62); + } + .dev:active { + cursor: grabbing; + } + .dev:hover { + background: var(--hp-accent); + color: var(--text-primary-color, #fff); + z-index: 5; + } + .dev.on { + background: var(--hp-on); + border-color: var(--hp-on); + color: #503c00; + box-shadow: 0 0 8px rgba(255, 212, 92, 0.7); + } + .dev.open { + background: var(--hp-open); + border-color: var(--hp-open); + color: #4a2800; + } + .dev.unavail { + opacity: 0.35; + } + .dev.virtual { + border-style: dashed; + } + .dev.sel { + border-color: #ffc14d; + box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35); + } + + .dev .tval { + position: absolute; + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: calc(var(--icon-size, 2.5cqw) * 0.1); + background: rgba(4, 18, 31, 0.9); + border: 1px solid var(--hp-accent); + border-radius: calc(var(--icon-size, 2.5cqw) * 0.18); + padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14); + font-size: calc(var(--icon-size, 2.5cqw) * 0.45); + font-weight: 700; + line-height: calc(var(--icon-size, 2.5cqw) * 0.68); + color: #dff1ff; + white-space: nowrap; + pointer-events: none; + } + .dev .lqi { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: calc(var(--icon-size, 2.5cqw) * 0.05); + font-size: calc(var(--icon-size, 2.5cqw) * 0.38); + font-weight: 700; + line-height: 1; + text-shadow: 0 0 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9); + white-space: nowrap; + pointer-events: none; + } + .editbar { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + border-bottom: 1px solid var(--hp-line); + font-size: 13px; + flex-wrap: wrap; + } + .tab .tabedit { + --mdc-icon-size: 13px; + margin-left: 6px; + opacity: 0.4; + vertical-align: middle; + } + .tab:hover .tabedit { + opacity: 0.9; + } + .tab.tabadd { + padding: 6px 8px; + } + .tab.tabadd ha-icon { + --mdc-icon-size: 15px; + } + .planrow { + display: flex; + align-items: center; + gap: 10px; + } + .planprev { + max-width: 120px; + max-height: 70px; + border: 1px solid var(--hp-line); + border-radius: 6px; + background: #fff; + } + .planname { + font-size: 12.5px; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .planname.muted { + color: var(--hp-muted); + } + .filebtn { + cursor: pointer; + } + .btn.danger { + border-color: #b3402a; + color: #ff7a5c; + } + .dialog .row .spacer { + flex: 1; + } + .dialog.wide { + width: min(440px, 94vw); + } + .dialog .body { + max-height: 66vh; + overflow-y: auto; + } + .descin { + width: 100%; + box-sizing: border-box; + background: var(--hp-bg); + border: 1px solid var(--hp-line); + color: var(--hp-txt); + border-radius: 6px; + padding: 6px 8px; + font-size: 13px; + font-family: inherit; + resize: vertical; + } + .bindsel { + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid var(--hp-line); + border-radius: 8px; + padding: 8px; + } + .bindsel .opt { + display: flex; + align-items: center; + gap: 6px; + border: 1px solid var(--hp-line); + background: transparent; + color: var(--hp-txt); + border-radius: 6px; + padding: 6px 8px; + cursor: pointer; + font-size: 12.5px; + font-family: inherit; + } + .bindsel .opt.on { + background: var(--hp-accent); + color: var(--text-primary-color, #fff); + border-color: var(--hp-accent); + } + .curbind { + display: flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + color: var(--hp-txt); + flex-wrap: wrap; + } + .curbind .ref { + color: var(--hp-muted); + font-size: 11px; + } + .candlist { + max-height: 160px; + overflow-y: auto; + border-top: 1px solid var(--hp-line); + } + .cand { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + cursor: pointer; + border-radius: 6px; + font-size: 12.5px; + } + .cand:hover { + background: rgba(127, 127, 127, 0.15); + } + .cand.sel { + background: var(--hp-accent); + color: var(--text-primary-color, #fff); + } + .cand .cs { + color: var(--hp-muted); + font-size: 11px; + white-space: nowrap; + } + .cand.sel .cs { + color: var(--text-primary-color, #fff); + opacity: 0.85; + } + .cand.muted { + color: var(--hp-muted); + cursor: default; + } + .pdfedit { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + } + .pdftag { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--hp-line); + border-radius: 6px; + padding: 3px 6px; + font-size: 12px; + } + .pdftag a { + color: var(--hp-txt); + text-decoration: none; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .pdftag .x { + --mdc-icon-size: 15px; + cursor: pointer; + color: var(--hp-muted); + } + .pdftag .x:hover { + color: #ff7a5c; + } + .inforow { + display: flex; + gap: 10px; + font-size: 13px; + margin: 3px 0; + } + .inforow .k { + color: var(--hp-muted); + min-width: 84px; + } + .inforow a { + color: var(--hp-accent); + word-break: break-all; + } + .infodesc { + font-size: 13px; + white-space: pre-wrap; + margin-top: 6px; + } + .infodesc.muted { + color: var(--hp-muted); + } + .pdflist { + display: flex; + flex-direction: column; + gap: 4px; + } + .pdf { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--hp-accent); + text-decoration: none; + } + ha-icon-picker { + display: block; + } + .dialogwrap { + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 90; + } + .dialog { + background: var(--card-background-color, var(--hp-bg)); + border: 1px solid var(--hp-accent); + border-radius: 14px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); + width: min(360px, 92vw); + overflow: hidden; + } + .dialog .hd { + padding: 12px 16px; + font-weight: 600; + border-bottom: 1px solid var(--hp-line); + display: flex; + align-items: center; + gap: 8px; + } + .dialog .hd ha-icon { + color: var(--hp-accent); + } + .dialog .body { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 6px; + } + .dialog .body label { + font-size: 12px; + color: var(--hp-muted); + margin-top: 6px; + } + .dialog .body .namein, + .dialog .body .areasel { + width: 100%; + box-sizing: border-box; + } + .dialog .row { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--hp-line); + } + .editbar .warn { + color: #ffc14d; + } + .editbar .sname { + font-weight: 600; + } + .editbar input { + width: 74px; + background: transparent; + border: 1px solid var(--hp-line); + color: var(--hp-txt); + border-radius: 6px; + padding: 5px 7px; + font-size: 13px; + } + .editbar label, + .editbar .hint { + color: var(--hp-muted); + font-size: 12px; + } + .menuwrap { + position: fixed; + inset: 0; + z-index: 80; + } + .menu { + position: fixed; + background: var(--hp-bg); + border: 1px solid var(--hp-accent); + border-radius: 10px; + box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45); + min-width: 210px; + max-width: 300px; + overflow: hidden; + transform: translate(0, 8px); + } + .menu .hd { + padding: 8px 12px; + font-weight: 600; + font-size: 12.5px; + border-bottom: 1px solid var(--hp-line); + display: flex; + align-items: center; + gap: 6px; + } + .menu .hd ha-icon, + .menu .it.all ha-icon { + color: var(--hp-accent); + --mdc-icon-size: 16px; + } + .menu .it { + padding: 8px 12px; + font-size: 12.5px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + } + .menu .it ha-icon { + --mdc-icon-size: 16px; + color: var(--hp-muted); + } + .menu .it:hover { + background: rgba(127, 127, 127, 0.15); + } + .menu .it.all { + color: var(--hp-accent); + font-weight: 600; + } + .tip { + position: fixed; + pointer-events: none; + background: var(--hp-bg); + border: 1px solid var(--hp-accent); + color: var(--hp-txt); + padding: 6px 10px; + border-radius: 8px; + font-size: 12.5px; + box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45); + z-index: 99; + max-width: 260px; + } + .tip .m { + color: var(--hp-muted); + font-size: 11px; + display: block; + } + .toast { + position: fixed; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + background: var(--hp-bg); + border: 1px solid var(--hp-accent); + color: var(--hp-txt); + padding: 9px 16px; + border-radius: 10px; + font-size: 13px; + box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45); + z-index: 120; + max-width: 90vw; + } + `; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..778468d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,75 @@ +/** Общие типы карточки House Plan. */ + +export interface RoomCfg { + id?: string; + name: string; + area: string | null; + x?: number; + y?: number; + w?: number; + h?: number; + poly?: number[][]; // полигон в рендер-единицах (модель) / нормированный (конфиг) +} + +export interface SpaceModel { + id: string; + title: string; + vb: number[]; // render units + bg: { href: string; x: number; y: number; w: number; h: number } | null; + rooms: RoomCfg[]; // render units +} + +export interface PdfRef { + name: string; + url: string; +} + +/** Маркер конфига: правит/дополняет авто-устройство ИЛИ описывает ручной/виртуальный значок. */ +export interface Marker { + id: string; + binding: string; // 'device:' | 'entity:' | 'virtual' + space?: string | null; + area?: string | null; + hidden?: boolean; + name?: string | null; + icon?: string | null; + model?: string | null; + link?: string | null; + description?: string | null; + pdfs?: PdfRef[]; +} + +export interface ServerConfig { + spaces: any[]; + markers: Marker[]; + settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean }; +} + +export interface DevItem { + id: string; + name: string; + model: string; + area: string; + space: string; + icon: string; + entities: string[]; + primary?: string; + temp?: number | null; + virtual?: boolean; + marker?: Marker; // связанный маркер конфига (метаданные, оверрайды) + bindingKind?: 'device' | 'entity' | 'virtual'; + bindingRef?: string; // device_id / entity_id + link?: string | null; + description?: string | null; + pdfs?: PdfRef[]; +} + +export interface CardConfig { + type: string; + title?: string; + default_floor?: string; + icon_size?: number; + show_temperature?: boolean; + live_states?: boolean; + show_signal?: boolean; +} diff --git a/test/logic.test.mjs b/test/logic.test.mjs index 284d7dc..49353c6 100644 --- a/test/logic.test.mjs +++ b/test/logic.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi, - fitView, declump, + fitView, declump, safeUrl, } from '../test-build/logic.js'; import { iconFor } from '../test-build/rules.js'; @@ -112,3 +112,18 @@ test('averageLqi: пусто → null, иначе округлённое сре assert.equal(averageLqi([100, 200]), 150); assert.equal(averageLqi([1, 2, 2]), 2); }); + +test('safeUrl: допускает http(s) и относительные, режет опасные схемы', () => { + assert.equal(safeUrl('https://example.com/a?b=1'), 'https://example.com/a?b=1'); + assert.equal(safeUrl('http://x.ru'), 'http://x.ru'); + assert.equal(safeUrl('//cdn.x.ru/f.pdf'), '//cdn.x.ru/f.pdf'); + assert.equal(safeUrl('/houseplan_files/files/m1/doc.pdf?v=1'), '/houseplan_files/files/m1/doc.pdf?v=1'); + assert.equal(safeUrl('docs/manual.pdf'), 'docs/manual.pdf'); + assert.equal(safeUrl('javascript:alert(1)'), null); + assert.equal(safeUrl('data:text/html,