diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index 3f4751b..d6e5264 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.6.1" +VERSION = "1.6.2" DEFAULT_CONFIG: dict = { "spaces": [], diff --git a/custom_components/houseplan/frontend/houseplan-card.js b/custom_components/houseplan/frontend/houseplan-card.js index e30dfe0..cb4aa64 100755 --- a/custom_components/houseplan/frontend/houseplan-card.js +++ b/custom_components/houseplan/frontend/houseplan-card.js @@ -4,7 +4,7 @@ const I=globalThis,i=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow .schema=${yI} .computeLabel=${I=>SI[I.name]||I.name} @value-changed=${this._valueChanged} - >`:v}_valueChanged(I){const i={...this._config,...I.detail.value},M=new Event("config-changed",{bubbles:!0,composed:!0});M.detail={config:i},this.dispatchEvent(M)}}eI.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",eI);const ZI="houseplan_card_layout_v1",mI=1e3,bI={f1:[1196.6656,1467.26],f2:[1170.0986,1073]},HI=I=>{const i=Math.max(0,Math.min(120,(I-40)/140*120));return`hsl(${Math.round(i)}, 85%, 55%)`},AI=(I,i,M)=>{const N=new Event(i,{bubbles:!0,composed:!0});N.detail=M??{},I.dispatchEvent(N)},xI=(I,i)=>{let M;return(...N)=>{clearTimeout(M),M=window.setTimeout(()=>I(...N),i)}};class LI extends cI{constructor(){super(...arguments),this._space="f1",this._edit=!1,this._layout={},this._serverStorage=!1,this._loaded=!1,this._serverCfg=null,this._cfgRev=0,this._unsubCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._tip=null,this._selId=null,this._toast="",this._migrating=!1,this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._infoCard=null,this._markerDialog=null,this._spaceDialog=null,this._keyHandler=I=>this._onKey(I),this._drag=null,this._dirtyPos=new Set,this._persistLayout=xI(()=>{if(this._serverStorage){const I=[...this._dirtyPos];this._dirtyPos.clear();for(const i of I){const I=this._layout[i];I&&this.hass.callWS({type:"houseplan/layout/update",device_id:i,pos:I}).catch(I=>this._showToast("Не удалось сохранить позицию: "+(I?.message||I)))}}else localStorage.setItem(ZI,JSON.stringify(this._layout))},600),this._saveConfig=xI(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev}).then(I=>{this._cfgRev=I?.rev??this._cfgRev+1}).catch(I=>{"conflict"===I?.code?(this._showToast("Конфиг изменён в другом окне — данные обновлены, повторите последнее действие"),this._cancelPath(),this._reloadConfigOnly()):this._showToast("Не удалось сохранить конфиг: "+(I?.message||I))})},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),this._unsubCfg&&(this._unsubCfg(),this._unsubCfg=null),super.disconnectedCallback()}_onKey(I){if("Escape"===I.key){if(this._infoCard)return void(this._infoCard=null);if(this._markerDialog)return void(this._markerDialog=null)}if(!this._markup)return;return"Escape"===I.key||(I.ctrlKey||I.metaKey)&&"z"===I.key.toLowerCase()?this._roomDialog?(I.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(I.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const I=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),I&&this._removeSegmentByKey(I),this._path=this._path.slice(0,-1)}_removeSegmentByKey(I){const i=this._curSpaceCfg;if(!i?.segments)return;const M=this._segments.findIndex(i=>this._segKey([i[0],i[1]],[i[2],i[3]])===I);M>=0&&(i.segments.splice(M,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(I){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...I},I.default_floor&&(this._space=I.default_floor)}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){if(this._norm)return this._serverCfg.spaces.map(I=>{const i=mI/I.aspect;return{id:I.id,title:I.title,vb:[I.view_box[0]*mI,I.view_box[1]*i,I.view_box[2]*mI,I.view_box[3]*i],bg:I.plan_url?{href:I.plan_url,x:0,y:0,w:mI,h:i}:null,rooms:I.rooms.map(I=>({id:I.id,name:I.name,area:I.area??null,x:null!=I.x?I.x*mI:void 0,y:null!=I.y?I.y*i:void 0,w:null!=I.w?I.w*mI:void 0,h:null!=I.h?I.h*i:void 0,poly:I.poly?I.poly.map(I=>[I[0]*mI,I[1]*i]):void 0}))}});const I=this._config?.rooms?.length?this._config.rooms:wI;return Object.keys(zI).map(i=>{const M=aI[i],N=OI[i];return{id:i,title:dI[i]||i,vb:zI[i],bg:N&&M?{href:N,x:M[0],y:M[1],w:M[2],h:M[3]}:null,rooms:I.filter(I=>I.floor===i).map(I=>({name:I.name,area:I.area||null,x:I.x,y:I.y,w:I.w,h:I.h}))}})}_spaceModel(I){const i=this._model;return i.find(i=>i.id===(I??this._space))||i[0]}get _areaToSpace(){const I={};for(const i of this._model)for(const M of i.rooms)M.area&&(I[M.area]={space:i.id,room:M});return I}get _settings(){return this._serverCfg?.settings||{}}get _excluded(){const I=this._settings.exclude_integrations;return I?new Set(I):lI}willUpdate(I){I.has("hass")&&this.hass&&(this._loaded||(this._loaded=!0,this._loadFromServer()),this._maybeRebuildDevices())}async _loadFromServer(){try{const[I,i]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._serverStorage=!0;const M=I?.config;this._serverCfg=M&&Array.isArray(M.spaces)?M:null,this._cfgRev=I?.rev||0,this._layout=i?.layout||{},this._unsubCfg||(this._unsubCfg=await this.hass.connection.subscribeEvents(I=>{(I?.data?.rev??-1)!==this._cfgRev&&this._reloadConfigOnly()},"houseplan_config_updated")),this._norm&&!this._model.find(I=>I.id===this._space)&&(this._space=this._model[0]?.id||this._space)}catch(I){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(ZI)||"{}")||{}}catch{this._layout={}}this._showToast("Интеграция houseplan не найдена — позиции сохраняются локально")}this._regSignature="",this.requestUpdate()}async _reloadConfigOnly(){try{const I=await this.hass.callWS({type:"houseplan/config/get"}),i=I?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._cfgRev=I?.rev||0,this._regSignature="",this._maybeRebuildDevices(),this.requestUpdate()}catch{}}_maybeRebuildDevices(){const I=this.hass;if(!I?.devices||!I?.entities||!I?.areas)return;const i=Object.keys(I.devices).length+":"+Object.keys(I.entities).length+":"+Object.keys(I.areas).length+":"+(this._norm?"n":"l");i===this._regSignature&&this._devices.length||(this._regSignature=i,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const I={};for(const[i,M]of Object.entries(this.hass.entities))M?.device_id&&(I[M.device_id]=I[M.device_id]||[]).push(i);return I}_domainOfDevice(I,i){if(I.identifiers?.[0]?.[0])return I.identifiers[0][0];for(const I of i){const i=this.hass.entities[I]?.platform;if(i)return i}return""}_primaryEntity(I,i){const M=I.map(I=>({eid:I,reg:this.hass.entities[I],st:this.hass.states[I]})).filter(I=>I.reg&&!I.reg.hidden),N=M.filter(I=>!I.reg.entity_category),T=N.length?N:M;if("mdi:thermometer"===i||"mdi:air-filter"===i){const I=T.find(I=>this._isTempEntity(I.eid));if(I)return I.eid}for(const I of sI){const i=T.find(i=>i.eid.split(".")[0]===I);if(i)return i.eid}return T[0]?.eid}_isTempEntity(I){const i=this.hass.states[I];if(!i)return/_temperature$/.test(I);const M=i.attributes||{};return"temperature"===M.device_class||/°C|°F/.test(M.unit_of_measurement||"")||/_temperature$/.test(I)}_lqiFor(I){const i=[];for(const M of I){const I=this.hass.states[M];if(!(/_linkquality$/.test(M)||"lqi"===(I?.attributes?.unit_of_measurement||""))||!I)continue;const N=parseFloat(I.state);isNaN(N)||i.push(N)}return i.length?Math.round(i.reduce((I,i)=>I+i,0)/i.length):null}_roomLqi(I){if(!I)return null;const i=[];for(const M of this._devices){if(M.area!==I||M.virtual)continue;const N=this._lqiFor(M.entities);null!=N&&i.push(N)}return i.length?Math.round(i.reduce((I,i)=>I+i,0)/i.length):null}_tempFor(I){for(const i of I){if(!this._isTempEntity(i))continue;const I=this.hass.states[i];if(!I)continue;const M=parseFloat(I.state);if(!isNaN(M))return Math.round(10*M)/10}return null}_lightGroups(){if(!1===this._settings.group_lights)return[];const I=this.hass,i=[];for(const[M,N]of Object.entries(I.entities)){if(!M.startsWith("light.")||N.hidden)continue;let T=null;if("group"===N.platform)T=N.area_id||null;else{if(!N.device_id)continue;{const i=I.devices[N.device_id];if("Group"!==i?.model)continue;T=i.area_id||N.area_id||null}}if(!T)continue;const D=I.states[M];i.push({eid:M,name:N.name||D?.attributes?.friendly_name||M,area:T})}return i}_groupedLightAreas(){return new Set(this._lightGroups().map(I=>I.area))}get _markers(){return this._serverCfg?.markers||[]}_markerFor(I,i){return this._markers.find(M=>M.binding===I+":"+i)}get _claimed(){const I=new Set;for(const i of this._markers){const[M,N]=i.binding.split(":");"device"!==M&&"entity"!==M||!N||I.add(i.binding)}return I}_applyMarker(I,i){I.marker=i,i.name&&(I.name=i.name),i.icon&&(I.icon=i.icon),null!=i.model&&(I.model=i.model),I.link=i.link??null,I.description=i.description??null,I.pdfs=i.pdfs||[]}_buildDevices(){const I=this.hass,i=this._areaToSpace,M=this._serverCfg?.device_overrides||{},N=!1!==this._settings.group_lights,T=this._groupedLightAreas(),D=this._excluded,j=this._entitiesByDevice(),c=this._claimed,g={},w=[];for(const z of Object.values(I.devices)){const I=z.area_id;if(!I||!i[I])continue;if("service"===z.entry_type)continue;if(c.has("device:"+z.id))continue;const d=M[z.id]||{},O=this._markerFor("device",z.id);if(O&&O.hidden||d.hidden)continue;const a=j[z.id]||[],l=this._domainOfDevice(z,a);if(D.has(l))continue;if("Group"===z.model)continue;if(/scene/i.test(z.model||""))continue;if(/bridge/i.test((z.model||"")+(z.name||"")))continue;if("myheat"===l&&z.via_device_id)continue;const t=(d.name||z.name_by_user||z.name||"без имени").trim(),k=t+"|"+I;if(g[k])continue;g[k]=1;let s=kI(t,z.model);if(a.some(I=>I.startsWith("lock."))&&(s="mdi:lock"),d.icon&&(s=d.icon),N&&"mdi:lightbulb"===s&&T.has(I))continue;const y={id:z.id,name:t,model:z.model||"",area:I,space:i[I].space,icon:s,entities:a,bindingKind:"device",bindingRef:z.id,pdfs:[]};y.primary=this._primaryEntity(a,s),"mdi:thermometer"!==s&&"mdi:air-filter"!==s||(y.temp=this._tempFor(a)),w.push(y)}for(const I of this._lightGroups())i[I.area]&&(c.has("entity:"+I.eid)||w.push({id:"lg_"+I.eid,name:I.name,model:"группа света",area:I.area,space:i[I.area].space,icon:"mdi:lightbulb-group",entities:[I.eid],primary:I.eid,bindingKind:"entity",bindingRef:I.eid,pdfs:[]}));for(const M of this._markers){if(M.hidden)continue;const[N,T]=M.binding.split(":");if("device"===N){const N=I.devices[T],D=N?.area_id||M.area||"",c=D&&i[D]?.space||M.space||this._model[0]?.id||"",g=N&&j[N.id]||[];let z=N?kI(N.name_by_user||N.name||"",N.model):"mdi:help-circle";g.some(I=>I.startsWith("lock."))&&(z="mdi:lock");const d={id:M.id,name:N?.name_by_user||N?.name||"устройство",model:N?.model||"",area:D,space:c,icon:z,entities:g,bindingKind:"device",bindingRef:T};d.primary=this._primaryEntity(g,z),"mdi:thermometer"!==z&&"mdi:air-filter"!==z||(d.temp=this._tempFor(g)),this._applyMarker(d,M),w.push(d)}else if("entity"===N){const N=I.entities[T],D=N?.area_id||N?.device_id&&I.devices[N.device_id]?.area_id||M.area||"",j=D&&i[D]?.space||M.space||this._model[0]?.id||"",c=I.states[T],g={id:M.id,name:N?.name||c?.attributes?.friendly_name||T,model:"",area:D,space:j,icon:"mdi:shape-outline",entities:[T],primary:T,bindingKind:"entity",bindingRef:T};this._applyMarker(g,M),w.push(g)}else{const I=M.area||"",N=M.space||I&&i[I]?.space||this._model[0]?.id||"",T={id:M.id,name:M.name||"виртуальное устройство",model:M.model||"",area:I,space:N,icon:M.icon||"mdi:map-marker",entities:[],bindingKind:"virtual",virtual:{id:M.id,space:N,name:M.name||"",icon:M.icon||"mdi:map-marker",x:0,y:0}};this._applyMarker(T,M),w.push(T)}}return w}_defaultPositions(){const I={};for(const i of this._model)for(const M of i.rooms){if(!M.area)continue;const N=this._devices.filter(I=>I.area===M.area&&I.space===i.id);if(!N.length)continue;const T=.12*Math.min(M.w,M.h),D=M.w-2*T,j=M.h-2*T,c=Math.max(1,Math.round(Math.sqrt(N.length*D/Math.max(j,1)))),g=Math.ceil(N.length/c),w=D/c,z=j/Math.max(g,1);N.forEach((i,N)=>{const D=N%c,j=Math.floor(N/c);I[i.id]={x:M.x+T+w*(D+.5),y:M.y+T+z*(j+.5)}})}return I}_pos(I){const i=this._spaceModel(I.space),M=this._layout[I.id];if(M)if(this._norm){if(M.s===I.space){const i=this._serverCfg.spaces.find(i=>i.id===I.space)?.aspect||1;return{x:M.x*mI,y:M.y*(mI/i)}}}else if(void 0===M.s)return{x:M.x,y:M.y};if(this._defPos[I.id])return this._defPos[I.id];const N=i.vb;return{x:N[0]+N[2]/2,y:N[1]+N[3]/2}}_savePos(I,i,M){if(this._norm){const N=this._gridPitch,T=Math.round(i/N)*N,D=Math.round(M/N)*N,j=this._serverCfg.spaces.find(i=>i.id===I.space)?.aspect||1;this._layout={...this._layout,[I.id]:{s:I.space,x:T/mI,y:D/(mI/j)}}}else this._layout={...this._layout,[I.id]:{x:Math.round(i),y:Math.round(M)}};this._dirtyPos.add(I.id),this._persistLayout()}_stateClass(I){if(!this._config?.live_states)return"";const i=I.primary?this.hass.states[I.primary]:void 0;if(!i)return"";if("unavailable"===i.state)return"unavail";const M=i.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(M))return"on"===i.state?"on":"";if("cover"===M||"valve"===M)return["open","opening"].includes(i.state)?"open":"";if("lock"===M)return["unlocked","open"].includes(i.state)?"open":"";if("binary_sensor"===M){const I=i.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(I))return"on"===i.state?"open":""}return"media_player"===M?["playing","on"].includes(i.state)?"on":"":"vacuum"===M&&["cleaning","returning"].includes(i.state)?"on":""}_liveTemp(I){return this._config?.show_temperature?"mdi:thermometer"!==I.icon&&"mdi:air-filter"!==I.icon?null:this._tempFor(I.entities):null}_openMoreInfo(I){I?AI(this,"hass-more-info",{entityId:I}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(I,i){if(I.stopPropagation(),!this._drag?.moved&&!this._markup)return this._edit?(this._selId=i.id,void this._openMarkerDialog(i)):void(this._infoCard=i)}_clickRoom(I){var i;!this._edit&&I.area&&(i="/config/areas/area/"+I.area,history.pushState(null,"",i),AI(window,"location-changed",{replace:!1}))}_pointerDown(I,i){if(!this._edit)return;I.preventDefault();const M=this._pos(i);this._drag={id:i.id,sx:I.clientX,sy:I.clientY,ox:M.x,oy:M.y,moved:!1},I.target.setPointerCapture(I.pointerId),this._tip=null}_pointerMove(I,i){if(!this._drag||this._drag.id!==i.id)return;const M=this.renderRoot.querySelector(".stage");if(!M)return;const N=this._spaceModel().vb,T=M.getBoundingClientRect(),D=(I.clientX-this._drag.sx)/T.width*N[2],j=(I.clientY-this._drag.sy)/T.height*N[3];Math.abs(I.clientX-this._drag.sx)+Math.abs(I.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const c=.008*Math.min(N[2],N[3]),g=Math.max(N[0]+c,Math.min(N[0]+N[2]-c,this._drag.ox+D)),w=Math.max(N[1]+c,Math.min(N[1]+N[3]-c,this._drag.oy+j));this._savePos(i,g,w)}_pointerUp(I,i){if(!this._drag||this._drag.id!==i.id)return;const M=this._drag.moved;this._drag=M?this._drag:null,M&&(this._selId=i.id,window.setTimeout(()=>this._drag=null,0))}_applyXY(I,i){if(!this._selId)return;const M=parseFloat(i);if(isNaN(M))return;const N=this._devices.find(I=>I.id===this._selId);if(!N||N.virtual)return;const T={...this._pos(N)};T[I]=M,this._savePos(N,T.x,T.y)}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(I){this._toast=I,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(I,i,M,N){this._drag||(this._tip={x:I.clientX,y:I.clientY,title:i,meta:M,lqi:N})}get _gridPitch(){return mI/120}get _curSpaceCfg(){return this._serverCfg?.spaces.find(I=>I.id===this._space)}get _spaceH(){const I=this._curSpaceCfg;return I?mI/I.aspect:mI}get _segments(){const I=this._curSpaceCfg,i=this._spaceH;return(I?.segments||[]).map(I=>[I[0]*mI,I[1]*i,I[2]*mI,I[3]*i])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._edit=!1,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(I){const i=this.renderRoot.querySelector(".stage"),M=this._spaceModel().vb,N=i.getBoundingClientRect();return[M[0]+(I.clientX-N.left)/N.width*M[2],M[1]+(I.clientY-N.top)/N.height*M[3]]}_snap(I){const i=this._gridPitch;return[Math.round(I[0]/i)*i,Math.round(I[1]/i)*i]}_samePt(I,i){return Math.abs(I[0]-i[0])<.001&&Math.abs(I[1]-i[1])<.001}_segKey(I,i){const[M,N]=I[0]this._segKey([I[0],I[1]],[I[2],I[3]])===T);return!D&&(M.segments=M.segments||[],M.segments.push([I[0]/mI,I[1]/N,i[0]/mI,i[1]/N]),this._saveConfig(),!0)}_distToSeg(I,i){const[M,N]=I,[T,D,j,c]=i,g=j-T,w=c-D;let z=((M-T)*g+(N-D)*w)/(g*g+w*w||1);z=Math.max(0,Math.min(1,z));const d=T+z*g,O=D+z*w;return Math.hypot(M-d,N-O)}_pointInRoom(I,i){if(i.poly){let M=!1;const N=i.poly;for(let i=0,T=N.length-1;iI[1]!=g>I[1]&&I[0]<(c-D)*(I[1]-j)/(g-j)+D&&(M=!M)}return M}return null!=i.x&&I[0]>=i.x&&I[0]<=i.x+i.w&&I[1]>=i.y&&I[1]<=i.y+i.h}_markupClick(I){if(!this._markup)return;const i=this._svgPoint(I);if("erase"===this._tool){const I=this._curSpaceCfg;if(!I?.segments?.length)return;const M=this._segments;let N=-1,T=.5*this._gridPitch;return M.forEach((I,M)=>{const D=this._distToSeg(i,I);D=0&&(I.segments.splice(N,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const I=[...this._spaceModel().rooms].reverse().find(I=>this._pointInRoom(i,I));if(!I)return;if(!confirm(`Удалить комнату «${I.name}»?`))return;const M=this._curSpaceCfg;return M.rooms=M.rooms.filter(i=>i.id!==I.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const M=this._snap(i);if(!this._path.length)return this._path=[M],void(this._pathSegs=[]);const N=this._path[this._path.length-1];if(this._samePt(M,N))return;const T=this._addSegment(N,M);this._pathSegs=[...this._pathSegs,T?this._segKey(N,M):null],this._path=[...this._path,M],this._path.length>=4&&this._samePt(M,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(I){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(I)))}_saveRoom(){if(!this._contourClosed||!this._areaSel&&!this._nameSel)return;const I=this._curSpaceCfg;if(!I)return;const i=this._spaceH,M=this._path.slice(0,-1),N=this._areaSel?this.hass.areas[this._areaSel]?.name:"";I.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||N||"Комната",area:this._areaSel||null,poly:M.map(I=>[I[0]/mI,I[1]/i])}),this._saveConfig(),this._path=[],this._pathSegs=[],this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Комната сохранена")}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const I=new Set;for(const i of this._serverCfg?.spaces||[])for(const M of i.rooms||[])M.area&&I.add(M.area);return Object.values(this.hass?.areas||{}).filter(i=>!I.has(i.area_id)).sort((I,i)=>(I.name||"").localeCompare(i.name||""))}_openMarkerDialog(I){this._norm?this._markerDialog=I?{devId:I.id,name:I.name,binding:"virtual"===I.bindingKind?"virtual":I.bindingKind+":"+I.bindingRef,bindingFilter:"",icon:I.marker?.icon||"",model:I.model||"",link:I.link||"",description:I.description||"",pdfs:[...I.pdfs||[]],room:I.space&&I.area?I.space+"#"+I.area:"",busy:!1}:{name:"",binding:"virtual",bindingFilter:"",icon:"",model:"",link:"",description:"",pdfs:[],room:"",busy:!1}:this._showToast("Редактирование устройств доступно после переноса конфига на сервер")}_bindingCandidates(){const I=this.hass,i=new Set;for(const I of this._devices)I.id!==this._markerDialog?.devId&&("device"===I.bindingKind&&I.bindingRef&&i.add("device:"+I.bindingRef),"entity"===I.bindingKind&&I.bindingRef&&i.add("entity:"+I.bindingRef));const M=new Set;for(const I of this._devices)"device"===I.bindingKind&&I.name&&M.add(I.name.trim()+"|"+(I.area||""));const N=[];for(const T of Object.values(I.devices)){if("service"===T.entry_type)continue;const I="device:"+T.id;if(i.has(I))continue;const D=(T.name_by_user||T.name||T.id).trim();I!==this._markerDialog?.binding&&M.has(D+"|"+(T.area_id||""))||N.push({value:I,label:D,sub:(T.model||"устройство")+("Group"===T.model?" · Z2M-группа":"")})}const T=new Set(["group","template","derivative","min_max","threshold","integration","statistics","trend","utility_meter","tod","switch_as_x","schedule"]);for(const[M,D]of Object.entries(I.entities)){const j="entity:"+M;if(i.has(j))continue;const c=T.has(D.platform),g="group"===D.platform;if(!c&&!g)continue;if(D.hidden)continue;const w=I.states[M];N.push({value:j,label:D.name||w?.attributes?.friendly_name||M,sub:M.split(".")[0]+" · "+("group"===D.platform?"группа":"хелпер")})}const D=(this._markerDialog?.bindingFilter||"").toLowerCase().trim(),j=D?N.filter(I=>(I.label+" "+I.sub+" "+I.value).toLowerCase().includes(D)):N;return j.sort((I,i)=>I.label.localeCompare(i.label)),j.slice(0,200)}_allRoomsFlat(){const I=[];for(const i of this._serverCfg?.spaces||[])for(const M of i.rooms||[])M.area&&I.push({value:i.id+"#"+M.area,label:(i.title||i.id)+" · "+M.name});return I}async _pickMarkerFiles(I){const i=I.target,M=i.files?[...i.files]:[];if(!M.length||!this._markerDialog)return;const N=this._markerDialog.devId||"new";for(const I of M)try{const i=new Uint8Array(await I.arrayBuffer());let M="";for(let I=0;Ii.url!==I)})}async _saveMarker(){const I=this._markerDialog;if(I&&!I.busy)if("virtual"!==I.binding||I.name.trim()){this._markerDialog={...I,busy:!0};try{const i=this._serverCfg;let M;i.markers=i.markers||[];let N=null,T=null;if("virtual"===I.binding){const[i,D]=I.room?I.room.split("#"):["",""];N=i||this._space,T=D||null,M=I.devId&&I.devId.startsWith("v_")?I.devId:"v_"+Date.now().toString(36)}else{const[i,N]=I.binding.split(":");M="device"===i?N:"lg_"+N}const D=I.devId,j={id:M,binding:I.binding,name:I.name.trim()||null,icon:I.icon||null,model:I.model.trim()||null,link:I.link.trim()||null,description:I.description.trim()||null,pdfs:I.pdfs};if("virtual"===I.binding&&(j.space=N,j.area=T),i.markers=i.markers.filter(I=>I.id!==M&&I.id!==D),i.markers.push(j),!this._layout[M]){const I=this._spaceModel(N||void 0);let i=I.vb[0]+I.vb[2]/2,D=I.vb[1]+I.vb[3]/2;if(T){const M=I.rooms.find(I=>I.area===T);M&&([i,D]=this._roomCenter(M))}this._layout[M]=this._normPos(N||this._space,i,D)}await this._saveConfigNow(),await this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство сохранено")}catch(I){this._markerDialog={...this._markerDialog,busy:!1},this._showToast("Ошибка: "+(I?.message||I))}}else this._showToast("Укажите имя виртуального устройства")}async _deleteMarker(){const I=this._markerDialog;if(!I)return;const i=I.devId?this._devices.find(i=>i.id===I.devId):null,M=I.name||"устройство";if(!confirm(`Убрать «${M}» с плана?`))return;const N=this._serverCfg;N.markers=N.markers||[],i&&"virtual"===i.bindingKind?N.markers=N.markers.filter(I=>I.id!==i.id):i&&i.marker?(N.markers=N.markers.filter(I=>I.id!==i.id),"device"===i.bindingKind&&i.bindingRef?N.markers.push({id:i.id,binding:"device:"+i.bindingRef,hidden:!0}):"entity"===i.bindingKind&&i.bindingRef&&N.markers.push({id:i.id,binding:"entity:"+i.bindingRef,hidden:!0})):i&&"device"===i.bindingKind&&i.bindingRef?N.markers.push({id:i.id,binding:"device:"+i.bindingRef,hidden:!0}):i&&"entity"===i.bindingKind&&i.bindingRef&&N.markers.push({id:i.id,binding:"entity:"+i.bindingRef,hidden:!0});try{await this._saveConfigNow(),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство убрано с плана")}catch(I){this._showToast("Ошибка: "+(I?.message||I))}}_normPos(I,i,M){const N=this._serverCfg.spaces.find(i=>i.id===I)?.aspect||1;return{s:I,x:i/mI,y:M/(mI/N)}}_openSpaceDialog(I,i){if(this._norm)if("edit"===I){const M=this._serverCfg.spaces.find(I=>I.id===i);if(!M)return;this._spaceDialog={mode:I,spaceId:i,title:M.title,planUrl:M.plan_url||null,planFile:null,busy:!1}}else this._spaceDialog={mode:I,title:"",planUrl:null,planFile:null,busy:!1};else this._showToast("Управление пространствами доступно после переноса конфига на сервер")}async _pickPlanFile(I){const i=I.target,M=i.files?.[0];if(!M||!this._spaceDialog)return;const N={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[M.type]||(M.name.toLowerCase().endsWith(".svg")?"svg":"");if(!N)return void this._showToast("Поддерживаются SVG, PNG, JPG, WebP");const T=new Uint8Array(await M.arrayBuffer());let D="";for(let I=0;I{const i=new Image;i.onload=()=>I(i.naturalWidth&&i.naturalHeight?i.naturalWidth/i.naturalHeight:1.414),i.onerror=()=>I(1.414),i.src=c});URL.revokeObjectURL(c),this._spaceDialog={...this._spaceDialog,planFile:{ext:N,b64:j,aspect:g,name:M.name}}}async _saveSpaceDialog(){const I=this._spaceDialog;if(I&&!I.busy&&I.title.trim()){this._spaceDialog={...I,busy:!0};try{const i=this._serverCfg;let M;if("create"===I.mode?(M={id:"s"+Date.now().toString(36),title:I.title.trim(),plan_url:null,aspect:1.414,view_box:[0,0,1,1],rooms:[],segments:[]},i.spaces.push(M)):(M=i.spaces.find(i=>i.id===I.spaceId),M.title=I.title.trim()),I.planFile){const i=await this.hass.callWS({type:"houseplan/plan/set",space_id:M.id,ext:I.planFile.ext,data:I.planFile.b64});M.plan_url=i.url,M.aspect=I.planFile.aspect}await this._saveConfigNow(),this._spaceDialog=null,"create"===I.mode&&(this._space=M.id),this._regSignature="",this._maybeRebuildDevices(),this._showToast("create"===I.mode?"Пространство добавлено":"Пространство сохранено")}catch(I){this._spaceDialog={...this._spaceDialog,busy:!1},this._showToast("Ошибка: "+(I?.message||I))}}}async _deleteSpace(){const I=this._spaceDialog;if(!I||"edit"!==I.mode)return;const i=this._serverCfg.spaces.find(i=>i.id===I.spaceId);if(confirm(`Удалить пространство «${i.title}» со всеми комнатами и разметкой?`)){this._serverCfg.spaces=this._serverCfg.spaces.filter(i=>i.id!==I.spaceId);try{await this._saveConfigNow(),this._spaceDialog=null,this._space===I.spaceId&&(this._space=this._serverCfg.spaces[0]?.id||""),this._regSignature="",this._maybeRebuildDevices(),this._showToast("Пространство удалено")}catch(I){this._showToast("Ошибка удаления: "+(I?.message||I))}}}async _saveConfigNow(){const I=await this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev});this._cfgRev=I?.rev??this._cfgRev+1}async _migrateToServer(){if(this._serverStorage&&!this._norm&&!this._migrating&&confirm("Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?")){this._migrating=!0;try{const I=[];for(const i of Object.keys(zI)){const M=zI[i],N=aI[i]||M,[T,D,j,c]=N;let g=null,w=j/c;const z=OI[i];if(z&&aI[i]){const I=bI[i];I&&(w=I[0]/I[1]);const M=z.split(",")[1];g=(await this.hass.callWS({type:"houseplan/plan/set",space_id:i,ext:"svg",data:M})).url}const d=wI.filter(I=>I.floor===i).map((I,M)=>({id:i+"_r"+M,name:I.name,area:I.area||null,x:(I.x-T)/j,y:(I.y-D)/c,w:I.w/j,h:I.h/c}));I.push({id:i,title:dI[i]||i,plan_url:g,aspect:w,view_box:[(M[0]-T)/j,(M[1]-D)/c,M[2]/j,M[3]/c],rooms:d})}const i={spaces:I,device_overrides:{},virtual_devices:[],settings:{exclude_integrations:[...lI],group_lights:!0}},M={};for(const I of this._devices){if(I.virtual)continue;const i=this._layout[I.id]||this._defPos[I.id];if(!i)continue;const N=aI[I.space]||zI[I.space];M[I.id]={s:I.space,x:(i.x-N[0])/N[2],y:(i.y-N[1])/N[3]}}await this.hass.callWS({type:"houseplan/config/set",config:i}),await this.hass.callWS({type:"houseplan/layout/set",layout:M}),this._serverCfg=i,this._layout=M,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Конфигурация перенесена на сервер — карта работает от server config")}catch(I){this._showToast("Ошибка миграции: "+(I?.message||I))}finally{this._migrating=!1}}}render(){if(!this._config||!this.hass)return v;const I=this._model;if(!I.length)return Q`
Нет настроенных пространств
`;const i=this._spaceModel(),M=i.vb,N=this._devices.filter(I=>I.space===i.id),T=this._config.icon_size??2.5,D=T>8?2.5:T;return Q` + >`:v}_valueChanged(I){const i={...this._config,...I.detail.value},M=new Event("config-changed",{bubbles:!0,composed:!0});M.detail={config:i},this.dispatchEvent(M)}}eI.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",eI);const ZI="houseplan_card_layout_v1",mI=1e3,bI={f1:[1196.6656,1467.26],f2:[1170.0986,1073]},HI=I=>{const i=Math.max(0,Math.min(120,(I-40)/140*120));return`hsl(${Math.round(i)}, 85%, 55%)`},AI=(I,i,M)=>{const N=new Event(i,{bubbles:!0,composed:!0});N.detail=M??{},I.dispatchEvent(N)},xI=(I,i)=>{let M;return(...N)=>{clearTimeout(M),M=window.setTimeout(()=>I(...N),i)}};class LI extends cI{constructor(){super(...arguments),this._space="f1",this._edit=!1,this._layout={},this._serverStorage=!1,this._loaded=!1,this._serverCfg=null,this._cfgRev=0,this._unsubCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._tip=null,this._selId=null,this._toast="",this._migrating=!1,this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._infoCard=null,this._markerDialog=null,this._spaceDialog=null,this._keyHandler=I=>this._onKey(I),this._drag=null,this._dirtyPos=new Set,this._persistLayout=xI(()=>{if(this._serverStorage){const I=[...this._dirtyPos];this._dirtyPos.clear();for(const i of I){const I=this._layout[i];I&&this.hass.callWS({type:"houseplan/layout/update",device_id:i,pos:I}).catch(I=>this._showToast("Не удалось сохранить позицию: "+(I?.message||I)))}}else localStorage.setItem(ZI,JSON.stringify(this._layout))},600),this._saveConfig=xI(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev}).then(I=>{this._cfgRev=I?.rev??this._cfgRev+1}).catch(I=>{"conflict"===I?.code?(this._showToast("Конфиг изменён в другом окне — данные обновлены, повторите последнее действие"),this._cancelPath(),this._reloadConfigOnly()):this._showToast("Не удалось сохранить конфиг: "+(I?.message||I))})},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),this._unsubCfg&&(this._unsubCfg(),this._unsubCfg=null),super.disconnectedCallback()}_onKey(I){if("Escape"===I.key){if(this._infoCard)return void(this._infoCard=null);if(this._markerDialog)return void(this._markerDialog=null)}if(!this._markup)return;return"Escape"===I.key||(I.ctrlKey||I.metaKey)&&"z"===I.key.toLowerCase()?this._roomDialog?(I.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(I.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const I=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),I&&this._removeSegmentByKey(I),this._path=this._path.slice(0,-1)}_removeSegmentByKey(I){const i=this._curSpaceCfg;if(!i?.segments)return;const M=this._segments.findIndex(i=>this._segKey([i[0],i[1]],[i[2],i[3]])===I);M>=0&&(i.segments.splice(M,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(I){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...I},I.default_floor&&(this._space=I.default_floor)}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){if(this._norm)return this._serverCfg.spaces.map(I=>{const i=mI/I.aspect;return{id:I.id,title:I.title,vb:[I.view_box[0]*mI,I.view_box[1]*i,I.view_box[2]*mI,I.view_box[3]*i],bg:I.plan_url?{href:I.plan_url,x:0,y:0,w:mI,h:i}:null,rooms:I.rooms.map(I=>({id:I.id,name:I.name,area:I.area??null,x:null!=I.x?I.x*mI:void 0,y:null!=I.y?I.y*i:void 0,w:null!=I.w?I.w*mI:void 0,h:null!=I.h?I.h*i:void 0,poly:I.poly?I.poly.map(I=>[I[0]*mI,I[1]*i]):void 0}))}});const I=this._config?.rooms?.length?this._config.rooms:wI;return Object.keys(zI).map(i=>{const M=aI[i],N=OI[i];return{id:i,title:dI[i]||i,vb:zI[i],bg:N&&M?{href:N,x:M[0],y:M[1],w:M[2],h:M[3]}:null,rooms:I.filter(I=>I.floor===i).map(I=>({name:I.name,area:I.area||null,x:I.x,y:I.y,w:I.w,h:I.h}))}})}_spaceModel(I){const i=this._model;return i.find(i=>i.id===(I??this._space))||i[0]}get _areaToSpace(){const I={};for(const i of this._model)for(const M of i.rooms)M.area&&(I[M.area]={space:i.id,room:M});return I}get _settings(){return this._serverCfg?.settings||{}}get _excluded(){const I=this._settings.exclude_integrations;return I?new Set(I):lI}willUpdate(I){I.has("hass")&&this.hass&&(this._loaded||(this._loaded=!0,this._loadFromServer()),this._maybeRebuildDevices())}async _loadFromServer(){try{const[I,i]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._serverStorage=!0;const M=I?.config;this._serverCfg=M&&Array.isArray(M.spaces)?M:null,this._cfgRev=I?.rev||0,this._layout=i?.layout||{},this._unsubCfg||(this._unsubCfg=await this.hass.connection.subscribeEvents(I=>{(I?.data?.rev??-1)!==this._cfgRev&&this._reloadConfigOnly()},"houseplan_config_updated")),this._norm&&!this._model.find(I=>I.id===this._space)&&(this._space=this._model[0]?.id||this._space)}catch(I){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(ZI)||"{}")||{}}catch{this._layout={}}this._showToast("Интеграция houseplan не найдена — позиции сохраняются локально")}this._regSignature="",this.requestUpdate()}async _reloadConfigOnly(){try{const I=await this.hass.callWS({type:"houseplan/config/get"}),i=I?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._cfgRev=I?.rev||0,this._regSignature="",this._maybeRebuildDevices(),this.requestUpdate()}catch{}}_maybeRebuildDevices(){const I=this.hass;if(!I?.devices||!I?.entities||!I?.areas)return;const i=Object.keys(I.devices).length+":"+Object.keys(I.entities).length+":"+Object.keys(I.areas).length+":"+(this._norm?"n":"l");i===this._regSignature&&this._devices.length||(this._regSignature=i,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const I={};for(const[i,M]of Object.entries(this.hass.entities))M?.device_id&&(I[M.device_id]=I[M.device_id]||[]).push(i);return I}_domainOfDevice(I,i){if(I.identifiers?.[0]?.[0])return I.identifiers[0][0];for(const I of i){const i=this.hass.entities[I]?.platform;if(i)return i}return""}_primaryEntity(I,i){const M=I.map(I=>({eid:I,reg:this.hass.entities[I],st:this.hass.states[I]})).filter(I=>I.reg&&!I.reg.hidden),N=M.filter(I=>!I.reg.entity_category),T=N.length?N:M;if("mdi:thermometer"===i||"mdi:air-filter"===i){const I=T.find(I=>this._isTempEntity(I.eid));if(I)return I.eid}for(const I of sI){const i=T.find(i=>i.eid.split(".")[0]===I);if(i)return i.eid}return T[0]?.eid}_isTempEntity(I){const i=this.hass.states[I];if(!i)return/_temperature$/.test(I);const M=i.attributes||{};return"temperature"===M.device_class||/°C|°F/.test(M.unit_of_measurement||"")||/_temperature$/.test(I)}_lqiFor(I){const i=[];for(const M of I){const I=this.hass.states[M];if(!(/_linkquality$/.test(M)||"lqi"===(I?.attributes?.unit_of_measurement||""))||!I)continue;const N=parseFloat(I.state);isNaN(N)||i.push(N)}return i.length?Math.round(i.reduce((I,i)=>I+i,0)/i.length):null}_roomLqi(I){if(!I)return null;const i=[];for(const M of this._devices){if(M.area!==I||M.virtual)continue;const N=this._lqiFor(M.entities);null!=N&&i.push(N)}return i.length?Math.round(i.reduce((I,i)=>I+i,0)/i.length):null}_tempFor(I){for(const i of I){if(!this._isTempEntity(i))continue;const I=this.hass.states[i];if(!I)continue;const M=parseFloat(I.state);if(!isNaN(M))return Math.round(10*M)/10}return null}_lightGroups(){if(!1===this._settings.group_lights)return[];const I=this.hass,i=[];for(const[M,N]of Object.entries(I.entities)){if(!M.startsWith("light.")||N.hidden)continue;let T=null;if("group"===N.platform)T=N.area_id||null;else{if(!N.device_id)continue;{const i=I.devices[N.device_id];if("Group"!==i?.model)continue;T=i.area_id||N.area_id||null}}if(!T)continue;const D=I.states[M];i.push({eid:M,name:N.name||D?.attributes?.friendly_name||M,area:T})}return i}_groupedLightAreas(){return new Set(this._lightGroups().map(I=>I.area))}get _markers(){return this._serverCfg?.markers||[]}_markerFor(I,i){return this._markers.find(M=>M.binding===I+":"+i)}get _claimed(){const I=new Set;for(const i of this._markers){const[M,N]=i.binding.split(":");"device"!==M&&"entity"!==M||!N||I.add(i.binding)}return I}_applyMarker(I,i){I.marker=i,i.name&&(I.name=i.name),i.icon&&(I.icon=i.icon),null!=i.model&&(I.model=i.model),I.link=i.link??null,I.description=i.description??null,I.pdfs=i.pdfs||[]}_buildDevices(){const I=this.hass,i=this._areaToSpace,M=this._serverCfg?.device_overrides||{},N=!1!==this._settings.group_lights,T=this._groupedLightAreas(),D=this._excluded,j=this._entitiesByDevice(),c=this._claimed,g={},w=[];for(const z of Object.values(I.devices)){const I=z.area_id;if(!I||!i[I])continue;if("service"===z.entry_type)continue;if(c.has("device:"+z.id))continue;const d=M[z.id]||{},O=this._markerFor("device",z.id);if(O&&O.hidden||d.hidden)continue;const a=j[z.id]||[],l=this._domainOfDevice(z,a);if(D.has(l))continue;if("Group"===z.model)continue;if(/scene/i.test(z.model||""))continue;if(/bridge/i.test((z.model||"")+(z.name||"")))continue;if("myheat"===l&&z.via_device_id)continue;const t=(d.name||z.name_by_user||z.name||"без имени").trim(),k=t+"|"+I;if(g[k])continue;g[k]=1;let s=kI(t,z.model);if(a.some(I=>I.startsWith("lock."))&&(s="mdi:lock"),d.icon&&(s=d.icon),N&&"mdi:lightbulb"===s&&T.has(I))continue;const y={id:z.id,name:t,model:z.model||"",area:I,space:i[I].space,icon:s,entities:a,bindingKind:"device",bindingRef:z.id,pdfs:[]};y.primary=this._primaryEntity(a,s),"mdi:thermometer"!==s&&"mdi:air-filter"!==s||(y.temp=this._tempFor(a)),w.push(y)}for(const I of this._lightGroups())i[I.area]&&(c.has("entity:"+I.eid)||w.push({id:"lg_"+I.eid,name:I.name,model:"группа света",area:I.area,space:i[I.area].space,icon:"mdi:lightbulb-group",entities:[I.eid],primary:I.eid,bindingKind:"entity",bindingRef:I.eid,pdfs:[]}));for(const M of this._markers){if(M.hidden)continue;const[N,T]=M.binding.split(":");if("device"===N){const N=I.devices[T],D=M.area||N?.area_id||"",c=D&&i[D]?.space||M.space||this._model[0]?.id||"",g=N&&j[N.id]||[];let z=N?kI(N.name_by_user||N.name||"",N.model):"mdi:help-circle";g.some(I=>I.startsWith("lock."))&&(z="mdi:lock");const d={id:M.id,name:N?.name_by_user||N?.name||"устройство",model:N?.model||"",area:D,space:c,icon:z,entities:g,bindingKind:"device",bindingRef:T};d.primary=this._primaryEntity(g,z),"mdi:thermometer"!==z&&"mdi:air-filter"!==z||(d.temp=this._tempFor(g)),this._applyMarker(d,M),w.push(d)}else if("entity"===N){const N=I.entities[T],D=M.area||N?.area_id||N?.device_id&&I.devices[N.device_id]?.area_id||"",j=D&&i[D]?.space||M.space||this._model[0]?.id||"",c=I.states[T],g={id:M.id,name:N?.name||c?.attributes?.friendly_name||T,model:"",area:D,space:j,icon:"mdi:shape-outline",entities:[T],primary:T,bindingKind:"entity",bindingRef:T};this._applyMarker(g,M),w.push(g)}else{const I=M.area||"",N=M.space||I&&i[I]?.space||this._model[0]?.id||"",T={id:M.id,name:M.name||"виртуальное устройство",model:M.model||"",area:I,space:N,icon:M.icon||"mdi:map-marker",entities:[],bindingKind:"virtual",virtual:{id:M.id,space:N,name:M.name||"",icon:M.icon||"mdi:map-marker",x:0,y:0}};this._applyMarker(T,M),w.push(T)}}return w}_defaultPositions(){const I={};for(const i of this._model)for(const M of i.rooms){if(!M.area)continue;const N=this._devices.filter(I=>I.area===M.area&&I.space===i.id);if(!N.length)continue;const T=.12*Math.min(M.w,M.h),D=M.w-2*T,j=M.h-2*T,c=Math.max(1,Math.round(Math.sqrt(N.length*D/Math.max(j,1)))),g=Math.ceil(N.length/c),w=D/c,z=j/Math.max(g,1);N.forEach((i,N)=>{const D=N%c,j=Math.floor(N/c);I[i.id]={x:M.x+T+w*(D+.5),y:M.y+T+z*(j+.5)}})}return I}_pos(I){const i=this._spaceModel(I.space),M=this._layout[I.id];if(M)if(this._norm){if(M.s===I.space){const i=this._serverCfg.spaces.find(i=>i.id===I.space)?.aspect||1;return{x:M.x*mI,y:M.y*(mI/i)}}}else if(void 0===M.s)return{x:M.x,y:M.y};if(this._defPos[I.id])return this._defPos[I.id];const N=i.vb;return{x:N[0]+N[2]/2,y:N[1]+N[3]/2}}_savePos(I,i,M){if(this._norm){const N=this._gridPitch,T=Math.round(i/N)*N,D=Math.round(M/N)*N,j=this._serverCfg.spaces.find(i=>i.id===I.space)?.aspect||1;this._layout={...this._layout,[I.id]:{s:I.space,x:T/mI,y:D/(mI/j)}}}else this._layout={...this._layout,[I.id]:{x:Math.round(i),y:Math.round(M)}};this._dirtyPos.add(I.id),this._persistLayout()}_stateClass(I){if(!this._config?.live_states)return"";const i=I.primary?this.hass.states[I.primary]:void 0;if(!i)return"";if("unavailable"===i.state)return"unavail";const M=i.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(M))return"on"===i.state?"on":"";if("cover"===M||"valve"===M)return["open","opening"].includes(i.state)?"open":"";if("lock"===M)return["unlocked","open"].includes(i.state)?"open":"";if("binary_sensor"===M){const I=i.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(I))return"on"===i.state?"open":""}return"media_player"===M?["playing","on"].includes(i.state)?"on":"":"vacuum"===M&&["cleaning","returning"].includes(i.state)?"on":""}_liveTemp(I){return this._config?.show_temperature?"mdi:thermometer"!==I.icon&&"mdi:air-filter"!==I.icon?null:this._tempFor(I.entities):null}_openMoreInfo(I){I?AI(this,"hass-more-info",{entityId:I}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(I,i){if(I.stopPropagation(),!this._drag?.moved&&!this._markup)return this._edit?(this._selId=i.id,void this._openMarkerDialog(i)):void(this._infoCard=i)}_clickRoom(I){var i;!this._edit&&I.area&&(i="/config/areas/area/"+I.area,history.pushState(null,"",i),AI(window,"location-changed",{replace:!1}))}_pointerDown(I,i){if(!this._edit)return;I.preventDefault();const M=this._pos(i);this._drag={id:i.id,sx:I.clientX,sy:I.clientY,ox:M.x,oy:M.y,moved:!1},I.target.setPointerCapture(I.pointerId),this._tip=null}_pointerMove(I,i){if(!this._drag||this._drag.id!==i.id)return;const M=this.renderRoot.querySelector(".stage");if(!M)return;const N=this._spaceModel().vb,T=M.getBoundingClientRect(),D=(I.clientX-this._drag.sx)/T.width*N[2],j=(I.clientY-this._drag.sy)/T.height*N[3];Math.abs(I.clientX-this._drag.sx)+Math.abs(I.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const c=.008*Math.min(N[2],N[3]),g=Math.max(N[0]+c,Math.min(N[0]+N[2]-c,this._drag.ox+D)),w=Math.max(N[1]+c,Math.min(N[1]+N[3]-c,this._drag.oy+j));this._savePos(i,g,w)}_pointerUp(I,i){if(!this._drag||this._drag.id!==i.id)return;const M=this._drag.moved;this._drag=M?this._drag:null,M&&(this._selId=i.id,window.setTimeout(()=>this._drag=null,0))}_applyXY(I,i){if(!this._selId)return;const M=parseFloat(i);if(isNaN(M))return;const N=this._devices.find(I=>I.id===this._selId);if(!N||N.virtual)return;const T={...this._pos(N)};T[I]=M,this._savePos(N,T.x,T.y)}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(I){this._toast=I,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(I,i,M,N){this._drag||(this._tip={x:I.clientX,y:I.clientY,title:i,meta:M,lqi:N})}get _gridPitch(){return mI/120}get _curSpaceCfg(){return this._serverCfg?.spaces.find(I=>I.id===this._space)}get _spaceH(){const I=this._curSpaceCfg;return I?mI/I.aspect:mI}get _segments(){const I=this._curSpaceCfg,i=this._spaceH;return(I?.segments||[]).map(I=>[I[0]*mI,I[1]*i,I[2]*mI,I[3]*i])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._edit=!1,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(I){const i=this.renderRoot.querySelector(".stage"),M=this._spaceModel().vb,N=i.getBoundingClientRect();return[M[0]+(I.clientX-N.left)/N.width*M[2],M[1]+(I.clientY-N.top)/N.height*M[3]]}_snap(I){const i=this._gridPitch;return[Math.round(I[0]/i)*i,Math.round(I[1]/i)*i]}_samePt(I,i){return Math.abs(I[0]-i[0])<.001&&Math.abs(I[1]-i[1])<.001}_segKey(I,i){const[M,N]=I[0]this._segKey([I[0],I[1]],[I[2],I[3]])===T);return!D&&(M.segments=M.segments||[],M.segments.push([I[0]/mI,I[1]/N,i[0]/mI,i[1]/N]),this._saveConfig(),!0)}_distToSeg(I,i){const[M,N]=I,[T,D,j,c]=i,g=j-T,w=c-D;let z=((M-T)*g+(N-D)*w)/(g*g+w*w||1);z=Math.max(0,Math.min(1,z));const d=T+z*g,O=D+z*w;return Math.hypot(M-d,N-O)}_pointInRoom(I,i){if(i.poly){let M=!1;const N=i.poly;for(let i=0,T=N.length-1;iI[1]!=g>I[1]&&I[0]<(c-D)*(I[1]-j)/(g-j)+D&&(M=!M)}return M}return null!=i.x&&I[0]>=i.x&&I[0]<=i.x+i.w&&I[1]>=i.y&&I[1]<=i.y+i.h}_markupClick(I){if(!this._markup)return;const i=this._svgPoint(I);if("erase"===this._tool){const I=this._curSpaceCfg;if(!I?.segments?.length)return;const M=this._segments;let N=-1,T=.5*this._gridPitch;return M.forEach((I,M)=>{const D=this._distToSeg(i,I);D=0&&(I.segments.splice(N,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const I=[...this._spaceModel().rooms].reverse().find(I=>this._pointInRoom(i,I));if(!I)return;if(!confirm(`Удалить комнату «${I.name}»?`))return;const M=this._curSpaceCfg;return M.rooms=M.rooms.filter(i=>i.id!==I.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const M=this._snap(i);if(!this._path.length)return this._path=[M],void(this._pathSegs=[]);const N=this._path[this._path.length-1];if(this._samePt(M,N))return;const T=this._addSegment(N,M);this._pathSegs=[...this._pathSegs,T?this._segKey(N,M):null],this._path=[...this._path,M],this._path.length>=4&&this._samePt(M,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(I){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(I)))}_saveRoom(){if(!this._contourClosed||!this._areaSel&&!this._nameSel)return;const I=this._curSpaceCfg;if(!I)return;const i=this._spaceH,M=this._path.slice(0,-1),N=this._areaSel?this.hass.areas[this._areaSel]?.name:"";I.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||N||"Комната",area:this._areaSel||null,poly:M.map(I=>[I[0]/mI,I[1]/i])}),this._saveConfig(),this._path=[],this._pathSegs=[],this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Комната сохранена")}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const I=new Set;for(const i of this._serverCfg?.spaces||[])for(const M of i.rooms||[])M.area&&I.add(M.area);return Object.values(this.hass?.areas||{}).filter(i=>!I.has(i.area_id)).sort((I,i)=>(I.name||"").localeCompare(i.name||""))}_openMarkerDialog(I){this._norm?this._markerDialog=I?{devId:I.id,name:I.name,binding:"virtual"===I.bindingKind?"virtual":I.bindingKind+":"+I.bindingRef,bindingFilter:"",icon:I.marker?.icon||"",model:I.model||"",link:I.link||"",description:I.description||"",pdfs:[...I.pdfs||[]],room:I.space&&I.area?I.space+"#"+I.area:"",busy:!1}:{name:"",binding:"virtual",bindingFilter:"",icon:"",model:"",link:"",description:"",pdfs:[],room:"",busy:!1}:this._showToast("Редактирование устройств доступно после переноса конфига на сервер")}_bindingCandidates(){const I=this.hass,i=new Set;for(const I of this._devices)I.id!==this._markerDialog?.devId&&("device"===I.bindingKind&&I.bindingRef&&i.add("device:"+I.bindingRef),"entity"===I.bindingKind&&I.bindingRef&&i.add("entity:"+I.bindingRef));const M=new Set;for(const I of this._devices)"device"===I.bindingKind&&I.name&&M.add(I.name.trim()+"|"+(I.area||""));const N=[];for(const T of Object.values(I.devices)){if("service"===T.entry_type)continue;const I="device:"+T.id;if(i.has(I))continue;const D=(T.name_by_user||T.name||T.id).trim();I!==this._markerDialog?.binding&&M.has(D+"|"+(T.area_id||""))||N.push({value:I,label:D,sub:(T.model||"устройство")+("Group"===T.model?" · Z2M-группа":"")})}const T=new Set(["group","template","derivative","min_max","threshold","integration","statistics","trend","utility_meter","tod","switch_as_x","schedule"]);for(const[M,D]of Object.entries(I.entities)){const j="entity:"+M;if(i.has(j))continue;const c=T.has(D.platform),g="group"===D.platform;if(!c&&!g)continue;if(D.hidden)continue;const w=I.states[M];N.push({value:j,label:D.name||w?.attributes?.friendly_name||M,sub:M.split(".")[0]+" · "+("group"===D.platform?"группа":"хелпер")})}const D=(this._markerDialog?.bindingFilter||"").toLowerCase().trim(),j=D?N.filter(I=>(I.label+" "+I.sub+" "+I.value).toLowerCase().includes(D)):N;return j.sort((I,i)=>I.label.localeCompare(i.label)),j.slice(0,200)}_allRoomsFlat(){const I=[];for(const i of this._serverCfg?.spaces||[])for(const M of i.rooms||[])M.area&&I.push({value:i.id+"#"+M.area,label:(i.title||i.id)+" · "+M.name});return I}async _pickMarkerFiles(I){const i=I.target,M=i.files?[...i.files]:[];if(!M.length||!this._markerDialog)return;const N=this._markerDialog.devId||"new";for(const I of M)try{const i=new Uint8Array(await I.arrayBuffer());let M="";for(let I=0;Ii.url!==I)})}async _saveMarker(){const I=this._markerDialog;if(I&&!I.busy)if("virtual"!==I.binding||I.name.trim()){this._markerDialog={...I,busy:!0};try{const i=this._serverCfg;let M;i.markers=i.markers||[];const[N,T]=I.room?I.room.split("#"):["",""];let D=N||null,j=T||null;if("virtual"===I.binding)D||(D=this._space),M=I.devId&&I.devId.startsWith("v_")?I.devId:"v_"+Date.now().toString(36);else{const[i,N]=I.binding.split(":");M="device"===i?N:"lg_"+N}const c=I.devId,g={id:M,binding:I.binding,name:I.name.trim()||null,icon:I.icon||null,model:I.model.trim()||null,link:I.link.trim()||null,description:I.description.trim()||null,pdfs:I.pdfs};("virtual"===I.binding||I.room)&&(g.space=D,g.area=j);const w=c?this._devices.find(I=>I.id===c):null,z=!!I.room&&null!=w&&(w.space!==D||w.area!==j);if(i.markers=i.markers.filter(I=>I.id!==M&&I.id!==c),i.markers.push(g),!this._layout[M]||z){const I=this._spaceModel(D||void 0);let i=I.vb[0]+I.vb[2]/2,N=I.vb[1]+I.vb[3]/2;if(j){const M=I.rooms.find(I=>I.area===j);M&&([i,N]=this._roomCenter(M))}this._layout[M]=this._normPos(D||this._space,i,N)}await this._saveConfigNow(),await this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство сохранено")}catch(I){this._markerDialog={...this._markerDialog,busy:!1},this._showToast("Ошибка: "+(I?.message||I))}}else this._showToast("Укажите имя виртуального устройства")}async _deleteMarker(){const I=this._markerDialog;if(!I)return;const i=I.devId?this._devices.find(i=>i.id===I.devId):null,M=I.name||"устройство";if(!confirm(`Убрать «${M}» с плана?`))return;const N=this._serverCfg;N.markers=N.markers||[],i&&"virtual"===i.bindingKind?N.markers=N.markers.filter(I=>I.id!==i.id):i&&i.marker?(N.markers=N.markers.filter(I=>I.id!==i.id),"device"===i.bindingKind&&i.bindingRef?N.markers.push({id:i.id,binding:"device:"+i.bindingRef,hidden:!0}):"entity"===i.bindingKind&&i.bindingRef&&N.markers.push({id:i.id,binding:"entity:"+i.bindingRef,hidden:!0})):i&&"device"===i.bindingKind&&i.bindingRef?N.markers.push({id:i.id,binding:"device:"+i.bindingRef,hidden:!0}):i&&"entity"===i.bindingKind&&i.bindingRef&&N.markers.push({id:i.id,binding:"entity:"+i.bindingRef,hidden:!0});try{await this._saveConfigNow(),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство убрано с плана")}catch(I){this._showToast("Ошибка: "+(I?.message||I))}}_normPos(I,i,M){const N=this._serverCfg.spaces.find(i=>i.id===I)?.aspect||1;return{s:I,x:i/mI,y:M/(mI/N)}}_openSpaceDialog(I,i){if(this._norm)if("edit"===I){const M=this._serverCfg.spaces.find(I=>I.id===i);if(!M)return;this._spaceDialog={mode:I,spaceId:i,title:M.title,planUrl:M.plan_url||null,planFile:null,busy:!1}}else this._spaceDialog={mode:I,title:"",planUrl:null,planFile:null,busy:!1};else this._showToast("Управление пространствами доступно после переноса конфига на сервер")}async _pickPlanFile(I){const i=I.target,M=i.files?.[0];if(!M||!this._spaceDialog)return;const N={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[M.type]||(M.name.toLowerCase().endsWith(".svg")?"svg":"");if(!N)return void this._showToast("Поддерживаются SVG, PNG, JPG, WebP");const T=new Uint8Array(await M.arrayBuffer());let D="";for(let I=0;I{const i=new Image;i.onload=()=>I(i.naturalWidth&&i.naturalHeight?i.naturalWidth/i.naturalHeight:1.414),i.onerror=()=>I(1.414),i.src=c});URL.revokeObjectURL(c),this._spaceDialog={...this._spaceDialog,planFile:{ext:N,b64:j,aspect:g,name:M.name}}}async _saveSpaceDialog(){const I=this._spaceDialog;if(I&&!I.busy&&I.title.trim()){this._spaceDialog={...I,busy:!0};try{const i=this._serverCfg;let M;if("create"===I.mode?(M={id:"s"+Date.now().toString(36),title:I.title.trim(),plan_url:null,aspect:1.414,view_box:[0,0,1,1],rooms:[],segments:[]},i.spaces.push(M)):(M=i.spaces.find(i=>i.id===I.spaceId),M.title=I.title.trim()),I.planFile){const i=await this.hass.callWS({type:"houseplan/plan/set",space_id:M.id,ext:I.planFile.ext,data:I.planFile.b64});M.plan_url=i.url,M.aspect=I.planFile.aspect}await this._saveConfigNow(),this._spaceDialog=null,"create"===I.mode&&(this._space=M.id),this._regSignature="",this._maybeRebuildDevices(),this._showToast("create"===I.mode?"Пространство добавлено":"Пространство сохранено")}catch(I){this._spaceDialog={...this._spaceDialog,busy:!1},this._showToast("Ошибка: "+(I?.message||I))}}}async _deleteSpace(){const I=this._spaceDialog;if(!I||"edit"!==I.mode)return;const i=this._serverCfg.spaces.find(i=>i.id===I.spaceId);if(confirm(`Удалить пространство «${i.title}» со всеми комнатами и разметкой?`)){this._serverCfg.spaces=this._serverCfg.spaces.filter(i=>i.id!==I.spaceId);try{await this._saveConfigNow(),this._spaceDialog=null,this._space===I.spaceId&&(this._space=this._serverCfg.spaces[0]?.id||""),this._regSignature="",this._maybeRebuildDevices(),this._showToast("Пространство удалено")}catch(I){this._showToast("Ошибка удаления: "+(I?.message||I))}}}async _saveConfigNow(){const I=await this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev});this._cfgRev=I?.rev??this._cfgRev+1}async _migrateToServer(){if(this._serverStorage&&!this._norm&&!this._migrating&&confirm("Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?")){this._migrating=!0;try{const I=[];for(const i of Object.keys(zI)){const M=zI[i],N=aI[i]||M,[T,D,j,c]=N;let g=null,w=j/c;const z=OI[i];if(z&&aI[i]){const I=bI[i];I&&(w=I[0]/I[1]);const M=z.split(",")[1];g=(await this.hass.callWS({type:"houseplan/plan/set",space_id:i,ext:"svg",data:M})).url}const d=wI.filter(I=>I.floor===i).map((I,M)=>({id:i+"_r"+M,name:I.name,area:I.area||null,x:(I.x-T)/j,y:(I.y-D)/c,w:I.w/j,h:I.h/c}));I.push({id:i,title:dI[i]||i,plan_url:g,aspect:w,view_box:[(M[0]-T)/j,(M[1]-D)/c,M[2]/j,M[3]/c],rooms:d})}const i={spaces:I,device_overrides:{},virtual_devices:[],settings:{exclude_integrations:[...lI],group_lights:!0}},M={};for(const I of this._devices){if(I.virtual)continue;const i=this._layout[I.id]||this._defPos[I.id];if(!i)continue;const N=aI[I.space]||zI[I.space];M[I.id]={s:I.space,x:(i.x-N[0])/N[2],y:(i.y-N[1])/N[3]}}await this.hass.callWS({type:"houseplan/config/set",config:i}),await this.hass.callWS({type:"houseplan/layout/set",layout:M}),this._serverCfg=i,this._layout=M,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Конфигурация перенесена на сервер — карта работает от server config")}catch(I){this._showToast("Ошибка миграции: "+(I?.message||I))}finally{this._migrating=!1}}}render(){if(!this._config||!this.hass)return v;const I=this._model;if(!I.length)return Q`
Нет настроенных пространств
`;const i=this._spaceModel(),M=i.vb,N=this._devices.filter(I=>I.space===i.id),T=this._config.icon_size??2.5,D=T>8?2.5:T;return Q`
@@ -134,6 +134,9 @@ const I=globalThis,i=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow ${I.model||M||I.link||I.description||I.pdfs&&I.pdfs.length?v:Q`
Нет дополнительной информации
`}
+ ${I.primary?Q``:v} @@ -171,12 +174,12 @@ const I=globalThis,i=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow
- ${i?Q` - `:v} + + ${customElements.get("ha-icon-picker")?Q`"houseplan-card"===I.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.6.1 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `,customElements.get("houseplan-card")||customElements.define("houseplan-card",LI),window.customCards=window.customCards||[],window.customCards.find(I=>"houseplan-card"===I.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.6.2 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/dist/houseplan-card.js b/dist/houseplan-card.js index e30dfe0..cb4aa64 100755 --- a/dist/houseplan-card.js +++ b/dist/houseplan-card.js @@ -4,7 +4,7 @@ const I=globalThis,i=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow .schema=${yI} .computeLabel=${I=>SI[I.name]||I.name} @value-changed=${this._valueChanged} - >`:v}_valueChanged(I){const i={...this._config,...I.detail.value},M=new Event("config-changed",{bubbles:!0,composed:!0});M.detail={config:i},this.dispatchEvent(M)}}eI.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",eI);const ZI="houseplan_card_layout_v1",mI=1e3,bI={f1:[1196.6656,1467.26],f2:[1170.0986,1073]},HI=I=>{const i=Math.max(0,Math.min(120,(I-40)/140*120));return`hsl(${Math.round(i)}, 85%, 55%)`},AI=(I,i,M)=>{const N=new Event(i,{bubbles:!0,composed:!0});N.detail=M??{},I.dispatchEvent(N)},xI=(I,i)=>{let M;return(...N)=>{clearTimeout(M),M=window.setTimeout(()=>I(...N),i)}};class LI extends cI{constructor(){super(...arguments),this._space="f1",this._edit=!1,this._layout={},this._serverStorage=!1,this._loaded=!1,this._serverCfg=null,this._cfgRev=0,this._unsubCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._tip=null,this._selId=null,this._toast="",this._migrating=!1,this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._infoCard=null,this._markerDialog=null,this._spaceDialog=null,this._keyHandler=I=>this._onKey(I),this._drag=null,this._dirtyPos=new Set,this._persistLayout=xI(()=>{if(this._serverStorage){const I=[...this._dirtyPos];this._dirtyPos.clear();for(const i of I){const I=this._layout[i];I&&this.hass.callWS({type:"houseplan/layout/update",device_id:i,pos:I}).catch(I=>this._showToast("Не удалось сохранить позицию: "+(I?.message||I)))}}else localStorage.setItem(ZI,JSON.stringify(this._layout))},600),this._saveConfig=xI(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev}).then(I=>{this._cfgRev=I?.rev??this._cfgRev+1}).catch(I=>{"conflict"===I?.code?(this._showToast("Конфиг изменён в другом окне — данные обновлены, повторите последнее действие"),this._cancelPath(),this._reloadConfigOnly()):this._showToast("Не удалось сохранить конфиг: "+(I?.message||I))})},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),this._unsubCfg&&(this._unsubCfg(),this._unsubCfg=null),super.disconnectedCallback()}_onKey(I){if("Escape"===I.key){if(this._infoCard)return void(this._infoCard=null);if(this._markerDialog)return void(this._markerDialog=null)}if(!this._markup)return;return"Escape"===I.key||(I.ctrlKey||I.metaKey)&&"z"===I.key.toLowerCase()?this._roomDialog?(I.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(I.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const I=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),I&&this._removeSegmentByKey(I),this._path=this._path.slice(0,-1)}_removeSegmentByKey(I){const i=this._curSpaceCfg;if(!i?.segments)return;const M=this._segments.findIndex(i=>this._segKey([i[0],i[1]],[i[2],i[3]])===I);M>=0&&(i.segments.splice(M,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(I){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...I},I.default_floor&&(this._space=I.default_floor)}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){if(this._norm)return this._serverCfg.spaces.map(I=>{const i=mI/I.aspect;return{id:I.id,title:I.title,vb:[I.view_box[0]*mI,I.view_box[1]*i,I.view_box[2]*mI,I.view_box[3]*i],bg:I.plan_url?{href:I.plan_url,x:0,y:0,w:mI,h:i}:null,rooms:I.rooms.map(I=>({id:I.id,name:I.name,area:I.area??null,x:null!=I.x?I.x*mI:void 0,y:null!=I.y?I.y*i:void 0,w:null!=I.w?I.w*mI:void 0,h:null!=I.h?I.h*i:void 0,poly:I.poly?I.poly.map(I=>[I[0]*mI,I[1]*i]):void 0}))}});const I=this._config?.rooms?.length?this._config.rooms:wI;return Object.keys(zI).map(i=>{const M=aI[i],N=OI[i];return{id:i,title:dI[i]||i,vb:zI[i],bg:N&&M?{href:N,x:M[0],y:M[1],w:M[2],h:M[3]}:null,rooms:I.filter(I=>I.floor===i).map(I=>({name:I.name,area:I.area||null,x:I.x,y:I.y,w:I.w,h:I.h}))}})}_spaceModel(I){const i=this._model;return i.find(i=>i.id===(I??this._space))||i[0]}get _areaToSpace(){const I={};for(const i of this._model)for(const M of i.rooms)M.area&&(I[M.area]={space:i.id,room:M});return I}get _settings(){return this._serverCfg?.settings||{}}get _excluded(){const I=this._settings.exclude_integrations;return I?new Set(I):lI}willUpdate(I){I.has("hass")&&this.hass&&(this._loaded||(this._loaded=!0,this._loadFromServer()),this._maybeRebuildDevices())}async _loadFromServer(){try{const[I,i]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._serverStorage=!0;const M=I?.config;this._serverCfg=M&&Array.isArray(M.spaces)?M:null,this._cfgRev=I?.rev||0,this._layout=i?.layout||{},this._unsubCfg||(this._unsubCfg=await this.hass.connection.subscribeEvents(I=>{(I?.data?.rev??-1)!==this._cfgRev&&this._reloadConfigOnly()},"houseplan_config_updated")),this._norm&&!this._model.find(I=>I.id===this._space)&&(this._space=this._model[0]?.id||this._space)}catch(I){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(ZI)||"{}")||{}}catch{this._layout={}}this._showToast("Интеграция houseplan не найдена — позиции сохраняются локально")}this._regSignature="",this.requestUpdate()}async _reloadConfigOnly(){try{const I=await this.hass.callWS({type:"houseplan/config/get"}),i=I?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._cfgRev=I?.rev||0,this._regSignature="",this._maybeRebuildDevices(),this.requestUpdate()}catch{}}_maybeRebuildDevices(){const I=this.hass;if(!I?.devices||!I?.entities||!I?.areas)return;const i=Object.keys(I.devices).length+":"+Object.keys(I.entities).length+":"+Object.keys(I.areas).length+":"+(this._norm?"n":"l");i===this._regSignature&&this._devices.length||(this._regSignature=i,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const I={};for(const[i,M]of Object.entries(this.hass.entities))M?.device_id&&(I[M.device_id]=I[M.device_id]||[]).push(i);return I}_domainOfDevice(I,i){if(I.identifiers?.[0]?.[0])return I.identifiers[0][0];for(const I of i){const i=this.hass.entities[I]?.platform;if(i)return i}return""}_primaryEntity(I,i){const M=I.map(I=>({eid:I,reg:this.hass.entities[I],st:this.hass.states[I]})).filter(I=>I.reg&&!I.reg.hidden),N=M.filter(I=>!I.reg.entity_category),T=N.length?N:M;if("mdi:thermometer"===i||"mdi:air-filter"===i){const I=T.find(I=>this._isTempEntity(I.eid));if(I)return I.eid}for(const I of sI){const i=T.find(i=>i.eid.split(".")[0]===I);if(i)return i.eid}return T[0]?.eid}_isTempEntity(I){const i=this.hass.states[I];if(!i)return/_temperature$/.test(I);const M=i.attributes||{};return"temperature"===M.device_class||/°C|°F/.test(M.unit_of_measurement||"")||/_temperature$/.test(I)}_lqiFor(I){const i=[];for(const M of I){const I=this.hass.states[M];if(!(/_linkquality$/.test(M)||"lqi"===(I?.attributes?.unit_of_measurement||""))||!I)continue;const N=parseFloat(I.state);isNaN(N)||i.push(N)}return i.length?Math.round(i.reduce((I,i)=>I+i,0)/i.length):null}_roomLqi(I){if(!I)return null;const i=[];for(const M of this._devices){if(M.area!==I||M.virtual)continue;const N=this._lqiFor(M.entities);null!=N&&i.push(N)}return i.length?Math.round(i.reduce((I,i)=>I+i,0)/i.length):null}_tempFor(I){for(const i of I){if(!this._isTempEntity(i))continue;const I=this.hass.states[i];if(!I)continue;const M=parseFloat(I.state);if(!isNaN(M))return Math.round(10*M)/10}return null}_lightGroups(){if(!1===this._settings.group_lights)return[];const I=this.hass,i=[];for(const[M,N]of Object.entries(I.entities)){if(!M.startsWith("light.")||N.hidden)continue;let T=null;if("group"===N.platform)T=N.area_id||null;else{if(!N.device_id)continue;{const i=I.devices[N.device_id];if("Group"!==i?.model)continue;T=i.area_id||N.area_id||null}}if(!T)continue;const D=I.states[M];i.push({eid:M,name:N.name||D?.attributes?.friendly_name||M,area:T})}return i}_groupedLightAreas(){return new Set(this._lightGroups().map(I=>I.area))}get _markers(){return this._serverCfg?.markers||[]}_markerFor(I,i){return this._markers.find(M=>M.binding===I+":"+i)}get _claimed(){const I=new Set;for(const i of this._markers){const[M,N]=i.binding.split(":");"device"!==M&&"entity"!==M||!N||I.add(i.binding)}return I}_applyMarker(I,i){I.marker=i,i.name&&(I.name=i.name),i.icon&&(I.icon=i.icon),null!=i.model&&(I.model=i.model),I.link=i.link??null,I.description=i.description??null,I.pdfs=i.pdfs||[]}_buildDevices(){const I=this.hass,i=this._areaToSpace,M=this._serverCfg?.device_overrides||{},N=!1!==this._settings.group_lights,T=this._groupedLightAreas(),D=this._excluded,j=this._entitiesByDevice(),c=this._claimed,g={},w=[];for(const z of Object.values(I.devices)){const I=z.area_id;if(!I||!i[I])continue;if("service"===z.entry_type)continue;if(c.has("device:"+z.id))continue;const d=M[z.id]||{},O=this._markerFor("device",z.id);if(O&&O.hidden||d.hidden)continue;const a=j[z.id]||[],l=this._domainOfDevice(z,a);if(D.has(l))continue;if("Group"===z.model)continue;if(/scene/i.test(z.model||""))continue;if(/bridge/i.test((z.model||"")+(z.name||"")))continue;if("myheat"===l&&z.via_device_id)continue;const t=(d.name||z.name_by_user||z.name||"без имени").trim(),k=t+"|"+I;if(g[k])continue;g[k]=1;let s=kI(t,z.model);if(a.some(I=>I.startsWith("lock."))&&(s="mdi:lock"),d.icon&&(s=d.icon),N&&"mdi:lightbulb"===s&&T.has(I))continue;const y={id:z.id,name:t,model:z.model||"",area:I,space:i[I].space,icon:s,entities:a,bindingKind:"device",bindingRef:z.id,pdfs:[]};y.primary=this._primaryEntity(a,s),"mdi:thermometer"!==s&&"mdi:air-filter"!==s||(y.temp=this._tempFor(a)),w.push(y)}for(const I of this._lightGroups())i[I.area]&&(c.has("entity:"+I.eid)||w.push({id:"lg_"+I.eid,name:I.name,model:"группа света",area:I.area,space:i[I.area].space,icon:"mdi:lightbulb-group",entities:[I.eid],primary:I.eid,bindingKind:"entity",bindingRef:I.eid,pdfs:[]}));for(const M of this._markers){if(M.hidden)continue;const[N,T]=M.binding.split(":");if("device"===N){const N=I.devices[T],D=N?.area_id||M.area||"",c=D&&i[D]?.space||M.space||this._model[0]?.id||"",g=N&&j[N.id]||[];let z=N?kI(N.name_by_user||N.name||"",N.model):"mdi:help-circle";g.some(I=>I.startsWith("lock."))&&(z="mdi:lock");const d={id:M.id,name:N?.name_by_user||N?.name||"устройство",model:N?.model||"",area:D,space:c,icon:z,entities:g,bindingKind:"device",bindingRef:T};d.primary=this._primaryEntity(g,z),"mdi:thermometer"!==z&&"mdi:air-filter"!==z||(d.temp=this._tempFor(g)),this._applyMarker(d,M),w.push(d)}else if("entity"===N){const N=I.entities[T],D=N?.area_id||N?.device_id&&I.devices[N.device_id]?.area_id||M.area||"",j=D&&i[D]?.space||M.space||this._model[0]?.id||"",c=I.states[T],g={id:M.id,name:N?.name||c?.attributes?.friendly_name||T,model:"",area:D,space:j,icon:"mdi:shape-outline",entities:[T],primary:T,bindingKind:"entity",bindingRef:T};this._applyMarker(g,M),w.push(g)}else{const I=M.area||"",N=M.space||I&&i[I]?.space||this._model[0]?.id||"",T={id:M.id,name:M.name||"виртуальное устройство",model:M.model||"",area:I,space:N,icon:M.icon||"mdi:map-marker",entities:[],bindingKind:"virtual",virtual:{id:M.id,space:N,name:M.name||"",icon:M.icon||"mdi:map-marker",x:0,y:0}};this._applyMarker(T,M),w.push(T)}}return w}_defaultPositions(){const I={};for(const i of this._model)for(const M of i.rooms){if(!M.area)continue;const N=this._devices.filter(I=>I.area===M.area&&I.space===i.id);if(!N.length)continue;const T=.12*Math.min(M.w,M.h),D=M.w-2*T,j=M.h-2*T,c=Math.max(1,Math.round(Math.sqrt(N.length*D/Math.max(j,1)))),g=Math.ceil(N.length/c),w=D/c,z=j/Math.max(g,1);N.forEach((i,N)=>{const D=N%c,j=Math.floor(N/c);I[i.id]={x:M.x+T+w*(D+.5),y:M.y+T+z*(j+.5)}})}return I}_pos(I){const i=this._spaceModel(I.space),M=this._layout[I.id];if(M)if(this._norm){if(M.s===I.space){const i=this._serverCfg.spaces.find(i=>i.id===I.space)?.aspect||1;return{x:M.x*mI,y:M.y*(mI/i)}}}else if(void 0===M.s)return{x:M.x,y:M.y};if(this._defPos[I.id])return this._defPos[I.id];const N=i.vb;return{x:N[0]+N[2]/2,y:N[1]+N[3]/2}}_savePos(I,i,M){if(this._norm){const N=this._gridPitch,T=Math.round(i/N)*N,D=Math.round(M/N)*N,j=this._serverCfg.spaces.find(i=>i.id===I.space)?.aspect||1;this._layout={...this._layout,[I.id]:{s:I.space,x:T/mI,y:D/(mI/j)}}}else this._layout={...this._layout,[I.id]:{x:Math.round(i),y:Math.round(M)}};this._dirtyPos.add(I.id),this._persistLayout()}_stateClass(I){if(!this._config?.live_states)return"";const i=I.primary?this.hass.states[I.primary]:void 0;if(!i)return"";if("unavailable"===i.state)return"unavail";const M=i.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(M))return"on"===i.state?"on":"";if("cover"===M||"valve"===M)return["open","opening"].includes(i.state)?"open":"";if("lock"===M)return["unlocked","open"].includes(i.state)?"open":"";if("binary_sensor"===M){const I=i.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(I))return"on"===i.state?"open":""}return"media_player"===M?["playing","on"].includes(i.state)?"on":"":"vacuum"===M&&["cleaning","returning"].includes(i.state)?"on":""}_liveTemp(I){return this._config?.show_temperature?"mdi:thermometer"!==I.icon&&"mdi:air-filter"!==I.icon?null:this._tempFor(I.entities):null}_openMoreInfo(I){I?AI(this,"hass-more-info",{entityId:I}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(I,i){if(I.stopPropagation(),!this._drag?.moved&&!this._markup)return this._edit?(this._selId=i.id,void this._openMarkerDialog(i)):void(this._infoCard=i)}_clickRoom(I){var i;!this._edit&&I.area&&(i="/config/areas/area/"+I.area,history.pushState(null,"",i),AI(window,"location-changed",{replace:!1}))}_pointerDown(I,i){if(!this._edit)return;I.preventDefault();const M=this._pos(i);this._drag={id:i.id,sx:I.clientX,sy:I.clientY,ox:M.x,oy:M.y,moved:!1},I.target.setPointerCapture(I.pointerId),this._tip=null}_pointerMove(I,i){if(!this._drag||this._drag.id!==i.id)return;const M=this.renderRoot.querySelector(".stage");if(!M)return;const N=this._spaceModel().vb,T=M.getBoundingClientRect(),D=(I.clientX-this._drag.sx)/T.width*N[2],j=(I.clientY-this._drag.sy)/T.height*N[3];Math.abs(I.clientX-this._drag.sx)+Math.abs(I.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const c=.008*Math.min(N[2],N[3]),g=Math.max(N[0]+c,Math.min(N[0]+N[2]-c,this._drag.ox+D)),w=Math.max(N[1]+c,Math.min(N[1]+N[3]-c,this._drag.oy+j));this._savePos(i,g,w)}_pointerUp(I,i){if(!this._drag||this._drag.id!==i.id)return;const M=this._drag.moved;this._drag=M?this._drag:null,M&&(this._selId=i.id,window.setTimeout(()=>this._drag=null,0))}_applyXY(I,i){if(!this._selId)return;const M=parseFloat(i);if(isNaN(M))return;const N=this._devices.find(I=>I.id===this._selId);if(!N||N.virtual)return;const T={...this._pos(N)};T[I]=M,this._savePos(N,T.x,T.y)}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(I){this._toast=I,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(I,i,M,N){this._drag||(this._tip={x:I.clientX,y:I.clientY,title:i,meta:M,lqi:N})}get _gridPitch(){return mI/120}get _curSpaceCfg(){return this._serverCfg?.spaces.find(I=>I.id===this._space)}get _spaceH(){const I=this._curSpaceCfg;return I?mI/I.aspect:mI}get _segments(){const I=this._curSpaceCfg,i=this._spaceH;return(I?.segments||[]).map(I=>[I[0]*mI,I[1]*i,I[2]*mI,I[3]*i])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._edit=!1,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(I){const i=this.renderRoot.querySelector(".stage"),M=this._spaceModel().vb,N=i.getBoundingClientRect();return[M[0]+(I.clientX-N.left)/N.width*M[2],M[1]+(I.clientY-N.top)/N.height*M[3]]}_snap(I){const i=this._gridPitch;return[Math.round(I[0]/i)*i,Math.round(I[1]/i)*i]}_samePt(I,i){return Math.abs(I[0]-i[0])<.001&&Math.abs(I[1]-i[1])<.001}_segKey(I,i){const[M,N]=I[0]this._segKey([I[0],I[1]],[I[2],I[3]])===T);return!D&&(M.segments=M.segments||[],M.segments.push([I[0]/mI,I[1]/N,i[0]/mI,i[1]/N]),this._saveConfig(),!0)}_distToSeg(I,i){const[M,N]=I,[T,D,j,c]=i,g=j-T,w=c-D;let z=((M-T)*g+(N-D)*w)/(g*g+w*w||1);z=Math.max(0,Math.min(1,z));const d=T+z*g,O=D+z*w;return Math.hypot(M-d,N-O)}_pointInRoom(I,i){if(i.poly){let M=!1;const N=i.poly;for(let i=0,T=N.length-1;iI[1]!=g>I[1]&&I[0]<(c-D)*(I[1]-j)/(g-j)+D&&(M=!M)}return M}return null!=i.x&&I[0]>=i.x&&I[0]<=i.x+i.w&&I[1]>=i.y&&I[1]<=i.y+i.h}_markupClick(I){if(!this._markup)return;const i=this._svgPoint(I);if("erase"===this._tool){const I=this._curSpaceCfg;if(!I?.segments?.length)return;const M=this._segments;let N=-1,T=.5*this._gridPitch;return M.forEach((I,M)=>{const D=this._distToSeg(i,I);D=0&&(I.segments.splice(N,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const I=[...this._spaceModel().rooms].reverse().find(I=>this._pointInRoom(i,I));if(!I)return;if(!confirm(`Удалить комнату «${I.name}»?`))return;const M=this._curSpaceCfg;return M.rooms=M.rooms.filter(i=>i.id!==I.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const M=this._snap(i);if(!this._path.length)return this._path=[M],void(this._pathSegs=[]);const N=this._path[this._path.length-1];if(this._samePt(M,N))return;const T=this._addSegment(N,M);this._pathSegs=[...this._pathSegs,T?this._segKey(N,M):null],this._path=[...this._path,M],this._path.length>=4&&this._samePt(M,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(I){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(I)))}_saveRoom(){if(!this._contourClosed||!this._areaSel&&!this._nameSel)return;const I=this._curSpaceCfg;if(!I)return;const i=this._spaceH,M=this._path.slice(0,-1),N=this._areaSel?this.hass.areas[this._areaSel]?.name:"";I.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||N||"Комната",area:this._areaSel||null,poly:M.map(I=>[I[0]/mI,I[1]/i])}),this._saveConfig(),this._path=[],this._pathSegs=[],this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Комната сохранена")}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const I=new Set;for(const i of this._serverCfg?.spaces||[])for(const M of i.rooms||[])M.area&&I.add(M.area);return Object.values(this.hass?.areas||{}).filter(i=>!I.has(i.area_id)).sort((I,i)=>(I.name||"").localeCompare(i.name||""))}_openMarkerDialog(I){this._norm?this._markerDialog=I?{devId:I.id,name:I.name,binding:"virtual"===I.bindingKind?"virtual":I.bindingKind+":"+I.bindingRef,bindingFilter:"",icon:I.marker?.icon||"",model:I.model||"",link:I.link||"",description:I.description||"",pdfs:[...I.pdfs||[]],room:I.space&&I.area?I.space+"#"+I.area:"",busy:!1}:{name:"",binding:"virtual",bindingFilter:"",icon:"",model:"",link:"",description:"",pdfs:[],room:"",busy:!1}:this._showToast("Редактирование устройств доступно после переноса конфига на сервер")}_bindingCandidates(){const I=this.hass,i=new Set;for(const I of this._devices)I.id!==this._markerDialog?.devId&&("device"===I.bindingKind&&I.bindingRef&&i.add("device:"+I.bindingRef),"entity"===I.bindingKind&&I.bindingRef&&i.add("entity:"+I.bindingRef));const M=new Set;for(const I of this._devices)"device"===I.bindingKind&&I.name&&M.add(I.name.trim()+"|"+(I.area||""));const N=[];for(const T of Object.values(I.devices)){if("service"===T.entry_type)continue;const I="device:"+T.id;if(i.has(I))continue;const D=(T.name_by_user||T.name||T.id).trim();I!==this._markerDialog?.binding&&M.has(D+"|"+(T.area_id||""))||N.push({value:I,label:D,sub:(T.model||"устройство")+("Group"===T.model?" · Z2M-группа":"")})}const T=new Set(["group","template","derivative","min_max","threshold","integration","statistics","trend","utility_meter","tod","switch_as_x","schedule"]);for(const[M,D]of Object.entries(I.entities)){const j="entity:"+M;if(i.has(j))continue;const c=T.has(D.platform),g="group"===D.platform;if(!c&&!g)continue;if(D.hidden)continue;const w=I.states[M];N.push({value:j,label:D.name||w?.attributes?.friendly_name||M,sub:M.split(".")[0]+" · "+("group"===D.platform?"группа":"хелпер")})}const D=(this._markerDialog?.bindingFilter||"").toLowerCase().trim(),j=D?N.filter(I=>(I.label+" "+I.sub+" "+I.value).toLowerCase().includes(D)):N;return j.sort((I,i)=>I.label.localeCompare(i.label)),j.slice(0,200)}_allRoomsFlat(){const I=[];for(const i of this._serverCfg?.spaces||[])for(const M of i.rooms||[])M.area&&I.push({value:i.id+"#"+M.area,label:(i.title||i.id)+" · "+M.name});return I}async _pickMarkerFiles(I){const i=I.target,M=i.files?[...i.files]:[];if(!M.length||!this._markerDialog)return;const N=this._markerDialog.devId||"new";for(const I of M)try{const i=new Uint8Array(await I.arrayBuffer());let M="";for(let I=0;Ii.url!==I)})}async _saveMarker(){const I=this._markerDialog;if(I&&!I.busy)if("virtual"!==I.binding||I.name.trim()){this._markerDialog={...I,busy:!0};try{const i=this._serverCfg;let M;i.markers=i.markers||[];let N=null,T=null;if("virtual"===I.binding){const[i,D]=I.room?I.room.split("#"):["",""];N=i||this._space,T=D||null,M=I.devId&&I.devId.startsWith("v_")?I.devId:"v_"+Date.now().toString(36)}else{const[i,N]=I.binding.split(":");M="device"===i?N:"lg_"+N}const D=I.devId,j={id:M,binding:I.binding,name:I.name.trim()||null,icon:I.icon||null,model:I.model.trim()||null,link:I.link.trim()||null,description:I.description.trim()||null,pdfs:I.pdfs};if("virtual"===I.binding&&(j.space=N,j.area=T),i.markers=i.markers.filter(I=>I.id!==M&&I.id!==D),i.markers.push(j),!this._layout[M]){const I=this._spaceModel(N||void 0);let i=I.vb[0]+I.vb[2]/2,D=I.vb[1]+I.vb[3]/2;if(T){const M=I.rooms.find(I=>I.area===T);M&&([i,D]=this._roomCenter(M))}this._layout[M]=this._normPos(N||this._space,i,D)}await this._saveConfigNow(),await this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство сохранено")}catch(I){this._markerDialog={...this._markerDialog,busy:!1},this._showToast("Ошибка: "+(I?.message||I))}}else this._showToast("Укажите имя виртуального устройства")}async _deleteMarker(){const I=this._markerDialog;if(!I)return;const i=I.devId?this._devices.find(i=>i.id===I.devId):null,M=I.name||"устройство";if(!confirm(`Убрать «${M}» с плана?`))return;const N=this._serverCfg;N.markers=N.markers||[],i&&"virtual"===i.bindingKind?N.markers=N.markers.filter(I=>I.id!==i.id):i&&i.marker?(N.markers=N.markers.filter(I=>I.id!==i.id),"device"===i.bindingKind&&i.bindingRef?N.markers.push({id:i.id,binding:"device:"+i.bindingRef,hidden:!0}):"entity"===i.bindingKind&&i.bindingRef&&N.markers.push({id:i.id,binding:"entity:"+i.bindingRef,hidden:!0})):i&&"device"===i.bindingKind&&i.bindingRef?N.markers.push({id:i.id,binding:"device:"+i.bindingRef,hidden:!0}):i&&"entity"===i.bindingKind&&i.bindingRef&&N.markers.push({id:i.id,binding:"entity:"+i.bindingRef,hidden:!0});try{await this._saveConfigNow(),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство убрано с плана")}catch(I){this._showToast("Ошибка: "+(I?.message||I))}}_normPos(I,i,M){const N=this._serverCfg.spaces.find(i=>i.id===I)?.aspect||1;return{s:I,x:i/mI,y:M/(mI/N)}}_openSpaceDialog(I,i){if(this._norm)if("edit"===I){const M=this._serverCfg.spaces.find(I=>I.id===i);if(!M)return;this._spaceDialog={mode:I,spaceId:i,title:M.title,planUrl:M.plan_url||null,planFile:null,busy:!1}}else this._spaceDialog={mode:I,title:"",planUrl:null,planFile:null,busy:!1};else this._showToast("Управление пространствами доступно после переноса конфига на сервер")}async _pickPlanFile(I){const i=I.target,M=i.files?.[0];if(!M||!this._spaceDialog)return;const N={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[M.type]||(M.name.toLowerCase().endsWith(".svg")?"svg":"");if(!N)return void this._showToast("Поддерживаются SVG, PNG, JPG, WebP");const T=new Uint8Array(await M.arrayBuffer());let D="";for(let I=0;I{const i=new Image;i.onload=()=>I(i.naturalWidth&&i.naturalHeight?i.naturalWidth/i.naturalHeight:1.414),i.onerror=()=>I(1.414),i.src=c});URL.revokeObjectURL(c),this._spaceDialog={...this._spaceDialog,planFile:{ext:N,b64:j,aspect:g,name:M.name}}}async _saveSpaceDialog(){const I=this._spaceDialog;if(I&&!I.busy&&I.title.trim()){this._spaceDialog={...I,busy:!0};try{const i=this._serverCfg;let M;if("create"===I.mode?(M={id:"s"+Date.now().toString(36),title:I.title.trim(),plan_url:null,aspect:1.414,view_box:[0,0,1,1],rooms:[],segments:[]},i.spaces.push(M)):(M=i.spaces.find(i=>i.id===I.spaceId),M.title=I.title.trim()),I.planFile){const i=await this.hass.callWS({type:"houseplan/plan/set",space_id:M.id,ext:I.planFile.ext,data:I.planFile.b64});M.plan_url=i.url,M.aspect=I.planFile.aspect}await this._saveConfigNow(),this._spaceDialog=null,"create"===I.mode&&(this._space=M.id),this._regSignature="",this._maybeRebuildDevices(),this._showToast("create"===I.mode?"Пространство добавлено":"Пространство сохранено")}catch(I){this._spaceDialog={...this._spaceDialog,busy:!1},this._showToast("Ошибка: "+(I?.message||I))}}}async _deleteSpace(){const I=this._spaceDialog;if(!I||"edit"!==I.mode)return;const i=this._serverCfg.spaces.find(i=>i.id===I.spaceId);if(confirm(`Удалить пространство «${i.title}» со всеми комнатами и разметкой?`)){this._serverCfg.spaces=this._serverCfg.spaces.filter(i=>i.id!==I.spaceId);try{await this._saveConfigNow(),this._spaceDialog=null,this._space===I.spaceId&&(this._space=this._serverCfg.spaces[0]?.id||""),this._regSignature="",this._maybeRebuildDevices(),this._showToast("Пространство удалено")}catch(I){this._showToast("Ошибка удаления: "+(I?.message||I))}}}async _saveConfigNow(){const I=await this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev});this._cfgRev=I?.rev??this._cfgRev+1}async _migrateToServer(){if(this._serverStorage&&!this._norm&&!this._migrating&&confirm("Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?")){this._migrating=!0;try{const I=[];for(const i of Object.keys(zI)){const M=zI[i],N=aI[i]||M,[T,D,j,c]=N;let g=null,w=j/c;const z=OI[i];if(z&&aI[i]){const I=bI[i];I&&(w=I[0]/I[1]);const M=z.split(",")[1];g=(await this.hass.callWS({type:"houseplan/plan/set",space_id:i,ext:"svg",data:M})).url}const d=wI.filter(I=>I.floor===i).map((I,M)=>({id:i+"_r"+M,name:I.name,area:I.area||null,x:(I.x-T)/j,y:(I.y-D)/c,w:I.w/j,h:I.h/c}));I.push({id:i,title:dI[i]||i,plan_url:g,aspect:w,view_box:[(M[0]-T)/j,(M[1]-D)/c,M[2]/j,M[3]/c],rooms:d})}const i={spaces:I,device_overrides:{},virtual_devices:[],settings:{exclude_integrations:[...lI],group_lights:!0}},M={};for(const I of this._devices){if(I.virtual)continue;const i=this._layout[I.id]||this._defPos[I.id];if(!i)continue;const N=aI[I.space]||zI[I.space];M[I.id]={s:I.space,x:(i.x-N[0])/N[2],y:(i.y-N[1])/N[3]}}await this.hass.callWS({type:"houseplan/config/set",config:i}),await this.hass.callWS({type:"houseplan/layout/set",layout:M}),this._serverCfg=i,this._layout=M,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Конфигурация перенесена на сервер — карта работает от server config")}catch(I){this._showToast("Ошибка миграции: "+(I?.message||I))}finally{this._migrating=!1}}}render(){if(!this._config||!this.hass)return v;const I=this._model;if(!I.length)return Q`
Нет настроенных пространств
`;const i=this._spaceModel(),M=i.vb,N=this._devices.filter(I=>I.space===i.id),T=this._config.icon_size??2.5,D=T>8?2.5:T;return Q` + >`:v}_valueChanged(I){const i={...this._config,...I.detail.value},M=new Event("config-changed",{bubbles:!0,composed:!0});M.detail={config:i},this.dispatchEvent(M)}}eI.properties={hass:{attribute:!1},_config:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",eI);const ZI="houseplan_card_layout_v1",mI=1e3,bI={f1:[1196.6656,1467.26],f2:[1170.0986,1073]},HI=I=>{const i=Math.max(0,Math.min(120,(I-40)/140*120));return`hsl(${Math.round(i)}, 85%, 55%)`},AI=(I,i,M)=>{const N=new Event(i,{bubbles:!0,composed:!0});N.detail=M??{},I.dispatchEvent(N)},xI=(I,i)=>{let M;return(...N)=>{clearTimeout(M),M=window.setTimeout(()=>I(...N),i)}};class LI extends cI{constructor(){super(...arguments),this._space="f1",this._edit=!1,this._layout={},this._serverStorage=!1,this._loaded=!1,this._serverCfg=null,this._cfgRev=0,this._unsubCfg=null,this._devices=[],this._regSignature="",this._defPos={},this._tip=null,this._selId=null,this._toast="",this._migrating=!1,this._markup=!1,this._tool="draw",this._path=[],this._pathSegs=[],this._cursorPt=null,this._areaSel="",this._nameSel="",this._roomDialog=!1,this._infoCard=null,this._markerDialog=null,this._spaceDialog=null,this._keyHandler=I=>this._onKey(I),this._drag=null,this._dirtyPos=new Set,this._persistLayout=xI(()=>{if(this._serverStorage){const I=[...this._dirtyPos];this._dirtyPos.clear();for(const i of I){const I=this._layout[i];I&&this.hass.callWS({type:"houseplan/layout/update",device_id:i,pos:I}).catch(I=>this._showToast("Не удалось сохранить позицию: "+(I?.message||I)))}}else localStorage.setItem(ZI,JSON.stringify(this._layout))},600),this._saveConfig=xI(()=>{this._serverCfg&&this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev}).then(I=>{this._cfgRev=I?.rev??this._cfgRev+1}).catch(I=>{"conflict"===I?.code?(this._showToast("Конфиг изменён в другом окне — данные обновлены, повторите последнее действие"),this._cancelPath(),this._reloadConfigOnly()):this._showToast("Не удалось сохранить конфиг: "+(I?.message||I))})},500)}connectedCallback(){super.connectedCallback(),window.addEventListener("keydown",this._keyHandler)}disconnectedCallback(){window.removeEventListener("keydown",this._keyHandler),this._unsubCfg&&(this._unsubCfg(),this._unsubCfg=null),super.disconnectedCallback()}_onKey(I){if("Escape"===I.key){if(this._infoCard)return void(this._infoCard=null);if(this._markerDialog)return void(this._markerDialog=null)}if(!this._markup)return;return"Escape"===I.key||(I.ctrlKey||I.metaKey)&&"z"===I.key.toLowerCase()?this._roomDialog?(I.preventDefault(),void this._roomDialogCancel()):void("draw"===this._tool&&this._path.length&&(I.preventDefault(),this._undoPoint())):void 0}_undoPoint(){if(!this._path.length)return;if(1===this._path.length)return this._path=[],void(this._pathSegs=[]);const I=this._pathSegs[this._pathSegs.length-1];this._pathSegs=this._pathSegs.slice(0,-1),I&&this._removeSegmentByKey(I),this._path=this._path.slice(0,-1)}_removeSegmentByKey(I){const i=this._curSpaceCfg;if(!i?.segments)return;const M=this._segments.findIndex(i=>this._segKey([i[0],i[1]],[i[2],i[3]])===I);M>=0&&(i.segments.splice(M,1),this._saveConfig())}static getConfigElement(){return document.createElement("houseplan-card-editor")}static getStubConfig(){return{type:"custom:houseplan-card",title:"План дома"}}setConfig(I){this._config={icon_size:2.5,show_temperature:!0,live_states:!0,show_signal:!0,...I},I.default_floor&&(this._space=I.default_floor)}getCardSize(){return 12}get _norm(){return!(!this._serverCfg||!this._serverCfg.spaces.length)}get _model(){if(this._norm)return this._serverCfg.spaces.map(I=>{const i=mI/I.aspect;return{id:I.id,title:I.title,vb:[I.view_box[0]*mI,I.view_box[1]*i,I.view_box[2]*mI,I.view_box[3]*i],bg:I.plan_url?{href:I.plan_url,x:0,y:0,w:mI,h:i}:null,rooms:I.rooms.map(I=>({id:I.id,name:I.name,area:I.area??null,x:null!=I.x?I.x*mI:void 0,y:null!=I.y?I.y*i:void 0,w:null!=I.w?I.w*mI:void 0,h:null!=I.h?I.h*i:void 0,poly:I.poly?I.poly.map(I=>[I[0]*mI,I[1]*i]):void 0}))}});const I=this._config?.rooms?.length?this._config.rooms:wI;return Object.keys(zI).map(i=>{const M=aI[i],N=OI[i];return{id:i,title:dI[i]||i,vb:zI[i],bg:N&&M?{href:N,x:M[0],y:M[1],w:M[2],h:M[3]}:null,rooms:I.filter(I=>I.floor===i).map(I=>({name:I.name,area:I.area||null,x:I.x,y:I.y,w:I.w,h:I.h}))}})}_spaceModel(I){const i=this._model;return i.find(i=>i.id===(I??this._space))||i[0]}get _areaToSpace(){const I={};for(const i of this._model)for(const M of i.rooms)M.area&&(I[M.area]={space:i.id,room:M});return I}get _settings(){return this._serverCfg?.settings||{}}get _excluded(){const I=this._settings.exclude_integrations;return I?new Set(I):lI}willUpdate(I){I.has("hass")&&this.hass&&(this._loaded||(this._loaded=!0,this._loadFromServer()),this._maybeRebuildDevices())}async _loadFromServer(){try{const[I,i]=await Promise.all([this.hass.callWS({type:"houseplan/config/get"}),this.hass.callWS({type:"houseplan/layout/get"})]);this._serverStorage=!0;const M=I?.config;this._serverCfg=M&&Array.isArray(M.spaces)?M:null,this._cfgRev=I?.rev||0,this._layout=i?.layout||{},this._unsubCfg||(this._unsubCfg=await this.hass.connection.subscribeEvents(I=>{(I?.data?.rev??-1)!==this._cfgRev&&this._reloadConfigOnly()},"houseplan_config_updated")),this._norm&&!this._model.find(I=>I.id===this._space)&&(this._space=this._model[0]?.id||this._space)}catch(I){this._serverStorage=!1,this._serverCfg=null;try{this._layout=JSON.parse(localStorage.getItem(ZI)||"{}")||{}}catch{this._layout={}}this._showToast("Интеграция houseplan не найдена — позиции сохраняются локально")}this._regSignature="",this.requestUpdate()}async _reloadConfigOnly(){try{const I=await this.hass.callWS({type:"houseplan/config/get"}),i=I?.config;this._serverCfg=i&&Array.isArray(i.spaces)?i:null,this._cfgRev=I?.rev||0,this._regSignature="",this._maybeRebuildDevices(),this.requestUpdate()}catch{}}_maybeRebuildDevices(){const I=this.hass;if(!I?.devices||!I?.entities||!I?.areas)return;const i=Object.keys(I.devices).length+":"+Object.keys(I.entities).length+":"+Object.keys(I.areas).length+":"+(this._norm?"n":"l");i===this._regSignature&&this._devices.length||(this._regSignature=i,this._devices=this._buildDevices(),this._defPos=this._defaultPositions())}_entitiesByDevice(){const I={};for(const[i,M]of Object.entries(this.hass.entities))M?.device_id&&(I[M.device_id]=I[M.device_id]||[]).push(i);return I}_domainOfDevice(I,i){if(I.identifiers?.[0]?.[0])return I.identifiers[0][0];for(const I of i){const i=this.hass.entities[I]?.platform;if(i)return i}return""}_primaryEntity(I,i){const M=I.map(I=>({eid:I,reg:this.hass.entities[I],st:this.hass.states[I]})).filter(I=>I.reg&&!I.reg.hidden),N=M.filter(I=>!I.reg.entity_category),T=N.length?N:M;if("mdi:thermometer"===i||"mdi:air-filter"===i){const I=T.find(I=>this._isTempEntity(I.eid));if(I)return I.eid}for(const I of sI){const i=T.find(i=>i.eid.split(".")[0]===I);if(i)return i.eid}return T[0]?.eid}_isTempEntity(I){const i=this.hass.states[I];if(!i)return/_temperature$/.test(I);const M=i.attributes||{};return"temperature"===M.device_class||/°C|°F/.test(M.unit_of_measurement||"")||/_temperature$/.test(I)}_lqiFor(I){const i=[];for(const M of I){const I=this.hass.states[M];if(!(/_linkquality$/.test(M)||"lqi"===(I?.attributes?.unit_of_measurement||""))||!I)continue;const N=parseFloat(I.state);isNaN(N)||i.push(N)}return i.length?Math.round(i.reduce((I,i)=>I+i,0)/i.length):null}_roomLqi(I){if(!I)return null;const i=[];for(const M of this._devices){if(M.area!==I||M.virtual)continue;const N=this._lqiFor(M.entities);null!=N&&i.push(N)}return i.length?Math.round(i.reduce((I,i)=>I+i,0)/i.length):null}_tempFor(I){for(const i of I){if(!this._isTempEntity(i))continue;const I=this.hass.states[i];if(!I)continue;const M=parseFloat(I.state);if(!isNaN(M))return Math.round(10*M)/10}return null}_lightGroups(){if(!1===this._settings.group_lights)return[];const I=this.hass,i=[];for(const[M,N]of Object.entries(I.entities)){if(!M.startsWith("light.")||N.hidden)continue;let T=null;if("group"===N.platform)T=N.area_id||null;else{if(!N.device_id)continue;{const i=I.devices[N.device_id];if("Group"!==i?.model)continue;T=i.area_id||N.area_id||null}}if(!T)continue;const D=I.states[M];i.push({eid:M,name:N.name||D?.attributes?.friendly_name||M,area:T})}return i}_groupedLightAreas(){return new Set(this._lightGroups().map(I=>I.area))}get _markers(){return this._serverCfg?.markers||[]}_markerFor(I,i){return this._markers.find(M=>M.binding===I+":"+i)}get _claimed(){const I=new Set;for(const i of this._markers){const[M,N]=i.binding.split(":");"device"!==M&&"entity"!==M||!N||I.add(i.binding)}return I}_applyMarker(I,i){I.marker=i,i.name&&(I.name=i.name),i.icon&&(I.icon=i.icon),null!=i.model&&(I.model=i.model),I.link=i.link??null,I.description=i.description??null,I.pdfs=i.pdfs||[]}_buildDevices(){const I=this.hass,i=this._areaToSpace,M=this._serverCfg?.device_overrides||{},N=!1!==this._settings.group_lights,T=this._groupedLightAreas(),D=this._excluded,j=this._entitiesByDevice(),c=this._claimed,g={},w=[];for(const z of Object.values(I.devices)){const I=z.area_id;if(!I||!i[I])continue;if("service"===z.entry_type)continue;if(c.has("device:"+z.id))continue;const d=M[z.id]||{},O=this._markerFor("device",z.id);if(O&&O.hidden||d.hidden)continue;const a=j[z.id]||[],l=this._domainOfDevice(z,a);if(D.has(l))continue;if("Group"===z.model)continue;if(/scene/i.test(z.model||""))continue;if(/bridge/i.test((z.model||"")+(z.name||"")))continue;if("myheat"===l&&z.via_device_id)continue;const t=(d.name||z.name_by_user||z.name||"без имени").trim(),k=t+"|"+I;if(g[k])continue;g[k]=1;let s=kI(t,z.model);if(a.some(I=>I.startsWith("lock."))&&(s="mdi:lock"),d.icon&&(s=d.icon),N&&"mdi:lightbulb"===s&&T.has(I))continue;const y={id:z.id,name:t,model:z.model||"",area:I,space:i[I].space,icon:s,entities:a,bindingKind:"device",bindingRef:z.id,pdfs:[]};y.primary=this._primaryEntity(a,s),"mdi:thermometer"!==s&&"mdi:air-filter"!==s||(y.temp=this._tempFor(a)),w.push(y)}for(const I of this._lightGroups())i[I.area]&&(c.has("entity:"+I.eid)||w.push({id:"lg_"+I.eid,name:I.name,model:"группа света",area:I.area,space:i[I.area].space,icon:"mdi:lightbulb-group",entities:[I.eid],primary:I.eid,bindingKind:"entity",bindingRef:I.eid,pdfs:[]}));for(const M of this._markers){if(M.hidden)continue;const[N,T]=M.binding.split(":");if("device"===N){const N=I.devices[T],D=M.area||N?.area_id||"",c=D&&i[D]?.space||M.space||this._model[0]?.id||"",g=N&&j[N.id]||[];let z=N?kI(N.name_by_user||N.name||"",N.model):"mdi:help-circle";g.some(I=>I.startsWith("lock."))&&(z="mdi:lock");const d={id:M.id,name:N?.name_by_user||N?.name||"устройство",model:N?.model||"",area:D,space:c,icon:z,entities:g,bindingKind:"device",bindingRef:T};d.primary=this._primaryEntity(g,z),"mdi:thermometer"!==z&&"mdi:air-filter"!==z||(d.temp=this._tempFor(g)),this._applyMarker(d,M),w.push(d)}else if("entity"===N){const N=I.entities[T],D=M.area||N?.area_id||N?.device_id&&I.devices[N.device_id]?.area_id||"",j=D&&i[D]?.space||M.space||this._model[0]?.id||"",c=I.states[T],g={id:M.id,name:N?.name||c?.attributes?.friendly_name||T,model:"",area:D,space:j,icon:"mdi:shape-outline",entities:[T],primary:T,bindingKind:"entity",bindingRef:T};this._applyMarker(g,M),w.push(g)}else{const I=M.area||"",N=M.space||I&&i[I]?.space||this._model[0]?.id||"",T={id:M.id,name:M.name||"виртуальное устройство",model:M.model||"",area:I,space:N,icon:M.icon||"mdi:map-marker",entities:[],bindingKind:"virtual",virtual:{id:M.id,space:N,name:M.name||"",icon:M.icon||"mdi:map-marker",x:0,y:0}};this._applyMarker(T,M),w.push(T)}}return w}_defaultPositions(){const I={};for(const i of this._model)for(const M of i.rooms){if(!M.area)continue;const N=this._devices.filter(I=>I.area===M.area&&I.space===i.id);if(!N.length)continue;const T=.12*Math.min(M.w,M.h),D=M.w-2*T,j=M.h-2*T,c=Math.max(1,Math.round(Math.sqrt(N.length*D/Math.max(j,1)))),g=Math.ceil(N.length/c),w=D/c,z=j/Math.max(g,1);N.forEach((i,N)=>{const D=N%c,j=Math.floor(N/c);I[i.id]={x:M.x+T+w*(D+.5),y:M.y+T+z*(j+.5)}})}return I}_pos(I){const i=this._spaceModel(I.space),M=this._layout[I.id];if(M)if(this._norm){if(M.s===I.space){const i=this._serverCfg.spaces.find(i=>i.id===I.space)?.aspect||1;return{x:M.x*mI,y:M.y*(mI/i)}}}else if(void 0===M.s)return{x:M.x,y:M.y};if(this._defPos[I.id])return this._defPos[I.id];const N=i.vb;return{x:N[0]+N[2]/2,y:N[1]+N[3]/2}}_savePos(I,i,M){if(this._norm){const N=this._gridPitch,T=Math.round(i/N)*N,D=Math.round(M/N)*N,j=this._serverCfg.spaces.find(i=>i.id===I.space)?.aspect||1;this._layout={...this._layout,[I.id]:{s:I.space,x:T/mI,y:D/(mI/j)}}}else this._layout={...this._layout,[I.id]:{x:Math.round(i),y:Math.round(M)}};this._dirtyPos.add(I.id),this._persistLayout()}_stateClass(I){if(!this._config?.live_states)return"";const i=I.primary?this.hass.states[I.primary]:void 0;if(!i)return"";if("unavailable"===i.state)return"unavail";const M=i.entity_id.split(".")[0];if(["light","switch","fan","humidifier"].includes(M))return"on"===i.state?"on":"";if("cover"===M||"valve"===M)return["open","opening"].includes(i.state)?"open":"";if("lock"===M)return["unlocked","open"].includes(i.state)?"open":"";if("binary_sensor"===M){const I=i.attributes?.device_class;if(["door","window","garage_door","opening","gas","smoke","moisture","problem"].includes(I))return"on"===i.state?"open":""}return"media_player"===M?["playing","on"].includes(i.state)?"on":"":"vacuum"===M&&["cleaning","returning"].includes(i.state)?"on":""}_liveTemp(I){return this._config?.show_temperature?"mdi:thermometer"!==I.icon&&"mdi:air-filter"!==I.icon?null:this._tempFor(I.entities):null}_openMoreInfo(I){I?AI(this,"hass-more-info",{entityId:I}):this._showToast("У устройства нет подходящей сущности")}_clickDevice(I,i){if(I.stopPropagation(),!this._drag?.moved&&!this._markup)return this._edit?(this._selId=i.id,void this._openMarkerDialog(i)):void(this._infoCard=i)}_clickRoom(I){var i;!this._edit&&I.area&&(i="/config/areas/area/"+I.area,history.pushState(null,"",i),AI(window,"location-changed",{replace:!1}))}_pointerDown(I,i){if(!this._edit)return;I.preventDefault();const M=this._pos(i);this._drag={id:i.id,sx:I.clientX,sy:I.clientY,ox:M.x,oy:M.y,moved:!1},I.target.setPointerCapture(I.pointerId),this._tip=null}_pointerMove(I,i){if(!this._drag||this._drag.id!==i.id)return;const M=this.renderRoot.querySelector(".stage");if(!M)return;const N=this._spaceModel().vb,T=M.getBoundingClientRect(),D=(I.clientX-this._drag.sx)/T.width*N[2],j=(I.clientY-this._drag.sy)/T.height*N[3];Math.abs(I.clientX-this._drag.sx)+Math.abs(I.clientY-this._drag.sy)>3&&(this._drag.moved=!0);const c=.008*Math.min(N[2],N[3]),g=Math.max(N[0]+c,Math.min(N[0]+N[2]-c,this._drag.ox+D)),w=Math.max(N[1]+c,Math.min(N[1]+N[3]-c,this._drag.oy+j));this._savePos(i,g,w)}_pointerUp(I,i){if(!this._drag||this._drag.id!==i.id)return;const M=this._drag.moved;this._drag=M?this._drag:null,M&&(this._selId=i.id,window.setTimeout(()=>this._drag=null,0))}_applyXY(I,i){if(!this._selId)return;const M=parseFloat(i);if(isNaN(M))return;const N=this._devices.find(I=>I.id===this._selId);if(!N||N.virtual)return;const T={...this._pos(N)};T[I]=M,this._savePos(N,T.x,T.y)}_resetLayout(){confirm("Сбросить позиции всех иконок к авто-раскладке?")&&(this._layout={},this._persistLayout())}_showToast(I){this._toast=I,clearTimeout(this._toastTimer),this._toastTimer=window.setTimeout(()=>{this._toast=""},3500)}_showTip(I,i,M,N){this._drag||(this._tip={x:I.clientX,y:I.clientY,title:i,meta:M,lqi:N})}get _gridPitch(){return mI/120}get _curSpaceCfg(){return this._serverCfg?.spaces.find(I=>I.id===this._space)}get _spaceH(){const I=this._curSpaceCfg;return I?mI/I.aspect:mI}get _segments(){const I=this._curSpaceCfg,i=this._spaceH;return(I?.segments||[]).map(I=>[I[0]*mI,I[1]*i,I[2]*mI,I[3]*i])}_toggleMarkup(){this._norm?(this._markup=!this._markup,this._edit=!1,this._path=[],this._cursorPt=null,this._tool="draw"):this._showToast("Разметка доступна после переноса конфига на сервер (режим правки → «На сервер»)")}_svgPoint(I){const i=this.renderRoot.querySelector(".stage"),M=this._spaceModel().vb,N=i.getBoundingClientRect();return[M[0]+(I.clientX-N.left)/N.width*M[2],M[1]+(I.clientY-N.top)/N.height*M[3]]}_snap(I){const i=this._gridPitch;return[Math.round(I[0]/i)*i,Math.round(I[1]/i)*i]}_samePt(I,i){return Math.abs(I[0]-i[0])<.001&&Math.abs(I[1]-i[1])<.001}_segKey(I,i){const[M,N]=I[0]this._segKey([I[0],I[1]],[I[2],I[3]])===T);return!D&&(M.segments=M.segments||[],M.segments.push([I[0]/mI,I[1]/N,i[0]/mI,i[1]/N]),this._saveConfig(),!0)}_distToSeg(I,i){const[M,N]=I,[T,D,j,c]=i,g=j-T,w=c-D;let z=((M-T)*g+(N-D)*w)/(g*g+w*w||1);z=Math.max(0,Math.min(1,z));const d=T+z*g,O=D+z*w;return Math.hypot(M-d,N-O)}_pointInRoom(I,i){if(i.poly){let M=!1;const N=i.poly;for(let i=0,T=N.length-1;iI[1]!=g>I[1]&&I[0]<(c-D)*(I[1]-j)/(g-j)+D&&(M=!M)}return M}return null!=i.x&&I[0]>=i.x&&I[0]<=i.x+i.w&&I[1]>=i.y&&I[1]<=i.y+i.h}_markupClick(I){if(!this._markup)return;const i=this._svgPoint(I);if("erase"===this._tool){const I=this._curSpaceCfg;if(!I?.segments?.length)return;const M=this._segments;let N=-1,T=.5*this._gridPitch;return M.forEach((I,M)=>{const D=this._distToSeg(i,I);D=0&&(I.segments.splice(N,1),this._saveConfig(),this.requestUpdate()))}if("delroom"===this._tool){const I=[...this._spaceModel().rooms].reverse().find(I=>this._pointInRoom(i,I));if(!I)return;if(!confirm(`Удалить комнату «${I.name}»?`))return;const M=this._curSpaceCfg;return M.rooms=M.rooms.filter(i=>i.id!==I.id),this._saveConfig(),this._regSignature="",this._maybeRebuildDevices(),void this.requestUpdate()}const M=this._snap(i);if(!this._path.length)return this._path=[M],void(this._pathSegs=[]);const N=this._path[this._path.length-1];if(this._samePt(M,N))return;const T=this._addSegment(N,M);this._pathSegs=[...this._pathSegs,T?this._segKey(N,M):null],this._path=[...this._path,M],this._path.length>=4&&this._samePt(M,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(I){this._markup&&"draw"===this._tool&&this._path.length&&!this._contourClosed&&(this._cursorPt=this._snap(this._svgPoint(I)))}_saveRoom(){if(!this._contourClosed||!this._areaSel&&!this._nameSel)return;const I=this._curSpaceCfg;if(!I)return;const i=this._spaceH,M=this._path.slice(0,-1),N=this._areaSel?this.hass.areas[this._areaSel]?.name:"";I.rooms.push({id:"r"+Date.now().toString(36),name:this._nameSel||N||"Комната",area:this._areaSel||null,poly:M.map(I=>[I[0]/mI,I[1]/i])}),this._saveConfig(),this._path=[],this._pathSegs=[],this._areaSel="",this._nameSel="",this._roomDialog=!1,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Комната сохранена")}_cancelPath(){this._path=[],this._pathSegs=[],this._cursorPt=null,this._roomDialog=!1}_roomDialogCancel(){this._roomDialog=!1,this._undoPoint()}get _freeAreas(){const I=new Set;for(const i of this._serverCfg?.spaces||[])for(const M of i.rooms||[])M.area&&I.add(M.area);return Object.values(this.hass?.areas||{}).filter(i=>!I.has(i.area_id)).sort((I,i)=>(I.name||"").localeCompare(i.name||""))}_openMarkerDialog(I){this._norm?this._markerDialog=I?{devId:I.id,name:I.name,binding:"virtual"===I.bindingKind?"virtual":I.bindingKind+":"+I.bindingRef,bindingFilter:"",icon:I.marker?.icon||"",model:I.model||"",link:I.link||"",description:I.description||"",pdfs:[...I.pdfs||[]],room:I.space&&I.area?I.space+"#"+I.area:"",busy:!1}:{name:"",binding:"virtual",bindingFilter:"",icon:"",model:"",link:"",description:"",pdfs:[],room:"",busy:!1}:this._showToast("Редактирование устройств доступно после переноса конфига на сервер")}_bindingCandidates(){const I=this.hass,i=new Set;for(const I of this._devices)I.id!==this._markerDialog?.devId&&("device"===I.bindingKind&&I.bindingRef&&i.add("device:"+I.bindingRef),"entity"===I.bindingKind&&I.bindingRef&&i.add("entity:"+I.bindingRef));const M=new Set;for(const I of this._devices)"device"===I.bindingKind&&I.name&&M.add(I.name.trim()+"|"+(I.area||""));const N=[];for(const T of Object.values(I.devices)){if("service"===T.entry_type)continue;const I="device:"+T.id;if(i.has(I))continue;const D=(T.name_by_user||T.name||T.id).trim();I!==this._markerDialog?.binding&&M.has(D+"|"+(T.area_id||""))||N.push({value:I,label:D,sub:(T.model||"устройство")+("Group"===T.model?" · Z2M-группа":"")})}const T=new Set(["group","template","derivative","min_max","threshold","integration","statistics","trend","utility_meter","tod","switch_as_x","schedule"]);for(const[M,D]of Object.entries(I.entities)){const j="entity:"+M;if(i.has(j))continue;const c=T.has(D.platform),g="group"===D.platform;if(!c&&!g)continue;if(D.hidden)continue;const w=I.states[M];N.push({value:j,label:D.name||w?.attributes?.friendly_name||M,sub:M.split(".")[0]+" · "+("group"===D.platform?"группа":"хелпер")})}const D=(this._markerDialog?.bindingFilter||"").toLowerCase().trim(),j=D?N.filter(I=>(I.label+" "+I.sub+" "+I.value).toLowerCase().includes(D)):N;return j.sort((I,i)=>I.label.localeCompare(i.label)),j.slice(0,200)}_allRoomsFlat(){const I=[];for(const i of this._serverCfg?.spaces||[])for(const M of i.rooms||[])M.area&&I.push({value:i.id+"#"+M.area,label:(i.title||i.id)+" · "+M.name});return I}async _pickMarkerFiles(I){const i=I.target,M=i.files?[...i.files]:[];if(!M.length||!this._markerDialog)return;const N=this._markerDialog.devId||"new";for(const I of M)try{const i=new Uint8Array(await I.arrayBuffer());let M="";for(let I=0;Ii.url!==I)})}async _saveMarker(){const I=this._markerDialog;if(I&&!I.busy)if("virtual"!==I.binding||I.name.trim()){this._markerDialog={...I,busy:!0};try{const i=this._serverCfg;let M;i.markers=i.markers||[];const[N,T]=I.room?I.room.split("#"):["",""];let D=N||null,j=T||null;if("virtual"===I.binding)D||(D=this._space),M=I.devId&&I.devId.startsWith("v_")?I.devId:"v_"+Date.now().toString(36);else{const[i,N]=I.binding.split(":");M="device"===i?N:"lg_"+N}const c=I.devId,g={id:M,binding:I.binding,name:I.name.trim()||null,icon:I.icon||null,model:I.model.trim()||null,link:I.link.trim()||null,description:I.description.trim()||null,pdfs:I.pdfs};("virtual"===I.binding||I.room)&&(g.space=D,g.area=j);const w=c?this._devices.find(I=>I.id===c):null,z=!!I.room&&null!=w&&(w.space!==D||w.area!==j);if(i.markers=i.markers.filter(I=>I.id!==M&&I.id!==c),i.markers.push(g),!this._layout[M]||z){const I=this._spaceModel(D||void 0);let i=I.vb[0]+I.vb[2]/2,N=I.vb[1]+I.vb[3]/2;if(j){const M=I.rooms.find(I=>I.area===j);M&&([i,N]=this._roomCenter(M))}this._layout[M]=this._normPos(D||this._space,i,N)}await this._saveConfigNow(),await this.hass.callWS({type:"houseplan/layout/set",layout:this._layout}),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство сохранено")}catch(I){this._markerDialog={...this._markerDialog,busy:!1},this._showToast("Ошибка: "+(I?.message||I))}}else this._showToast("Укажите имя виртуального устройства")}async _deleteMarker(){const I=this._markerDialog;if(!I)return;const i=I.devId?this._devices.find(i=>i.id===I.devId):null,M=I.name||"устройство";if(!confirm(`Убрать «${M}» с плана?`))return;const N=this._serverCfg;N.markers=N.markers||[],i&&"virtual"===i.bindingKind?N.markers=N.markers.filter(I=>I.id!==i.id):i&&i.marker?(N.markers=N.markers.filter(I=>I.id!==i.id),"device"===i.bindingKind&&i.bindingRef?N.markers.push({id:i.id,binding:"device:"+i.bindingRef,hidden:!0}):"entity"===i.bindingKind&&i.bindingRef&&N.markers.push({id:i.id,binding:"entity:"+i.bindingRef,hidden:!0})):i&&"device"===i.bindingKind&&i.bindingRef?N.markers.push({id:i.id,binding:"device:"+i.bindingRef,hidden:!0}):i&&"entity"===i.bindingKind&&i.bindingRef&&N.markers.push({id:i.id,binding:"entity:"+i.bindingRef,hidden:!0});try{await this._saveConfigNow(),this._markerDialog=null,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Устройство убрано с плана")}catch(I){this._showToast("Ошибка: "+(I?.message||I))}}_normPos(I,i,M){const N=this._serverCfg.spaces.find(i=>i.id===I)?.aspect||1;return{s:I,x:i/mI,y:M/(mI/N)}}_openSpaceDialog(I,i){if(this._norm)if("edit"===I){const M=this._serverCfg.spaces.find(I=>I.id===i);if(!M)return;this._spaceDialog={mode:I,spaceId:i,title:M.title,planUrl:M.plan_url||null,planFile:null,busy:!1}}else this._spaceDialog={mode:I,title:"",planUrl:null,planFile:null,busy:!1};else this._showToast("Управление пространствами доступно после переноса конфига на сервер")}async _pickPlanFile(I){const i=I.target,M=i.files?.[0];if(!M||!this._spaceDialog)return;const N={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[M.type]||(M.name.toLowerCase().endsWith(".svg")?"svg":"");if(!N)return void this._showToast("Поддерживаются SVG, PNG, JPG, WebP");const T=new Uint8Array(await M.arrayBuffer());let D="";for(let I=0;I{const i=new Image;i.onload=()=>I(i.naturalWidth&&i.naturalHeight?i.naturalWidth/i.naturalHeight:1.414),i.onerror=()=>I(1.414),i.src=c});URL.revokeObjectURL(c),this._spaceDialog={...this._spaceDialog,planFile:{ext:N,b64:j,aspect:g,name:M.name}}}async _saveSpaceDialog(){const I=this._spaceDialog;if(I&&!I.busy&&I.title.trim()){this._spaceDialog={...I,busy:!0};try{const i=this._serverCfg;let M;if("create"===I.mode?(M={id:"s"+Date.now().toString(36),title:I.title.trim(),plan_url:null,aspect:1.414,view_box:[0,0,1,1],rooms:[],segments:[]},i.spaces.push(M)):(M=i.spaces.find(i=>i.id===I.spaceId),M.title=I.title.trim()),I.planFile){const i=await this.hass.callWS({type:"houseplan/plan/set",space_id:M.id,ext:I.planFile.ext,data:I.planFile.b64});M.plan_url=i.url,M.aspect=I.planFile.aspect}await this._saveConfigNow(),this._spaceDialog=null,"create"===I.mode&&(this._space=M.id),this._regSignature="",this._maybeRebuildDevices(),this._showToast("create"===I.mode?"Пространство добавлено":"Пространство сохранено")}catch(I){this._spaceDialog={...this._spaceDialog,busy:!1},this._showToast("Ошибка: "+(I?.message||I))}}}async _deleteSpace(){const I=this._spaceDialog;if(!I||"edit"!==I.mode)return;const i=this._serverCfg.spaces.find(i=>i.id===I.spaceId);if(confirm(`Удалить пространство «${i.title}» со всеми комнатами и разметкой?`)){this._serverCfg.spaces=this._serverCfg.spaces.filter(i=>i.id!==I.spaceId);try{await this._saveConfigNow(),this._spaceDialog=null,this._space===I.spaceId&&(this._space=this._serverCfg.spaces[0]?.id||""),this._regSignature="",this._maybeRebuildDevices(),this._showToast("Пространство удалено")}catch(I){this._showToast("Ошибка удаления: "+(I?.message||I))}}}async _saveConfigNow(){const I=await this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev});this._cfgRev=I?.rev??this._cfgRev+1}async _migrateToServer(){if(this._serverStorage&&!this._norm&&!this._migrating&&confirm("Перенести текущую конфигурацию (планы, комнаты, раскладку) на сервер HA?")){this._migrating=!0;try{const I=[];for(const i of Object.keys(zI)){const M=zI[i],N=aI[i]||M,[T,D,j,c]=N;let g=null,w=j/c;const z=OI[i];if(z&&aI[i]){const I=bI[i];I&&(w=I[0]/I[1]);const M=z.split(",")[1];g=(await this.hass.callWS({type:"houseplan/plan/set",space_id:i,ext:"svg",data:M})).url}const d=wI.filter(I=>I.floor===i).map((I,M)=>({id:i+"_r"+M,name:I.name,area:I.area||null,x:(I.x-T)/j,y:(I.y-D)/c,w:I.w/j,h:I.h/c}));I.push({id:i,title:dI[i]||i,plan_url:g,aspect:w,view_box:[(M[0]-T)/j,(M[1]-D)/c,M[2]/j,M[3]/c],rooms:d})}const i={spaces:I,device_overrides:{},virtual_devices:[],settings:{exclude_integrations:[...lI],group_lights:!0}},M={};for(const I of this._devices){if(I.virtual)continue;const i=this._layout[I.id]||this._defPos[I.id];if(!i)continue;const N=aI[I.space]||zI[I.space];M[I.id]={s:I.space,x:(i.x-N[0])/N[2],y:(i.y-N[1])/N[3]}}await this.hass.callWS({type:"houseplan/config/set",config:i}),await this.hass.callWS({type:"houseplan/layout/set",layout:M}),this._serverCfg=i,this._layout=M,this._regSignature="",this._maybeRebuildDevices(),this._showToast("Конфигурация перенесена на сервер — карта работает от server config")}catch(I){this._showToast("Ошибка миграции: "+(I?.message||I))}finally{this._migrating=!1}}}render(){if(!this._config||!this.hass)return v;const I=this._model;if(!I.length)return Q`
Нет настроенных пространств
`;const i=this._spaceModel(),M=i.vb,N=this._devices.filter(I=>I.space===i.id),T=this._config.icon_size??2.5,D=T>8?2.5:T;return Q`
@@ -134,6 +134,9 @@ const I=globalThis,i=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow ${I.model||M||I.link||I.description||I.pdfs&&I.pdfs.length?v:Q`
Нет дополнительной информации
`}
+ ${I.primary?Q``:v} @@ -171,12 +174,12 @@ const I=globalThis,i=I.ShadowRoot&&(void 0===I.ShadyCSS||I.ShadyCSS.nativeShadow
- ${i?Q` - `:v} + + ${customElements.get("ha-icon-picker")?Q`"houseplan-card"===I.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.6.1 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `,customElements.get("houseplan-card")||customElements.define("houseplan-card",LI),window.customCards=window.customCards||[],window.customCards.find(I=>"houseplan-card"===I.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.6.2 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 31a14a8..9c3fdd8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.6.2 — 2026-07-04 +- Поле «Комната» в диалоге устройства теперь для ВСЕХ значков (не только виртуальных): + для привязанных — «переопределить размещение» (по умолчанию по зоне устройства); смена + комнаты переставляет значок в её центр. Сохраняется в marker.space/area и выигрывает у зоны HA. +- В инфо-карточке добавлена кнопка «Редактировать» — открывает диалог редактирования устройства. + ## v1.6.0 — 2026-07-04 (Фаза 3: редактор устройств) - Модель «маркеров» (config.markers[]): гибрид — авто-устройства HA появляются сами, маркеры правят/перепривязывают/дополняют их и описывают ручные/виртуальные значки. id маркера diff --git a/package.json b/package.json index 088081f..b463d40 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "houseplan-card", - "version": "1.6.1", + "version": "1.6.2", "description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)", "license": "MIT", "type": "module", diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 9430cd9..3fe0d32 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -12,7 +12,7 @@ import { FLOOR_BG, FLOOR_BG_RECT } from './data/backgrounds'; import { EXCLUDED_DOMAINS, iconFor, DOMAIN_PRIORITY } from './rules'; import './editor'; -const CARD_VERSION = '1.6.1'; +const CARD_VERSION = '1.6.2'; const LS_KEY = 'houseplan_card_layout_v1'; const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов @@ -671,7 +671,7 @@ class HouseplanCard extends LitElement { const [kind, ref] = m.binding.split(':'); if (kind === 'device') { const dev = h.devices[ref]; - const area = dev?.area_id || m.area || ''; + 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'; @@ -693,7 +693,7 @@ class HouseplanCard extends LitElement { rest.push(item); } else if (kind === 'entity') { const reg = h.entities[ref]; - const area = reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || m.area || ''; + 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 = { @@ -1281,12 +1281,12 @@ class HouseplanCard extends LitElement { cfg.markers = cfg.markers || []; // определить id маркера let id: string; - let space: string | null = null; - let area: string | null = null; + // комната (выбранная вручную) переопределяет пространство/зону для любого значка + const [roomSp, roomAr] = dlg.room ? dlg.room.split('#') : ['', '']; + let space: string | null = roomSp || null; + let area: string | null = roomAr || null; if (dlg.binding === 'virtual') { - const [sp, ar] = dlg.room ? dlg.room.split('#') : ['', '']; - space = sp || this._space; - area = ar || null; + if (!space) space = this._space; id = dlg.devId && dlg.devId.startsWith('v_') ? dlg.devId : 'v_' + Date.now().toString(36); } else { const [kind, ref] = dlg.binding.split(':'); @@ -1303,15 +1303,19 @@ class HouseplanCard extends LitElement { description: dlg.description.trim() || null, pdfs: dlg.pdfs, }; - if (dlg.binding === 'virtual') { + // сохраняем выбор комнаты (для виртуальных всегда; для привязанных — если выбрана) + if (dlg.binding === 'virtual' || dlg.room) { marker.space = space; marker.area = area; } + // сменилась комната → переставить значок в её центр + const prevDev = oldId ? this._devices.find((x) => x.id === oldId) : null; + const roomChanged = !!dlg.room && prevDev != null && (prevDev.space !== space || prevDev.area !== area); // удалить прежний маркер (по старому id и по новому id) cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId); cfg.markers.push(marker); - // позиция: если новый значок или сменилось пространство — поставить в центр комнаты/пространства - if (!this._layout[id]) { + // позиция: новый значок ИЛИ сменилась комната → поставить в центр комнаты/пространства + if (!this._layout[id] || roomChanged) { const spm = this._spaceModel(space || undefined); let cx = spm.vb[0] + spm.vb[2] / 2; let cy = spm.vb[1] + spm.vb[3] / 2; @@ -1799,6 +1803,9 @@ class HouseplanCard extends LitElement { : nothing}
+ ${d.primary ? html`
- ${isVirtual - ? html` - ` - : nothing} + + ${customElements.get('ha-icon-picker')