`}_vacMapId(t,e){const i=this._vacEntity(t),s=i?this.hass?.states[i]?.attributes?.selected_map:null;return o=e.mapId,n=s,"default"!==o?o:null!=n?String(n):"default";var o,n}_vacSaveMatrix(t,e,i,s){const o=this._devices.find(e=>e.id===t),n=o?this._vacEnsureMarker(o):this._serverCfg?.markers?.find(e=>e.id===t);if(!n)return!1;const r={...n.vacuum||{}};return r.source=e,r.calibration={...r.calibration||{},[i]:s.map(t=>Number(t.toFixed(6)))},n.vacuum=r,this._regSignature="",this._saveConfig(),this.requestUpdate(),!0}_vacAutoCalibrate(t){const e=this._vacSource(t),i=e?ts(this.hass?.states[e]?.attributes):null;if(!e||!i||i.rooms.length<3)return void this._showToast(this._t("vac.autocal_no_rooms"));const s=this._spaceModel(t.space),o=(s?.rooms||[]).map(t=>({r:t,poly:we(t)})).filter(({r:t,poly:e})=>t.name&&e).map(({r:t,poly:e})=>{const i=Re(e);return{name:t.name,cx:i[0],cy:i[1]}}),n=ss(i.rooms,o);n?this._vacSaveMatrix(t.id,e,this._vacMapId(t,i),n.matrix)&&(n.residual>50&&this._showToast(ei(this._t("vac.autocal_res_warn"),{rooms:String(n.matched.length)})),this._showToast(ei(this._t("vac.autocal_done"),{rooms:String(n.matched.length)}))):this._showToast(this._t("vac.autocal_no_match"))}_vacStartFit(t){const e=this._vacSource(t),i=e?ts(this.hass?.states[e]?.attributes):null;if(!e||!i)return void this._showToast(this._t("vac.cal_need_pos"));const s=this._vacMapId(t,i),o=t.marker?.vacuum?.calibration?.[s],n=this._spaceModel(t.space),r=n?.vb||[0,0,Qs,Qs],a=o&&6===o.length&&function(t){const e=t[0]*t[4]-t[1]*t[3];if(!Number.isFinite(e)||Math.abs(e)<1e-12)return null;const i=e<0,s=Math.sqrt(Math.abs(e));let o=180*Math.atan2(-t[1],t[4])/Math.PI;return o=(90*Math.round(o/90)%360+360)%360,{ox:t[2],oy:t[5],s:s,rot:o,mir:i}}(o)||function(t,e){const i=[],s=[];for(const e of t)null!=e.x0?(i.push(e.x0,e.x1),s.push(e.y0,e.y1)):(i.push(e.cx),s.push(e.cy));if(!i.length)return{ox:e[0]+e[2]/2,oy:e[1]+e[3]/2,s:e[2]/1e4,rot:0,mir:!0};const o=Math.min(...i),n=Math.max(...i),r=Math.min(...s),a=Math.max(...s),l=Math.max(n-o,a-r)||1,c={ox:0,oy:0,s:.6*Math.min(e[2],e[3])/l,rot:0,mir:!0},h=as(c),[p,d]=Yi(h,(o+n)/2,(r+a)/2);return c.ox=e[0]+e[2]/2-p,c.oy=e[1]+e[3]/2-d,c}(i.rooms,r);this._markerDialog=null,t.space!==this._space&&(this._space=t.space),this._vacFit={markerId:t.id,source:e,mapId:s,p:a,drag:null}}_vacFitSave(){const t=this._vacFit;if(!t)return;const e=this._vacSaveMatrix(t.markerId,t.source,t.mapId,as(t.p));this._vacFit=null,e&&this._showToast(this._t("vac.cal_done"))}_vacFitTurn(t){const e=this._vacFit;if(!e)return;const i=ts(this.hass?.states[e.source]?.attributes),s=this._vacGhostCentre(i?.rooms||[]),o={...e.p,...t};this._vacFit={...e,p:ls(o,e.p,s[0],s[1])}}_vacGhostCentre(t){const e=[],i=[];for(const s of t)e.push(s.x0??s.cx,s.x1??s.cx),i.push(s.y0??s.cy,s.y1??s.cy);return e.length?[(Math.min(...e)+Math.max(...e))/2,(Math.min(...i)+Math.max(...i))/2]:[0,0]}_vacDelta(t,e,i){const s=this._stageEl,o=s?.clientWidth||1,n=s?.clientHeight||1;return[e/o*t.w,i/n*t.h]}_vacFitPointer(t,e){const i=this._vacFit;if(!i)return;if(t.stopPropagation(),"pointerdown"===t.type){const e=t.target,s=e.getAttribute?.("data-corner");try{t.currentTarget.setPointerCapture?.(t.pointerId)}catch{}return void(this._vacFit={...i,drag:s?{kind:"scale",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:Number(s.split(",")[0]),fy:Number(s.split(",")[1])}:{kind:"move",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:0,fy:0}})}const s=i.drag;if(s){if("pointermove"===t.type){const[o,n]=this._vacDelta(e,t.clientX-s.sx,t.clientY-s.sy);if("move"===s.kind)this._vacFit={...i,p:{...s.p0,ox:s.p0.ox+o,oy:s.p0.oy+n}};else{const t=ts(this.hass?.states[i.source]?.attributes),e=this._vacGhostCentre(t?.rooms||[]),r=as(s.p0),[a,l]=Yi(r,e[0],e[1]),[c,h]=Yi(r,s.fx,s.fy),p=Math.hypot(a-c,l-h)||1,[d,u]=[2*a-c,2*l-h],_=Math.hypot(d+2*o-c,u+2*n-h)/2,m=Math.max(.05,_/p),g={...s.p0,s:s.p0.s*m};this._vacFit={...i,p:ls(g,s.p0,s.fx,s.fy)}}return}"pointerup"!==t.type&&"pointercancel"!==t.type||(this._vacFit={...i,drag:null})}}_renderVacFit(t){const e=this._vacFit;if(!e)return V;const i=ts(this.hass?.states[e.source]?.attributes);if(!i)return V;const s=as(e.p),o=[],n=[],r=[];for(const t of i.rooms){if(null==t.x0)continue;const e=[[t.x0,t.y0],[t.x1,t.y0],[t.x1,t.y1],[t.x0,t.y1]].map(([t,e])=>Yi(s,t,e));e.forEach(([t,e])=>{n.push(t),r.push(e)});const[i,a]=Yi(s,t.cx,t.cy);o.push(j`
- ${t.name}`)}let a=V;if(i.pos){const[e,o]=Yi(s,i.pos.x,i.pos.y);a=j``}const l=[];if(n.length){const e=(()=>{const t=s[0]*s[4]-s[1]*s[3];return(e,i)=>[(s[4]*(e-s[2])-s[1]*(i-s[5]))/t,(-s[3]*(e-s[2])+s[0]*(i-s[5]))/t]})(),i=Math.min(...n),o=Math.max(...n),a=Math.min(...r),c=Math.max(...r),h=.022*t.w;for(const[t,s,n,r]of[[i,a,o,c],[o,a,i,c],[o,c,i,a],[i,c,o,a]]){const i=e(n,r);l.push(j``}_vacMapId(t,e){const i=this._vacEntity(t),s=i?this.hass?.states[i]?.attributes?.selected_map:null;return o=e.mapId,n=s,"default"!==o?o:null!=n?String(n):"default";var o,n}_vacSaveMatrix(t,e,i,s){const o=this._devices.find(e=>e.id===t),n=o?this._vacEnsureMarker(o):this._serverCfg?.markers?.find(e=>e.id===t);if(!n)return!1;const r={...n.vacuum||{}};return r.source=e,r.calibration={...r.calibration||{},[i]:s.map(t=>Number(t.toFixed(6)))},n.vacuum=r,this._regSignature="",this._saveConfig(),this.requestUpdate(),!0}_vacAutoCalibrate(t){const e=this._vacSource(t),i=e?bs(this.hass?.states[e]?.attributes):null;if(!e||!i||i.rooms.length<3)return void this._showToast(this._t("vac.autocal_no_rooms"));const s=this._spaceModel(t.space),o=(s?.rooms||[]).map(t=>({r:t,poly:ke(t)})).filter(({r:t,poly:e})=>t.name&&e).map(({r:t,poly:e})=>{const i=Ne(e);return{name:t.name,cx:i[0],cy:i[1]}}),n=$s(i.rooms,o);n?this._vacSaveMatrix(t.id,e,this._vacMapId(t,i),n.matrix)&&(n.residual>50&&this._showToast(ni(this._t("vac.autocal_res_warn"),{rooms:String(n.matched.length)})),this._showToast(ni(this._t("vac.autocal_done"),{rooms:String(n.matched.length)}))):this._showToast(this._t("vac.autocal_no_match"))}_vacStartFit(t){const e=this._vacSource(t),i=e?bs(this.hass?.states[e]?.attributes):null;if(!e||!i)return void this._showToast(this._t("vac.cal_need_pos"));const s=this._vacMapId(t,i),o=t.marker?.vacuum?.calibration?.[s],n=this._spaceModel(t.space),r=n?.vb||[0,0,yo,yo],a=o&&6===o.length&&function(t){const e=t[0]*t[4]-t[1]*t[3];if(!Number.isFinite(e)||Math.abs(e)<1e-12)return null;const i=e<0,s=Math.sqrt(Math.abs(e));let o=180*Math.atan2(-t[1],t[4])/Math.PI;return o=(90*Math.round(o/90)%360+360)%360,{ox:t[2],oy:t[5],s:s,rot:o,mir:i}}(o)||function(t,e){const i=[],s=[];for(const e of t)null!=e.x0?(i.push(e.x0,e.x1),s.push(e.y0,e.y1)):(i.push(e.cx),s.push(e.cy));if(!i.length)return{ox:e[0]+e[2]/2,oy:e[1]+e[3]/2,s:e[2]/1e4,rot:0,mir:!0};const o=Math.min(...i),n=Math.max(...i),r=Math.min(...s),a=Math.max(...s),l=Math.max(n-o,a-r)||1,c={ox:0,oy:0,s:.6*Math.min(e[2],e[3])/l,rot:0,mir:!0},h=Ms(c),[d,p]=ms(h,(o+n)/2,(r+a)/2);return c.ox=e[0]+e[2]/2-d,c.oy=e[1]+e[3]/2-p,c}(i.rooms,r);this._markerDialog=null,t.space!==this._space&&(this._space=t.space),this._vacFit={markerId:t.id,source:e,mapId:s,p:a,drag:null}}_vacFitSave(){const t=this._vacFit;if(!t)return;const e=this._vacSaveMatrix(t.markerId,t.source,t.mapId,Ms(t.p));this._vacFit=null,e&&this._showToast(this._t("vac.cal_done"))}_vacFitTurn(t){const e=this._vacFit;if(!e)return;const i=bs(this.hass?.states[e.source]?.attributes),s=this._vacGhostCentre(i?.rooms||[]),o={...e.p,...t};this._vacFit={...e,p:Cs(o,e.p,s[0],s[1])}}_vacGhostCentre(t){const e=[],i=[];for(const s of t)e.push(s.x0??s.cx,s.x1??s.cx),i.push(s.y0??s.cy,s.y1??s.cy);return e.length?[(Math.min(...e)+Math.max(...e))/2,(Math.min(...i)+Math.max(...i))/2]:[0,0]}_vacDelta(t,e,i){const s=this._stageEl,o=s?.clientWidth||1,n=s?.clientHeight||1;return[e/o*t.w,i/n*t.h]}_vacFitPointer(t,e){const i=this._vacFit;if(!i)return;if(t.stopPropagation(),"pointerdown"===t.type){const e=t.target,s=e.getAttribute?.("data-corner");try{t.currentTarget.setPointerCapture?.(t.pointerId)}catch{}return void(this._vacFit={...i,drag:s?{kind:"scale",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:Number(s.split(",")[0]),fy:Number(s.split(",")[1])}:{kind:"move",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:0,fy:0}})}const s=i.drag;if(s){if("pointermove"===t.type){const[o,n]=this._vacDelta(e,t.clientX-s.sx,t.clientY-s.sy);if("move"===s.kind)this._vacFit={...i,p:{...s.p0,ox:s.p0.ox+o,oy:s.p0.oy+n}};else{const t=bs(this.hass?.states[i.source]?.attributes),e=this._vacGhostCentre(t?.rooms||[]),r=Ms(s.p0),[a,l]=ms(r,e[0],e[1]),[c,h]=ms(r,s.fx,s.fy),d=Math.hypot(a-c,l-h)||1,[p,u]=[2*a-c,2*l-h],_=Math.hypot(p+2*o-c,u+2*n-h)/2,g=Math.max(.05,_/d),m={...s.p0,s:s.p0.s*g};this._vacFit={...i,p:Cs(m,s.p0,s.fx,s.fy)}}return}"pointerup"!==t.type&&"pointercancel"!==t.type||(this._vacFit={...i,drag:null})}}_renderVacFit(t){const e=this._vacFit;if(!e)return V;const i=bs(this.hass?.states[e.source]?.attributes);if(!i)return V;const s=Ms(e.p),o=[],n=[],r=[];for(const t of i.rooms){if(null==t.x0)continue;const e=[[t.x0,t.y0],[t.x1,t.y0],[t.x1,t.y1],[t.x0,t.y1]].map(([t,e])=>ms(s,t,e));e.forEach(([t,e])=>{n.push(t),r.push(e)});const[i,a]=ms(s,t.cx,t.cy);o.push(j`
+ ${t.name}`)}let a=V;if(i.pos){const[e,o]=ms(s,i.pos.x,i.pos.y);a=j``}const l=[];if(n.length){const e=(()=>{const t=s[0]*s[4]-s[1]*s[3];return(e,i)=>[(s[4]*(e-s[2])-s[1]*(i-s[5]))/t,(-s[3]*(e-s[2])+s[0]*(i-s[5]))/t]})(),i=Math.min(...n),o=Math.max(...n),a=Math.min(...r),c=Math.max(...r),h=.022*t.w;for(const[t,s,n,r]of[[i,a,o,c],[o,a,i,c],[o,c,i,a],[i,c,o,a]]){const i=e(n,r);l.push(j``)}}return B``}_vacRafLoop(){this._vacRaf=requestAnimationFrame(()=>{const t=this.renderRoot,e=this._stageEl,i=this._vacLastView,s=t?.querySelectorAll?.(".vacpuck")||[];if(!e||!i||!s.length)return void(this._vacRaf=0);const o=e.getBoundingClientRect();for(const e of s){const s=e.getAttribute("data-mid"),n=e.getBoundingClientRect(),r=i.x+(n.left+n.width/2-o.left)/o.width*i.w,a=i.y+(n.top+n.height/2-o.top)/o.height*i.h;for(const e of t.querySelectorAll(`line.tip[data-mid="${s}"]`))e.setAttribute("x2",r.toFixed(1)),e.setAttribute("y2",a.toFixed(1))}this._vacRafLoop()})}_renderVacuums(t,e){if(this._markup||"decor"===this._mode)return V;const i=this._space+"|"+e.x+"|"+e.y+"|"+e.w+"|"+e.h,s=this._vacJumpOnce||i!==this._vacViewKey;this._vacViewKey=i,this._vacJumpOnce=!1;const o=[],n=[];for(const i of t){if(i.hidden||!this._isVacDev(i))continue;const t=this._vacSource(i);if(!t)continue;const r=ts(this.hass?.states[t]?.attributes);if(!r)continue;const a=i.marker?.vacuum?.calibration?.[this._vacMapId(i,r)];if(!a||6!==a.length)continue;const l=this._vacRt.get(i.id),c=l?.moving??!1,h=cs(i.marker?.vacuum),p="always"===h||"cleaning"===h&&c,d=this._vacSrvTrails[i.id],u=this._vacMapId(i,r),_=d?.current?.map_id===u&&Array.isArray(d.current.points)?d.current:null,m=d?.previous?.map_id===u&&Array.isArray(d.previous.points)?d.previous:null;if("always"===h&&m&&m.points.length>1){const t=m.points.map(([t,e])=>{const[i,s]=Yi(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");n.push(j``)}if(p&&(c||_)){const t=_?.points||r.path||l?.trail||[],e=c&&(_||r.path)&&t.length>1?t.slice(0,-1):t;if(e.length>1){const t=e.map(([t,e])=>{const[i,s]=Yi(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");if(n.push(j``),c){const[t,s]=Yi(a,e[e.length-1][0],e[e.length-1][1]),o=t.toFixed(1),r=s.toFixed(1);n.push(j``)}}}if(!c||!r.pos)continue;const[g,f]=Yi(a,r.pos.x,r.pos.y),v=(g-e.x)/e.w*100,b=(f-e.y)/e.h*100,y=l&&l.lastTs>0&&Date.now()-l.lastTs>6e4,w=i.marker?.icon||i.icon||"mdi:robot-vacuum";o.push(B`
this._vacFitPointer(e,t)}>${o}${a}${l}`}_vacRafLoop(){this._vacRaf=requestAnimationFrame(()=>{const t=this.renderRoot,e=this._stageEl,i=this._vacLastView,s=t?.querySelectorAll?.(".vacpuck")||[];if(!e||!i||!s.length)return void(this._vacRaf=0);const o=e.getBoundingClientRect();for(const e of s){const s=e.getAttribute("data-mid"),n=e.getBoundingClientRect(),r=i.x+(n.left+n.width/2-o.left)/o.width*i.w,a=i.y+(n.top+n.height/2-o.top)/o.height*i.h;for(const e of t.querySelectorAll(`line.tip[data-mid="${s}"]`))e.setAttribute("x2",r.toFixed(1)),e.setAttribute("y2",a.toFixed(1))}this._vacRafLoop()})}_renderVacuums(t,e){if(this._markup||"decor"===this._mode)return V;const i=this._space+"|"+e.x+"|"+e.y+"|"+e.w+"|"+e.h,s=this._vacJumpOnce||i!==this._vacViewKey;this._vacViewKey=i,this._vacJumpOnce=!1;const o=[],n=[];for(const i of t){if(i.hidden||!this._isVacDev(i))continue;const t=this._vacSource(i);if(!t)continue;const r=bs(this.hass?.states[t]?.attributes);if(!r)continue;const a=i.marker?.vacuum?.calibration?.[this._vacMapId(i,r)];if(!a||6!==a.length)continue;const l=this._vacRt.get(i.id),c=l?.moving??!1,h=zs(i.marker?.vacuum),d="always"===h||"cleaning"===h&&c,p=this._vacSrvTrails[i.id],u=this._vacMapId(i,r),_=p?.current?.map_id===u&&Array.isArray(p.current.points)?p.current:null,g=p?.previous?.map_id===u&&Array.isArray(p.previous.points)?p.previous:null;if("always"===h&&g&&g.points.length>1){const t=g.points.map(([t,e])=>{const[i,s]=ms(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");n.push(j``)}if(d&&(c||_)){const t=_?.points||r.path||l?.trail||[],e=c&&(_||r.path)&&t.length>1?t.slice(0,-1):t;if(e.length>1){const t=e.map(([t,e])=>{const[i,s]=ms(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");if(n.push(j``),c){const[t,s]=ms(a,e[e.length-1][0],e[e.length-1][1]),o=t.toFixed(1),r=s.toFixed(1);n.push(j``)}}}if(!c||!r.pos)continue;const[m,f]=ms(a,r.pos.x,r.pos.y),v=(m-e.x)/e.w*100,b=(f-e.y)/e.h*100,y=l&&l.lastTs>0&&Date.now()-l.lastTs>6e4,w=i.marker?.icon||i.icon||"mdi:robot-vacuum";o.push(B`
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=Qs,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=Qs,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
+ ${this._fmtLen(e,i)} · ${r}°
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=yo,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=ke(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=yo,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=ke(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
${i.map(e=>{const[i,n,r,a]="x"===e.axis?[e.at,e.from[1],e.at,t[1]+Math.sign(t[1]-e.from[1])*o]:[e.from[0],e.at,t[0]+Math.sign(t[0]-e.from[0])*o,e.at];return j``})}
- `}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_openingAmt(t){const e=t.contact?this.hass.states[t.contact]?.state:null;return function(t,e,i=!1){return null==e||"unavailable"===e||"unknown"===e?"door"===t?1:0:$e(e)!==!!i?1:0}(t.type,e,!!t.invert)}_renderOpenings(t){const e=this._openingsR;if(!e.length)return j``;const i=t.color;return j`${e.map(t=>{const e=t.rlen/2,s=this._openingAmt(t),o=s>0&&!!t.contact?"var(--hp-open)":i,n=t.flip_h?-1:1,r=t.flip_v?-1:1;let a;if("window"===t.type){const t=Math.PI/2*e;a=j`
+ `}_renderOpeningCenterTick(){const t=this._opMeasure.guide,e=(t.angle+90)*Math.PI/180;return j``}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_openingAmt(t){const e=t.contact?this.hass.states[t.contact]?.state:null;return function(t,e,i=!1){return null==e||"unavailable"===e||"unknown"===e?"door"===t?1:0:ze(e)!==!!i?1:0}(t.type,e,!!t.invert)}_renderOpenings(t){const e=this._openingsR;if(!e.length)return j``;const i=t.color;return j`${e.map(t=>{const e=t.rlen/2,s=this._openingAmt(t),o=s>0&&!!t.contact?"var(--hp-open)":i,n=t.flip_h?-1:1,r=t.flip_v?-1:1;let a;if("window"===t.type){const t=Math.PI/2*e;a=j`
${this._t("opening.contact_label")}
${e(this._contactCandidates(),t.contact,e=>this._openingDialog={...t,contact:e})}
- ${t.contact?B`
`}_cardEntities(t){const e=this.hass,i=[],s=new Set,o=t=>{if(!t||s.has(t)||!e.states[t])return;const o=e.entities[t];if("config"===o?.entity_category||"diagnostic"===o?.entity_category)return;s.add(t);const n=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(n)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(n)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(n)&&i.push({eid:t,kind:"value"})};for(const e of t.marker?.controls||[])o(e);t.primary&&o(t.primary);for(const e of t.entities)o(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null,s=(t.marker?.controls||[]).filter(gi);return B`
this._infoCard=null}>
+
`}_cardEntities(t){const e=this.hass,i=[],s=new Set,o=t=>{if(!t||s.has(t)||!e.states[t])return;const o=e.entities[t];if("config"===o?.entity_category||"diagnostic"===o?.entity_category)return;s.add(t);const n=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(n)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(n)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(n)&&i.push({eid:t,kind:"value"})};for(const e of t.marker?.controls||[])o(e);t.primary&&o(t.primary);for(const e of t.entities)o(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null,s=(t.marker?.controls||[]).filter(yi);return B`
`}}so.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},so.ZOOM_MAX=8,so.ZOOM_MIN=.4,so._touchSeen=!1,so._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,so.styles=Ps,customElements.get("houseplan-card")||customElements.define("houseplan-card",so),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.55.3 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+
`}}xo.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},xo.ZOOM_MAX=8,xo.ZOOM_MIN=.4,xo._touchSeen=!1,xo._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,xo.styles=Js,customElements.get("houseplan-card")||customElements.define("houseplan-card",xo),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.56.0 ","background:#3ea6ff;color:#04121f;font-weight:700","");
diff --git a/custom_components/houseplan/manifest.json b/custom_components/houseplan/manifest.json
index 61d1c9da..d43ae98a 100755
--- a/custom_components/houseplan/manifest.json
+++ b/custom_components/houseplan/manifest.json
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
- "version": "1.55.3"
+ "version": "1.56.0"
}
diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py
index 50c6e505..a82d59e5 100644
--- a/custom_components/houseplan/validation.py
+++ b/custom_components/houseplan/validation.py
@@ -168,6 +168,22 @@ ROOM_SCHEMA = vol.All(
),
_require_geometry,
)
+
+def _north_deg(value):
+ """Compass (docs/SUN.md): strict integer degrees, 0..359.
+
+ Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
+ int in Python — none of those is a compass reading a client stored.
+ """
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise vol.Invalid("north_deg must be an integer in 0..359")
+ if not 0 <= value <= 359:
+ raise vol.Invalid("north_deg must be an integer in 0..359")
+ return value
+
+
+_BG_MODE = vol.In(["static", "daynight"])
+
SPACE_DISPLAY_SCHEMA = vol.Schema(
{
vol.Optional("show_borders"): bool,
@@ -185,6 +201,10 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("label_lqi"): bool,
vol.Optional("label_light"): bool,
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
+ # sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
+ vol.Optional("north_deg"): vol.Any(None, _north_deg),
+ vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
+ vol.Optional("sun_rays"): vol.Any(None, bool),
},
extra=vol.ALLOW_EXTRA,
)
@@ -299,6 +319,8 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
+ # climate current_temperature: badge + room-average vote (off unless True)
+ vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
# asserts every option the editor offers is accepted here (issue #3)
@@ -323,6 +345,11 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
+ # sun on the plan (docs/SUN.md): global defaults
+ vol.Optional("north_deg"): _north_deg,
+ vol.Optional("bg_mode"): _BG_MODE,
+ vol.Optional("sun_rays"): bool,
+ vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
diff --git a/demo/shot_daynight.mjs b/demo/shot_daynight.mjs
new file mode 100644
index 00000000..70a4c6fe
--- /dev/null
+++ b/demo/shot_daynight.mjs
@@ -0,0 +1,36 @@
+// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
+import { launch } from './serve.mjs';
+const outDir = process.argv[2] || '/tmp';
+const { page, browser } = await launch({ width: 700, height: 700 }, 1);
+await page.emulateMedia({ reducedMotion: 'reduce' });
+await page.evaluate(async () => {
+ const c = window.__card;
+ const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
+ sp.openings = [
+ { id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
+ { id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
+ { id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
+ ];
+ sp.plan_url = ''; // hand-drawn plan: white paper — the risky case for a white day
+ sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
+ c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
+ c._cfgRev = (c._cfgRev || 0) + 1;
+});
+const shot = async (az, el, name) => {
+ await page.evaluate(async ([az, el]) => {
+ const c = window.__card;
+ c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
+ entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
+ attributes: { azimuth: az, elevation: el },
+ } } };
+ c.requestUpdate(); await c.updateComplete;
+ }, [az, el]);
+ await page.waitForTimeout(300);
+ const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
+ await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
+};
+await shot(180, 60, 'dn_noon');
+await shot(265, 3, 'dn_sunset');
+await shot(0, -20, 'dn_night');
+await browser.close();
+console.log('shots done');
diff --git a/demo/shot_sun.mjs b/demo/shot_sun.mjs
new file mode 100644
index 00000000..45844359
--- /dev/null
+++ b/demo/shot_sun.mjs
@@ -0,0 +1,52 @@
+// Screenshots for docs/SUN.md: evening wedges over the glow fill + the compass
+// dial in the general settings. Usage: node demo/shot_sun.mjs
+import { launch } from './serve.mjs';
+const outDir = process.argv[2] || '/tmp';
+const { page, browser } = await launch({ width: 900, height: 860 }, 2);
+// instant colors for the still shot: the day/night transition is tens of seconds
+await page.emulateMedia({ reducedMotion: 'reduce' });
+
+await page.evaluate(async () => {
+ const c = window.__card;
+ const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
+ sp.openings = [
+ { id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
+ { id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
+ { id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
+ { id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
+ { id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
+ ];
+ sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
+ c._serverCfg.settings = {
+ ...(c._serverCfg.settings || {}),
+ north_deg: 0, sun_rays: true, bg_mode: 'daynight',
+ };
+ c._cfgRev = (c._cfgRev || 0) + 1;
+ // a warm evening: the sun low in the west; the living-room light off so
+ // the glow pool does not swamp the west wedge
+ c.hass = { ...c.hass, states: { ...c.hass.states,
+ 'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
+ 'sun.sun': {
+ entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 265, elevation: 3 },
+ },
+ } };
+ c.requestUpdate();
+ await c.updateComplete;
+});
+await page.waitForTimeout(400);
+const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
+await stage.asElement().screenshot({ path: outDir + '/sun_rays.png' });
+
+await page.evaluate(async () => {
+ const c = window.__card;
+ c._openSettingsDialog();
+ c._settingsDialog = { ...c._settingsDialog, northDeg: 330 };
+ c.requestUpdate();
+ await c.updateComplete;
+ c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
+});
+await page.waitForTimeout(200);
+const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
+await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
+await browser.close();
+console.log('shots written to ' + outDir);
diff --git a/demo/smoke_bg_color.mjs b/demo/smoke_bg_color.mjs
index 5b9b8236..49b91e2d 100644
--- a/demo/smoke_bg_color.mjs
+++ b/demo/smoke_bg_color.mjs
@@ -141,4 +141,205 @@ const res = await page.evaluate(async () => {
return out;
});
checkAll(res);
+
+// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
+// The scene bg_color must never bleed through the plan itself: opaque
+// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
+// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
+// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
+// The scene colour is visible ONLY around the paper; the 'daynight' night
+// dims the paper via brightness only.
+const paper = await page.evaluate(async () => {
+ const out = {};
+ const c = window.__card;
+ const sr = () => c.shadowRoot || c.renderRoot;
+ const upd = async () => { c.requestUpdate(); await c.updateComplete; };
+ c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
+ c._cfgRev = (c._cfgRev || 0) + 1;
+ await upd();
+ const m = c._spaceModel();
+ const p = sr().querySelector('.stage svg rect.hp-paper');
+ out.paperExists = !!p;
+ if (p) {
+ const a = (n) => Number(p.getAttribute(n));
+ out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
+ && Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
+ const cs = getComputedStyle(p);
+ out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
+ && cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
+ }
+ // editors keep the paper too (their canvas ignores bg_color anyway)
+ c._setMode('decor');
+ await upd();
+ out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
+ c._setMode('view');
+ await upd();
+ // daynight night: the paper dims via the brightness filter ONLY, never alpha
+ c._serverCfg.settings.bg_mode = 'daynight';
+ c._serverCfg.settings.north_deg = 0;
+ c._cfgRev++;
+ c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
+ entity_id: 'sun.sun', state: 'below_horizon',
+ attributes: { azimuth: 180, elevation: -20 },
+ } } };
+ await upd();
+ out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
+ const pn = sr().querySelector('.stage svg rect.hp-paper');
+ out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
+ delete c._serverCfg.settings.bg_mode;
+ delete c._serverCfg.settings.north_deg;
+ c._cfgRev++;
+ await upd();
+ return out;
+});
+Object.assign(res, paper);
+
+// ---- 12) drawn plan: the paper follows the ROOM CONTOURS ---------------
+// Owner: «белая подложка должна быть по границам комнат» — an L-shaped house
+// plus a detached building must NOT grow a white bounding rectangle. One
+// paper shape per room, in exactly the room's own geometry; the acid scene
+// colour shows in the L's pocket and between the buildings, right up to the
+// exterior walls; inside the rooms it never shows. A live resize preview
+// (_rszPreview) moves the paper together with the dragged wall.
+const drawn = await page.evaluate(async () => {
+ const out = {};
+ const c = window.__card;
+ const sr = () => c.shadowRoot || c.renderRoot;
+ const upd = async () => { c.requestUpdate(); await c.updateComplete; };
+ const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
+ sp.plan_url = ''; // hand-drawn plan now
+ // binary-exact fractions -> exact render coords (0.125*1000 = 125 etc.)
+ sp.rooms = [
+ { id: 'rL', name: 'L', poly: [[0.125, 0.125], [0.625, 0.125], [0.625, 0.375], [0.375, 0.375], [0.375, 0.625], [0.125, 0.625]] },
+ { id: 'rQ', name: 'Q', x: 0.75, y: 0.75, w: 0.1875, h: 0.1875 },
+ ];
+ sp.openings = [];
+ sp.decor = [];
+ c._devices = []; // no icons over the pixel probes below
+ c._cfgRev = (c._cfgRev || 0) + 1;
+ c._cfgEpoch++;
+ await upd();
+ const papers = () => [...sr().querySelectorAll('.stage.noplan svg .hp-paper')];
+ let ps = papers();
+ out.paperPerRoom = ps.length === sp.rooms.length;
+ out.paperShapesMatchRooms = ps.length === 2
+ && ps[0].tagName.toLowerCase() === 'polygon'
+ && ps[0].getAttribute('points') === sp.rooms[0].poly.map((p) => (p[0] * 1000) + ',' + (p[1] * 1000)).join(' ')
+ && ps[1].tagName.toLowerCase() === 'rect';
+ out.drawnPaperWhite = ps.length > 0 && ps.every((p) => getComputedStyle(p).fill === 'rgb(255, 255, 255)');
+ out.paperNoStroke = ps.length > 0 && ps.every((p) => {
+ const cs = getComputedStyle(p);
+ return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
+ });
+ // paper first, everything else on top of it (one .hp-paperg group wraps
+ // all paper shapes so the daynight drop shadow composites without seams)
+ const first = sr().querySelector('.stage.noplan svg').firstElementChild;
+ out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
+ && !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
+ // live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
+ // must move WITH the room, not lag behind until the drop
+ const pv = JSON.parse(JSON.stringify(sp));
+ pv.rooms[0].poly = pv.rooms[0].poly.map(([x, y]) => [x === 0.625 ? 0.6875 : x, y]);
+ c._rszPreview = { space: c._space, sp: pv };
+ c._cfgEpoch++;
+ await upd();
+ out.previewMovesPaper = (papers()[0]?.getAttribute('points') || '').includes('687.5,125');
+ c._rszPreview = null;
+ c._cfgEpoch++;
+ await upd();
+ out.previewRestores = (papers()[0]?.getAttribute('points') || '').includes('625,125');
+ // screen coords for the pixel probes (svg user units -> viewport px)
+ const svgEl = sr().querySelector('.stage svg');
+ const ctm = svgEl.getScreenCTM();
+ const toScr = (x, y) => { const q = svgEl.createSVGPoint(); q.x = x; q.y = y; const r = q.matrixTransform(ctm); return [r.x, r.y]; };
+ out.probes = {
+ pocket: toScr(500, 500), // the L's notch — bg must show here
+ gap: toScr(687, 300), // between the two buildings
+ inside: [toScr(250, 250), toScr(500, 250), toScr(250, 500), toScr(843.75, 843.75)],
+ };
+ return out;
+});
+const { probes } = drawn;
+delete drawn.probes;
+Object.assign(res, drawn);
+
+// pixel proof: the acid scene colour IS in the L's pocket and between the
+// buildings (where the old bounding-box paper drew a white square), and is
+// NOT inside any room
+if (probes) {
+ const shot = (await page.screenshot()).toString('base64');
+ const px = await page.evaluate(async ({ shot, probes }) => {
+ const img = new Image();
+ img.src = 'data:image/png;base64,' + shot;
+ await img.decode();
+ const cv = document.createElement('canvas');
+ cv.width = img.width;
+ cv.height = img.height;
+ const ctx = cv.getContext('2d', { willReadFrequently: true });
+ ctx.drawImage(img, 0, 0);
+ const at = ([x, y]) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
+ const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
+ return {
+ acidInPocket: acid(at(probes.pocket)),
+ acidBetweenBuildings: acid(at(probes.gap)),
+ noAcidInsideRooms: probes.inside.every((q) => !acid(at(q))),
+ };
+ }, { shot, probes });
+ Object.assign(res, px);
+} else {
+ res.acidInPocket = false;
+ res.acidBetweenBuildings = false;
+ res.noAcidInsideRooms = false;
+}
+
+// ---- 13) the static space-card follows the same paper contract ----------
+const stat = await page.evaluate(async () => {
+ const out = {};
+ const c = window.__card;
+ await customElements.whenDefined('houseplan-space-card');
+ // NOTE: the config-store cache is module-level and already primed by the
+ // earlier static-card sections (with the PRE-§12 rooms), so a mocked
+ // callWS would never reach the card — inject the snapshot directly.
+ const mk = async (cfg) => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const el = document.createElement('houseplan-space-card');
+ el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
+ el._snap = { config: cfg, rev: 1, layout: {} };
+ el._loadedOnce = true;
+ el.hass = c.hass;
+ host.appendChild(el);
+ const t0 = Date.now();
+ while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000)
+ await new Promise((r) => setTimeout(r, 80));
+ await el.updateComplete;
+ return { el, host };
+ };
+ const base = JSON.parse(JSON.stringify(c._serverCfg));
+ base.spaces.find((s) => s.id === c._space).plan_url = '/assets/f1.svg';
+ base.settings = { ...(base.settings || {}), bg_color: '#ff00ff' };
+ const a = await mk(base);
+ const pa = a.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
+ out.staticPaperExists = !!pa;
+ out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
+ && getComputedStyle(pa).fill !== 'none';
+ a.host.remove();
+ // drawn plan: per-room paper shapes (the L + the detached rect from §12)
+ const drawnCfg = JSON.parse(JSON.stringify(base));
+ const dsp = drawnCfg.spaces.find((s) => s.id === c._space);
+ dsp.plan_url = '';
+ const b = await mk(drawnCfg);
+ const pbs = [...b.el.renderRoot.querySelectorAll('.hp-static-stage svg .hp-paper')];
+ out.staticDrawnPaperPerRoom = pbs.length === dsp.rooms.length;
+ out.staticDrawnPaperShapes = pbs.length === 2
+ && pbs[0].tagName.toLowerCase() === 'polygon'
+ && pbs[1].tagName.toLowerCase() === 'rect';
+ b.host.remove();
+ return out;
+});
+Object.assign(res, stat);
+checkAll({ ...paper, ...drawn, ...stat,
+ acidInPocket: res.acidInPocket,
+ acidBetweenBuildings: res.acidBetweenBuildings,
+ noAcidInsideRooms: res.noAcidInsideRooms });
await finish(browser, res);
diff --git a/demo/smoke_climate_temp.mjs b/demo/smoke_climate_temp.mjs
new file mode 100644
index 00000000..9a9205fa
--- /dev/null
+++ b/demo/smoke_climate_temp.mjs
@@ -0,0 +1,147 @@
+// Опция «Использовать датчик температуры устройства» (marker.use_climate_temp):
+// климат-устройства (кондиционеры, термостаты) знают температуру комнаты
+// (attributes.current_temperature). По умолчанию ВЫКЛ — ни плашки, ни вклада
+// в среднюю. Включена через диалог (реальный клик по чекбоксу): плашка .tval
+// у значка + голос в средней комнаты (термометр 20 + климат 23.5 → 21.75,
+// т.е. 21.8 по сетке карточки 0.1°). Unavailable-климат не ломает ничего.
+import { launch, check, checkAll, finish } from './serve.mjs';
+const { page, browser } = await launch();
+const out = {};
+
+// --- впрыскиваем климат-устройства в демо-hass ---------------------------
+await page.evaluate(() => {
+ const c = window.__card;
+ const h = window.__mkHass();
+ const inject = (hass) => ({
+ ...hass,
+ devices: {
+ ...hass.devices,
+ d_ac: { id: 'd_ac', name: 'Air conditioner', model: 'AC-12', area_id: 'living_room', identifiers: [['demo', 'd_ac']], entry_type: null, via_device_id: null },
+ d_trv: { id: 'd_trv', name: 'Radiator valve', model: 'TRV-1', area_id: 'bedroom', identifiers: [['demo', 'd_trv']], entry_type: null, via_device_id: null },
+ },
+ entities: {
+ ...hass.entities,
+ 'climate.ac': { entity_id: 'climate.ac', device_id: 'd_ac', platform: 'demo' },
+ 'climate.trv': { entity_id: 'climate.trv', device_id: 'd_trv', platform: 'demo' },
+ },
+ states: {
+ ...hass.states,
+ // термометр гостиной — ровно 20, чтобы средняя с климатом была детерминированной
+ 'sensor.living_temp': { ...hass.states['sensor.living_temp'], state: '20' },
+ 'climate.ac': { entity_id: 'climate.ac', state: 'cool', attributes: { friendly_name: 'Air conditioner', current_temperature: 23.5, temperature: 24 } },
+ // мёртвый климат: state unavailable, атрибутов нет
+ 'climate.trv': { entity_id: 'climate.trv', state: 'unavailable', attributes: { friendly_name: 'Radiator valve' } },
+ },
+ });
+ window.__injectClimate = () => { c.hass = inject(window.__mkHass()); };
+ window.__injectClimate();
+ c._regSignature = '';
+ c._maybeRebuildDevices();
+ c.requestUpdate();
+});
+await page.waitForFunction(() => window.__card._devices.some((d) => d.entities?.includes('climate.ac')));
+
+// --- без опции: ни плашки, ни вклада в среднюю ---------------------------
+Object.assign(out, await page.evaluate(async () => {
+ const o = {};
+ const c = window.__card;
+ await c.updateComplete;
+ const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
+ o.acBuilt = !!ac;
+ o.noBadgeByDefault = c._liveTemp(ac) === null;
+ o.avgWithoutOption = c._climate().get('living_room')?.temp; // === 20
+ return o;
+}), { avgWithoutOption: 20 });
+
+// --- диалог: чекбокс есть у климат-устройства, нет у обычного, клик мышью --
+Object.assign(out, await page.evaluate(async () => {
+ const o = {};
+ const c = window.__card;
+ const sr = () => c.shadowRoot || c.renderRoot;
+ // у обычного термометра чекбокса нет
+ const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
+ c._openMarkerDialog(th); await c.updateComplete;
+ o.noCheckboxForThermometer = !sr().querySelector('.dialog .climrow');
+ c._markerDialog = null; await c.updateComplete;
+ // у кондиционера — есть и по умолчанию снят
+ const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
+ c._openMarkerDialog(ac); await c.updateComplete;
+ const row = sr().querySelector('.dialog .climrow input');
+ o.checkboxForClimate = !!row;
+ o.uncheckedByDefault = !!row && !row.checked;
+ if (row) row.scrollIntoView({ block: 'center' });
+ return o;
+}));
+
+// реальный клик по чекбоксу — координатами, через весь стек событий
+const box = await page.evaluate(() => {
+ const c = window.__card;
+ const sr = c.shadowRoot || c.renderRoot;
+ const r = sr.querySelector('.dialog .climrow input')?.getBoundingClientRect();
+ return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
+});
+check('checkboxClickable', !!box);
+if (box) await page.mouse.click(box.x, box.y);
+Object.assign(out, await page.evaluate(async () => {
+ const o = {};
+ const c = window.__card;
+ await c.updateComplete;
+ o.clickTicksState = c._markerDialog?.useClimateTemp === true;
+ await c._saveMarker(); await c.updateComplete;
+ const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:d_ac');
+ o.savedInMarker = m?.use_climate_temp === true;
+ return o;
+}));
+
+// --- с опцией: плашка «23.5°», средняя пересчиталась ----------------------
+Object.assign(out, await page.evaluate(async () => {
+ const o = {};
+ const c = window.__card;
+ const sr = () => c.shadowRoot || c.renderRoot;
+ await c.updateComplete;
+ const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
+ o.liveTempFromClimate = c._liveTemp(ac); // === 23.5
+ const badges = [...sr().querySelectorAll('.dev .tval')].map((el) => el.textContent.trim());
+ o.badgeVisible = badges.includes('23.5°');
+ // средняя гостиной: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
+ o.avgWithOption = c._climate().get('living_room')?.temp;
+ // и на КАРТОЧКЕ КОМНАТЫ: включаем метрику температуры и читаем текст
+ const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
+ sp.settings = { ...(sp.settings || {}), label_temp: true, show_names: true };
+ c.requestUpdate(); await c.updateComplete;
+ const labels = [...sr().querySelectorAll('.roomlabel .rlm')].map((el) => el.textContent.trim());
+ o.roomCardShowsAvg = labels.some((t) => t.includes('21.8°'));
+ return o;
+}), { liveTempFromClimate: 23.5, avgWithOption: 21.8 });
+
+// --- unavailable-климат: опция включена, но ни плашки, ни вклада ----------
+Object.assign(out, await page.evaluate(async () => {
+ const o = {};
+ const c = window.__card;
+ c._serverCfg.markers = [...c._serverCfg.markers,
+ { id: 'd_trv', binding: 'device:d_trv', use_climate_temp: true, hidden: false }];
+ c._cfgEpoch++; c._regSignature = '';
+ c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
+ const trv = c._devices.find((d) => d.entities?.includes('climate.trv'));
+ o.unavailNoBadge = !!trv && c._liveTemp(trv) === null;
+ o.unavailNoVote = c._climate().get('bedroom')?.temp ?? null; // в спальне нет термометров
+ // а гостиная как была 21.8
+ o.avgStillCorrect = c._climate().get('living_room')?.temp;
+ return o;
+}), { unavailNoVote: null, avgStillCorrect: 21.8 });
+
+// --- чекбокс переживает пересоздание диалога ------------------------------
+Object.assign(out, await page.evaluate(async () => {
+ const o = {};
+ const c = window.__card;
+ const sr = () => c.shadowRoot || c.renderRoot;
+ const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
+ c._openMarkerDialog(ac); await c.updateComplete;
+ o.survivesReopen = c._markerDialog?.useClimateTemp === true;
+ o.checkboxChecked = sr().querySelector('.dialog .climrow input')?.checked === true;
+ c._markerDialog = null;
+ return o;
+}));
+
+checkAll(out, { avgWithoutOption: 20, liveTempFromClimate: 23.5, avgWithOption: 21.8, unavailNoVote: null, avgStillCorrect: 21.8 });
+await finish(browser, out);
diff --git a/demo/smoke_general_settings.mjs b/demo/smoke_general_settings.mjs
index 71cd19ca..1766d185 100644
--- a/demo/smoke_general_settings.mjs
+++ b/demo/smoke_general_settings.mjs
@@ -4,7 +4,7 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
- // 1) диалог общих настроек: 7 цветовых строк, 3 группы
+ // 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
@@ -28,8 +28,8 @@ const res = await page.evaluate(async () => {
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
- "rows": 12, // 10 цветов + радиус свечения + фон вокруг плана
- "groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background"],
+ "rows": 14, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
+ "groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun"],
"saved": {"c": "#ff00ff", "a": 0.5},
"lqiBefore": 7,
"lqiAfter": 0,
diff --git a/demo/smoke_ha_controls.mjs b/demo/smoke_ha_controls.mjs
new file mode 100644
index 00000000..e5e01ce0
--- /dev/null
+++ b/demo/smoke_ha_controls.mjs
@@ -0,0 +1,86 @@
+// Native HA controls in dialogs (ha-switch / ha-slider) + the fallback.
+// The demo env has no HA frontend, so every OTHER smoke already runs the
+// fallback branch (plain input[type=checkbox|range]). Here we check both:
+// 1) fallback: without ha-* the dialogs render the classic inputs;
+// 2) ha branch: after registering lightweight ha-switch/ha-slider stubs
+// (checked/value + `change`/`input`, the real contract of the HA ones)
+// a re-opened dialog renders them, values flow BOTH ways.
+import { launch, checkAll, finish } from './serve.mjs';
+const { page, browser } = await launch();
+const res = await page.evaluate(async () => {
+ const out = {};
+ const c = window.__card;
+ const sr = () => c.shadowRoot || c.renderRoot;
+
+ // --- 1) fallback branch: no ha-* registered -> classic inputs ----------
+ c._openMarkerDialog(c._devices[0]); await c.updateComplete;
+ out.fallbackCheckbox = !!sr().querySelector('.dialog .srcrow input[type=checkbox]');
+ out.fallbackRange = !!sr().querySelector('.dialog input[type=range]');
+ out.fallbackNoHaSwitch = !sr().querySelector('.dialog ha-switch');
+ c._markerDialog = null; await c.updateComplete;
+
+ // --- 2) register stubs mimicking the HA contract -----------------------
+ customElements.define('ha-switch', class extends HTMLElement {
+ #on = false;
+ get checked() { return this.#on; }
+ set checked(v) { this.#on = !!v; }
+ get disabled() { return this.hasAttribute('disabled'); }
+ set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
+ });
+ customElements.define('ha-slider', class extends HTMLElement {
+ #v = 0;
+ get value() { return this.#v; }
+ set value(v) { this.#v = Number(v); }
+ set min(_) {} set max(_) {} set step(_) {}
+ });
+
+ c._openMarkerDialog(c._devices[0]); await c.updateComplete;
+ const switches = [...sr().querySelectorAll('.dialog ha-switch')];
+ out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
+ out.haNoPlainCheckbox = !sr().querySelector('.dialog .srcrow input[type=checkbox]');
+ const sliders = [...sr().querySelectorAll('.dialog ha-slider')];
+ out.haSliderRendered = sliders.length >= 2; // size + angle
+
+ // --- 3) card -> control: state is pushed into the element --------------
+ c._markerDialog = { ...c._markerDialog, isLight: true, hideFromPlan: false };
+ await c.updateComplete;
+ out.downstreamChecked = [...sr().querySelectorAll('.dialog ha-switch')].some((el) => el.checked === true);
+
+ // --- 4) control -> card: change with .checked lands in the dialog ------
+ const before = c._markerDialog.hideFromPlan;
+ const sw = [...sr().querySelectorAll('.dialog ha-switch')].find((el) => el.checked === before);
+ sw.checked = !before;
+ sw.dispatchEvent(new Event('change', { bubbles: true }));
+ await c.updateComplete;
+ out.upstreamChecked = c._markerDialog.hideFromPlan === !before || c._markerDialog.isLight === !before
+ || c._markerDialog.useClimateTemp === !before || c._markerDialog.tapConfirm === !before;
+
+ // --- 5) slider both ways ----------------------------------------------
+ const sizeBefore = c._markerDialog.size;
+ const sl = [...sr().querySelectorAll('.dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
+ out.downstreamValue = !!sl;
+ sl.value = 2;
+ sl.dispatchEvent(new Event('input', { bubbles: true }));
+ await c.updateComplete;
+ out.upstreamValue = c._markerDialog.size === 2;
+ // `change` (fired on release, HA-version dependent) reaches the same handler
+ sl.value = 2.5;
+ sl.dispatchEvent(new Event('change', { bubbles: true }));
+ await c.updateComplete;
+ out.upstreamValueChangeEvt = c._markerDialog.size === 2.5;
+
+ c._markerDialog = null; await c.updateComplete;
+
+ // --- 6) general settings: opacity sliders take the ha branch too -------
+ c._openSettingsDialog(); await c.updateComplete;
+ out.gsHaSliders = sr().querySelectorAll('.dialog ha-slider').length >= 9;
+ const gsl = sr().querySelector('.dialog ha-slider');
+ gsl.value = 55;
+ gsl.dispatchEvent(new Event('input', { bubbles: true }));
+ await c.updateComplete;
+ out.gsUpstream = Math.round((Object.values(c._settingsDialog.colors)[0].a) * 100) === 55;
+ c._settingsDialog = null; await c.updateComplete;
+ return out;
+});
+checkAll(res, {});
+await finish(browser, res);
diff --git a/demo/smoke_opening_measure.mjs b/demo/smoke_opening_measure.mjs
new file mode 100644
index 00000000..a24d1b3a
--- /dev/null
+++ b/demo/smoke_opening_measure.mjs
@@ -0,0 +1,172 @@
+// Opening drag rulers (owner 2026-08-03): while a door/window is dragged along
+// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
+// along the wall), and when the opening's center hits the wall's center a
+// perpendicular dashed tick appears and the center magnet-snaps (Shift opts
+// out). The wall is ONE room's edge (owner: "only the wall of one room") —
+// r2's collinear top edge never merges into r1's, so the ruler runs 40..550
+// (center 295), not the old merged 40..960. Real pointer events; the numbers
+// are checked against the geometry: cell = 5 cm, grid pitch = 1000/240, so
+// cm = units * 1.2 and r1's top wall (510 u) is 6.12 m.
+import { launch, check, finish } from './serve.mjs';
+const { page, browser } = await launch();
+
+await page.evaluate(() => {
+ const c = window.__card;
+ const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
+ // 80-unit door on r1's top wall (y = 0.14), off-center
+ sp.openings = [{ id: 'op1', type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08 }];
+ c._setMode('plan'); c._tool = 'opening';
+ c._cfgEpoch++; c.requestUpdate();
+ return c.updateComplete && true;
+});
+const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
+await settle();
+
+const screenPt = (x, y) => page.evaluate(([x, y]) => {
+ const c = window.__card;
+ const stage = c.renderRoot.querySelector('.stage');
+ const r = stage.getBoundingClientRect();
+ const svg = stage.querySelector('svg');
+ const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
+ return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
+}, [x, y]);
+const badges = () => page.evaluate(() =>
+ [...window.__card.renderRoot.querySelectorAll('.measurelabel.opshoulder')].map((el) => el.textContent.trim()).sort());
+const nums = async () => (await badges()).map((t) => parseFloat(t));
+const tick = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opcentertick').length);
+const near = (a, b, tol) => Math.abs(a - b) <= tol;
+
+// ---------- drag to an off-center spot: two live badges, no tick ----------
+const [sx, sy] = await screenPt(200, 140);
+const [t1x] = await screenPt(460, 140);
+await page.mouse.move(sx, sy);
+await page.mouse.down();
+await page.mouse.move(t1x, sy, { steps: 5 });
+await settle();
+// center 460 -> edges 420/500; shoulders measured on r1's OWN edge 40..550:
+// 420-40=380 u (4.56 m) and 550-500=50 u (0.60 m) — the old merged build
+// showed 960-500=460 u (5.52 m) on the right
+{
+ const [a, b] = await nums();
+ check('two_badges_visible', (await badges()).length, 2);
+ check('badge_right_0_60', near(a, 0.6, 0.04));
+ check('badge_left_4_56', near(b, 4.56, 0.04));
+ // exact invariant, immune to pointer rounding: shoulders + opening = wall
+ check('badges_sum_to_wall', near(a + b, 6.12 - 0.96, 0.021));
+}
+check('no_tick_off_center', await tick(), 0);
+
+// ---------- live update on the way ----------
+const [t2x] = await screenPt(360, 140);
+await page.mouse.move(t2x, sy, { steps: 3 });
+await settle();
+// center 360 -> shoulders 320-40=280 u (3.36 m) and 550-400=150 u (1.80 m)
+{
+ const [a, b] = await nums();
+ check('badge_right_updates_1_80', near(a, 1.8, 0.04));
+ check('badge_left_updates_3_36', near(b, 3.36, 0.04));
+}
+
+// ---------- near the OWN edge center (40+550)/2 = 295: tick + magnet ----------
+const [t3x] = await screenPt(293.7, 140);
+await page.mouse.move(t3x, sy, { steps: 3 });
+await settle();
+check('tick_at_center', await tick(), 1);
+check('magnet_snaps_center', near(await page.evaluate(() =>
+ window.__card._curSpaceCfg.openings[0].x), 0.295, 1e-6));
+// both shoulders equal after the snap: (510 - 80) / 2 = 215 u -> 2.58 m
+check('badges_equal_at_center', await badges(), ['2.58 m', '2.58 m']);
+// the tick is perpendicular to the wall (horizontal wall -> a vertical dash)
+const geom = await page.evaluate(() => {
+ const el = window.__card.renderRoot.querySelector('.opcentertick');
+ if (!el) return null;
+ const dashed = getComputedStyle(el).strokeDasharray;
+ return { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
+ y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2'),
+ dashed: dashed !== 'none' && dashed !== '' };
+});
+check('tick_perpendicular_at_wall_center', !!geom
+ && near(geom.x1, 295, 1e-6) && near(geom.x2, 295, 1e-6) && near(geom.y2 - geom.y1, 30, 1e-6));
+check('tick_dashed', !!geom && geom.dashed);
+
+// ---------- release: everything disappears, the snapped x is committed ----------
+await page.mouse.up();
+await settle();
+check('badges_gone_after_drop', (await badges()).length, 0);
+check('tick_gone_after_drop', await tick(), 0);
+check('committed_x_center', near(await page.evaluate(() =>
+ window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
+
+// ---------- Shift disables the magnet (badges stay) ----------
+// park the opening off-center first: a drag that returns to within 3 px of its
+// own starting point is filtered by the tap threshold, so the Shift approach
+// must start away from the center
+const [cx0] = await screenPt(295, 140);
+await page.mouse.move(cx0, sy);
+await page.mouse.down();
+await page.mouse.move(t2x, sy, { steps: 3 });
+await page.mouse.up();
+await settle();
+await page.keyboard.down('Shift');
+await page.mouse.down();
+await page.mouse.move(t3x, sy, { steps: 3 });
+await settle();
+check('no_tick_with_shift', await tick(), 0);
+const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
+check('no_magnet_with_shift', near(freeX, 0.2937, 0.004) && freeX !== 0.295);
+check('badges_still_there_with_shift', (await badges()).length, 2);
+await page.mouse.up();
+await page.keyboard.up('Shift');
+await settle();
+check('badges_gone_final', (await badges()).length, 0);
+
+// ---------- opening on a SHARED wall of two rooms: the own room's edge ----------
+// staggered fragments on the boundary x=550: r1's right edge 140..460 (its
+// room is made the short one), r2's left edge 140..580. The snap resolves to
+// r1's edge (nearest, first in roomEdges order on the tie), so the shoulders
+// and the center come from 140..460 — the old build measured the merged
+// 140..580 union
+await page.evaluate(() => {
+ const c = window.__card;
+ const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
+ sp.rooms = [
+ { id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.46], [0.04, 0.46]] },
+ { id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.58], [0.55, 0.58]] },
+ ];
+ sp.openings = [{ id: 'op1', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.08 }];
+ c._cfgEpoch++; c.requestUpdate();
+ return c.updateComplete && true;
+});
+await settle();
+const [vx0, vy0] = await screenPt(550, 300);
+const [, vy1] = await screenPt(550, 360);
+await page.mouse.move(vx0, vy0);
+await page.mouse.down();
+await page.mouse.move(vx0, vy1, { steps: 3 });
+await settle();
+// center 360 -> edges 320/400; r1's edge 140..460: 320-140=180 u (2.16 m) and
+// 460-400=60 u (0.72 m); the merged build showed 580-400=180 u (2.16 m) below
+{
+ const [a, b] = await nums();
+ check('shared_wall_two_badges', (await badges()).length, 2);
+ check('shared_wall_bottom_0_72', near(a, 0.72, 0.04));
+ check('shared_wall_top_2_16', near(b, 2.16, 0.04));
+}
+check('shared_wall_no_tick_off_center', await tick(), 0);
+await page.mouse.up();
+await settle();
+// approach the OWN edge center (140+460)/2 = 300 — NOT the merged 360
+const [, vy2] = await screenPt(550, 301.3);
+await page.mouse.move(vx0, vy1);
+await page.mouse.down();
+await page.mouse.move(vx0, vy2, { steps: 3 });
+await settle();
+check('shared_wall_tick_at_own_center', await tick(), 1);
+check('shared_wall_magnet_y_0_3', near(await page.evaluate(() =>
+ window.__card._curSpaceCfg.openings[0].y), 0.3, 1e-6));
+// equal shoulders on the own edge: (320 - 80) / 2 = 120 u -> 1.44 m
+check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
+await page.mouse.up();
+await settle();
+
+await finish(browser, { done: true });
diff --git a/demo/smoke_sun.mjs b/demo/smoke_sun.mjs
new file mode 100644
index 00000000..5aefc434
--- /dev/null
+++ b/demo/smoke_sun.mjs
@@ -0,0 +1,293 @@
+// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
+// walls, direction follows north_deg, night is empty, wedges clip to rooms,
+// day/night background vs static, per-space inheritance, cloud fading,
+// memoisation across hass ticks, editors stay clean.
+import { launch, check, checkAll, finish } from './serve.mjs';
+const { page, browser } = await launch({ width: 900, height: 900 }, 1);
+const res = await page.evaluate(async () => {
+ const out = {};
+ const c = window.__card;
+ const sr = () => c.shadowRoot || c.renderRoot;
+ const upd = async () => { c.requestUpdate(); await c.updateComplete; };
+ const setSun = async (azimuth, elevation) => {
+ c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
+ entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
+ attributes: { azimuth, elevation },
+ } } };
+ await upd();
+ };
+ const setWeather = async (state) => {
+ c.hass = { ...c.hass, states: { ...c.hass.states, 'weather.home': {
+ entity_id: 'weather.home', state, attributes: {},
+ } } };
+ await upd();
+ };
+ const cfg = () => c._serverCfg;
+ // DEV-B701-01: local mutations bump _cfgEpoch (what _saveConfig() does
+ // synchronously). The old helper bumped _cfgRev — a server ack that the
+ // production save path does NOT produce until the debounced write lands —
+ // and thereby masked the stale sun-ray cache.
+ const touchCfg = () => { c._cfgEpoch++; };
+ const litIds = () => (c._sunRaysCache?.rays || []).map((r) => r.openingId).sort();
+ const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
+ const stageStyle = () => sr().querySelector('.stage').getAttribute('style') || '';
+
+ // ---- the test plan: windows on all four OUTER walls + one interior ----
+ const sp = cfg().spaces.find((s) => s.id === 'f1');
+ sp.openings = [
+ { id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 }, // top of r1
+ { id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 }, // right of r3
+ { id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 }, // bottom of r4
+ { id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }, // left of r1
+ { id: 'wI', type: 'window', x: 0.55, y: 0.30, angle: 90, length: 0.08 }, // r1|r2 wall
+ { id: 'dS', type: 'door', x: 0.20, y: 0.86, angle: 0, length: 0.1 }, // doors never glow
+ ];
+ touchCfg();
+
+ // 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
+ cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
+ touchCfg();
+ await setSun(90, 5);
+ out.silentWithoutNorth = domPolys().length === 0;
+ out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
+
+ // 2) compass set → morning east sun lights ONLY the east window
+ cfg().settings.north_deg = 0;
+ touchCfg();
+ await setSun(90, 5);
+ out.morningEast = JSON.stringify(litIds()) === '["wE"]';
+ out.morningDomDrawn = domPolys().length > 0;
+
+ // 3) noon south → only the south window, wedge shorter than the morning one
+ const eastLen = c._sunRaysCache?.rays?.[0]?.len ?? 0;
+ await setSun(180, 60);
+ out.noonSouth = JSON.stringify(litIds()) === '["wS"]';
+ out.noonShorter = (c._sunRaysCache?.rays?.[0]?.len ?? 1e9) < eastLen;
+
+ // 4) evening west → only the west window; wedge stays inside its room r1
+ await setSun(270, 4);
+ out.eveningWest = JSON.stringify(litIds()) === '["wW"]';
+ const ray = c._sunRaysCache?.rays?.[0] || { polys: [] };
+ out.wedgeClippedToRoom = ray.polys.length > 0 && ray.polys.every((poly) => poly.every(([x, y]) =>
+ x >= 40 - 0.5 && x <= 550 + 0.5 && y >= 140 - 0.5 && y <= 580 + 0.5));
+
+ // 5) night → nothing
+ await setSun(90, -10);
+ out.nightEmpty = domPolys().length === 0;
+
+ // 6) rotated compass: the same morning east sun now lights the NORTH window
+ cfg().settings.north_deg = 90;
+ touchCfg();
+ await setSun(90, 5);
+ out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
+ cfg().settings.north_deg = 0;
+ touchCfg();
+
+ // 7) memoisation: an unrelated hass tick must NOT recompute the wedges
+ await setSun(90, 5);
+ const cacheBefore = c._sunRaysCache;
+ c.hass = { ...c.hass, states: { ...c.hass.states } }; // plain tick, sun unchanged
+ await upd();
+ out.memoSurvivesTick = c._sunRaysCache === cacheBefore;
+ await setSun(91, 5);
+ out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
+
+ // 8) day/night background: daynight breathes, static does not
+ await setSun(180, 60);
+ out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
+ cfg().settings.bg_mode = 'daynight';
+ touchCfg();
+ await setSun(180, 60);
+ out.daynightNoonBg = stageStyle().includes('background:#ffffff');
+ out.daynightClass = !!sr().querySelector('.stage.daynight');
+ await setSun(180, -20);
+ out.daynightNightBg = stageStyle().includes('background:#070c14');
+ out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
+ cfg().settings.bg_mode = 'static';
+ touchCfg();
+ await setSun(180, -20);
+ out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
+ delete cfg().settings.bg_mode;
+ touchCfg();
+
+ // 9) per-space inheritance: the space override wins over the global flag
+ sp.settings = { ...(sp.settings || {}), sun_rays: false };
+ touchCfg();
+ await setSun(90, 5);
+ out.spaceOverridesOff = domPolys().length === 0;
+ delete sp.settings.sun_rays;
+ // and a per-space compass override redirects the light
+ sp.settings.north_deg = 90;
+ touchCfg();
+ await setSun(90, 5);
+ out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
+ delete sp.settings.north_deg;
+ touchCfg();
+
+ // 10) clouds: rain kills the wedges, cloudy only fades them
+ cfg().settings.weather_entity = 'weather.home';
+ touchCfg();
+ await setWeather('pouring');
+ await setSun(90, 5);
+ out.rainKillsRays = domPolys().length === 0;
+ await setWeather('cloudy');
+ const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
+ || sr().querySelector('stop')?.getAttribute('stop-opacity'));
+ out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.18 * 0.4) < 1e-6;
+ await setWeather('sunny');
+ const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
+ out.clearFullAlpha = Math.abs(stopClear - 0.18) < 1e-6;
+ delete cfg().settings.weather_entity;
+ touchCfg();
+
+ // 11) feature off: without the flag nothing is drawn even with sun+north
+ delete cfg().settings.sun_rays;
+ touchCfg();
+ await setSun(90, 5);
+ out.offDrawsNothing = domPolys().length === 0;
+ cfg().settings.sun_rays = true;
+ cfg().settings.bg_mode = 'daynight';
+ touchCfg();
+
+ // 12) editors stay clean: no wedges, no day/night canvas
+ await setSun(90, 5);
+ c._setMode('plan');
+ await upd();
+ out.editorNoRays = domPolys().length === 0;
+ out.editorNoDaynight = !sr().querySelector('.stage.daynight');
+ c._setMode('view');
+ await upd();
+ out.viewRaysBack = domPolys().length > 0;
+
+ return out;
+});
+
+// 13) the general-settings dialog: a REAL drag on the compass dial sets degrees
+await page.evaluate(async () => {
+ const c = window.__card;
+ c._openSettingsDialog();
+ c.requestUpdate();
+ await c.updateComplete;
+ c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
+});
+const box = await page.evaluate(() => {
+ const r = window.__card.shadowRoot.querySelector('.compass').getBoundingClientRect();
+ return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
+});
+await page.mouse.move(box.cx + 45, box.cy); // on the ring, due right = 90°
+await page.mouse.down();
+await page.mouse.move(box.cx, box.cy + 45); // drag on to due down = 180°
+await page.mouse.up();
+const dlg = await page.evaluate(() => {
+ const c = window.__card;
+ const sr = c.shadowRoot;
+ const out = {
+ compassDragSets180: c._settingsDialog?.northDeg === 180,
+ dialogHasModeSelect: !!sr.querySelector('.dialog select.areasel'),
+ dialogHasRaysToggle: [...sr.querySelectorAll('.dialog .srcrow input[type=checkbox]')].length > 0,
+ dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
+ dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
+ };
+ c._settingsDialog = null;
+ return out;
+});
+Object.assign(res, dlg);
+
+// 14) DEV-B701-01 regression: the PRODUCTION local-save path (no touchCfg, no
+// fake rev/epoch bump from the test) must move the wedge. A REAL pointer drag
+// of the east window ends in _opPointerUp -> _saveConfig(); the WS write is
+// debounced 500 ms, so at check time _cfgRev is still the boot revision — the
+// ray must follow the window anyway, and again after the late server ack.
+const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
+const screenPt = (x, y) => page.evaluate(([x, y]) => {
+ const c = window.__card;
+ const stage = c.renderRoot.querySelector('.stage');
+ const r = stage.getBoundingClientRect();
+ const svgEl = stage.querySelector('svg');
+ const [vx, vy, vw, vh] = svgEl.getAttribute('viewBox').split(' ').map(Number);
+ return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
+}, [x, y]);
+const rayInfo = () => page.evaluate(() => {
+ const c = window.__card;
+ const rays = c._sunRaysCache?.rays || [];
+ const r = rays.find((q) => q.openingId === 'wE');
+ return {
+ ids: rays.map((q) => q.openingId).sort(),
+ midY: r ? (r.a[1] + r.b[1]) / 2 : NaN,
+ minX: r ? Math.min(...r.polys.flat().map((p) => p[0])) : NaN,
+ rev: c._cfgRev,
+ wEy: c._serverCfg.spaces.find((s) => s.id === 'f1').openings.find((o) => o.id === 'wE').y,
+ };
+});
+
+// baseline: morning east sun, only wE glows, window sits at y = 600
+await page.evaluate(async () => {
+ const c = window.__card;
+ c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
+ entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 90, elevation: 5 },
+ } } };
+ c.requestUpdate(); await c.updateComplete;
+});
+const base = await rayInfo();
+check('b701_baseline_east_only', base.ids, ['wE']);
+check('b701_baseline_mid_600', Math.abs(base.midY - 600) < 1, true);
+
+// the real drag: enter the opening editor and pull wE down the east wall
+await page.evaluate(async () => {
+ const c = window.__card;
+ c._setMode('plan'); c._tool = 'opening';
+ c.requestUpdate(); await c.updateComplete;
+});
+await settle();
+const [dx, dy] = await screenPt(960, 600);
+const [, dty] = await screenPt(960, 700);
+await page.mouse.move(dx, dy);
+await page.mouse.down();
+await page.mouse.move(dx, dty, { steps: 6 });
+await page.mouse.up();
+await settle();
+
+// leave the editor IMMEDIATELY — well inside the 500 ms debounce window
+const after = await page.evaluate(async () => {
+ const c = window.__card;
+ c._setMode('view');
+ c.requestUpdate(); await c.updateComplete;
+ return true;
+});
+check('b701_left_editor', after);
+const moved = await rayInfo();
+check('b701_window_really_moved', Math.abs(moved.wEy * 1000 - 700) < 15, true);
+check('b701_rev_still_boot', moved.rev, base.rev); // the write has NOT landed yet
+check('b701_ray_follows_window', Math.abs(moved.midY - moved.wEy * 1000) < 1, true);
+check('b701_ray_not_stale', Math.abs(moved.midY - 600) > 50, true);
+
+// same defect class for ROOM geometry: r3 shrinks to a thin strip along its
+// east wall through the real mutation endpoint (_saveConfig, still no ack).
+// The wedge reaches ~188 units into the room (minX ~772), so the new west
+// boundary at x = 900 MUST cut it — a stale clip keeps points near x = 772.
+const clipped = await page.evaluate(async () => {
+ const c = window.__card;
+ const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
+ sp.rooms.find((r) => r.id === 'r3').poly = [[0.90, 0.46], [0.96, 0.46], [0.96, 0.86], [0.90, 0.86]];
+ c._saveConfig(); // the one true local-save entry point
+ c.requestUpdate(); await c.updateComplete;
+ return true;
+});
+check('b701_room_mutated', clipped);
+const reclipped = await rayInfo();
+check('b701_room_rev_still_boot', reclipped.rev, base.rev);
+check('b701_room_reclips_ray', reclipped.minX >= 900 - 1, true);
+
+// let the debounced write land: the ray must survive the late server ack
+await page.waitForTimeout(800);
+const acked = await page.evaluate(async () => {
+ const c = window.__card;
+ c.requestUpdate(); await c.updateComplete;
+ return true;
+});
+check('b701_acked', acked);
+const final = await rayInfo();
+check('b701_write_landed', final.rev !== base.rev, true);
+check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true);
+
+await finish(browser, checkAll(res));
diff --git a/demo/smoke_temp_fill.mjs b/demo/smoke_temp_fill.mjs
index 106ef789..123d6391 100644
--- a/demo/smoke_temp_fill.mjs
+++ b/demo/smoke_temp_fill.mjs
@@ -32,7 +32,7 @@ checkAll(res, {
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
- "dialogTempFields": 3,
- "dialogHiddenWhenNone": 1,
+ "dialogTempFields": 4, // масштаб + мин/макс + компас пространства (docs/SUN.md)
+ "dialogHiddenWhenNone": 2, // мин/макс скрыты; масштаб и компас остаются
});
await finish(browser, res);
diff --git a/demo/srv/assets/houseplan-card.js b/demo/srv/assets/houseplan-card.js
index 5c5b37b9..f3ec9a37 100755
--- a/demo/srv/assets/houseplan-card.js
+++ b/demo/srv/assets/houseplan-card.js
@@ -1,10 +1,10 @@
-const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let o=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const n=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new o(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new o("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:p,getPrototypeOf:d}=Object,u=globalThis,_=u.trustedTypes,m=_?_.emptyScript:"",g=u.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},b=(t,e)=>!a(t,e),y={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:b};Symbol.metadata??=Symbol("metadata"),u.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=y){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:o}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const n=s?.call(this);o?.call(this,e),this.requestUpdate(t,n,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??y}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=d(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...p(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),o=t.litNonce;void 0!==o&&s.setAttribute("nonce",o),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const o=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==o?this.removeAttribute(s):this.setAttribute(s,o),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),o="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const n=o.fromAttribute(e,t.type);this[s]=n??this._$Ej?.get(s)??n,this._$Em=null}}requestUpdate(t,e,i,s=!1,o){if(void 0!==t){const n=this.constructor;if(!1===s&&(o=this[t]),i??=n.getPropertyOptions(t),!((i.hasChanged??b)(o,e)||i.useDefault&&i.reflect&&o===this._$Ej?.get(t)&&!this.hasAttribute(n._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:o},n){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,n??e??this[t]),!0!==o||void 0!==n)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(u.reactiveElementVersions??=[]).push("2.1.2");const x=globalThis,k=t=>t,$=x.trustedTypes,S=$?$.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",z=`lit$${Math.random().toFixed(9).slice(2)}$`,C="?"+z,D=`<${C}>`,T=document,P=()=>T.createComment(""),E=t=>null===t||"object"!=typeof t&&"function"!=typeof t,R=Array.isArray,A="[ \t\n\f\r]",O=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,I=/-->/g,N=/>/g,L=RegExp(`>|${A}(?:([^\\s"'>=/]+)(${A}*=${A}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),F=/'/g,q=/"/g,H=/^(?:script|style|textarea|title)$/i,U=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),B=U(1),j=U(2),W=Symbol.for("lit-noChange"),V=Symbol.for("lit-nothing"),G=new WeakMap,K=T.createTreeWalker(T,129);function Z(t,e){if(!R(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const J=(t,e)=>{const i=t.length-1,s=[];let o,n=2===e?"":3===e?"":"")),s]};class Y{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let o=0,n=0;const r=t.length-1,a=this.parts,[l,c]=J(t,e);if(this.el=Y.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length0){s.textContent=$?$.emptyScript:"";for(let i=0;iR(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==V&&E(this._$AH)?this._$AA.nextSibling.data=t:this.T(T.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Y.createElement(Z(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=G.get(t.strings);return void 0===e&&G.set(t.strings,e=new Y(t)),e}k(t){R(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const o of t)s===e.length?e.push(i=new tt(this.O(P()),this.O(P()),this,this.options)):i=e[s],i._$AI(o),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=V}_$AI(t,e=this,i,s){const o=this.strings;let n=!1;if(void 0===o)t=X(this,t,e,0),n=!E(t)||t!==this._$AH&&t!==W,n&&(this._$AH=t);else{const s=t;let r,a;for(t=o[0],r=0;r{const s=i?.renderBefore??e;let o=s._$litPart$;if(void 0===o){const t=i?.renderBefore??null;s._$litPart$=o=new tt(e.insertBefore(P(),t),t,void 0,i??{})}return o._$AI(t),o})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return W}}lt._$litElement$=!0,lt.finalized=!0,at.litElementHydrateSupport?.({LitElement:lt});const ct=at.litElementPolyfillSupport;ct?.({LitElement:lt}),(at.litElementVersions??=[]).push("4.2.2");const ht=new Set(["hacs","sun","backup","hassio","met","telegram_bot","mobile_app","systemmonitor","better_thermostat","adaptive_lighting","yandex_pogoda","upnp_serial_number"]),pt=[{pattern:"протечк|leak|water sensor",icon:"mdi:water-alert"},{pattern:"клапан|valve",icon:"mdi:pipe-valve"},{pattern:"дым|smoke",icon:"mdi:smoke-detector"},{pattern:"термоголов|trv|radiator",icon:"mdi:radiator"},{pattern:"чайник|kettle|термопот",icon:"mdi:kettle"},{pattern:"сауна|sauna|harvia|парная|парилк",icon:"mdi:hot-tub"},{pattern:"температ|temperature|climate sensor",icon:"mdi:thermometer"},{pattern:"qingping|air monitor|молекул|air quality",icon:"mdi:air-filter"},{pattern:"штор|curtain|blind|shade",icon:"mdi:roller-shade"},{pattern:"розетк|plug|socket|outlet",icon:"mdi:power-socket-de"},{pattern:"выключат|switch",icon:"mdi:light-switch"},{pattern:"лампа|лампочк|bulb|gx53|светильник|rgb|lamp|light strip",icon:"mdi:lightbulb"},{pattern:"камер|camera",icon:"mdi:cctv"},{pattern:"замок|ttlock|lock|sn609|sn9161",icon:"mdi:lock"},{pattern:"ворота|garage|gate",icon:"mdi:garage-variant"},{pattern:"калитк|door|открыт|contact",icon:"mdi:door"},{pattern:"счётчик|счетчик|kws|meter",icon:"mdi:meter-electric"},{pattern:"вводный автомат|breaker|wifimcbn",icon:"mdi:electric-switch"},{pattern:"myheat|котёл|котел|boiler|отоплен|heating",icon:"mdi:water-boiler"},{pattern:"холодильник|fridge",icon:"mdi:fridge"},{pattern:"стиральн|washer|washing",icon:"mdi:washing-machine"},{pattern:"сушилк|dryer",icon:"mdi:tumble-dryer"},{pattern:"пылесос|vacuum|dreame|roborock",icon:"mdi:robot-vacuum"},{pattern:"soundbar",icon:"mdi:soundbar"},{pattern:"колонк|станц|speaker|яндекс|yandex|алиса|alice",icon:"mdi:speaker"},{pattern:"tv|телевизор|hyundaitv|mitv|television",icon:"mdi:television"},{pattern:"keenetic|роутер|router|mesh|access point",icon:"mdi:router-wireless"},{pattern:"ибп|ups|kirpich",icon:"mdi:battery-charging-high"},{pattern:"slzb|координат|zigbee|coordinator",icon:"mdi:zigbee"},{pattern:"motion|движен|presence|присутств",icon:"mdi:motion-sensor"},{pattern:"humidity|влажн",icon:"mdi:water-percent"}];function dt(t){const e=[];for(const i of t)if(i&&"string"==typeof i.pattern&&i.icon)try{e.push({re:new RegExp(i.pattern,"i"),icon:i.icon})}catch{}return e}const ut=dt(pt),_t={temperature:"mdi:thermometer",humidity:"mdi:water-percent",motion:"mdi:motion-sensor",occupancy:"mdi:motion-sensor",door:"mdi:door",window:"mdi:window-closed",garage_door:"mdi:garage-variant",smoke:"mdi:smoke-detector",moisture:"mdi:water-alert",gas:"mdi:gas-cylinder",power:"mdi:meter-electric",energy:"mdi:meter-electric",illuminance:"mdi:brightness-5",co2:"mdi:molecule-co2",pm25:"mdi:air-filter",battery:"mdi:battery"},mt="mdi:chip";function gt(t,e,i){const s=((t||"")+" "+(e||"")).toLowerCase();for(const{re:t,icon:e}of i??ut)if(t.test(s))return e;return mt}const ft=["light","switch","cover","valve","lock","climate","fan","media_player","camera","vacuum","humidifier","water_heater","alarm_control_panel","sensor","binary_sensor","event","button","number","select","update"];var vt=/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i,bt=Math.ceil,yt=Math.floor,wt="[BigNumber Error] ",xt=wt+"Number primitive has more than 15 significant digits: ",kt=1e14,$t=14,St=9007199254740991,Mt=[1,10,100,1e3,1e4,1e5,1e6,1e7,1e8,1e9,1e10,1e11,1e12,1e13],zt=1e7,Ct=1e9;function Dt(t){var e=0|t;return t>0||t===e?e:e-1}function Tt(t){for(var e,i,s=1,o=t.length,n=t[0]+"";sc^i?1:-1;for(a=(l=o.length)<(c=n.length)?l:c,r=0;rn[r]^i?1:-1;return l==c?0:l>c^i?1:-1}function Et(t,e,i,s){if(ti||t!==yt(t))throw Error(wt+(s||"Argument")+("number"==typeof t?ti?" out of range: ":" not an integer: ":" not a primitive number: ")+String(t))}function Rt(t){var e=t.c.length-1;return Dt(t.e/$t)==e&&t.c[e]%2!=0}function At(t,e){return(t.length>1?t.charAt(0)+"."+t.slice(1):t)+(e<0?"e":"e+")+e}function Ot(t,e,i){var s,o;if(e<0){for(o=i+".";++e;o+=i);t=o+t}else if(++e>(s=t.length)){for(o=i,e-=s;--e;o+=i);t+=o}else eb?d.c=d.e=null:t.e=10;l/=10,a++);return void(a>b?d.c=d.e=null:(d.e=a,d.c=[t]))}p=String(t)}else{if(!vt.test(p=String(t)))return o(d,p,c);d.s=45==p.charCodeAt(0)?(p=p.slice(1),-1):1}(a=p.indexOf("."))>-1&&(p=p.replace(".","")),(l=p.search(/e/i))>0?(a<0&&(a=l),a+=+p.slice(l+1),p=p.substring(0,l)):a<0&&(a=p.length)}else{if(Et(e,2,$.length,"Base"),10==e&&S)return T(d=new M(t),_+d.e+1,m);if(p=String(t),c="number"==typeof t){if(0*t!=0)return o(d,p,c,e);if(d.s=1/t<0?(p=p.slice(1),-1):1,M.DEBUG&&p.replace(/^0\.0*|\./,"").length>15)throw Error(xt+t)}else d.s=45===p.charCodeAt(0)?(p=p.slice(1),-1):1;for(i=$.slice(0,e),a=l=0,h=p.length;la){a=h;continue}}else if(!r&&(p==p.toUpperCase()&&(p=p.toLowerCase())||p==p.toLowerCase()&&(p=p.toUpperCase()))){r=!0,l=-1,a=0;continue}return o(d,String(t),c,e)}c=!1,(a=(p=s(p,e,10,d.s)).indexOf("."))>-1?p=p.replace(".",""):a=p.length}for(l=0;48===p.charCodeAt(l);l++);for(h=p.length;48===p.charCodeAt(--h););if(p=p.slice(l,++h)){if(h-=l,c&&M.DEBUG&&h>15&&(t>St||t!==yt(t)))throw Error(xt+d.s*t);if((a=a-l-1)>b)d.c=d.e=null;else if(a=f)?At(l,r):Ot(l,r,"0");else if(n=(t=T(new M(t),e,i)).e,a=(l=Tt(t.c)).length,1==s||2==s&&(e<=n||n<=g)){for(;ar),l=Ot(l,n,"0"),n+1>a){if(--e>0)for(l+=".";e--;l+="0");}else if((e+=n-a)>0)for(n+1==a&&(l+=".");e--;l+="0");return t.s<0&&o?"-"+l:l}function C(t,e){for(var i,s,o=1,n=new M(t[0]);o=10;o/=10,s++);return(i=s+i*$t-1)>b?t.c=t.e=null:i=10;a/=10,o++);if((n=e-o)<0)n+=$t,r=e,l=p[c=0],h=yt(l/d[o-r-1]%10);else if((c=bt((n+1)/$t))>=p.length){if(!s)break t;for(;p.length<=c;p.push(0));l=h=0,o=1,r=(n%=$t)-$t+1}else{for(l=a=p[c],o=1;a>=10;a/=10,o++);h=(r=(n%=$t)-$t+o)<0?0:yt(l/d[o-r-1]%10)}if(s=s||e<0||null!=p[c+1]||(r<0?l:l%d[o-r-1]),s=i<4?(h||s)&&(0==i||i==(t.s<0?3:2)):h>5||5==h&&(4==i||s||6==i&&(n>0?r>0?l/d[o-r]:0:p[c-1])%10&1||i==(t.s<0?8:7)),e<1||!p[0])return p.length=0,s?(e-=t.e+1,p[0]=d[($t-e%$t)%$t],t.e=-e||0):p[0]=t.e=0,t;if(0==n?(p.length=c,a=1,c--):(p.length=c+1,a=d[$t-n],p[c]=r>0?yt(l/d[o-r]%d[r])*a:0),s)for(;;){if(0==c){for(n=1,r=p[0];r>=10;r/=10,n++);for(r=p[0]+=a,a=1;r>=10;r/=10,a++);n!=a&&(t.e++,p[0]==kt&&(p[0]=1));break}if(p[c]+=a,p[c]!=kt)break;p[c--]=0,a=1}for(n=p.length;0===p[--n];p.pop());}t.e>b?t.c=t.e=null:t.e=f?At(e,i):Ot(e,i,"0"),t.s<0?"-"+e:e)}return M.clone=t,M.ROUND_UP=0,M.ROUND_DOWN=1,M.ROUND_CEIL=2,M.ROUND_FLOOR=3,M.ROUND_HALF_UP=4,M.ROUND_HALF_DOWN=5,M.ROUND_HALF_EVEN=6,M.ROUND_HALF_CEIL=7,M.ROUND_HALF_FLOOR=8,M.EUCLID=9,M.config=M.set=function(t){var e,i;if(null!=t){if("object"!=typeof t)throw Error(wt+"Object expected: "+t);if(t.hasOwnProperty(e="DECIMAL_PLACES")&&(Et(i=t[e],0,Ct,e),_=i),t.hasOwnProperty(e="ROUNDING_MODE")&&(Et(i=t[e],0,8,e),m=i),t.hasOwnProperty(e="EXPONENTIAL_AT")&&((i=t[e])&&i.pop?(Et(i[0],-Ct,0,e),Et(i[1],0,Ct,e),g=i[0],f=i[1]):(Et(i,-Ct,Ct,e),g=-(f=i<0?-i:i))),t.hasOwnProperty(e="RANGE"))if((i=t[e])&&i.pop)Et(i[0],-Ct,-1,e),Et(i[1],1,Ct,e),v=i[0],b=i[1];else{if(Et(i,-Ct,Ct,e),!i)throw Error(wt+e+" cannot be zero: "+i);v=-(b=i<0?-i:i)}if(t.hasOwnProperty(e="CRYPTO")){if((i=t[e])!==!!i)throw Error(wt+e+" not true or false: "+i);if(i){if("undefined"==typeof crypto||!crypto||!crypto.getRandomValues&&!crypto.randomBytes)throw y=!i,Error(wt+"crypto unavailable");y=i}else y=i}if(t.hasOwnProperty(e="MODULO_MODE")&&(Et(i=t[e],0,9,e),w=i),t.hasOwnProperty(e="POW_PRECISION")&&(Et(i=t[e],0,Ct,e),x=i),t.hasOwnProperty(e="FORMAT")){if("object"!=typeof(i=t[e]))throw Error(wt+e+" not an object: "+i);k=i}if(t.hasOwnProperty(e="ALPHABET")){if("string"!=typeof(i=t[e])||/^.?$|[+\-.\s]|(.).*\1/.test(i))throw Error(wt+e+" invalid: "+i);S="0123456789"==i.slice(0,10),$=i}}return{DECIMAL_PLACES:_,ROUNDING_MODE:m,EXPONENTIAL_AT:[g,f],RANGE:[v,b],CRYPTO:y,MODULO_MODE:w,POW_PRECISION:x,FORMAT:k,ALPHABET:$}},M.isBigNumber=function(t){if(!t||!0!==t._isBigNumber)return!1;if(!M.DEBUG)return!0;var e,i,s=t.c,o=t.e,n=t.s;t:if("[object Array]"=={}.toString.call(s)){if((1===n||-1===n)&&o>=-Ct&&o<=Ct&&o===yt(o)){if(0===s[0]){if(0===o&&1===s.length)return!0;break t}if((e=(o+1)%$t)<1&&(e+=$t),String(s[0]).length==e){for(e=0;e=kt||i!==yt(i))break t;if(0!==i)return!0}}}else if(null===s&&null===o&&(null===n||1===n||-1===n))return!0;throw Error(wt+"Invalid BigNumber: "+t)},M.maximum=M.max=function(){return C(arguments,-1)},M.minimum=M.min=function(){return C(arguments,1)},M.random=(n=9007199254740992,r=Math.random()*n&2097151?function(){return yt(Math.random()*n)}:function(){return 8388608*(1073741824*Math.random()|0)+(8388608*Math.random()|0)},function(t){var e,i,s,o,n,a=0,l=[],c=new M(u);if(null==t?t=_:Et(t,0,Ct),o=bt(t/$t),y)if(crypto.getRandomValues){for(e=crypto.getRandomValues(new Uint32Array(o*=2));a>>11))>=9e15?(i=crypto.getRandomValues(new Uint32Array(2)),e[a]=i[0],e[a+1]=i[1]):(l.push(n%1e14),a+=2);a=o/2}else{if(!crypto.randomBytes)throw y=!1,Error(wt+"crypto unavailable");for(e=crypto.randomBytes(o*=7);a=9e15?crypto.randomBytes(7).copy(e,a):(l.push(n%1e14),a+=7);a=o/7}if(!y)for(;a=10;n/=10,a++);a<$t&&(s-=$t-a)}return c.e=s,c.c=l,c}),M.sum=function(){for(var t=1,e=arguments,i=new M(e[0]);ti-1&&(null==r[o+1]&&(r[o+1]=0),r[o+1]+=r[o]/i|0,r[o]%=i)}return r.reverse()}return function(s,o,n,r,a){var l,c,h,p,d,u,g,f,v=s.indexOf("."),b=_,y=m;for(v>=0&&(p=x,x=0,s=s.replace(".",""),u=(f=new M(o)).pow(s.length-v),x=p,f.c=e(Ot(Tt(u.c),u.e,"0"),10,n,t),f.e=f.c.length),h=p=(g=e(s,o,n,a?(l=$,t):(l=t,$))).length;0==g[--p];g.pop());if(!g[0])return l.charAt(0);if(v<0?--h:(u.c=g,u.e=h,u.s=r,g=(u=i(u,f,b,y,n)).c,d=u.r,h=u.e),v=g[c=h+b+1],p=n/2,d=d||c<0||null!=g[c+1],d=y<4?(null!=v||d)&&(0==y||y==(u.s<0?3:2)):v>p||v==p&&(4==y||d||6==y&&1&g[c-1]||y==(u.s<0?8:7)),c<1||!g[0])s=d?Ot(l.charAt(1),-b,l.charAt(0)):l.charAt(0);else{if(g.length=c,d)for(--n;++g[--c]>n;)g[c]=0,c||(++h,g=[1].concat(g));for(p=g.length;!g[--p];);for(v=0,s="";v<=p;s+=l.charAt(g[v++]));s=Ot(s,h,l.charAt(0))}return s}}(),i=function(){function t(t,e,i){var s,o,n,r,a=0,l=t.length,c=e%zt,h=e/zt|0;for(t=t.slice();l--;)a=((o=c*(n=t[l]%zt)+(s=h*n+(r=t[l]/zt|0)*c)%zt*zt+a)/i|0)+(s/zt|0)+h*r,t[l]=o%i;return a&&(t=[a].concat(t)),t}function e(t,e,i,s){var o,n;if(i!=s)n=i>s?1:-1;else for(o=n=0;oe[o]?1:-1;break}return n}function i(t,e,i,s){for(var o=0;i--;)t[i]-=o,o=t[i]1;t.splice(0,1));}return function(s,o,n,r,a){var l,c,h,p,d,u,_,m,g,f,v,b,y,w,x,k,$,S=s.s==o.s?1:-1,z=s.c,C=o.c;if(!(z&&z[0]&&C&&C[0]))return new M(s.s&&o.s&&(z?!C||z[0]!=C[0]:C)?z&&0==z[0]||!C?0*S:S/0:NaN);for(g=(m=new M(S)).c=[],S=n+(c=s.e-o.e)+1,a||(a=kt,c=Dt(s.e/$t)-Dt(o.e/$t),S=S/$t|0),h=0;C[h]==(z[h]||0);h++);if(C[h]>(z[h]||0)&&c--,S<0)g.push(1),p=!0;else{for(w=z.length,k=C.length,h=0,S+=2,(d=yt(a/(C[0]+1)))>1&&(C=t(C,d,a),z=t(z,d,a),k=C.length,w=z.length),y=k,v=(f=z.slice(0,k)).length;v=a/2&&x++;do{if(d=0,(l=e(C,f,k,v))<0){if(b=f[0],k!=v&&(b=b*a+(f[1]||0)),(d=yt(b/x))>1)for(d>=a&&(d=a-1),_=(u=t(C,d,a)).length,v=f.length;1==e(u,f,_,v);)d--,i(u,k<_?$:C,_,a),_=u.length,l=1;else 0==d&&(l=d=1),_=(u=C.slice()).length;if(_=10;S/=10,h++);T(m,n+(m.e=h+c*$t-1)+1,r,p)}else m.e=c,m.r=+p;return m}}(),a=/^(-?)0([xbo])(?=\w[\w.]*$)/i,l=/^([^.]+)\.$/,c=/^\.([^.]+)$/,h=/^-?(Infinity|NaN)$/,p=/^\s*\+(?=[\w.])|^\s+|\s+$/g,o=function(t,e,i,s){var o,n=i?e:e.replace(p,"");if(h.test(n))t.s=isNaN(n)?null:n<0?-1:1;else{if(!i&&(n=n.replace(a,function(t,e,i){return o="x"==(i=i.toLowerCase())?16:"b"==i?2:8,s&&s!=o?t:e}),s&&(o=s,n=n.replace(l,"$1").replace(c,"0.$1")),e!=n))return new M(n,o);if(M.DEBUG)throw Error(wt+"Not a"+(s?" base "+s:"")+" number: "+e);t.s=null}t.c=t.e=null},d.absoluteValue=d.abs=function(){var t=new M(this);return t.s<0&&(t.s=1),t},d.comparedTo=function(t,e){return Pt(this,new M(t,e))},d.decimalPlaces=d.dp=function(t,e){var i,s,o,n=this;if(null!=t)return Et(t,0,Ct),null==e?e=m:Et(e,0,8),T(new M(n),t+n.e+1,e);if(!(i=n.c))return null;if(s=((o=i.length-1)-Dt(this.e/$t))*$t,o=i[o])for(;o%10==0;o/=10,s--);return s<0&&(s=0),s},d.dividedBy=d.div=function(t,e){return i(this,new M(t,e),_,m)},d.dividedToIntegerBy=d.idiv=function(t,e){return i(this,new M(t,e),0,1)},d.exponentiatedBy=d.pow=function(t,e){var i,s,o,n,r,a,l,c,h=this;if((t=new M(t)).c&&!t.isInteger())throw Error(wt+"Exponent not an integer: "+P(t));if(null!=e&&(e=new M(e)),r=t.e>14,!h.c||!h.c[0]||1==h.c[0]&&!h.e&&1==h.c.length||!t.c||!t.c[0])return c=new M(Math.pow(+P(h),r?t.s*(2-Rt(t)):+P(t))),e?c.mod(e):c;if(a=t.s<0,e){if(e.c?!e.c[0]:!e.s)return new M(NaN);(s=!a&&h.isInteger()&&e.isInteger())&&(h=h.mod(e))}else{if(t.e>9&&(h.e>0||h.e<-1||(0==h.e?h.c[0]>1||r&&h.c[1]>=24e7:h.c[0]<8e13||r&&h.c[0]<=9999975e7)))return n=h.s<0&&Rt(t)?-0:0,h.e>-1&&(n=1/n),new M(a?1/n:n);x&&(n=bt(x/$t+2))}for(r?(i=new M(.5),a&&(t.s=1),l=Rt(t)):l=(o=Math.abs(+P(t)))%2,c=new M(u);;){if(l){if(!(c=c.times(h)).c)break;n?c.c.length>n&&(c.c.length=n):s&&(c=c.mod(e))}if(o){if(0===(o=yt(o/2)))break;l=o%2}else if(T(t=t.times(i),t.e+1,1),t.e>14)l=Rt(t);else{if(0===(o=+P(t)))break;l=o%2}h=h.times(h),n?h.c&&h.c.length>n&&(h.c.length=n):s&&(h=h.mod(e))}return s?c:(a&&(c=u.div(c)),e?c.mod(e):n?T(c,x,m,void 0):c)},d.integerValue=function(t){var e=new M(this);return null==t?t=m:Et(t,0,8),T(e,e.e+1,t)},d.isEqualTo=d.eq=function(t,e){return 0===Pt(this,new M(t,e))},d.isFinite=function(){return!!this.c},d.isGreaterThan=d.gt=function(t,e){return Pt(this,new M(t,e))>0},d.isGreaterThanOrEqualTo=d.gte=function(t,e){return 1===(e=Pt(this,new M(t,e)))||0===e},d.isInteger=function(){return!!this.c&&Dt(this.e/$t)>this.c.length-2},d.isLessThan=d.lt=function(t,e){return Pt(this,new M(t,e))<0},d.isLessThanOrEqualTo=d.lte=function(t,e){return-1===(e=Pt(this,new M(t,e)))||0===e},d.isNaN=function(){return!this.s},d.isNegative=function(){return this.s<0},d.isPositive=function(){return this.s>0},d.isZero=function(){return!!this.c&&0==this.c[0]},d.minus=function(t,e){var i,s,o,n,r=this,a=r.s;if(e=(t=new M(t,e)).s,!a||!e)return new M(NaN);if(a!=e)return t.s=-e,r.plus(t);var l=r.e/$t,c=t.e/$t,h=r.c,p=t.c;if(!l||!c){if(!h||!p)return h?(t.s=-e,t):new M(p?r:NaN);if(!h[0]||!p[0])return p[0]?(t.s=-e,t):new M(h[0]?r:3==m?-0:0)}if(l=Dt(l),c=Dt(c),h=h.slice(),a=l-c){for((n=a<0)?(a=-a,o=h):(c=l,o=p),o.reverse(),e=a;e--;o.push(0));o.reverse()}else for(s=(n=(a=h.length)<(e=p.length))?a:e,a=e=0;e0)for(;e--;h[i++]=0);for(e=kt-1;s>a;){if(h[--s]
`}_vacMapId(t,e){const i=this._vacEntity(t),s=i?this.hass?.states[i]?.attributes?.selected_map:null;return o=e.mapId,n=s,"default"!==o?o:null!=n?String(n):"default";var o,n}_vacSaveMatrix(t,e,i,s){const o=this._devices.find(e=>e.id===t),n=o?this._vacEnsureMarker(o):this._serverCfg?.markers?.find(e=>e.id===t);if(!n)return!1;const r={...n.vacuum||{}};return r.source=e,r.calibration={...r.calibration||{},[i]:s.map(t=>Number(t.toFixed(6)))},n.vacuum=r,this._regSignature="",this._saveConfig(),this.requestUpdate(),!0}_vacAutoCalibrate(t){const e=this._vacSource(t),i=e?ts(this.hass?.states[e]?.attributes):null;if(!e||!i||i.rooms.length<3)return void this._showToast(this._t("vac.autocal_no_rooms"));const s=this._spaceModel(t.space),o=(s?.rooms||[]).map(t=>({r:t,poly:we(t)})).filter(({r:t,poly:e})=>t.name&&e).map(({r:t,poly:e})=>{const i=Re(e);return{name:t.name,cx:i[0],cy:i[1]}}),n=ss(i.rooms,o);n?this._vacSaveMatrix(t.id,e,this._vacMapId(t,i),n.matrix)&&(n.residual>50&&this._showToast(ei(this._t("vac.autocal_res_warn"),{rooms:String(n.matched.length)})),this._showToast(ei(this._t("vac.autocal_done"),{rooms:String(n.matched.length)}))):this._showToast(this._t("vac.autocal_no_match"))}_vacStartFit(t){const e=this._vacSource(t),i=e?ts(this.hass?.states[e]?.attributes):null;if(!e||!i)return void this._showToast(this._t("vac.cal_need_pos"));const s=this._vacMapId(t,i),o=t.marker?.vacuum?.calibration?.[s],n=this._spaceModel(t.space),r=n?.vb||[0,0,Qs,Qs],a=o&&6===o.length&&function(t){const e=t[0]*t[4]-t[1]*t[3];if(!Number.isFinite(e)||Math.abs(e)<1e-12)return null;const i=e<0,s=Math.sqrt(Math.abs(e));let o=180*Math.atan2(-t[1],t[4])/Math.PI;return o=(90*Math.round(o/90)%360+360)%360,{ox:t[2],oy:t[5],s:s,rot:o,mir:i}}(o)||function(t,e){const i=[],s=[];for(const e of t)null!=e.x0?(i.push(e.x0,e.x1),s.push(e.y0,e.y1)):(i.push(e.cx),s.push(e.cy));if(!i.length)return{ox:e[0]+e[2]/2,oy:e[1]+e[3]/2,s:e[2]/1e4,rot:0,mir:!0};const o=Math.min(...i),n=Math.max(...i),r=Math.min(...s),a=Math.max(...s),l=Math.max(n-o,a-r)||1,c={ox:0,oy:0,s:.6*Math.min(e[2],e[3])/l,rot:0,mir:!0},h=as(c),[p,d]=Yi(h,(o+n)/2,(r+a)/2);return c.ox=e[0]+e[2]/2-p,c.oy=e[1]+e[3]/2-d,c}(i.rooms,r);this._markerDialog=null,t.space!==this._space&&(this._space=t.space),this._vacFit={markerId:t.id,source:e,mapId:s,p:a,drag:null}}_vacFitSave(){const t=this._vacFit;if(!t)return;const e=this._vacSaveMatrix(t.markerId,t.source,t.mapId,as(t.p));this._vacFit=null,e&&this._showToast(this._t("vac.cal_done"))}_vacFitTurn(t){const e=this._vacFit;if(!e)return;const i=ts(this.hass?.states[e.source]?.attributes),s=this._vacGhostCentre(i?.rooms||[]),o={...e.p,...t};this._vacFit={...e,p:ls(o,e.p,s[0],s[1])}}_vacGhostCentre(t){const e=[],i=[];for(const s of t)e.push(s.x0??s.cx,s.x1??s.cx),i.push(s.y0??s.cy,s.y1??s.cy);return e.length?[(Math.min(...e)+Math.max(...e))/2,(Math.min(...i)+Math.max(...i))/2]:[0,0]}_vacDelta(t,e,i){const s=this._stageEl,o=s?.clientWidth||1,n=s?.clientHeight||1;return[e/o*t.w,i/n*t.h]}_vacFitPointer(t,e){const i=this._vacFit;if(!i)return;if(t.stopPropagation(),"pointerdown"===t.type){const e=t.target,s=e.getAttribute?.("data-corner");try{t.currentTarget.setPointerCapture?.(t.pointerId)}catch{}return void(this._vacFit={...i,drag:s?{kind:"scale",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:Number(s.split(",")[0]),fy:Number(s.split(",")[1])}:{kind:"move",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:0,fy:0}})}const s=i.drag;if(s){if("pointermove"===t.type){const[o,n]=this._vacDelta(e,t.clientX-s.sx,t.clientY-s.sy);if("move"===s.kind)this._vacFit={...i,p:{...s.p0,ox:s.p0.ox+o,oy:s.p0.oy+n}};else{const t=ts(this.hass?.states[i.source]?.attributes),e=this._vacGhostCentre(t?.rooms||[]),r=as(s.p0),[a,l]=Yi(r,e[0],e[1]),[c,h]=Yi(r,s.fx,s.fy),p=Math.hypot(a-c,l-h)||1,[d,u]=[2*a-c,2*l-h],_=Math.hypot(d+2*o-c,u+2*n-h)/2,m=Math.max(.05,_/p),g={...s.p0,s:s.p0.s*m};this._vacFit={...i,p:ls(g,s.p0,s.fx,s.fy)}}return}"pointerup"!==t.type&&"pointercancel"!==t.type||(this._vacFit={...i,drag:null})}}_renderVacFit(t){const e=this._vacFit;if(!e)return V;const i=ts(this.hass?.states[e.source]?.attributes);if(!i)return V;const s=as(e.p),o=[],n=[],r=[];for(const t of i.rooms){if(null==t.x0)continue;const e=[[t.x0,t.y0],[t.x1,t.y0],[t.x1,t.y1],[t.x0,t.y1]].map(([t,e])=>Yi(s,t,e));e.forEach(([t,e])=>{n.push(t),r.push(e)});const[i,a]=Yi(s,t.cx,t.cy);o.push(j`
- ${t.name}`)}let a=V;if(i.pos){const[e,o]=Yi(s,i.pos.x,i.pos.y);a=j``}const l=[];if(n.length){const e=(()=>{const t=s[0]*s[4]-s[1]*s[3];return(e,i)=>[(s[4]*(e-s[2])-s[1]*(i-s[5]))/t,(-s[3]*(e-s[2])+s[0]*(i-s[5]))/t]})(),i=Math.min(...n),o=Math.max(...n),a=Math.min(...r),c=Math.max(...r),h=.022*t.w;for(const[t,s,n,r]of[[i,a,o,c],[o,a,i,c],[o,c,i,a],[i,c,o,a]]){const i=e(n,r);l.push(j``}_vacMapId(t,e){const i=this._vacEntity(t),s=i?this.hass?.states[i]?.attributes?.selected_map:null;return o=e.mapId,n=s,"default"!==o?o:null!=n?String(n):"default";var o,n}_vacSaveMatrix(t,e,i,s){const o=this._devices.find(e=>e.id===t),n=o?this._vacEnsureMarker(o):this._serverCfg?.markers?.find(e=>e.id===t);if(!n)return!1;const r={...n.vacuum||{}};return r.source=e,r.calibration={...r.calibration||{},[i]:s.map(t=>Number(t.toFixed(6)))},n.vacuum=r,this._regSignature="",this._saveConfig(),this.requestUpdate(),!0}_vacAutoCalibrate(t){const e=this._vacSource(t),i=e?bs(this.hass?.states[e]?.attributes):null;if(!e||!i||i.rooms.length<3)return void this._showToast(this._t("vac.autocal_no_rooms"));const s=this._spaceModel(t.space),o=(s?.rooms||[]).map(t=>({r:t,poly:ke(t)})).filter(({r:t,poly:e})=>t.name&&e).map(({r:t,poly:e})=>{const i=Ne(e);return{name:t.name,cx:i[0],cy:i[1]}}),n=$s(i.rooms,o);n?this._vacSaveMatrix(t.id,e,this._vacMapId(t,i),n.matrix)&&(n.residual>50&&this._showToast(ni(this._t("vac.autocal_res_warn"),{rooms:String(n.matched.length)})),this._showToast(ni(this._t("vac.autocal_done"),{rooms:String(n.matched.length)}))):this._showToast(this._t("vac.autocal_no_match"))}_vacStartFit(t){const e=this._vacSource(t),i=e?bs(this.hass?.states[e]?.attributes):null;if(!e||!i)return void this._showToast(this._t("vac.cal_need_pos"));const s=this._vacMapId(t,i),o=t.marker?.vacuum?.calibration?.[s],n=this._spaceModel(t.space),r=n?.vb||[0,0,yo,yo],a=o&&6===o.length&&function(t){const e=t[0]*t[4]-t[1]*t[3];if(!Number.isFinite(e)||Math.abs(e)<1e-12)return null;const i=e<0,s=Math.sqrt(Math.abs(e));let o=180*Math.atan2(-t[1],t[4])/Math.PI;return o=(90*Math.round(o/90)%360+360)%360,{ox:t[2],oy:t[5],s:s,rot:o,mir:i}}(o)||function(t,e){const i=[],s=[];for(const e of t)null!=e.x0?(i.push(e.x0,e.x1),s.push(e.y0,e.y1)):(i.push(e.cx),s.push(e.cy));if(!i.length)return{ox:e[0]+e[2]/2,oy:e[1]+e[3]/2,s:e[2]/1e4,rot:0,mir:!0};const o=Math.min(...i),n=Math.max(...i),r=Math.min(...s),a=Math.max(...s),l=Math.max(n-o,a-r)||1,c={ox:0,oy:0,s:.6*Math.min(e[2],e[3])/l,rot:0,mir:!0},h=Ms(c),[d,p]=ms(h,(o+n)/2,(r+a)/2);return c.ox=e[0]+e[2]/2-d,c.oy=e[1]+e[3]/2-p,c}(i.rooms,r);this._markerDialog=null,t.space!==this._space&&(this._space=t.space),this._vacFit={markerId:t.id,source:e,mapId:s,p:a,drag:null}}_vacFitSave(){const t=this._vacFit;if(!t)return;const e=this._vacSaveMatrix(t.markerId,t.source,t.mapId,Ms(t.p));this._vacFit=null,e&&this._showToast(this._t("vac.cal_done"))}_vacFitTurn(t){const e=this._vacFit;if(!e)return;const i=bs(this.hass?.states[e.source]?.attributes),s=this._vacGhostCentre(i?.rooms||[]),o={...e.p,...t};this._vacFit={...e,p:Cs(o,e.p,s[0],s[1])}}_vacGhostCentre(t){const e=[],i=[];for(const s of t)e.push(s.x0??s.cx,s.x1??s.cx),i.push(s.y0??s.cy,s.y1??s.cy);return e.length?[(Math.min(...e)+Math.max(...e))/2,(Math.min(...i)+Math.max(...i))/2]:[0,0]}_vacDelta(t,e,i){const s=this._stageEl,o=s?.clientWidth||1,n=s?.clientHeight||1;return[e/o*t.w,i/n*t.h]}_vacFitPointer(t,e){const i=this._vacFit;if(!i)return;if(t.stopPropagation(),"pointerdown"===t.type){const e=t.target,s=e.getAttribute?.("data-corner");try{t.currentTarget.setPointerCapture?.(t.pointerId)}catch{}return void(this._vacFit={...i,drag:s?{kind:"scale",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:Number(s.split(",")[0]),fy:Number(s.split(",")[1])}:{kind:"move",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:0,fy:0}})}const s=i.drag;if(s){if("pointermove"===t.type){const[o,n]=this._vacDelta(e,t.clientX-s.sx,t.clientY-s.sy);if("move"===s.kind)this._vacFit={...i,p:{...s.p0,ox:s.p0.ox+o,oy:s.p0.oy+n}};else{const t=bs(this.hass?.states[i.source]?.attributes),e=this._vacGhostCentre(t?.rooms||[]),r=Ms(s.p0),[a,l]=ms(r,e[0],e[1]),[c,h]=ms(r,s.fx,s.fy),d=Math.hypot(a-c,l-h)||1,[p,u]=[2*a-c,2*l-h],_=Math.hypot(p+2*o-c,u+2*n-h)/2,g=Math.max(.05,_/d),m={...s.p0,s:s.p0.s*g};this._vacFit={...i,p:Cs(m,s.p0,s.fx,s.fy)}}return}"pointerup"!==t.type&&"pointercancel"!==t.type||(this._vacFit={...i,drag:null})}}_renderVacFit(t){const e=this._vacFit;if(!e)return V;const i=bs(this.hass?.states[e.source]?.attributes);if(!i)return V;const s=Ms(e.p),o=[],n=[],r=[];for(const t of i.rooms){if(null==t.x0)continue;const e=[[t.x0,t.y0],[t.x1,t.y0],[t.x1,t.y1],[t.x0,t.y1]].map(([t,e])=>ms(s,t,e));e.forEach(([t,e])=>{n.push(t),r.push(e)});const[i,a]=ms(s,t.cx,t.cy);o.push(j`
+ ${t.name}`)}let a=V;if(i.pos){const[e,o]=ms(s,i.pos.x,i.pos.y);a=j``}const l=[];if(n.length){const e=(()=>{const t=s[0]*s[4]-s[1]*s[3];return(e,i)=>[(s[4]*(e-s[2])-s[1]*(i-s[5]))/t,(-s[3]*(e-s[2])+s[0]*(i-s[5]))/t]})(),i=Math.min(...n),o=Math.max(...n),a=Math.min(...r),c=Math.max(...r),h=.022*t.w;for(const[t,s,n,r]of[[i,a,o,c],[o,a,i,c],[o,c,i,a],[i,c,o,a]]){const i=e(n,r);l.push(j``)}}return B``}_vacRafLoop(){this._vacRaf=requestAnimationFrame(()=>{const t=this.renderRoot,e=this._stageEl,i=this._vacLastView,s=t?.querySelectorAll?.(".vacpuck")||[];if(!e||!i||!s.length)return void(this._vacRaf=0);const o=e.getBoundingClientRect();for(const e of s){const s=e.getAttribute("data-mid"),n=e.getBoundingClientRect(),r=i.x+(n.left+n.width/2-o.left)/o.width*i.w,a=i.y+(n.top+n.height/2-o.top)/o.height*i.h;for(const e of t.querySelectorAll(`line.tip[data-mid="${s}"]`))e.setAttribute("x2",r.toFixed(1)),e.setAttribute("y2",a.toFixed(1))}this._vacRafLoop()})}_renderVacuums(t,e){if(this._markup||"decor"===this._mode)return V;const i=this._space+"|"+e.x+"|"+e.y+"|"+e.w+"|"+e.h,s=this._vacJumpOnce||i!==this._vacViewKey;this._vacViewKey=i,this._vacJumpOnce=!1;const o=[],n=[];for(const i of t){if(i.hidden||!this._isVacDev(i))continue;const t=this._vacSource(i);if(!t)continue;const r=ts(this.hass?.states[t]?.attributes);if(!r)continue;const a=i.marker?.vacuum?.calibration?.[this._vacMapId(i,r)];if(!a||6!==a.length)continue;const l=this._vacRt.get(i.id),c=l?.moving??!1,h=cs(i.marker?.vacuum),p="always"===h||"cleaning"===h&&c,d=this._vacSrvTrails[i.id],u=this._vacMapId(i,r),_=d?.current?.map_id===u&&Array.isArray(d.current.points)?d.current:null,m=d?.previous?.map_id===u&&Array.isArray(d.previous.points)?d.previous:null;if("always"===h&&m&&m.points.length>1){const t=m.points.map(([t,e])=>{const[i,s]=Yi(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");n.push(j``)}if(p&&(c||_)){const t=_?.points||r.path||l?.trail||[],e=c&&(_||r.path)&&t.length>1?t.slice(0,-1):t;if(e.length>1){const t=e.map(([t,e])=>{const[i,s]=Yi(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");if(n.push(j``),c){const[t,s]=Yi(a,e[e.length-1][0],e[e.length-1][1]),o=t.toFixed(1),r=s.toFixed(1);n.push(j``)}}}if(!c||!r.pos)continue;const[g,f]=Yi(a,r.pos.x,r.pos.y),v=(g-e.x)/e.w*100,b=(f-e.y)/e.h*100,y=l&&l.lastTs>0&&Date.now()-l.lastTs>6e4,w=i.marker?.icon||i.icon||"mdi:robot-vacuum";o.push(B`
this._vacFitPointer(e,t)}>${o}${a}${l}`}_vacRafLoop(){this._vacRaf=requestAnimationFrame(()=>{const t=this.renderRoot,e=this._stageEl,i=this._vacLastView,s=t?.querySelectorAll?.(".vacpuck")||[];if(!e||!i||!s.length)return void(this._vacRaf=0);const o=e.getBoundingClientRect();for(const e of s){const s=e.getAttribute("data-mid"),n=e.getBoundingClientRect(),r=i.x+(n.left+n.width/2-o.left)/o.width*i.w,a=i.y+(n.top+n.height/2-o.top)/o.height*i.h;for(const e of t.querySelectorAll(`line.tip[data-mid="${s}"]`))e.setAttribute("x2",r.toFixed(1)),e.setAttribute("y2",a.toFixed(1))}this._vacRafLoop()})}_renderVacuums(t,e){if(this._markup||"decor"===this._mode)return V;const i=this._space+"|"+e.x+"|"+e.y+"|"+e.w+"|"+e.h,s=this._vacJumpOnce||i!==this._vacViewKey;this._vacViewKey=i,this._vacJumpOnce=!1;const o=[],n=[];for(const i of t){if(i.hidden||!this._isVacDev(i))continue;const t=this._vacSource(i);if(!t)continue;const r=bs(this.hass?.states[t]?.attributes);if(!r)continue;const a=i.marker?.vacuum?.calibration?.[this._vacMapId(i,r)];if(!a||6!==a.length)continue;const l=this._vacRt.get(i.id),c=l?.moving??!1,h=zs(i.marker?.vacuum),d="always"===h||"cleaning"===h&&c,p=this._vacSrvTrails[i.id],u=this._vacMapId(i,r),_=p?.current?.map_id===u&&Array.isArray(p.current.points)?p.current:null,g=p?.previous?.map_id===u&&Array.isArray(p.previous.points)?p.previous:null;if("always"===h&&g&&g.points.length>1){const t=g.points.map(([t,e])=>{const[i,s]=ms(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");n.push(j``)}if(d&&(c||_)){const t=_?.points||r.path||l?.trail||[],e=c&&(_||r.path)&&t.length>1?t.slice(0,-1):t;if(e.length>1){const t=e.map(([t,e])=>{const[i,s]=ms(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");if(n.push(j``),c){const[t,s]=ms(a,e[e.length-1][0],e[e.length-1][1]),o=t.toFixed(1),r=s.toFixed(1);n.push(j``)}}}if(!c||!r.pos)continue;const[m,f]=ms(a,r.pos.x,r.pos.y),v=(m-e.x)/e.w*100,b=(f-e.y)/e.h*100,y=l&&l.lastTs>0&&Date.now()-l.lastTs>6e4,w=i.marker?.icon||i.icon||"mdi:robot-vacuum";o.push(B`
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=Qs,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=Qs,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
+ ${this._fmtLen(e,i)} · ${r}°
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=yo,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=ke(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=yo,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=ke(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
${i.map(e=>{const[i,n,r,a]="x"===e.axis?[e.at,e.from[1],e.at,t[1]+Math.sign(t[1]-e.from[1])*o]:[e.from[0],e.at,t[0]+Math.sign(t[0]-e.from[0])*o,e.at];return j``})}
- `}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_openingAmt(t){const e=t.contact?this.hass.states[t.contact]?.state:null;return function(t,e,i=!1){return null==e||"unavailable"===e||"unknown"===e?"door"===t?1:0:$e(e)!==!!i?1:0}(t.type,e,!!t.invert)}_renderOpenings(t){const e=this._openingsR;if(!e.length)return j``;const i=t.color;return j`${e.map(t=>{const e=t.rlen/2,s=this._openingAmt(t),o=s>0&&!!t.contact?"var(--hp-open)":i,n=t.flip_h?-1:1,r=t.flip_v?-1:1;let a;if("window"===t.type){const t=Math.PI/2*e;a=j`
+ `}_renderOpeningCenterTick(){const t=this._opMeasure.guide,e=(t.angle+90)*Math.PI/180;return j``}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_openingAmt(t){const e=t.contact?this.hass.states[t.contact]?.state:null;return function(t,e,i=!1){return null==e||"unavailable"===e||"unknown"===e?"door"===t?1:0:ze(e)!==!!i?1:0}(t.type,e,!!t.invert)}_renderOpenings(t){const e=this._openingsR;if(!e.length)return j``;const i=t.color;return j`${e.map(t=>{const e=t.rlen/2,s=this._openingAmt(t),o=s>0&&!!t.contact?"var(--hp-open)":i,n=t.flip_h?-1:1,r=t.flip_v?-1:1;let a;if("window"===t.type){const t=Math.PI/2*e;a=j`
${this._t("opening.contact_label")}
${e(this._contactCandidates(),t.contact,e=>this._openingDialog={...t,contact:e})}
- ${t.contact?B`this._openingDialog={...t,invert:e.target.checked}} />
+ ${t.contact?B`${this._boolInput(t.invert,e=>this._openingDialog={...t,invert:e})}
${this._t("opening.invert")}`:V}
${"door"===t.type?B`${this._t("opening.lock_label")}
${e(this._lockCandidates(),t.lock,e=>this._openingDialog={...t,lock:e})}`:V}
- this._openingDialog={...t,flipH:e.target.checked}} />
+ ${this._boolInput(t.flipH,e=>this._openingDialog={...t,flipH:e})}
${this._t("opening.flip_h")}
- this._openingDialog={...t,flipV:e.target.checked}} />
+ ${this._boolInput(t.flipV,e=>this._openingDialog={...t,flipV:e})}
${this._t("opening.flip_v")}
`}_cardEntities(t){const e=this.hass,i=[],s=new Set,o=t=>{if(!t||s.has(t)||!e.states[t])return;const o=e.entities[t];if("config"===o?.entity_category||"diagnostic"===o?.entity_category)return;s.add(t);const n=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(n)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(n)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(n)&&i.push({eid:t,kind:"value"})};for(const e of t.marker?.controls||[])o(e);t.primary&&o(t.primary);for(const e of t.entities)o(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null,s=(t.marker?.controls||[]).filter(gi);return B`
this._infoCard=null}>
+
`}_cardEntities(t){const e=this.hass,i=[],s=new Set,o=t=>{if(!t||s.has(t)||!e.states[t])return;const o=e.entities[t];if("config"===o?.entity_category||"diagnostic"===o?.entity_category)return;s.add(t);const n=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(n)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(n)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(n)&&i.push({eid:t,kind:"value"})};for(const e of t.marker?.controls||[])o(e);t.primary&&o(t.primary);for(const e of t.entities)o(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null,s=(t.marker?.controls||[]).filter(yi);return B`
`}}so.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},so.ZOOM_MAX=8,so.ZOOM_MIN=.4,so._touchSeen=!1,so._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,so.styles=Ps,customElements.get("houseplan-card")||customElements.define("houseplan-card",so),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.55.3 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+
`}_vacMapId(t,e){const i=this._vacEntity(t),s=i?this.hass?.states[i]?.attributes?.selected_map:null;return o=e.mapId,n=s,"default"!==o?o:null!=n?String(n):"default";var o,n}_vacSaveMatrix(t,e,i,s){const o=this._devices.find(e=>e.id===t),n=o?this._vacEnsureMarker(o):this._serverCfg?.markers?.find(e=>e.id===t);if(!n)return!1;const r={...n.vacuum||{}};return r.source=e,r.calibration={...r.calibration||{},[i]:s.map(t=>Number(t.toFixed(6)))},n.vacuum=r,this._regSignature="",this._saveConfig(),this.requestUpdate(),!0}_vacAutoCalibrate(t){const e=this._vacSource(t),i=e?ts(this.hass?.states[e]?.attributes):null;if(!e||!i||i.rooms.length<3)return void this._showToast(this._t("vac.autocal_no_rooms"));const s=this._spaceModel(t.space),o=(s?.rooms||[]).map(t=>({r:t,poly:we(t)})).filter(({r:t,poly:e})=>t.name&&e).map(({r:t,poly:e})=>{const i=Re(e);return{name:t.name,cx:i[0],cy:i[1]}}),n=ss(i.rooms,o);n?this._vacSaveMatrix(t.id,e,this._vacMapId(t,i),n.matrix)&&(n.residual>50&&this._showToast(ei(this._t("vac.autocal_res_warn"),{rooms:String(n.matched.length)})),this._showToast(ei(this._t("vac.autocal_done"),{rooms:String(n.matched.length)}))):this._showToast(this._t("vac.autocal_no_match"))}_vacStartFit(t){const e=this._vacSource(t),i=e?ts(this.hass?.states[e]?.attributes):null;if(!e||!i)return void this._showToast(this._t("vac.cal_need_pos"));const s=this._vacMapId(t,i),o=t.marker?.vacuum?.calibration?.[s],n=this._spaceModel(t.space),r=n?.vb||[0,0,Qs,Qs],a=o&&6===o.length&&function(t){const e=t[0]*t[4]-t[1]*t[3];if(!Number.isFinite(e)||Math.abs(e)<1e-12)return null;const i=e<0,s=Math.sqrt(Math.abs(e));let o=180*Math.atan2(-t[1],t[4])/Math.PI;return o=(90*Math.round(o/90)%360+360)%360,{ox:t[2],oy:t[5],s:s,rot:o,mir:i}}(o)||function(t,e){const i=[],s=[];for(const e of t)null!=e.x0?(i.push(e.x0,e.x1),s.push(e.y0,e.y1)):(i.push(e.cx),s.push(e.cy));if(!i.length)return{ox:e[0]+e[2]/2,oy:e[1]+e[3]/2,s:e[2]/1e4,rot:0,mir:!0};const o=Math.min(...i),n=Math.max(...i),r=Math.min(...s),a=Math.max(...s),l=Math.max(n-o,a-r)||1,c={ox:0,oy:0,s:.6*Math.min(e[2],e[3])/l,rot:0,mir:!0},h=as(c),[p,d]=Yi(h,(o+n)/2,(r+a)/2);return c.ox=e[0]+e[2]/2-p,c.oy=e[1]+e[3]/2-d,c}(i.rooms,r);this._markerDialog=null,t.space!==this._space&&(this._space=t.space),this._vacFit={markerId:t.id,source:e,mapId:s,p:a,drag:null}}_vacFitSave(){const t=this._vacFit;if(!t)return;const e=this._vacSaveMatrix(t.markerId,t.source,t.mapId,as(t.p));this._vacFit=null,e&&this._showToast(this._t("vac.cal_done"))}_vacFitTurn(t){const e=this._vacFit;if(!e)return;const i=ts(this.hass?.states[e.source]?.attributes),s=this._vacGhostCentre(i?.rooms||[]),o={...e.p,...t};this._vacFit={...e,p:ls(o,e.p,s[0],s[1])}}_vacGhostCentre(t){const e=[],i=[];for(const s of t)e.push(s.x0??s.cx,s.x1??s.cx),i.push(s.y0??s.cy,s.y1??s.cy);return e.length?[(Math.min(...e)+Math.max(...e))/2,(Math.min(...i)+Math.max(...i))/2]:[0,0]}_vacDelta(t,e,i){const s=this._stageEl,o=s?.clientWidth||1,n=s?.clientHeight||1;return[e/o*t.w,i/n*t.h]}_vacFitPointer(t,e){const i=this._vacFit;if(!i)return;if(t.stopPropagation(),"pointerdown"===t.type){const e=t.target,s=e.getAttribute?.("data-corner");try{t.currentTarget.setPointerCapture?.(t.pointerId)}catch{}return void(this._vacFit={...i,drag:s?{kind:"scale",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:Number(s.split(",")[0]),fy:Number(s.split(",")[1])}:{kind:"move",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:0,fy:0}})}const s=i.drag;if(s){if("pointermove"===t.type){const[o,n]=this._vacDelta(e,t.clientX-s.sx,t.clientY-s.sy);if("move"===s.kind)this._vacFit={...i,p:{...s.p0,ox:s.p0.ox+o,oy:s.p0.oy+n}};else{const t=ts(this.hass?.states[i.source]?.attributes),e=this._vacGhostCentre(t?.rooms||[]),r=as(s.p0),[a,l]=Yi(r,e[0],e[1]),[c,h]=Yi(r,s.fx,s.fy),p=Math.hypot(a-c,l-h)||1,[d,u]=[2*a-c,2*l-h],_=Math.hypot(d+2*o-c,u+2*n-h)/2,m=Math.max(.05,_/p),g={...s.p0,s:s.p0.s*m};this._vacFit={...i,p:ls(g,s.p0,s.fx,s.fy)}}return}"pointerup"!==t.type&&"pointercancel"!==t.type||(this._vacFit={...i,drag:null})}}_renderVacFit(t){const e=this._vacFit;if(!e)return V;const i=ts(this.hass?.states[e.source]?.attributes);if(!i)return V;const s=as(e.p),o=[],n=[],r=[];for(const t of i.rooms){if(null==t.x0)continue;const e=[[t.x0,t.y0],[t.x1,t.y0],[t.x1,t.y1],[t.x0,t.y1]].map(([t,e])=>Yi(s,t,e));e.forEach(([t,e])=>{n.push(t),r.push(e)});const[i,a]=Yi(s,t.cx,t.cy);o.push(j`
- ${t.name}`)}let a=V;if(i.pos){const[e,o]=Yi(s,i.pos.x,i.pos.y);a=j``}const l=[];if(n.length){const e=(()=>{const t=s[0]*s[4]-s[1]*s[3];return(e,i)=>[(s[4]*(e-s[2])-s[1]*(i-s[5]))/t,(-s[3]*(e-s[2])+s[0]*(i-s[5]))/t]})(),i=Math.min(...n),o=Math.max(...n),a=Math.min(...r),c=Math.max(...r),h=.022*t.w;for(const[t,s,n,r]of[[i,a,o,c],[o,a,i,c],[o,c,i,a],[i,c,o,a]]){const i=e(n,r);l.push(j``}_vacMapId(t,e){const i=this._vacEntity(t),s=i?this.hass?.states[i]?.attributes?.selected_map:null;return o=e.mapId,n=s,"default"!==o?o:null!=n?String(n):"default";var o,n}_vacSaveMatrix(t,e,i,s){const o=this._devices.find(e=>e.id===t),n=o?this._vacEnsureMarker(o):this._serverCfg?.markers?.find(e=>e.id===t);if(!n)return!1;const r={...n.vacuum||{}};return r.source=e,r.calibration={...r.calibration||{},[i]:s.map(t=>Number(t.toFixed(6)))},n.vacuum=r,this._regSignature="",this._saveConfig(),this.requestUpdate(),!0}_vacAutoCalibrate(t){const e=this._vacSource(t),i=e?bs(this.hass?.states[e]?.attributes):null;if(!e||!i||i.rooms.length<3)return void this._showToast(this._t("vac.autocal_no_rooms"));const s=this._spaceModel(t.space),o=(s?.rooms||[]).map(t=>({r:t,poly:ke(t)})).filter(({r:t,poly:e})=>t.name&&e).map(({r:t,poly:e})=>{const i=Ne(e);return{name:t.name,cx:i[0],cy:i[1]}}),n=$s(i.rooms,o);n?this._vacSaveMatrix(t.id,e,this._vacMapId(t,i),n.matrix)&&(n.residual>50&&this._showToast(ni(this._t("vac.autocal_res_warn"),{rooms:String(n.matched.length)})),this._showToast(ni(this._t("vac.autocal_done"),{rooms:String(n.matched.length)}))):this._showToast(this._t("vac.autocal_no_match"))}_vacStartFit(t){const e=this._vacSource(t),i=e?bs(this.hass?.states[e]?.attributes):null;if(!e||!i)return void this._showToast(this._t("vac.cal_need_pos"));const s=this._vacMapId(t,i),o=t.marker?.vacuum?.calibration?.[s],n=this._spaceModel(t.space),r=n?.vb||[0,0,yo,yo],a=o&&6===o.length&&function(t){const e=t[0]*t[4]-t[1]*t[3];if(!Number.isFinite(e)||Math.abs(e)<1e-12)return null;const i=e<0,s=Math.sqrt(Math.abs(e));let o=180*Math.atan2(-t[1],t[4])/Math.PI;return o=(90*Math.round(o/90)%360+360)%360,{ox:t[2],oy:t[5],s:s,rot:o,mir:i}}(o)||function(t,e){const i=[],s=[];for(const e of t)null!=e.x0?(i.push(e.x0,e.x1),s.push(e.y0,e.y1)):(i.push(e.cx),s.push(e.cy));if(!i.length)return{ox:e[0]+e[2]/2,oy:e[1]+e[3]/2,s:e[2]/1e4,rot:0,mir:!0};const o=Math.min(...i),n=Math.max(...i),r=Math.min(...s),a=Math.max(...s),l=Math.max(n-o,a-r)||1,c={ox:0,oy:0,s:.6*Math.min(e[2],e[3])/l,rot:0,mir:!0},h=Ms(c),[d,p]=ms(h,(o+n)/2,(r+a)/2);return c.ox=e[0]+e[2]/2-d,c.oy=e[1]+e[3]/2-p,c}(i.rooms,r);this._markerDialog=null,t.space!==this._space&&(this._space=t.space),this._vacFit={markerId:t.id,source:e,mapId:s,p:a,drag:null}}_vacFitSave(){const t=this._vacFit;if(!t)return;const e=this._vacSaveMatrix(t.markerId,t.source,t.mapId,Ms(t.p));this._vacFit=null,e&&this._showToast(this._t("vac.cal_done"))}_vacFitTurn(t){const e=this._vacFit;if(!e)return;const i=bs(this.hass?.states[e.source]?.attributes),s=this._vacGhostCentre(i?.rooms||[]),o={...e.p,...t};this._vacFit={...e,p:Cs(o,e.p,s[0],s[1])}}_vacGhostCentre(t){const e=[],i=[];for(const s of t)e.push(s.x0??s.cx,s.x1??s.cx),i.push(s.y0??s.cy,s.y1??s.cy);return e.length?[(Math.min(...e)+Math.max(...e))/2,(Math.min(...i)+Math.max(...i))/2]:[0,0]}_vacDelta(t,e,i){const s=this._stageEl,o=s?.clientWidth||1,n=s?.clientHeight||1;return[e/o*t.w,i/n*t.h]}_vacFitPointer(t,e){const i=this._vacFit;if(!i)return;if(t.stopPropagation(),"pointerdown"===t.type){const e=t.target,s=e.getAttribute?.("data-corner");try{t.currentTarget.setPointerCapture?.(t.pointerId)}catch{}return void(this._vacFit={...i,drag:s?{kind:"scale",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:Number(s.split(",")[0]),fy:Number(s.split(",")[1])}:{kind:"move",sx:t.clientX,sy:t.clientY,p0:{...i.p},fx:0,fy:0}})}const s=i.drag;if(s){if("pointermove"===t.type){const[o,n]=this._vacDelta(e,t.clientX-s.sx,t.clientY-s.sy);if("move"===s.kind)this._vacFit={...i,p:{...s.p0,ox:s.p0.ox+o,oy:s.p0.oy+n}};else{const t=bs(this.hass?.states[i.source]?.attributes),e=this._vacGhostCentre(t?.rooms||[]),r=Ms(s.p0),[a,l]=ms(r,e[0],e[1]),[c,h]=ms(r,s.fx,s.fy),d=Math.hypot(a-c,l-h)||1,[p,u]=[2*a-c,2*l-h],_=Math.hypot(p+2*o-c,u+2*n-h)/2,g=Math.max(.05,_/d),m={...s.p0,s:s.p0.s*g};this._vacFit={...i,p:Cs(m,s.p0,s.fx,s.fy)}}return}"pointerup"!==t.type&&"pointercancel"!==t.type||(this._vacFit={...i,drag:null})}}_renderVacFit(t){const e=this._vacFit;if(!e)return V;const i=bs(this.hass?.states[e.source]?.attributes);if(!i)return V;const s=Ms(e.p),o=[],n=[],r=[];for(const t of i.rooms){if(null==t.x0)continue;const e=[[t.x0,t.y0],[t.x1,t.y0],[t.x1,t.y1],[t.x0,t.y1]].map(([t,e])=>ms(s,t,e));e.forEach(([t,e])=>{n.push(t),r.push(e)});const[i,a]=ms(s,t.cx,t.cy);o.push(j`
+ ${t.name}`)}let a=V;if(i.pos){const[e,o]=ms(s,i.pos.x,i.pos.y);a=j``}const l=[];if(n.length){const e=(()=>{const t=s[0]*s[4]-s[1]*s[3];return(e,i)=>[(s[4]*(e-s[2])-s[1]*(i-s[5]))/t,(-s[3]*(e-s[2])+s[0]*(i-s[5]))/t]})(),i=Math.min(...n),o=Math.max(...n),a=Math.min(...r),c=Math.max(...r),h=.022*t.w;for(const[t,s,n,r]of[[i,a,o,c],[o,a,i,c],[o,c,i,a],[i,c,o,a]]){const i=e(n,r);l.push(j``)}}return B``}_vacRafLoop(){this._vacRaf=requestAnimationFrame(()=>{const t=this.renderRoot,e=this._stageEl,i=this._vacLastView,s=t?.querySelectorAll?.(".vacpuck")||[];if(!e||!i||!s.length)return void(this._vacRaf=0);const o=e.getBoundingClientRect();for(const e of s){const s=e.getAttribute("data-mid"),n=e.getBoundingClientRect(),r=i.x+(n.left+n.width/2-o.left)/o.width*i.w,a=i.y+(n.top+n.height/2-o.top)/o.height*i.h;for(const e of t.querySelectorAll(`line.tip[data-mid="${s}"]`))e.setAttribute("x2",r.toFixed(1)),e.setAttribute("y2",a.toFixed(1))}this._vacRafLoop()})}_renderVacuums(t,e){if(this._markup||"decor"===this._mode)return V;const i=this._space+"|"+e.x+"|"+e.y+"|"+e.w+"|"+e.h,s=this._vacJumpOnce||i!==this._vacViewKey;this._vacViewKey=i,this._vacJumpOnce=!1;const o=[],n=[];for(const i of t){if(i.hidden||!this._isVacDev(i))continue;const t=this._vacSource(i);if(!t)continue;const r=ts(this.hass?.states[t]?.attributes);if(!r)continue;const a=i.marker?.vacuum?.calibration?.[this._vacMapId(i,r)];if(!a||6!==a.length)continue;const l=this._vacRt.get(i.id),c=l?.moving??!1,h=cs(i.marker?.vacuum),p="always"===h||"cleaning"===h&&c,d=this._vacSrvTrails[i.id],u=this._vacMapId(i,r),_=d?.current?.map_id===u&&Array.isArray(d.current.points)?d.current:null,m=d?.previous?.map_id===u&&Array.isArray(d.previous.points)?d.previous:null;if("always"===h&&m&&m.points.length>1){const t=m.points.map(([t,e])=>{const[i,s]=Yi(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");n.push(j``)}if(p&&(c||_)){const t=_?.points||r.path||l?.trail||[],e=c&&(_||r.path)&&t.length>1?t.slice(0,-1):t;if(e.length>1){const t=e.map(([t,e])=>{const[i,s]=Yi(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");if(n.push(j``),c){const[t,s]=Yi(a,e[e.length-1][0],e[e.length-1][1]),o=t.toFixed(1),r=s.toFixed(1);n.push(j``)}}}if(!c||!r.pos)continue;const[g,f]=Yi(a,r.pos.x,r.pos.y),v=(g-e.x)/e.w*100,b=(f-e.y)/e.h*100,y=l&&l.lastTs>0&&Date.now()-l.lastTs>6e4,w=i.marker?.icon||i.icon||"mdi:robot-vacuum";o.push(B`
this._vacFitPointer(e,t)}>${o}${a}${l}`}_vacRafLoop(){this._vacRaf=requestAnimationFrame(()=>{const t=this.renderRoot,e=this._stageEl,i=this._vacLastView,s=t?.querySelectorAll?.(".vacpuck")||[];if(!e||!i||!s.length)return void(this._vacRaf=0);const o=e.getBoundingClientRect();for(const e of s){const s=e.getAttribute("data-mid"),n=e.getBoundingClientRect(),r=i.x+(n.left+n.width/2-o.left)/o.width*i.w,a=i.y+(n.top+n.height/2-o.top)/o.height*i.h;for(const e of t.querySelectorAll(`line.tip[data-mid="${s}"]`))e.setAttribute("x2",r.toFixed(1)),e.setAttribute("y2",a.toFixed(1))}this._vacRafLoop()})}_renderVacuums(t,e){if(this._markup||"decor"===this._mode)return V;const i=this._space+"|"+e.x+"|"+e.y+"|"+e.w+"|"+e.h,s=this._vacJumpOnce||i!==this._vacViewKey;this._vacViewKey=i,this._vacJumpOnce=!1;const o=[],n=[];for(const i of t){if(i.hidden||!this._isVacDev(i))continue;const t=this._vacSource(i);if(!t)continue;const r=bs(this.hass?.states[t]?.attributes);if(!r)continue;const a=i.marker?.vacuum?.calibration?.[this._vacMapId(i,r)];if(!a||6!==a.length)continue;const l=this._vacRt.get(i.id),c=l?.moving??!1,h=zs(i.marker?.vacuum),d="always"===h||"cleaning"===h&&c,p=this._vacSrvTrails[i.id],u=this._vacMapId(i,r),_=p?.current?.map_id===u&&Array.isArray(p.current.points)?p.current:null,g=p?.previous?.map_id===u&&Array.isArray(p.previous.points)?p.previous:null;if("always"===h&&g&&g.points.length>1){const t=g.points.map(([t,e])=>{const[i,s]=ms(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");n.push(j``)}if(d&&(c||_)){const t=_?.points||r.path||l?.trail||[],e=c&&(_||r.path)&&t.length>1?t.slice(0,-1):t;if(e.length>1){const t=e.map(([t,e])=>{const[i,s]=ms(a,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");if(n.push(j``),c){const[t,s]=ms(a,e[e.length-1][0],e[e.length-1][1]),o=t.toFixed(1),r=s.toFixed(1);n.push(j``)}}}if(!c||!r.pos)continue;const[m,f]=ms(a,r.pos.x,r.pos.y),v=(m-e.x)/e.w*100,b=(f-e.y)/e.h*100,y=l&&l.lastTs>0&&Date.now()-l.lastTs>6e4,w=i.marker?.icon||i.icon||"mdi:robot-vacuum";o.push(B`
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=Qs,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=Qs,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
+ ${this._fmtLen(e,i)} · ${r}°
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=yo,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=ke(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=yo,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=ke(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
${i.map(e=>{const[i,n,r,a]="x"===e.axis?[e.at,e.from[1],e.at,t[1]+Math.sign(t[1]-e.from[1])*o]:[e.from[0],e.at,t[0]+Math.sign(t[0]-e.from[0])*o,e.at];return j``})}
- `}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_openingAmt(t){const e=t.contact?this.hass.states[t.contact]?.state:null;return function(t,e,i=!1){return null==e||"unavailable"===e||"unknown"===e?"door"===t?1:0:$e(e)!==!!i?1:0}(t.type,e,!!t.invert)}_renderOpenings(t){const e=this._openingsR;if(!e.length)return j``;const i=t.color;return j`${e.map(t=>{const e=t.rlen/2,s=this._openingAmt(t),o=s>0&&!!t.contact?"var(--hp-open)":i,n=t.flip_h?-1:1,r=t.flip_v?-1:1;let a;if("window"===t.type){const t=Math.PI/2*e;a=j`
+ `}_renderOpeningCenterTick(){const t=this._opMeasure.guide,e=(t.angle+90)*Math.PI/180;return j``}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_openingAmt(t){const e=t.contact?this.hass.states[t.contact]?.state:null;return function(t,e,i=!1){return null==e||"unavailable"===e||"unknown"===e?"door"===t?1:0:ze(e)!==!!i?1:0}(t.type,e,!!t.invert)}_renderOpenings(t){const e=this._openingsR;if(!e.length)return j``;const i=t.color;return j`${e.map(t=>{const e=t.rlen/2,s=this._openingAmt(t),o=s>0&&!!t.contact?"var(--hp-open)":i,n=t.flip_h?-1:1,r=t.flip_v?-1:1;let a;if("window"===t.type){const t=Math.PI/2*e;a=j`
${this._t("opening.contact_label")}
${e(this._contactCandidates(),t.contact,e=>this._openingDialog={...t,contact:e})}
- ${t.contact?B`this._openingDialog={...t,invert:e.target.checked}} />
+ ${t.contact?B`${this._boolInput(t.invert,e=>this._openingDialog={...t,invert:e})}
${this._t("opening.invert")}`:V}
${"door"===t.type?B`${this._t("opening.lock_label")}
${e(this._lockCandidates(),t.lock,e=>this._openingDialog={...t,lock:e})}`:V}
- this._openingDialog={...t,flipH:e.target.checked}} />
+ ${this._boolInput(t.flipH,e=>this._openingDialog={...t,flipH:e})}
${this._t("opening.flip_h")}
- this._openingDialog={...t,flipV:e.target.checked}} />
+ ${this._boolInput(t.flipV,e=>this._openingDialog={...t,flipV:e})}
${this._t("opening.flip_v")}
`}_cardEntities(t){const e=this.hass,i=[],s=new Set,o=t=>{if(!t||s.has(t)||!e.states[t])return;const o=e.entities[t];if("config"===o?.entity_category||"diagnostic"===o?.entity_category)return;s.add(t);const n=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(n)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(n)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(n)&&i.push({eid:t,kind:"value"})};for(const e of t.marker?.controls||[])o(e);t.primary&&o(t.primary);for(const e of t.entities)o(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null,s=(t.marker?.controls||[]).filter(gi);return B`
this._infoCard=null}>
+
`}_cardEntities(t){const e=this.hass,i=[],s=new Set,o=t=>{if(!t||s.has(t)||!e.states[t])return;const o=e.entities[t];if("config"===o?.entity_category||"diagnostic"===o?.entity_category)return;s.add(t);const n=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(n)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(n)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(n)&&i.push({eid:t,kind:"value"})};for(const e of t.marker?.controls||[])o(e);t.primary&&o(t.primary);for(const e of t.entities)o(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?this.hass.formatEntityState?.(e)??e.state:null,s=(t.marker?.controls||[]).filter(yi);return B`
`}}so.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},so.ZOOM_MAX=8,so.ZOOM_MIN=.4,so._touchSeen=!1,so._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,so.styles=Ps,customElements.get("houseplan-card")||customElements.define("houseplan-card",so),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.55.3 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+
`}}xo.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},xo.ZOOM_MAX=8,xo.ZOOM_MIN=.4,xo._touchSeen=!1,xo._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,xo.styles=Js,customElements.get("houseplan-card")||customElements.define("houseplan-card",xo),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.56.0 ","background:#3ea6ff;color:#04121f;font-weight:700","");
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index efcbdd0b..5fdb03fb 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,49 @@
# Changelog
+## v1.56.0 — 2026-08-03
+
+Minor release: the sun comes to the floor plan — a compass, a day/night
+backdrop and real sunlight through the windows — plus room temperature
+from climate devices, a design-token pass over the UI, live rulers for
+opening drags, and a sun-cache fix.
+
+- **The sun on your floor plan** (docs/SUN.md). Tell the plan where
+ north is — a compass dial in the general settings, with a per-space
+ override — and the card starts living by Home Assistant's own
+ `sun.sun`:
+ - *Day/night backdrop* (`bg_mode: daynight`): the scene behind the
+ plan follows the sun's elevation — white at full day, warm gold
+ through the golden hour, cooling dusk, deep night. The plan itself
+ dims only ~10% at night, so the rooms stay readable.
+ - *Light through the windows* (`sun_rays`): every window on an
+ exterior wall casts a soft wedge of light into its room — clipped
+ by the room's own contour, warmer near the horizon, stretching
+ long at sunrise and sunset, short at noon, gone after dark.
+ - *Clouds, if you want them* (`weather_entity`): point at a weather
+ entity and an overcast sky mutes the wedges; rain or snow puts
+ them out. A dead sensor never kills the sun.
+ - The plan now sits on an *opaque sheet of paper* traced along the
+ room contours: the scene background — `bg_color` or the daynight
+ sky — is visible only around the walls and never bleeds through
+ the rooms; in day/night mode the sheet casts a soft shadow.
+ - The whole feature is silent until the compass is set; without
+ `sun.sun` the settings dialog explains why.
+- **Room temperature from climate devices.** A new checkbox in the
+ device dialog puts the AC's or thermostat's `current_temperature` on
+ the badge next to its icon and into the room's average temperature.
+- **UI modernization.** A design-token pass over `styles.ts` — 209
+ hard-coded values unified into spacing/radius/font/shadow scales —
+ and native `ha-switch` / `ha-slider` controls in the dialogs, with a
+ hard fallback to the old plain inputs where HA components are
+ unavailable.
+- **Live rulers for opening drags.** Dragging a door or window now
+ shows the live distances to both ends of its wall, measured along
+ the owning room's edge; a dashed guide and a soft magnet snap the
+ opening to the wall's center (hold Shift to disable the magnet).
+- **Fix DEV-B701-01** — the sun-ray cache is now invalidated by local
+ geometry edits too, so a wedge can no longer go stale after you move
+ a wall locally before the server confirms the change.
+
## v1.55.3 — 2026-08-02
Patch release: two fixes for the v1.55.2 first-open veil, found by an
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index 30690df5..2ac0ccaa 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -6,6 +6,50 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
+## v1.56.0 — 2026-08-03
+
+Минорный релиз: на план приходит солнце — компас, фон «день/ночь» и
+настоящий свет из окон — плюс температура с климатических устройств,
+дизайн-токены в UI, живые линейки при перетаскивании проёмов и фикс
+кэша солнечных лучей.
+
+- **Солнце на плане дома** (docs/SUN.md). Скажите плану, где север —
+ круговой компас в общих настройках с переопределением в каждом
+ пространстве — и карточка начинает жить по `sun.sun` самого
+ Home Assistant:
+ - *Фон «день/ночь»* (`bg_mode: daynight`): сцена за планом следует
+ за высотой солнца — белый ясный день, тёплое золото золотого
+ часа, остывающие сумерки, глубокая ночь. Сам план ночью
+ приглушается лишь на ~10%, комнаты остаются читаемыми.
+ - *Свет из окон* (`sun_rays`): каждое окно на внешней стене пускает
+ в свою комнату мягкий клин света — обрезанный контуром комнаты,
+ теплее у горизонта, длинный на рассвете и закате, короткий в
+ полдень и гаснущий ночью.
+ - *Облачность — по желанию* (`weather_entity`): укажите
+ weather-сущность, и пасмурное небо приглушит клинья, а дождь или
+ снег погасит совсем. Мёртвый датчик солнце не выключает.
+ - План теперь лежит на *непрозрачном листе бумаги*, повторяющем
+ контуры комнат: фон сцены — `bg_color` или небо «дня/ночи» —
+ виден только вокруг стен и не просвечивает сквозь комнаты; в
+ режиме «день/ночь» у листа мягкая тень.
+ - Пока компас не задан, фича полностью молчит; без `sun.sun`
+ диалог настроек объясняет, почему.
+- **Температура с климатических устройств.** Новый чекбокс в диалоге
+ устройства выводит `current_temperature` кондиционера или
+ термостата на плашку у значка и учитывает её в средней температуре
+ комнаты.
+- **Модернизация UI.** Токен-проход по `styles.ts` — 209 «зашитых»
+ значений сведены в шкалы отступов/радиусов/шрифтов/теней — и
+ родные `ha-switch` / `ha-slider` в диалогах с жёстким фолбэком на
+ старые инпуты там, где компоненты HA недоступны.
+- **Живые линейки проёмов.** При перетаскивании двери или окна теперь
+ видны живые расстояния до обоих концов стены — по ребру своей
+ комнаты; пунктирная направляющая и мягкий магнит центрируют проём
+ на стене (Shift отключает магнит).
+- **Фикс DEV-B701-01** — кэш солнечных лучей теперь сбрасывается и
+ локальными правками геометрии: клин больше не «застревает», если
+ подвинуть стену до подтверждения сервера.
+
## v1.55.3 — 2026-08-02
Патч-релиз: два исправления вуали первого открытия из v1.55.2,
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 282fbd3e..16ff9cd0 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -11,16 +11,16 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
-## Snapshot (2026-08-02)
+## Snapshot (2026-08-03)
| Item | State |
|---|---|
-| Version | **v1.55.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance. Closes the v1.55.2 audit findings AUD-1552-01/02 (boot-veil lifecycle: reconnect restart + full protective window with trailing quiescence + post-reveal soft grace; regressions in `demo/smoke_preloader_lifecycle.mjs`) |
+| Version | **v1.56.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance. Minor release: the Sun feature (docs/SUN.md — north compass, day/night backdrop, window light wedges, optional weather cloud cover, opaque room-contour paper), opt-in room temperature from climate devices, design-token pass over styles.ts + native ha-switch/ha-slider, opening-drag shoulder rulers with a center magnet, and the DEV-B701-01 sun-ray cache fix |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
-| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.55.3** via direct copy (HACS custom repo also installed) |
+| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.56.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
diff --git a/docs/SUN.md b/docs/SUN.md
new file mode 100644
index 00000000..73fa1cba
--- /dev/null
+++ b/docs/SUN.md
@@ -0,0 +1,167 @@
+# Sun on the plan — the spec (source of truth)
+
+Status: approved by the owner 2026-08-03. Shipped in v1.56.0.
+Scope decisions final: the compass lives in the GENERAL settings with a
+per-space override, the feature is silent until `north_deg` is set
+anywhere, wedges ship for the FULL card only in v1, and mutual shading
+of the building's wings is explicitly NOT computed.
+
+## Principle
+
+The plan learns where north is, and from that single number plus HA's
+own `sun.sun` the card knows where the sun stands relative to every
+wall. Two visuals follow: the stage background can breathe with the
+day (day → golden hour → dusk → night), and windows on exterior walls
+cast soft wedges of light into their rooms. Everything is display
+only — no entities are created, no services are called.
+
+## Data
+
+- Source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north,
+ clockwise; `attributes.elevation` in degrees, negative below the
+ horizon). No `sun.sun` in the install → the whole feature stays
+ silent and the settings dialog says why.
+- Sun attributes update rarely (~30–120 s). Sun geometry is recomputed
+ ONLY when (azimuth, elevation) or the config change — never on every
+ `hass` tick. The wedge layer memoises on
+ `(azimuth, elevation, config rev, space id, weather state)`.
+- Angle on the plan: `plan_angle = azimuth − north_deg` (normalised to
+ 0–360). With `north_deg = 0` the top of the canvas is north; the
+ direction TOWARD the sun on the canvas is
+ `(sin(plan_angle), −cos(plan_angle))` (y grows downward).
+
+## Compass — `settings.north_deg`
+
+- Integer 0–359, degrees clockwise from "up on the canvas" to true
+ north. Lives in the GENERAL settings (⚙) as a circular dial: drag
+ the «N» arrow around the ring, 1° steps, 15° with Shift held; a
+ plain number input sits next to it for accessibility and precision.
+- Per-space override in the space settings (empty = inherit), the same
+ pattern as `show_lqi` / `fill_mode`.
+- While `north_deg` is null at BOTH levels the whole sun feature is
+ inert: static background, no wedges, nothing computed. The settings
+ dialogs show a hint.
+- Backend validation: integer in 0–359 at both levels.
+
+## Plan background — `settings.bg_mode: 'static' | 'daynight'`
+
+- Global default in the general settings, per-space override (null =
+ inherit). Default `'static'`.
+- `'static'` — the existing `bg_color` behaviour, color picker and
+ all. Nothing changes for existing installs.
+- `'daynight'` — the stage background follows the sun's elevation:
+ WHITE at full day (the brightest moment of the day is white — owner,
+ 2026-08-03), a warm bright shift in the golden hour (elevation below
+ ~10°), cooling through dusk, deep darkening at night. The scale
+ (piecewise-linear between stops, `BG_STOPS` in `src/sun.ts`):
+
+ | elevation | color | phase |
+ | --- | --- | --- |
+ | −90°…−12° | `#070c14` | deep night |
+ | −4° | `#131a28` | dusk cools down |
+ | 0° | `#4a3527` | warm band right at the horizon |
+ | +10° | `#e8ddcf` | morning light — warm and bright |
+ | +30°…+90° | `#ffffff` | plain day, white |
+ The PLAN
+ itself dims only ~10% at night (`filter: brightness(.9)`), so the
+ daytime room fills stay readable. Transitions are a CSS
+ background/filter transition tens of seconds long;
+ `prefers-reduced-motion` gets the current colors statically.
+- The UI is a two-option selector; the color picker shows only for
+ `'static'`.
+- Backend validation: `In(['static', 'daynight'])` at both levels.
+- `'daynight'` follows the general gate: without `north_deg` (or
+ without `sun.sun`) it behaves as `'static'`.
+- **The scene background never bleeds through the plan** (owner,
+ 2026-08-03). In BOTH modes the background — `bg_color` or the
+ daynight sky — is visible only AROUND the plan: opaque `.hp-paper`
+ shapes sit under everything the plan draws. An image plan papers the
+ backdrop image rect (the canvas IS the paper); a hand-drawn plan
+ papers the ROOM CONTOURS — one shape per room in exactly the room's
+ own geometry (fill only, no stroke), never their bounding box, so
+ the background reaches the exterior walls of an L-shaped house and
+ fills the gaps between detached buildings (an empty drawn space has
+ no paper). Open (virtual) boundaries do not affect the paper; a live
+ resize preview moves it together with the rooms. Its colour is the
+ pre-bg_color canvas — white for hand-drawn plans, the theme card
+ background under an image. The night dimming above is the
+ `brightness` filter on the zoomwrap ONLY; the paper's alpha never
+ changes. Applies to view/kiosk/editors and the static space-card
+ alike (smoke_bg_color).
+
+## Window light wedges — `settings.sun_rays`
+
+Boolean, global + per-space (null = inherit), default OFF.
+
+For every opening of type «window» sitting on an EXTERIOR wall — a
+wall stretch with no other room on its outer side, decided by probing
+the existing room geometry just off both sides of the window; windows
+on interior walls do not participate, and open (virtual) boundaries
+never qualify because both sides are rooms — the card draws a wedge
+when BOTH hold:
+
+- the sun is above the horizon (`elevation > 0`), and
+- the dot product of the wall's outward normal with the direction
+ toward the sun is positive (the sun actually faces this window).
+
+The wedge is a quadrilateral cast from the window's span along the
+direction AWAY from the sun (light falls inward), clipped by the
+room's polygon (`polyclip` intersection, the same dependency
+`src/resize.ts` already uses). Its length is `k(elevation)` in window
+lengths: ~2.5 at sunrise/sunset tapering to ~0.8 at the zenith
+(`0.8 + 1.7·(1 − elevation/90)^1.6`). A linear gradient runs bright at
+the window and dissolves inward; the color is warm orange while
+`elevation < 10°` and neutral by day; peak opacity is modest (~0.18 —
+two overlapping wedges never exceed a readable ceiling). Near the
+horizon the opacity ramps in over the first ~2° so wedges never pop.
+
+Layer order: ABOVE room fills (and the glow layer), BELOW devices and
+labels (those live in the HTML `devlayer` anyway). Night
+(`elevation ≤ 0`) → no wedges. Wedges work under BOTH `bg_mode`s.
+
+## Cloud cover — `settings.weather_entity` (optional)
+
+String entity id or null; GLOBAL settings only. When set and the
+entity's state reads overcast, the wedges fade by an opacity
+multiplier — ~0.25 fully overcast, 0 (gone) in rain/snow:
+
+| states | factor |
+| --- | --- |
+| clear, sunny, clear-night, windy, exceptional, unset entity | 1.0 |
+| partlycloudy, windy-variant | 0.7 |
+| cloudy | 0.4 |
+| overcast, fog | 0.25 |
+| rainy, pouring, snowy, snowy-rainy, hail, lightning, lightning-rainy | 0.0 |
+| unknown, unavailable | 1.0 (a dead sensor must not kill the sun) |
+
+Backend validation: string or null.
+
+## Edge cases and limits
+
+- No `sun.sun` → silent feature + a hint in the settings dialog.
+- `north_deg` unset everywhere → silent feature + a hint.
+- `prefers-reduced-motion` → no transitions; colors and wedges render
+ statically for the current sun position.
+- Kiosk mode → works (same view path).
+- Static `houseplan-space-card` → the background honours the effective
+ `bg_mode`/color; wedges are v1 FULL-CARD ONLY (documented limit).
+- Editors (plan/devices/decor) → no wedges and no day/night: the
+ editor canvases render exactly as before.
+- Mutual shading of the building's own wings (an L-shaped house
+ shadowing its inner corner) is NOT computed — a lit window casts its
+ wedge even when another wing geometrically blocks the sun. Accepted
+ v1 limit.
+- Wedges of windows on all four wall orientations are unit-tested,
+ including the 359→0 azimuth wrap.
+
+## Files
+
+- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge
+ quads + clipping, cloud factor, settings inheritance); unit-tested
+ in `test/sun.test.mjs`.
+- `src/houseplan-card.ts` — the memoised wedge layer, the day/night
+ stage background, both settings dialogs (compass dial included).
+- `src/space-render.ts` — the static card's background only.
+- `custom_components/houseplan/validation.py` — the four settings at
+ both levels; tests in `tests_backend/test_validation.py`.
+- `demo/smoke_sun.mjs` — end-to-end behaviour against the demo rig.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index ffb2ae7d..4e6f18c8 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -120,6 +120,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
walls, click (any tool) opens its properties — with a 3 px drag threshold
since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings]
+- [ ] Opening drag rulers (2026-08-03): while an opening is dragged, a measure
+ badge on EACH shoulder shows the along-the-wall distance from the wall end
+ to the nearest opening edge, live; the wall is ONE room's edge — the edge
+ the opening is snapped to — so a neighbouring room's collinear edge is
+ never merged in; at that edge's center (±half a grid step) a
+ perpendicular dashed tick appears and the center magnet-snaps — Shift
+ disables the magnet; badges and tick vanish on release
+ [auto: smoke_opening_measure + unit openingShoulders]
## Onboarding ★
@@ -866,3 +874,59 @@ require hands on real hardware — they remain for the human pass.
one release = one undo step (rooms AND openings)
- [ ] Device markers do not move; the room settings gear re-centres itself
- [ ] Smoke: `node demo/smoke_room_resize.mjs`
+
+## Sun on the plan (docs/SUN.md)
+
+- [ ] Everything below is INERT until `north_deg` is set (general ⚙ or the
+ space settings) AND the install has `sun.sun`; both dialogs hint at
+ whichever is missing
+- [ ] The ⚙ compass: dragging the «N» arrow turns it in 1° steps (15° with
+ Shift); the number field mirrors the dial and accepts 0–359; «Clear»
+ returns the unset state
+- [ ] «Plan background» selector: `static` keeps the existing color picker
+ and behaviour byte-for-byte; `daynight` hides the picker and the stage
+ follows the sun — neutral day, warm golden hour, dark night — with a
+ slow (tens of seconds) transition; the PLAN dims only ~10% at night
+- [ ] Opaque plan paper (2026-08-03, owner): the scene background —
+ `bg_color` or the daynight sky — is visible ONLY around the plan and
+ NEVER bleeds through it, in view/kiosk/editors and on the static
+ space-card. An image plan papers the backdrop image rect
+ (`rect.hp-paper`). A hand-drawn plan papers the ROOM CONTOURS: one
+ `.hp-paper` shape per room in exactly the room's own geometry (fill
+ only, no stroke), so an L-shaped house or detached buildings never
+ grow a white bounding rectangle — the scene colour reaches the
+ exterior walls, shows in the L's pocket and between buildings, and an
+ empty drawn space has no paper at all. A live resize preview
+ (`_rszPreview`) moves the paper together with the dragged wall.
+ Colours: historical white for drawn plans, the theme card background
+ under an image. At night (`daynight`) the paper dims via the zoomwrap
+ `brightness` filter only — its alpha stays 1. Pixel-proofed against
+ an acid `#ff00ff` background [auto: smoke_bg_color]
+- [ ] Per-space overrides (background mode, north, sun-in-windows) inherit
+ when empty, exactly like show_lqi/fill_mode
+- [ ] «Sunlight through windows» (default OFF): wedges appear only from
+ windows on EXTERIOR walls facing the sun; interior windows, open
+ (virtual) boundaries and doors never light up
+- [ ] Wedge direction follows the compass; length grows toward
+ sunrise/sunset and shrinks toward noon; every wedge is clipped by its
+ room polygon; night = no wedges at all
+- [ ] Weather entity (optional, global): cloudy fades the wedges, rain/snow
+ removes them, a dead/unknown weather sensor changes nothing
+- [ ] Sun geometry recomputes ONLY when the sun attributes or the config
+ change — an unrelated `hass` tick reuses the memo
+- [ ] Editors (plan/devices/decor) render with NO wedges and NO day/night;
+ kiosk works; the static space-card honours the background mode
+ (wedges are full-card-only in v1)
+- [ ] `prefers-reduced-motion` → no transitions, static colors
+- [ ] Smoke: `node demo/smoke_sun.mjs`; units: `test/sun.test.mjs`;
+ backend: `tests_backend/test_validation.py` (sun settings)
+
+## Climate temperature opt-in (dev)
+
+- [ ] «Use the device's temperature sensor» (marker dialog, climate devices
+ only, default OFF): current_temperature shows as the standard `.tval`
+ badge and joins the room average like a thermometer; unavailable /
+ missing attribute = no badge, no vote; hidden devices keep voting
+ (registry-wide climate, like hidden thermometers); the tick survives
+ dialog recreation [auto: smoke_climate_temp; units: test/devices.test.mjs;
+ backend: tests_backend/test_validation.py (use_climate_temp)]
diff --git a/package-lock.json b/package-lock.json
index 1e061ba0..ba30f110 100755
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
- "version": "1.55.3",
+ "version": "1.56.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
- "version": "1.55.3",
+ "version": "1.56.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
diff --git a/package.json b/package.json
index 84b4906e..03bf277a 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
- "version": "1.55.3",
+ "version": "1.56.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
diff --git a/src/devices.ts b/src/devices.ts
index dfc1d710..4a25609f 100644
--- a/src/devices.ts
+++ b/src/devices.ts
@@ -146,6 +146,23 @@ export function tempFor(hass: any, entIds: string[]): number | null {
return null;
}
+/**
+ * Room temperature as a CLIMATE device reports it: the first climate.* entity
+ * with a finite attributes.current_temperature (owner's spec: several climate
+ * entities -> the first valid one wins). Unavailable/unknown entities and a
+ * missing attribute yield null - no badge, no vote in the room average.
+ */
+export function climateTempFor(hass: any, entIds: string[]): number | null {
+ for (const eid of entIds) {
+ if (!eid.startsWith('climate.')) continue;
+ const st = hass.states[eid];
+ if (!st || st.state === 'unavailable' || st.state === 'unknown') continue;
+ const v = parseFloat(st.attributes?.current_temperature);
+ if (Number.isFinite(v)) return Math.round(v * 10) / 10;
+ }
+ return null;
+}
+
/** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */
export function isHumEntity(hass: any, eid: string): boolean {
if (hass.entities?.[eid]?.entity_category) return false;
@@ -508,10 +525,21 @@ export interface AreaClimate { temp: number | null; hum: number | null }
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
*/
export function areaClimateMap(
- hass: any, rules?: CompiledIconRule[],
+ hass: any, rules?: CompiledIconRule[], markers?: Marker[] | null,
): Map {
const out = new Map();
if (!hass?.entities) return out;
+ // Opt-in climate sources (marker.use_climate_temp): an AC or thermostat
+ // knows the room temperature (current_temperature) - when the user ticks
+ // the option, that reading votes in the room average like any thermometer.
+ // The set holds binding refs (device ids and entity ids); with no opted
+ // markers the pass below is byte-for-byte the old one.
+ const climOpt = new Set();
+ for (const m of markers || []) {
+ if (m?.use_climate_temp !== true) continue;
+ const i = (m.binding || '').indexOf(':');
+ if (i > 0) climOpt.add(m.binding.slice(i + 1));
+ }
// area -> device (or lone entity) -> the entities that belong to it
const byArea = new Map>();
for (const [eid, reg] of Object.entries(hass.entities)) {
@@ -523,8 +551,15 @@ export function areaClimateMap(
// 90 C and a virtual better_thermostat duplicating the real sensor (field
// question, 2026-07-27). Three guards, cheapest first:
if (reg.entity_category) continue; // diagnostic/config readings
- if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
- if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
+ // An explicitly opted climate entity skips the platform/name filters:
+ // the tick is the user saying "this one DOES measure room air" - even on
+ // an excluded platform (better_thermostat) or a suspicious entity id.
+ const optClimate = climOpt.size > 0 && eid.startsWith('climate.')
+ && (climOpt.has(eid) || (reg.device_id && climOpt.has(reg.device_id)));
+ if (!optClimate) {
+ if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
+ if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
+ }
let groups = byArea.get(area);
if (!groups) { groups = new Map(); byArea.set(area, groups); }
const key = reg.device_id || eid;
@@ -543,13 +578,18 @@ export function areaClimateMap(
for (const [area, groups] of byArea) {
const temps: number[] = [];
const hums: number[] = [];
- for (const g of groups.values()) {
+ for (const [key, g] of groups) {
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
if (air) {
const t = tempFor(hass, g.ents);
if (t != null) temps.push(t);
}
+ // opted climate device/entity: current_temperature joins the average
+ if (climOpt.size > 0 && (climOpt.has(key) || g.ents.some((e) => climOpt.has(e)))) {
+ const t = climateTempFor(hass, g.ents);
+ if (t != null) temps.push(t);
+ }
if (air || icon === 'mdi:water-percent') {
const h = humFor(hass, g.ents);
if (h != null) hums.push(h);
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index fed45757..84933fdc 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -13,9 +13,9 @@ import {
} from './rules';
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
- segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
+ segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
- snapToWall, openingAmount, interiorPoint, poleOfInaccessibility, subst,
+ snapToWall, openingAmount, openingShoulders, interiorPoint, poleOfInaccessibility, subst,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
@@ -28,6 +28,10 @@ import {
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
} from './resize';
+import {
+ computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
+ sunStateOf, cloudFactor, rayAlpha, rayColor, type SunRay,
+} from './sun';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
import {
@@ -36,7 +40,7 @@ import {
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
VAC_TRAIL_LINGER_MS, Pt as VacPt,
} from './vacuum';
-import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
+import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -47,7 +51,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
-const CARD_VERSION = '1.55.3';
+const CARD_VERSION = '1.56.0';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -270,6 +274,11 @@ class HouseplanCard extends LitElement {
} | null = null;
private _openingInfo: OpeningCfg | null = null;
private _opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean } | null = null;
+ // live ruler badges + the "centered on the wall" tick while an opening is dragged
+ private _opMeasure: {
+ labels: { x: number; y: number; text: string }[];
+ guide: { x: number; y: number; angle: number } | null;
+ } | null = null;
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
@@ -326,7 +335,15 @@ class HouseplanCard extends LitElement {
private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
- private _settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; busy: boolean } | null = null;
+ private _settingsDialog: {
+ colors: FillColors; glowRadius: number; bgColor: string | null;
+ /** sun on the plan (docs/SUN.md) */
+ northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean; weatherEntity: string;
+ busy: boolean;
+ } | null = null;
+ /** Wedge memo: recomputed only when (azimuth, elevation, north, cfg rev) change (docs/SUN.md). */
+ private _sunRaysCache: { key: string; rays: SunRay[] } | null = null;
+ private _compassDrag = false;
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
private _importQueue: string[] = []; // floor titles still to create
private _importTotal = 0;
@@ -367,6 +384,7 @@ class HouseplanCard extends LitElement {
controlsFilter: string;
glowRadius: string; // per-device glow radius in display units; '' = global default
isLight: boolean; // force this marker to glow (dumb fixtures behind a switch)
+ useClimateTemp: boolean; // badge + room-average vote from climate current_temperature
model: string;
link: string;
description: string;
@@ -396,6 +414,9 @@ class HouseplanCard extends LitElement {
roomColor: string;
roomOpacity: number; // 0..1
bgColor: string | null; // background around the plan; null = inherit general
+ bgMode: 'static' | 'daynight' | null; // plan background mode; null = inherit (docs/SUN.md)
+ northDeg: number | null; // per-space compass override; null = inherit
+ sunRays: boolean | null; // per-space wedges override; null = inherit
fillMode: 'none' | 'lqi' | 'light' | 'temp' | 'glow';
tempMin: number;
tempMax: number;
@@ -489,6 +510,7 @@ class HouseplanCard extends LitElement {
_tool: { state: true },
_rszSel: { state: true },
_rszLive: { state: true },
+ _opMeasure: { state: true },
_path: { state: true },
_cursorPt: { state: true },
_mergeSel: { state: true },
@@ -1457,10 +1479,29 @@ class HouseplanCard extends LitElement {
private _liveTemp(d: DevItem): number | null {
if (!this._config?.show_temperature) return null;
+ // Opt-in climate source (marker.use_climate_temp): the AC/thermostat's
+ // current_temperature gets the same badge a thermometer has. No valid
+ // reading (missing attribute, unavailable) -> no badge at all.
+ if (d.marker?.use_climate_temp === true) {
+ const t = climateTempFor(this.hass, d.entities);
+ if (t != null) return t;
+ }
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null;
return tempFor(this.hass, d.entities);
}
+ /** Does the dialog's binding carry a climate entity? Gates the opt-in checkbox. */
+ private _bindingHasClimate(binding: string): boolean {
+ if (binding.startsWith('entity:')) return binding.slice(7).startsWith('climate.');
+ if (binding.startsWith('device:')) {
+ const ref = binding.slice(7);
+ for (const [eid, reg] of Object.entries(this.hass?.entities || {})) {
+ if (reg?.device_id === ref && eid.startsWith('climate.')) return true;
+ }
+ }
+ return false;
+ }
+
private _liveHum(d: DevItem): number | null {
if (!this._config?.show_temperature) return null; // same "sensor values" toggle as temperature
if (!d.primary || !isHumEntity(this.hass, d.primary)) return null;
@@ -1581,6 +1622,24 @@ class HouseplanCard extends LitElement {
return b ? [b.x, b.y, b.w, b.h] : m.vb;
}
+ /**
+ * Opaque plan "paper" (owner 2026-08-03): the scene background — bg_color or
+ * the 'daynight' sky — must never bleed through the plan itself, so opaque
+ * shapes sit under everything the plan draws. With a backdrop image the
+ * paper is the image rect (the canvas IS the paper) — this helper returns
+ * it. For a hand-drawn plan the paper follows the ROOM CONTOURS instead
+ * (paperRoomShapes in logic.ts): one shape per room in exactly the room's
+ * own geometry, so an L-shaped house or detached buildings never grow a
+ * white bounding rectangle — the scene colour is visible right up to the
+ * exterior walls, and an empty drawn space has no paper at all. Its colour
+ * is the pre-bg_color canvas (styles.ts .hp-paper): white for drawn plans,
+ * the theme card background under an image. At night 'daynight' dims it via
+ * the zoomwrap brightness filter ONLY — its alpha stays 1 (docs/SUN.md).
+ */
+ private _paperRect(m: SpaceModel): { x: number; y: number; w: number; h: number } | null {
+ return m.bg ? { x: m.bg.x, y: m.bg.y, w: m.bg.w, h: m.bg.h } : null;
+ }
+
/** Aspect ratio of the scene (width/height, px). */
private _stageAspect(): number {
const s = this._stageEl;
@@ -3099,8 +3158,30 @@ class HouseplanCard extends LitElement {
const sp = this._curSpaceCfg;
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
if (!cfg) return;
- const nx = snap.x / NORM_W;
- const ny = snap.y / this._spaceH;
+ // ruler badges on both shoulders + soft magnet to the wall's center
+ // (owner 2026-08-03); tol = half a grid step, Shift opts out of the magnet
+ // (same convention as the coarse-angle Shift elsewhere in the editor)
+ const rooms = this._spaceModel().rooms;
+ const tol = this._gridPitch / 2;
+ let cx = snap.x, cy = snap.y;
+ let sh = openingShoulders([cx, cy], snap.angle, cfg.length * NORM_W, rooms, tol);
+ if (sh && sh.centered && !ev.shiftKey && (cx !== sh.wallCenter[0] || cy !== sh.wallCenter[1])) {
+ [cx, cy] = sh.wallCenter;
+ sh = openingShoulders([cx, cy], snap.angle, cfg.length * NORM_W, rooms, tol);
+ }
+ if (sh) {
+ const imperial = this.hass?.config?.unit_system?.length === 'mi';
+ const lbl = (d: number, m: number[]) =>
+ ({ x: m[0], y: m[1], text: formatLength((d / this._gridPitch) * this._cellCm, imperial) });
+ this._opMeasure = {
+ labels: [lbl(sh.sideA, sh.midA), lbl(sh.sideB, sh.midB)],
+ guide: sh.centered && !ev.shiftKey
+ ? { x: sh.wallCenter[0], y: sh.wallCenter[1], angle: snap.angle }
+ : null,
+ };
+ } else this._opMeasure = null;
+ const nx = cx / NORM_W;
+ const ny = cy / this._spaceH;
if (cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle) this._opDrag.dirty = true;
cfg.x = nx;
cfg.y = ny;
@@ -3111,6 +3192,7 @@ class HouseplanCard extends LitElement {
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
if (!this._opDrag || this._opDrag.id !== o.id) return;
const moved = this._opDrag.moved;
+ this._opMeasure = null; // badges and the center tick live only through the drag
// only write when the geometry actually changed (audit L4)
if (moved && this._opDrag.dirty) this._saveConfig();
// keep the flag until the click event that follows pointerup, then let it go
@@ -3483,6 +3565,7 @@ class HouseplanCard extends LitElement {
controls: [...(d.marker?.controls || [])],
controlsFilter: '',
isLight: d.marker?.is_light === true,
+ useClimateTemp: d.marker?.use_climate_temp === true,
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this._imperial
? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10
@@ -3505,7 +3588,7 @@ class HouseplanCard extends LitElement {
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', tapTarget: '', tapConfirm: false, runFilter: '',
defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
- glowRadius: '', model: '',
+ useClimateTemp: false, glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false,
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
};
@@ -3727,6 +3810,7 @@ class HouseplanCard extends LitElement {
controls: dlg.controls.length ? dlg.controls : null,
// pdfs may be rewritten below when rebinding changes the marker id
is_light: dlg.isLight ? true : null,
+ use_climate_temp: dlg.useClimateTemp ? true : null,
glow_radius_cm: (() => {
const v = parseFloat(dlg.glowRadius);
if (!Number.isFinite(v) || v <= 0) return null;
@@ -3900,6 +3984,9 @@ class HouseplanCard extends LitElement {
showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
bgColor: disp.bgColor,
+ bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight' ? sp.settings.bg_mode : null,
+ northDeg: northDegOf({}, sp.settings),
+ sunRays: typeof sp.settings?.sun_rays === 'boolean' ? sp.settings.sun_rays : null,
tempMin: disp.tempMin, tempMax: disp.tempMax,
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
cardFontScale: disp.cardFontScale,
@@ -3915,6 +4002,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
bgColor: null,
+ bgMode: null, northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
@@ -4143,6 +4231,9 @@ class HouseplanCard extends LitElement {
room_color: d.roomColor,
room_opacity: d.roomOpacity,
bg_color: d.bgColor || undefined, // empty = inherit the general setting
+ bg_mode: d.bgMode || undefined, // sun on the plan (docs/SUN.md); empty = inherit
+ north_deg: d.northDeg ?? undefined,
+ sun_rays: d.sunRays ?? undefined,
fill_mode: d.fillMode,
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
@@ -4250,6 +4341,7 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
bgColor: null,
+ bgMode: null, northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
@@ -4305,12 +4397,153 @@ class HouseplanCard extends LitElement {
`;
}
+ // ================= SUN ON THE PLAN (docs/SUN.md) =================
+
+ /** Global settings with the general-settings dialog's pending values. */
+ private _sunGlobal(): any {
+ const gd = this._settingsDialog;
+ if (!gd) return this._settings;
+ return {
+ ...this._settings,
+ north_deg: gd.northDeg ?? undefined,
+ bg_mode: gd.bgMode,
+ sun_rays: gd.sunRays,
+ weather_entity: (gd.weatherEntity || '').trim() || undefined,
+ };
+ }
+
+ /** Current space settings with the space dialog's pending values. */
+ private _sunSpace(): any {
+ const sd = this._spaceDialog;
+ const saved = this._curSpaceCfg?.settings || {};
+ if (!sd || sd.mode !== 'edit' || sd.spaceId !== this._space) return saved;
+ return {
+ ...saved,
+ north_deg: sd.northDeg ?? undefined,
+ bg_mode: sd.bgMode ?? undefined,
+ sun_rays: sd.sunRays ?? undefined,
+ };
+ }
+
+ /** Effective compass; null = the whole sun feature is inert (docs/SUN.md). */
+ private _effNorth(): number | null {
+ return northDegOf(this._sunGlobal(), this._sunSpace());
+ }
+
+ private _effBgMode(): 'static' | 'daynight' {
+ return bgModeOf(this._sunGlobal(), this._sunSpace());
+ }
+
+ private _effSunRays(): boolean {
+ return sunRaysOn(this._sunGlobal(), this._sunSpace());
+ }
+
+ /** sun.sun, but only when the feature is armed (north_deg set somewhere). */
+ private _sunNow(): { azimuth: number; elevation: number } | null {
+ return this._effNorth() !== null ? sunStateOf(this.hass) : null;
+ }
+
+ /**
+ * Window light wedges (docs/SUN.md): view/kiosk only. Geometry recomputes
+ * ONLY when the memo key changes — sun attributes tick every ~30-120 s and
+ * everything else in `hass` must not trigger the polygon clipping.
+ */
+ private _renderSunRays(space: SpaceModel): TemplateResult {
+ const empty = svg`` as unknown as TemplateResult;
+ if (this._editing || !this._effSunRays()) return empty;
+ const north = this._effNorth();
+ const sun = north !== null ? sunStateOf(this.hass) : null;
+ if (!sun || sun.elevation <= 0) return empty;
+ const weather = weatherEntityOf(this._sunGlobal());
+ const cloud = cloudFactor(weather ? this.hass?.states?.[weather]?.state : null);
+ const alpha = rayAlpha(sun.elevation, cloud);
+ if (alpha <= 0) return empty;
+ // DEV-B701-01: the geometry signal must be _cfgEpoch, not _cfgRev.
+ // Every local mutation ends in _saveConfig(), which bumps the epoch
+ // SYNCHRONOUSLY; _cfgRev only moves after the debounced WS write is
+ // acked, so a rev-keyed memo served wedges for the OLD window position
+ // during the whole write window (and forever if the write failed).
+ const key = `${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${this._cfgEpoch}`;
+ if (!this._sunRaysCache || this._sunRaysCache.key !== key) {
+ const rooms = space.rooms
+ .map((r) => ({ id: r.id || '', poly: roomPoly(r) }))
+ .filter((r): r is { id: string; poly: number[][] } => !!r.id && !!r.poly);
+ const windows = this._openingsR
+ .filter((o) => o.type === 'window')
+ .map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
+ this._sunRaysCache = { key, rays: computeSunRays(rooms, windows, sun.azimuth, sun.elevation, north!) };
+ }
+ const rays = this._sunRaysCache.rays;
+ if (!rays.length) return empty;
+ const color = rayColor(dayPhase(sun.elevation).warmth);
+ return svg`
+ ${rays.map((r, i) => {
+ const mx = (r.a[0] + r.b[0]) / 2;
+ const my = (r.a[1] + r.b[1]) / 2;
+ return svg`
+
+
+ `;
+ })}
+
+
+ ${rays.map((r, i) => r.polys.map((p) => svg``))}
+ ` as unknown as TemplateResult;
+ }
+
+ /** One drag/click sample on the compass dial → dialog north_deg. */
+ private _compassPoint(ev: PointerEvent): void {
+ const el = ev.currentTarget as SVGSVGElement;
+ const r = el.getBoundingClientRect();
+ const dx = ev.clientX - (r.left + r.width / 2);
+ const dy = ev.clientY - (r.top + r.height / 2);
+ if (Math.hypot(dx, dy) < 5) return; // the dead centre says nothing about angle
+ let a = Math.round((Math.atan2(dx, -dy) * 180) / Math.PI);
+ if (ev.shiftKey) a = Math.round(a / 15) * 15; // coarse 15° steps with Shift
+ a = ((a % 360) + 360) % 360;
+ this._settingsDialog = { ...this._settingsDialog!, northDeg: a };
+ }
+
+ /** The compass dial: drag the «N» arrow around the ring (docs/SUN.md). */
+ private _renderCompass(): TemplateResult {
+ const d = this._settingsDialog!;
+ const deg = d.northDeg;
+ return html``;
+ }
+
/**
* Effective stage background: per-space override → global setting → '' (the
* theme default from the stylesheet). An open dialog previews its pending
- * value, so the color can be picked against the live plan.
+ * value, so the color can be picked against the live plan. In 'daynight'
+ * mode (docs/SUN.md) the sun's elevation paints the stage instead.
*/
private _stageBg(disp: SpaceDisplay): string {
+ if (this._effBgMode() === 'daynight') {
+ const sun = this._sunNow();
+ if (sun) return dayPhase(sun.elevation).bg;
+ }
const gd = this._settingsDialog;
const sd = this._spaceDialog;
const globalBg = gd ? gd.bgColor || '' : stageBgOf(this._settings, { bgColor: null });
@@ -4343,6 +4576,10 @@ class HouseplanCard extends LitElement {
colors: JSON.parse(JSON.stringify(this._fillColors)),
glowRadius,
bgColor: stageBgOf(this._settings, { bgColor: null }) || null,
+ northDeg: northDegOf(this._settings, {}),
+ bgMode: bgModeOf(this._settings, {}),
+ sunRays: sunRaysOn(this._settings, {}),
+ weatherEntity: weatherEntityOf(this._settings) || '',
busy: false,
};
};
@@ -4367,6 +4604,17 @@ class HouseplanCard extends LitElement {
else delete settings.glow_radius_cm;
if (d.bgColor) settings.bg_color = d.bgColor;
else delete settings.bg_color;
+ // sun on the plan (docs/SUN.md): defaults are never stored
+ if (d.northDeg !== null && Number.isInteger(d.northDeg) && d.northDeg >= 0 && d.northDeg <= 359)
+ settings.north_deg = d.northDeg;
+ else delete settings.north_deg;
+ if (d.bgMode === 'daynight') settings.bg_mode = 'daynight';
+ else delete settings.bg_mode;
+ if (d.sunRays) settings.sun_rays = true;
+ else delete settings.sun_rays;
+ const we = (d.weatherEntity || '').trim();
+ if (we) settings.weather_entity = we;
+ else delete settings.weather_entity;
this._serverCfg = { ...cfg, settings };
await this._saveConfigNow();
this._settingsDialog = null;
@@ -4382,6 +4630,34 @@ class HouseplanCard extends LitElement {
}
}
+ /** Boolean toggle for dialog rows: the native ha-switch when the HA
+ * frontend provides it, the classic checkbox otherwise (older HA, the
+ * smoke env). The ha-* API is undocumented and shifts between HA
+ * releases, so the presence check is the ONLY coupling: both branches
+ * fire `change` and both are read back via `.checked` off the event
+ * target - one handler, two renderers. */
+ private _boolInput(checked: boolean, onChange: (v: boolean) => void, disabled = false): TemplateResult {
+ const h = (e: Event) => onChange(!!(e.target as HTMLInputElement).checked);
+ return customElements.get('ha-switch')
+ ? html``
+ : html``;
+ }
+
+ /** Range slider for dialog rows: ha-slider when available, plain
+ * input[type=range] otherwise. Same fallback contract as _boolInput;
+ * ha-slider emits `input` while dragging and `change` on release
+ * (which of the two carries the final value differs between HA
+ * versions - listen to both, the handler is idempotent). */
+ private _rangeInput(min: number, max: number, step: number, value: number, onInput: (v: number) => void): TemplateResult {
+ const h = (e: Event) => {
+ const n = Number((e.target as HTMLInputElement).value);
+ if (Number.isFinite(n)) onInput(n);
+ };
+ return customElements.get('ha-slider')
+ ? html``
+ : html``;
+ }
+
private _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
const d = this._settingsDialog!;
const v = d.colors[key];
@@ -4389,8 +4665,7 @@ class HouseplanCard extends LitElement {
${this._t(labelKey as any)}
this._setFillColor(key, { c: (e.target as HTMLInputElement).value })} />
- this._setFillColor(key, { a: Number((e.target as HTMLInputElement).value) / 100 })} />
+ ${this._rangeInput(0, 100, 1, Math.round(v.a * 100), (n) => this._setFillColor(key, { a: n / 100 }))}
${Math.round(v.a * 100)}%
${this._renderCardPreview(
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 8006d99a..84b35b37 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -328,6 +328,8 @@
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
"room.settings_short": "Room settings",
"room.unnamed": "Unnamed room",
+ "marker.use_climate_temp": "Use the device's temperature sensor",
+ "marker.use_climate_temp_tip": "Air conditioners and thermostats know the room temperature (current_temperature). Shows it as a badge next to the icon and counts it in the room average — like a regular thermometer.",
"marker.is_light": "This device is a light source",
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
"confirm.unlock": "Unlock “{name}”?",
@@ -384,7 +386,28 @@
"gs.bg_color": "Background around the plan",
"gs.bg_default": "Theme default",
"gs.bg_theme": "theme default",
+ "gs.bg_mode": "Plan background",
+ "gs.bg_static": "Static color",
+ "gs.bg_daynight": "Follows the sun (day/night)",
+ "gs.bg_daynight_hint": "The stage follows the sun: neutral day, warm golden hour, deep night. Needs the compass below.",
+ "gs.sun_group": "Sun",
+ "gs.sun_missing": "The sun.sun entity was not found — the sun features stay off.",
+ "gs.north": "North on the plan",
+ "gs.north_ph": "not set",
+ "gs.north_hint": "Point the arrow at north (1° steps, 15° with Shift) or type the degrees — until then the sun features stay off.",
+ "gs.north_clear": "Clear",
+ "gs.north_letter": "N",
+ "gs.sun_rays": "Sunlight through windows",
+ "gs.weather": "Weather entity",
+ "gs.weather_ph": "weather.home (optional)",
"space.bg_color": "Background around the plan",
"space.bg_inherit": "Inherit general",
- "space.bg_inherited": "inherits general settings"
+ "space.bg_inherited": "inherits general settings",
+ "space.bg_mode": "Plan background",
+ "space.north": "North on the plan (override)",
+ "space.north_inherited": "inherited: {v}",
+ "space.sun_rays": "Sunlight through windows",
+ "space.sun_inherit": "Inherit general",
+ "space.sun_on": "On",
+ "space.sun_off": "Off"
}
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index 525dd528..e5b1f775 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -328,6 +328,8 @@
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
"room.settings_short": "Настройки комнаты",
"room.unnamed": "Комната без имени",
+ "marker.use_climate_temp": "Использовать датчик температуры устройства",
+ "marker.use_climate_temp_tip": "Кондиционеры и термостаты знают температуру комнаты (current_temperature). Показывает её плашкой у значка и учитывает в средней температуре комнаты — как обычный термометр.",
"marker.is_light": "Это устройство — источник света",
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
"confirm.unlock": "Открыть замок «{name}»?",
@@ -384,7 +386,28 @@
"gs.bg_color": "Цвет фона вокруг плана",
"gs.bg_default": "Как в теме",
"gs.bg_theme": "по умолчанию — из темы",
+ "gs.bg_mode": "Фон плана",
+ "gs.bg_static": "Статичный цвет",
+ "gs.bg_daynight": "Следует за солнцем (день/ночь)",
+ "gs.bg_daynight_hint": "Фон следует за солнцем: нейтральный день, тёплый золотой час, глубокая ночь. Нужен компас ниже.",
+ "gs.sun_group": "Солнце",
+ "gs.sun_missing": "Сущность sun.sun не найдена — солнечные функции выключены.",
+ "gs.north": "Север на плане",
+ "gs.north_ph": "не задан",
+ "gs.north_hint": "Направьте стрелку на север (шаг 1°, с Shift 15°) или введите градусы — до этого солнечные функции молчат.",
+ "gs.north_clear": "Сбросить",
+ "gs.north_letter": "С",
+ "gs.sun_rays": "Солнце в окнах",
+ "gs.weather": "Сущность погоды",
+ "gs.weather_ph": "weather.home (необязательно)",
"space.bg_color": "Цвет фона вокруг плана",
"space.bg_inherit": "Наследовать общий",
- "space.bg_inherited": "наследуется из общих настроек"
+ "space.bg_inherited": "наследуется из общих настроек",
+ "space.bg_mode": "Фон плана",
+ "space.north": "Север на плане (переопределение)",
+ "space.north_inherited": "наследуется: {v}",
+ "space.sun_rays": "Солнце в окнах",
+ "space.sun_inherit": "Наследовать общий",
+ "space.sun_on": "Вкл",
+ "space.sun_off": "Выкл"
}
diff --git a/src/logic.ts b/src/logic.ts
index 4b7da364..fab89629 100644
--- a/src/logic.ts
+++ b/src/logic.ts
@@ -62,6 +62,30 @@ export function roomPoly(r: any): number[][] | null {
return null;
}
+/**
+ * Paper shapes for a hand-drawn plan (owner 2026-08-03): the opaque paper
+ * follows the ROOM CONTOURS, not their bounding box — an L-shaped house or a
+ * detached building must not grow a white rectangle around itself. One shape
+ * per room, in EXACTLY the geometry the room itself renders (same polygon
+ * points string, same rounded rect), so the paper never peeks past a wall;
+ * the union of the stack is the paper. Islands simply paint over their
+ * parent; open (virtual) boundaries do not affect the paper at all.
+ */
+export function paperRoomShapes(rooms: any[]): Array<
+ | { poly: string }
+ | { rect: { x: number; y: number; w: number; h: number; rx: number } }
+> {
+ const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
+ for (const r of rooms || []) {
+ if (r?.poly?.length >= 3) {
+ out.push({ poly: r.poly.map((p: number[]) => p.join(',')).join(' ') });
+ } else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
+ out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
+ }
+ }
+ return out;
+}
+
export function roomEdges(rooms: any[]): number[][] {
const out: number[][] = [];
const seen = new Set();
@@ -115,6 +139,76 @@ export function snapToWall(
return best;
}
+export interface OpeningShoulders {
+ /** Endpoints of the wall edge — the ONE room-polygon edge the opening sits on. */
+ wallA: number[];
+ wallB: number[];
+ /** Distance (render units) from each wall end to the NEAREST opening edge, >= 0. */
+ sideA: number;
+ sideB: number;
+ /** Midpoint of each shoulder — where a measure badge sits. */
+ midA: number[];
+ midB: number[];
+ /** Center of the wall edge. */
+ wallCenter: number[];
+ /** The opening's center coincides with the wall center (within tol). */
+ centered: boolean;
+}
+
+/**
+ * Shoulders of an opening being dragged along a wall: distances from the
+ * opening's edges to the ends of the wall it sits on, plus the "centered on the
+ * wall" flag (owner 2026-08-03: live ruler badges + a perpendicular dashed tick
+ * when the opening is exactly in the middle). The wall is ONE room-polygon
+ * edge — exactly the edge the opening is snapped to (owner 2026-08-03:
+ * «only the wall of one room», collinear edges of a NEIGHBOURING room are
+ * never merged in). Selection mirrors snapToWall: among the edges collinear
+ * with the opening's line, the one nearest to its center wins, first in
+ * roomEdges order on a tie — so the ruler always measures the same edge the
+ * drag snapped to. Works for angled walls too: everything is measured along
+ * the wall's direction. Returns null when the center does not lie on any wall.
+ */
+export function openingShoulders(
+ center: number[], angleDeg: number, lengthPx: number, rooms: any[], tol: number, eps = 1.0,
+): OpeningShoulders | null {
+ const rad = (angleDeg * Math.PI) / 180;
+ const dir = [Math.cos(rad), Math.sin(rad)];
+ // edges lying on the opening's wall line → pick the ONE nearest to the
+ // center along that line (t=0 at the center). Same tie-break as snapToWall:
+ // strictly-nearer wins, so on a tie (shared-boundary fragments, a junction
+ // vertex) the first edge in roomEdges order is used — the very edge the
+ // drag snapped to. NO merging of collinear runs across rooms.
+ let wall: [number, number] | null = null;
+ let bestD = eps;
+ for (const e of roomEdges(rooms)) {
+ const pts = [[e[0], e[1]], [e[2], e[3]]];
+ const off = (p: number[]) => Math.abs(dir[0] * (p[1] - center[1]) - dir[1] * (p[0] - center[0]));
+ if (off(pts[0]) > eps || off(pts[1]) > eps) continue; // not collinear with the wall line
+ const t0 = (pts[0][0] - center[0]) * dir[0] + (pts[0][1] - center[1]) * dir[1];
+ const t1 = (pts[1][0] - center[0]) * dir[0] + (pts[1][1] - center[1]) * dir[1];
+ const lo = Math.min(t0, t1), hi = Math.max(t0, t1);
+ const d = lo > 0 ? lo : hi < 0 ? -hi : 0; // distance from the center to the edge, along the line
+ if (d < bestD) { bestD = d; wall = [lo, hi]; }
+ }
+ if (!wall) return null; // center is not on any edge of this direction
+ const [tMin, tMax] = wall;
+ const half = lengthPx / 2;
+ const sideA = Math.max(0, -half - tMin);
+ const sideB = Math.max(0, tMax - half);
+ const at = (t: number) => [center[0] + dir[0] * t, center[1] + dir[1] * t];
+ const tMid = (tMin + tMax) / 2;
+ return {
+ wallA: at(tMin),
+ wallB: at(tMax),
+ sideA,
+ sideB,
+ midA: at((tMin - half) / 2),
+ midB: at((half + tMax) / 2),
+ wallCenter: at(tMid),
+ centered: Math.abs(tMid) <= tol,
+ };
+}
+
/**
* How far open an opening is drawn, 0..1, from its contact sensor state.
* No sensor bound → doors default to open (the familiar swing symbol), windows to
diff --git a/src/space-card.ts b/src/space-card.ts
index faf4795b..e0a56a7d 100644
--- a/src/space-card.ts
+++ b/src/space-card.ts
@@ -205,6 +205,15 @@ class HouseplanSpaceCard extends LitElement {
height: 100%;
display: block;
}
+ /* Opaque plan paper — the scene bg_color/daynight sky shows only AROUND
+ the plan (owner 2026-08-03). The static card keeps its historical
+ canvas colour: the theme card background. Drawn plans paper the room
+ contours (per-room shapes, fill only, no stroke) — same contract as
+ the full card. */
+ .hp-paper {
+ fill: var(--ha-card-background, var(--card-background-color, #111));
+ stroke: none;
+ }
.hp-static-stage .devlayer {
position: absolute;
inset: 0;
diff --git a/src/space-render.ts b/src/space-render.ts
index 980e60f2..87130baf 100644
--- a/src/space-render.ts
+++ b/src/space-render.ts
@@ -8,9 +8,10 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
-import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf } from './logic';
+import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf, paperRoomShapes } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
+import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
@@ -146,12 +147,37 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: [];
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
- // the static card paints the same background around the plan as the full one
- const stageBg = stageBgOf(o.cfg?.settings, disp);
+ // The static card paints the same background around the plan as the full
+ // one. In 'daynight' mode (docs/SUN.md) the sun's elevation paints it —
+ // wedges stay full-card-only in v1, the background alone follows the sky.
+ const spaceSettings = (o.cfg.spaces.find((sp: any) => sp.id === o.spaceId) as any)?.settings || {};
+ let sunBg = '';
+ if (bgModeOf(o.cfg?.settings, spaceSettings) === 'daynight' && northDegOf(o.cfg?.settings, spaceSettings) !== null) {
+ const sun = sunStateOf(o.hass);
+ if (sun) sunBg = dayPhase(sun.elevation).bg;
+ }
+ const stageBg = sunBg || stageBgOf(o.cfg?.settings, disp);
+
+ // Opaque plan paper (owner 2026-08-03), same contract as the full card: the
+ // scene background never bleeds through the plan. With a backdrop image the
+ // paper is the image rect; a hand-drawn plan papers the ROOM CONTOURS
+ // (paperRoomShapes) — never their bounding box, so the scene colour reaches
+ // the exterior walls of an L-shaped house and fills the gaps between
+ // detached buildings. An empty drawn space has no paper at all.
+ const paper = space.bg
+ ? { x: space.bg.x, y: space.bg.y, w: space.bg.w, h: space.bg.h }
+ : null;
return html`