{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:1.414),e.onerror=()=>t(1.414),e.src=o});URL.revokeObjectURL(o),this._spaceDialog={...this._spaceDialog,planFile:{ext:s,b64:r,aspect:l,name:i.name}}}async _saveSpaceDialog(){const t=this._spaceDialog;if(t&&!t.busy&&t.title.trim()){this._spaceDialog={...t,busy:!0};try{const e=this._serverCfg;let i;if("create"===t.mode?(i={id:"s"+Date.now().toString(36),title:t.title.trim(),plan_url:null,aspect:1.414,view_box:[0,0,1,1],rooms:[],segments:[]},e.spaces.push(i)):(i=e.spaces.find(e=>e.id===t.spaceId),i.title=t.title.trim()),t.planFile){const e=await this.hass.callWS({type:"houseplan/plan/set",space_id:i.id,ext:t.planFile.ext,data:t.planFile.b64});i.plan_url=e.url,i.aspect=t.planFile.aspect}await this._saveConfigNow(),this._spaceDialog=null,"create"===t.mode&&(this._space=i.id),this._regSignature="",this._maybeRebuildDevices(),this._showToast("create"===t.mode?"Пространство добавлено":"Пространство сохранено")}catch(t){this._spaceDialog={...this._spaceDialog,busy:!1},this._showToast("Ошибка: "+(t?.message||t))}}}async _deleteSpace(){const t=this._spaceDialog;if(!t||"edit"!==t.mode)return;const e=this._serverCfg.spaces.find(e=>e.id===t.spaceId);if(confirm(`Удалить пространство «${e.title}» со всеми комнатами и разметкой?`)){this._serverCfg.spaces=this._serverCfg.spaces.filter(e=>e.id!==t.spaceId);try{await this._saveConfigNow(),this._spaceDialog=null,this._space===t.spaceId&&(this._space=this._serverCfg.spaces[0]?.id||""),this._regSignature="",this._maybeRebuildDevices(),this._showToast("Пространство удалено")}catch(t){this._showToast("Ошибка удаления: "+(t?.message||t))}}}async _saveConfigNow(){const t=await this.hass.callWS({type:"houseplan/config/set",config:this._serverCfg,expected_rev:this._cfgRev});this._cfgRev=t?.rev??this._cfgRev+1}render(){if(!this._config||!this.hass)return B;const t=this._model;if(!t.length)return j`
${this._config.title||"План дома"}
@@ -65,12 +65,12 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
@@ -89,19 +89,19 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
`:B}
${this._toast?j`${this._toast}
`:B}
- `}_renderDevice(t,e){const i=this._pos(t),s=(i.x-e[0])/e[2]*100,a=(i.y-e[1])/e[3]*100,n=this._stateClass(t),o=this._liveTemp(t),r=this._config?.show_signal&&!t.virtual?this._lqiFor(t.entities):null;return j`this._clickDevice(e,t)}
- @mousemove=${e=>this._showTip(e,t.name,t.model+(null!=o?" · "+o+"°":"")+(null!=r?" · LQI "+r:""))}
+ @mousemove=${e=>this._showTip(e,t.name,t.model+(null!=r?" · "+r+"°":"")+(null!=o?" · LQI "+o:""))}
@mouseleave=${()=>this._tip=null}
@pointerdown=${e=>this._pointerDown(e,t)}
@pointermove=${e=>this._pointerMove(e,t)}
@pointerup=${e=>this._pointerUp(e,t)}
>
- ${null!=o?j`${o}°`:B}
- ${null!=r?j`${r}`:B}
+ ${null!=r?j`${r}°`:B}
+ ${null!=o?j`${o}`:B}
`}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_renderMarkupDefs(t){const e=this._gridPitch,i=.14*e;return L`
@@ -986,4 +986,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
z-index: 120;
max-width: 90vw;
}
- `,customElements.get("houseplan-card")||customElements.define("houseplan-card",xt),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.7.3 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+ `,customElements.get("houseplan-card")||customElements.define("houseplan-card",xt),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Интерактивный план дома: пространства, комнаты, устройства с живыми состояниями и drag-раскладкой."}),console.info("%c HOUSEPLAN-CARD %c v1.7.4 ","background:#3ea6ff;color:#04121f;font-weight:700","");
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 3d2ad6f..a895eed 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -128,4 +128,7 @@ name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. Гибрид: а
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; событие `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8МБ) | `{ok, url}` |
-| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64, ≤25МБ) | `{ok, url, name}` |
+| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, лимит WS) |
+
+**Загрузка файлов — HTTP** (не WS, у него лимит размера сообщения): `POST /api/houseplan/upload`
+(multipart: marker_id + file), HomeAssistantView, requires_auth. Отдача — `/houseplan_files/files/`.
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 6fdaadd..d648d1d 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## v1.7.4 — 2026-07-04 (PDF по HTTP вместо WebSocket)
+- КОРНЕВАЯ ПРИЧИНА невозможности загрузить PDF: файл слался как base64 одним WebSocket-сообщением,
+ а у WS есть лимит размера — реальный мануал (>2–3 МБ) превышал его, соединение рвалось
+ («Connection lost»), из-за чего и падала загрузка, и закрывался диалог (обрыв WS → переподключение
+ → ре-рендер). Подтверждено тестом: 1 МБ по WS ок, 3 МБ → Connection lost.
+- Файлы-инструкции теперь грузятся через HTTP-эндпоинт `POST /api/houseplan/upload` (multipart,
+ HomeAssistantView, requires_auth, admin_only-проверка) — как медиа в самом HA. Проверено:
+ 3 МБ (182мс) и 10 МБ (446мс) загружаются на ура.
+- Читаемые ошибки везде (`_errText`): больше никаких «[object Object]»; понятные тексты для
+ too_large / bad_ext / unauthorized.
+- WS-команда houseplan/file/set оставлена для совместимости, но фронт использует HTTP.
+
+
## v1.7.3 — 2026-07-04 (фиксы диалога устройства)
- PDF-инструкции не загружались: ручной base64 (`btoa(String.fromCharCode(...subarray))`) падал
на реальных файлах (RangeError при спреде больших массивов), а при закрытии диалога во время
diff --git a/package.json b/package.json
index c781917..d457e7a 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
- "version": "1.7.3",
+ "version": "1.7.4",
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
"license": "MIT",
"type": "module",
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 6e2a306..6ebcb8f 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -13,7 +13,7 @@ import {
} from './logic';
import './editor';
-const CARD_VERSION = '1.7.3';
+const CARD_VERSION = '1.7.4';
const LS_KEY = 'houseplan_card_layout_v1';
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
@@ -1202,42 +1202,60 @@ class HouseplanCard extends LitElement {
return res;
}
- /** base64 файла через FileReader — надёжно для любого размера (без спреда больших массивов). */
- private _fileToBase64(file: File): Promise {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => {
- const res = String(reader.result || '');
- const comma = res.indexOf(',');
- resolve(comma >= 0 ? res.slice(comma + 1) : res);
- };
- reader.onerror = () => reject(reader.error || new Error('read error'));
- reader.readAsDataURL(file);
- });
+ /** Читаемый текст ошибки (никогда не «[object Object]»). */
+ private _errText(e: any): string {
+ if (!e) return 'неизвестная ошибка';
+ if (typeof e === 'string') return e;
+ if (e.message) return e.message;
+ if (e.error) return e.error;
+ if (e.code != null) return 'код ' + e.code;
+ try {
+ return JSON.stringify(e);
+ } catch {
+ return String(e);
+ }
}
+ /**
+ * Загрузка файлов-инструкций через HTTP (multipart) — не через WebSocket, у которого лимит
+ * размера сообщения рвёт соединение на больших PDF.
+ */
private async _pickMarkerFiles(ev: Event): Promise {
const input = ev.target as HTMLInputElement;
const files = input.files ? [...input.files] : [];
input.value = '';
if (!files.length || !this._markerDialog) return;
const mid = this._markerDialog.devId || 'new';
+ const token = this.hass?.auth?.data?.access_token;
const uploaded: PdfRef[] = [];
for (const file of files) {
try {
- const data = await this._fileToBase64(file);
- const resp = await this.hass.callWS({
- type: 'houseplan/file/set', marker_id: mid, filename: file.name, data,
+ const fd = new FormData();
+ fd.append('marker_id', mid);
+ fd.append('file', file, file.name);
+ const resp = await fetch('/api/houseplan/upload', {
+ method: 'POST',
+ body: fd,
+ headers: token ? { authorization: `Bearer ${token}` } : {},
});
- uploaded.push({ name: resp.name || file.name, url: resp.url });
+ const json = await resp.json().catch(() => ({}));
+ if (!resp.ok || json.error) {
+ const map: Record = {
+ too_large: 'файл больше ' + (json.max_mb || 25) + ' МБ',
+ bad_ext: 'недопустимый тип (нужен PDF/изображение)',
+ unauthorized: 'нужны права администратора',
+ };
+ throw new Error(map[json.error] || json.error || 'HTTP ' + resp.status);
+ }
+ uploaded.push({ name: json.name || file.name, url: json.url });
} catch (e: any) {
- this._showToast('Файл «' + file.name + '» не загружен: ' + (e?.code || e?.message || e));
+ this._showToast('Файл «' + file.name + '» не загружен: ' + this._errText(e));
}
}
- // диалог мог быть переоткрыт/закрыт за время загрузки — добавляем, только если он ещё открыт
+ // диалог мог закрыться за время загрузки — добавляем, только если он ещё открыт
if (uploaded.length && this._markerDialog) {
this._markerDialog = { ...this._markerDialog, pdfs: [...this._markerDialog.pdfs, ...uploaded] };
- if (uploaded.length) this._showToast('Прикреплено файлов: ' + uploaded.length);
+ this._showToast('Прикреплено файлов: ' + uploaded.length);
}
}