mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
fix v1.7.4: PDF upload via HTTP endpoint instead of WebSocket — root cause was WS message-size limit dropping the connection on real (>2-3MB) PDFs (which also closed the dialog via reconnect). New POST /api/houseplan/upload (HomeAssistantView, multipart, auth); verified 3MB(182ms)+10MB(446ms). Legible errors (_errText), no more [object Object]
This commit is contained in:
@@ -27,11 +27,14 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config) -> bool:
|
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||||
"""Регистрируем WS-команды и хранилища на старте."""
|
"""Регистрируем WS-команды, HTTP-загрузку и хранилища на старте."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
hass.data[DOMAIN]["store"] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||||
hass.data[DOMAIN]["config_store"] = Store(hass, STORAGE_VERSION, STORAGE_CONFIG_KEY)
|
hass.data[DOMAIN]["config_store"] = Store(hass, STORAGE_VERSION, STORAGE_CONFIG_KEY)
|
||||||
hp_ws.async_register(hass)
|
hp_ws.async_register(hass)
|
||||||
|
from .http_api import HouseplanUploadView
|
||||||
|
|
||||||
|
hass.http.register_view(HouseplanUploadView())
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -10,7 +10,7 @@ PLANS_DIR = "houseplan/plans" # относительно каталога ко
|
|||||||
FILES_URL = "/houseplan_files/files"
|
FILES_URL = "/houseplan_files/files"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.7.3"
|
VERSION = "1.7.4"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,83 @@
|
|||||||
|
"""HTTP-эндпоинт загрузки файлов-инструкций House Plan.
|
||||||
|
|
||||||
|
Файлы (PDF и т.п.) грузятся не через WebSocket (у него лимит размера сообщения —
|
||||||
|
большой PDF рвёт соединение), а обычным multipart POST — как медиа в самом HA.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import CONF_ADMIN_ONLY, DOMAIN, FILES_DIR, FILES_URL
|
||||||
|
from .validation import (
|
||||||
|
FILE_EXTENSIONS,
|
||||||
|
MAX_FILE_BYTES,
|
||||||
|
file_ext,
|
||||||
|
sanitize_filename,
|
||||||
|
sanitize_marker_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HouseplanUploadView(HomeAssistantView):
|
||||||
|
"""POST /api/houseplan/upload — сохранить файл маркера, вернуть URL."""
|
||||||
|
|
||||||
|
url = "/api/houseplan/upload"
|
||||||
|
name = "api:houseplan:upload"
|
||||||
|
requires_auth = True
|
||||||
|
|
||||||
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
|
hass: HomeAssistant = request.app["hass"]
|
||||||
|
entry = hass.data.get(DOMAIN, {}).get("entry")
|
||||||
|
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||||
|
if admin_only:
|
||||||
|
user = request.get("hass_user")
|
||||||
|
if user is None or not user.is_admin:
|
||||||
|
return web.json_response({"error": "unauthorized"}, status=403)
|
||||||
|
|
||||||
|
marker_id = "misc"
|
||||||
|
filename: str | None = None
|
||||||
|
blob: bytes | None = None
|
||||||
|
try:
|
||||||
|
reader = await request.multipart()
|
||||||
|
async for part in reader:
|
||||||
|
if part.name == "marker_id":
|
||||||
|
marker_id = sanitize_marker_id(await part.text())
|
||||||
|
elif part.name == "file":
|
||||||
|
filename = part.filename or "file"
|
||||||
|
blob = await part.read(decode=False)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOGGER.warning("House Plan upload: ошибка чтения multipart: %s", err)
|
||||||
|
return web.json_response({"error": "bad_request"}, status=400)
|
||||||
|
|
||||||
|
if blob is None or not filename:
|
||||||
|
return web.json_response({"error": "no_file"}, status=400)
|
||||||
|
ext = file_ext(filename)
|
||||||
|
if ext not in FILE_EXTENSIONS:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
|
||||||
|
)
|
||||||
|
if len(blob) > MAX_FILE_BYTES:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
|
||||||
|
)
|
||||||
|
|
||||||
|
safe_name = sanitize_filename(filename)
|
||||||
|
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
|
||||||
|
path = target_dir / safe_name
|
||||||
|
|
||||||
|
def _write() -> None:
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(blob)
|
||||||
|
|
||||||
|
await hass.async_add_executor_job(_write)
|
||||||
|
mtime = int(path.stat().st_mtime)
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
||||||
|
)
|
||||||
@@ -15,5 +15,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/justbusiness/houseplan-card/issues",
|
"issue_tracker": "https://github.com/justbusiness/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "1.7.3"
|
"version": "1.7.4"
|
||||||
}
|
}
|
||||||
Vendored
+9
-9
File diff suppressed because one or more lines are too long
@@ -128,4 +128,7 @@ name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. Гибрид: а
|
|||||||
| `houseplan/config/get` | — | `{config, rev}` |
|
| `houseplan/config/get` | — | `{config, rev}` |
|
||||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; событие `houseplan_config_updated` |
|
| `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/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/`.
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# 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 (фиксы диалога устройства)
|
## v1.7.3 — 2026-07-04 (фиксы диалога устройства)
|
||||||
- PDF-инструкции не загружались: ручной base64 (`btoa(String.fromCharCode(...subarray))`) падал
|
- PDF-инструкции не загружались: ручной base64 (`btoa(String.fromCharCode(...subarray))`) падал
|
||||||
на реальных файлах (RangeError при спреде больших массивов), а при закрытии диалога во время
|
на реальных файлах (RangeError при спреде больших массивов), а при закрытии диалога во время
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.7.3",
|
"version": "1.7.4",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+38
-20
@@ -13,7 +13,7 @@ import {
|
|||||||
} from './logic';
|
} from './logic';
|
||||||
import './editor';
|
import './editor';
|
||||||
|
|
||||||
const CARD_VERSION = '1.7.3';
|
const CARD_VERSION = '1.7.4';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
const LS_KEY = 'houseplan_card_layout_v1';
|
||||||
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
|
const NORM_W = 1000; // ширина рендер-пространства для нормированных конфигов
|
||||||
|
|
||||||
@@ -1202,42 +1202,60 @@ class HouseplanCard extends LitElement {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** base64 файла через FileReader — надёжно для любого размера (без спреда больших массивов). */
|
/** Читаемый текст ошибки (никогда не «[object Object]»). */
|
||||||
private _fileToBase64(file: File): Promise<string> {
|
private _errText(e: any): string {
|
||||||
return new Promise((resolve, reject) => {
|
if (!e) return 'неизвестная ошибка';
|
||||||
const reader = new FileReader();
|
if (typeof e === 'string') return e;
|
||||||
reader.onload = () => {
|
if (e.message) return e.message;
|
||||||
const res = String(reader.result || '');
|
if (e.error) return e.error;
|
||||||
const comma = res.indexOf(',');
|
if (e.code != null) return 'код ' + e.code;
|
||||||
resolve(comma >= 0 ? res.slice(comma + 1) : res);
|
try {
|
||||||
};
|
return JSON.stringify(e);
|
||||||
reader.onerror = () => reject(reader.error || new Error('read error'));
|
} catch {
|
||||||
reader.readAsDataURL(file);
|
return String(e);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка файлов-инструкций через HTTP (multipart) — не через WebSocket, у которого лимит
|
||||||
|
* размера сообщения рвёт соединение на больших PDF.
|
||||||
|
*/
|
||||||
private async _pickMarkerFiles(ev: Event): Promise<void> {
|
private async _pickMarkerFiles(ev: Event): Promise<void> {
|
||||||
const input = ev.target as HTMLInputElement;
|
const input = ev.target as HTMLInputElement;
|
||||||
const files = input.files ? [...input.files] : [];
|
const files = input.files ? [...input.files] : [];
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (!files.length || !this._markerDialog) return;
|
if (!files.length || !this._markerDialog) return;
|
||||||
const mid = this._markerDialog.devId || 'new';
|
const mid = this._markerDialog.devId || 'new';
|
||||||
|
const token = this.hass?.auth?.data?.access_token;
|
||||||
const uploaded: PdfRef[] = [];
|
const uploaded: PdfRef[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const data = await this._fileToBase64(file);
|
const fd = new FormData();
|
||||||
const resp = await this.hass.callWS({
|
fd.append('marker_id', mid);
|
||||||
type: 'houseplan/file/set', marker_id: mid, filename: file.name, data,
|
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<string, string> = {
|
||||||
|
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) {
|
} catch (e: any) {
|
||||||
this._showToast('Файл «' + file.name + '» не загружен: ' + (e?.code || e?.message || e));
|
this._showToast('Файл «' + file.name + '» не загружен: ' + this._errText(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// диалог мог быть переоткрыт/закрыт за время загрузки — добавляем, только если он ещё открыт
|
// диалог мог закрыться за время загрузки — добавляем, только если он ещё открыт
|
||||||
if (uploaded.length && this._markerDialog) {
|
if (uploaded.length && this._markerDialog) {
|
||||||
this._markerDialog = { ...this._markerDialog, pdfs: [...this._markerDialog.pdfs, ...uploaded] };
|
this._markerDialog = { ...this._markerDialog, pdfs: [...this._markerDialog.pdfs, ...uploaded] };
|
||||||
if (uploaded.length) this._showToast('Прикреплено файлов: ' + uploaded.length);
|
this._showToast('Прикреплено файлов: ' + uploaded.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user