feat: add private help and feedback reports (#43)

Issue: #43
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-02 02:17:35 +03:00
parent 7490575799
commit 1e2e0fa61c
48 changed files with 3426 additions and 366 deletions
+15
View File
@@ -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,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 @@
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)}
+4
View File
@@ -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})