`}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=cs,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=cs,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=us,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=us,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
${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:ke(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`
+ `}_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`
- `}_renderMarkupLayer(t){const e=this._openPairs().flatMap(t=>t.segs),i=e.length?ui(this._segments,e,.02*this._gridPitch):this._segments,s=this._path,o=this._gridPitch;return j`
+ `}_renderMarkupLayer(t){const e=this._openPairs().flatMap(t=>t.segs),i=e.length?_i(this._segments,e,.02*this._gridPitch):this._segments,s=this._path,o=this._gridPitch;return j`
${i.map(t=>j``)}
${s.length>1?j``:G}
@@ -2208,13 +2229,13 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
@click=${()=>this._setMode("view")}>
-
`}_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(hi);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(di);return B`
`}}us.properties={_hdrH:{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},_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},_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}},us.ZOOM_MAX=8,us.ZOOM_MIN=.4,us._touchSeen=!1,us._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,us.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",us),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.50.4 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+
`}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=cs,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=cs,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=us,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=us,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
${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:ke(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`
+ `}_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`
- `}_renderMarkupLayer(t){const e=this._openPairs().flatMap(t=>t.segs),i=e.length?ui(this._segments,e,.02*this._gridPitch):this._segments,s=this._path,o=this._gridPitch;return j`
+ `}_renderMarkupLayer(t){const e=this._openPairs().flatMap(t=>t.segs),i=e.length?_i(this._segments,e,.02*this._gridPitch):this._segments,s=this._path,o=this._gridPitch;return j`
${i.map(t=>j``)}
${s.length>1?j``:G}
@@ -2208,13 +2229,13 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
@click=${()=>this._setMode("view")}>
-
`}_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(hi);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(di);return B`
`}}us.properties={_hdrH:{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},_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},_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}},us.ZOOM_MAX=8,us.ZOOM_MIN=.4,us._touchSeen=!1,us._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,us.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",us),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.50.4 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+
`}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=cs,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=cs,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=us,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=us,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
${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:ke(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`
+ `}_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`
- `}_renderMarkupLayer(t){const e=this._openPairs().flatMap(t=>t.segs),i=e.length?ui(this._segments,e,.02*this._gridPitch):this._segments,s=this._path,o=this._gridPitch;return j`
+ `}_renderMarkupLayer(t){const e=this._openPairs().flatMap(t=>t.segs),i=e.length?_i(this._segments,e,.02*this._gridPitch):this._segments,s=this._path,o=this._gridPitch;return j`
${i.map(t=>j``)}
${s.length>1?j``:G}
@@ -2208,13 +2229,13 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
@click=${()=>this._setMode("view")}>
-
`}_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(hi);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(di);return B`
`}}us.properties={_hdrH:{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},_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},_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}},us.ZOOM_MAX=8,us.ZOOM_MIN=.4,us._touchSeen=!1,us._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,us.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",us),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.50.4 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+
`}}fs.properties={_hdrH:{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},_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},_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}},fs.ZOOM_MAX=8,fs.ZOOM_MIN=.4,fs._touchSeen=!1,fs._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,fs.styles=Wi,customElements.get("houseplan-card")||customElements.define("houseplan-card",fs),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.51.0 ","background:#3ea6ff;color:#04121f;font-weight:700","");
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index dcd44ab5..255eb1f5 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,61 @@
# Changelog
+## v1.51.0 — 2026-07-29
+
+**Hiding is an explicit flag now** (docs/FILTERING.md)
+
+- **Every device dialog — virtual ones included — has a "Hide device from
+ plan" checkbox.** The old on-the-fly filter survives only as the SEEDER of
+ those flags: on first load by an editing client the config is materialised
+ once — non-physical devices (bridges, scenes, service integrations, lamps
+ folded into a light group) get the flag, and from then on the flag belongs
+ to you. Unticking it is final: the seeder never revisits a device you have
+ decided about. New non-physical devices hide silently; physical ones keep
+ the red-dot flow.
+- **"Show all" became "Show hidden"** — a local tool of the device editor
+ (nothing flips on the wall tablets), showing hidden devices as translucent
+ BLUE dashed ghosts: clearly apart from a grey unavailable icon, and with no
+ live-state paint at all — a ghost is configuration, not status. Click one
+ to untick the box. "Remove from plan" is gone for bound devices (the
+ checkbox is the way); a virtual device's Delete still deletes.
+- Hidden devices still count toward the room's Zigbee signal, but cast no
+ glow and no light fill — an invisible device casts no visible light. Room
+ climate is unchanged. Old configs behave exactly as before until an
+ editing client materialises them.
+
+**Yellow means working right now**
+
+- One principle for the glowing icon: a light is shining, a socket is
+ powering, a fan is spinning, media is playing, a vacuum is cleaning — or a
+ radiator valve is ACTUALLY heating (hvac_action), not merely enabled for
+ the winter. Previously a TRV could glow yellow because its anti-scaling
+ service switch was on while the actually-heating one stayed dark: the
+ primary-entity search let a vendor's config switch outrank the visible
+ climate entity. Fixed — a service entity never beats the device's visible
+ main function (this also fixes tap-toggle and icon morphing on such
+ devices).
+- The glow pool and the icon color now ask the same question: a lit light
+ yellows its icon in every fill mode, by exactly the condition that lights
+ its glow spot. The README (en+ru) documents the color language.
+
+**The editors, on a phone**
+
+- Pinch zoom and pan gestures now work in every editor: drawing is
+ click-based, so the two coexist — a moving finger pans, two fingers pinch,
+ releasing after a gesture never draws a point, a clean tap still does.
+
+**The room settings button**
+
+- Detached from the (movable) room name: it sits at the VISUAL centre of the
+ room — the centre of the largest inscribed circle with a pull toward the
+ area centroid, so an elongated room centres it on both axes and an L-shaped
+ one keeps it in the middle of its widest part, never down a thin limb.
+- Half its former size, sized from the device icon (70% of the icon box) and
+ zooming WITH the plan instead of keeping a constant screen size.
+- The small metric rows under the room name (temperature, humidity, signal,
+ lights) now show in the plan editor too, and the name renders in exactly
+ the same spot in view mode and in the editor.
+
## v1.50.4 — 2026-07-29
**From the v1.50.3 review**
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index ccce4dab..c4d36f7c 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -6,6 +6,63 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
+## v1.51.0 — 2026-07-29
+
+**Скрытие — теперь явная галка** (docs/FILTERING.md)
+
+- **В диалоге каждого устройства — включая виртуальные — есть галка «Скрыть
+ устройство с плана».** Старый фильтр «на лету» остался только сеятелем
+ этих галок: при первом открытии редактором конфиг один раз
+ материализуется — нефизические устройства (мосты, сцены, служебные
+ интеграции, лампы, свёрнутые в световую группу) получают галку, и дальше
+ она принадлежит вам. Снятая галка — навсегда: сеятель не возвращается к
+ устройству, о котором вы уже решили. Новые нефизические прячутся молча;
+ физические — с красной точкой, как раньше.
+- **«Показать все» стала «Показать скрытые»** — локальный инструмент
+ редактора устройств (на настенных планшетах ничего не мигает): скрытые
+ рисуются полупрозрачными СИНИМИ пунктирными призраками — не спутать с
+ серым недоступным — и совсем без живой индикации: призрак — это
+ конфигурация, а не статус. Клик — и галку можно снять. «Удалить с плана»
+ у привязанных устройств убрана (галка — единственный путь); «Удалить» у
+ виртуального по-прежнему удаляет.
+- Скрытые устройства учитываются в Zigbee-сигнале комнаты, но не дают ни
+ пятна света, ни заливки — не видно устройства, не видно и света. Климат
+ комнат без изменений. Старые конфиги ведут себя по-старому, пока их не
+ материализует клиент с правом записи.
+
+**Жёлтый значит «работает прямо сейчас»**
+
+- Один принцип для светящегося значка: лампа светит, розетка подаёт,
+ вентилятор крутится, медиа играет, пылесос убирает — или термоголовка
+ РЕАЛЬНО греет (hvac_action), а не просто включена на зиму. Раньше
+ термоголовка могла светиться жёлтым из-за включённой защиты от накипи, а
+ реально греющая оставалась тёмной: выбор главной сущности пропускал
+ служебный свитч производителя вперёд видимого climate. Исправлено —
+ служебная сущность больше никогда не побеждает видимую основную функцию
+ (это чинит и tap-переключение, и морфинг иконок у таких устройств).
+- Пятно света и цвет значка задают один и тот же вопрос: горящая лампа
+ желтит значок в любом режиме заливки ровно тем условием, что зажигает её
+ пятно. README (en+ru) описывает язык цветов.
+
+**Редакторы на телефоне**
+
+- Пинч-зум и панорамирование жестами работают во всех редакторах: рисование
+ кликовое, так что они совместимы — палец с движением панорамирует, два
+ пальца зумируют, отпускание после жеста не ставит точку, чистый тап —
+ ставит.
+
+**Кнопка настроек комнаты**
+
+- Отвязана от (передвигаемого) названия: стоит в ВИЗУАЛЬНОМ центре комнаты —
+ центр наибольшей вписанной окружности с тяготением к центроиду площади,
+ поэтому вытянутая комната центрирует её по обеим осям, а Г-образная
+ держит в середине широкой части и не утаскивает в узкую.
+- Вдвое меньше прежней, размер от иконки устройства (70% бокса) и зумится
+ ВМЕСТЕ с планом, а не держит постоянный экранный размер.
+- Мелкие подписи под названием комнаты (температура, влажность, сигнал,
+ свет) теперь видны и в редакторе плана, а название рендерится ровно в том
+ же месте в просмотре и в редакторе.
+
## v1.50.4 — 2026-07-29
**По ревью v1.50.3**
diff --git a/docs/FILTERING.md b/docs/FILTERING.md
new file mode 100644
index 00000000..b37ef9d4
--- /dev/null
+++ b/docs/FILTERING.md
@@ -0,0 +1,61 @@
+# Filtering: the explicit "hide from plan" flag
+
+Agreed with the owner 2026-07-29. This document is the source of truth for the
+mechanism; the code follows it.
+
+## Principle
+
+Whether a device is on the plan is an EXPLICIT, per-device fact: the
+"Hide from plan" checkbox, stored as `marker.hidden`. The old on-the-fly
+filtering algorithm survives only as the SEEDER of those flags — it decides
+the initial value once, and the user owns the flag from then on.
+
+## Data model
+
+- `marker.hidden: true` — hidden from the plan. For an auto device without a
+ marker, hiding creates a stub marker (this mechanism predates this spec).
+- `marker.hidden: false` (marker present) — explicitly VISIBLE: the seeder
+ never touches a device that has any marker, so unhiding must KEEP the stub
+ marker. That is the re-seed protection.
+- No marker — never evaluated by the seeder yet, or a plain physical device.
+- `settings.filter_seeded: true` — this config has been materialised.
+- `settings.show_all` — removed (deleted during materialisation). The old
+ toggle was shared config state; the new one is a local editor tool.
+
+## Seeding
+
+"Non-physical" = the old filter rules: excluded integration domains, model
+"Group", scene-like models, bridges, myheat children, and individual lamp
+devices in an area covered by a light group (when group folding is on).
+
+The seeder runs on the editing client (write permission required) whenever
+devices rebuild, and creates `hidden: true` stub markers for non-physical
+devices in BOUND areas that have NO marker. It is idempotent: marked devices
+are never revisited. It fires on:
+
+1. first load of a config without `filter_seeded` (materialises the current
+ behaviour; nothing changes visually, the flags become real and editable);
+2. an area newly bound to the plan;
+3. a new device appearing in a bound area — non-physical ones are hidden
+ silently (no red dot); physical ones keep the red-dot flow.
+
+Until a config is seeded (`filter_seeded` absent), `buildDevices` applies the
+LEGACY runtime filter, so a read-only client on an old config sees exactly
+the old behaviour until an editing client materialises it.
+
+## Behaviour
+
+- Hidden devices ARE built (flagged `hidden`), but not rendered in any mode,
+ except the device editor with "Show hidden devices" on — there they render
+ ghosted (translucent, dashed) and clicking opens the dialog to untick.
+- "Show hidden devices" (rename of "Show all") is LOCAL, ephemeral state of
+ the current tab.
+- Room LQI counts hidden devices (owner's decision).
+- Light fill and glow do NOT count hidden devices — an invisible device casts
+ no visible light (owner's decision). Room climate is registry-wide and
+ unaffected, as before.
+- The checkbox appears in the dialog of EVERY device kind, virtual included.
+- "Remove from plan" disappears for auto/entity devices (the checkbox is the
+ one way to hide); a virtual device's "Delete" remains a real deletion.
+- Duplicate names are still numbered, light groups still fold — those are
+ aggregation, not hiding.
diff --git a/docs/STATUS.md b/docs/STATUS.md
index cbae5654..552213e6 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
-| Version | **v1.50.4** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
+| Version | **v1.51.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| 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; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
-| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.4** via direct copy (HACS custom repo also installed) |
+| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.51.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 |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 3e977579..c7146578 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -239,6 +239,29 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved]
+- [ ] Room settings button (dev): detached from the (movable) name label —
+ always at the room's geometric centre, one button-height below it; sized
+ at 70% of a device icon and zooming WITH the plan; the small metric rows
+ under the room name now show in the plan editor too
+ [auto: smoke_room_cards gearDetached/plainInPlan]
+- [ ] Hide-from-plan flag (dev, docs/FILTERING.md): every device dialog has
+ the checkbox, incl. virtual; hidden devices vanish from every mode and
+ the count, still count toward room LQI, cast no glow/light fill; the
+ device editor's "Show hidden" (local, per tab) shows them as BLUE
+ dashed ghosts — distinct from a grey unavailable icon — with NO live
+ state paint (no yellow, no alarm, no ripple);
+ unticking keeps a hidden:false marker (re-seed protection); an old
+ config materialises on first load by an editing client and legacy
+ clients keep the old behaviour until then
+ [auto: smoke_hidden_flag + unit seedHiddenBindings/seeded/legacy]
+- [ ] Yellow means working (dev): a TRV whose hvac_action is heating glows
+ yellow; one that is merely enabled (idle) or has a service switch on
+ (anti-scaling, child lock) stays dark; a lit light yellows its icon in
+ every fill mode by the same condition that lights the glow pool
+ [auto: smoke_yellow_principle + unit primaryEntity/litLightEntity]
+- [ ] Editor gestures on touch (dev): in the plan editor on a phone, pinch
+ zooms and a moving finger pans; releasing after a gesture does not draw
+ a point, a clean tap still does [auto: smoke_editor_gestures]
- [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero
viewport and a negative rect renders identically sane in BOTH cards —
full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry]
diff --git a/package-lock.json b/package-lock.json
index f0bda139..b1e8c1eb 100755
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
- "version": "1.50.4",
+ "version": "1.51.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
- "version": "1.50.4",
+ "version": "1.51.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
diff --git a/package.json b/package.json
index 042c8cee..221383e1 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
- "version": "1.50.4",
+ "version": "1.51.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 1c079221..dfc1d710 100644
--- a/src/devices.ts
+++ b/src/devices.ts
@@ -14,6 +14,8 @@ export interface BuildCtx {
markers: Marker[];
settings: ServerConfig['settings'];
excluded: Set;
+ /** LEGACY only: honoured while the config has no settings.filter_seeded.
+ * Seeded configs hide by explicit marker flags (docs/FILTERING.md). */
showAll: boolean;
firstSpaceId: string;
/** Localized display strings for generated device names. */
@@ -56,14 +58,22 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
const all = entIds
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
.filter((e) => e.reg);
- // Tiers: visible primary entities first, but a HIDDEN light still beats a
- // visible config switch. Real case: individual lamps folded into a light
- // group get hidden in the registry — the device's main function is still
- // the lamp, and tap-toggle must flip IT, not the do-not-disturb switch.
+ // Tiers, in order: functional and visible; functional but hidden (lamps
+ // folded into a light group get hidden in the registry — the device's main
+ // function is still the lamp, and tap-toggle must flip IT, not the
+ // do-not-disturb switch); config/diagnostic but visible; everything.
+ //
+ // The TIER loop is the OUTER one. It used to be inner, which made the
+ // domain priority stronger than visibility — and `switch` outranks
+ // `climate`, so a TRV's primary was whatever service switch the vendor
+ // ships (child lock, anti-scaling): the icon glowed yellow because scale
+ // protection was on, while the head that was actually HEATING stayed dark
+ // (owner's install, verified live 2026-07-29). A service entity must never
+ // beat the device's visible main function.
const tiers = [
all.filter((e) => !e.reg.hidden && !e.reg.entity_category),
- all.filter((e) => !e.reg.hidden),
all.filter((e) => !e.reg.entity_category),
+ all.filter((e) => !e.reg.hidden),
all,
];
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
@@ -72,16 +82,35 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
if (t) return t.eid;
}
}
- for (const dom of DOMAIN_PRIORITY) {
- for (const tier of tiers) {
+ for (const tier of tiers) {
+ for (const dom of DOMAIN_PRIORITY) {
const found = tier.find((e) => e.eid.split('.')[0] === dom);
if (found) return found.eid;
}
}
+ // no known domain anywhere — first entity of the best non-empty tier
for (const tier of tiers) if (tier.length) return tier[0].eid;
return undefined;
}
+/**
+ * The lit light entity of a device, or null — ONE truth for "this thing is
+ * shining": the glow-mode pool and the yellow icon both ask here, so the
+ * light pool and the icon can never disagree (owner's principle, 2026-07-29).
+ * Normally that is a lit `light.*`; with the "is a light source" flag (a smart
+ * switch driving dumb fixtures) any lit entity counts — the switch itself, or
+ * the lights it controls when they are bound.
+ */
+export function litLightEntity(
+ hass: any, d: { entities: string[]; marker?: { is_light?: boolean | null; controls?: string[] | null } | null },
+): string | null {
+ const forced = d.marker?.is_light === true;
+ const pool = forced
+ ? [...(d.marker?.controls || []), ...d.entities]
+ : d.entities.filter((e) => e.startsWith('light.'));
+ return pool.find((e) => hass.states[e]?.state === 'on') || null;
+}
+
/** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
export function lqiFor(hass: any, entIds: string[]): number | null {
const vals: number[] = [];
@@ -175,6 +204,7 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
function applyMarker(item: DevItem, m: Marker): void {
item.marker = m;
+ if (m.hidden) item.hidden = true;
if (m.name) item.name = m.name;
if (m.icon) item.icon = m.icon;
if (m.model != null) item.model = m.model;
@@ -184,6 +214,43 @@ function applyMarker(item: DevItem, m: Marker): void {
item.tapAction = m.tap_action ?? null;
}
+/**
+ * The SEEDER (docs/FILTERING.md): bindings of non-physical devices in bound
+ * areas that have no marker yet. The editing client turns each into a
+ * `hidden: true` stub marker — after that the flag belongs to the user, and a
+ * marker of ANY kind (even `hidden: false`) makes the device invisible to
+ * this function forever. Idempotent by construction.
+ */
+export function seedHiddenBindings(ctx: Omit): string[] {
+ const { hass: h, areaToSpace, markers, settings, excluded, iconRules } = ctx;
+ const groupLights = settings.group_lights !== false;
+ const groups = lightGroups(h, groupLights);
+ const groupedAreas = new Set(groups.map((g) => g.area));
+ const entsBy = entitiesByDevice(h);
+ const marked = new Set(markers.map((m) => m.binding));
+ const out: string[] = [];
+ for (const dev of Object.values(h.devices)) {
+ const area = dev.area_id;
+ if (!area || !areaToSpace[area]) continue;
+ if (dev.entry_type === 'service') continue;
+ if (marked.has('device:' + dev.id)) continue;
+ const entIds = entsBy[dev.id] || [];
+ const dom = domainOfDevice(h, dev, entIds);
+ let nonPhysical =
+ excluded.has(dom)
+ || dev.model === 'Group'
+ || /scene/i.test(dev.model || '')
+ || /bridge/i.test((dev.model || '') + (dev.name || ''))
+ || (dom === 'myheat' && !!dev.via_device_id);
+ if (!nonPhysical && groupLights && groupedAreas.has(area)) {
+ const name = (dev.name_by_user || dev.name || '').trim();
+ if (resolveIcon(h, name, dev.model, entIds, iconRules) === 'mdi:lightbulb') nonPhysical = true;
+ }
+ if (nonPhysical) out.push('device:' + dev.id);
+ }
+ return out;
+}
+
/** Filtering + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
@@ -207,11 +274,13 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
if (dev.entry_type === 'service') continue;
if (claimed.has('device:' + dev.id)) continue; // a marker will take over below
const marker = markerFor('device', dev.id);
- if (marker && marker.hidden) continue;
+ if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
- // filtering (can be turned off with the “show all” toggle)
- if (!showAll) {
+ // LEGACY runtime filter: only while the config is not yet materialised
+ // (docs/FILTERING.md). A seeded config hides by explicit marker flags.
+ const legacy = !settings.filter_seeded;
+ if (legacy && !showAll) {
if (excluded.has(dom)) continue;
if (dev.model === 'Group') continue;
if (/scene/i.test(dev.model || '')) continue;
@@ -222,7 +291,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const key = name + '|' + area;
let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
- if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
+ if (legacy && !showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
// duplicates by “name|zone” are numbered rather than hidden
seen[key] = (seen[key] || 0) + 1;
const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name;
@@ -265,7 +334,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
// 3) explicit markers (rebinding/metadata/virtual)
for (const m of markers) {
- if (m.hidden) continue;
+ // Hidden is a FLAG now, not an absence: the device is built (room LQI
+ // still counts it) and the renderer decides. Legacy configs keep the old
+ // "hidden = gone" until they are seeded (docs/FILTERING.md).
+ if (m.hidden && !settings.filter_seeded) continue;
const [kind, ref] = m.binding.split(':');
if (kind === 'device') {
const dev = h.devices[ref];
@@ -343,10 +415,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
* Light situation of an area: 'on' if any light entity of the area's devices is on,
* 'off' if lights exist but none is on, 'none' when the area has no lights at all.
*/
-export function areaLights(hass: any, devices: { area: string; entities: string[] }[], area: string): 'on' | 'off' | 'none' {
+export function areaLights(hass: any, devices: { area: string; entities: string[]; hidden?: boolean }[], area: string): 'on' | 'off' | 'none' {
let seen = false;
for (const d of devices) {
- if (d.area !== area) continue;
+ if (d.area !== area || d.hidden) continue; // an invisible device casts no visible light
for (const eid of d.entities) {
if (!eid.startsWith('light.')) continue;
seen = true;
@@ -503,13 +575,13 @@ export function areaClimate(
/** How many of the area's lights are on: {on, total}, or null without lights. */
export function areaLightStats(
hass: any,
- devices: { area: string; entities: string[] }[],
+ devices: { area: string; entities: string[]; hidden?: boolean }[],
area: string,
): { on: number; total: number } | null {
const seen = new Set();
let on = 0;
for (const dv of devices) {
- if (dv.area !== area) continue;
+ if (dv.area !== area || dv.hidden) continue; // hidden lights are not part of the picture
for (const eid of dv.entities) {
if (!eid.startsWith('light.') || seen.has(eid)) continue;
seen.add(eid);
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index c691877f..e581bc4f 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -15,7 +15,7 @@ import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, 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,
+ snapToWall, openingAmount, interiorPoint, poleOfInaccessibility,
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,
@@ -25,7 +25,7 @@ import {
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
} from './logic';
import { ContentSigner } from './signing';
-import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
+import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -36,7 +36,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
-const CARD_VERSION = '1.50.4';
+const CARD_VERSION = '1.51.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -293,7 +293,8 @@ class HouseplanCard extends LitElement {
link: string;
description: string;
pdfs: PdfRef[];
- room: string; // 'space#area' for a virtual one
+ room: string;
+ hideFromPlan: boolean; // 'space#area' for a virtual one
busy: boolean;
} | null = null;
private _spaceDialog: {
@@ -671,13 +672,67 @@ class HouseplanCard extends LitElement {
return this._serverCfg?.settings || {};
}
+ /** LOCAL editor tool (docs/FILTERING.md): show the hidden devices ghosted.
+ * The old toggle wrote settings.show_all — shared state that flipped the
+ * plan for every wall tablet at once. Legacy configs still honour it
+ * through buildDevices until they are seeded. */
+ private _showHidden = false;
+
private get _showAll(): boolean {
- return !!this._settings.show_all;
+ return this._settings.filter_seeded ? this._showHidden : !!this._settings.show_all;
}
private _toggleShowAll(): void {
if (!this._serverCfg) return;
- this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._showAll } };
+ if (this._settings.filter_seeded) {
+ this._showHidden = !this._showHidden;
+ this.requestUpdate();
+ return;
+ }
+ // legacy config: the old shared behaviour until an editor materialises it
+ this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._settings.show_all } };
+ this._regSignature = '';
+ this._maybeRebuildDevices();
+ this._saveConfig();
+ this.requestUpdate();
+ }
+
+ /**
+ * The seeder (docs/FILTERING.md): materialise the filter into explicit
+ * hidden flags. Runs after every device rebuild on a client that may write;
+ * idempotent — a device with ANY marker is never revisited, so an unticked
+ * checkbox (a marker with hidden: false) is re-seed protection. Also
+ * removes freshly hidden ids from the red-dot list: a dot on an invisible
+ * device would wait forever.
+ */
+ private _seedHiddenDevices(): void {
+ if (!this._serverCfg || !this._norm || !this._canEdit) return;
+ const cfg = this._serverCfg;
+ const bindings = seedHiddenBindings({
+ hass: this.hass,
+ areaToSpace: Object.fromEntries(
+ Object.entries(this._areaToSpace).map(([a, v]) => [a, v.space]),
+ ),
+ markers: this._markers,
+ settings: this._settings,
+ excluded: this._excluded,
+ firstSpaceId: this._model[0]?.id || '',
+ iconRules: this._iconRules,
+ });
+ if (!bindings.length && cfg.settings?.filter_seeded) return;
+ cfg.markers = cfg.markers || [];
+ const hiddenIds: string[] = [];
+ for (const b of bindings) {
+ const id = 'h' + b.slice(b.indexOf(':') + 1);
+ cfg.markers.push({ id, binding: b, hidden: true });
+ hiddenIds.push(b.slice(b.indexOf(':') + 1));
+ }
+ const st = { ...(cfg.settings || {}), filter_seeded: true } as any;
+ delete st.show_all; // the shared toggle retires with the runtime filter
+ if (hiddenIds.length && Array.isArray(st.new_device_ids)) {
+ st.new_device_ids = st.new_device_ids.filter((x: string) => !hiddenIds.includes(x));
+ }
+ cfg.settings = st;
this._regSignature = '';
this._maybeRebuildDevices();
this._saveConfig();
@@ -1007,6 +1062,7 @@ class HouseplanCard extends LitElement {
});
this._defPos = this._defaultPositions();
this._syncNewDevices();
+ this._seedHiddenDevices();
}
/**
@@ -1157,6 +1213,10 @@ class HouseplanCard extends LitElement {
const controls = (d.marker?.controls || []).filter(isControllable);
if (controls.length)
return controls.some((e) => this.hass.states[e]?.state === 'on') ? 'on' : '';
+ // A shining light makes its icon yellow in EVERY fill mode — and it is
+ // the same condition that lights the glow pool, so the pool and the icon
+ // cannot disagree (they used to read different entities).
+ if (litLightEntity(this.hass, d)) return 'on';
const p = d.primary ? this.hass.states[d.primary] : undefined;
if (!p) return '';
if (p.state === 'unavailable') return 'unavail';
@@ -1165,6 +1225,14 @@ class HouseplanCard extends LitElement {
// TESTING.md edge-case run)
const dom = d.primary!.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : '';
+ if (dom === 'climate') {
+ // yellow = actually working right now ("which radiators are heating"),
+ // not "enabled for the winter": hvac_action when the integration
+ // reports one, the coarser state only as a fallback
+ const act = p.attributes?.hvac_action;
+ if (act != null) return ['heating', 'cooling', 'drying', 'fan'].includes(act) ? 'on' : '';
+ return ['off', 'unknown'].includes(p.state) ? '' : 'on';
+ }
if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
if (dom === 'binary_sensor') {
@@ -1416,8 +1484,17 @@ class HouseplanCard extends LitElement {
clearTimeout(this._kioskHoldTimer);
}
}
- // do not interfere with icon dragging and markup drawing
- if (this._drag || this._markup) return;
+ // do not interfere with icon and label dragging
+ if (this._drag) return;
+ if (this._markup) {
+ // Drawing is CLICK-based, so gestures coexist with it: a finger that
+ // MOVES pans, two fingers pinch, and _suppressClick keeps the release
+ // from feeding the active tool. The editor used to opt out of gestures
+ // entirely, which left a phone with no way to zoom or pan the plan
+ // (owner's report). Pointers that begin on interactive children still
+ // stay out — labels and handles run their own drags.
+ if ((ev.target as HTMLElement).closest?.('.roomlabel, .rlhandle, .dev, .oplock, .op-hit, button')) return;
+ }
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
@@ -1447,6 +1524,8 @@ class HouseplanCard extends LitElement {
return;
}
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
+ // the tool preview (snap dot, measure label) keeps following the finger
+ if (this._markup && this._pointers.size === 1) this._markupMove(ev);
if (this._pinchStart && this._pointers.size >= 2) {
const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
@@ -1822,6 +1901,8 @@ class HouseplanCard extends LitElement {
private _markupClick(ev: MouseEvent): void {
if (!this._markup) return;
+ // a pan or pinch just happened — the synthesized click is not a draw
+ if (this._suppressClick) return;
// Room cards swallow markup clicks: dragging, resizing or just clicking a
// card must not feed the active tool (draw point, delete room, merge/split
// pick, opening placement). The drag itself already stops pointer events,
@@ -2728,6 +2809,7 @@ class HouseplanCard extends LitElement {
room: d.marker?.room_id
? d.space + '#@' + d.marker.room_id
: d.space && d.area ? d.space + '#' + d.area : '',
+ hideFromPlan: d.marker?.hidden === true,
busy: false,
};
} else {
@@ -2737,7 +2819,7 @@ class HouseplanCard extends LitElement {
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
glowRadius: '', model: '',
- link: '', description: '', pdfs: [], room: '', busy: false,
+ link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false,
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
};
}
@@ -2941,6 +3023,10 @@ class HouseplanCard extends LitElement {
link: dlg.link.trim() || null,
description: dlg.description.trim() || null,
pdfs: dlg.pdfs,
+ // Explicit false, not absence: a marker of any kind is what tells the
+ // seeder "the user decided" — unticking must not invite a re-hide
+ // (docs/FILTERING.md).
+ hidden: dlg.hideFromPlan ? true : false,
};
// save the room choice (always for virtual ones; for bound ones — if chosen)
if (dlg.binding === 'virtual' || dlg.room) {
@@ -3587,15 +3673,13 @@ class HouseplanCard extends LitElement {
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
for (const d of this._devices) {
if (d.space !== space.id) continue;
+ if (d.hidden) continue; // an invisible device casts no visible light (docs/FILTERING.md)
// A light source is normally a device with a lit light.* entity. With the
// "is a light source" flag (field request: a smart SWITCH driving dumb
// fixtures) any lit entity counts — the switch itself, or the lights it
// controls when they are bound.
- const forced = d.marker?.is_light === true;
- const pool = forced
- ? [...(d.marker?.controls || []), ...d.entities]
- : d.entities.filter((e) => e.startsWith('light.'));
- const lightEid = pool.find((e) => this.hass.states[e]?.state === 'on');
+ // the SAME condition that turns the icon yellow (devices.ts)
+ const lightEid = litLightEntity(this.hass, d);
if (!lightEid) continue;
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
if (!glow) continue;
@@ -3873,7 +3957,11 @@ class HouseplanCard extends LitElement {
}
const space = this._spaceModel();
const vb = space.vb;
- const devs = this._devices.filter((d) => d.space === space.id);
+ // hidden devices render ONLY in the device editor with "show hidden" on
+ // (ghosted); everywhere else the flag removes them from sight — but not
+ // from the build, so room LQI still counts them (docs/FILTERING.md)
+ const showGhosts = this._mode === 'devices' && this._showAll;
+ const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
const disp = spaceDisplayOf(this._curSpaceCfg);
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5;
@@ -3932,7 +4020,7 @@ class HouseplanCard extends LitElement {
)}