mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
feat: add private help and feedback reports (#43)
Issue: #43 User-Visible: yes
This commit is contained in:
@@ -49,6 +49,18 @@ FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.70.0-beta.2"
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
# URL would turn the integration into an SSRF proxy and make the privacy notice
|
||||
# false. The relay is deployed and operated by the project; changing it is a
|
||||
# reviewed release change, not a Home Assistant option.
|
||||
SUPPORT_RELAY_URL = "https://support.houseplan.tech/v1/reports"
|
||||
SUPPORT_PREVIEW_TTL_S = 10 * 60
|
||||
MAX_SUPPORT_PREVIEWS_PER_USER = 3
|
||||
MAX_SUPPORT_PREVIEWS_TOTAL = 3
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES = 8 * 1024 * 1024
|
||||
MAX_SUPPORT_MESSAGE_CODEPOINTS = 10_000
|
||||
MAX_SUPPORT_CONTACT_CODEPOINTS = 320
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
# user have different compatibility lifecycles.
|
||||
@@ -91,6 +103,9 @@ ERROR_CODES: frozenset[str] = frozenset({
|
||||
"marker_control_self", "missing_content", "missing_plan", "no_backup",
|
||||
"not_ready", "not_toggleable", "nothing_to_repair", "preview_expired",
|
||||
"preview_owner_mismatch", "space_in_use", "space_not_found",
|
||||
"support_invalid_message", "support_package_too_large",
|
||||
"support_preview_expired", "support_rate_limited", "support_rejected",
|
||||
"support_unavailable",
|
||||
"too_large", "unauthorized", "unsupported_export_version",
|
||||
"value_badge_source_required", "wall_model_client_outdated",
|
||||
"wall_model_migration_blocked",
|
||||
|
||||
@@ -1,125 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "2195ace683267664d035370d92e860e463a0a745074964ec60eb91b9460fe406",
|
||||
"fingerprint": "087d27ff0b95debda974220d6caa06c83df26b0c798e8298eddc1c90ee6353f6",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-DXakp5oa.js",
|
||||
"houseplan-assets/houseplan-card-5sP7gmz1.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 287369,
|
||||
"initialViewGzipBytes": 290370,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
|
||||
"houseplan-assets/de-BEGoVhHu.js",
|
||||
"houseplan-assets/editor-b0lFtnKI.js",
|
||||
"houseplan-assets/fr-BDUBoPOz.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bf-E2NBt.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-MR4ITSR4.js"
|
||||
"houseplan-assets/backdrop-pick-DDeYL4Ri.js",
|
||||
"houseplan-assets/de-CTrgjw4G.js",
|
||||
"houseplan-assets/editor-m5cOb3Eu.js",
|
||||
"houseplan-assets/fr-CZcUQIQ6.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-dBvEi_Y4.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-X9KZW5U_.js"
|
||||
],
|
||||
"lazyGzipBytes": 199380,
|
||||
"lazyGzipBytes": 205776,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
|
||||
"houseplan-assets/editor-b0lFtnKI.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bf-E2NBt.js"
|
||||
"houseplan-assets/backdrop-pick-DDeYL4Ri.js",
|
||||
"houseplan-assets/editor-m5cOb3Eu.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-dBvEi_Y4.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 144690,
|
||||
"lazyEditorGzipBytes": 148681,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-MR4ITSR4.js"
|
||||
"houseplan-assets/backdrop-pick-DDeYL4Ri.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-X9KZW5U_.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14029,
|
||||
"lazyOnboardingGzipBytes": 14026,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-BEGoVhHu.js",
|
||||
"houseplan-assets/fr-BDUBoPOz.js"
|
||||
"houseplan-assets/de-CTrgjw4G.js",
|
||||
"houseplan-assets/fr-CZcUQIQ6.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 47731,
|
||||
"lazyLocaleGzipBytes": 50138,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-YVCyO6A0.js",
|
||||
"sha256": "7eb697cfff4e5c645e5dc870c020d321bdbcc12a7cbb5fb69ff0fd3eaf8a4827",
|
||||
"path": "houseplan-assets/backdrop-pick-DDeYL4Ri.js",
|
||||
"sha256": "999099104d79952b9c9cdc9a71d044e510398366a0526ddb1d091e4697560828",
|
||||
"rawBytes": 20636,
|
||||
"gzipBytes": 7070,
|
||||
"gzipBytes": 7069,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DXakp5oa.js"
|
||||
"houseplan-assets/houseplan-card-5sP7gmz1.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-BEGoVhHu.js",
|
||||
"sha256": "1e681536e02fb02a2626b3f3d73903bd2ceacdcb7b4310f1ebdabb67e8ea7857",
|
||||
"rawBytes": 79660,
|
||||
"gzipBytes": 24116,
|
||||
"path": "houseplan-assets/de-CTrgjw4G.js",
|
||||
"sha256": "f91434ee331ed288d117ba5500c0051ece177d99c05615ff2511e610701b2cf8",
|
||||
"rawBytes": 83981,
|
||||
"gzipBytes": 25340,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-b0lFtnKI.js",
|
||||
"sha256": "52920f458ae8514f4ab6651a33845aa1b88d3741323f470e0231d2c911ce34dd",
|
||||
"path": "houseplan-assets/editor-m5cOb3Eu.js",
|
||||
"sha256": "9dedbdd213bba49452e19f9d1c1c507d0af5d32003d9976eccac889f2cde85be",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1581,
|
||||
"gzipBytes": 1579,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DXakp5oa.js"
|
||||
"houseplan-assets/houseplan-card-5sP7gmz1.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-BDUBoPOz.js",
|
||||
"sha256": "59f029c824f5836d699de2034813e9bfd481c837bae50a81de358ff86c042f3b",
|
||||
"rawBytes": 81807,
|
||||
"gzipBytes": 23615,
|
||||
"path": "houseplan-assets/fr-CZcUQIQ6.js",
|
||||
"sha256": "0795dff649cec998870462d21df351bfad990f31550f90d2660471b2c01f51cb",
|
||||
"rawBytes": 86212,
|
||||
"gzipBytes": 24798,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-DXakp5oa.js",
|
||||
"sha256": "f7940dd06a85c23dd2d28fd498a7a6305da32111e871f784351f3e0f5d91794b",
|
||||
"rawBytes": 1017921,
|
||||
"gzipBytes": 286572,
|
||||
"path": "houseplan-assets/houseplan-card-5sP7gmz1.js",
|
||||
"sha256": "45fdd464fac57f508f180fb5e14674425a48e1d32cc374a0482bd684f0962acd",
|
||||
"rawBytes": 1032271,
|
||||
"gzipBytes": 289576,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-BEGoVhHu.js",
|
||||
"houseplan-assets/editor-b0lFtnKI.js",
|
||||
"houseplan-assets/fr-BDUBoPOz.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bf-E2NBt.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-MR4ITSR4.js"
|
||||
"houseplan-assets/de-CTrgjw4G.js",
|
||||
"houseplan-assets/editor-m5cOb3Eu.js",
|
||||
"houseplan-assets/fr-CZcUQIQ6.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-dBvEi_Y4.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-X9KZW5U_.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-Bf-E2NBt.js",
|
||||
"sha256": "378202552bee2c88535b64244cba36ec1f1de7f21676bf6d177b3ab1931d209c",
|
||||
"rawBytes": 527211,
|
||||
"gzipBytes": 136039,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-dBvEi_Y4.js",
|
||||
"sha256": "832daf3acccdf2f18112adba65874123084209bb1e11f54d89553f905cef1338",
|
||||
"rawBytes": 543198,
|
||||
"gzipBytes": 140033,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
|
||||
"houseplan-assets/houseplan-card-DXakp5oa.js"
|
||||
"houseplan-assets/backdrop-pick-DDeYL4Ri.js",
|
||||
"houseplan-assets/houseplan-card-5sP7gmz1.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-MR4ITSR4.js",
|
||||
"sha256": "b26c1bed651ebef10d36bd435119b3a0e194cc5419999ccfc4ecdff9299c260c",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-X9KZW5U_.js",
|
||||
"sha256": "d09f3965b43df77d543f03723306d7d3a5c79b325d244ad22213d2860dcda1d7",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6959,
|
||||
"gzipBytes": 6957,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
|
||||
"houseplan-assets/houseplan-card-DXakp5oa.js"
|
||||
"houseplan-assets/backdrop-pick-DDeYL4Ri.js",
|
||||
"houseplan-assets/houseplan-card-5sP7gmz1.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "9317a9b6fcb643f24931d79e55a7ed692828058ccb9c9b811559c3854962175e",
|
||||
"sha256": "9fd734e140645820dacb371bd8adfc8c28ad25b8130588d781639dd926941355",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 797,
|
||||
"gzipBytes": 794,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DXakp5oa.js"
|
||||
"houseplan-assets/houseplan-card-5sP7gmz1.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+5
-5
@@ -1,14 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2195ace683267664d035370d92e860e463a0a745074964ec60eb91b9460fe406";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-DXakp5oa.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},_=this._schema,f=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="087d27ff0b95debda974220d6caa06c83df26b0c798e8298eddc1c90ee6353f6";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-5sP7gmz1.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(_.slice(0,3))}
|
||||
${f?c`<div class="default-floor-error" role="alert"
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:f})}
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(_.slice(3))}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+163
-37
File diff suppressed because one or more lines are too long
+175
-65
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2195ace683267664d035370d92e860e463a0a745074964ec60eb91b9460fe406";try{await import("./houseplan-assets/houseplan-card-DXakp5oa.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="087d27ff0b95debda974220d6caa06c83df26b0c798e8298eddc1c90ee6353f6";try{await import("./houseplan-assets/houseplan-card-5sP7gmz1.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
|
||||
@@ -100,6 +100,10 @@ class HouseplanData:
|
||||
# Parsed import candidates are short-lived, user-bound and memory-only.
|
||||
# dict keeps insertion order, which lets the preview service evict oldest.
|
||||
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# #43: already-sanitized support-package bytes only. Raw config/layout is
|
||||
# never retained here; the write lock below is used to take one coherent
|
||||
# deep copy and the privacy projection immediately replaces it.
|
||||
support_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# #330 §4.2: junction-limit violation counts of the STORED document,
|
||||
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
|
||||
# memory-only, invalidated by a rev mismatch; the previous document is
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Strict, allowlisted House Plan support-package projection (#43).
|
||||
|
||||
This module deliberately has no Home Assistant imports. Besides making the
|
||||
privacy boundary easy to test, that prevents an innocent serializer helper from
|
||||
gaining access to registries, states, URLs or paths that the package must never
|
||||
contain. The caller supplies one coherent config/layout copy and bounded,
|
||||
already-classified runtime facts; this code constructs a new object field by
|
||||
field. It never serializes the source and then tries to redact it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .const import (
|
||||
EXPORT_VERSION,
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES,
|
||||
PLAN_MODEL_VERSION,
|
||||
)
|
||||
|
||||
PACKAGE_FORMAT = "houseplan-support-package"
|
||||
PACKAGE_VERSION = 1
|
||||
_SAFE_VERSION = re.compile(r"\A[0-9A-Za-z._+-]{1,32}\Z")
|
||||
_SAFE_ICON = re.compile(r"\Amdi:[a-z0-9-]{1,80}\Z")
|
||||
_LANGUAGES = frozenset({"en", "ru", "de", "fr"})
|
||||
_BROWSERS = frozenset({"chromium", "firefox", "webkit", "unknown"})
|
||||
_REGISTRY_ACCESS = frozenset({"full", "partial", "unavailable"})
|
||||
_REGISTRY_AGE = frozenset({"fresh", "stale", "unknown"})
|
||||
|
||||
|
||||
class SupportPackageError(ValueError):
|
||||
"""A stable public failure without source data in its text."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _safe_version(value: object) -> str:
|
||||
text = str(value or "unknown")
|
||||
return text if _SAFE_VERSION.fullmatch(text) else "unknown"
|
||||
|
||||
|
||||
def validate_frontend_facts(value: object) -> dict[str, Any]:
|
||||
"""Accept only the small runtime enum/boolean contract from the browser."""
|
||||
if not isinstance(value, dict):
|
||||
raise SupportPackageError("support_rejected")
|
||||
browser = value.get("browser_family")
|
||||
language = value.get("language")
|
||||
registry = value.get("registry_access")
|
||||
age = value.get("registry_age_bucket")
|
||||
major = value.get("browser_major")
|
||||
if browser not in _BROWSERS or language not in _LANGUAGES:
|
||||
raise SupportPackageError("support_rejected")
|
||||
if registry not in _REGISTRY_ACCESS or age not in _REGISTRY_AGE:
|
||||
raise SupportPackageError("support_rejected")
|
||||
if isinstance(major, bool) or not isinstance(major, int) or not 0 <= major <= 999:
|
||||
raise SupportPackageError("support_rejected")
|
||||
for key in ("coarse_pointer", "hover_capable"):
|
||||
if not isinstance(value.get(key), bool):
|
||||
raise SupportPackageError("support_rejected")
|
||||
return {
|
||||
"browser_family": browser,
|
||||
"browser_major": major,
|
||||
"language": language,
|
||||
"coarse_pointer": value["coarse_pointer"],
|
||||
"hover_capable": value["hover_capable"],
|
||||
"registry_access": registry,
|
||||
"registry_age_bucket": age,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Pseudonyms:
|
||||
namespace: str
|
||||
maps: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
|
||||
def get(self, kind: str, raw: object) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
value = str(raw)
|
||||
if not value:
|
||||
return None
|
||||
items = self.maps.setdefault(kind, {})
|
||||
if value not in items:
|
||||
items[value] = f"{kind}-{self.namespace}-{len(items) + 1}"
|
||||
return items[value]
|
||||
|
||||
|
||||
def _copy_keys(source: object, keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
if not isinstance(source, dict):
|
||||
return {}
|
||||
return {key: source[key] for key in keys if key in source}
|
||||
|
||||
|
||||
def _point(value: object) -> list[float] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
||||
return None
|
||||
if any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value):
|
||||
return None
|
||||
return [value[0], value[1]]
|
||||
|
||||
|
||||
def _points(value: object) -> list[list[float]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [point for item in value if (point := _point(item)) is not None]
|
||||
|
||||
|
||||
def _entity_ref(ids: _Pseudonyms, value: object) -> str | None:
|
||||
return ids.get("entity", value)
|
||||
|
||||
|
||||
def _binding(ids: _Pseudonyms, value: object) -> tuple[str, str]:
|
||||
text = str(value or "")
|
||||
if text == "virtual":
|
||||
return "virtual", "virtual"
|
||||
kind, separator, raw = text.partition(":")
|
||||
if separator and kind in {"device", "entity"} and raw:
|
||||
# Reuse the same per-kind namespace as controls/value sources so one
|
||||
# raw entity remains one pseudonym everywhere in this package.
|
||||
pseudo = ids.get(kind, raw)
|
||||
return kind, f"{kind}:{pseudo}"
|
||||
return "unknown", "unknown"
|
||||
|
||||
|
||||
def _custom_fill(value: object) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return _copy_keys(value, ("c", "a"))
|
||||
|
||||
|
||||
def _global_settings(value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays"))
|
||||
if not isinstance(value, dict):
|
||||
return out
|
||||
fill_colors = value.get("fill_colors")
|
||||
if isinstance(fill_colors, dict):
|
||||
out["fill_colors"] = {
|
||||
str(key): _copy_keys(item, ("c", "a"))
|
||||
for key, item in fill_colors.items()
|
||||
if isinstance(key, str) and isinstance(item, dict)
|
||||
}
|
||||
style = value.get("decor_default_style")
|
||||
if isinstance(style, dict):
|
||||
out["decor_default_style"] = _copy_keys(
|
||||
style, ("color", "opacity", "width_cm", "fill", "fill_color", "fill_opacity"),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _space_settings(value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, (
|
||||
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
|
||||
"fill_mode", "glow_enabled", "temp_min", "temp_max", "show_lqi",
|
||||
"hide_decor", "hide_openings", "label_temp", "label_hum", "label_lqi",
|
||||
"label_light", "card_font_scale", "north_deg", "bg_mode", "sun_rays",
|
||||
))
|
||||
if isinstance(value, dict) and "custom_fill" in value:
|
||||
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
|
||||
return out
|
||||
|
||||
|
||||
def _room_settings(ids: _Pseudonyms, value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, ("fill_mode", "glow", "name_scale", "label_scale"))
|
||||
if not isinstance(value, dict):
|
||||
return out
|
||||
if "custom_fill" in value:
|
||||
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
|
||||
for key in ("temp_source", "hum_source"):
|
||||
source = value.get(key)
|
||||
if source:
|
||||
kind, pseudo = _binding(ids, source)
|
||||
out[key] = pseudo
|
||||
out[f"{key}_kind"] = kind
|
||||
return out
|
||||
|
||||
|
||||
def _project_room(ids: _Pseudonyms, room: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"id": ids.get("room", room.get("id")),
|
||||
"name": f"Room {index}",
|
||||
}
|
||||
out.update(_copy_keys(room, ("x", "y", "w", "h")))
|
||||
if "poly" in room:
|
||||
out["poly"] = _points(room.get("poly"))
|
||||
if isinstance(room.get("wall_ids"), list):
|
||||
out["wall_ids"] = [ids.get("wall", item) for item in room["wall_ids"]]
|
||||
if isinstance(room.get("open_to"), list):
|
||||
out["open_to"] = [ids.get("room", item) for item in room["open_to"]]
|
||||
settings = _room_settings(ids, room.get("settings"))
|
||||
if settings:
|
||||
out["settings"] = settings
|
||||
return out
|
||||
|
||||
|
||||
def _project_decor(ids: _Pseudonyms, item: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = str(item.get("kind") or "unknown")
|
||||
out: dict[str, Any] = {"id": ids.get("decor", item.get("id")), "kind": kind}
|
||||
out.update(_copy_keys(item, (
|
||||
"color", "opacity", "width_cm", "width", "x1", "y1", "x2", "y2",
|
||||
"line_style", "x", "y", "w", "h", "angle", "fill", "fill_color",
|
||||
"fill_opacity", "size", "size_cm", "scale", "symbol", "flip_h", "flip_v",
|
||||
)))
|
||||
if kind == "text":
|
||||
out["text"] = "[redacted text]"
|
||||
if item.get("entity"):
|
||||
out["entity"] = _entity_ref(ids, item.get("entity"))
|
||||
return out
|
||||
|
||||
|
||||
def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
kind = value.get("kind")
|
||||
if kind in {"entity_state", "entity_attribute"}:
|
||||
out: dict[str, Any] = {"kind": kind, "entity_id": _entity_ref(ids, value.get("entity_id"))}
|
||||
# Attribute names can be arbitrary user strings and are not needed to
|
||||
# reproduce geometry or marker placement, so they are deliberately absent.
|
||||
return out
|
||||
if kind == "derived_lqi":
|
||||
return {"kind": kind}
|
||||
if kind == "derived_marker_state":
|
||||
raw = str(value.get("ref") or "")
|
||||
marker = raw[7:] if raw.startswith("marker:") else raw
|
||||
return {"kind": kind, "ref": f"marker:{ids.get('marker', marker)}"}
|
||||
return None
|
||||
|
||||
|
||||
def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
binding_kind, binding = _binding(ids, marker.get("binding"))
|
||||
out: dict[str, Any] = {
|
||||
"id": ids.get("marker", marker.get("id")),
|
||||
"binding": binding,
|
||||
"binding_kind": binding_kind,
|
||||
}
|
||||
out.update(_copy_keys(marker, (
|
||||
"hidden", "removed", "tap_action", "tap_confirm", "display", "ripple_color",
|
||||
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
|
||||
"use_climate_temp",
|
||||
)))
|
||||
icon = marker.get("icon")
|
||||
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
|
||||
out["icon"] = icon
|
||||
if marker.get("space"):
|
||||
out["space"] = ids.get("space", marker.get("space"))
|
||||
if marker.get("room_id"):
|
||||
out["room_id"] = ids.get("room", marker.get("room_id"))
|
||||
for key in ("light_entity", "toggle_entity", "tap_target"):
|
||||
if marker.get(key):
|
||||
out[key] = _entity_ref(ids, marker.get(key))
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
out["controls"] = [_entity_ref(ids, value) for value in controls]
|
||||
vacuum = marker.get("vacuum")
|
||||
if isinstance(vacuum, dict):
|
||||
out["vacuum"] = _copy_keys(vacuum, ("live", "trail", "trail_mode", "room_highlight"))
|
||||
for key in ("value_source",):
|
||||
projected = _project_value_source(ids, marker.get(key))
|
||||
if projected:
|
||||
out[key] = projected
|
||||
badge = marker.get("value_badge")
|
||||
if isinstance(badge, dict):
|
||||
safe_badge = _copy_keys(badge, ("enabled", "position"))
|
||||
source = _project_value_source(ids, badge.get("source"))
|
||||
if source:
|
||||
safe_badge["source"] = source
|
||||
out["value_badge"] = safe_badge
|
||||
return out
|
||||
|
||||
|
||||
def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"id": ids.get("space", space.get("id")),
|
||||
"title": f"Space {index}",
|
||||
"has_plan": bool(space.get("plan_url")),
|
||||
}
|
||||
out.update(_copy_keys(space, (
|
||||
"cell_cm", "plan_aspect", "plan_x", "plan_y", "plan_scale", "plan_scale_x",
|
||||
"plan_scale_y", "plan_angle", "view_box", "zero_wall_style",
|
||||
)))
|
||||
settings = _space_settings(space.get("settings"))
|
||||
if settings:
|
||||
out["settings"] = settings
|
||||
|
||||
out["rooms"] = [
|
||||
_project_room(ids, room, room_index)
|
||||
for room_index, room in enumerate(space.get("rooms") or [], 1)
|
||||
if isinstance(room, dict)
|
||||
]
|
||||
out["wall_segments"] = [
|
||||
{
|
||||
"id": ids.get("wall", item.get("id")),
|
||||
"a": _point(item.get("a")), "b": _point(item.get("b")), "cm": item.get("cm"),
|
||||
}
|
||||
for item in space.get("wall_segments") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["walls"] = [
|
||||
{
|
||||
"key": ids.get("wall", item.get("key")), "cm": item.get("cm"),
|
||||
**({"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
||||
if "a" in item and "b" in item else {}),
|
||||
}
|
||||
for item in space.get("walls") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["room_drafts"] = []
|
||||
for draft in space.get("room_drafts") or []:
|
||||
if not isinstance(draft, dict):
|
||||
continue
|
||||
out["room_drafts"].append({
|
||||
"id": ids.get("draft", draft.get("id")),
|
||||
"points": _points(draft.get("points")),
|
||||
"segments": [
|
||||
{
|
||||
**({"id": ids.get("wall", segment.get("id"))} if segment.get("id") else {}),
|
||||
"cm": segment.get("cm"),
|
||||
}
|
||||
for segment in draft.get("segments") or [] if isinstance(segment, dict)
|
||||
],
|
||||
})
|
||||
out["partitions"] = [
|
||||
{"id": ids.get("partition", item.get("id")), "a": _point(item.get("a")),
|
||||
"b": _point(item.get("b")), "cm": item.get("cm")}
|
||||
for item in space.get("partitions") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["wall_columns"] = [
|
||||
{"id": ids.get("column", item.get("id")), **_copy_keys(item, ("shape", "center", "cm", "angle"))}
|
||||
for item in space.get("wall_columns") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["openings"] = []
|
||||
for opening in space.get("openings") or []:
|
||||
if not isinstance(opening, dict):
|
||||
continue
|
||||
projected = {"id": ids.get("opening", opening.get("id"))}
|
||||
projected.update(_copy_keys(opening, (
|
||||
"type", "x", "y", "angle", "length", "invert", "flip_h", "flip_v",
|
||||
)))
|
||||
for key in ("contact", "lock"):
|
||||
if opening.get(key):
|
||||
projected[key] = _entity_ref(ids, opening.get(key))
|
||||
host = opening.get("host")
|
||||
if isinstance(host, dict) and host.get("kind") in {"wall", "partition"}:
|
||||
kind = host["kind"]
|
||||
projected["host"] = {
|
||||
"kind": kind,
|
||||
"id": ids.get(kind, host.get("id")),
|
||||
"t": host.get("t"),
|
||||
}
|
||||
out["openings"].append(projected)
|
||||
out["decor"] = [
|
||||
_project_decor(ids, item) for item in space.get("decor") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["open_spans"] = [
|
||||
{"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
||||
for item in space.get("open_spans") or [] if isinstance(item, dict)
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def _project_layout(ids: _Pseudonyms, layout: object) -> dict[str, Any]:
|
||||
if not isinstance(layout, dict):
|
||||
return {}
|
||||
marker_ids = ids.maps.get("marker", {})
|
||||
room_ids = ids.maps.get("room", {})
|
||||
out: dict[str, Any] = {}
|
||||
for raw_key, value in layout.items():
|
||||
if not isinstance(raw_key, str) or not isinstance(value, dict):
|
||||
continue
|
||||
if raw_key in marker_ids:
|
||||
key = marker_ids[raw_key]
|
||||
elif raw_key.startswith("rl_") and raw_key[3:] in room_ids:
|
||||
key = "rl_" + room_ids[raw_key[3:]]
|
||||
else:
|
||||
# Unknown keys may be stale device ids. Omitting them is the only
|
||||
# fail-closed choice; copying them would disclose the raw id.
|
||||
continue
|
||||
position = _copy_keys(value, ("x", "y", "k"))
|
||||
if value.get("s"):
|
||||
position["s"] = ids.get("space", value.get("s"))
|
||||
out[key] = position
|
||||
return out
|
||||
|
||||
|
||||
def _summary(config: object, layout: object) -> dict[str, Any]:
|
||||
spaces = config.get("spaces", []) if isinstance(config, dict) else []
|
||||
markers = config.get("markers", []) if isinstance(config, dict) else []
|
||||
kinds: Counter[str] = Counter()
|
||||
bindings: Counter[str] = Counter()
|
||||
lifecycles: Counter[str] = Counter()
|
||||
decor_kinds: Counter[str] = Counter()
|
||||
for marker in markers:
|
||||
if not isinstance(marker, dict):
|
||||
continue
|
||||
bindings[_binding_kind(marker.get("binding"))] += 1
|
||||
lifecycles[
|
||||
"removed" if marker.get("removed") else "hidden" if marker.get("hidden") else "active"
|
||||
] += 1
|
||||
for space in spaces:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
for opening in space.get("openings") or []:
|
||||
if isinstance(opening, dict):
|
||||
kinds[str(opening.get("type") or "unknown")] += 1
|
||||
for item in space.get("decor") or []:
|
||||
if isinstance(item, dict):
|
||||
decor_kinds[str(item.get("kind") or "unknown")] += 1
|
||||
return {
|
||||
"spaces": len(spaces),
|
||||
"rooms": sum(len(space.get("rooms") or []) for space in spaces if isinstance(space, dict)),
|
||||
"room_drafts": sum(len(space.get("room_drafts") or []) for space in spaces if isinstance(space, dict)),
|
||||
"walls": sum(len(space.get("wall_segments") or space.get("walls") or []) for space in spaces if isinstance(space, dict)),
|
||||
"partitions": sum(len(space.get("partitions") or []) for space in spaces if isinstance(space, dict)),
|
||||
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
|
||||
"openings": dict(sorted(kinds.items())),
|
||||
"decor": dict(sorted(decor_kinds.items())),
|
||||
"markers": {
|
||||
"total": len(markers),
|
||||
"lifecycle": dict(sorted(lifecycles.items())),
|
||||
"binding": dict(sorted(bindings.items())),
|
||||
},
|
||||
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
|
||||
}
|
||||
|
||||
|
||||
def _binding_kind(value: object) -> str:
|
||||
text = str(value or "")
|
||||
if text == "virtual":
|
||||
return "virtual"
|
||||
if text.startswith("device:"):
|
||||
return "device"
|
||||
if text.startswith("entity:"):
|
||||
return "entity"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def build_support_package(
|
||||
config: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
*,
|
||||
config_rev: int,
|
||||
layout_rev: int,
|
||||
card_version: str,
|
||||
integration_version: str,
|
||||
home_assistant_version: str,
|
||||
runtime: dict[str, Any],
|
||||
repairs: list[dict[str, Any]] | None = None,
|
||||
namespace: str | None = None,
|
||||
) -> tuple[bytes, dict[str, Any]]:
|
||||
"""Build canonical bytes and a bounded summary for the preview response."""
|
||||
facts = validate_frontend_facts(runtime)
|
||||
ids = _Pseudonyms(namespace or secrets.token_hex(4))
|
||||
spaces = [item for item in config.get("spaces", []) if isinstance(item, dict)]
|
||||
markers = [item for item in config.get("markers", []) if isinstance(item, dict)]
|
||||
projected_spaces = [
|
||||
_project_space(ids, space, index) for index, space in enumerate(spaces, 1)
|
||||
]
|
||||
projected_markers = [_project_marker(ids, marker) for marker in markers]
|
||||
summary = _summary(config, layout)
|
||||
package = {
|
||||
"format": PACKAGE_FORMAT,
|
||||
"version": PACKAGE_VERSION,
|
||||
"versions": {
|
||||
"card": _safe_version(card_version),
|
||||
"integration": _safe_version(integration_version),
|
||||
"home_assistant": _safe_version(home_assistant_version),
|
||||
"model": PLAN_MODEL_VERSION,
|
||||
"export_schema": EXPORT_VERSION,
|
||||
},
|
||||
"runtime": facts,
|
||||
"revisions": {"config": int(config_rev), "layout": int(layout_rev)},
|
||||
"summary": summary,
|
||||
"validation": {
|
||||
"config": "valid", "layout": "valid", "unknown_fields": "dropped",
|
||||
},
|
||||
"repairs": repairs or [],
|
||||
"plan_backup": {
|
||||
"config": {
|
||||
"model_version": int(config.get("model_version", PLAN_MODEL_VERSION)),
|
||||
"settings": _global_settings(config.get("settings")),
|
||||
"spaces": projected_spaces,
|
||||
"markers": projected_markers,
|
||||
},
|
||||
"layout": _project_layout(ids, layout),
|
||||
},
|
||||
}
|
||||
raw = (json.dumps(
|
||||
package, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
|
||||
) + "\n").encode("utf-8")
|
||||
if len(raw) > MAX_SUPPORT_ATTACHMENT_BYTES:
|
||||
raise SupportPackageError("support_package_too_large")
|
||||
return raw, {
|
||||
"size": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"spaces": summary["spaces"],
|
||||
"format": PACKAGE_FORMAT,
|
||||
"version": PACKAGE_VERSION,
|
||||
"versions": package["versions"],
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Bounded outbound submit to the compile-time House Plan support relay (#43)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import aiohttp
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import SUPPORT_RELAY_URL
|
||||
|
||||
_REPORT_ID_PREFIX = "hpr-"
|
||||
|
||||
|
||||
class SupportTransportError(RuntimeError):
|
||||
"""Stable frontend-facing error; never contains the remote response."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
async def async_submit_report(
|
||||
hass,
|
||||
*,
|
||||
message: str,
|
||||
contact: str,
|
||||
versions: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
attachment: bytes | None,
|
||||
attachment_sha256: str | None,
|
||||
filename_token: str,
|
||||
) -> str:
|
||||
"""Send once, without redirects, proxies, arbitrary hosts or reflected text."""
|
||||
target = urlsplit(SUPPORT_RELAY_URL)
|
||||
if target.scheme != "https" or target.hostname != "support.houseplan.tech":
|
||||
raise SupportTransportError("support_unavailable")
|
||||
|
||||
request: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"message": message,
|
||||
"idempotency_key": idempotency_key,
|
||||
"versions": {key: str(value) for key, value in versions.items()},
|
||||
}
|
||||
if contact:
|
||||
request["contact"] = contact
|
||||
if attachment is not None:
|
||||
request["attachment"] = {
|
||||
"size": len(attachment),
|
||||
"sha256": str(attachment_sha256 or ""),
|
||||
}
|
||||
form = aiohttp.FormData()
|
||||
form.add_field(
|
||||
"request", json.dumps(request, ensure_ascii=False, separators=(",", ":")),
|
||||
content_type="application/json",
|
||||
)
|
||||
if attachment is not None:
|
||||
form.add_field(
|
||||
"attachment", attachment,
|
||||
filename=f"houseplan-support-{filename_token[:32]}.json",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
timeout = aiohttp.ClientTimeout(total=20, sock_connect=5)
|
||||
try:
|
||||
async with session.post(
|
||||
SUPPORT_RELAY_URL, data=form, timeout=timeout, allow_redirects=False,
|
||||
) as response:
|
||||
if response.status == 429:
|
||||
raise SupportTransportError("support_rate_limited")
|
||||
if response.status == 413:
|
||||
raise SupportTransportError("support_package_too_large")
|
||||
if response.status in {400, 401, 403, 404, 405, 409, 415, 422}:
|
||||
raise SupportTransportError("support_rejected")
|
||||
if response.status != 200:
|
||||
raise SupportTransportError("support_unavailable")
|
||||
# A compromised/misconfigured relay cannot make HA buffer an
|
||||
# unbounded response or reflect its details to the browser.
|
||||
body = await response.content.read(4097)
|
||||
if len(body) > 4096:
|
||||
raise SupportTransportError("support_unavailable")
|
||||
except SupportTransportError:
|
||||
raise
|
||||
except (aiohttp.ClientError, TimeoutError, OSError):
|
||||
raise SupportTransportError("support_unavailable") from None
|
||||
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
report_id = payload.get("report_id") if isinstance(payload, dict) else None
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
report_id = None
|
||||
if not isinstance(report_id, str) or not report_id.startswith(_REPORT_ID_PREFIX):
|
||||
raise SupportTransportError("support_unavailable")
|
||||
if not 8 <= len(report_id) <= 64 or any(
|
||||
char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
|
||||
for char in report_id
|
||||
):
|
||||
raise SupportTransportError("support_unavailable")
|
||||
return report_id
|
||||
@@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
@@ -14,20 +16,29 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
CONTENT_URL,
|
||||
DEFAULT_CONFIG,
|
||||
DOMAIN,
|
||||
EXPORT_VERSION,
|
||||
FILES_DIR,
|
||||
MAX_PLANS_BYTES,
|
||||
MAX_PLANS_FILES,
|
||||
MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
MAX_SUPPORT_CONTACT_CODEPOINTS,
|
||||
MAX_SUPPORT_MESSAGE_CODEPOINTS,
|
||||
MAX_SUPPORT_PREVIEWS_PER_USER,
|
||||
MAX_SUPPORT_PREVIEWS_TOTAL,
|
||||
PLAN_MODEL_VERSION,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
SUPPORT_PREVIEW_TTL_S,
|
||||
VERSION,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
@@ -70,6 +81,8 @@ from .store import (
|
||||
from .store import (
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
)
|
||||
from .support_package import SupportPackageError, build_support_package
|
||||
from .support_transport import SupportTransportError, async_submit_report
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA,
|
||||
LAYOUT_SCHEMA,
|
||||
@@ -185,6 +198,9 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_export_create)
|
||||
websocket_api.async_register_command(hass, ws_import_revalidate)
|
||||
websocket_api.async_register_command(hass, ws_import_apply)
|
||||
websocket_api.async_register_command(hass, ws_support_preview)
|
||||
websocket_api.async_register_command(hass, ws_support_preview_discard)
|
||||
websocket_api.async_register_command(hass, ws_support_submit)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -213,6 +229,30 @@ def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
|
||||
connection.send_error(msg_id, err.code, err.message)
|
||||
|
||||
|
||||
def _send_support_error(connection, msg_id: int, code: str) -> None:
|
||||
"""Return only a stable code; support data never enters an error string."""
|
||||
connection.send_error(msg_id, code, code)
|
||||
|
||||
|
||||
def _prune_support_previews(rt: HouseplanData, now: float | None = None) -> None:
|
||||
current = time.monotonic() if now is None else now
|
||||
for token, preview in list(rt.support_previews.items()):
|
||||
if float(preview.get("expires", 0)) <= current:
|
||||
rt.support_previews.pop(token, None)
|
||||
|
||||
|
||||
def _support_repairs(hass: HomeAssistant) -> list[dict[str, Any]]:
|
||||
"""Expose stable House Plan repair families, never raw issue ids/placeholders."""
|
||||
counts: Counter[str] = Counter()
|
||||
registry = ir.async_get(hass)
|
||||
for domain, issue_id in list(registry.issues):
|
||||
if domain != DOMAIN:
|
||||
continue
|
||||
if str(issue_id).startswith("broken_plan_"):
|
||||
counts["broken_plan"] += 1
|
||||
return [{"code": code, "count": count} for code, count in sorted(counts.items())]
|
||||
|
||||
|
||||
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the exact non-layout portion of a layout-store document."""
|
||||
return {
|
||||
@@ -2034,3 +2074,239 @@ async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveC
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
removed = await rec.async_delete(msg["marker_id"]) if rec else False
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
# ---------------- private support package / feedback (#43) ----------------
|
||||
|
||||
|
||||
def _support_string(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise vol.Invalid("support field must be a string")
|
||||
return value
|
||||
|
||||
|
||||
def _support_bool(value: object) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise vol.Invalid("support field must be a boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _support_browser_major(value: object) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 999:
|
||||
raise vol.Invalid("browser_major must be an integer in 0..999")
|
||||
return value
|
||||
|
||||
|
||||
_SUPPORT_TOKEN = vol.All(
|
||||
_support_string, vol.Length(min=16, max=128), vol.Match(r"^[0-9a-f]+$")
|
||||
)
|
||||
_SUPPORT_ID = vol.All(
|
||||
_support_string, vol.Length(min=8, max=128), vol.Match(r"^[A-Za-z0-9_.:-]+$")
|
||||
)
|
||||
_SUPPORT_VERSION = vol.All(
|
||||
_support_string, vol.Length(min=1, max=32), vol.Match(r"^[0-9A-Za-z._+-]+$")
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/support/preview",
|
||||
vol.Required("card_version"): _SUPPORT_VERSION,
|
||||
vol.Required("browser_family"): vol.In(["chromium", "firefox", "webkit", "unknown"]),
|
||||
vol.Required("browser_major"): _support_browser_major,
|
||||
vol.Required("language"): vol.In(["en", "ru", "de", "fr"]),
|
||||
vol.Required("coarse_pointer"): _support_bool,
|
||||
vol.Required("hover_capable"): _support_bool,
|
||||
vol.Required("registry_access"): vol.In(["full", "partial", "unavailable"]),
|
||||
vol.Required("registry_age_bucket"): vol.In(["fresh", "stale", "unknown"]),
|
||||
vol.Required("draft_id"): _SUPPORT_ID,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_support_preview(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Build and retain one exact, already-sanitized support-package snapshot."""
|
||||
if not _check_write(hass, connection):
|
||||
_send_support_error(connection, msg["id"], "unauthorized")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
|
||||
# The write lock guarantees that config/layout and both revisions describe
|
||||
# one accepted pair. Projection happens after the deep copy, so the lock is
|
||||
# not held while JSON is built and a large plan cannot stall an editor save.
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = copy.deepcopy({**DEFAULT_CONFIG, **(config_data.get("config") or {})})
|
||||
layout = copy.deepcopy(layout_data.get("layout") or {})
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
|
||||
# A package must not turn malformed stored data into an apparently valid
|
||||
# diagnostic artifact. Validation runs on disposable copies because the
|
||||
# schemas canonicalize coordinates.
|
||||
repairs = _support_repairs(hass)
|
||||
|
||||
def _build_snapshot() -> tuple[bytes, dict[str, Any]]:
|
||||
CONFIG_SCHEMA(copy.deepcopy(config))
|
||||
LAYOUT_SCHEMA(copy.deepcopy(layout))
|
||||
return build_support_package(
|
||||
config,
|
||||
layout,
|
||||
config_rev=config_rev,
|
||||
layout_rev=layout_rev,
|
||||
card_version=msg["card_version"],
|
||||
integration_version=VERSION,
|
||||
home_assistant_version=HA_VERSION,
|
||||
runtime={
|
||||
"browser_family": msg["browser_family"],
|
||||
"browser_major": msg["browser_major"],
|
||||
"language": msg["language"],
|
||||
"coarse_pointer": msg["coarse_pointer"],
|
||||
"hover_capable": msg["hover_capable"],
|
||||
"registry_access": msg["registry_access"],
|
||||
"registry_age_bucket": msg["registry_age_bucket"],
|
||||
},
|
||||
repairs=repairs,
|
||||
)
|
||||
|
||||
try:
|
||||
# The maximum valid package is deliberately large. Keep validation,
|
||||
# pseudonymisation and canonical JSON away from Home Assistant's event
|
||||
# loop while retaining the coherent copies captured under write_lock.
|
||||
raw, preview = await hass.async_add_executor_job(_build_snapshot)
|
||||
except (vol.Invalid, ValueError, TypeError, OverflowError) as error:
|
||||
code = error.code if isinstance(error, SupportPackageError) else "support_rejected"
|
||||
_send_support_error(connection, msg["id"], code)
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
owner = _connection_user_id(connection)
|
||||
_prune_support_previews(rt, now)
|
||||
# A refresh replaces only this card instance's draft. Other cards keep
|
||||
# their token and exact bytes.
|
||||
for old_token, record in list(rt.support_previews.items()):
|
||||
if record.get("owner") == owner and record.get("draft_id") == msg["draft_id"]:
|
||||
rt.support_previews.pop(old_token, None)
|
||||
owned = sum(1 for item in rt.support_previews.values() if item.get("owner") == owner)
|
||||
if owned >= MAX_SUPPORT_PREVIEWS_PER_USER or len(rt.support_previews) >= MAX_SUPPORT_PREVIEWS_TOTAL:
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
token = secrets.token_hex(24)
|
||||
expires = now + SUPPORT_PREVIEW_TTL_S
|
||||
rt.support_previews[token] = {
|
||||
"owner": owner,
|
||||
"draft_id": msg["draft_id"],
|
||||
"created": now,
|
||||
"expires": expires,
|
||||
"bytes": raw,
|
||||
"sha256": preview["sha256"],
|
||||
"versions": preview["versions"],
|
||||
}
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"token": token,
|
||||
"expires_in": SUPPORT_PREVIEW_TTL_S,
|
||||
"size": preview["size"],
|
||||
"sha256": preview["sha256"],
|
||||
"spaces": preview["spaces"],
|
||||
"format": preview["format"],
|
||||
"version": preview["version"],
|
||||
"text": raw.decode("utf-8"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/support/preview/discard",
|
||||
vol.Required("token"): _SUPPORT_TOKEN,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_support_preview_discard(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Idempotently discard only a preview owned by this HA user."""
|
||||
if not _check_write(hass, connection):
|
||||
_send_support_error(connection, msg["id"], "unauthorized")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
_prune_support_previews(rt)
|
||||
preview = rt.support_previews.get(msg["token"])
|
||||
if preview is not None and preview.get("owner") != _connection_user_id(connection):
|
||||
_send_support_error(connection, msg["id"], "support_preview_expired")
|
||||
return
|
||||
rt.support_previews.pop(msg["token"], None)
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/support/submit",
|
||||
vol.Required("message"): _support_string,
|
||||
vol.Optional("contact", default=""): _support_string,
|
||||
vol.Optional("preview_token"): _SUPPORT_TOKEN,
|
||||
vol.Required("idempotency_key"): _SUPPORT_ID,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_support_submit(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Submit text and, when selected, the exact previewed package bytes."""
|
||||
if not _check_write(hass, connection):
|
||||
_send_support_error(connection, msg["id"], "unauthorized")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
message = msg["message"].strip()
|
||||
contact = msg.get("contact", "").strip()
|
||||
if not message or len(message) > MAX_SUPPORT_MESSAGE_CODEPOINTS:
|
||||
_send_support_error(connection, msg["id"], "support_invalid_message")
|
||||
return
|
||||
if len(contact) > MAX_SUPPORT_CONTACT_CODEPOINTS:
|
||||
_send_support_error(connection, msg["id"], "support_rejected")
|
||||
return
|
||||
|
||||
_prune_support_previews(rt)
|
||||
token = msg.get("preview_token")
|
||||
preview = rt.support_previews.get(token) if token else None
|
||||
if token and (preview is None or preview.get("owner") != _connection_user_id(connection)):
|
||||
_send_support_error(connection, msg["id"], "support_preview_expired")
|
||||
return
|
||||
attachment = preview.get("bytes") if preview else None
|
||||
versions = preview.get("versions") if preview else {
|
||||
"card": VERSION,
|
||||
"integration": VERSION,
|
||||
"home_assistant": HA_VERSION,
|
||||
"model": PLAN_MODEL_VERSION,
|
||||
"export_schema": EXPORT_VERSION,
|
||||
}
|
||||
try:
|
||||
report_id = await async_submit_report(
|
||||
hass,
|
||||
message=message,
|
||||
contact=contact,
|
||||
versions=versions,
|
||||
idempotency_key=msg["idempotency_key"],
|
||||
attachment=attachment,
|
||||
attachment_sha256=preview.get("sha256") if preview else None,
|
||||
filename_token=token or msg["idempotency_key"].lower().replace("_", "-"),
|
||||
)
|
||||
except SupportTransportError as error:
|
||||
_send_support_error(connection, msg["id"], error.code)
|
||||
return
|
||||
if token:
|
||||
# Retry after a timeout keeps the token; only a confirmed delivery
|
||||
# consumes it. The relay's idempotency record handles uncertain first
|
||||
# attempts with the same frontend key.
|
||||
rt.support_previews.pop(token, None)
|
||||
connection.send_result(msg["id"], {"report_id": report_id})
|
||||
|
||||
Reference in New Issue
Block a user