Compare commits

...
Author SHA1 Message Date
claude[bot] 3d6cea455d docs: review document for #43
Issue: #43
User-Visible: no
2026-09-02 00:05:33 +00:00
Sergey Matyunin 0e0ca3d4dc test: cover help and feedback browser flows (#43)
Issue: #43
User-Visible: no
2026-09-02 02:52:24 +03:00
claude[bot] 40d63080bd docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 23:42:03 +00:00
Sergey Matyunin ffddc38376 fix: satisfy validation gates for support reports (#43)
Issue: #43
User-Visible: no
2026-09-02 02:28:38 +03:00
Sergey Matyunin 1ba5363038 Merge origin/dev into issue/43-help-feedback
Issue: #43
User-Visible: no
2026-09-02 02:18:45 +03:00
Sergey Matyunin 1e2e0fa61c feat: add private help and feedback reports (#43)
Issue: #43
User-Visible: yes
2026-09-02 02:17:35 +03:00
claude[bot] 7490575799 docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 22:42:11 +00:00
Codex 3ce5bc0ea2 docs: require the trusted-proxy switch for the rate-limit source (#43)
User-Visible: no
Issue: #43
2026-09-02 01:34:58 +03:00
claude[bot] 321bd9691b docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 22:32:15 +00:00
Codex cd8d01aced docs: pin the rate-limit source and the webhook recipe (#43)
User-Visible: no
Issue: #43
2026-09-02 01:23:41 +03:00
claude[bot] 55b51a0270 docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 22:21:09 +00:00
Codex c43bf84789 feat(relay): deliver through the maintainer's Home Assistant webhook (#43)
User-Visible: no
Issue: #43
2026-09-02 01:03:57 +03:00
Codex c7efcf1699 fix(relay): pin the rate-limit source to the proxy-supplied address (#43)
User-Visible: no
Issue: #43
2026-09-02 00:24:41 +03:00
Codex 6b10cd52af docs: switch the support sink to a maintainer channel (#43)
User-Visible: no
Issue: #43
2026-09-02 00:12:41 +03:00
Codex 0010281952 feat(relay): receive support reports on the project stand (#43)
User-Visible: no
Issue: #43
2026-09-02 00:12:41 +03:00
claude[bot] cf7f47db27 docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 20:45:50 +00:00
Sergey Matyunin e25aa302d4 docs: address support spec review
Issue: #43
User-Visible: no
2026-09-01 23:41:24 +03:00
claude[bot]andSergey Matyunin 4559b5cd74 docs: review document for #43
Issue: #43
User-Visible: no
2026-09-01 23:41:24 +03:00
Sergey Matyunin 00b6845058 docs: rewrite support feedback spec
Issue: #43
User-Visible: no
2026-09-01 23:41:24 +03:00
80 changed files with 8180 additions and 442 deletions
+3 -1
View File
@@ -279,7 +279,7 @@ jobs:
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
{
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
@@ -828,6 +828,8 @@ jobs:
python -m pytest tests_backend/ -q
--cov=custom_components/houseplan --cov-branch
--cov-report=xml --cov-report=term
- name: Support relay unit tests
run: python -m unittest discover -s scripts/support-relay/tests -q
# #42: гейт «не ниже baseline» — пороги 90/95 поднимаются trivial-правкой числа
- name: Порог покрытия не ниже baseline
run: |
+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": "6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DXakp5oa.js",
"houseplan-assets/houseplan-card-CZDS_78e.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 287369,
"initialViewGzipBytes": 290378,
"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-BOES8LdP.js",
"houseplan-assets/de-CAtsunWz.js",
"houseplan-assets/editor-DCuNBi0k.js",
"houseplan-assets/fr-Bqax3ohv.js",
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
],
"lazyGzipBytes": 199380,
"lazyGzipBytes": 205828,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
"houseplan-assets/editor-b0lFtnKI.js",
"houseplan-assets/houseplan-editor-runtime-Bf-E2NBt.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/editor-DCuNBi0k.js",
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js"
],
"lazyEditorGzipBytes": 144690,
"lazyEditorGzipBytes": 148719,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
"houseplan-assets/houseplan-onboarding-runtime-MR4ITSR4.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
],
"lazyOnboardingGzipBytes": 14029,
"lazyLocaleFiles": [
"houseplan-assets/de-BEGoVhHu.js",
"houseplan-assets/fr-BDUBoPOz.js"
"houseplan-assets/de-CAtsunWz.js",
"houseplan-assets/fr-Bqax3ohv.js"
],
"lazyLocaleGzipBytes": 47731,
"lazyLocaleGzipBytes": 50151,
"files": [
{
"path": "houseplan-assets/backdrop-pick-YVCyO6A0.js",
"sha256": "7eb697cfff4e5c645e5dc870c020d321bdbcc12a7cbb5fb69ff0fd3eaf8a4827",
"path": "houseplan-assets/backdrop-pick-BOES8LdP.js",
"sha256": "aea5ea988e5aff3ffa8765511b0ed96acd7b4b3b5f3719242360716f3c6bd502",
"rawBytes": 20636,
"gzipBytes": 7070,
"gzipBytes": 7071,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BEGoVhHu.js",
"sha256": "1e681536e02fb02a2626b3f3d73903bd2ceacdcb7b4310f1ebdabb67e8ea7857",
"rawBytes": 79660,
"gzipBytes": 24116,
"path": "houseplan-assets/de-CAtsunWz.js",
"sha256": "883dd5ab7905636373de30c2793fc5418d867087644dc198f93576eb4b87cd3a",
"rawBytes": 83981,
"gzipBytes": 25347,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-b0lFtnKI.js",
"sha256": "52920f458ae8514f4ab6651a33845aa1b88d3741323f470e0231d2c911ce34dd",
"path": "houseplan-assets/editor-DCuNBi0k.js",
"sha256": "55ddef2226d3bc262b582216e556c667ad3dd8c2e793891afee33d1fb6687bdb",
"rawBytes": 3826,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BDUBoPOz.js",
"sha256": "59f029c824f5836d699de2034813e9bfd481c837bae50a81de358ff86c042f3b",
"rawBytes": 81807,
"gzipBytes": 23615,
"path": "houseplan-assets/fr-Bqax3ohv.js",
"sha256": "5f75459b58f7bd380c21eb6998b417e743f65270425b2bc1dc46e9785d71ac56",
"rawBytes": 86212,
"gzipBytes": 24804,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DXakp5oa.js",
"sha256": "f7940dd06a85c23dd2d28fd498a7a6305da32111e871f784351f3e0f5d91794b",
"rawBytes": 1017921,
"gzipBytes": 286572,
"path": "houseplan-assets/houseplan-card-CZDS_78e.js",
"sha256": "7bac8a128b73c811e62484ce99a13a121b7bb25c1dfc8fa48a49715f1c4a3690",
"rawBytes": 1032271,
"gzipBytes": 289581,
"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-CAtsunWz.js",
"houseplan-assets/editor-DCuNBi0k.js",
"houseplan-assets/fr-Bqax3ohv.js",
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-Bf-E2NBt.js",
"sha256": "378202552bee2c88535b64244cba36ec1f1de7f21676bf6d177b3ab1931d209c",
"rawBytes": 527211,
"gzipBytes": 136039,
"path": "houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
"sha256": "0d15c5f868b3684dbf6d81ebfbe925f874f08b3e8a68db12f160f3807d22ee20",
"rawBytes": 543272,
"gzipBytes": 140067,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-MR4ITSR4.js",
"sha256": "b26c1bed651ebef10d36bd435119b3a0e194cc5419999ccfc4ecdff9299c260c",
"path": "houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js",
"sha256": "c119a22e0435de0f3e7ed8c62f5813f7aeee31e1da8698f3506ab19e4a7c87a1",
"rawBytes": 28088,
"gzipBytes": 6959,
"gzipBytes": 6958,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "9317a9b6fcb643f24931d79e55a7ed692828058ccb9c9b811559c3854962175e",
"sha256": "d538bbccca6042eee23ee0df4506530c5bf121824167e6a30cac722ce6ba1b1c",
"rawBytes": 1183,
"gzipBytes": 797,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -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__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";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-CZDS_78e.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);
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2195ace683267664d035370d92e860e463a0a745074964ec60eb91b9460fe406";import{l as t,ar as s,A as e,t as o,c as a,bW as l,bX as i,bY as h,bZ as c,ao as n,b_ as r,c3 as p,b$ as _,c0 as d,c1 as u,c2 as g,cY as m,an as b,ap as $,cZ as v}from"./houseplan-card-DXakp5oa.js";import{i as f,c as y,e as w,r as D,a as k,b as S,s as C,t as M}from"./backdrop-pick-YVCyO6A0.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="2195ace683267664d035370d92e860e463a0a745074964ec60eb91b9460fe406";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";import{l as t,ar as s,A as e,t as o,c as a,bW as l,bX as i,bY as h,bZ as c,ao as n,b_ as r,c3 as p,b$ as _,c0 as d,c1 as u,c2 as g,cY as m,an as b,ap as $,cZ as v}from"./houseplan-card-CZDS_78e.js";import{i as f,c as y,e as w,r as D,a as k,b as S,s as C,t as M}from"./backdrop-pick-BOES8LdP.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:d,tempMax:_,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
@@ -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__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";try{await import("./houseplan-assets/houseplan-card-CZDS_78e.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})
+57 -2
View File
@@ -32,6 +32,9 @@ const spanOverDoorFixture = JSON.parse(readFileSync(
const wallUnionIsolationFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
));
const cardVersion = JSON.parse(readFileSync(
new URL('../../package.json', import.meta.url), 'utf8',
)).version;
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture()
@@ -733,7 +736,7 @@ export async function prepareGoldenScenario(page, scenario) {
await page.mouse.move(0, 0);
const fixture = prepareGoldenFixture(scenario);
const result = await page.evaluate(async ({ fixture, scenario }) => {
const result = await page.evaluate(async ({ fixture, scenario, cardVersion }) => {
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -1599,6 +1602,58 @@ export async function prepareGoldenScenario(page, scenario) {
if (trigger.getAttribute('aria-expanded') !== 'true' || !inside(triggerRect)
|| !inside(surfaceRect) || dialog.scrollWidth > dialog.clientWidth + 1)
throw new Error(`golden general-settings help is clipped: ${scenario.openHelp}`);
} else if (scenario.dialog === 'support') {
card._haIntegrationVersion = cardVersion;
card._openSupportDialog();
await card.updateComplete;
const opened = card._supportDialog;
const text = '{"format":"houseplan-support-package","version":1}\n';
const preview = {
token: 'a'.repeat(48),
expiresAt: Date.now() + 300_000,
size: new TextEncoder().encode(text).byteLength,
sha256: 'b'.repeat(64),
spaces: 2,
format: 'houseplan-support-package',
version: 1,
text,
preparedAt: Date.now() - 60_000,
};
const patch = scenario.supportState === 'empty' ? {}
: scenario.supportState === 'preview' ? {
contact: 'user@example.test', message: 'The room light is not rendered.',
attach: true, status: 'ready', preview,
} : scenario.supportState === 'validation' ? {
status: 'error', errorCode: 'validation.message_required',
} : scenario.supportState === 'success' ? {
contact: 'user@example.test', message: 'The room light is not rendered.',
status: 'success', reportId: 'HP-43-GOLDEN',
} : scenario.supportState === 'relay-error' ? {
contact: '@houseplan-user', message: 'The report relay timed out.',
attach: true, status: 'error', preview, errorCode: 'support_rate_limited',
} : null;
if (!opened || !patch) {
throw new Error(`invalid golden support state: ${scenario.id}`);
}
card._supportDialog = { ...opened, ...patch };
card.requestUpdate();
await card.updateComplete;
await frame();
const dialog = card.renderRoot.querySelector('#support-dialog');
const body = dialog?.querySelector('.supportbody');
const expected = scenario.supportState === 'preview' ? '.supportpreview'
: scenario.supportState === 'validation' || scenario.supportState === 'relay-error'
? '#support-error'
: scenario.supportState === 'success' ? '#support-receipt' : '.supportform';
const focus = dialog?.querySelector(expected);
focus?.scrollIntoView({ block: 'center', inline: 'nearest' });
await frame();
if (!dialog || !body || !focus || body.scrollWidth > body.clientWidth + 1
|| dialog.scrollWidth > dialog.clientWidth + 1
|| !dialog.querySelector('.aboutver')
|| dialog.querySelectorAll('a.aboutlink').length < 3) {
throw new Error(`golden support dialog is incomplete or clipped: ${scenario.id}`);
}
} else if (scenario.dialog === 'device-ripple-color') {
card._setMode('devices');
await card.updateComplete;
@@ -1753,7 +1808,7 @@ export async function prepareGoldenScenario(page, scenario) {
cachedRays: card._sunRaysCache?.rays?.length || 0,
} } : {}),
};
}, { fixture, scenario });
}, { fixture, scenario, cardVersion });
if (scenario.tabDrag) {
const drag = await page.evaluate((placement) => {
const card = window.__goldenCard;
+22 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 53;
export const GOLDEN_MATRIX_VERSION = 54;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -824,6 +824,27 @@ export const GOLDEN_SCENARIOS = Object.freeze([
dialog: 'general-help', openHelp: 'gs.bg_mode.help', deviceScaleFactor: 2,
helpTextRegion: { key: 'gs.bg_mode.help', minPixels: 30 },
language: 'ru', theme: 'dark', viewport: { width: 390, height: 900 }, ...page },
// #43 AC14: the support dialog owns reviewed desktop, phone and tablet
// states. The six scenes deliberately cover both themes while keeping the
// individual states named in the specification independently reviewable.
{ id: 'support-desktop-empty-light-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'empty', language: 'en', theme: 'light',
viewport: { width: 1000, height: 980 }, ...page },
{ id: 'support-desktop-preview-dark-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'preview', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 980 }, ...page },
{ id: 'support-phone-validation-light-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'validation', language: 'ru', theme: 'light',
viewport: { width: 320, height: 760 }, ...page },
{ id: 'support-phone-success-dark-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'success', language: 'en', theme: 'dark',
viewport: { width: 390, height: 820 }, ...page },
{ id: 'support-relay-error-light-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'relay-error', language: 'en', theme: 'light',
viewport: { width: 900, height: 900 }, ...page },
{ id: 'support-tablet-preview-dark-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'support', supportState: 'preview', language: 'ru', theme: 'dark',
viewport: { width: 768, height: 900 }, ...page },
{ id: 'device-ripple-color-popover-mobile-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device-ripple-color', deviceId: 'golden-light-two',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
+7 -26
View File
@@ -1,5 +1,4 @@
import { launch, check, checkAll, finish } from './serve.mjs';
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -22,10 +21,10 @@ const res = await page.evaluate(async () => {
.find((row) => row.textContent.trim() === c._t('gs.wall_group'));
out.glowRadiusInsideGlowGroup = !!glowRadius && !!wallGroup
&& !!(glowRadius.compareDocumentPosition(wallGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
out.aboutVersion = sr().querySelector('hp-dialog .aboutver')?.textContent.trim() ?? null;
out.aboutLinks = [...sr().querySelectorAll('hp-dialog a.aboutlink')].map((a) => ({
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
// #43: About moved into the dedicated Help & Feedback dialog and must not
// survive as a duplicate at the bottom of General Settings.
out.aboutMovedOut = !sr().querySelector('hp-dialog .aboutver')
&& sr().querySelectorAll('hp-dialog a.aboutlink').length === 0;
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
await c._saveSettingsDialog(); await c.updateComplete;
@@ -54,30 +53,12 @@ const res = await page.evaluate(async () => {
out.spaceLqiOverridesCardDefault = sr().querySelectorAll('.dev .lqi').length > 0;
return out;
});
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
const assetsRoot = new URL('./srv/assets/', import.meta.url);
const assetManifest = JSON.parse(readFileSync(new URL('houseplan-assets.json', assetsRoot), 'utf8'));
const bundle = assetManifest.files
.filter((file) => file.path.endsWith('.js'))
.map((file) => readFileSync(new URL(file.path, assetsRoot), 'utf8'))
.join('\n');
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 15, // 11 цветов (включая wall_fill) + радиус свечения + фон
// + «Оптимизировать планы» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-source glow", "Walls", "Stage background", "Sun", "Backup and transfer", "Plan maintenance", "About"],
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
"aboutLinks": [
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
],
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-source glow", "Walls", "Stage background", "Sun", "Backup and transfer", "Plan maintenance"],
"aboutMovedOut": true,
"saved": {"c": "#ff00ff", "a": 0.5},
"newSpaceUsesDaynight": true,
"floorImportUsesDaynight": true,
+270
View File
@@ -0,0 +1,270 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
const PREVIEW_TEXT = '{"format":"houseplan-support-package","version":1}\n';
const PREVIEW_SHA = createHash('sha256').update(PREVIEW_TEXT).digest('hex');
const { page, browser } = await launch({ width: 1000, height: 900 });
const result = await page.evaluate(async ({ version, previewText, previewSha }) => {
const card = window.__card;
const root = () => card.renderRoot || card.shadowRoot;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const until = async (predicate, label, attempts = 100) => {
for (let index = 0; index < attempts; index++) {
if (predicate()) return;
await wait(10);
}
throw new Error(`support smoke timed out: ${label}`);
};
const update = async () => {
await card.updateComplete;
await frame();
};
const setInput = async (selector, value) => {
const input = root().querySelector(selector);
if (!input) throw new Error(`support smoke input missing: ${selector}`);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await update();
};
const close = async () => {
await card._editorRuntime._closeSupportDialog();
await update();
};
const open = async () => {
const button = root().querySelector('.support-button');
if (!button) throw new Error('support button missing');
button.click();
await until(() => !!card._supportDialog, 'dialog open');
await update();
};
card._haIntegrationVersion = version;
const originalCallWS = card.hass.callWS.bind(card.hass);
const calls = [];
let submitMode = 'success';
card.hass = {
...card.hass,
callWS: async (message) => {
if (!String(message.type || '').startsWith('houseplan/support/')) {
return originalCallWS(message);
}
calls.push(structuredClone(message));
if (message.type === 'houseplan/support/preview') {
return {
text: previewText,
size: new TextEncoder().encode(previewText).byteLength,
expires_in: 300,
spaces: 2,
token: 'a'.repeat(48),
sha256: previewSha,
version: 1,
format: 'houseplan-support-package',
};
}
if (message.type === 'houseplan/support/submit') {
if (submitMode === 'rate') throw { code: 'support_rate_limited' };
if (submitMode === 'timeout') throw { code: 'support_unavailable' };
if (submitMode === 'unknown') throw { code: 'unknown_command' };
return { report_id: 'HP-43-SMOKE' };
}
return { ok: true };
},
};
await update();
const out = {};
const modeResults = [];
for (const mode of ['view', 'plan', 'devices', 'decor']) {
card._setMode(mode);
await until(() => card._mode === mode && !card._modeTransitionBusy, `mode ${mode}`);
await update();
const before = {
mode: card._mode,
zoom: card._zoom,
selectedDevice: card._selId,
selectedDecor: card._decorSel,
};
const button = root().querySelector('.support-button');
const settings = button?.previousElementSibling;
await open();
modeResults.push({
mode,
afterSettings: settings?.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:cog-outline',
unchanged: JSON.stringify(before) === JSON.stringify({
mode: card._mode,
zoom: card._zoom,
selectedDevice: card._selId,
selectedDecor: card._decorSel,
}),
});
await close();
}
out.modeAndOrder = modeResults.every((item) => item.afterSettings && item.unchanged)
&& new Set(modeResults.map((item) => item.mode)).size === 4;
const originalConfig = card._config;
card._config = { ...card._config, kiosk: true };
card.requestUpdate();
await update();
const kioskButton = root().querySelector('.support-button');
out.kioskHidden = !kioskButton || getComputedStyle(kioskButton).display === 'none'
|| getComputedStyle(kioskButton.closest('.hdr')).display === 'none';
card._config = originalConfig;
card._serverCanWrite = false;
card.requestUpdate();
await update();
out.readonlyAbsent = !root().querySelector('.support-button');
card._serverCanWrite = true;
card.requestUpdate();
await update();
const supportCallsBeforeOpen = calls.length;
await open();
const dialog = root().querySelector('#support-dialog');
const touchRect = root().querySelector('.support-button').getBoundingClientRect();
const guide = dialog?.querySelector('#support-docs-heading + a');
out.openIsLocal = calls.length === supportCallsBeforeOpen;
out.aboutAndEnglishGuide = !!dialog?.querySelector('.aboutver')
&& dialog.querySelectorAll('.supportlinks a.aboutlink').length === 2
&& guide?.href.endsWith('/docs/USER-GUIDE.md');
out.freshDefaults = dialog?.querySelector('#support-contact')?.value === ''
&& dialog?.querySelector('#support-message')?.value === ''
&& dialog?.querySelector('.supportattach input')?.checked === false;
out.touchTarget = touchRect.width >= 44 && touchRect.height >= 44;
out.noHorizontalOverflow = dialog.scrollWidth <= dialog.clientWidth + 1
&& dialog.querySelector('.supportbody').scrollWidth
<= dialog.querySelector('.supportbody').clientWidth + 1;
await close();
card._config = { ...card._config, language: 'ru' };
await open();
out.russianGuide = root().querySelector('#support-docs-heading + a')?.href
.endsWith('/docs/USER-GUIDE.ru.md') === true;
await close();
card._config = { ...card._config, language: 'en' };
card._haIntegrationVersion = '0.0.0';
await open();
out.oldBackendDegrades = !!root().querySelector('#support-dialog .supportupdate')
&& !root().querySelector('#support-dialog .supportform')
&& !!root().querySelector('#support-dialog .aboutver')
&& root().querySelectorAll('#support-dialog a.aboutlink').length >= 3;
await close();
card._haIntegrationVersion = version;
await open();
const submitCallsBeforeValidation = calls.filter((call) => call.type === 'houseplan/support/submit').length;
await card._editorRuntime._submitSupport();
await update();
out.validation = card._supportDialog?.errorCode === 'validation.message_required'
&& root().activeElement?.id === 'support-message'
&& calls.filter((call) => call.type === 'houseplan/support/submit').length
=== submitCallsBeforeValidation;
await setInput('#support-contact', ' user@example.test ');
await setInput('#support-message', ' Exact support message. ');
root().querySelector('.supportattach input').click();
await until(() => card._supportDialog?.status === 'ready', 'preview ready');
await update();
const preview = card._supportDialog.preview;
root().querySelector('.supportpreview summary').click();
await update();
out.preview = !!root().querySelector('.supportwarning')
&& root().querySelector('.supportraw')?.value === previewText
&& preview?.text === previewText
&& preview?.sha256 === previewSha
&& root().querySelector('.supporthash code')?.textContent === previewSha;
let downloadedBlob = null;
let downloadedName = '';
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
const originalAnchorClick = HTMLAnchorElement.prototype.click;
URL.createObjectURL = (blob) => { downloadedBlob = blob; return 'blob:houseplan-smoke'; };
URL.revokeObjectURL = () => {};
HTMLAnchorElement.prototype.click = function click() { downloadedName = this.download; };
card._editorRuntime._downloadSupportPreview();
const downloadedText = await downloadedBlob?.text();
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
HTMLAnchorElement.prototype.click = originalAnchorClick;
out.downloadExact = downloadedText === previewText
&& downloadedName === `houseplan-support-${'a'.repeat(12)}.json`;
submitMode = 'success';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.status === 'success', 'success submit');
await update();
const successRequest = calls.findLast((call) => call.type === 'houseplan/support/submit');
out.success = root().querySelector('#support-receipt')?.textContent.includes('HP-43-SMOKE') === true
&& root().activeElement?.id === 'support-receipt'
&& successRequest?.message === 'Exact support message.'
&& successRequest?.contact === 'user@example.test'
&& successRequest?.preview_token === 'a'.repeat(48);
await close();
await open();
out.freshAfterSuccess = card._supportDialog?.contact === ''
&& card._supportDialog?.message === '' && card._supportDialog?.attach === false;
await setInput('#support-message', 'Retry this exact message.');
const retryKey = card._supportDialog.idempotencyKey;
submitMode = 'rate';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.status === 'error', 'rate limit error');
await update();
const ratePreserved = card._supportDialog.message === 'Retry this exact message.'
&& !!root().querySelector('.supportmanual')
&& !root().querySelector('#support-receipt');
submitMode = 'timeout';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.errorCode === 'support_unavailable', 'timeout error');
submitMode = 'unknown';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.status === 'error', 'unknown command error');
submitMode = 'success';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.status === 'success', 'retry success');
await update();
const retryRequests = calls.filter((call) => call.type === 'houseplan/support/submit'
&& call.message === 'Retry this exact message.');
out.retryAndManualRecovery = ratePreserved && retryRequests.length === 4
&& retryRequests.every((call) => call.idempotency_key === retryKey)
&& root().querySelector('#support-receipt')?.textContent.includes('HP-43-SMOKE') === true;
return out;
}, { version: VERSION, previewText: PREVIEW_TEXT, previewSha: PREVIEW_SHA });
const responsive = async (viewport) => {
await page.setViewportSize(viewport);
return page.evaluate(async () => {
const card = window.__card;
if (card._supportDialog) await card._editorRuntime._closeSupportDialog();
card._setMode('view');
await card.updateComplete;
const button = card.renderRoot.querySelector('.support-button');
button?.click();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const dialog = card.renderRoot.querySelector('#support-dialog');
const body = dialog?.querySelector('.supportbody');
const footer = dialog?.querySelector('.supportfooter');
const dialogRect = dialog?.getBoundingClientRect();
const buttonRect = button?.getBoundingClientRect();
return !!dialog && !!body && !!footer && !!dialogRect && !!buttonRect
&& dialogRect.left >= -1 && dialogRect.right <= innerWidth + 1
&& dialogRect.top >= -1 && dialogRect.bottom <= innerHeight + 1
&& dialog.scrollWidth <= dialog.clientWidth + 1
&& body.scrollWidth <= body.clientWidth + 1
&& body.clientHeight > 0 && footer.getBoundingClientRect().height > 0
&& buttonRect.width >= 44 && buttonRect.height >= 44;
});
};
result.phonePortrait = await responsive({ width: 320, height: 760 });
result.phoneLandscape = await responsive({ width: 760, height: 320 });
checkAll(result);
await finish(browser, result);
+56 -56
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "2195ace683267664d035370d92e860e463a0a745074964ec60eb91b9460fe406",
"fingerprint": "6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DXakp5oa.js",
"houseplan-assets/houseplan-card-CZDS_78e.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 287369,
"initialViewGzipBytes": 290378,
"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-BOES8LdP.js",
"houseplan-assets/de-CAtsunWz.js",
"houseplan-assets/editor-DCuNBi0k.js",
"houseplan-assets/fr-Bqax3ohv.js",
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
],
"lazyGzipBytes": 199380,
"lazyGzipBytes": 205828,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
"houseplan-assets/editor-b0lFtnKI.js",
"houseplan-assets/houseplan-editor-runtime-Bf-E2NBt.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/editor-DCuNBi0k.js",
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js"
],
"lazyEditorGzipBytes": 144690,
"lazyEditorGzipBytes": 148719,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
"houseplan-assets/houseplan-onboarding-runtime-MR4ITSR4.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
],
"lazyOnboardingGzipBytes": 14029,
"lazyLocaleFiles": [
"houseplan-assets/de-BEGoVhHu.js",
"houseplan-assets/fr-BDUBoPOz.js"
"houseplan-assets/de-CAtsunWz.js",
"houseplan-assets/fr-Bqax3ohv.js"
],
"lazyLocaleGzipBytes": 47731,
"lazyLocaleGzipBytes": 50151,
"files": [
{
"path": "houseplan-assets/backdrop-pick-YVCyO6A0.js",
"sha256": "7eb697cfff4e5c645e5dc870c020d321bdbcc12a7cbb5fb69ff0fd3eaf8a4827",
"path": "houseplan-assets/backdrop-pick-BOES8LdP.js",
"sha256": "aea5ea988e5aff3ffa8765511b0ed96acd7b4b3b5f3719242360716f3c6bd502",
"rawBytes": 20636,
"gzipBytes": 7070,
"gzipBytes": 7071,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BEGoVhHu.js",
"sha256": "1e681536e02fb02a2626b3f3d73903bd2ceacdcb7b4310f1ebdabb67e8ea7857",
"rawBytes": 79660,
"gzipBytes": 24116,
"path": "houseplan-assets/de-CAtsunWz.js",
"sha256": "883dd5ab7905636373de30c2793fc5418d867087644dc198f93576eb4b87cd3a",
"rawBytes": 83981,
"gzipBytes": 25347,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-b0lFtnKI.js",
"sha256": "52920f458ae8514f4ab6651a33845aa1b88d3741323f470e0231d2c911ce34dd",
"path": "houseplan-assets/editor-DCuNBi0k.js",
"sha256": "55ddef2226d3bc262b582216e556c667ad3dd8c2e793891afee33d1fb6687bdb",
"rawBytes": 3826,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BDUBoPOz.js",
"sha256": "59f029c824f5836d699de2034813e9bfd481c837bae50a81de358ff86c042f3b",
"rawBytes": 81807,
"gzipBytes": 23615,
"path": "houseplan-assets/fr-Bqax3ohv.js",
"sha256": "5f75459b58f7bd380c21eb6998b417e743f65270425b2bc1dc46e9785d71ac56",
"rawBytes": 86212,
"gzipBytes": 24804,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DXakp5oa.js",
"sha256": "f7940dd06a85c23dd2d28fd498a7a6305da32111e871f784351f3e0f5d91794b",
"rawBytes": 1017921,
"gzipBytes": 286572,
"path": "houseplan-assets/houseplan-card-CZDS_78e.js",
"sha256": "7bac8a128b73c811e62484ce99a13a121b7bb25c1dfc8fa48a49715f1c4a3690",
"rawBytes": 1032271,
"gzipBytes": 289581,
"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-CAtsunWz.js",
"houseplan-assets/editor-DCuNBi0k.js",
"houseplan-assets/fr-Bqax3ohv.js",
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-Bf-E2NBt.js",
"sha256": "378202552bee2c88535b64244cba36ec1f1de7f21676bf6d177b3ab1931d209c",
"rawBytes": 527211,
"gzipBytes": 136039,
"path": "houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
"sha256": "0d15c5f868b3684dbf6d81ebfbe925f874f08b3e8a68db12f160f3807d22ee20",
"rawBytes": 543272,
"gzipBytes": 140067,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-MR4ITSR4.js",
"sha256": "b26c1bed651ebef10d36bd435119b3a0e194cc5419999ccfc4ecdff9299c260c",
"path": "houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js",
"sha256": "c119a22e0435de0f3e7ed8c62f5813f7aeee31e1da8698f3506ab19e4a7c87a1",
"rawBytes": 28088,
"gzipBytes": 6959,
"gzipBytes": 6958,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-YVCyO6A0.js",
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/backdrop-pick-BOES8LdP.js",
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "9317a9b6fcb643f24931d79e55a7ed692828058ccb9c9b811559c3854962175e",
"sha256": "d538bbccca6042eee23ee0df4506530c5bf121824167e6a30cac722ce6ba1b1c",
"rawBytes": 1183,
"gzipBytes": 797,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DXakp5oa.js"
"houseplan-assets/houseplan-card-CZDS_78e.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";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-CZDS_78e.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);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2195ace683267664d035370d92e860e463a0a745074964ec60eb91b9460fe406";import{l as t,ar as s,A as e,t as o,c as a,bW as l,bX as i,bY as h,bZ as c,ao as n,b_ as r,c3 as p,b$ as _,c0 as d,c1 as u,c2 as g,cY as m,an as b,ap as $,cZ as v}from"./houseplan-card-DXakp5oa.js";import{i as f,c as y,e as w,r as D,a as k,b as S,s as C,t as M}from"./backdrop-pick-YVCyO6A0.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="2195ace683267664d035370d92e860e463a0a745074964ec60eb91b9460fe406";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";import{l as t,ar as s,A as e,t as o,c as a,bW as l,bX as i,bY as h,bZ as c,ao as n,b_ as r,c3 as p,b$ as _,c0 as d,c1 as u,c2 as g,cY as m,an as b,ap as $,cZ as v}from"./houseplan-card-CZDS_78e.js";import{i as f,c as y,e as w,r as D,a as k,b as S,s as C,t as M}from"./backdrop-pick-BOES8LdP.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:d,tempMax:_,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
+1 -1
View File
@@ -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__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";try{await import("./houseplan-assets/houseplan-card-CZDS_78e.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)}
+38 -1
View File
@@ -1,6 +1,6 @@
# House Plan architecture
Updated: 2026-08-19 (#113 optional space-model lifecycle). The repository = a HACS integration (category **Integration**)
Updated: 2026-09-02 (#43 private support reports). The repository = a HACS integration (category **Integration**)
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
## Styles (#266)
@@ -1617,3 +1617,40 @@ not the feeling that "editor text should be lazy".
`invalid_partition_opening_jamb_margin` ship structured JSON details, and
the frontend renders unknown codes localized (code-first, raw messages go
to the console).
## Private support boundary (#43)
The Help & feedback surface is rendered by the lazy editor runtime even when
the card remains in View. Its form state is component memory only. About and
language-routed User Guide links need no backend; report controls are exposed
only when `houseplan/config/get.integration_version` exactly matches the card.
`houseplan/support/preview` takes bounded frontend capability enums and a
dialog-scoped random id. Under the shared write lock it loads one coherent
config/layout pair, validates disposable copies and passes them to
`support_package.py`. That module is a strict projection boundary: it creates a
new allowlisted object with package-local pseudonyms, canonical sorted JSON and
a trailing newline. It never serializes raw storage and then redacts it.
The resulting bytes, SHA-256 and expiry are held in `HouseplanData` memory,
bound to the HA user and draft for ten minutes. The browser preview, JSON
download and submit all use those exact bytes. Refresh replaces only the same
draft; discard and confirmed submit consume the token. Authorization uses the
same `may_write` policy as every House Plan write.
`houseplan/support/submit` validates text again, resolves only an owned live
token and calls `support_transport.py`. That transport has one compile-time
HTTPS URL, disables redirects, bounds timeouts/response bytes and maps every
remote failure to a stable local code without reflecting response content.
The relay under `scripts/support-relay/` is independently deployable and is
excluded from the HACS artifact. It spools before private delivery, enforces
rate/idempotency limits and is purged on the schedule documented in
`docs/SUPPORT-PRIVACY.md`.
The three WebSocket commands are:
| Command | Input authority | Result |
|---|---|---|
| `houseplan/support/preview` | `may_write`, strict facts + draft id | opaque token, TTL, byte count, SHA-256 and exact JSON text |
| `houseplan/support/preview/discard` | `may_write`, token owner | idempotent cleanup |
| `houseplan/support/submit` | `may_write`, text + optional owned token | bounded private report id |
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- The new **Help & feedback** dialog keeps version, documentation and project
links together and can send a private report without exposing it publicly.
A diagnostic attachment is always opt-in: House Plan shows the exact
anonymized JSON first, removes names and Home Assistant IDs, warns that exact
home geometry remains, and keeps failed drafts available for retry or manual
recovery ([#43](https://github.com/Matysh/houseplan-card/issues/43)).
- Delete and unlock confirmations now use House Plan's native dialog shell and
expose an alert dialog together with its consequence text to screen readers;
ordinary dialogs continue to use Home Assistant's dialog chrome
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- Новый диалог **«Помощь и обратная связь»** объединяет версию, документацию и
ссылки проекта и умеет отправлять закрытый репорт без публичной публикации.
Диагностическое вложение всегда включается отдельно: House Plan сначала
показывает точный обезличенный JSON, убирает имена и HA id, предупреждает о
сохранении точной геометрии дома, а при ошибке оставляет черновик для повтора
или ручной отправки ([#43](https://github.com/Matysh/houseplan-card/issues/43)).
- Подтверждения удаления и разблокировки теперь используют нативную оболочку
House Plan и передаются скринридеру как срочный диалог вместе с текстом
последствий; обычные окна по-прежнему используют оболочку Home Assistant
+53
View File
@@ -0,0 +1,53 @@
# House Plan private support reports
Updated: 2026-09-02, issue #43.
House Plan keeps normal configuration, layout and uploaded content inside the
user's Home Assistant. The support relay is contacted only after the user
presses **Send** in **Help & feedback**. Opening the dialog and building or
downloading a preview do not make an external request.
## What is sent
Every report contains the user's plain-text message, optional contact, bounded
software versions and an idempotency key. The diagnostic JSON is optional and
off by default. If selected, the integration sends the exact canonical bytes
shown in the preview together with their size and SHA-256.
The package is constructed field by field. It contains plan geometry and
dimensions, safe display settings, structural counts, validation/repair
families and bounded browser/registry capability enums. Space, room, wall,
opening, marker and binding references receive random package-local names.
It excludes original names and text, Home Assistant installation/location,
device/entity/area IDs, current states and attributes, IP addresses, hostnames,
URLs, paths, filenames, plan/backdrop/manual bytes, vacuum calibration and
trails, backup history, message and contact. Unknown fields are dropped rather
than copied and redacted later.
## Preview and authorization
Only a Home Assistant user allowed to write House Plan can build or send a
report. Preview bytes live in integration memory for at most ten minutes and
are bound to that HA user and one dialog draft. Closing the dialog, disabling
the attachment or a successful submit removes the token; process exit also
removes it. Download uses the same preview text. The backend never rebuilds an
attachment during submit.
## Transport and retention
The integration can contact only `https://support.houseplan.tech/v1/reports`.
The HTTPS host is compiled into the backend; redirects and user-configured
destinations are refused. Provider response bodies and submitted content are
not written to Home Assistant logs.
The relay writes an accepted report to its private spool before delivery to a
private maintainer channel. Delivered reports, including message, contact and
optional JSON, are retained for no more than **30 days**. Rate-limit and
idempotency records are retained for no more than **24 hours**. A daily purge
enforces both limits. No public GitHub issue or public Telegram post is created.
To request early deletion, contact the maintainer through the project's
[Telegram chat](https://t.me/ha_houseplan) and provide the report ID shown after
submission. The report ID is also the reference for follow-up; do not publish
the downloaded support package unless you deliberately choose to do so.
+29
View File
@@ -3516,3 +3516,32 @@ require hands on real hardware — they remain for the human pass.
the space's config carries no `hide_decor` / `hide_openings` at all, and
a plan saved by an older card still opens here unchanged
[auto: smoke_hide_layers, tests_backend test_hide_layer_settings]
## Help & private feedback (#43)
- [ ] Header order is Fit/zoom → General settings → Help; Help has a 44×44
target, remains available in View and all editors, and is absent in kiosk.
About appears once in Help, Russian routes to the Russian User Guide and
every other locale routes to English [unit: `support-feedback.test.mjs`;
pre-beta: support dialog smoke/golden matrix].
- [ ] A fresh dialog has empty message/contact and attachment off. Empty or
over-limit Unicode input cannot submit; Ctrl/Cmd+Enter uses the same
guard [unit: `support-feedback.test.mjs`; pre-beta: phone validation
smoke].
- [ ] Preview builds no external request and exposes exact JSON bytes, size and
hash. Download equals preview; refresh changes the package namespace;
expiry/discard/replacement and successful submit invalidate only the
intended owner/draft token [backend: `test_support_package.py`,
`test_ha_websocket.py`; pre-beta: browser network capture].
- [ ] Forbidden sentinels (raw and escaped/base64), unknown fields, names, HA
ids, URLs/paths and live states never reach package bytes. Geometry and
referential pseudonyms survive [backend: `test_support_package.py`].
- [ ] Relay transport uses only the compiled HTTPS host, follows no redirect,
bounds timeouts/response and never reflects provider text. Relay request,
rate/idempotency, spool-before-delivery and purge tests run in CI
[backend: `test_ha_support_transport.py`; relay: `python -m unittest
discover -s scripts/support-relay/tests -q`].
- [ ] Success keeps a copyable report id; failure keeps the draft and exact
attachment with Retry, Copy message, Download and manual links. Old or
mismatched backend leaves About/Guide usable but exposes no fake submit
[pre-beta: success/429/timeout/unknown-command smokes in light/dark].
+31 -1
View File
@@ -10,7 +10,8 @@ House Plan installs two Lovelace cards together:
to the full plan.
Configuration, uploaded files and shared positions stay inside Home Assistant.
House Plan does not use a House Plan cloud service.
Only the explicit Help & feedback action can contact the House Plan support
relay, and exact plan geometry is attached only after you opt in and preview it.
> **Input support.** View and kiosk are supported on phones, tablets and wall
> touch panels. Create and maintain a plan on a desktop browser with a mouse
@@ -42,6 +43,7 @@ House Plan does not use a House Plan cloud service.
20. [Storage, multiple cards and backups](#20-storage-multiple-cards-and-backups)
21. [Current limitations](#21-current-limitations)
22. [Troubleshooting](#22-troubleshooting)
23. [Help and private feedback](#23-help-and-private-feedback)
<!-- docs-section: model -->
@@ -1089,3 +1091,31 @@ per space. The configuration package is limited to 2 MB.
When reporting a problem, include House Plan version, HA version, browser,
console/server errors and reproducible steps. Replace private entity IDs and
plans with synthetic equivalents.
<!-- docs-section: support -->
## 23. Help and private feedback
The **Help & feedback** button follows **General settings** in the card header.
It is available to users allowed to edit House Plan in View and all editors,
but is hidden in kiosk. The dialog contains the current card version, the
GitHub and Telegram links, and this guide. Russian UI opens the Russian guide;
all other languages open this English guide.
Enter a required message and, optionally, a contact such as an email, Telegram
username or WhatsApp number. The form is kept only in the open card instance:
it is not written to House Plan settings, local storage or Home Assistant.
The diagnostic attachment is **off by default**. When enabled, House Plan
builds an allowlisted package in the integration and shows its exact size,
SHA-256 and JSON before sending. The package excludes names, Home Assistant
entity/device/area IDs, live states, URLs, paths, files, message and contact.
It does include exact room/wall/opening geometry and home dimensions. Use
**Show data** to inspect the exact bytes and **Download JSON** to keep them.
The preview expires after ten minutes; refresh it before sending if required.
On success, keep the report ID shown by the dialog. A network or relay failure
does not close or clear the draft and never claims delivery: retry with the
same preview, or copy the message/download the JSON and continue through the
provided Telegram or GitHub links. Card and integration versions must match.
Retention and deletion details are in [SUPPORT-PRIVACY.md](SUPPORT-PRIVACY.md).
+33 -1
View File
@@ -8,7 +8,9 @@ House Plan — локальная интеграция и две Lovelace-кар
- `custom:houseplan-card` — интерактивный план с редакторами, состояниями и управлением;
- `custom:houseplan-space-card` — статическая схема одного пространства со ссылкой на полный план.
Все планы, настройки и позиции хранятся внутри Home Assistant. Облачный сервис House Plan не используется.
Все планы, настройки и позиции хранятся внутри Home Assistant. Только явная
отправка через «Помощь и обратная связь» обращается к relay поддержки, а точная
геометрия плана прикладывается лишь после отдельного согласия и предпросмотра.
> **Уровень поддержки устройств ввода.** Просмотр и киоск должны полноценно и
> удобно работать на телефонах, планшетах и настенных touch-панелях. Создавать и
@@ -42,6 +44,7 @@ House Plan — локальная интеграция и две Lovelace-кар
20. [Хранение, совместная работа и резервные копии](#20-хранение-совместная-работа-и-резервные-копии)
21. [Ограничения текущей версии](#21-ограничения-текущей-версии)
22. [Диагностика](#22-диагностика)
23. [Помощь и закрытая обратная связь](#23-помощь-и-закрытая-обратная-связь)
<!-- docs-section: model -->
@@ -1909,3 +1912,32 @@ cycle: 0
- Замки открываются только через специальную карточку проёма с подтверждением; не обходите это ручной конфигурацией.
- Перед удалением пространства, массовым объединением комнат или оптимизацией сделайте резервную копию Home Assistant.
- Всегда читайте отчёт оптимизации: она исправляет все старые и импортированные координаты между узлами.
<!-- docs-section: support -->
## 23. Помощь и закрытая обратная связь
Кнопка **«Помощь и обратная связь»** находится в шапке сразу после **«Общих
настроек»**. Она доступна пользователю с правом редактирования в просмотре и во
всех редакторах, но скрыта в киоске. В диалоге находятся версия карточки,
ссылки GitHub/Telegram и руководство пользователя. Русский интерфейс открывает
русское руководство, остальные языки — английское.
Введите обязательное сообщение и, при желании, контакт: email, Telegram или
WhatsApp. Поля живут только в открытом экземпляре карточки и не записываются в
настройки House Plan, localStorage или Home Assistant.
Диагностическое вложение **по умолчанию выключено**. После включения интеграция
собирает пакет по строгому списку разрешённых полей и до отправки показывает
его точный размер, SHA-256 и JSON. В пакете нет имён, HA id устройств,
сущностей и зон, текущих состояний, URL, путей, файлов, сообщения или контакта.
Но в нём есть **точная геометрия и размеры дома**: комнаты, стены и проёмы.
Кнопка **«Показать данные»** открывает ровно отправляемый JSON, **«Скачать JSON»**
сохраняет те же байты. Предпросмотр истекает через десять минут; перед отправкой
его можно обновить.
После успеха сохраните показанный номер репорта. Ошибка сети или relay не
закрывает и не очищает форму и не обещает доставку: можно повторить запрос с
тем же снимком либо скопировать сообщение, скачать JSON и продолжить через
Telegram/GitHub. Версии карточки и интеграции должны совпадать. Сроки хранения
и порядок удаления описаны в [SUPPORT-PRIVACY.md](SUPPORT-PRIVACY.md).
+11 -11
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceFingerprint": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"captureScriptSha256": "135dd9a566653b05cae3010d7861fca6453daef0ecdcdf1ba10059e65f0d4de8",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "f4ecb7fd1f107ccda6c8a1e90be1bce0f2690f93e5e42776ca783297f4143135"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "946801f6475a40c0e31a70e4b67eaa34c6f70df5329a63915f445e6e4811ca6a"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "617b51b3648498787b5039980c9f3eceb75ba56ed63a1a20e616bc05bc304362"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "9a79b7e517741e74701d1172ec3c66a340aaa9049a7f7a7b2fe86f4c3a2eccf0"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "8fb569f326627700e08156c1ca35a4042bcb529a955663b54228d04357c881d0"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "720fc3aa2070d1fcf6907091941751c623f30cc6a001ff95e22de9be998136df"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "b2294c6de0b18ef386b5a2d18422a866b84d095f133d31e56e307664e31e5bc0"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "8dbc5864124c4bddbe076722a1503e666c40f432cfe7fff86e6d196c428365f3"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "d09709ae1479cfaa1902068cb2a6015dfee3b58f1bbd349bafaa2c0592812463"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7faa6c2eb29239ed511cc4b351d1f0f38b3fff92cf2039b77b823ef090528a4d",
"sourceSha256": "c3e3e7dba1910e09c702472e6ea09d7ef13b91e082c09d2f9474437c776fb26e",
"imageSha256": "c0b20a31c2987c8737e7b746c7d358e6c97231ea7e06d73050a2d3aebb216558"
}
},
+395
View File
@@ -0,0 +1,395 @@
# CODE-REVIEW-43-r1
- Issue: [#43](https://github.com/Matysh/houseplan-card/issues/43) — Диалог помощи и обратной связи с обезличенным support report
- Ветка: `issue/43-help-feedback`
- SHA ревью: `1ba5363038055b3f3442b62a761161d05c6d03c7` (merge `origin/dev` в issue-ветку поверх `1e2e0fa6` — продуктовая реализация)
- Заход: r1 (первый код-ревью; ТЗ прошло 5 раундов ревью и зелёное, `S5-ready` → `S7-code-review`)
- Вердикт: **жёлтый** · блокирующих циклов 1/4 · High: 0 · Medium: 3 → в задаче
## 1. Скоуп
Диапазон `origin/dev...HEAD`: 71 файл, +7023/−411. Реализация ТЗ
`docs/specs/043-private-support-report.md` (ревизия 2, зелёное ревью r5):
- фронтенд: кнопка «Помощь и обратная связь» в шапке, новый диалог (перенос
«О карточке», языковая ссылка на USER-GUIDE, форма отчёта, preview
диагностического пакета) — `src/houseplan-card.ts`,
`src/houseplan-editor-runtime.ts`, `src/support-feedback.ts`,
`src/hp-dialog.ts`, `src/styles/*.ts`, `src/i18n/*.json`;
- backend: три websocket-команды `houseplan/support/{preview,preview/discard,submit}`,
allowlist-проекция `support_package.py`, транспорт `support_transport.py`,
константы в `const.py`;
- отдельный class-B сервис `scripts/support-relay/**` (написан и развёрнут на
проектном стенде ещё во время цикла ТЗ, при закрытых DoR-зависимостях §17;
это уже было предметом пяти раундов ревью ТЗ, включая живой пентест владельца
на трассировке `X-Forwarded-For`);
- документация: `USER-GUIDE.{md,ru.md}`, `ARCHITECTURE.md`,
новый `SUPPORT-PRIVACY.md`, `TESTING.md`, оба `CHANGELOG`.
Это первый код-ревью задачи — раунд полный, дельты по §2.10 нет.
## 2. Как проверялось
Ревью кода отвечает на вопрос «оно вообще работает» вместо ручного
тестирования (§2.7). Ниже — что выполнено лично, а не заявлено.
### 2.1 Дешёвые гейты (прогнаны лично, HEAD `1ba53630`)
| Гейт | Команда | Результат |
|---|---|---|
| Типы | `npx tsc --noEmit` | зелёный, 0 ошибок, 6.2 с |
| Юнит/интеграционные (frontend) | `npm test` | 1724 теста: 1723 passed, 1 skipped, 0 failed, 28.2 с |
| Сборка + сверка бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | идентичны; `npm run bundle:sync` не меняет рабочее дерево (`git status` чист) — все три копии (`dist`, `custom_components/.../frontend`, `demo/srv/assets`) уже синхронны в коммите |
| Новый `any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | **красный** — 4 новых явных `any` без `// any-ok:` (находка Medium №3 ниже) |
| Docs-контракт | `node scripts/check-docs.mjs` | **красный** — `screenshot source fingerprint is stale` (находка Medium №1 ниже) |
| process-gate | `node scripts/process-gate.mjs --range origin/dev..HEAD --issues` | «гейт пройден, предупреждений 0», 15 коммитов, включая проверку статуса issue через `gh` |
`check-docs` обязателен, потому что диапазон меняет `src/**`
(`houseplan-card.ts`, `houseplan-editor-runtime.ts`, `hp-dialog.ts`, оба
`styles/*.ts`, все 4 `i18n/*.json`) — правило §8 не оставляет выбора.
Проверено, что стойкость не унаследована из `dev`: тот же скрипт на чистом
`origin/dev` (отдельный worktree, `319b25c2`) даёт «Documentation checks passed
(7 files, 10 external links)» — значит, устаревание отпечатка порождено именно
этим диффом, а не фоновой работой #410.
### 2.2 Бэкенд Python (доступность харнесса)
Локальный образ ревьюера без `.venv-backend` (как и описанный в `AGENTS.md`
случай «Local Windows checkout»). Установил недостающие пакеты вручную, чтобы
не оставлять этот участок полностью непроверенным:
- `pip install pytest voluptuous aiohttp pytest-asyncio homeassistant` (без
пина) → `tests_backend/test_support_package.py`: **10 passed** (модуль
`support_package.py` намеренно не импортирует HA, тест не нуждается в
харнессе); `tests_backend/test_ha_support_transport.py`: **7 passed** (этому
файлу тоже хватает голого `homeassistant.helpers.aiohttp_client`, харнесс
`pytest-homeassistant-custom-component` не нужен).
- `pip install pytest-homeassistant-custom-component` (снова без пина; CI пинует
`homeassistant==2026.8.3` + `phcc==0.13.357` под Python 3.14, здесь только
3.12) → резолвер подобрал несовместимую пару, `python -m pytest
tests_backend -q` дал **96 failed / 387 passed** по всему `test_ha_*.py`,
включая файлы, никак не связанные с #43 (`test_ha_upload.py`,
`test_ha_virtual_lights.py`). Это диагностика рассинхронизации версий
харнесса, а не регрессия диффа — **результат не используется как
доказательство ни в одну сторону**. Четыре support-специфичных теста в
`test_ha_websocket.py` (`test_support_preview_is_authorized_exact_and_consumed_only_after_success`,
`test_support_preview_replacement_and_discard_are_draft_local`,
`test_support_text_only_submit_carries_safe_versions_without_plan_data`,
`test_support_commands_reject_read_only_user_before_build_or_transport`,
`test_support_preview_schema_does_not_coerce_client_facts[...]`) упали той же
генерической `assert False`, что и заведомо исправные несвязанные тесты —
подтверждает, что причина в среде, не в тесте.
- Итог: полный HA-харнесс в этом ревью не поднят (нет Python 3.14 в песочнице,
тянуть его ради одного прогона — непропорциональная трата времени). Авторский
хендофф в issue называет точные числа с зелёными прогонами на его машине
(`privacy/backend targeted — 15 passed`, `support relay — 36 passed`) —
доверяю числу для support_package.py/support_transport.py (сам перепроверил,
совпадает — 10+7=17, близко к заявленным 15 при другом подсчёте узкого
таргета), но для `test_ha_websocket.py` авторские числа не перепроверены
исполнением. Соответствующие AC (AC9, AC10) закрыты ниже пометкой «проверено
чтением, не исполнением», не автотестом с моей стороны.
### 2.3 Relay (`scripts/support-relay/**`)
`python3 -m unittest discover -s scripts/support-relay/tests -q` → **36
passed** (тот же набор, что и в последнем зелёном спек-ревью r5; код `hp_relay/**`
этим диапазоном не менялся — сверено `git diff origin/dev...HEAD --
scripts/support-relay/hp_relay` вместе с диапазоном коммитов истории: relay был
написан и вычитан во время цикла ТЗ пятью раундами ревью, включая живой пентест
владельца против подмены `X-Forwarded-For`). Прочитал код заново (не как
унаследованное, а как часть этого код-ревью — предмет здесь другой гейт,
код-ревью, а не спек-ревью):
- `hp_relay/app.py:147-158` — источник rate-limit берётся из последнего
элемента `X-Forwarded-For` только при `trusted_proxy=True` (default),
иначе — адрес TCP-соединения; совпадает с `scripts/support-relay/deploy/Caddyfile.fragment`
(`header_up X-Forwarded-For {remote_host}` — заголовок перезаписывается
целиком на обоих продовых сайтах).
- `hp_relay/delivery.py` — оба канала (`TelegramDelivery`, `HaWebhookDelivery`)
не используют `parse_mode`, то есть Telegram показывает текст пользователя
буквально; ответ провайдера не отражается наружу и не попадает в HTTP-ответ
клиенту (`app.py` мапит статус на закрытый список кодов).
- `hp_relay/app.py` — единственный лог-метод (`log_message`) переопределён и не
печатает адрес источника, только команду и путь.
Отдельно нашёл несостыковку единиц измерения в конфигурации прокси (находка
Low №4 ниже) — не блокирует, эффекта на легитимный трафик не имеет по расчёту.
### 2.4 Смоки и golden
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` печатает ~40
существующих смоков — все совпадают по диффу через уже существующие символы
(`_config`, `_editorRuntime`, `_infoCard`, `_markerDialog`), ни один не создан и
не изменён этим диффом. `ls demo/smoke_*.mjs | grep -iE "support|help|feedback"`
находит `smoke_feedback_v2.mjs` и `smoke_help_affordance.mjs` — оба
существовали в `origin/dev` до этой ветки и относятся к issue #68 (контекстная
помощь `hp-help`/`.rlgearbtn`), никак не к диалогу #43. **Ни один существующий,
ни один новый браузерный смок не касается нового диалога, кнопки или формы.**
`git diff --stat origin/dev...HEAD -- demo/` — пусто; `-- demo/golden` — пусто.
Причина находки Medium №2 ниже.
### 2.5 Инварианты модели
Не прогонял `npm run invariants -- --config <...>`. Диапазон не меняет ни
`validation.py`, ни модуль геометрии/толщины стен, ни схему `layout` (сверено
`git diff --stat` — `custom_components/houseplan/{diagnostics,import_export,validation}.py`
не тронуты). `support_package.py` только **читает** уже провалидированные
config/layout под тем же `write_lock` и строит отдельный allowlist-объект;
ничего не пишет обратно в хранимую модель. Инварианты о ключах записи толщины и
разрешимости ссылок относятся к хранимой модели, а не к экспортному снапшоту —
гейт не по предмету этого диффа, не «пропущен», а не применим.
### 2.6 Одно число — один источник
Проследил цепочку preview: backend строит `bytes`/`sha256`/`size` один раз в
`ws_support_preview` (`websocket_api.py:2153-2221`), кладёт в
`rt.support_previews[token]` и больше не пересчитывает — submit
(`websocket_api.py:2260-2312`) берёт `preview.get("bytes")`/`preview.get("sha256")`
из того же токена, никогда не перестраивая пакет. На фронтенде
`_buildSupportPreview` (`houseplan-editor-runtime.ts:9193-9250`) сверяет
`response.size` с независимо посчитанным `TextEncoder().encode(text).byteLength`
и падает в `support_rejected` при расхождении, затем кладёт результат в один
`SupportPreview`-объект (`support-feedback.ts:6-16`), который читают бейдж
размера, строка SHA-256, `<textarea>` с сырым JSON и кнопка Download
(`_downloadSupportPreview`) — везде один и тот же объект, ни одного второго
независимого вычисления не найдено. Единственное число, которое пользователь
видит дважды (preview vs отправленное), доказуемо имеет один источник по обе
стороны границы.
## 3. Разбор по AC (§13 ТЗ)
Обозначения: **PASS/тест** — автотест, который умеет падать (проверил сам или
логикой теста); **PASS/чтение** — проверено чтением кода, не исполнением;
**UNVERIFIED** — ни то, ни другое в этом ревью.
| AC | Итог | Свидетельство |
|---|---|---|
| AC1 | PASS/чтение | `houseplan-card.ts` — кнопка Help делит тот же `_norm && _canEdit` блок и kiosk-класс, что и General Settings; порядок в DOM (zoom → settings → help) совпадает с `test/support-feedback.test.mjs`. Открытие — только `newSupportDialogState()`, ни один mode/zoom/selection не трогается. **Браузерного смока с реальным View/тремя редакторами/kiosk-негативом, который спек называет доказательством, нет** — см. находку Medium №2 |
| AC2 | PASS/тест+чтение | `gs.about_group`/`about_version`/`github`/`telegram` перенесены ровно один раз (`test/support-feedback.test.mjs`, счётчик вхождений); маршрутизация RU→RU/остальное→EN подтверждена чтением (`houseplan-editor-runtime.ts:9360-9363`) и юнит-тестом |
| AC3 | PASS/тест | `support-feedback.ts` — `supportDraftError`, code-point-точная длина (тест на эмодзи), чекбокс всегда `false` при новом открытии, не сохраняется |
| AC4 | PASS/чтение | Текст предупреждения называет точную геометрию явно; открытие диалога не делает сетевых вызовов; включение чекбокса вызывает **только внутренний** `houseplan/support/preview` (это и есть контракт §6.4/§9.3 — «preview не трогает внешний relay»), внешний relay достижим только из `submit` |
| AC5 | PASS/тест | Backend никогда не перестраивает пакет на submit (`preview.get("bytes")`); `test_support_package.py` доказывает детерминизм байт/хеша; фронтенд сверяет присланный `size` с независимым подсчётом байт текста |
| AC6 | PASS/тест+чтение | Каждая `_project_*`-функция в `support_package.py` строит новый `dict` по allowlist полей, ни разу не мутируя исходный `config`/`layout` (`grep` по файлу не находит `config[...] =`/`.pop`); `test_geometry_and_references_survive_with_package_local_pseudonyms` проверяет разрешимость ссылок после ремаппинга |
| AC7 | PASS/тест (с оговоркой) | `test_privacy_projection_never_contains_raw_or_encoded_forbidden_values` сеет sentinel в каждое запрещённое поле, включая неизвестные вложенные ключи, и проверяет отсутствие verbatim/JSON-escaped/base64. **Путь `_support_repairs()` (реальное чтение HA issue registry, `websocket_api.py:244-253`) этим тестом не задет** — тест кормит `build_support_package` уже готовым списком `repairs`, минуя чтение реестра. Прочитал функцию: она физически не может вернуть ничего, кроме `{"code": "broken_plan", "count": N}}` (raw `issue_id`/`space_id` нигде не покидают функцию) — оцениваю PASS по чтению для этого конкретного пути, но замечаю расхождение с §14.1, который явно требует sentinel-фикстуру именно для `broken_plan_<spaceId>` |
| AC8 | PASS/тест | Тот же sentinel-тест сеет неизвестные top-level и вложенные ключи — allowlist «строит новое», а не «копирует и чистит», поэтому регрессия к redaction-after-copy была бы поймана |
| AC9 | PASS/чтение (частично тест) | `_check_write`/`may_write` на всех трёх командах, тест на read-only пользователя (`test_support_commands_reject_read_only_user_before_build_or_transport`) зелёный по чтению кода (харнесс не поднят — см. §2.2). Проверка владения токеном (`preview.get("owner") != _connection_user_id(...)`) в discard/submit простая и однозначная по чтению; **межпользовательского теста (два `can_write`-пользователя, один не видит чужой токен) в кодовой базе нет**, хотя у соседней фичи (import-preview) есть точно такой паттерн теста. Не блокирую отдельно — логика идентична уже проверенному паттерну соседней фичи, но фиксирую пробел |
| AC10 | PASS/чтение | Submit никогда не перечитывает config/layout — структурная гарантия, а не только тестовая. Замена/discard токена — тест зелёный (по чтению, харнесс не поднят). **TTL 10 минут ни один тест не проверяет fake-clock** (для аналогичной import-preview фичи такой тест есть: `runtime.import_previews[token]["expires"] = 0`), хотя АС в ТЗ явно обещает «Fake-clock backend tests». Код (`preview["expires"] <= time.monotonic()`) простой, оцениваю PASS по чтению |
| AC11 | PASS/тест | `support_transport.py` — HTTPS/фиксированный хост/без редиректов/таймауты 5/20с, статус мапится на закрытый список кодов **до** чтения тела (413 отрабатывает корректно независимо от того, кто вернул этот статус — сам relay или проксирующий Caddy), ответ ограничен 4097 байт и не отражается. `test_transport_uses_only_fixed_https_host_without_redirects`, `test_transport_maps_remote_status_without_reflecting_response`, `test_transport_rejects_unbounded_or_invalid_receipt` — все зелёные (запустил сам, §2.2). Отсутствие логирования message/contact подтверждено `grep`-ом по файлу (0 вызовов логгера) — чтением, не `caplog`-тестом |
| AC12 / AC12a | PASS/тест | 36/36 в `scripts/support-relay/tests`, включая `test_client_cannot_pick_its_own_rate_bucket` и `test_direct_node_ignores_the_forwarded_header` — оба уже мутационно доказаны в спек-ревью r3-r5, код с тех пор не менялся |
| AC13 | PASS/чтение | Успех не закрывает диалог, показывает id + Copy; ошибка сохраняет черновик, предлагает Retry/Download/ссылки. **Браузерного смока success/429/timeout, который спек называет доказательством, нет** — см. Medium №2 |
| AC14 | PASS/чтение, UNVERIFIED по заявленному способу доказательства | 44×44 CSS px, media-запрос на 320px, `aria-describedby`, управление фокусом — всё присутствует в источнике. **Спек требует «Reviewed desktop + phone + tablet goldens and touch smoke» — этого артефакта нет вообще** (см. Medium №2). Реальный рендер в браузере (фактические вычисленные размеры, фокус в настоящем DOM, реакция на нажатие Tab) статическим чтением TS не доказывается |
| AC15 | PASS/чтение | `diagnostics.py`, `import_export.py`, `validation.py` — 0 изменений в диапазоне (`git diff --stat`) |
| AC16 | PASS/чтение+прод-эксплуатация | Секрета в `custom_components/**` нет, только `SUPPORT_RELAY_URL`-константа; issue-хендоффы фиксируют реальный health-check прод/staging, ретеншн-таймеры на стенде |
| AC17 | PASS/чтение (frontend), UNVERIFIED (backend perf) | Фронтенд: `SupportDialogState`-тип импортируется как type-only, реальный код лежит в существующем lazy-чанке редактора — нового eager-импорта нет. **Backend perf-бюджет (≤750 мс/≤24 MiB на максимальный конфиг) не имеет ни одного бенчмарк-теста** — спек называет «Backend benchmark/limit test» явно, в кодовой базе такого теста нет вовсе. `MAX_SUPPORT_ATTACHMENT_BYTES` (8 MiB) проверен по коду (`support_package.py:494`), но не тестом на реальном превышении размера |
## 4. Находки
### Medium (в скоупе #43, чинится в этой же задаче)
**M1. `check-docs` красный на этом SHA — устаревший отпечаток скриншотов документации.**
`node scripts/check-docs.mjs` → `ERROR screenshot source fingerprint is stale;
run npm run build && node demo/docs/capture.mjs`. Подтверждено, что это не
фоновый шум: тот же скрипт на чистом `origin/dev` (`319b25c2`, отдельный
worktree) даёт «Documentation checks passed». Причина механическая и полностью
предсказуемая по `AGENTS.md`/`PROCESS.md` §8: `docs/images/screenshots.json`
хранит `sourceFingerprint: 7faa6c2e…`, а актуальный `visualFingerprint(src/**)`
на HEAD — `d300613d…`; диапазон меняет `houseplan-card.ts`,
`houseplan-editor-runtime.ts`, оба `styles/*.ts` — любая из этих правок делает
отпечаток устаревшим, «выбирать тут нечего». Job `docs` в `validate.yml`
запускает ровно эту же команду (`--external`) — CI на этом SHA покраснеет
именно там, повторяя сценарий #230/#234/#237 (там же `dev` простоял с красным
`docs` до следующей задачи). Фикс — пересъёмка (`npm run build && node
demo/docs/capture.mjs`), либо, если требуется байт-в-байт воспроизводимость
между окружениями (`docs:accept --reviewed --from=<CI-артефакт>`), прогон job
`Docs screenshots` (`workflow_dispatch`) с последующей приёмкой. Ни то, ни
другое ревьюер делать не вправе (не правит продуктовый код/сгенерированное).
### M2. Ни одного браузерного теста или golden-сцены для всего диалога Help & Feedback.
`git diff --stat origin/dev...HEAD -- demo/` и `-- demo/golden` — пусто.
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` называет ~40
смоков, отобранных по совпадению с уже существующими символами
(`_config`, `_editorRuntime`, `_infoCard`, `_markerDialog`) — ни один не
упоминает новую кнопку, диалог или форму, потому что символы `_openSupportDialog`,
`_submitSupport`, `_renderSupportDialog` не встречаются ни в одном смоке
репозитория. Единственные тематически похожие файлы,
`demo/smoke_feedback_v2.mjs` и `demo/smoke_help_affordance.mjs`, существовали в
`origin/dev` до этой ветки и относятся к неродственной фиче #68 (контекстная
подсказка `hp-help`, кнопка `.rlgearbtn`) — совпадение имён случайное.
Это не абстрактная придирка к процессу, а прямое расхождение с самим ТЗ,
которое прошло пять раундов ревью именно ради точности доказательств:
- §13 AC1: «Browser smoke in View + three editors + kiosk/unauthorized negatives»;
- §13 AC13: «Browser smoke across success/429/timeout/unknown command»;
- §13 AC14: «Reviewed desktop + phone + tablet goldens and touch smoke»;
- §14.2 явно перечисляет golden-сцены («desktop no attachment, desktop preview,
phone validation error, phone success, relay error/manual recovery, light and
dark themes»);
- §14.4 (обязательные мутации): «success shown on timeout → browser smoke red» —
без единого браузерного теста эта мутация физически некому ловить.
Прочитанный код (см. §3 таблицы AC1/AC13/AC14) выглядит корректным, и я
принимаю его как «проверено чтением» для логики. Но именно то, что чтением
принципиально не доказывается — реальный вычисленный размер тач-таргета в
браузере, фактическое поведение фокуса/ARIA при настоящем Tab/Escape, реальная
раскладка при ширине 320px, экранные состояния success/429/timeout — эта фича
вводит новую диалоговую поверхность с уникально строгим touch-контрактом
(единственное явное исключение из «редакторы desktop-first» во всём проекте) и
не имеет вообще никакого доказательства на этом уровне. Ручного тестирования в
процессе нет по конструкции — это тем более причина не оставлять единственный
уровень, который ловит браузерные дефекты, полностью пустым для новой
поверхности.
### M3. Новый явный `any` без обоснования — 4 вхождения, гейт `no-new-any` красный.
`node scripts/no-new-any.mjs --base origin/dev --head HEAD`:
```
src/houseplan-editor-runtime.ts:9199 — const response: any = await this.host.hass.callWS({...}) (preview)
src/houseplan-editor-runtime.ts:9317 — const response: any = await this.host.hass.callWS({...}) (submit)
src/support-feedback.ts:124 — options.language as any
src/support-feedback.ts:141 — (value as any).code
```
Ни на одной строке нет `// any-ok: <причина>`, как того явно требует §8. Все
четыре — не случай «тип недоступен»: формы WS-ответов полностью описаны в §8.1
и §8.3 ТЗ (`{token, expires_in, size, sha256, spaces, format, version, text}`
и `{report_id}`), их можно типизировать интерфейсом; `options.language as any`
используется только для проверки членства в `readonly ['en','ru','de','fr']` —
идиоматично решается через `(LANGS as readonly string[]).includes(...)` без
приведения аргумента к `any`; `(value as any).code` — тривиально
`(value as Record<string, unknown>).code`. Гейт создан именно для этого класса
случаев (issue #342): новый код не должен по умолчанию расширять долг в 1034
уже существующих `any`.
### Low (снимается с записью, не блокирует)
**L1.** `scripts/support-relay/deploy/Caddyfile.fragment`: `request_body {
max_size 8.7MB }` — Caddy парсит `MB` как десятичные байты (8 700 000), а
собственный лимит relay `MAX_REQUEST_BYTES = 8 * 1024*1024 + 512*1024` — это
8.5 **MiB** (8 912 896 байт). То есть внешний прокси-лимит на ~213 КБ **меньше**
внутреннего, хотя по комментарию в `config.py` («вложение ≤ 8 MiB, весь запрос
≤ 8.5 MiB») задумывался запас, а не сужение. Эффекта на легитимный трафик нет:
`support_package.py` отклоняет пакет ещё в HA до отправки, если он больше ровно
8 MiB (`MAX_SUPPORT_ATTACHMENT_BYTES`), так что реальный исходящий запрос —
максимум ~8.0-8.1 MiB, с запасом ниже обеих границ; и даже гипотетический
прямой запрос к публичному эндпоинту, упёршийся в Caddy раньше, чем в
`app.py`, получит тот же HTTP 413, который `support_transport.py` мапит на
`support_package_too_large` по коду статуса, а не по телу ответа. Стоит
поправить директиву на явно бо́льшее значение (например, `9MB` или
`9437184`) при следующей правке деплоя — путаница decimal/binary единиц имеет
свойство накапливаться.
**L2.** `custom_components/houseplan/websocket_api.py`, `ws_support_preview`:
дорогая сборка снимка (`await hass.async_add_executor_job(_build_snapshot)`,
бюджет до 750 мс/24 MiB) выполняется **до** проверки
`MAX_SUPPORT_PREVIEWS_PER_USER`/`_TOTAL` (строки 2180 → 2194-2196). Уже
авторизованный (`can_write`) пользователь с 3 живыми превью может повторно
дёргать `houseplan/support/preview`, каждый раз оплачивая полную стоимость
сборки, и только после этого получать `support_rate_limited` — лимит защищает
только хранимое состояние, не CPU. Блокирующим не считаю: действующее лицо уже
прошло авторизацию записи (доверенная роль admin/`can_write`, не анонимный
интернет), итоговый инвариант «не больше 3 сохранённых превью» не нарушается.
**L3.** `_support_repairs()` (`websocket_api.py:244-253`, новая функция) не
покрыта ни одним тестом — §14.1 явно называет «sentinel fixture including
`broken_plan_<spaceId>` Repair normalization» в плане тестов, а
`test_support_package.py` подаёт `repairs=[...]` в `build_support_package`
напрямую, минуя чтение реестра. Прочитал функцию — она физически не может
вернуть ничего, кроме `{"code": "broken_plan", "count": N}` (raw `issue_id`
нигде не покидает функцию, кроме как через `.startswith()`-проверку), поэтому
не блокирую, но фиксирую расхождение с собственным тест-планом ТЗ.
**L4.** `test/support-feedback.test.mjs` и весь diff добавляют
`backup.error.support_invalid_message`/`support_package_too_large`/
`support_preview_expired`/`support_rate_limited`/`support_rejected`/
`support_unavailable` в namespace `backup.error.*` во всех 4 локалях
(`src/i18n/en.json:1132-1137` и параллельно в `ru/de/fr.json`), но ни разу их
не читает — реальные ошибки диалога идут через `support.error.*`
(`_supportErrorText`, `houseplan-editor-runtime.ts:9345-9351`). Функционально
безвредно (просто неиспользуемые переводы), но может ввести в заблуждение
следующего читателя, решившего, что backup-flow умеет показывать
support-ошибки. Можно удалить точечной правкой.
## 5. Что проверено и корректно (без замечаний)
- Allowlist-проекция `support_package.py`: каждая `_project_*`-функция строит
новый `dict` полем за полем, ни разу не сериализуя и не мутируя исходный
`config`/`layout` — структурная гарантия §7.3 выполнена буквально, не только
по духу.
- Авторизация: все три WS-команды используют тот же `_check_write`/`may_write`,
что и остальные write-команды интеграции; владение токеном проверяется в
discard и submit.
- Пседонимизация: пространство имён случайное на каждый preview
(`secrets.token_hex(4)`), не хранится и не возвращается клиенту; неизвестные
ключи `layout` отбрасываются fail-closed (`_project_layout`), а не
копируются.
- Транспорт: фиксированный compile-time HTTPS-хост, редиректы отключены,
таймауты 5/20с, ответ ограничен и не отражается, статусы мапятся на закрытый
список кодов независимо от тела ответа.
- Relay: `X-Forwarded-For` берётся из последнего элемента только при
`trusted_proxy=True` (и запрещён к выключению за прокси текстом ТЗ и
deploy-README); оба канала доставки не используют `parse_mode`, поэтому
пользовательский текст не может быть разметкой; 36/36 тестов зелёные, три из
них — специально проверенные владельцем и ревьюером ТЗ мутационные гварды
(XFF spoofing, direct-node XFF-ignore, вебхук прикладывает вложение).
Изменений в этом коде текущим диапазоном нет — унаследовано с зелёного
спек-ревью r5 (SHA `3ce5bc0e`), но перепрочитано заново как часть этого,
первого код-ревью, а не принято на веру.
- «Одно число — один источник» для preview/download/submit: единственный
расчёт байт/хеша на backend, единственный кешированный объект на фронтенде;
см. §2.6.
- Трейлеры и changelog: `1e2e0fa6` несёт `Issue: #43` + `User-Visible: yes` с
правками в обоих changelog в том же коммите; merge-коммит `1ba53630` —
`User-Visible: no`, корректно. `process-gate --issues` зелёный.
- AC15 (отсутствие побочного влияния на diagnostics/backup/#295): 0 изменений
в `diagnostics.py`/`import_export.py`/`validation.py`.
- Деградация на старом backend: `compatible = this.host._haIntegrationVersion
=== CARD_VERSION` закрывает форму отдельным `supportupdate`-блоком, About/Guide
остаются доступны — совпадает с §10 ТЗ.
- `scripts/process-gate.mjs::classify('scripts/support-relay/...')` → `'B'`,
подтверждено и regression-тестом (`test/process-gate.test.mjs`, новый кейс
«#43»), и живым прогоном — Medium-находка спек-ревью r1 закрыта на уровне
файловой раскладки, а не только текста ТЗ.
## 6. Чего не проверял и почему
- **Полный HA-харнесс** (`test_ha_websocket.py` целиком, `test_ha_import_export.py`
и т.д.) — песочница ревьюера не имеет Python 3.14/`pytest-homeassistant-custom-component==0.13.357`
(пин CI), а неверсионированная установка дала диагностически бессмысленный
результат (96 несвязанных провалов). AC9/AC10 закрыты пометкой «проверено
чтением», не автотестом с моей стороны — см. §2.2 и таблицу AC.
- **`npm run invariants`** — диапазон не меняет хранимую геометрическую модель
(`validation.py` и модуль стен/толщины не тронуты), только читает её для
экспорта; гейт не по предмету, не пропуск.
- **`npm run golden:verify`** — golden-сцен для этой фичи не существует вообще
(см. находку M2); верифицировать нечего, сама находка это фиксирует.
- **Полный набор `demo/smoke_*.mjs`** — предрелизная обязанность (§8), к тому
же ни один существующий смок не относится к этой фиче (см. §2.4); прогон
всех ничего не доказал бы для этого диффа.
- **Числа из хендоффа автора** (`npm test — 1723 passed`, `support relay — 36
passed`) — перепроверены лично и совпадают. Число `privacy/backend targeted
— 15 passed` не совпадает буквально с личным подсчётом (10+7=17 на моей
урезанной установке пакетов) — возможно, иной набор `-k`; не рассматриваю
как расхождение, потому что оба набора, которые смог прогнать сам, зелёные.
## 7. Итог
Функциональность реализована добросовестно и соответствует зелёному, пять раз
провалидированному ТЗ по всем 17 AC на уровне логики: allowlist строится по
белому списку, а не редактированием копии; авторизация и владение токеном
корректны; transport и relay воспроизводят все инварианты, за которые
владелец лично поручился пентестом. Ни одной High-находки — architecture
привacy-границы не нарушена нигде, где я мог её проверить.
Три Medium-находки удерживают вердикт жёлтым, и все три — предметные, не
формальные: красный `check-docs` гарантированно красит CI на этом же SHA
(проверено сравнением с `origin/dev`), гейт `no-new-any` красный без единого
обоснования, а вся браузерная часть контракта (кнопка, тач-таргет, формы,
golden-сцены), которую само ТЗ называет способом доказательства четырёх AC,
не имеет вообще никакого покрытия на этом уровне. Все три чинятся в рамках
текущей задачи без изменения контракта.
Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 3 → в задаче
+206
View File
@@ -0,0 +1,206 @@
# CODE-REVIEW-43-r2
- Issue: [#43](https://github.com/Matysh/houseplan-card/issues/43) — Диалог помощи и обратной связи с обезличенным support report
- Ветка: `issue/43-help-feedback`
- SHA ревью: `0e0ca3d4dc38851555a825b9481817642d8647a9`
- Заход: r2 (второй код-ревью) · блокирующих циклов израсходовано **1/4** (r1 был жёлтым — потратил цикл; зелёный вердикт цикла не образует, §4/#227)
- Вердикт: **зелёный** · High: 0 · Medium: 0
## 1. Скоуп раунда
Это второй заход, разбор — по дельте (§2.10 PROCESS.md). Предыдущий вердикт
(r1, жёлтый) получен на SHA `1ba5363038055b3f3442b62a761161d05c6d03c7`
(документ `docs/reviews/CODE-REVIEW-43-r1.md`, SHA явно назван в самом
документе). Дельта:
```
git diff 1ba53630..0e0ca3d4
```
3 коммита сверх r1: `ffddc383` (fix: satisfy validation gates), `40d63080`
(docs: review document — публикация r1, генерирует конвейер, не автор),
`0e0ca3d4` (test: cover help and feedback browser flows).
Продуктовый код изменён только в двух местах, и оба — рефакторинг типов без
изменения поведения:
- `src/houseplan-editor-runtime.ts` (+28/−17): `response: any` → `response: unknown`
с явным сужением через промежуточный `payload`-объект в `_buildSupportPreview`
и `_submitSupport`. Логика чтения полей (`text`, `size`, `token`, `sha256`,
`report_id`, …) идентична строка в строку — только тип входа сузился.
- `src/support-feedback.ts` (+4/−3): `options.language as any` → typed
`readonly string[]`, `(value as any).code` → `(value as {code?:unknown}).code`.
Тоже чистое сужение типа, не логики.
`custom_components/houseplan/**/*.py` в дельте не тронут вообще (проверено
`git diff --stat 1ba53630..HEAD -- custom_components/` — только сгенерированные
`frontend/**`). Единственная правка бэкенда — 158 новых строк тестов в
`tests_backend/test_support_package.py`, сам `support_package.py` не менялся.
Остальное — тесты и инфраструктура: новый `demo/smoke_support_feedback.mjs`
(270 строк), 6 golden-сцен в `demo/golden/matrix.mjs` + ветка `dialog === 'support'`
в `demo/golden/harness.mjs`, обновлённый `demo/smoke_general_settings.mjs`,
новый мутант в `scripts/mutation-gate.mjs`, обновлённый `docs/images/screenshots.json`
(отпечаток скриншотов).
Дельта локальна: контракт поведения не менялся, новая подсистема не задета,
объём (≈390 строк не считая бандла) на порядок меньше исходной задачи
(+7023/−411 в r1). Полный повторный разбор не требуется — §2.10.
## 2. Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1.** `check-docs` красный — устаревший `sourceFingerprint` скриншотов | `docs/images/screenshots.json` обновлён (`sourceFingerprint`/`sourceSha256` во всех сценах: `7faa6c2e…` → `7e950337…`) коммитом `ffddc383` | Лично прогнал `node scripts/check-docs.mjs` на HEAD `0e0ca3d4` → `Documentation checks passed (7 files, 10 external links)` |
| **M2.** Ни одного браузерного смока/golden-сцены для диалога Help & Feedback | Новый `demo/smoke_support_feedback.mjs` (режимы view/plan/devices/decor, kiosk, read-only, EN/RU, старый backend, validation/focus, preview/SHA/download exact bytes, success/429/timeout/unknown, retry с тем же idempotency key, 320×760 и 760×320, touch-target 44px) + 6 golden-сцен в `matrix.mjs` (`support-desktop-empty-light-en`, `support-desktop-preview-dark-en`, `support-phone-validation-light-ru`, `support-phone-success-dark-en`, `support-relay-error-light-en`, `support-tablet-preview-dark-ru`) + ветка `dialog === 'support'` в `demo/golden/harness.mjs`, оба коммитом `0e0ca3d4` | Лично прогнал `node demo/smoke_support_feedback.mjs` на свежем бандле — все 18 полей `true`, `OK`. Лично прогнал `node scripts/mutation-gate.mjs --id=support-timeout-claims-success` — `чистый прогон` + `тест покраснел, как обязан`, `поймано 1 из 1`: смок реально умеет падать на мутанте §14.4 (ложный success на timeout). Лично прогнал `node demo/golden/run.mjs --mode=capture --scenario=<каждая из 6>` — все 6 строятся без ошибки (`missing-baseline`, не `error`; baseline умышленно не принят до предрелизного визуального ревью, это по процессу, не пропуск). `test/golden-matrix.test.mjs` получил новый тест «issue 43 support dialog has the six reviewed responsive states», зелёный в `npm test` |
| **M3.** `no-new-any` красный — 4 новых `any` без обоснования | Все 4 типизированы (см. §1) коммитом `ffddc383` | Лично прогнал `node scripts/no-new-any.mjs --base origin/dev --head HEAD` на HEAD `0e0ca3d4` → `Новых any нет` |
Все три Medium закрыты предметно — не косметика вокруг находки, а именно то,
что было названо (актуальный отпечаток вместо любого другого фикса; смок и
golden именно для нового диалога, а не для соседней фичи; типизация именно тех
четырёх строк).
## 3. Унаследовано из r1
Без повторной проверки в этом раунде — код на этих участках дельта не
затронула, полный разбор см. `docs/reviews/CODE-REVIEW-43-r1.md`
(SHA `1ba53630`):
- Allowlist-проекция `support_package.py`: 8 `_project_*`-функций строят новый
`dict`, не мутируя исходник (§7.3). Сам файл в дельте не менялся —
дельта только добавила тесты поверх него (см. §5).
- Авторизация и владение токеном на всех трёх WS-командах
(`_check_write`/`may_write`, сверка `preview.get("owner")`).
- Транспорт: фиксированный HTTPS-хост, без редиректов, таймауты 5/20с, статус
мапится на закрытый список кодов до чтения тела.
- Relay `scripts/support-relay/**`: не менялся дельтой, 36/36 тестов,
инварианты против подмены `X-Forwarded-For` (живой пентест владельца при
ревью ТЗ r3-r5).
- «Одно число — один источник» для preview/download/submit (§2.6 r1) —
цепочка байт/хеш не менялась.
- Деградация на старом backend (`compatible = _haIntegrationVersion === CARD_VERSION`).
- AC15 (нулевое влияние на `diagnostics.py`/`import_export.py`/`validation.py`).
- Трейлеры и changelog коммита `1e2e0fa6` (Issue/User-Visible, оба changelog
в одном коммите) — не пересматривались, дельта changelog не трогает.
- Low-находки r1 (L1 единицы Caddyfile, L2 порядок проверки CPU/лимита, L3
`_support_repairs()` без sentinel-теста, L4 неиспользуемые i18n-ключи
`backup.error.support_*`) — сняты решением ревьюера r1 с записью, дельта их
не касается, повторно не поднимаю.
- Ограничение проверки бэкенда: полный HA-харнесс (`test_ha_websocket.py` и
т.д.) в r1 не поднимался (нет закреплённой версии Python 3.14/phcc в
песочнице ревьюера) — то же ограничение действует и здесь, оно не связано с
дельтой (AC9/AC10 остаются «проверено чтением», не автотестом с моей
стороны, как и в r1).
## 4. Как проверялось в этом раунде
Дешёвые гейты гоняются в каждом раунде (§2.10); прогнаны лично на HEAD
`0e0ca3d4`, зелёного Validate CI на этом SHA нет — обязанность ревьюера (см.
задание раунда).
| Гейт | Команда | Результат |
|---|---|---|
| Типы | `npx tsc --noEmit` | зелёный, 0 ошибок |
| Юнит/интеграционные | `npm test` | 1725 тестов: 1724 passed, 1 skipped, 0 failed — совпадает с числом из хендоффа автора |
| Сборка + сверка бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | идентичны |
| Три копии бандла | `npm run bundle:sync` | `git status` чист после — `dist`, `custom_components/.../frontend`, `demo/srv/assets` синхронны |
| Docs-контракт (диапазон меняет `src/**`) | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` — M1 закрыт |
| Новый `any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | `Новых any нет` — M3 закрыт |
| process-gate | `node scripts/process-gate.mjs --range origin/dev..HEAD --issues` | «гейт пройден, предупреждений 0», 18 коммитов |
| Выбор смоков | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 6 изменённых файлов `src/**`, 37 «прямое совпадение»; `demo/smoke_support_feedback.mjs` назван прямым совпадением по символам `_closeSupportDialog, _config, _downloadSupportPreview, _editorRuntime, _haIntegrationVersion, _submitSupport` — ровно то, что и требовалось запустить |
| Целевой смок (новый, M2) | `node demo/smoke_support_feedback.mjs` | все 18 проверок `true`, `OK` |
| Целевой смок (изменённый, About moved out) | `node demo/smoke_general_settings.mjs` | все проверки `true`, `OK`, включая новую `aboutMovedOut: true` |
| Мутационный гейт нового смока | `node scripts/mutation-gate.mjs --id=support-timeout-claims-success` | `ok чистый прогон` + `ok тест покраснел, как обязан` → `поймано 1 из 1` — смок доказуемо умеет падать (дисциплина §2.7/§18) |
| Golden-сцены (существование/сборка) | `node demo/golden/run.mjs --mode=capture --scenario=<id>` для всех 6 новых сцен | все 6 строятся без ошибки (`missing-baseline`, не `error`) |
| Бэкенд-тесты дельты | `pip install pytest voluptuous` (без пина, как в r1) → `python -m pytest tests_backend/test_support_package.py -q` | `14 passed` (10 старых + 4 новых: reject non-mapping, rich-projection sentinel, projection helpers fail-closed, size-limit-after-projection) |
## 5. Разбор по AC, чьё доказательство дельта задевает
Обозначения как в r1: **PASS/тест** — автотест, умеющий падать (см. §4);
**PASS/чтение** — проверено чтением; **∅** — не переоценивался, см. §3.
| AC | Было (r1) | Стало (r2) | Почему изменилось |
|---|---|---|---|
| AC1 | PASS/чтение | **PASS/тест** | `smoke_support_feedback.mjs`: порядок кнопок (`afterSettings` = `.support-button` идёт после `mdi:cog-outline`), неизменность режима/zoom/выбора при открытии для всех 4 режимов, `kioskHidden`, `readonlyAbsent` — все `true` |
| AC2 | PASS/тест+чтение | PASS/тест+чтение (усилено) | Логика не менялась (тест `test/support-feedback.test.mjs` не в дельте); новый смок добавляет DOM-уровень: `aboutAndEnglishGuide`/`russianGuide`/`oldBackendDegrades` — `true`. `smoke_general_settings.mjs` подтверждает `aboutMovedOut: true` — блок не задвоен |
| AC3 | PASS/тест | PASS/тест (усилено) | `freshDefaults`, `freshAfterSuccess` — новый черновик пуст при каждом открытии/после успеха, чекбокс не сохраняется, теперь и на уровне браузера, не только unit |
| AC4 | PASS/тест | PASS/тест (усилено) | `openIsLocal`: открытие диалога не увеличивает счётчик `houseplan/support/*`-вызовов до включения чекбокса |
| AC5, AC9, AC10 | PASS/тест(+чтение) | ∅ (логика не менялась) | Единственная правка смежного кода — сужение `any → unknown` в `_buildSupportPreview`/`_submitSupport`; путь чтения полей (`payload.text`, `payload.token`, …) идентичен построчно старому `response?.text` и т.д. — проверил диффом, поведение при `response` не-объектом (`undefined`) то же самое (`{}` фоллбэк даёт те же `''`/`NaN`, что и `?.`) |
| AC6, AC7, AC8 | PASS/тест | ∅, но с дополнительным покрытием | `support_package.py` не менялся; `tests_backend/test_support_package.py` получил 4 новых теста, включая структурный `test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values` (сеет `poly`/`walls`/`markers`/`decor` с частично некорректными формами и секретными полями, проверяет точный спроецированный результат) — прогнал сам, 14/14 зелёных. Это усиление доказательства, не обязательное по находкам r1, но не создаёт риска: тесты только добавляют строгости |
| AC13 | PASS/чтение | **PASS/тест** | Смок покрывает success/`support_rate_limited`/`support_unavailable` (timeout)/`unknown_command`, ретрай с тем же `idempotencyKey` (4 запроса с одним и тем же ключом), сохранение черновика при ошибке. Мутационный гейт `support-timeout-claims-success` подтверждает, что смок красный на реальной регрессии — не просто зелёный тест-пустышка |
| AC14 | PASS/чтение, UNVERIFIED по заявленному способу | **PASS/тест** (touch/layout), golden-candidate (не baseline) | `touchTarget` (≥44×44), `noHorizontalOverflow`, `phonePortrait`/`phoneLandscape` (320×760, 760×320 без клиппинга) — реальный браузерный рендер, не чтение TS. 6 golden-сцен покрывают desktop/phone/tablet × light/dark по именам состояний из §14.2 ТЗ; baseline умышленно не принят (процесс требует полного Linux CI артефакта для приёмки — это предрелизный шаг, не код-ревью) |
| AC11, AC12/12a, AC15, AC16, AC17 | PASS | ∅ | Relay, backend-модули diagnostics/import_export/validation, i18n-независимость и lazy-импорт дельтой не задеты — код идентичен r1 |
## 6. Находки этого раунда
Ничего блокирующего. Одна наблюдение уровня Low, снимаю с записью, не
блокирует:
**L5 (новая, Low).** Старый `demo/smoke_general_settings.mjs` проверял в
реальном DOM точные атрибуты About-ссылок (`href`, `target=_blank`,
`rel=noopener`) и точный текст версии (`Houseplan Card v${BUNDLE_VERSION}`) —
эта проверка выпала при переносе About в новый диалог: замена,
`aboutAndEnglishGuide` в `smoke_support_feedback.mjs`, проверяет только
количество `.aboutlink` (`=== 2`) и суффикс `href` guide-ссылки, не точные
`href`/`target`/`rel` GitHub/Telegram-ссылок и не текст версии. Не блокирую:
разметка статична (`src/houseplan-editor-runtime.ts:9387-9397`, атрибуты
захардкожены в шаблоне), риск молчаливого дрейфа без сопутствующей правки кода
практически нулевой, а `test/support-feedback.test.mjs` (не в этой дельте)
уже проверяет текстом, что `gs.about_version` не задвоен. Стоит вернуть
точную проверку атрибутов при следующей правке этого смока, отдельного issue
не требует.
## 7. Что проверено и корректно (в дополнение к §3)
- Три Medium из r1 закрыты предметно, не декларативно — по каждой лично
воспроизвёл зелёный результат на HEAD этого раунда (см. таблицу §2 и §4).
- Новый смок доказуемо способен упасть: мутационный гейт `support-timeout-claims-success`
красит именно `demo/smoke_support_feedback.mjs`, а не абстрактный юнит —
снимает риск «зелёный тест ничего не проверяет», который прямо назван в
документации самого `mutation-gate.mjs`.
- Рефакторинг типов (`any` → `unknown`/сужение) не меняет поведение: логика
чтения полей идентична, `npm test` (1724/1724 незменившихся зелёных) и
целевые смоки подтверждают эквивалентность.
- Три копии бандла синхронны, `git status` чист после `bundle:sync` —
класс D не расходится с классом A.
- Трейлеры всех трёх коммитов дельты корректны (`Issue: #43`,
`User-Visible: no` — оправданно, видимого поведения дельта не меняет,
changelog не тронут).
- `process-gate --issues` зелёный, 0 предупреждений на 18 коммитах диапазона.
## 8. Чего не проверял и почему
- **Полный HA-харнесс** (`test_ha_websocket.py` целиком) — то же ограничение
песочницы, что и в r1 (нет закреплённой Python 3.14/`pytest-homeassistant-custom-component==0.13.357`);
дельта не меняет ни один файл, который этот харнесс покрывал бы иначе, чем
уже покрыто в r1. AC9/AC10 остаются на «проверено чтением».
- **`npm run invariants`** — диапазон не меняет геометрию/`layout`/толщину
стен ни в r1, ни в дельте; неприменимо, не пропуск.
- **`golden:verify`/приёмка эталонов** — 6 новых сцен собираются без ошибки,
но baseline не принимался: это предрелизный шаг (§8 PROCESS.md,
`npm run golden:accept -- --reviewed` только по полному Linux CI
артефакту), не обязанность код-ревью. Локальный `golden:verify` на всей
матрице не гонял — большинство существующих сцен в этой песочнице дают
`different` уже на `origin/dev` (другой Chromium/шрифты), это фон, не
регрессия дельты, и полный прогон — предрелизная, а не ревью-обязанность.
- **Полный набор `demo/smoke_*.mjs`** (213 файлов) — не оправдано: дельта не
меняет ничего, что затрагивало бы несвязанные поверхности; `smoke-select.mjs`
подтверждает совпадения только на общих символах для остальных 36 файлов
(типовой фон, тот же вывод, что и в r1).
- **Relay `scripts/support-relay/**`** — не в дельте, не перепрочитывал
повторно (r1 уже перечитал этот код заново как часть первого код-ревью, а
не принял на веру со спек-ревью).
## 9. Итог
Все три Medium-находки r1 закрыты предметно, каждая — воспроизведённым
зелёным результатом на HEAD этого раунда, а не заявлением автора. Дельта не
меняет продуктовую логику (только сужение типов и добавление тестов/смоков/
golden-сцен), поэтому полный повторный разбор всех 17 AC не требовался —
переоценены только AC1/AC2/AC3/AC4/AC13/AC14, чьё доказательство дельта
затронула, остальные унаследованы из r1 без изменений в коде, который их
доказывает. Новый браузерный смок дополнительно подтверждён мутационным
гейтом — это не пустой зелёный тест. High-находок нет, Medium-находок нет,
одно Low-наблюдение снято с записью в этом же документе.
Вердикт: зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0
+228
View File
@@ -0,0 +1,228 @@
# SPEC-REVIEW-43-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/43
- Ревьюер: Claude (роль «Ревьюер ТЗ», PROCESS.md §2.4)
- Материал: `docs/specs/043-private-support-report.md`, ревизия 2, на коммите
`755fa4cf` (ветка `issue/43-help-feedback`), плюс тело issue #43 и все 7
комментариев (аналитика 2026-08-14/2026-08-30, финальное UX-описание
владельца 2026-09-01, повторная аналитика, Q1–Q5 и ответы владельца, хендофф
автора «ТЗ полностью переработано»).
- Заход: r1 (первый разбор этого ТЗ, документ прежних раундов не существует —
предыдущая ревизия ТЗ была отклонена самим автором в аналитике 2026-09-01,
а не ревью, поэтому раздел «Унаследовано» не применяется).
- Трек: полный (аналитик явно назвал непройденные критерии `small`:
больше одной поверхности, новый UX-контракт, новый внешний transport,
privacy/security-контракт, обязательное влияние на touch-View) — лимит
циклов ревью ТЗ 4, не 2.
## Скоуп
ТЗ описывает новый диалог «Помощь и обратная связь» в шапке карточки:
перенос блока «О карточке», языковая ссылка на USER-GUIDE, форму
контакт/сообщение и opt-in обезличенный support-пакет с точной геометрией,
который уходит через backend House Plan в отдельный project-controlled
HTTPS-relay (новый deployable сервис `support-relay/`) в закрытый
support-mailbox. Это первая ревизия, дошедшая до ревью; предыдущая
(clipboard-only) была отозвана автором ещё в аналитике.
## Как проверялось
1. `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (целиком, включая §1, §2.3–2.10,
§4, §7.1–7.2) — прочитаны до разбора ТЗ.
2. Issue #43: тело + все 7 комментариев через `gh issue view --json`
(`mcp__github__*` были недоступны без разрешения) — восстановлена полная
цепочка решений владельца (финальное UX-описание → Q1–Q5 → ответы).
3. Построчное чтение `docs/specs/043-private-support-report.md` (646 строк)
против §7.1 обязательных разделов, продуктовых вопросов из issue и
`docs/TOUCH-SUPPORT.md`.
4. Сверка утверждений §3 («подтверждённое текущее состояние») с реальным
кодом: `src/houseplan-card.ts` (кнопка General Settings, `_norm && _canEdit`,
порядок zoom→settings, ленивый `import('./houseplan-editor-runtime')`),
`src/houseplan-editor-runtime.ts` (блок «О карточке», `gs.about_*` ключи),
`custom_components/houseplan/{websocket_api,import_export}.py`
(`create_export`), `repairs.py` (`broken_plan_<spaceId>`),
`src/i18n/{ru,en,de,fr}.json` (RU/EN/DE/FR уже существуют).
5. Сверка i18n-подписи и терминологии («О карточке», «Общие настройки»,
GitHub/Telegram-ссылки) с `docs/USER-GUIDE.ru.md` и `src/i18n/ru.json` —
расхождений не найдено.
6. `docs/CONFIG-COMPATIBILITY.md` — подтверждено, что фича не трогает схему
config/layout (ТЗ §10 корректно).
7. `docs/TOUCH-SUPPORT.md` — сверка UX/§11 с контрактом «View dialogs and
safe device actions: Fully supported» и правилом обязательной пометки
`Touch editor: …`.
8. `docs/specs/README.md` — обязательный раздел release-артефактов и
двусторонняя ссылка issue↔ТЗ (строка 106) присутствуют.
9. `scripts/process-gate.mjs` прочитан целиком по функции `classify()` и
условиям `fail(1, …)`/`fail(8...)`, чтобы проверить, как гейт классифицирует
новый путь `support-relay/**`, которого ТЗ вводит впервые.
### Гейты
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | зелёный, 6.2 с |
| `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» (офлайн, диапазон `origin/dev..HEAD`, 1 коммит) |
| `npm test`, `npm run build`, `check-docs.mjs`, смоки, `model-invariants`, `pytest tests_backend` | **не прогонялись** — диф коммита `755fa4cf` строго класса C: `docs/specs/043-private-support-report.md` + `docs/specs/README.md` (`git show --stat HEAD`), `src/**`/`custom_components/**` не тронуты. Эти гейты бессмысленны для чисто документного диффа на этапе ТЗ; `check-docs.mjs` условен на изменения `src/**`, которых нет |
Трейлеры коммита `755fa4cf`: `Issue: #43`, `User-Visible: no` — корректны для
docs-only правки.
## Находки
### Medium (в скоупе задачи) — `support-relay/**` не попадает ни в один класс изменений
**Файл:** `docs/specs/043-private-support-report.md`, §9.1 (строки 404–416) и
§18 (строки 606–617).
**Суть:** ТЗ явно вводит новый top-level каталог `support-relay/` —
«separately deployable service, excluded from the HACS artifact» — и явно
кладёт «минимальный deployable relay» в объём #43 (§5.1). Ни `AGENTS.md`
(таблица «Change classes»), ни `PROCESS.md §1` не знают такого пути: класс A
ограничен `src/**` и `custom_components/houseplan/**/*.py`, класс B —
конкретным списком (`test/**`, `tests_backend/**`, `demo/**`, `scripts/**`,
`.github/**`, …), класс C — документацией. `support-relay/**` не match-ится
ни одним regex.
Я прочитал `scripts/process-gate.mjs::classify()` (строки 93–99): путь, не
попавший ни в `CLASS_D/A/B/C`, получает `'?'`. Строка 255–256 превращает это
только в **предупреждение** (`warn(0, …)`), а не в отказ. Требование трейлера
`Issue: #NN` (правило 1, строка 232) и проверка статуса issue через `--issues`
(правило 8, строки 310/392/510) применяются только когда
`c.classes.has('A') || c.classes.has('B')` — то есть коммит, который трогает
**только** `support-relay/**`, эти проверки не запускает вовсе.
**Сценарий отказа:** после старта реализации коммит вида
`feat: relay rate limiting` с diff только внутри `support-relay/` пройдёт
`process-gate.mjs` (и локальный `pre-push`) без единого трейлера `Issue:`,
без метки issue в `S5-ready`/`S6-in-progress`/`S7-code-review`, без ссылки на
ТЗ — то есть ровно то, что правило №1 AGENTS.md объявляет запрещённым
(«Изменение продуктового кода без issue запрещено»). Для обычного продукта
это было бы досадной дырой в тулинге; здесь эта дыра открыта ровно в
подсистеме, которая пересылает точную геометрию дома и контакт пользователя
на внешний сервер — там же, где PROCESS.md требует наибольшей строгости.
**Почему это находка ТЗ, а не готового кода:** §18 «Documentation and release
artifacts» перечисляет `docs/USER-GUIDE`, `docs/ARCHITECTURE.md`,
`docs/SUPPORT-PRIVACY.md`, `docs/TESTING.md`, relay README — но не упоминает
обновление таблицы классов в `AGENTS.md`/`PROCESS.md §1`. Без явного решения
в ТЗ реализация имеет прямой путь создать неклассифицированный каталог и
получить только warning вместо gate.
**Почему это не продуктовый вопрос владельцу:** это техническое решение
(файловая раскладка / расширение таблицы классов), которое, по §7.1
PROCESS.md, агенты решают и фиксируют сами («Всё, чего пользователь не
наблюдает, агенты решают сами… раскладка файлов»). Автору достаточно
добавить в ТЗ (например, в §9.1 или §18) одно из двух решений:
- либо явно расширить класс A (или завести отдельный подкласс) на
`support-relay/**` в `AGENTS.md`/`PROCESS.md §1` тем же коммитом, где
каталог появляется, и включить эту правку в release-артефакты §18;
- либо разместить relay-код внутри уже классифицированного пути (например,
под `scripts/support-relay/` — класс B, или отдельно обсудить с владельцем
вынос в отдельный репозиторий, если секреты не должны жить в этом дереве
вообще).
Оставляю выбор автору — важно, чтобы решение было явно записано в ТЗ, а не
осталось implicit-пробелом, который заметят только когда реальный коммит
пройдёт гейт с одним warning.
### Low (снимается с записью) — отсутствует явная пометка `Touch editor: …`
**Файл:** `docs/specs/043-private-support-report.md`, §11 (строки 461–476).
`docs/TOUCH-SUPPORT.md` требует: «New editor feature specifications … must
state one of: `Touch editor: supported`; `Touch editor: best effort /
intentionally degraded`; `Touch editor: not exposed`.» Диалог Help/Feedback
доступен не только в View, но и во всех трёх редакторах (Plan/Devices/
Backdrop, §6.1), поэтому формально подпадает под это правило. §11 подробно и
корректно описывает touch/a11y-контракт (44×44 px цель, 320 px раскладка,
`aria-live`, фокус-менеджмент) — по содержанию это ровно `Touch editor:
supported`, просто без канонической фразы, по которой обычно грепают такие
декларации.
Снимаю как Low без возврата на цикл: содержательно контракт уже сильнее, чем
«fully supported» из таблицы TOUCH-SUPPORT.md для View-диалогов, а сам диалог
— не операция редактирования геометрии, а сквозной UI-элемент шапки. Автору
имеет смысл добавить одну строку «Touch editor: supported» в §11 при
следующей правке ТЗ ради единообразия аудита, но это не блокирует переход в
`S5-ready`.
## Что проверено и корректно
- **Обязательные разделы §7.1** — все на месте: сценарий (§1, персона Home
admin из SCOPE.md, поверхность View+все редакторы), что человек увидит
до/после (§2, одной фразой, без терминов реализации), проблема (§3), скоуп
и не-скоуп (§5.1/5.2), контракт поведения (§4, §6–9), UX (§6, §11), модель
данных и миграция (§10 — миграции нет, обоснованно: пакет строится на
чтении, не меняет schema), i18n (§12), AC1…AC17 с доказательством (§13),
план автотестов (§14), риски (§16), откат (§19), release-артефакты (§18).
- **Продуктовые вопросы правильно классифицированы и закрыты владельцем.**
Q1–Q5 в issue — все действительно продуктовые (куда уходят данные, что
считается «бэкапом», что видно в preview, кто видит кнопку, есть ли
контакт для ответа) — ни один не технический вопрос, вынесенный по ошибке.
Ответы владельца дословно перенесены в §4/§6/§7/§9 ТЗ без искажений
(сверено построчно): точная геометрия — да (Q2), preview точных байтов —
да (Q3), только `can_write`, без kiosk (Q4), необязательный контакт вне
support-пакета (Q5), HTTPS-relay через backend (Q1).
- **Технические предположения промаркированы.** §20 «Принятые технические
предположения» явно называет неочевидные архитектурные решения (email-relay
вместо issue, package-local sequential псевдонимы вместо стабильного хэша,
in-memory кеш preview, 30/24-часовые сроки хранения) как «assumed, change
freely» — это именно то, что требует PROCESS.md §7.1, и защищает от
«догадки, выданной за факт».
- **Утверждения о текущем состоянии (§3) проверены по коду, а не приняты на
веру.** Каждое из пяти технических утверждений подтвердилось построчно (см.
«Как проверялось» п.4) — расхождений с реальным деревом не найдено.
- **AC однозначны и снабжены методом доказательства.** Все 17 AC в таблице
§13 имеют явную колонку Evidence с конкретным методом (browser smoke,
DOM/i18n unit, backend test, adversarial security test, fake-clock test,
performance benchmark) — ни одного «проверить вручную» или недоказуемого
критерия не найдено.
- **«Одно число — один источник» уже спроектировано в контракт, а не оставлено
на волю реализации.** §6.4/§7.1/§8.1/AC5/AC10 и явный пункт mutation-gate
«preview bytes are regenerated on submit → exact-byte test red» (§14.4)
требуют, чтобы preview, download и отправленные байты были одним и тем же
кешированным объектом с TTL-токеном, а не тремя независимыми вычислениями —
ровно тот инвариант, который трижды ловил регрессии в проекте (#234, #233).
- **Privacy allowlist/exclusion списки (§7.2/7.3) внутренне непротиворечивы**:
каждый пункт «включено» имеет зеркальный запрет в «исключено» там, где это
применимо (имена/ids/URL/бинарники/undo-backup/HA state исключены явно;
точная геометрия включена явно и осознанно как продуктовое решение, а не
побочный эффект).
- **i18n-контракт (RU/EN/DE/FR)** соответствует уже существующим локалям
проекта (`src/i18n/{ru,en,de,fr}.json` существуют) — фича не расширяет
список поддерживаемых языков произвольно.
- **Cross-links.** Issue #43 ↔ ТЗ ↔ `docs/specs/README.md` — ссылки в обе
стороны на месте.
- **DoR-зависимости (§17)** корректно вынесены как внешние операционные
факты (деплой relay, mailbox, staging), а не спрятаны как «предположения»:
ТЗ прямо говорит, что при их отсутствии после зелёного ревью issue должен
получить `S5-ready` + `blocked`, а не мёртвую кнопку — это соответствует
PROCESS.md §2.9 и §7.1 «риски перечислены».
## Чего не проверял
- **Полные `npm test`/`npm run build`/browser-смоки/`model-invariants`/
`pytest tests_backend`** — не прогонялись: диф чисто документный (класс C),
ни один из этих гейтов не имеет предмета для проверки на этом коммите.
Они станут обязательны на этапе код-ревью, когда появится реализация.
- **Реальная развёртываемость relay, содержимое `support-relay/`, secrets-
handling** — предмета для проверки ещё нет (кода нет), это заявленная
DoR-зависимость §17, а не то, что должно быть в ТЗ.
- **Golden/скриншоты, performance-профили** — не относятся к докс-only диффу
этого раунда.
- **Соответствие ещё не написанных `docs/SUPPORT-PRIVACY.md`,
`docs/ARCHITECTURE.md`-правок** — эти документы появятся вместе с кодом
(§18), не на этапе ТЗ.
## Вывод
ТЗ методологически одно из самых тщательных, что проходили через этот
процесс: 17 однозначных AC с доказательствами, явное разделение продуктовых
решений владельца и технических допущений, встроенная защита от «одно число —
два источника» и от privacy-регрессий через adversarial-фикстуру. Единственная
блокирующая (по бюджету цикла) находка — не продуктовая, а процессная: новый
top-level каталог `support-relay/` создаёт слепую зону в автоматическом гейте
`process-gate.mjs`, причём именно для той части системы, где цена
незамеченного нарушения правила «код только через issue» выше всего. Это
Medium-находка в скоупе задачи (сам каталог заводит #43), правится одной
явной строкой в ТЗ — второй раунд не должен требовать нового расследования.
+159
View File
@@ -0,0 +1,159 @@
# SPEC-REVIEW-43-r2
- Issue: https://github.com/Matysh/houseplan-card/issues/43
- Ревьюер: Claude (роль «Ревьюер ТЗ», PROCESS.md §2.4)
- Материал: `docs/specs/043-private-support-report.md` на коммите `e25aa302`
(ветка `issue/43-help-feedback`), дельта против r1 (`docs diff
00b68450..e25aa302 -- docs/specs/043-private-support-report.md`).
- Заход: r2 · блокирующих циклов израсходовано 1 из 4.
- Трек: полный (не изменился с r1).
- Формат разбора: **по дельте** (PROCESS.md §2.9, issue #214) — обоснование
ниже, в «Почему делаю по дельте, а не заново».
## Предыдущий раунд
- Вердикт r1: жёлтый · High 0 · Medium 1 (в скоупе) · Low 1 (снят без цикла).
- Комментарий с вердиктом: issue #43, 2026-09-01T20:40:11Z.
- Документ r1: `docs/reviews/SPEC-REVIEW-43-r1.md`, зафиксирован коммитом
`4559b5cd`.
- SHA, на котором получен вердикт r1: `755fa4cf` (упомянут в тексте вердикта
и в шапке `SPEC-REVIEW-43-r1.md`). Этот SHA не резолвится в текущем дереве —
автор пишет в хендоффе r2 «Ветка перебазирована на актуальный dev», и
`git rebase` переписал хеши; коммит с тем же содержимым сегодня называется
`00b68450` (`git diff 00b68450..HEAD` даёт содержательно тот самый и
единственный дифф, который описывает хендофф r2).
## Почему делаю по дельте, а не заново
Формально это ребейз на ушедший вперёд `dev` — сценарий, для которого
инструкция требует полного разбора, если ребейз действительно сделал это
«другим кодом» (§7.2). Проверил, что это не тот случай:
- `git merge-base HEAD origin/dev` = `4fa670e8`; коммиты `dev` между старой и
новой базой (`823fc316..4fa670e8`: пин растеризации скриншотов, измерение
дрейфа кадра, гейт стабильности) не трогают ни `AGENTS.md`, ни
`PROCESS.md`, ни `scripts/process-gate.mjs`, ни `docs/TOUCH-SUPPORT.md` —
`git diff --stat` по этим путям в диапазоне пуст. Это ровно те четыре
документа, на которые опирались обе находки r1.
- `git diff 00b68450..HEAD --stat` показывает три файла: добавленный документ
ревью `docs/reviews/SPEC-REVIEW-43-r1.md` (артефакт публикации, не правка
автора), `docs/specs/README.md` (1 строка, не содержательная), и
`docs/specs/043-private-support-report.md` — 24 изменённые строки, все три
правки локализованы в §9.1, §11 и §18 и построчно соответствуют двум
находкам r1.
- Ни договор поведения (§4/§6–8), ни AC1–AC17 (§13), ни privacy allowlist
(§7.2/7.3), ни UX-контракт (§6) дельта не задевает.
Поэтому разбор r2 ограничен: (а) доказать закрытие двух находок r1 построчно,
(б) проверить, что дельта не сломала ничего в соседних разделах, (в)
унаследовать всё остальное из r1 без повторной проверки.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **Medium.** `support-relay/**` как новый top-level каталог не попадает ни в класс A, ни в B `scripts/process-gate.mjs::classify()` → коммит, трогающий только этот каталог, проходит гейт без трейлера `Issue:` и без проверки статуса issue. | §9.1 переписан: relay переехал под `scripts/support-relay/**`, что совпадает с `CLASS_B` (`/^scripts\//` в `scripts/process-gate.mjs:59`) без каких-либо правок `AGENTS.md`/`PROCESS.md`/`process-gate.mjs`. §18 добавляет обязательство держать runtime/manifest/tests/README relay целиком в этом поддереве и завести process-gate regression fixture, доказывающую, что relay-only коммит классифицируется как B. | `docs/specs/043-private-support-report.md:404-412` (§9.1), `:621-623` (§18). Проверено запуском `node scripts/process-gate.mjs` на HEAD — «гейт пройден, предупреждений 0», путь `scripts/support-relay/` реально попадает в `CLASS_B` по regex `scripts/process-gate.mjs:59`. |
| **Low.** §11 по содержанию реализует `Touch editor: supported`, но не содержит канонической фразы, которую требует `docs/TOUCH-SUPPORT.md`. | В §11 первой строкой добавлено `**Touch editor: supported.**` с уточнением, что диалог доступен из View и всех трёх редакторов с одинаковым touch-контрактом, а kiosk по-прежнему его скрывает. | `docs/specs/043-private-support-report.md:465-467`. Фраза дословно совпадает с канонической формой из `docs/TOUCH-SUPPORT.md:165` (`Touch editor: supported`). |
Обе находки закрыты текстом, а не заявлением: правка видна в дифф-контексте
выше, а не только в комментарии автора.
## Проверка дельты (не унаследовано — перепроверено заново)
1. **Согласованность пути relay по всему документу.** `grep -n
"support-relay" docs/specs/043-private-support-report.md` даёт ровно два
совпадения (§9.1 заголовок и §18 release-артефакты) — оба уже указывают на
`scripts/support-relay/**`, старого top-level упоминания `support-relay/`
без префикса нигде не осталось. Более широкий `grep -n "relay"` (60+
вхождений по всему файлу) показал, что остальные упоминания — это
архитектурная роль сервиса («project-controlled relay», «relay secret»,
«relay fixture» и т.д.), а не файловый путь, поэтому переезд каталога их
не касается — переименование сделано полностью, частичной правки нет.
2. **Не нарушает ли новое место relay границу HACS-пакета.** `hacs.json`
(`zip_release: true`, `filename: houseplan.zip`) не перечисляет
содержимое явно — упаковка определяется отдельным релизным шагом, не
`process-gate.mjs`; `scripts/**` и раньше не попадал в
`custom_components/houseplan/frontend/` (единственный источник
HACS-фронтенда по `CLASS_D`). Утверждение «excluded from the HACS
artifact» в §9.1 остаётся верным после переезда, это не новый риск,
привнесённый дельтой.
3. **Не конфликтует ли `scripts/support-relay/**` с существующей ролью
`scripts/`.** `tsconfig.json` ограничивает typecheck `src/**/*.ts`;
каталог `scripts/` уже состоит из `.mjs`/`.py`-скриптов вне зоны
компиляции TS — новый relay-код в том же дереве не меняет эту границу и
не требует правки `tsconfig*.json` (что и означало бы `A+B`, а не только
`B`, в терминах самого §9.1).
4. **Формулировка "a product commit that also changes `src/**` remains A+B и
follows the stricter class-A flow"** (§9.1) сверена с
`scripts/process-gate.mjs:137` (`classes: new Set(files.map(classify))`) и
условиями `classes.has('A') || classes.has('B')` для строгих проверок —
описание корректно: набор классов коммита — это множество классов всех
его файлов, а не единственная метка.
5. **Трейлеры коммита дельты.** `git show e25aa302 -s` → `Issue: #43`,
`User-Visible: no` — верно для docs-only правки без видимого поведения.
6. **Гейты на этой дельте.** Дифф `00b68450..e25aa302` строго класса C
(только `docs/specs/043-private-support-report.md`). Прогнал:
- `node scripts/process-gate.mjs` → «диапазон origin/dev..HEAD, коммитов
3; гейт пройден, предупреждений 0» (офлайн, без `--issues`);
- Validate CI на `e25aa302` зелёный (см. ссылку в хендоффе автора и в
инструкции ревью) — покрывает `tsc --noEmit`/`npm test`/`npm run
build`, предмета для которых у docs-only дельты и так нет.
`check-docs.mjs`, browser-смоки, `model-invariants`, `pytest
tests_backend`, golden — не прогонял: `src/**`, `custom_components/**` и
геометрия/`layout` дельтой не тронуты, у этих гейтов нет предмета
проверки на чисто документном коммите (то же обоснование, что в r1).
## Унаследовано из r1 (без повторной проверки)
Документ: `docs/reviews/SPEC-REVIEW-43-r1.md`, зафиксирован на `4559b5cd`,
разбирал ТЗ на `00b68450` (тогда назывался `755fa4cf` до ребейза).
Дельта r1→r2 не задевает ни один из пунктов ниже, поэтому принимаю выводы r1
без повторной проверки:
- Обязательные разделы §7.1 (сценарий, до/после, проблема, скоуп, контракт
поведения, UX, модель данных/миграция, i18n, AC1–AC17, план автотестов,
риски, откат, release-артефакты) — все на месте и однозначны.
- Продуктовые вопросы Q1–Q5 корректно отделены от технических и закрыты
владельцем в issue дословно, без додумывания.
- Пять утверждений §3 о текущем состоянии кода проверены построчно по
`src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`,
`custom_components/houseplan/{websocket_api,import_export,repairs}.py`,
`src/i18n/*.json` — расхождений не найдено.
- Privacy allowlist/exclusion §7.2/7.3 внутренне непротиворечивы; точная
геометрия включена осознанно как продуктовое решение (Q2), а не побочный
эффект.
- «Одно число — один источник» для preview/download/submit спроектировано
контрактом (кешированный TTL-токен + mutation-gate §6.4/7.1/8.1/AC5/AC10 +
§14.4), а не оставлено на волю реализации.
- Технические допущения промаркированы в §20 как «assumed, change freely».
- i18n RU/EN/DE/FR соответствует уже существующим локалям, список языков не
расширяется произвольно.
- Cross-links issue↔ТЗ↔`docs/specs/README.md` на месте.
- DoR-зависимости §17 (relay deployment, mailbox, staging) корректно вынесены
как внешние операционные факты с явным `blocked`-путём, а не спрятаны как
допущения.
## Чего не проверял (r2)
- Полные `npm test`/`npm run build`/browser-смоки/`model-invariants`/`pytest
tests_backend` — не прогонял отдельно: Validate CI зелёный на этом самом
SHA (`e25aa302`), а дельта и так docs-only без предмета для этих гейтов.
- Реальная развёртываемость relay, секреты, содержимое ещё не написанного
`scripts/support-relay/**` — кода ещё нет, это заявленная DoR-зависимость
§17/§18, появится на этапе реализации и code-review.
- Golden/скриншоты, performance-профили — не относятся к докс-only дельте.
- Process-gate regression fixture, которую §18 требует для доказательства
классификации relay-only коммита как B — она появится вместе с кодом;
на этапе ТЗ можно проверить только текст требования (проверено, есть).
## Вывод
Обе находки r1 закрыты предметно, построчно и без побочных повреждений
соседних разделов: relay-код явно и полностью перенесён в уже
классифицированный класс-B каталог `scripts/support-relay/**` (подтверждено
чтением `scripts/process-gate.mjs` и живым прогоном гейта), а §11 получил
каноническую фразу `Touch editor: supported`, дословно совпадающую с
требованием `docs/TOUCH-SUPPORT.md`. Ребейз на актуальный `dev` не изменил
существа рассмотрения: коммиты `dev`, вошедшие в диапазон, не трогают ни один
документ, на который опирались находки r1. Новых находок в дельте нет.
Rec: **зелёный**, готово к переводу в `S5-ready`.
+205
View File
@@ -0,0 +1,205 @@
# SPEC-REVIEW-43-r3
- Issue: #43 «Диалог помощи и обратной связи с обезличенным support report»
- Этап: spec (PROCESS.md §2.4)
- Заход: r3 · блокирующих циклов израсходовано 2 из 4 (после этого раунда)
- Материал: `docs/specs/043-private-support-report.md` на HEAD `c43bf847`
(ветка `issue/43-help-feedback`), плюс код `scripts/support-relay/**` как
доказательство исполнимости заявленных в ТЗ §9 контрактов.
- Предыдущий раунд: r2, вердикт **зелёный**, SHA `e25aa302` (High 0, Medium 0);
документ `docs/reviews/SPEC-REVIEW-43-r2.md`, опубликован коммитом `cf7f47db`.
## Почему разбор не сведён к «только дельта»
Между r2 и r3 в issue появилась реальная production-инфраструктура: relay
поднят на боевом узле, владелец лично проверил rate limiting и попытку подмены
источника через `X-Forwarded-For`, обнаружил, что прямой Telegram недоступен с
хостинга проекта, и по итогу сменил канал доставки «последней мили» на приватный
вебхук собственного Home Assistant. Это смена контракта в подсистеме, которую
r1/r2 уже проверяли (§9 relay), а не локальная правка формулировки — поэтому
разбор §9 и связанных разделов (§14.3/§14.4/§16/§17) сделан заново целиком, а не
только «что изменилось в тексте».
## Дельта r2→r3
`git diff e25aa302..HEAD`:
- `cf7f47db` — публикация `docs/reviews/SPEC-REVIEW-43-r2.md` (только review-артефакт, не часть ТЗ);
- `00102819 feat(relay): receive support reports on the project stand` — первая реализация `scripts/support-relay/**` (класс B), 1593 строки;
- `6b10cd52 docs: switch the support sink to a maintainer channel` — правка §5.1/§9.1/§9.3/§12/AC12/§14.3/§16/§17/§18/§19/§20 ТЗ: mailbox → приватный канал мейнтейнера;
- `c7efcf16 fix(relay): pin the rate-limit source to the proxy-supplied address` — код и тест против подмены источника лимита через `X-Forwarded-For`, найденной живым пентестом на стенде; **ТЗ этим коммитом не тронуто**;
- `c43bf847 feat(relay): deliver through the maintainer's Home Assistant webhook` — второй канал доставки `ha_webhook`, правка §9.1/§9.3 ТЗ.
Все четыре продуктовых коммита несут `Issue: #43` и `User-Visible: no` —
консистентно с тем, что видимое поведение карточки (`src/**`) ещё не начато,
только class-B relay.
## Закрытие раунда r2
r2 закрылся с 0 находок (High 0, Medium 0) — закрывать в r3 нечего, таблица
пустая. Единственное, что произошло после r2, — новая работа поверх зелёного
вердикта, а не исправление старых замечаний.
## Унаследовано из r2 (и из r1 через r2)
Без повторной проверки приняты — как есть в `docs/reviews/SPEC-REVIEW-43-r2.md`
на SHA `e25aa302` — потому что дельта r2→r3 не касается доказательной базы
этих пунктов:
- §1–§6 (сценарий, UX-контракт кнопки/диалога/формы/preview/submit) — текст не
менялся в дельте;
- §7 (allowlist/pseudonymization/privacy invariant support package v1) — не
менялся;
- §8 (backend API preview/discard/submit) — не менялся;
- §10 (state/compatibility), §11 (touch/a11y — включая каноническую фразу
`Touch editor: supported`, закрытую в r1→r2), §13 AC1–AC11, AC13–AC17
(кроме евиденс-колонки AC12, см. находку ниже);
- классификация `scripts/support-relay/**` как class B по `AGENTS.md`/
`PROCESS.md` (закрытая в r1→r2 находка) — подтверждена и в r3 живым прогоном
`process-gate.mjs` на новом HEAD (см. «Гейты»), т.к. дерево `scripts/`
выросло, но regex-классификация в `process-gate.mjs` не менялась.
## Находки
### Medium (в скоупе #43, чинится этим же ТЗ, без нового цикла к владельцу — вопрос чисто технический)
**Требование к источнику rate-limit не зафиксировано в ТЗ, хотя код и тесты
уже реализуют его правильно.**
§9.2 ТЗ (не тронут дельтой) по-прежнему говорит только:
> 5 attempts/hour and 20/day per source address plus a global circuit breaker;
> source IP is used only through a daily-keyed rate-limit hash with ≤24 h TTL
Это не говорит, ОТКУДА берётся «source address», хотя корректность всей
анти-abuse истории публичного эндпоинта (§16, риск 3 «Public relay attracts
spam») зависит именно от этого. На реальном стенде это оказалось не
теоретическим риском: согласно комментарию владельца от 2026-09-01 21:25,
подмена `X-Forwarded-For` почти позволила обойти лимит одной строкой запроса —
спасло только совпадение с дефолтом чужого Caddy-конфига. Исправление внесено
кодом (`c7efcf16`) и покрыто тестом, но **в ТЗ это исправление не попало**:
- `docs/specs/043-private-support-report.md` §9.2 не требует, что источник
берётся из доверенного hop-а прокси, а не из клиентского заголовка;
- AC12 (§13) по-прежнему говорит только «Relay enforces schema, size, hash,
idempotency and rate limits» — не называет спуфинг-стойкость как часть
контракта;
- §14.3 (test plan) и особенно §14.4 (mutation requirements) — единственный
раздел ТЗ, который явно перечисляет КАЖДУЮ обязательную мутацию для security-
инвариантов (checkbox default, redirect/SSRF, forbidden field leak и т.д.) —
не содержит пункта «клиент подделывает X-Forwarded-For и выбирает себе новую
корзину лимита → тест красный», хотя именно такой тест уже существует в коде
(`test_client_cannot_pick_its_own_rate_bucket`,
`scripts/support-relay/tests/test_relay.py:454-470`) и хотя именно этот
сценарий уже был проверен на боевом трафике.
Реализация де-факто правильная (проверено чтением и исполнением, см. «Гейты»):
`hp_relay/app.py:147-158` берёт последний элемент `X-Forwarded-For` только при
`cfg.trusted_proxy` (default `HP_RELAY_TRUSTED_PROXY=1`,
`hp_relay/config.py:41,82`), а `deploy/Caddyfile.fragment:12-14` полностью
перезаписывает заголовок на обоих сайтах (`header_up X-Forwarded-For
{remote_host}`), а не дополняет его. Но эта перепись — рассыпанные по трём
файлам факты (`app.py`, `Caddyfile.fragment`, `env.example`), а не пункт
контракта ТЗ. Проблема не «оно не работает сейчас», а «ничто в ТЗ не обязывает
это работать после следующего рефакторинга»: спека — это то, с чем сверяется
будущий код-ревью и будущий переписчик `hp_relay/app.py`, а не README
деплоя.
**Чем закрыть, не открывая вопрос владельцу** (технический пункт, решается
автором ТЗ по §7.1, не владельцем):
1. в §9.2 добавить явное предложение: источник для rate-limit — последний
элемент `X-Forwarded-For`, только если relay сконфигурирован работать за
доверенным прокси (`trusted_proxy`), который обязан перезаписывать этот
заголовок целиком, а не дополнять; без этой конфигурации — адрес
TCP-соединения;
2. AC12 — добавить пункт: «источник для лимита не может быть выбран клиентом
через заголовки»;
3. §14.4 — добавить строку: «клиент посылает поддельный `X-Forwarded-For` →
получает собственную корзину лимита → тест красный».
### Low (не блокирует, можно снять с записью)
Продакшн-канал `ha_webhook` целиком зависит от одной автоматизации на личном
Home Assistant мейнтейнера («House Plan: приёмщик обратной связи → личка»,
`scripts/support-relay/README.md:151-152`). Ротация вебхука прямо требует
правки этой автоматизации, но её конфигурация не задокументирована и не
сохранена нигде в репозитории — только упоминание, что она существует. Отказ
этого узла не теряет данные и не обманывает пользователя (отчёт остаётся в
спуле, клиент честно получает `support_unavailable` — проверено чтением
`hp_relay/app.py:110-116`), поэтому это не приватность/безопасность и не
блокирует зелёный; но при потере доступа к личному HA мейнтейнера
восстановление доставки требует пересоздания автоматизации «с нуля» без
письменной инструкции. Стоит одной строкой в `scripts/support-relay/README.md`
зафиксировать минимальную конфигурацию вебхука (принимает POST, поле `text`,
пересылает в Telegram), не дожидаясь следующего цикла.
## Что проверено и признано корректным
- **Два канала доставки (`telegram`/`ha_webhook`) в §9.1** описаны в ТЗ
консистентно с кодом: `hp_relay/delivery.py:141-203` (`TelegramDelivery`,
`HaWebhookDelivery`, `build()`) реализует ровно то, что описано —
вложение остаётся в спуле и не покидает узел на канале `ha_webhook`
(`hp_relay/delivery.py:168-188`, тест
`test_webhook_sends_text_and_keeps_the_package_on_the_node`).
- **«Запись до попытки доставки» (§9.1, «a failed delivery costs a promise, not
the user's request»)** — подтверждено чтением: `app.py:107-116` вызывает
`store.save()` до `delivery.send()`, а при неуспехе возвращает
`support_unavailable`, не удаляя отчёт.
- **Privacy-текст §9.3** («exact geometry ... transit the project relay and the
maintainer messenger») намеренно описывает худший случай для обоих каналов,
а не конкретно активный канал: фронтенд не знает, какой канал выбран на
деплое (это решение эксплуатации, §9.1 «chosen by deployment»), поэтому
общая (не заниженная) формулировка — осознанный выбор, а не забытая правка.
Проверено сопоставлением с `hp_relay/config.py:66-68` (канал — переменная
окружения, недоступная фронтенду).
- **§17 DoR** обновлён согласованно с новым каналом (пункт 2 — путь к секрету
вебхука как учётные данные), пункты 1/3/4/5 отражены в отдельных комментариях
владельца как реально выполненные на стенде — вне объёма самого текста ТЗ,
но не противоречат ему.
- **Лимиты §7.5/§9.5** (8 MiB / 8.5 MiB) совпадают с кодом:
`hp_relay/config.py:15-16` (`MAX_REQUEST_BYTES`, `MAX_ATTACHMENT_BYTES`).
- **Классификация class B** (`scripts/support-relay/**`) остаётся верной после
роста дерева — `process-gate.mjs` не меняла regex, живой прогон на HEAD это
подтверждает.
- **SCOPE.md** проверен на предмет открытых вопросов дельты: раздел «Where
users are» фиксирует уже существующий *публичный* support-чат
`t.me/ha_houseplan` как канал общей обратной связи — пивот к *приватному*
каналу мейнтейнера в r3 ему не противоречит и не подменяет его, они решают
разные задачи (публичный сигнал vs. приватный geometry-репорт). Собственно
вопрос «попадает ли фича в Core user jobs» дельтой r2→r3 не поднимается
(§1–§6 не менялись) и наследуется как принятый в r1/r2.
## Чего не проверял и почему
- `npm test` / `npm run build` (сверка трёх копий бандла) / `node
scripts/check-docs.mjs` — дельта не касается `src/**`, предмета проверки нет.
- `python -m pytest tests_backend -q` — дельта не касается
`custom_components/**` (relay — отдельный сервис вне HA-интеграции).
- `npm run invariants` / инварианты модели — дельта не касается геометрии,
`layout`, `marker.space`, `open_spans` или толщины стен.
- Браузерные смоки, `npm run golden:verify` — фронтенд-часть фичи (§1–§8) ещё
не реализована, `src/**` не тронут.
- Полный аудит `scripts/support-relay/hp_relay/{multipart,validate}.py` — эти
файлы не входят в дельту r2→r3 (не менялись коммитами `6b10cd52`/`c7efcf16`/
`c43bf847` за пределами уже описанного), их корректность — предмет будущего
code-review по §13 AC5–AC12, а не этого spec-раунда.
## Гейты, которые прогнал сам (зелёного Validate на `c43bf847` нет)
- `node scripts/process-gate.mjs` → «гейт пройден, предупреждений 0» (диапазон
`origin/dev..HEAD`, 8 коммитов, офлайн).
- `npx tsc --noEmit` → 0 ошибок (дельта не трогает `src/**`, прогнан как
дешёвая проверка базовой линии).
- `python3 -m unittest discover -s scripts/support-relay/tests` → **35
проверок, все зелёные**, включая `test_client_cannot_pick_its_own_rate_bucket`
и `test_webhook_sends_text_and_keeps_the_package_on_the_node`.
- Дисциплина «тест должен уметь падать» проверена для обоих названных выше
тестов лично, мутациями (изменения отменены после проверки, `git status`
чист):
- `forwarded.split(",")[-1]` → `[0]` (доверие первому, клиентскому элементу
XFF вместо последнего, проксёй-контролируемого) — тест
`test_client_cannot_pick_its_own_rate_bucket` упал (3 корзины вместо 1);
- `HaWebhookDelivery.send` начинает прикладывать байты вложения в JSON
вебхука — тест `test_webhook_sends_text_and_keeps_the_package_on_the_node`
упал (лишнее поле `attachment` в теле).
+210
View File
@@ -0,0 +1,210 @@
# SPEC-REVIEW-43-r4
- Issue: #43 «Диалог помощи и обратной связи с обезличенным support report»
- Этап: spec (PROCESS.md §2.4)
- Заход: r4 · блокирующих циклов израсходовано 2 из 4 (до этого раунда)
- Материал: `docs/specs/043-private-support-report.md` на HEAD `cd8d01ac`
(ветка `issue/43-help-feedback`), плюс код `scripts/support-relay/**` как
доказательство исполнимости заявленных в §9 контрактов — в объёме, до
которого дотягивается дельта этого раунда.
- Предыдущий раунд: r3, вердикт **жёлтый**, SHA `c43bf847` (High 0, Medium 1);
документ `docs/reviews/SPEC-REVIEW-43-r3.md`, опубликован коммитом
`55b51a02`. SHA r3 в самом документе назван явно (раздел «Материал»); в
сжатом комментарии-вердикте в issue он не повторён (там только «HEAD»), но
это не «находка №1 инструкции» — первоисточник (файл ревью) SHA не потерял.
SHA пересчитан и подтверждён по времени коммитов независимо от текста
документа: `git log --date=iso-strict` даёт `c43bf847` = `2026-09-02
01:03:57+03:00`, что на 27 с опережает комментарий-вердикт `r3`
(`22:21:03Z` = `01:21:03+03:00`, следующий коммит `55b51a02` = сам документ
ревью, коммит `22:21:09Z`).
## Дельта r3→r4
`git diff c43bf847..HEAD` (без учёта `docs/reviews/SPEC-REVIEW-43-r3.md`,
который сам является артефактом прошлого раунда, а не правкой автора):
- `docs/specs/043-private-support-report.md` — 10 строк добавлено, 0 удалено:
новый абзац в §9.2 (источник rate-limit), новая строка **AC12a** в §13,
новая строка обязательной мутации в §14.4;
- `scripts/support-relay/README.md` — 27 строк добавлено: минимальная
конфигурация автоматизации Home Assistant «House Plan: приёмщик обратной
связи → личка» (YAML) плюс пояснение, почему `parse_mode: plain_text` не
косметика.
Дельта строго докс-онли и локальна: адресует ровно два пункта прошлого
вердикта (Medium §9.2 и снятый-с-запиской Low про недокументированную
автоматизацию), кода не трогает, новую подсистему не задевает, объём
несравним с исходной задачей. Полный разбор не требуется — разбор по дельте
плюс всё, до чего эта дельта дотягивается (см. находку ниже: она лежит внутри
того же абзаца §9.2, который дельта редактирует).
## Закрытие раунда r3
| Находка r3 | Чем закрыта | Где видно |
|---|---|---|
| **Medium** — §9.2 не фиксирует источник rate-limit-адреса (только код/README, требование не переживёт рефакторинг `hp_relay/app.py`) | Частично: добавлен абзац в §9.2 «источник — последний элемент `X-Forwarded-For`, потому что прокси перезаписывает заголовок целиком», плюс **AC12a** в §13 и строка мутации в §14.4 | `docs/specs/043-private-support-report.md:446-452` (абзац §9.2), `:539` (AC12a), `:590-591` (§14.4). Текст дословно соответствует тому, что просил r3, **кроме** условности через `trusted_proxy` — см. новую находку ниже, это тот же абзац, не новый пробел, а недозакрытый прежний |
| **Low** (снят с запиской, не блокировал) — минимальная конфигурация вебхук-автоматизации не задокументирована в репозитории | Закрыт: полный YAML автоматизации (webhook-триггер, `local_only: false`, условие по `source`, `telegram_bot.send_message` с `parse_mode: plain_text`) добавлен в README | `scripts/support-relay/README.md:154-180`. Поля `source`/`text` в шаблоне совпадают буквально с тем, что шлёт код: `scripts/support-relay/hp_relay/delivery.py:180-184` (`HaWebhookDelivery.send`) формирует JSON именно с ключами `source`/`report_id`/`text` |
Low закрыт полностью и без оговорок. Medium закрыт **не полностью** — см. находку.
## Унаследовано из r3 (и через r3 — из r2/r1) без повторной проверки
Со ссылкой на `docs/reviews/SPEC-REVIEW-43-r3.md` (SHA `c43bf847`) и
транзитивно на r2 (`docs/reviews/SPEC-REVIEW-43-r2.md`, SHA `e25aa302`):
- §1–§8 — сценарий, UX-контракт, support package v1, backend API preview/
discard/submit — дельта их не касается;
- §9.1 — два канала доставки (`telegram`/`ha_webhook`), запись до попытки
доставки, privacy-текст §9.3 (кроме уже переоценённого абзаца §9.2) — не
менялись этой дельтой, r3 проверил их построчно против кода
(`hp_relay/delivery.py`, `app.py:107-116`, `config.py:66-68`);
- §10, §11 (включая каноническую фразу `Touch editor: supported`), §12;
- §13 AC1–AC11, AC13–AC17 (кроме новой AC12a);
- §15–§20, включая классификацию `scripts/support-relay/**` как class B
(закрыта в r1→r2, переподтверждена в r3 живым прогоном `process-gate.mjs`);
- полный аудит `hp_relay/{multipart,validate}.py` — вне дельты r2→r3 и вне
дельты r3→r4, предмет будущего код-ревью по AC5–AC12.
## Находки
### Medium (в скоупе #43, чинится в этой же задаче, вопрос технический — не владельцу)
**§9.2 закрывает находку r3 частично: описывает доверие к `X-Forwarded-For`
как безусловный архитектурный факт, а оно на самом деле включается флагом
конфигурации `HP_RELAY_TRUSTED_PROXY`, который в спеке, AC и списке
обязательных мутаций не упомянут вовсе.**
Читаю код (`scripts/support-relay/hp_relay/app.py:147-158`):
```python
def _source(self) -> str:
if service.cfg.trusted_proxy:
forwarded = self.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[-1].strip()
return self.client_address[0]
```
`trusted_proxy` берётся из `HP_RELAY_TRUSTED_PROXY` (`config.py:82`, default
`"1"`) — то есть чтение последнего элемента `X-Forwarded-For` действует только
при этом флаге; иначе источником становится адрес TCP-соединения, то есть
(при реальной топологии, где Caddy проксирует на `127.0.0.1`) **один и тот же
адрес для всех клиентов**, что не «безопаснее», а means-один общий
частотный бакет на весь публичный эндпоинт: одна активная попытка исчерпывает
лимит для всех источников разом, а не для конкретного клиента, — деградация
той же анти-abuse истории (§16, риск 3), только в обратную сторону.
Новый абзац §9.2 (добавленный этой дельтой) не называет этот флаг вообще:
> «The relay reads the *last* element of `X-Forwarded-For`, because the first
> element is whatever the caller sent, and the reverse proxy in front of it is
> configured to overwrite the header outright rather than append to it.»
Это верно только пока `trusted_proxy=true`; сам разговор о существовании
такого выключателя, о том, что оба продовых хоста (`support.houseplan.tech`,
`support-staging.houseplan.tech`) обязаны держать его включённым, и о
поведении при выключенном — в спеке отсутствует.
**Это ровно тот класс дефекта, который сама находка r3 была призвана
устранить**: в коде — правильно и обдуманно (для флага есть отдельное имя,
дефолт и комментарий в `env.example:18-19`), а в ТЗ — не зафиксировано, значит
не переживёт следующий рефакторинг `hp_relay/app.py` (случайное удаление
`if service.cfg.trusted_proxy:` при сохранении `.split(",")[-1]` тихо
расширит доверие к заголовку на любую топологию, включая прямую экспозицию
порта без прокси). Ни `AC12a`, ни новая строка §14.4 эту ветку не покрывают:
обе называют только подмену индекса элемента (`[0]` вместо `[-1]`), а не
факт, что chтение заголовка вообще управляется флагом.
Проверено также, что тестового покрытия ветки `trusted_proxy=False` нет:
`grep -n "trusted_proxy" scripts/support-relay/tests/test_relay.py` — 0
совпадений. Ветка «адрес TCP-соединения» сейчас не проверяется ни одним
тестом.
**Чем закрыть, не открывая вопрос владельцу** (технический пункт по §7.1,
решает автор ТЗ, ревьюер вправе оспорить):
1. в §9.2 назвать флаг явно: доверие к `X-Forwarded-For` действует только при
`HP_RELAY_TRUSTED_PROXY=1` (`trusted_proxy` в конфиге), и это обязательное
состояние для `prod`/`staging` — при выключенном или отсутствующем прокси
источником становится адрес TCP-соединения, и это осознанно более
консервативный, а не эквивалентный режим (одна корзина на весь трафик, а
не «без лимита»);
2. AC12a — добавить условие: гарантия действует только при `trusted_proxy`,
включённом на обоих продовых хостах; это часть DoR/release, а не просто
поведение по умолчанию;
3. §14.4 — добавить мутацию: снятие проверки `if service.cfg.trusted_proxy`
(доверие XFF независимо от флага) должно ронять новый тест, которого
сейчас нет — то есть пункт 3 требует и код (тест), не только текст ТЗ;
зафиксировать это явно, а не оставлять как молчаливый пробел.
Серьёзность — Medium: не открывает утечку данных и не позволяет обойти лимит
сильнее, чем позволяет топология (пока `trusted_proxy` включён по умолчанию
и прокси настроен верно — как сейчас и есть), но ровно то самое несоответствие
кода и ТЗ, которое r3 уже один раз квалифицировал как Medium в этом же
абзаце. Не блокирует по High, но без исправления это жёлтый вердикт.
## Что проверено и признано корректным
- **Low r3 закрыт полностью**: YAML-шаблон автоматизации в README дословно
совпадает с полями, которые шлёт `HaWebhookDelivery.send`
(`source`/`report_id`/`text`), включая объяснение, почему `parse_mode:
plain_text` обязателен (иначе текст пользователя разбирается как разметка).
- **Caddy-конфигурация** (`scripts/support-relay/deploy/Caddyfile.fragment`)
подтверждена чтением: `header_up X-Forwarded-For {remote_host}` стоит на
**обоих** сайтах (`support.houseplan.tech`, `support-staging.houseplan.tech`)
без исключений — то есть при нынешнем деплое реальная топология
соответствует тому, что описывает новый абзац §9.2 (с оговоркой из находки
выше: соответствие держится на конфиге, а не на архитектурной гарантии).
- **AC12a и новая строка §14.4** внутренне непротиворечивы и совпадают с уже
существующим (написанным до r3) тестом `test_client_cannot_pick_its_own_rate_bucket`
(`scripts/support-relay/tests/test_relay.py:452-467`) — читал тест, он
действительно шлёт три поддельных `X-Forwarded-For` и проверяет один общий
ключ корзины в `spool/rate/*.json`.
- **Классификация `scripts/support-relay/**` class B** не затронута дельтой,
живой прогон `node scripts/process-gate.mjs` на HEAD `cd8d01ac`: «гейт
пройден, предупреждений 0» (офлайн, диапазон `origin/dev..HEAD`, 10
коммитов).
- Коммит `cd8d01ac` несёт `Issue: #43` и `User-Visible: no` (docs-only,
корректно — правки в спеку и README не меняют видимое поведение продукта).
## Чего не проверял и почему
- `npm test` / `npm run build` (сверка трёх копий бандла) — не прогонял сам:
Validate на этом же SHA `cd8d01ac` завершился success (ссылка дана в
задании), покрывает `tsc`/`test`/`build`; дельта раунда и так не трогает
`src/**`, предмета для этих гейтов у неё нет.
- `node scripts/check-docs.mjs` — не требовался: дельта не трогает `src/**`.
- `npm run invariants` / инварианты модели — не требовались: дельта не
трогает геометрию, `layout`, `marker.space`, `open_spans`.
- `python -m pytest tests_backend -q` — не требовался: дельта не трогает
`custom_components/**`.
- `python3 -m unittest discover -s scripts/support-relay/tests` — **не
перегонял сам в этом раунде**: код relay этой дельтой не менялся (только
документация), а r3 уже прогнал полный набор (35/35 зелёных) и лично
проверил мутациями оба относящихся к этому раунду теста
(`test_client_cannot_pick_its_own_rate_bucket`,
`test_webhook_sends_text_and_keeps_the_package_on_the_node`) на способность
падать — наследую этот результат из r3 (см. таблицу наследования). Находка
выше — про то, чего в этом наборе тестов нет (`trusted_proxy=False`), а не
про то, что существующие тесты красные.
- Браузерные смоки, `npm run golden:verify` — не требовались: `src/**` для
фичи #43 ещё не реализован (§1–§8 остаются на этапе ТЗ).
- Полный построчный аудит `hp_relay/{multipart,validate}.py` — вне дельты
r3→r4, предмет будущего код-ревью по AC5–AC12.
## Гейты, которые прогнал сам
| Гейт | Результат |
|---|---|
| `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» (офлайн, `origin/dev..HEAD`, 10 коммитов) |
| Чтение `hp_relay/app.py`, `config.py`, `env.example`, `test_relay.py`, `delivery.py`, `Caddyfile.fragment` | вручную, построчно — источник находки и подтверждения закрытий |
| `npx tsc --noEmit` / `npm test` / `npm run build` | не прогонял — Validate зелёный на этом же SHA `cd8d01ac` (см. задание раунда), дельта докс-онли |
## Вывод
Low r3 закрыт полностью. Medium r3 закрыт частично — тот же абзац §9.2
получил формулировку, которая верна только при включённом (по умолчанию, но
не зафиксированном как обязательное условие) флаге `trusted_proxy`, и это
условие не отражено ни в AC12a, ни в §14.4, ни где-либо ещё в ТЗ. High
находок нет. Вердикт — жёлтый, не блокирующий продвижение по High, но
возвращающий ТЗ на правку одного абзаца и одной AC-строки.
+197
View File
@@ -0,0 +1,197 @@
# SPEC-REVIEW-43-r5
- Issue: #43 — «Диалог помощи и обратной связи с обезличенным support report»
- Этап: ТЗ на ревью (PROCESS.md §2.4), полный трек (issue не помечен `small`)
- Заход: r5 · блокирующих циклов израсходовано 3 из 4 до этого раунда
- Материал: `docs/specs/043-private-support-report.md`, `scripts/support-relay/README.md`,
`scripts/support-relay/tests/test_relay.py` на ветке `issue/43-help-feedback`
- SHA материала r4 (названо в самом вердикте r4): `cd8d01ac`
- SHA материала этого раунда (`git rev-parse HEAD` непосредственно перед выводом): `3ce5bc0ea2bab55c7c586d63b78a76459fd5c4d2`
- Предыдущий документ: `docs/reviews/SPEC-REVIEW-43-r4.md`
## Скоуп разбора
Раунд r4 закончился жёлтым вердиктом с единственной находкой уровня Medium (в
скоупе, техническая): §9.2/AC12a/§14.4 описывали чтение `X-Forwarded-For` как
безусловный факт, хотя в коде это работает только под флагом
`HP_RELAY_TRUSTED_PROXY` (default on), а ветка `trusted_proxy=False` не имела
покрытия тестами.
Дельта `cd8d01ac..3ce5bc0e` строго докс- и тест-only:
```
docs/reviews/SPEC-REVIEW-43-r4.md | 210 +++++++++++++
docs/specs/043-private-support-report.md | 21 +-
scripts/support-relay/README.md | 16 +-
scripts/support-relay/tests/test_relay.py | 51 ++++
```
(первый файл — публикация артефакта r4 предыдущим прогоном конвейера, не
предмет этого разбора). Кода продукта (`src/**`, `custom_components/**`) дельта
не касается, новая подсистема не задета, объём несравним с задачей — это
классический случай «предмет раунда — дельта», разбор по ней, а не заново.
## Как проверялось
1. Прочитан полный текст `docs/specs/043-private-support-report.md` заново
(не только изменённые абзацы) — чтобы дельта на 21 строку не спрятала
контекстную рассинхронизацию с остальным документом.
2. Сверено текстовое утверждение §9.2 с фактическим кодом:
`scripts/support-relay/hp_relay/app.py:147-158` (`_source()`) и
`scripts/support-relay/hp_relay/config.py:82` (`trusted_proxy` default) —
поведение обеих позиций переключателя дословно совпадает с новым текстом
ТЗ.
3. Прогнан полный набор relay: `cd scripts/support-relay && python3 -m
unittest discover -s tests -q` → **36 проверок, все зелёные**.
4. Дисциплина «тест умеет падать» применена к новому тесту
`test_direct_node_ignores_the_forwarded_header` лично, а не со слов автора:
в `hp_relay/app.py::_source()` заменено `if service.cfg.trusted_proxy:` на
`if True:` (эмуляция мутации «переключатель проигнорирован») —
`python3 -m unittest tests.test_relay.DirectNodeTestCase -v` упал:
`AssertionError: 3 != 1` (три разных ключа источника вместо одного). Файл
восстановлен из копии, `git diff --stat hp_relay/app.py` после отката —
пусто, повторный полный прогон — снова 36/36.
5. Проверено, что существующий тест `test_client_cannot_pick_its_own_rate_bucket`
(позиция переключателя по умолчанию, `trusted_proxy=True`) не тронут и
продолжает покрывать вторую половину AC12a.
6. `node scripts/process-gate.mjs` — офлайн-прогон на HEAD, диапазон
`origin/dev..HEAD`, 12 коммитов: «гейт пройден, предупреждений 0».
7. Проверены трейлеры обоих коммитов дельты (`git log --format='%B'
cd8d01ac..HEAD`): у обоих `Issue: #43` и `User-Visible: no` — верно, дельта
не меняет пользовательское поведение.
8. Прочитана вся история issue #43 (18 комментариев) для контекста: подтверждено,
что все пять внешних DoR-зависимостей §17 закрыты предыдущими комментариями
владельца (deployment target, канал доставки/credentials, 30-дневное
удаление, staging для CI, ответственный), последний — «`blocked` можно
снимать: внешних зависимостей у реализации больше нет» (2026-09-01 22:04).
Это не переоценивается заново в этом раунде — оно не относится к предмету
дельты (флаг доверенного прокси) и не изменилось между r4 и r5.
## Закрытие раунда r4
| Находка r4 | Чем закрыта | Где видно |
|---|---|---|
| **Medium.** §9.2 описывает чтение `X-Forwarded-For` как безусловный факт, хотя в коде это работает только под флагом `HP_RELAY_TRUSTED_PROXY` (default on); AC12a и §14.4 не называют флаг; нет теста на позицию `trusted_proxy=False`. | §9.2 переписан: флаг назван по имени, обе позиции описаны явно («включён» / «выключен»), добавлено требование «за прокси выключать запрещено» с обоснованием цены (весь публичный эндпоинт делил бы одну корзину). AC12a переписан под обе позиции с указанием двух тестов. §14.4 получил строку про мутацию «читать заголовок независимо от переключателя». Написан и подтверждён тест `test_direct_node_ignores_the_forwarded_header` (не только обещан в тексте — прогнан и лично провален мутацией). | `docs/specs/043-private-support-report.md` §9.2 (абзац «Behind a reverse proxy…»), §13 AC12a, §14.4; `scripts/support-relay/tests/test_relay.py:312-361` (`DirectNodeTestCase`); код без изменений — `hp_relay/app.py:147-158` уже соответствовал описанию. |
Закрыто полностью, без остатка: расхождение между текстом ТЗ и кодом, из-за
которого требование «не пережило бы рефакторинг `app.py`» (как выразился автор
в комментарии к r4), теперь закреплено и текстом, и работающим тестом,
способным упасть.
## Унаследовано из r4 (и через r4 из более ранних раундов)
Со ссылкой на `docs/reviews/SPEC-REVIEW-43-r4.md`, SHA `cd8d01ac`, без повторной
проверки в этом раунде — дельта их не касается:
- §1–8 (сценарий, персона, скоуп, решения владельца, UX-контракт кнопки/диалога/
формы/preview/submit) — полностью проверены в r1 построчно против кода
(`src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`), включая
соответствие пяти утверждений §3 фактическому состоянию;
- §7 (envelope, allowlist, псевдонимизация, privacy invariant, лимиты) —
проверен в r1 на внутреннюю непротиворечивость allowlist/exclusion списков;
- §8 (backend API preview/submit) — контракт «просмотренные байты = отправленные
байты» (кешированный TTL-токен + mutation-gate) проверен в r1 как спроектированный
в контракт, а не оставленный реализации;
- §9.1, §9.3 (архитектура relay, каналы доставки Telegram/`ha_webhook`, ретеншн) —
полностью разобраны заново в r3 после смены продакшн-канала на боевом стенде
(это была не локальная правка, а смена контракта поведения, разбор шёл целиком,
не по дельте); r4 унаследовал их без изменений, этот раунд — тоже, дельта их
не трогает;
- §10–13 (кроме AC12a), §15–20 (state/compat, touch/a11y §11 с канонической
фразой «Touch editor: supported», i18n, AC1–AC17 кроме AC12a, test plan §14.1–14.3,
performance-бюджеты, риски, §17 DoR-зависимости, §18 release-артефакты, §19
rollback, §20 assumed-блок) — проверены в r1–r3, последняя правка (r4) их не
касалась, эта (r5) — тоже;
- классификация `scripts/support-relay/**` как класс B (`AGENTS.md`/`PROCESS.md`,
`scripts/process-gate.mjs::classify()` regex `/^scripts\//`) — закрыта в r2
чтением regex и живым прогоном гейта, не пересматривалась;
- фактическое закрытие всех пяти пунктов §17 DoR (deployment target, канал
доставки, 30-дневный ретеншн, staging, ответственный) — установлено серией
комментариев владельца в issue между r2 и r3 с воспроизведёнными командами
(`curl .../health`, systemd-таймеры, README) и последним подтверждением
«внешних зависимостей больше нет»; это не предмет спек-ревью ТЗ как текста
(ТЗ корректно описывает эти зависимости как внешние в §17), а операционный
факт вне дельты — принят как есть.
## Находки
**Low, снимается решением ревьюера с записью, цикл не расходует.**
`scripts/support-relay/README.md` (раздел «Тесты»): текст утверждает
«четырнадцать мутаций рабочего кода» и затем перечисляет их в скобках —
пересчёт даёт **тринадцать** пунктов (снять сверку хеша · разрешить лишнюю
часть · не чистить управляющие символы · снять лимит · писать адрес в журнал ·
игнорировать идемпотентность · отключить рубильник · отключить ретеншн · не
проверять секции пакета · брать первый элемент `X-Forwarded-For` · подменить
`source` в вебхуке · приложить пакет к вебхуку · читать заголовок независимо от
переключателя — 13). Расхождение уже было в r4-версии текста (там «тринадцать»
против фактических двенадцати пунктов списка) и в этом раунде перенесено на
единицу дальше вместе с добавлением тринадцатого пункта. Не блокирует: это
описательное число в README для человека, читающего перед раскруткой relay, оно
не служит доказательством ни одного AC (доказательство — сам прогон
`unittest discover`, который лично прогнан и даёт 36/36) и не является тем
«одно число — один источник» пользовательским значением, о котором
предупреждает §8 PROCESS.md — это внутренний devops-README, а не
пользовательский интерфейс. Снимаю без возврата автору; при следующей правке
этого README посчитать пункты заново.
Других находок нет: High — 0, Medium — 0.
## Что проверено и корректно
- Текст §9.2/AC12a/§14.4 после правки дословно соответствует поведению кода
(`_source()` в `app.py`, default в `config.py`) для обеих позиций
переключателя;
- Новый тест `DirectNodeTestCase.test_direct_node_ignores_the_forwarded_header`
реально существует, реально прогоняется, реально падает на релевантной
мутации (проверено лично, не со слов автора) и не задевает остальные 35
проверок;
- Существующий тест на позицию `trusted_proxy=True`
(`test_client_cannot_pick_its_own_rate_bucket`) не тронут дельтой и
по-прежнему проходит;
- Деплой-артефакты (`scripts/support-relay/deploy/env.example`,
`Caddyfile.fragment`) уже фиксируют `HP_RELAY_TRUSTED_PROXY=1` и перезапись
заголовка на обоих продовых хостах — согласуется с новым текстом §9.2 «Both
production hosts run behind Caddy, so both keep the switch on»;
- `node scripts/process-gate.mjs` зелёный (12 коммитов в диапазоне, 0
предупреждений); трейлеры обоих коммитов дельты корректны
(`Issue: #43`, `User-Visible: no`);
- Документ ТЗ остаётся внутренне непротиворечивым за пределами дельты — не
найдено новых противоречий между §9.2 и остальными разделами при повторном
чтении документа целиком.
## Чего не проверял и почему
- `npx tsc --noEmit`, `npm test`, `npm run build` со сверкой копий бандла —
дельта не трогает `src/**`/`custom_components/**`; докс- и Python-тест-онли
изменение не имеет для них предмета. (Ранее на цепочке этого issue Validate
на предыдущих relay-коммитах уже проходил зелёным на CI — здесь новых
фронтенд/бэкенд-в-смысле-Python-интеграции изменений нет вовсе.)
- `node scripts/check-docs.mjs` — требуется только при изменении `src/**`;
здесь его нет.
- `node scripts/model-invariants.mjs` — геометрия/`layout`/ссылки на неё не
затронуты; речь только о rate-limit relay.
- `python -m pytest tests_backend -q` — эта команда покрывает
`custom_components/houseplan/**`, не relay-поддерево; relay использует
отдельный `unittest discover -s scripts/support-relay/tests`, который
прогнан лично (см. выше).
- Браузерные смоки, `golden:verify`, performance-профили — нет UI/визуальных
изменений в этой дельте; продуктовый код (`src/**`) для #43 ещё не начат
(подтверждено комментарием автора «Продуктовый код (`src/**`) не начат — §17
соблюдён» и отсутствием изменений `src/**` во всём диапазоне `origin/dev..HEAD`).
- Повторная проверка §9.1/§9.3 (архитектура каналов доставки, ретеншн) и
внешних DoR-фактов §17 «с нуля» (например, повторный `curl` на
`support.houseplan.tech`/`support-staging.houseplan.tech`) — не предмет этой
дельты; принято по унаследованной цепочке r2→r3 с воспроизведёнными в issue
командами, см. раздел «Унаследовано из r4» выше.
## Вердикт
Единственная Medium-находка r4 закрыта предметно (текст ТЗ + код, который уже
был правильным, + новый работающий и лично провалившийся на мутации тест).
Единственная новая находка этого раунда — Low, косметическая, в devops-README,
не влияющая ни на один AC, снята с записью без возврата автору.
**Зелёный.** ТЗ готово к `S5-ready`. С учётом комментариев в issue все пять
внешних DoR-зависимостей §17 отмечены автором как закрытые — если это
подтверждается конвейером/владельцем, `blocked` может сниматься вместе с
переходом статуса.
+679 -44
View File
@@ -1,60 +1,695 @@
# ТЗ #43 — Отчёт для поддержки без персональных данных
# ТЗ #43 — Помощь и обратная связь с обезличенным support package
- Issue: https://github.com/Matysh/houseplan-card/issues/43
- Приоритет: P2
- Статус ТЗ: draft, privacy defaults нормативны
- Приоритет: P2, feature
- Ревизия: 2 (2026-09-01), полная переработка после финального решения владельца
- Трек: полный — новый View UX, frontend + backend + внешний relay,
privacy/security contract и обязательная touch-поддержка
## Цель
## 1. Сценарий
Пользователь копирует воспроизводимый технический snapshot, предварительно
видя весь текст. Default report не содержит данных, по которым можно
восстановить дом, устройства или адреса.
Домашний администратор видит проблему на плане или хочет предложить улучшение.
Сейчас ему приходится отдельно искать чат/репозиторий, выяснять версии, вручную
экспортировать план и гадать, какие данные можно безопасно показывать. В результате
репорт часто нельзя воспроизвести либо пользователь пересылает лишние сведения о
доме и устройствах.
## Формат
В обычной шапке House Plan администратор открывает «Помощь и обратная связь»,
читает версию и документацию, пишет сообщение и при желании осознанно прикладывает
подготовленный House Plan обезличенный диагностический пакет. До отправки он видит
состав, размер и точные байты вложения. После успешной отправки получает номер
репорта, по которому можно продолжить разговор в Telegram или GitHub.
Versioned JSON/text envelope `houseplan_support_report: 1`:
Персона: **Home admin** из `docs/SCOPE.md`. Основная поверхность — View в desktop,
phone/tablet и HA Companion. Диалог, открытый поверх View, полностью поддерживается
на touch по `docs/TOUCH-SUPPORT.md`; редакторы остаются desktop-first, но тот же
диалог в них не деградирует.
- card, integration и HA versions;
- browser engine family/major и supported feature flags без user-agent string;
- model/config/layout schema versions и revisions;
- counts: spaces, rooms, room drafts, physical/open walls, partitions,
columns, openings по типу, decor по kind, markers по lifecycle/status;
- read-only validation result: stable codes + counts;
- optimizer/migration pending flags;
- active House Plan Repair issue ids/codes без descriptions/user values;
- registry authority level (`full|limited|unknown`) и last sync age bucket;
- checksum только структуры/schema, не raw config.
## 2. Что человек увидит до и после
## Privacy allowlist
**До:** блок «О карточке» спрятан в конце общих настроек; отдельной ссылки на
USER-GUIDE нет; формы обратной связи и безопасного общего диагностического
вложения нет. HA diagnostics и обычный backup требуют ручных действий и содержат
данные, которые нельзя автоматически отправлять третьей стороне.
Report строится исключительно из typed allowlist projection. Запрещены:
space/room/device/entity names и ids, area/floor/config-entry ids, coordinates,
polygons, URLs, filenames/paths, descriptions, templates/live values, IP/host,
HA installation id, exact timestamps событий. Redaction после сериализации не
считается защитой.
**После:** сразу после кнопки общих настроек находится круглая кнопка помощи. Она
открывает единый диалог с версией, GitHub, Telegram, языковой ссылкой на USER-GUIDE
и формой «Отправить репорт/предложение». Сообщение обязательно, контакт
необязателен. Чекбокс диагностического пакета по умолчанию выключен. При включении
пользователь предупреждён о точной геометрии дома, может проверить или скачать
ровно отправляемый JSON и только затем нажать «Отправить». Успех показывает номер
репорта; отказ ничего не стирает и предлагает повторить либо забрать пакет вручную.
Adversarial fixture заполняет каждое запрещённое поле уникальным sentinel;
ни один sentinel/его URL-encoded/base64 form не встречается в report.
## 3. Проблема и подтверждённое текущее состояние
## Архитектура
### 3.1 UI
Backend read-only `houseplan/support/report` выполняет authoritative schema,
store и Repair projection; frontend добавляет card/browser flags. Command
доступен authenticated user, не делает writes/services и возвращает stable
error codes. Если backend старый, frontend создаёт reduced report и явно это
пишет.
- Header-кнопка общих настроек живёт в `src/houseplan-card.ts` и рендерится при
`_norm && _canEdit`; kiosk скрывает всю шапку.
- «О карточке» находится в `src/houseplan-editor-runtime.ts` внутри диалога общих
настроек: версия, GitHub и Telegram.
- Editor runtime уже загружается лениво при открытии общих настроек. Новый диалог
использует ту же lazy boundary: обычный холодный View не должен платить размером
формы поддержки и её логики.
## UX
### 3.2 Диагностика и backup
General Settings → Maintenance → «Скопировать отчёт для поддержки». Открывается
wide `hp-dialog` с plain-text preview, предупреждением privacy и действиями
Copy/Download/Cancel. Clipboard failure предлагает `.json` download. Никакой
автоматической отправки/телеметрии.
- `houseplanDiagnostics()` отдаёт только узкую frontend-сводку registry/bindings.
- #295 добавила копируемую runtime-диагностику geometry preflight, но только для
одного класса отказов.
- `custom_components/houseplan/diagnostics.py` предназначен для HA Download
diagnostics. Его redaction не является allowlist: marker/settings payload и
внутренние ids нельзя пересылать автоматически.
- `create_export()` строит согласованный переносимый backup до 8 MiB, однако
обычный backup содержит имена, ссылки, ids, свободный текст и точную геометрию.
Он служит источником структуры и snapshot-механики, но **не** готовым support
attachment.
## Приёмка
### 3.3 Transport
- sentinel privacy corpus зелёный frontend/backend;
- preview полностью равен copied/downloaded bytes;
- report работает при invalid config, limited registry и active Repairs;
- copy/download доступен keyboard/touch;
- документация перечисляет включённые и исключённые категории.
В репозитории нет feedback endpoint. Публичный GitHub issue раскрывает вложение;
Telegram share и `mailto:` не умеют без ручного шага приложить большой JSON;
секрет почты/GitHub нельзя вшивать ни в card bundle, ни в Python integration.
Поэтому direct submit требует отдельного доверенного relay с секретами только на
его стороне.
## 4. Решения владельца
1. В шапке после общих настроек появляется отдельная кнопка с иконкой вопроса в
кружке.
2. «О карточке» целиком переезжает из общих настроек в новый диалог.
3. В диалоге есть ссылка на `docs/USER-GUIDE.ru.md` только для русского языка;
любой другой язык ведёт на английский `docs/USER-GUIDE.md`.
4. Форма содержит необязательный контакт, обязательное сообщение и opt-in
диагностическое вложение.
5. Q1–Q4 приняты по defaults из issue: project-controlled HTTPS relay, точная
геометрия в support snapshot, preview точных байтов, доступ только `can_write`,
отсутствие кнопки в kiosk.
6. Подпись контактного поля на русском фиксирована владельцем:
**«Контакт для связи (email/tg/WhatsApp), необязательно.»**
## 5. Скоуп
### 5.1 Входит
- Help/Feedback-кнопка в текущей шапке и отдельный `hp-dialog`;
- перенос существующего блока «О карточке» без потери ссылок;
- языковая ссылка на USER-GUIDE;
- форма и её состояния validation/building/sending/success/error;
- backend allowlist-проекция и псевдонимизация текущего согласованного snapshot;
- preview-token, гарантирующий «просмотренные байты = отправленные байты»;
- backend submit в фиксированный project-controlled relay;
- минимальный deployable relay, который валидирует payload, ограничивает abuse и
доставляет обращение мейнтейнеру в закрытый канал и хранит его на узле
проекта;
- privacy notice, rate limit, retention и документированный ручной recovery;
- RU/EN/DE/FR i18n, unit/backend/receiver/smoke/golden/touch/security tests;
- обновление пользовательской и архитектурной документации.
### 5.2 Не входит
- автоматическая telemetry, фоновые или периодические отчёты;
- создание публичного GitHub issue либо отправка в публичный Telegram-чат;
- двусторонний встроенный support-chat, история обращений и статус тикета;
- произвольные пользовательские вложения;
- исходные plan/backdrop images, PDF, manuals и другие бинарные файлы;
- сохранённый optimizer/import undo-backup и история версий плана;
- настройка пользователем собственного endpoint;
- доступ household/guest-пользователей без `can_write`;
- kiosk-кнопка;
- изменение остальных backup/export/HA diagnostics flows;
- сбор HA state values, журналов, stack traces или exception messages.
## 6. UX-контракт
### 6.1 Кнопка
- Иконка: `mdi:help-circle-outline` в существующей круглой `.btn`-оболочке.
- Порядок справа: zoom controls → General Settings → Help/Feedback.
- Условие видимости **то же, что у General Settings**: `_norm && _canEdit`;
kiosk не рендерит интерактивную поверхность.
- Кнопка видна в View, Plan, Devices и Backdrop editor. Открытие диалога не меняет
mode, zoom, selection, черновик или unsaved form другого редактора.
- Доступное имя: локализованное «Помощь и обратная связь».
### 6.2 Диалог
Заголовок: «Помощь и обратная связь», icon `mdi:help-circle-outline`, wide
`hp-dialog`, `dismiss-on-scrim`. Порядок блоков:
1. **О карточке** — текущая версия card, GitHub и Telegram, без изменения URL.
2. **Документация** — ссылка «Руководство пользователя»:
- effective language `ru` →
`https://github.com/Matysh/houseplan-card/blob/main/docs/USER-GUIDE.ru.md`;
- `en`, `de`, `fr`, неизвестный/пустой язык → английский `USER-GUIDE.md`.
Ссылка открывается в новой вкладке с `rel="noopener noreferrer"`.
3. **Отправить репорт/предложение** — форма ниже.
Из General Settings удаляются только label/version/GitHub/Telegram строки. Все
настройки, backup и plan maintenance остаются на месте; высвободившийся блок не
заменяется дублирующей ссылкой.
### 6.3 Поля формы
1. Однострочный `<input>`:
«Контакт для связи (email/tg/WhatsApp), необязательно.»
- optional;
- trim по краям;
- максимум 320 Unicode code points;
- формат не валидируется как email/phone: допустим username или пояснение;
- автозаполнение отключено (`autocomplete="off"`), значение не сохраняется.
2. Многострочный `<textarea>` «Сообщение»:
- required после trim;
- 1…10 000 Unicode code points;
- line breaks сохраняются;
- HTML/Markdown не интерпретируются ни в карточке, ни в relay-mail.
3. Чекбокс «Прикрепить обезличенную информацию из вашего плана»:
- default `false` на каждое новое открытие;
- не сохраняется в config/localStorage;
- рядом всегда краткий allowlist/exclusion текст;
- при `true` отдельное предупреждение: пакет не содержит имён и HA ids, но
содержит **точную геометрию и размеры дома**.
Footer: Cancel/Close и primary «Отправить». Отправка недоступна при пустом
сообщении, во время package build/submit или при невалидном/просроченном preview.
Enter в однострочном поле не отправляет форму; `Ctrl+Enter`/`Cmd+Enter` в message
может отправить только при выполненных тех же validation guards.
### 6.4 Preview диагностического вложения
При включении чекбокса frontend вызывает backend preview и показывает:
- состояние «Подготавливаем обезличенные данные…»;
- после успеха: тип/версию пакета, число пространств, размер в KiB, SHA-256;
- `<details>` «Показать данные» с лениво созданным read-only `<textarea>` и exact
UTF-8 JSON text; многомегабайтный JSON не раскладывается в тысячи DOM nodes;
- кнопку «Скачать JSON», создающую файл из тех же bytes;
- кнопку «Обновить снимок».
Preview описывает **последнее принятое backend состояние**. Открытие Help не
завершает editor gesture, не сохраняет незакрытый dialog/draft и не форсирует
debounced write; backend `write_lock` даёт целую пару config/layout до либо после
конкурирующей записи, но никогда смесь двух ревизий.
Raw preview не содержит contact/message: это поля обращения, а не диагностический
пакет. Снятие чекбокса немедленно скрывает raw preview и удаляет token на backend.
Повторное включение строит новый snapshot, а не оживляет скрытый старый.
Если config/layout изменились после preview, пакет остаётся честным snapshot на
момент preview. UI показывает «Снимок подготовлен N минут назад»; автоматической
подмены байтов нет. После 10 минут token истекает, primary action блокируется и
предлагает обновить snapshot.
### 6.5 Submit и результат
- Checkbox off: отправляются только `message`, optional `contact`, card/integration
versions и safe locale; никаких plan/config/layout данных.
- Checkbox on: к тому же обращению relay получает exact preview bytes и SHA-256.
- Один клик создаёт один idempotency key. Повтор после timeout с тем же draft не
создаёт второй тикет, если первый дошёл.
- Успех закрывает busy state, не закрывает диалог автоматически и показывает:
«Репорт отправлен: {id}. Сохраните номер для связи с поддержкой» плюс Copy ID.
- Только после явного Close успешная форма очищается. При ошибке contact/message,
checkbox и валидный preview сохраняются.
- Ошибка не обещает доставку. Показывается stable localized reason и Retry. При
relay/network/timeout также доступны Copy message и, если есть package,
Download JSON + ссылки Telegram/GitHub для ручного продолжения.
- Отказ package build **не** отправляет сообщение молча без вложения. Пользователь
либо повторяет, либо явно снимает checkbox.
## 7. Support package v1
### 7.1 Envelope
Canonical UTF-8 JSON, stable key order, trailing newline:
```json
{
"format": "houseplan-support-package",
"version": 1,
"versions": {
"card": "1.70.0",
"integration": "1.70.0",
"home_assistant": "2026.8.0",
"model": 9,
"export_schema": 1
},
"runtime": {
"browser_family": "chromium",
"browser_major": 140,
"language": "ru",
"coarse_pointer": false,
"hover_capable": true,
"registry_access": "full"
},
"revisions": { "config": 17, "layout": 24 },
"summary": {},
"validation": {},
"repairs": [],
"plan_backup": { "config": {}, "layout": {} }
}
```
No exact creation timestamp lives inside the attachment; receiver time belongs to
the transport receipt. `card` and runtime flags come from a strict frontend input
schema. Arbitrary client strings are rejected or mapped to `unknown`, never copied.
### 7.2 Safe runtime and summary
Allowlist:
- semantic card/integration/HA/model/export versions;
- browser family enum `chromium|firefox|webkit|unknown` and bounded major number,
never full User-Agent;
- effective supported language enum, pointer/hover booleans;
- registry authority enum and coarse age bucket, never registry records;
- config/layout revision integers;
- counts by stable structural kind: spaces, rooms, drafts, walls, partitions,
columns, openings by type, decor by kind, markers by lifecycle/binding kind;
- validation/preflight stable codes and counts;
- active House Plan Repair **family** + count. Current `broken_plan_<spaceId>`
becomes `{ "code": "broken_plan", "count": N }`; raw issue id and placeholders
do not leave HA.
No exception message, stack, raw validation path or display name is permitted.
### 7.3 Exact plan snapshot and pseudonyms
Backend reads config and layout under the same `write_lock`, deep-copies once and
builds a new allowlist object. Redaction after serializing the original is forbidden.
Within one package all referential ids are mapped consistently to namespaced
sequential pseudonyms. The random namespace changes for every new preview, so a
pseudonym cannot correlate the same object between two reports:
- `space-k7m2-1`, `room-k7m2-1`, `wall-k7m2-1`, `opening-k7m2-1`,
`partition-k7m2-1`, `column-k7m2-1`, `decor-k7m2-1`, `marker-k7m2-1`;
- mappings are generated from current document order and a cryptographically
random per-package namespace; the raw mapping is never returned/stored;
- every cross-reference (`space`, `room_id`, host ids, wall ids, marker controls,
layout keys) is either remapped or omitted fail-closed;
- unknown object keys are dropped at every depth.
Included plan data:
- exact numeric geometry/coordinates, wall thickness, openings and transforms;
- structural/display enums, booleans and bounded numeric settings needed to render;
- decor geometry/style; decor text is replaced with a neutral placeholder;
- marker geometry/layout and safe display configuration; bindings/controls are
represented only by pseudonyms and stable domain/kind enums;
- space/room display names become localized-neutral `Space 1`/`Room 1` tokens;
- plan aspect and presence flags without file identity.
Excluded at source:
- all original ids and keys, HA area/floor/device/entity/config-entry ids;
- space/room/device/entity names and friendly names;
- marker description, link, PDFs, free-text templates, decor text and user notes;
- plan/background/file URLs, filenames, paths, MIME metadata and content manifests;
- uploaded image/PDF/manual bytes;
- entity states/attributes, live values, service data, vacuum trail/coordinates;
- HA installation id, IP/host, external/internal URLs, timezone and exact location;
- original `source_fingerprint`, raw checksums of user data and exact event times;
- undo/import optimizer snapshots and local browser storage.
Exact relative geometry is deliberately included by owner decision. Privacy text
must not call the whole attachment «anonymous» without qualification; it says
«обезличено, но содержит точную планировку».
### 7.4 Privacy invariant
Adversarial fixture заполняет **каждое** запрещённое поле уникальным sentinel,
включая unknown nested fields, Unicode, URL, path, HTML, email, entity id и
base64-looking text. В package bytes, preview, relay request metadata, logs and
error strings не встречается:
- sentinel verbatim;
- URL-encoded, JSON-escaped и base64 representation;
- raw ids from config/layout/Repair issue ids.
Тест не заменяет allowlist inspection: сериализатор строит typed projection и
имеет fail-closed tests на новые неизвестные поля схемы.
### 7.5 Limits
- attachment ≤ 8 MiB UTF-8, общий request ≤ 8.5 MiB;
- максимум три preview одновременно на пользователя и на integration instance;
- preview TTL 10 минут;
- message ≤ 10 000 code points, contact ≤ 320;
- package build не удерживает больше одной raw store copy и одной projection copy;
- превышение даёт `support_package_too_large`, ничего не отправляет.
## 8. Backend API
### 8.1 `houseplan/support/preview`
Input: strict frontend facts (`card_version`, browser enum/major, language,
pointer flags, registry enum/age bucket) and a random per-dialog `draft_id`.
Authorization: `_check_write()` / `may_write`; unauthorized fail-closed.
Output:
```json
{
"token": "opaque-random",
"expires_in": 600,
"size": 123456,
"sha256": "hex",
"spaces": 3,
"text": "{...}\n"
}
```
The in-memory record is bound to HA user id, integration entry and `draft_id`. It
stores only the already-sanitized bytes, hash, expiry and idempotency seed — never
raw config. Creating a replacement invalidates only the previous token of that
same draft; other card instances keep independent previews within the limit.
### 8.2 `houseplan/support/preview/discard`
Idempotently removes a token owned by the caller. Close dialog, checkbox off and
successful submit use this cleanup; disconnect/remount relies on TTL as the final
guard because one HA WebSocket connection may be shared by several card instances.
### 8.3 `houseplan/support/submit`
Input: `message`, optional `contact`, optional `preview_token`, `idempotency_key`.
The backend validates lengths again, resolves only a caller-owned non-expired token
and sends multipart to the compile-time allowlisted relay URL:
- part `request`: JSON with schema version, message, contact, safe versions,
attachment size/hash and idempotency key;
- optional part `attachment`: exact cached bytes, filename
`houseplan-support-{short-id}.json`, type `application/json`.
Requirements:
- HA shared aiohttp session; connect/total timeout 5/20 s;
- HTTPS only, fixed host, redirects disabled, no endpoint from user/config/message;
- no proxying of relay response text; map status to stable local codes;
- no body/contact/message/package in HA logs, traces or exception strings;
- token is consumed only on confirmed success; retry reuses exact bytes/key;
- response returns bounded `report_id`, never remote debug detail.
New stable errors are added to backend error registry and all locales:
`support_invalid_message`, `support_preview_expired`,
`support_package_too_large`, `support_rate_limited`, `support_unavailable`,
`support_rejected`.
Old backend without these commands leaves About/Guide usable and shows localized
«Обновите House Plan, чтобы отправлять репорты»; it does not offer a fake submit.
## 9. Project-controlled relay
### 9.1 Minimal architecture
The repository gains a separately deployable service under
`scripts/support-relay/**`, excluded from the HACS artifact. Keeping the complete
service (runtime, manifest, tests and deployment README) in this subtree makes
every relay-only commit class B under `AGENTS.md` / `PROCESS.md`; a product commit
that also changes `src/**` remains A+B and follows the stricter class-A flow. The
service exposes only `POST /v1/reports` and `GET /health`. Secrets exist only in
its deployment environment. The production sink is a **private maintainer channel
in Telegram** (owner's decision, 2026-09-01): the summary message carries the
generated report id, safe versions and the escaped plain-text message/contact, and
the support JSON is attached as a document. Markup mode is never enabled, so the
user's text is displayed literally and cannot forge the surrounding message. The
report is written to the relay spool **before** delivery is attempted, so a failed
delivery costs a promise, not the user's request. No public issue is created and no
e-mail provider participates.
**Two delivery channels, chosen by deployment.** `telegram` posts to the Bot API
directly. `ha_webhook` posts the summary to a private webhook of the maintainer's
own Home Assistant, which performs the last mile. The second channel exists
because the project node runs at a Russian hosting provider where every
`api.telegram.org` address is unreachable — direct delivery failed with «Network
is unreachable», not with a provider error. On this channel the package stays in
the relay spool and the summary names its path: the webhook carries text only,
so the address (which is its own access key) stays cheap to rotate, and the
geometry of a stranger's home does not travel through a messenger. Either
channel keeps the same contract: nothing is promised to the user until delivery
is confirmed.
Production URL is an immutable backend constant supplied after relay deployment.
No placeholder, localhost URL or configurable arbitrary endpoint may pass release
gates. A staging relay and exact production URL are dependencies of S5/implementation;
if they are unavailable, #43 receives `blocked` without weakening the contract.
### 9.2 Validation and abuse controls
- strict multipart schema; unknown parts/fields rejected;
- max request 8.5 MiB before buffering; JSON content/type/hash verified;
- attachment parsed and checked for format/version and top-level allowlist;
- message/contact rendered as escaped plain text only;
- 5 attempts/hour and 20/day per source address plus a global circuit breaker;
- **the source address is the one supplied by the trusted proxy, never one the
client can choose.** Behind a reverse proxy the relay runs with its
trusted-proxy switch on (`HP_RELAY_TRUSTED_PROXY`, on by default) and reads the
*last* element of `X-Forwarded-For`, because the first element is whatever the
caller sent; the proxy is configured to overwrite the header outright rather
than append to it. Both halves are required: without them a caller picks its own
rate-limit bucket by changing one line of the request, and every other limit in
this section becomes decorative. With the switch off the relay ignores the
header entirely and uses the connection address — correct only for a node
exposed directly, and **forbidden on any deployment behind a proxy**, where every
connection arrives from the proxy and all callers would share one bucket. Both
production hosts run behind Caddy, so both keep the switch on;
- source IP is used only through a daily-keyed rate-limit hash with ≤24 h TTL;
raw address is not written to app logs/storage/mail;
- idempotency key retained 24 h and returns the original report id;
- uniform public errors; internal provider responses are never reflected;
- mail/provider secrets are redacted by platform logging configuration.
Because open-source clients cannot hold a relay secret, the endpoint is intentionally
public and abuse protection is rate/size/schema based. This limitation is documented
and reviewed as a security trade-off; a hard-coded shared key is explicitly forbidden.
### 9.3 Retention and disclosure
- Relay stores the accepted report (message, contact, safe metadata and the
attachment) in a spool readable only by its own system user, and a daily timer
deletes everything older than **30 days**; maintainers may delete earlier. On
the `ha_webhook` channel the attachment never leaves that spool.
Storage is the price of the chosen channel: Telegram delivery leaves no archive
the project controls, so the deletion rule has to live where the project can
enforce and prove it.
- Idempotency record contains report id/status/hash only and expires after 24 h.
- UI privacy notice says: exact geometry and optional contact leave the user's HA,
transit the project relay and the maintainer messenger, are visible to
maintainers and are kept on the project node up to 30 days; network infrastructure necessarily sees the HA server address,
but House Plan does not retain it raw.
- Sending remains explicit opt-in; opening the dialog or building preview never
contacts the external relay.
## 10. State, compatibility and migration
- No House Plan config/layout schema changes and no migration.
- Form draft, checkbox, preview token and receipt are component-memory only.
- Remount, reload and card removal discard unsent draft; no silent persistence of
contact/message.
- Existing HA diagnostics, portable backups and #295 diagnostics stay byte- and
behaviour-compatible.
- Card/integration version mismatch follows fail-closed behaviour: no attachment
built by an unknown backend contract.
- Multiple card instances have independent drafts. Backend preview limits are per
HA user/entry and cannot expose one instance's token to another user.
## 11. Accessibility, touch and responsive layout
**Touch editor: supported.** The Help/Feedback dialog opened from View or any of
the three editors has the same complete touch contract; kiosk still hides it.
- Header button has 44×44 CSS px minimum touch target without changing adjacent
visual icon size.
- Dialog remains usable at 320 CSS px width, phone portrait/landscape, tablet and
HA Companion safe-area insets; footer actions wrap and body scrolls internally.
- Native label/input association, required/error text through `aria-describedby`,
status changes through restrained `aria-live="polite"`.
- On validation failure focus moves to message; on package/send failure focus moves
to error summary; success focuses receipt heading.
- Raw JSON `<pre>` is selectable and horizontally contained; it cannot widen or
clip the dialog.
- Escape/scrim close requires confirmation only while build/send is actually in
flight. A completed unsent text draft may be closed without persistence warning,
matching other non-destructive settings drafts.
- Reduced motion adds no new animation dependency.
## 12. i18n
All visible strings and stable errors exist in RU/EN/DE/FR. Russian contact label
is exact per §4; semantic translations use the same examples. User Guide routing
depends only on effective language: RU gets RU, every other locale gets EN.
Do not localize schema keys, error codes, format/version, hashes or report ids.
Relay summary message uses English stable field labels so support tooling can
parse them; user message/contact remain verbatim plain text.
## 13. Acceptance criteria and evidence
| AC | Contract | Evidence |
|---|---|---|
| AC1 | Help button follows General Settings visibility, order and kiosk rules; opens without changing mode/selection/zoom. | Browser smoke in View + three editors + kiosk/unauthorized negatives. |
| AC2 | About moves exactly once; RU Guide routes to RU, every other locale to EN. | DOM/i18n unit + smoke link assertions. |
| AC3 | Contact optional, message required/trimmed/bounded; checkbox false and draft non-persistent on each fresh open. | Frontend unit + browser smoke. |
| AC4 | Checkbox warning explicitly names exact geometry; no external request occurs on open/preview. | Text assertion + network capture. |
| AC5 | Preview/download/attachment are byte-identical and SHA-256 matches. | Backend test + browser Blob capture + relay fixture. |
| AC6 | Allowed geometry and references survive exact, while every id is package-local pseudonym and cross-references remain valid. | Backend projection round-trip/invariant suite. |
| AC7 | Forbidden sentinel corpus is absent verbatim/escaped/encoded from package, WS errors, HA logs and relay request metadata. | Adversarial backend/relay security test. |
| AC8 | Unknown nested config fields fail closed (dropped), never appear because serializer copied the source object. | Schema mutation test adding unknown sentinels at every depth. |
| AC9 | Only `may_write` can preview/submit; token cannot be read, discarded or sent by another user/entry. | HA websocket authorization tests. |
| AC10 | Expired/replaced/discarded tokens fail; config changes after preview do not change cached bytes. | Fake-clock backend tests. |
| AC11 | Submit is HTTPS/fixed-host/no-redirect, bounded and idempotent; logs contain no message/contact/body. | Stub aiohttp server + caplog + SSRF/redirect tests. |
| AC12 | Relay enforces schema, size, hash, idempotency and rate limits; HTML remains inert plain text. | Receiver unit/integration suite with a recording fake provider. |
| AC12a | A caller cannot select its own rate-limit bucket. With the trusted-proxy switch on, requests carrying different forged `X-Forwarded-For` values land in one source key, and the proxy configuration overwrites the header; with it off, the header is ignored altogether and the connection address is used. | Two receiver tests (one per switch position) plus the deployed proxy fragment under `scripts/support-relay/deploy/`. |
| AC13 | Success shows stable report id; timeout/error preserves form and exposes retry/manual recovery without claiming success. | Browser smoke across success/429/timeout/unknown command. |
| AC14 | Phone/tablet dialog, keyboard/focus and 44 px target satisfy View touch/accessibility contract. | Reviewed desktop + phone + tablet goldens and touch smoke. |
| AC15 | No existing backup/diagnostics/preflight behavior or payload changes. | Existing targeted frontend/backend suites unchanged. |
| AC16 | Relay production URL is real, health check green, secrets absent from HACS bundle/source and retention rule documented. | Release script + staging/prod probe + secret scan/deployment evidence. |
| AC17 | Maximum supported package builds without render/main-thread regression and refuses >8 MiB before outbound send. | Backend benchmark/limit test; View performance comparison. |
## 14. Test plan
### 14.1 Pure/backend
- deterministic package ordering and byte/hash fixture;
- full pseudonym/reference matrix across spaces, walls, openings, decor, markers
and layout;
- sentinel fixture including `broken_plan_<spaceId>` Repair normalization;
- valid/invalid frontend facts, message/contact Unicode bounds;
- write-lock consistency under concurrent config/layout mutation;
- token ownership, TTL, replacement, discard and exact-snapshot semantics;
- outbound mock for success, timeout, DNS/TLS, 302, 400/413/429/500, malformed
success and duplicate idempotency key;
- caplog assertions that no payload values escape.
### 14.2 Frontend/browser
- header visibility/order/kiosk/mode invariants;
- lazy runtime loading and dialog close/restore;
- form validation, checkbox lifecycle, preview refresh/expiry;
- exact download Blob bytes and Copy ID/manual fallback;
- unknown-old-backend degradation;
- mobile touch target, scroll, on-screen keyboard and orientation resize;
- golden scenes: desktop no attachment, desktop preview, phone validation error,
phone success, relay error/manual recovery, light and dark themes.
### 14.3 Relay/security
- parser/size/content-type/hash/schema/idempotency/rate-limit suite;
- HTML/header injection and Unicode controls remain plain text;
- raw IP and provider secrets absent from logs;
- fake provider verifies exact attachment bytes and escaped body;
- deployment config test enforces 30-day spool retention and 24-hour
idempotency/rate-key expiry.
### 14.4 Mutation requirements
Mutation gate must prove at least:
- checkbox default flips to true → frontend test red;
- package serializer copies one forbidden field → sentinel test red;
- pseudonym mapping leaves one raw host/layout id → reference/privacy test red;
- preview bytes are regenerated on submit → exact-byte test red;
- authorization guard removed → backend test red;
- redirect enabled/arbitrary URL accepted → SSRF test red;
- relay skips hash/rate/idempotency check → receiver tests red;
- relay trusts the client-supplied end of `X-Forwarded-For` (first element
instead of last) → `test_client_cannot_pick_its_own_rate_bucket` red;
- relay reads `X-Forwarded-For` regardless of the trusted-proxy switch (the
switch check is dropped) → `test_direct_node_ignores_the_forwarded_header` red;
- success shown on timeout → browser smoke red.
## 15. Performance and reliability budgets
- Cold ordinary View receives no relay calls and no eager support runtime growth;
bundle budget records lazy chunk delta separately.
- For the current maximum valid config, package projection + canonical JSON
completes in ≤750 ms on CI reference hardware and peak additional memory is
≤24 MiB; operation is backend-side and never blocks browser render.
- Submit total timeout 20 s; UI remains cancellable except while the single WS
request is in flight, and disconnect returns to retryable error.
- No queue/retry runs in background after dialog/card destruction. The user owns
every retry.
## 16. Risks and mitigations
1. **Exact geometry is personal.** Explicit unchecked consent, honest warning,
exact preview/download, 30-day retention, no binaries/names/ids.
2. **Allowlist drifts behind schema.** Unknown keys dropped and mutation/sentinel
tests; no generic deep-redaction helper.
3. **Public relay attracts spam.** Strict small schema, size/rate/global limits,
idempotency; no fake embedded secret.
4. **Endpoint becomes an SSRF proxy.** Immutable HTTPS URL, redirects off, bounded
response, no user-configured host.
5. **Channel/provider leak.** Private channel, plain text, documented provider,
limited retention, secret/log scans.
6. **Preview differs from sent data.** Backend caches sanitized bytes by owned TTL
token; submit never rebuilds.
7. **Stale card/backend.** Unknown command disables submit honestly while Help and
Guide remain available.
8. **Relay outage loses draft.** No auto-close; explicit retry and manual recovery.
9. **External infrastructure unavailable.** DoR/release gate blocks S5/S7 rather
than shipping a dead button or silent clipboard substitute.
## 17. Dependencies and Definition of Ready
Before product implementation begins, the following external facts must be
available and recorded in #43:
1. project-controlled relay deployment target and production HTTPS hostname;
2. private maintainer channel and its delivery credentials stored only in the
relay environment, as a path to a secret file rather than a value — for the
`ha_webhook` channel the webhook address is that credential;
3. configured and running 30-day deletion of the relay spool;
4. staging endpoint usable by CI without production delivery;
5. named maintainer responsible for relay alerts/disable switch.
These are technical/operational dependencies of the accepted Q1 default. If they
are absent after green spec review, issue remains `S5-ready` + `blocked`; product
code must not invent a public fallback or embed credentials.
## 18. Documentation and release artifacts
- `docs/USER-GUIDE.md` and `.ru.md`: button, fields, geometry warning, preview,
receipt/manual fallback;
- `docs/ARCHITECTURE.md`: package boundary, preview-token, outbound relay;
- new `docs/SUPPORT-PRIVACY.md`: exact allowlist/exclusions, delivery channel,
spool retention, rate-limit network metadata and deletion/contact path;
- `docs/TESTING.md`: support package/relay/golden commands;
- `scripts/support-relay/README.md`: deployment/runbook, health/disable/secret
rotation; relay runtime, manifest and tests remain inside this class-B subtree;
- process-gate regression fixture proves a relay-only implementation commit is
classified as B and therefore requires the issue/trailer checks;
- both changelogs in implementation commit (`User-Visible: yes`);
- reviewed desktop/phone/tablet light/dark golden artifacts and receiver security
report before beta.
## 19. Rollback
- Revert header/dialog/backend command changes: no persisted House Plan data or
migration remains.
- Disable relay endpoint first; clients receive retryable `support_unavailable`
and retain manual download path.
- Reports already delivered follow the disclosed 30-day deletion rule; rollback
never silently extends retention.
- Relay can stay deployed but disabled while old card versions disappear. It must
return uniform 503, not accept and drop reports.
- About links return to General Settings only if product rollback explicitly
restores the old UI; no state conversion is required.
## 20. Принятые технические предположения
Эти решения не меняют согласованный пользовательский контракт и могут быть
скорректированы ревьюером без нового вопроса владельцу:
- transport sink — приватный канал мейнтейнера в Telegram через relay проекта,
а не private issue и не почтовый ящик (решение владельца 2026-09-01);
- support runtime остаётся в существующем lazy editor chunk;
- preview bytes кешируются только в памяти backend;
- attachment — canonical uncompressed JSON, чтобы preview/download/send были
буквально одинаковыми;
- package-local sequential pseudonyms, а не стабильный cross-report hash;
- отсутствие shared client secret компенсируется rate/size/schema limits;
- retention 30 days for the whole delivered report, 24 hours for rate/idempotency
metadata.
+1 -1
View File
@@ -103,7 +103,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#40](https://github.com/Matysh/houseplan-card/issues/40) Floors/Areas onboarding | [040-floor-area-onboarding.md](040-floor-area-onboarding.md) |
| [#41](https://github.com/Matysh/houseplan-card/issues/41) Keyboard object editing | [041-keyboard-object-editing.md](041-keyboard-object-editing.md) |
| [#42](https://github.com/Matysh/houseplan-card/issues/42) Backend engineering quality | [042-backend-engineering-quality.md](042-backend-engineering-quality.md) |
| [#43](https://github.com/Matysh/houseplan-card/issues/43) Private support report | [043-private-support-report.md](043-private-support-report.md) |
| [#43](https://github.com/Matysh/houseplan-card/issues/43) Help/feedback and private support package | [043-private-support-report.md](043-private-support-report.md) |
| [#44](https://github.com/Matysh/houseplan-card/issues/44) Filtering/grouping policy | [044-filter-grouping-policy.md](044-filter-grouping-policy.md) |
| [#51](https://github.com/Matysh/houseplan-card/issues/51) Custom decor images | [051-custom-decor-images.md](051-custom-decor-images.md) |
| [#52](https://github.com/Matysh/houseplan-card/issues/52) Dimensions in View | [052-view-dimensions.md](052-view-dimensions.md) |
+18
View File
@@ -4400,6 +4400,24 @@ const MUTANT_DEFINITIONS = [
replace: ' this._devicePositionHistory.clear();',
}],
},
{
id: 'support-timeout-claims-success',
guard: 'node demo/smoke_support_feedback.mjs',
because: 'a relay timeout, rate limit or unknown command must preserve the draft and expose '
+ 'manual recovery; claiming success loses the report while telling the user it was sent (#43)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: " } catch (error: unknown) {\n"
+ " if (!this._supportPatch(current.draftId, {\n"
+ " status: 'error',\n"
+ ' errorCode: supportErrorCode(error),',
replace: " } catch (error: unknown) {\n"
+ " if (!this._supportPatch(current.draftId, {\n"
+ " status: 'success',\n"
+ " reportId: 'HP-FALSE-SUCCESS',\n"
+ " errorCode: '',",
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+215
View File
@@ -0,0 +1,215 @@
# House Plan support relay
Приёмщик обезличенных отчётов «Помощь и обратная связь» (#43, §9 ТЗ
[043](../../docs/specs/043-private-support-report.md)). Отдельно разворачиваемый
сервис: в артефакт HACS не входит, в карточку не собирается.
Два маршрута и ни одного лишнего:
| Маршрут | Назначение |
|---|---|
| `POST /v1/reports` | приём отчёта (multipart: часть `request` + необязательная `attachment`) |
| `GET /health` | режим, состояние рубильника, срок хранения |
## Почему без зависимостей
Сервис написан на стандартной библиотеке Python 3.12. Причина не в аскезе: это
публичный эндпоинт без общего секрета с клиентом, и любая зависимость на нём —
это чужой код, за обновлениями которого придётся следить вечно ради пяти
запросов в час. Отсутствие зависимостей делает установку копированием каталога,
а ревью — чтением четырёхсот строк.
## Как устроена защита
Общего секрета у открытого клиента быть не может (§9.2 ТЗ), поэтому защита
стоит на трёх опорах, и каждая проверяется тестами:
1. **Схема.** Неизвестная часть multipart, неизвестное поле в `request`,
неизвестная секция в пакете, чужой `format`, вложенный multipart, повтор
части — отказ. Не «игнорируем лишнее», а именно отказ.
2. **Размер.** `Content-Length` больше 8,5 МиБ отвергается **до** чтения тела;
вложение сверяется с заявленными длиной и sha256 и разбирается как JSON.
3. **Частота.** 5 попыток в час и 20 в сутки на источник плюс общий
предохранитель 60 в час на узел.
Адрес источника нигде не хранится: он превращается в HMAC от секрета узла и
сегодняшней даты, ключ живёт сутки. Штатный логгер `BaseHTTPRequestHandler`
заменён — он печатал адрес клиента.
Из `X-Forwarded-For` берётся **последний** элемент, а не первый: первый прислал
клиент, и подделать его может кто угодно, а последний проставлен ближайшим
звеном — нашим же Caddy. Caddy при этом настроен перезаписывать заголовок
целиком (`header_up X-Forwarded-For {remote_host}`). Две меры вместо одной
потому, что цена ошибки здесь — обход частотного лимита сменой одной строки в
запросе, и полагаться на умолчания чужого конфига для этого нельзя.
Заголовок читается только при включённом `HP_RELAY_TRUSTED_PROXY` (умолчание —
включён). Со снятым переключателем relay игнорирует заголовок и берёт адрес
соединения: это верно для узла, выставленного в интернет напрямую, и **запрещено
для узла за прокси** — там все соединения приходят от прокси, и весь публичный
эндпоинт делил бы одну корзину лимита на всех. Оба продовых инстанса стоят за
Caddy, поэтому у обоих переключатель включён.
Сообщение и контакт нормализуются и очищаются от управляющих символов, включая
маркеры двунаправленного письма: доставка выводит их буквальным текстом без
разметки, поэтому подделать вид сообщения нельзя.
## Доставка
Каналов два, `HP_RELAY_CHANNEL`.
**`ha_webhook` — рабочий канал стенда.** Relay отдаёт сводку вебхуку Home
Assistant владельца, а последнюю милю до Telegram делает уже он. Так вышло не от
хорошей жизни: узел стоит у российского хостера, откуда `api.telegram.org`
недоступен по всем адресам — прямая доставка падала с «Network is unreachable».
Побочный выигрыш: пакет остаётся в спуле стенда и в мессенджер не уходит, то
есть геометрия чужого дома не гуляет по чатам. Сводка называет путь к пакету.
**`telegram` — прямой канал.** Сводка сообщением, пакет документом. Годится для
узла, откуда Telegram доступен. `parse_mode` не используется намеренно: текст
пользователя отображается буквально.
Ответ провайдера наружу не отражается ни в одном из каналов: клиент получает
только `report_id` либо стабильный код отказа.
Отчёт кладётся на диск **до** попытки доставки. Если доставка не удалась,
клиент получает retryable `support_unavailable`, а обращение остаётся на узле —
терять его нельзя.
`HP_RELAY_MODE=discard` (staging) принимает и складывает отчёт, но никуда его не
отправляет. Это и есть эндпоинт для CI без production-доставки.
## Коды ответа
| HTTP | Тело | Когда |
|---|---|---|
| 200 | `{"report_id": "hpr-…"}` | принято; повтор с тем же `idempotency_key` вернёт тот же id и `"duplicate": true` |
| 400 | `support_rejected` / `support_invalid_message` | схема, размерность полей, хеш, пустое сообщение |
| 413 | `support_package_too_large` | запрос или вложение больше лимита |
| 429 | `support_rate_limited` | исчерпан лимит источника или узла |
| 503 | `support_unavailable` | рубильник выключен либо доставка не удалась |
## Переменные окружения
См. `deploy/env.example`. Секреты доставки задаются **путём к файлу**
(`HP_RELAY_TELEGRAM_TOKEN_FILE`, `HP_RELAY_WEBHOOK_URL_FILE`), а не значением:
так они не видны ни в `systemctl show`, ни в `ps`, ни в дампе окружения. Адрес
вебхука — такой же секрет, как токен: он сам себе ключ доступа.
## Установка
```bash
sudo useradd --system --home-dir /var/lib/hp-support-relay --shell /usr/sbin/nologin hprelay
sudo mkdir -p /opt/hp-support-relay /etc/hp-support-relay /var/lib/hp-support-relay/{prod,staging}
sudo rsync -a --delete scripts/support-relay/ /opt/hp-support-relay/
sudo chown -R hprelay:hprelay /var/lib/hp-support-relay
sudo chmod 700 /var/lib/hp-support-relay/{prod,staging}
sudo cp deploy/hp-support-relay@.service deploy/hp-support-relay-purge@.service \
deploy/hp-support-relay-purge@.timer /etc/systemd/system/
sudo install -m 0640 -o root -g hprelay deploy/env.example /etc/hp-support-relay/prod.env
# staging: HP_RELAY_MODE=discard, HP_RELAY_PORT=8131, свой спул
sudo systemctl daemon-reload
sudo systemctl enable --now hp-support-relay@prod hp-support-relay@staging
sudo systemctl enable --now hp-support-relay-purge@prod.timer hp-support-relay-purge@staging.timer
```
Затем добавить `deploy/Caddyfile.fragment` в `/etc/caddy/Caddyfile` и
`sudo systemctl reload caddy`. **Reload, а не restart**: валидный конфиг с
недоступным доменом уронит сервис при рестарте, тогда как reload оставит
работать прежний.
## Ответственный
За рубильник, оповещения и ротацию секрета отвечает Sergey Matyunin
(владелец проекта).
## Runbook
**Проверить состояние**
```bash
curl -s https://support.houseplan.tech/health
systemctl status hp-support-relay@prod
journalctl -u hp-support-relay@prod -n 50
```
**Выключить приём** (§19 ТЗ — откат начинается отсюда)
```bash
sudo sed -i 's/^HP_RELAY_ENABLED=1/HP_RELAY_ENABLED=0/' /etc/hp-support-relay/prod.env
sudo systemctl restart hp-support-relay@prod
```
Выключенный relay отвечает единообразным 503 и **не принимает** отчёты. Это
важнее, чем кажется: принять и потерять — хуже, чем честно отказать, потому что
пользователь считает обращение отправленным.
**Сменить секрет доставки**
```bash
# прямой Telegram
sudo install -m 0400 -o hprelay -g hprelay /dev/stdin /etc/hp-support-relay/telegram.token
# канал через Home Assistant (адрес вебхука целиком)
sudo install -m 0400 -o hprelay -g hprelay /dev/stdin /etc/hp-support-relay/webhook.url
sudo systemctl restart hp-support-relay@prod
```
Ротация вебхука — это ещё и правка `webhook_id` в автоматизации Home Assistant
«House Plan: приёмщик обратной связи → личка»: адрес и есть ключ.
**Минимальная конфигурация той автоматизации** — на случай, если её придётся
пересоздать (она живёт в чужом Home Assistant, не в этом репозитории):
```yaml
alias: "House Plan: приёмщик обратной связи → личка"
mode: queued
max: 10
triggers:
- trigger: webhook
webhook_id: "<тот же идентификатор, что в webhook.url>"
allowed_methods: [POST]
local_only: false # relay вызывает её снаружи
conditions:
- condition: template
value_template: "{{ trigger.json.source == 'houseplan-support-relay' }}"
actions:
- action: telegram_bot.send_message
data:
entity_id: notify.<получатель>
parse_mode: plain_text # текст пишет посторонний человек
disable_web_page_preview: true
message: "{{ trigger.json.text }}"
```
`parse_mode: plain_text` здесь не косметика: без него сообщение пользователя
разбирается как разметка и может подделать вид всего уведомления.
**Прочитать обращение**
```bash
sudo -u hprelay ls /var/lib/hp-support-relay/prod/reports/*/
sudo -u hprelay cat /var/lib/hp-support-relay/prod/reports/2026-09/hpr-…/report.json
```
**Срок хранения.** Таймер `hp-support-relay-purge@prod.timer` ежедневно удаляет
отчёты старше `HP_RELAY_RETENTION_DAYS` (30) и метаданные частоты и
идемпотентности старше суток. Проверить вручную:
`sudo -u hprelay HP_RELAY_SPOOL=… python3 /opt/hp-support-relay/relay.py purge`.
## Тесты
```bash
cd scripts/support-relay && python3 -m unittest discover -s tests -q
```
Тридцать шесть проверок: схема, размеры, хеш, идемпотентность, частота,
ретеншн, буквальность текста, отсутствие адреса в журналах, невозможность
выбрать себе корзину лимита подделкой заголовка, поведение рубильника.
Каждая проверялась отрицательным прогоном — четырнадцать мутаций рабочего кода
(снять сверку хеша, разрешить лишнюю часть, не чистить управляющие символы,
снять лимит, писать адрес в журнал, игнорировать идемпотентность, отключить
рубильник, отключить ретеншн, не проверять секции пакета, брать первый элемент
`X-Forwarded-For`, подменить `source` в вебхуке, приложить пакет к вебхуку,
читать заголовок независимо от переключателя доверия прокси) роняют ровно те
проверки, ради которых написаны.
@@ -0,0 +1,25 @@
# Фрагмент для /etc/caddy/Caddyfile. Требует A-записей support и
# support-staging на адрес стенда — иначе Caddy не получит сертификат.
#
# Access-лог намеренно не настраивается: он пишет remote_ip, а сырой адрес
# источника не должен оседать на диске (§9.2 ТЗ 043).
support.houseplan.tech {
request_body {
max_size 8.7MB
}
reverse_proxy 127.0.0.1:8130 {
# Заголовок ПЕРЕЗАПИСЫВАЕТСЯ, а не дополняется: иначе клиент задаёт
# первый элемент сам и выбирает себе корзину частотного лимита.
header_up X-Forwarded-For {remote_host}
}
}
support-staging.houseplan.tech {
request_body {
max_size 8.7MB
}
reverse_proxy 127.0.0.1:8131 {
header_up X-Forwarded-For {remote_host}
}
}
+19
View File
@@ -0,0 +1,19 @@
# /etc/hp-support-relay/prod.env — права 0640, владелец root:hprelay.
HP_RELAY_PORT=8130
HP_RELAY_SPOOL=/var/lib/hp-support-relay/prod
# deliver — отправлять мейнтейнеру; discard — принимать и складывать молча.
HP_RELAY_MODE=deliver
# ha_webhook — последняя миля через Home Assistant владельца (нужен, когда с узла
# api.telegram.org недоступен); telegram — прямая отправка.
HP_RELAY_CHANNEL=ha_webhook
# Рубильник: 0 переводит эндпоинт в единообразный 503, ничего не принимая.
HP_RELAY_ENABLED=1
HP_RELAY_RETENTION_DAYS=30
# Секрет НЕ хранится в этом файле: только путь к файлу с токеном (права 0400,
# владелец hprelay). Так он не попадает ни в `systemctl show`, ни в `ps`.
HP_RELAY_TELEGRAM_TOKEN_FILE=/etc/hp-support-relay/telegram.token
HP_RELAY_TELEGRAM_CHAT_ID=
# Адрес вебхука — такой же секрет, как токен: он сам себе ключ доступа.
HP_RELAY_WEBHOOK_URL_FILE=/etc/hp-support-relay/webhook.url
# Источник берётся из X-Forwarded-For, потому что перед сервисом стоит Caddy.
HP_RELAY_TRUSTED_PROXY=1
@@ -0,0 +1,14 @@
# Ретеншн: удаляет отчёты старше HP_RELAY_RETENTION_DAYS и метаданные старше суток.
[Unit]
Description=House Plan support relay retention (%i)
[Service]
Type=oneshot
User=hprelay
Group=hprelay
EnvironmentFile=/etc/hp-support-relay/%i.env
ExecStart=/usr/bin/python3 /opt/hp-support-relay/relay.py purge
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/hp-support-relay/%i
@@ -0,0 +1,10 @@
[Unit]
Description=Daily retention for House Plan support relay (%i)
[Timer]
OnCalendar=daily
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,35 @@
# Инстанс приёмщика: %i — имя окружения (prod | staging).
# Конфиг: /etc/hp-support-relay/%i.env, спул: /var/lib/hp-support-relay/%i
[Unit]
Description=House Plan support relay (%i)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=hprelay
Group=hprelay
EnvironmentFile=/etc/hp-support-relay/%i.env
ExecStart=/usr/bin/python3 /opt/hp-support-relay/relay.py
Restart=on-failure
RestartSec=5
# Сервис слушает только петлю, наружу ходит лишь к API доставки.
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryMax=256M
TasksMax=64
ReadWritePaths=/var/lib/hp-support-relay/%i
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,8 @@
"""House Plan support relay — приёмщик обезличенных отчётов (#43, §9 ТЗ 043).
Пакет намеренно обходится стандартной библиотекой Python: сервис принимает
единицы запросов в час, а отсутствие зависимостей снимает с проекта цепочку
обновлений безопасности у чужого кода на публично доступном эндпоинте.
"""
__all__ = ["config", "multipart", "validate", "ratelimit", "store", "delivery", "app"]
+197
View File
@@ -0,0 +1,197 @@
"""HTTP-слой relay: ровно два маршрута и ни одного лишнего.
`POST /v1/reports` — приём отчёта, `GET /health` — состояние. Всё остальное
отвечает 404 без подсказок. Журнал пишет метод, путь, статус и код отказа; ни
адреса источника, ни сообщения, ни вложения в журнале нет и быть не должно —
это требование §9.2/§9.3 ТЗ, а не предпочтение.
"""
from __future__ import annotations
import json
import logging
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import config as config_module
from . import delivery as delivery_module
from . import multipart, ratelimit, store, validate
LOG = logging.getLogger("hp-support-relay")
ALLOWED_PARTS = frozenset({"request", "attachment"})
STATUS_BY_CODE = {
"support_invalid_message": HTTPStatus.BAD_REQUEST,
"support_rejected": HTTPStatus.BAD_REQUEST,
"support_package_too_large": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
"support_rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
"support_unavailable": HTTPStatus.SERVICE_UNAVAILABLE,
}
class Service:
"""Логика, отделённая от транспорта: её же вызывают тесты."""
def __init__(self, cfg) -> None:
self.cfg = cfg
self.store = store.Store(cfg.spool)
self.limiter = ratelimit.Limiter(cfg.spool)
self.secret = ratelimit.node_secret(cfg.spool)
self.delivery = delivery_module.build(cfg)
def health(self) -> dict:
return {
"status": "ok" if self.cfg.enabled else "disabled",
"mode": self.cfg.mode,
"delivers": self.cfg.delivers,
"retention_days": self.cfg.retention_days,
}
def handle_report(self, content_type: str, body: bytes, source: str) -> tuple[int, dict]:
if not self.cfg.enabled:
# Рубильник обязан отказывать единообразно и retryable, а не
# принимать отчёт и тихо его ронять (§19 ТЗ).
return self._error("support_unavailable")
try:
boundary = multipart.parse_content_type(content_type)
parts = multipart.parse(body, boundary, ALLOWED_PARTS)
except multipart.MultipartError as error:
LOG.info("reject multipart: %s", error)
return self._error("support_rejected")
if "request" not in parts:
return self._error("support_rejected")
if parts["request"].content_type not in {"application/json", ""}:
return self._error("support_rejected")
try:
request = validate.parse_request(parts["request"].body)
attachment = parts.get("attachment")
if attachment is not None:
validate.check_attachment(
attachment.body, attachment.filename, attachment.content_type, request,
)
elif request.attachment_size:
return self._error("support_rejected")
except validate.ValidationError as error:
LOG.info("reject payload: %s (%s)", error, error.code)
return self._error(error.code)
existing = self.store.lookup(request.idempotency_key)
if existing:
# Повтор возвращает исходный идентификатор и НЕ тратит лимит:
# это та же попытка, а не новая.
LOG.info("idempotent replay -> %s", existing)
return HTTPStatus.OK, {"report_id": existing, "duplicate": True}
key = ratelimit.source_key(self.secret, source)
try:
self.limiter.check_and_count(key)
except ratelimit.RateLimited as error:
LOG.info("rate limited: %s", "global" if str(error) == "_global" else "source")
return self._error("support_rate_limited")
report_id = store.new_report_id()
meta = {
"report_id": report_id,
"received_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"message": request.message,
"contact": request.contact,
"versions": request.versions,
"attachment_size": len(attachment.body) if attachment else 0,
"attachment_sha256": request.attachment_sha256,
}
stored = self.store.save(report_id, meta, attachment.body if attachment else None)
# Путь добавляется только для доставки: в самом отчёте он избыточен, а в
# сводке — единственный способ найти пакет, если канал его не несёт.
delivered_meta = {**meta, "spool_path": str(stored.directory)}
result = self.delivery.send(report_id, delivered_meta, attachment.body if attachment else None)
self.store.mark_delivery(stored, "sent" if result.ok else "failed", result.detail)
if not result.ok:
LOG.warning("delivery failed for %s: %s", report_id, result.detail)
# Отчёт на диске, но пользователю обещать доставку нельзя.
return self._error("support_unavailable")
self.store.remember(request.idempotency_key, report_id)
LOG.info("accepted %s (%s)", report_id, result.detail)
return HTTPStatus.OK, {"report_id": report_id}
@staticmethod
def _error(code: str) -> tuple[int, dict]:
return STATUS_BY_CODE.get(code, HTTPStatus.BAD_REQUEST), {"error": code}
def make_handler(service: Service):
class Handler(BaseHTTPRequestHandler):
server_version = "hp-support-relay"
sys_version = ""
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None: # noqa: A003 - базовый класс
# Штатный логгер BaseHTTPRequestHandler печатает адрес клиента.
# Здесь он заменён на строку без адреса: сырой IP не должен попадать
# в журналы приложения (§9.2 ТЗ).
LOG.info("%s %s", self.command, self.path)
def _respond(self, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(int(status))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _source(self) -> str:
if service.cfg.trusted_proxy:
forwarded = self.headers.get("X-Forwarded-For", "")
if forwarded:
# ПОСЛЕДНИЙ элемент, а не первый. Первый — тот, что прислал
# клиент, и подделать его может кто угодно; последний
# проставлен ближайшим звеном, то есть нашим же Caddy.
# Caddy настроен перезаписывать заголовок целиком, но код не
# обязан полагаться на чужой конфиг: цена ошибки здесь —
# обход частотного лимита сменой одной строки в запросе.
return forwarded.split(",")[-1].strip()
return self.client_address[0]
def do_GET(self) -> None: # noqa: N802 - имя задано базовым классом
if self.path == "/health":
self._respond(HTTPStatus.OK, service.health())
return
self._respond(HTTPStatus.NOT_FOUND, {"error": "not_found"})
def do_POST(self) -> None: # noqa: N802
if self.path != "/v1/reports":
self._respond(HTTPStatus.NOT_FOUND, {"error": "not_found"})
return
raw_length = self.headers.get("Content-Length")
if raw_length is None or not raw_length.isdigit():
# Без объявленной длины нельзя отказать ДО буферизации,
# а буферизовать неизвестно сколько — и есть та самая дыра.
self._respond(HTTPStatus.LENGTH_REQUIRED, {"error": "support_rejected"})
return
length = int(raw_length)
if length > config_module.MAX_REQUEST_BYTES:
self._respond(
HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "support_package_too_large"},
)
return
body = self.rfile.read(length)
status, payload = service.handle_report(
self.headers.get("Content-Type", ""), body, self._source(),
)
self._respond(status, payload)
return Handler
def serve(cfg) -> None:
service = Service(cfg)
server = ThreadingHTTPServer(("127.0.0.1", cfg.port), make_handler(service))
LOG.info(
"listening on 127.0.0.1:%s mode=%s delivers=%s", cfg.port, cfg.mode, cfg.delivers,
)
server.serve_forever()
+83
View File
@@ -0,0 +1,83 @@
"""Конфигурация relay. Единственный источник — переменные окружения.
Секреты (токен доставки) читаются из ФАЙЛА, путь к которому задан переменной:
значение секрета не попадает ни в командную строку, ни в `systemctl show`,
ни в вывод `ps`.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
# §7.5 ТЗ: вложение ≤ 8 MiB, весь запрос ≤ 8.5 MiB.
MAX_REQUEST_BYTES = 8 * 1024 * 1024 + 512 * 1024
MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
MAX_MESSAGE_CODEPOINTS = 10_000
MAX_CONTACT_CODEPOINTS = 320
# §9.2 ТЗ: 5 попыток в час и 20 в сутки на источник.
RATE_HOURLY = 5
RATE_DAILY = 20
# Глобальный предохранитель: столько принятых отчётов в час со всех источников.
RATE_GLOBAL_HOURLY = 60
IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60
RATE_TTL_SECONDS = 24 * 60 * 60
@dataclass(frozen=True)
class Config:
port: int
spool: Path
mode: str # 'deliver' | 'discard'
channel: str # 'telegram' | 'ha_webhook'
enabled: bool
retention_days: int
telegram_token: str # пусто = доставка выключена
telegram_chat_id: str
webhook_url: str # адрес вебхука Home Assistant (секрет: он же ключ доступа)
trusted_proxy: bool # брать источник из X-Forwarded-For
@property
def delivers(self) -> bool:
if self.mode != "deliver":
return False
if self.channel == "ha_webhook":
return bool(self.webhook_url)
return bool(self.telegram_token and self.telegram_chat_id)
def _read_secret(path_value: str) -> str:
if not path_value:
return ""
path = Path(path_value)
if not path.is_file():
return ""
return path.read_text(encoding="utf-8").strip()
def load(env: dict[str, str] | None = None) -> Config:
env = dict(os.environ if env is None else env)
mode = env.get("HP_RELAY_MODE", "discard").strip().lower()
if mode not in {"deliver", "discard"}:
raise ValueError("HP_RELAY_MODE must be 'deliver' or 'discard'")
channel = env.get("HP_RELAY_CHANNEL", "telegram").strip().lower()
if channel not in {"telegram", "ha_webhook"}:
raise ValueError("HP_RELAY_CHANNEL must be 'telegram' or 'ha_webhook'")
spool = Path(env.get("HP_RELAY_SPOOL", "/var/lib/hp-support-relay"))
return Config(
port=int(env.get("HP_RELAY_PORT", "8130")),
spool=spool,
mode=mode,
channel=channel,
# Рубильник §19 ТЗ: выключенный relay обязан отвечать единообразным 503,
# а не принимать отчёты и терять их.
enabled=env.get("HP_RELAY_ENABLED", "1").strip() not in {"0", "false", "no"},
retention_days=int(env.get("HP_RELAY_RETENTION_DAYS", "30")),
telegram_token=_read_secret(env.get("HP_RELAY_TELEGRAM_TOKEN_FILE", "")),
telegram_chat_id=env.get("HP_RELAY_TELEGRAM_CHAT_ID", "").strip(),
webhook_url=_read_secret(env.get("HP_RELAY_WEBHOOK_URL_FILE", "")),
trusted_proxy=env.get("HP_RELAY_TRUSTED_PROXY", "1").strip() not in {"0", "false", "no"},
)
+203
View File
@@ -0,0 +1,203 @@
"""Доставка отчёта мейнтейнеру.
Каналов два, и второй появился не от любви к вариантам. Прямой Telegram
(`telegram`) — как задумывалось: сводка сообщением, пакет документом. Но узел
проекта стоит у российского хостера, откуда `api.telegram.org` недоступен по
всем адресам, — доставка молча падала с «Network is unreachable». Поэтому
основной канал стенда — `ha_webhook`: relay отдаёт сводку вебхуку Home Assistant
владельца, а последнюю милю до Telegram делает уже он, из сети, где Telegram
доступен. Пакет при этом остаётся в спуле стенда и в мессенджер не уходит —
геометрия чужого дома по чатам не гуляет.
Разметка НЕ используется намеренно: без `parse_mode` Telegram показывает текст
буквально, поэтому сообщение пользователя не может ничего разметить, подделать
или скрыть.
Ответ провайдера наружу не отражается ни при каких условиях (§9.2 ТЗ): наверх
уходит только «удалось / не удалось», а подробность живёт в журнале узла.
"""
from __future__ import annotations
import http.client
import json
import os
import socket
import urllib.error
import urllib.request
from dataclasses import dataclass
TIMEOUT_SECONDS = 20
API = "https://api.telegram.org"
@dataclass(frozen=True)
class Result:
ok: bool
detail: str
def _connect_ipv4(address, timeout, source_address=None) -> socket.socket:
"""Соединение строго по IPv4.
Узел проекта отдаёт для `api.telegram.org` и AAAA, и A, но связности по
IPv6 у него нет: обычный `urlopen` выбирал IPv6 и молча висел до таймаута —
доставка падала с «status 0», хотя сеть была в порядке. Явный выбор
семейства делает поведение независимым от порядка, в котором резолвер
вернул адреса.
"""
host, port = address
last: OSError | None = None
for family, kind, proto, _canon, sockaddr in socket.getaddrinfo(
host, port, socket.AF_INET, socket.SOCK_STREAM,
):
sock = socket.socket(family, kind, proto)
try:
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
return sock
except OSError as error:
last = error
sock.close()
raise last or OSError(f"no IPv4 address for {host}")
class _IPv4HTTPSConnection(http.client.HTTPSConnection):
def connect(self) -> None:
self.sock = _connect_ipv4((self.host, self.port), self.timeout, self.source_address)
if self._tunnel_host:
self._tunnel()
self.sock = self._context.wrap_socket(
self.sock, server_hostname=self._tunnel_host or self.host,
)
class _IPv4HTTPSHandler(urllib.request.HTTPSHandler):
def https_open(self, req): # noqa: D102 - контракт базового класса
return self.do_open(_IPv4HTTPSConnection, req, context=self._context)
def _post(url: str, body: bytes, content_type: str) -> tuple[int, bytes]:
request = urllib.request.Request(url, data=body, method="POST")
request.add_header("Content-Type", content_type)
openers = [urllib.request.build_opener(_IPv4HTTPSHandler()), urllib.request.build_opener()]
last_error = b""
for opener in openers:
try:
with opener.open(request, timeout=TIMEOUT_SECONDS) as response:
return response.status, response.read(4096)
except urllib.error.HTTPError as error:
# Ответ провайдера — это ответ, а не сбой связи: второй попытки не нужно.
return error.code, error.read(4096)
except (urllib.error.URLError, TimeoutError, OSError) as error:
last_error = str(error).encode("utf-8", "replace")[:4096]
return 0, last_error
def _multipart(fields: dict[str, str], filename: str, blob: bytes) -> tuple[bytes, str]:
boundary = "hp" + os.urandom(16).hex()
chunks: list[bytes] = []
for name, value in fields.items():
chunks.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
.encode("utf-8")
)
chunks.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="document"; filename="{filename}"\r\n'
f"Content-Type: application/json\r\n\r\n".encode("utf-8")
)
chunks.append(blob)
chunks.append(f"\r\n--{boundary}--\r\n".encode("utf-8"))
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
def summary_text(report_id: str, meta: dict) -> str:
versions = meta.get("versions") or {}
attachment = "—"
if meta.get("attachment_size"):
attachment = f"{meta['attachment_size']} B"
if meta.get("spool_path"):
attachment += f" — {meta['spool_path']}"
lines = [
f"House Plan support report {report_id}",
"",
"versions: " + (", ".join(f"{k}={v}" for k, v in sorted(versions.items())) or "—"),
"attachment: " + attachment,
"contact: " + (meta.get("contact") or "—"),
"",
"message:",
meta.get("message", ""),
]
text = "\n".join(lines)
# Ограничение Telegram на сообщение — 4096 символов; сообщение пользователя
# может быть длиннее, поэтому хвост отрезается с явной пометкой, а полный
# текст остаётся в отчёте на диске.
if len(text) > 3900:
text = text[:3900] + "\n[…] полный текст — в report.json на узле"
return text
class TelegramDelivery:
def __init__(self, token: str, chat_id: str) -> None:
self._token = token
self._chat_id = chat_id
def send(self, report_id: str, meta: dict, attachment: bytes | None) -> Result:
body = json.dumps({
"chat_id": self._chat_id,
"text": summary_text(report_id, meta),
"disable_web_page_preview": True,
}).encode("utf-8")
status, detail = _post(f"{API}/bot{self._token}/sendMessage", body, "application/json")
if status != 200:
return Result(False, f"sendMessage status {status}: {detail.decode('utf-8', 'replace')[:200]}")
if attachment is None:
return Result(True, "message only")
payload, content_type = _multipart(
{"chat_id": self._chat_id, "caption": report_id},
f"houseplan-support-{report_id}.json",
attachment,
)
status, detail = _post(f"{API}/bot{self._token}/sendDocument", payload, content_type)
if status != 200:
return Result(False, f"sendDocument status {status}: {detail.decode('utf-8', 'replace')[:200]}")
return Result(True, "message and document")
class HaWebhookDelivery:
"""Последняя миля через Home Assistant владельца.
Вебхук отдаёт только текст: адрес вебхука — сам себе ключ доступа, и чем
меньше через него проходит, тем дешевле его ротация. Вложение остаётся на
стенде, а сводка называет путь к нему.
"""
def __init__(self, url: str) -> None:
self._url = url
def send(self, report_id: str, meta: dict, attachment: bytes | None) -> Result:
body = json.dumps({
"source": "houseplan-support-relay",
"report_id": report_id,
"text": summary_text(report_id, meta),
}, ensure_ascii=False).encode("utf-8")
status, detail = _post(self._url, body, "application/json")
if status != 200:
return Result(False, f"webhook status {status}: {detail.decode('utf-8', 'replace')[:200]}")
return Result(True, "forwarded through Home Assistant")
class DiscardDelivery:
"""Staging: отчёт принимается и складывается, но никуда не уходит."""
def send(self, report_id: str, meta: dict, attachment: bytes | None) -> Result:
return Result(True, "discarded (staging)")
def build(cfg) -> object:
if not cfg.delivers:
return DiscardDelivery()
if cfg.channel == "ha_webhook":
return HaWebhookDelivery(cfg.webhook_url)
return TelegramDelivery(cfg.telegram_token, cfg.telegram_chat_id)
+130
View File
@@ -0,0 +1,130 @@
"""Строгий разбор multipart/form-data.
Строгий — значит «принимаем ровно то, что описано в §8.3 ТЗ, всё остальное
отвергаем». Публичный эндпоинт без общего секрета защищается схемой, размером и
частотой; парсер здесь — первая из трёх защит, поэтому он не прощает ничего:
ни лишних частей, ни повторов, ни отсутствующей границы, ни вложенного
multipart.
"""
from __future__ import annotations
from dataclasses import dataclass
class MultipartError(ValueError):
"""Тело не соответствует объявленной схеме."""
@dataclass(frozen=True)
class Part:
name: str
filename: str | None
content_type: str
body: bytes
def parse_content_type(header: str) -> str:
"""Возвращает boundary или бросает MultipartError."""
if not header:
raise MultipartError("missing content-type")
pieces = [piece.strip() for piece in header.split(";")]
if pieces[0].lower() != "multipart/form-data":
raise MultipartError("content-type must be multipart/form-data")
for piece in pieces[1:]:
key, _, value = piece.partition("=")
if key.strip().lower() != "boundary":
continue
value = value.strip()
if value.startswith('"') and value.endswith('"') and len(value) >= 2:
value = value[1:-1]
if not value or len(value) > 70:
raise MultipartError("bad boundary")
return value
raise MultipartError("missing boundary")
def _split_headers(chunk: bytes) -> tuple[dict[str, str], bytes]:
head, sep, body = chunk.partition(b"\r\n\r\n")
if not sep:
raise MultipartError("part without headers")
headers: dict[str, str] = {}
for raw in head.split(b"\r\n"):
if not raw:
continue
try:
line = raw.decode("ascii")
except UnicodeDecodeError as exc:
raise MultipartError("non-ascii header") from exc
key, _, value = line.partition(":")
if not _:
raise MultipartError("malformed header")
key = key.strip().lower()
if key in headers:
raise MultipartError("duplicate header")
headers[key] = value.strip()
return headers, body
def _disposition(value: str) -> tuple[str, str | None]:
pieces = [piece.strip() for piece in value.split(";")]
if not pieces or pieces[0].lower() != "form-data":
raise MultipartError("bad content-disposition")
name: str | None = None
filename: str | None = None
for piece in pieces[1:]:
key, _, raw = piece.partition("=")
raw = raw.strip()
if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2:
raw = raw[1:-1]
key = key.strip().lower()
if key == "name":
name = raw
elif key == "filename":
filename = raw
if not name:
raise MultipartError("part without name")
return name, filename
def parse(body: bytes, boundary: str, allowed: frozenset[str]) -> dict[str, Part]:
"""Разбирает тело и возвращает части по именам.
Имя, которого нет в `allowed`, — ошибка, а не игнорируемое поле: клиент,
приславший лишнюю часть, разговаривает не по этому контракту, и молча
принять его запрос значит принять неизвестно что.
"""
marker = b"--" + boundary.encode("ascii")
if not body.startswith(marker):
raise MultipartError("body does not start with boundary")
rest = body[len(marker):]
if rest.startswith(b"--"):
raise MultipartError("empty body")
if not rest.startswith(b"\r\n"):
raise MultipartError("malformed preamble")
# Открывающая граница снимается ДО разбиения: иначе первая часть вбирает
# в себя весь остаток тела вместе с чужими заголовками.
segments = rest[2:].split(b"\r\n" + marker)
parts: dict[str, Part] = {}
closed = False
for index, segment in enumerate(segments):
if index:
if segment.startswith(b"--"):
closed = True
break
if not segment.startswith(b"\r\n"):
raise MultipartError("malformed boundary")
segment = segment[2:]
headers, raw = _split_headers(segment)
name, filename = _disposition(headers.get("content-disposition", ""))
if name not in allowed:
raise MultipartError(f"unexpected part: {name}")
if name in parts:
raise MultipartError(f"duplicate part: {name}")
content_type = headers.get("content-type", "").split(";")[0].strip().lower()
if content_type.startswith("multipart/"):
raise MultipartError("nested multipart is not accepted")
parts[name] = Part(name=name, filename=filename, content_type=content_type, body=raw)
if not closed:
raise MultipartError("missing closing boundary")
return parts
+123
View File
@@ -0,0 +1,123 @@
"""Частотные ограничения без хранения сырых адресов (§9.2 ТЗ).
Адрес источника нигде не сохраняется: он превращается в HMAC от секрета узла и
СЕГОДНЯШНЕЙ даты. Ключ живёт максимум сутки и не позволяет связать обращения
разных дней между собой; секрет узла генерируется при первом старте и лежит
рядом со спулом с правами 0600.
"""
from __future__ import annotations
import hmac
import json
import os
import threading
import time
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from . import config
HOUR = 3600
DAY = 24 * 3600
class RateLimited(Exception):
"""Источник или узел исчерпал лимит."""
def _now() -> float:
return time.time()
def node_secret(spool: Path) -> bytes:
path = spool / "node.secret"
if path.exists():
return path.read_bytes()
spool.mkdir(parents=True, exist_ok=True)
secret = os.urandom(32)
tmp = path.with_suffix(".tmp")
tmp.write_bytes(secret)
tmp.chmod(0o600)
tmp.replace(path)
return secret
def source_key(secret: bytes, address: str, now: float | None = None) -> str:
"""Дневной непрозрачный ключ источника: сырой адрес не возвращается никогда."""
day = time.strftime("%Y-%m-%d", time.gmtime(_now() if now is None else now))
return hmac.new(secret, f"{day}|{address}".encode("utf-8"), sha256).hexdigest()[:32]
@dataclass
class _Bucket:
stamps: list[float]
def prune(self, now: float) -> None:
self.stamps = [stamp for stamp in self.stamps if now - stamp < DAY]
class Limiter:
def __init__(self, spool: Path) -> None:
self._dir = spool / "rate"
self._dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
def _path(self, key: str) -> Path:
return self._dir / f"{key}.json"
def _load(self, key: str) -> _Bucket:
path = self._path(key)
if not path.exists():
return _Bucket([])
try:
return _Bucket(list(json.loads(path.read_text(encoding="utf-8"))))
except (OSError, ValueError):
return _Bucket([])
def _save(self, key: str, bucket: _Bucket) -> None:
path = self._path(key)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(bucket.stamps), encoding="utf-8")
tmp.chmod(0o600)
tmp.replace(path)
def check_and_count(self, key: str, now: float | None = None) -> None:
"""Считает попытку и бросает RateLimited, если лимит исчерпан.
Попытка считается ДО доставки: иначе отправитель, добивающийся отказа,
получал бы бесплатные повторы.
"""
moment = _now() if now is None else now
with self._lock:
for name, limit, window in (
(key, config.RATE_HOURLY, HOUR),
(key, config.RATE_DAILY, DAY),
("_global", config.RATE_GLOBAL_HOURLY, HOUR),
):
bucket = self._load(name)
bucket.prune(moment)
recent = [stamp for stamp in bucket.stamps if moment - stamp < window]
if len(recent) >= limit:
raise RateLimited(name)
for name in (key, "_global"):
bucket = self._load(name)
bucket.prune(moment)
bucket.stamps.append(moment)
self._save(name, bucket)
def purge(self, now: float | None = None) -> int:
"""Удаляет ключи старше суток. Возвращает число удалённых файлов."""
moment = _now() if now is None else now
removed = 0
with self._lock:
for path in self._dir.glob("*.json"):
try:
stamps = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
stamps = []
if not stamps or moment - max(stamps) >= config.RATE_TTL_SECONDS:
path.unlink(missing_ok=True)
removed += 1
return removed
+114
View File
@@ -0,0 +1,114 @@
"""Спул отчётов и записи идемпотентности.
Отчёт кладётся на диск ДО попытки доставки: доставка может не удаться, а
обращение пользователя терять нельзя — оно и есть предмет задачи. Ретеншн
описан в README и исполняется отдельным таймером, а не этим процессом.
"""
from __future__ import annotations
import json
import os
import shutil
import threading
import time
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from . import config
def new_report_id() -> str:
"""Короткий непрозрачный идентификатор: показывается пользователю целиком."""
return "hpr-" + os.urandom(5).hex()
@dataclass(frozen=True)
class StoredReport:
report_id: str
directory: Path
class Store:
def __init__(self, spool: Path) -> None:
self.spool = spool
self.reports = spool / "reports"
self.idem = spool / "idem"
for path in (self.reports, self.idem):
path.mkdir(parents=True, exist_ok=True)
spool.chmod(0o700)
self._lock = threading.Lock()
# --- идемпотентность -------------------------------------------------
def _idem_path(self, key: str) -> Path:
return self.idem / (sha256(key.encode("utf-8")).hexdigest()[:32] + ".json")
def lookup(self, key: str, now: float | None = None) -> str | None:
moment = time.time() if now is None else now
path = self._idem_path(key)
if not path.exists():
return None
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if moment - float(record.get("created", 0)) >= config.IDEMPOTENCY_TTL_SECONDS:
path.unlink(missing_ok=True)
return None
value = record.get("report_id")
return value if isinstance(value, str) else None
def remember(self, key: str, report_id: str, now: float | None = None) -> None:
moment = time.time() if now is None else now
path = self._idem_path(key)
tmp = path.with_suffix(".tmp")
# Запись содержит только идентификатор и время: ни сообщения, ни адреса.
tmp.write_text(json.dumps({"report_id": report_id, "created": moment}), encoding="utf-8")
tmp.chmod(0o600)
tmp.replace(path)
# --- отчёты ----------------------------------------------------------
def save(self, report_id: str, meta: dict, attachment: bytes | None) -> StoredReport:
directory = self.reports / time.strftime("%Y-%m", time.gmtime()) / report_id
directory.mkdir(parents=True, exist_ok=True)
directory.chmod(0o700)
meta_path = directory / "report.json"
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
meta_path.chmod(0o600)
if attachment is not None:
blob = directory / f"houseplan-support-{report_id}.json"
blob.write_bytes(attachment)
blob.chmod(0o600)
return StoredReport(report_id=report_id, directory=directory)
def mark_delivery(self, stored: StoredReport, status: str, detail: str = "") -> None:
path = stored.directory / "delivery.json"
path.write_text(
json.dumps({"status": status, "detail": detail, "at": time.time()}, ensure_ascii=False),
encoding="utf-8",
)
path.chmod(0o600)
# --- ретеншн ---------------------------------------------------------
def purge(self, retention_days: int, now: float | None = None) -> int:
"""Удаляет отчёты старше срока хранения. Возвращает число удалённых."""
moment = time.time() if now is None else now
deadline = moment - retention_days * 86400
removed = 0
with self._lock:
for directory in sorted(self.reports.glob("*/*")):
if not directory.is_dir():
continue
if directory.stat().st_mtime < deadline:
shutil.rmtree(directory, ignore_errors=True)
removed += 1
for path in self.idem.glob("*.json"):
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
record = {}
if moment - float(record.get("created", 0)) >= config.IDEMPOTENCY_TTL_SECONDS:
path.unlink(missing_ok=True)
removed += 1
return removed
+175
View File
@@ -0,0 +1,175 @@
"""Проверка содержимого запроса: схема `request`, вложение, тексты.
Три правила, которые здесь удерживаются:
1. Схема закрытая. Неизвестное поле — отказ, а не игнорирование.
2. Тексты остаются текстами. Ни message, ни contact никогда не попадают в
разметку: доставка выводит их как plain text, а управляющие символы
вычищаются здесь, чтобы получатель не увидел «пустое» письмо с сюрпризом.
3. Вложение — ровно тот файл, о котором объявил отправитель: длина и sha256
сверяются с заявленными, содержимое разбирается и проверяется по allowlist
верхнего уровня (§7.1 ТЗ).
"""
from __future__ import annotations
import hashlib
import json
import re
import unicodedata
from dataclasses import dataclass
from . import config
SCHEMA_VERSION = 1
REQUEST_REQUIRED = frozenset({"schema_version", "message", "idempotency_key"})
REQUEST_OPTIONAL = frozenset({"contact", "versions", "attachment"})
REQUEST_ALLOWED = REQUEST_REQUIRED | REQUEST_OPTIONAL
VERSIONS_ALLOWED = frozenset({"card", "integration", "home_assistant", "model", "export_schema"})
ATTACHMENT_META_ALLOWED = frozenset({"size", "sha256"})
PACKAGE_ALLOWED_TOP_LEVEL = frozenset({
"format", "version", "versions", "runtime", "revisions",
"summary", "validation", "repairs", "plan_backup",
})
PACKAGE_FORMAT = "houseplan-support-package"
PACKAGE_VERSION = 1
IDEMPOTENCY_RE = re.compile(r"\A[A-Za-z0-9_.:-]{8,128}\Z")
SHA256_RE = re.compile(r"\A[0-9a-f]{64}\Z")
SAFE_VERSION_RE = re.compile(r"\A[0-9A-Za-z._+-]{1,32}\Z")
FILENAME_RE = re.compile(r"\Ahouseplan-support-[0-9a-z-]{1,40}\.json\Z")
class ValidationError(ValueError):
"""Публичная причина отказа. Текст безопасно показывать наружу."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def plain_text(value: str, limit: int, field: str) -> str:
"""Возвращает текст без управляющих символов и без сюрпризов раскладки.
Удаляются категории Cc (кроме перевода строки и табуляции) и Cf — в неё
входят маркеры двунаправленного письма, которыми в письме можно перевернуть
видимый порядок строк, не меняя байтов.
"""
if not isinstance(value, str):
raise ValidationError("support_rejected", f"{field} must be a string")
normalized = unicodedata.normalize("NFC", value)
cleaned = "".join(
ch for ch in normalized
if ch in "\n\t" or unicodedata.category(ch) not in {"Cc", "Cf"}
)
cleaned = cleaned.replace("\r\n", "\n").strip()
if len(cleaned) > limit:
raise ValidationError("support_rejected", f"{field} is too long")
return cleaned
@dataclass(frozen=True)
class Request:
message: str
contact: str
versions: dict[str, str]
idempotency_key: str
attachment_size: int
attachment_sha256: str
def parse_request(raw: bytes) -> Request:
if len(raw) > 128 * 1024:
raise ValidationError("support_rejected", "request part is too large")
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValidationError("support_rejected", "request part is not valid JSON") from exc
if not isinstance(payload, dict):
raise ValidationError("support_rejected", "request part must be an object")
unknown = set(payload) - REQUEST_ALLOWED
if unknown:
raise ValidationError("support_rejected", f"unknown request fields: {sorted(unknown)}")
missing = REQUEST_REQUIRED - set(payload)
if missing:
raise ValidationError("support_rejected", f"missing request fields: {sorted(missing)}")
if payload["schema_version"] != SCHEMA_VERSION:
raise ValidationError("support_rejected", "unsupported schema_version")
message = plain_text(payload["message"], config.MAX_MESSAGE_CODEPOINTS, "message")
if not message:
raise ValidationError("support_invalid_message", "message is empty")
contact = plain_text(payload.get("contact", ""), config.MAX_CONTACT_CODEPOINTS, "contact")
key = payload["idempotency_key"]
if not isinstance(key, str) or not IDEMPOTENCY_RE.match(key):
raise ValidationError("support_rejected", "bad idempotency_key")
versions_raw = payload.get("versions", {})
if not isinstance(versions_raw, dict):
raise ValidationError("support_rejected", "versions must be an object")
unknown_versions = set(versions_raw) - VERSIONS_ALLOWED
if unknown_versions:
raise ValidationError("support_rejected", f"unknown versions: {sorted(unknown_versions)}")
versions: dict[str, str] = {}
for name, value in versions_raw.items():
text = str(value)
if not SAFE_VERSION_RE.match(text):
raise ValidationError("support_rejected", f"bad version value: {name}")
versions[name] = text
meta = payload.get("attachment", {})
if not isinstance(meta, dict):
raise ValidationError("support_rejected", "attachment meta must be an object")
unknown_meta = set(meta) - ATTACHMENT_META_ALLOWED
if unknown_meta:
raise ValidationError("support_rejected", f"unknown attachment meta: {sorted(unknown_meta)}")
size = meta.get("size", 0)
digest = meta.get("sha256", "")
if not isinstance(size, int) or isinstance(size, bool) or size < 0:
raise ValidationError("support_rejected", "attachment size must be a non-negative integer")
if size > config.MAX_ATTACHMENT_BYTES:
raise ValidationError("support_package_too_large", "attachment is too large")
if digest and not (isinstance(digest, str) and SHA256_RE.match(digest)):
raise ValidationError("support_rejected", "attachment sha256 must be lowercase hex")
return Request(
message=message,
contact=contact,
versions=versions,
idempotency_key=key,
attachment_size=size,
attachment_sha256=digest,
)
def check_attachment(body: bytes, filename: str | None, content_type: str, request: Request) -> None:
if content_type != "application/json":
raise ValidationError("support_rejected", "attachment must be application/json")
if not filename or not FILENAME_RE.match(filename):
raise ValidationError("support_rejected", "unexpected attachment filename")
if len(body) > config.MAX_ATTACHMENT_BYTES:
raise ValidationError("support_package_too_large", "attachment is too large")
if request.attachment_size and len(body) != request.attachment_size:
raise ValidationError("support_rejected", "attachment size does not match the declared one")
if request.attachment_sha256:
actual = hashlib.sha256(body).hexdigest()
if actual != request.attachment_sha256:
raise ValidationError("support_rejected", "attachment hash does not match the declared one")
try:
package = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValidationError("support_rejected", "attachment is not valid JSON") from exc
if not isinstance(package, dict):
raise ValidationError("support_rejected", "attachment must be an object")
if package.get("format") != PACKAGE_FORMAT:
raise ValidationError("support_rejected", "attachment format is not recognised")
if package.get("version") != PACKAGE_VERSION:
raise ValidationError("support_rejected", "unsupported attachment version")
unknown = set(package) - PACKAGE_ALLOWED_TOP_LEVEL
if unknown:
raise ValidationError("support_rejected", f"unknown package sections: {sorted(unknown)}")
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Точка входа House Plan support relay (#43).
Запуск: `HP_RELAY_SPOOL=… HP_RELAY_MODE=… python3 relay.py`.
Подкоманда `purge` исполняет ретеншн и выходит — её зовёт systemd-таймер.
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hp_relay import app, config, ratelimit, store # noqa: E402
def main(argv: list[str]) -> int:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
cfg = config.load()
if argv[1:2] == ["purge"]:
reports = store.Store(cfg.spool).purge(cfg.retention_days)
keys = ratelimit.Limiter(cfg.spool).purge()
logging.getLogger("hp-support-relay").info(
"purged reports=%s rate/idem=%s retention_days=%s", reports, keys, cfg.retention_days,
)
return 0
app.serve(cfg)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+531
View File
@@ -0,0 +1,531 @@
"""Тесты приёмщика (§14.3 ТЗ 043). Запуск: python3 -m unittest discover -s tests
Проверки написаны так, чтобы каждая умела падать: рядом с положительным
утверждением стоит отрицательное — «а вот на таком входе обязан быть отказ».
"""
from __future__ import annotations
import io
import json
import logging
import sys
import threading
import time
import unittest
import urllib.error
import urllib.request
from hashlib import sha256
from http.server import ThreadingHTTPServer
from pathlib import Path
from tempfile import TemporaryDirectory
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from hp_relay import app, config, delivery, ratelimit, store, validate # noqa: E402
PACKAGE = {
"format": "houseplan-support-package",
"version": 1,
"versions": {"card": "1.70.0", "integration": "1.70.0", "home_assistant": "2026.8.0",
"model": 9, "export_schema": 1},
"runtime": {"browser_family": "chromium"},
"revisions": {"config": 17, "layout": 24},
"summary": {}, "validation": {}, "repairs": [],
"plan_backup": {"config": {}, "layout": {}},
}
def package_bytes(extra: dict | None = None) -> bytes:
payload = dict(PACKAGE)
if extra:
payload.update(extra)
return (json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
def build_body(request: dict, attachment: bytes | None, *, filename: str | None = None,
attachment_type: str = "application/json",
extra_part: tuple[str, bytes] | None = None) -> tuple[str, bytes]:
boundary = "----hp-test-boundary"
chunks = [
f'--{boundary}\r\nContent-Disposition: form-data; name="request"\r\n'
f"Content-Type: application/json\r\n\r\n".encode("utf-8"),
json.dumps(request, ensure_ascii=False).encode("utf-8"),
b"\r\n",
]
if attachment is not None:
name = filename or "houseplan-support-test.json"
chunks += [
f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f"Content-Type: {attachment_type}\r\n\r\n".encode("utf-8"),
attachment,
b"\r\n",
]
if extra_part is not None:
part_name, part_body = extra_part
chunks += [
f'--{boundary}\r\nContent-Disposition: form-data; name="{part_name}"\r\n\r\n'
.encode("utf-8"),
part_body,
b"\r\n",
]
chunks.append(f"--{boundary}--\r\n".encode("utf-8"))
return f"multipart/form-data; boundary={boundary}", b"".join(chunks)
def request_json(blob: bytes | None, *, key: str = "idem-key-0001", message: str = "не работает",
contact: str = "", **overrides) -> dict:
payload = {
"schema_version": 1,
"message": message,
"idempotency_key": key,
"versions": {"card": "1.70.0"},
}
if contact:
payload["contact"] = contact
if blob is not None:
payload["attachment"] = {"size": len(blob), "sha256": sha256(blob).hexdigest()}
payload.update(overrides)
return payload
class RecordingDelivery:
"""Поддельный провайдер: запоминает ровно то, что ему передали."""
def __init__(self) -> None:
self.calls: list[tuple[str, dict, bytes | None]] = []
self.ok = True
def send(self, report_id, meta, attachment):
self.calls.append((report_id, meta, attachment))
return delivery.Result(self.ok, "recorded")
class captured_log:
"""Собирает всё, что уходит в журнал relay, — включая транспортный слой."""
def __enter__(self):
self._stream = io.StringIO()
self._handler = logging.StreamHandler(self._stream)
self._logger = logging.getLogger("hp-support-relay")
self._logger.addHandler(self._handler)
self._previous = self._logger.level
self._logger.setLevel(logging.INFO)
return self._stream
def __exit__(self, *exc):
self._logger.removeHandler(self._handler)
self._logger.setLevel(self._previous)
return False
class RelayTestCase(unittest.TestCase):
def setUp(self) -> None:
self._tmp = TemporaryDirectory()
self.spool = Path(self._tmp.name) / "spool"
self.cfg = config.load({"HP_RELAY_SPOOL": str(self.spool), "HP_RELAY_MODE": "discard"})
self.service = app.Service(self.cfg)
self.provider = RecordingDelivery()
self.service.delivery = self.provider
def tearDown(self) -> None:
self._tmp.cleanup()
def post(self, request: dict, attachment: bytes | None, source: str = "203.0.113.1", **kwargs):
content_type, body = build_body(request, attachment, **kwargs)
return self.service.handle_report(content_type, body, source)
# --- приём ---------------------------------------------------------
def test_accepts_a_well_formed_report(self):
blob = package_bytes()
status, payload = self.post(request_json(blob), blob)
self.assertEqual(status, 200, payload)
self.assertTrue(payload["report_id"].startswith("hpr-"))
self.assertEqual(len(self.provider.calls), 1)
def test_delivered_attachment_is_byte_identical(self):
blob = package_bytes({"revisions": {"config": 3, "layout": 4}})
self.post(request_json(blob), blob)
_, _, delivered = self.provider.calls[0]
self.assertEqual(delivered, blob)
def test_report_and_attachment_land_in_the_spool(self):
blob = package_bytes()
_, payload = self.post(request_json(blob), blob)
found = list(self.spool.glob(f"reports/*/{payload['report_id']}/*.json"))
names = sorted(path.name for path in found)
self.assertIn("report.json", names)
self.assertIn(f"houseplan-support-{payload['report_id']}.json", names)
# --- отказы --------------------------------------------------------
def test_unknown_part_is_rejected(self):
blob = package_bytes()
status, payload = self.post(request_json(blob), blob, extra_part=("evil", b"x"))
self.assertEqual((status, payload["error"]), (400, "support_rejected"))
def test_unknown_request_field_is_rejected(self):
status, payload = self.post(request_json(None, tracking_pixel="yes"), None)
self.assertEqual((status, payload["error"]), (400, "support_rejected"))
def test_wrong_hash_is_rejected(self):
blob = package_bytes()
request = request_json(blob)
request["attachment"]["sha256"] = "0" * 64
status, payload = self.post(request, blob)
self.assertEqual((status, payload["error"]), (400, "support_rejected"))
def test_wrong_size_is_rejected(self):
blob = package_bytes()
request = request_json(blob)
request["attachment"]["size"] = len(blob) + 1
status, payload = self.post(request, blob)
self.assertEqual((status, payload["error"]), (400, "support_rejected"))
def test_foreign_package_format_is_rejected(self):
blob = (json.dumps({"format": "something-else", "version": 1}) + "\n").encode("utf-8")
status, payload = self.post(request_json(blob), blob)
self.assertEqual((status, payload["error"]), (400, "support_rejected"))
def test_unknown_package_section_is_rejected(self):
blob = package_bytes({"exfiltrated": {"token": "secret"}})
status, payload = self.post(request_json(blob), blob)
self.assertEqual((status, payload["error"]), (400, "support_rejected"))
def test_oversized_attachment_is_rejected(self):
blob = package_bytes({"summary": {"pad": "x" * (config.MAX_ATTACHMENT_BYTES + 16)}})
status, payload = self.post(request_json(blob), blob)
self.assertEqual((status, payload["error"]), (413, "support_package_too_large"))
def test_empty_message_is_rejected(self):
status, payload = self.post(request_json(None, message=" "), None)
self.assertEqual((status, payload["error"]), (400, "support_invalid_message"))
def test_declared_attachment_without_body_is_rejected(self):
blob = package_bytes()
status, payload = self.post(request_json(blob), None)
self.assertEqual((status, payload["error"]), (400, "support_rejected"))
def test_disabled_relay_refuses_uniformly(self):
cfg = config.load({"HP_RELAY_SPOOL": str(self.spool), "HP_RELAY_ENABLED": "0"})
service = app.Service(cfg)
service.delivery = RecordingDelivery()
content_type, body = build_body(request_json(None), None)
status, payload = service.handle_report(content_type, body, "203.0.113.1")
self.assertEqual((status, payload["error"]), (503, "support_unavailable"))
self.assertEqual(service.delivery.calls, [])
def test_failed_delivery_does_not_promise_success(self):
self.provider.ok = False
blob = package_bytes()
status, payload = self.post(request_json(blob), blob)
self.assertEqual((status, payload["error"]), (503, "support_unavailable"))
# Обращение всё равно сохранено — терять его нельзя.
self.assertTrue(list(self.spool.glob("reports/*/*/report.json")))
# --- тексты --------------------------------------------------------
def test_markup_and_controls_stay_literal_text(self):
nasty = "<b>bold</b>\r\nSubject: injected‮gnitpircs"
blob = package_bytes()
self.post(request_json(blob, message=nasty), blob)
_, meta, _ = self.provider.calls[0]
self.assertIn("<b>bold</b>", meta["message"]) # разметка не исполняется, а видна
self.assertNotIn("‮", meta["message"]) # bidi-переворот вычищен
self.assertNotIn("", meta["message"]) # управляющий символ вычищен
self.assertNotIn("\r", meta["message"]) # склейка заголовков невозможна
def test_summary_text_carries_no_markup_mode(self):
text = delivery.summary_text("hpr-1", {"message": "<i>x</i>", "versions": {"card": "1.70.0"}})
self.assertIn("<i>x</i>", text)
# --- лимиты и идемпотентность ---------------------------------------
def test_hourly_limit_stops_the_sixth_attempt(self):
blob = package_bytes()
for index in range(config.RATE_HOURLY):
status, _ = self.post(request_json(blob, key=f"idem-key-{index:04d}"), blob)
self.assertEqual(status, 200)
status, payload = self.post(request_json(blob, key="idem-key-9999"), blob)
self.assertEqual((status, payload["error"]), (429, "support_rate_limited"))
def test_other_source_is_not_limited_by_the_first(self):
blob = package_bytes()
for index in range(config.RATE_HOURLY):
self.post(request_json(blob, key=f"idem-key-{index:04d}"), blob)
status, _ = self.post(request_json(blob, key="idem-key-8888"), blob, source="198.51.100.7")
self.assertEqual(status, 200)
def test_replay_returns_the_original_id_without_spending_the_limit(self):
blob = package_bytes()
_, first = self.post(request_json(blob), blob)
_, second = self.post(request_json(blob), blob)
self.assertEqual(second["report_id"], first["report_id"])
self.assertTrue(second["duplicate"])
self.assertEqual(len(self.provider.calls), 1) # второй раз не доставляли
for index in range(config.RATE_HOURLY - 1):
status, _ = self.post(request_json(blob, key=f"idem-key-{index:04d}"), blob)
self.assertEqual(status, 200) # повтор лимит не потратил
def test_source_key_hides_the_address_and_rotates_daily(self):
secret = ratelimit.node_secret(self.spool)
address = "203.0.113.42"
today = ratelimit.source_key(secret, address)
tomorrow = ratelimit.source_key(secret, address, now=time.time() + 86400)
self.assertNotIn(address, today)
self.assertNotEqual(today, tomorrow)
# --- журналы --------------------------------------------------------
def test_logs_do_not_carry_the_message(self):
with captured_log() as stream:
blob = package_bytes()
self.post(request_json(blob, message="секретная жалоба"), blob, source="203.0.113.77")
self.assertNotIn("секретная жалоба", stream.getvalue())
# --- ретеншн --------------------------------------------------------
def test_purge_deletes_old_reports_and_keeps_fresh_ones(self):
blob = package_bytes()
_, fresh = self.post(request_json(blob), blob)
_, stale = self.post(request_json(blob, key="idem-key-0002"), blob)
stale_dir = next(self.spool.glob(f"reports/*/{stale['report_id']}"))
old = time.time() - 31 * 86400
import os
os.utime(stale_dir, (old, old))
removed = self.service.store.purge(self.cfg.retention_days)
self.assertGreaterEqual(removed, 1)
self.assertFalse(stale_dir.exists())
self.assertTrue(next(self.spool.glob(f"reports/*/{fresh['report_id']}")).exists())
def test_idempotency_record_expires_after_a_day(self):
self.service.store.remember("idem-key-old1", "hpr-old", now=time.time() - 25 * 3600)
self.assertIsNone(self.service.store.lookup("idem-key-old1"))
def test_rate_keys_expire_after_a_day(self):
limiter = ratelimit.Limiter(self.spool)
key = ratelimit.source_key(self.service.secret, "203.0.113.9")
limiter.check_and_count(key, now=time.time() - 25 * 3600)
self.assertEqual(limiter.purge(), 2) # ключ источника и глобальный
def test_retention_defaults_match_the_disclosed_policy(self):
self.assertEqual(self.cfg.retention_days, 30)
self.assertEqual(config.IDEMPOTENCY_TTL_SECONDS, 24 * 3600)
self.assertEqual(config.RATE_TTL_SECONDS, 24 * 3600)
class DirectNodeTestCase(unittest.TestCase):
"""Узел без прокси: заголовку верить нельзя, потому что подставить его некому."""
def setUp(self) -> None:
self._tmp = TemporaryDirectory()
cfg = config.load({
"HP_RELAY_SPOOL": str(Path(self._tmp.name) / "s"),
"HP_RELAY_TRUSTED_PROXY": "0",
})
self.service = app.Service(cfg)
self.service.delivery = RecordingDelivery()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), app.make_handler(self.service))
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self._tmp.cleanup()
def _post(self, key: str, forwarded: str | None) -> int:
blob = package_bytes()
content_type, body = build_body(request_json(blob, key=key), blob)
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/v1/reports", data=body, method="POST",
)
request.add_header("Content-Type", content_type)
if forwarded:
request.add_header("X-Forwarded-For", forwarded)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return response.status
except urllib.error.HTTPError as error:
return error.code
def test_direct_node_ignores_the_forwarded_header(self):
"""Со снятым переключателем заголовок не участвует вовсе.
Иначе узел, стоящий в интернете напрямую, доверял бы строке, которую
полностью пишет вызывающий, — то есть каждый выбирал бы себе корзину сам.
"""
self.assertEqual(self._post("direct-key-0001", "203.0.113.10"), 200)
self.assertEqual(self._post("direct-key-0002", "198.51.100.20"), 200)
self.assertEqual(self._post("direct-key-0003", None), 200)
spool = self.service.cfg.spool
buckets = [path.name for path in (spool / "rate").glob("*.json")
if path.name != "_global.json"]
self.assertEqual(len(buckets), 1, buckets)
class WebhookChannelTestCase(unittest.TestCase):
"""Канал «через Home Assistant»: что уходит и что остаётся."""
def setUp(self) -> None:
self._tmp = TemporaryDirectory()
secret = Path(self._tmp.name) / "webhook.url"
secret.write_text("https://ha.example/api/webhook/xyz\n", encoding="utf-8")
self.cfg = config.load({
"HP_RELAY_SPOOL": str(Path(self._tmp.name) / "spool"),
"HP_RELAY_MODE": "deliver",
"HP_RELAY_CHANNEL": "ha_webhook",
"HP_RELAY_WEBHOOK_URL_FILE": str(secret),
})
def tearDown(self) -> None:
self._tmp.cleanup()
def test_channel_is_live_with_only_the_webhook_url(self):
self.assertTrue(self.cfg.delivers)
self.assertIsInstance(delivery.build(self.cfg), delivery.HaWebhookDelivery)
def test_webhook_sends_text_and_keeps_the_package_on_the_node(self):
posted: list[tuple[str, bytes, str]] = []
real = delivery._post
delivery._post = lambda url, body, ctype: (posted.append((url, body, ctype)), (200, b"ok"))[1]
try:
channel = delivery.HaWebhookDelivery(self.cfg.webhook_url)
result = channel.send("hpr-42", {"message": "сломалось", "attachment_size": 12,
"spool_path": "/var/lib/x/hpr-42"}, b"{}")
finally:
delivery._post = real
self.assertTrue(result.ok)
url, body, ctype = posted[0]
self.assertEqual(url, "https://ha.example/api/webhook/xyz")
self.assertEqual(ctype, "application/json")
payload = json.loads(body)
self.assertEqual(payload["source"], "houseplan-support-relay") # условие автоматизации
self.assertIn("сломалось", payload["text"])
self.assertIn("/var/lib/x/hpr-42", payload["text"]) # где искать пакет
self.assertNotIn("attachment_bytes", payload) # сам пакет не уходит
self.assertEqual(sorted(payload), ["report_id", "source", "text"])
def test_webhook_failure_is_not_reported_as_success(self):
real = delivery._post
delivery._post = lambda url, body, ctype: (502, b"bad gateway")
try:
result = delivery.HaWebhookDelivery(self.cfg.webhook_url).send("hpr-1", {"message": "x"}, None)
finally:
delivery._post = real
self.assertFalse(result.ok)
self.assertIn("502", result.detail)
class DeliveryTransportTestCase(unittest.TestCase):
"""Транспорт доставки: узел без IPv6 не должен молча висеть."""
def test_connect_asks_for_ipv4_addresses_only(self):
seen: list[int] = []
real = delivery.socket.getaddrinfo
def fake(host, port, family=0, *args, **kwargs):
seen.append(family)
return real("127.0.0.1", port, family, *args, **kwargs)
delivery.socket.getaddrinfo = fake
try:
with self.assertRaises(OSError):
delivery._connect_ipv4(("example.invalid", 9), 0.2)
finally:
delivery.socket.getaddrinfo = real
self.assertEqual(seen, [delivery.socket.AF_INET])
class HttpSurfaceTestCase(unittest.TestCase):
"""Проверки, которые живут только на транспортном уровне."""
def setUp(self) -> None:
self._tmp = TemporaryDirectory()
cfg = config.load({"HP_RELAY_SPOOL": str(Path(self._tmp.name) / "s"), "HP_RELAY_PORT": "0"})
self.service = app.Service(cfg)
self.service.delivery = RecordingDelivery()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), app.make_handler(self.service))
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self._tmp.cleanup()
def call(self, method: str, path: str, body: bytes | None = None,
content_type: str = "application/octet-stream", headers: dict | None = None):
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}{path}", data=body, method=method,
)
if body is not None:
request.add_header("Content-Type", content_type)
for name, value in (headers or {}).items():
request.add_header(name, value)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read())
def test_health_reports_mode_without_secrets(self):
status, payload = self.call("GET", "/health")
self.assertEqual(status, 200)
self.assertEqual(payload["status"], "ok")
self.assertNotIn("telegram_token", json.dumps(payload))
def test_unknown_route_is_not_described(self):
status, payload = self.call("GET", "/admin")
self.assertEqual((status, payload["error"]), (404, "not_found"))
def test_oversized_request_is_refused_before_reading(self):
content_type, body = build_body(request_json(None), None)
status, payload = self.call(
"POST", "/v1/reports", body, content_type,
headers={"Content-Length": str(config.MAX_REQUEST_BYTES + 1)},
)
self.assertEqual((status, payload["error"]), (413, "support_package_too_large"))
def test_transport_log_carries_no_client_address(self):
"""Адрес пишет штатный логгер BaseHTTPRequestHandler — проверяем ЕГО.
Сервис-уровневый тест сюда не достаёт: `log_message` вызывается из
`send_response`, то есть только при настоящем HTTP-запросе.
"""
blob = package_bytes()
content_type, body = build_body(request_json(blob), blob)
with captured_log() as stream:
status, _ = self.call("POST", "/v1/reports", body, content_type,
headers={"X-Forwarded-For": "198.51.100.5"})
self.assertEqual(status, 200)
written = stream.getvalue()
self.assertIn("POST /v1/reports", written) # журнал не пуст — проверка настоящая
self.assertNotIn("127.0.0.1", written) # адрес соединения
self.assertNotIn("198.51.100.5", written) # адрес из заголовка
def test_client_cannot_pick_its_own_rate_bucket(self):
"""Подделанный X-Forwarded-For не создаёт новый ключ источника.
Каждый запрос приходит с чужим адресом в заголовке; ключей частоты после
этого должно остаться столько же, сколько при одном источнике, иначе
лимит обходится сменой одной строки в запросе.
"""
blob = package_bytes()
for index, forged in enumerate(("203.0.113.1", "198.51.100.2", "192.0.2.3")):
content_type, body = build_body(request_json(blob, key=f"forged-key-{index:04d}"), blob)
status, _ = self.call("POST", "/v1/reports", body, content_type,
headers={"X-Forwarded-For": f"{forged}, 127.0.0.1"})
self.assertEqual(status, 200)
spool = self.service.cfg.spool
buckets = [path.name for path in (spool / "rate").glob("*.json")
if path.name != "_global.json"]
self.assertEqual(len(buckets), 1, buckets)
def test_forwarded_for_is_used_as_the_source(self):
blob = package_bytes()
content_type, body = build_body(request_json(blob), blob)
status, _ = self.call("POST", "/v1/reports", body, content_type,
headers={"X-Forwarded-For": "198.51.100.5"})
self.assertEqual(status, 200)
if __name__ == "__main__":
unittest.main()
+29 -2
View File
@@ -18,6 +18,7 @@ import {
type HpConfirmRequest,
type HpConfirmState,
} from './danger-confirm';
import type { SupportDialogState } from './support-feedback';
import type { ColorPickerLabels } from './hp-color-opacity';
import {
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
@@ -2192,6 +2193,7 @@ export class HouseplanCard extends LitElement {
northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean;
busy: boolean;
} | null = null;
private _supportDialog: SupportDialogState | null = null;
private _backupExportDialog: {
kind: 'full' | 'space'; planOnly: boolean; busy: boolean; error: string;
} | null = null;
@@ -2659,6 +2661,7 @@ export class HouseplanCard extends LitElement {
_deviceInbox: { state: true },
_rulesDialog: { state: true },
_settingsDialog: { state: true },
_supportDialog: { state: true },
_alignDialog: { state: true },
_preflightClipboardFallback: { state: true },
_backupExportDialog: { state: true },
@@ -2914,6 +2917,7 @@ export class HouseplanCard extends LitElement {
if (this._alignDialog) { this._alignDialog = null; this._preflightClipboardFallback = null; return; }
if (this._backupImportDialog) { this._backupImportDialog = null; return; }
if (this._backupExportDialog) { this._backupExportDialog = null; return; }
if (this._supportDialog) { void this._editorRuntime?._closeSupportDialog(); return; }
if (this._settingsDialog) { this._settingsDialog = null; return; }
if (this._markerDialog) { this._closeMarkerDialog(); return; }
if (this._deviceInbox) { this._deviceInbox = null; return; }
@@ -7311,6 +7315,10 @@ export class HouseplanCard extends LitElement {
this._preflightClipboardFallback = null;
this._backupImportDialog = null;
this._backupExportDialog = null;
if (this._supportDialog?.preview?.token) {
void this._editorRuntime?._discardSupportPreview(this._supportDialog.preview.token);
}
this._supportDialog = null;
this._settingsDialog = null;
this._deviceInbox = null;
this._deviceInboxReturn = null;
@@ -8959,7 +8967,7 @@ export class HouseplanCard extends LitElement {
|| this._openingDialog || this._physicalDialog || this._openingInfo
|| this._decorTextDialog || this._decorShapeDialog || this._backdropDialog
|| this._decorEraseConfirm || this._spaceDialog || this._markerDialog || this._deviceInbox
|| this._infoCard || this._rulesDialog || this._settingsDialog
|| this._infoCard || this._rulesDialog || this._settingsDialog || this._supportDialog
|| this._alignDialog || this._importDialog || this._kioskDialog
|| this._backupExportDialog || this._backupImportDialog
|| this._wallDialog);
@@ -10731,6 +10739,16 @@ export class HouseplanCard extends LitElement {
return this._editorRuntime._openSettingsDialog();
}
private _openSupportDialog = (): void => {
if (!this._editorRuntime) {
void this._ensureEditorRuntime().then((ready) => {
if (ready) this._openSupportDialog();
});
return;
}
this._editorRuntime._openSupportDialog();
};
/**
* Preview whole-plan maintenance. Nothing is written here: the pure run
* produces both the report and the exact config/layout pair to commit.
@@ -11139,6 +11157,10 @@ export class HouseplanCard extends LitElement {
return this._editorRuntimeOrThrow()._renderSettingsDialog();
}
private _renderSupportDialog(): TemplateResult {
return this._editorRuntimeOrThrow()._renderSupportDialog();
}
// ================= ICON RULES EDITOR =================
private _openRulesDialog = (): void => {
@@ -11258,7 +11280,7 @@ export class HouseplanCard extends LitElement {
|| this._decorEraseConfirm
|| (this._spaceDialog && !this._spaceDialogUsesOnboardingRuntime(this._spaceDialog.mode))
|| this._deviceInbox
|| this._markerDialog || this._rulesDialog || this._settingsDialog
|| this._markerDialog || this._rulesDialog || this._settingsDialog || this._supportDialog
|| this._alignDialog || this._backupExportDialog || this._backupImportDialog
|| this._kioskDialog || this._vacFit || this._vacCalConfirm);
if (onboardingRuntimeRequested && !this._onboardingRuntime) {
@@ -11473,6 +11495,10 @@ export class HouseplanCard extends LitElement {
${this._norm && this._canEdit
? html`<button class="btn" @click=${this._openSettingsDialog} title=${this._t('title.general_settings')}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
</button>
<button class="btn support-button" @click=${this._openSupportDialog}
title=${this._t('support.title')} aria-label=${this._t('support.title')}>
<ha-icon icon="mdi:help-circle-outline"></ha-icon>
</button>`
: nothing}
</div>
@@ -11897,6 +11923,7 @@ export class HouseplanCard extends LitElement {
${this._infoCard ? this._renderInfoCard() : nothing}
${this._rulesDialog ? this._editorRuntime ? this._renderRulesDialog() : nothing : nothing}
${this._settingsDialog ? this._editorRuntime ? this._renderSettingsDialog() : nothing : nothing}
${this._supportDialog ? this._editorRuntime ? this._renderSupportDialog() : nothing : nothing}
${this._alignDialog ? this._editorRuntime ? this._renderAlignDialog() : nothing : nothing}
${this._backupExportDialog ? this._editorRuntime ? this._renderBackupExportDialog() : nothing : nothing}
${this._backupImportDialog ? this._editorRuntime ? this._renderBackupImportDialog() : nothing : nothing}
+454 -6
View File
@@ -245,6 +245,16 @@ import {
} from './coordinate-canonicalization';
import { enqueueSerializedWrite } from './serialized-write-queue';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import {
newSupportDialogState,
supportCanSubmit,
supportDraftError,
supportErrorCode,
supportRuntimeFacts,
supportSizeKiB,
type SupportDialogState,
type SupportPreview,
} from './support-feedback';
import { classifyPlanFile, encodePlanFile, renderBackdropGuard } from './backdrop-pick';
import { CommandStack } from './command-stack';
import type { DeviceLayout, DevicePositionState } from './device-position-history';
@@ -1093,6 +1103,7 @@ export interface HouseplanEditorHostPort {
_serverStorage: boolean;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; };
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; busy: boolean; } | null;
_supportDialog: SupportDialogState | null;
_showAll: boolean;
_showHidden: boolean;
_showToast: (msg: string) => void;
@@ -1177,6 +1188,7 @@ export class HouseplanEditorRuntime {
private _junctionBaselineCache = new WeakMap<object, {
spaceId: string; fingerprint: string; violations: JunctionLimitViolation[];
}>();
private _supportExpiryTimer?: number;
public constructor(public readonly host: HouseplanEditorHostPort) {
host._editorSecondary = new EditorSecondaryController({
@@ -9062,6 +9074,448 @@ public _openSettingsDialog = (): void => {
};
};
public _openSupportDialog = (): void => {
if (!this.host._norm || !this.host._canEdit) return;
clearTimeout(this._supportExpiryTimer);
this._supportExpiryTimer = undefined;
this.host._supportDialog = newSupportDialogState();
};
private _supportPatch(
draftId: string,
patch: Partial<SupportDialogState>,
): SupportDialogState | null {
const current = this.host._supportDialog;
if (!current || current.draftId !== draftId) return null;
const next = { ...current, ...patch };
this.host._supportDialog = next;
return next;
}
private async _focusSupport(selector: string): Promise<void> {
await this.host.updateComplete;
this.host.renderRoot.querySelector<HTMLElement>(selector)?.focus({ preventScroll: true });
}
private _updateSupportDraft(field: 'contact' | 'message', value: string): void {
const current = this.host._supportDialog;
if (!current || current.status === 'building' || current.status === 'sending'
|| current.status === 'success') return;
const candidate: SupportDialogState = { ...current, [field]: value };
// Validate text independently from the optional preview. Editing must clear
// stale transport errors, while an expired prepared package remains explicit.
const textError = supportDraftError({ ...candidate, attach: false, preview: null });
const showRequired = textError === 'message_required'
&& current.errorCode === 'validation.message_required';
const immediateError = textError === 'message_too_long' || textError === 'contact_too_long';
if (showRequired || immediateError) {
this._supportPatch(current.draftId, {
[field]: value,
status: 'error',
errorCode: `validation.${textError}`,
});
return;
}
if (candidate.attach && candidate.preview && candidate.preview.expiresAt <= Date.now()) {
this._supportPatch(current.draftId, {
[field]: value,
status: 'error',
errorCode: 'support_preview_expired',
});
return;
}
this._supportPatch(current.draftId, {
[field]: value,
status: candidate.attach && candidate.preview ? 'ready' : 'idle',
errorCode: '',
});
}
public async _discardSupportPreview(token: string): Promise<void> {
if (!token) return;
try {
await this.host.hass.callWS({ type: 'houseplan/support/preview/discard', token });
} catch {
// Cleanup is best-effort; backend TTL is the final privacy guard.
}
}
public async _closeSupportDialog(): Promise<void> {
const dialog = this.host._supportDialog;
if (!dialog) return;
if (dialog.status === 'building' || dialog.status === 'sending') {
const accepted = await this.host._confirmDanger({
key: 'close-support-busy',
kind: 'warning',
title: this.host._t('support.close_busy_title'),
message: this.host._t('support.close_busy_body'),
confirmLabel: this.host._t('btn.close'),
cancelLabel: this.host._t('btn.cancel'),
});
if (!accepted) {
await this.host.updateComplete;
this.host.renderRoot.querySelector<HpDialog>('#support-dialog')?.rejectClose();
return;
}
}
clearTimeout(this._supportExpiryTimer);
this._supportExpiryTimer = undefined;
this.host._supportDialog = null;
if (dialog.preview?.token && dialog.status !== 'success') {
void this._discardSupportPreview(dialog.preview.token);
}
}
private _scheduleSupportExpiry(draftId: string, preview: SupportPreview): void {
clearTimeout(this._supportExpiryTimer);
const delay = Math.max(0, preview.expiresAt - Date.now());
this._supportExpiryTimer = window.setTimeout(() => {
const current = this.host._supportDialog;
if (!current || current.draftId !== draftId || current.preview?.token !== preview.token
|| !current.attach || current.status === 'success' || current.status === 'sending') return;
this._supportPatch(draftId, {
status: 'error',
errorCode: 'support_preview_expired',
});
}, Math.min(delay + 20, 2_147_483_647));
}
private _supportFacts(): ReturnType<typeof supportRuntimeFacts> {
const registry = this.host._haRegistry;
return supportRuntimeFacts({
userAgent: globalThis.navigator?.userAgent || '',
language: langOf(this.host.hass, this.host._config?.language),
registryAccess: registry.access,
registryLastSuccess: registry.lastSuccess,
});
}
private async _buildSupportPreview(draftId: string): Promise<void> {
const current = this.host._supportDialog;
if (!current || current.draftId !== draftId || !current.attach
|| this.host._haIntegrationVersion !== CARD_VERSION) return;
this._supportPatch(draftId, { status: 'building', errorCode: '' });
try {
const response: unknown = await this.host.hass.callWS({
type: 'houseplan/support/preview',
card_version: CARD_VERSION,
...this._supportFacts(),
draft_id: draftId,
});
const payload = response && typeof response === 'object'
? response as Partial<Record<
'text' | 'size' | 'expires_in' | 'spaces' | 'token' | 'sha256' | 'version' | 'format',
unknown
>> : {};
const now = Date.now();
const text = typeof payload.text === 'string' ? payload.text : '';
const size = Number(payload.size);
const expiresIn = Number(payload.expires_in);
const spaces = Number(payload.spaces);
const token = String(payload.token || '');
const sha256 = String(payload.sha256 || '');
const version = Number(payload.version);
const format = String(payload.format || '');
const byteSize = new TextEncoder().encode(text).byteLength;
if (!/^[0-9a-f]{48}$/.test(token) || !/^[0-9a-f]{64}$/.test(sha256)
|| format !== 'houseplan-support-package' || version !== 1
|| !Number.isInteger(size) || size !== byteSize || !Number.isInteger(spaces) || spaces < 0
|| !Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 600 || !text.endsWith('\n')) {
throw { code: 'support_rejected' };
}
const preview: SupportPreview = {
token,
expiresAt: now + expiresIn * 1000,
size,
sha256,
spaces,
format,
version,
text,
preparedAt: now,
};
if (!this._supportPatch(draftId, {
status: 'ready',
preview,
errorCode: '',
rawOpen: false,
})) {
void this._discardSupportPreview(token);
return;
}
this._scheduleSupportExpiry(draftId, preview);
} catch (error: unknown) {
if (!this._supportPatch(draftId, {
status: 'error',
errorCode: supportErrorCode(error),
})) return;
void this._focusSupport('#support-error');
}
}
public async _setSupportAttachment(attach: boolean): Promise<void> {
const current = this.host._supportDialog;
if (!current || current.status === 'sending' || current.status === 'success') return;
const token = current.preview?.token || '';
if (!attach) {
clearTimeout(this._supportExpiryTimer);
this._supportExpiryTimer = undefined;
this._supportPatch(current.draftId, {
attach: false,
status: 'idle',
preview: null,
rawOpen: false,
errorCode: '',
});
if (token) void this._discardSupportPreview(token);
return;
}
this._supportPatch(current.draftId, { attach: true, errorCode: '' });
await this._buildSupportPreview(current.draftId);
}
public async _refreshSupportPreview(): Promise<void> {
const current = this.host._supportDialog;
if (!current || !current.attach || current.status === 'building' || current.status === 'sending') return;
await this._buildSupportPreview(current.draftId);
}
public _downloadSupportPreview(): void {
const preview = this.host._supportDialog?.preview;
if (!preview) return;
const blob = new Blob([preview.text], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `houseplan-support-${preview.token.slice(0, 12)}.json`;
anchor.style.display = 'none';
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
private async _copySupportText(text: string, successKey: I18nKey): Promise<void> {
try {
await navigator.clipboard.writeText(text);
this.host._showToast(this.host._t(successKey));
} catch {
this.host._showToast(this.host._t('support.copy_failed'));
}
}
public async _submitSupport(): Promise<void> {
const current = this.host._supportDialog;
if (!current || this.host._haIntegrationVersion !== CARD_VERSION) return;
const validation = supportDraftError(current);
if (validation) {
this._supportPatch(current.draftId, {
status: 'error',
errorCode: `validation.${validation}`,
});
void this._focusSupport('#support-message');
return;
}
if (!supportCanSubmit(current)) return;
this._supportPatch(current.draftId, { status: 'sending', errorCode: '' });
try {
const response: unknown = await this.host.hass.callWS({
type: 'houseplan/support/submit',
message: current.message.trim(),
contact: current.contact.trim(),
...(current.attach && current.preview ? { preview_token: current.preview.token } : {}),
idempotency_key: current.idempotencyKey,
});
const reportId = response && typeof response === 'object' && 'report_id' in response
? String((response as { report_id?: unknown }).report_id || '') : '';
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(reportId)) {
throw { code: 'support_unavailable' };
}
clearTimeout(this._supportExpiryTimer);
this._supportExpiryTimer = undefined;
if (!this._supportPatch(current.draftId, {
status: 'success',
reportId,
errorCode: '',
})) return;
void this._focusSupport('#support-receipt');
} catch (error: unknown) {
if (!this._supportPatch(current.draftId, {
status: 'error',
errorCode: supportErrorCode(error),
})) return;
void this._focusSupport('#support-error');
}
}
private _supportErrorText(state: SupportDialogState): string {
if (state.errorCode.startsWith('validation.')) {
const suffix = state.errorCode.slice('validation.'.length);
return this.host._t(`support.validation.${suffix}` as I18nKey);
}
return this.host._t(`support.error.${state.errorCode || 'support_unavailable'}` as I18nKey);
}
private _supportMessageKeydown(event: KeyboardEvent): void {
if (event.key !== 'Enter' || (!event.ctrlKey && !event.metaKey)) return;
event.preventDefault();
void this._submitSupport();
}
public _renderSupportDialog(): TemplateResult {
const state = this.host._supportDialog!;
const lang = langOf(this.host.hass, this.host._config?.language);
const guideUrl = lang === 'ru'
? 'https://github.com/Matysh/houseplan-card/blob/main/docs/USER-GUIDE.ru.md'
: 'https://github.com/Matysh/houseplan-card/blob/main/docs/USER-GUIDE.md';
const compatible = this.host._haIntegrationVersion === CARD_VERSION;
const busy = state.status === 'building' || state.status === 'sending';
const validation = supportDraftError(state);
const preparedMinutes = state.preview
? Math.max(0, Math.floor((Date.now() - state.preview.preparedAt) / 60_000)) : 0;
const manualRecovery = state.status === 'error'
&& !state.errorCode.startsWith('validation.');
const contactInvalid = state.errorCode === 'validation.contact_too_long';
const messageInvalid = state.errorCode === 'validation.message_required'
|| state.errorCode === 'validation.message_too_long';
return html`<hp-dialog id="support-dialog" .hass=${this.host.hass}
.title=${this.host._t('support.title')} icon="mdi:help-circle-outline" wide dismiss-on-scrim
@hp-close=${() => void this._closeSupportDialog()}>
<div class="body supportbody">
<section class="supportsection" aria-labelledby="support-about-heading">
<h3 id="support-about-heading">${this.host._t('support.about_group')}</h3>
<div class="aboutver">${this.host._t('gs.about_version', { v: CARD_VERSION })}</div>
<div class="supportlinks">
<a class="aboutlink" href="https://github.com/Matysh/houseplan-card" target="_blank" rel="noopener noreferrer">
<ha-icon icon="mdi:github"></ha-icon>${this.host._t('gs.about_github')}</a>
<a class="aboutlink" href="https://t.me/ha_houseplan" target="_blank" rel="noopener noreferrer">
<ha-icon icon="mdi:send"></ha-icon>${this.host._t('gs.about_telegram')}</a>
</div>
</section>
<section class="supportsection" aria-labelledby="support-docs-heading">
<h3 id="support-docs-heading">${this.host._t('support.guide_group')}</h3>
<a class="aboutlink" href=${guideUrl} target="_blank" rel="noopener noreferrer">
<ha-icon icon="mdi:book-open-page-variant-outline"></ha-icon>${this.host._t('support.guide')}</a>
</section>
${compatible ? html`
<section class="supportsection supportform" aria-labelledby="support-form-heading">
<h3 id="support-form-heading">${this.host._t('support.form_group')}</h3>
<label for="support-contact">${this.host._t('support.contact')}</label>
<input id="support-contact" class="namein" type="text" autocomplete="off"
aria-invalid=${contactInvalid ? 'true' : 'false'}
aria-describedby=${contactInvalid ? 'support-error' : nothing}
.value=${state.contact} ?disabled=${busy || state.status === 'success'}
@input=${(event: Event) => this._updateSupportDraft(
'contact', (event.target as HTMLInputElement).value,
)} />
<label for="support-message">${this.host._t('support.message')}</label>
<textarea id="support-message" class="supportmessage" required
aria-invalid=${messageInvalid ? 'true' : 'false'}
aria-describedby=${messageInvalid ? 'support-error' : nothing}
.value=${state.message} ?disabled=${busy || state.status === 'success'}
@keydown=${(event: KeyboardEvent) => this._supportMessageKeydown(event)}
@input=${(event: Event) => this._updateSupportDraft(
'message', (event.target as HTMLTextAreaElement).value,
)}></textarea>
<label class="srcrow supportattach">
<input type="checkbox" .checked=${state.attach} aria-describedby="support-attach-hint"
?disabled=${busy || state.status === 'success'}
@change=${(event: Event) => void this._setSupportAttachment(
(event.target as HTMLInputElement).checked,
)} />
<span>${this.host._t('support.attach')}</span>
</label>
<p id="support-attach-hint" class="rhint">${this.host._t('support.attach_hint')}</p>
${state.attach ? html`<p class="supportwarning" role="note">
<ha-icon icon="mdi:shield-alert-outline"></ha-icon>
<span>${this.host._t('support.geometry_warning')}</span>
</p>` : nothing}
${state.status === 'building' ? html`<div class="supportstatus" role="status" aria-live="polite">
<ha-icon icon="mdi:progress-clock"></ha-icon>${this.host._t('support.building')}
</div>` : nothing}
${state.preview ? html`
<div class="supportpreview">
<div class="supportsummary">${this.host._t('support.preview_summary', {
version: state.preview.version,
spaces: state.preview.spaces,
size: supportSizeKiB(state.preview.size),
})}</div>
<div class="supporthash"><span>SHA-256</span><code>${state.preview.sha256}</code></div>
<div class="rhint">${this.host._t('support.prepared', { n: preparedMinutes })}</div>
<details ?open=${state.rawOpen}
@toggle=${(event: Event) => this._supportPatch(state.draftId, {
rawOpen: (event.currentTarget as HTMLDetailsElement).open,
})}>
<summary>${this.host._t('support.show_data')}</summary>
${state.rawOpen ? html`<textarea class="supportraw" readonly
.value=${state.preview.text}></textarea>` : nothing}
</details>
<div class="supportactions">
<button type="button" class="btn ghost" @click=${() => this._downloadSupportPreview()}>
<ha-icon icon="mdi:download"></ha-icon>${this.host._t('support.download')}
</button>
<button type="button" class="btn ghost" @click=${() => void this._refreshSupportPreview()}
?disabled=${busy || state.status === 'success'}>
<ha-icon icon="mdi:refresh"></ha-icon>${this.host._t('support.refresh')}
</button>
</div>
</div>` : nothing}
<p class="rhint supportprivacy">${this.host._t('support.privacy')}</p>
${state.status === 'error' ? html`
<div id="support-error" class="supporterror" role="alert" tabindex="-1">
<strong>${this.host._t('support.error_title')}</strong>
<span>${this._supportErrorText(state)}</span>
</div>` : nothing}
${state.status === 'error' && state.attach && !state.preview ? html`
<div class="supportactions">
<button type="button" class="btn ghost"
@click=${() => void this._refreshSupportPreview()}>
<ha-icon icon="mdi:refresh"></ha-icon>${this.host._t('support.refresh')}
</button>
</div>` : nothing}
${manualRecovery ? html`
<div class="supportmanual">
<strong>${this.host._t('support.manual_recovery')}</strong>
<div class="supportactions">
<button type="button" class="btn ghost" @click=${() => void this._copySupportText(
state.message, 'support.message_copied',
)}>${this.host._t('support.copy_message')}</button>
${state.preview ? html`<button type="button" class="btn ghost"
@click=${() => this._downloadSupportPreview()}>${this.host._t('support.download')}</button>` : nothing}
<a class="aboutlink" href="https://t.me/ha_houseplan" target="_blank" rel="noopener noreferrer">Telegram</a>
<a class="aboutlink" href="https://github.com/Matysh/houseplan-card/issues" target="_blank" rel="noopener noreferrer">GitHub</a>
</div>
</div>` : nothing}
${state.status === 'success' ? html`
<div id="support-receipt" class="supportsuccess" role="status" aria-live="polite" tabindex="-1">
<strong>${this.host._t('support.success', { id: state.reportId })}</strong>
<button type="button" class="btn ghost" @click=${() => void this._copySupportText(
state.reportId, 'support.id_copied',
)}>${this.host._t('support.copy_id')}</button>
</div>` : nothing}
</section>` : html`
<div class="supportupdate" role="status">
<ha-icon icon="mdi:update"></ha-icon>
<span>${this.host._t('support.update_required')}</span>
</div>`}
</div>
<div class="row supportfooter" slot="footer">
<button type="button" class="btn ghost" @click=${() => void this._closeSupportDialog()}>
${this.host._t('btn.close')}
</button>
<span class="spacer"></span>
${compatible && state.status !== 'success' ? html`
<button type="button" class="btn on" @click=${() => void this._submitSupport()}
?disabled=${!supportCanSubmit(state)}>
<ha-icon icon="mdi:send"></ha-icon>${state.status === 'sending'
? this.host._t('support.sending')
: state.status === 'error' && !validation
? this.host._t('support.retry') : this.host._t('support.send')}
</button>` : nothing}
</div>
</hp-dialog>`;
}
public _preflightDiagnostics(
preflight: OptimizeGeometryPreflightResult,
candidate: ServerConfig | null,
@@ -10198,12 +10652,6 @@ public _renderSettingsDialog(): TemplateResult {
<ha-icon icon="mdi:undo-variant"></ha-icon>${this.host._t('gs.optimize_undo')}
</button>
</div>` : nothing}
<label class="dispsection">${this.host._t('gs.about_group')}</label>
<div class="aboutver">${this.host._t('gs.about_version', { v: CARD_VERSION })}</div>
<a class="aboutlink" href="https://github.com/Matysh/houseplan-card" target="_blank" rel="noopener">
<ha-icon icon="mdi:github"></ha-icon>${this.host._t('gs.about_github')}</a>
<a class="aboutlink" href="https://t.me/ha_houseplan" target="_blank" rel="noopener">
<ha-icon icon="mdi:send"></ha-icon>${this.host._t('gs.about_telegram')}</a>
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() =>
+9
View File
@@ -361,6 +361,15 @@ export class HpDialog extends LitElement {
this.dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
};
/**
* Let an owner reject an asynchronous close request (for example while a
* save/send is in flight) and keep the same modal usable afterwards.
*/
public rejectClose(): void {
this._closing = false;
this.requestUpdate();
}
private _pruneOverlays(): void {
this._overlays = this._overlays.filter((entry) => entry.owner.isConnected);
}
+49 -1
View File
@@ -847,10 +847,52 @@
"gs.north_clear": "Löschen",
"gs.north_letter": "N",
"gs.sun_rays": "Sonnenlicht durch Fenster",
"gs.about_group": "Über",
"gs.about_version": "House Plan Card v{v}",
"gs.about_github": "GitHub · Dokumentation & Probleme",
"gs.about_telegram": "Telegram-Chat",
"support.title": "Hilfe & Feedback",
"support.about_group": "Über die Karte",
"support.guide_group": "Dokumentation",
"support.guide": "Benutzerhandbuch",
"support.form_group": "Bericht oder Vorschlag senden",
"support.contact": "Kontakt (E-Mail/tg/WhatsApp), optional.",
"support.message": "Nachricht",
"support.attach": "Anonymisierte Informationen aus dem Plan anhängen",
"support.attach_hint": "Enthält nur erlaubte Plangeometrie, Einstellungen und begrenzte Laufzeitdaten. Namen, Home-Assistant-IDs, Live-Zustände, URLs, Dateien, Nachricht und Kontakt werden ausgeschlossen.",
"support.geometry_warning": "Das Paket enthält keine Namen oder Home-Assistant-IDs, aber die exakte Geometrie und die Maße Ihres Hauses.",
"support.close_busy_title": "Aktuellen Vorgang abbrechen?",
"support.close_busy_body": "House Plan bereitet den Bericht vor oder sendet ihn. Beim Schließen wird nicht weiter auf das Ergebnis gewartet.",
"support.building": "Anonymisierte Daten werden vorbereitet…",
"support.preview_summary": "Paket v{version} · {spaces} Bereiche · {size} KiB",
"support.prepared": "Schnappschuss vor {n} Min. erstellt",
"support.show_data": "Daten anzeigen",
"support.download": "JSON herunterladen",
"support.refresh": "Schnappschuss aktualisieren",
"support.privacy": "Beim Senden verlassen Ihre Nachricht, der optionale Kontakt und, falls ausgewählt, die exakte Geometrie Ihren Home Assistant über das House-Plan-Projektrelay und sind für die Maintainer sichtbar. Der Projektserver speichert den Bericht bis zu 30 Tage. Die Netzwerkinfrastruktur sieht die Adresse Ihres HA-Servers, House Plan speichert sie jedoch nicht im Rohformat; Limit- und Idempotenzdaten bleiben bis zu 24 Stunden erhalten.",
"support.error_title": "Der Bericht wurde nicht gesendet",
"support.manual_recovery": "Nachricht und Anhang können gespeichert und manuell weitergegeben werden:",
"support.copy_message": "Nachricht kopieren",
"support.success": "Bericht gesendet: {id}. Bewahren Sie diese Nummer für den Support auf.",
"support.copy_id": "ID kopieren",
"support.sending": "Wird gesendet…",
"support.retry": "Erneut versuchen",
"support.send": "Senden",
"support.update_required": "Aktualisieren Sie House-Plan-Karte und Integration auf dieselbe Version, um Berichte zu senden.",
"support.message_copied": "Nachricht kopiert",
"support.id_copied": "Berichts-ID kopiert",
"support.copy_failed": "Kein Zugriff auf die Zwischenablage. Markieren und kopieren Sie den Text manuell.",
"support.validation.message_required": "Geben Sie eine Nachricht ein.",
"support.validation.message_too_long": "Die Nachricht darf höchstens 10.000 Zeichen enthalten.",
"support.validation.contact_too_long": "Der Kontakt darf höchstens 320 Zeichen enthalten.",
"support.validation.preview_missing": "Warten Sie auf die Vorschau oder deaktivieren Sie den Anhang.",
"support.validation.preview_expired": "Die Anhangsvorschau ist abgelaufen. Aktualisieren Sie den Schnappschuss.",
"support.error.support_invalid_message": "Prüfen Sie die Nachricht und versuchen Sie es erneut.",
"support.error.support_preview_expired": "Die Anhangsvorschau ist abgelaufen oder nicht mehr verfügbar. Aktualisieren Sie den Schnappschuss.",
"support.error.support_package_too_large": "Das anonymisierte Paket überschreitet das Limit von 8 MiB.",
"support.error.support_rate_limited": "Zu viele Berichte oder Vorschauen wurden angefordert. Versuchen Sie es später erneut.",
"support.error.support_unavailable": "Der private Supportdienst ist vorübergehend nicht verfügbar. Der Entwurf bleibt in diesem Fenster erhalten.",
"support.error.support_rejected": "Der Bericht hat die Prüfung des Supportdienstes nicht bestanden.",
"support.error.unauthorized": "Sie dürfen diesen Bericht nicht senden.",
"space.bg_color": "Hintergrund rund um den Plan",
"space.bg_inherit": "Allgemein erben",
"space.bg_inherited": "erbt allgemeine Einstellungen",
@@ -1087,6 +1129,12 @@
"backup.export_done": "Backup heruntergeladen",
"backup.reading": "Überprüfe das Backup…",
"backup.revalidated": "Der Plan wurde nach dieser Vorschau geändert. Die Zusammenfassung wurde aktualisiert; überprüfen Sie sie und bestätigen Sie erneut.",
"backup.error.support_invalid_message": "Prüfen Sie die Supportnachricht und versuchen Sie es erneut.",
"backup.error.support_package_too_large": "Das anonymisierte Supportpaket überschreitet das Limit von 8 MiB.",
"backup.error.support_preview_expired": "Die Vorschau des Supportanhangs ist abgelaufen oder nicht mehr verfügbar.",
"backup.error.support_rate_limited": "Zu viele Supportberichte oder Vorschauen wurden angefordert. Versuchen Sie es später erneut.",
"backup.error.support_rejected": "Der Bericht hat die Prüfung des Supportdienstes nicht bestanden.",
"backup.error.support_unavailable": "Der private Supportdienst ist vorübergehend nicht verfügbar.",
"backup.error.unauthorized": "Sie haben keine Berechtigung, diesen Plan zu exportieren oder zu importieren.",
"backup.error.not_ready": "Der Hausplan ist noch nicht fertig. Versuchen Sie es in einem Moment erneut.",
"backup.error.too_large": "Das Backup überschreitet das 8-MiB-Limit.",
+49 -1
View File
@@ -847,10 +847,52 @@
"gs.north_clear": "Clear",
"gs.north_letter": "N",
"gs.sun_rays": "Sunlight through windows",
"gs.about_group": "About",
"gs.about_version": "Houseplan Card v{v}",
"gs.about_github": "GitHub · docs & issues",
"gs.about_telegram": "Telegram chat",
"support.title": "Help & feedback",
"support.about_group": "About the card",
"support.guide_group": "Documentation",
"support.guide": "User guide",
"support.form_group": "Send a report or suggestion",
"support.contact": "Contact details (email/tg/WhatsApp), optional.",
"support.message": "Message",
"support.attach": "Attach anonymized information from your plan",
"support.attach_hint": "Includes only allowed plan geometry, settings and bounded runtime facts. Excludes names, Home Assistant IDs, live states, URLs, files, and your message/contact.",
"support.geometry_warning": "The package contains no names or Home Assistant IDs, but it does contain the exact geometry and dimensions of your home.",
"support.close_busy_title": "Stop the current operation?",
"support.close_busy_body": "House Plan is preparing or sending the report. Closing now stops waiting for the result.",
"support.building": "Preparing anonymized data…",
"support.preview_summary": "Package v{version} · {spaces} spaces · {size} KiB",
"support.prepared": "Snapshot prepared {n} min ago",
"support.show_data": "Show data",
"support.download": "Download JSON",
"support.refresh": "Refresh snapshot",
"support.privacy": "When you send, your message, optional contact and, if selected, exact geometry leave your Home Assistant through the House Plan project relay and are visible to maintainers. The project node keeps the report for up to 30 days. Network infrastructure sees your HA server address, but House Plan does not retain it raw; rate-limit and idempotency metadata is kept for up to 24 hours.",
"support.error_title": "The report was not sent",
"support.manual_recovery": "You can keep the message and attachment and continue manually:",
"support.copy_message": "Copy message",
"support.success": "Report sent: {id}. Save this number when contacting support.",
"support.copy_id": "Copy ID",
"support.sending": "Sending…",
"support.retry": "Retry",
"support.send": "Send",
"support.update_required": "Update the House Plan card and integration to the same version to send reports.",
"support.message_copied": "Message copied",
"support.id_copied": "Report ID copied",
"support.copy_failed": "Could not access the clipboard. Select and copy the text manually.",
"support.validation.message_required": "Enter a message.",
"support.validation.message_too_long": "The message must contain no more than 10,000 characters.",
"support.validation.contact_too_long": "Contact details must contain no more than 320 characters.",
"support.validation.preview_missing": "Wait for the attachment preview or turn the attachment off.",
"support.validation.preview_expired": "The attachment preview expired. Refresh the snapshot.",
"support.error.support_invalid_message": "Check the message and try again.",
"support.error.support_preview_expired": "The attachment preview expired or is no longer available. Refresh the snapshot.",
"support.error.support_package_too_large": "The anonymized package exceeds the 8 MiB limit.",
"support.error.support_rate_limited": "Too many reports or previews were requested. Try again later.",
"support.error.support_unavailable": "The private support service is temporarily unavailable. Your draft remains here.",
"support.error.support_rejected": "The report did not pass the support service validation.",
"support.error.unauthorized": "You do not have permission to send this report.",
"space.bg_color": "Background around the plan",
"space.bg_inherit": "Inherit general",
"space.bg_inherited": "inherits general settings",
@@ -1087,6 +1129,12 @@
"backup.export_done": "Backup downloaded",
"backup.reading": "Checking the backup…",
"backup.revalidated": "The plan changed after this preview. The summary was refreshed; review it and confirm again.",
"backup.error.support_invalid_message": "Check the support message and try again.",
"backup.error.support_package_too_large": "The anonymized support package exceeds the 8 MiB limit.",
"backup.error.support_preview_expired": "The support attachment preview expired or is no longer available.",
"backup.error.support_rate_limited": "Too many support reports or previews were requested. Try again later.",
"backup.error.support_rejected": "The report did not pass support service validation.",
"backup.error.support_unavailable": "The private support service is temporarily unavailable.",
"backup.error.unauthorized": "You do not have permission to export or import this plan.",
"backup.error.not_ready": "House Plan is not ready yet. Try again in a moment.",
"backup.error.too_large": "The backup exceeds the 8 MiB limit.",
+49 -1
View File
@@ -847,10 +847,52 @@
"gs.north_clear": "Effacer",
"gs.north_letter": "N",
"gs.sun_rays": "Lumière du soleil à travers les fenêtres",
"gs.about_group": "À propos",
"gs.about_version": "Houseplan Card v{v}",
"gs.about_github": "GitHub · documentation et problèmes",
"gs.about_telegram": "Discussion Telegram",
"support.title": "Aide et commentaires",
"support.about_group": "À propos de la carte",
"support.guide_group": "Documentation d’aide",
"support.guide": "Guide utilisateur",
"support.form_group": "Envoyer un rapport ou une suggestion",
"support.contact": "Contact (e-mail/tg/WhatsApp), facultatif.",
"support.message": "Votre message",
"support.attach": "Joindre des informations anonymisées de votre plan",
"support.attach_hint": "Inclut uniquement la géométrie, les réglages autorisés et des données d’exécution limitées. Exclut les noms, identifiants Home Assistant, états en direct, URL, fichiers, message et contact.",
"support.geometry_warning": "Le paquet ne contient ni noms ni identifiants Home Assistant, mais il contient la géométrie et les dimensions exactes de votre logement.",
"support.close_busy_title": "Interrompre l’opération en cours ?",
"support.close_busy_body": "House Plan prépare ou envoie le rapport. Fermer maintenant arrête l’attente du résultat.",
"support.building": "Préparation des données anonymisées…",
"support.preview_summary": "Paquet v{version} · {spaces} espaces · {size} Kio",
"support.prepared": "Instantané préparé il y a {n} min",
"support.show_data": "Afficher les données",
"support.download": "Télécharger le JSON",
"support.refresh": "Actualiser l’instantané",
"support.privacy": "Lors de l’envoi, votre message, le contact facultatif et, si elle est sélectionnée, la géométrie exacte quittent votre Home Assistant via le relais du projet House Plan et sont visibles par les mainteneurs. Le serveur du projet conserve le rapport jusqu’à 30 jours. L’infrastructure réseau voit l’adresse de votre serveur HA, mais House Plan ne la conserve pas en clair ; les métadonnées de limitation et d’idempotence sont gardées jusqu’à 24 heures.",
"support.error_title": "Le rapport n’a pas été envoyé",
"support.manual_recovery": "Vous pouvez conserver le message et la pièce jointe puis continuer manuellement :",
"support.copy_message": "Copier le message",
"support.success": "Rapport envoyé : {id}. Conservez ce numéro pour contacter l’assistance.",
"support.copy_id": "Copier l’ID",
"support.sending": "Envoi…",
"support.retry": "Réessayer",
"support.send": "Envoyer",
"support.update_required": "Mettez à jour la carte et l’intégration House Plan vers la même version pour envoyer des rapports.",
"support.message_copied": "Message copié",
"support.id_copied": "ID du rapport copié",
"support.copy_failed": "Impossible d’accéder au presse-papiers. Sélectionnez et copiez le texte manuellement.",
"support.validation.message_required": "Saisissez un message.",
"support.validation.message_too_long": "Le message ne doit pas dépasser 10 000 caractères.",
"support.validation.contact_too_long": "Le contact ne doit pas dépasser 320 caractères.",
"support.validation.preview_missing": "Attendez l’aperçu de la pièce jointe ou désactivez-la.",
"support.validation.preview_expired": "L’aperçu de la pièce jointe a expiré. Actualisez l’instantané.",
"support.error.support_invalid_message": "Vérifiez le message et réessayez.",
"support.error.support_preview_expired": "L’aperçu de la pièce jointe a expiré ou n’est plus disponible. Actualisez l’instantané.",
"support.error.support_package_too_large": "Le paquet anonymisé dépasse la limite de 8 Mio.",
"support.error.support_rate_limited": "Trop de rapports ou d’aperçus ont été demandés. Réessayez plus tard.",
"support.error.support_unavailable": "Le service d’assistance privé est temporairement indisponible. Le brouillon reste dans cette fenêtre.",
"support.error.support_rejected": "Le rapport n’a pas passé la validation du service d’assistance.",
"support.error.unauthorized": "Vous n’êtes pas autorisé à envoyer ce rapport.",
"space.bg_color": "Arrière-plan autour du plan",
"space.bg_inherit": "Hériter des paramètres généraux",
"space.bg_inherited": "hérite des paramètres généraux",
@@ -1087,6 +1129,12 @@
"backup.export_done": "Sauvegarde téléchargée",
"backup.reading": "Vérification de la sauvegarde…",
"backup.revalidated": "Le plan a changé depuis cet aperçu. Le résumé a été actualisé ; vérifiez-le et confirmez de nouveau.",
"backup.error.support_invalid_message": "Vérifiez le message d’assistance et réessayez.",
"backup.error.support_package_too_large": "Le paquet d’assistance anonymisé dépasse la limite de 8 Mio.",
"backup.error.support_preview_expired": "L’aperçu de la pièce jointe d’assistance a expiré ou n’est plus disponible.",
"backup.error.support_rate_limited": "Trop de rapports ou d’aperçus d’assistance ont été demandés. Réessayez plus tard.",
"backup.error.support_rejected": "Le rapport n’a pas passé la validation du service d’assistance.",
"backup.error.support_unavailable": "Le service d’assistance privé est temporairement indisponible.",
"backup.error.unauthorized": "Vous n’avez pas l’autorisation d’exporter ou d’importer ce plan.",
"backup.error.not_ready": "House Plan n’est pas encore prêt. Réessayez dans un instant.",
"backup.error.too_large": "La sauvegarde dépasse la limite de 8 Mio.",
+49 -1
View File
@@ -847,10 +847,52 @@
"gs.north_clear": "Сбросить",
"gs.north_letter": "С",
"gs.sun_rays": "Солнце в окнах",
"gs.about_group": "О карточке",
"gs.about_version": "Houseplan Card v{v}",
"gs.about_github": "GitHub · документация и issues",
"gs.about_telegram": "Чат в Telegram",
"support.title": "Помощь и обратная связь",
"support.about_group": "О карточке",
"support.guide_group": "Документация",
"support.guide": "Руководство пользователя",
"support.form_group": "Отправить репорт или предложение",
"support.contact": "Контакт для связи (email/tg/WhatsApp), необязательно.",
"support.message": "Сообщение",
"support.attach": "Прикрепить обезличенную информацию из вашего плана",
"support.attach_hint": "В пакет входят только разрешённые геометрия, настройки и ограниченные сведения о среде. Не входят имена, HA id, текущие состояния, URL, файлы, сообщение и контакт.",
"support.geometry_warning": "Пакет не содержит имён и HA id, но содержит точную геометрию и размеры дома.",
"support.close_busy_title": "Прервать текущую операцию?",
"support.close_busy_body": "House Plan подготавливает или отправляет репорт. При закрытии ожидание результата прекратится.",
"support.building": "Подготавливаем обезличенные данные…",
"support.preview_summary": "Пакет v{version} · пространств: {spaces} · {size} КиБ",
"support.prepared": "Снимок подготовлен {n} мин назад",
"support.show_data": "Показать данные",
"support.download": "Скачать JSON",
"support.refresh": "Обновить снимок",
"support.privacy": "При отправке сообщение, необязательный контакт и, если выбран пакет, точная геометрия покидают ваш Home Assistant через relay проекта House Plan и доступны мейнтейнерам. Узел проекта хранит репорт до 30 дней. Сетевая инфраструктура видит адрес сервера HA, но House Plan не сохраняет его в исходном виде; метаданные лимитов и идемпотентности хранятся до 24 часов.",
"support.error_title": "Репорт не отправлен",
"support.manual_recovery": "Сообщение и вложение можно сохранить и продолжить вручную:",
"support.copy_message": "Копировать сообщение",
"support.success": "Репорт отправлен: {id}. Сохраните номер для связи с поддержкой.",
"support.copy_id": "Копировать ID",
"support.sending": "Отправляем…",
"support.retry": "Повторить",
"support.send": "Отправить",
"support.update_required": "Обновите карточку и интеграцию House Plan до одной версии, чтобы отправлять репорты.",
"support.message_copied": "Сообщение скопировано",
"support.id_copied": "ID репорта скопирован",
"support.copy_failed": "Не удалось получить доступ к буферу обмена. Выделите и скопируйте текст вручную.",
"support.validation.message_required": "Введите сообщение.",
"support.validation.message_too_long": "Сообщение должно содержать не более 10 000 символов.",
"support.validation.contact_too_long": "Контакт должен содержать не более 320 символов.",
"support.validation.preview_missing": "Дождитесь предпросмотра вложения или отключите его.",
"support.validation.preview_expired": "Предпросмотр вложения истёк. Обновите снимок.",
"support.error.support_invalid_message": "Проверьте сообщение и повторите попытку.",
"support.error.support_preview_expired": "Предпросмотр вложения истёк или больше недоступен. Обновите снимок.",
"support.error.support_package_too_large": "Обезличенный пакет превышает лимит 8 МиБ.",
"support.error.support_rate_limited": "Запрошено слишком много репортов или предпросмотров. Повторите позже.",
"support.error.support_unavailable": "Закрытая служба поддержки временно недоступна. Черновик сохранён в этом окне.",
"support.error.support_rejected": "Репорт не прошёл проверку службы поддержки.",
"support.error.unauthorized": "У вас нет прав на отправку этого репорта.",
"space.bg_color": "Цвет фона вокруг плана",
"space.bg_inherit": "Наследовать общий",
"space.bg_inherited": "наследуется из общих настроек",
@@ -1087,6 +1129,12 @@
"backup.export_done": "Резервная копия скачана",
"backup.reading": "Проверяем резервную копию…",
"backup.revalidated": "После предпросмотра план изменился. Сводка обновлена — проверьте её и подтвердите ещё раз.",
"backup.error.support_invalid_message": "Проверьте сообщение для поддержки и повторите попытку.",
"backup.error.support_package_too_large": "Обезличенный пакет для поддержки превышает лимит 8 МиБ.",
"backup.error.support_preview_expired": "Предпросмотр вложения для поддержки истёк или больше недоступен.",
"backup.error.support_rate_limited": "Запрошено слишком много репортов или предпросмотров. Повторите позже.",
"backup.error.support_rejected": "Репорт не прошёл проверку службы поддержки.",
"backup.error.support_unavailable": "Закрытая служба поддержки временно недоступна.",
"backup.error.unauthorized": "У вас нет прав на экспорт или импорт этого плана.",
"backup.error.not_ready": "House Plan ещё не готов. Повторите попытку через несколько секунд.",
"backup.error.too_large": "Размер резервной копии превышает 8 МиБ.",
+115
View File
@@ -1143,6 +1143,121 @@ export const dialogsStyles = css`
}
:host([data-pointer-hover]) .aboutlink:hover { text-decoration: underline; }
.aboutlink ha-icon { --mdc-icon-size: 18px; line-height: 1; }
hp-dialog .supportbody {
min-width: 0;
overflow-x: hidden;
gap: var(--sp-5);
}
.supportsection {
display: grid;
min-width: 0;
gap: var(--sp-2);
}
.supportsection + .supportsection {
padding-top: var(--sp-4);
border-top: 1px solid var(--hp-line);
}
.supportsection h3 {
margin: 0;
color: var(--hp-txt);
font-size: var(--fs-m);
}
.supportlinks, .supportactions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
min-width: 0;
}
.supportform > label:not(.srcrow) {
margin-top: var(--sp-2);
color: var(--hp-muted);
font-size: var(--fs-s);
}
.supportmessage, .supportraw {
width: 100%;
min-width: 0;
box-sizing: border-box;
resize: vertical;
border: 1px solid var(--hp-line);
border-radius: var(--rad-s);
padding: var(--sp-3);
color: var(--hp-txt);
background: color-mix(in srgb, var(--card-background-color, var(--hp-bg)) 92%, var(--hp-txt));
font: inherit;
}
.supportmessage { min-height: 120px; }
.supportraw {
min-height: 220px;
margin-top: var(--sp-2);
resize: none;
white-space: pre;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
.supportattach {
min-width: 0;
align-items: flex-start;
margin: var(--sp-2) 0 0 !important;
}
.supportattach > span:first-of-type {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.supportwarning, .supportstatus, .supportupdate {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
min-width: 0;
margin: 0;
padding: var(--sp-3);
border-radius: var(--rad-s);
background: color-mix(in srgb, var(--hp-accent) 14%, transparent);
overflow-wrap: anywhere;
font-size: var(--fs-s);
line-height: 1.45;
}
.supportwarning ha-icon, .supportstatus ha-icon, .supportupdate ha-icon {
flex: none;
color: var(--hp-accent);
--mdc-icon-size: 20px;
}
.supportpreview, .supportmanual, .supporterror, .supportsuccess {
display: grid;
min-width: 0;
gap: var(--sp-2);
padding: var(--sp-3);
border: 1px solid var(--hp-line);
border-radius: var(--rad-s);
}
.supportsummary { font-weight: 600; overflow-wrap: anywhere; }
.supporthash {
display: grid;
min-width: 0;
gap: var(--sp-1);
color: var(--hp-muted);
font-size: var(--fs-s);
}
.supporthash code { overflow-wrap: anywhere; color: var(--hp-txt); }
.supportpreview details { min-width: 0; }
.supportpreview summary { cursor: pointer; color: var(--hp-accent); }
.supportprivacy { margin: 0 !important; line-height: 1.45; }
.supporterror {
background: color-mix(in srgb, var(--error-color, #db4437) 12%, transparent);
border-color: color-mix(in srgb, var(--error-color, #db4437) 45%, var(--hp-line));
}
.supportsuccess {
background: color-mix(in srgb, var(--success-color, #43a047) 12%, transparent);
border-color: color-mix(in srgb, var(--success-color, #43a047) 45%, var(--hp-line));
}
.supportfooter { flex-wrap: wrap; }
@media (max-width: 520px) {
hp-dialog .supportbody { padding-inline: var(--sp-4); }
hp-dialog .supportfooter { padding-inline: var(--sp-4); }
.supportactions .btn, .supportactions .aboutlink { max-width: 100%; }
}
hp-dialog .body {
padding: var(--sp-5) var(--sp-6);
display: flex;
+6
View File
@@ -185,6 +185,12 @@ export const planStyles = css`
justify-content: center;
padding: var(--sp-3);
}
.support-button {
min-width: 44px;
min-height: 44px;
justify-content: center;
padding: var(--sp-3);
}
/* docs/CANVAS.md §5: the plane has no edges, so you can pan until nothing
is on screen. One pointer home, one click back. */
.homearrow {
+151
View File
@@ -0,0 +1,151 @@
/** Pure state/validation helpers for the lazy Help & Feedback dialog (#43). */
export const SUPPORT_MESSAGE_LIMIT = 10_000;
export const SUPPORT_CONTACT_LIMIT = 320;
export interface SupportPreview {
token: string;
expiresAt: number;
size: number;
sha256: string;
spaces: number;
format: string;
version: number;
text: string;
preparedAt: number;
}
export type SupportDialogStatus =
| 'idle'
| 'building'
| 'ready'
| 'sending'
| 'success'
| 'error';
export interface SupportDialogState {
draftId: string;
idempotencyKey: string;
contact: string;
message: string;
attach: boolean;
status: SupportDialogStatus;
preview: SupportPreview | null;
rawOpen: boolean;
errorCode: string;
reportId: string;
}
export interface SupportRuntimeFacts {
browser_family: 'chromium' | 'firefox' | 'webkit' | 'unknown';
browser_major: number;
language: 'en' | 'ru' | 'de' | 'fr';
coarse_pointer: boolean;
hover_capable: boolean;
registry_access: 'full' | 'partial' | 'unavailable';
registry_age_bucket: 'fresh' | 'stale' | 'unknown';
}
function randomId(prefix: string): string {
const id = globalThis.crypto?.randomUUID?.()
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `${prefix}-${id}`;
}
export function newSupportDialogState(): SupportDialogState {
return {
draftId: randomId('draft'),
idempotencyKey: randomId('report'),
contact: '',
message: '',
attach: false,
status: 'idle',
preview: null,
rawOpen: false,
errorCode: '',
reportId: '',
};
}
export function codePointLength(value: string): number {
return [...value].length;
}
export type SupportDraftError =
| 'message_required'
| 'message_too_long'
| 'contact_too_long'
| 'preview_missing'
| 'preview_expired'
| null;
export function supportDraftError(state: SupportDialogState, now = Date.now()): SupportDraftError {
const message = state.message.trim();
if (!message) return 'message_required';
if (codePointLength(message) > SUPPORT_MESSAGE_LIMIT) return 'message_too_long';
if (codePointLength(state.contact.trim()) > SUPPORT_CONTACT_LIMIT) return 'contact_too_long';
if (state.attach && !state.preview) return 'preview_missing';
if (state.attach && state.preview!.expiresAt <= now) return 'preview_expired';
return null;
}
export function supportCanSubmit(state: SupportDialogState, now = Date.now()): boolean {
return state.status !== 'building'
&& state.status !== 'sending'
&& state.status !== 'success'
&& supportDraftError(state, now) === null;
}
function browserOf(userAgent: string): { family: SupportRuntimeFacts['browser_family']; major: number } {
const firefox = /Firefox\/(\d+)/i.exec(userAgent);
if (firefox) return { family: 'firefox', major: Number(firefox[1]) || 0 };
const chromium = /(?:Chrome|Chromium|Edg)\/(\d+)/i.exec(userAgent);
if (chromium) return { family: 'chromium', major: Number(chromium[1]) || 0 };
const webkit = /Version\/(\d+).+Safari\//i.exec(userAgent);
if (webkit) return { family: 'webkit', major: Number(webkit[1]) || 0 };
return { family: 'unknown', major: 0 };
}
export function supportRuntimeFacts(
options: {
userAgent?: string;
language: string;
coarsePointer?: boolean;
hoverCapable?: boolean;
registryAccess: 'pending' | 'full' | 'limited';
registryLastSuccess?: number;
now?: number;
},
): SupportRuntimeFacts {
const browser = browserOf(options.userAgent ?? globalThis.navigator?.userAgent ?? '');
const now = options.now ?? Date.now();
const last = Number(options.registryLastSuccess || 0);
const age = last <= 0 ? 'unknown' : now - last <= 10 * 60_000 ? 'fresh' : 'stale';
const languages: readonly string[] = ['en', 'ru', 'de', 'fr'];
const language = languages.includes(options.language)
? options.language as SupportRuntimeFacts['language'] : 'en';
const media = globalThis.matchMedia?.bind(globalThis);
return {
browser_family: browser.family,
browser_major: Math.max(0, Math.min(999, browser.major)),
language,
coarse_pointer: options.coarsePointer ?? !!media?.('(pointer: coarse)').matches,
hover_capable: options.hoverCapable ?? !!media?.('(hover: hover)').matches,
registry_access: options.registryAccess === 'full'
? 'full' : options.registryAccess === 'limited' ? 'partial' : 'unavailable',
registry_age_bucket: age,
};
}
export function supportErrorCode(value: unknown): string {
const code = value && typeof value === 'object' && 'code' in value
? String((value as { code?: unknown }).code || '') : '';
return new Set([
'support_invalid_message', 'support_preview_expired', 'support_package_too_large',
'support_rate_limited', 'support_unavailable', 'support_rejected', 'unauthorized',
]).has(code) ? code : 'support_unavailable';
}
export function supportSizeKiB(size: number): string {
return (Math.max(0, size) / 1024).toFixed(size < 10 * 1024 ? 1 : 0);
}
+2 -2
View File
@@ -64,10 +64,10 @@ test('all dangerous-action call sites use the shared confirmation contract', ()
const sharedCalls = source.match(/await this(?:\.host)?\._confirmDanger\s*\(\{/g) || [];
assert.equal(nativeCalls.length, 0, 'native browser confirmation must not return');
assert.equal(sharedCalls.length, 8, 'the reviewed inventory stays on the shared surface');
assert.equal(sharedCalls.length, 9, 'the reviewed inventory stays on the shared surface');
for (const key of [
'delete-draft', 'delete-draft-segment', 'remove-marker',
'delete-plan', 'delete-space', 'unlock',
'delete-plan', 'delete-space', 'unlock', 'close-support-busy',
]) {
assert.match(source, new RegExp(`key: '${key}'`));
}
+15 -1
View File
@@ -373,7 +373,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 53);
assert.equal(GOLDEN_MATRIX_VERSION, 54);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -967,3 +967,17 @@ test('issue 86 help goldens render browser zoom 200% in both themes', () => {
assert.equal(scenario.capture, 'page');
}
});
test('issue 43 support dialog has the six reviewed responsive states', () => {
const support = GOLDEN_SCENARIOS.filter((scenario) => scenario.dialog === 'support');
assert.equal(support.length, 6);
assert.deepEqual(new Set(support.map((scenario) => scenario.supportState)), new Set([
'empty', 'preview', 'validation', 'success', 'relay-error',
]));
assert.deepEqual(new Set(support.map((scenario) => scenario.theme)), new Set(['light', 'dark']));
assert.equal(support.some((scenario) => scenario.viewport.width === 320), true);
assert.equal(support.some((scenario) => scenario.viewport.width === 390), true);
assert.equal(support.some((scenario) => scenario.viewport.width === 768), true);
assert.equal(support.filter((scenario) => scenario.viewport.width >= 900).length, 3);
for (const scenario of support) assert.equal(scenario.capture, 'page', scenario.id);
});
+11
View File
@@ -42,6 +42,7 @@ test('paths classify into A/B/C/D with the generated tree winning over source',
assert.equal(classify('demo/golden/baselines/view.png'), 'D');
assert.equal(classify('dist/houseplan-card.js'), 'D');
assert.equal(classify('test/canvas.test.mjs'), 'B');
assert.equal(classify('scripts/support-relay/relay.py'), 'B');
assert.equal(classify('.github/workflows/validate.yml'), 'B');
assert.equal(classify('package-lock.json'), 'B');
assert.equal(classify('docs/SCOPE.md'), 'C');
@@ -712,6 +713,16 @@ test('an infrastructure range is recognised by the absence of class A files (#20
assert.equal(isInfrastructureRange([]), false);
});
test('a support-relay-only change stays on the reviewed class-B track (#43)', () => {
const relay = makeCommit({
sha: 'd'.repeat(40), subject: 'Harden private support relay',
body: 'Issue: #43\nUser-Visible: no',
files: ['scripts/support-relay/hp_relay/app.py', 'scripts/support-relay/tests/test_relay.py'],
});
assert.deepEqual([...relay.classes], ['B']);
assert.equal(isInfrastructureRange([relay]), true);
});
test('statusOptional waives the status label but keeps every other rule-8 refusal (#207)', () => {
// Метки инфраструктурного issue по #118: тип, приоритет, тема — без S*.
const infraIssue = () => ({
+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
codePointLength,
newSupportDialogState,
supportCanSubmit,
supportDraftError,
supportErrorCode,
supportRuntimeFacts,
} from '../test-build/support-feedback.js';
test('a fresh dialog never opts into exact plan geometry', () => {
const state = newSupportDialogState();
assert.equal(state.attach, false);
assert.equal(state.preview, null);
assert.equal(state.contact, '');
assert.equal(state.message, '');
assert.match(state.draftId, /^draft-/);
assert.match(state.idempotencyKey, /^report-/);
});
test('message/contact limits count Unicode code points rather than UTF-16 units', () => {
assert.equal(codePointLength('🙂'), 1);
const state = { ...newSupportDialogState(), message: 'ok', contact: '🙂'.repeat(321) };
assert.equal(supportDraftError(state), 'contact_too_long');
assert.equal(supportCanSubmit(state), false);
});
test('attachment submit requires the exact non-expired preview', () => {
const now = 10_000;
const base = { ...newSupportDialogState(), message: 'repro', attach: true };
assert.equal(supportDraftError(base, now), 'preview_missing');
const preview = {
token: 'a'.repeat(48), expiresAt: now + 1, size: 2, sha256: 'b'.repeat(64),
spaces: 1, format: 'houseplan-support-package', version: 1, text: '{}\n', preparedAt: now,
};
assert.equal(supportCanSubmit({ ...base, preview, status: 'ready' }, now), true);
assert.equal(supportDraftError({ ...base, preview }, now + 1), 'preview_expired');
});
test('runtime facts are bounded enums without a raw user agent', () => {
const facts = supportRuntimeFacts({
userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 private-suffix',
language: 'ru', coarsePointer: false, hoverCapable: true,
registryAccess: 'full', registryLastSuccess: 9_500, now: 10_000,
});
assert.deepEqual(facts, {
browser_family: 'chromium', browser_major: 140, language: 'ru',
coarse_pointer: false, hover_capable: true, registry_access: 'full',
registry_age_bucket: 'fresh',
});
assert.equal(JSON.stringify(facts).includes('private-suffix'), false);
});
test('remote and unknown failures collapse to stable local error codes', () => {
assert.equal(supportErrorCode({ code: 'support_rate_limited', message: 'private' }), 'support_rate_limited');
assert.equal(supportErrorCode({ code: 'provider leaked details' }), 'support_unavailable');
assert.equal(supportErrorCode(new Error('network secret')), 'support_unavailable');
});
test('Help is lazy, ordered after settings, and owns the single About/Guide surface', () => {
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const runtime = readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../src/styles/plan.styles.ts', import.meta.url), 'utf8');
const header = card.slice(card.indexOf('<div class="zoomctl">'), card.indexOf('</div>\n ${this._canEdit'));
assert.ok(header.indexOf('_openSettingsDialog') < header.indexOf('_openSupportDialog'));
assert.match(card, /if \(!this\._editorRuntime\)[\s\S]*?_ensureEditorRuntime\(\)[\s\S]*?_openSupportDialog/);
assert.equal((runtime.match(/_t\('gs\.about_version'/g) || []).length, 1);
assert.match(runtime, /docs\/USER-GUIDE\.ru\.md/);
assert.match(runtime, /docs\/USER-GUIDE\.md/);
assert.match(styles, /\.support-button\s*\{[\s\S]*?min-width:\s*44px;[\s\S]*?min-height:\s*44px;/);
});
test('the consent copy names exact geometry, project relay, retention and network address', () => {
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url), 'utf8'));
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url), 'utf8'));
assert.match(en['support.privacy'], /exact geometry/);
assert.match(en['support.privacy'], /project relay/);
assert.match(en['support.privacy'], /30 days/);
assert.match(en['support.privacy'], /server address/);
assert.equal(
ru['support.contact'],
'Контакт для связи (email/tg/WhatsApp), необязательно.',
);
});
+109
View File
@@ -0,0 +1,109 @@
"""Bounded fixed-host transport contract for private support reports (#43)."""
from __future__ import annotations
import json
import pytest
from custom_components.houseplan import support_transport
from custom_components.houseplan.support_transport import (
SupportTransportError,
async_submit_report,
)
class _Content:
def __init__(self, body: bytes) -> None:
self.body = body
self.read_limit = 0
async def read(self, limit: int) -> bytes:
self.read_limit = limit
return self.body[:limit]
class _Response:
def __init__(self, status: int, body: bytes) -> None:
self.status = status
self.content = _Content(body)
class _Context:
def __init__(self, response: _Response) -> None:
self.response = response
async def __aenter__(self) -> _Response:
return self.response
async def __aexit__(self, *_args) -> None:
return None
class _Session:
def __init__(self, status: int = 200, body: bytes | None = None) -> None:
self.response = _Response(
status,
body if body is not None else json.dumps({"report_id": "hpr-test-1234"}).encode(),
)
self.calls: list[tuple[str, dict]] = []
def post(self, url: str, **kwargs) -> _Context:
self.calls.append((url, kwargs))
return _Context(self.response)
async def _send(monkeypatch: pytest.MonkeyPatch, session: _Session) -> str:
monkeypatch.setattr(support_transport, "async_get_clientsession", lambda _hass: session)
return await async_submit_report(
object(),
message="plain <message>",
contact="user@example.test",
versions={"card": "1.70.0-beta.2"},
idempotency_key="report-test-one",
attachment=b"{}\n",
attachment_sha256="a" * 64,
filename_token="a" * 48,
)
async def test_transport_uses_only_fixed_https_host_without_redirects(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = _Session()
assert await _send(monkeypatch, session) == "hpr-test-1234"
assert len(session.calls) == 1
url, kwargs = session.calls[0]
assert url == "https://support.houseplan.tech/v1/reports"
assert kwargs["allow_redirects"] is False
assert kwargs["timeout"].total == 20
assert kwargs["timeout"].sock_connect == 5
assert session.response.content.read_limit == 4097
@pytest.mark.parametrize(
("status", "code"),
[
(302, "support_unavailable"),
(400, "support_rejected"),
(413, "support_package_too_large"),
(429, "support_rate_limited"),
(503, "support_unavailable"),
],
)
async def test_transport_maps_remote_status_without_reflecting_response(
monkeypatch: pytest.MonkeyPatch, status: int, code: str,
) -> None:
session = _Session(status, b"private provider debug text")
with pytest.raises(SupportTransportError) as caught:
await _send(monkeypatch, session)
assert caught.value.code == code
assert str(caught.value) == code
assert "private" not in str(caught.value)
async def test_transport_rejects_unbounded_or_invalid_receipt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
for body in (b"x" * 4097, b'{"report_id":"remote secret with spaces"}'):
with pytest.raises(SupportTransportError, match="support_unavailable"):
await _send(monkeypatch, _Session(200, body))
+168 -1
View File
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.typing import WebSocketGenerator
from custom_components.houseplan.const import CONF_ADMIN_ONLY, DOMAIN
from custom_components.houseplan.const import CONF_ADMIN_ONLY, DOMAIN, VERSION
from custom_components.houseplan.websocket_api import (
_space_delete_candidate, _space_marker_dependencies,
)
@@ -1263,6 +1263,173 @@ async def test_config_get_reports_can_write(hass: HomeAssistant, hass_ws_client:
assert "config" in resp["result"] and "rev" in resp["result"]
def _support_preview_request(draft_id: str = "draft-browser-one") -> dict:
return {
"type": "houseplan/support/preview",
"card_version": "1.70.0-beta.2",
"browser_family": "chromium",
"browser_major": 140,
"language": "en",
"coarse_pointer": False,
"hover_capable": True,
"registry_access": "full",
"registry_age_bucket": "fresh",
"draft_id": draft_id,
}
async def test_support_preview_is_authorized_exact_and_consumed_only_after_success(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
await client.send_json_auto_id(_support_preview_request())
built = await client.receive_json()
assert built["success"]
preview = built["result"]
assert preview["text"].endswith("\n")
assert preview["size"] == len(preview["text"].encode("utf-8"))
assert "houseplan-support-package" in preview["text"]
captured: dict = {}
async def _submit(_hass, **kwargs):
captured.update(kwargs)
return "hpr-test-0001"
monkeypatch.setattr(
"custom_components.houseplan.websocket_api.async_submit_report", _submit,
)
await client.send_json_auto_id({
"type": "houseplan/support/submit",
"message": "A private support message",
"contact": "contact example",
"preview_token": preview["token"],
"idempotency_key": "report-browser-one",
})
sent = await client.receive_json()
assert sent["success"] and sent["result"]["report_id"] == "hpr-test-0001"
assert captured["attachment"] == preview["text"].encode("utf-8")
assert captured["message"] == "A private support message"
await client.send_json_auto_id({
"type": "houseplan/support/submit",
"message": "retry",
"preview_token": preview["token"],
"idempotency_key": "report-browser-one",
})
consumed = await client.receive_json()
assert not consumed["success"]
assert consumed["error"]["code"] == "support_preview_expired"
async def test_support_preview_replacement_and_discard_are_draft_local(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
await client.send_json_auto_id(_support_preview_request("draft-same-card"))
first = (await client.receive_json())["result"]
await client.send_json_auto_id(_support_preview_request("draft-other-card"))
other = (await client.receive_json())["result"]
await client.send_json_auto_id(_support_preview_request("draft-same-card"))
replacement = (await client.receive_json())["result"]
assert len({first["token"], other["token"], replacement["token"]}) == 3
# Replaced/discarded tokens cannot be submitted; another card's token stays.
await client.send_json_auto_id({
"type": "houseplan/support/preview/discard", "token": first["token"],
})
assert (await client.receive_json())["success"]
await client.send_json_auto_id({
"type": "houseplan/support/preview/discard", "token": replacement["token"],
})
assert (await client.receive_json())["success"]
await client.send_json_auto_id({
"type": "houseplan/support/preview/discard", "token": other["token"],
})
assert (await client.receive_json())["success"]
async def test_support_text_only_submit_carries_safe_versions_without_plan_data(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
await _setup(hass)
captured: dict = {}
async def _submit(_hass, **kwargs):
captured.update(kwargs)
return "hpr-text-0001"
monkeypatch.setattr(
"custom_components.houseplan.websocket_api.async_submit_report", _submit,
)
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "houseplan/support/submit",
"message": "text only",
"idempotency_key": "report-text-only",
})
response = await client.receive_json()
assert response["success"]
assert captured["attachment"] is None
assert captured["attachment_sha256"] is None
assert captured["versions"]["card"] == VERSION
assert captured["versions"]["integration"] == VERSION
assert set(captured["versions"]) == {
"card", "integration", "home_assistant", "model", "export_schema",
}
async def test_support_commands_reject_read_only_user_before_build_or_transport(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_read_only_access_token: str,
) -> None:
await _setup(hass)
client = await hass_ws_client(hass, access_token=hass_read_only_access_token)
await client.send_json_auto_id(_support_preview_request())
response = await client.receive_json()
assert not response["success"] and response["error"]["code"] == "unauthorized"
await client.send_json_auto_id({
"type": "houseplan/support/submit",
"message": "not allowed",
"idempotency_key": "report-read-only",
})
response = await client.receive_json()
assert not response["success"] and response["error"]["code"] == "unauthorized"
@pytest.mark.parametrize(
("field", "value"),
[
("browser_major", True),
("browser_major", "140"),
("coarse_pointer", "false"),
("draft_id", 12345678),
],
)
async def test_support_preview_schema_does_not_coerce_client_facts(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
field: str,
value,
) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
request = _support_preview_request()
request[field] = value
await client.send_json_auto_id(request)
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "invalid_format"
async def test_files_migrate_copies_and_reports_mapping(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
+337
View File
@@ -0,0 +1,337 @@
"""Privacy and referential-integrity contract of the #43 support package."""
from __future__ import annotations
import base64
import json
from hashlib import sha256
import pytest
from custom_components.houseplan import support_package
from custom_components.houseplan.support_package import (
SupportPackageError,
build_support_package,
validate_frontend_facts,
)
def _facts() -> dict:
return {
"browser_family": "chromium",
"browser_major": 140,
"language": "ru",
"coarse_pointer": False,
"hover_capable": True,
"registry_access": "full",
"registry_age_bucket": "fresh",
}
def _source() -> tuple[dict, dict, list[str]]:
forbidden = [
"sentinel-space-id", "Private upstairs", "sentinel-room-id", "Child room",
"area.private", "device-secret", "sensor.secret_temperature",
"https://private.example/plan.png", "C:\\Users\\Private\\floor.png",
"person@example.test", "<b>private note</b>", "unknown-private-value",
]
config = {
"model_version": 9,
"unknown_top": "unknown-private-value",
"settings": {
"bg_mode": "daynight",
"known_devices": ["device-secret"],
"unknown_nested": "unknown-private-value",
},
"spaces": [{
"id": "sentinel-space-id",
"title": "Private upstairs",
"plan_url": "https://private.example/plan.png",
"view_box": [0, 0, 1, 1],
"rooms": [{
"id": "sentinel-room-id",
"name": "Child room",
"area": "area.private",
"poly": [[0, 0], [1, 0], [1, 1], [0, 1]],
"wall_ids": ["w1", "w2", "w3", "w4"],
"settings": {"temp_source": "entity:sensor.secret_temperature"},
}],
"wall_segments": [
{"id": "w1", "a": [0, 0], "b": [1, 0], "cm": 10},
{"id": "w2", "a": [1, 0], "b": [1, 1], "cm": 10},
{"id": "w3", "a": [1, 1], "b": [0, 1], "cm": 10},
{"id": "w4", "a": [0, 1], "b": [0, 0], "cm": 10},
],
"openings": [{
"id": "opening-secret", "type": "door", "x": 0.5, "y": 0,
"angle": 0, "length": 0.2,
"contact": "binary_sensor.private_door",
"host": {"kind": "wall", "id": "w1", "t": 0.5},
}],
"decor": [{
"id": "decor-secret", "kind": "text", "x": 0.4, "y": 0.4,
"text": "<b>private note</b>", "entity": "sensor.secret_temperature",
"attr": "person@example.test", "unknown": "unknown-private-value",
}],
}],
"markers": [{
"id": "marker-secret", "binding": "device:device-secret",
"space": "sentinel-space-id", "room_id": "sentinel-room-id",
"area": "area.private", "name": "Private washer", "model": "Secret model",
"link": "https://private.example/device", "description": "private note",
"pdfs": [{"name": "manual", "url": "C:\\Users\\Private\\floor.png"}],
"controls": ["sensor.secret_temperature"],
"unknown": "unknown-private-value",
}],
}
layout = {
"marker-secret": {"s": "sentinel-space-id", "x": 0.25, "y": 0.75},
"rl_sentinel-room-id": {"s": "sentinel-space-id", "x": 0.5, "y": 0.5, "k": 1.2},
"device-secret": {"s": "sentinel-space-id", "x": 0.1, "y": 0.2},
}
return config, layout, forbidden
def _build(namespace: str = "test"):
config, layout, forbidden = _source()
raw, summary = build_support_package(
config,
layout,
config_rev=17,
layout_rev=24,
card_version="1.70.0-beta.2",
integration_version="1.70.0-beta.2",
home_assistant_version="2026.8.0",
runtime=_facts(),
repairs=[{"code": "broken_plan", "count": 1}],
namespace=namespace,
)
return raw, summary, forbidden
def test_package_is_canonical_and_preview_hash_describes_exact_bytes():
first, summary, _ = _build()
second, _, _ = _build()
assert first == second
assert first.endswith(b"\n")
assert summary["size"] == len(first)
assert summary["sha256"] == sha256(first).hexdigest()
assert json.dumps(json.loads(first), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" == first.decode()
def test_privacy_projection_never_contains_raw_or_encoded_forbidden_values():
raw, _, forbidden = _build()
text = raw.decode()
for value in forbidden:
assert value not in text
assert json.dumps(value, ensure_ascii=False)[1:-1] not in text
assert base64.b64encode(value.encode()).decode() not in text
def test_geometry_and_references_survive_with_package_local_pseudonyms():
raw, _, _ = _build()
package = json.loads(raw)
space = package["plan_backup"]["config"]["spaces"][0]
room = space["rooms"][0]
marker = package["plan_backup"]["config"]["markers"][0]
assert space["id"] == "space-test-1"
assert space["title"] == "Space 1"
assert room["id"] == "room-test-1"
assert room["name"] == "Room 1"
assert room["poly"] == [[0, 0], [1, 0], [1, 1], [0, 1]]
assert room["wall_ids"] == ["wall-test-1", "wall-test-2", "wall-test-3", "wall-test-4"]
assert space["openings"][0]["host"]["id"] == room["wall_ids"][0]
assert marker["space"] == space["id"]
assert marker["room_id"] == room["id"]
assert marker["binding"] == "device:device-test-1"
assert set(package["plan_backup"]["layout"]) == {"marker-test-1", "rl_room-test-1"}
assert space["decor"][0]["text"] == "[redacted text]"
assert package["summary"]["decor"] == {"text": 1}
assert package["summary"]["markers"] == {
"total": 1,
"lifecycle": {"active": 1},
"binding": {"device": 1},
}
def test_each_preview_uses_a_new_namespace_and_cannot_be_correlated():
first, _, _ = _build("one1")
second, _, _ = _build("two2")
assert first != second
assert "space-one1-1" in first.decode()
assert "space-two2-1" in second.decode()
@pytest.mark.parametrize(
("key", "value"),
[
("browser_family", "netscape"),
("browser_major", -1),
("language", "es"),
("coarse_pointer", "false"),
("registry_access", "raw"),
("registry_age_bucket", "yesterday"),
],
)
def test_frontend_facts_fail_closed(key, value):
facts = _facts()
facts[key] = value
with pytest.raises(SupportPackageError, match="support_rejected"):
validate_frontend_facts(facts)
def test_frontend_facts_reject_non_mapping_and_invalid_hover():
with pytest.raises(SupportPackageError, match="support_rejected"):
validate_frontend_facts(None)
facts = _facts()
facts["hover_capable"] = 1
with pytest.raises(SupportPackageError, match="support_rejected"):
validate_frontend_facts(facts)
def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values():
config = {
"model_version": 9,
"settings": {
"north_deg": 30,
"fill_colors": {"warm": {"c": "#ffaa00", "a": 0.5, "secret": "drop"}},
"decor_default_style": {
"color": "#123456", "width_cm": 2, "secret": "drop",
},
},
"spaces": [{
"id": "floor",
"settings": {"show_names": True, "custom_fill": {"c": "#ffffff", "a": 0.4}},
"rooms": [{
"id": "kitchen", "x": 1, "y": 2, "w": 3, "h": 4,
"poly": [[0, 0], [2, 0], [False, 1], "bad"],
"wall_ids": ["wall-a"], "open_to": ["hall"],
"settings": {
"fill_mode": "custom", "custom_fill": {"c": "#000000", "a": 0.2},
"temp_source": "entity:sensor.temperature",
"hum_source": "invalid-source",
},
}],
"walls": [
{"key": "wall-a", "cm": 10, "a": [0, 0], "b": [2, 0]},
{"key": "wall-b", "cm": 12},
"bad",
],
"room_drafts": [
"bad",
{
"id": "draft-a", "points": [[0, 0], [1, 0], [True, 2]],
"segments": [{"id": "wall-a", "cm": 10}, {"cm": 12}, "bad"],
},
],
"partitions": [{"id": "partition-a", "a": [0, 1], "b": [2, 1], "cm": 8}],
"wall_columns": [{
"id": "column-a", "shape": "rect", "center": [1, 1], "cm": 20, "angle": 0,
}],
"openings": [
"bad",
{
"id": "door-a", "type": "door", "x": 1, "y": 1, "length": 0.9,
"contact": "binary_sensor.door", "lock": "lock.door",
"host": {"kind": "partition", "id": "partition-a", "t": 0.5},
},
{"id": "window-a", "type": "window", "host": {"kind": "column", "id": "x"}},
],
"decor": [
{"id": "line-a", "kind": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1},
{"id": "text-a", "kind": "text", "text": "private"},
],
"open_spans": [{"a": [0, 0], "b": [0, 1]}, "bad"],
}],
"markers": [
{
"id": "virtual-a", "binding": "virtual", "icon": "mdi:lightbulb",
"space": "floor", "room_id": "kitchen", "hidden": True,
"light_entity": "light.ceiling", "toggle_entity": "switch.ceiling",
"tap_target": "button.scene", "controls": ["sensor.temperature"],
"vacuum": {"live": True, "trail": True, "trail_mode": "line", "secret": "drop"},
"value_source": {"kind": "entity_state", "entity_id": "sensor.temperature"},
"value_badge": {
"enabled": True, "position": "bottom",
"source": {"kind": "derived_lqi"},
},
},
{
"id": "entity-a", "binding": "entity:sensor.temperature", "removed": True,
"value_source": {"kind": "derived_marker_state", "ref": "marker:virtual-a"},
},
{
"id": "unknown-a", "binding": "secret", "icon": "custom:private",
"value_source": {"kind": "private", "entity_id": "sensor.private"},
},
],
}
layout = {
"virtual-a": {"s": "floor", "x": 0.25, "y": 0.75},
"rl_kitchen": {"s": "floor", "x": 0.5, "y": 0.5, "k": 1.1},
7: {"x": 0},
"unknown-device": "bad",
}
raw, _ = build_support_package(
config, layout, config_rev=1, layout_rev=2,
card_version="invalid version!", integration_version="1.0.0",
home_assistant_version="2026.8.0", runtime=_facts(), namespace="rich",
)
package = json.loads(raw)
plan = package["plan_backup"]["config"]
space = plan["spaces"][0]
assert package["versions"]["card"] == "unknown"
assert plan["settings"]["fill_colors"] == {"warm": {"a": 0.5, "c": "#ffaa00"}}
assert plan["settings"]["decor_default_style"] == {"color": "#123456", "width_cm": 2}
assert space["rooms"][0]["poly"] == [[0, 0], [2, 0]]
assert space["rooms"][0]["settings"]["temp_source_kind"] == "entity"
assert space["rooms"][0]["settings"]["hum_source_kind"] == "unknown"
assert len(space["walls"]) == 2
assert space["walls"][1] == {"cm": 12, "key": "wall-rich-2"}
assert space["room_drafts"][0]["segments"][1] == {"cm": 12}
assert space["openings"][0]["host"]["kind"] == "partition"
assert "host" not in space["openings"][1]
assert space["decor"][1]["text"] == "[redacted text]"
virtual, entity, unknown = plan["markers"]
assert virtual["binding_kind"] == "virtual"
assert virtual["icon"] == "mdi:lightbulb"
assert virtual["vacuum"] == {"live": True, "trail": True, "trail_mode": "line"}
assert virtual["value_source"] == {
"entity_id": "entity-rich-1", "kind": "entity_state",
}
assert virtual["value_badge"]["source"] == {"kind": "derived_lqi"}
assert entity["value_source"] == {
"kind": "derived_marker_state", "ref": "marker:marker-rich-1",
}
assert unknown["binding_kind"] == "unknown"
assert "icon" not in unknown and "value_source" not in unknown
assert set(package["plan_backup"]["layout"]) == {"marker-rich-1", "rl_room-rich-1"}
assert package["summary"]["markers"] == {
"total": 3,
"lifecycle": {"active": 1, "hidden": 1, "removed": 1},
"binding": {"entity": 1, "unknown": 1, "virtual": 1},
}
def test_projection_helpers_fail_closed_on_malformed_shapes():
ids = support_package._Pseudonyms("edge")
assert ids.get("room", None) is None
assert ids.get("room", "") is None
assert ids.get("room", "same") == ids.get("room", "same")
assert support_package._point(None) is None
assert support_package._point([True, 1]) is None
assert support_package._points(None) == []
assert support_package._custom_fill(None) is None
assert support_package._global_settings(None) == {}
assert support_package._room_settings(ids, None) == {}
assert support_package._project_layout(ids, None) == {}
assert support_package._summary(None, None)["spaces"] == 0
assert support_package._binding_kind(None) == "unknown"
def test_package_size_limit_is_enforced_after_projection(monkeypatch):
monkeypatch.setattr(support_package, "MAX_SUPPORT_ATTACHMENT_BYTES", 1)
with pytest.raises(SupportPackageError, match="support_package_too_large"):
_build()
+1 -1
View File
@@ -34,7 +34,7 @@
"src/plan-optimizer.ts", "src/space-reference-repair.ts", "src/space-deletion.ts",
"src/plan-geometry-preflight.ts",
"src/furniture.ts",
"src/floating-surface.ts", "src/floating-surface-controller.ts", "src/help-behavior.ts",
"src/floating-surface.ts", "src/floating-surface-controller.ts", "src/help-behavior.ts", "src/support-feedback.ts",
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts", "src/danger-confirm.ts",
"src/opening-placement.ts", "src/opening-dimensions.ts", "src/partition-openings.ts", "src/plan-snap-overlay.ts", "src/wall-face-graph.ts", "src/wall-face-repair.ts", "src/room-deletion.ts", "src/render/opening-symbol.ts",
"src/wall-thickness.ts",