commit 5f2b6b8efbeb9bc71116246fa3ccc05f77ee7f52 Author: Striker72rus Date: Wed Mar 25 13:48:26 2026 +0300 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..ec2e775 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +.DS_Store +ha-addon +custom_components +node_modules +storage/@eaDir +config/@eaDir +config/config.json +lib/@eaDir +assets/@eaDir +storage/*.json diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..ff9d3e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config/config.json +storage/ +@eaDir/ diff --git a/@eaDir/.git@SynoEAStream b/@eaDir/.git@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/@eaDir/.git@SynoEAStream differ diff --git a/@eaDir/README.md@SynoEAStream b/@eaDir/README.md@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/@eaDir/README.md@SynoEAStream differ diff --git a/@eaDir/README.md@SynoResource b/@eaDir/README.md@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/@eaDir/README.md@SynoResource differ diff --git a/@eaDir/api.php@SynoEAStream b/@eaDir/api.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/@eaDir/api.php@SynoEAStream differ diff --git a/@eaDir/api.php@SynoResource b/@eaDir/api.php@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/@eaDir/api.php@SynoResource differ diff --git a/@eaDir/assets@SynoEAStream b/@eaDir/assets@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/@eaDir/assets@SynoEAStream differ diff --git a/@eaDir/config@SynoEAStream b/@eaDir/config@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/@eaDir/config@SynoEAStream differ diff --git a/@eaDir/favicon.ico@SynoEAStream b/@eaDir/favicon.ico@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/@eaDir/favicon.ico@SynoEAStream differ diff --git a/@eaDir/favicon.ico@SynoResource b/@eaDir/favicon.ico@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/@eaDir/favicon.ico@SynoResource differ diff --git a/@eaDir/index.php@SynoEAStream b/@eaDir/index.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/@eaDir/index.php@SynoEAStream differ diff --git a/@eaDir/index.php@SynoResource b/@eaDir/index.php@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/@eaDir/index.php@SynoResource differ diff --git a/@eaDir/lib@SynoEAStream b/@eaDir/lib@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/@eaDir/lib@SynoEAStream differ diff --git a/@eaDir/storage@SynoEAStream b/@eaDir/storage@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/@eaDir/storage@SynoEAStream differ diff --git a/@eaDir/wallpanell.code-workspace@SynoEAStream b/@eaDir/wallpanell.code-workspace@SynoEAStream new file mode 100755 index 0000000..a1ae015 Binary files /dev/null and b/@eaDir/wallpanell.code-workspace@SynoEAStream differ diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..f34ea00 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM php:8.2-cli-alpine + +RUN apk add --no-cache curl-dev && docker-php-ext-install curl + +WORKDIR /app + +COPY index.php /app/index.php +COPY api.php /app/api.php +COPY favicon.ico /app/favicon.ico +COPY assets /app/assets +COPY lib /app/lib +COPY config/addon-default-config.json /app/config/config.json +COPY run.sh /run.sh + +RUN chmod a+x /run.sh + +EXPOSE 8099 + +CMD ["/run.sh"] diff --git a/README.md b/README.md new file mode 100755 index 0000000..9faabb7 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Wall Panel + +Таблет-ориентированная панель для Home Assistant на `PHP + HTML + JS`. +Основной HA-способ запуска теперь `Home Assistant Add-on` с ingress и отдельным портом. + +## Запуск + +```bash +php -S 0.0.0.0:8080 +``` + +Откройте `http://localhost:8080`. + +## Конфиг + +Основной файл: + +- [`config/config.json`](config/config.json) + +В него кладутся: + +- `home_assistant.base_url` +- `home_assistant.token` +- `camera.rtsp_url` +- `camera.stream_url` +- `camera.poster_url` +- `rooms` + +Если `base_url` и `token` пустые, панель работает в demo mode с тестовыми карточками. + +## Home Assistant Add-on + +Для HA теперь рекомендован add-on-режим: + +- запускает тот же PHP-интерфейс внутри Supervisor; +- открывается через ingress; +- доступен через отдельный порт; +- хранит конфиг в add-on volume; +- не зависит от `custom_components`. + +Файлы add-on лежат в папке: + +- [`wall_panel/config.yaml`](/Volumes/web/wallpanell/wall_panel/config.yaml) +- [`wall_panel/Dockerfile`](/Volumes/web/wallpanell/wall_panel/Dockerfile) +- [`wall_panel/run.sh`](/Volumes/web/wallpanell/wall_panel/run.sh) +- [`repository.yaml`](/Volumes/web/wallpanell/repository.yaml) + +### Как использовать + +1. Добавьте Git URL репозитория в Home Assistant как add-on repository. +2. Установите add-on. +3. Запустите его. +4. Откройте панель через ingress или через проброшенный порт `8099/tcp`. + +### Конфигурация + +Add-on использует persistent config file: + +- `/config/config.json` внутри контейнера + +Этот файл является runtime source of truth для панели в HA-режиме и переживает рестарт add-on. + +Частые настройки можно менять прямо из UI add-on в Home Assistant: + +- `app.title` +- `app.poll_interval_ms` +- `app.main_room_name` +- `app.main_room_icon` +- `app.edit_mode` +- `app.battery_history_hours` +- `home_assistant.base_url` +- `home_assistant.token` +- `home_assistant.verify_ssl` +- `home_assistant.weather_entity_id` +- `camera.rtsp_url` +- `camera.stream_url` +- `camera.stream_mode` +- `camera.poster_url` +- `camera.popup_timeout_minutes` + +Сложные структуры, вроде `rooms`, по-прежнему удобнее держать в JSON. + +### Старый embed-режим + +Отдельный PHP-доступ по-прежнему работает: + +`http://YOUR_PANEL_HOST/index.php?embed=1` + +Это полезно, если вы запускаете панель вне HA или хотите iframe-карточку вручную. + +## Popup камеры + +Для браузера нужен не прямой `rtsp://`, а bridge, который отдаёт `HLS` или `WebRTC`. + +Popup открывается через endpoint: + +```bash +POST /api.php?action=popup +{ + "sensor_entity_id": "binary_sensor.doorbell_all_occupancy", + "state": "on" +} +``` + +Закрытие: + +```bash +POST /api.php?action=popup +{ + "sensor_entity_id": "binary_sensor.doorbell_all_occupancy", + "state": "off" +} +``` + +## Room overrides + +Для комнаты можно сохранять overrides через: + +```bash +POST /api.php?action=save-entity-override +{ + "room_id": "living_room", + "entity_id": "light.living_room_main", + "visible": true, + "order": 10, + "card_type": "toggle", + "title": "Основной свет", + "icon": "mdi:ceiling-light" +} +``` + +## Empty room slots + +Для desktop-раскладки можно создавать пустые слоты: + +```bash +POST /api.php?action=create-room-layout-item +{ + "room_id": "living_room" +} +``` + +Перемещение: + +```bash +POST /api.php?action=save-room-layout-item +{ + "room_id": "living_room", + "layout_item_id": "slot_xxx", + "order": 120 +} +``` + +Удаление: + +```bash +POST /api.php?action=delete-room-layout-item +{ + "room_id": "living_room", + "layout_item_id": "slot_xxx" +} +``` diff --git a/api.php b/api.php new file mode 100755 index 0000000..0ed4b91 --- /dev/null +++ b/api.php @@ -0,0 +1,254 @@ + false, 'error' => 'entity_id is required'], 400); + } + + api_json([ + 'ok' => true, + 'entity_id' => $entityId, + 'hours' => $hours, + 'history' => $client->fetchEntityHistory($entityId, $hours), + ]); + } + + if ($action === 'service') { + $payload = api_input(); + $entityId = trim((string)($payload['entity_id'] ?? '')); + $command = trim((string)($payload['command'] ?? 'toggle')); + $value = $payload['value'] ?? null; + + if ($entityId === '') { + api_json(['ok' => false, 'error' => 'entity_id is required'], 400); + } + + [$domain, $service, $serviceData] = app_service_for_entity($entityId, $command); + if ($command === 'set_temperature' && $value !== null) { + $serviceData['temperature'] = $value; + } + if ($command === 'set_hvac_mode' && $value !== null) { + $serviceData['hvac_mode'] = $value; + } + if ($command === 'set_fan_mode' && $value !== null) { + $serviceData['fan_mode'] = $value; + } + if ($command === 'set_swing_mode' && $value !== null) { + $serviceData['swing_mode'] = $value; + } + if ($command === 'set_preset_mode' && $value !== null) { + $serviceData['preset_mode'] = $value; + } + if ($command === 'set_position' && $value !== null) { + $serviceData['position'] = $value; + } + + $result = $client->callService($domain, $service, $serviceData); + api_json(['ok' => true, 'result' => $result]); + } + + if ($action === 'save-entity-override') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $entityId = trim((string)($payload['entity_id'] ?? '')); + + if ($roomId === '' || $entityId === '') { + api_json(['ok' => false, 'error' => 'room_id and entity_id are required'], 400); + } + + $patch = [ + 'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null, + 'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null, + 'card_type' => array_key_exists('card_type', $payload) ? (string)$payload['card_type'] : null, + 'title' => array_key_exists('title', $payload) ? (string)$payload['title'] : null, + 'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null, + ]; + + $config = app_update_entity_override($config, $roomId, $entityId, $patch); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'save-space-override') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + + if ($roomId === '') { + api_json(['ok' => false, 'error' => 'room_id is required'], 400); + } + + $patch = [ + 'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null, + 'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null, + 'name' => array_key_exists('name', $payload) ? (string)$payload['name'] : null, + 'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null, + ]; + + if (array_key_exists('temperature_sensor_entity_id', $payload)) { + $patch['temperature_sensor_entity_id'] = (string)$payload['temperature_sensor_entity_id']; + } + + $config = app_update_room_override($config, $roomId, $patch); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'create-room-layout-item') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + + if ($roomId === '') { + api_json(['ok' => false, 'error' => 'room_id is required'], 400); + } + + $layoutItemId = trim((string)($payload['layout_item_id'] ?? '')); + if ($layoutItemId === '') { + $layoutItemId = 'slot_' . str_replace('.', '', uniqid('', true)); + } + + $order = array_key_exists('order', $payload) ? (int)$payload['order'] : null; + $config = app_update_room_layout_item($config, $roomId, $layoutItemId, [ + 'order' => $order, + ]); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + + api_json([ + 'ok' => true, + 'layout_item_id' => $layoutItemId, + 'config' => ['rooms' => $config['rooms']], + ]); + } + + if ($action === 'save-room-layout-item') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $layoutItemId = trim((string)($payload['layout_item_id'] ?? '')); + + if ($roomId === '' || $layoutItemId === '') { + api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400); + } + + $patch = [ + 'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null, + ]; + + $config = app_update_room_layout_item($config, $roomId, $layoutItemId, $patch); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'delete-room-layout-item') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $layoutItemId = trim((string)($payload['layout_item_id'] ?? '')); + + if ($roomId === '' || $layoutItemId === '') { + api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400); + } + + $config = app_delete_room_layout_item($config, $roomId, $layoutItemId); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'reorder-room-grid') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $entries = $payload['entries'] ?? []; + + if ($roomId === '' || !is_array($entries)) { + api_json(['ok' => false, 'error' => 'room_id and entries are required'], 400); + } + + $config = app_reorder_room_grid($config, $roomId, $entries); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'save-settings') { + $payload = api_input(); + if (array_key_exists('edit_mode', $payload)) { + $config['app']['edit_mode'] = (bool)$payload['edit_mode']; + } + if (array_key_exists('title', $payload) && trim((string)$payload['title']) !== '') { + $config['app']['title'] = trim((string)$payload['title']); + } + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'settings' => $config['app']]); + } + + if ($action === 'popup') { + $payload = api_input(); + $popup = app_handle_popup_event($config, $payload); + api_json(['ok' => true, 'popup' => $popup]); + } + + api_json(['ok' => false, 'error' => 'Unknown action'], 404); +} catch (Throwable $e) { + api_json([ + 'ok' => false, + 'error' => $e->getMessage(), + ], 500); +} diff --git a/assets/@eaDir/app.css@SynoEAStream b/assets/@eaDir/app.css@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/assets/@eaDir/app.css@SynoEAStream differ diff --git a/assets/@eaDir/app.css@SynoResource b/assets/@eaDir/app.css@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/assets/@eaDir/app.css@SynoResource differ diff --git a/assets/@eaDir/app.js@SynoEAStream b/assets/@eaDir/app.js@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/assets/@eaDir/app.js@SynoEAStream differ diff --git a/assets/@eaDir/app.js@SynoResource b/assets/@eaDir/app.js@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/assets/@eaDir/app.js@SynoResource differ diff --git a/assets/app.css b/assets/app.css new file mode 100755 index 0000000..74d54ab --- /dev/null +++ b/assets/app.css @@ -0,0 +1,2540 @@ +:root { + color-scheme: dark; + --bg: #090b10; + --bg-elevated: #11141b; + --surface: rgba(22, 25, 33, 0.92); + --surface-strong: #1c2028; + --surface-soft: rgba(32, 36, 46, 0.82); + --surface-bright: #eef1f7; + --text: #edf0f6; + --text-muted: rgba(237, 240, 246, 0.62); + --text-subtle: rgba(237, 240, 246, 0.42); + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.16); + --accent: #67d6ff; + --accent-2: #88f0c7; + --warning: #ffcf6d; + --danger: #ff7d7d; + --shadow: 0 16px 48px rgba(0, 0, 0, 0.34); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 18px; + --radius-sm: 14px; + --sidebar-width: 400px; + --content-pad: 32px; + --font-sans: "Manrope", "Segoe UI", sans-serif; + --font-display: "Space Grotesk", "Manrope", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + background: + radial-gradient(circle at top left, rgba(77, 110, 138, 0.15), transparent 28%), + radial-gradient(circle at bottom right, rgba(63, 168, 140, 0.12), transparent 24%), + linear-gradient(180deg, #080a0f 0%, #0d1118 100%); + color: var(--text); + overflow: hidden; +} + +body.is-embedded { + overflow: auto; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.app-shell { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + min-height: 100vh; + height: 100vh; +} + +.app-shell--embed { + grid-template-columns: clamp(250px, 22vw, 320px) minmax(0, 1fr); + height: auto; + min-height: 100vh; +} + +.sidebar { + position: relative; + padding: 24px 20px 20px 20px; + background: linear-gradient(180deg, rgba(10, 12, 17, 0.98), rgba(12, 14, 20, 0.88)); + border-right: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.app-shell--embed .sidebar { + padding: 18px 16px 16px; + border-right: 1px solid rgba(255, 255, 255, 0.05); +} + +.sidebar::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(180deg, rgba(255,255,255,0.03), transparent 12%), + linear-gradient(90deg, rgba(102, 214, 255, 0.04), transparent 24%); + mix-blend-mode: screen; +} + +.app-shell--embed .sidebar::after { + display: none; +} + +.clock-panel { + padding: 12px 8px 12px 8px; +} + +.app-shell--embed .clock-panel { + padding: 0 0 8px; +} + +.clock-panel__time { + font-family: var(--font-display); + font-size: clamp(56px, 6.4vw, 82px); + line-height: 0.94; + letter-spacing: -0.06em; + font-weight: 700; +} + +.clock-panel__date { + margin-top: 8px; + color: var(--text-muted); + font-size: 18px; + line-height: 1.1; + white-space: nowrap; +} + +.app-shell--embed .clock-panel__time { + font-size: clamp(34px, 3.8vw, 56px); +} + +.app-shell--embed .clock-panel__date { + font-size: 14px; + white-space: normal; +} + +.rooms-panel { + margin-top: 12px; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.app-shell--embed .rooms-panel { + margin-top: 8px; +} + +.panel-header, +.content-header, +.camera-modal__header, +.camera-modal__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-header { + margin-bottom: 10px; +} + +.panel-header__label { + font-size: 19px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.panel-header__sub { + margin-top: 6px; + color: var(--text-subtle); + font-size: 14px; +} + +#rooms-count:empty { + display: none; +} + +.content-header { + justify-content: space-between; + align-items: flex-start; +} + +.content-header__back { + display: none !important; + flex: 0 0 auto; +} + +.content-header > div { + min-width: 0; +} + +.content-header__actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: 16px; + flex-wrap: wrap; + justify-content: flex-end; + margin-left: auto; +} + +.content-header__ghost-button { + min-height: 40px; + padding-inline: 12px; + border-radius: 12px; +} + +.content-header__ghost-button span { + font-size: 13px; + font-weight: 700; +} + +.icon-button { + appearance: none; + border: 1px solid var(--border); + background: rgba(255,255,255,0.03); + color: var(--text); + width: 40px; + height: 40px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; +} + +.icon-button:hover { + transform: translateY(-1px); + background: rgba(255,255,255,0.08); + border-color: var(--border-strong); +} + +.icon-button.is-active { + background: rgba(103, 214, 255, 0.14); + border-color: rgba(103, 214, 255, 0.32); +} + +.icon-button--ghost { + width: 44px; + height: 44px; + border-radius: 16px; +} + +.room-list { + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; + min-height: 0; + padding-right: 6px; + padding-bottom: 6px; +} + +.app-shell--embed .room-list { + gap: 10px; + overflow-y: auto; + padding-right: 4px; + padding-bottom: 4px; +} + +.room-list__group { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.room-item { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, rgba(28, 31, 39, 0.92), rgba(20, 23, 30, 0.92)); + color: var(--text); + cursor: pointer; + text-align: left; + min-height: 132px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + transition: border-color 180ms ease, transform 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.app-shell--embed .room-item { + min-height: 94px; + padding: 7px 8px; +} + +.app-shell--embed .room-item__icon { + width: 42px; + height: 42px; +} + +.app-shell--embed .room-item__icon i { + font-size: 32px; +} + +.app-shell--embed .room-item__name { + font-size: 14px; +} + +.room-item.has-temp { + padding-right: 0px; +} + +.room-item.has-temp .room-item__body { + padding-right: 0px; +} + +.room-item:hover { + transform: translateY(-1px); + border-color: rgba(103, 214, 255, 0.24); +} + +.room-item.is-selected { + background: linear-gradient(180deg, rgba(103, 214, 255, 0.16), rgba(32, 35, 45, 0.98)); + border-color: rgba(103, 214, 255, 0.3); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255,255,255,0.05); +} + +.room-item.is-hidden-room { + opacity: 0.72; +} + +.room-item.is-main { + min-height: 132px; +} + +.room-item.is-battery-room { + background: + radial-gradient(circle at top right, rgba(255, 193, 7, 0.12), transparent 40%), + linear-gradient(180deg, rgba(32, 28, 20, 0.94), rgba(21, 20, 18, 0.95)); + border-color: rgba(255, 193, 7, 0.18); +} + +.room-item.is-battery-room .room-item__icon { + color: #ffc94d; + --icon-node-img-filter: brightness(0) saturate(100%) invert(79%) sepia(41%) saturate(909%) hue-rotate(357deg) brightness(103%) contrast(101%); +} + +.room-item.is-battery-room.is-selected { + background: + radial-gradient(circle at top right, rgba(255, 193, 7, 0.16), transparent 40%), + linear-gradient(180deg, rgba(51, 43, 20, 0.96), rgba(24, 22, 18, 0.98)); + border-color: rgba(255, 193, 7, 0.38); +} + +.room-item__icon { + width: 50px; + height: 50px; + border-radius: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); + color: var(--accent); + --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); +} + +.room-item__icon i { + font-size: 40px; + line-height: 1; +} + +.room-item__icon .icon-node, +.grid-card__icon .icon-node { + width: 40px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.icon-node__img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + filter: var(--icon-node-img-filter, none); +} + +.icon-node__svg { + width: 100%; + height: 100%; + display: block; + fill: currentColor; +} + +.room-item.is-selected .room-item__icon { + background: rgba(255,255,255,0.9); + color: #101318; + --icon-node-img-filter: brightness(0) saturate(100%); +} + +.room-item__name { + font-size: 16px; + font-weight: 700; + line-height: 1.08; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} + +.room-item__body { + min-width: 0; + width: 100%; +} + +.room-item__meta { + margin-top: 5px; + font-size: 12px; + color: var(--text-muted); +} + +.room-item__temp { + position: absolute; + top: 10px; + right: 3px; + padding: 3px 6px; + border-radius: 999px; + background: rgba(103, 214, 255, 0.12); + color: var(--accent); + border: 1px solid rgba(103, 214, 255, 0.22); + font-size: 20px; + font-weight: 700; + line-height: 1; + z-index: 1; + pointer-events: none; + white-space: nowrap; +} + +.room-item__content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + width: 100%; +} + +.room-item__mini-actions { + display: flex; + align-items: center; + gap: 6px; + justify-content: flex-start; + width: 100%; + margin-top: 0; + flex-wrap: wrap; +} + +.room-item.is-editing .room-item__mini-actions { + display: flex; +} + +.mini-action { + width: 30px; + height: 30px; + border-radius: 11px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.05); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.mini-action--wide { + width: 32px; + height: 32px; +} + +.room-list__divider { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 2px 2px; + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 11px; + grid-column: 1 / -1; +} + +.room-list__divider::before, +.room-list__divider::after { + content: ""; + height: 1px; + background: rgba(255,255,255,0.08); + flex: 1; +} + +.room-list__divider-label { + flex: 0 0 auto; +} + +.content { + position: relative; + padding: var(--content-pad); + overflow: auto; +} + +.app-shell--embed .content { + padding: 22px 16px 20px; +} + +.content-top { + display: none; + margin-bottom: 16px; + margin-top: -30px; +} + +.app-shell--embed .content-top { + margin-top: 0; +} + +.content-top.is-main { + display: block; +} + +.content-header { + min-height: 72px; + margin-bottom: 16px; + justify-content: flex-start; +} + +.app-shell--embed .content-header { + min-height: auto; + margin-bottom: 12px; +} + +.content-header.is-main .content-header__eyebrow, +.content-header.is-main .content-header__meta { + display: none; +} + +.content-header.is-main .content-header__title { + margin-top: 0; +} + +.content-header__eyebrow { + color: var(--text-muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.16em; +} + +.content-header__title { + margin: 6px 0 0; + font-family: var(--font-display); + font-size: clamp(30px, 3.4vw, 48px); + line-height: 1.02; + letter-spacing: -0.05em; +} + +.app-shell--embed .content-header__title { + font-size: clamp(24px, 2.5vw, 40px); +} + +.content-header__meta { + margin-top: 10px; + color: var(--text-muted); + font-size: 15px; +} + +.dashboard-grid { + position: relative; + width: 100%; +} + +.grid-surface { + display: grid; + gap: 18px; + width: 100%; +} + +.grid-card { + position: relative; + overflow: hidden; + border-radius: var(--radius-xl); + border: 1px solid var(--border); + background: linear-gradient(180deg, rgba(29, 33, 41, 0.96), rgba(18, 20, 27, 0.94)); + box-shadow: var(--shadow); + min-height: 120px; +} + +.grid-card.is-hidden { + opacity: 0.52; + border-style: dashed; + filter: saturate(0.8); +} + +.grid-card--ghost { + background: transparent; + border-color: transparent; + box-shadow: none; + min-height: 120px; +} + +.grid-card--ghost .grid-card__inner { + justify-content: space-between; + gap: 10px; +} + +.grid-card--ghost .grid-card__header { + gap: 8px; +} + +.grid-card--ghost .grid-card__icon { + background: rgba(255,255,255,0.02); + border-color: rgba(255,255,255,0.05); + color: rgba(237, 240, 246, 0.42); +} + +.grid-card--ghost .grid-card__title { + font-size: 18px; +} + +.grid-card--ghost .grid-card__subtitle { + margin-top: 2px; + font-size: 13px; +} + +.grid-card--ghost .grid-card__footer--edit { + margin-top: auto; +} + +.grid-card--ghost.is-editing { + background: + radial-gradient(circle at top left, rgba(103, 214, 255, 0.08), transparent 35%), + rgba(255, 255, 255, 0.01); + border-color: rgba(103, 214, 255, 0.18); + border-style: dashed; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} + +.grid-card--ghost.is-editing .grid-card__title-line, +.grid-card--ghost.is-editing .grid-card__subtitle { + color: var(--text-muted); +} + +.grid-card--ghost.is-editing .grid-card__icon { + color: var(--accent); +} + +.main-dashboard__cards .grid-card--door { + transition: background 220ms ease, border-color 220ms ease, box-shadow 220ms ease, transform 220ms ease, filter 220ms ease; +} + +.main-dashboard__cards .grid-card--door.is-active { + background: + radial-gradient(circle at top right, rgba(160, 221, 255, 0.16), transparent 42%), + linear-gradient(180deg, rgba(44, 50, 61, 0.98), rgba(22, 26, 34, 0.98)); + border-color: rgba(160, 221, 255, 0.3); + box-shadow: + 0 18px 36px rgba(0, 0, 0, 0.26), + inset 0 1px 0 rgba(255,255,255,0.05); + animation: door-card-pulse 3.2s ease-in-out infinite; +} + +.main-dashboard__cards .grid-card--door.is-active::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(circle at 50% 0%, rgba(160, 221, 255, 0.14), transparent 58%); + opacity: 0.35; + animation: door-card-glow 3.2s ease-in-out infinite; +} + +@keyframes door-card-pulse { + 0%, 100% { + transform: translateY(0); + filter: brightness(1); + } + 50% { + transform: translateY(-1px); + filter: brightness(1.03); + } +} + +@keyframes door-card-glow { + 0%, 100% { + opacity: 0.24; + } + 50% { + opacity: 0.48; + } +} + +.main-dashboard__cards .grid-card--door .grid-card__icon { + transform-style: preserve-3d; +} + +.main-dashboard__cards .grid-card--door.is-active .grid-card__icon > * { + display: inline-flex; + transform-origin: 50% 60%; + animation: door-icon-swing 2.8s ease-in-out infinite; + will-change: transform; +} + +@keyframes door-icon-swing { + 0%, 100% { + transform: perspective(90px) rotateY(0deg) rotateZ(0deg) scale(1); + } + 22% { + transform: perspective(90px) rotateY(-26deg) rotateZ(-2deg) scale(1.04); + } + 44% { + transform: perspective(90px) rotateY(0deg) rotateZ(0deg) scale(1); + } + 68% { + transform: perspective(90px) rotateY(22deg) rotateZ(2deg) scale(0.98); + } + 84% { + transform: perspective(90px) rotateY(0deg) rotateZ(0deg) scale(1); + } +} + +.room-entities-section__grid .grid-card.is-active { + background: linear-gradient(180deg, rgb(146 154 170 / 98%), rgb(44 47 53 / 96%)); + border-color: rgba(160, 221, 255, 0.28); + box-shadow: + 0 16px 32px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.room-entities-section__grid .grid-card.is-active .grid-card__title, +.room-entities-section__grid .grid-card.is-active .grid-card__title-line { + color: var(--text); +} + +.room-entities-section__grid .grid-card.is-active .grid-card__subtitle, +.room-entities-section__grid .grid-card.is-active .grid-card__kind { + color: rgba(255, 255, 255, 0.68); +} + +.room-entities-section__grid .grid-card.is-active .grid-card__icon { + background: rgba(255,255,255,0.08); + border-color: rgba(160, 221, 255, 0.18); + color: var(--accent); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} + +.grid-card--weather { + grid-column: 1 / span 3; +} + +.grid-card--auto { + grid-column: span 3; +} + +.grid-card--entity { + grid-column: span 3; +} + +.grid-card--entity-wide { + grid-column: span 3; +} + +.grid-card--cover { + grid-column: span 3; +} + +.grid-card--climate { + grid-column: span 3; +} + +.grid-card--full { + grid-column: 1 / -1; +} + +.grid-card__inner { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; + padding: 14px; +} + +.grid-card__top { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 12px; +} + +.grid-card__icon { + width: 42px; + height: 42px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.06); + color: var(--accent); + font-size: 22px; + flex: 0 0 auto; + --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); +} + +.grid-card__icon--active { + background: rgba(255,255,255,0.92); + color: #0f1115; + --icon-node-img-filter: brightness(0) saturate(100%); +} + +.grid-card__title { + font-size: 22px; + font-weight: 700; + line-height: 1.08; + display: flex; + flex-direction: column; + gap: 2px; +} + +.grid-card__title_weather{ + font-size: 40px; + font-weight: 700; + line-height: 1.08; + display: flex; + flex-direction: column; + gap: 2px; +} + +.grid-card__title-line { + display: block; +} + +.grid-card__subtitle { + margin-top: 6px; + color: var(--text-muted); + font-size: 20px; +} + +.grid-card__kind { + margin-top: 2px; + color: var(--text-subtle); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.grid-card__header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + min-width: 0; + flex: 1 1 auto; + width: 100%; +} + +.grid-card--tap { + cursor: pointer; + touch-action: manipulation; +} + +.grid-card--tap:focus-visible, +.room-item:focus-visible, +.icon-button:focus-visible, +.mushroom-button:focus-visible, +.round-button:focus-visible, +.mini-action:focus-visible { + outline: 2px solid rgba(103, 214, 255, 0.6); + outline-offset: 2px; +} + +.room-item.is-dragging { + opacity: 0.55; + transform: scale(0.985); + cursor: grabbing; +} + +.room-item.is-editing { + cursor: grab; + user-select: none; +} + +.grid-card__footer { + display: grid; + gap: 8px; + margin-top: auto; +} + +.grid-card__footer-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.grid-card__footer--edit { + padding-top: 6px; +} + +.grid-card__footer--edit .mushroom-button { + width: 100%; + min-height: 42px; + padding: 8px 10px; + border-radius: 12px; + font-size: 12px; + gap: 6px; +} + +.grid-card__footer--edit .mushroom-button--wide { + grid-column: 1 / -1; +} + +.grid-card__footer--edit .mushroom-button--small { + min-height: 42px; +} + +.grid-card__layout-settings { + display: grid; + gap: 10px; + margin-top: 2px; +} + +.grid-card__layout-settings .mushroom-button { + width: 100%; +} + +.mushroom-button { + min-height: 48px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.04); + color: var(--text); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, color 180ms ease; +} + +.mushroom-button:hover { + transform: translateY(-1px); + border-color: rgba(103, 214, 255, 0.24); +} + +.mushroom-button.is-on { + background: var(--surface-bright); + color: #101318; + border-color: rgba(255,255,255,0.55); +} + +.mushroom-button.is-disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.mushroom-button__icon { + font-size: 20px; + line-height: 1; +} + +.mushroom-button__body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + text-align: left; +} + +.mushroom-button--square .mushroom-button__body { + align-items: center; + text-align: center; +} + +.mushroom-button__title { + font-size: 14px; + font-weight: 700; +} + +.mushroom-button__subtitle { + color: inherit; + opacity: 0.7; + font-size: 12px; +} + +.mushroom-button--small { + min-height: 40px; + padding: 8px 10px; + border-radius: 12px; +} + +.mushroom-button--wide { + width: 100%; +} + +.mushroom-button--square { + width: 80px; + min-height: 80px; + padding: 10px 8px; + flex-direction: column; + gap: 6px; + text-align: center; +} + +.weather-card { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.weather-card--compact { + height: 100%; + justify-content: space-between; +} + +.weather-card__rows { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weather-card__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); +} + +.weather-card__row-label { + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.weather-card__row-value { + font-weight: 800; + font-size: 22px; + color: var(--text); +} + +.stat-row { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 12px 14px; + border-radius: 16px; + background: rgba(255,255,255,0.04); +} + +.stat-row__label { + color: var(--text-muted); +} + +.stat-row__value { + font-weight: 700; +} + +.climate-card { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; +} + +.climate-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + width: 100%; +} + +.climate-card__meta { + display: grid; + justify-items: end; + text-align: right; + gap: 2px; + flex: 0 0 auto; + min-width: 72px; +} + +.climate-card__meta-target { + font-family: var(--font-display); + font-size: clamp(24px, 2.8vw, 32px); + line-height: 1; + letter-spacing: -0.06em; +} + +.climate-card__meta-current { + color: var(--text-muted); + font-size: 11px; + line-height: 1.1; +} + +.climate-card .grid-card__header { + flex: 1 1 auto; +} + +.climate-card__buttons { + display: flex; + gap: 10px; + justify-content: center; +} + +.round-button { + width: 44px; + height: 44px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.04); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.cover-card { + display: flex; + flex-direction: column; + gap: 10px; +} + +.cover-card__rail { + display: grid; + gap: 8px; +} + +.cover-progress { + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: rgba(255,255,255,0.08); +} + +.cover-progress__value { + height: 100%; + background: linear-gradient(90deg, #66d6ff, #88f0c7); + border-radius: inherit; +} + +.cover-card__buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255,255,255,0.05); + color: var(--text-muted); + font-size: 12px; +} + +.loading-card { + grid-column: 1 / -1; + padding: 28px; + border-radius: var(--radius-xl); + background: rgba(255,255,255,0.04); + border: 1px solid var(--border); + color: var(--text-muted); +} + +.modal-backdrop { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 22px; + background: rgba(4, 6, 10, 0.72); + backdrop-filter: blur(12px); + z-index: 40; +} + +.modal-backdrop.is-open { + display: flex; +} + +body.is-mobile-ui #camera-modal { + display: none !important; +} + +.camera-modal { + position: relative; + width: calc(100vw - 24px); + height: calc(100vh - 24px); + max-width: 1600px; + max-height: none; + border-radius: 22px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(18, 20, 27, 0.98), rgba(10, 12, 17, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.camera-modal__close { + position: fixed; + top: 18px; + right: 18px; + z-index: 9999; + background: rgba(15, 18, 24, 0.88); + border-color: rgba(255,255,255,0.12); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35); + color: var(--text); + backdrop-filter: blur(10px); + pointer-events: auto; + touch-action: manipulation; +} + +.camera-modal__body { + flex: 1 1 auto; + min-height: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.camera-stage { + position: relative; + flex: 1 1 auto; + min-height: 0; + border-radius: 0; + overflow: hidden; + background: #0b0d11; + border: 1px solid rgba(255,255,255,0.06); +} + +.camera-stage__poster, +.camera-stage iframe, +.camera-stage video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border: 0; + background: #0b0d11; +} + +.camera-stage__poster { + z-index: 1; +} + +.camera-stage iframe, +.camera-stage video { + z-index: 2; + opacity: 0; + transition: opacity 180ms ease; +} + +.camera-stage iframe.is-ready, +.camera-stage video.is-ready { + opacity: 1; +} + +.camera-stage__placeholder { + position: absolute; + inset: 0; + display: none; + place-items: center; + text-align: center; + padding: 24px; + background: linear-gradient(180deg, rgba(13,16,22,0.08), rgba(13,16,22,0.38)); + backdrop-filter: blur(2px); + pointer-events: none; + z-index: 3; +} + +.camera-stage__placeholder.is-visible { + display: grid; +} + +.camera-stage__placeholder-icon { + width: 86px; + height: 86px; + border-radius: 30px; + display: grid; + place-items: center; + background: rgba(255,255,255,0.06); + font-size: 40px; + color: var(--accent); +} + +.camera-stage__placeholder-title { + margin-top: 16px; + font-size: 22px; + font-weight: 700; +} + +.camera-stage__placeholder-subtitle { + margin-top: 8px; + color: var(--text-muted); + max-width: 420px; +} + +.camera-modal__footer { + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 8px; + color: var(--text-muted); + padding: 12px 18px 16px; + min-height: 56px; +} + +.camera-modal__countdown { + font-weight: 700; + color: var(--text); + min-height: 1.2em; + line-height: 1.2; + display: block; + white-space: nowrap; +} + +.entity-modal { + width: min(78vw, 1080px); + max-height: 90vh; + border-radius: 30px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 22, 29, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.temperature-sensor-modal { + width: min(62vw, 760px); + max-height: 84vh; + border-radius: 30px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 22, 29, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.temperature-sensor-modal__header { + padding: 18px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.temperature-sensor-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + min-height: 14px; +} + +.temperature-sensor-modal__title { + margin-top: 5px; + font-size: 18px; + font-weight: 700; +} + +.temperature-sensor-modal__body { + padding: 18px; + display: grid; + gap: 14px; + overflow: auto; +} + +.temperature-sensor-modal__current { + display: grid; + gap: 8px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); +} + +.temperature-sensor-modal__current-label { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 11px; +} + +.temperature-sensor-modal__current-value { + font-size: 16px; + font-weight: 700; +} + +.temperature-sensor-modal__empty { + color: var(--text-muted); + padding: 20px 0; +} + +.temperature-sensor-modal__list { + display: grid; + gap: 10px; +} + +.temperature-sensor-modal__option { + width: 100%; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.04); + color: var(--text); + border-radius: 18px; + padding: 12px 14px; + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + cursor: pointer; + text-align: left; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; +} + +.temperature-sensor-modal__option:hover { + transform: translateY(-1px); + background: rgba(255,255,255,0.06); + border-color: var(--border-strong); +} + +.temperature-sensor-modal__option.is-active { + background: rgba(103, 214, 255, 0.14); + border-color: rgba(103, 214, 255, 0.28); +} + +.temperature-sensor-modal__option-main { + display: grid; + gap: 4px; + min-width: 0; +} + +.temperature-sensor-modal__option-name { + font-weight: 700; + line-height: 1.15; +} + +.temperature-sensor-modal__option-meta { + color: var(--text-muted); + font-size: 13px; + line-height: 1.2; +} + +.temperature-sensor-modal__option-value { + color: var(--accent); + font-weight: 800; + white-space: nowrap; +} + +.entity-modal__header { + padding: 18px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.entity-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + min-height: 14px; +} + +.entity-modal__title { + margin-top: 5px; + font-size: 18px; + font-weight: 700; +} + +.entity-modal__body { + padding: 18px; + display: grid; + gap: 18px; + overflow: auto; +} + +.entity-modal__fallback { + color: var(--text-muted); + padding: 20px 0; +} + +.entity-modal__cover, +.entity-modal__climate { + display: grid; + gap: 16px; +} + +.entity-modal__cover { + grid-template-columns: minmax(0, 1fr) 118px; + align-items: stretch; +} + +.entity-modal__rail { + display: grid; + gap: 14px; + min-width: 0; +} + +.entity-modal__rail--cover { + grid-template-rows: auto minmax(0, 1fr) auto; + justify-items: center; + align-content: stretch; + gap: 12px; +} + +.entity-modal__cover-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.entity-modal__cover-label, +.entity-modal__current-label, +.entity-modal__options-title { + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.entity-modal__cover-value, +.entity-modal__current-value { + font-size: 18px; + font-weight: 800; + color: var(--text); +} + +.entity-modal__cover-track { + position: relative; + justify-self: center; + width: 56px; + min-height: 280px; + border-radius: 999px; + background: + radial-gradient(circle at 50% 6%, rgba(255,255,255,0.10), transparent 28%), + linear-gradient(180deg, rgba(18, 20, 27, 0.96), rgba(10, 12, 17, 0.98)); + overflow: hidden; + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.06), + inset 0 -18px 28px rgba(0, 0, 0, 0.26); + touch-action: none; + user-select: none; + cursor: ns-resize; +} + +.entity-modal__cover-fill { + position: absolute; + left: 0; + right: 0; + bottom: 0; + border-radius: inherit; + background: + linear-gradient(180deg, rgba(103, 214, 255, 0.96) 0%, rgba(126, 236, 220, 0.94) 58%, rgba(188, 255, 242, 0.96) 100%); + box-shadow: + 0 10px 26px rgba(103, 214, 255, 0.14), + inset 0 1px 0 rgba(255,255,255,0.20); + width: 100%; + height: 0%; +} + +.entity-modal__cover-handle { + position: absolute; + left: 50%; + bottom: -10px; + width: 36px; + height: 20px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.18); + background: + linear-gradient(180deg, rgba(255,255,255,0.98), rgba(228,236,244,0.9)); + box-shadow: + 0 10px 22px rgba(0, 0, 0, 0.26), + inset 0 1px 0 rgba(255,255,255,0.7); + transform: translateX(-50%); + z-index: 2; + pointer-events: none; +} + +.entity-modal__actions--vertical { + display: grid; + grid-auto-rows: minmax(52px, auto); + gap: 10px; + align-content: center; + justify-self: stretch; +} + +.entity-modal__actions--vertical .mushroom-button--square { + width: 100%; + min-height: 72px; +} + +.entity-modal__actions--vertical .mushroom-button__body { + gap: 2px; +} + +.entity-modal__actions--vertical .mushroom-button__icon { + font-size: 18px; +} + +.entity-modal__actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.entity-modal__actions .mushroom-button--square { + width: 100%; + min-height: 96px; +} + +.entity-modal__actions .mushroom-button__title { + line-height: 1.05; +} + +.entity-modal__actions.entity-modal__actions--vertical { + grid-template-columns: 1fr; + grid-auto-rows: minmax(52px, auto); + align-content: center; +} + +.entity-modal__actions.entity-modal__actions--vertical .mushroom-button--square { + min-height: 72px; +} + +.entity-modal__climate-summary { + display: grid; + gap: 8px; + justify-items: center; + text-align: center; + padding-top: 8px; +} + +.entity-modal__target-row { + display: flex; + align-items: baseline; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +.entity-modal__target-state { + color: var(--text-muted); + font-size: 17px; + font-weight: 700; +} + +.entity-modal__target-temp { + font-family: var(--font-display); + font-size: clamp(44px, 6vw, 72px); + line-height: 1; + letter-spacing: -0.06em; +} + +.entity-modal__target-temp span { + font-size: 0.36em; + vertical-align: top; +} + +.entity-modal__temperature-controls { + display: flex; + justify-content: center; + gap: 14px; +} + +.entity-modal__round-button { + width: 54px; + height: 54px; +} + +.entity-modal__modes { + display: grid; + gap: 12px; +} + +.entity-modal__options-block { + display: grid; + gap: 8px; +} + +.entity-modal__chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.entity-chip { + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.04); + color: var(--text); + border-radius: 999px; + padding: 8px 12px; + min-height: 36px; + cursor: pointer; + text-align: center; + line-height: 1.05; +} + +.entity-chip.is-active { + background: rgba(103, 214, 255, 0.16); + border-color: rgba(103, 214, 255, 0.28); + color: var(--text); +} + +.confirm-modal { + width: min(420px, calc(100vw - 36px)); + border-radius: 28px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 23, 30, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + padding: 22px; +} + +.confirm-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.confirm-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; +} + +.confirm-modal__title { + margin-top: 6px; + font-size: 20px; + font-weight: 800; + line-height: 1.08; +} + +.confirm-modal__body { + margin-top: 14px; + color: var(--text-muted); + font-size: 15px; + line-height: 1.4; +} + +.confirm-modal__footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.confirm-modal .mushroom-button { + min-width: 100px; +} + +.hidden { + display: none !important; +} + +.main-dashboard { + display: grid; + gap: 18px; + grid-column: 1 / -1; + width: 100%; +} + +.main-dashboard__hero { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 18px; + align-items: stretch; +} + +.main-dashboard__weather-slot { + min-width: 0; + display: flex; + align-self: stretch; +} + +.main-dashboard__weather-slot .grid-card--weather { + width: 100%; + max-width: none; + grid-column: auto; + height: 100%; + min-height: 100%; +} + +.main-dashboard__actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + align-items: start; + min-width: 0; + align-self: start; +} + +.main-dashboard__hero-stack { + display: grid; + gap: 14px; + min-width: 0; + align-self: start; + align-content: start; +} + +.main-print-strip { + width: 100%; + min-height: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.main-print-strip__inner { + display: grid; + gap: 6px; + padding: 0 2px 0; +} + +.main-print-strip__header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-height: 16px; +} + +.main-print-strip__badge { + flex: 0 0 auto; + padding: 2px 8px; + border-radius: 999px; + background: rgba(103, 214, 255, 0.14); + border: 1px solid rgba(103, 214, 255, 0.22); + color: var(--accent); + font-size: 11px; + font-weight: 800; + line-height: 1; +} + +.main-print-strip__progress { + height: 6px; + border-radius: 999px; + overflow: hidden; + background: rgba(255,255,255,0.08); +} + +.main-print-strip__progress-fill { + width: 0%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #67d6ff, #88f0c7); +} + +.main-print-strip__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-height: 14px; +} + +.main-print-strip__remaining { + color: var(--text-muted); + font-size: 12px; + font-weight: 700; + line-height: 1; + white-space: nowrap; + letter-spacing: 0.02em; +} + +.main-quick-action { + min-height: 0; + padding: 12px 10px 11px; + border-radius: 20px; + border: 1px solid rgba(0, 0, 0, 0.04); + background: var(--quick-action-bg, rgba(255, 255, 255, 0.08)); + color: var(--quick-action-color, var(--text)); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.14); + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 6px; + text-align: center; + cursor: pointer; + transition: transform 180ms ease, box-shadow 180ms ease, filter 180ms ease; + position: relative; + overflow: hidden; +} + +.main-quick-action:hover { + transform: translateY(-1px); + filter: saturate(1.03); +} + +.main-quick-action.is-active { + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22); +} + +.main-quick-action__icon { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--quick-action-icon-color, currentColor); + flex: 0 0 auto; +} + +.main-quick-action__icon i { + font-size: 20px; + line-height: 1; +} + +.main-quick-action__icon .icon-node, +.main-quick-action__icon .icon-node__img, +.main-quick-action__icon .icon-node__svg { + width: 100%; + height: 100%; +} + +.main-quick-action__label { + font-size: 13px; + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.01em; + word-break: break-word; +} + +.main-dashboard__hero-spacer { + flex: 1 1 auto; + min-height: 1px; + border-radius: var(--radius-xl); +} + +.main-boiler-card { + min-height: 112px; + border-color: rgba(255, 186, 92, 0.18); + background: + radial-gradient(circle at top right, rgba(255, 186, 92, 0.14), transparent 42%), + linear-gradient(180deg, rgba(37, 31, 24, 0.98), rgba(19, 20, 27, 0.96)); +} + +.main-boiler-card__inner { + display: grid; + gap: 6px; + padding: 10px 12px 10px; +} + +.main-boiler-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.main-boiler-card__text { + display: grid; + gap: 2px; +} + +.main-boiler-card__eyebrow { + color: rgba(255, 214, 160, 0.72); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.main-boiler-card__body { + display: grid; + grid-template-columns: minmax(86px, 96px) minmax(0, 1fr); + gap: 8px; + align-items: center; + min-width: 0; +} + +.main-boiler-card__value-column { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + min-width: 0; +} + +.main-boiler-card__range { + flex: 0 0 auto; + padding: 4px 8px; + border-radius: 999px; + background: rgba(255, 186, 92, 0.11); + color: rgba(255, 214, 160, 0.9); + font-size: 11px; + font-weight: 700; +} + +.main-boiler-card__value-row { + display: flex; + align-items: baseline; + gap: 6px; +} + +.main-boiler-card__value-label { + color: rgba(255, 214, 160, 0.7); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.main-boiler-card__value { + color: var(--text); + font-family: var(--font-display); + font-size: clamp(22px, 2.4vw, 28px); + line-height: 0.98; + letter-spacing: -0.06em; +} + +.main-boiler-card__unit { + color: rgba(255, 255, 255, 0.72); + font-size: 12px; + font-weight: 700; + transform: translateY(-2px); +} + +.main-boiler-card__chart-wrap { + position: relative; + min-height: 60px; + border-radius: 14px; + overflow: hidden; + background: linear-gradient(180deg, rgba(255, 186, 92, 0.04), rgba(255, 186, 92, 0.01)); +} + +.main-boiler-card__chart { + width: 100%; + height: 100%; + display: block; +} + +.main-boiler-card__loading { + position: absolute; + left: 10px; + bottom: 6px; + color: rgba(255, 214, 160, 0.72); + font-size: 12px; + font-weight: 600; +} + +.main-dashboard__cards { + width: 100%; + grid-template-columns: repeat(15, minmax(0, 1fr)); +} + +.main-dashboard__cards .grid-card { + grid-column: span 3; + width: 100%; + aspect-ratio: 1 / 1; + min-height: 0; +} + +.main-dashboard__cards .grid-card__inner { + padding: 12px; +} + +.main-dashboard__cards .grid-card--cover { + justify-self: stretch; + max-width: none; +} + +.room-entities-section { + display: grid; + gap: 14px; + width: 100%; + grid-column: 1 / -1; +} + +.room-entities-section + .room-entities-section { + margin-top: 16px; +} + +.room-entities-section__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + padding: 0 4px; +} + +.room-entities-section__title { + color: var(--text-subtle); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.room-entities-section__meta { + color: var(--text-subtle); + font-size: 12px; +} + +.room-entities-section--hidden { + padding-top: 16px; + border-top: 1px solid rgba(255,255,255,0.07); +} + +.room-entities-section--hidden .room-entities-section__title { + color: var(--warning); +} + +.battery-room { + display: grid; + gap: 14px; +} + +.battery-room__header { + align-items: flex-start; +} + +.battery-room__list { + display: grid; + gap: 12px; +} + +.battery-room__empty { + grid-column: 1 / -1; +} + +.battery-card { + min-height: 96px; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(255,255,255,0.04), transparent 34%), + linear-gradient(180deg, rgba(27, 30, 37, 0.98), rgba(18, 20, 26, 0.98)); + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.battery-card--critical, +.battery-card--empty { + border-color: rgba(255, 125, 125, 0.3); +} + +.battery-card--low { + border-color: rgba(255, 191, 84, 0.28); +} + +.battery-card--unavailable { + border-color: rgba(237, 240, 246, 0.14); +} + +.battery-card--unknown { + border-color: rgba(103, 214, 255, 0.18); +} + +.battery-card--ok { + border-color: rgba(136, 240, 199, 0.14); +} + +.battery-card__inner { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 16px 18px; +} + +.battery-card__main { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.battery-card__icon { + width: 46px; + height: 46px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.05); + color: #ffc94d; + flex: 0 0 auto; +} + +.battery-card--critical .battery-card__icon, +.battery-card--empty .battery-card__icon { + color: #ff7d7d; +} + +.battery-card--low .battery-card__icon { + color: #ffbf54; +} + +.battery-card--unavailable .battery-card__icon { + color: rgba(237, 240, 246, 0.52); +} + +.battery-card--unknown .battery-card__icon { + color: var(--accent); +} + +.battery-card--ok .battery-card__icon { + color: var(--accent-2); +} + +.battery-card__text { + min-width: 0; +} + +.battery-card__title { + font-size: 18px; + font-weight: 800; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.battery-card__source { + margin-top: 5px; + color: var(--text-muted); + font-size: 13px; +} + +.battery-card__side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + justify-content: center; + text-align: right; +} + +.battery-card__percent { + font-size: 26px; + line-height: 1; + font-weight: 800; + color: var(--text); +} + +.battery-card--critical .battery-card__percent, +.battery-card--empty .battery-card__percent { + color: #ff7d7d; +} + +.battery-card--low .battery-card__percent { + color: #ffbf54; +} + +.battery-card--unavailable .battery-card__percent, +.battery-card--unknown .battery-card__percent { + color: var(--text-muted); +} + +.battery-card--ok .battery-card__percent { + color: var(--accent-2); +} + +.battery-card__status { + color: var(--text-subtle); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.battery-card__footer { + grid-column: 1 / -1; + margin-top: -2px; + padding-left: 60px; + color: var(--text-muted); + font-size: 13px; +} + +.room-entities-section__grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); + width: 100%; + align-items: stretch; +} + +.room-entities-section__grid .grid-card--entity, +.room-entities-section__grid .grid-card--entity-wide, +.room-entities-section__grid .grid-card--cover, +.room-entities-section__grid .grid-card--climate, +.room-entities-section__grid .grid-card--auto, +.room-entities-section__grid .grid-card--ghost { + grid-column: span 1; +} + +.room-entities-section__grid--hidden .grid-card { + opacity: 0.86; + border-style: dashed; +} + +.room-entities-section__grid--hidden .grid-card__footer--edit .mushroom-button { + background: rgba(255,255,255,0.03); +} + +.dashboard-grid > .grid-surface { + grid-template-columns: repeat(15, minmax(0, 1fr)); + width: 100%; +} + +@media (max-width: 1200px) { + .app-shell { + grid-template-columns: 230px 1fr; + } + + .main-dashboard__hero { + grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); + } + + .main-boiler-card { + min-height: 108px; + } + + .main-quick-action { + padding: 10px 10px 9px; + border-radius: 18px; + } + + .main-quick-action__label { + font-size: 12px; + } + + .grid-card--weather { + grid-column: 1 / -1; + } + + .grid-card--auto, + .grid-card--entity, + .grid-card--entity-wide, + .grid-card--cover, + .grid-card--climate { + grid-column: span 3; + } +} + +@media (max-width: 920px) { + body { + overflow: hidden; + } + + .app-shell { + grid-template-columns: 1fr; + height: 100dvh; + min-height: 100dvh; + } + + .app-shell.is-mobile { + overflow: hidden; + } + + .app-shell.is-mobile .sidebar, + .app-shell.is-mobile .content { + min-height: 0; + height: 100%; + } + + .app-shell.is-mobile .sidebar { + overflow: auto; + border-right: 0; + border-bottom: 1px solid rgba(255,255,255,0.05); + padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); + } + + .app-shell.is-mobile .content { + overflow: auto; + padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); + } + + .app-shell.is-mobile.mobile-view-spaces .content { + display: none; + } + + .app-shell.is-mobile.mobile-view-room .sidebar { + display: none; + } + + .app-shell.is-mobile .content-header { + align-items: center; + gap: 12px; + min-height: 0; + margin-bottom: 14px; + } + + .app-shell.is-mobile .content-header__back { + display: inline-flex !important; + } + + .app-shell.is-mobile .content-header__title { + margin-top: 0; + font-size: clamp(26px, 7vw, 36px); + } + + .app-shell.is-mobile .content-header__meta { + font-size: 13px; + } + + .app-shell.is-mobile .room-item.is-selected { + background: linear-gradient(180deg, rgba(28, 31, 39, 0.92), rgba(20, 23, 30, 0.92)); + border-color: var(--border); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + } + + .app-shell.is-mobile .room-item.is-selected .room-item__icon { + background: rgba(255,255,255,0.04); + color: var(--accent); + --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); + } + + .app-shell.is-mobile .content-top { + margin-top: -12px; + } + + .main-dashboard__hero { + grid-template-columns: 1fr; + } + + .main-dashboard__hero-stack { + gap: 12px; + } + + .main-dashboard__actions { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + } + + .main-quick-action { + min-height: 76px; + } + + .grid-card--weather { + grid-column: 1 / -1; + } + + .main-dashboard__cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .main-dashboard__cards .grid-card { + grid-column: span 1; + } + + .room-entities-section__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .room-entities-section__grid .grid-card--entity, + .room-entities-section__grid .grid-card--entity-wide, + .room-entities-section__grid .grid-card--cover, + .room-entities-section__grid .grid-card--climate, + .room-entities-section__grid .grid-card--auto, + .room-entities-section__grid .grid-card--ghost { + grid-column: span 1; + } + + .grid-card--ghost { + display: none; + } + + .main-quick-action { + min-height: 72px; + } + + .main-boiler-card { + min-height: 106px; + } + + .main-boiler-card__body { + grid-template-columns: minmax(82px, 92px) minmax(0, 1fr); + gap: 6px; + } + + .camera-modal { + width: calc(100vw - 16px); + height: calc(100vh - 16px); + border-radius: 18px; + } + + .entity-modal { + width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + border-radius: 24px; + } + + .temperature-sensor-modal { + width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + border-radius: 24px; + } + + .entity-modal__body { + padding: 16px; + gap: 14px; + } + + .entity-modal__cover { + grid-template-columns: minmax(0, 1fr) 88px; + gap: 12px; + } + + .entity-modal__cover-track { + width: 44px; + min-height: 240px; + border-radius: 999px; + } + + .entity-modal__cover-handle { + width: 30px; + height: 18px; + } + + .entity-modal__actions--vertical { + grid-auto-rows: minmax(48px, auto); + gap: 8px; + } +} diff --git a/assets/app.js b/assets/app.js new file mode 100755 index 0000000..7538504 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,4553 @@ +(function () { + const bootstrap = window.APP_BOOTSTRAP || {}; + const MOBILE_BREAKPOINT = 920; + const state = { + snapshot: bootstrap, + embedMode: Boolean(bootstrap?.ui?.embed), + selectedRoomId: 'main', + isMobileViewport: false, + mobileView: 'spaces', + editMode: Boolean(bootstrap?.settings?.edit_mode), + clockTimer: null, + hlsInstance: null, + popupDismissTimer: null, + popupAutoOpenBlockedUntil: 0, + roomAutoReturnTimer: null, + mainBoilerHistory: { + entityId: null, + points: [], + loadedAt: 0, + loading: false, + error: null, + promise: null, + }, + entityPopup: { + active: false, + entityId: null, + }, + temperatureSensorPopup: { + active: false, + roomId: null, + }, + lastPopupSignature: '', + lastEntityPopupSignature: '', + lastTemperatureSensorPopupSignature: '', + roomDrag: null, + layoutItemSettingsOpen: {}, + confirmResolver: null, + haSocket: null, + haSocketState: 'disconnected', + haReconnectTimer: null, + haReconnectDelay: 1000, + haSubscribeId: 1, + roomSelectionToken: 0, + snapshotPollTimer: null, + }; + + const els = {}; + + function $(id) { + return document.getElementById(id); + } + + function q(sel, root = document) { + return root.querySelector(sel); + } + + function qa(sel, root = document) { + return Array.from(root.querySelectorAll(sel)); + } + + function mobileViewportQuery() { + return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); + } + + function isMobileViewport() { + return Boolean(state.isMobileViewport); + } + + function isMobileRoomView() { + return isMobileViewport() && state.mobileView === 'room'; + } + + function detectEmbeddedContext() { + if (Boolean(bootstrap?.ui?.embed)) { + return true; + } + + try { + return window.self !== window.top; + } catch (error) { + return true; + } + } + + function setMobileView(nextView) { + if (!isMobileViewport()) { + state.mobileView = 'room'; + return; + } + + state.mobileView = nextView === 'room' ? 'room' : 'spaces'; + } + + function syncViewportState() { + const query = mobileViewportQuery(); + const nextIsMobile = Boolean(query.matches); + const changed = nextIsMobile !== state.isMobileViewport; + + state.isMobileViewport = nextIsMobile; + if (nextIsMobile) { + state.mobileView = changed ? 'spaces' : (state.mobileView || 'spaces'); + } else { + state.mobileView = 'room'; + clearRoomAutoReturnTimer(); + scheduleRoomAutoReturn(state.selectedRoomId || 'main'); + } + + if (nextIsMobile) { + clearRoomAutoReturnTimer(); + if (state.selectedRoomId === 'batteries' && state.snapshot) { + state.selectedRoomId = 'main'; + patchSnapshotSelection('main'); + } + } + + return nextIsMobile; + } + + function iconClass(icon) { + if (!icon) return 'mdi mdi-help-circle-outline'; + return icon.startsWith('mdi:') ? `mdi ${icon.replace('mdi:', 'mdi-')}` : icon; + } + + function normalizeIconSource(source) { + const value = String(source ?? '').trim(); + if (!value) return ''; + return value.replace(/\.svg$/i, ''); + } + + function createSvgIcon(definition) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', definition?.viewBox || '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + svg.classList.add('icon-node__svg'); + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', definition?.path || ''); + svg.appendChild(path); + return svg; + } + + function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { + const [prefix, name] = source.split(':', 2); + const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; + const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; + if (typeof getIcon !== 'function') { + return null; + } + + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + Promise.resolve(getIcon(name)).then((definition) => { + if (!definition || !wrap.isConnected) return; + wrap.replaceChildren(createSvgIcon(definition)); + }).catch(() => { + if (!wrap.isConnected) return; + wrap.replaceChildren(createIconElement(fallback)); + }); + return wrap; + } + + function createIconElement(icon, fallback = 'mdi:help-circle-outline') { + const source = normalizeIconSource(icon) || fallback; + + if (source.startsWith('mdi:')) { + const i = document.createElement('i'); + i.className = iconClass(source); + return i; + } + + if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) { + const custom = createCustomIconElement(source, fallback); + if (custom) { + return custom; + } + const mappedSource = source.startsWith('fas:') + ? source.replace(/^fas:/, 'fa-solid:') + : source.startsWith('far:') + ? source.replace(/^far:/, 'fa-regular:') + : source.replace(/^fab:/, 'fa-brands:'); + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + const img = document.createElement('img'); + img.className = 'icon-node__img'; + img.alt = ''; + img.decoding = 'async'; + img.loading = 'lazy'; + img.referrerPolicy = 'no-referrer'; + img.src = `https://api.iconify.design/${mappedSource}.svg`; + img.addEventListener('error', () => { + if (img.dataset.fallbackApplied === '1') return; + img.dataset.fallbackApplied = '1'; + wrap.replaceChildren(createIconElement(fallback)); + }); + wrap.appendChild(img); + return wrap; + } + + const custom = createCustomIconElement(source, fallback); + if (custom) { + return custom; + } + + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + + if (source.includes(':')) { + const img = document.createElement('img'); + img.className = 'icon-node__img'; + img.alt = ''; + img.decoding = 'async'; + img.loading = 'lazy'; + img.referrerPolicy = 'no-referrer'; + img.src = `https://api.iconify.design/${source}.svg`; + img.addEventListener('error', () => { + if (img.dataset.fallbackApplied === '1') return; + img.dataset.fallbackApplied = '1'; + wrap.replaceChildren(createIconElement(fallback)); + }); + wrap.appendChild(img); + return wrap; + } + + return createIconElement(fallback); + } + + function esc(value) { + return String(value ?? ''); + } + + function entitySortTime(entity) { + const value = entity?.last_changed || entity?.last_updated || entity?.attributes?.last_changed || ''; + const time = Date.parse(value); + return Number.isFinite(time) ? time : 0; + } + + function splitEntityName(name) { + const value = String(name ?? ''); + const parts = value.split('|').map((part) => part.trim()).filter(Boolean); + if (parts.length >= 2) { + return [parts[0], parts.slice(1).join(' | ')]; + } + return [value.trim()]; + } + + function buildEntityTitle(name) { + const lines = splitEntityName(name); + const title = document.createElement('div'); + title.className = 'grid-card__title'; + const mainLine = document.createElement('span'); + mainLine.className = 'grid-card__title-line'; + mainLine.textContent = lines[0] || ''; + title.appendChild(mainLine); + + if (lines.length > 1) { + const subtitle = document.createElement('span'); + subtitle.className = 'grid-card__subtitle'; + subtitle.textContent = lines.slice(1).join(' | '); + title.appendChild(subtitle); + } + return title; + } + + function isMainDisplayEntity(entity) { + const domain = String(entity?.domain || '').toLowerCase(); + const isDoorContact = Boolean(entity?.is_door_contact); + const state = String(entity?.state || '').toLowerCase(); + if (domain === 'cover') { + return ['open', 'opening', 'closing'].includes(state); + } + if (domain === 'binary_sensor' && isDoorContact) { + return ['on', 'open'].includes(state); + } + return ['on', 'cool', 'heat', 'heating', 'cooling'].includes(state) || (domain === 'fan' && state === 'on'); + } + + function optimisticStateForCommand(entity, command) { + const current = String(entity?.state || '').toLowerCase(); + const active = isMainDisplayEntity(entity); + + switch (command) { + case 'turn_on': + return 'on'; + case 'turn_off': + return 'off'; + case 'open': + return 'open'; + case 'close': + return 'closed'; + case 'toggle': + if (String(entity?.domain || '').toLowerCase() === 'cover') { + return active ? 'closed' : 'open'; + } + return active ? 'off' : 'on'; + case 'stop': + return current || null; + default: + return null; + } + } + + function popupTriggerEntities() { + const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities; + const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities; + const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; + return new Set(triggers.map((value) => String(value))); + } + + function cameraConfig() { + return state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; + } + + function resolvePopupStreamMode(streamUrl, explicitMode = '') { + const normalizedUrl = String(streamUrl || '').trim(); + const urlMode = inferStreamMode(normalizedUrl); + const mode = String(explicitMode || '').trim().toLowerCase(); + + if (!normalizedUrl) { + return mode || 'poster'; + } + + if (urlMode === 'iframe') { + return 'iframe'; + } + + if (urlMode === 'hls') { + return 'hls'; + } + + if (urlMode === 'video') { + return 'video'; + } + + if (mode && !['poster', 'hls'].includes(mode)) { + return mode; + } + + return 'poster'; + } + + function mergePopupWithCamera(popup = {}) { + const camera = cameraConfig(); + const streamUrl = popup.stream_url || camera.stream_url || ''; + const streamMode = resolvePopupStreamMode(streamUrl, popup.stream_mode || camera.stream_mode || ''); + return { + ...popup, + poster_url: popup.poster_url || camera.poster_url || '', + stream_url: streamUrl, + stream_mode: streamMode, + title: popup.title || 'Камера', + }; + } + + function pluralizeRooms(count) { + const n = Math.abs(Number(count) || 0) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return 'пространств'; + if (n1 > 1 && n1 < 5) return 'пространства'; + if (n1 === 1) return 'пространство'; + return 'пространств'; + } + + function pluralizeRu(count, one, few, many) { + const n = Math.abs(Number(count) || 0) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return many; + if (n1 > 1 && n1 < 5) return few; + if (n1 === 1) return one; + return many; + } + + function pluralizeEntities(count) { + return pluralizeRu(count, 'объект', 'объекта', 'объектов'); + } + + function pluralizeActiveEntities(count) { + return pluralizeRu(count, 'активный', 'активных', 'активных'); + } + + function pluralizeIncludedEntities(count) { + return pluralizeRu(count, 'включенный объект', 'включенных объекта', 'включенных объектов'); + } + + function formatTime(date = new Date()) { + return new Intl.DateTimeFormat('ru-RU', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); + } + + function formatDate(date = new Date()) { + const weekday = new Intl.DateTimeFormat('ru-RU', { weekday: 'long' }).format(date); + const dayMonth = new Intl.DateTimeFormat('ru-RU', { + day: 'numeric', + month: 'long', + }).format(date); + return `${weekday}, ${dayMonth}`; + } + + function buildUrl(action, params = {}) { + const url = new URL('api.php', window.location.href); + url.searchParams.set('action', action); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, value); + } + }); + return url.toString(); + } + + async function apiGet(action, params = {}) { + const res = await fetch(buildUrl(action, params), { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + return res.json(); + } + + async function apiPost(action, payload = {}) { + const res = await fetch(buildUrl(action), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), + }); + const json = await res.json(); + if (!res.ok || json.ok === false) { + throw new Error(json.error || `Request failed: ${res.status}`); + } + return json; + } + + async function fetchSnapshot(roomId = state.selectedRoomId || 'main') { + return apiGet('snapshot', { space_id: roomId || 'main' }); + } + + async function loadSnapshot(roomId = state.selectedRoomId || 'main') { + const snapshot = await fetchSnapshot(roomId); + state.snapshot = snapshot; + return snapshot; + } + + function currentRoom() { + const snapshot = state.snapshot || {}; + if (state.selectedRoomId === 'batteries') { + return snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || null; + } + const spaces = snapshot.spaces || snapshot.rooms || []; + return spaces.find((space) => space.id === state.selectedRoomId) || snapshot.selected_space || snapshot.selected_room || null; + } + + function roomEntityCollection(snapshot, roomId) { + const room = roomId === 'main' + ? { entities: snapshot.main_entities || [] } + : roomId === 'batteries' + ? (snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || { entities: [] }) + : snapshot.space_index?.[roomId] + || snapshot.space_entities?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null); + return room?.entities || []; + } + + function sortRoomEntities(entities) { + return (Array.isArray(entities) ? entities : []) + .slice() + .sort((left, right) => { + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left?.name || '').localeCompare(String(right?.name || ''), 'ru'); + }); + } + + function sortMainEntities(entities) { + return (Array.isArray(entities) ? entities : []) + .slice() + .sort((left, right) => { + const leftTime = entitySortTime(left); + const rightTime = entitySortTime(right); + if (leftTime !== rightTime) return leftTime - rightTime; + + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + + return String(left?.name || '').localeCompare(String(right?.name || ''), 'ru'); + }); + } + + function roomEntities(snapshot, roomId) { + const collection = roomEntityCollection(snapshot, roomId).filter((entity) => entity.visible !== false); + return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection); + } + + function roomEntitiesIncludingHidden(snapshot, roomId) { + const collection = roomEntityCollection(snapshot, roomId); + return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection); + } + + function roomLayoutItemCollection(snapshot, roomId) { + if (!snapshot || !roomId || roomId === 'main') { + return []; + } + + const room = snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null); + const items = Array.isArray(room?.layout_items) ? room.layout_items : []; + return items.filter((item) => item && typeof item === 'object' && String(item.type || 'ghost') === 'ghost'); + } + + function roomLayoutItems(snapshot, roomId) { + return roomLayoutItemCollection(snapshot, roomId) + .slice() + .sort((left, right) => { + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left?.id || '').localeCompare(String(right?.id || ''), 'ru'); + }); + } + + function roomGridEntries(snapshot, roomId) { + const entries = []; + + roomEntities(snapshot, roomId).forEach((entity) => { + entries.push({ + kind: 'entity', + id: entity.entity_id, + order: Number(entity.order ?? 9999) || 9999, + sortLabel: String(entity.name || entity.entity_id || ''), + payload: entity, + }); + }); + + if (!isMobileViewport() && roomId !== 'main') { + roomLayoutItems(snapshot, roomId).forEach((item) => { + entries.push({ + kind: 'layout', + id: item.id, + order: Number(item.order ?? 9999) || 9999, + sortLabel: String(item.id || ''), + payload: item, + }); + }); + } + + entries.sort((left, right) => { + if (left.order !== right.order) { + return left.order - right.order; + } + if (left.kind !== right.kind) { + return left.kind === 'entity' ? -1 : 1; + } + return left.sortLabel.localeCompare(right.sortLabel, 'ru'); + }); + + return entries; + } + + function isTemperatureSensorEntity(entity) { + if (!entity || String(entity.domain || '').toLowerCase() !== 'sensor') { + return false; + } + + const entityId = String(entity.entity_id || ''); + const attributes = entity.attributes || {}; + const deviceClass = String(attributes.device_class || '').toLowerCase(); + const unit = String(attributes.unit_of_measurement || '').trim().toLowerCase(); + + return deviceClass === 'temperature' + || unit === '°c' + || unit === 'c' + || entityId.endsWith('_temperature'); + } + + function roomTemperatureSensorLabel(entity) { + const name = String(entity?.name || entity?.attributes?.friendly_name || entity?.entity_id || 'Датчик'); + const value = entity?.attributes?.current_temperature ?? entity?.attributes?.temperature ?? entity?.state ?? null; + const numeric = Number(value); + const valueText = Number.isFinite(numeric) ? `${Math.round(numeric)}°` : '—'; + + return { + name, + valueText, + meta: String(entity?.entity_id || ''), + }; + } + + function roomTemperatureSensorCandidates(snapshot, roomId) { + const room = snapshot?.space_index?.[roomId] + || (snapshot?.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot?.selected_room?.id === roomId ? snapshot.selected_room : null); + const entities = Array.isArray(room?.entities) ? room.entities : []; + const selectedId = String(room?.temperature_sensor_entity_id || '').trim(); + const candidates = entities.filter((entity) => entity && isTemperatureSensorEntity(entity)); + + candidates.sort((left, right) => { + const leftSelected = String(left.entity_id || '') === selectedId ? 0 : 1; + const rightSelected = String(right.entity_id || '') === selectedId ? 0 : 1; + if (leftSelected !== rightSelected) return leftSelected - rightSelected; + + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + + return String(left.name || left.entity_id || '').localeCompare(String(right.name || right.entity_id || ''), 'ru'); + }); + + return candidates; + } + + function roomTemperatureBadge(snapshot, room) { + const roomId = String(room?.id || ''); + if (!roomId || roomId === 'main') { + return null; + } + + const roomIndex = snapshot?.space_index?.[roomId] || room || {}; + const selectedId = String(roomIndex.temperature_sensor_entity_id || room.temperature_sensor_entity_id || '').trim(); + const entities = Array.isArray(roomIndex.entities) ? roomIndex.entities : []; + + if (selectedId) { + const selected = entities.find((entity) => entity && String(entity.entity_id || '') === selectedId); + if (selected) { + const value = selected.attributes?.current_temperature ?? selected.attributes?.temperature ?? selected.state ?? null; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return `${Math.round(numeric)}°`; + } + } + + if (room.temperature_badge) { + return room.temperature_badge; + } + } + + const firstCandidate = entities.find((entity) => entity && isTemperatureSensorEntity(entity)); + if (firstCandidate) { + const value = firstCandidate.attributes?.current_temperature ?? firstCandidate.attributes?.temperature ?? firstCandidate.state ?? null; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return `${Math.round(numeric)}°`; + } + } + + if (room.temperature_badge) { + return room.temperature_badge; + } + + return null; + } + + function entityKindLabel(entity) { + return String(entity?.domain || entity?.entity_id?.split('.')?.[0] || '').toLowerCase() || 'entity'; + } + + function renderEntityTypeLabel(entity) { + const kind = document.createElement('div'); + kind.className = 'grid-card__kind'; + kind.textContent = entityKindLabel(entity); + return kind; + } + + function entityFromSnapshot(snapshot, entityId) { + return getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + } + + function entityPopupEntity() { + const snapshot = state.snapshot || bootstrap; + const entityId = state.entityPopup?.entityId; + return entityFromSnapshot(snapshot, entityId); + } + + function openEntityPopup(entityId) { + const snapshot = state.snapshot || bootstrap; + const entity = entityFromSnapshot(snapshot, entityId); + if (!entity) return; + state.entityPopup = { + active: true, + entityId, + kind: entityKindLabel(entity), + }; + renderEntityPopup(snapshot); + } + + function closeEntityPopup() { + state.entityPopup = { + active: false, + entityId: null, + }; + const backdrop = els.entityBackdrop; + if (backdrop) { + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + } + if (els.entityBody) { + els.entityBody.innerHTML = ''; + } + if (els.entityTitle) { + els.entityTitle.textContent = 'Устройство'; + } + if (els.entityEyebrow) { + els.entityEyebrow.textContent = ''; + } + } + + function closeTemperatureSensorPopup() { + state.temperatureSensorPopup = { + active: false, + roomId: null, + }; + state.lastTemperatureSensorPopupSignature = ''; + const backdrop = els.temperatureSensorBackdrop; + if (backdrop) { + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + } + if (els.temperatureSensorBody) { + els.temperatureSensorBody.innerHTML = ''; + } + } + + function openTemperatureSensorPopup(roomId) { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null) + || null; + if (!room || roomId === 'main' || roomId === 'batteries') return; + + state.temperatureSensorPopup = { + active: true, + roomId, + }; + renderTemperatureSensorPopup(snapshot); + } + + function renderEntityPopup(snapshot) { + const backdrop = els.entityBackdrop; + if (!backdrop) return false; + + const popupState = state.entityPopup || {}; + const entity = popupState.active ? entityFromSnapshot(snapshot, popupState.entityId) : null; + if (!popupState.active || !entity) { + closeEntityPopup(); + return false; + } + + const signature = JSON.stringify([ + entity.entity_id || '', + entity.state || '', + entity.attributes?.current_position ?? '', + entity.attributes?.temperature ?? '', + entity.attributes?.current_temperature ?? '', + entity.attributes?.hvac_mode ?? '', + entity.attributes?.fan_mode ?? '', + entity.attributes?.swing_mode ?? '', + entity.attributes?.preset_mode ?? '', + entity.attributes?.hvac_action ?? '', + state.editMode ? '1' : '0', + ]); + + if (signature === state.lastEntityPopupSignature && backdrop.classList.contains('is-open')) { + return true; + } + + state.lastEntityPopupSignature = signature; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + if (els.entityTitle) { + els.entityTitle.textContent = entity.name || 'Устройство'; + } + if (els.entityEyebrow) { + els.entityEyebrow.textContent = entityKindLabel(entity); + } + if (els.entityBody) { + els.entityBody.replaceChildren(); + if (entity.domain === 'cover') { + els.entityBody.appendChild(renderCoverPopup(entity)); + } else if (entity.domain === 'climate') { + els.entityBody.appendChild(renderClimatePopup(entity)); + } else { + const fallback = document.createElement('div'); + fallback.className = 'entity-modal__fallback'; + fallback.textContent = 'Для этого типа пока нет popup.'; + els.entityBody.appendChild(fallback); + } + } + + return true; + } + + function renderTemperatureSensorPopup(snapshot) { + const backdrop = els.temperatureSensorBackdrop; + if (!backdrop) return false; + + if (isMobileViewport() || !state.editMode) { + closeTemperatureSensorPopup(); + return false; + } + + const popupState = state.temperatureSensorPopup || {}; + const roomId = popupState.active ? popupState.roomId : null; + const room = roomId + ? (snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null)) + : null; + + if (!popupState.active || !room || roomId === 'main' || roomId === 'batteries') { + closeTemperatureSensorPopup(); + return false; + } + + const candidates = roomTemperatureSensorCandidates(snapshot, roomId); + const selectedId = String(room.temperature_sensor_entity_id || room?.temperature_sensor_entity_id || '').trim(); + const signature = JSON.stringify([ + roomId, + selectedId, + candidates.map((entity) => `${entity.entity_id}:${entity.state}:${entity.attributes?.current_temperature ?? entity.attributes?.temperature ?? ''}`).join('|'), + state.editMode ? '1' : '0', + ]); + + if (signature === state.lastTemperatureSensorPopupSignature && backdrop.classList.contains('is-open')) { + return true; + } + + state.lastTemperatureSensorPopupSignature = signature; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + if (els.temperatureSensorTitle) { + els.temperatureSensorTitle.textContent = room.name ? `Выбрать датчик температуры · ${room.name}` : 'Выбрать датчик температуры'; + } + + if (!els.temperatureSensorBody) { + return true; + } + + els.temperatureSensorBody.replaceChildren(); + + const current = document.createElement('div'); + current.className = 'temperature-sensor-modal__current'; + const selectedEntity = selectedId + ? candidates.find((entity) => String(entity.entity_id || '') === selectedId) + : null; + current.innerHTML = ` +
Текущий выбор
+
${selectedEntity ? esc(selectedEntity.name || selectedEntity.entity_id) : 'Автоматически'}
+ `; + els.temperatureSensorBody.appendChild(current); + + const resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.className = `temperature-sensor-modal__option ${!selectedId ? 'is-active' : ''}`; + resetButton.innerHTML = ` + + Автоматически + Использовать первый подходящий датчик в комнате + + ${!selectedId ? 'Выбрано' : 'Сбросить'} + `; + resetButton.addEventListener('click', async () => { + await saveSpacePatch(room, { temperature_sensor_entity_id: '' }); + closeTemperatureSensorPopup(); + }); + els.temperatureSensorBody.appendChild(resetButton); + + if (!candidates.length) { + const empty = document.createElement('div'); + empty.className = 'temperature-sensor-modal__empty'; + empty.textContent = 'В этой комнате не найдено температурных датчиков.'; + els.temperatureSensorBody.appendChild(empty); + return true; + } + + const list = document.createElement('div'); + list.className = 'temperature-sensor-modal__list'; + + candidates.forEach((entity) => { + const label = roomTemperatureSensorLabel(entity); + const option = document.createElement('button'); + option.type = 'button'; + option.className = `temperature-sensor-modal__option ${String(entity.entity_id || '') === selectedId ? 'is-active' : ''}`; + option.innerHTML = ` + + ${esc(label.name)} + ${esc(label.meta)} + + ${esc(label.valueText)} + `; + option.addEventListener('click', async () => { + await saveSpacePatch(room, { temperature_sensor_entity_id: entity.entity_id }); + closeTemperatureSensorPopup(); + }); + list.appendChild(option); + }); + + els.temperatureSensorBody.appendChild(list); + return true; + } + + function syncLayoutState() { + if (!els.appShell) return; + + const mobile = isMobileViewport(); + const embedded = Boolean(state.embedMode); + document.body.classList.toggle('is-mobile-ui', mobile); + document.body.classList.toggle('is-embedded', embedded); + els.appShell.classList.toggle('is-mobile', mobile); + els.appShell.classList.toggle('is-desktop', !mobile); + els.appShell.classList.toggle('app-shell--embed', embedded); + els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room'); + els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); + + if (els.selectedRoomBack) { + els.selectedRoomBack.hidden = !isMobileRoomView(); + } + } + + function normalizePositionValue(value) { + const next = Number(value); + if (!Number.isFinite(next)) return null; + return Math.max(0, Math.min(100, Math.round(next))); + } + + function shouldShowMainEntity(entity) { + if (!entity) return false; + const domain = String(entity.domain || entity.entity_id?.split('.')?.[0] || '').toLowerCase(); + const state = String(entity.state || '').toLowerCase(); + const isAuto = Boolean(entity.is_auto); + const isHidden = Boolean(entity.is_hidden); + const isDoorContact = Boolean(entity.is_door_contact); + if (!isAuto || isHidden) return false; + if (!['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain)) return false; + if (domain === 'binary_sensor' && !isDoorContact) return false; + return domain === 'cover' + ? ['open', 'opening', 'closing'].includes(state) + : domain === 'binary_sensor' + ? ['on', 'open'].includes(state) + : ['on', 'cool', 'heat', 'heating', 'cooling'].includes(state); + } + + function mainCardsContainer() { + return q('.main-dashboard__cards', els.dashboardSurface); + } + + function currentDashboardCardsContainer() { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + return mainCardsContainer(); + } + return els.dashboardSurface; + } + + function findRenderedCard(entityId) { + if (!entityId) return null; + return q(`[data-entity-id="${CSS.escape(entityId)}"]`, els.dashboardSurface); + } + + function sortMainCardsBySnapshot(container) { + const snapshot = state.snapshot || {}; + const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((entity) => entity.entity_id); + const order = new Map(orderedMainIds.map((entityId, index) => [entityId, index])); + const cards = Array.from(container?.querySelectorAll('.grid-card[data-entity-id]') || []); + cards.sort((left, right) => { + const leftId = left.dataset.entityId || ''; + const rightId = right.dataset.entityId || ''; + const leftOrder = order.has(leftId) ? order.get(leftId) : Number.MAX_SAFE_INTEGER; + const rightOrder = order.has(rightId) ? order.get(rightId) : Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return leftId.localeCompare(rightId, 'ru'); + }); + cards.forEach((card) => container.appendChild(card)); + } + + function updateMainWeatherCard() { + const snapshot = state.snapshot || {}; + const hero = q('.main-dashboard__hero', els.dashboardSurface); + if (!hero) return false; + const next = renderMainHero(snapshot); + hero.replaceWith(next); + return true; + } + + function updateMainEntityCard(entityId) { + const snapshot = state.snapshot || {}; + const container = mainCardsContainer(); + if (!container) return false; + + const entity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + const existing = q(`[data-entity-id="${CSS.escape(entityId)}"]`, container); + const shouldShow = shouldShowMainEntity(entity); + if (existing && !shouldShow) { + existing.remove(); + return true; + } + if (!shouldShow) { + return false; + } + + const nextCard = renderEntityCard(entity, { isMain: true }); + if (existing) { + existing.replaceWith(nextCard); + return true; + } + + const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((item) => item.entity_id); + const nextIndex = orderedMainIds.indexOf(entityId); + const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]')); + for (const card of cards) { + const cardId = card.dataset.entityId; + const cardIndex = orderedMainIds.indexOf(cardId); + if (cardIndex > nextIndex && nextIndex !== -1) { + card.before(nextCard); + return true; + } + } + + container.appendChild(nextCard); + return true; + } + + function updateRoomEntityCard(entityId) { + const snapshot = state.snapshot || {}; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + return updateMainEntityCard(entityId); + } + if (room.id === 'batteries') { + renderDashboardOnly(); + return true; + } + renderDashboardOnly(); + return true; + } + + function setCardInteractionLock(entityId) { + state.roomDrag = state.roomDrag || {}; + state.roomDrag.suppressClickUntil = Date.now() + 180; + state.roomDrag.entityId = entityId; + } + + function openConfirm(options = {}) { + const backdrop = els.confirmBackdrop; + if (!backdrop) return Promise.resolve(false); + els.confirmTitle.textContent = options.title || 'Хотите закрыть?'; + els.confirmMessage.textContent = options.message || 'Это действие отправит команду закрытия.'; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + return new Promise((resolve) => { + state.confirmResolver = resolve; + const finish = (result) => { + if (state.confirmResolver) { + const resolver = state.confirmResolver; + state.confirmResolver = null; + resolver(result); + } + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + }; + + const onYes = () => finish(true); + const onNo = () => finish(false); + const onBackdrop = (event) => { + if (event.target === backdrop) onNo(); + }; + + const cleanup = () => { + els.confirmYes.removeEventListener('click', onYes); + els.confirmNo.removeEventListener('click', onNo); + backdrop.removeEventListener('click', onBackdrop); + }; + + els.confirmYes.addEventListener('click', () => { + cleanup(); + onYes(); + }, { once: true }); + els.confirmNo.addEventListener('click', () => { + cleanup(); + onNo(); + }, { once: true }); + backdrop.addEventListener('click', (event) => { + if (event.target === backdrop) { + cleanup(); + onNo(); + } + }, { once: true }); + }); + } + + function roomCollections() { + const snapshot = state.snapshot || {}; + const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : Array.isArray(snapshot.rooms) ? snapshot.rooms : []; + const visible = spaces.filter((room) => room.id !== 'main' && room.visible !== false); + const hidden = spaces.filter((room) => room.id !== 'main' && room.visible === false); + return { visible, hidden }; + } + + function orderedRoomIdsFromGroup(groupEl) { + return qa('.room-item', groupEl) + .map((item) => item.dataset.roomId) + .filter((roomId) => roomId && roomId !== 'main'); + } + + function roomById(roomId) { + const snapshot = state.snapshot || {}; + const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : Array.isArray(snapshot.rooms) ? snapshot.rooms : []; + return spaces.find((room) => room.id === roomId) || null; + } + + async function persistRoomOrderForGroup(groupEl, hidden = false) { + const snapshot = state.snapshot || {}; + const roomIds = orderedRoomIdsFromGroup(groupEl); + if (!roomIds.length) return; + + const baseOrder = hidden ? 1000 : 0; + const roomsById = new Map((snapshot.spaces || snapshot.rooms || []).map((room) => [room.id, room])); + const changes = roomIds.map((roomId, index) => { + const room = roomsById.get(roomId); + const nextOrder = baseOrder + (index * 10); + if (!room || Number(room.order ?? 9999) === nextOrder) { + return null; + } + return { roomId, nextOrder }; + }).filter(Boolean); + + if (!changes.length) return; + + await Promise.all(changes.map(({ roomId, nextOrder }) => apiPost('save-space-override', { + room_id: roomId, + order: nextOrder, + }))); + + changes.forEach(({ roomId, nextOrder }) => { + patchSnapshotSpace(roomId, { order: nextOrder }); + }); + renderSidebarOnly(); + } + + function clearRoomDragState() { + const drag = state.roomDrag; + if (!drag) return; + if (drag.itemEl) { + drag.itemEl.classList.remove('is-dragging'); + drag.itemEl.removeAttribute('aria-grabbed'); + } + state.roomDrag = null; + } + + function startRoomDrag(room, itemEl, groupEl, hidden, event) { + if (!state.editMode || room.id === 'main') return; + if (event.target.closest('button')) return; + + const pointerId = event.pointerId; + const drag = { + roomId: room.id, + itemEl, + groupEl, + hidden, + pointerId, + startX: event.clientX, + startY: event.clientY, + moved: false, + suppressClickUntil: 0, + }; + state.roomDrag = drag; + itemEl.classList.add('is-dragging'); + itemEl.setAttribute('aria-grabbed', 'true'); + if (itemEl.setPointerCapture) { + try { + itemEl.setPointerCapture(pointerId); + } catch (error) { + console.warn(error); + } + } + event.preventDefault(); + } + + function roomDropTargetAtPoint(x, y, groupEl, draggedId) { + const node = document.elementFromPoint(x, y); + const item = node?.closest?.('.room-item'); + if (!item || item.dataset.roomId === draggedId || item.dataset.roomGroup !== (state.roomDrag?.hidden ? 'hidden' : 'visible')) { + return null; + } + if (!groupEl.contains(item)) { + return null; + } + return item; + } + + function moveRoomDrag(clientX, clientY) { + const drag = state.roomDrag; + if (!drag) return; + + const dx = Math.abs(clientX - drag.startX); + const dy = Math.abs(clientY - drag.startY); + if (!drag.moved && Math.max(dx, dy) < 6) { + return; + } + + drag.moved = true; + const target = roomDropTargetAtPoint(clientX, clientY, drag.groupEl, drag.roomId); + if (!target) return; + + const targetRect = target.getBoundingClientRect(); + const before = clientY < (targetRect.top + targetRect.height / 2); + if (before) { + drag.groupEl.insertBefore(drag.itemEl, target); + } else { + drag.groupEl.insertBefore(drag.itemEl, target.nextSibling); + } + } + + async function finishRoomDrag() { + const drag = state.roomDrag; + if (!drag) return; + + const itemEl = drag.itemEl; + const groupEl = drag.groupEl; + const hidden = drag.hidden; + const moved = drag.moved; + itemEl.classList.remove('is-dragging'); + itemEl.removeAttribute('aria-grabbed'); + + if (drag.itemEl.releasePointerCapture && drag.pointerId !== null) { + try { + drag.itemEl.releasePointerCapture(drag.pointerId); + } catch (error) { + console.warn(error); + } + } + + state.roomDrag = { + ...drag, + suppressClickUntil: Date.now() + 200, + }; + + if (moved) { + await persistRoomOrderForGroup(groupEl, hidden); + } + + window.setTimeout(() => { + if (state.roomDrag && Date.now() >= (state.roomDrag.suppressClickUntil || 0)) { + state.roomDrag = null; + } + }, 220); + } + + function updateEntityInCollection(collection, entityId, updater) { + if (!Array.isArray(collection)) return false; + let changed = false; + collection.forEach((entity) => { + if (!entity || entity.entity_id !== entityId) return; + updater(entity); + changed = true; + }); + return changed; + } + + function updateEntityInMap(map, entityId, updater) { + if (!map || typeof map !== 'object') return false; + const entity = map[entityId]; + if (!entity || typeof entity !== 'object') return false; + updater(entity); + return true; + } + + function patchSnapshotEntity(entityId, patch = {}) { + const snapshot = state.snapshot || {}; + let changed = false; + + const applyPatch = (entity) => { + Object.assign(entity, patch); + changed = true; + }; + + updateEntityInCollection(snapshot.main_entities, entityId, applyPatch); + updateEntityInMap(snapshot.entity_index, entityId, applyPatch); + updateEntityInMap(snapshot.space_index, entityId, applyPatch); + + if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { + Object.values(snapshot.space_entities).forEach((collection) => { + updateEntityInCollection(collection, entityId, applyPatch); + }); + } + + if (snapshot.battery_room?.entities) { + updateEntityInCollection(snapshot.battery_room.entities, entityId, applyPatch); + } + + if (snapshot.selected_space?.entities) { + updateEntityInCollection(snapshot.selected_space.entities, entityId, applyPatch); + } + + if (snapshot.selected_room?.entities) { + updateEntityInCollection(snapshot.selected_room.entities, entityId, applyPatch); + } + + return changed; + } + + function syncMainEntities(entityId, sourceEntity, patch = {}) { + const snapshot = state.snapshot || {}; + const list = Array.isArray(snapshot.main_entities) ? snapshot.main_entities : []; + const index = list.findIndex((entity) => entity && entity.entity_id === entityId); + const entity = index >= 0 ? list[index] : null; + const nextState = String(patch.state ?? sourceEntity?.state ?? '').toLowerCase(); + const definition = getEntityDefinition(snapshot, entityId) || sourceEntity || entity || { entity_id: entityId }; + const domain = String(definition?.domain || entity?.domain || entityId.split('.')[0] || '').toLowerCase(); + const isDoorContact = Boolean(definition?.is_door_contact || entity?.is_door_contact); + const shouldDisplay = domain === 'cover' + ? ['open', 'opening', 'closing'].includes(nextState) + : domain === 'binary_sensor' + ? isDoorContact && ['on', 'open'].includes(nextState) + : ['on', 'cool', 'heat', 'heating', 'cooling'].includes(nextState) || (domain === 'fan' && nextState === 'on'); + const isAllowedDomain = ['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain); + const isAuto = Boolean(definition?.is_auto); + + if (definition?.is_hidden || !isAuto) { + if (index >= 0) { + list.splice(index, 1); + if (snapshot.selected_space?.id === 'main') { + snapshot.selected_space.entities = list; + snapshot.selected_room = snapshot.selected_space; + } + return true; + } + return false; + } + + if (!isAllowedDomain || (domain === 'binary_sensor' && !isDoorContact)) { + if (index >= 0) { + list.splice(index, 1); + if (snapshot.selected_space?.id === 'main') { + snapshot.selected_space.entities = list; + snapshot.selected_room = snapshot.selected_space; + } + return true; + } + return false; + } + + if (!shouldDisplay) { + if (index >= 0) { + list.splice(index, 1); + } else { + return false; + } + } else if (index >= 0) { + Object.assign(entity, { + ...definition, + ...patch, + }); + } else { + list.push({ + ...definition, + ...patch, + last_changed: patch.last_changed || sourceEntity?.last_changed || sourceEntity?.last_updated || new Date().toISOString(), + }); + } + + list.sort((a, b) => { + const timeA = entitySortTime(a); + const timeB = entitySortTime(b); + if (timeA !== timeB) return timeA - timeB; + return String(a.name || '').localeCompare(String(b.name || ''), 'ru'); + }); + + if (snapshot.selected_space?.id === 'main') { + snapshot.selected_space.entities = list; + snapshot.selected_room = snapshot.selected_space; + } + + return true; + } + + function getEntityFromSnapshot(snapshot, entityId) { + if (!snapshot || !entityId) return null; + const collections = [ + snapshot.main_entities, + snapshot.selected_space?.entities, + snapshot.selected_room?.entities, + ]; + if (snapshot.entity_index && typeof snapshot.entity_index === 'object') { + collections.push(Object.values(snapshot.entity_index)); + } + if (snapshot.space_index && typeof snapshot.space_index === 'object') { + Object.values(snapshot.space_index).forEach((room) => { + if (room?.entities) { + collections.push(room.entities); + } + }); + } + if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { + Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities)); + } + if (snapshot.battery_room?.entities) { + collections.push(snapshot.battery_room.entities); + } + + for (const collection of collections) { + if (!Array.isArray(collection)) continue; + const found = collection.find((entity) => entity && entity.entity_id === entityId); + if (found) return found; + } + + return null; + } + + function getEntityDefinition(snapshot, entityId) { + if (!snapshot || !entityId) return null; + if (snapshot.entity_index && typeof snapshot.entity_index === 'object' && snapshot.entity_index[entityId]) { + return snapshot.entity_index[entityId]; + } + return getEntityFromSnapshot(snapshot, entityId); + } + + function statePayloadChanged(existing, incoming) { + if (!existing || !incoming) return true; + if (String(existing.state ?? '') !== String(incoming.state ?? '')) return true; + return JSON.stringify(existing.attributes || {}) !== JSON.stringify(incoming.attributes || {}); + } + + function patchSnapshotSpace(roomId, patch = {}) { + const snapshot = state.snapshot || {}; + const collections = [snapshot.spaces, snapshot.rooms]; + let changed = false; + + collections.forEach((collection) => { + if (!Array.isArray(collection)) return; + collection.forEach((room) => { + if (!room || room.id !== roomId) return; + Object.assign(room, patch); + changed = true; + }); + }); + + if (snapshot.space_index && snapshot.space_index[roomId]) { + Object.assign(snapshot.space_index[roomId], patch); + changed = true; + } + + if (snapshot.selected_space?.id === roomId) { + Object.assign(snapshot.selected_space, patch); + changed = true; + } + + if (snapshot.selected_room?.id === roomId) { + Object.assign(snapshot.selected_room, patch); + changed = true; + } + + return changed; + } + + function patchSnapshotSelection(roomId) { + const snapshot = state.snapshot || {}; + const spaces = snapshot.spaces || snapshot.rooms || []; + const room = roomId === 'main' + ? { + id: 'main', + name: snapshot.settings?.main_room_name || 'Главная', + icon: snapshot.settings?.main_room_icon || 'mdi:home', + visible: true, + entities: snapshot.main_entities || [], + } + : roomId === 'batteries' + ? snapshot.battery_room + : snapshot.space_index?.[roomId] || spaces.find((space) => space.id === roomId); + + if (!room) return; + + state.selectedRoomId = roomId; + if (roomId === 'main') { + clearRoomAutoReturnTimer(); + snapshot.selected_space = { + id: 'main', + name: snapshot.settings?.main_room_name || 'Главная', + icon: snapshot.settings?.main_room_icon || 'mdi:home', + visible: true, + entities: snapshot.main_entities || [], + }; + snapshot.selected_room = snapshot.selected_space; + return; + } + + if (roomId === 'batteries') { + snapshot.selected_space = { + ...room, + entities: Array.isArray(room.entities) ? room.entities : [], + }; + snapshot.selected_room = snapshot.selected_space; + return; + } + + const entities = snapshot.space_index?.[roomId]?.entities || snapshot.space_entities?.[roomId] || room.entities || []; + snapshot.selected_space = { + ...room, + entities, + }; + snapshot.selected_room = snapshot.selected_space; + } + + function applyPopupState(active, sensorEntityId) { + const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; + const popup = state.snapshot?.popup || {}; + if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) { + return; + } + const next = { + ...popup, + active, + sensor_entity_id: sensorEntityId || null, + opened_at: active ? Math.floor(Date.now() / 1000) : popup.opened_at || null, + expires_at: active ? Math.floor(Date.now() / 1000) + (Number(camera.popup_timeout_minutes || 3) * 60) : null, + poster_url: camera.poster_url || popup.poster_url || '', + stream_url: camera.stream_url || popup.stream_url || '', + stream_mode: camera.stream_mode || popup.stream_mode || 'hls', + title: popup.title || 'Камера', + }; + + state.snapshot = state.snapshot || bootstrap; + state.snapshot.popup = next; + renderPopup(state.snapshot); + } + + function applyPopupSnapshot(popup = {}) { + const snapshot = state.snapshot || bootstrap; + snapshot.popup = mergePopupWithCamera({ + ...(snapshot.popup || {}), + ...popup, + }); + renderPopup(snapshot); + } + + function syncTriggerPopup(entityId, stateValue) { + const value = String(stateValue || '').toLowerCase(); + if (!['on', 'off'].includes(value)) { + return; + } + + apiPost('popup', { sensor_entity_id: entityId, state: value }) + .then((response) => { + if (response?.popup) { + applyPopupSnapshot(response.popup); + } + }) + .catch((error) => { + console.warn(error); + }); + } + + function haConnection() { + return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {}; + } + + function haWsUrl(baseUrl) { + if (!baseUrl) return ''; + try { + const url = new URL(baseUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.pathname = '/api/websocket'; + url.search = ''; + url.hash = ''; + return url.toString(); + } catch (error) { + return ''; + } + } + + function setStatus(text, tone = '') { + if (!els.connectionStatus) return; + els.connectionStatus.textContent = text; + els.connectionStatus.dataset.tone = tone; + } + + function clearRoomAutoReturnTimer() { + if (state.roomAutoReturnTimer) { + clearTimeout(state.roomAutoReturnTimer); + state.roomAutoReturnTimer = null; + } + } + + function scheduleRoomAutoReturn(roomId) { + const nextRoomId = roomId || 'main'; + clearRoomAutoReturnTimer(); + if (nextRoomId === 'main' || isMobileViewport()) { + return; + } + + state.roomAutoReturnTimer = window.setTimeout(() => { + state.roomAutoReturnTimer = null; + if ((state.selectedRoomId || 'main') === nextRoomId) { + setSelectedRoom('main'); + } + }, 120000); + } + + async function setSelectedRoom(roomId) { + const nextRoomId = roomId || 'main'; + const token = ++state.roomSelectionToken; + clearRoomAutoReturnTimer(); + closeTemperatureSensorPopup(); + patchSnapshotSelection(nextRoomId); + if (isMobileViewport()) { + setMobileView('room'); + } + render(); + scheduleRoomAutoReturn(nextRoomId); + try { + const snapshot = await fetchSnapshot(nextRoomId); + if (token !== state.roomSelectionToken) { + return; + } + state.snapshot = snapshot; + patchSnapshotSelection(nextRoomId); + render(); + } catch (error) { + if (token !== state.roomSelectionToken) { + return; + } + console.warn(error); + } + } + + function createButton(label, subtitle, icon, className = '', attrs = {}) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = className; + Object.entries(attrs).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + btn.dataset[key] = value; + } + }); + + const iconEl = document.createElement('i'); + iconEl.className = iconClass(icon); + + const body = document.createElement('div'); + body.className = 'mushroom-button__body'; + + const titleEl = document.createElement('div'); + titleEl.className = 'mushroom-button__title'; + titleEl.textContent = label; + + body.appendChild(titleEl); + + if (subtitle) { + const subEl = document.createElement('div'); + subEl.className = 'mushroom-button__subtitle'; + subEl.textContent = subtitle; + body.appendChild(subEl); + } + + btn.appendChild(iconEl); + btn.appendChild(body); + return btn; + } + + function mainWeatherCard(weather) { + const card = document.createElement('article'); + card.className = 'grid-card grid-card--weather grid-card--weather-compact'; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner weather-card weather-card--compact'; + + const title = document.createElement('div'); + title.className = 'grid-card__title_weather'; + title.textContent = 'Погода'; + + const rows = document.createElement('div'); + rows.className = 'weather-card__rows'; + [ + ['Интернет', weather?.temperature != null ? `${Number(weather.temperature).toFixed(0)}°C` : '—'], + ['Датчик', weather?.sensor_temperature != null ? `${weather.sensor_temperature}°C` : '—'], + ['Ветер', weather?.wind_speed != null ? `${Math.round(Number(weather.wind_speed))} км/ч` : '—'], + ].forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = 'weather-card__row'; + row.innerHTML = `${label}${value}`; + rows.appendChild(row); + }); + + inner.appendChild(title); + inner.appendChild(rows); + card.appendChild(inner); + return card; + } + + function mainWeatherActions(snapshot = state.snapshot || bootstrap) { + const fromSnapshot = snapshot?.settings?.main_weather_actions; + const fromBootstrap = bootstrap?.settings?.main_weather_actions; + const actions = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; + return actions.filter((action) => action && String(action.entity_id || '').trim() !== ''); + } + + function mainWeatherActionEntity(snapshot, action) { + const entityId = String(action?.state_entity_id || action?.entity_id || '').trim(); + if (!entityId) return null; + return getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId) || null; + } + + function mainWeatherActionIsActive(snapshot, action) { + const entity = mainWeatherActionEntity(snapshot, action); + if (!entity) return false; + const current = String(entity.state ?? '').trim().toLowerCase(); + const compareValue = action?.active_value ?? action?.value; + if (compareValue === null || compareValue === undefined || String(compareValue).trim() === '') { + return !['off', 'false', '0', 'unknown', 'unavailable', 'idle'].includes(current); + } + return current === String(compareValue).trim().toLowerCase(); + } + + function mainWeatherActionAffectsEntity(entityId) { + const nextEntityId = String(entityId || '').trim(); + if (!nextEntityId) return false; + return mainWeatherActions().some((action) => { + const stateEntityId = String(action.state_entity_id || action.entity_id || '').trim(); + return stateEntityId === nextEntityId || String(action.entity_id || '').trim() === nextEntityId; + }); + } + + function mainPrintAffectsEntity(entityId) { + const nextEntityId = String(entityId || '').trim(); + if (!nextEntityId) return false; + const config = mainPrintConfig(); + if (!config) return false; + return [ + config.current_stage_entity_id, + config.print_progress_entity_id, + config.start_time_entity_id, + config.end_time_entity_id, + ].includes(nextEntityId); + } + + function mainWeatherActionLabel(action, active) { + const value = action?.value; + const label = active + ? (action?.label_active ?? action?.active_label ?? '') + : (action?.label_inactive ?? action?.inactive_label ?? ''); + if (String(label || '').trim() !== '') { + return String(label); + } + if (value !== null && value !== undefined && String(value).trim() !== '') { + return active ? `${value}°` : `Установить ${value}°`; + } + return active ? 'Активно' : 'Включить'; + } + + function renderMainWeatherActions(snapshot) { + const actions = mainWeatherActions(snapshot); + if (!actions.length) return null; + + const wrap = document.createElement('div'); + wrap.className = 'main-dashboard__actions'; + + actions.forEach((action) => { + const active = mainWeatherActionIsActive(snapshot, action); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `main-quick-action ${active ? 'is-active' : ''}`; + btn.dataset.entityId = String(action.entity_id || ''); + btn.dataset.stateEntityId = String(action.state_entity_id || action.entity_id || ''); + btn.dataset.command = String(action.command || 'set_temperature'); + btn.dataset.value = action.value !== undefined && action.value !== null ? String(action.value) : ''; + btn.style.setProperty('--quick-action-bg', active ? (action.active_color || '#4caf50') : (action.inactive_color || '#c8e6c9')); + btn.style.setProperty('--quick-action-color', active ? (action.active_text_color || 'white') : (action.inactive_text_color || 'black')); + btn.style.setProperty('--quick-action-icon-color', active ? (action.active_icon_color || 'white') : (action.inactive_icon_color || 'gray')); + btn.style.setProperty('--icon-node-img-filter', active + ? 'brightness(0) saturate(100%) invert(100%)' + : 'brightness(0) saturate(100%) invert(42%)'); + + const icon = document.createElement('div'); + icon.className = 'main-quick-action__icon'; + icon.appendChild(createIconElement(action.icon || 'mdi:thermometer')); + + const label = document.createElement('div'); + label.className = 'main-quick-action__label'; + label.textContent = mainWeatherActionLabel(action, active); + + btn.append(icon, label); + btn.addEventListener('click', () => { + handleMainWeatherAction(action); + }); + btn.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMainWeatherAction(action); + } + }); + wrap.appendChild(btn); + }); + + return wrap; + } + + function renderMainPrintCard(snapshot = state.snapshot || bootstrap) { + const info = mainPrintState(snapshot); + if (!info) return null; + + const card = document.createElement('article'); + card.className = 'main-print-strip'; + + const inner = document.createElement('div'); + inner.className = 'main-print-strip__inner'; + + const header = document.createElement('div'); + header.className = 'main-print-strip__header'; + + const badge = document.createElement('div'); + badge.className = 'main-print-strip__badge'; + badge.textContent = `${Math.max(0, Math.round(info.progress ?? 0))}%`; + + header.appendChild(badge); + + const progress = document.createElement('div'); + progress.className = 'main-print-strip__progress'; + const fill = document.createElement('div'); + fill.className = 'main-print-strip__progress-fill'; + fill.style.width = `${Math.max(0, Math.min(100, Number(info.progress ?? 0) || 0))}%`; + progress.appendChild(fill); + + const footer = document.createElement('div'); + footer.className = 'main-print-strip__footer'; + + const remaining = document.createElement('div'); + remaining.className = 'main-print-strip__remaining'; + remaining.textContent = info.remainingSeconds !== null + ? formatDurationText(info.remainingSeconds) + : '—'; + + footer.appendChild(remaining); + inner.append(header, progress, footer); + card.appendChild(inner); + return card; + } + + function mainBoilerConfig(snapshot = state.snapshot || bootstrap) { + const fromSnapshot = snapshot?.settings?.main_boiler; + const fromBootstrap = bootstrap?.settings?.main_boiler; + const config = (fromSnapshot && typeof fromSnapshot === 'object') + ? fromSnapshot + : ((fromBootstrap && typeof fromBootstrap === 'object') ? fromBootstrap : null); + if (!config) return null; + + const sensorEntityId = String(config.sensor_entity_id || '').trim(); + if (!sensorEntityId) return null; + + return { + title: String(config.title || 'Бойлер'), + sensor_entity_id: sensorEntityId, + history_hours: Math.max(1, Number(config.history_hours || 24) || 24), + }; + } + + function mainPrintConfig(snapshot = state.snapshot || bootstrap) { + const fromSnapshot = snapshot?.settings?.main_print; + const fromBootstrap = bootstrap?.settings?.main_print; + const config = (fromSnapshot && typeof fromSnapshot === 'object') + ? fromSnapshot + : ((fromBootstrap && typeof fromBootstrap === 'object') ? fromBootstrap : null); + if (!config) return null; + + const currentStageEntityId = String(config.current_stage_entity_id || '').trim(); + const printProgressEntityId = String(config.print_progress_entity_id || '').trim(); + const startTimeEntityId = String(config.start_time_entity_id || '').trim(); + const endTimeEntityId = String(config.end_time_entity_id || '').trim(); + if (!currentStageEntityId || !printProgressEntityId || !startTimeEntityId || !endTimeEntityId) { + return null; + } + + return { + title: String(config.title || '').trim(), + current_stage_entity_id: currentStageEntityId, + print_progress_entity_id: printProgressEntityId, + start_time_entity_id: startTimeEntityId, + end_time_entity_id: endTimeEntityId, + }; + } + + function parseDateValue(value) { + const text = String(value ?? '').trim(); + if (!text) return null; + + const numeric = Number(text); + if (Number.isFinite(numeric) && String(Math.trunc(numeric)) === text.replace(/\.0+$/, '')) { + return numeric > 1e12 ? numeric : numeric * 1000; + } + + const parsed = Date.parse(text); + return Number.isFinite(parsed) ? parsed : null; + } + + function formatDurationText(seconds) { + const total = Math.max(0, Math.round(Number(seconds) || 0)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + + if (hours > 0) { + return secs > 0 ? `${hours}ч ${minutes}м ${secs}с` : `${hours}ч ${minutes}м`; + } + if (minutes > 0) { + return secs > 0 ? `${minutes}м ${secs}с` : `${minutes}м`; + } + return `${secs}с`; + } + + function mainPrintState(snapshot = state.snapshot || bootstrap) { + const config = mainPrintConfig(snapshot); + if (!config) return null; + + const stage = getEntityFromSnapshot(snapshot, config.current_stage_entity_id) + || getEntityDefinition(snapshot, config.current_stage_entity_id); + if (!stage || String(stage.state || '').toLowerCase() !== 'printing') { + return null; + } + + const progressEntity = getEntityFromSnapshot(snapshot, config.print_progress_entity_id) + || getEntityDefinition(snapshot, config.print_progress_entity_id); + const startEntity = getEntityFromSnapshot(snapshot, config.start_time_entity_id) + || getEntityDefinition(snapshot, config.start_time_entity_id); + const endEntity = getEntityFromSnapshot(snapshot, config.end_time_entity_id) + || getEntityDefinition(snapshot, config.end_time_entity_id); + + const progressValueRaw = Number(String(progressEntity?.state ?? '').replace(',', '.')); + const progress = Number.isFinite(progressValueRaw) + ? Math.max(0, Math.min(100, progressValueRaw)) + : null; + + const startTs = parseDateValue(startEntity?.state ?? startEntity?.attributes?.value ?? startEntity?.attributes?.timestamp); + const endTs = parseDateValue(endEntity?.state ?? endEntity?.attributes?.value ?? endEntity?.attributes?.timestamp); + const nowTs = Date.now(); + let remainingSeconds = null; + + if (startTs !== null && endTs !== null && endTs > startTs) { + remainingSeconds = Math.max(0, (endTs - nowTs) / 1000); + } else if (endTs !== null) { + remainingSeconds = Math.max(0, (endTs - nowTs) / 1000); + } + + return { + title: String(config.title || '').trim(), + stage: String(stage.state || 'printing'), + progress, + remainingSeconds, + }; + } + + function updateMainPrintStrip(snapshot = state.snapshot || bootstrap) { + if (!els.mainPrintStripSlot) return; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id !== 'main') { + els.mainPrintStripSlot.innerHTML = ''; + return; + } + + els.mainPrintStripSlot.innerHTML = ''; + const printStrip = renderMainPrintCard(snapshot); + if (printStrip) { + els.mainPrintStripSlot.appendChild(printStrip); + } + } + + function formatTemperatureValue(value) { + const next = Number(String(value ?? '').replace(',', '.')); + if (!Number.isFinite(next)) { + return null; + } + + const digits = Math.abs(next % 1) > 0.05 ? 1 : 0; + return new Intl.NumberFormat('ru-RU', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(next); + } + + function normalizeHistoryPoints(payload, fallbackValue = null) { + const raw = Array.isArray(payload?.history) ? payload.history : payload; + const groups = Array.isArray(raw) && raw.length > 0 && Array.isArray(raw[0]) ? raw : [raw]; + const points = []; + + groups.forEach((group) => { + if (!Array.isArray(group)) return; + group.forEach((entry) => { + if (!entry || typeof entry !== 'object') return; + const rawValue = entry.state ?? entry.value ?? null; + const numericValue = Number(String(rawValue ?? '').replace(',', '.')); + if (!Number.isFinite(numericValue)) return; + const timestamp = Date.parse(entry.last_changed || entry.last_updated || ''); + if (!Number.isFinite(timestamp)) return; + points.push({ + timestamp, + value: numericValue, + }); + }); + }); + + points.sort((left, right) => left.timestamp - right.timestamp); + + const deduped = []; + for (const point of points) { + const last = deduped[deduped.length - 1]; + if (last && last.timestamp === point.timestamp) { + deduped[deduped.length - 1] = point; + continue; + } + deduped.push(point); + } + + if (!deduped.length) { + const fallbackNumeric = Number(String(fallbackValue ?? '').replace(',', '.')); + if (Number.isFinite(fallbackNumeric)) { + const now = Date.now(); + return [ + { timestamp: now - 60_000, value: fallbackNumeric }, + { timestamp: now, value: fallbackNumeric }, + ]; + } + } + + return deduped; + } + + function boilerHistoryState(entityId) { + const history = state.mainBoilerHistory || {}; + if (history.entityId !== entityId) { + return []; + } + return Array.isArray(history.points) ? history.points : []; + } + + function renderBoilerSparkline(points) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 240 72'); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('main-boiler-card__chart'); + + const ns = 'http://www.w3.org/2000/svg'; + const defs = document.createElementNS(ns, 'defs'); + const gradient = document.createElementNS(ns, 'linearGradient'); + gradient.setAttribute('id', 'boiler-chart-fill'); + gradient.setAttribute('x1', '0%'); + gradient.setAttribute('x2', '0%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('y2', '100%'); + + const stopTop = document.createElementNS(ns, 'stop'); + stopTop.setAttribute('offset', '0%'); + stopTop.setAttribute('stop-color', 'rgba(255, 186, 92, 0.38)'); + const stopBottom = document.createElementNS(ns, 'stop'); + stopBottom.setAttribute('offset', '100%'); + stopBottom.setAttribute('stop-color', 'rgba(255, 186, 92, 0.02)'); + gradient.append(stopTop, stopBottom); + defs.appendChild(gradient); + svg.appendChild(defs); + + if (!Array.isArray(points) || points.length === 0) { + const line = document.createElementNS(ns, 'path'); + line.setAttribute('d', 'M 0 48 L 240 48'); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', 'rgba(255, 186, 92, 0.28)'); + line.setAttribute('stroke-width', '2'); + svg.appendChild(line); + return svg; + } + + const values = points.map((point) => Number(point.value)).filter((value) => Number.isFinite(value)); + if (!values.length) { + return svg; + } + + let min = Math.min(...values); + let max = Math.max(...values); + if (min === max) { + min -= 0.5; + max += 0.5; + } else { + const padding = Math.max((max - min) * 0.2, 0.3); + min -= padding; + max += padding; + } + + const width = 240; + const height = 72; + const chartTop = 8; + const chartBottom = 60; + const chartHeight = chartBottom - chartTop; + const span = max - min || 1; + const linePoints = points.map((point, index) => { + const ratioX = points.length === 1 ? 0.5 : index / (points.length - 1); + const ratioY = (Number(point.value) - min) / span; + return { + x: ratioX * width, + y: chartBottom - (ratioY * chartHeight), + }; + }); + + const linePath = linePoints + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`) + .join(' '); + + const areaPath = [ + `M 0 ${chartBottom}`, + `L ${linePoints[0].x.toFixed(2)} ${linePoints[0].y.toFixed(2)}`, + ...linePoints.slice(1).map((point) => `L ${point.x.toFixed(2)} ${point.y.toFixed(2)}`), + `L ${width} ${chartBottom}`, + 'Z', + ].join(' '); + + const area = document.createElementNS(ns, 'path'); + area.setAttribute('d', areaPath); + area.setAttribute('fill', 'url(#boiler-chart-fill)'); + area.setAttribute('stroke', 'none'); + svg.appendChild(area); + + const line = document.createElementNS(ns, 'path'); + line.setAttribute('d', linePath); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', '#ffba5c'); + line.setAttribute('stroke-width', '2.4'); + line.setAttribute('stroke-linecap', 'round'); + line.setAttribute('stroke-linejoin', 'round'); + svg.appendChild(line); + + const lastPoint = linePoints[linePoints.length - 1]; + const dot = document.createElementNS(ns, 'circle'); + dot.setAttribute('cx', lastPoint.x.toFixed(2)); + dot.setAttribute('cy', lastPoint.y.toFixed(2)); + dot.setAttribute('r', '3.2'); + dot.setAttribute('fill', '#ffba5c'); + dot.setAttribute('stroke', 'rgba(24, 25, 29, 0.95)'); + dot.setAttribute('stroke-width', '2'); + svg.appendChild(dot); + + return svg; + } + + function scheduleMainBoilerHistoryLoad(snapshot = state.snapshot || bootstrap, force = false) { + const config = mainBoilerConfig(snapshot); + if (!config) { + return Promise.resolve([]); + } + + const cache = state.mainBoilerHistory || {}; + const isSameEntity = cache.entityId === config.sensor_entity_id; + const isFresh = isSameEntity && Array.isArray(cache.points) && cache.points.length > 0 && !force && (Date.now() - Number(cache.loadedAt || 0) < 5 * 60 * 1000); + if (isFresh) { + return Promise.resolve(cache.points); + } + + if (cache.promise && isSameEntity && !force) { + return cache.promise; + } + + const currentEntity = getEntityFromSnapshot(snapshot, config.sensor_entity_id) || getEntityDefinition(snapshot, config.sensor_entity_id); + const currentValue = currentEntity?.state ?? null; + + state.mainBoilerHistory = { + ...cache, + entityId: config.sensor_entity_id, + loading: true, + error: null, + }; + + const promise = apiGet('history', { + entity_id: config.sensor_entity_id, + hours: config.history_hours || 24, + }).then((response) => { + const points = normalizeHistoryPoints(response?.history ?? response, currentValue); + state.mainBoilerHistory = { + entityId: config.sensor_entity_id, + points, + loadedAt: Date.now(), + loading: false, + error: null, + promise: null, + }; + if ((state.selectedRoomId || 'main') === 'main') { + updateMainWeatherCard(); + } + return points; + }).catch((error) => { + state.mainBoilerHistory = { + entityId: config.sensor_entity_id, + points: Array.isArray(cache.points) ? cache.points : normalizeHistoryPoints([], currentValue), + loadedAt: Number(cache.loadedAt || 0), + loading: false, + error: error?.message || String(error), + promise: null, + }; + if ((state.selectedRoomId || 'main') === 'main') { + updateMainWeatherCard(); + } + return state.mainBoilerHistory.points; + }); + + state.mainBoilerHistory.promise = promise; + return promise; + } + + function renderMainBoilerCard(snapshot) { + const config = mainBoilerConfig(snapshot); + if (!config) { + return null; + } + + const entity = getEntityFromSnapshot(snapshot, config.sensor_entity_id) || getEntityDefinition(snapshot, config.sensor_entity_id); + const currentValue = formatTemperatureValue(entity?.state); + const historyPoints = boilerHistoryState(config.sensor_entity_id); + const history = state.mainBoilerHistory || {}; + + const card = document.createElement('article'); + card.className = 'grid-card main-boiler-card'; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner main-boiler-card__inner'; + + const header = document.createElement('div'); + header.className = 'main-boiler-card__header'; + + const text = document.createElement('div'); + text.className = 'main-boiler-card__text'; + + const eyebrow = document.createElement('div'); + eyebrow.className = 'main-boiler-card__eyebrow'; + eyebrow.textContent = 'Температура бойлера'; + text.appendChild(eyebrow); + + const range = document.createElement('div'); + range.className = 'main-boiler-card__range'; + range.textContent = '24 часа'; + header.append(text, range); + + const body = document.createElement('div'); + body.className = 'main-boiler-card__body'; + + const valueColumn = document.createElement('div'); + valueColumn.className = 'main-boiler-card__value-column'; + + const valueLabel = document.createElement('div'); + valueLabel.className = 'main-boiler-card__value-label'; + valueLabel.textContent = 'Сейчас'; + + const valueRow = document.createElement('div'); + valueRow.className = 'main-boiler-card__value-row'; + + const value = document.createElement('div'); + value.className = 'main-boiler-card__value'; + value.textContent = currentValue || '—'; + + const unit = document.createElement('div'); + unit.className = 'main-boiler-card__unit'; + unit.textContent = '°C'; + + valueRow.append(value, unit); + valueColumn.append(valueLabel, valueRow); + + const chartWrap = document.createElement('div'); + chartWrap.className = 'main-boiler-card__chart-wrap'; + if (history.loading && (!historyPoints || !historyPoints.length)) { + chartWrap.classList.add('is-loading'); + } + chartWrap.appendChild(renderBoilerSparkline(historyPoints)); + + if (history.loading && (!historyPoints || !historyPoints.length)) { + const loading = document.createElement('div'); + loading.className = 'main-boiler-card__loading'; + loading.textContent = 'Загружаем график...'; + chartWrap.appendChild(loading); + } + + body.append(valueColumn, chartWrap); + inner.append(header, body); + card.appendChild(inner); + scheduleMainBoilerHistoryLoad(snapshot); + return card; + } + + function renderMainHero(snapshot) { + const hero = document.createElement('div'); + hero.className = 'main-dashboard__hero'; + + const weatherSlot = document.createElement('div'); + weatherSlot.className = 'main-dashboard__weather-slot'; + if (snapshot.weather) { + weatherSlot.appendChild(mainWeatherCard(snapshot.weather)); + } + hero.appendChild(weatherSlot); + + const stack = document.createElement('div'); + stack.className = 'main-dashboard__hero-stack'; + + const actions = renderMainWeatherActions(snapshot); + if (actions) { + stack.appendChild(actions); + } + + const boiler = renderMainBoilerCard(snapshot); + if (boiler) { + stack.appendChild(boiler); + } + + if (!stack.childNodes.length) { + const spacer = document.createElement('div'); + spacer.className = 'main-dashboard__hero-spacer'; + stack.appendChild(spacer); + } + + hero.appendChild(stack); + + return hero; + } + + function serviceValueForCommand(command, value) { + if (value === null || value === undefined || value === '') { + return null; + } + if (command === 'set_position' || command === 'set_temperature') { + return Number(value); + } + return value; + } + + function climateOptionButtons(entity, attrName, command, title) { + const values = Array.isArray(entity.attributes?.[attrName]) ? entity.attributes[attrName] : []; + if (!values.length) return null; + + const section = document.createElement('div'); + section.className = 'entity-modal__options-block'; + + const heading = document.createElement('div'); + heading.className = 'entity-modal__options-title'; + heading.textContent = climateGroupTitle(attrName) || title; + section.appendChild(heading); + + const list = document.createElement('div'); + list.className = 'entity-modal__chips'; + const current = String(entity.attributes?.[command.replace('set_', '')] || entity.attributes?.hvac_mode || entity.attributes?.fan_mode || entity.attributes?.swing_mode || entity.attributes?.preset_mode || '').toLowerCase(); + + values.forEach((value) => { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = `entity-chip ${String(value).toLowerCase() === current ? 'is-active' : ''}`; + chip.textContent = climateOptionLabel(attrName, value); + chip.title = String(value); + chip.addEventListener('click', () => { + handleClimateCommand(entity, command, value); + }); + list.appendChild(chip); + }); + + section.appendChild(list); + return section; + } + + function renderCoverPopup(entity) { + const wrap = document.createElement('div'); + wrap.className = 'entity-modal__cover'; + + const rail = document.createElement('div'); + rail.className = 'entity-modal__rail entity-modal__rail--cover'; + + const initialValue = coverPositionValue(entity); + + const valueRow = document.createElement('div'); + valueRow.className = 'entity-modal__cover-meta'; + + const label = document.createElement('div'); + label.className = 'entity-modal__cover-label'; + label.textContent = 'Открыт на'; + + const value = document.createElement('div'); + value.className = 'entity-modal__cover-value'; + value.textContent = `${initialValue}%`; + valueRow.append(label, value); + + const actions = document.createElement('div'); + actions.className = 'entity-modal__actions entity-modal__actions--vertical'; + + const openBtn = createButton('Открыть', null, 'mdi:arrow-up', 'mushroom-button mushroom-button--small mushroom-button--square'); + openBtn.addEventListener('click', () => handleEntityAction(entity, 'open')); + + const stopBtn = createButton('Стоп', null, 'mdi:stop', 'mushroom-button mushroom-button--small mushroom-button--square'); + stopBtn.addEventListener('click', () => handleEntityAction(entity, 'stop')); + + const closeBtn = createButton('Закрыть', null, 'mdi:arrow-down', 'mushroom-button mushroom-button--small mushroom-button--square'); + closeBtn.addEventListener('click', () => handleEntityAction(entity, 'close')); + + actions.append(openBtn, stopBtn, closeBtn); + + const progress = document.createElement('div'); + progress.className = 'entity-modal__cover-track'; + progress.tabIndex = 0; + progress.setAttribute('role', 'slider'); + progress.setAttribute('aria-label', 'Позиция жалюзи'); + progress.setAttribute('aria-valuemin', '0'); + progress.setAttribute('aria-valuemax', '100'); + + const fill = document.createElement('div'); + fill.className = 'entity-modal__cover-fill'; + fill.style.height = `${initialValue}%`; + fill.style.bottom = '0'; + fill.style.width = '100%'; + progress.appendChild(fill); + + const handle = document.createElement('div'); + handle.className = 'entity-modal__cover-handle'; + progress.appendChild(handle); + + let currentValue = initialValue; + const syncValue = (nextValue) => { + currentValue = Math.max(0, Math.min(100, Math.round(nextValue))); + fill.style.height = `${currentValue}%`; + handle.style.bottom = `calc(${currentValue}% - 10px)`; + value.textContent = `${currentValue}%`; + progress.setAttribute('aria-valuenow', String(currentValue)); + progress.setAttribute('aria-valuetext', `${currentValue}%`); + }; + syncValue(initialValue); + + const updateFromPointer = (clientY) => { + const rect = progress.getBoundingClientRect(); + const ratio = 1 - ((clientY - rect.top) / rect.height); + const nextValue = Math.max(0, Math.min(100, Math.round(ratio * 100))); + syncValue(nextValue); + }; + + let dragPointerId = null; + const onPointerMove = (event) => { + if (dragPointerId !== event.pointerId) return; + event.preventDefault(); + updateFromPointer(event.clientY); + }; + const onPointerUp = (event) => { + if (dragPointerId !== event.pointerId) return; + event.preventDefault(); + progress.releasePointerCapture?.(dragPointerId); + dragPointerId = null; + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); + handleCoverPosition(entity, currentValue); + }; + + progress.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + dragPointerId = event.pointerId; + progress.setPointerCapture?.(dragPointerId); + updateFromPointer(event.clientY); + window.addEventListener('pointermove', onPointerMove, { passive: false }); + window.addEventListener('pointerup', onPointerUp, { passive: false }); + window.addEventListener('pointercancel', onPointerUp, { passive: false }); + }); + + progress.addEventListener('keydown', (event) => { + const step = event.shiftKey ? 10 : 5; + if (event.key === 'ArrowUp' || event.key === 'ArrowRight') { + event.preventDefault(); + syncValue(currentValue + step); + handleCoverPosition(entity, currentValue); + } else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') { + event.preventDefault(); + syncValue(currentValue - step); + handleCoverPosition(entity, currentValue); + } else if (event.key === 'Home') { + event.preventDefault(); + syncValue(0); + handleCoverPosition(entity, 0); + } else if (event.key === 'End') { + event.preventDefault(); + syncValue(100); + handleCoverPosition(entity, 100); + } + }); + + rail.append(valueRow, progress); + wrap.append(rail, actions); + return wrap; + } + + function renderClimatePopup(entity) { + const wrap = document.createElement('div'); + wrap.className = 'entity-modal__climate'; + + const tempBlock = document.createElement('div'); + tempBlock.className = 'entity-modal__climate-summary'; + tempBlock.innerHTML = ` +
Текущая температура
+
${esc(entity.attributes?.current_temperature ?? '—')}°C
+
+
${esc(climateStateLabel(entity.attributes?.hvac_action || entity.state || '—'))}
+
${esc(entity.attributes?.temperature ?? '—')}°C
+
+ `; + + const controls = document.createElement('div'); + controls.className = 'entity-modal__temperature-controls'; + + const minus = document.createElement('button'); + minus.type = 'button'; + minus.className = 'round-button entity-modal__round-button'; + minus.innerHTML = ''; + minus.addEventListener('click', () => handleClimateTemperature(entity, -1)); + + const plus = document.createElement('button'); + plus.type = 'button'; + plus.className = 'round-button entity-modal__round-button'; + plus.innerHTML = ''; + plus.addEventListener('click', () => handleClimateTemperature(entity, 1)); + + controls.append(minus, plus); + + const modes = document.createElement('div'); + modes.className = 'entity-modal__modes'; + [ + ['hvac_modes', 'Режим', 'set_hvac_mode'], + ['fan_modes', 'Вентилятор', 'set_fan_mode'], + ['swing_modes', 'Качание', 'set_swing_mode'], + ['preset_modes', 'Предустановки', 'set_preset_mode'], + ].forEach(([attrName, title, command]) => { + const block = climateOptionButtons(entity, attrName, command, title); + if (block) { + modes.appendChild(block); + } + }); + + wrap.append(tempBlock, controls, modes); + return wrap; + } + + function climateGroupTitle(attrName) { + const key = String(attrName || '').toLowerCase(); + switch (key) { + case 'hvac_modes': + return 'Режим'; + case 'fan_modes': + return 'Вентилятор'; + case 'swing_modes': + return 'Качание'; + case 'preset_modes': + return 'Предустановки'; + default: + return 'Режим'; + } + } + + function climateOptionLabel(attrName, value) { + const key = String(value ?? '').trim().toLowerCase(); + const normalized = key.replace(/\s+/g, '_'); + + const maps = { + hvac_modes: { + off: 'Выключено', + auto: 'Авто', + cool: 'Охлаждение', + heat: 'Обогрев', + dry: 'Осушение', + fan_only: 'Только вентилятор', + heat_cool: 'Авто', + eco: 'Эко', + away: 'Вне дома', + sleep: 'Сон', + }, + fan_modes: { + auto: 'Авто', + low: 'Низкая', + low_mid: 'Ниже средней', + low_medium: 'Ниже средней', + medium: 'Средняя', + mid: 'Средняя', + mid_high: 'Выше средней', + high: 'Высокая', + turbo: 'Турбо', + diffuse: 'Рассеянный', + }, + swing_modes: { + off: 'Выкл', + top: 'Верх', + middletop1: 'Верх 1', + middletop2: 'Верх 2', + middlebottom2: 'Низ 2', + middlebottom1: 'Низ 1', + bottom: 'Низ', + swing: 'Авто', + auto: 'Авто', + }, + preset_modes: { + none: 'Нет', + sleep: 'Сон', + boost: 'Турбо', + eco: 'Эко', + away: 'Вне дома', + home: 'Дома', + comfort: 'Комфорт', + quiet: 'Тихо', + }, + }; + + const map = maps[String(attrName || '').toLowerCase()] || {}; + if (map[normalized]) { + return map[normalized]; + } + + return String(value ?? '').replace(/_/g, ' '); + } + + function climateStateLabel(value) { + const key = String(value ?? '').trim().toLowerCase(); + const labels = { + off: 'Выключено', + idle: 'Ожидание', + auto: 'Авто', + cool: 'Охлаждение', + heating: 'Нагрев', + heat: 'Обогрев', + cooling: 'Охлаждение', + dry: 'Осушение', + fan: 'Вентиляция', + heat_cool: 'Авто', + on: 'Включено', + }; + return labels[key] || String(value ?? '—').replace(/_/g, ' '); + } + + function coverPositionValue(entity) { + const currentPosition = Number(entity?.attributes?.current_position); + if (Number.isFinite(currentPosition)) { + return Math.max(0, Math.min(100, Math.round(currentPosition))); + } + + const state = String(entity?.state || '').toLowerCase(); + return state === 'open' || state === 'opening' ? 100 : 0; + } + + async function handleEntityService(entity, command, value = null, patch = null) { + try { + if (patch) { + patchSnapshotEntity(entity.entity_id, patch); + } + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + refreshCurrentRoomLayout(entity.entity_id); + await apiPost('service', { + entity_id: entity.entity_id, + command, + ...(value !== null && value !== undefined ? { value } : {}), + }); + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + } catch (error) { + console.error(error); + setStatus('Ошибка команды', 'error'); + } + } + + function handleCoverPosition(entity, value) { + const next = normalizePositionValue(value); + if (next === null) return; + const currentPosition = Number(entity.attributes?.current_position); + let stateValue = undefined; + if (next === 0) { + stateValue = 'closed'; + } else if (next === 100) { + stateValue = 'open'; + } else if (Number.isFinite(currentPosition)) { + stateValue = next >= currentPosition ? 'opening' : 'closing'; + } + const patch = { + attributes: { + ...(entity.attributes || {}), + current_position: next, + }, + }; + if (stateValue) { + patch.state = stateValue; + } + handleEntityService(entity, 'set_position', next, patch); + } + + async function handleMainWeatherAction(action) { + const snapshot = state.snapshot || bootstrap; + const entityId = String(action?.entity_id || '').trim(); + if (!entityId) return; + + const command = String(action?.command || 'set_temperature').trim() || 'set_temperature'; + const value = action?.value; + const stateEntityId = String(action?.state_entity_id || entityId).trim() || entityId; + const stateEntity = getEntityFromSnapshot(snapshot, stateEntityId) || getEntityDefinition(snapshot, stateEntityId); + const targetEntity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + const nextValue = value !== null && value !== undefined && value !== '' ? String(value) : ''; + + if (stateEntity) { + const nextAttributes = { ...(stateEntity.attributes || {}) }; + if (command === 'set_temperature' && value !== null && value !== undefined && value !== '') { + nextAttributes.temperature = Number(value); + } + const patch = { + attributes: nextAttributes, + }; + if (stateEntityId !== entityId || command !== 'set_temperature') { + patch.state = nextValue || stateEntity.state; + } + patchSnapshotEntity(stateEntityId, patch); + } + + if (targetEntity && targetEntity.entity_id !== stateEntityId) { + const nextAttributes = { ...(targetEntity.attributes || {}) }; + if (command === 'set_temperature' && value !== null && value !== undefined && value !== '') { + nextAttributes.temperature = Number(value); + } + patchSnapshotEntity(targetEntity.entity_id, { + attributes: nextAttributes, + }); + } + + renderDashboardOnly(); + + try { + await apiPost('service', { + entity_id: entityId, + command, + ...(value !== null && value !== undefined ? { value } : {}), + }); + } catch (error) { + console.error(error); + setStatus('Ошибка команды', 'error'); + } + } + + function handleClimateCommand(entity, command, value) { + const patch = {}; + if (command === 'set_hvac_mode') { + patch.attributes = { + ...(entity.attributes || {}), + hvac_mode: value, + }; + } else if (command === 'set_fan_mode') { + patch.attributes = { + ...(entity.attributes || {}), + fan_mode: value, + }; + } else if (command === 'set_swing_mode') { + patch.attributes = { + ...(entity.attributes || {}), + swing_mode: value, + }; + } else if (command === 'set_preset_mode') { + patch.attributes = { + ...(entity.attributes || {}), + preset_mode: value, + }; + } + handleEntityService(entity, command, value, Object.keys(patch).length ? patch : null); + } + + function renderToggleCard(entity, { isMain = false } = {}) { + const card = document.createElement('article'); + const active = ['on', 'open', 'cool', 'heat', 'heating', 'cooling'].includes(String(entity.state).toLowerCase()); + const isDoorContact = Boolean(entity.is_door_contact); + card.className = `grid-card ${!isDoorContact ? 'grid-card--tap ' : ''}${isMain ? 'grid-card--auto' : 'grid-card--entity'} ${isDoorContact ? 'grid-card--door' : ''} ${active ? 'is-active' : ''}`; + card.dataset.entityId = entity.entity_id; + if (!isDoorContact) { + card.dataset.clickToggle = 'true'; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + } + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner'; + + const icon = document.createElement('div'); + icon.className = `grid-card__icon${isMain && active && !isDoorContact ? ' grid-card__icon--active' : ''}${isDoorContact ? ' grid-card__icon--door' : ''}`; + icon.appendChild(createIconElement(entity.icon)); + + const text = buildEntityTitle(entity.name); + + const left = document.createElement('div'); + left.className = 'grid-card__header'; + left.append(icon, text); + if (state.editMode) { + left.appendChild(renderEntityTypeLabel(entity)); + } + inner.appendChild(left); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + card.appendChild(inner); + if (!isDoorContact) { + card.addEventListener('click', (event) => { + if (event.target.closest('button')) return; + handleEntityAction(entity, 'toggle'); + }); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleEntityAction(entity, 'toggle'); + } + }); + } + return card; + } + + function renderCoverCard(entity, options = {}) { + const card = document.createElement('article'); + const currentPosition = coverPositionValue(entity); + const coverState = String(entity.state).toLowerCase(); + const isOpen = ['open', 'opening'].includes(coverState); + const hasVisiblePosition = currentPosition > 0 || isOpen; + card.className = `grid-card grid-card--cover ${!options.isMain && isOpen ? 'is-active' : ''}`; + card.dataset.entityId = entity.entity_id; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner cover-card'; + + const icon = document.createElement('div'); + icon.className = `grid-card__icon${options.isMain && isOpen ? ' grid-card__icon--active' : ''}`; + icon.appendChild(createIconElement(entity.icon)); + + const text = buildEntityTitle(entity.name); + + const left = document.createElement('div'); + left.className = 'grid-card__header'; + left.append(icon, text); + if (state.editMode) { + left.appendChild(renderEntityTypeLabel(entity)); + } + + const rail = document.createElement('div'); + rail.className = 'cover-card__rail'; + + if (hasVisiblePosition) { + const progress = document.createElement('div'); + progress.className = 'cover-progress'; + const bar = document.createElement('div'); + bar.className = 'cover-progress__value'; + const pos = currentPosition > 0 ? currentPosition : 100; + bar.style.width = `${Math.max(0, Math.min(100, pos))}%`; + progress.appendChild(bar); + + if (!options.isMain) { + rail.append(progress); + inner.append(left, rail); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + } else { + inner.append(left, progress); + } + } else { + inner.append(left); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + } + + card.appendChild(inner); + card.addEventListener('click', async (event) => { + if (event.target.closest('button')) return; + if (options.isMain) { + const confirmed = await openConfirm({ + title: 'Хотите закрыть?', + message: `${entity.name} будет закрыт.`, + }); + if (confirmed) { + handleEntityAction(entity, 'close'); + } + return; + } + openEntityPopup(entity.entity_id); + }); + card.addEventListener('keydown', async (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + if (options.isMain) { + const confirmed = await openConfirm({ + title: 'Хотите закрыть?', + message: `${entity.name} будет закрыт.`, + }); + if (confirmed) { + handleEntityAction(entity, 'close'); + } + return; + } + openEntityPopup(entity.entity_id); + }); + return card; + } + + function renderClimateCard(entity, options = {}) { + const card = document.createElement('article'); + const active = !['off', 'unavailable', 'unknown'].includes(String(entity.state).toLowerCase()); + card.className = `grid-card grid-card--climate grid-card--tap ${!options.isMain && active ? 'is-active' : ''}`; + card.dataset.entityId = entity.entity_id; + card.dataset.clickToggle = 'true'; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner climate-card'; + + const icon = document.createElement('div'); + icon.className = `grid-card__icon${options.isMain && active ? ' grid-card__icon--active' : ''}`; + icon.appendChild(createIconElement(entity.icon)); + + const text = buildEntityTitle(entity.name); + + const left = document.createElement('div'); + left.className = 'grid-card__header'; + left.append(icon, text); + if (state.editMode) { + left.appendChild(renderEntityTypeLabel(entity)); + } + + const tempMeta = document.createElement('div'); + tempMeta.className = 'climate-card__meta'; + tempMeta.innerHTML = ` +
${esc(entity.attributes?.temperature ?? '—')}°
+
Сейчас ${esc(entity.attributes?.current_temperature ?? '—')}°
+ `; + + const topRow = document.createElement('div'); + topRow.className = 'climate-card__top'; + topRow.append(left, tempMeta); + + inner.append(topRow); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + card.appendChild(inner); + card.addEventListener('click', (event) => { + if (event.target.closest('button')) return; + if (options.isMain) { + handleEntityAction(entity, active ? 'turn_off' : 'turn_on'); + return; + } + openEntityPopup(entity.entity_id); + }); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (options.isMain) { + handleEntityAction(entity, active ? 'turn_off' : 'turn_on'); + return; + } + openEntityPopup(entity.entity_id); + } + }); + return card; + } + + function renderEditActions(entity) { + const wrap = document.createElement('div'); + wrap.className = 'grid-card__footer grid-card__footer--edit'; + + const hidden = entity.visible === false || entity.override?.visible === false; + + const hideBtn = document.createElement('button'); + hideBtn.type = 'button'; + hideBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + hideBtn.textContent = hidden ? 'Показать' : 'Скрыть'; + hideBtn.addEventListener('click', (event) => { + event.stopPropagation(); + saveOverridePatch(entity, { visible: hidden ? true : false }); + }); + + if (hidden) { + wrap.append(hideBtn); + return wrap; + } + + const actions = document.createElement('div'); + actions.className = 'grid-card__footer-actions'; + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'mushroom-button mushroom-button--small'; + upBtn.innerHTML = ' Вверх'; + upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1)); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'mushroom-button mushroom-button--small'; + downBtn.innerHTML = ' Вниз'; + downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1)); + + actions.append(upBtn, downBtn); + wrap.append(hideBtn, actions); + return wrap; + } + + function renderRoomEditActions(room) { + const wrap = document.createElement('div'); + wrap.className = 'room-item__mini-actions'; + + const hidden = room.visible === false; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'mini-action mini-action--wide'; + btn.innerHTML = hidden ? '' : ''; + btn.title = hidden ? 'Показать' : 'Скрыть'; + btn.addEventListener('click', (event) => { + event.stopPropagation(); + saveSpacePatch(room, { visible: hidden ? true : false }); + }); + + wrap.appendChild(btn); + return wrap; + } + + function wireRoomItemDragEvents(item, room, groupEl, hidden) { + if (!state.editMode || room.id === 'main' || room.virtual || room.id === 'batteries') return; + + item.draggable = true; + item.addEventListener('pointerdown', (event) => { + if (event.target.closest('button')) return; + startRoomDrag(room, item, groupEl, hidden, event); + }); + item.addEventListener('pointermove', (event) => { + if (!state.roomDrag || state.roomDrag.roomId !== room.id) return; + moveRoomDrag(event.clientX, event.clientY); + }); + item.addEventListener('pointerup', async (event) => { + if (!state.roomDrag || state.roomDrag.roomId !== room.id) return; + try { + await finishRoomDrag(); + } catch (error) { + console.warn(error); + } + event.preventDefault(); + }); + item.addEventListener('pointercancel', async () => { + if (!state.roomDrag || state.roomDrag.roomId !== room.id) return; + try { + await finishRoomDrag(); + } catch (error) { + console.warn(error); + } + }); + item.addEventListener('dragstart', (event) => { + event.preventDefault(); + }); + } + + function renderEntityCard(entity, options = {}) { + const type = entity.card_type || entity.domain; + const card = type === 'cover' + ? renderCoverCard(entity, options) + : type === 'climate' + ? renderClimateCard(entity, options) + : renderToggleCard(entity, options); + + if (entity.visible === false) { + card.classList.add('is-hidden'); + } + if (state.editMode) { + card.classList.add('is-editing'); + } + + return card; + } + + function renderLayoutCard(item, room) { + const card = document.createElement('article'); + card.className = 'grid-card grid-card--ghost'; + card.dataset.layoutItemId = item.id; + card.tabIndex = state.editMode ? 0 : -1; + + if (state.editMode) { + card.classList.add('is-editing'); + } + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner grid-card__ghost-inner'; + + if (state.editMode) { + const header = document.createElement('div'); + header.className = 'grid-card__header'; + + const icon = document.createElement('div'); + icon.className = 'grid-card__icon grid-card__icon--ghost'; + icon.appendChild(createIconElement('mdi:checkbox-blank-outline')); + + const title = document.createElement('div'); + title.className = 'grid-card__title'; + title.innerHTML = 'Пустая карточка'; + + const subtitle = document.createElement('div'); + subtitle.className = 'grid-card__subtitle'; + subtitle.textContent = 'Свободное место для раскладки плиток'; + + header.append(icon, title, subtitle); + inner.appendChild(header); + inner.appendChild(renderLayoutItemEditActions(room, item)); + } + + card.appendChild(inner); + return card; + } + + function renderLayoutItemEditActions(room, item) { + const wrap = document.createElement('div'); + wrap.className = 'grid-card__footer grid-card__footer--edit'; + + const actions = document.createElement('div'); + actions.className = 'grid-card__footer-actions'; + + const isSettingsOpen = Boolean(state.layoutItemSettingsOpen?.[item.id]); + + const settingsBtn = document.createElement('button'); + settingsBtn.type = 'button'; + settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + settingsBtn.innerHTML = ' Настройки'; + settingsBtn.addEventListener('click', (event) => { + event.stopPropagation(); + state.layoutItemSettingsOpen = { + ...(state.layoutItemSettingsOpen || {}), + [item.id]: !isSettingsOpen, + }; + renderDashboardOnly(); + }); + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'mushroom-button mushroom-button--small'; + upBtn.innerHTML = ' Вверх'; + upBtn.addEventListener('click', (event) => { + event.stopPropagation(); + reorderRoomGridEntry(room.id, 'layout', item.id, -1); + }); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'mushroom-button mushroom-button--small'; + downBtn.innerHTML = ' Вниз'; + downBtn.addEventListener('click', (event) => { + event.stopPropagation(); + reorderRoomGridEntry(room.id, 'layout', item.id, 1); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + deleteBtn.innerHTML = ' Удалить'; + deleteBtn.addEventListener('click', (event) => { + event.stopPropagation(); + deleteRoomLayoutItem(room.id, item.id); + }); + + actions.append(upBtn, downBtn, settingsBtn); + wrap.append(actions, deleteBtn); + + if (isSettingsOpen) { + const settings = document.createElement('div'); + settings.className = 'grid-card__layout-settings'; + + const tempBtn = document.createElement('button'); + tempBtn.type = 'button'; + tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + tempBtn.innerHTML = ' Выбрать датчик температуры'; + tempBtn.addEventListener('click', (event) => { + event.stopPropagation(); + openTemperatureSensorPopup(room.id); + }); + + settings.appendChild(tempBtn); + wrap.appendChild(settings); + } + + return wrap; + } + + async function reorderRoomGridEntry(roomId, kind, entryId, direction) { + const nextRoomId = roomId || currentRoom()?.id || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !entryId || !direction || isMobileViewport()) { + return null; + } + + try { + const snapshot = state.snapshot || bootstrap; + const entries = roomGridEntries(snapshot, nextRoomId); + const currentIndex = entries.findIndex((entry) => entry.kind === kind && entry.id === entryId); + if (currentIndex < 0) { + return null; + } + + const targetIndex = currentIndex + direction; + if (targetIndex < 0 || targetIndex >= entries.length) { + return null; + } + + const reordered = entries.slice(); + const [moved] = reordered.splice(currentIndex, 1); + reordered.splice(targetIndex, 0, moved); + + await apiPost('reorder-room-grid', { + room_id: nextRoomId, + entries: reordered.map((entry) => ({ + kind: entry.kind, + id: entry.id, + })), + }); + + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + + return null; + } + + function renderBatteryCard(item) { + const status = String(item.battery_status || 'unknown'); + const card = document.createElement('article'); + card.className = `grid-card battery-card battery-card--${status}`; + card.dataset.entityId = item.entity_id; + card.dataset.batteryStatus = status; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner battery-card__inner'; + + const main = document.createElement('div'); + main.className = 'battery-card__main'; + + const icon = document.createElement('div'); + icon.className = 'battery-card__icon'; + icon.appendChild(createIconElement(item.battery_icon || 'mdi:battery-outline')); + + const text = document.createElement('div'); + text.className = 'battery-card__text'; + + const title = document.createElement('div'); + title.className = 'battery-card__title'; + title.textContent = item.name || item.entity_id; + + const source = document.createElement('div'); + source.className = 'battery-card__source'; + source.textContent = item.source_text || [item.source_room_name, item.source_device_name].filter(Boolean).join(' | ') || 'Без комнаты'; + + text.append(title, source); + main.append(icon, text); + + const side = document.createElement('div'); + side.className = 'battery-card__side'; + + const percent = document.createElement('div'); + percent.className = 'battery-card__percent'; + percent.textContent = item.battery_percent_text || item.battery_status_label || '—'; + + const statusLabel = document.createElement('div'); + statusLabel.className = 'battery-card__status'; + statusLabel.textContent = item.battery_status_label || 'Неизвестно'; + + side.append(percent, statusLabel); + + const footer = document.createElement('div'); + footer.className = 'battery-card__footer'; + footer.textContent = item.forecast_text + || item.forecast_reason + || (status === 'ok' ? 'Прогноз недоступен' : item.battery_status_label || ''); + const hasFooter = Boolean(footer.textContent); + + inner.append(main, side); + if (hasFooter) { + inner.appendChild(footer); + } + card.appendChild(inner); + return card; + } + + function renderRoomButtons(snapshot, rooms, batteryRoom = null) { + els.roomList.innerHTML = ''; + const sortedRooms = [...(rooms || [])].sort((left, right) => { + if (left.id === 'main') return -1; + if (right.id === 'main') return 1; + + const leftOrder = Number(left.order ?? 9999); + const rightOrder = Number(right.order ?? 9999); + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + const leftFloor = Number(left.floor_level ?? 0); + const rightFloor = Number(right.floor_level ?? 0); + if (leftFloor !== rightFloor) { + return leftFloor - rightFloor; + } + + return String(left.name || '').localeCompare(String(right.name || ''), 'ru'); + }); + + const visibleRooms = sortedRooms.filter((room) => room.id === 'main' || room.visible !== false); + const hiddenRooms = sortedRooms.filter((room) => room.id !== 'main' && room.visible === false); + + const visibleGroup = document.createElement('div'); + visibleGroup.className = 'room-list__group room-list__group--visible'; + let hiddenGroup = null; + + const renderItem = (room, hidden = false) => { + const item = document.createElement('div'); + item.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${hidden ? 'is-hidden-room' : ''} ${state.editMode && !room.virtual ? 'is-editing' : ''}`; + item.dataset.roomId = room.id; + item.dataset.roomGroup = hidden ? 'hidden' : 'visible'; + item.tabIndex = 0; + item.setAttribute('role', 'button'); + item.addEventListener('click', (event) => { + if (event.target.closest('button')) return; + if (state.roomDrag?.moved && state.roomDrag?.roomId === room.id && Date.now() < (state.roomDrag?.suppressClickUntil || 0)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + setSelectedRoom(room.id); + }); + item.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedRoom(room.id); + } + }); + + const content = document.createElement('div'); + content.className = 'room-item__content'; + + const icon = document.createElement('div'); + icon.className = 'room-item__icon'; + icon.appendChild(createIconElement(room.icon || 'mdi:home-variant')); + + const body = document.createElement('div'); + body.className = 'room-item__body'; + const activeCount = room.id === 'batteries' + ? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0 + : Number(room.active_entity_count ?? room.entity_count ?? 0) || 0; + const metaText = room.id === 'main' + ? 'Главный экран' + : room.id === 'batteries' + ? (room.battery_summary_text || `${room.entity_count || 0} батареек`) + : activeCount > 0 + ? `${activeCount} ${pluralizeActiveEntities(activeCount)}` + : 'Нет активных'; + body.innerHTML = ` +
${esc(room.name)}
+
${metaText}
+ `; + content.append(icon, body); + + const tempBadge = roomTemperatureBadge(snapshot, room); + if (tempBadge) { + item.classList.add('has-temp'); + const temp = document.createElement('div'); + temp.className = 'room-item__temp'; + temp.textContent = tempBadge; + item.appendChild(temp); + } + + item.append(content); + if (state.editMode && room.id !== 'main' && !room.virtual && room.id !== 'batteries') { + item.appendChild(renderRoomEditActions(room)); + } + wireRoomItemDragEvents(item, room, hidden ? hiddenGroup : visibleGroup, hidden); + return item; + }; + + visibleRooms.forEach((room) => { + visibleGroup.appendChild(renderItem(room, false)); + }); + + if (batteryRoom && !isMobileViewport()) { + visibleGroup.appendChild(renderItem(batteryRoom, false)); + } + + els.roomList.appendChild(visibleGroup); + + if (state.editMode && hiddenRooms.length) { + const divider = document.createElement('div'); + divider.className = 'room-list__divider'; + + const label = document.createElement('div'); + label.className = 'room-list__divider-label'; + label.textContent = 'Скрытые'; + + divider.append(label); + els.roomList.appendChild(divider); + + hiddenGroup = document.createElement('div'); + hiddenGroup.className = 'room-list__group room-list__group--hidden'; + hiddenRooms.forEach((room) => { + hiddenGroup.appendChild(renderItem(room, true)); + }); + els.roomList.appendChild(hiddenGroup); + } + } + + function renderSelectedRoom(snapshot) { + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (els.contentTop) { + els.contentTop.classList.toggle('is-main', room.id === 'main'); + } + if (els.contentHeader) { + els.contentHeader.classList.toggle('hidden', room.id === 'main' && !isMobileRoomView()); + } + updateMainPrintStrip(snapshot); + if (room.id === 'batteries') { + els.selectedRoomEyebrow.textContent = 'Псевдо-комната'; + els.selectedRoomTitle.textContent = room.name || 'Батарейки'; + const total = Number(room.entity_count ?? 0) || 0; + const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0; + const unavailable = Number(room.unavailable_count ?? 0) || 0; + const unknown = Number(room.unknown_count ?? 0) || 0; + const summaryParts = []; + if (critical > 0) { + summaryParts.push(`${critical} ${pluralizeRu(critical, 'проблемная', 'проблемных', 'проблемных')}`); + } + if (unavailable > 0) { + summaryParts.push(`${unavailable} ${pluralizeRu(unavailable, 'недоступная', 'недоступных', 'недоступных')}`); + } + if (unknown > 0) { + summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`); + } + els.selectedRoomMeta.textContent = summaryParts.length + ? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}` + : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`; + renderSelectedRoomActions(snapshot); + return; + } + if (room.id !== 'main') { + els.selectedRoomEyebrow.textContent = 'Пространство'; + els.selectedRoomTitle.textContent = room.name || 'Панель'; + const entities = roomEntities(snapshot, room.id || 'main'); + const activeCount = Number(room.active_entity_count ?? entities.length) || 0; + els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`; + renderSelectedRoomActions(snapshot); + return; + } + + const entities = roomEntities(snapshot, room.id || 'main'); + els.selectedRoomEyebrow.textContent = ''; + els.selectedRoomTitle.textContent = room.name || 'Панель'; + els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`; + renderSelectedRoomActions(snapshot); + } + + function renderSelectedRoomActions(snapshot) { + if (!els.selectedRoomActions) return; + + const room = snapshot.selected_space || snapshot.selected_room || {}; + els.selectedRoomActions.replaceChildren(); + + if (isMobileViewport() || !state.editMode || room.id === 'main' || room.id === 'batteries') { + return; + } + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; + addButton.innerHTML = 'Пустая карточка'; + addButton.addEventListener('click', () => { + createRoomLayoutItem(room.id); + }); + + const temperatureButton = document.createElement('button'); + temperatureButton.type = 'button'; + temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; + temperatureButton.innerHTML = 'Выбрать датчик температуры'; + temperatureButton.addEventListener('click', () => { + openTemperatureSensorPopup(room.id); + }); + + els.selectedRoomActions.append(addButton, temperatureButton); + } + + function renderDashboard(snapshot) { + const room = snapshot.selected_space || snapshot.selected_room || {}; + const grid = els.dashboardSurface; + grid.innerHTML = ''; + + if (room.id === 'main') { + const layout = document.createElement('div'); + layout.className = 'main-dashboard'; + + const hero = renderMainHero(snapshot); + + const cards = document.createElement('div'); + cards.className = 'grid-surface main-dashboard__cards'; + + const mainEntities = roomEntities(snapshot, 'main'); + mainEntities.forEach((entity) => { + cards.appendChild(renderEntityCard(entity, { isMain: true })); + }); + + layout.append(hero, cards); + grid.appendChild(layout); + return; + } + + if (room.id === 'batteries') { + const section = document.createElement('section'); + section.className = 'room-entities-section battery-room'; + + const header = document.createElement('div'); + header.className = 'room-entities-section__header battery-room__header'; + + const title = document.createElement('div'); + title.className = 'room-entities-section__title'; + title.textContent = room.battery_summary_text || `${Number(room.entity_count ?? 0) || 0} батареек`; + header.appendChild(title); + section.appendChild(header); + + const list = document.createElement('div'); + list.className = 'battery-room__list'; + const items = Array.isArray(room.entities) ? room.entities : []; + + items.forEach((item) => { + list.appendChild(renderBatteryCard(item)); + }); + + if (!items.length) { + const empty = document.createElement('article'); + empty.className = 'loading-card battery-room__empty'; + empty.textContent = 'Батарейки с ярлыком «Батарейка» не найдены.'; + list.appendChild(empty); + } + + section.appendChild(list); + grid.appendChild(section); + return; + } + + const visibleEntries = roomGridEntries(snapshot, room.id); + const hiddenEntitiesList = state.editMode && room.id !== 'main' + ? roomEntitiesIncludingHidden(snapshot, room.id).filter((entity) => entity.visible === false) + : []; + + const visibleSection = document.createElement('section'); + visibleSection.className = 'room-entities-section'; + + if (state.editMode) { + const visibleHeader = document.createElement('div'); + visibleHeader.className = 'room-entities-section__header'; + + const visibleTitle = document.createElement('div'); + visibleTitle.className = 'room-entities-section__title'; + visibleTitle.textContent = 'Объекты'; + visibleHeader.appendChild(visibleTitle); + visibleSection.appendChild(visibleHeader); + } + + const visibleGrid = document.createElement('div'); + visibleGrid.className = 'grid-surface room-entities-section__grid'; + visibleEntries.forEach((entry) => { + if (entry.kind === 'layout') { + visibleGrid.appendChild(renderLayoutCard(entry.payload, room)); + } else { + visibleGrid.appendChild(renderEntityCard(entry.payload)); + } + }); + + if (!visibleEntries.length) { + const empty = document.createElement('article'); + empty.className = 'loading-card grid-card--full'; + empty.textContent = 'В этой комнате нет доступных объектов.'; + visibleGrid.appendChild(empty); + } + + visibleSection.appendChild(visibleGrid); + grid.appendChild(visibleSection); + + if (state.editMode && hiddenEntitiesList.length) { + const hiddenSection = document.createElement('section'); + hiddenSection.className = 'room-entities-section room-entities-section--hidden'; + + const hiddenHeader = document.createElement('div'); + hiddenHeader.className = 'room-entities-section__header'; + + const hiddenTitle = document.createElement('div'); + hiddenTitle.className = 'room-entities-section__title'; + hiddenTitle.textContent = 'Скрытые объекты'; + + const hiddenMeta = document.createElement('div'); + hiddenMeta.className = 'room-entities-section__meta'; + hiddenMeta.textContent = `${hiddenEntitiesList.length} ${pluralizeEntities(hiddenEntitiesList.length)}`; + + hiddenHeader.append(hiddenTitle, hiddenMeta); + hiddenSection.appendChild(hiddenHeader); + + const hiddenGrid = document.createElement('div'); + hiddenGrid.className = 'grid-surface room-entities-section__grid room-entities-section__grid--hidden'; + hiddenEntitiesList.forEach((entity) => { + hiddenGrid.appendChild(renderEntityCard(entity)); + }); + hiddenSection.appendChild(hiddenGrid); + grid.appendChild(hiddenSection); + } + } + + function renderPopup(snapshot) { + if (isMobileViewport()) { + hidePopup({ preserveSnapshot: true }); + return; + } + + const popup = mergePopupWithCamera(snapshot.popup || {}); + const signature = JSON.stringify([ + popup.active, + popup.sensor_entity_id || '', + popup.expires_at || '', + popup.stream_url || '', + popup.poster_url || '', + popup.stream_mode || '', + ]); + + if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) { + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + return; + } + + state.lastPopupSignature = signature; + + if (!popup.active) { + hidePopup(); + return; + } + + els.cameraPoster.src = popup.poster_url || ''; + els.cameraPoster.alt = popup.sensor_entity_id || 'camera'; + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + els.cameraPlaceholder.classList.add('is-visible'); + + const expiresAt = Number(popup.expires_at || 0); + if (expiresAt > 0) { + let closeRequested = false; + const updateCountdown = () => { + const remaining = Math.max(0, expiresAt - Math.floor(Date.now() / 1000)); + const mins = Math.floor(remaining / 60); + const secs = remaining % 60; + if (remaining > 0) { + els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`; + return; + } + + els.cameraCountdown.textContent = 'Закрытие...'; + if (closeRequested) { + return; + } + closeRequested = true; + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = null; + apiPost('popup', { command: 'close' }) + .then((response) => { + if (response?.popup) { + applyPopupSnapshot(response.popup); + } else { + hidePopup(); + } + }) + .catch(() => { + hidePopup(); + }); + }; + updateCountdown(); + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = setInterval(updateCountdown, 1000); + } else { + els.cameraCountdown.textContent = ''; + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = null; + } + + const streamUrl = popup.stream_url || ''; + const resolvedMode = resolvePopupStreamMode(streamUrl, popup.stream_mode || ''); + renderStream(streamUrl, resolvedMode, popup.poster_url || ''); + } + + function hidePopup(options = {}) { + const { suppressAutoOpen = false, preserveSnapshot = false } = options; + if (suppressAutoOpen) { + state.popupAutoOpenBlockedUntil = Date.now() + 60000; + } + state.lastPopupSignature = ''; + state.snapshot = state.snapshot || bootstrap; + if (!preserveSnapshot && state.snapshot.popup) { + state.snapshot.popup = { + ...state.snapshot.popup, + active: false, + }; + } + els.cameraBackdrop.classList.remove('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'true'); + els.cameraStage.innerHTML = ''; + els.cameraStage.appendChild(els.cameraPoster); + els.cameraStage.appendChild(els.cameraPlaceholder); + els.cameraPlaceholder.classList.add('is-visible'); + els.cameraPoster.removeAttribute('src'); + els.cameraCountdown.textContent = ''; + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = null; + destroyStream(); + } + + async function showDebugPopup() { + try { + const response = await apiPost('popup', { command: 'open' }); + const snapshot = state.snapshot || bootstrap; + state.snapshot = snapshot; + applyPopupSnapshot(response.popup || {}); + } catch (error) { + console.error(error); + setStatus('Ошибка popup', 'error'); + } + } + + function destroyStream() { + if (state.hlsInstance) { + try { + state.hlsInstance.destroy(); + } catch (error) { + console.warn(error); + } + state.hlsInstance = null; + } + } + + function inferStreamMode(url) { + if (!url) return 'poster'; + if (url.includes('.m3u8')) return 'hls'; + if (url.includes('.mp4')) return 'video'; + if (url.includes('stream.html')) return 'iframe'; + if (url.startsWith('http')) return 'iframe'; + return 'iframe'; + } + + function mutedStreamUrl(url) { + if (!url) return ''; + try { + const parsed = new URL(url, window.location.href); + if (parsed.pathname.includes('webrtc.html')) { + parsed.searchParams.set('media', 'video'); + } + parsed.searchParams.set('mute', '1'); + parsed.searchParams.set('volume', '0'); + parsed.searchParams.set('autoplay', '1'); + return parsed.toString(); + } catch (error) { + return url; + } + } + + async function loadHlsScript() { + if (window.Hls) return; + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js'; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + async function renderStream(url, mode, posterUrl) { + destroyStream(); + els.cameraStage.innerHTML = ''; + els.cameraStage.appendChild(els.cameraPoster); + els.cameraStage.appendChild(els.cameraPlaceholder); + + els.cameraPlaceholder.classList.add('is-visible'); + els.cameraPoster.src = posterUrl || ''; + + if (!url) { + return; + } + + if (mode === 'iframe') { + const iframe = document.createElement('iframe'); + iframe.classList.add('is-loading'); + iframe.src = mutedStreamUrl(url); + iframe.allow = 'autoplay; fullscreen; picture-in-picture'; + iframe.referrerPolicy = 'no-referrer'; + iframe.addEventListener('load', () => { + iframe.classList.add('is-ready'); + els.cameraPlaceholder.classList.remove('is-visible'); + }); + iframe.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible')); + els.cameraStage.appendChild(iframe); + return; + } + + const video = document.createElement('video'); + video.classList.add('is-loading'); + video.autoplay = true; + video.muted = true; + video.defaultMuted = true; + video.volume = 0; + video.playsInline = true; + video.setAttribute('muted', ''); + video.setAttribute('playsinline', ''); + video.setAttribute('webkit-playsinline', ''); + video.controls = false; + video.poster = posterUrl || ''; + video.preload = 'metadata'; + video.addEventListener('loadeddata', () => { + video.classList.add('is-ready'); + els.cameraPlaceholder.classList.remove('is-visible'); + }); + video.addEventListener('canplay', () => { + video.classList.add('is-ready'); + els.cameraPlaceholder.classList.remove('is-visible'); + }); + video.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible')); + els.cameraStage.appendChild(video); + + if (mode === 'hls') { + if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = url; + return; + } + + try { + await loadHlsScript(); + if (window.Hls) { + const hls = new window.Hls({ + lowLatencyMode: true, + }); + hls.loadSource(url); + hls.attachMedia(video); + state.hlsInstance = hls; + hls.on(window.Hls.Events.MANIFEST_PARSED, () => els.cameraPlaceholder.classList.remove('is-visible')); + hls.on(window.Hls.Events.ERROR, () => els.cameraPlaceholder.classList.add('is-visible')); + return; + } + } catch (error) { + console.warn('HLS load failed', error); + } + } + + video.src = url; + } + + function render() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) { + return; + } + + syncLayoutState(); + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + renderSelectedRoom(snapshot); + renderDashboard(snapshot); + renderPopup(snapshot); + renderEntityPopup(snapshot); + renderTemperatureSensorPopup(snapshot); + + const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); + els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; + els.editModeToggle.classList.toggle('is-active', state.editMode); + els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + } + + function renderDashboardOnly() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; + syncLayoutState(); + renderSelectedRoom(snapshot); + renderDashboard(snapshot); + renderPopup(snapshot); + renderEntityPopup(snapshot); + renderTemperatureSensorPopup(snapshot); + } + + function refreshCurrentRoomLayout(entityId) { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + updateMainEntityCard(entityId); + renderSelectedRoom(snapshot); + return; + } + + if (room.id === 'batteries') { + renderDashboardOnly(); + return; + } + + renderDashboardOnly(); + } + + function refreshCurrentRoomOrder() { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + const container = mainCardsContainer(); + if (container) sortMainCardsBySnapshot(container); + return; + } + + const container = els.dashboardSurface; + const order = new Map(roomEntities(snapshot, room.id).map((entity) => [entity.entity_id, Number(entity.order ?? 9999)])); + const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]')); + cards.sort((left, right) => { + const leftOrder = order.get(left.dataset.entityId) ?? Number.MAX_SAFE_INTEGER; + const rightOrder = order.get(right.dataset.entityId) ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left.dataset.entityId || '').localeCompare(String(right.dataset.entityId || ''), 'ru'); + }); + cards.forEach((card) => container.appendChild(card)); + } + + function renderSidebarOnly() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); + els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; + els.editModeToggle.classList.toggle('is-active', state.editMode); + els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + } + + function renderSelectionOnly() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; + syncLayoutState(); + renderSelectedRoom(snapshot); + } + + async function handleEntityAction(entity, command) { + try { + const snapshot = state.snapshot || bootstrap; + const nextState = optimisticStateForCommand(entity, command); + const isCurrentRoomEntity = state.selectedRoomId !== 'main' + && roomEntities(snapshot, state.selectedRoomId).some((item) => item.entity_id === entity.entity_id); + + if (nextState !== null) { + if (state.selectedRoomId === 'main' || isMainDisplayEntity(entity)) { + patchSnapshotEntity(entity.entity_id, { + state: nextState, + attributes: entity.attributes || {}, + last_changed: entity.last_changed || entity.last_updated || new Date().toISOString(), + }); + syncMainEntities(entity.entity_id, entity, { + state: nextState, + attributes: entity.attributes || {}, + last_changed: entity.last_changed || entity.last_updated || new Date().toISOString(), + }); + refreshCurrentRoomLayout(entity.entity_id); + } else if (isCurrentRoomEntity) { + patchSnapshotEntity(entity.entity_id, { + state: nextState, + attributes: entity.attributes || {}, + }); + refreshCurrentRoomLayout(entity.entity_id); + } + } + + await apiPost('service', { + entity_id: entity.entity_id, + command, + }); + } catch (error) { + console.error(error); + setStatus('Ошибка команды', 'error'); + } + } + + async function handleClimateTemperature(entity, delta) { + const current = Number(entity.attributes?.temperature); + if (!Number.isFinite(current)) return; + const target = Math.round((current + delta) * 2) / 2; + try { + patchSnapshotEntity(entity.entity_id, { + attributes: { + ...(entity.attributes || {}), + temperature: target, + }, + }); + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + refreshCurrentRoomLayout(entity.entity_id); + await apiPost('service', { + entity_id: entity.entity_id, + command: 'set_temperature', + value: target, + }); + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + } catch (error) { + console.error(error); + setStatus('Ошибка температуры', 'error'); + } + } + + async function saveOverridePatch(entity, patch) { + const room = currentRoom(); + if (!room || room.id === 'main') return; + + try { + await apiPost('save-entity-override', { + room_id: room.id, + entity_id: entity.entity_id, + ...patch, + }); + patchSnapshotEntity(entity.entity_id, { + visible: patch.visible !== undefined ? Boolean(patch.visible) : undefined, + order: patch.order !== undefined ? patch.order : undefined, + }); + try { + await loadSnapshot(state.selectedRoomId || room.id || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function saveSpacePatch(room, patch) { + try { + const nextPatch = {}; + if (patch.visible !== undefined) nextPatch.visible = Boolean(patch.visible); + if (patch.order !== undefined) nextPatch.order = patch.order; + if (patch.name !== undefined) nextPatch.name = patch.name; + if (patch.icon !== undefined) nextPatch.icon = patch.icon; + if (patch.temperature_sensor_entity_id !== undefined) { + nextPatch.temperature_sensor_entity_id = String(patch.temperature_sensor_entity_id || ''); + } + + await apiPost('save-space-override', { + room_id: room.id, + ...patch, + }); + patchSnapshotSpace(room.id, nextPatch); + try { + await loadSnapshot(state.selectedRoomId || room.id || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function createRoomLayoutItem(roomId) { + const room = currentRoom(); + const nextRoomId = roomId || room?.id || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || isMobileViewport()) { + return null; + } + + try { + const snapshot = state.snapshot || bootstrap; + const items = roomGridEntries(snapshot, nextRoomId); + const maxOrder = items.reduce((max, item) => Math.max(max, Number(item.order ?? 9999) || 9999), 0); + await apiPost('create-room-layout-item', { + room_id: nextRoomId, + order: maxOrder + 10, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + + return null; + } + + async function saveRoomLayoutItem(roomId, layoutItemId, patch) { + const nextRoomId = roomId || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return; + + try { + await apiPost('save-room-layout-item', { + room_id: nextRoomId, + layout_item_id: layoutItemId, + ...patch, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function deleteRoomLayoutItem(roomId, layoutItemId) { + const nextRoomId = roomId || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return; + + try { + await apiPost('delete-room-layout-item', { + room_id: nextRoomId, + layout_item_id: layoutItemId, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + function wireEvents() { + els.selectedRoomBack?.addEventListener('click', () => { + if (!isMobileViewport()) return; + closeEntityPopup(); + setMobileView('spaces'); + syncLayoutState(); + renderSidebarOnly(); + renderSelectionOnly(); + }); + + els.cameraBackdrop.addEventListener('click', (event) => { + if (event.target === els.cameraBackdrop) { + apiPost('popup', { command: 'close' }).catch(() => {}); + hidePopup({ suppressAutoOpen: true }); + } + }); + + els.cameraModalPanel.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + const closeCameraPopup = async (event) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + try { + await apiPost('popup', { command: 'close' }); + } catch (error) { + console.warn(error); + } + hidePopup({ suppressAutoOpen: true }); + }; + + els.cameraClose.addEventListener('pointerdown', closeCameraPopup); + els.cameraClose.addEventListener('click', closeCameraPopup); + + els.entityBackdrop?.addEventListener('click', (event) => { + if (event.target === els.entityBackdrop) { + closeEntityPopup(); + } + }); + + els.entityModalPanel?.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + els.entityClose?.addEventListener('click', () => { + closeEntityPopup(); + }); + + els.temperatureSensorBackdrop?.addEventListener('click', (event) => { + if (event.target === els.temperatureSensorBackdrop) { + closeTemperatureSensorPopup(); + } + }); + + els.temperatureSensorModalPanel?.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + els.temperatureSensorClose?.addEventListener('click', () => { + closeTemperatureSensorPopup(); + }); + + els.popupDebugButton?.addEventListener('click', () => { + showDebugPopup(); + }); + + els.editModeToggle.addEventListener('click', async () => { + state.editMode = !state.editMode; + try { + await apiPost('save-settings', { + edit_mode: state.editMode, + }); + } catch (error) { + console.warn(error); + } + state.snapshot = state.snapshot || bootstrap; + if (state.snapshot?.settings) { + state.snapshot.settings.edit_mode = state.editMode; + } + if (!state.editMode && currentRoom()?.visible === false) { + patchSnapshotSelection('main'); + } + try { + await loadSnapshot(state.selectedRoomId || 'main'); + } catch (error) { + console.warn(error); + } + render(); + }); + } + + function initRefs() { + els.appShell = q('.app-shell'); + els.clockTime = $('clock-time'); + els.clockDate = $('clock-date'); + els.roomsCount = $('rooms-count'); + els.roomList = $('room-list'); + els.editModeToggle = $('edit-mode-toggle'); + els.selectedRoomBack = $('selected-room-back'); + els.contentTop = q('.content-top'); + els.mainPrintStripSlot = $('main-print-strip-slot'); + els.contentHeader = q('.content-header'); + els.selectedRoomActions = $('selected-room-actions'); + els.selectedRoomEyebrow = $('selected-room-eyebrow'); + els.selectedRoomTitle = $('selected-room-title'); + els.selectedRoomMeta = $('selected-room-meta'); + els.dashboardSurface = $('dashboard-surface'); + els.cameraBackdrop = $('camera-modal'); + els.cameraModalPanel = $('camera-modal-panel'); + els.cameraClose = $('camera-modal-close'); + els.popupDebugButton = $('popup-debug-button'); + els.cameraStage = $('camera-stage'); + els.cameraPoster = $('camera-poster'); + els.cameraPlaceholder = $('camera-placeholder'); + els.cameraCountdown = $('camera-countdown'); + els.confirmBackdrop = $('confirm-modal'); + els.confirmTitle = $('confirm-modal-title'); + els.confirmMessage = $('confirm-modal-message'); + els.confirmYes = $('confirm-modal-yes'); + els.confirmNo = $('confirm-modal-no'); + els.entityBackdrop = $('entity-modal'); + els.entityModalPanel = $('entity-modal-panel'); + els.entityClose = $('entity-modal-close'); + els.entityEyebrow = $('entity-modal-eyebrow'); + els.entityTitle = $('entity-modal-title'); + els.entityBody = $('entity-modal-body'); + els.temperatureSensorBackdrop = $('temperature-sensor-modal'); + els.temperatureSensorModalPanel = $('temperature-sensor-modal-panel'); + els.temperatureSensorClose = $('temperature-sensor-modal-close'); + els.temperatureSensorTitle = $('temperature-sensor-modal-title'); + els.temperatureSensorBody = $('temperature-sensor-modal-body'); + + if (els.cameraPoster && !els.cameraPoster.dataset.boundErrorHandler) { + els.cameraPoster.dataset.boundErrorHandler = '1'; + els.cameraPoster.addEventListener('error', () => { + els.cameraPoster.removeAttribute('src'); + els.cameraPlaceholder.classList.add('is-visible'); + }); + els.cameraPoster.addEventListener('load', () => { + els.cameraPlaceholder.classList.remove('is-visible'); + }); + } + } + + function updateClock() { + if (els.clockTime) { + els.clockTime.textContent = formatTime(new Date()); + } + if (els.clockDate) { + els.clockDate.textContent = formatDate(new Date()); + } + updateMainPrintStrip(); + } + + function stopRealtime() { + if (state.haSocket) { + try { + state.haSocket.close(); + } catch (error) { + console.warn(error); + } + state.haSocket = null; + } + clearTimeout(state.haReconnectTimer); + state.haReconnectTimer = null; + state.haSocketState = 'disconnected'; + stopSnapshotPolling(); + } + + function scheduleReconnect() { + clearTimeout(state.haReconnectTimer); + state.haSocketState = 'reconnecting'; + const delay = Math.min(state.haReconnectDelay, 30000); + state.haReconnectDelay = Math.min(state.haReconnectDelay * 2, 30000); + state.haReconnectTimer = window.setTimeout(() => { + connectRealtime(); + }, delay); + } + + function stopSnapshotPolling() { + if (state.snapshotPollTimer) { + clearInterval(state.snapshotPollTimer); + state.snapshotPollTimer = null; + } + } + + function startSnapshotPolling() { + const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000)); + if (state.snapshotPollTimer) { + clearInterval(state.snapshotPollTimer); + } + + state.snapshotPollTimer = window.setInterval(async () => { + try { + await loadSnapshot(state.selectedRoomId || 'main'); + render(); + } catch (error) { + console.warn(error); + } + }, interval); + } + + function handleHaMessage(message) { + if (!message || typeof message !== 'object') { + return; + } + + if (message.type === 'auth_required') { + const connection = haConnection(); + if (!connection.token) return; + state.haSocket?.send(JSON.stringify({ + type: 'auth', + access_token: connection.token, + })); + return; + } + + if (message.type === 'auth_ok') { + state.haSocketState = 'auth_ok'; + state.haReconnectDelay = 1000; + state.haSocket?.send(JSON.stringify({ + id: state.haSubscribeId++, + type: 'subscribe_events', + event_type: 'state_changed', + })); + return; + } + + if (message.type === 'result' && message.success) { + if (state.haSocketState !== 'connected') { + state.haSocketState = 'connected'; + } + return; + } + + if (message.type === 'event' && message.event?.event_type === 'state_changed') { + const event = message.event; + const entityId = event?.data?.entity_id; + const newState = event?.data?.new_state; + if (entityId && newState) { + const snapshot = state.snapshot || bootstrap; + const currentRoomId = state.selectedRoomId || 'main'; + const existingEntity = getEntityFromSnapshot(snapshot, entityId); + const entityDefinition = getEntityDefinition(snapshot, entityId); + const entityRecord = existingEntity || entityDefinition; + if (entityRecord?.is_hidden) { + patchSnapshotEntity(entityId, { + state: newState.state, + attributes: newState.attributes || {}, + last_changed: newState.last_changed || newState.last_updated || entityRecord.last_changed, + last_updated: newState.last_updated || entityRecord.last_updated, + }); + return; + } + const affectsWeather = snapshot.weather?.entity_id === entityId || entityId === 'sensor.weather_temperature'; + const affectsRoom = currentRoomId !== 'main' && existingEntity !== null; + const affectsTemperatureBadge = isTemperatureSensorEntity(entityRecord); + + if (entityRecord && !statePayloadChanged(entityRecord, newState)) { + const triggerEntities = popupTriggerEntities(); + if (triggerEntities.has(entityId)) { + syncTriggerPopup(entityId, newState.state); + } + return; + } + + patchSnapshotEntity(entityId, { + state: newState.state, + attributes: newState.attributes || {}, + last_changed: newState.last_changed || newState.last_updated || entityRecord?.last_changed, + last_updated: newState.last_updated || entityRecord?.last_updated, + }); + + const mainChanged = syncMainEntities(entityId, entityRecord, { + state: newState.state, + attributes: newState.attributes || {}, + last_changed: newState.last_changed || newState.last_updated || entityRecord?.last_changed, + last_updated: newState.last_updated || entityRecord?.last_updated, + }); + + if (snapshot.weather?.entity_id === entityId) { + snapshot.weather.state = newState.state; + snapshot.weather.temperature = newState.attributes?.temperature ?? snapshot.weather.temperature; + snapshot.weather.wind_speed = newState.attributes?.wind_speed ?? snapshot.weather.wind_speed; + snapshot.weather.condition = newState.attributes?.condition ?? newState.state; + } + + if (snapshot.weather && entityId === 'sensor.weather_temperature') { + snapshot.weather.sensor_temperature = newState.state; + } + + const triggerEntities = popupTriggerEntities(); + if (triggerEntities.has(entityId)) { + syncTriggerPopup(entityId, newState.state); + } + + const affectsBoiler = mainBoilerConfig(snapshot)?.sensor_entity_id === entityId; + const affectsPrint = mainPrintAffectsEntity(entityId); + + if (currentRoomId === 'main' && (mainChanged || affectsWeather || mainWeatherActionAffectsEntity(entityId) || affectsBoiler || affectsPrint)) { + if (affectsWeather || mainWeatherActionAffectsEntity(entityId) || affectsBoiler || affectsPrint) { + updateMainWeatherCard(); + } + if (mainChanged) { + updateMainEntityCard(entityId); + } + renderSelectedRoom(snapshot); + renderSidebarOnly(); + } else if (affectsRoom) { + updateRoomEntityCard(entityId); + renderSelectedRoom(snapshot); + renderSidebarOnly(); + } + + if (affectsTemperatureBadge) { + renderSidebarOnly(); + } + + if (state.entityPopup?.active) { + renderEntityPopup(snapshot); + } + } + } + } + + function connectRealtime() { + const connection = haConnection(); + const baseUrl = connection.base_url || ''; + const token = connection.token || ''; + const wsUrl = haWsUrl(baseUrl); + if (!wsUrl || !token) { + state.haSocketState = 'unavailable'; + setStatus('Online', 'online'); + startSnapshotPolling(); + return; + } + + if (window.location.protocol === 'https:' && wsUrl.startsWith('ws://')) { + state.haSocketState = 'unavailable'; + setStatus('Polling mode', 'online'); + startSnapshotPolling(); + return; + } + + stopSnapshotPolling(); + stopRealtime(); + state.haSocketState = 'connecting'; + setStatus('Connecting WS...', 'loading'); + + try { + const socket = new WebSocket(wsUrl); + state.haSocket = socket; + socket.onopen = () => { + state.haSocketState = 'open'; + }; + socket.onmessage = (event) => { + try { + handleHaMessage(JSON.parse(event.data)); + } catch (error) { + console.warn(error); + } + }; + socket.onerror = () => { + setStatus('WS error', 'error'); + startSnapshotPolling(); + }; + socket.onclose = () => { + state.haSocket = null; + if (state.haSocketState !== 'disconnected') { + scheduleReconnect(); + } + }; + } catch (error) { + console.error(error); + scheduleReconnect(); + } + } + + async function start() { + initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); + syncViewportState(); + updateClock(); + clearInterval(state.clockTimer); + state.clockTimer = setInterval(updateClock, 1000); + wireEvents(); + + const viewportQuery = mobileViewportQuery(); + const handleViewportChange = () => { + syncViewportState(); + render(); + }; + if (typeof viewportQuery.addEventListener === 'function') { + viewportQuery.addEventListener('change', handleViewportChange); + } else if (typeof viewportQuery.addListener === 'function') { + viewportQuery.addListener(handleViewportChange); + } + + const initial = window.APP_BOOTSTRAP || {}; + state.snapshot = initial; + render(); + connectRealtime(); + if (!state.snapshotPollTimer) { + startSnapshotPolling(); + } + } + + document.addEventListener('DOMContentLoaded', start); +})(); diff --git a/config/@eaDir/config.json@SynoEAStream b/config/@eaDir/config.json@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/config/@eaDir/config.json@SynoEAStream differ diff --git a/config/@eaDir/config.json@SynoResource b/config/@eaDir/config.json@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/config/@eaDir/config.json@SynoResource differ diff --git a/config/addon-default-config.json b/config/addon-default-config.json new file mode 100755 index 0000000..a6393d7 --- /dev/null +++ b/config/addon-default-config.json @@ -0,0 +1,32 @@ +{ + "app": { + "title": "Wall Panel", + "poll_interval_ms": 5000, + "main_room_name": "Главная", + "main_room_icon": "mdi:home", + "edit_mode": false, + "battery_history_hours": 4320 + }, + "home_assistant": { + "base_url": "", + "token": "", + "verify_ssl": true, + "sync_url": "", + "sync_token": "", + "sync_timeout": 10, + "sync_verify_ssl": true, + "sync_cache_seconds": 30, + "weather_entity_id": "", + "auto_label": "auto", + "auto_entity_ids": [] + }, + "camera": { + "rtsp_url": "", + "stream_url": "", + "stream_mode": "hls", + "poster_url": "", + "popup_timeout_minutes": 3, + "trigger_entities": [] + }, + "rooms": [] +} diff --git a/custom_components/wall_panel/__init__.py b/custom_components/wall_panel/__init__.py new file mode 100755 index 0000000..929304c --- /dev/null +++ b/custom_components/wall_panel/__init__.py @@ -0,0 +1,48 @@ +"""Wall Panel integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import CONF_CONFIG, DOMAIN +from .frontend import async_setup_frontend +from .helpers import current_entry_config +from .views import WallPanelConfigView + + +async def async_setup_entry(hass: HomeAssistant, entry) -> bool: + """Set up Wall Panel from a config entry.""" + + state = hass.data.setdefault(DOMAIN, {}) + state[entry.entry_id] = { + "entry": entry, + "config": current_entry_config(entry), + } + + panel_url_path = await async_setup_frontend(hass, entry) + state[entry.entry_id]["panel_url_path"] = panel_url_path + + if not state.get("_config_view_registered"): + hass.http.register_view(WallPanelConfigView) + state["_config_view_registered"] = True + + entry.async_on_unload(entry.add_update_listener(_async_options_updated)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry) -> bool: + """Unload Wall Panel.""" + + from homeassistant.components.frontend import async_remove_panel + + state = hass.data.get(DOMAIN, {}) + panel_url_path = str(state.get(entry.entry_id, {}).get("panel_url_path") or entry.options.get("frontend_url_path", "wall-panel") or "wall-panel").strip() + async_remove_panel(hass, panel_url_path) + state.pop(entry.entry_id, None) + return True + + +async def _async_options_updated(hass: HomeAssistant, entry) -> None: + """Reload the integration when options change.""" + + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/wall_panel/config_flow.py b/custom_components/wall_panel/config_flow.py new file mode 100755 index 0000000..73502e9 --- /dev/null +++ b/custom_components/wall_panel/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow for Wall Panel.""" + +from __future__ import annotations + +import json +import secrets +from typing import Any + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, TextSelectorType + +from .const import ( + CONF_CONFIG, + CONF_FRONTEND_URL_PATH, + CONF_PANEL_URL, + CONF_REQUIRE_ADMIN, + CONF_SIDEBAR_ICON, + CONF_SIDEBAR_TITLE, + CONF_SYNC_TOKEN, + DEFAULT_FRONTEND_URL_PATH, + DEFAULT_PANEL_URL, + DEFAULT_SIDEBAR_ICON, + DEFAULT_SIDEBAR_TITLE, + DOMAIN, +) +from .helpers import config_to_json, normalize_config, parse_config_json + + +def _schema(defaults: dict[str, Any]) -> vol.Schema: + return vol.Schema({ + vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, "Wall Panel")): str, + vol.Optional(CONF_PANEL_URL, default=defaults.get(CONF_PANEL_URL, DEFAULT_PANEL_URL)): str, + vol.Optional(CONF_SIDEBAR_TITLE, default=defaults.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE)): str, + vol.Optional(CONF_SIDEBAR_ICON, default=defaults.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON)): str, + vol.Optional(CONF_FRONTEND_URL_PATH, default=defaults.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH)): str, + vol.Optional(CONF_REQUIRE_ADMIN, default=bool(defaults.get(CONF_REQUIRE_ADMIN, False))): bool, + vol.Optional(CONF_SYNC_TOKEN, default=defaults.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24))): str, + vol.Optional( + CONF_CONFIG, + default=defaults.get(CONF_CONFIG, config_to_json(normalize_config({}))), + ): TextSelector(TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT)), + }) + + +class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Wall Panel.""" + + VERSION = 1 + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + errors: dict[str, str] = {} + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + try: + config = parse_config_json(user_input[CONF_CONFIG]) + except (json.JSONDecodeError, ValueError): + errors[CONF_CONFIG] = "invalid_json" + else: + data = { + CONF_NAME: user_input.get(CONF_NAME, "Wall Panel"), + CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""), + CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE), + CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON), + CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH), + CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)), + CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""), + CONF_CONFIG: config, + } + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=data[CONF_SIDEBAR_TITLE], data={}, options=data) + + defaults = { + CONF_NAME: "Wall Panel", + CONF_PANEL_URL: DEFAULT_PANEL_URL, + CONF_SIDEBAR_TITLE: DEFAULT_SIDEBAR_TITLE, + CONF_SIDEBAR_ICON: DEFAULT_SIDEBAR_ICON, + CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH, + CONF_REQUIRE_ADMIN: False, + CONF_SYNC_TOKEN: secrets.token_urlsafe(24), + CONF_CONFIG: config_to_json(normalize_config({})), + } + return self.async_show_form(step_id="user", data_schema=_schema(defaults), errors=errors) + + async def async_step_import(self, user_input: dict[str, Any]): + return await self.async_step_user(user_input) + + +class WallPanelOptionsFlow(config_entries.OptionsFlow): + """Handle options for Wall Panel.""" + + def __init__(self, config_entry): + self.config_entry = config_entry + + async def async_step_init(self, user_input: dict[str, Any] | None = None): + errors: dict[str, str] = {} + if user_input is not None: + try: + config = parse_config_json(user_input[CONF_CONFIG]) + except (json.JSONDecodeError, ValueError): + errors[CONF_CONFIG] = "invalid_json" + else: + data = dict(self.config_entry.options) + data.update({ + CONF_NAME: user_input.get(CONF_NAME, data.get(CONF_NAME, "Wall Panel")), + CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""), + CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE), + CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON), + CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH), + CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)), + CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""), + CONF_CONFIG: config, + }) + return self.async_create_entry(title="", data=data) + + defaults = { + CONF_NAME: self.config_entry.options.get(CONF_NAME, "Wall Panel"), + CONF_PANEL_URL: self.config_entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL), + CONF_SIDEBAR_TITLE: self.config_entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE), + CONF_SIDEBAR_ICON: self.config_entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON), + CONF_FRONTEND_URL_PATH: self.config_entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH), + CONF_REQUIRE_ADMIN: self.config_entry.options.get(CONF_REQUIRE_ADMIN, False), + CONF_SYNC_TOKEN: self.config_entry.options.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)), + CONF_CONFIG: config_to_json(normalize_config(self.config_entry.options.get(CONF_CONFIG, {}))), + } + return self.async_show_form(step_id="init", data_schema=_schema(defaults), errors=errors) + + +def async_get_options_flow(config_entry): + return WallPanelOptionsFlow(config_entry) diff --git a/custom_components/wall_panel/const.py b/custom_components/wall_panel/const.py new file mode 100755 index 0000000..1bd768e --- /dev/null +++ b/custom_components/wall_panel/const.py @@ -0,0 +1,17 @@ +"""Constants for Wall Panel.""" + +DOMAIN = "wall_panel" + +CONF_PANEL_URL = "panel_url" +CONF_CONFIG = "config" +CONF_SYNC_TOKEN = "sync_token" +CONF_SIDEBAR_TITLE = "sidebar_title" +CONF_SIDEBAR_ICON = "sidebar_icon" +CONF_FRONTEND_URL_PATH = "frontend_url_path" +CONF_REQUIRE_ADMIN = "require_admin" + +DEFAULT_NAME = "Wall Panel" +DEFAULT_PANEL_URL = "" +DEFAULT_SIDEBAR_TITLE = "Wall Panel" +DEFAULT_SIDEBAR_ICON = "mdi:view-dashboard" +DEFAULT_FRONTEND_URL_PATH = "wall-panel" diff --git a/custom_components/wall_panel/frontend.py b/custom_components/wall_panel/frontend.py new file mode 100755 index 0000000..a302fbd --- /dev/null +++ b/custom_components/wall_panel/frontend.py @@ -0,0 +1,68 @@ +"""Frontend registration for Wall Panel.""" + +from __future__ import annotations + +from pathlib import Path + +from homeassistant.components.frontend import async_register_built_in_panel +from homeassistant.components.http import StaticPathConfig +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_FRONTEND_URL_PATH, + CONF_PANEL_URL, + CONF_REQUIRE_ADMIN, + CONF_SIDEBAR_ICON, + CONF_SIDEBAR_TITLE, + DEFAULT_FRONTEND_URL_PATH, + DEFAULT_SIDEBAR_ICON, + DEFAULT_SIDEBAR_TITLE, + DOMAIN, +) + + +async def async_setup_frontend(hass: HomeAssistant, entry) -> str: + """Register the custom panel and static frontend assets.""" + + frontend_dir = Path(__file__).parent / "frontend" + state = hass.data.setdefault(DOMAIN, {}) + if not state.get("_static_paths_registered"): + await hass.http.async_register_static_paths([ + StaticPathConfig( + f"/api/{DOMAIN}/frontend", + str(frontend_dir), + cache_headers=False, + ), + ]) + state["_static_paths_registered"] = True + + panel_url_path = str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH).strip() + sidebar_title = str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE).strip() + sidebar_icon = str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON).strip() + require_admin = bool(entry.options.get(CONF_REQUIRE_ADMIN, False)) + panel_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip() + + async_register_built_in_panel( + hass, + component_name="custom", + sidebar_title=sidebar_title, + sidebar_icon=sidebar_icon, + frontend_url_path=panel_url_path, + config={ + "_panel_custom": { + "name": "wall-panel-panel", + "module_url": f"/api/{DOMAIN}/frontend/panel.js", + "embed_iframe": False, + "trust_external": False, + "config": { + "panel_url": panel_url, + "panel_url_path": panel_url_path, + "entry_id": entry.entry_id, + }, + } + }, + require_admin=require_admin, + update=True, + ) + + return panel_url_path diff --git a/custom_components/wall_panel/frontend/panel.js b/custom_components/wall_panel/frontend/panel.js new file mode 100755 index 0000000..0858a3f --- /dev/null +++ b/custom_components/wall_panel/frontend/panel.js @@ -0,0 +1,133 @@ +class WallPanelPanel extends HTMLElement { + constructor() { + super(); + this._hass = null; + this._panel = null; + this._narrow = false; + this.attachShadow({ mode: 'open' }); + } + + set hass(hass) { + this._hass = hass; + this._render(); + } + + set panel(panel) { + this._panel = panel; + this._render(); + } + + set narrow(narrow) { + this._narrow = Boolean(narrow); + this._render(); + } + + connectedCallback() { + this._render(); + } + + _resolveUrl(rawUrl) { + const value = String(rawUrl || '').trim(); + if (!value) { + return ''; + } + + try { + const url = new URL(value, window.location.href); + if (!url.searchParams.has('embed')) { + url.searchParams.set('embed', '1'); + } + if (!url.searchParams.has('mode')) { + url.searchParams.set('mode', 'ha'); + } + return url.toString(); + } catch (error) { + return value; + } + } + + _render() { + if (!this.shadowRoot) { + return; + } + + const config = this._panel?.config || {}; + const customConfig = config._panel_custom || {}; + const payload = customConfig.config || customConfig || config; + const panelUrl = this._resolveUrl( + payload.panel_url || config.panel_url || customConfig.panel_url || '' + ); + + this.shadowRoot.innerHTML = ` + +
+ `; + + const wrap = this.shadowRoot.querySelector('.wrap'); + if (!wrap) { + return; + } + + if (!panelUrl) { + wrap.innerHTML = ` +
+
+ Wall Panel is not configured +
Set the PHP panel URL in the integration options.
+
+
+ `; + return; + } + + const iframe = document.createElement('iframe'); + iframe.src = panelUrl; + iframe.loading = 'eager'; + iframe.referrerPolicy = 'no-referrer'; + iframe.allow = 'autoplay; fullscreen; picture-in-picture'; + wrap.replaceChildren(iframe); + } +} + +if (!customElements.get('wall-panel-panel')) { + customElements.define('wall-panel-panel', WallPanelPanel); +} diff --git a/custom_components/wall_panel/helpers.py b/custom_components/wall_panel/helpers.py new file mode 100755 index 0000000..d000aff --- /dev/null +++ b/custom_components/wall_panel/helpers.py @@ -0,0 +1,283 @@ +"""Helper functions for Wall Panel.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from typing import Any + +from .const import ( + CONF_CONFIG, + CONF_FRONTEND_URL_PATH, + CONF_PANEL_URL, + CONF_REQUIRE_ADMIN, + CONF_SIDEBAR_ICON, + CONF_SIDEBAR_TITLE, + CONF_SYNC_TOKEN, + DEFAULT_FRONTEND_URL_PATH, + DEFAULT_PANEL_URL, + DEFAULT_SIDEBAR_ICON, + DEFAULT_SIDEBAR_TITLE, +) + + +def default_config() -> dict[str, Any]: + return { + "app": { + "title": "Wall Panel", + "poll_interval_ms": 5000, + "main_room_name": "Главная", + "main_room_icon": "mdi:home", + "edit_mode": False, + "battery_history_hours": 4320, + }, + "home_assistant": { + "base_url": "", + "token": "", + "verify_ssl": True, + "sync_url": "", + "sync_token": "", + "sync_timeout": 10, + "sync_verify_ssl": True, + "sync_cache_seconds": 30, + "weather_entity_id": "", + "auto_label": "auto", + "auto_entity_ids": [], + }, + "camera": { + "rtsp_url": "", + "stream_url": "", + "stream_mode": "hls", + "poster_url": "", + "popup_timeout_minutes": 3, + "trigger_entities": [], + }, + "rooms": [], + } + + +def normalize_config(value: Any) -> dict[str, Any]: + config = deepcopy(default_config()) + if not isinstance(value, dict): + return config + + _deep_merge(config, value) + if not isinstance(config.get("rooms"), list): + config["rooms"] = [] + return config + + +def config_to_json(config: dict[str, Any]) -> str: + return json.dumps(config, ensure_ascii=False, indent=2, sort_keys=False) + + +def parse_config_json(raw: str) -> dict[str, Any]: + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("Config JSON must be an object") + return normalize_config(data) + + +def current_entry_config(entry) -> dict[str, Any]: + return normalize_config(entry.options.get(CONF_CONFIG)) + + +def current_entry_panel(entry) -> dict[str, Any]: + return { + CONF_PANEL_URL: str(entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL) or ""), + CONF_SYNC_TOKEN: str(entry.options.get(CONF_SYNC_TOKEN, "") or ""), + CONF_SIDEBAR_TITLE: str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE), + CONF_SIDEBAR_ICON: str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON), + CONF_FRONTEND_URL_PATH: str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH), + CONF_REQUIRE_ADMIN: bool(entry.options.get(CONF_REQUIRE_ADMIN, False)), + } + + +def save_settings(config: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + app = config.setdefault("app", {}) + if "edit_mode" in payload: + app["edit_mode"] = bool(payload["edit_mode"]) + if isinstance(payload.get("title"), str) and payload["title"].strip(): + app["title"] = payload["title"].strip() + return config + + +def update_entity_override(config: dict[str, Any], room_id: str, entity_id: str, patch: dict[str, Any]) -> dict[str, Any]: + room = _ensure_room(config, room_id) + overrides = room.setdefault("entity_overrides", {}) + current = overrides.get(entity_id, {}) + if not isinstance(current, dict): + current = {} + + merged = deepcopy(current) + for key, value in patch.items(): + if value is not None: + merged[key] = value + overrides[entity_id] = merged + return config + + +def update_room_override(config: dict[str, Any], room_id: str, patch: dict[str, Any]) -> dict[str, Any]: + room = _ensure_room(config, room_id) + for key, value in patch.items(): + if value is not None: + room[key] = value + return config + + +def update_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str, patch: dict[str, Any]) -> dict[str, Any]: + room = _ensure_room(config, room_id) + items = room.setdefault("layout_items", []) + if not isinstance(items, list): + items = [] + room["layout_items"] = items + + current = None + for item in items: + if isinstance(item, dict) and str(item.get("id", "")) == layout_item_id: + current = item + break + + if current is None: + current = { + "id": layout_item_id, + "type": "ghost", + } + items.append(current) + + for key, value in patch.items(): + if value is not None: + current[key] = value + + _sort_room_layout_items(room) + return config + + +def create_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str, order: int | None = None) -> dict[str, Any]: + return update_room_layout_item(config, room_id, layout_item_id, { + "order": order, + "type": "ghost", + }) + + +def delete_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str) -> dict[str, Any]: + room = _ensure_room(config, room_id) + items = room.get("layout_items", []) + if not isinstance(items, list): + room["layout_items"] = [] + return config + + room["layout_items"] = [ + item for item in items + if not isinstance(item, dict) or str(item.get("id", "")) != layout_item_id + ] + return config + + +def reorder_room_grid(config: dict[str, Any], room_id: str, entries: list[Any]) -> dict[str, Any]: + room = _ensure_room(config, room_id) + normalized: list[dict[str, str]] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + kind = str(entry.get("kind", "")).strip() + item_id = str(entry.get("id", "")).strip() + if kind not in {"entity", "layout"} or not item_id: + continue + normalized.append({"kind": kind, "id": item_id}) + + entity_overrides = room.setdefault("entity_overrides", {}) + if not isinstance(entity_overrides, dict): + entity_overrides = {} + room["entity_overrides"] = entity_overrides + + layout_items = room.setdefault("layout_items", []) + if not isinstance(layout_items, list): + layout_items = [] + room["layout_items"] = layout_items + + layout_by_id = { + str(item.get("id", "")): item + for item in layout_items + if isinstance(item, dict) and str(item.get("id", "")).strip() + } + + order = 10000 + for entry in normalized: + if entry["kind"] == "entity": + current = entity_overrides.get(entry["id"], {}) + if not isinstance(current, dict): + current = {} + merged = deepcopy(current) + merged["order"] = order + entity_overrides[entry["id"]] = merged + else: + current = layout_by_id.get(entry["id"], { + "id": entry["id"], + "type": "ghost", + }) + merged = deepcopy(current) + merged["id"] = entry["id"] + merged["type"] = "ghost" + merged["order"] = order + layout_by_id[entry["id"]] = merged + order += 10 + + room["layout_items"] = sorted( + layout_by_id.values(), + key=lambda item: (int(item.get("order", 9999) or 9999), str(item.get("id", ""))), + ) + return config + + +def build_patch_payload(payload: dict[str, Any], keys: list[str]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key in keys: + if key in payload: + result[key] = payload[key] + return result + + +def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None: + for key, value in source.items(): + if isinstance(value, dict) and isinstance(target.get(key), dict): + _deep_merge(target[key], value) + else: + target[key] = deepcopy(value) + + +def _ensure_room(config: dict[str, Any], room_id: str) -> dict[str, Any]: + rooms = config.setdefault("rooms", []) + if not isinstance(rooms, list): + rooms = [] + config["rooms"] = rooms + + for room in rooms: + if isinstance(room, dict) and str(room.get("id", "")) == room_id: + room.setdefault("visible", True) + room.setdefault("entity_ids", []) + room.setdefault("entity_overrides", {}) + room.setdefault("layout_items", []) + return room + + room = { + "id": room_id, + "visible": True, + "entity_ids": [], + "entity_overrides": {}, + "layout_items": [], + } + rooms.append(room) + return room + + +def _sort_room_layout_items(room: dict[str, Any]) -> None: + items = room.get("layout_items", []) + if not isinstance(items, list): + room["layout_items"] = [] + return + + room["layout_items"] = sorted( + [item for item in items if isinstance(item, dict)], + key=lambda item: (int(item.get("order", 9999) or 9999), str(item.get("id", ""))), + ) diff --git a/custom_components/wall_panel/manifest.json b/custom_components/wall_panel/manifest.json new file mode 100755 index 0000000..6c8d9f8 --- /dev/null +++ b/custom_components/wall_panel/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "wall_panel", + "name": "Wall Panel", + "version": "1.0.0", + "documentation": "https://example.invalid/wall-panel", + "codeowners": [], + "config_flow": true, + "iot_class": "local_polling", + "requirements": [] +} diff --git a/custom_components/wall_panel/strings.json b/custom_components/wall_panel/strings.json new file mode 100755 index 0000000..7bb1b1c --- /dev/null +++ b/custom_components/wall_panel/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "Wall Panel", + "description": "Connect Wall Panel to Home Assistant.", + "data": { + "name": "Name", + "panel_url": "PHP panel URL", + "sidebar_title": "Sidebar title", + "sidebar_icon": "Sidebar icon", + "frontend_url_path": "Frontend URL path", + "require_admin": "Require admin", + "sync_token": "Sync token", + "config": "Canonical config JSON" + } + } + }, + "error": { + "invalid_json": "Invalid JSON" + } + } +} diff --git a/custom_components/wall_panel/translations/en.json b/custom_components/wall_panel/translations/en.json new file mode 100755 index 0000000..7bb1b1c --- /dev/null +++ b/custom_components/wall_panel/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "Wall Panel", + "description": "Connect Wall Panel to Home Assistant.", + "data": { + "name": "Name", + "panel_url": "PHP panel URL", + "sidebar_title": "Sidebar title", + "sidebar_icon": "Sidebar icon", + "frontend_url_path": "Frontend URL path", + "require_admin": "Require admin", + "sync_token": "Sync token", + "config": "Canonical config JSON" + } + } + }, + "error": { + "invalid_json": "Invalid JSON" + } + } +} diff --git a/custom_components/wall_panel/views.py b/custom_components/wall_panel/views.py new file mode 100755 index 0000000..a8b4893 --- /dev/null +++ b/custom_components/wall_panel/views.py @@ -0,0 +1,160 @@ +"""HTTP views for Wall Panel.""" + +from __future__ import annotations + +import secrets +from typing import Any + +from aiohttp import web +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant + +from .const import CONF_CONFIG, CONF_SYNC_TOKEN, DOMAIN +from .helpers import ( + build_patch_payload, + config_to_json, + create_room_layout_item, + current_entry_config, + delete_room_layout_item, + normalize_config, + reorder_room_grid, + save_settings, + update_entity_override, + update_room_layout_item, + update_room_override, +) + + +def _entry_from_hass(hass: HomeAssistant, entry_id: str): + return hass.data.get(DOMAIN, {}).get(entry_id, {}).get("entry") + + +def _request_token(request: web.Request) -> str: + header = request.headers.get("X-Wall-Panel-Token", "").strip() + if header: + return header + + auth = request.headers.get("Authorization", "").strip() + if auth.lower().startswith("bearer "): + return auth[7:].strip() + + return request.query.get("token", "").strip() + + +def _authorized(entry, request: web.Request) -> bool: + expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip() + if not expected: + return False + + return secrets.compare_digest(_request_token(request), expected) + + +def _response(data: Any, status: int = 200) -> web.Response: + if isinstance(data, str): + return web.Response(text=data, status=status, content_type="text/plain; charset=utf-8") + + return web.json_response(data, status=status) + + +def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None: + options = dict(entry.options) + options[CONF_CONFIG] = config + hass.config_entries.async_update_entry(entry, options=options) + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["config"] = config + + +class WallPanelConfigView(HomeAssistantView): + """Serve and update the canonical Wall Panel config.""" + + url = "/api/wall_panel/config/{entry_id}" + name = "api:wall_panel:config" + requires_auth = False + + async def async_get(self, request: web.Request, entry_id: str) -> web.Response: + hass = request.app["hass"] + entry = _entry_from_hass(hass, entry_id) + if entry is None: + return _response({"ok": False, "error": "Unknown entry"}, 404) + if not _authorized(entry, request): + return _response({"ok": False, "error": "Unauthorized"}, 401) + + config = current_entry_config(entry) + return _response(config) + + async def async_post(self, request: web.Request, entry_id: str) -> web.Response: + hass = request.app["hass"] + entry = _entry_from_hass(hass, entry_id) + if entry is None: + return _response({"ok": False, "error": "Unknown entry"}, 404) + if not _authorized(entry, request): + return _response({"ok": False, "error": "Unauthorized"}, 401) + + payload = await request.json() + if not isinstance(payload, dict): + return _response({"ok": False, "error": "Invalid payload"}, 400) + + config = current_entry_config(entry) + action = str(payload.get("action", "") or "").strip().lower() + action_payload = payload.get("payload") + if not isinstance(action_payload, dict): + action_payload = payload + + if action == "save-settings": + config = save_settings(config, action_payload) + elif action == "save-entity-override": + room_id = str(action_payload.get("room_id", "") or "").strip() + entity_id = str(action_payload.get("entity_id", "") or "").strip() + if not room_id or not entity_id: + return _response({"ok": False, "error": "room_id and entity_id are required"}, 400) + patch = build_patch_payload(action_payload, ["visible", "order", "card_type", "title", "icon"]) + config = update_entity_override(config, room_id, entity_id, patch) + elif action == "save-space-override": + room_id = str(action_payload.get("room_id", "") or "").strip() + if not room_id: + return _response({"ok": False, "error": "room_id is required"}, 400) + patch = build_patch_payload(action_payload, ["visible", "order", "name", "icon", "temperature_sensor_entity_id"]) + config = update_room_override(config, room_id, patch) + elif action == "create-room-layout-item": + room_id = str(action_payload.get("room_id", "") or "").strip() + if not room_id: + return _response({"ok": False, "error": "room_id is required"}, 400) + layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip() + if not layout_item_id: + layout_item_id = f"slot_{secrets.token_hex(12)}" + order = action_payload.get("order") + config = create_room_layout_item(config, room_id, layout_item_id, int(order) if order is not None else None) + _save_entry_config(hass, entry, config) + return _response({ + "ok": True, + "layout_item_id": layout_item_id, + "config": config, + }) + elif action == "save-room-layout-item": + room_id = str(action_payload.get("room_id", "") or "").strip() + layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip() + if not room_id or not layout_item_id: + return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400) + patch = build_patch_payload(action_payload, ["order"]) + config = update_room_layout_item(config, room_id, layout_item_id, patch) + elif action == "delete-room-layout-item": + room_id = str(action_payload.get("room_id", "") or "").strip() + layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip() + if not room_id or not layout_item_id: + return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400) + config = delete_room_layout_item(config, room_id, layout_item_id) + elif action == "reorder-room-grid": + room_id = str(action_payload.get("room_id", "") or "").strip() + entries = action_payload.get("entries", []) + if not room_id or not isinstance(entries, list): + return _response({"ok": False, "error": "room_id and entries are required"}, 400) + config = reorder_room_grid(config, room_id, entries) + elif isinstance(payload.get("config"), dict): + config = normalize_config(payload["config"]) + else: + return _response({"ok": False, "error": "Unknown action"}, 404) + + _save_entry_config(hass, entry, config) + return _response({ + "ok": True, + "config": config, + }) diff --git a/favicon.ico b/favicon.ico new file mode 100755 index 0000000..824b434 Binary files /dev/null and b/favicon.ico differ diff --git a/index.php b/index.php new file mode 100755 index 0000000..628351c --- /dev/null +++ b/index.php @@ -0,0 +1,164 @@ + $embedMode, + 'mode' => $runtimeMode, + 'shell' => $embedMode ? 'embed' : 'standalone', + 'config_source' => app_remote_sync_enabled($config) ? 'ha' : 'file', +]; +$appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +?> + + + + + + + <?= $appTitle ?> + + + + + + + + + + +
+ + +
+
+
+
+
+ +
+
+

Загрузка

+
+
+
+
+ +
+
+
Загрузка панели...
+
+
+
+
+ + + + + + + + + + diff --git a/lib/@eaDir/bootstrap.php@SynoEAStream b/lib/@eaDir/bootstrap.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/lib/@eaDir/bootstrap.php@SynoEAStream differ diff --git a/lib/@eaDir/bootstrap.php@SynoResource b/lib/@eaDir/bootstrap.php@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/lib/@eaDir/bootstrap.php@SynoResource differ diff --git a/lib/@eaDir/config.php@SynoEAStream b/lib/@eaDir/config.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/lib/@eaDir/config.php@SynoEAStream differ diff --git a/lib/@eaDir/config.php@SynoResource b/lib/@eaDir/config.php@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/lib/@eaDir/config.php@SynoResource differ diff --git a/lib/@eaDir/dashboard.php@SynoEAStream b/lib/@eaDir/dashboard.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/lib/@eaDir/dashboard.php@SynoEAStream differ diff --git a/lib/@eaDir/dashboard.php@SynoResource b/lib/@eaDir/dashboard.php@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/lib/@eaDir/dashboard.php@SynoResource differ diff --git a/lib/@eaDir/ha_client.php@SynoEAStream b/lib/@eaDir/ha_client.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/lib/@eaDir/ha_client.php@SynoEAStream differ diff --git a/lib/@eaDir/ha_client.php@SynoResource b/lib/@eaDir/ha_client.php@SynoResource new file mode 100755 index 0000000..9c645d4 Binary files /dev/null and b/lib/@eaDir/ha_client.php@SynoResource differ diff --git a/lib/bootstrap.php b/lib/bootstrap.php new file mode 100755 index 0000000..fc20b03 --- /dev/null +++ b/lib/bootstrap.php @@ -0,0 +1,9 @@ + [ + 'title' => 'Wall Panel', + 'poll_interval_ms' => 5000, + 'main_room_name' => 'Главная', + 'main_room_icon' => 'mdi:home', + 'edit_mode' => false, + 'battery_history_hours' => 4320, + ], + 'home_assistant' => [ + 'base_url' => '', + 'token' => '', + 'verify_ssl' => true, + 'sync_url' => '', + 'sync_token' => '', + 'sync_timeout' => 10, + 'sync_verify_ssl' => true, + 'sync_cache_seconds' => 30, + 'weather_entity_id' => '', + 'auto_label' => 'auto', + 'auto_entity_ids' => [], + ], + 'camera' => [ + 'rtsp_url' => 'rtsp://10.0.6.110:45321/feff99fa45f317e7', + 'stream_url' => '', + 'stream_mode' => 'hls', + 'poster_url' => 'http://10.0.6.110:5000/api/doorbell/latest.jpg', + 'popup_timeout_minutes' => 3, + 'trigger_entities' => [ + 'binary_sensor.door_all_occupancy', + 'binary_sensor.barn_all_occupancy', + 'binary_sensor.doorbell_all_occupancy', + ], + ], + 'rooms' => [], + ]; +} + +function app_config_path(): string +{ + $override = trim((string)getenv('WALL_PANEL_CONFIG_PATH')); + if ($override !== '') { + return $override; + } + + return APP_ROOT . '/config/config.json'; +} + +function app_runtime_mode(): string +{ + return strtolower(trim((string)getenv('WALL_PANEL_RUNTIME_MODE'))); +} + +function app_addon_options_path(): string +{ + $override = trim((string)getenv('WALL_PANEL_OPTIONS_PATH')); + if ($override !== '') { + return $override; + } + + return '/data/options.json'; +} + +function app_storage_path(string $file): string +{ + $override = trim((string)getenv('WALL_PANEL_STORAGE_DIR')); + $base = $override !== '' ? rtrim($override, '/') : APP_ROOT . '/storage'; + return $base . '/' . ltrim($file, '/'); +} + +function app_ensure_directory(string $path): void +{ + if (!is_dir($path)) { + mkdir($path, 0775, true); + } +} + +function app_load_config(): array +{ + $path = app_config_path(); + app_ensure_directory(dirname($path)); + + $config = app_default_config(); + $storedConfig = $config; + if (!file_exists($path)) { + $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Failed to encode default config'); + } + + file_put_contents($path, $json . PHP_EOL, LOCK_EX); + } else { + $raw = file_get_contents($path); + if ($raw === false || trim($raw) !== '') { + $decoded = json_decode((string)$raw, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON in config/config.json'); + } + + $config = array_replace_recursive($config, $decoded); + $storedConfig = $config; + } + } + + $optionsPath = app_addon_options_path(); + if (is_file($optionsPath)) { + $options = app_load_json_file($optionsPath, []); + if (is_array($options) && $options !== []) { + $config = array_replace_recursive($config, $options); + if (app_runtime_mode() === 'addon' && $config !== $storedConfig) { + app_save_config($config); + } + } + } + + return $config; +} + +function app_save_config(array $config): void +{ + $path = app_config_path(); + app_ensure_directory(dirname($path)); + + $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Failed to encode config'); + } + + file_put_contents($path, $json . PHP_EOL, LOCK_EX); +} + +function app_load_json_file(string $path, array $fallback = []): array +{ + if (!file_exists($path)) { + return $fallback; + } + + $raw = file_get_contents($path); + if ($raw === false || trim($raw) === '') { + return $fallback; + } + + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : $fallback; +} + +function app_save_json_file(string $path, array $data): void +{ + app_ensure_directory(dirname($path)); + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Failed to encode JSON'); + } + + file_put_contents($path, $json . PHP_EOL, LOCK_EX); +} + +function app_remote_sync_enabled(array $config): bool +{ + $syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? '')); + $syncToken = trim((string)($config['home_assistant']['sync_token'] ?? '')); + + return $syncUrl !== '' && $syncToken !== ''; +} + +function app_remote_sync_cache_path(): string +{ + return app_storage_path('ha_sync_cache.json'); +} + +function app_remote_sync_cache_ttl(array $config): int +{ + return max(5, (int)($config['home_assistant']['sync_cache_seconds'] ?? 30)); +} + +function app_http_json_request( + string $method, + string $url, + array $headers = [], + ?array $body = null, + int $timeout = 10, + bool $verifySsl = true, + bool $throwOnFailure = false +): array|null { + $ch = curl_init($url); + if ($ch === false) { + if ($throwOnFailure) { + throw new RuntimeException('Failed to initialize HTTP client'); + } + return null; + } + + $requestHeaders = array_merge([ + 'Accept: application/json', + ], $headers); + if ($body !== null) { + $requestHeaders[] = 'Content-Type: application/json'; + } + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => strtoupper($method), + CURLOPT_HTTPHEADER => $requestHeaders, + CURLOPT_TIMEOUT => max(1, $timeout), + CURLOPT_CONNECTTIMEOUT => max(1, min(7, $timeout)), + CURLOPT_SSL_VERIFYPEER => $verifySsl, + CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0, + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + $raw = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($errno !== 0) { + if ($throwOnFailure) { + throw new RuntimeException('HTTP request failed: ' . $error); + } + return null; + } + + if ($status >= 400) { + if ($throwOnFailure) { + throw new RuntimeException('HTTP request returned status ' . $status); + } + return null; + } + + $decoded = json_decode((string)$raw, true); + return is_array($decoded) ? $decoded : []; +} + +function app_merge_synced_config(array $baseConfig, array $syncedConfig): array +{ + $syncFields = [ + 'sync_url' => (string)($baseConfig['home_assistant']['sync_url'] ?? ''), + 'sync_token' => (string)($baseConfig['home_assistant']['sync_token'] ?? ''), + 'sync_timeout' => (int)($baseConfig['home_assistant']['sync_timeout'] ?? 10), + 'sync_verify_ssl' => (bool)($baseConfig['home_assistant']['sync_verify_ssl'] ?? true), + 'sync_cache_seconds' => (int)($baseConfig['home_assistant']['sync_cache_seconds'] ?? 30), + ]; + + $merged = array_replace_recursive($baseConfig, $syncedConfig); + foreach ($syncFields as $key => $value) { + $merged['home_assistant'][$key] = $value; + } + + return $merged; +} + +function app_load_remote_config(array $config, bool $refresh = false): array|null +{ + if (!app_remote_sync_enabled($config)) { + return null; + } + + $cachePath = app_remote_sync_cache_path(); + $cache = app_load_json_file($cachePath, []); + $cachedConfig = is_array($cache['config'] ?? null) ? $cache['config'] : null; + $cachedAt = (int)($cache['fetched_at'] ?? 0); + $ttl = app_remote_sync_cache_ttl($config); + + if (!$refresh && $cachedConfig !== null && $cachedAt > 0 && (time() - $cachedAt) < $ttl) { + return $cachedConfig; + } + + $syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? '')); + $syncToken = trim((string)($config['home_assistant']['sync_token'] ?? '')); + $timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10)); + $verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true); + + $response = app_http_json_request('GET', $syncUrl, [ + 'X-Wall-Panel-Token: ' . $syncToken, + ], null, $timeout, $verifySsl, false); + + $remoteConfig = null; + if (is_array($response)) { + if (is_array($response['config'] ?? null)) { + $remoteConfig = $response['config']; + } elseif (!isset($response['ok']) || (bool)$response['ok'] !== false) { + $remoteConfig = $response; + } + } + + if (is_array($remoteConfig)) { + app_save_json_file($cachePath, [ + 'fetched_at' => time(), + 'config' => $remoteConfig, + ]); + return $remoteConfig; + } + + if ($cachedConfig !== null) { + return $cachedConfig; + } + + return null; +} + +function app_clear_remote_config_cache(): void +{ + $path = app_remote_sync_cache_path(); + if (file_exists($path)) { + @unlink($path); + } +} + +function app_sync_remote_action(array $config, string $action, array $payload): array|null +{ + if (!app_remote_sync_enabled($config)) { + return null; + } + + $syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? '')); + $syncToken = trim((string)($config['home_assistant']['sync_token'] ?? '')); + $timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10)); + $verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true); + + $response = app_http_json_request('POST', $syncUrl, [ + 'X-Wall-Panel-Token: ' . $syncToken, + ], [ + 'action' => $action, + 'payload' => $payload, + ], $timeout, $verifySsl, false); + + if (!is_array($response)) { + return null; + } + + if (is_array($response['config'] ?? null)) { + app_save_json_file(app_remote_sync_cache_path(), [ + 'fetched_at' => time(), + 'config' => $response['config'], + ]); + } + + return $response; +} diff --git a/lib/dashboard.php b/lib/dashboard.php new file mode 100755 index 0000000..49a81f5 --- /dev/null +++ b/lib/dashboard.php @@ -0,0 +1,2039 @@ + $entityId, + 'state_entity_id' => trim((string)($item['state_entity_id'] ?? $entityId)) ?: $entityId, + 'command' => trim((string)($item['command'] ?? 'set_temperature')) ?: 'set_temperature', + 'value' => array_key_exists('value', $item) ? $item['value'] : null, + 'active_value' => array_key_exists('active_value', $item) ? $item['active_value'] : (array_key_exists('value', $item) ? $item['value'] : null), + 'label_active' => (string)($item['label_active'] ?? $item['active_label'] ?? $item['label'] ?? ''), + 'label_inactive' => (string)($item['label_inactive'] ?? $item['inactive_label'] ?? $item['label'] ?? ''), + 'icon' => (string)($item['icon'] ?? 'mdi:thermometer'), + 'active_color' => (string)($item['active_color'] ?? '#4caf50'), + 'inactive_color' => (string)($item['inactive_color'] ?? '#c8e6c9'), + 'active_text_color' => (string)($item['active_text_color'] ?? 'white'), + 'inactive_text_color' => (string)($item['inactive_text_color'] ?? 'black'), + 'active_icon_color' => (string)($item['active_icon_color'] ?? 'white'), + 'inactive_icon_color' => (string)($item['inactive_icon_color'] ?? 'gray'), + ]; + } + + return array_values($actions); +} + +function app_main_boiler(array $config): array +{ + $boiler = $config['app']['main_boiler'] ?? []; + if (!is_array($boiler)) { + $boiler = []; + } + + return [ + 'title' => (string)($boiler['title'] ?? 'Бойлер'), + 'sensor_entity_id' => trim((string)($boiler['sensor_entity_id'] ?? '')), + 'history_hours' => max(1, (int)($boiler['history_hours'] ?? 24)), + ]; +} + +function app_main_print(array $config): array +{ + $print = $config['app']['main_print'] ?? []; + if (!is_array($print)) { + $print = []; + } + + return [ + 'title' => (string)($print['title'] ?? 'Печать'), + 'current_stage_entity_id' => trim((string)($print['current_stage_entity_id'] ?? '')), + 'print_progress_entity_id' => trim((string)($print['print_progress_entity_id'] ?? '')), + 'start_time_entity_id' => trim((string)($print['start_time_entity_id'] ?? '')), + 'end_time_entity_id' => trim((string)($print['end_time_entity_id'] ?? '')), + ]; +} + +function app_state_file(): string +{ + return app_storage_path('popup_state.json'); +} + +function app_load_popup_state(): array +{ + return app_load_json_file(app_state_file(), [ + 'active' => false, + 'sensor_entity_id' => null, + 'opened_at' => null, + 'expires_at' => null, + ]); +} + +function app_save_popup_state(array $state): void +{ + app_save_json_file(app_state_file(), $state); +} + +function app_clear_expired_popup_state(array $config): array +{ + $state = app_load_popup_state(); + if (!($state['active'] ?? false)) { + return $state; + } + + $expiresAt = (int)($state['expires_at'] ?? 0); + if ($expiresAt > 0 && time() >= $expiresAt) { + $state['active'] = false; + $state['expires_at'] = null; + app_save_popup_state($state); + } + + return $state; +} + +function app_popup_close_delay_seconds(): int +{ + return 30; +} + +function app_default_card_type(string $entityId): string +{ + $domain = explode('.', $entityId, 2)[0] ?? ''; + return match ($domain) { + 'cover' => 'cover', + 'climate' => 'climate', + 'light', 'switch' => 'toggle', + default => 'toggle', + }; +} + +function app_entity_domain(string $entityId): string +{ + return explode('.', $entityId, 2)[0] ?? 'unknown'; +} + +function app_entity_name(array $entity, array $registryEntry = [], array $override = []): string +{ + if (!empty($override['title'])) { + return (string)$override['title']; + } + + $domain = app_entity_domain((string)($entity['entity_id'] ?? '')); + if ($domain === 'fan' && !empty($registryEntry['name'])) { + return (string)$registryEntry['name']; + } + + if (!empty($entity['attributes']['friendly_name'])) { + return (string)$entity['attributes']['friendly_name']; + } + + if (!empty($registryEntry['name'])) { + return (string)$registryEntry['name']; + } + + return $entity['entity_id']; +} + +function app_entity_icon(array $entity, array $override = []): string +{ + if (!empty($override['icon'])) { + return (string)$override['icon']; + } + + if (!empty($entity['attributes']['icon'])) { + return (string)$entity['attributes']['icon']; + } + + return match (app_entity_domain((string)$entity['entity_id'])) { + 'light' => 'mdi:lightbulb', + 'switch' => 'mdi:toggle-switch', + 'cover' => 'mdi:curtains', + 'climate' => 'mdi:air-conditioner', + 'weather' => 'mdi:weather-partly-cloudy', + 'binary_sensor' => 'mdi:motion-sensor', + default => 'mdi:devices', + }; +} + +function app_labels_from_entity(array $entity, array $registryEntry = []): array +{ + $labels = []; + foreach ([ + $entity['attributes']['labels'] ?? null, + $entity['attributes']['label_ids'] ?? null, + $entity['labels'] ?? null, + $registryEntry['labels'] ?? null, + $registryEntry['label_ids'] ?? null, + ] as $source) { + $labels = array_merge($labels, app_flatten_label_source($source)); + } + return array_values(array_unique(array_map('strval', $labels))); +} + +function app_entity_has_auto_label(array $labels, string $autoLabel): bool +{ + $needle = strtolower(trim($autoLabel)); + if ($needle === '') { + return false; + } + + foreach ($labels as $label) { + $candidate = strtolower(trim((string)$label)); + if ($candidate === '') { + continue; + } + if ($candidate === $needle || str_contains($candidate, $needle)) { + return true; + } + } + + return false; +} + +function app_flatten_label_source(mixed $source): array +{ + if ($source === null) { + return []; + } + + if (is_string($source) || is_numeric($source)) { + return [(string)$source]; + } + + if (!is_array($source)) { + return []; + } + + $labels = []; + foreach ($source as $item) { + if (is_string($item) || is_numeric($item)) { + $labels[] = (string)$item; + continue; + } + + if (is_array($item)) { + foreach (['name', 'label', 'id', 'label_id', 'entity_id'] as $key) { + if (!empty($item[$key])) { + $labels[] = (string)$item[$key]; + } + } + } + } + + return $labels; +} + +function app_is_active_entity(array $entity): bool +{ + $state = strtolower((string)($entity['state'] ?? '')); + $domain = app_entity_domain((string)$entity['entity_id']); + $deviceClass = strtolower((string)($entity['attributes']['device_class'] ?? '')); + + if (in_array($state, ['unavailable', 'unknown', 'none'], true)) { + return false; + } + + return match ($domain) { + 'binary_sensor' => $deviceClass === 'door' + ? in_array($state, ['on', 'open'], true) + : !in_array($state, ['off', 'false', '0', 'idle'], true), + 'cover' => in_array($state, ['open', 'opening', 'closing'], true), + 'climate' => !in_array($state, ['off', 'unavailable', 'unknown'], true), + default => !in_array($state, ['off', 'false', '0', 'idle'], true), + }; +} + +function app_is_door_contact_entity(array $entity, array $registryEntry = []): bool +{ + $entityId = (string)($entity['entity_id'] ?? ''); + if ($entityId === '' || app_entity_domain($entityId) !== 'binary_sensor') { + return false; + } + + $deviceClass = strtolower((string)($entity['attributes']['device_class'] ?? '')); + if (in_array($deviceClass, ['door', 'garage_door'], true)) { + return true; + } + + $name = strtolower((string)($entity['attributes']['friendly_name'] ?? $registryEntry['name'] ?? '')); + if ($name !== '' && (str_contains($name, 'door') || str_contains($name, 'двер'))) { + return true; + } + + return false; +} + +function app_select_weather_entity(array $states, array $config): ?array +{ + $preferred = trim((string)($config['home_assistant']['weather_entity_id'] ?? '')); + if ($preferred !== '') { + foreach ($states as $entity) { + if (($entity['entity_id'] ?? null) === $preferred) { + return $entity; + } + } + } + + foreach ($states as $entity) { + if (($entity['entity_id'] ?? null) === 'weather.yandex_weather') { + return $entity; + } + } + + foreach ($states as $entity) { + if (app_entity_domain((string)($entity['entity_id'] ?? '')) === 'weather') { + return $entity; + } + } + + return null; +} + +function app_registry_map(array $entityRegistry): array +{ + $map = []; + foreach ($entityRegistry as $item) { + if (!empty($item['entity_id'])) { + $map[(string)$item['entity_id']] = $item; + } + } + return $map; +} + +function app_device_registry_map(array $deviceRegistry): array +{ + $map = []; + foreach ($deviceRegistry as $item) { + if (!is_array($item)) { + continue; + } + + $deviceId = (string)($item['device_id'] ?? $item['id'] ?? $item['di'] ?? ''); + if ($deviceId === '') { + continue; + } + + $map[$deviceId] = $item; + } + + return $map; +} + +function app_device_display_name(array $device, string $fallback = ''): string +{ + $name = trim((string)($device['name_by_user'] ?? $device['name'] ?? $device['original_name'] ?? '')); + if ($name !== '') { + return $name; + } + + $fallback = trim($fallback); + return $fallback !== '' ? $fallback : 'Без устройства'; +} + +function app_room_definitions(array $config, array $haData): array +{ + $rooms = []; + $areasById = []; + $floorsById = []; + $roomOverrides = []; + + foreach ($haData['floors'] ?? [] as $floor) { + $floorId = (string)($floor['floor_id'] ?? $floor['id'] ?? ''); + if ($floorId === '') { + continue; + } + $floorsById[$floorId] = [ + 'floor_id' => $floorId, + 'name' => (string)($floor['name'] ?? $floorId), + 'icon' => (string)($floor['icon'] ?? 'mdi:stairs'), + 'level' => isset($floor['level']) ? (int)$floor['level'] : 0, + ]; + } + + foreach ($haData['areas'] ?? [] as $area) { + $areaId = (string)($area['area_id'] ?? $area['id'] ?? ''); + if ($areaId === '') { + continue; + } + $area['area_id'] = $areaId; + $areasById[$areaId] = $area; + } + + $configuredRooms = $config['rooms'] ?? []; + if (!is_array($configuredRooms)) { + $configuredRooms = []; + } + + foreach ($configuredRooms as $room) { + if (!is_array($room) || empty($room['id'])) { + continue; + } + $room['visible'] = $room['visible'] ?? true; + $room['order'] = isset($room['order']) ? (int)$room['order'] : 9999; + $room['temperature_sensor_entity_id'] = trim((string)($room['temperature_sensor_entity_id'] ?? '')); + $room['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : []; + $room['layout_items'] = app_room_layout_items($room); + $roomOverrides[(string)$room['id']] = $room; + } + + foreach ($areasById as $areaId => $area) { + $floorId = (string)($area['floor_id'] ?? $area['fi'] ?? $area['floor'] ?? ''); + if ($floorId === '' || !isset($floorsById[$floorId])) { + continue; + } + + $room = $roomOverrides[$areaId] ?? []; + $overrideName = trim((string)($room['name'] ?? '')); + if ($overrideName === '' || $overrideName === $areaId) { + $overrideName = ''; + } + $rooms[$areaId] = [ + 'id' => $areaId, + 'name' => $overrideName !== '' ? $overrideName : (string)($area['name'] ?? $areaId), + 'icon' => (string)($room['icon'] ?? $area['icon'] ?? 'mdi:home-variant'), + 'area_id' => $areaId, + 'floor_id' => $floorId, + 'floor_name' => (string)($room['floor_name'] ?? $floorsById[$floorId]['name'] ?? $floorId), + 'floor_level' => (int)($room['floor_level'] ?? $floorsById[$floorId]['level'] ?? 0), + 'visible' => array_key_exists('visible', $room) ? (bool)$room['visible'] : true, + 'order' => isset($room['order']) ? (int)$room['order'] : 9999, + 'entity_ids' => is_array($room['entity_ids'] ?? null) ? $room['entity_ids'] : [], + 'entity_overrides' => is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : [], + 'layout_items' => app_room_layout_items($room), + 'temperature_sensor_entity_id' => trim((string)($room['temperature_sensor_entity_id'] ?? '')), + ]; + } + + $rooms = array_values($rooms); + usort($rooms, static function (array $a, array $b): int { + $visibleA = (bool)($a['visible'] ?? true); + $visibleB = (bool)($b['visible'] ?? true); + if ($visibleA !== $visibleB) { + return $visibleA ? -1 : 1; + } + + $orderA = (int)($a['order'] ?? 9999); + $orderB = (int)($b['order'] ?? 9999); + if ($orderA !== $orderB) { + return $orderA <=> $orderB; + } + + $floorLevelA = (int)($a['floor_level'] ?? 0); + $floorLevelB = (int)($b['floor_level'] ?? 0); + if ($floorLevelA !== $floorLevelB) { + return $floorLevelA <=> $floorLevelB; + } + + return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? '')); + }); + + return $rooms; +} + +function app_room_layout_items(array $room): array +{ + $items = $room['layout_items'] ?? []; + if (!is_array($items)) { + return []; + } + + $normalized = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $itemId = trim((string)($item['id'] ?? $item['layout_item_id'] ?? '')); + $type = trim((string)($item['type'] ?? 'ghost')) ?: 'ghost'; + if ($itemId === '' || $type !== 'ghost') { + continue; + } + + $normalized[] = [ + 'id' => $itemId, + 'type' => 'ghost', + 'order' => isset($item['order']) ? (int)$item['order'] : 9999, + ]; + } + + usort($normalized, static function (array $a, array $b): int { + $orderA = (int)($a['order'] ?? 9999); + $orderB = (int)($b['order'] ?? 9999); + if ($orderA !== $orderB) { + return $orderA <=> $orderB; + } + + return strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? '')); + }); + + return $normalized; +} + +function app_label_matches(string $label, string $needle): bool +{ + $label = trim($label); + $needle = trim($needle); + if ($label === '' || $needle === '') { + return false; + } + + $normalize = static function (string $value): string { + $value = preg_replace('/\s+/u', ' ', trim($value)) ?? trim($value); + return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); + }; + + $labelNorm = $normalize($label); + $needleNorm = $normalize($needle); + return $labelNorm === $needleNorm || str_contains($labelNorm, $needleNorm); +} + +function app_battery_label_matches(string $label): bool +{ + return app_label_matches($label, 'Батарейка') + || app_label_matches($label, 'Battery') + || app_label_matches($label, 'batareika') + || app_label_matches($label, 'batareyka'); +} + +function pluralizeRu(int $count, string $one, string $few, string $many): string +{ + $count = abs($count); + $mod10 = $count % 10; + $mod100 = $count % 100; + + if ($mod10 === 1 && $mod100 !== 11) { + return $one; + } + + if ($mod10 >= 2 && $mod10 <= 4 && !($mod100 >= 12 && $mod100 <= 14)) { + return $few; + } + + return $many; +} + +function app_battery_cache_path(): string +{ + return app_storage_path('battery_cache.json'); +} + +function app_load_battery_cache(): array +{ + return app_load_json_file(app_battery_cache_path(), [ + 'items' => [], + ]); +} + +function app_save_battery_cache(array $cache): void +{ + app_save_json_file(app_battery_cache_path(), $cache); +} + +function app_battery_status_rank(string $status): int +{ + return match ($status) { + 'empty' => 0, + 'critical' => 1, + 'low' => 2, + 'unavailable' => 3, + 'unknown' => 4, + default => 5, + }; +} + +function app_battery_status_label(string $status): string +{ + return match ($status) { + 'empty' => 'Разряжена', + 'critical' => 'Скоро разрядится', + 'low' => 'Низкий заряд', + 'unavailable' => 'Недоступно', + 'unknown' => 'Неизвестно', + default => 'Норма', + }; +} + +function app_battery_percent_text(?float $percent): ?string +{ + if ($percent === null) { + return null; + } + + $value = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.'); + return $value . '%'; +} + +function app_battery_percentage_from_entity(array $entity): ?float +{ + $candidates = [ + $entity['state'] ?? null, + $entity['attributes']['battery_level'] ?? null, + $entity['attributes']['battery_percentage'] ?? null, + $entity['attributes']['battery'] ?? null, + $entity['attributes']['percentage'] ?? null, + ]; + + foreach ($candidates as $candidate) { + if ($candidate === null) { + continue; + } + + $text = trim((string)$candidate); + if ($text === '') { + continue; + } + + $text = str_replace(',', '.', $text); + $text = rtrim($text, '%'); + if (!is_numeric($text)) { + continue; + } + + $value = (float)$text; + if ($value < 0) { + continue; + } + + if ($value <= 100) { + return round($value, 1); + } + } + + return null; +} + +function app_battery_status_from_entity(array $entity, ?float $percent): string +{ + $state = strtolower(trim((string)($entity['state'] ?? ''))); + if ($state === 'unavailable') { + return 'unavailable'; + } + if ($state === 'unknown' || $state === '') { + return $percent === null ? 'unknown' : 'ok'; + } + + if ($percent === null) { + return 'unknown'; + } + + if ($percent <= 0) { + return 'empty'; + } + if ($percent <= 10) { + return 'critical'; + } + if ($percent <= 30) { + return 'low'; + } + return 'ok'; +} + +function app_battery_icon_for_status(string $status, ?float $percent = null): string +{ + return match ($status) { + 'empty' => 'mdi:battery-outline', + 'critical' => 'mdi:battery-alert', + 'low' => 'mdi:battery-20', + 'unavailable' => 'mdi:battery-off-outline', + 'unknown' => 'mdi:battery-unknown', + default => $percent !== null && $percent >= 80 ? 'mdi:battery' : 'mdi:battery-medium', + }; +} + +function app_battery_forecast_duration_text(int $minutes): string +{ + $minutes = max(0, $minutes); + $days = intdiv($minutes, 1440); + $hours = intdiv($minutes % 1440, 60); + $mins = $minutes % 60; + + if ($days > 0) { + $pieces = [$days . 'д']; + if ($hours > 0) { + $pieces[] = $hours . 'ч'; + } + return implode(' ', $pieces); + } + + if ($hours > 0) { + $pieces = [$hours . 'ч']; + if ($mins > 0) { + $pieces[] = $mins . 'м'; + } + return implode(' ', $pieces); + } + + return $mins . 'м'; +} + +function app_battery_history_points(array $history): array +{ + $points = []; + $groups = array_is_list($history) && isset($history[0]) && is_array($history[0]) ? $history : [$history]; + + foreach ($groups as $group) { + if (!is_array($group)) { + continue; + } + + foreach ($group as $entry) { + if (!is_array($entry)) { + continue; + } + + $rawValue = $entry['state'] ?? $entry['value'] ?? null; + if ($rawValue === null) { + continue; + } + + $valueText = str_replace(',', '.', trim((string)$rawValue)); + $valueText = rtrim($valueText, '%'); + if (!is_numeric($valueText)) { + continue; + } + + $value = (float)$valueText; + if ($value < 0 || $value > 100) { + continue; + } + + $timestamp = null; + foreach (['last_changed', 'last_updated', 'ts', 'timestamp'] as $key) { + if (!empty($entry[$key])) { + $parsed = strtotime((string)$entry[$key]); + if ($parsed !== false) { + $timestamp = $parsed; + break; + } + } + } + + if ($timestamp === null) { + continue; + } + + $points[] = [ + 'timestamp' => $timestamp, + 'value' => round($value, 1), + ]; + } + } + + usort($points, static function (array $a, array $b): int { + return ($a['timestamp'] ?? 0) <=> ($b['timestamp'] ?? 0); + }); + + $deduped = []; + foreach ($points as $point) { + $last = $deduped[array_key_last($deduped)] ?? null; + if ($last && (int)($last['timestamp'] ?? 0) === (int)($point['timestamp'] ?? 0)) { + $deduped[array_key_last($deduped)] = $point; + continue; + } + $deduped[] = $point; + } + + return $deduped; +} + +function app_battery_forecast_from_points(array $points, ?float $currentPercent): array +{ + $percent = $currentPercent !== null ? max(0.0, min(100.0, $currentPercent)) : null; + $count = count($points); + if ($count < 3 || $percent === null || $percent <= 0) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => null, + 'forecast_reason' => $count < 3 + ? 'Недостаточно истории' + : ($percent === null ? 'Нет числового значения' : 'Батарея уже разряжена'), + ]; + } + + $subset = array_slice($points, max(0, $count - 20)); + $firstTs = (int)($subset[0]['timestamp'] ?? 0); + $sumX = 0.0; + $sumY = 0.0; + $sumXX = 0.0; + $sumXY = 0.0; + $n = 0; + + foreach ($subset as $point) { + $ts = (int)($point['timestamp'] ?? 0); + $value = (float)($point['value'] ?? 0); + if ($ts <= 0) { + continue; + } + + $x = ($ts - $firstTs) / 3600; + $y = $value; + $sumX += $x; + $sumY += $y; + $sumXX += $x * $x; + $sumXY += $x * $y; + $n++; + } + + if ($n < 3) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => null, + 'forecast_reason' => 'Недостаточно истории', + ]; + } + + $denominator = ($n * $sumXX) - ($sumX * $sumX); + if (abs($denominator) < 1e-9) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => null, + 'forecast_reason' => 'Нет заметного разряда', + ]; + } + + $slopePerHour = (($n * $sumXY) - ($sumX * $sumY)) / $denominator; + if ($slopePerHour >= -0.01) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => round($slopePerHour, 4), + 'forecast_reason' => $slopePerHour > 0.01 ? 'Заряд не падает' : 'Нет заметного разряда', + ]; + } + + $minutesLeft = (int)ceil($percent / abs($slopePerHour) * 60); + if ($minutesLeft <= 0 || $minutesLeft > 525600) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => round($slopePerHour, 4), + 'forecast_reason' => 'Прогноз вне диапазона', + ]; + } + + return [ + 'forecast_minutes_left' => $minutesLeft, + 'forecast_text' => '≈ ' . app_battery_forecast_duration_text($minutesLeft) . ' до разряда', + 'forecast_slope_per_hour' => round($slopePerHour, 4), + 'forecast_reason' => null, + ]; +} + +function app_battery_source_room_name(array $entityIndexItem, array $roomNameByAreaId): string +{ + $areaId = trim((string)($entityIndexItem['area_id'] ?? '')); + if ($areaId !== '' && isset($roomNameByAreaId[$areaId])) { + return (string)$roomNameByAreaId[$areaId]; + } + + return $areaId !== '' ? $areaId : 'Без комнаты'; +} + +function app_battery_item_status_summary(array $items): array +{ + $counts = [ + 'empty' => 0, + 'critical' => 0, + 'low' => 0, + 'unavailable' => 0, + 'unknown' => 0, + 'ok' => 0, + ]; + + foreach ($items as $item) { + $status = (string)($item['battery_status'] ?? 'unknown'); + if (isset($counts[$status])) { + $counts[$status]++; + } + } + + return $counts; +} + +function app_battery_summary_text(array $counts, int $total): string +{ + $problem = ($counts['empty'] ?? 0) + ($counts['critical'] ?? 0) + ($counts['low'] ?? 0); + $unavailable = (int)($counts['unavailable'] ?? 0); + $unknown = (int)($counts['unknown'] ?? 0); + + $parts = []; + if ($problem > 0) { + $parts[] = $problem . ' ' . pluralizeRu($problem, 'проблемная', 'проблемных', 'проблемных'); + } + if ($unavailable > 0) { + $parts[] = $unavailable . ' ' . pluralizeRu($unavailable, 'недоступная', 'недоступных', 'недоступных'); + } + if ($unknown > 0) { + $parts[] = $unknown . ' ' . pluralizeRu($unknown, 'неизвестная', 'неизвестных', 'неизвестных'); + } + + if ($parts) { + return implode(' · ', $parts); + } + + return $total > 0 + ? $total . ' ' . pluralizeRu($total, 'батарейка', 'батарейки', 'батареек') + : 'Нет батареек'; +} + +function app_battery_history_hours(array $config): int +{ + return max(24, (int)($config['app']['battery_history_hours'] ?? 4320)); +} + +function app_battery_room(array $config, array $haData, array $rooms, HomeAssistantClient $client, bool $refreshForecast = false): array +{ + $entityIndex = app_entity_index($config, $haData); + $registryMap = app_registry_map($haData['entity_registry'] ?? []); + $deviceMap = app_device_registry_map($haData['device_registry'] ?? []); + $roomNameByAreaId = []; + foreach ($rooms as $room) { + if (!is_array($room)) { + continue; + } + $areaId = trim((string)($room['area_id'] ?? $room['id'] ?? '')); + if ($areaId === '') { + continue; + } + $roomNameByAreaId[$areaId] = (string)($room['name'] ?? $areaId); + } + + $cache = app_load_battery_cache(); + $cacheItems = is_array($cache['items'] ?? null) ? $cache['items'] : []; + $cacheChanged = false; + $now = time(); + $historyHours = app_battery_history_hours($config); + $batteryEntities = []; + $deviceGroups = []; + + foreach ($entityIndex as $entityId => $item) { + $registryEntry = is_array($registryMap[$entityId] ?? null) ? $registryMap[$entityId] : []; + $deviceId = trim((string)($item['device_id'] ?? $registryEntry['device_id'] ?? $registryEntry['di'] ?? '')); + $device = $deviceId !== '' && isset($deviceMap[$deviceId]) && is_array($deviceMap[$deviceId]) ? $deviceMap[$deviceId] : []; + $entityLabels = is_array($item['labels'] ?? null) ? $item['labels'] : []; + $deviceLabels = app_labels_from_entity([], $device); + $hasBatteryLabel = false; + foreach ($deviceLabels as $label) { + if (app_battery_label_matches((string)$label)) { + $hasBatteryLabel = true; + break; + } + } + + if (!$hasBatteryLabel) { + continue; + } + + $groupKey = $deviceId !== '' ? 'device:' . $deviceId : 'entity:' . $entityId; + if (!isset($deviceGroups[$groupKey])) { + $deviceGroups[$groupKey] = [ + 'device_id' => $deviceId, + 'device' => $device, + 'items' => [], + ]; + } + + $deviceGroups[$groupKey]['items'][] = [ + 'entity' => $item, + 'registry' => $registryEntry, + ]; + } + + foreach ($deviceGroups as $group) { + $bestCandidate = null; + $bestScore = PHP_INT_MIN; + + foreach ($group['items'] as $candidate) { + $candidateItem = is_array($candidate['entity'] ?? null) ? $candidate['entity'] : []; + $candidateEntityId = (string)($candidateItem['entity_id'] ?? ''); + if ($candidateEntityId === '') { + continue; + } + + $isBatterySensor = preg_match('/^sensor\..*_battery$/i', $candidateEntityId) === 1; + $percent = app_battery_percentage_from_entity($candidateItem); + $state = strtolower(trim((string)($candidateItem['state'] ?? ''))); + $score = $isBatterySensor ? 1000 : 0; + if ($percent !== null) { + $score += 100; + } + if ($state !== '' && !in_array($state, ['unknown', 'unavailable', 'none'], true)) { + $score += 10; + } + if (($candidateItem['domain'] ?? '') === 'sensor') { + $score += 5; + } + if ($bestCandidate === null || $score > $bestScore || ($score === $bestScore && strcasecmp($candidateEntityId, (string)($bestCandidate['entity_id'] ?? '')) < 0)) { + $bestCandidate = [ + 'entity_id' => $candidateEntityId, + 'item' => $candidateItem, + 'is_battery_sensor' => $isBatterySensor, + ]; + $bestScore = $score; + } + } + + if ($bestCandidate === null || !$bestCandidate['is_battery_sensor']) { + continue; + } + + $bestItem = $bestCandidate['item']; + $entityId = (string)($bestItem['entity_id'] ?? ''); + $percent = app_battery_percentage_from_entity($bestItem); + $status = app_battery_status_from_entity($bestItem, $percent); + $cacheEntry = is_array($cacheItems[$entityId] ?? null) ? $cacheItems[$entityId] : []; + $forecast = [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => null, + 'forecast_reason' => null, + ]; + + $cachedAt = (int)($cacheEntry['loaded_at'] ?? 0); + $cacheMatchesWindow = (int)($cacheEntry['history_hours'] ?? 0) === $historyHours; + $freshEnough = $cachedAt > 0 && ($now - $cachedAt) < 6 * 3600 && $cacheMatchesWindow; + $shouldRefresh = ($refreshForecast || !$cacheMatchesWindow) + && $percent !== null + && !in_array($status, ['unavailable', 'unknown'], true); + + if ($shouldRefresh && (!$freshEnough || !isset($cacheEntry['forecast_minutes_left']))) { + try { + $history = $client->fetchEntityHistory($entityId, $historyHours); + $points = app_battery_history_points($history); + $forecast = app_battery_forecast_from_points($points, $percent); + $cacheItems[$entityId] = [ + 'loaded_at' => $now, + 'history_hours' => $historyHours, + 'points' => $points, + 'forecast_minutes_left' => $forecast['forecast_minutes_left'], + 'forecast_text' => $forecast['forecast_text'], + 'forecast_slope_per_hour' => $forecast['forecast_slope_per_hour'], + 'forecast_reason' => $forecast['forecast_reason'] ?? null, + 'percent' => $percent, + ]; + $cacheChanged = true; + } catch (Throwable $e) { + if (is_array($cacheEntry) && isset($cacheEntry['forecast_minutes_left'])) { + $forecast = [ + 'forecast_minutes_left' => isset($cacheEntry['forecast_minutes_left']) ? (int)$cacheEntry['forecast_minutes_left'] : null, + 'forecast_text' => isset($cacheEntry['forecast_text']) ? (string)$cacheEntry['forecast_text'] : null, + 'forecast_slope_per_hour' => isset($cacheEntry['forecast_slope_per_hour']) ? (float)$cacheEntry['forecast_slope_per_hour'] : null, + 'forecast_reason' => isset($cacheEntry['forecast_reason']) ? (string)$cacheEntry['forecast_reason'] : null, + ]; + } + } + } elseif ($cacheMatchesWindow && is_array($cacheEntry) && isset($cacheEntry['forecast_minutes_left'])) { + $forecast = [ + 'forecast_minutes_left' => isset($cacheEntry['forecast_minutes_left']) ? (int)$cacheEntry['forecast_minutes_left'] : null, + 'forecast_text' => isset($cacheEntry['forecast_text']) ? (string)$cacheEntry['forecast_text'] : null, + 'forecast_slope_per_hour' => isset($cacheEntry['forecast_slope_per_hour']) ? (float)$cacheEntry['forecast_slope_per_hour'] : null, + 'forecast_reason' => isset($cacheEntry['forecast_reason']) ? (string)$cacheEntry['forecast_reason'] : null, + ]; + } + + $deviceId = (string)($group['device_id'] ?? ''); + $device = is_array($group['device'] ?? null) ? $group['device'] : []; + $deviceName = app_device_display_name($device, $deviceId); + $sourceItem = $bestItem; + $sourceAreaId = trim((string)($sourceItem['area_id'] ?? '')); + if ($sourceAreaId === '' && !empty($device['area_id'])) { + $sourceAreaId = trim((string)$device['area_id']); + if ($sourceAreaId !== '') { + $sourceItem['area_id'] = $sourceAreaId; + } + } + $sourceRoomName = app_battery_source_room_name($sourceItem, $roomNameByAreaId); + $sourceText = trim($sourceRoomName); + if ($sourceText === '' || $sourceText === 'Без комнаты') { + $sourceText = $deviceName !== '' ? $deviceName : $sourceText; + } elseif ($deviceName !== '') { + $sourceText .= ' | ' . $deviceName; + } + + $batteryEntities[] = [ + 'entity_id' => $entityId, + 'name' => (string)($bestItem['name'] ?? $entityId), + 'source_room_name' => $sourceRoomName, + 'source_device_name' => $deviceName, + 'source_text' => $sourceText, + 'domain' => (string)($bestItem['domain'] ?? app_entity_domain($entityId)), + 'battery_percent' => $percent, + 'battery_percent_text' => app_battery_percent_text($percent), + 'battery_status' => $status, + 'battery_status_label' => app_battery_status_label($status), + 'battery_icon' => app_battery_icon_for_status($status, $percent), + 'forecast_minutes_left' => $forecast['forecast_minutes_left'], + 'forecast_text' => $forecast['forecast_text'], + 'forecast_reason' => $forecast['forecast_reason'] ?? null, + 'last_seen_state' => (string)($bestItem['state'] ?? 'unknown'), + 'labels' => $bestItem['labels'] ?? [], + 'order' => (int)($bestItem['order'] ?? 9999), + ]; + } + + if ($cacheChanged) { + app_save_battery_cache(['items' => $cacheItems]); + } + + usort($batteryEntities, static function (array $a, array $b): int { + $rankA = app_battery_status_rank((string)($a['battery_status'] ?? 'ok')); + $rankB = app_battery_status_rank((string)($b['battery_status'] ?? 'ok')); + if ($rankA !== $rankB) { + return $rankA <=> $rankB; + } + + $percentA = $a['battery_percent']; + $percentB = $b['battery_percent']; + if ($percentA !== null && $percentB !== null && $percentA !== $percentB) { + return $percentA <=> $percentB; + } + if ($percentA !== null && $percentB === null) { + return -1; + } + if ($percentA === null && $percentB !== null) { + return 1; + } + + $forecastA = $a['forecast_minutes_left'] ?? null; + $forecastB = $b['forecast_minutes_left'] ?? null; + if ($forecastA !== null && $forecastB !== null && $forecastA !== $forecastB) { + return $forecastA <=> $forecastB; + } + if ($forecastA !== null && $forecastB === null) { + return -1; + } + if ($forecastA === null && $forecastB !== null) { + return 1; + } + + return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? '')); + }); + + $counts = app_battery_item_status_summary($batteryEntities); + $total = count($batteryEntities); + $problem = (int)($counts['empty'] ?? 0) + (int)($counts['critical'] ?? 0) + (int)($counts['low'] ?? 0) + (int)($counts['unavailable'] ?? 0) + (int)($counts['unknown'] ?? 0); + $summaryText = app_battery_summary_text($counts, $total); + + return [ + 'id' => 'batteries', + 'name' => 'Батарейки', + 'icon' => 'mdi:battery-outline', + 'visible' => true, + 'virtual' => true, + 'layout_items' => [], + 'entities' => $batteryEntities, + 'entity_count' => $total, + 'active_entity_count' => $problem, + 'problem_count' => $problem, + 'battery_counts' => $counts, + 'battery_summary_text' => $summaryText, + 'critical_count' => (int)($counts['empty'] ?? 0) + (int)($counts['critical'] ?? 0) + (int)($counts['low'] ?? 0), + 'low_count' => (int)($counts['low'] ?? 0), + 'unavailable_count' => (int)($counts['unavailable'] ?? 0), + 'unknown_count' => (int)($counts['unknown'] ?? 0), + 'order' => -1000, + ]; +} + +function app_room_entities(array $room, array $haData, bool $includeHidden = false): array +{ + $states = $haData['states'] ?? []; + $registryMap = app_registry_map($haData['entity_registry'] ?? []); + $deviceMap = app_device_registry_map($haData['device_registry'] ?? []); + $explicitIds = $room['entity_ids'] ?? []; + $areaId = $room['area_id'] ?? $room['id'] ?? null; + $entityOverrides = $room['entity_overrides'] ?? []; + + $candidates = []; + foreach ($states as $entity) { + $entityId = (string)($entity['entity_id'] ?? ''); + if ($entityId === '') { + continue; + } + + $registryEntry = $registryMap[$entityId] ?? []; + $entityAreaId = (string)($registryEntry['area_id'] ?? ''); + $matchesArea = $areaId !== null && $areaId !== '' && $entityAreaId === (string)$areaId; + $matchesExplicit = is_array($explicitIds) && in_array($entityId, $explicitIds, true); + $matchesDeviceArea = false; + + if (!$matchesArea && !$matchesExplicit && $entityAreaId === '') { + $deviceId = (string)($registryEntry['device_id'] ?? $registryEntry['id'] ?? $registryEntry['di'] ?? ''); + $deviceAreaId = $deviceId !== '' ? (string)($deviceMap[$deviceId]['area_id'] ?? '') : ''; + $matchesDeviceArea = $areaId !== null && $areaId !== '' && $deviceAreaId === (string)$areaId; + } + + if (!$matchesArea && !$matchesExplicit && !$matchesDeviceArea) { + continue; + } + + if (!empty($registryEntry['hidden_by']) || !empty($registryEntry['disabled_by'])) { + continue; + } + + $override = is_array($entityOverrides[$entityId] ?? null) ? $entityOverrides[$entityId] : []; + $isVisible = $override['visible'] ?? true; + if ($isVisible === false && !$includeHidden) { + continue; + } + + $domain = app_entity_domain($entityId); + $cardType = (string)($override['card_type'] ?? app_default_card_type($entityId)); + $labels = app_labels_from_entity($entity, $registryEntry); + + $candidates[] = [ + 'entity_id' => $entityId, + 'domain' => $domain, + 'name' => app_entity_name($entity, $registryEntry, $override), + 'icon' => app_entity_icon($entity, $override), + 'state' => $entity['state'] ?? 'unknown', + 'attributes' => $entity['attributes'] ?? [], + 'labels' => $labels, + 'area_id' => $entityAreaId ?: null, + 'card_type' => $cardType, + 'order' => (int)($override['order'] ?? 9999), + 'subtitle' => app_entity_subtitle($entity, $cardType), + 'override' => $override, + 'visible' => (bool)$isVisible, + ]; + } + + usort($candidates, static function (array $a, array $b): int { + if ($a['order'] === $b['order']) { + return strcasecmp($a['name'], $b['name']); + } + return $a['order'] <=> $b['order']; + }); + + return $candidates; +} + +function app_entity_index(array $config, array $haData): array +{ + $states = $haData['states'] ?? []; + $registryMap = app_registry_map($haData['entity_registry'] ?? []); + $autoLabel = (string)($config['home_assistant']['auto_label'] ?? 'auto'); + $manualAuto = array_flip(array_map('strval', $config['home_assistant']['auto_entity_ids'] ?? [])); + $items = []; + + foreach ($states as $entity) { + $entityId = (string)($entity['entity_id'] ?? ''); + if ($entityId === '') { + continue; + } + + $registryEntry = $registryMap[$entityId] ?? []; + $labels = app_labels_from_entity($entity, $registryEntry); + $isHidden = !empty($registryEntry['hidden_by']) + || !empty($registryEntry['disabled_by']) + || !empty($registryEntry['entity_registry_enabled_default']) && empty($entity['state']); + $items[$entityId] = [ + 'entity_id' => $entityId, + 'domain' => app_entity_domain($entityId), + 'name' => app_entity_name($entity, $registryEntry, []), + 'icon' => app_entity_icon($entity, []), + 'state' => $entity['state'] ?? 'unknown', + 'attributes' => $entity['attributes'] ?? [], + 'labels' => $labels, + 'area_id' => (string)($registryEntry['area_id'] ?? ''), + 'device_id' => (string)($registryEntry['device_id'] ?? $registryEntry['di'] ?? ''), + 'card_type' => app_default_card_type($entityId), + 'is_door_contact' => app_is_door_contact_entity($entity, $registryEntry), + 'is_auto' => app_entity_has_auto_label($labels, $autoLabel) || isset($manualAuto[$entityId]), + 'is_hidden' => (bool)$isHidden, + ]; + } + + return $items; +} + +function app_entity_subtitle(array $entity, string $cardType): string +{ + $state = (string)($entity['state'] ?? ''); + $attr = $entity['attributes'] ?? []; + + return match ($cardType) { + 'cover' => isset($attr['current_position']) ? 'Позиция ' . $attr['current_position'] . '%' : match ($state) { + 'open' => 'Открыто', + 'closed' => 'Закрыто', + default => ucfirst($state), + }, + 'climate' => app_climate_subtitle($entity), + default => app_generic_subtitle($state, $attr), + }; +} + +function app_generic_subtitle(string $state, array $attr): string +{ + if ($state === '') { + return 'Нет данных'; + } + + if (isset($attr['current_temperature']) && is_numeric($attr['current_temperature'])) { + return (string)$state . ' · ' . rtrim(rtrim((string)$attr['current_temperature'], '0'), '.') . '°'; + } + + return match ($state) { + 'on' => 'Включено', + 'off' => 'Выключено', + 'open' => 'Открыто', + 'closed' => 'Закрыто', + default => ucfirst($state), + }; +} + +function app_climate_subtitle(array $entity): string +{ + $attr = $entity['attributes'] ?? []; + $pieces = []; + if (isset($attr['current_temperature']) && is_numeric($attr['current_temperature'])) { + $pieces[] = rtrim(rtrim((string)$attr['current_temperature'], '0'), '.') . '°'; + } + if (isset($attr['temperature']) && is_numeric($attr['temperature'])) { + $pieces[] = 'Цель ' . rtrim(rtrim((string)$attr['temperature'], '0'), '.') . '°'; + } + if (!empty($attr['hvac_action'])) { + $pieces[] = (string)$attr['hvac_action']; + } + return $pieces ? implode(' · ', $pieces) : 'Климат'; +} + +function app_room_active_count(array $items): int +{ + $count = 0; + foreach ($items as $item) { + if (is_array($item) && app_is_active_entity($item)) { + $count++; + } + } + return $count; +} + +function app_room_temperature_badge(array $items): ?string +{ + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $attr = $item['attributes'] ?? []; + $candidate = $attr['current_temperature'] ?? $attr['temperature'] ?? null; + if ($candidate === null || !is_numeric($candidate)) { + continue; + } + + return (string)round((float)$candidate) . '°'; + } + + return null; +} + +function app_room_temperature_candidate(array $room, array $items): ?string +{ + $selectedSensorId = trim((string)($room['temperature_sensor_entity_id'] ?? '')); + if ($selectedSensorId === '') { + return null; + } + + $entity = null; + foreach ($items as $candidate) { + if (!is_array($candidate)) { + continue; + } + if (($candidate['entity_id'] ?? null) === $selectedSensorId) { + $entity = $candidate; + break; + } + } + + if (!$entity) { + return null; + } + + $entityId = (string)($entity['entity_id'] ?? ''); + $attr = $entity['attributes'] ?? []; + $candidate = $attr['current_temperature'] ?? $attr['temperature'] ?? $entity['state'] ?? null; + if ($candidate === null || !is_numeric($candidate)) { + return null; + } + + $domain = app_entity_domain($entityId); + $deviceClass = strtolower((string)($attr['device_class'] ?? '')); + $unit = strtolower(trim((string)($attr['unit_of_measurement'] ?? ''))); + if ($domain !== 'sensor') { + return null; + } + if ($domain === 'sensor' && $deviceClass !== 'temperature' && $unit !== '°c' && $unit !== 'c' && !str_ends_with($entityId, '_temperature')) { + return null; + } + + return (string)round((float)$candidate) . '°'; +} + +function app_main_entities(array $config, array $haData): array +{ + $states = $haData['states'] ?? []; + $registryMap = app_registry_map($haData['entity_registry'] ?? []); + $autoLabel = (string)($config['home_assistant']['auto_label'] ?? 'auto'); + $manualAuto = array_flip(array_map('strval', $config['home_assistant']['auto_entity_ids'] ?? [])); + $items = []; + + foreach ($states as $entity) { + $entityId = (string)($entity['entity_id'] ?? ''); + if ($entityId === '') { + continue; + } + $registryEntry = $registryMap[$entityId] ?? []; + $labels = app_labels_from_entity($entity, $registryEntry); + $isAuto = app_entity_has_auto_label($labels, $autoLabel) || isset($manualAuto[$entityId]); + $isHidden = !empty($registryEntry['hidden_by']) || !empty($registryEntry['disabled_by']); + $domain = app_entity_domain($entityId); + $isDoorContact = app_is_door_contact_entity($entity, $registryEntry); + if (!$isAuto || !app_is_active_entity($entity) || $isHidden) { + continue; + } + + if (!in_array($domain, ['light', 'switch', 'cover', 'fan', 'binary_sensor'], true)) { + continue; + } + + if ($domain === 'binary_sensor' && !$isDoorContact) { + continue; + } + $cardType = app_default_card_type($entityId); + $items[] = [ + 'entity_id' => $entityId, + 'domain' => $domain, + 'name' => app_entity_name($entity, $registryEntry, []), + 'icon' => app_entity_icon($entity, []), + 'state' => $entity['state'] ?? 'unknown', + 'attributes' => $entity['attributes'] ?? [], + 'labels' => $labels, + 'card_type' => $cardType, + 'is_door_contact' => $isDoorContact, + 'subtitle' => app_entity_subtitle($entity, $cardType), + 'last_changed' => (string)($entity['last_changed'] ?? $entity['last_updated'] ?? ''), + ]; + } + + usort($items, static function (array $a, array $b): int { + $timeA = strtotime((string)($a['last_changed'] ?? '')) ?: 0; + $timeB = strtotime((string)($b['last_changed'] ?? '')) ?: 0; + if ($timeA !== $timeB) { + return $timeA <=> $timeB; + } + + return strcasecmp($a['name'], $b['name']); + }); + + return $items; +} + +function app_weather_summary(?array $entity): ?array +{ + if (!$entity) { + return null; + } + + $attr = $entity['attributes'] ?? []; + + return [ + 'entity_id' => $entity['entity_id'] ?? null, + 'name' => $attr['friendly_name'] ?? 'Погода', + 'state' => $entity['state'] ?? null, + 'temperature' => $attr['temperature'] ?? null, + 'sensor_temperature' => null, + 'wind_speed' => $attr['wind_speed'] ?? null, + 'condition' => $attr['condition'] ?? ($entity['state'] ?? null), + ]; +} + +function app_find_state(array $states, string $entityId): ?array +{ + foreach ($states as $entity) { + if (($entity['entity_id'] ?? null) === $entityId) { + return $entity; + } + } + + return null; +} + +function app_room_summary(array $room, array $items, array $haData): array +{ + $activeCount = app_room_active_count($items); + $allItems = app_room_entities($room, $haData, true); + $temperatureBadge = app_room_temperature_candidate($room, $allItems); + if ($temperatureBadge === null) { + $temperatureBadge = app_room_temperature_badge($items); + } + if ($temperatureBadge === null) { + $explicitTemperatureIds = []; + foreach (($room['entity_overrides'] ?? []) as $entityId => $override) { + if (!is_array($override)) { + continue; + } + if (str_ends_with((string)$entityId, '_temperature') && !empty($override['visible'])) { + $explicitTemperatureIds[] = (string)$entityId; + } + if (str_ends_with((string)$entityId, '_temperature') && array_key_exists('visible', $override) && $override['visible'] === false) { + $explicitTemperatureIds[] = (string)$entityId; + } + } + + foreach ($explicitTemperatureIds as $temperatureEntityId) { + foreach (($haData['states'] ?? []) as $entity) { + if (($entity['entity_id'] ?? null) !== $temperatureEntityId) { + continue; + } + $candidate = $entity['attributes']['current_temperature'] ?? $entity['attributes']['temperature'] ?? $entity['state'] ?? null; + if ($candidate !== null && is_numeric($candidate)) { + $temperatureBadge = (string)round((float)$candidate) . '°'; + break 2; + } + } + } + + if ($temperatureBadge === null) { + $temperatureBadge = app_room_temperature_badge($allItems); + } + } + return [ + 'id' => $room['id'], + 'name' => $room['name'] ?? $room['id'], + 'icon' => $room['icon'] ?? 'mdi:home-variant', + 'floor_id' => $room['floor_id'] ?? null, + 'floor_name' => $room['floor_name'] ?? null, + 'floor_level' => isset($room['floor_level']) ? (int)$room['floor_level'] : null, + 'visible' => (bool)($room['visible'] ?? true), + 'order' => (int)($room['order'] ?? 9999), + 'temperature_sensor_entity_id' => trim((string)($room['temperature_sensor_entity_id'] ?? '')), + 'entity_count' => $activeCount, + 'active_entity_count' => $activeCount, + 'temperature_badge' => $temperatureBadge, + ]; +} + +function app_build_snapshot(array $config, HomeAssistantClient $client, ?string $selectedRoomId = null): array +{ + $haData = $client->fetchSnapshotData(); + $demo = $client->isDemo(); + $editMode = (bool)($config['app']['edit_mode'] ?? false); + $rooms = app_room_definitions($config, $haData); + $mainRoom = [ + 'id' => 'main', + 'name' => (string)($config['app']['main_room_name'] ?? 'Главная'), + 'icon' => (string)($config['app']['main_room_icon'] ?? 'mdi:home'), + 'visible' => true, + 'entity_count' => 0, + ]; + + $roomSummaries = [$mainRoom]; + $roomViews = []; + $spaceIndex = []; + $spaceEntities = []; + + foreach ($rooms as $room) { + $entities = app_room_entities($room, $haData, $editMode); + $summary = app_room_summary($room, $entities, $haData); + $roomSummaries[] = $summary; + $roomViews[$room['id']] = [ + 'id' => $room['id'], + 'name' => $room['name'] ?? $room['id'], + 'icon' => $room['icon'] ?? 'mdi:home-variant', + 'visible' => (bool)($room['visible'] ?? true), + 'order' => (int)($room['order'] ?? 9999), + 'entities' => $entities, + 'layout_items' => app_room_layout_items($room), + 'entity_count' => $summary['entity_count'], + 'active_entity_count' => $summary['active_entity_count'], + 'temperature_badge' => $summary['temperature_badge'], + ]; + $spaceIndex[$room['id']] = $roomViews[$room['id']]; + $spaceEntities[$room['id']] = $entities; + } + + $batteryRoom = app_battery_room($config, $haData, $rooms, $client, $selectedRoomId === 'batteries'); + + $states = $haData['states'] ?? []; + $entityIndex = app_entity_index($config, $haData); + $mainEntities = app_main_entities($config, $haData); + $mainActiveCount = count($mainEntities); + $mainRoom['entity_count'] = $mainActiveCount; + $mainRoom['active_entity_count'] = $mainActiveCount; + $mainRoom['temperature_badge'] = null; + $roomSummaries[0] = $mainRoom; + $weatherEntity = app_select_weather_entity($states, $config); + $weather = app_weather_summary($weatherEntity); + $weatherSensor = app_find_state($states, 'sensor.weather_temperature'); + if ($weather !== null) { + $weather['sensor_temperature'] = $weatherSensor['state'] ?? null; + } + + $selectedRoomId = $selectedRoomId ?: 'main'; + if (!isset($roomViews[$selectedRoomId]) && !in_array($selectedRoomId, ['main', 'batteries'], true)) { + $selectedRoomId = 'main'; + } + + $selectedRoom = $selectedRoomId === 'main' + ? [ + 'id' => 'main', + 'name' => $mainRoom['name'], + 'icon' => $mainRoom['icon'], + 'visible' => true, + 'entities' => $mainEntities, + 'layout_items' => [], + 'entity_count' => count($mainEntities), + 'active_entity_count' => count($mainEntities), + 'temperature_badge' => null, + ] + : ($selectedRoomId === 'batteries' + ? $batteryRoom + : ($spaceIndex[$selectedRoomId] ?? $roomViews[$selectedRoomId])); + + if ($selectedRoomId === 'main') { + $selectedRoom['weather'] = $weather; + } + + $popup = app_clear_expired_popup_state($config); + $camera = $config['camera'] ?? []; + + return [ + 'ok' => true, + 'demo' => $demo, + 'server_time' => time(), + 'settings' => [ + 'title' => $config['app']['title'] ?? 'Wall Panel', + 'poll_interval_ms' => (int)($config['app']['poll_interval_ms'] ?? 5000), + 'edit_mode' => (bool)($config['app']['edit_mode'] ?? false), + 'main_room_name' => $mainRoom['name'], + 'ha_connection' => [ + 'base_url' => (string)($config['home_assistant']['base_url'] ?? ''), + 'token' => (string)($config['home_assistant']['token'] ?? ''), + 'verify_ssl' => (bool)($config['home_assistant']['verify_ssl'] ?? true), + ], + 'camera' => [ + 'poster_url' => (string)($camera['poster_url'] ?? ''), + 'stream_url' => (string)($camera['stream_url'] ?? ''), + 'stream_mode' => (string)($camera['stream_mode'] ?? 'hls'), + 'popup_timeout_minutes' => (int)($camera['popup_timeout_minutes'] ?? 3), + 'trigger_entities' => app_trigger_entities($config), + ], + 'main_weather_actions' => app_main_weather_actions($config), + 'main_boiler' => app_main_boiler($config), + 'main_print' => app_main_print($config), + ], + 'spaces' => $roomSummaries, + 'selected_space' => $selectedRoom, + 'space_index' => $spaceIndex, + 'space_entities' => $spaceEntities, + 'entity_index' => $entityIndex, + 'weather' => $weather, + 'main_entities' => $mainEntities, + 'battery_room' => $batteryRoom, + 'temperature_sensor_entity_id' => $selectedRoom['temperature_sensor_entity_id'] ?? null, + 'popup' => [ + 'active' => (bool)($popup['active'] ?? false), + 'sensor_entity_id' => $popup['sensor_entity_id'] ?? null, + 'opened_at' => $popup['opened_at'] ?? null, + 'expires_at' => $popup['expires_at'] ?? null, + 'poster_url' => (string)($camera['poster_url'] ?? ''), + 'stream_url' => (string)($camera['stream_url'] ?? ''), + 'stream_mode' => (string)($camera['stream_mode'] ?? 'hls'), + 'title' => 'Камера', + ], + 'rooms' => $roomSummaries, + 'selected_room' => $selectedRoom, + ]; +} + +function app_update_entity_override(array $config, string $roomId, string $entityId, array $patch): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + $found = false; + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + $room['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : []; + $current = is_array($room['entity_overrides'][$entityId] ?? null) ? $room['entity_overrides'][$entityId] : []; + $room['entity_overrides'][$entityId] = array_replace_recursive($current, array_filter($patch, static fn($value) => $value !== null)); + $found = true; + break; + } + unset($room); + + if (!$found) { + $newRoom = [ + 'id' => $roomId, + 'visible' => true, + 'entity_ids' => [], + 'entity_overrides' => [ + $entityId => array_filter($patch, static fn($value) => $value !== null), + ], + ]; + if (array_key_exists('name', $patch) && trim((string)$patch['name']) !== '') { + $newRoom['name'] = (string)$patch['name']; + } + if (array_key_exists('icon', $patch) && trim((string)$patch['icon']) !== '') { + $newRoom['icon'] = (string)$patch['icon']; + } + $rooms[] = [ + ...$newRoom, + ]; + } + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_update_room_override(array $config, string $roomId, array $patch): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + $found = false; + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + + foreach (['visible', 'order', 'name', 'icon'] as $key) { + if (array_key_exists($key, $patch) && $patch[$key] !== null) { + $room[$key] = $key === 'visible' ? (bool)$patch[$key] : ($key === 'order' ? (int)$patch[$key] : (string)$patch[$key]); + } + } + if (array_key_exists('temperature_sensor_entity_id', $patch)) { + $room['temperature_sensor_entity_id'] = trim((string)($patch['temperature_sensor_entity_id'] ?? '')); + } + + if (!isset($room['entity_ids']) || !is_array($room['entity_ids'])) { + $room['entity_ids'] = []; + } + if (!isset($room['entity_overrides']) || !is_array($room['entity_overrides'])) { + $room['entity_overrides'] = []; + } + if (!isset($room['layout_items']) || !is_array($room['layout_items'])) { + $room['layout_items'] = []; + } + + $found = true; + break; + } + unset($room); + + if (!$found) { + $newRoom = [ + 'id' => $roomId, + 'visible' => array_key_exists('visible', $patch) ? (bool)$patch['visible'] : true, + 'order' => array_key_exists('order', $patch) ? (int)$patch['order'] : 9999, + 'entity_ids' => [], + 'entity_overrides' => [], + 'layout_items' => [], + 'temperature_sensor_entity_id' => array_key_exists('temperature_sensor_entity_id', $patch) + ? trim((string)($patch['temperature_sensor_entity_id'] ?? '')) + : '', + ]; + if (array_key_exists('name', $patch) && trim((string)$patch['name']) !== '') { + $newRoom['name'] = (string)$patch['name']; + } + if (array_key_exists('icon', $patch) && trim((string)$patch['icon']) !== '') { + $newRoom['icon'] = (string)$patch['icon']; + } + $rooms[] = $newRoom; + } + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_update_room_layout_item(array $config, string $roomId, string $layoutItemId, array $patch): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + $found = false; + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + + $room['layout_items'] = app_room_layout_items($room); + $current = null; + foreach ($room['layout_items'] as $index => $item) { + if ((string)($item['id'] ?? '') === $layoutItemId) { + $current = $item; + break; + } + } + + if ($current === null) { + $current = [ + 'id' => $layoutItemId, + 'type' => 'ghost', + 'order' => 9999, + ]; + } + + $next = array_replace($current, array_filter($patch, static fn($value) => $value !== null)); + $next['id'] = $layoutItemId; + $next['type'] = 'ghost'; + $next['order'] = isset($next['order']) ? (int)$next['order'] : 9999; + + $updated = []; + $replaced = false; + foreach ($room['layout_items'] as $item) { + if ((string)($item['id'] ?? '') === $layoutItemId) { + $updated[] = $next; + $replaced = true; + } else { + $updated[] = $item; + } + } + + if (!$replaced) { + $updated[] = $next; + } + + usort($updated, static function (array $a, array $b): int { + $orderA = (int)($a['order'] ?? 9999); + $orderB = (int)($b['order'] ?? 9999); + if ($orderA !== $orderB) { + return $orderA <=> $orderB; + } + return strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? '')); + }); + + $room['layout_items'] = $updated; + $found = true; + break; + } + unset($room); + + if (!$found) { + $rooms[] = [ + 'id' => $roomId, + 'visible' => true, + 'entity_ids' => [], + 'entity_overrides' => [], + 'layout_items' => [[ + 'id' => $layoutItemId, + 'type' => 'ghost', + 'order' => isset($patch['order']) ? (int)$patch['order'] : 9999, + ]], + ]; + } + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_delete_room_layout_item(array $config, string $roomId, string $layoutItemId): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + + $room['layout_items'] = array_values(array_filter( + app_room_layout_items($room), + static fn(array $item): bool => (string)($item['id'] ?? '') !== $layoutItemId + )); + break; + } + unset($room); + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_reorder_room_grid(array $config, string $roomId, array $entries): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + $normalizedEntries = []; + foreach ($entries as $entry) { + if (!is_array($entry)) { + continue; + } + + $kind = trim((string)($entry['kind'] ?? '')); + $id = trim((string)($entry['id'] ?? '')); + if ($id === '' || !in_array($kind, ['entity', 'layout'], true)) { + continue; + } + + $normalizedEntries[] = [ + 'kind' => $kind, + 'id' => $id, + ]; + } + + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + + $room['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : []; + $room['layout_items'] = app_room_layout_items($room); + $layoutById = []; + foreach ($room['layout_items'] as $item) { + if (is_array($item) && !empty($item['id'])) { + $layoutById[(string)$item['id']] = $item; + } + } + + $order = 10000; + foreach ($normalizedEntries as $entry) { + if ($entry['kind'] === 'entity') { + $current = is_array($room['entity_overrides'][$entry['id']] ?? null) ? $room['entity_overrides'][$entry['id']] : []; + $room['entity_overrides'][$entry['id']] = array_replace($current, [ + 'order' => $order, + ]); + } else { + $current = is_array($layoutById[$entry['id']] ?? null) ? $layoutById[$entry['id']] : [ + 'id' => $entry['id'], + 'type' => 'ghost', + ]; + $layoutById[$entry['id']] = array_replace($current, [ + 'id' => $entry['id'], + 'type' => 'ghost', + 'order' => $order, + ]); + } + $order += 10; + } + + $room['layout_items'] = array_values($layoutById); + usort($room['layout_items'], static function (array $a, array $b): int { + $orderA = (int)($a['order'] ?? 9999); + $orderB = (int)($b['order'] ?? 9999); + if ($orderA !== $orderB) { + return $orderA <=> $orderB; + } + + return strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? '')); + }); + break; + } + unset($room); + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_handle_popup_event(array $config, array $payload): array +{ + $camera = $config['camera'] ?? []; + $watch = app_trigger_entities($config); + $sensor = (string)($payload['sensor_entity_id'] ?? $payload['entity_id'] ?? ''); + $state = strtolower((string)($payload['state'] ?? $payload['to'] ?? '')); + $command = strtolower((string)($payload['command'] ?? '')); + $popupState = app_load_popup_state(); + $timeoutMinutes = max(1, (int)($camera['popup_timeout_minutes'] ?? 3)); + $closeDelaySeconds = app_popup_close_delay_seconds(); + $timeoutSeconds = max($closeDelaySeconds, $timeoutMinutes * 60); + + if ($command === 'open') { + $popupState = [ + 'active' => true, + 'sensor_entity_id' => $sensor !== '' ? $sensor : 'debug', + 'opened_at' => time(), + 'expires_at' => time() + $timeoutSeconds, + ]; + app_save_popup_state($popupState); + } elseif (in_array($sensor, $watch, true) && $state === 'on') { + $popupState = [ + 'active' => true, + 'sensor_entity_id' => $sensor, + 'opened_at' => time(), + 'expires_at' => null, + ]; + app_save_popup_state($popupState); + } elseif ($state === 'off' && ($popupState['sensor_entity_id'] ?? null) === $sensor && ($popupState['active'] ?? false)) { + $popupState['active'] = true; + $popupState['expires_at'] = time() + $closeDelaySeconds; + if (empty($popupState['opened_at'])) { + $popupState['opened_at'] = time(); + } + app_save_popup_state($popupState); + } elseif (($payload['command'] ?? '') === 'close') { + $popupState['active'] = false; + $popupState['expires_at'] = null; + app_save_popup_state($popupState); + } + + return $popupState; +} + +function app_service_for_entity(string $entityId, string $command): array +{ + $domain = app_entity_domain($entityId); + + return match ($command) { + 'toggle' => [$domain, 'toggle', ['entity_id' => $entityId]], + 'turn_on' => [$domain, 'turn_on', ['entity_id' => $entityId]], + 'turn_off' => [$domain, 'turn_off', ['entity_id' => $entityId]], + 'open' => ['cover', 'open_cover', ['entity_id' => $entityId]], + 'close' => ['cover', 'close_cover', ['entity_id' => $entityId]], + 'stop' => ['cover', 'stop_cover', ['entity_id' => $entityId]], + 'set_position' => ['cover', 'set_cover_position', ['entity_id' => $entityId]], + 'set_temperature' => ['climate', 'set_temperature', ['entity_id' => $entityId]], + 'set_hvac_mode' => ['climate', 'set_hvac_mode', ['entity_id' => $entityId]], + 'set_fan_mode' => ['climate', 'set_fan_mode', ['entity_id' => $entityId]], + 'set_swing_mode' => ['climate', 'set_swing_mode', ['entity_id' => $entityId]], + 'set_preset_mode' => ['climate', 'set_preset_mode', ['entity_id' => $entityId]], + default => [$domain, $command, ['entity_id' => $entityId]], + }; +} diff --git a/lib/ha_client.php b/lib/ha_client.php new file mode 100755 index 0000000..888ba2f --- /dev/null +++ b/lib/ha_client.php @@ -0,0 +1,658 @@ +config = $config; + $baseUrl = trim((string)($config['home_assistant']['base_url'] ?? '')); + $token = trim((string)($config['home_assistant']['token'] ?? '')); + $this->demoMode = $baseUrl === '' || $token === '' || str_contains($baseUrl, 'example.com') || str_contains($token, 'PASTE_') || str_contains($token, 'YOUR_'); + } + + public function isDemo(): bool + { + return $this->demoMode; + } + + public function fetchSnapshotData(): array + { + if ($this->demoMode) { + return $this->demoData(); + } + $states = $this->request('GET', '/api/states'); + $areas = $this->fetchWsList('config/area_registry/list') ?? $this->tryRequest('GET', '/api/areas') ?? []; + $floors = $this->fetchWsList('config/floor_registry/list') ?? []; + $entities = $this->fetchWsList('config/entity_registry/list_for_display') ?? $this->tryRequest('GET', '/api/config/entity_registry/list') ?? []; + $devices = $this->fetchWsList('config/device_registry/list') ?? $this->tryRequest('GET', '/api/config/device_registry/list') ?? []; + + return [ + 'states' => is_array($states) ? $states : [], + 'areas' => is_array($areas) ? $areas : [], + 'floors' => is_array($floors) ? $floors : [], + 'entity_registry' => is_array($entities) ? $entities : [], + 'device_registry' => is_array($devices) ? $devices : [], + ]; + } + + public function fetchEntityHistory(string $entityId, int $hours = 24): array + { + $entityId = trim($entityId); + $hours = max(1, min(168, $hours)); + + if ($this->demoMode) { + return $this->demoHistory($entityId, $hours); + } + + if ($entityId === '') { + return []; + } + + $start = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) + ->modify('-' . $hours . ' hours') + ->format(DATE_ATOM); + + $path = '/api/history/period/' . rawurlencode($start) . '?' . http_build_query([ + 'filter_entity_id' => $entityId, + 'minimal_response' => 1, + 'no_attributes' => 1, + ]); + + $response = $this->request('GET', $path); + return is_array($response) ? $response : []; + } + + public function callService(string $domain, string $service, array $data): array + { + if ($this->demoMode) { + return [ + 'ok' => true, + 'demo' => true, + 'domain' => $domain, + 'service' => $service, + 'data' => $data, + ]; + } + + return $this->request('POST', '/api/services/' . rawurlencode($domain) . '/' . rawurlencode($service), $data); + } + + private function request(string $method, string $path, ?array $body = null): array + { + $response = $this->tryRequest($method, $path, $body, true); + if (!is_array($response)) { + throw new RuntimeException('Unexpected response from Home Assistant'); + } + + return $response; + } + + private function tryRequest(string $method, string $path, ?array $body = null, bool $throwOnHttpError = false): array|null + { + $baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/'); + $url = $baseUrl . $path; + $token = trim((string)$this->config['home_assistant']['token']); + + $ch = curl_init($url); + if ($ch === false) { + throw new RuntimeException('Failed to initialize HTTP client'); + } + + $headers = [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + 'Accept: application/json', + ]; + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 15, + CURLOPT_CONNECTTIMEOUT => 7, + CURLOPT_SSL_VERIFYPEER => (bool)($this->config['home_assistant']['verify_ssl'] ?? true), + CURLOPT_SSL_VERIFYHOST => (bool)($this->config['home_assistant']['verify_ssl'] ?? true) ? 2 : 0, + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + $raw = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($errno !== 0) { + if ($throwOnHttpError) { + throw new RuntimeException('Home Assistant request failed: ' . $error); + } + return null; + } + + if ($status >= 400) { + if ($throwOnHttpError) { + throw new RuntimeException('Home Assistant returned HTTP ' . $status); + } + return null; + } + + $decoded = json_decode((string)$raw, true); + return is_array($decoded) ? $decoded : []; + } + + private function fetchWsList(string $type): array|null + { + $response = $this->wsCall($type); + if (!is_array($response)) { + return null; + } + + if (isset($response['result']) && is_array($response['result'])) { + return $this->normalizeWsResultList($type, $response['result']); + } + + return null; + } + + private function normalizeWsResultList(string $type, array $result): array + { + if (array_is_list($result)) { + return match ($type) { + 'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result), + 'config/area_registry/list' => $this->normalizeAreaRegistry($result), + 'config/floor_registry/list' => $this->normalizeFloorRegistry($result), + 'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result), + default => $result, + }; + } + + return match ($type) { + 'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result['entities'] ?? []), + 'config/area_registry/list' => $this->normalizeAreaRegistry($result['areas'] ?? []), + 'config/floor_registry/list' => $this->normalizeFloorRegistry($result['floors'] ?? []), + 'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result['devices'] ?? $result['device_registry'] ?? $result['items'] ?? []), + default => $result, + }; + } + + private function wsCall(string $type): array|null + { + $baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/'); + $parts = parse_url($baseUrl); + if (!is_array($parts) || empty($parts['host'])) { + return null; + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'http')); + $secure = in_array($scheme, ['https', 'wss'], true); + $host = (string)$parts['host']; + $port = (int)($parts['port'] ?? ($secure ? 443 : 80)); + $path = '/api/websocket'; + $target = ($secure ? 'tls' : 'tcp') . '://' . $host . ':' . $port; + $contextOptions = [ + 'ssl' => [ + 'verify_peer' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true), + 'verify_peer_name' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true), + 'allow_self_signed' => !(bool)($this->config['home_assistant']['verify_ssl'] ?? true), + 'SNI_enabled' => true, + 'peer_name' => $host, + ], + ]; + + $stream = @stream_socket_client( + $target, + $errno, + $error, + 15, + STREAM_CLIENT_CONNECT, + stream_context_create($contextOptions) + ); + + if ($stream === false) { + return null; + } + + stream_set_timeout($stream, 15); + $key = base64_encode(random_bytes(16)); + $request = implode("\r\n", [ + 'GET ' . $path . ' HTTP/1.1', + 'Host: ' . $host . ':' . $port, + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Key: ' . $key, + 'Sec-WebSocket-Version: 13', + '', + '', + ]); + fwrite($stream, $request); + + $status = $this->readHttpHeader($stream); + if ($status === null || !str_contains($status, ' 101 ')) { + fclose($stream); + return null; + } + + $auth = $this->readWsJson($stream); + if (!is_array($auth) || ($auth['type'] ?? '') !== 'auth_required') { + fclose($stream); + return null; + } + + $this->writeWsJson($stream, [ + 'type' => 'auth', + 'access_token' => (string)$this->config['home_assistant']['token'], + ]); + + $authResult = $this->readWsJson($stream); + if (!is_array($authResult) || ($authResult['type'] ?? '') !== 'auth_ok') { + fclose($stream); + return null; + } + + $requestId = random_int(1, 1_000_000); + $this->writeWsJson($stream, [ + 'id' => $requestId, + 'type' => $type, + ]); + + while (($message = $this->readWsJson($stream)) !== null) { + if (($message['id'] ?? null) === $requestId) { + fclose($stream); + if (($message['success'] ?? false) !== true) { + return null; + } + return is_array($message) ? $message : null; + } + } + + fclose($stream); + return null; + } + + private function normalizeEntityRegistryForDisplay(array $result): array + { + $entityCategories = is_array($result['entity_categories'] ?? null) ? $result['entity_categories'] : []; + $entities = $result; + if (!array_is_list($entities)) { + $entities = $result['entities'] ?? []; + } + if (!is_array($entities)) { + return []; + } + + $normalized = []; + foreach ($entities as $entity) { + if (!is_array($entity)) { + continue; + } + + $normalized[] = [ + 'entity_id' => $entity['ei'] ?? null, + 'area_id' => $entity['ai'] ?? null, + 'labels' => $this->normalizeWsLabels($entity['lb'] ?? null), + 'device_id' => $entity['di'] ?? null, + 'icon' => $entity['ic'] ?? null, + 'translation_key' => $entity['tk'] ?? null, + 'entity_category' => isset($entity['ec']) ? ($entityCategories[$entity['ec']] ?? null) : null, + 'hidden_by' => !empty($entity['hb']) ? 'true' : null, + 'name' => $entity['en'] ?? null, + 'has_entity_name' => !empty($entity['hn']), + 'platform' => $entity['pl'] ?? null, + ]; + } + + return $normalized; + } + + private function normalizeAreaRegistry(array $areas): array + { + $normalized = []; + foreach ($areas as $area) { + if (!is_array($area)) { + continue; + } + + $normalized[] = [ + 'area_id' => $area['area_id'] ?? $area['id'] ?? null, + 'name' => $area['name'] ?? $area['en'] ?? null, + 'icon' => $area['icon'] ?? $area['ic'] ?? null, + 'picture' => $area['picture'] ?? $area['pi'] ?? null, + 'floor_id' => $area['floor_id'] ?? $area['fi'] ?? $area['floor'] ?? null, + ]; + } + + return $normalized; + } + + private function normalizeFloorRegistry(array $floors): array + { + $normalized = []; + foreach ($floors as $floor) { + if (!is_array($floor)) { + continue; + } + + $normalized[] = [ + 'floor_id' => $floor['floor_id'] ?? $floor['id'] ?? null, + 'name' => $floor['name'] ?? $floor['en'] ?? null, + 'icon' => $floor['icon'] ?? $floor['ic'] ?? null, + 'level' => $floor['level'] ?? $floor['lv'] ?? null, + ]; + } + + return $normalized; + } + + private function normalizeDeviceRegistryForDisplay(array $devices): array + { + $normalized = []; + foreach ($devices as $device) { + if (!is_array($device)) { + continue; + } + + $normalized[] = [ + 'device_id' => $device['device_id'] ?? $device['id'] ?? $device['di'] ?? null, + 'area_id' => $device['area_id'] ?? $device['ai'] ?? null, + 'labels' => $this->normalizeWsLabels($device['labels'] ?? null), + 'name' => $device['name'] ?? $device['en'] ?? null, + 'manufacturer' => $device['manufacturer'] ?? $device['mf'] ?? null, + 'model' => $device['model'] ?? $device['md'] ?? null, + ]; + } + + return $normalized; + } + + private function normalizeWsLabels(mixed $labels): array + { + if ($labels === null) { + return []; + } + + if (is_string($labels) || is_numeric($labels)) { + return [(string)$labels]; + } + + if (!is_array($labels)) { + return []; + } + + return array_values(array_unique(array_map('strval', $labels))); + } + + private function readHttpHeader($stream): ?string + { + $buffer = ''; + while (!feof($stream)) { + $chunk = fgets($stream); + if ($chunk === false) { + break; + } + $buffer .= $chunk; + if (str_contains($buffer, "\r\n\r\n")) { + break; + } + } + + return $buffer !== '' ? $buffer : null; + } + + private function writeWsJson($stream, array $payload): void + { + fwrite($stream, $this->encodeWsFrame(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}')); + } + + private function readWsJson($stream): array|null + { + $payload = $this->readWsFrame($stream); + if ($payload === null || $payload === '') { + return null; + } + + $decoded = json_decode($payload, true); + return is_array($decoded) ? $decoded : null; + } + + private function encodeWsFrame(string $payload): string + { + $finOpcode = chr(0x81); + $len = strlen($payload); + $maskBit = 0x80; + $mask = random_bytes(4); + $header = ''; + + if ($len <= 125) { + $header = chr($maskBit | $len); + } elseif ($len <= 65535) { + $header = chr($maskBit | 126) . pack('n', $len); + } else { + $header = chr($maskBit | 127) . pack('J', $len); + } + + $masked = ''; + for ($i = 0; $i < $len; $i++) { + $masked .= $payload[$i] ^ $mask[$i % 4]; + } + + return $finOpcode . $header . $mask . $masked; + } + + private function readWsFrame($stream): ?string + { + $first = fread($stream, 2); + if ($first === false || strlen($first) < 2) { + return null; + } + + $bytes = array_values(unpack('C2', $first)); + $opcode = $bytes[0] & 0x0f; + $isMasked = (bool)($bytes[1] & 0x80); + $len = $bytes[1] & 0x7f; + + if ($len === 126) { + $extended = fread($stream, 2); + if ($extended === false || strlen($extended) < 2) { + return null; + } + $len = unpack('n', $extended)[1]; + } elseif ($len === 127) { + $extended = fread($stream, 8); + if ($extended === false || strlen($extended) < 8) { + return null; + } + $parts = unpack('N2', $extended); + $len = ((int)$parts[1] << 32) | (int)$parts[2]; + } + + $mask = ''; + if ($isMasked) { + $mask = fread($stream, 4); + if ($mask === false || strlen($mask) < 4) { + return null; + } + } + + $payload = ''; + while (strlen($payload) < $len && !feof($stream)) { + $chunk = fread($stream, $len - strlen($payload)); + if ($chunk === false || $chunk === '') { + break; + } + $payload .= $chunk; + } + + if ($isMasked && $mask !== '') { + $unmasked = ''; + for ($i = 0, $payloadLen = strlen($payload); $i < $payloadLen; $i++) { + $unmasked .= $payload[$i] ^ $mask[$i % 4]; + } + $payload = $unmasked; + } + + if ($opcode === 0x9) { + $this->writeWsPong($stream, $payload); + return $this->readWsFrame($stream); + } + + if ($opcode === 0x8) { + return null; + } + + return $payload; + } + + private function writeWsPong($stream, string $payload): void + { + $len = strlen($payload); + $header = chr(0x8A); + if ($len <= 125) { + $header .= chr($len); + } elseif ($len <= 65535) { + $header .= chr(126) . pack('n', $len); + } else { + $header .= chr(127) . pack('J', $len); + } + fwrite($stream, $header . $payload); + } + + private function demoData(): array + { + return [ + 'states' => [ + [ + 'entity_id' => 'light.living_room_main', + 'state' => 'on', + 'attributes' => [ + 'friendly_name' => 'Основной свет', + 'icon' => 'mdi:ceiling-light', + 'labels' => ['auto'], + ], + ], + [ + 'entity_id' => 'switch.tv_power', + 'state' => 'off', + 'attributes' => [ + 'friendly_name' => 'ТВ', + 'icon' => 'mdi:television', + 'labels' => ['auto'], + ], + ], + [ + 'entity_id' => 'cover.living_room_curtain', + 'state' => 'open', + 'attributes' => [ + 'friendly_name' => 'Штора', + 'icon' => 'mdi:curtains', + 'current_position' => 82, + ], + ], + [ + 'entity_id' => 'climate.living_room_ac', + 'state' => 'cool', + 'attributes' => [ + 'friendly_name' => 'Кондиционер', + 'icon' => 'mdi:air-conditioner', + 'temperature' => 22, + 'current_temperature' => 24.5, + 'hvac_action' => 'cooling', + 'fan_mode' => 'auto', + ], + ], + [ + 'entity_id' => 'light.kitchen_counter', + 'state' => 'off', + 'attributes' => [ + 'friendly_name' => 'Подсветка', + 'icon' => 'mdi:lightbulb', + 'labels' => ['auto'], + ], + ], + [ + 'entity_id' => 'switch.coffee_machine', + 'state' => 'on', + 'attributes' => [ + 'friendly_name' => 'Кофемашина', + 'icon' => 'mdi:coffee-maker', + 'labels' => ['auto'], + ], + ], + [ + 'entity_id' => 'weather.yandex_weather', + 'state' => 'sunny', + 'attributes' => [ + 'friendly_name' => 'Yandex Weather', + 'temperature' => 18.3, + 'humidity' => 56, + 'wind_speed' => 4.8, + ], + ], + [ + 'entity_id' => 'sensor.weather_temperature', + 'state' => '17.8', + 'attributes' => [ + 'friendly_name' => 'Weather temperature', + ], + ], + [ + 'entity_id' => 'sensor.modeco_2_temperature', + 'state' => '57.0', + 'attributes' => [ + 'friendly_name' => 'ModEco 2 Temperature', + ], + ], + ], + 'areas' => [ + ['area_id' => 'living_room', 'name' => 'Гостиная', 'icon' => 'mdi:sofa'], + ['area_id' => 'kitchen', 'name' => 'Кухня', 'icon' => 'mdi:stove'], + ], + 'entity_registry' => [ + ['entity_id' => 'light.living_room_main', 'area_id' => 'living_room', 'labels' => ['auto']], + ['entity_id' => 'switch.tv_power', 'area_id' => 'living_room', 'labels' => ['auto']], + ['entity_id' => 'cover.living_room_curtain', 'area_id' => 'living_room', 'labels' => []], + ['entity_id' => 'climate.living_room_ac', 'area_id' => 'living_room', 'labels' => []], + ['entity_id' => 'light.kitchen_counter', 'area_id' => 'kitchen', 'labels' => ['auto']], + ['entity_id' => 'switch.coffee_machine', 'area_id' => 'kitchen', 'labels' => ['auto']], + ['entity_id' => 'weather.yandex_weather', 'area_id' => null, 'labels' => []], + ['entity_id' => 'sensor.weather_temperature', 'area_id' => null, 'labels' => []], + ['entity_id' => 'sensor.modeco_2_temperature', 'area_id' => null, 'labels' => []], + ], + 'device_registry' => [], + ]; + } + + private function demoHistory(string $entityId, int $hours): array + { + if ($entityId === '') { + return []; + } + + $now = time(); + $start = $now - ($hours * 3600); + $steps = max(24, min(96, $hours * 4)); + $points = []; + $base = 57.0; + + for ($index = 0; $index <= $steps; $index++) { + $ratio = $steps > 0 ? ($index / $steps) : 1; + $timestamp = $start + (int)(($now - $start) * $ratio); + $drift = sin($ratio * M_PI * 5.5) * 0.8 + cos($ratio * M_PI * 11) * 0.35; + $value = $base + $drift; + + $points[] = [ + 'entity_id' => $entityId, + 'state' => number_format($value, 1, '.', ''), + 'last_changed' => gmdate(DATE_ATOM, $timestamp), + 'last_updated' => gmdate(DATE_ATOM, $timestamp), + ]; + } + + return [$points]; + } +} diff --git a/lib/ha_sync.php b/lib/ha_sync.php new file mode 100755 index 0000000..bf910ba --- /dev/null +++ b/lib/ha_sync.php @@ -0,0 +1,4 @@ + false, 'error' => 'entity_id is required'], 400); + } + + api_json([ + 'ok' => true, + 'entity_id' => $entityId, + 'hours' => $hours, + 'history' => $client->fetchEntityHistory($entityId, $hours), + ]); + } + + if ($action === 'service') { + $payload = api_input(); + $entityId = trim((string)($payload['entity_id'] ?? '')); + $command = trim((string)($payload['command'] ?? 'toggle')); + $value = $payload['value'] ?? null; + + if ($entityId === '') { + api_json(['ok' => false, 'error' => 'entity_id is required'], 400); + } + + [$domain, $service, $serviceData] = app_service_for_entity($entityId, $command); + if ($command === 'set_temperature' && $value !== null) { + $serviceData['temperature'] = $value; + } + if ($command === 'set_hvac_mode' && $value !== null) { + $serviceData['hvac_mode'] = $value; + } + if ($command === 'set_fan_mode' && $value !== null) { + $serviceData['fan_mode'] = $value; + } + if ($command === 'set_swing_mode' && $value !== null) { + $serviceData['swing_mode'] = $value; + } + if ($command === 'set_preset_mode' && $value !== null) { + $serviceData['preset_mode'] = $value; + } + if ($command === 'set_position' && $value !== null) { + $serviceData['position'] = $value; + } + + $result = $client->callService($domain, $service, $serviceData); + api_json(['ok' => true, 'result' => $result]); + } + + if ($action === 'save-entity-override') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $entityId = trim((string)($payload['entity_id'] ?? '')); + + if ($roomId === '' || $entityId === '') { + api_json(['ok' => false, 'error' => 'room_id and entity_id are required'], 400); + } + + $patch = [ + 'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null, + 'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null, + 'card_type' => array_key_exists('card_type', $payload) ? (string)$payload['card_type'] : null, + 'title' => array_key_exists('title', $payload) ? (string)$payload['title'] : null, + 'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null, + ]; + + $config = app_update_entity_override($config, $roomId, $entityId, $patch); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'save-space-override') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + + if ($roomId === '') { + api_json(['ok' => false, 'error' => 'room_id is required'], 400); + } + + $patch = [ + 'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null, + 'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null, + 'name' => array_key_exists('name', $payload) ? (string)$payload['name'] : null, + 'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null, + ]; + + if (array_key_exists('temperature_sensor_entity_id', $payload)) { + $patch['temperature_sensor_entity_id'] = (string)$payload['temperature_sensor_entity_id']; + } + + $config = app_update_room_override($config, $roomId, $patch); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'create-room-layout-item') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + + if ($roomId === '') { + api_json(['ok' => false, 'error' => 'room_id is required'], 400); + } + + $layoutItemId = trim((string)($payload['layout_item_id'] ?? '')); + if ($layoutItemId === '') { + $layoutItemId = 'slot_' . str_replace('.', '', uniqid('', true)); + } + + $order = array_key_exists('order', $payload) ? (int)$payload['order'] : null; + $config = app_update_room_layout_item($config, $roomId, $layoutItemId, [ + 'order' => $order, + ]); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + + api_json([ + 'ok' => true, + 'layout_item_id' => $layoutItemId, + 'config' => ['rooms' => $config['rooms']], + ]); + } + + if ($action === 'save-room-layout-item') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $layoutItemId = trim((string)($payload['layout_item_id'] ?? '')); + + if ($roomId === '' || $layoutItemId === '') { + api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400); + } + + $patch = [ + 'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null, + ]; + + $config = app_update_room_layout_item($config, $roomId, $layoutItemId, $patch); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'delete-room-layout-item') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $layoutItemId = trim((string)($payload['layout_item_id'] ?? '')); + + if ($roomId === '' || $layoutItemId === '') { + api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400); + } + + $config = app_delete_room_layout_item($config, $roomId, $layoutItemId); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'reorder-room-grid') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $entries = $payload['entries'] ?? []; + + if ($roomId === '' || !is_array($entries)) { + api_json(['ok' => false, 'error' => 'room_id and entries are required'], 400); + } + + $config = app_reorder_room_grid($config, $roomId, $entries); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'save-settings') { + $payload = api_input(); + if (array_key_exists('edit_mode', $payload)) { + $config['app']['edit_mode'] = (bool)$payload['edit_mode']; + } + if (array_key_exists('title', $payload) && trim((string)$payload['title']) !== '') { + $config['app']['title'] = trim((string)$payload['title']); + } + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); + api_json(['ok' => true, 'settings' => $config['app']]); + } + + if ($action === 'popup') { + $payload = api_input(); + $popup = app_handle_popup_event($config, $payload); + api_json(['ok' => true, 'popup' => $popup]); + } + + api_json(['ok' => false, 'error' => 'Unknown action'], 404); +} catch (Throwable $e) { + api_json([ + 'ok' => false, + 'error' => $e->getMessage(), + ], 500); +} diff --git a/wall_panel/assets/@eaDir/app.css@SynoEAStream b/wall_panel/assets/@eaDir/app.css@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/wall_panel/assets/@eaDir/app.css@SynoEAStream differ diff --git a/wall_panel/assets/@eaDir/app.js@SynoEAStream b/wall_panel/assets/@eaDir/app.js@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/wall_panel/assets/@eaDir/app.js@SynoEAStream differ diff --git a/wall_panel/assets/app.css b/wall_panel/assets/app.css new file mode 100755 index 0000000..74d54ab --- /dev/null +++ b/wall_panel/assets/app.css @@ -0,0 +1,2540 @@ +:root { + color-scheme: dark; + --bg: #090b10; + --bg-elevated: #11141b; + --surface: rgba(22, 25, 33, 0.92); + --surface-strong: #1c2028; + --surface-soft: rgba(32, 36, 46, 0.82); + --surface-bright: #eef1f7; + --text: #edf0f6; + --text-muted: rgba(237, 240, 246, 0.62); + --text-subtle: rgba(237, 240, 246, 0.42); + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.16); + --accent: #67d6ff; + --accent-2: #88f0c7; + --warning: #ffcf6d; + --danger: #ff7d7d; + --shadow: 0 16px 48px rgba(0, 0, 0, 0.34); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 18px; + --radius-sm: 14px; + --sidebar-width: 400px; + --content-pad: 32px; + --font-sans: "Manrope", "Segoe UI", sans-serif; + --font-display: "Space Grotesk", "Manrope", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + background: + radial-gradient(circle at top left, rgba(77, 110, 138, 0.15), transparent 28%), + radial-gradient(circle at bottom right, rgba(63, 168, 140, 0.12), transparent 24%), + linear-gradient(180deg, #080a0f 0%, #0d1118 100%); + color: var(--text); + overflow: hidden; +} + +body.is-embedded { + overflow: auto; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.app-shell { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + min-height: 100vh; + height: 100vh; +} + +.app-shell--embed { + grid-template-columns: clamp(250px, 22vw, 320px) minmax(0, 1fr); + height: auto; + min-height: 100vh; +} + +.sidebar { + position: relative; + padding: 24px 20px 20px 20px; + background: linear-gradient(180deg, rgba(10, 12, 17, 0.98), rgba(12, 14, 20, 0.88)); + border-right: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.app-shell--embed .sidebar { + padding: 18px 16px 16px; + border-right: 1px solid rgba(255, 255, 255, 0.05); +} + +.sidebar::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(180deg, rgba(255,255,255,0.03), transparent 12%), + linear-gradient(90deg, rgba(102, 214, 255, 0.04), transparent 24%); + mix-blend-mode: screen; +} + +.app-shell--embed .sidebar::after { + display: none; +} + +.clock-panel { + padding: 12px 8px 12px 8px; +} + +.app-shell--embed .clock-panel { + padding: 0 0 8px; +} + +.clock-panel__time { + font-family: var(--font-display); + font-size: clamp(56px, 6.4vw, 82px); + line-height: 0.94; + letter-spacing: -0.06em; + font-weight: 700; +} + +.clock-panel__date { + margin-top: 8px; + color: var(--text-muted); + font-size: 18px; + line-height: 1.1; + white-space: nowrap; +} + +.app-shell--embed .clock-panel__time { + font-size: clamp(34px, 3.8vw, 56px); +} + +.app-shell--embed .clock-panel__date { + font-size: 14px; + white-space: normal; +} + +.rooms-panel { + margin-top: 12px; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.app-shell--embed .rooms-panel { + margin-top: 8px; +} + +.panel-header, +.content-header, +.camera-modal__header, +.camera-modal__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-header { + margin-bottom: 10px; +} + +.panel-header__label { + font-size: 19px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.panel-header__sub { + margin-top: 6px; + color: var(--text-subtle); + font-size: 14px; +} + +#rooms-count:empty { + display: none; +} + +.content-header { + justify-content: space-between; + align-items: flex-start; +} + +.content-header__back { + display: none !important; + flex: 0 0 auto; +} + +.content-header > div { + min-width: 0; +} + +.content-header__actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: 16px; + flex-wrap: wrap; + justify-content: flex-end; + margin-left: auto; +} + +.content-header__ghost-button { + min-height: 40px; + padding-inline: 12px; + border-radius: 12px; +} + +.content-header__ghost-button span { + font-size: 13px; + font-weight: 700; +} + +.icon-button { + appearance: none; + border: 1px solid var(--border); + background: rgba(255,255,255,0.03); + color: var(--text); + width: 40px; + height: 40px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; +} + +.icon-button:hover { + transform: translateY(-1px); + background: rgba(255,255,255,0.08); + border-color: var(--border-strong); +} + +.icon-button.is-active { + background: rgba(103, 214, 255, 0.14); + border-color: rgba(103, 214, 255, 0.32); +} + +.icon-button--ghost { + width: 44px; + height: 44px; + border-radius: 16px; +} + +.room-list { + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; + min-height: 0; + padding-right: 6px; + padding-bottom: 6px; +} + +.app-shell--embed .room-list { + gap: 10px; + overflow-y: auto; + padding-right: 4px; + padding-bottom: 4px; +} + +.room-list__group { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.room-item { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, rgba(28, 31, 39, 0.92), rgba(20, 23, 30, 0.92)); + color: var(--text); + cursor: pointer; + text-align: left; + min-height: 132px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + transition: border-color 180ms ease, transform 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.app-shell--embed .room-item { + min-height: 94px; + padding: 7px 8px; +} + +.app-shell--embed .room-item__icon { + width: 42px; + height: 42px; +} + +.app-shell--embed .room-item__icon i { + font-size: 32px; +} + +.app-shell--embed .room-item__name { + font-size: 14px; +} + +.room-item.has-temp { + padding-right: 0px; +} + +.room-item.has-temp .room-item__body { + padding-right: 0px; +} + +.room-item:hover { + transform: translateY(-1px); + border-color: rgba(103, 214, 255, 0.24); +} + +.room-item.is-selected { + background: linear-gradient(180deg, rgba(103, 214, 255, 0.16), rgba(32, 35, 45, 0.98)); + border-color: rgba(103, 214, 255, 0.3); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255,255,255,0.05); +} + +.room-item.is-hidden-room { + opacity: 0.72; +} + +.room-item.is-main { + min-height: 132px; +} + +.room-item.is-battery-room { + background: + radial-gradient(circle at top right, rgba(255, 193, 7, 0.12), transparent 40%), + linear-gradient(180deg, rgba(32, 28, 20, 0.94), rgba(21, 20, 18, 0.95)); + border-color: rgba(255, 193, 7, 0.18); +} + +.room-item.is-battery-room .room-item__icon { + color: #ffc94d; + --icon-node-img-filter: brightness(0) saturate(100%) invert(79%) sepia(41%) saturate(909%) hue-rotate(357deg) brightness(103%) contrast(101%); +} + +.room-item.is-battery-room.is-selected { + background: + radial-gradient(circle at top right, rgba(255, 193, 7, 0.16), transparent 40%), + linear-gradient(180deg, rgba(51, 43, 20, 0.96), rgba(24, 22, 18, 0.98)); + border-color: rgba(255, 193, 7, 0.38); +} + +.room-item__icon { + width: 50px; + height: 50px; + border-radius: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); + color: var(--accent); + --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); +} + +.room-item__icon i { + font-size: 40px; + line-height: 1; +} + +.room-item__icon .icon-node, +.grid-card__icon .icon-node { + width: 40px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.icon-node__img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + filter: var(--icon-node-img-filter, none); +} + +.icon-node__svg { + width: 100%; + height: 100%; + display: block; + fill: currentColor; +} + +.room-item.is-selected .room-item__icon { + background: rgba(255,255,255,0.9); + color: #101318; + --icon-node-img-filter: brightness(0) saturate(100%); +} + +.room-item__name { + font-size: 16px; + font-weight: 700; + line-height: 1.08; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} + +.room-item__body { + min-width: 0; + width: 100%; +} + +.room-item__meta { + margin-top: 5px; + font-size: 12px; + color: var(--text-muted); +} + +.room-item__temp { + position: absolute; + top: 10px; + right: 3px; + padding: 3px 6px; + border-radius: 999px; + background: rgba(103, 214, 255, 0.12); + color: var(--accent); + border: 1px solid rgba(103, 214, 255, 0.22); + font-size: 20px; + font-weight: 700; + line-height: 1; + z-index: 1; + pointer-events: none; + white-space: nowrap; +} + +.room-item__content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + width: 100%; +} + +.room-item__mini-actions { + display: flex; + align-items: center; + gap: 6px; + justify-content: flex-start; + width: 100%; + margin-top: 0; + flex-wrap: wrap; +} + +.room-item.is-editing .room-item__mini-actions { + display: flex; +} + +.mini-action { + width: 30px; + height: 30px; + border-radius: 11px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.05); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.mini-action--wide { + width: 32px; + height: 32px; +} + +.room-list__divider { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 2px 2px; + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 11px; + grid-column: 1 / -1; +} + +.room-list__divider::before, +.room-list__divider::after { + content: ""; + height: 1px; + background: rgba(255,255,255,0.08); + flex: 1; +} + +.room-list__divider-label { + flex: 0 0 auto; +} + +.content { + position: relative; + padding: var(--content-pad); + overflow: auto; +} + +.app-shell--embed .content { + padding: 22px 16px 20px; +} + +.content-top { + display: none; + margin-bottom: 16px; + margin-top: -30px; +} + +.app-shell--embed .content-top { + margin-top: 0; +} + +.content-top.is-main { + display: block; +} + +.content-header { + min-height: 72px; + margin-bottom: 16px; + justify-content: flex-start; +} + +.app-shell--embed .content-header { + min-height: auto; + margin-bottom: 12px; +} + +.content-header.is-main .content-header__eyebrow, +.content-header.is-main .content-header__meta { + display: none; +} + +.content-header.is-main .content-header__title { + margin-top: 0; +} + +.content-header__eyebrow { + color: var(--text-muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.16em; +} + +.content-header__title { + margin: 6px 0 0; + font-family: var(--font-display); + font-size: clamp(30px, 3.4vw, 48px); + line-height: 1.02; + letter-spacing: -0.05em; +} + +.app-shell--embed .content-header__title { + font-size: clamp(24px, 2.5vw, 40px); +} + +.content-header__meta { + margin-top: 10px; + color: var(--text-muted); + font-size: 15px; +} + +.dashboard-grid { + position: relative; + width: 100%; +} + +.grid-surface { + display: grid; + gap: 18px; + width: 100%; +} + +.grid-card { + position: relative; + overflow: hidden; + border-radius: var(--radius-xl); + border: 1px solid var(--border); + background: linear-gradient(180deg, rgba(29, 33, 41, 0.96), rgba(18, 20, 27, 0.94)); + box-shadow: var(--shadow); + min-height: 120px; +} + +.grid-card.is-hidden { + opacity: 0.52; + border-style: dashed; + filter: saturate(0.8); +} + +.grid-card--ghost { + background: transparent; + border-color: transparent; + box-shadow: none; + min-height: 120px; +} + +.grid-card--ghost .grid-card__inner { + justify-content: space-between; + gap: 10px; +} + +.grid-card--ghost .grid-card__header { + gap: 8px; +} + +.grid-card--ghost .grid-card__icon { + background: rgba(255,255,255,0.02); + border-color: rgba(255,255,255,0.05); + color: rgba(237, 240, 246, 0.42); +} + +.grid-card--ghost .grid-card__title { + font-size: 18px; +} + +.grid-card--ghost .grid-card__subtitle { + margin-top: 2px; + font-size: 13px; +} + +.grid-card--ghost .grid-card__footer--edit { + margin-top: auto; +} + +.grid-card--ghost.is-editing { + background: + radial-gradient(circle at top left, rgba(103, 214, 255, 0.08), transparent 35%), + rgba(255, 255, 255, 0.01); + border-color: rgba(103, 214, 255, 0.18); + border-style: dashed; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} + +.grid-card--ghost.is-editing .grid-card__title-line, +.grid-card--ghost.is-editing .grid-card__subtitle { + color: var(--text-muted); +} + +.grid-card--ghost.is-editing .grid-card__icon { + color: var(--accent); +} + +.main-dashboard__cards .grid-card--door { + transition: background 220ms ease, border-color 220ms ease, box-shadow 220ms ease, transform 220ms ease, filter 220ms ease; +} + +.main-dashboard__cards .grid-card--door.is-active { + background: + radial-gradient(circle at top right, rgba(160, 221, 255, 0.16), transparent 42%), + linear-gradient(180deg, rgba(44, 50, 61, 0.98), rgba(22, 26, 34, 0.98)); + border-color: rgba(160, 221, 255, 0.3); + box-shadow: + 0 18px 36px rgba(0, 0, 0, 0.26), + inset 0 1px 0 rgba(255,255,255,0.05); + animation: door-card-pulse 3.2s ease-in-out infinite; +} + +.main-dashboard__cards .grid-card--door.is-active::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(circle at 50% 0%, rgba(160, 221, 255, 0.14), transparent 58%); + opacity: 0.35; + animation: door-card-glow 3.2s ease-in-out infinite; +} + +@keyframes door-card-pulse { + 0%, 100% { + transform: translateY(0); + filter: brightness(1); + } + 50% { + transform: translateY(-1px); + filter: brightness(1.03); + } +} + +@keyframes door-card-glow { + 0%, 100% { + opacity: 0.24; + } + 50% { + opacity: 0.48; + } +} + +.main-dashboard__cards .grid-card--door .grid-card__icon { + transform-style: preserve-3d; +} + +.main-dashboard__cards .grid-card--door.is-active .grid-card__icon > * { + display: inline-flex; + transform-origin: 50% 60%; + animation: door-icon-swing 2.8s ease-in-out infinite; + will-change: transform; +} + +@keyframes door-icon-swing { + 0%, 100% { + transform: perspective(90px) rotateY(0deg) rotateZ(0deg) scale(1); + } + 22% { + transform: perspective(90px) rotateY(-26deg) rotateZ(-2deg) scale(1.04); + } + 44% { + transform: perspective(90px) rotateY(0deg) rotateZ(0deg) scale(1); + } + 68% { + transform: perspective(90px) rotateY(22deg) rotateZ(2deg) scale(0.98); + } + 84% { + transform: perspective(90px) rotateY(0deg) rotateZ(0deg) scale(1); + } +} + +.room-entities-section__grid .grid-card.is-active { + background: linear-gradient(180deg, rgb(146 154 170 / 98%), rgb(44 47 53 / 96%)); + border-color: rgba(160, 221, 255, 0.28); + box-shadow: + 0 16px 32px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.room-entities-section__grid .grid-card.is-active .grid-card__title, +.room-entities-section__grid .grid-card.is-active .grid-card__title-line { + color: var(--text); +} + +.room-entities-section__grid .grid-card.is-active .grid-card__subtitle, +.room-entities-section__grid .grid-card.is-active .grid-card__kind { + color: rgba(255, 255, 255, 0.68); +} + +.room-entities-section__grid .grid-card.is-active .grid-card__icon { + background: rgba(255,255,255,0.08); + border-color: rgba(160, 221, 255, 0.18); + color: var(--accent); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} + +.grid-card--weather { + grid-column: 1 / span 3; +} + +.grid-card--auto { + grid-column: span 3; +} + +.grid-card--entity { + grid-column: span 3; +} + +.grid-card--entity-wide { + grid-column: span 3; +} + +.grid-card--cover { + grid-column: span 3; +} + +.grid-card--climate { + grid-column: span 3; +} + +.grid-card--full { + grid-column: 1 / -1; +} + +.grid-card__inner { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; + padding: 14px; +} + +.grid-card__top { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 12px; +} + +.grid-card__icon { + width: 42px; + height: 42px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.06); + color: var(--accent); + font-size: 22px; + flex: 0 0 auto; + --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); +} + +.grid-card__icon--active { + background: rgba(255,255,255,0.92); + color: #0f1115; + --icon-node-img-filter: brightness(0) saturate(100%); +} + +.grid-card__title { + font-size: 22px; + font-weight: 700; + line-height: 1.08; + display: flex; + flex-direction: column; + gap: 2px; +} + +.grid-card__title_weather{ + font-size: 40px; + font-weight: 700; + line-height: 1.08; + display: flex; + flex-direction: column; + gap: 2px; +} + +.grid-card__title-line { + display: block; +} + +.grid-card__subtitle { + margin-top: 6px; + color: var(--text-muted); + font-size: 20px; +} + +.grid-card__kind { + margin-top: 2px; + color: var(--text-subtle); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.grid-card__header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + min-width: 0; + flex: 1 1 auto; + width: 100%; +} + +.grid-card--tap { + cursor: pointer; + touch-action: manipulation; +} + +.grid-card--tap:focus-visible, +.room-item:focus-visible, +.icon-button:focus-visible, +.mushroom-button:focus-visible, +.round-button:focus-visible, +.mini-action:focus-visible { + outline: 2px solid rgba(103, 214, 255, 0.6); + outline-offset: 2px; +} + +.room-item.is-dragging { + opacity: 0.55; + transform: scale(0.985); + cursor: grabbing; +} + +.room-item.is-editing { + cursor: grab; + user-select: none; +} + +.grid-card__footer { + display: grid; + gap: 8px; + margin-top: auto; +} + +.grid-card__footer-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.grid-card__footer--edit { + padding-top: 6px; +} + +.grid-card__footer--edit .mushroom-button { + width: 100%; + min-height: 42px; + padding: 8px 10px; + border-radius: 12px; + font-size: 12px; + gap: 6px; +} + +.grid-card__footer--edit .mushroom-button--wide { + grid-column: 1 / -1; +} + +.grid-card__footer--edit .mushroom-button--small { + min-height: 42px; +} + +.grid-card__layout-settings { + display: grid; + gap: 10px; + margin-top: 2px; +} + +.grid-card__layout-settings .mushroom-button { + width: 100%; +} + +.mushroom-button { + min-height: 48px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.04); + color: var(--text); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, color 180ms ease; +} + +.mushroom-button:hover { + transform: translateY(-1px); + border-color: rgba(103, 214, 255, 0.24); +} + +.mushroom-button.is-on { + background: var(--surface-bright); + color: #101318; + border-color: rgba(255,255,255,0.55); +} + +.mushroom-button.is-disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.mushroom-button__icon { + font-size: 20px; + line-height: 1; +} + +.mushroom-button__body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + text-align: left; +} + +.mushroom-button--square .mushroom-button__body { + align-items: center; + text-align: center; +} + +.mushroom-button__title { + font-size: 14px; + font-weight: 700; +} + +.mushroom-button__subtitle { + color: inherit; + opacity: 0.7; + font-size: 12px; +} + +.mushroom-button--small { + min-height: 40px; + padding: 8px 10px; + border-radius: 12px; +} + +.mushroom-button--wide { + width: 100%; +} + +.mushroom-button--square { + width: 80px; + min-height: 80px; + padding: 10px 8px; + flex-direction: column; + gap: 6px; + text-align: center; +} + +.weather-card { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.weather-card--compact { + height: 100%; + justify-content: space-between; +} + +.weather-card__rows { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weather-card__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); +} + +.weather-card__row-label { + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.weather-card__row-value { + font-weight: 800; + font-size: 22px; + color: var(--text); +} + +.stat-row { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 12px 14px; + border-radius: 16px; + background: rgba(255,255,255,0.04); +} + +.stat-row__label { + color: var(--text-muted); +} + +.stat-row__value { + font-weight: 700; +} + +.climate-card { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; +} + +.climate-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + width: 100%; +} + +.climate-card__meta { + display: grid; + justify-items: end; + text-align: right; + gap: 2px; + flex: 0 0 auto; + min-width: 72px; +} + +.climate-card__meta-target { + font-family: var(--font-display); + font-size: clamp(24px, 2.8vw, 32px); + line-height: 1; + letter-spacing: -0.06em; +} + +.climate-card__meta-current { + color: var(--text-muted); + font-size: 11px; + line-height: 1.1; +} + +.climate-card .grid-card__header { + flex: 1 1 auto; +} + +.climate-card__buttons { + display: flex; + gap: 10px; + justify-content: center; +} + +.round-button { + width: 44px; + height: 44px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.04); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.cover-card { + display: flex; + flex-direction: column; + gap: 10px; +} + +.cover-card__rail { + display: grid; + gap: 8px; +} + +.cover-progress { + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: rgba(255,255,255,0.08); +} + +.cover-progress__value { + height: 100%; + background: linear-gradient(90deg, #66d6ff, #88f0c7); + border-radius: inherit; +} + +.cover-card__buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255,255,255,0.05); + color: var(--text-muted); + font-size: 12px; +} + +.loading-card { + grid-column: 1 / -1; + padding: 28px; + border-radius: var(--radius-xl); + background: rgba(255,255,255,0.04); + border: 1px solid var(--border); + color: var(--text-muted); +} + +.modal-backdrop { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 22px; + background: rgba(4, 6, 10, 0.72); + backdrop-filter: blur(12px); + z-index: 40; +} + +.modal-backdrop.is-open { + display: flex; +} + +body.is-mobile-ui #camera-modal { + display: none !important; +} + +.camera-modal { + position: relative; + width: calc(100vw - 24px); + height: calc(100vh - 24px); + max-width: 1600px; + max-height: none; + border-radius: 22px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(18, 20, 27, 0.98), rgba(10, 12, 17, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.camera-modal__close { + position: fixed; + top: 18px; + right: 18px; + z-index: 9999; + background: rgba(15, 18, 24, 0.88); + border-color: rgba(255,255,255,0.12); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35); + color: var(--text); + backdrop-filter: blur(10px); + pointer-events: auto; + touch-action: manipulation; +} + +.camera-modal__body { + flex: 1 1 auto; + min-height: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.camera-stage { + position: relative; + flex: 1 1 auto; + min-height: 0; + border-radius: 0; + overflow: hidden; + background: #0b0d11; + border: 1px solid rgba(255,255,255,0.06); +} + +.camera-stage__poster, +.camera-stage iframe, +.camera-stage video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border: 0; + background: #0b0d11; +} + +.camera-stage__poster { + z-index: 1; +} + +.camera-stage iframe, +.camera-stage video { + z-index: 2; + opacity: 0; + transition: opacity 180ms ease; +} + +.camera-stage iframe.is-ready, +.camera-stage video.is-ready { + opacity: 1; +} + +.camera-stage__placeholder { + position: absolute; + inset: 0; + display: none; + place-items: center; + text-align: center; + padding: 24px; + background: linear-gradient(180deg, rgba(13,16,22,0.08), rgba(13,16,22,0.38)); + backdrop-filter: blur(2px); + pointer-events: none; + z-index: 3; +} + +.camera-stage__placeholder.is-visible { + display: grid; +} + +.camera-stage__placeholder-icon { + width: 86px; + height: 86px; + border-radius: 30px; + display: grid; + place-items: center; + background: rgba(255,255,255,0.06); + font-size: 40px; + color: var(--accent); +} + +.camera-stage__placeholder-title { + margin-top: 16px; + font-size: 22px; + font-weight: 700; +} + +.camera-stage__placeholder-subtitle { + margin-top: 8px; + color: var(--text-muted); + max-width: 420px; +} + +.camera-modal__footer { + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 8px; + color: var(--text-muted); + padding: 12px 18px 16px; + min-height: 56px; +} + +.camera-modal__countdown { + font-weight: 700; + color: var(--text); + min-height: 1.2em; + line-height: 1.2; + display: block; + white-space: nowrap; +} + +.entity-modal { + width: min(78vw, 1080px); + max-height: 90vh; + border-radius: 30px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 22, 29, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.temperature-sensor-modal { + width: min(62vw, 760px); + max-height: 84vh; + border-radius: 30px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 22, 29, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.temperature-sensor-modal__header { + padding: 18px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.temperature-sensor-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + min-height: 14px; +} + +.temperature-sensor-modal__title { + margin-top: 5px; + font-size: 18px; + font-weight: 700; +} + +.temperature-sensor-modal__body { + padding: 18px; + display: grid; + gap: 14px; + overflow: auto; +} + +.temperature-sensor-modal__current { + display: grid; + gap: 8px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); +} + +.temperature-sensor-modal__current-label { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 11px; +} + +.temperature-sensor-modal__current-value { + font-size: 16px; + font-weight: 700; +} + +.temperature-sensor-modal__empty { + color: var(--text-muted); + padding: 20px 0; +} + +.temperature-sensor-modal__list { + display: grid; + gap: 10px; +} + +.temperature-sensor-modal__option { + width: 100%; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.04); + color: var(--text); + border-radius: 18px; + padding: 12px 14px; + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + cursor: pointer; + text-align: left; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; +} + +.temperature-sensor-modal__option:hover { + transform: translateY(-1px); + background: rgba(255,255,255,0.06); + border-color: var(--border-strong); +} + +.temperature-sensor-modal__option.is-active { + background: rgba(103, 214, 255, 0.14); + border-color: rgba(103, 214, 255, 0.28); +} + +.temperature-sensor-modal__option-main { + display: grid; + gap: 4px; + min-width: 0; +} + +.temperature-sensor-modal__option-name { + font-weight: 700; + line-height: 1.15; +} + +.temperature-sensor-modal__option-meta { + color: var(--text-muted); + font-size: 13px; + line-height: 1.2; +} + +.temperature-sensor-modal__option-value { + color: var(--accent); + font-weight: 800; + white-space: nowrap; +} + +.entity-modal__header { + padding: 18px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.entity-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + min-height: 14px; +} + +.entity-modal__title { + margin-top: 5px; + font-size: 18px; + font-weight: 700; +} + +.entity-modal__body { + padding: 18px; + display: grid; + gap: 18px; + overflow: auto; +} + +.entity-modal__fallback { + color: var(--text-muted); + padding: 20px 0; +} + +.entity-modal__cover, +.entity-modal__climate { + display: grid; + gap: 16px; +} + +.entity-modal__cover { + grid-template-columns: minmax(0, 1fr) 118px; + align-items: stretch; +} + +.entity-modal__rail { + display: grid; + gap: 14px; + min-width: 0; +} + +.entity-modal__rail--cover { + grid-template-rows: auto minmax(0, 1fr) auto; + justify-items: center; + align-content: stretch; + gap: 12px; +} + +.entity-modal__cover-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.entity-modal__cover-label, +.entity-modal__current-label, +.entity-modal__options-title { + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.entity-modal__cover-value, +.entity-modal__current-value { + font-size: 18px; + font-weight: 800; + color: var(--text); +} + +.entity-modal__cover-track { + position: relative; + justify-self: center; + width: 56px; + min-height: 280px; + border-radius: 999px; + background: + radial-gradient(circle at 50% 6%, rgba(255,255,255,0.10), transparent 28%), + linear-gradient(180deg, rgba(18, 20, 27, 0.96), rgba(10, 12, 17, 0.98)); + overflow: hidden; + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.06), + inset 0 -18px 28px rgba(0, 0, 0, 0.26); + touch-action: none; + user-select: none; + cursor: ns-resize; +} + +.entity-modal__cover-fill { + position: absolute; + left: 0; + right: 0; + bottom: 0; + border-radius: inherit; + background: + linear-gradient(180deg, rgba(103, 214, 255, 0.96) 0%, rgba(126, 236, 220, 0.94) 58%, rgba(188, 255, 242, 0.96) 100%); + box-shadow: + 0 10px 26px rgba(103, 214, 255, 0.14), + inset 0 1px 0 rgba(255,255,255,0.20); + width: 100%; + height: 0%; +} + +.entity-modal__cover-handle { + position: absolute; + left: 50%; + bottom: -10px; + width: 36px; + height: 20px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.18); + background: + linear-gradient(180deg, rgba(255,255,255,0.98), rgba(228,236,244,0.9)); + box-shadow: + 0 10px 22px rgba(0, 0, 0, 0.26), + inset 0 1px 0 rgba(255,255,255,0.7); + transform: translateX(-50%); + z-index: 2; + pointer-events: none; +} + +.entity-modal__actions--vertical { + display: grid; + grid-auto-rows: minmax(52px, auto); + gap: 10px; + align-content: center; + justify-self: stretch; +} + +.entity-modal__actions--vertical .mushroom-button--square { + width: 100%; + min-height: 72px; +} + +.entity-modal__actions--vertical .mushroom-button__body { + gap: 2px; +} + +.entity-modal__actions--vertical .mushroom-button__icon { + font-size: 18px; +} + +.entity-modal__actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.entity-modal__actions .mushroom-button--square { + width: 100%; + min-height: 96px; +} + +.entity-modal__actions .mushroom-button__title { + line-height: 1.05; +} + +.entity-modal__actions.entity-modal__actions--vertical { + grid-template-columns: 1fr; + grid-auto-rows: minmax(52px, auto); + align-content: center; +} + +.entity-modal__actions.entity-modal__actions--vertical .mushroom-button--square { + min-height: 72px; +} + +.entity-modal__climate-summary { + display: grid; + gap: 8px; + justify-items: center; + text-align: center; + padding-top: 8px; +} + +.entity-modal__target-row { + display: flex; + align-items: baseline; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +.entity-modal__target-state { + color: var(--text-muted); + font-size: 17px; + font-weight: 700; +} + +.entity-modal__target-temp { + font-family: var(--font-display); + font-size: clamp(44px, 6vw, 72px); + line-height: 1; + letter-spacing: -0.06em; +} + +.entity-modal__target-temp span { + font-size: 0.36em; + vertical-align: top; +} + +.entity-modal__temperature-controls { + display: flex; + justify-content: center; + gap: 14px; +} + +.entity-modal__round-button { + width: 54px; + height: 54px; +} + +.entity-modal__modes { + display: grid; + gap: 12px; +} + +.entity-modal__options-block { + display: grid; + gap: 8px; +} + +.entity-modal__chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.entity-chip { + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.04); + color: var(--text); + border-radius: 999px; + padding: 8px 12px; + min-height: 36px; + cursor: pointer; + text-align: center; + line-height: 1.05; +} + +.entity-chip.is-active { + background: rgba(103, 214, 255, 0.16); + border-color: rgba(103, 214, 255, 0.28); + color: var(--text); +} + +.confirm-modal { + width: min(420px, calc(100vw - 36px)); + border-radius: 28px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 23, 30, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + padding: 22px; +} + +.confirm-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.confirm-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; +} + +.confirm-modal__title { + margin-top: 6px; + font-size: 20px; + font-weight: 800; + line-height: 1.08; +} + +.confirm-modal__body { + margin-top: 14px; + color: var(--text-muted); + font-size: 15px; + line-height: 1.4; +} + +.confirm-modal__footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.confirm-modal .mushroom-button { + min-width: 100px; +} + +.hidden { + display: none !important; +} + +.main-dashboard { + display: grid; + gap: 18px; + grid-column: 1 / -1; + width: 100%; +} + +.main-dashboard__hero { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 18px; + align-items: stretch; +} + +.main-dashboard__weather-slot { + min-width: 0; + display: flex; + align-self: stretch; +} + +.main-dashboard__weather-slot .grid-card--weather { + width: 100%; + max-width: none; + grid-column: auto; + height: 100%; + min-height: 100%; +} + +.main-dashboard__actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + align-items: start; + min-width: 0; + align-self: start; +} + +.main-dashboard__hero-stack { + display: grid; + gap: 14px; + min-width: 0; + align-self: start; + align-content: start; +} + +.main-print-strip { + width: 100%; + min-height: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.main-print-strip__inner { + display: grid; + gap: 6px; + padding: 0 2px 0; +} + +.main-print-strip__header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-height: 16px; +} + +.main-print-strip__badge { + flex: 0 0 auto; + padding: 2px 8px; + border-radius: 999px; + background: rgba(103, 214, 255, 0.14); + border: 1px solid rgba(103, 214, 255, 0.22); + color: var(--accent); + font-size: 11px; + font-weight: 800; + line-height: 1; +} + +.main-print-strip__progress { + height: 6px; + border-radius: 999px; + overflow: hidden; + background: rgba(255,255,255,0.08); +} + +.main-print-strip__progress-fill { + width: 0%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #67d6ff, #88f0c7); +} + +.main-print-strip__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-height: 14px; +} + +.main-print-strip__remaining { + color: var(--text-muted); + font-size: 12px; + font-weight: 700; + line-height: 1; + white-space: nowrap; + letter-spacing: 0.02em; +} + +.main-quick-action { + min-height: 0; + padding: 12px 10px 11px; + border-radius: 20px; + border: 1px solid rgba(0, 0, 0, 0.04); + background: var(--quick-action-bg, rgba(255, 255, 255, 0.08)); + color: var(--quick-action-color, var(--text)); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.14); + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 6px; + text-align: center; + cursor: pointer; + transition: transform 180ms ease, box-shadow 180ms ease, filter 180ms ease; + position: relative; + overflow: hidden; +} + +.main-quick-action:hover { + transform: translateY(-1px); + filter: saturate(1.03); +} + +.main-quick-action.is-active { + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22); +} + +.main-quick-action__icon { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--quick-action-icon-color, currentColor); + flex: 0 0 auto; +} + +.main-quick-action__icon i { + font-size: 20px; + line-height: 1; +} + +.main-quick-action__icon .icon-node, +.main-quick-action__icon .icon-node__img, +.main-quick-action__icon .icon-node__svg { + width: 100%; + height: 100%; +} + +.main-quick-action__label { + font-size: 13px; + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.01em; + word-break: break-word; +} + +.main-dashboard__hero-spacer { + flex: 1 1 auto; + min-height: 1px; + border-radius: var(--radius-xl); +} + +.main-boiler-card { + min-height: 112px; + border-color: rgba(255, 186, 92, 0.18); + background: + radial-gradient(circle at top right, rgba(255, 186, 92, 0.14), transparent 42%), + linear-gradient(180deg, rgba(37, 31, 24, 0.98), rgba(19, 20, 27, 0.96)); +} + +.main-boiler-card__inner { + display: grid; + gap: 6px; + padding: 10px 12px 10px; +} + +.main-boiler-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.main-boiler-card__text { + display: grid; + gap: 2px; +} + +.main-boiler-card__eyebrow { + color: rgba(255, 214, 160, 0.72); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.main-boiler-card__body { + display: grid; + grid-template-columns: minmax(86px, 96px) minmax(0, 1fr); + gap: 8px; + align-items: center; + min-width: 0; +} + +.main-boiler-card__value-column { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + min-width: 0; +} + +.main-boiler-card__range { + flex: 0 0 auto; + padding: 4px 8px; + border-radius: 999px; + background: rgba(255, 186, 92, 0.11); + color: rgba(255, 214, 160, 0.9); + font-size: 11px; + font-weight: 700; +} + +.main-boiler-card__value-row { + display: flex; + align-items: baseline; + gap: 6px; +} + +.main-boiler-card__value-label { + color: rgba(255, 214, 160, 0.7); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.main-boiler-card__value { + color: var(--text); + font-family: var(--font-display); + font-size: clamp(22px, 2.4vw, 28px); + line-height: 0.98; + letter-spacing: -0.06em; +} + +.main-boiler-card__unit { + color: rgba(255, 255, 255, 0.72); + font-size: 12px; + font-weight: 700; + transform: translateY(-2px); +} + +.main-boiler-card__chart-wrap { + position: relative; + min-height: 60px; + border-radius: 14px; + overflow: hidden; + background: linear-gradient(180deg, rgba(255, 186, 92, 0.04), rgba(255, 186, 92, 0.01)); +} + +.main-boiler-card__chart { + width: 100%; + height: 100%; + display: block; +} + +.main-boiler-card__loading { + position: absolute; + left: 10px; + bottom: 6px; + color: rgba(255, 214, 160, 0.72); + font-size: 12px; + font-weight: 600; +} + +.main-dashboard__cards { + width: 100%; + grid-template-columns: repeat(15, minmax(0, 1fr)); +} + +.main-dashboard__cards .grid-card { + grid-column: span 3; + width: 100%; + aspect-ratio: 1 / 1; + min-height: 0; +} + +.main-dashboard__cards .grid-card__inner { + padding: 12px; +} + +.main-dashboard__cards .grid-card--cover { + justify-self: stretch; + max-width: none; +} + +.room-entities-section { + display: grid; + gap: 14px; + width: 100%; + grid-column: 1 / -1; +} + +.room-entities-section + .room-entities-section { + margin-top: 16px; +} + +.room-entities-section__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + padding: 0 4px; +} + +.room-entities-section__title { + color: var(--text-subtle); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.room-entities-section__meta { + color: var(--text-subtle); + font-size: 12px; +} + +.room-entities-section--hidden { + padding-top: 16px; + border-top: 1px solid rgba(255,255,255,0.07); +} + +.room-entities-section--hidden .room-entities-section__title { + color: var(--warning); +} + +.battery-room { + display: grid; + gap: 14px; +} + +.battery-room__header { + align-items: flex-start; +} + +.battery-room__list { + display: grid; + gap: 12px; +} + +.battery-room__empty { + grid-column: 1 / -1; +} + +.battery-card { + min-height: 96px; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(255,255,255,0.04), transparent 34%), + linear-gradient(180deg, rgba(27, 30, 37, 0.98), rgba(18, 20, 26, 0.98)); + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.battery-card--critical, +.battery-card--empty { + border-color: rgba(255, 125, 125, 0.3); +} + +.battery-card--low { + border-color: rgba(255, 191, 84, 0.28); +} + +.battery-card--unavailable { + border-color: rgba(237, 240, 246, 0.14); +} + +.battery-card--unknown { + border-color: rgba(103, 214, 255, 0.18); +} + +.battery-card--ok { + border-color: rgba(136, 240, 199, 0.14); +} + +.battery-card__inner { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 16px 18px; +} + +.battery-card__main { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.battery-card__icon { + width: 46px; + height: 46px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.05); + color: #ffc94d; + flex: 0 0 auto; +} + +.battery-card--critical .battery-card__icon, +.battery-card--empty .battery-card__icon { + color: #ff7d7d; +} + +.battery-card--low .battery-card__icon { + color: #ffbf54; +} + +.battery-card--unavailable .battery-card__icon { + color: rgba(237, 240, 246, 0.52); +} + +.battery-card--unknown .battery-card__icon { + color: var(--accent); +} + +.battery-card--ok .battery-card__icon { + color: var(--accent-2); +} + +.battery-card__text { + min-width: 0; +} + +.battery-card__title { + font-size: 18px; + font-weight: 800; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.battery-card__source { + margin-top: 5px; + color: var(--text-muted); + font-size: 13px; +} + +.battery-card__side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + justify-content: center; + text-align: right; +} + +.battery-card__percent { + font-size: 26px; + line-height: 1; + font-weight: 800; + color: var(--text); +} + +.battery-card--critical .battery-card__percent, +.battery-card--empty .battery-card__percent { + color: #ff7d7d; +} + +.battery-card--low .battery-card__percent { + color: #ffbf54; +} + +.battery-card--unavailable .battery-card__percent, +.battery-card--unknown .battery-card__percent { + color: var(--text-muted); +} + +.battery-card--ok .battery-card__percent { + color: var(--accent-2); +} + +.battery-card__status { + color: var(--text-subtle); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.battery-card__footer { + grid-column: 1 / -1; + margin-top: -2px; + padding-left: 60px; + color: var(--text-muted); + font-size: 13px; +} + +.room-entities-section__grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); + width: 100%; + align-items: stretch; +} + +.room-entities-section__grid .grid-card--entity, +.room-entities-section__grid .grid-card--entity-wide, +.room-entities-section__grid .grid-card--cover, +.room-entities-section__grid .grid-card--climate, +.room-entities-section__grid .grid-card--auto, +.room-entities-section__grid .grid-card--ghost { + grid-column: span 1; +} + +.room-entities-section__grid--hidden .grid-card { + opacity: 0.86; + border-style: dashed; +} + +.room-entities-section__grid--hidden .grid-card__footer--edit .mushroom-button { + background: rgba(255,255,255,0.03); +} + +.dashboard-grid > .grid-surface { + grid-template-columns: repeat(15, minmax(0, 1fr)); + width: 100%; +} + +@media (max-width: 1200px) { + .app-shell { + grid-template-columns: 230px 1fr; + } + + .main-dashboard__hero { + grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); + } + + .main-boiler-card { + min-height: 108px; + } + + .main-quick-action { + padding: 10px 10px 9px; + border-radius: 18px; + } + + .main-quick-action__label { + font-size: 12px; + } + + .grid-card--weather { + grid-column: 1 / -1; + } + + .grid-card--auto, + .grid-card--entity, + .grid-card--entity-wide, + .grid-card--cover, + .grid-card--climate { + grid-column: span 3; + } +} + +@media (max-width: 920px) { + body { + overflow: hidden; + } + + .app-shell { + grid-template-columns: 1fr; + height: 100dvh; + min-height: 100dvh; + } + + .app-shell.is-mobile { + overflow: hidden; + } + + .app-shell.is-mobile .sidebar, + .app-shell.is-mobile .content { + min-height: 0; + height: 100%; + } + + .app-shell.is-mobile .sidebar { + overflow: auto; + border-right: 0; + border-bottom: 1px solid rgba(255,255,255,0.05); + padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); + } + + .app-shell.is-mobile .content { + overflow: auto; + padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); + } + + .app-shell.is-mobile.mobile-view-spaces .content { + display: none; + } + + .app-shell.is-mobile.mobile-view-room .sidebar { + display: none; + } + + .app-shell.is-mobile .content-header { + align-items: center; + gap: 12px; + min-height: 0; + margin-bottom: 14px; + } + + .app-shell.is-mobile .content-header__back { + display: inline-flex !important; + } + + .app-shell.is-mobile .content-header__title { + margin-top: 0; + font-size: clamp(26px, 7vw, 36px); + } + + .app-shell.is-mobile .content-header__meta { + font-size: 13px; + } + + .app-shell.is-mobile .room-item.is-selected { + background: linear-gradient(180deg, rgba(28, 31, 39, 0.92), rgba(20, 23, 30, 0.92)); + border-color: var(--border); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + } + + .app-shell.is-mobile .room-item.is-selected .room-item__icon { + background: rgba(255,255,255,0.04); + color: var(--accent); + --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); + } + + .app-shell.is-mobile .content-top { + margin-top: -12px; + } + + .main-dashboard__hero { + grid-template-columns: 1fr; + } + + .main-dashboard__hero-stack { + gap: 12px; + } + + .main-dashboard__actions { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + } + + .main-quick-action { + min-height: 76px; + } + + .grid-card--weather { + grid-column: 1 / -1; + } + + .main-dashboard__cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .main-dashboard__cards .grid-card { + grid-column: span 1; + } + + .room-entities-section__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .room-entities-section__grid .grid-card--entity, + .room-entities-section__grid .grid-card--entity-wide, + .room-entities-section__grid .grid-card--cover, + .room-entities-section__grid .grid-card--climate, + .room-entities-section__grid .grid-card--auto, + .room-entities-section__grid .grid-card--ghost { + grid-column: span 1; + } + + .grid-card--ghost { + display: none; + } + + .main-quick-action { + min-height: 72px; + } + + .main-boiler-card { + min-height: 106px; + } + + .main-boiler-card__body { + grid-template-columns: minmax(82px, 92px) minmax(0, 1fr); + gap: 6px; + } + + .camera-modal { + width: calc(100vw - 16px); + height: calc(100vh - 16px); + border-radius: 18px; + } + + .entity-modal { + width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + border-radius: 24px; + } + + .temperature-sensor-modal { + width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + border-radius: 24px; + } + + .entity-modal__body { + padding: 16px; + gap: 14px; + } + + .entity-modal__cover { + grid-template-columns: minmax(0, 1fr) 88px; + gap: 12px; + } + + .entity-modal__cover-track { + width: 44px; + min-height: 240px; + border-radius: 999px; + } + + .entity-modal__cover-handle { + width: 30px; + height: 18px; + } + + .entity-modal__actions--vertical { + grid-auto-rows: minmax(48px, auto); + gap: 8px; + } +} diff --git a/wall_panel/assets/app.js b/wall_panel/assets/app.js new file mode 100755 index 0000000..7538504 --- /dev/null +++ b/wall_panel/assets/app.js @@ -0,0 +1,4553 @@ +(function () { + const bootstrap = window.APP_BOOTSTRAP || {}; + const MOBILE_BREAKPOINT = 920; + const state = { + snapshot: bootstrap, + embedMode: Boolean(bootstrap?.ui?.embed), + selectedRoomId: 'main', + isMobileViewport: false, + mobileView: 'spaces', + editMode: Boolean(bootstrap?.settings?.edit_mode), + clockTimer: null, + hlsInstance: null, + popupDismissTimer: null, + popupAutoOpenBlockedUntil: 0, + roomAutoReturnTimer: null, + mainBoilerHistory: { + entityId: null, + points: [], + loadedAt: 0, + loading: false, + error: null, + promise: null, + }, + entityPopup: { + active: false, + entityId: null, + }, + temperatureSensorPopup: { + active: false, + roomId: null, + }, + lastPopupSignature: '', + lastEntityPopupSignature: '', + lastTemperatureSensorPopupSignature: '', + roomDrag: null, + layoutItemSettingsOpen: {}, + confirmResolver: null, + haSocket: null, + haSocketState: 'disconnected', + haReconnectTimer: null, + haReconnectDelay: 1000, + haSubscribeId: 1, + roomSelectionToken: 0, + snapshotPollTimer: null, + }; + + const els = {}; + + function $(id) { + return document.getElementById(id); + } + + function q(sel, root = document) { + return root.querySelector(sel); + } + + function qa(sel, root = document) { + return Array.from(root.querySelectorAll(sel)); + } + + function mobileViewportQuery() { + return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); + } + + function isMobileViewport() { + return Boolean(state.isMobileViewport); + } + + function isMobileRoomView() { + return isMobileViewport() && state.mobileView === 'room'; + } + + function detectEmbeddedContext() { + if (Boolean(bootstrap?.ui?.embed)) { + return true; + } + + try { + return window.self !== window.top; + } catch (error) { + return true; + } + } + + function setMobileView(nextView) { + if (!isMobileViewport()) { + state.mobileView = 'room'; + return; + } + + state.mobileView = nextView === 'room' ? 'room' : 'spaces'; + } + + function syncViewportState() { + const query = mobileViewportQuery(); + const nextIsMobile = Boolean(query.matches); + const changed = nextIsMobile !== state.isMobileViewport; + + state.isMobileViewport = nextIsMobile; + if (nextIsMobile) { + state.mobileView = changed ? 'spaces' : (state.mobileView || 'spaces'); + } else { + state.mobileView = 'room'; + clearRoomAutoReturnTimer(); + scheduleRoomAutoReturn(state.selectedRoomId || 'main'); + } + + if (nextIsMobile) { + clearRoomAutoReturnTimer(); + if (state.selectedRoomId === 'batteries' && state.snapshot) { + state.selectedRoomId = 'main'; + patchSnapshotSelection('main'); + } + } + + return nextIsMobile; + } + + function iconClass(icon) { + if (!icon) return 'mdi mdi-help-circle-outline'; + return icon.startsWith('mdi:') ? `mdi ${icon.replace('mdi:', 'mdi-')}` : icon; + } + + function normalizeIconSource(source) { + const value = String(source ?? '').trim(); + if (!value) return ''; + return value.replace(/\.svg$/i, ''); + } + + function createSvgIcon(definition) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', definition?.viewBox || '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + svg.classList.add('icon-node__svg'); + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', definition?.path || ''); + svg.appendChild(path); + return svg; + } + + function createCustomIconElement(source, fallback = 'mdi:help-circle-outline') { + const [prefix, name] = source.split(':', 2); + const customSet = window.customIcons?.[prefix] || window.customIconsets?.[prefix]; + const getIcon = typeof customSet === 'function' ? customSet : customSet?.getIcon; + if (typeof getIcon !== 'function') { + return null; + } + + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + Promise.resolve(getIcon(name)).then((definition) => { + if (!definition || !wrap.isConnected) return; + wrap.replaceChildren(createSvgIcon(definition)); + }).catch(() => { + if (!wrap.isConnected) return; + wrap.replaceChildren(createIconElement(fallback)); + }); + return wrap; + } + + function createIconElement(icon, fallback = 'mdi:help-circle-outline') { + const source = normalizeIconSource(icon) || fallback; + + if (source.startsWith('mdi:')) { + const i = document.createElement('i'); + i.className = iconClass(source); + return i; + } + + if (source.startsWith('fas:') || source.startsWith('far:') || source.startsWith('fab:')) { + const custom = createCustomIconElement(source, fallback); + if (custom) { + return custom; + } + const mappedSource = source.startsWith('fas:') + ? source.replace(/^fas:/, 'fa-solid:') + : source.startsWith('far:') + ? source.replace(/^far:/, 'fa-regular:') + : source.replace(/^fab:/, 'fa-brands:'); + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + const img = document.createElement('img'); + img.className = 'icon-node__img'; + img.alt = ''; + img.decoding = 'async'; + img.loading = 'lazy'; + img.referrerPolicy = 'no-referrer'; + img.src = `https://api.iconify.design/${mappedSource}.svg`; + img.addEventListener('error', () => { + if (img.dataset.fallbackApplied === '1') return; + img.dataset.fallbackApplied = '1'; + wrap.replaceChildren(createIconElement(fallback)); + }); + wrap.appendChild(img); + return wrap; + } + + const custom = createCustomIconElement(source, fallback); + if (custom) { + return custom; + } + + const wrap = document.createElement('span'); + wrap.className = 'icon-node'; + + if (source.includes(':')) { + const img = document.createElement('img'); + img.className = 'icon-node__img'; + img.alt = ''; + img.decoding = 'async'; + img.loading = 'lazy'; + img.referrerPolicy = 'no-referrer'; + img.src = `https://api.iconify.design/${source}.svg`; + img.addEventListener('error', () => { + if (img.dataset.fallbackApplied === '1') return; + img.dataset.fallbackApplied = '1'; + wrap.replaceChildren(createIconElement(fallback)); + }); + wrap.appendChild(img); + return wrap; + } + + return createIconElement(fallback); + } + + function esc(value) { + return String(value ?? ''); + } + + function entitySortTime(entity) { + const value = entity?.last_changed || entity?.last_updated || entity?.attributes?.last_changed || ''; + const time = Date.parse(value); + return Number.isFinite(time) ? time : 0; + } + + function splitEntityName(name) { + const value = String(name ?? ''); + const parts = value.split('|').map((part) => part.trim()).filter(Boolean); + if (parts.length >= 2) { + return [parts[0], parts.slice(1).join(' | ')]; + } + return [value.trim()]; + } + + function buildEntityTitle(name) { + const lines = splitEntityName(name); + const title = document.createElement('div'); + title.className = 'grid-card__title'; + const mainLine = document.createElement('span'); + mainLine.className = 'grid-card__title-line'; + mainLine.textContent = lines[0] || ''; + title.appendChild(mainLine); + + if (lines.length > 1) { + const subtitle = document.createElement('span'); + subtitle.className = 'grid-card__subtitle'; + subtitle.textContent = lines.slice(1).join(' | '); + title.appendChild(subtitle); + } + return title; + } + + function isMainDisplayEntity(entity) { + const domain = String(entity?.domain || '').toLowerCase(); + const isDoorContact = Boolean(entity?.is_door_contact); + const state = String(entity?.state || '').toLowerCase(); + if (domain === 'cover') { + return ['open', 'opening', 'closing'].includes(state); + } + if (domain === 'binary_sensor' && isDoorContact) { + return ['on', 'open'].includes(state); + } + return ['on', 'cool', 'heat', 'heating', 'cooling'].includes(state) || (domain === 'fan' && state === 'on'); + } + + function optimisticStateForCommand(entity, command) { + const current = String(entity?.state || '').toLowerCase(); + const active = isMainDisplayEntity(entity); + + switch (command) { + case 'turn_on': + return 'on'; + case 'turn_off': + return 'off'; + case 'open': + return 'open'; + case 'close': + return 'closed'; + case 'toggle': + if (String(entity?.domain || '').toLowerCase() === 'cover') { + return active ? 'closed' : 'open'; + } + return active ? 'off' : 'on'; + case 'stop': + return current || null; + default: + return null; + } + } + + function popupTriggerEntities() { + const fromSnapshot = state.snapshot?.settings?.camera?.trigger_entities; + const fromBootstrap = bootstrap?.settings?.camera?.trigger_entities; + const triggers = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; + return new Set(triggers.map((value) => String(value))); + } + + function cameraConfig() { + return state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; + } + + function resolvePopupStreamMode(streamUrl, explicitMode = '') { + const normalizedUrl = String(streamUrl || '').trim(); + const urlMode = inferStreamMode(normalizedUrl); + const mode = String(explicitMode || '').trim().toLowerCase(); + + if (!normalizedUrl) { + return mode || 'poster'; + } + + if (urlMode === 'iframe') { + return 'iframe'; + } + + if (urlMode === 'hls') { + return 'hls'; + } + + if (urlMode === 'video') { + return 'video'; + } + + if (mode && !['poster', 'hls'].includes(mode)) { + return mode; + } + + return 'poster'; + } + + function mergePopupWithCamera(popup = {}) { + const camera = cameraConfig(); + const streamUrl = popup.stream_url || camera.stream_url || ''; + const streamMode = resolvePopupStreamMode(streamUrl, popup.stream_mode || camera.stream_mode || ''); + return { + ...popup, + poster_url: popup.poster_url || camera.poster_url || '', + stream_url: streamUrl, + stream_mode: streamMode, + title: popup.title || 'Камера', + }; + } + + function pluralizeRooms(count) { + const n = Math.abs(Number(count) || 0) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return 'пространств'; + if (n1 > 1 && n1 < 5) return 'пространства'; + if (n1 === 1) return 'пространство'; + return 'пространств'; + } + + function pluralizeRu(count, one, few, many) { + const n = Math.abs(Number(count) || 0) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return many; + if (n1 > 1 && n1 < 5) return few; + if (n1 === 1) return one; + return many; + } + + function pluralizeEntities(count) { + return pluralizeRu(count, 'объект', 'объекта', 'объектов'); + } + + function pluralizeActiveEntities(count) { + return pluralizeRu(count, 'активный', 'активных', 'активных'); + } + + function pluralizeIncludedEntities(count) { + return pluralizeRu(count, 'включенный объект', 'включенных объекта', 'включенных объектов'); + } + + function formatTime(date = new Date()) { + return new Intl.DateTimeFormat('ru-RU', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); + } + + function formatDate(date = new Date()) { + const weekday = new Intl.DateTimeFormat('ru-RU', { weekday: 'long' }).format(date); + const dayMonth = new Intl.DateTimeFormat('ru-RU', { + day: 'numeric', + month: 'long', + }).format(date); + return `${weekday}, ${dayMonth}`; + } + + function buildUrl(action, params = {}) { + const url = new URL('api.php', window.location.href); + url.searchParams.set('action', action); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, value); + } + }); + return url.toString(); + } + + async function apiGet(action, params = {}) { + const res = await fetch(buildUrl(action, params), { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + return res.json(); + } + + async function apiPost(action, payload = {}) { + const res = await fetch(buildUrl(action), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), + }); + const json = await res.json(); + if (!res.ok || json.ok === false) { + throw new Error(json.error || `Request failed: ${res.status}`); + } + return json; + } + + async function fetchSnapshot(roomId = state.selectedRoomId || 'main') { + return apiGet('snapshot', { space_id: roomId || 'main' }); + } + + async function loadSnapshot(roomId = state.selectedRoomId || 'main') { + const snapshot = await fetchSnapshot(roomId); + state.snapshot = snapshot; + return snapshot; + } + + function currentRoom() { + const snapshot = state.snapshot || {}; + if (state.selectedRoomId === 'batteries') { + return snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || null; + } + const spaces = snapshot.spaces || snapshot.rooms || []; + return spaces.find((space) => space.id === state.selectedRoomId) || snapshot.selected_space || snapshot.selected_room || null; + } + + function roomEntityCollection(snapshot, roomId) { + const room = roomId === 'main' + ? { entities: snapshot.main_entities || [] } + : roomId === 'batteries' + ? (snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || { entities: [] }) + : snapshot.space_index?.[roomId] + || snapshot.space_entities?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null); + return room?.entities || []; + } + + function sortRoomEntities(entities) { + return (Array.isArray(entities) ? entities : []) + .slice() + .sort((left, right) => { + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left?.name || '').localeCompare(String(right?.name || ''), 'ru'); + }); + } + + function sortMainEntities(entities) { + return (Array.isArray(entities) ? entities : []) + .slice() + .sort((left, right) => { + const leftTime = entitySortTime(left); + const rightTime = entitySortTime(right); + if (leftTime !== rightTime) return leftTime - rightTime; + + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + + return String(left?.name || '').localeCompare(String(right?.name || ''), 'ru'); + }); + } + + function roomEntities(snapshot, roomId) { + const collection = roomEntityCollection(snapshot, roomId).filter((entity) => entity.visible !== false); + return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection); + } + + function roomEntitiesIncludingHidden(snapshot, roomId) { + const collection = roomEntityCollection(snapshot, roomId); + return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection); + } + + function roomLayoutItemCollection(snapshot, roomId) { + if (!snapshot || !roomId || roomId === 'main') { + return []; + } + + const room = snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null); + const items = Array.isArray(room?.layout_items) ? room.layout_items : []; + return items.filter((item) => item && typeof item === 'object' && String(item.type || 'ghost') === 'ghost'); + } + + function roomLayoutItems(snapshot, roomId) { + return roomLayoutItemCollection(snapshot, roomId) + .slice() + .sort((left, right) => { + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left?.id || '').localeCompare(String(right?.id || ''), 'ru'); + }); + } + + function roomGridEntries(snapshot, roomId) { + const entries = []; + + roomEntities(snapshot, roomId).forEach((entity) => { + entries.push({ + kind: 'entity', + id: entity.entity_id, + order: Number(entity.order ?? 9999) || 9999, + sortLabel: String(entity.name || entity.entity_id || ''), + payload: entity, + }); + }); + + if (!isMobileViewport() && roomId !== 'main') { + roomLayoutItems(snapshot, roomId).forEach((item) => { + entries.push({ + kind: 'layout', + id: item.id, + order: Number(item.order ?? 9999) || 9999, + sortLabel: String(item.id || ''), + payload: item, + }); + }); + } + + entries.sort((left, right) => { + if (left.order !== right.order) { + return left.order - right.order; + } + if (left.kind !== right.kind) { + return left.kind === 'entity' ? -1 : 1; + } + return left.sortLabel.localeCompare(right.sortLabel, 'ru'); + }); + + return entries; + } + + function isTemperatureSensorEntity(entity) { + if (!entity || String(entity.domain || '').toLowerCase() !== 'sensor') { + return false; + } + + const entityId = String(entity.entity_id || ''); + const attributes = entity.attributes || {}; + const deviceClass = String(attributes.device_class || '').toLowerCase(); + const unit = String(attributes.unit_of_measurement || '').trim().toLowerCase(); + + return deviceClass === 'temperature' + || unit === '°c' + || unit === 'c' + || entityId.endsWith('_temperature'); + } + + function roomTemperatureSensorLabel(entity) { + const name = String(entity?.name || entity?.attributes?.friendly_name || entity?.entity_id || 'Датчик'); + const value = entity?.attributes?.current_temperature ?? entity?.attributes?.temperature ?? entity?.state ?? null; + const numeric = Number(value); + const valueText = Number.isFinite(numeric) ? `${Math.round(numeric)}°` : '—'; + + return { + name, + valueText, + meta: String(entity?.entity_id || ''), + }; + } + + function roomTemperatureSensorCandidates(snapshot, roomId) { + const room = snapshot?.space_index?.[roomId] + || (snapshot?.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot?.selected_room?.id === roomId ? snapshot.selected_room : null); + const entities = Array.isArray(room?.entities) ? room.entities : []; + const selectedId = String(room?.temperature_sensor_entity_id || '').trim(); + const candidates = entities.filter((entity) => entity && isTemperatureSensorEntity(entity)); + + candidates.sort((left, right) => { + const leftSelected = String(left.entity_id || '') === selectedId ? 0 : 1; + const rightSelected = String(right.entity_id || '') === selectedId ? 0 : 1; + if (leftSelected !== rightSelected) return leftSelected - rightSelected; + + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + + return String(left.name || left.entity_id || '').localeCompare(String(right.name || right.entity_id || ''), 'ru'); + }); + + return candidates; + } + + function roomTemperatureBadge(snapshot, room) { + const roomId = String(room?.id || ''); + if (!roomId || roomId === 'main') { + return null; + } + + const roomIndex = snapshot?.space_index?.[roomId] || room || {}; + const selectedId = String(roomIndex.temperature_sensor_entity_id || room.temperature_sensor_entity_id || '').trim(); + const entities = Array.isArray(roomIndex.entities) ? roomIndex.entities : []; + + if (selectedId) { + const selected = entities.find((entity) => entity && String(entity.entity_id || '') === selectedId); + if (selected) { + const value = selected.attributes?.current_temperature ?? selected.attributes?.temperature ?? selected.state ?? null; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return `${Math.round(numeric)}°`; + } + } + + if (room.temperature_badge) { + return room.temperature_badge; + } + } + + const firstCandidate = entities.find((entity) => entity && isTemperatureSensorEntity(entity)); + if (firstCandidate) { + const value = firstCandidate.attributes?.current_temperature ?? firstCandidate.attributes?.temperature ?? firstCandidate.state ?? null; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return `${Math.round(numeric)}°`; + } + } + + if (room.temperature_badge) { + return room.temperature_badge; + } + + return null; + } + + function entityKindLabel(entity) { + return String(entity?.domain || entity?.entity_id?.split('.')?.[0] || '').toLowerCase() || 'entity'; + } + + function renderEntityTypeLabel(entity) { + const kind = document.createElement('div'); + kind.className = 'grid-card__kind'; + kind.textContent = entityKindLabel(entity); + return kind; + } + + function entityFromSnapshot(snapshot, entityId) { + return getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + } + + function entityPopupEntity() { + const snapshot = state.snapshot || bootstrap; + const entityId = state.entityPopup?.entityId; + return entityFromSnapshot(snapshot, entityId); + } + + function openEntityPopup(entityId) { + const snapshot = state.snapshot || bootstrap; + const entity = entityFromSnapshot(snapshot, entityId); + if (!entity) return; + state.entityPopup = { + active: true, + entityId, + kind: entityKindLabel(entity), + }; + renderEntityPopup(snapshot); + } + + function closeEntityPopup() { + state.entityPopup = { + active: false, + entityId: null, + }; + const backdrop = els.entityBackdrop; + if (backdrop) { + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + } + if (els.entityBody) { + els.entityBody.innerHTML = ''; + } + if (els.entityTitle) { + els.entityTitle.textContent = 'Устройство'; + } + if (els.entityEyebrow) { + els.entityEyebrow.textContent = ''; + } + } + + function closeTemperatureSensorPopup() { + state.temperatureSensorPopup = { + active: false, + roomId: null, + }; + state.lastTemperatureSensorPopupSignature = ''; + const backdrop = els.temperatureSensorBackdrop; + if (backdrop) { + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + } + if (els.temperatureSensorBody) { + els.temperatureSensorBody.innerHTML = ''; + } + } + + function openTemperatureSensorPopup(roomId) { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null) + || null; + if (!room || roomId === 'main' || roomId === 'batteries') return; + + state.temperatureSensorPopup = { + active: true, + roomId, + }; + renderTemperatureSensorPopup(snapshot); + } + + function renderEntityPopup(snapshot) { + const backdrop = els.entityBackdrop; + if (!backdrop) return false; + + const popupState = state.entityPopup || {}; + const entity = popupState.active ? entityFromSnapshot(snapshot, popupState.entityId) : null; + if (!popupState.active || !entity) { + closeEntityPopup(); + return false; + } + + const signature = JSON.stringify([ + entity.entity_id || '', + entity.state || '', + entity.attributes?.current_position ?? '', + entity.attributes?.temperature ?? '', + entity.attributes?.current_temperature ?? '', + entity.attributes?.hvac_mode ?? '', + entity.attributes?.fan_mode ?? '', + entity.attributes?.swing_mode ?? '', + entity.attributes?.preset_mode ?? '', + entity.attributes?.hvac_action ?? '', + state.editMode ? '1' : '0', + ]); + + if (signature === state.lastEntityPopupSignature && backdrop.classList.contains('is-open')) { + return true; + } + + state.lastEntityPopupSignature = signature; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + if (els.entityTitle) { + els.entityTitle.textContent = entity.name || 'Устройство'; + } + if (els.entityEyebrow) { + els.entityEyebrow.textContent = entityKindLabel(entity); + } + if (els.entityBody) { + els.entityBody.replaceChildren(); + if (entity.domain === 'cover') { + els.entityBody.appendChild(renderCoverPopup(entity)); + } else if (entity.domain === 'climate') { + els.entityBody.appendChild(renderClimatePopup(entity)); + } else { + const fallback = document.createElement('div'); + fallback.className = 'entity-modal__fallback'; + fallback.textContent = 'Для этого типа пока нет popup.'; + els.entityBody.appendChild(fallback); + } + } + + return true; + } + + function renderTemperatureSensorPopup(snapshot) { + const backdrop = els.temperatureSensorBackdrop; + if (!backdrop) return false; + + if (isMobileViewport() || !state.editMode) { + closeTemperatureSensorPopup(); + return false; + } + + const popupState = state.temperatureSensorPopup || {}; + const roomId = popupState.active ? popupState.roomId : null; + const room = roomId + ? (snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null)) + : null; + + if (!popupState.active || !room || roomId === 'main' || roomId === 'batteries') { + closeTemperatureSensorPopup(); + return false; + } + + const candidates = roomTemperatureSensorCandidates(snapshot, roomId); + const selectedId = String(room.temperature_sensor_entity_id || room?.temperature_sensor_entity_id || '').trim(); + const signature = JSON.stringify([ + roomId, + selectedId, + candidates.map((entity) => `${entity.entity_id}:${entity.state}:${entity.attributes?.current_temperature ?? entity.attributes?.temperature ?? ''}`).join('|'), + state.editMode ? '1' : '0', + ]); + + if (signature === state.lastTemperatureSensorPopupSignature && backdrop.classList.contains('is-open')) { + return true; + } + + state.lastTemperatureSensorPopupSignature = signature; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + if (els.temperatureSensorTitle) { + els.temperatureSensorTitle.textContent = room.name ? `Выбрать датчик температуры · ${room.name}` : 'Выбрать датчик температуры'; + } + + if (!els.temperatureSensorBody) { + return true; + } + + els.temperatureSensorBody.replaceChildren(); + + const current = document.createElement('div'); + current.className = 'temperature-sensor-modal__current'; + const selectedEntity = selectedId + ? candidates.find((entity) => String(entity.entity_id || '') === selectedId) + : null; + current.innerHTML = ` +
Текущий выбор
+
${selectedEntity ? esc(selectedEntity.name || selectedEntity.entity_id) : 'Автоматически'}
+ `; + els.temperatureSensorBody.appendChild(current); + + const resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.className = `temperature-sensor-modal__option ${!selectedId ? 'is-active' : ''}`; + resetButton.innerHTML = ` + + Автоматически + Использовать первый подходящий датчик в комнате + + ${!selectedId ? 'Выбрано' : 'Сбросить'} + `; + resetButton.addEventListener('click', async () => { + await saveSpacePatch(room, { temperature_sensor_entity_id: '' }); + closeTemperatureSensorPopup(); + }); + els.temperatureSensorBody.appendChild(resetButton); + + if (!candidates.length) { + const empty = document.createElement('div'); + empty.className = 'temperature-sensor-modal__empty'; + empty.textContent = 'В этой комнате не найдено температурных датчиков.'; + els.temperatureSensorBody.appendChild(empty); + return true; + } + + const list = document.createElement('div'); + list.className = 'temperature-sensor-modal__list'; + + candidates.forEach((entity) => { + const label = roomTemperatureSensorLabel(entity); + const option = document.createElement('button'); + option.type = 'button'; + option.className = `temperature-sensor-modal__option ${String(entity.entity_id || '') === selectedId ? 'is-active' : ''}`; + option.innerHTML = ` + + ${esc(label.name)} + ${esc(label.meta)} + + ${esc(label.valueText)} + `; + option.addEventListener('click', async () => { + await saveSpacePatch(room, { temperature_sensor_entity_id: entity.entity_id }); + closeTemperatureSensorPopup(); + }); + list.appendChild(option); + }); + + els.temperatureSensorBody.appendChild(list); + return true; + } + + function syncLayoutState() { + if (!els.appShell) return; + + const mobile = isMobileViewport(); + const embedded = Boolean(state.embedMode); + document.body.classList.toggle('is-mobile-ui', mobile); + document.body.classList.toggle('is-embedded', embedded); + els.appShell.classList.toggle('is-mobile', mobile); + els.appShell.classList.toggle('is-desktop', !mobile); + els.appShell.classList.toggle('app-shell--embed', embedded); + els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room'); + els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); + + if (els.selectedRoomBack) { + els.selectedRoomBack.hidden = !isMobileRoomView(); + } + } + + function normalizePositionValue(value) { + const next = Number(value); + if (!Number.isFinite(next)) return null; + return Math.max(0, Math.min(100, Math.round(next))); + } + + function shouldShowMainEntity(entity) { + if (!entity) return false; + const domain = String(entity.domain || entity.entity_id?.split('.')?.[0] || '').toLowerCase(); + const state = String(entity.state || '').toLowerCase(); + const isAuto = Boolean(entity.is_auto); + const isHidden = Boolean(entity.is_hidden); + const isDoorContact = Boolean(entity.is_door_contact); + if (!isAuto || isHidden) return false; + if (!['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain)) return false; + if (domain === 'binary_sensor' && !isDoorContact) return false; + return domain === 'cover' + ? ['open', 'opening', 'closing'].includes(state) + : domain === 'binary_sensor' + ? ['on', 'open'].includes(state) + : ['on', 'cool', 'heat', 'heating', 'cooling'].includes(state); + } + + function mainCardsContainer() { + return q('.main-dashboard__cards', els.dashboardSurface); + } + + function currentDashboardCardsContainer() { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + return mainCardsContainer(); + } + return els.dashboardSurface; + } + + function findRenderedCard(entityId) { + if (!entityId) return null; + return q(`[data-entity-id="${CSS.escape(entityId)}"]`, els.dashboardSurface); + } + + function sortMainCardsBySnapshot(container) { + const snapshot = state.snapshot || {}; + const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((entity) => entity.entity_id); + const order = new Map(orderedMainIds.map((entityId, index) => [entityId, index])); + const cards = Array.from(container?.querySelectorAll('.grid-card[data-entity-id]') || []); + cards.sort((left, right) => { + const leftId = left.dataset.entityId || ''; + const rightId = right.dataset.entityId || ''; + const leftOrder = order.has(leftId) ? order.get(leftId) : Number.MAX_SAFE_INTEGER; + const rightOrder = order.has(rightId) ? order.get(rightId) : Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return leftId.localeCompare(rightId, 'ru'); + }); + cards.forEach((card) => container.appendChild(card)); + } + + function updateMainWeatherCard() { + const snapshot = state.snapshot || {}; + const hero = q('.main-dashboard__hero', els.dashboardSurface); + if (!hero) return false; + const next = renderMainHero(snapshot); + hero.replaceWith(next); + return true; + } + + function updateMainEntityCard(entityId) { + const snapshot = state.snapshot || {}; + const container = mainCardsContainer(); + if (!container) return false; + + const entity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + const existing = q(`[data-entity-id="${CSS.escape(entityId)}"]`, container); + const shouldShow = shouldShowMainEntity(entity); + if (existing && !shouldShow) { + existing.remove(); + return true; + } + if (!shouldShow) { + return false; + } + + const nextCard = renderEntityCard(entity, { isMain: true }); + if (existing) { + existing.replaceWith(nextCard); + return true; + } + + const orderedMainIds = sortMainEntities(snapshot.main_entities || []).map((item) => item.entity_id); + const nextIndex = orderedMainIds.indexOf(entityId); + const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]')); + for (const card of cards) { + const cardId = card.dataset.entityId; + const cardIndex = orderedMainIds.indexOf(cardId); + if (cardIndex > nextIndex && nextIndex !== -1) { + card.before(nextCard); + return true; + } + } + + container.appendChild(nextCard); + return true; + } + + function updateRoomEntityCard(entityId) { + const snapshot = state.snapshot || {}; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + return updateMainEntityCard(entityId); + } + if (room.id === 'batteries') { + renderDashboardOnly(); + return true; + } + renderDashboardOnly(); + return true; + } + + function setCardInteractionLock(entityId) { + state.roomDrag = state.roomDrag || {}; + state.roomDrag.suppressClickUntil = Date.now() + 180; + state.roomDrag.entityId = entityId; + } + + function openConfirm(options = {}) { + const backdrop = els.confirmBackdrop; + if (!backdrop) return Promise.resolve(false); + els.confirmTitle.textContent = options.title || 'Хотите закрыть?'; + els.confirmMessage.textContent = options.message || 'Это действие отправит команду закрытия.'; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + return new Promise((resolve) => { + state.confirmResolver = resolve; + const finish = (result) => { + if (state.confirmResolver) { + const resolver = state.confirmResolver; + state.confirmResolver = null; + resolver(result); + } + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + }; + + const onYes = () => finish(true); + const onNo = () => finish(false); + const onBackdrop = (event) => { + if (event.target === backdrop) onNo(); + }; + + const cleanup = () => { + els.confirmYes.removeEventListener('click', onYes); + els.confirmNo.removeEventListener('click', onNo); + backdrop.removeEventListener('click', onBackdrop); + }; + + els.confirmYes.addEventListener('click', () => { + cleanup(); + onYes(); + }, { once: true }); + els.confirmNo.addEventListener('click', () => { + cleanup(); + onNo(); + }, { once: true }); + backdrop.addEventListener('click', (event) => { + if (event.target === backdrop) { + cleanup(); + onNo(); + } + }, { once: true }); + }); + } + + function roomCollections() { + const snapshot = state.snapshot || {}; + const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : Array.isArray(snapshot.rooms) ? snapshot.rooms : []; + const visible = spaces.filter((room) => room.id !== 'main' && room.visible !== false); + const hidden = spaces.filter((room) => room.id !== 'main' && room.visible === false); + return { visible, hidden }; + } + + function orderedRoomIdsFromGroup(groupEl) { + return qa('.room-item', groupEl) + .map((item) => item.dataset.roomId) + .filter((roomId) => roomId && roomId !== 'main'); + } + + function roomById(roomId) { + const snapshot = state.snapshot || {}; + const spaces = Array.isArray(snapshot.spaces) ? snapshot.spaces : Array.isArray(snapshot.rooms) ? snapshot.rooms : []; + return spaces.find((room) => room.id === roomId) || null; + } + + async function persistRoomOrderForGroup(groupEl, hidden = false) { + const snapshot = state.snapshot || {}; + const roomIds = orderedRoomIdsFromGroup(groupEl); + if (!roomIds.length) return; + + const baseOrder = hidden ? 1000 : 0; + const roomsById = new Map((snapshot.spaces || snapshot.rooms || []).map((room) => [room.id, room])); + const changes = roomIds.map((roomId, index) => { + const room = roomsById.get(roomId); + const nextOrder = baseOrder + (index * 10); + if (!room || Number(room.order ?? 9999) === nextOrder) { + return null; + } + return { roomId, nextOrder }; + }).filter(Boolean); + + if (!changes.length) return; + + await Promise.all(changes.map(({ roomId, nextOrder }) => apiPost('save-space-override', { + room_id: roomId, + order: nextOrder, + }))); + + changes.forEach(({ roomId, nextOrder }) => { + patchSnapshotSpace(roomId, { order: nextOrder }); + }); + renderSidebarOnly(); + } + + function clearRoomDragState() { + const drag = state.roomDrag; + if (!drag) return; + if (drag.itemEl) { + drag.itemEl.classList.remove('is-dragging'); + drag.itemEl.removeAttribute('aria-grabbed'); + } + state.roomDrag = null; + } + + function startRoomDrag(room, itemEl, groupEl, hidden, event) { + if (!state.editMode || room.id === 'main') return; + if (event.target.closest('button')) return; + + const pointerId = event.pointerId; + const drag = { + roomId: room.id, + itemEl, + groupEl, + hidden, + pointerId, + startX: event.clientX, + startY: event.clientY, + moved: false, + suppressClickUntil: 0, + }; + state.roomDrag = drag; + itemEl.classList.add('is-dragging'); + itemEl.setAttribute('aria-grabbed', 'true'); + if (itemEl.setPointerCapture) { + try { + itemEl.setPointerCapture(pointerId); + } catch (error) { + console.warn(error); + } + } + event.preventDefault(); + } + + function roomDropTargetAtPoint(x, y, groupEl, draggedId) { + const node = document.elementFromPoint(x, y); + const item = node?.closest?.('.room-item'); + if (!item || item.dataset.roomId === draggedId || item.dataset.roomGroup !== (state.roomDrag?.hidden ? 'hidden' : 'visible')) { + return null; + } + if (!groupEl.contains(item)) { + return null; + } + return item; + } + + function moveRoomDrag(clientX, clientY) { + const drag = state.roomDrag; + if (!drag) return; + + const dx = Math.abs(clientX - drag.startX); + const dy = Math.abs(clientY - drag.startY); + if (!drag.moved && Math.max(dx, dy) < 6) { + return; + } + + drag.moved = true; + const target = roomDropTargetAtPoint(clientX, clientY, drag.groupEl, drag.roomId); + if (!target) return; + + const targetRect = target.getBoundingClientRect(); + const before = clientY < (targetRect.top + targetRect.height / 2); + if (before) { + drag.groupEl.insertBefore(drag.itemEl, target); + } else { + drag.groupEl.insertBefore(drag.itemEl, target.nextSibling); + } + } + + async function finishRoomDrag() { + const drag = state.roomDrag; + if (!drag) return; + + const itemEl = drag.itemEl; + const groupEl = drag.groupEl; + const hidden = drag.hidden; + const moved = drag.moved; + itemEl.classList.remove('is-dragging'); + itemEl.removeAttribute('aria-grabbed'); + + if (drag.itemEl.releasePointerCapture && drag.pointerId !== null) { + try { + drag.itemEl.releasePointerCapture(drag.pointerId); + } catch (error) { + console.warn(error); + } + } + + state.roomDrag = { + ...drag, + suppressClickUntil: Date.now() + 200, + }; + + if (moved) { + await persistRoomOrderForGroup(groupEl, hidden); + } + + window.setTimeout(() => { + if (state.roomDrag && Date.now() >= (state.roomDrag.suppressClickUntil || 0)) { + state.roomDrag = null; + } + }, 220); + } + + function updateEntityInCollection(collection, entityId, updater) { + if (!Array.isArray(collection)) return false; + let changed = false; + collection.forEach((entity) => { + if (!entity || entity.entity_id !== entityId) return; + updater(entity); + changed = true; + }); + return changed; + } + + function updateEntityInMap(map, entityId, updater) { + if (!map || typeof map !== 'object') return false; + const entity = map[entityId]; + if (!entity || typeof entity !== 'object') return false; + updater(entity); + return true; + } + + function patchSnapshotEntity(entityId, patch = {}) { + const snapshot = state.snapshot || {}; + let changed = false; + + const applyPatch = (entity) => { + Object.assign(entity, patch); + changed = true; + }; + + updateEntityInCollection(snapshot.main_entities, entityId, applyPatch); + updateEntityInMap(snapshot.entity_index, entityId, applyPatch); + updateEntityInMap(snapshot.space_index, entityId, applyPatch); + + if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { + Object.values(snapshot.space_entities).forEach((collection) => { + updateEntityInCollection(collection, entityId, applyPatch); + }); + } + + if (snapshot.battery_room?.entities) { + updateEntityInCollection(snapshot.battery_room.entities, entityId, applyPatch); + } + + if (snapshot.selected_space?.entities) { + updateEntityInCollection(snapshot.selected_space.entities, entityId, applyPatch); + } + + if (snapshot.selected_room?.entities) { + updateEntityInCollection(snapshot.selected_room.entities, entityId, applyPatch); + } + + return changed; + } + + function syncMainEntities(entityId, sourceEntity, patch = {}) { + const snapshot = state.snapshot || {}; + const list = Array.isArray(snapshot.main_entities) ? snapshot.main_entities : []; + const index = list.findIndex((entity) => entity && entity.entity_id === entityId); + const entity = index >= 0 ? list[index] : null; + const nextState = String(patch.state ?? sourceEntity?.state ?? '').toLowerCase(); + const definition = getEntityDefinition(snapshot, entityId) || sourceEntity || entity || { entity_id: entityId }; + const domain = String(definition?.domain || entity?.domain || entityId.split('.')[0] || '').toLowerCase(); + const isDoorContact = Boolean(definition?.is_door_contact || entity?.is_door_contact); + const shouldDisplay = domain === 'cover' + ? ['open', 'opening', 'closing'].includes(nextState) + : domain === 'binary_sensor' + ? isDoorContact && ['on', 'open'].includes(nextState) + : ['on', 'cool', 'heat', 'heating', 'cooling'].includes(nextState) || (domain === 'fan' && nextState === 'on'); + const isAllowedDomain = ['light', 'switch', 'cover', 'fan', 'binary_sensor'].includes(domain); + const isAuto = Boolean(definition?.is_auto); + + if (definition?.is_hidden || !isAuto) { + if (index >= 0) { + list.splice(index, 1); + if (snapshot.selected_space?.id === 'main') { + snapshot.selected_space.entities = list; + snapshot.selected_room = snapshot.selected_space; + } + return true; + } + return false; + } + + if (!isAllowedDomain || (domain === 'binary_sensor' && !isDoorContact)) { + if (index >= 0) { + list.splice(index, 1); + if (snapshot.selected_space?.id === 'main') { + snapshot.selected_space.entities = list; + snapshot.selected_room = snapshot.selected_space; + } + return true; + } + return false; + } + + if (!shouldDisplay) { + if (index >= 0) { + list.splice(index, 1); + } else { + return false; + } + } else if (index >= 0) { + Object.assign(entity, { + ...definition, + ...patch, + }); + } else { + list.push({ + ...definition, + ...patch, + last_changed: patch.last_changed || sourceEntity?.last_changed || sourceEntity?.last_updated || new Date().toISOString(), + }); + } + + list.sort((a, b) => { + const timeA = entitySortTime(a); + const timeB = entitySortTime(b); + if (timeA !== timeB) return timeA - timeB; + return String(a.name || '').localeCompare(String(b.name || ''), 'ru'); + }); + + if (snapshot.selected_space?.id === 'main') { + snapshot.selected_space.entities = list; + snapshot.selected_room = snapshot.selected_space; + } + + return true; + } + + function getEntityFromSnapshot(snapshot, entityId) { + if (!snapshot || !entityId) return null; + const collections = [ + snapshot.main_entities, + snapshot.selected_space?.entities, + snapshot.selected_room?.entities, + ]; + if (snapshot.entity_index && typeof snapshot.entity_index === 'object') { + collections.push(Object.values(snapshot.entity_index)); + } + if (snapshot.space_index && typeof snapshot.space_index === 'object') { + Object.values(snapshot.space_index).forEach((room) => { + if (room?.entities) { + collections.push(room.entities); + } + }); + } + if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { + Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities)); + } + if (snapshot.battery_room?.entities) { + collections.push(snapshot.battery_room.entities); + } + + for (const collection of collections) { + if (!Array.isArray(collection)) continue; + const found = collection.find((entity) => entity && entity.entity_id === entityId); + if (found) return found; + } + + return null; + } + + function getEntityDefinition(snapshot, entityId) { + if (!snapshot || !entityId) return null; + if (snapshot.entity_index && typeof snapshot.entity_index === 'object' && snapshot.entity_index[entityId]) { + return snapshot.entity_index[entityId]; + } + return getEntityFromSnapshot(snapshot, entityId); + } + + function statePayloadChanged(existing, incoming) { + if (!existing || !incoming) return true; + if (String(existing.state ?? '') !== String(incoming.state ?? '')) return true; + return JSON.stringify(existing.attributes || {}) !== JSON.stringify(incoming.attributes || {}); + } + + function patchSnapshotSpace(roomId, patch = {}) { + const snapshot = state.snapshot || {}; + const collections = [snapshot.spaces, snapshot.rooms]; + let changed = false; + + collections.forEach((collection) => { + if (!Array.isArray(collection)) return; + collection.forEach((room) => { + if (!room || room.id !== roomId) return; + Object.assign(room, patch); + changed = true; + }); + }); + + if (snapshot.space_index && snapshot.space_index[roomId]) { + Object.assign(snapshot.space_index[roomId], patch); + changed = true; + } + + if (snapshot.selected_space?.id === roomId) { + Object.assign(snapshot.selected_space, patch); + changed = true; + } + + if (snapshot.selected_room?.id === roomId) { + Object.assign(snapshot.selected_room, patch); + changed = true; + } + + return changed; + } + + function patchSnapshotSelection(roomId) { + const snapshot = state.snapshot || {}; + const spaces = snapshot.spaces || snapshot.rooms || []; + const room = roomId === 'main' + ? { + id: 'main', + name: snapshot.settings?.main_room_name || 'Главная', + icon: snapshot.settings?.main_room_icon || 'mdi:home', + visible: true, + entities: snapshot.main_entities || [], + } + : roomId === 'batteries' + ? snapshot.battery_room + : snapshot.space_index?.[roomId] || spaces.find((space) => space.id === roomId); + + if (!room) return; + + state.selectedRoomId = roomId; + if (roomId === 'main') { + clearRoomAutoReturnTimer(); + snapshot.selected_space = { + id: 'main', + name: snapshot.settings?.main_room_name || 'Главная', + icon: snapshot.settings?.main_room_icon || 'mdi:home', + visible: true, + entities: snapshot.main_entities || [], + }; + snapshot.selected_room = snapshot.selected_space; + return; + } + + if (roomId === 'batteries') { + snapshot.selected_space = { + ...room, + entities: Array.isArray(room.entities) ? room.entities : [], + }; + snapshot.selected_room = snapshot.selected_space; + return; + } + + const entities = snapshot.space_index?.[roomId]?.entities || snapshot.space_entities?.[roomId] || room.entities || []; + snapshot.selected_space = { + ...room, + entities, + }; + snapshot.selected_room = snapshot.selected_space; + } + + function applyPopupState(active, sensorEntityId) { + const camera = state.snapshot?.settings?.camera || bootstrap?.settings?.camera || {}; + const popup = state.snapshot?.popup || {}; + if (active && Date.now() < Number(state.popupAutoOpenBlockedUntil || 0)) { + return; + } + const next = { + ...popup, + active, + sensor_entity_id: sensorEntityId || null, + opened_at: active ? Math.floor(Date.now() / 1000) : popup.opened_at || null, + expires_at: active ? Math.floor(Date.now() / 1000) + (Number(camera.popup_timeout_minutes || 3) * 60) : null, + poster_url: camera.poster_url || popup.poster_url || '', + stream_url: camera.stream_url || popup.stream_url || '', + stream_mode: camera.stream_mode || popup.stream_mode || 'hls', + title: popup.title || 'Камера', + }; + + state.snapshot = state.snapshot || bootstrap; + state.snapshot.popup = next; + renderPopup(state.snapshot); + } + + function applyPopupSnapshot(popup = {}) { + const snapshot = state.snapshot || bootstrap; + snapshot.popup = mergePopupWithCamera({ + ...(snapshot.popup || {}), + ...popup, + }); + renderPopup(snapshot); + } + + function syncTriggerPopup(entityId, stateValue) { + const value = String(stateValue || '').toLowerCase(); + if (!['on', 'off'].includes(value)) { + return; + } + + apiPost('popup', { sensor_entity_id: entityId, state: value }) + .then((response) => { + if (response?.popup) { + applyPopupSnapshot(response.popup); + } + }) + .catch((error) => { + console.warn(error); + }); + } + + function haConnection() { + return state.snapshot?.settings?.ha_connection || bootstrap?.settings?.ha_connection || {}; + } + + function haWsUrl(baseUrl) { + if (!baseUrl) return ''; + try { + const url = new URL(baseUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.pathname = '/api/websocket'; + url.search = ''; + url.hash = ''; + return url.toString(); + } catch (error) { + return ''; + } + } + + function setStatus(text, tone = '') { + if (!els.connectionStatus) return; + els.connectionStatus.textContent = text; + els.connectionStatus.dataset.tone = tone; + } + + function clearRoomAutoReturnTimer() { + if (state.roomAutoReturnTimer) { + clearTimeout(state.roomAutoReturnTimer); + state.roomAutoReturnTimer = null; + } + } + + function scheduleRoomAutoReturn(roomId) { + const nextRoomId = roomId || 'main'; + clearRoomAutoReturnTimer(); + if (nextRoomId === 'main' || isMobileViewport()) { + return; + } + + state.roomAutoReturnTimer = window.setTimeout(() => { + state.roomAutoReturnTimer = null; + if ((state.selectedRoomId || 'main') === nextRoomId) { + setSelectedRoom('main'); + } + }, 120000); + } + + async function setSelectedRoom(roomId) { + const nextRoomId = roomId || 'main'; + const token = ++state.roomSelectionToken; + clearRoomAutoReturnTimer(); + closeTemperatureSensorPopup(); + patchSnapshotSelection(nextRoomId); + if (isMobileViewport()) { + setMobileView('room'); + } + render(); + scheduleRoomAutoReturn(nextRoomId); + try { + const snapshot = await fetchSnapshot(nextRoomId); + if (token !== state.roomSelectionToken) { + return; + } + state.snapshot = snapshot; + patchSnapshotSelection(nextRoomId); + render(); + } catch (error) { + if (token !== state.roomSelectionToken) { + return; + } + console.warn(error); + } + } + + function createButton(label, subtitle, icon, className = '', attrs = {}) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = className; + Object.entries(attrs).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + btn.dataset[key] = value; + } + }); + + const iconEl = document.createElement('i'); + iconEl.className = iconClass(icon); + + const body = document.createElement('div'); + body.className = 'mushroom-button__body'; + + const titleEl = document.createElement('div'); + titleEl.className = 'mushroom-button__title'; + titleEl.textContent = label; + + body.appendChild(titleEl); + + if (subtitle) { + const subEl = document.createElement('div'); + subEl.className = 'mushroom-button__subtitle'; + subEl.textContent = subtitle; + body.appendChild(subEl); + } + + btn.appendChild(iconEl); + btn.appendChild(body); + return btn; + } + + function mainWeatherCard(weather) { + const card = document.createElement('article'); + card.className = 'grid-card grid-card--weather grid-card--weather-compact'; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner weather-card weather-card--compact'; + + const title = document.createElement('div'); + title.className = 'grid-card__title_weather'; + title.textContent = 'Погода'; + + const rows = document.createElement('div'); + rows.className = 'weather-card__rows'; + [ + ['Интернет', weather?.temperature != null ? `${Number(weather.temperature).toFixed(0)}°C` : '—'], + ['Датчик', weather?.sensor_temperature != null ? `${weather.sensor_temperature}°C` : '—'], + ['Ветер', weather?.wind_speed != null ? `${Math.round(Number(weather.wind_speed))} км/ч` : '—'], + ].forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = 'weather-card__row'; + row.innerHTML = `${label}${value}`; + rows.appendChild(row); + }); + + inner.appendChild(title); + inner.appendChild(rows); + card.appendChild(inner); + return card; + } + + function mainWeatherActions(snapshot = state.snapshot || bootstrap) { + const fromSnapshot = snapshot?.settings?.main_weather_actions; + const fromBootstrap = bootstrap?.settings?.main_weather_actions; + const actions = Array.isArray(fromSnapshot) ? fromSnapshot : Array.isArray(fromBootstrap) ? fromBootstrap : []; + return actions.filter((action) => action && String(action.entity_id || '').trim() !== ''); + } + + function mainWeatherActionEntity(snapshot, action) { + const entityId = String(action?.state_entity_id || action?.entity_id || '').trim(); + if (!entityId) return null; + return getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId) || null; + } + + function mainWeatherActionIsActive(snapshot, action) { + const entity = mainWeatherActionEntity(snapshot, action); + if (!entity) return false; + const current = String(entity.state ?? '').trim().toLowerCase(); + const compareValue = action?.active_value ?? action?.value; + if (compareValue === null || compareValue === undefined || String(compareValue).trim() === '') { + return !['off', 'false', '0', 'unknown', 'unavailable', 'idle'].includes(current); + } + return current === String(compareValue).trim().toLowerCase(); + } + + function mainWeatherActionAffectsEntity(entityId) { + const nextEntityId = String(entityId || '').trim(); + if (!nextEntityId) return false; + return mainWeatherActions().some((action) => { + const stateEntityId = String(action.state_entity_id || action.entity_id || '').trim(); + return stateEntityId === nextEntityId || String(action.entity_id || '').trim() === nextEntityId; + }); + } + + function mainPrintAffectsEntity(entityId) { + const nextEntityId = String(entityId || '').trim(); + if (!nextEntityId) return false; + const config = mainPrintConfig(); + if (!config) return false; + return [ + config.current_stage_entity_id, + config.print_progress_entity_id, + config.start_time_entity_id, + config.end_time_entity_id, + ].includes(nextEntityId); + } + + function mainWeatherActionLabel(action, active) { + const value = action?.value; + const label = active + ? (action?.label_active ?? action?.active_label ?? '') + : (action?.label_inactive ?? action?.inactive_label ?? ''); + if (String(label || '').trim() !== '') { + return String(label); + } + if (value !== null && value !== undefined && String(value).trim() !== '') { + return active ? `${value}°` : `Установить ${value}°`; + } + return active ? 'Активно' : 'Включить'; + } + + function renderMainWeatherActions(snapshot) { + const actions = mainWeatherActions(snapshot); + if (!actions.length) return null; + + const wrap = document.createElement('div'); + wrap.className = 'main-dashboard__actions'; + + actions.forEach((action) => { + const active = mainWeatherActionIsActive(snapshot, action); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `main-quick-action ${active ? 'is-active' : ''}`; + btn.dataset.entityId = String(action.entity_id || ''); + btn.dataset.stateEntityId = String(action.state_entity_id || action.entity_id || ''); + btn.dataset.command = String(action.command || 'set_temperature'); + btn.dataset.value = action.value !== undefined && action.value !== null ? String(action.value) : ''; + btn.style.setProperty('--quick-action-bg', active ? (action.active_color || '#4caf50') : (action.inactive_color || '#c8e6c9')); + btn.style.setProperty('--quick-action-color', active ? (action.active_text_color || 'white') : (action.inactive_text_color || 'black')); + btn.style.setProperty('--quick-action-icon-color', active ? (action.active_icon_color || 'white') : (action.inactive_icon_color || 'gray')); + btn.style.setProperty('--icon-node-img-filter', active + ? 'brightness(0) saturate(100%) invert(100%)' + : 'brightness(0) saturate(100%) invert(42%)'); + + const icon = document.createElement('div'); + icon.className = 'main-quick-action__icon'; + icon.appendChild(createIconElement(action.icon || 'mdi:thermometer')); + + const label = document.createElement('div'); + label.className = 'main-quick-action__label'; + label.textContent = mainWeatherActionLabel(action, active); + + btn.append(icon, label); + btn.addEventListener('click', () => { + handleMainWeatherAction(action); + }); + btn.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMainWeatherAction(action); + } + }); + wrap.appendChild(btn); + }); + + return wrap; + } + + function renderMainPrintCard(snapshot = state.snapshot || bootstrap) { + const info = mainPrintState(snapshot); + if (!info) return null; + + const card = document.createElement('article'); + card.className = 'main-print-strip'; + + const inner = document.createElement('div'); + inner.className = 'main-print-strip__inner'; + + const header = document.createElement('div'); + header.className = 'main-print-strip__header'; + + const badge = document.createElement('div'); + badge.className = 'main-print-strip__badge'; + badge.textContent = `${Math.max(0, Math.round(info.progress ?? 0))}%`; + + header.appendChild(badge); + + const progress = document.createElement('div'); + progress.className = 'main-print-strip__progress'; + const fill = document.createElement('div'); + fill.className = 'main-print-strip__progress-fill'; + fill.style.width = `${Math.max(0, Math.min(100, Number(info.progress ?? 0) || 0))}%`; + progress.appendChild(fill); + + const footer = document.createElement('div'); + footer.className = 'main-print-strip__footer'; + + const remaining = document.createElement('div'); + remaining.className = 'main-print-strip__remaining'; + remaining.textContent = info.remainingSeconds !== null + ? formatDurationText(info.remainingSeconds) + : '—'; + + footer.appendChild(remaining); + inner.append(header, progress, footer); + card.appendChild(inner); + return card; + } + + function mainBoilerConfig(snapshot = state.snapshot || bootstrap) { + const fromSnapshot = snapshot?.settings?.main_boiler; + const fromBootstrap = bootstrap?.settings?.main_boiler; + const config = (fromSnapshot && typeof fromSnapshot === 'object') + ? fromSnapshot + : ((fromBootstrap && typeof fromBootstrap === 'object') ? fromBootstrap : null); + if (!config) return null; + + const sensorEntityId = String(config.sensor_entity_id || '').trim(); + if (!sensorEntityId) return null; + + return { + title: String(config.title || 'Бойлер'), + sensor_entity_id: sensorEntityId, + history_hours: Math.max(1, Number(config.history_hours || 24) || 24), + }; + } + + function mainPrintConfig(snapshot = state.snapshot || bootstrap) { + const fromSnapshot = snapshot?.settings?.main_print; + const fromBootstrap = bootstrap?.settings?.main_print; + const config = (fromSnapshot && typeof fromSnapshot === 'object') + ? fromSnapshot + : ((fromBootstrap && typeof fromBootstrap === 'object') ? fromBootstrap : null); + if (!config) return null; + + const currentStageEntityId = String(config.current_stage_entity_id || '').trim(); + const printProgressEntityId = String(config.print_progress_entity_id || '').trim(); + const startTimeEntityId = String(config.start_time_entity_id || '').trim(); + const endTimeEntityId = String(config.end_time_entity_id || '').trim(); + if (!currentStageEntityId || !printProgressEntityId || !startTimeEntityId || !endTimeEntityId) { + return null; + } + + return { + title: String(config.title || '').trim(), + current_stage_entity_id: currentStageEntityId, + print_progress_entity_id: printProgressEntityId, + start_time_entity_id: startTimeEntityId, + end_time_entity_id: endTimeEntityId, + }; + } + + function parseDateValue(value) { + const text = String(value ?? '').trim(); + if (!text) return null; + + const numeric = Number(text); + if (Number.isFinite(numeric) && String(Math.trunc(numeric)) === text.replace(/\.0+$/, '')) { + return numeric > 1e12 ? numeric : numeric * 1000; + } + + const parsed = Date.parse(text); + return Number.isFinite(parsed) ? parsed : null; + } + + function formatDurationText(seconds) { + const total = Math.max(0, Math.round(Number(seconds) || 0)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + + if (hours > 0) { + return secs > 0 ? `${hours}ч ${minutes}м ${secs}с` : `${hours}ч ${minutes}м`; + } + if (minutes > 0) { + return secs > 0 ? `${minutes}м ${secs}с` : `${minutes}м`; + } + return `${secs}с`; + } + + function mainPrintState(snapshot = state.snapshot || bootstrap) { + const config = mainPrintConfig(snapshot); + if (!config) return null; + + const stage = getEntityFromSnapshot(snapshot, config.current_stage_entity_id) + || getEntityDefinition(snapshot, config.current_stage_entity_id); + if (!stage || String(stage.state || '').toLowerCase() !== 'printing') { + return null; + } + + const progressEntity = getEntityFromSnapshot(snapshot, config.print_progress_entity_id) + || getEntityDefinition(snapshot, config.print_progress_entity_id); + const startEntity = getEntityFromSnapshot(snapshot, config.start_time_entity_id) + || getEntityDefinition(snapshot, config.start_time_entity_id); + const endEntity = getEntityFromSnapshot(snapshot, config.end_time_entity_id) + || getEntityDefinition(snapshot, config.end_time_entity_id); + + const progressValueRaw = Number(String(progressEntity?.state ?? '').replace(',', '.')); + const progress = Number.isFinite(progressValueRaw) + ? Math.max(0, Math.min(100, progressValueRaw)) + : null; + + const startTs = parseDateValue(startEntity?.state ?? startEntity?.attributes?.value ?? startEntity?.attributes?.timestamp); + const endTs = parseDateValue(endEntity?.state ?? endEntity?.attributes?.value ?? endEntity?.attributes?.timestamp); + const nowTs = Date.now(); + let remainingSeconds = null; + + if (startTs !== null && endTs !== null && endTs > startTs) { + remainingSeconds = Math.max(0, (endTs - nowTs) / 1000); + } else if (endTs !== null) { + remainingSeconds = Math.max(0, (endTs - nowTs) / 1000); + } + + return { + title: String(config.title || '').trim(), + stage: String(stage.state || 'printing'), + progress, + remainingSeconds, + }; + } + + function updateMainPrintStrip(snapshot = state.snapshot || bootstrap) { + if (!els.mainPrintStripSlot) return; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id !== 'main') { + els.mainPrintStripSlot.innerHTML = ''; + return; + } + + els.mainPrintStripSlot.innerHTML = ''; + const printStrip = renderMainPrintCard(snapshot); + if (printStrip) { + els.mainPrintStripSlot.appendChild(printStrip); + } + } + + function formatTemperatureValue(value) { + const next = Number(String(value ?? '').replace(',', '.')); + if (!Number.isFinite(next)) { + return null; + } + + const digits = Math.abs(next % 1) > 0.05 ? 1 : 0; + return new Intl.NumberFormat('ru-RU', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(next); + } + + function normalizeHistoryPoints(payload, fallbackValue = null) { + const raw = Array.isArray(payload?.history) ? payload.history : payload; + const groups = Array.isArray(raw) && raw.length > 0 && Array.isArray(raw[0]) ? raw : [raw]; + const points = []; + + groups.forEach((group) => { + if (!Array.isArray(group)) return; + group.forEach((entry) => { + if (!entry || typeof entry !== 'object') return; + const rawValue = entry.state ?? entry.value ?? null; + const numericValue = Number(String(rawValue ?? '').replace(',', '.')); + if (!Number.isFinite(numericValue)) return; + const timestamp = Date.parse(entry.last_changed || entry.last_updated || ''); + if (!Number.isFinite(timestamp)) return; + points.push({ + timestamp, + value: numericValue, + }); + }); + }); + + points.sort((left, right) => left.timestamp - right.timestamp); + + const deduped = []; + for (const point of points) { + const last = deduped[deduped.length - 1]; + if (last && last.timestamp === point.timestamp) { + deduped[deduped.length - 1] = point; + continue; + } + deduped.push(point); + } + + if (!deduped.length) { + const fallbackNumeric = Number(String(fallbackValue ?? '').replace(',', '.')); + if (Number.isFinite(fallbackNumeric)) { + const now = Date.now(); + return [ + { timestamp: now - 60_000, value: fallbackNumeric }, + { timestamp: now, value: fallbackNumeric }, + ]; + } + } + + return deduped; + } + + function boilerHistoryState(entityId) { + const history = state.mainBoilerHistory || {}; + if (history.entityId !== entityId) { + return []; + } + return Array.isArray(history.points) ? history.points : []; + } + + function renderBoilerSparkline(points) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 240 72'); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('main-boiler-card__chart'); + + const ns = 'http://www.w3.org/2000/svg'; + const defs = document.createElementNS(ns, 'defs'); + const gradient = document.createElementNS(ns, 'linearGradient'); + gradient.setAttribute('id', 'boiler-chart-fill'); + gradient.setAttribute('x1', '0%'); + gradient.setAttribute('x2', '0%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('y2', '100%'); + + const stopTop = document.createElementNS(ns, 'stop'); + stopTop.setAttribute('offset', '0%'); + stopTop.setAttribute('stop-color', 'rgba(255, 186, 92, 0.38)'); + const stopBottom = document.createElementNS(ns, 'stop'); + stopBottom.setAttribute('offset', '100%'); + stopBottom.setAttribute('stop-color', 'rgba(255, 186, 92, 0.02)'); + gradient.append(stopTop, stopBottom); + defs.appendChild(gradient); + svg.appendChild(defs); + + if (!Array.isArray(points) || points.length === 0) { + const line = document.createElementNS(ns, 'path'); + line.setAttribute('d', 'M 0 48 L 240 48'); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', 'rgba(255, 186, 92, 0.28)'); + line.setAttribute('stroke-width', '2'); + svg.appendChild(line); + return svg; + } + + const values = points.map((point) => Number(point.value)).filter((value) => Number.isFinite(value)); + if (!values.length) { + return svg; + } + + let min = Math.min(...values); + let max = Math.max(...values); + if (min === max) { + min -= 0.5; + max += 0.5; + } else { + const padding = Math.max((max - min) * 0.2, 0.3); + min -= padding; + max += padding; + } + + const width = 240; + const height = 72; + const chartTop = 8; + const chartBottom = 60; + const chartHeight = chartBottom - chartTop; + const span = max - min || 1; + const linePoints = points.map((point, index) => { + const ratioX = points.length === 1 ? 0.5 : index / (points.length - 1); + const ratioY = (Number(point.value) - min) / span; + return { + x: ratioX * width, + y: chartBottom - (ratioY * chartHeight), + }; + }); + + const linePath = linePoints + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`) + .join(' '); + + const areaPath = [ + `M 0 ${chartBottom}`, + `L ${linePoints[0].x.toFixed(2)} ${linePoints[0].y.toFixed(2)}`, + ...linePoints.slice(1).map((point) => `L ${point.x.toFixed(2)} ${point.y.toFixed(2)}`), + `L ${width} ${chartBottom}`, + 'Z', + ].join(' '); + + const area = document.createElementNS(ns, 'path'); + area.setAttribute('d', areaPath); + area.setAttribute('fill', 'url(#boiler-chart-fill)'); + area.setAttribute('stroke', 'none'); + svg.appendChild(area); + + const line = document.createElementNS(ns, 'path'); + line.setAttribute('d', linePath); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', '#ffba5c'); + line.setAttribute('stroke-width', '2.4'); + line.setAttribute('stroke-linecap', 'round'); + line.setAttribute('stroke-linejoin', 'round'); + svg.appendChild(line); + + const lastPoint = linePoints[linePoints.length - 1]; + const dot = document.createElementNS(ns, 'circle'); + dot.setAttribute('cx', lastPoint.x.toFixed(2)); + dot.setAttribute('cy', lastPoint.y.toFixed(2)); + dot.setAttribute('r', '3.2'); + dot.setAttribute('fill', '#ffba5c'); + dot.setAttribute('stroke', 'rgba(24, 25, 29, 0.95)'); + dot.setAttribute('stroke-width', '2'); + svg.appendChild(dot); + + return svg; + } + + function scheduleMainBoilerHistoryLoad(snapshot = state.snapshot || bootstrap, force = false) { + const config = mainBoilerConfig(snapshot); + if (!config) { + return Promise.resolve([]); + } + + const cache = state.mainBoilerHistory || {}; + const isSameEntity = cache.entityId === config.sensor_entity_id; + const isFresh = isSameEntity && Array.isArray(cache.points) && cache.points.length > 0 && !force && (Date.now() - Number(cache.loadedAt || 0) < 5 * 60 * 1000); + if (isFresh) { + return Promise.resolve(cache.points); + } + + if (cache.promise && isSameEntity && !force) { + return cache.promise; + } + + const currentEntity = getEntityFromSnapshot(snapshot, config.sensor_entity_id) || getEntityDefinition(snapshot, config.sensor_entity_id); + const currentValue = currentEntity?.state ?? null; + + state.mainBoilerHistory = { + ...cache, + entityId: config.sensor_entity_id, + loading: true, + error: null, + }; + + const promise = apiGet('history', { + entity_id: config.sensor_entity_id, + hours: config.history_hours || 24, + }).then((response) => { + const points = normalizeHistoryPoints(response?.history ?? response, currentValue); + state.mainBoilerHistory = { + entityId: config.sensor_entity_id, + points, + loadedAt: Date.now(), + loading: false, + error: null, + promise: null, + }; + if ((state.selectedRoomId || 'main') === 'main') { + updateMainWeatherCard(); + } + return points; + }).catch((error) => { + state.mainBoilerHistory = { + entityId: config.sensor_entity_id, + points: Array.isArray(cache.points) ? cache.points : normalizeHistoryPoints([], currentValue), + loadedAt: Number(cache.loadedAt || 0), + loading: false, + error: error?.message || String(error), + promise: null, + }; + if ((state.selectedRoomId || 'main') === 'main') { + updateMainWeatherCard(); + } + return state.mainBoilerHistory.points; + }); + + state.mainBoilerHistory.promise = promise; + return promise; + } + + function renderMainBoilerCard(snapshot) { + const config = mainBoilerConfig(snapshot); + if (!config) { + return null; + } + + const entity = getEntityFromSnapshot(snapshot, config.sensor_entity_id) || getEntityDefinition(snapshot, config.sensor_entity_id); + const currentValue = formatTemperatureValue(entity?.state); + const historyPoints = boilerHistoryState(config.sensor_entity_id); + const history = state.mainBoilerHistory || {}; + + const card = document.createElement('article'); + card.className = 'grid-card main-boiler-card'; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner main-boiler-card__inner'; + + const header = document.createElement('div'); + header.className = 'main-boiler-card__header'; + + const text = document.createElement('div'); + text.className = 'main-boiler-card__text'; + + const eyebrow = document.createElement('div'); + eyebrow.className = 'main-boiler-card__eyebrow'; + eyebrow.textContent = 'Температура бойлера'; + text.appendChild(eyebrow); + + const range = document.createElement('div'); + range.className = 'main-boiler-card__range'; + range.textContent = '24 часа'; + header.append(text, range); + + const body = document.createElement('div'); + body.className = 'main-boiler-card__body'; + + const valueColumn = document.createElement('div'); + valueColumn.className = 'main-boiler-card__value-column'; + + const valueLabel = document.createElement('div'); + valueLabel.className = 'main-boiler-card__value-label'; + valueLabel.textContent = 'Сейчас'; + + const valueRow = document.createElement('div'); + valueRow.className = 'main-boiler-card__value-row'; + + const value = document.createElement('div'); + value.className = 'main-boiler-card__value'; + value.textContent = currentValue || '—'; + + const unit = document.createElement('div'); + unit.className = 'main-boiler-card__unit'; + unit.textContent = '°C'; + + valueRow.append(value, unit); + valueColumn.append(valueLabel, valueRow); + + const chartWrap = document.createElement('div'); + chartWrap.className = 'main-boiler-card__chart-wrap'; + if (history.loading && (!historyPoints || !historyPoints.length)) { + chartWrap.classList.add('is-loading'); + } + chartWrap.appendChild(renderBoilerSparkline(historyPoints)); + + if (history.loading && (!historyPoints || !historyPoints.length)) { + const loading = document.createElement('div'); + loading.className = 'main-boiler-card__loading'; + loading.textContent = 'Загружаем график...'; + chartWrap.appendChild(loading); + } + + body.append(valueColumn, chartWrap); + inner.append(header, body); + card.appendChild(inner); + scheduleMainBoilerHistoryLoad(snapshot); + return card; + } + + function renderMainHero(snapshot) { + const hero = document.createElement('div'); + hero.className = 'main-dashboard__hero'; + + const weatherSlot = document.createElement('div'); + weatherSlot.className = 'main-dashboard__weather-slot'; + if (snapshot.weather) { + weatherSlot.appendChild(mainWeatherCard(snapshot.weather)); + } + hero.appendChild(weatherSlot); + + const stack = document.createElement('div'); + stack.className = 'main-dashboard__hero-stack'; + + const actions = renderMainWeatherActions(snapshot); + if (actions) { + stack.appendChild(actions); + } + + const boiler = renderMainBoilerCard(snapshot); + if (boiler) { + stack.appendChild(boiler); + } + + if (!stack.childNodes.length) { + const spacer = document.createElement('div'); + spacer.className = 'main-dashboard__hero-spacer'; + stack.appendChild(spacer); + } + + hero.appendChild(stack); + + return hero; + } + + function serviceValueForCommand(command, value) { + if (value === null || value === undefined || value === '') { + return null; + } + if (command === 'set_position' || command === 'set_temperature') { + return Number(value); + } + return value; + } + + function climateOptionButtons(entity, attrName, command, title) { + const values = Array.isArray(entity.attributes?.[attrName]) ? entity.attributes[attrName] : []; + if (!values.length) return null; + + const section = document.createElement('div'); + section.className = 'entity-modal__options-block'; + + const heading = document.createElement('div'); + heading.className = 'entity-modal__options-title'; + heading.textContent = climateGroupTitle(attrName) || title; + section.appendChild(heading); + + const list = document.createElement('div'); + list.className = 'entity-modal__chips'; + const current = String(entity.attributes?.[command.replace('set_', '')] || entity.attributes?.hvac_mode || entity.attributes?.fan_mode || entity.attributes?.swing_mode || entity.attributes?.preset_mode || '').toLowerCase(); + + values.forEach((value) => { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = `entity-chip ${String(value).toLowerCase() === current ? 'is-active' : ''}`; + chip.textContent = climateOptionLabel(attrName, value); + chip.title = String(value); + chip.addEventListener('click', () => { + handleClimateCommand(entity, command, value); + }); + list.appendChild(chip); + }); + + section.appendChild(list); + return section; + } + + function renderCoverPopup(entity) { + const wrap = document.createElement('div'); + wrap.className = 'entity-modal__cover'; + + const rail = document.createElement('div'); + rail.className = 'entity-modal__rail entity-modal__rail--cover'; + + const initialValue = coverPositionValue(entity); + + const valueRow = document.createElement('div'); + valueRow.className = 'entity-modal__cover-meta'; + + const label = document.createElement('div'); + label.className = 'entity-modal__cover-label'; + label.textContent = 'Открыт на'; + + const value = document.createElement('div'); + value.className = 'entity-modal__cover-value'; + value.textContent = `${initialValue}%`; + valueRow.append(label, value); + + const actions = document.createElement('div'); + actions.className = 'entity-modal__actions entity-modal__actions--vertical'; + + const openBtn = createButton('Открыть', null, 'mdi:arrow-up', 'mushroom-button mushroom-button--small mushroom-button--square'); + openBtn.addEventListener('click', () => handleEntityAction(entity, 'open')); + + const stopBtn = createButton('Стоп', null, 'mdi:stop', 'mushroom-button mushroom-button--small mushroom-button--square'); + stopBtn.addEventListener('click', () => handleEntityAction(entity, 'stop')); + + const closeBtn = createButton('Закрыть', null, 'mdi:arrow-down', 'mushroom-button mushroom-button--small mushroom-button--square'); + closeBtn.addEventListener('click', () => handleEntityAction(entity, 'close')); + + actions.append(openBtn, stopBtn, closeBtn); + + const progress = document.createElement('div'); + progress.className = 'entity-modal__cover-track'; + progress.tabIndex = 0; + progress.setAttribute('role', 'slider'); + progress.setAttribute('aria-label', 'Позиция жалюзи'); + progress.setAttribute('aria-valuemin', '0'); + progress.setAttribute('aria-valuemax', '100'); + + const fill = document.createElement('div'); + fill.className = 'entity-modal__cover-fill'; + fill.style.height = `${initialValue}%`; + fill.style.bottom = '0'; + fill.style.width = '100%'; + progress.appendChild(fill); + + const handle = document.createElement('div'); + handle.className = 'entity-modal__cover-handle'; + progress.appendChild(handle); + + let currentValue = initialValue; + const syncValue = (nextValue) => { + currentValue = Math.max(0, Math.min(100, Math.round(nextValue))); + fill.style.height = `${currentValue}%`; + handle.style.bottom = `calc(${currentValue}% - 10px)`; + value.textContent = `${currentValue}%`; + progress.setAttribute('aria-valuenow', String(currentValue)); + progress.setAttribute('aria-valuetext', `${currentValue}%`); + }; + syncValue(initialValue); + + const updateFromPointer = (clientY) => { + const rect = progress.getBoundingClientRect(); + const ratio = 1 - ((clientY - rect.top) / rect.height); + const nextValue = Math.max(0, Math.min(100, Math.round(ratio * 100))); + syncValue(nextValue); + }; + + let dragPointerId = null; + const onPointerMove = (event) => { + if (dragPointerId !== event.pointerId) return; + event.preventDefault(); + updateFromPointer(event.clientY); + }; + const onPointerUp = (event) => { + if (dragPointerId !== event.pointerId) return; + event.preventDefault(); + progress.releasePointerCapture?.(dragPointerId); + dragPointerId = null; + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); + handleCoverPosition(entity, currentValue); + }; + + progress.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + dragPointerId = event.pointerId; + progress.setPointerCapture?.(dragPointerId); + updateFromPointer(event.clientY); + window.addEventListener('pointermove', onPointerMove, { passive: false }); + window.addEventListener('pointerup', onPointerUp, { passive: false }); + window.addEventListener('pointercancel', onPointerUp, { passive: false }); + }); + + progress.addEventListener('keydown', (event) => { + const step = event.shiftKey ? 10 : 5; + if (event.key === 'ArrowUp' || event.key === 'ArrowRight') { + event.preventDefault(); + syncValue(currentValue + step); + handleCoverPosition(entity, currentValue); + } else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') { + event.preventDefault(); + syncValue(currentValue - step); + handleCoverPosition(entity, currentValue); + } else if (event.key === 'Home') { + event.preventDefault(); + syncValue(0); + handleCoverPosition(entity, 0); + } else if (event.key === 'End') { + event.preventDefault(); + syncValue(100); + handleCoverPosition(entity, 100); + } + }); + + rail.append(valueRow, progress); + wrap.append(rail, actions); + return wrap; + } + + function renderClimatePopup(entity) { + const wrap = document.createElement('div'); + wrap.className = 'entity-modal__climate'; + + const tempBlock = document.createElement('div'); + tempBlock.className = 'entity-modal__climate-summary'; + tempBlock.innerHTML = ` +
Текущая температура
+
${esc(entity.attributes?.current_temperature ?? '—')}°C
+
+
${esc(climateStateLabel(entity.attributes?.hvac_action || entity.state || '—'))}
+
${esc(entity.attributes?.temperature ?? '—')}°C
+
+ `; + + const controls = document.createElement('div'); + controls.className = 'entity-modal__temperature-controls'; + + const minus = document.createElement('button'); + minus.type = 'button'; + minus.className = 'round-button entity-modal__round-button'; + minus.innerHTML = ''; + minus.addEventListener('click', () => handleClimateTemperature(entity, -1)); + + const plus = document.createElement('button'); + plus.type = 'button'; + plus.className = 'round-button entity-modal__round-button'; + plus.innerHTML = ''; + plus.addEventListener('click', () => handleClimateTemperature(entity, 1)); + + controls.append(minus, plus); + + const modes = document.createElement('div'); + modes.className = 'entity-modal__modes'; + [ + ['hvac_modes', 'Режим', 'set_hvac_mode'], + ['fan_modes', 'Вентилятор', 'set_fan_mode'], + ['swing_modes', 'Качание', 'set_swing_mode'], + ['preset_modes', 'Предустановки', 'set_preset_mode'], + ].forEach(([attrName, title, command]) => { + const block = climateOptionButtons(entity, attrName, command, title); + if (block) { + modes.appendChild(block); + } + }); + + wrap.append(tempBlock, controls, modes); + return wrap; + } + + function climateGroupTitle(attrName) { + const key = String(attrName || '').toLowerCase(); + switch (key) { + case 'hvac_modes': + return 'Режим'; + case 'fan_modes': + return 'Вентилятор'; + case 'swing_modes': + return 'Качание'; + case 'preset_modes': + return 'Предустановки'; + default: + return 'Режим'; + } + } + + function climateOptionLabel(attrName, value) { + const key = String(value ?? '').trim().toLowerCase(); + const normalized = key.replace(/\s+/g, '_'); + + const maps = { + hvac_modes: { + off: 'Выключено', + auto: 'Авто', + cool: 'Охлаждение', + heat: 'Обогрев', + dry: 'Осушение', + fan_only: 'Только вентилятор', + heat_cool: 'Авто', + eco: 'Эко', + away: 'Вне дома', + sleep: 'Сон', + }, + fan_modes: { + auto: 'Авто', + low: 'Низкая', + low_mid: 'Ниже средней', + low_medium: 'Ниже средней', + medium: 'Средняя', + mid: 'Средняя', + mid_high: 'Выше средней', + high: 'Высокая', + turbo: 'Турбо', + diffuse: 'Рассеянный', + }, + swing_modes: { + off: 'Выкл', + top: 'Верх', + middletop1: 'Верх 1', + middletop2: 'Верх 2', + middlebottom2: 'Низ 2', + middlebottom1: 'Низ 1', + bottom: 'Низ', + swing: 'Авто', + auto: 'Авто', + }, + preset_modes: { + none: 'Нет', + sleep: 'Сон', + boost: 'Турбо', + eco: 'Эко', + away: 'Вне дома', + home: 'Дома', + comfort: 'Комфорт', + quiet: 'Тихо', + }, + }; + + const map = maps[String(attrName || '').toLowerCase()] || {}; + if (map[normalized]) { + return map[normalized]; + } + + return String(value ?? '').replace(/_/g, ' '); + } + + function climateStateLabel(value) { + const key = String(value ?? '').trim().toLowerCase(); + const labels = { + off: 'Выключено', + idle: 'Ожидание', + auto: 'Авто', + cool: 'Охлаждение', + heating: 'Нагрев', + heat: 'Обогрев', + cooling: 'Охлаждение', + dry: 'Осушение', + fan: 'Вентиляция', + heat_cool: 'Авто', + on: 'Включено', + }; + return labels[key] || String(value ?? '—').replace(/_/g, ' '); + } + + function coverPositionValue(entity) { + const currentPosition = Number(entity?.attributes?.current_position); + if (Number.isFinite(currentPosition)) { + return Math.max(0, Math.min(100, Math.round(currentPosition))); + } + + const state = String(entity?.state || '').toLowerCase(); + return state === 'open' || state === 'opening' ? 100 : 0; + } + + async function handleEntityService(entity, command, value = null, patch = null) { + try { + if (patch) { + patchSnapshotEntity(entity.entity_id, patch); + } + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + refreshCurrentRoomLayout(entity.entity_id); + await apiPost('service', { + entity_id: entity.entity_id, + command, + ...(value !== null && value !== undefined ? { value } : {}), + }); + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + } catch (error) { + console.error(error); + setStatus('Ошибка команды', 'error'); + } + } + + function handleCoverPosition(entity, value) { + const next = normalizePositionValue(value); + if (next === null) return; + const currentPosition = Number(entity.attributes?.current_position); + let stateValue = undefined; + if (next === 0) { + stateValue = 'closed'; + } else if (next === 100) { + stateValue = 'open'; + } else if (Number.isFinite(currentPosition)) { + stateValue = next >= currentPosition ? 'opening' : 'closing'; + } + const patch = { + attributes: { + ...(entity.attributes || {}), + current_position: next, + }, + }; + if (stateValue) { + patch.state = stateValue; + } + handleEntityService(entity, 'set_position', next, patch); + } + + async function handleMainWeatherAction(action) { + const snapshot = state.snapshot || bootstrap; + const entityId = String(action?.entity_id || '').trim(); + if (!entityId) return; + + const command = String(action?.command || 'set_temperature').trim() || 'set_temperature'; + const value = action?.value; + const stateEntityId = String(action?.state_entity_id || entityId).trim() || entityId; + const stateEntity = getEntityFromSnapshot(snapshot, stateEntityId) || getEntityDefinition(snapshot, stateEntityId); + const targetEntity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + const nextValue = value !== null && value !== undefined && value !== '' ? String(value) : ''; + + if (stateEntity) { + const nextAttributes = { ...(stateEntity.attributes || {}) }; + if (command === 'set_temperature' && value !== null && value !== undefined && value !== '') { + nextAttributes.temperature = Number(value); + } + const patch = { + attributes: nextAttributes, + }; + if (stateEntityId !== entityId || command !== 'set_temperature') { + patch.state = nextValue || stateEntity.state; + } + patchSnapshotEntity(stateEntityId, patch); + } + + if (targetEntity && targetEntity.entity_id !== stateEntityId) { + const nextAttributes = { ...(targetEntity.attributes || {}) }; + if (command === 'set_temperature' && value !== null && value !== undefined && value !== '') { + nextAttributes.temperature = Number(value); + } + patchSnapshotEntity(targetEntity.entity_id, { + attributes: nextAttributes, + }); + } + + renderDashboardOnly(); + + try { + await apiPost('service', { + entity_id: entityId, + command, + ...(value !== null && value !== undefined ? { value } : {}), + }); + } catch (error) { + console.error(error); + setStatus('Ошибка команды', 'error'); + } + } + + function handleClimateCommand(entity, command, value) { + const patch = {}; + if (command === 'set_hvac_mode') { + patch.attributes = { + ...(entity.attributes || {}), + hvac_mode: value, + }; + } else if (command === 'set_fan_mode') { + patch.attributes = { + ...(entity.attributes || {}), + fan_mode: value, + }; + } else if (command === 'set_swing_mode') { + patch.attributes = { + ...(entity.attributes || {}), + swing_mode: value, + }; + } else if (command === 'set_preset_mode') { + patch.attributes = { + ...(entity.attributes || {}), + preset_mode: value, + }; + } + handleEntityService(entity, command, value, Object.keys(patch).length ? patch : null); + } + + function renderToggleCard(entity, { isMain = false } = {}) { + const card = document.createElement('article'); + const active = ['on', 'open', 'cool', 'heat', 'heating', 'cooling'].includes(String(entity.state).toLowerCase()); + const isDoorContact = Boolean(entity.is_door_contact); + card.className = `grid-card ${!isDoorContact ? 'grid-card--tap ' : ''}${isMain ? 'grid-card--auto' : 'grid-card--entity'} ${isDoorContact ? 'grid-card--door' : ''} ${active ? 'is-active' : ''}`; + card.dataset.entityId = entity.entity_id; + if (!isDoorContact) { + card.dataset.clickToggle = 'true'; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + } + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner'; + + const icon = document.createElement('div'); + icon.className = `grid-card__icon${isMain && active && !isDoorContact ? ' grid-card__icon--active' : ''}${isDoorContact ? ' grid-card__icon--door' : ''}`; + icon.appendChild(createIconElement(entity.icon)); + + const text = buildEntityTitle(entity.name); + + const left = document.createElement('div'); + left.className = 'grid-card__header'; + left.append(icon, text); + if (state.editMode) { + left.appendChild(renderEntityTypeLabel(entity)); + } + inner.appendChild(left); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + card.appendChild(inner); + if (!isDoorContact) { + card.addEventListener('click', (event) => { + if (event.target.closest('button')) return; + handleEntityAction(entity, 'toggle'); + }); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleEntityAction(entity, 'toggle'); + } + }); + } + return card; + } + + function renderCoverCard(entity, options = {}) { + const card = document.createElement('article'); + const currentPosition = coverPositionValue(entity); + const coverState = String(entity.state).toLowerCase(); + const isOpen = ['open', 'opening'].includes(coverState); + const hasVisiblePosition = currentPosition > 0 || isOpen; + card.className = `grid-card grid-card--cover ${!options.isMain && isOpen ? 'is-active' : ''}`; + card.dataset.entityId = entity.entity_id; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner cover-card'; + + const icon = document.createElement('div'); + icon.className = `grid-card__icon${options.isMain && isOpen ? ' grid-card__icon--active' : ''}`; + icon.appendChild(createIconElement(entity.icon)); + + const text = buildEntityTitle(entity.name); + + const left = document.createElement('div'); + left.className = 'grid-card__header'; + left.append(icon, text); + if (state.editMode) { + left.appendChild(renderEntityTypeLabel(entity)); + } + + const rail = document.createElement('div'); + rail.className = 'cover-card__rail'; + + if (hasVisiblePosition) { + const progress = document.createElement('div'); + progress.className = 'cover-progress'; + const bar = document.createElement('div'); + bar.className = 'cover-progress__value'; + const pos = currentPosition > 0 ? currentPosition : 100; + bar.style.width = `${Math.max(0, Math.min(100, pos))}%`; + progress.appendChild(bar); + + if (!options.isMain) { + rail.append(progress); + inner.append(left, rail); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + } else { + inner.append(left, progress); + } + } else { + inner.append(left); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + } + + card.appendChild(inner); + card.addEventListener('click', async (event) => { + if (event.target.closest('button')) return; + if (options.isMain) { + const confirmed = await openConfirm({ + title: 'Хотите закрыть?', + message: `${entity.name} будет закрыт.`, + }); + if (confirmed) { + handleEntityAction(entity, 'close'); + } + return; + } + openEntityPopup(entity.entity_id); + }); + card.addEventListener('keydown', async (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + if (options.isMain) { + const confirmed = await openConfirm({ + title: 'Хотите закрыть?', + message: `${entity.name} будет закрыт.`, + }); + if (confirmed) { + handleEntityAction(entity, 'close'); + } + return; + } + openEntityPopup(entity.entity_id); + }); + return card; + } + + function renderClimateCard(entity, options = {}) { + const card = document.createElement('article'); + const active = !['off', 'unavailable', 'unknown'].includes(String(entity.state).toLowerCase()); + card.className = `grid-card grid-card--climate grid-card--tap ${!options.isMain && active ? 'is-active' : ''}`; + card.dataset.entityId = entity.entity_id; + card.dataset.clickToggle = 'true'; + card.tabIndex = 0; + card.setAttribute('role', 'button'); + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner climate-card'; + + const icon = document.createElement('div'); + icon.className = `grid-card__icon${options.isMain && active ? ' grid-card__icon--active' : ''}`; + icon.appendChild(createIconElement(entity.icon)); + + const text = buildEntityTitle(entity.name); + + const left = document.createElement('div'); + left.className = 'grid-card__header'; + left.append(icon, text); + if (state.editMode) { + left.appendChild(renderEntityTypeLabel(entity)); + } + + const tempMeta = document.createElement('div'); + tempMeta.className = 'climate-card__meta'; + tempMeta.innerHTML = ` +
${esc(entity.attributes?.temperature ?? '—')}°
+
Сейчас ${esc(entity.attributes?.current_temperature ?? '—')}°
+ `; + + const topRow = document.createElement('div'); + topRow.className = 'climate-card__top'; + topRow.append(left, tempMeta); + + inner.append(topRow); + if (state.editMode) { + inner.appendChild(renderEditActions(entity)); + } + card.appendChild(inner); + card.addEventListener('click', (event) => { + if (event.target.closest('button')) return; + if (options.isMain) { + handleEntityAction(entity, active ? 'turn_off' : 'turn_on'); + return; + } + openEntityPopup(entity.entity_id); + }); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (options.isMain) { + handleEntityAction(entity, active ? 'turn_off' : 'turn_on'); + return; + } + openEntityPopup(entity.entity_id); + } + }); + return card; + } + + function renderEditActions(entity) { + const wrap = document.createElement('div'); + wrap.className = 'grid-card__footer grid-card__footer--edit'; + + const hidden = entity.visible === false || entity.override?.visible === false; + + const hideBtn = document.createElement('button'); + hideBtn.type = 'button'; + hideBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + hideBtn.textContent = hidden ? 'Показать' : 'Скрыть'; + hideBtn.addEventListener('click', (event) => { + event.stopPropagation(); + saveOverridePatch(entity, { visible: hidden ? true : false }); + }); + + if (hidden) { + wrap.append(hideBtn); + return wrap; + } + + const actions = document.createElement('div'); + actions.className = 'grid-card__footer-actions'; + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'mushroom-button mushroom-button--small'; + upBtn.innerHTML = ' Вверх'; + upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1)); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'mushroom-button mushroom-button--small'; + downBtn.innerHTML = ' Вниз'; + downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1)); + + actions.append(upBtn, downBtn); + wrap.append(hideBtn, actions); + return wrap; + } + + function renderRoomEditActions(room) { + const wrap = document.createElement('div'); + wrap.className = 'room-item__mini-actions'; + + const hidden = room.visible === false; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'mini-action mini-action--wide'; + btn.innerHTML = hidden ? '' : ''; + btn.title = hidden ? 'Показать' : 'Скрыть'; + btn.addEventListener('click', (event) => { + event.stopPropagation(); + saveSpacePatch(room, { visible: hidden ? true : false }); + }); + + wrap.appendChild(btn); + return wrap; + } + + function wireRoomItemDragEvents(item, room, groupEl, hidden) { + if (!state.editMode || room.id === 'main' || room.virtual || room.id === 'batteries') return; + + item.draggable = true; + item.addEventListener('pointerdown', (event) => { + if (event.target.closest('button')) return; + startRoomDrag(room, item, groupEl, hidden, event); + }); + item.addEventListener('pointermove', (event) => { + if (!state.roomDrag || state.roomDrag.roomId !== room.id) return; + moveRoomDrag(event.clientX, event.clientY); + }); + item.addEventListener('pointerup', async (event) => { + if (!state.roomDrag || state.roomDrag.roomId !== room.id) return; + try { + await finishRoomDrag(); + } catch (error) { + console.warn(error); + } + event.preventDefault(); + }); + item.addEventListener('pointercancel', async () => { + if (!state.roomDrag || state.roomDrag.roomId !== room.id) return; + try { + await finishRoomDrag(); + } catch (error) { + console.warn(error); + } + }); + item.addEventListener('dragstart', (event) => { + event.preventDefault(); + }); + } + + function renderEntityCard(entity, options = {}) { + const type = entity.card_type || entity.domain; + const card = type === 'cover' + ? renderCoverCard(entity, options) + : type === 'climate' + ? renderClimateCard(entity, options) + : renderToggleCard(entity, options); + + if (entity.visible === false) { + card.classList.add('is-hidden'); + } + if (state.editMode) { + card.classList.add('is-editing'); + } + + return card; + } + + function renderLayoutCard(item, room) { + const card = document.createElement('article'); + card.className = 'grid-card grid-card--ghost'; + card.dataset.layoutItemId = item.id; + card.tabIndex = state.editMode ? 0 : -1; + + if (state.editMode) { + card.classList.add('is-editing'); + } + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner grid-card__ghost-inner'; + + if (state.editMode) { + const header = document.createElement('div'); + header.className = 'grid-card__header'; + + const icon = document.createElement('div'); + icon.className = 'grid-card__icon grid-card__icon--ghost'; + icon.appendChild(createIconElement('mdi:checkbox-blank-outline')); + + const title = document.createElement('div'); + title.className = 'grid-card__title'; + title.innerHTML = 'Пустая карточка'; + + const subtitle = document.createElement('div'); + subtitle.className = 'grid-card__subtitle'; + subtitle.textContent = 'Свободное место для раскладки плиток'; + + header.append(icon, title, subtitle); + inner.appendChild(header); + inner.appendChild(renderLayoutItemEditActions(room, item)); + } + + card.appendChild(inner); + return card; + } + + function renderLayoutItemEditActions(room, item) { + const wrap = document.createElement('div'); + wrap.className = 'grid-card__footer grid-card__footer--edit'; + + const actions = document.createElement('div'); + actions.className = 'grid-card__footer-actions'; + + const isSettingsOpen = Boolean(state.layoutItemSettingsOpen?.[item.id]); + + const settingsBtn = document.createElement('button'); + settingsBtn.type = 'button'; + settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + settingsBtn.innerHTML = ' Настройки'; + settingsBtn.addEventListener('click', (event) => { + event.stopPropagation(); + state.layoutItemSettingsOpen = { + ...(state.layoutItemSettingsOpen || {}), + [item.id]: !isSettingsOpen, + }; + renderDashboardOnly(); + }); + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'mushroom-button mushroom-button--small'; + upBtn.innerHTML = ' Вверх'; + upBtn.addEventListener('click', (event) => { + event.stopPropagation(); + reorderRoomGridEntry(room.id, 'layout', item.id, -1); + }); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'mushroom-button mushroom-button--small'; + downBtn.innerHTML = ' Вниз'; + downBtn.addEventListener('click', (event) => { + event.stopPropagation(); + reorderRoomGridEntry(room.id, 'layout', item.id, 1); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + deleteBtn.innerHTML = ' Удалить'; + deleteBtn.addEventListener('click', (event) => { + event.stopPropagation(); + deleteRoomLayoutItem(room.id, item.id); + }); + + actions.append(upBtn, downBtn, settingsBtn); + wrap.append(actions, deleteBtn); + + if (isSettingsOpen) { + const settings = document.createElement('div'); + settings.className = 'grid-card__layout-settings'; + + const tempBtn = document.createElement('button'); + tempBtn.type = 'button'; + tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + tempBtn.innerHTML = ' Выбрать датчик температуры'; + tempBtn.addEventListener('click', (event) => { + event.stopPropagation(); + openTemperatureSensorPopup(room.id); + }); + + settings.appendChild(tempBtn); + wrap.appendChild(settings); + } + + return wrap; + } + + async function reorderRoomGridEntry(roomId, kind, entryId, direction) { + const nextRoomId = roomId || currentRoom()?.id || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !entryId || !direction || isMobileViewport()) { + return null; + } + + try { + const snapshot = state.snapshot || bootstrap; + const entries = roomGridEntries(snapshot, nextRoomId); + const currentIndex = entries.findIndex((entry) => entry.kind === kind && entry.id === entryId); + if (currentIndex < 0) { + return null; + } + + const targetIndex = currentIndex + direction; + if (targetIndex < 0 || targetIndex >= entries.length) { + return null; + } + + const reordered = entries.slice(); + const [moved] = reordered.splice(currentIndex, 1); + reordered.splice(targetIndex, 0, moved); + + await apiPost('reorder-room-grid', { + room_id: nextRoomId, + entries: reordered.map((entry) => ({ + kind: entry.kind, + id: entry.id, + })), + }); + + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + + return null; + } + + function renderBatteryCard(item) { + const status = String(item.battery_status || 'unknown'); + const card = document.createElement('article'); + card.className = `grid-card battery-card battery-card--${status}`; + card.dataset.entityId = item.entity_id; + card.dataset.batteryStatus = status; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner battery-card__inner'; + + const main = document.createElement('div'); + main.className = 'battery-card__main'; + + const icon = document.createElement('div'); + icon.className = 'battery-card__icon'; + icon.appendChild(createIconElement(item.battery_icon || 'mdi:battery-outline')); + + const text = document.createElement('div'); + text.className = 'battery-card__text'; + + const title = document.createElement('div'); + title.className = 'battery-card__title'; + title.textContent = item.name || item.entity_id; + + const source = document.createElement('div'); + source.className = 'battery-card__source'; + source.textContent = item.source_text || [item.source_room_name, item.source_device_name].filter(Boolean).join(' | ') || 'Без комнаты'; + + text.append(title, source); + main.append(icon, text); + + const side = document.createElement('div'); + side.className = 'battery-card__side'; + + const percent = document.createElement('div'); + percent.className = 'battery-card__percent'; + percent.textContent = item.battery_percent_text || item.battery_status_label || '—'; + + const statusLabel = document.createElement('div'); + statusLabel.className = 'battery-card__status'; + statusLabel.textContent = item.battery_status_label || 'Неизвестно'; + + side.append(percent, statusLabel); + + const footer = document.createElement('div'); + footer.className = 'battery-card__footer'; + footer.textContent = item.forecast_text + || item.forecast_reason + || (status === 'ok' ? 'Прогноз недоступен' : item.battery_status_label || ''); + const hasFooter = Boolean(footer.textContent); + + inner.append(main, side); + if (hasFooter) { + inner.appendChild(footer); + } + card.appendChild(inner); + return card; + } + + function renderRoomButtons(snapshot, rooms, batteryRoom = null) { + els.roomList.innerHTML = ''; + const sortedRooms = [...(rooms || [])].sort((left, right) => { + if (left.id === 'main') return -1; + if (right.id === 'main') return 1; + + const leftOrder = Number(left.order ?? 9999); + const rightOrder = Number(right.order ?? 9999); + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + const leftFloor = Number(left.floor_level ?? 0); + const rightFloor = Number(right.floor_level ?? 0); + if (leftFloor !== rightFloor) { + return leftFloor - rightFloor; + } + + return String(left.name || '').localeCompare(String(right.name || ''), 'ru'); + }); + + const visibleRooms = sortedRooms.filter((room) => room.id === 'main' || room.visible !== false); + const hiddenRooms = sortedRooms.filter((room) => room.id !== 'main' && room.visible === false); + + const visibleGroup = document.createElement('div'); + visibleGroup.className = 'room-list__group room-list__group--visible'; + let hiddenGroup = null; + + const renderItem = (room, hidden = false) => { + const item = document.createElement('div'); + item.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${hidden ? 'is-hidden-room' : ''} ${state.editMode && !room.virtual ? 'is-editing' : ''}`; + item.dataset.roomId = room.id; + item.dataset.roomGroup = hidden ? 'hidden' : 'visible'; + item.tabIndex = 0; + item.setAttribute('role', 'button'); + item.addEventListener('click', (event) => { + if (event.target.closest('button')) return; + if (state.roomDrag?.moved && state.roomDrag?.roomId === room.id && Date.now() < (state.roomDrag?.suppressClickUntil || 0)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + setSelectedRoom(room.id); + }); + item.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedRoom(room.id); + } + }); + + const content = document.createElement('div'); + content.className = 'room-item__content'; + + const icon = document.createElement('div'); + icon.className = 'room-item__icon'; + icon.appendChild(createIconElement(room.icon || 'mdi:home-variant')); + + const body = document.createElement('div'); + body.className = 'room-item__body'; + const activeCount = room.id === 'batteries' + ? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0 + : Number(room.active_entity_count ?? room.entity_count ?? 0) || 0; + const metaText = room.id === 'main' + ? 'Главный экран' + : room.id === 'batteries' + ? (room.battery_summary_text || `${room.entity_count || 0} батареек`) + : activeCount > 0 + ? `${activeCount} ${pluralizeActiveEntities(activeCount)}` + : 'Нет активных'; + body.innerHTML = ` +
${esc(room.name)}
+
${metaText}
+ `; + content.append(icon, body); + + const tempBadge = roomTemperatureBadge(snapshot, room); + if (tempBadge) { + item.classList.add('has-temp'); + const temp = document.createElement('div'); + temp.className = 'room-item__temp'; + temp.textContent = tempBadge; + item.appendChild(temp); + } + + item.append(content); + if (state.editMode && room.id !== 'main' && !room.virtual && room.id !== 'batteries') { + item.appendChild(renderRoomEditActions(room)); + } + wireRoomItemDragEvents(item, room, hidden ? hiddenGroup : visibleGroup, hidden); + return item; + }; + + visibleRooms.forEach((room) => { + visibleGroup.appendChild(renderItem(room, false)); + }); + + if (batteryRoom && !isMobileViewport()) { + visibleGroup.appendChild(renderItem(batteryRoom, false)); + } + + els.roomList.appendChild(visibleGroup); + + if (state.editMode && hiddenRooms.length) { + const divider = document.createElement('div'); + divider.className = 'room-list__divider'; + + const label = document.createElement('div'); + label.className = 'room-list__divider-label'; + label.textContent = 'Скрытые'; + + divider.append(label); + els.roomList.appendChild(divider); + + hiddenGroup = document.createElement('div'); + hiddenGroup.className = 'room-list__group room-list__group--hidden'; + hiddenRooms.forEach((room) => { + hiddenGroup.appendChild(renderItem(room, true)); + }); + els.roomList.appendChild(hiddenGroup); + } + } + + function renderSelectedRoom(snapshot) { + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (els.contentTop) { + els.contentTop.classList.toggle('is-main', room.id === 'main'); + } + if (els.contentHeader) { + els.contentHeader.classList.toggle('hidden', room.id === 'main' && !isMobileRoomView()); + } + updateMainPrintStrip(snapshot); + if (room.id === 'batteries') { + els.selectedRoomEyebrow.textContent = 'Псевдо-комната'; + els.selectedRoomTitle.textContent = room.name || 'Батарейки'; + const total = Number(room.entity_count ?? 0) || 0; + const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0; + const unavailable = Number(room.unavailable_count ?? 0) || 0; + const unknown = Number(room.unknown_count ?? 0) || 0; + const summaryParts = []; + if (critical > 0) { + summaryParts.push(`${critical} ${pluralizeRu(critical, 'проблемная', 'проблемных', 'проблемных')}`); + } + if (unavailable > 0) { + summaryParts.push(`${unavailable} ${pluralizeRu(unavailable, 'недоступная', 'недоступных', 'недоступных')}`); + } + if (unknown > 0) { + summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`); + } + els.selectedRoomMeta.textContent = summaryParts.length + ? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}` + : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`; + renderSelectedRoomActions(snapshot); + return; + } + if (room.id !== 'main') { + els.selectedRoomEyebrow.textContent = 'Пространство'; + els.selectedRoomTitle.textContent = room.name || 'Панель'; + const entities = roomEntities(snapshot, room.id || 'main'); + const activeCount = Number(room.active_entity_count ?? entities.length) || 0; + els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`; + renderSelectedRoomActions(snapshot); + return; + } + + const entities = roomEntities(snapshot, room.id || 'main'); + els.selectedRoomEyebrow.textContent = ''; + els.selectedRoomTitle.textContent = room.name || 'Панель'; + els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`; + renderSelectedRoomActions(snapshot); + } + + function renderSelectedRoomActions(snapshot) { + if (!els.selectedRoomActions) return; + + const room = snapshot.selected_space || snapshot.selected_room || {}; + els.selectedRoomActions.replaceChildren(); + + if (isMobileViewport() || !state.editMode || room.id === 'main' || room.id === 'batteries') { + return; + } + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; + addButton.innerHTML = 'Пустая карточка'; + addButton.addEventListener('click', () => { + createRoomLayoutItem(room.id); + }); + + const temperatureButton = document.createElement('button'); + temperatureButton.type = 'button'; + temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; + temperatureButton.innerHTML = 'Выбрать датчик температуры'; + temperatureButton.addEventListener('click', () => { + openTemperatureSensorPopup(room.id); + }); + + els.selectedRoomActions.append(addButton, temperatureButton); + } + + function renderDashboard(snapshot) { + const room = snapshot.selected_space || snapshot.selected_room || {}; + const grid = els.dashboardSurface; + grid.innerHTML = ''; + + if (room.id === 'main') { + const layout = document.createElement('div'); + layout.className = 'main-dashboard'; + + const hero = renderMainHero(snapshot); + + const cards = document.createElement('div'); + cards.className = 'grid-surface main-dashboard__cards'; + + const mainEntities = roomEntities(snapshot, 'main'); + mainEntities.forEach((entity) => { + cards.appendChild(renderEntityCard(entity, { isMain: true })); + }); + + layout.append(hero, cards); + grid.appendChild(layout); + return; + } + + if (room.id === 'batteries') { + const section = document.createElement('section'); + section.className = 'room-entities-section battery-room'; + + const header = document.createElement('div'); + header.className = 'room-entities-section__header battery-room__header'; + + const title = document.createElement('div'); + title.className = 'room-entities-section__title'; + title.textContent = room.battery_summary_text || `${Number(room.entity_count ?? 0) || 0} батареек`; + header.appendChild(title); + section.appendChild(header); + + const list = document.createElement('div'); + list.className = 'battery-room__list'; + const items = Array.isArray(room.entities) ? room.entities : []; + + items.forEach((item) => { + list.appendChild(renderBatteryCard(item)); + }); + + if (!items.length) { + const empty = document.createElement('article'); + empty.className = 'loading-card battery-room__empty'; + empty.textContent = 'Батарейки с ярлыком «Батарейка» не найдены.'; + list.appendChild(empty); + } + + section.appendChild(list); + grid.appendChild(section); + return; + } + + const visibleEntries = roomGridEntries(snapshot, room.id); + const hiddenEntitiesList = state.editMode && room.id !== 'main' + ? roomEntitiesIncludingHidden(snapshot, room.id).filter((entity) => entity.visible === false) + : []; + + const visibleSection = document.createElement('section'); + visibleSection.className = 'room-entities-section'; + + if (state.editMode) { + const visibleHeader = document.createElement('div'); + visibleHeader.className = 'room-entities-section__header'; + + const visibleTitle = document.createElement('div'); + visibleTitle.className = 'room-entities-section__title'; + visibleTitle.textContent = 'Объекты'; + visibleHeader.appendChild(visibleTitle); + visibleSection.appendChild(visibleHeader); + } + + const visibleGrid = document.createElement('div'); + visibleGrid.className = 'grid-surface room-entities-section__grid'; + visibleEntries.forEach((entry) => { + if (entry.kind === 'layout') { + visibleGrid.appendChild(renderLayoutCard(entry.payload, room)); + } else { + visibleGrid.appendChild(renderEntityCard(entry.payload)); + } + }); + + if (!visibleEntries.length) { + const empty = document.createElement('article'); + empty.className = 'loading-card grid-card--full'; + empty.textContent = 'В этой комнате нет доступных объектов.'; + visibleGrid.appendChild(empty); + } + + visibleSection.appendChild(visibleGrid); + grid.appendChild(visibleSection); + + if (state.editMode && hiddenEntitiesList.length) { + const hiddenSection = document.createElement('section'); + hiddenSection.className = 'room-entities-section room-entities-section--hidden'; + + const hiddenHeader = document.createElement('div'); + hiddenHeader.className = 'room-entities-section__header'; + + const hiddenTitle = document.createElement('div'); + hiddenTitle.className = 'room-entities-section__title'; + hiddenTitle.textContent = 'Скрытые объекты'; + + const hiddenMeta = document.createElement('div'); + hiddenMeta.className = 'room-entities-section__meta'; + hiddenMeta.textContent = `${hiddenEntitiesList.length} ${pluralizeEntities(hiddenEntitiesList.length)}`; + + hiddenHeader.append(hiddenTitle, hiddenMeta); + hiddenSection.appendChild(hiddenHeader); + + const hiddenGrid = document.createElement('div'); + hiddenGrid.className = 'grid-surface room-entities-section__grid room-entities-section__grid--hidden'; + hiddenEntitiesList.forEach((entity) => { + hiddenGrid.appendChild(renderEntityCard(entity)); + }); + hiddenSection.appendChild(hiddenGrid); + grid.appendChild(hiddenSection); + } + } + + function renderPopup(snapshot) { + if (isMobileViewport()) { + hidePopup({ preserveSnapshot: true }); + return; + } + + const popup = mergePopupWithCamera(snapshot.popup || {}); + const signature = JSON.stringify([ + popup.active, + popup.sensor_entity_id || '', + popup.expires_at || '', + popup.stream_url || '', + popup.poster_url || '', + popup.stream_mode || '', + ]); + + if (popup.active && els.cameraBackdrop?.classList.contains('is-open') && signature === state.lastPopupSignature) { + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + return; + } + + state.lastPopupSignature = signature; + + if (!popup.active) { + hidePopup(); + return; + } + + els.cameraPoster.src = popup.poster_url || ''; + els.cameraPoster.alt = popup.sensor_entity_id || 'camera'; + els.cameraBackdrop.classList.add('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'false'); + els.cameraPlaceholder.classList.add('is-visible'); + + const expiresAt = Number(popup.expires_at || 0); + if (expiresAt > 0) { + let closeRequested = false; + const updateCountdown = () => { + const remaining = Math.max(0, expiresAt - Math.floor(Date.now() / 1000)); + const mins = Math.floor(remaining / 60); + const secs = remaining % 60; + if (remaining > 0) { + els.cameraCountdown.textContent = `Закроется через ${mins}:${String(secs).padStart(2, '0')}`; + return; + } + + els.cameraCountdown.textContent = 'Закрытие...'; + if (closeRequested) { + return; + } + closeRequested = true; + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = null; + apiPost('popup', { command: 'close' }) + .then((response) => { + if (response?.popup) { + applyPopupSnapshot(response.popup); + } else { + hidePopup(); + } + }) + .catch(() => { + hidePopup(); + }); + }; + updateCountdown(); + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = setInterval(updateCountdown, 1000); + } else { + els.cameraCountdown.textContent = ''; + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = null; + } + + const streamUrl = popup.stream_url || ''; + const resolvedMode = resolvePopupStreamMode(streamUrl, popup.stream_mode || ''); + renderStream(streamUrl, resolvedMode, popup.poster_url || ''); + } + + function hidePopup(options = {}) { + const { suppressAutoOpen = false, preserveSnapshot = false } = options; + if (suppressAutoOpen) { + state.popupAutoOpenBlockedUntil = Date.now() + 60000; + } + state.lastPopupSignature = ''; + state.snapshot = state.snapshot || bootstrap; + if (!preserveSnapshot && state.snapshot.popup) { + state.snapshot.popup = { + ...state.snapshot.popup, + active: false, + }; + } + els.cameraBackdrop.classList.remove('is-open'); + els.cameraBackdrop.setAttribute('aria-hidden', 'true'); + els.cameraStage.innerHTML = ''; + els.cameraStage.appendChild(els.cameraPoster); + els.cameraStage.appendChild(els.cameraPlaceholder); + els.cameraPlaceholder.classList.add('is-visible'); + els.cameraPoster.removeAttribute('src'); + els.cameraCountdown.textContent = ''; + clearInterval(state.popupDismissTimer); + state.popupDismissTimer = null; + destroyStream(); + } + + async function showDebugPopup() { + try { + const response = await apiPost('popup', { command: 'open' }); + const snapshot = state.snapshot || bootstrap; + state.snapshot = snapshot; + applyPopupSnapshot(response.popup || {}); + } catch (error) { + console.error(error); + setStatus('Ошибка popup', 'error'); + } + } + + function destroyStream() { + if (state.hlsInstance) { + try { + state.hlsInstance.destroy(); + } catch (error) { + console.warn(error); + } + state.hlsInstance = null; + } + } + + function inferStreamMode(url) { + if (!url) return 'poster'; + if (url.includes('.m3u8')) return 'hls'; + if (url.includes('.mp4')) return 'video'; + if (url.includes('stream.html')) return 'iframe'; + if (url.startsWith('http')) return 'iframe'; + return 'iframe'; + } + + function mutedStreamUrl(url) { + if (!url) return ''; + try { + const parsed = new URL(url, window.location.href); + if (parsed.pathname.includes('webrtc.html')) { + parsed.searchParams.set('media', 'video'); + } + parsed.searchParams.set('mute', '1'); + parsed.searchParams.set('volume', '0'); + parsed.searchParams.set('autoplay', '1'); + return parsed.toString(); + } catch (error) { + return url; + } + } + + async function loadHlsScript() { + if (window.Hls) return; + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js'; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + async function renderStream(url, mode, posterUrl) { + destroyStream(); + els.cameraStage.innerHTML = ''; + els.cameraStage.appendChild(els.cameraPoster); + els.cameraStage.appendChild(els.cameraPlaceholder); + + els.cameraPlaceholder.classList.add('is-visible'); + els.cameraPoster.src = posterUrl || ''; + + if (!url) { + return; + } + + if (mode === 'iframe') { + const iframe = document.createElement('iframe'); + iframe.classList.add('is-loading'); + iframe.src = mutedStreamUrl(url); + iframe.allow = 'autoplay; fullscreen; picture-in-picture'; + iframe.referrerPolicy = 'no-referrer'; + iframe.addEventListener('load', () => { + iframe.classList.add('is-ready'); + els.cameraPlaceholder.classList.remove('is-visible'); + }); + iframe.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible')); + els.cameraStage.appendChild(iframe); + return; + } + + const video = document.createElement('video'); + video.classList.add('is-loading'); + video.autoplay = true; + video.muted = true; + video.defaultMuted = true; + video.volume = 0; + video.playsInline = true; + video.setAttribute('muted', ''); + video.setAttribute('playsinline', ''); + video.setAttribute('webkit-playsinline', ''); + video.controls = false; + video.poster = posterUrl || ''; + video.preload = 'metadata'; + video.addEventListener('loadeddata', () => { + video.classList.add('is-ready'); + els.cameraPlaceholder.classList.remove('is-visible'); + }); + video.addEventListener('canplay', () => { + video.classList.add('is-ready'); + els.cameraPlaceholder.classList.remove('is-visible'); + }); + video.addEventListener('error', () => els.cameraPlaceholder.classList.add('is-visible')); + els.cameraStage.appendChild(video); + + if (mode === 'hls') { + if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = url; + return; + } + + try { + await loadHlsScript(); + if (window.Hls) { + const hls = new window.Hls({ + lowLatencyMode: true, + }); + hls.loadSource(url); + hls.attachMedia(video); + state.hlsInstance = hls; + hls.on(window.Hls.Events.MANIFEST_PARSED, () => els.cameraPlaceholder.classList.remove('is-visible')); + hls.on(window.Hls.Events.ERROR, () => els.cameraPlaceholder.classList.add('is-visible')); + return; + } + } catch (error) { + console.warn('HLS load failed', error); + } + } + + video.src = url; + } + + function render() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) { + return; + } + + syncLayoutState(); + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + renderSelectedRoom(snapshot); + renderDashboard(snapshot); + renderPopup(snapshot); + renderEntityPopup(snapshot); + renderTemperatureSensorPopup(snapshot); + + const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); + els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; + els.editModeToggle.classList.toggle('is-active', state.editMode); + els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + } + + function renderDashboardOnly() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; + syncLayoutState(); + renderSelectedRoom(snapshot); + renderDashboard(snapshot); + renderPopup(snapshot); + renderEntityPopup(snapshot); + renderTemperatureSensorPopup(snapshot); + } + + function refreshCurrentRoomLayout(entityId) { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + updateMainEntityCard(entityId); + renderSelectedRoom(snapshot); + return; + } + + if (room.id === 'batteries') { + renderDashboardOnly(); + return; + } + + renderDashboardOnly(); + } + + function refreshCurrentRoomOrder() { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.selected_space || snapshot.selected_room || {}; + if (room.id === 'main') { + const container = mainCardsContainer(); + if (container) sortMainCardsBySnapshot(container); + return; + } + + const container = els.dashboardSurface; + const order = new Map(roomEntities(snapshot, room.id).map((entity) => [entity.entity_id, Number(entity.order ?? 9999)])); + const cards = Array.from(container.querySelectorAll('.grid-card[data-entity-id]')); + cards.sort((left, right) => { + const leftOrder = order.get(left.dataset.entityId) ?? Number.MAX_SAFE_INTEGER; + const rightOrder = order.get(right.dataset.entityId) ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left.dataset.entityId || '').localeCompare(String(right.dataset.entityId || ''), 'ru'); + }); + cards.forEach((card) => container.appendChild(card)); + } + + function renderSidebarOnly() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); + const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); + els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; + els.editModeToggle.classList.toggle('is-active', state.editMode); + els.editModeToggle.title = state.editMode ? 'Edit mode on' : 'Edit mode off'; + } + + function renderSelectionOnly() { + const snapshot = state.snapshot || bootstrap; + if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; + syncLayoutState(); + renderSelectedRoom(snapshot); + } + + async function handleEntityAction(entity, command) { + try { + const snapshot = state.snapshot || bootstrap; + const nextState = optimisticStateForCommand(entity, command); + const isCurrentRoomEntity = state.selectedRoomId !== 'main' + && roomEntities(snapshot, state.selectedRoomId).some((item) => item.entity_id === entity.entity_id); + + if (nextState !== null) { + if (state.selectedRoomId === 'main' || isMainDisplayEntity(entity)) { + patchSnapshotEntity(entity.entity_id, { + state: nextState, + attributes: entity.attributes || {}, + last_changed: entity.last_changed || entity.last_updated || new Date().toISOString(), + }); + syncMainEntities(entity.entity_id, entity, { + state: nextState, + attributes: entity.attributes || {}, + last_changed: entity.last_changed || entity.last_updated || new Date().toISOString(), + }); + refreshCurrentRoomLayout(entity.entity_id); + } else if (isCurrentRoomEntity) { + patchSnapshotEntity(entity.entity_id, { + state: nextState, + attributes: entity.attributes || {}, + }); + refreshCurrentRoomLayout(entity.entity_id); + } + } + + await apiPost('service', { + entity_id: entity.entity_id, + command, + }); + } catch (error) { + console.error(error); + setStatus('Ошибка команды', 'error'); + } + } + + async function handleClimateTemperature(entity, delta) { + const current = Number(entity.attributes?.temperature); + if (!Number.isFinite(current)) return; + const target = Math.round((current + delta) * 2) / 2; + try { + patchSnapshotEntity(entity.entity_id, { + attributes: { + ...(entity.attributes || {}), + temperature: target, + }, + }); + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + refreshCurrentRoomLayout(entity.entity_id); + await apiPost('service', { + entity_id: entity.entity_id, + command: 'set_temperature', + value: target, + }); + if (state.entityPopup?.active) { + renderEntityPopup(state.snapshot || bootstrap); + } + } catch (error) { + console.error(error); + setStatus('Ошибка температуры', 'error'); + } + } + + async function saveOverridePatch(entity, patch) { + const room = currentRoom(); + if (!room || room.id === 'main') return; + + try { + await apiPost('save-entity-override', { + room_id: room.id, + entity_id: entity.entity_id, + ...patch, + }); + patchSnapshotEntity(entity.entity_id, { + visible: patch.visible !== undefined ? Boolean(patch.visible) : undefined, + order: patch.order !== undefined ? patch.order : undefined, + }); + try { + await loadSnapshot(state.selectedRoomId || room.id || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function saveSpacePatch(room, patch) { + try { + const nextPatch = {}; + if (patch.visible !== undefined) nextPatch.visible = Boolean(patch.visible); + if (patch.order !== undefined) nextPatch.order = patch.order; + if (patch.name !== undefined) nextPatch.name = patch.name; + if (patch.icon !== undefined) nextPatch.icon = patch.icon; + if (patch.temperature_sensor_entity_id !== undefined) { + nextPatch.temperature_sensor_entity_id = String(patch.temperature_sensor_entity_id || ''); + } + + await apiPost('save-space-override', { + room_id: room.id, + ...patch, + }); + patchSnapshotSpace(room.id, nextPatch); + try { + await loadSnapshot(state.selectedRoomId || room.id || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function createRoomLayoutItem(roomId) { + const room = currentRoom(); + const nextRoomId = roomId || room?.id || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || isMobileViewport()) { + return null; + } + + try { + const snapshot = state.snapshot || bootstrap; + const items = roomGridEntries(snapshot, nextRoomId); + const maxOrder = items.reduce((max, item) => Math.max(max, Number(item.order ?? 9999) || 9999), 0); + await apiPost('create-room-layout-item', { + room_id: nextRoomId, + order: maxOrder + 10, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + + return null; + } + + async function saveRoomLayoutItem(roomId, layoutItemId, patch) { + const nextRoomId = roomId || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return; + + try { + await apiPost('save-room-layout-item', { + room_id: nextRoomId, + layout_item_id: layoutItemId, + ...patch, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function deleteRoomLayoutItem(roomId, layoutItemId) { + const nextRoomId = roomId || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return; + + try { + await apiPost('delete-room-layout-item', { + room_id: nextRoomId, + layout_item_id: layoutItemId, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + function wireEvents() { + els.selectedRoomBack?.addEventListener('click', () => { + if (!isMobileViewport()) return; + closeEntityPopup(); + setMobileView('spaces'); + syncLayoutState(); + renderSidebarOnly(); + renderSelectionOnly(); + }); + + els.cameraBackdrop.addEventListener('click', (event) => { + if (event.target === els.cameraBackdrop) { + apiPost('popup', { command: 'close' }).catch(() => {}); + hidePopup({ suppressAutoOpen: true }); + } + }); + + els.cameraModalPanel.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + const closeCameraPopup = async (event) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + try { + await apiPost('popup', { command: 'close' }); + } catch (error) { + console.warn(error); + } + hidePopup({ suppressAutoOpen: true }); + }; + + els.cameraClose.addEventListener('pointerdown', closeCameraPopup); + els.cameraClose.addEventListener('click', closeCameraPopup); + + els.entityBackdrop?.addEventListener('click', (event) => { + if (event.target === els.entityBackdrop) { + closeEntityPopup(); + } + }); + + els.entityModalPanel?.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + els.entityClose?.addEventListener('click', () => { + closeEntityPopup(); + }); + + els.temperatureSensorBackdrop?.addEventListener('click', (event) => { + if (event.target === els.temperatureSensorBackdrop) { + closeTemperatureSensorPopup(); + } + }); + + els.temperatureSensorModalPanel?.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + els.temperatureSensorClose?.addEventListener('click', () => { + closeTemperatureSensorPopup(); + }); + + els.popupDebugButton?.addEventListener('click', () => { + showDebugPopup(); + }); + + els.editModeToggle.addEventListener('click', async () => { + state.editMode = !state.editMode; + try { + await apiPost('save-settings', { + edit_mode: state.editMode, + }); + } catch (error) { + console.warn(error); + } + state.snapshot = state.snapshot || bootstrap; + if (state.snapshot?.settings) { + state.snapshot.settings.edit_mode = state.editMode; + } + if (!state.editMode && currentRoom()?.visible === false) { + patchSnapshotSelection('main'); + } + try { + await loadSnapshot(state.selectedRoomId || 'main'); + } catch (error) { + console.warn(error); + } + render(); + }); + } + + function initRefs() { + els.appShell = q('.app-shell'); + els.clockTime = $('clock-time'); + els.clockDate = $('clock-date'); + els.roomsCount = $('rooms-count'); + els.roomList = $('room-list'); + els.editModeToggle = $('edit-mode-toggle'); + els.selectedRoomBack = $('selected-room-back'); + els.contentTop = q('.content-top'); + els.mainPrintStripSlot = $('main-print-strip-slot'); + els.contentHeader = q('.content-header'); + els.selectedRoomActions = $('selected-room-actions'); + els.selectedRoomEyebrow = $('selected-room-eyebrow'); + els.selectedRoomTitle = $('selected-room-title'); + els.selectedRoomMeta = $('selected-room-meta'); + els.dashboardSurface = $('dashboard-surface'); + els.cameraBackdrop = $('camera-modal'); + els.cameraModalPanel = $('camera-modal-panel'); + els.cameraClose = $('camera-modal-close'); + els.popupDebugButton = $('popup-debug-button'); + els.cameraStage = $('camera-stage'); + els.cameraPoster = $('camera-poster'); + els.cameraPlaceholder = $('camera-placeholder'); + els.cameraCountdown = $('camera-countdown'); + els.confirmBackdrop = $('confirm-modal'); + els.confirmTitle = $('confirm-modal-title'); + els.confirmMessage = $('confirm-modal-message'); + els.confirmYes = $('confirm-modal-yes'); + els.confirmNo = $('confirm-modal-no'); + els.entityBackdrop = $('entity-modal'); + els.entityModalPanel = $('entity-modal-panel'); + els.entityClose = $('entity-modal-close'); + els.entityEyebrow = $('entity-modal-eyebrow'); + els.entityTitle = $('entity-modal-title'); + els.entityBody = $('entity-modal-body'); + els.temperatureSensorBackdrop = $('temperature-sensor-modal'); + els.temperatureSensorModalPanel = $('temperature-sensor-modal-panel'); + els.temperatureSensorClose = $('temperature-sensor-modal-close'); + els.temperatureSensorTitle = $('temperature-sensor-modal-title'); + els.temperatureSensorBody = $('temperature-sensor-modal-body'); + + if (els.cameraPoster && !els.cameraPoster.dataset.boundErrorHandler) { + els.cameraPoster.dataset.boundErrorHandler = '1'; + els.cameraPoster.addEventListener('error', () => { + els.cameraPoster.removeAttribute('src'); + els.cameraPlaceholder.classList.add('is-visible'); + }); + els.cameraPoster.addEventListener('load', () => { + els.cameraPlaceholder.classList.remove('is-visible'); + }); + } + } + + function updateClock() { + if (els.clockTime) { + els.clockTime.textContent = formatTime(new Date()); + } + if (els.clockDate) { + els.clockDate.textContent = formatDate(new Date()); + } + updateMainPrintStrip(); + } + + function stopRealtime() { + if (state.haSocket) { + try { + state.haSocket.close(); + } catch (error) { + console.warn(error); + } + state.haSocket = null; + } + clearTimeout(state.haReconnectTimer); + state.haReconnectTimer = null; + state.haSocketState = 'disconnected'; + stopSnapshotPolling(); + } + + function scheduleReconnect() { + clearTimeout(state.haReconnectTimer); + state.haSocketState = 'reconnecting'; + const delay = Math.min(state.haReconnectDelay, 30000); + state.haReconnectDelay = Math.min(state.haReconnectDelay * 2, 30000); + state.haReconnectTimer = window.setTimeout(() => { + connectRealtime(); + }, delay); + } + + function stopSnapshotPolling() { + if (state.snapshotPollTimer) { + clearInterval(state.snapshotPollTimer); + state.snapshotPollTimer = null; + } + } + + function startSnapshotPolling() { + const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000)); + if (state.snapshotPollTimer) { + clearInterval(state.snapshotPollTimer); + } + + state.snapshotPollTimer = window.setInterval(async () => { + try { + await loadSnapshot(state.selectedRoomId || 'main'); + render(); + } catch (error) { + console.warn(error); + } + }, interval); + } + + function handleHaMessage(message) { + if (!message || typeof message !== 'object') { + return; + } + + if (message.type === 'auth_required') { + const connection = haConnection(); + if (!connection.token) return; + state.haSocket?.send(JSON.stringify({ + type: 'auth', + access_token: connection.token, + })); + return; + } + + if (message.type === 'auth_ok') { + state.haSocketState = 'auth_ok'; + state.haReconnectDelay = 1000; + state.haSocket?.send(JSON.stringify({ + id: state.haSubscribeId++, + type: 'subscribe_events', + event_type: 'state_changed', + })); + return; + } + + if (message.type === 'result' && message.success) { + if (state.haSocketState !== 'connected') { + state.haSocketState = 'connected'; + } + return; + } + + if (message.type === 'event' && message.event?.event_type === 'state_changed') { + const event = message.event; + const entityId = event?.data?.entity_id; + const newState = event?.data?.new_state; + if (entityId && newState) { + const snapshot = state.snapshot || bootstrap; + const currentRoomId = state.selectedRoomId || 'main'; + const existingEntity = getEntityFromSnapshot(snapshot, entityId); + const entityDefinition = getEntityDefinition(snapshot, entityId); + const entityRecord = existingEntity || entityDefinition; + if (entityRecord?.is_hidden) { + patchSnapshotEntity(entityId, { + state: newState.state, + attributes: newState.attributes || {}, + last_changed: newState.last_changed || newState.last_updated || entityRecord.last_changed, + last_updated: newState.last_updated || entityRecord.last_updated, + }); + return; + } + const affectsWeather = snapshot.weather?.entity_id === entityId || entityId === 'sensor.weather_temperature'; + const affectsRoom = currentRoomId !== 'main' && existingEntity !== null; + const affectsTemperatureBadge = isTemperatureSensorEntity(entityRecord); + + if (entityRecord && !statePayloadChanged(entityRecord, newState)) { + const triggerEntities = popupTriggerEntities(); + if (triggerEntities.has(entityId)) { + syncTriggerPopup(entityId, newState.state); + } + return; + } + + patchSnapshotEntity(entityId, { + state: newState.state, + attributes: newState.attributes || {}, + last_changed: newState.last_changed || newState.last_updated || entityRecord?.last_changed, + last_updated: newState.last_updated || entityRecord?.last_updated, + }); + + const mainChanged = syncMainEntities(entityId, entityRecord, { + state: newState.state, + attributes: newState.attributes || {}, + last_changed: newState.last_changed || newState.last_updated || entityRecord?.last_changed, + last_updated: newState.last_updated || entityRecord?.last_updated, + }); + + if (snapshot.weather?.entity_id === entityId) { + snapshot.weather.state = newState.state; + snapshot.weather.temperature = newState.attributes?.temperature ?? snapshot.weather.temperature; + snapshot.weather.wind_speed = newState.attributes?.wind_speed ?? snapshot.weather.wind_speed; + snapshot.weather.condition = newState.attributes?.condition ?? newState.state; + } + + if (snapshot.weather && entityId === 'sensor.weather_temperature') { + snapshot.weather.sensor_temperature = newState.state; + } + + const triggerEntities = popupTriggerEntities(); + if (triggerEntities.has(entityId)) { + syncTriggerPopup(entityId, newState.state); + } + + const affectsBoiler = mainBoilerConfig(snapshot)?.sensor_entity_id === entityId; + const affectsPrint = mainPrintAffectsEntity(entityId); + + if (currentRoomId === 'main' && (mainChanged || affectsWeather || mainWeatherActionAffectsEntity(entityId) || affectsBoiler || affectsPrint)) { + if (affectsWeather || mainWeatherActionAffectsEntity(entityId) || affectsBoiler || affectsPrint) { + updateMainWeatherCard(); + } + if (mainChanged) { + updateMainEntityCard(entityId); + } + renderSelectedRoom(snapshot); + renderSidebarOnly(); + } else if (affectsRoom) { + updateRoomEntityCard(entityId); + renderSelectedRoom(snapshot); + renderSidebarOnly(); + } + + if (affectsTemperatureBadge) { + renderSidebarOnly(); + } + + if (state.entityPopup?.active) { + renderEntityPopup(snapshot); + } + } + } + } + + function connectRealtime() { + const connection = haConnection(); + const baseUrl = connection.base_url || ''; + const token = connection.token || ''; + const wsUrl = haWsUrl(baseUrl); + if (!wsUrl || !token) { + state.haSocketState = 'unavailable'; + setStatus('Online', 'online'); + startSnapshotPolling(); + return; + } + + if (window.location.protocol === 'https:' && wsUrl.startsWith('ws://')) { + state.haSocketState = 'unavailable'; + setStatus('Polling mode', 'online'); + startSnapshotPolling(); + return; + } + + stopSnapshotPolling(); + stopRealtime(); + state.haSocketState = 'connecting'; + setStatus('Connecting WS...', 'loading'); + + try { + const socket = new WebSocket(wsUrl); + state.haSocket = socket; + socket.onopen = () => { + state.haSocketState = 'open'; + }; + socket.onmessage = (event) => { + try { + handleHaMessage(JSON.parse(event.data)); + } catch (error) { + console.warn(error); + } + }; + socket.onerror = () => { + setStatus('WS error', 'error'); + startSnapshotPolling(); + }; + socket.onclose = () => { + state.haSocket = null; + if (state.haSocketState !== 'disconnected') { + scheduleReconnect(); + } + }; + } catch (error) { + console.error(error); + scheduleReconnect(); + } + } + + async function start() { + initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); + syncViewportState(); + updateClock(); + clearInterval(state.clockTimer); + state.clockTimer = setInterval(updateClock, 1000); + wireEvents(); + + const viewportQuery = mobileViewportQuery(); + const handleViewportChange = () => { + syncViewportState(); + render(); + }; + if (typeof viewportQuery.addEventListener === 'function') { + viewportQuery.addEventListener('change', handleViewportChange); + } else if (typeof viewportQuery.addListener === 'function') { + viewportQuery.addListener(handleViewportChange); + } + + const initial = window.APP_BOOTSTRAP || {}; + state.snapshot = initial; + render(); + connectRealtime(); + if (!state.snapshotPollTimer) { + startSnapshotPolling(); + } + } + + document.addEventListener('DOMContentLoaded', start); +})(); diff --git a/wall_panel/config.yaml b/wall_panel/config.yaml new file mode 100755 index 0000000..6a84725 --- /dev/null +++ b/wall_panel/config.yaml @@ -0,0 +1,62 @@ +name: Wall Panel +description: Wall Panel PHP interface as a Home Assistant add-on +version: "1.0.1" +slug: wall_panel +url: https://git.striker72rus.ru/PHP/wallpanell.git +init: false +arch: + - aarch64 + - amd64 + - armhf + - armv7 + - i386 +startup: services +ingress: true +ingress_port: 8099 +panel_title: Wall Panel +panel_icon: mdi:view-dashboard +ports: + 8099/tcp: 8099 +ports_description: + 8099/tcp: Wall Panel web UI +map: + - addon_config:rw +homeassistant_api: true +options: + app: + title: Wall Panel + poll_interval_ms: 5000 + main_room_name: Главная + main_room_icon: mdi:home + edit_mode: false + battery_history_hours: 4320 + home_assistant: + base_url: "" + token: "" + verify_ssl: true + weather_entity_id: "" + camera: + rtsp_url: rtsp://10.0.6.110:45321/feff99fa45f317e7 + stream_url: "" + stream_mode: hls + poster_url: http://10.0.6.110:5000/api/doorbell/latest.jpg + popup_timeout_minutes: 3 +schema: + app: + title: str + poll_interval_ms: int + main_room_name: str + main_room_icon: str + edit_mode: bool + battery_history_hours: int + home_assistant: + base_url: str + token: str + verify_ssl: bool + weather_entity_id: str + camera: + rtsp_url: str + stream_url: str + stream_mode: str + poster_url: str + popup_timeout_minutes: int diff --git a/wall_panel/config/addon-default-config.json b/wall_panel/config/addon-default-config.json new file mode 100755 index 0000000..a6393d7 --- /dev/null +++ b/wall_panel/config/addon-default-config.json @@ -0,0 +1,32 @@ +{ + "app": { + "title": "Wall Panel", + "poll_interval_ms": 5000, + "main_room_name": "Главная", + "main_room_icon": "mdi:home", + "edit_mode": false, + "battery_history_hours": 4320 + }, + "home_assistant": { + "base_url": "", + "token": "", + "verify_ssl": true, + "sync_url": "", + "sync_token": "", + "sync_timeout": 10, + "sync_verify_ssl": true, + "sync_cache_seconds": 30, + "weather_entity_id": "", + "auto_label": "auto", + "auto_entity_ids": [] + }, + "camera": { + "rtsp_url": "", + "stream_url": "", + "stream_mode": "hls", + "poster_url": "", + "popup_timeout_minutes": 3, + "trigger_entities": [] + }, + "rooms": [] +} diff --git a/wall_panel/favicon.ico b/wall_panel/favicon.ico new file mode 100755 index 0000000..824b434 Binary files /dev/null and b/wall_panel/favicon.ico differ diff --git a/wall_panel/index.php b/wall_panel/index.php new file mode 100755 index 0000000..628351c --- /dev/null +++ b/wall_panel/index.php @@ -0,0 +1,164 @@ + $embedMode, + 'mode' => $runtimeMode, + 'shell' => $embedMode ? 'embed' : 'standalone', + 'config_source' => app_remote_sync_enabled($config) ? 'ha' : 'file', +]; +$appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +?> + + + + + + + <?= $appTitle ?> + + + + + + + + + + +
+ + +
+
+
+
+
+ +
+
+

Загрузка

+
+
+
+
+ +
+
+
Загрузка панели...
+
+
+
+
+ + + + + + + + + + diff --git a/wall_panel/lib/@eaDir/bootstrap.php@SynoEAStream b/wall_panel/lib/@eaDir/bootstrap.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/wall_panel/lib/@eaDir/bootstrap.php@SynoEAStream differ diff --git a/wall_panel/lib/@eaDir/config.php@SynoEAStream b/wall_panel/lib/@eaDir/config.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/wall_panel/lib/@eaDir/config.php@SynoEAStream differ diff --git a/wall_panel/lib/@eaDir/dashboard.php@SynoEAStream b/wall_panel/lib/@eaDir/dashboard.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/wall_panel/lib/@eaDir/dashboard.php@SynoEAStream differ diff --git a/wall_panel/lib/@eaDir/ha_client.php@SynoEAStream b/wall_panel/lib/@eaDir/ha_client.php@SynoEAStream new file mode 100755 index 0000000..71a85d9 Binary files /dev/null and b/wall_panel/lib/@eaDir/ha_client.php@SynoEAStream differ diff --git a/wall_panel/lib/bootstrap.php b/wall_panel/lib/bootstrap.php new file mode 100755 index 0000000..fc20b03 --- /dev/null +++ b/wall_panel/lib/bootstrap.php @@ -0,0 +1,9 @@ + [ + 'title' => 'Wall Panel', + 'poll_interval_ms' => 5000, + 'main_room_name' => 'Главная', + 'main_room_icon' => 'mdi:home', + 'edit_mode' => false, + 'battery_history_hours' => 4320, + ], + 'home_assistant' => [ + 'base_url' => '', + 'token' => '', + 'verify_ssl' => true, + 'sync_url' => '', + 'sync_token' => '', + 'sync_timeout' => 10, + 'sync_verify_ssl' => true, + 'sync_cache_seconds' => 30, + 'weather_entity_id' => '', + 'auto_label' => 'auto', + 'auto_entity_ids' => [], + ], + 'camera' => [ + 'rtsp_url' => 'rtsp://10.0.6.110:45321/feff99fa45f317e7', + 'stream_url' => '', + 'stream_mode' => 'hls', + 'poster_url' => 'http://10.0.6.110:5000/api/doorbell/latest.jpg', + 'popup_timeout_minutes' => 3, + 'trigger_entities' => [ + 'binary_sensor.door_all_occupancy', + 'binary_sensor.barn_all_occupancy', + 'binary_sensor.doorbell_all_occupancy', + ], + ], + 'rooms' => [], + ]; +} + +function app_config_path(): string +{ + $override = trim((string)getenv('WALL_PANEL_CONFIG_PATH')); + if ($override !== '') { + return $override; + } + + return APP_ROOT . '/config/config.json'; +} + +function app_storage_path(string $file): string +{ + $override = trim((string)getenv('WALL_PANEL_STORAGE_DIR')); + $base = $override !== '' ? rtrim($override, '/') : APP_ROOT . '/storage'; + return $base . '/' . ltrim($file, '/'); +} + +function app_ensure_directory(string $path): void +{ + if (!is_dir($path)) { + mkdir($path, 0775, true); + } +} + +function app_load_config(): array +{ + $path = app_config_path(); + app_ensure_directory(dirname($path)); + + $config = app_default_config(); + $storedConfig = $config; + if (!file_exists($path)) { + $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Failed to encode default config'); + } + + file_put_contents($path, $json . PHP_EOL, LOCK_EX); + } else { + $raw = file_get_contents($path); + if ($raw === false || trim($raw) !== '') { + $decoded = json_decode((string)$raw, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON in config/config.json'); + } + + $config = array_replace_recursive($config, $decoded); + $storedConfig = $config; + } + } + + $optionsPath = app_addon_options_path(); + if (is_file($optionsPath)) { + $options = app_load_json_file($optionsPath, []); + if (is_array($options) && $options !== []) { + $config = array_replace_recursive($config, $options); + if (app_runtime_mode() === 'addon' && $config !== $storedConfig) { + app_save_config($config); + } + } + } + + return $config; +} + +function app_save_config(array $config): void +{ + $path = app_config_path(); + app_ensure_directory(dirname($path)); + + $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Failed to encode config'); + } + + file_put_contents($path, $json . PHP_EOL, LOCK_EX); +} + +function app_load_json_file(string $path, array $fallback = []): array +{ + if (!file_exists($path)) { + return $fallback; + } + + $raw = file_get_contents($path); + if ($raw === false || trim($raw) === '') { + return $fallback; + } + + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : $fallback; +} + +function app_save_json_file(string $path, array $data): void +{ + app_ensure_directory(dirname($path)); + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Failed to encode JSON'); + } + + file_put_contents($path, $json . PHP_EOL, LOCK_EX); +} + +function app_remote_sync_enabled(array $config): bool +{ + $syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? '')); + $syncToken = trim((string)($config['home_assistant']['sync_token'] ?? '')); + + return $syncUrl !== '' && $syncToken !== ''; +} + +function app_remote_sync_cache_path(): string +{ + return app_storage_path('ha_sync_cache.json'); +} + +function app_remote_sync_cache_ttl(array $config): int +{ + return max(5, (int)($config['home_assistant']['sync_cache_seconds'] ?? 30)); +} + +function app_http_json_request( + string $method, + string $url, + array $headers = [], + ?array $body = null, + int $timeout = 10, + bool $verifySsl = true, + bool $throwOnFailure = false +): array|null { + $ch = curl_init($url); + if ($ch === false) { + if ($throwOnFailure) { + throw new RuntimeException('Failed to initialize HTTP client'); + } + return null; + } + + $requestHeaders = array_merge([ + 'Accept: application/json', + ], $headers); + if ($body !== null) { + $requestHeaders[] = 'Content-Type: application/json'; + } + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => strtoupper($method), + CURLOPT_HTTPHEADER => $requestHeaders, + CURLOPT_TIMEOUT => max(1, $timeout), + CURLOPT_CONNECTTIMEOUT => max(1, min(7, $timeout)), + CURLOPT_SSL_VERIFYPEER => $verifySsl, + CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0, + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + $raw = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($errno !== 0) { + if ($throwOnFailure) { + throw new RuntimeException('HTTP request failed: ' . $error); + } + return null; + } + + if ($status >= 400) { + if ($throwOnFailure) { + throw new RuntimeException('HTTP request returned status ' . $status); + } + return null; + } + + $decoded = json_decode((string)$raw, true); + return is_array($decoded) ? $decoded : []; +} + +function app_merge_synced_config(array $baseConfig, array $syncedConfig): array +{ + $syncFields = [ + 'sync_url' => (string)($baseConfig['home_assistant']['sync_url'] ?? ''), + 'sync_token' => (string)($baseConfig['home_assistant']['sync_token'] ?? ''), + 'sync_timeout' => (int)($baseConfig['home_assistant']['sync_timeout'] ?? 10), + 'sync_verify_ssl' => (bool)($baseConfig['home_assistant']['sync_verify_ssl'] ?? true), + 'sync_cache_seconds' => (int)($baseConfig['home_assistant']['sync_cache_seconds'] ?? 30), + ]; + + $merged = array_replace_recursive($baseConfig, $syncedConfig); + foreach ($syncFields as $key => $value) { + $merged['home_assistant'][$key] = $value; + } + + return $merged; +} + +function app_load_remote_config(array $config, bool $refresh = false): array|null +{ + if (!app_remote_sync_enabled($config)) { + return null; + } + + $cachePath = app_remote_sync_cache_path(); + $cache = app_load_json_file($cachePath, []); + $cachedConfig = is_array($cache['config'] ?? null) ? $cache['config'] : null; + $cachedAt = (int)($cache['fetched_at'] ?? 0); + $ttl = app_remote_sync_cache_ttl($config); + + if (!$refresh && $cachedConfig !== null && $cachedAt > 0 && (time() - $cachedAt) < $ttl) { + return $cachedConfig; + } + + $syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? '')); + $syncToken = trim((string)($config['home_assistant']['sync_token'] ?? '')); + $timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10)); + $verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true); + + $response = app_http_json_request('GET', $syncUrl, [ + 'X-Wall-Panel-Token: ' . $syncToken, + ], null, $timeout, $verifySsl, false); + + $remoteConfig = null; + if (is_array($response)) { + if (is_array($response['config'] ?? null)) { + $remoteConfig = $response['config']; + } elseif (!isset($response['ok']) || (bool)$response['ok'] !== false) { + $remoteConfig = $response; + } + } + + if (is_array($remoteConfig)) { + app_save_json_file($cachePath, [ + 'fetched_at' => time(), + 'config' => $remoteConfig, + ]); + return $remoteConfig; + } + + if ($cachedConfig !== null) { + return $cachedConfig; + } + + return null; +} + +function app_clear_remote_config_cache(): void +{ + $path = app_remote_sync_cache_path(); + if (file_exists($path)) { + @unlink($path); + } +} + +function app_sync_remote_action(array $config, string $action, array $payload): array|null +{ + if (!app_remote_sync_enabled($config)) { + return null; + } + + $syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? '')); + $syncToken = trim((string)($config['home_assistant']['sync_token'] ?? '')); + $timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10)); + $verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true); + + $response = app_http_json_request('POST', $syncUrl, [ + 'X-Wall-Panel-Token: ' . $syncToken, + ], [ + 'action' => $action, + 'payload' => $payload, + ], $timeout, $verifySsl, false); + + if (!is_array($response)) { + return null; + } + + if (is_array($response['config'] ?? null)) { + app_save_json_file(app_remote_sync_cache_path(), [ + 'fetched_at' => time(), + 'config' => $response['config'], + ]); + } + + return $response; +} diff --git a/wall_panel/lib/dashboard.php b/wall_panel/lib/dashboard.php new file mode 100755 index 0000000..49a81f5 --- /dev/null +++ b/wall_panel/lib/dashboard.php @@ -0,0 +1,2039 @@ + $entityId, + 'state_entity_id' => trim((string)($item['state_entity_id'] ?? $entityId)) ?: $entityId, + 'command' => trim((string)($item['command'] ?? 'set_temperature')) ?: 'set_temperature', + 'value' => array_key_exists('value', $item) ? $item['value'] : null, + 'active_value' => array_key_exists('active_value', $item) ? $item['active_value'] : (array_key_exists('value', $item) ? $item['value'] : null), + 'label_active' => (string)($item['label_active'] ?? $item['active_label'] ?? $item['label'] ?? ''), + 'label_inactive' => (string)($item['label_inactive'] ?? $item['inactive_label'] ?? $item['label'] ?? ''), + 'icon' => (string)($item['icon'] ?? 'mdi:thermometer'), + 'active_color' => (string)($item['active_color'] ?? '#4caf50'), + 'inactive_color' => (string)($item['inactive_color'] ?? '#c8e6c9'), + 'active_text_color' => (string)($item['active_text_color'] ?? 'white'), + 'inactive_text_color' => (string)($item['inactive_text_color'] ?? 'black'), + 'active_icon_color' => (string)($item['active_icon_color'] ?? 'white'), + 'inactive_icon_color' => (string)($item['inactive_icon_color'] ?? 'gray'), + ]; + } + + return array_values($actions); +} + +function app_main_boiler(array $config): array +{ + $boiler = $config['app']['main_boiler'] ?? []; + if (!is_array($boiler)) { + $boiler = []; + } + + return [ + 'title' => (string)($boiler['title'] ?? 'Бойлер'), + 'sensor_entity_id' => trim((string)($boiler['sensor_entity_id'] ?? '')), + 'history_hours' => max(1, (int)($boiler['history_hours'] ?? 24)), + ]; +} + +function app_main_print(array $config): array +{ + $print = $config['app']['main_print'] ?? []; + if (!is_array($print)) { + $print = []; + } + + return [ + 'title' => (string)($print['title'] ?? 'Печать'), + 'current_stage_entity_id' => trim((string)($print['current_stage_entity_id'] ?? '')), + 'print_progress_entity_id' => trim((string)($print['print_progress_entity_id'] ?? '')), + 'start_time_entity_id' => trim((string)($print['start_time_entity_id'] ?? '')), + 'end_time_entity_id' => trim((string)($print['end_time_entity_id'] ?? '')), + ]; +} + +function app_state_file(): string +{ + return app_storage_path('popup_state.json'); +} + +function app_load_popup_state(): array +{ + return app_load_json_file(app_state_file(), [ + 'active' => false, + 'sensor_entity_id' => null, + 'opened_at' => null, + 'expires_at' => null, + ]); +} + +function app_save_popup_state(array $state): void +{ + app_save_json_file(app_state_file(), $state); +} + +function app_clear_expired_popup_state(array $config): array +{ + $state = app_load_popup_state(); + if (!($state['active'] ?? false)) { + return $state; + } + + $expiresAt = (int)($state['expires_at'] ?? 0); + if ($expiresAt > 0 && time() >= $expiresAt) { + $state['active'] = false; + $state['expires_at'] = null; + app_save_popup_state($state); + } + + return $state; +} + +function app_popup_close_delay_seconds(): int +{ + return 30; +} + +function app_default_card_type(string $entityId): string +{ + $domain = explode('.', $entityId, 2)[0] ?? ''; + return match ($domain) { + 'cover' => 'cover', + 'climate' => 'climate', + 'light', 'switch' => 'toggle', + default => 'toggle', + }; +} + +function app_entity_domain(string $entityId): string +{ + return explode('.', $entityId, 2)[0] ?? 'unknown'; +} + +function app_entity_name(array $entity, array $registryEntry = [], array $override = []): string +{ + if (!empty($override['title'])) { + return (string)$override['title']; + } + + $domain = app_entity_domain((string)($entity['entity_id'] ?? '')); + if ($domain === 'fan' && !empty($registryEntry['name'])) { + return (string)$registryEntry['name']; + } + + if (!empty($entity['attributes']['friendly_name'])) { + return (string)$entity['attributes']['friendly_name']; + } + + if (!empty($registryEntry['name'])) { + return (string)$registryEntry['name']; + } + + return $entity['entity_id']; +} + +function app_entity_icon(array $entity, array $override = []): string +{ + if (!empty($override['icon'])) { + return (string)$override['icon']; + } + + if (!empty($entity['attributes']['icon'])) { + return (string)$entity['attributes']['icon']; + } + + return match (app_entity_domain((string)$entity['entity_id'])) { + 'light' => 'mdi:lightbulb', + 'switch' => 'mdi:toggle-switch', + 'cover' => 'mdi:curtains', + 'climate' => 'mdi:air-conditioner', + 'weather' => 'mdi:weather-partly-cloudy', + 'binary_sensor' => 'mdi:motion-sensor', + default => 'mdi:devices', + }; +} + +function app_labels_from_entity(array $entity, array $registryEntry = []): array +{ + $labels = []; + foreach ([ + $entity['attributes']['labels'] ?? null, + $entity['attributes']['label_ids'] ?? null, + $entity['labels'] ?? null, + $registryEntry['labels'] ?? null, + $registryEntry['label_ids'] ?? null, + ] as $source) { + $labels = array_merge($labels, app_flatten_label_source($source)); + } + return array_values(array_unique(array_map('strval', $labels))); +} + +function app_entity_has_auto_label(array $labels, string $autoLabel): bool +{ + $needle = strtolower(trim($autoLabel)); + if ($needle === '') { + return false; + } + + foreach ($labels as $label) { + $candidate = strtolower(trim((string)$label)); + if ($candidate === '') { + continue; + } + if ($candidate === $needle || str_contains($candidate, $needle)) { + return true; + } + } + + return false; +} + +function app_flatten_label_source(mixed $source): array +{ + if ($source === null) { + return []; + } + + if (is_string($source) || is_numeric($source)) { + return [(string)$source]; + } + + if (!is_array($source)) { + return []; + } + + $labels = []; + foreach ($source as $item) { + if (is_string($item) || is_numeric($item)) { + $labels[] = (string)$item; + continue; + } + + if (is_array($item)) { + foreach (['name', 'label', 'id', 'label_id', 'entity_id'] as $key) { + if (!empty($item[$key])) { + $labels[] = (string)$item[$key]; + } + } + } + } + + return $labels; +} + +function app_is_active_entity(array $entity): bool +{ + $state = strtolower((string)($entity['state'] ?? '')); + $domain = app_entity_domain((string)$entity['entity_id']); + $deviceClass = strtolower((string)($entity['attributes']['device_class'] ?? '')); + + if (in_array($state, ['unavailable', 'unknown', 'none'], true)) { + return false; + } + + return match ($domain) { + 'binary_sensor' => $deviceClass === 'door' + ? in_array($state, ['on', 'open'], true) + : !in_array($state, ['off', 'false', '0', 'idle'], true), + 'cover' => in_array($state, ['open', 'opening', 'closing'], true), + 'climate' => !in_array($state, ['off', 'unavailable', 'unknown'], true), + default => !in_array($state, ['off', 'false', '0', 'idle'], true), + }; +} + +function app_is_door_contact_entity(array $entity, array $registryEntry = []): bool +{ + $entityId = (string)($entity['entity_id'] ?? ''); + if ($entityId === '' || app_entity_domain($entityId) !== 'binary_sensor') { + return false; + } + + $deviceClass = strtolower((string)($entity['attributes']['device_class'] ?? '')); + if (in_array($deviceClass, ['door', 'garage_door'], true)) { + return true; + } + + $name = strtolower((string)($entity['attributes']['friendly_name'] ?? $registryEntry['name'] ?? '')); + if ($name !== '' && (str_contains($name, 'door') || str_contains($name, 'двер'))) { + return true; + } + + return false; +} + +function app_select_weather_entity(array $states, array $config): ?array +{ + $preferred = trim((string)($config['home_assistant']['weather_entity_id'] ?? '')); + if ($preferred !== '') { + foreach ($states as $entity) { + if (($entity['entity_id'] ?? null) === $preferred) { + return $entity; + } + } + } + + foreach ($states as $entity) { + if (($entity['entity_id'] ?? null) === 'weather.yandex_weather') { + return $entity; + } + } + + foreach ($states as $entity) { + if (app_entity_domain((string)($entity['entity_id'] ?? '')) === 'weather') { + return $entity; + } + } + + return null; +} + +function app_registry_map(array $entityRegistry): array +{ + $map = []; + foreach ($entityRegistry as $item) { + if (!empty($item['entity_id'])) { + $map[(string)$item['entity_id']] = $item; + } + } + return $map; +} + +function app_device_registry_map(array $deviceRegistry): array +{ + $map = []; + foreach ($deviceRegistry as $item) { + if (!is_array($item)) { + continue; + } + + $deviceId = (string)($item['device_id'] ?? $item['id'] ?? $item['di'] ?? ''); + if ($deviceId === '') { + continue; + } + + $map[$deviceId] = $item; + } + + return $map; +} + +function app_device_display_name(array $device, string $fallback = ''): string +{ + $name = trim((string)($device['name_by_user'] ?? $device['name'] ?? $device['original_name'] ?? '')); + if ($name !== '') { + return $name; + } + + $fallback = trim($fallback); + return $fallback !== '' ? $fallback : 'Без устройства'; +} + +function app_room_definitions(array $config, array $haData): array +{ + $rooms = []; + $areasById = []; + $floorsById = []; + $roomOverrides = []; + + foreach ($haData['floors'] ?? [] as $floor) { + $floorId = (string)($floor['floor_id'] ?? $floor['id'] ?? ''); + if ($floorId === '') { + continue; + } + $floorsById[$floorId] = [ + 'floor_id' => $floorId, + 'name' => (string)($floor['name'] ?? $floorId), + 'icon' => (string)($floor['icon'] ?? 'mdi:stairs'), + 'level' => isset($floor['level']) ? (int)$floor['level'] : 0, + ]; + } + + foreach ($haData['areas'] ?? [] as $area) { + $areaId = (string)($area['area_id'] ?? $area['id'] ?? ''); + if ($areaId === '') { + continue; + } + $area['area_id'] = $areaId; + $areasById[$areaId] = $area; + } + + $configuredRooms = $config['rooms'] ?? []; + if (!is_array($configuredRooms)) { + $configuredRooms = []; + } + + foreach ($configuredRooms as $room) { + if (!is_array($room) || empty($room['id'])) { + continue; + } + $room['visible'] = $room['visible'] ?? true; + $room['order'] = isset($room['order']) ? (int)$room['order'] : 9999; + $room['temperature_sensor_entity_id'] = trim((string)($room['temperature_sensor_entity_id'] ?? '')); + $room['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : []; + $room['layout_items'] = app_room_layout_items($room); + $roomOverrides[(string)$room['id']] = $room; + } + + foreach ($areasById as $areaId => $area) { + $floorId = (string)($area['floor_id'] ?? $area['fi'] ?? $area['floor'] ?? ''); + if ($floorId === '' || !isset($floorsById[$floorId])) { + continue; + } + + $room = $roomOverrides[$areaId] ?? []; + $overrideName = trim((string)($room['name'] ?? '')); + if ($overrideName === '' || $overrideName === $areaId) { + $overrideName = ''; + } + $rooms[$areaId] = [ + 'id' => $areaId, + 'name' => $overrideName !== '' ? $overrideName : (string)($area['name'] ?? $areaId), + 'icon' => (string)($room['icon'] ?? $area['icon'] ?? 'mdi:home-variant'), + 'area_id' => $areaId, + 'floor_id' => $floorId, + 'floor_name' => (string)($room['floor_name'] ?? $floorsById[$floorId]['name'] ?? $floorId), + 'floor_level' => (int)($room['floor_level'] ?? $floorsById[$floorId]['level'] ?? 0), + 'visible' => array_key_exists('visible', $room) ? (bool)$room['visible'] : true, + 'order' => isset($room['order']) ? (int)$room['order'] : 9999, + 'entity_ids' => is_array($room['entity_ids'] ?? null) ? $room['entity_ids'] : [], + 'entity_overrides' => is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : [], + 'layout_items' => app_room_layout_items($room), + 'temperature_sensor_entity_id' => trim((string)($room['temperature_sensor_entity_id'] ?? '')), + ]; + } + + $rooms = array_values($rooms); + usort($rooms, static function (array $a, array $b): int { + $visibleA = (bool)($a['visible'] ?? true); + $visibleB = (bool)($b['visible'] ?? true); + if ($visibleA !== $visibleB) { + return $visibleA ? -1 : 1; + } + + $orderA = (int)($a['order'] ?? 9999); + $orderB = (int)($b['order'] ?? 9999); + if ($orderA !== $orderB) { + return $orderA <=> $orderB; + } + + $floorLevelA = (int)($a['floor_level'] ?? 0); + $floorLevelB = (int)($b['floor_level'] ?? 0); + if ($floorLevelA !== $floorLevelB) { + return $floorLevelA <=> $floorLevelB; + } + + return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? '')); + }); + + return $rooms; +} + +function app_room_layout_items(array $room): array +{ + $items = $room['layout_items'] ?? []; + if (!is_array($items)) { + return []; + } + + $normalized = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $itemId = trim((string)($item['id'] ?? $item['layout_item_id'] ?? '')); + $type = trim((string)($item['type'] ?? 'ghost')) ?: 'ghost'; + if ($itemId === '' || $type !== 'ghost') { + continue; + } + + $normalized[] = [ + 'id' => $itemId, + 'type' => 'ghost', + 'order' => isset($item['order']) ? (int)$item['order'] : 9999, + ]; + } + + usort($normalized, static function (array $a, array $b): int { + $orderA = (int)($a['order'] ?? 9999); + $orderB = (int)($b['order'] ?? 9999); + if ($orderA !== $orderB) { + return $orderA <=> $orderB; + } + + return strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? '')); + }); + + return $normalized; +} + +function app_label_matches(string $label, string $needle): bool +{ + $label = trim($label); + $needle = trim($needle); + if ($label === '' || $needle === '') { + return false; + } + + $normalize = static function (string $value): string { + $value = preg_replace('/\s+/u', ' ', trim($value)) ?? trim($value); + return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); + }; + + $labelNorm = $normalize($label); + $needleNorm = $normalize($needle); + return $labelNorm === $needleNorm || str_contains($labelNorm, $needleNorm); +} + +function app_battery_label_matches(string $label): bool +{ + return app_label_matches($label, 'Батарейка') + || app_label_matches($label, 'Battery') + || app_label_matches($label, 'batareika') + || app_label_matches($label, 'batareyka'); +} + +function pluralizeRu(int $count, string $one, string $few, string $many): string +{ + $count = abs($count); + $mod10 = $count % 10; + $mod100 = $count % 100; + + if ($mod10 === 1 && $mod100 !== 11) { + return $one; + } + + if ($mod10 >= 2 && $mod10 <= 4 && !($mod100 >= 12 && $mod100 <= 14)) { + return $few; + } + + return $many; +} + +function app_battery_cache_path(): string +{ + return app_storage_path('battery_cache.json'); +} + +function app_load_battery_cache(): array +{ + return app_load_json_file(app_battery_cache_path(), [ + 'items' => [], + ]); +} + +function app_save_battery_cache(array $cache): void +{ + app_save_json_file(app_battery_cache_path(), $cache); +} + +function app_battery_status_rank(string $status): int +{ + return match ($status) { + 'empty' => 0, + 'critical' => 1, + 'low' => 2, + 'unavailable' => 3, + 'unknown' => 4, + default => 5, + }; +} + +function app_battery_status_label(string $status): string +{ + return match ($status) { + 'empty' => 'Разряжена', + 'critical' => 'Скоро разрядится', + 'low' => 'Низкий заряд', + 'unavailable' => 'Недоступно', + 'unknown' => 'Неизвестно', + default => 'Норма', + }; +} + +function app_battery_percent_text(?float $percent): ?string +{ + if ($percent === null) { + return null; + } + + $value = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.'); + return $value . '%'; +} + +function app_battery_percentage_from_entity(array $entity): ?float +{ + $candidates = [ + $entity['state'] ?? null, + $entity['attributes']['battery_level'] ?? null, + $entity['attributes']['battery_percentage'] ?? null, + $entity['attributes']['battery'] ?? null, + $entity['attributes']['percentage'] ?? null, + ]; + + foreach ($candidates as $candidate) { + if ($candidate === null) { + continue; + } + + $text = trim((string)$candidate); + if ($text === '') { + continue; + } + + $text = str_replace(',', '.', $text); + $text = rtrim($text, '%'); + if (!is_numeric($text)) { + continue; + } + + $value = (float)$text; + if ($value < 0) { + continue; + } + + if ($value <= 100) { + return round($value, 1); + } + } + + return null; +} + +function app_battery_status_from_entity(array $entity, ?float $percent): string +{ + $state = strtolower(trim((string)($entity['state'] ?? ''))); + if ($state === 'unavailable') { + return 'unavailable'; + } + if ($state === 'unknown' || $state === '') { + return $percent === null ? 'unknown' : 'ok'; + } + + if ($percent === null) { + return 'unknown'; + } + + if ($percent <= 0) { + return 'empty'; + } + if ($percent <= 10) { + return 'critical'; + } + if ($percent <= 30) { + return 'low'; + } + return 'ok'; +} + +function app_battery_icon_for_status(string $status, ?float $percent = null): string +{ + return match ($status) { + 'empty' => 'mdi:battery-outline', + 'critical' => 'mdi:battery-alert', + 'low' => 'mdi:battery-20', + 'unavailable' => 'mdi:battery-off-outline', + 'unknown' => 'mdi:battery-unknown', + default => $percent !== null && $percent >= 80 ? 'mdi:battery' : 'mdi:battery-medium', + }; +} + +function app_battery_forecast_duration_text(int $minutes): string +{ + $minutes = max(0, $minutes); + $days = intdiv($minutes, 1440); + $hours = intdiv($minutes % 1440, 60); + $mins = $minutes % 60; + + if ($days > 0) { + $pieces = [$days . 'д']; + if ($hours > 0) { + $pieces[] = $hours . 'ч'; + } + return implode(' ', $pieces); + } + + if ($hours > 0) { + $pieces = [$hours . 'ч']; + if ($mins > 0) { + $pieces[] = $mins . 'м'; + } + return implode(' ', $pieces); + } + + return $mins . 'м'; +} + +function app_battery_history_points(array $history): array +{ + $points = []; + $groups = array_is_list($history) && isset($history[0]) && is_array($history[0]) ? $history : [$history]; + + foreach ($groups as $group) { + if (!is_array($group)) { + continue; + } + + foreach ($group as $entry) { + if (!is_array($entry)) { + continue; + } + + $rawValue = $entry['state'] ?? $entry['value'] ?? null; + if ($rawValue === null) { + continue; + } + + $valueText = str_replace(',', '.', trim((string)$rawValue)); + $valueText = rtrim($valueText, '%'); + if (!is_numeric($valueText)) { + continue; + } + + $value = (float)$valueText; + if ($value < 0 || $value > 100) { + continue; + } + + $timestamp = null; + foreach (['last_changed', 'last_updated', 'ts', 'timestamp'] as $key) { + if (!empty($entry[$key])) { + $parsed = strtotime((string)$entry[$key]); + if ($parsed !== false) { + $timestamp = $parsed; + break; + } + } + } + + if ($timestamp === null) { + continue; + } + + $points[] = [ + 'timestamp' => $timestamp, + 'value' => round($value, 1), + ]; + } + } + + usort($points, static function (array $a, array $b): int { + return ($a['timestamp'] ?? 0) <=> ($b['timestamp'] ?? 0); + }); + + $deduped = []; + foreach ($points as $point) { + $last = $deduped[array_key_last($deduped)] ?? null; + if ($last && (int)($last['timestamp'] ?? 0) === (int)($point['timestamp'] ?? 0)) { + $deduped[array_key_last($deduped)] = $point; + continue; + } + $deduped[] = $point; + } + + return $deduped; +} + +function app_battery_forecast_from_points(array $points, ?float $currentPercent): array +{ + $percent = $currentPercent !== null ? max(0.0, min(100.0, $currentPercent)) : null; + $count = count($points); + if ($count < 3 || $percent === null || $percent <= 0) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => null, + 'forecast_reason' => $count < 3 + ? 'Недостаточно истории' + : ($percent === null ? 'Нет числового значения' : 'Батарея уже разряжена'), + ]; + } + + $subset = array_slice($points, max(0, $count - 20)); + $firstTs = (int)($subset[0]['timestamp'] ?? 0); + $sumX = 0.0; + $sumY = 0.0; + $sumXX = 0.0; + $sumXY = 0.0; + $n = 0; + + foreach ($subset as $point) { + $ts = (int)($point['timestamp'] ?? 0); + $value = (float)($point['value'] ?? 0); + if ($ts <= 0) { + continue; + } + + $x = ($ts - $firstTs) / 3600; + $y = $value; + $sumX += $x; + $sumY += $y; + $sumXX += $x * $x; + $sumXY += $x * $y; + $n++; + } + + if ($n < 3) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => null, + 'forecast_reason' => 'Недостаточно истории', + ]; + } + + $denominator = ($n * $sumXX) - ($sumX * $sumX); + if (abs($denominator) < 1e-9) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => null, + 'forecast_reason' => 'Нет заметного разряда', + ]; + } + + $slopePerHour = (($n * $sumXY) - ($sumX * $sumY)) / $denominator; + if ($slopePerHour >= -0.01) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => round($slopePerHour, 4), + 'forecast_reason' => $slopePerHour > 0.01 ? 'Заряд не падает' : 'Нет заметного разряда', + ]; + } + + $minutesLeft = (int)ceil($percent / abs($slopePerHour) * 60); + if ($minutesLeft <= 0 || $minutesLeft > 525600) { + return [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => round($slopePerHour, 4), + 'forecast_reason' => 'Прогноз вне диапазона', + ]; + } + + return [ + 'forecast_minutes_left' => $minutesLeft, + 'forecast_text' => '≈ ' . app_battery_forecast_duration_text($minutesLeft) . ' до разряда', + 'forecast_slope_per_hour' => round($slopePerHour, 4), + 'forecast_reason' => null, + ]; +} + +function app_battery_source_room_name(array $entityIndexItem, array $roomNameByAreaId): string +{ + $areaId = trim((string)($entityIndexItem['area_id'] ?? '')); + if ($areaId !== '' && isset($roomNameByAreaId[$areaId])) { + return (string)$roomNameByAreaId[$areaId]; + } + + return $areaId !== '' ? $areaId : 'Без комнаты'; +} + +function app_battery_item_status_summary(array $items): array +{ + $counts = [ + 'empty' => 0, + 'critical' => 0, + 'low' => 0, + 'unavailable' => 0, + 'unknown' => 0, + 'ok' => 0, + ]; + + foreach ($items as $item) { + $status = (string)($item['battery_status'] ?? 'unknown'); + if (isset($counts[$status])) { + $counts[$status]++; + } + } + + return $counts; +} + +function app_battery_summary_text(array $counts, int $total): string +{ + $problem = ($counts['empty'] ?? 0) + ($counts['critical'] ?? 0) + ($counts['low'] ?? 0); + $unavailable = (int)($counts['unavailable'] ?? 0); + $unknown = (int)($counts['unknown'] ?? 0); + + $parts = []; + if ($problem > 0) { + $parts[] = $problem . ' ' . pluralizeRu($problem, 'проблемная', 'проблемных', 'проблемных'); + } + if ($unavailable > 0) { + $parts[] = $unavailable . ' ' . pluralizeRu($unavailable, 'недоступная', 'недоступных', 'недоступных'); + } + if ($unknown > 0) { + $parts[] = $unknown . ' ' . pluralizeRu($unknown, 'неизвестная', 'неизвестных', 'неизвестных'); + } + + if ($parts) { + return implode(' · ', $parts); + } + + return $total > 0 + ? $total . ' ' . pluralizeRu($total, 'батарейка', 'батарейки', 'батареек') + : 'Нет батареек'; +} + +function app_battery_history_hours(array $config): int +{ + return max(24, (int)($config['app']['battery_history_hours'] ?? 4320)); +} + +function app_battery_room(array $config, array $haData, array $rooms, HomeAssistantClient $client, bool $refreshForecast = false): array +{ + $entityIndex = app_entity_index($config, $haData); + $registryMap = app_registry_map($haData['entity_registry'] ?? []); + $deviceMap = app_device_registry_map($haData['device_registry'] ?? []); + $roomNameByAreaId = []; + foreach ($rooms as $room) { + if (!is_array($room)) { + continue; + } + $areaId = trim((string)($room['area_id'] ?? $room['id'] ?? '')); + if ($areaId === '') { + continue; + } + $roomNameByAreaId[$areaId] = (string)($room['name'] ?? $areaId); + } + + $cache = app_load_battery_cache(); + $cacheItems = is_array($cache['items'] ?? null) ? $cache['items'] : []; + $cacheChanged = false; + $now = time(); + $historyHours = app_battery_history_hours($config); + $batteryEntities = []; + $deviceGroups = []; + + foreach ($entityIndex as $entityId => $item) { + $registryEntry = is_array($registryMap[$entityId] ?? null) ? $registryMap[$entityId] : []; + $deviceId = trim((string)($item['device_id'] ?? $registryEntry['device_id'] ?? $registryEntry['di'] ?? '')); + $device = $deviceId !== '' && isset($deviceMap[$deviceId]) && is_array($deviceMap[$deviceId]) ? $deviceMap[$deviceId] : []; + $entityLabels = is_array($item['labels'] ?? null) ? $item['labels'] : []; + $deviceLabels = app_labels_from_entity([], $device); + $hasBatteryLabel = false; + foreach ($deviceLabels as $label) { + if (app_battery_label_matches((string)$label)) { + $hasBatteryLabel = true; + break; + } + } + + if (!$hasBatteryLabel) { + continue; + } + + $groupKey = $deviceId !== '' ? 'device:' . $deviceId : 'entity:' . $entityId; + if (!isset($deviceGroups[$groupKey])) { + $deviceGroups[$groupKey] = [ + 'device_id' => $deviceId, + 'device' => $device, + 'items' => [], + ]; + } + + $deviceGroups[$groupKey]['items'][] = [ + 'entity' => $item, + 'registry' => $registryEntry, + ]; + } + + foreach ($deviceGroups as $group) { + $bestCandidate = null; + $bestScore = PHP_INT_MIN; + + foreach ($group['items'] as $candidate) { + $candidateItem = is_array($candidate['entity'] ?? null) ? $candidate['entity'] : []; + $candidateEntityId = (string)($candidateItem['entity_id'] ?? ''); + if ($candidateEntityId === '') { + continue; + } + + $isBatterySensor = preg_match('/^sensor\..*_battery$/i', $candidateEntityId) === 1; + $percent = app_battery_percentage_from_entity($candidateItem); + $state = strtolower(trim((string)($candidateItem['state'] ?? ''))); + $score = $isBatterySensor ? 1000 : 0; + if ($percent !== null) { + $score += 100; + } + if ($state !== '' && !in_array($state, ['unknown', 'unavailable', 'none'], true)) { + $score += 10; + } + if (($candidateItem['domain'] ?? '') === 'sensor') { + $score += 5; + } + if ($bestCandidate === null || $score > $bestScore || ($score === $bestScore && strcasecmp($candidateEntityId, (string)($bestCandidate['entity_id'] ?? '')) < 0)) { + $bestCandidate = [ + 'entity_id' => $candidateEntityId, + 'item' => $candidateItem, + 'is_battery_sensor' => $isBatterySensor, + ]; + $bestScore = $score; + } + } + + if ($bestCandidate === null || !$bestCandidate['is_battery_sensor']) { + continue; + } + + $bestItem = $bestCandidate['item']; + $entityId = (string)($bestItem['entity_id'] ?? ''); + $percent = app_battery_percentage_from_entity($bestItem); + $status = app_battery_status_from_entity($bestItem, $percent); + $cacheEntry = is_array($cacheItems[$entityId] ?? null) ? $cacheItems[$entityId] : []; + $forecast = [ + 'forecast_minutes_left' => null, + 'forecast_text' => null, + 'forecast_slope_per_hour' => null, + 'forecast_reason' => null, + ]; + + $cachedAt = (int)($cacheEntry['loaded_at'] ?? 0); + $cacheMatchesWindow = (int)($cacheEntry['history_hours'] ?? 0) === $historyHours; + $freshEnough = $cachedAt > 0 && ($now - $cachedAt) < 6 * 3600 && $cacheMatchesWindow; + $shouldRefresh = ($refreshForecast || !$cacheMatchesWindow) + && $percent !== null + && !in_array($status, ['unavailable', 'unknown'], true); + + if ($shouldRefresh && (!$freshEnough || !isset($cacheEntry['forecast_minutes_left']))) { + try { + $history = $client->fetchEntityHistory($entityId, $historyHours); + $points = app_battery_history_points($history); + $forecast = app_battery_forecast_from_points($points, $percent); + $cacheItems[$entityId] = [ + 'loaded_at' => $now, + 'history_hours' => $historyHours, + 'points' => $points, + 'forecast_minutes_left' => $forecast['forecast_minutes_left'], + 'forecast_text' => $forecast['forecast_text'], + 'forecast_slope_per_hour' => $forecast['forecast_slope_per_hour'], + 'forecast_reason' => $forecast['forecast_reason'] ?? null, + 'percent' => $percent, + ]; + $cacheChanged = true; + } catch (Throwable $e) { + if (is_array($cacheEntry) && isset($cacheEntry['forecast_minutes_left'])) { + $forecast = [ + 'forecast_minutes_left' => isset($cacheEntry['forecast_minutes_left']) ? (int)$cacheEntry['forecast_minutes_left'] : null, + 'forecast_text' => isset($cacheEntry['forecast_text']) ? (string)$cacheEntry['forecast_text'] : null, + 'forecast_slope_per_hour' => isset($cacheEntry['forecast_slope_per_hour']) ? (float)$cacheEntry['forecast_slope_per_hour'] : null, + 'forecast_reason' => isset($cacheEntry['forecast_reason']) ? (string)$cacheEntry['forecast_reason'] : null, + ]; + } + } + } elseif ($cacheMatchesWindow && is_array($cacheEntry) && isset($cacheEntry['forecast_minutes_left'])) { + $forecast = [ + 'forecast_minutes_left' => isset($cacheEntry['forecast_minutes_left']) ? (int)$cacheEntry['forecast_minutes_left'] : null, + 'forecast_text' => isset($cacheEntry['forecast_text']) ? (string)$cacheEntry['forecast_text'] : null, + 'forecast_slope_per_hour' => isset($cacheEntry['forecast_slope_per_hour']) ? (float)$cacheEntry['forecast_slope_per_hour'] : null, + 'forecast_reason' => isset($cacheEntry['forecast_reason']) ? (string)$cacheEntry['forecast_reason'] : null, + ]; + } + + $deviceId = (string)($group['device_id'] ?? ''); + $device = is_array($group['device'] ?? null) ? $group['device'] : []; + $deviceName = app_device_display_name($device, $deviceId); + $sourceItem = $bestItem; + $sourceAreaId = trim((string)($sourceItem['area_id'] ?? '')); + if ($sourceAreaId === '' && !empty($device['area_id'])) { + $sourceAreaId = trim((string)$device['area_id']); + if ($sourceAreaId !== '') { + $sourceItem['area_id'] = $sourceAreaId; + } + } + $sourceRoomName = app_battery_source_room_name($sourceItem, $roomNameByAreaId); + $sourceText = trim($sourceRoomName); + if ($sourceText === '' || $sourceText === 'Без комнаты') { + $sourceText = $deviceName !== '' ? $deviceName : $sourceText; + } elseif ($deviceName !== '') { + $sourceText .= ' | ' . $deviceName; + } + + $batteryEntities[] = [ + 'entity_id' => $entityId, + 'name' => (string)($bestItem['name'] ?? $entityId), + 'source_room_name' => $sourceRoomName, + 'source_device_name' => $deviceName, + 'source_text' => $sourceText, + 'domain' => (string)($bestItem['domain'] ?? app_entity_domain($entityId)), + 'battery_percent' => $percent, + 'battery_percent_text' => app_battery_percent_text($percent), + 'battery_status' => $status, + 'battery_status_label' => app_battery_status_label($status), + 'battery_icon' => app_battery_icon_for_status($status, $percent), + 'forecast_minutes_left' => $forecast['forecast_minutes_left'], + 'forecast_text' => $forecast['forecast_text'], + 'forecast_reason' => $forecast['forecast_reason'] ?? null, + 'last_seen_state' => (string)($bestItem['state'] ?? 'unknown'), + 'labels' => $bestItem['labels'] ?? [], + 'order' => (int)($bestItem['order'] ?? 9999), + ]; + } + + if ($cacheChanged) { + app_save_battery_cache(['items' => $cacheItems]); + } + + usort($batteryEntities, static function (array $a, array $b): int { + $rankA = app_battery_status_rank((string)($a['battery_status'] ?? 'ok')); + $rankB = app_battery_status_rank((string)($b['battery_status'] ?? 'ok')); + if ($rankA !== $rankB) { + return $rankA <=> $rankB; + } + + $percentA = $a['battery_percent']; + $percentB = $b['battery_percent']; + if ($percentA !== null && $percentB !== null && $percentA !== $percentB) { + return $percentA <=> $percentB; + } + if ($percentA !== null && $percentB === null) { + return -1; + } + if ($percentA === null && $percentB !== null) { + return 1; + } + + $forecastA = $a['forecast_minutes_left'] ?? null; + $forecastB = $b['forecast_minutes_left'] ?? null; + if ($forecastA !== null && $forecastB !== null && $forecastA !== $forecastB) { + return $forecastA <=> $forecastB; + } + if ($forecastA !== null && $forecastB === null) { + return -1; + } + if ($forecastA === null && $forecastB !== null) { + return 1; + } + + return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? '')); + }); + + $counts = app_battery_item_status_summary($batteryEntities); + $total = count($batteryEntities); + $problem = (int)($counts['empty'] ?? 0) + (int)($counts['critical'] ?? 0) + (int)($counts['low'] ?? 0) + (int)($counts['unavailable'] ?? 0) + (int)($counts['unknown'] ?? 0); + $summaryText = app_battery_summary_text($counts, $total); + + return [ + 'id' => 'batteries', + 'name' => 'Батарейки', + 'icon' => 'mdi:battery-outline', + 'visible' => true, + 'virtual' => true, + 'layout_items' => [], + 'entities' => $batteryEntities, + 'entity_count' => $total, + 'active_entity_count' => $problem, + 'problem_count' => $problem, + 'battery_counts' => $counts, + 'battery_summary_text' => $summaryText, + 'critical_count' => (int)($counts['empty'] ?? 0) + (int)($counts['critical'] ?? 0) + (int)($counts['low'] ?? 0), + 'low_count' => (int)($counts['low'] ?? 0), + 'unavailable_count' => (int)($counts['unavailable'] ?? 0), + 'unknown_count' => (int)($counts['unknown'] ?? 0), + 'order' => -1000, + ]; +} + +function app_room_entities(array $room, array $haData, bool $includeHidden = false): array +{ + $states = $haData['states'] ?? []; + $registryMap = app_registry_map($haData['entity_registry'] ?? []); + $deviceMap = app_device_registry_map($haData['device_registry'] ?? []); + $explicitIds = $room['entity_ids'] ?? []; + $areaId = $room['area_id'] ?? $room['id'] ?? null; + $entityOverrides = $room['entity_overrides'] ?? []; + + $candidates = []; + foreach ($states as $entity) { + $entityId = (string)($entity['entity_id'] ?? ''); + if ($entityId === '') { + continue; + } + + $registryEntry = $registryMap[$entityId] ?? []; + $entityAreaId = (string)($registryEntry['area_id'] ?? ''); + $matchesArea = $areaId !== null && $areaId !== '' && $entityAreaId === (string)$areaId; + $matchesExplicit = is_array($explicitIds) && in_array($entityId, $explicitIds, true); + $matchesDeviceArea = false; + + if (!$matchesArea && !$matchesExplicit && $entityAreaId === '') { + $deviceId = (string)($registryEntry['device_id'] ?? $registryEntry['id'] ?? $registryEntry['di'] ?? ''); + $deviceAreaId = $deviceId !== '' ? (string)($deviceMap[$deviceId]['area_id'] ?? '') : ''; + $matchesDeviceArea = $areaId !== null && $areaId !== '' && $deviceAreaId === (string)$areaId; + } + + if (!$matchesArea && !$matchesExplicit && !$matchesDeviceArea) { + continue; + } + + if (!empty($registryEntry['hidden_by']) || !empty($registryEntry['disabled_by'])) { + continue; + } + + $override = is_array($entityOverrides[$entityId] ?? null) ? $entityOverrides[$entityId] : []; + $isVisible = $override['visible'] ?? true; + if ($isVisible === false && !$includeHidden) { + continue; + } + + $domain = app_entity_domain($entityId); + $cardType = (string)($override['card_type'] ?? app_default_card_type($entityId)); + $labels = app_labels_from_entity($entity, $registryEntry); + + $candidates[] = [ + 'entity_id' => $entityId, + 'domain' => $domain, + 'name' => app_entity_name($entity, $registryEntry, $override), + 'icon' => app_entity_icon($entity, $override), + 'state' => $entity['state'] ?? 'unknown', + 'attributes' => $entity['attributes'] ?? [], + 'labels' => $labels, + 'area_id' => $entityAreaId ?: null, + 'card_type' => $cardType, + 'order' => (int)($override['order'] ?? 9999), + 'subtitle' => app_entity_subtitle($entity, $cardType), + 'override' => $override, + 'visible' => (bool)$isVisible, + ]; + } + + usort($candidates, static function (array $a, array $b): int { + if ($a['order'] === $b['order']) { + return strcasecmp($a['name'], $b['name']); + } + return $a['order'] <=> $b['order']; + }); + + return $candidates; +} + +function app_entity_index(array $config, array $haData): array +{ + $states = $haData['states'] ?? []; + $registryMap = app_registry_map($haData['entity_registry'] ?? []); + $autoLabel = (string)($config['home_assistant']['auto_label'] ?? 'auto'); + $manualAuto = array_flip(array_map('strval', $config['home_assistant']['auto_entity_ids'] ?? [])); + $items = []; + + foreach ($states as $entity) { + $entityId = (string)($entity['entity_id'] ?? ''); + if ($entityId === '') { + continue; + } + + $registryEntry = $registryMap[$entityId] ?? []; + $labels = app_labels_from_entity($entity, $registryEntry); + $isHidden = !empty($registryEntry['hidden_by']) + || !empty($registryEntry['disabled_by']) + || !empty($registryEntry['entity_registry_enabled_default']) && empty($entity['state']); + $items[$entityId] = [ + 'entity_id' => $entityId, + 'domain' => app_entity_domain($entityId), + 'name' => app_entity_name($entity, $registryEntry, []), + 'icon' => app_entity_icon($entity, []), + 'state' => $entity['state'] ?? 'unknown', + 'attributes' => $entity['attributes'] ?? [], + 'labels' => $labels, + 'area_id' => (string)($registryEntry['area_id'] ?? ''), + 'device_id' => (string)($registryEntry['device_id'] ?? $registryEntry['di'] ?? ''), + 'card_type' => app_default_card_type($entityId), + 'is_door_contact' => app_is_door_contact_entity($entity, $registryEntry), + 'is_auto' => app_entity_has_auto_label($labels, $autoLabel) || isset($manualAuto[$entityId]), + 'is_hidden' => (bool)$isHidden, + ]; + } + + return $items; +} + +function app_entity_subtitle(array $entity, string $cardType): string +{ + $state = (string)($entity['state'] ?? ''); + $attr = $entity['attributes'] ?? []; + + return match ($cardType) { + 'cover' => isset($attr['current_position']) ? 'Позиция ' . $attr['current_position'] . '%' : match ($state) { + 'open' => 'Открыто', + 'closed' => 'Закрыто', + default => ucfirst($state), + }, + 'climate' => app_climate_subtitle($entity), + default => app_generic_subtitle($state, $attr), + }; +} + +function app_generic_subtitle(string $state, array $attr): string +{ + if ($state === '') { + return 'Нет данных'; + } + + if (isset($attr['current_temperature']) && is_numeric($attr['current_temperature'])) { + return (string)$state . ' · ' . rtrim(rtrim((string)$attr['current_temperature'], '0'), '.') . '°'; + } + + return match ($state) { + 'on' => 'Включено', + 'off' => 'Выключено', + 'open' => 'Открыто', + 'closed' => 'Закрыто', + default => ucfirst($state), + }; +} + +function app_climate_subtitle(array $entity): string +{ + $attr = $entity['attributes'] ?? []; + $pieces = []; + if (isset($attr['current_temperature']) && is_numeric($attr['current_temperature'])) { + $pieces[] = rtrim(rtrim((string)$attr['current_temperature'], '0'), '.') . '°'; + } + if (isset($attr['temperature']) && is_numeric($attr['temperature'])) { + $pieces[] = 'Цель ' . rtrim(rtrim((string)$attr['temperature'], '0'), '.') . '°'; + } + if (!empty($attr['hvac_action'])) { + $pieces[] = (string)$attr['hvac_action']; + } + return $pieces ? implode(' · ', $pieces) : 'Климат'; +} + +function app_room_active_count(array $items): int +{ + $count = 0; + foreach ($items as $item) { + if (is_array($item) && app_is_active_entity($item)) { + $count++; + } + } + return $count; +} + +function app_room_temperature_badge(array $items): ?string +{ + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $attr = $item['attributes'] ?? []; + $candidate = $attr['current_temperature'] ?? $attr['temperature'] ?? null; + if ($candidate === null || !is_numeric($candidate)) { + continue; + } + + return (string)round((float)$candidate) . '°'; + } + + return null; +} + +function app_room_temperature_candidate(array $room, array $items): ?string +{ + $selectedSensorId = trim((string)($room['temperature_sensor_entity_id'] ?? '')); + if ($selectedSensorId === '') { + return null; + } + + $entity = null; + foreach ($items as $candidate) { + if (!is_array($candidate)) { + continue; + } + if (($candidate['entity_id'] ?? null) === $selectedSensorId) { + $entity = $candidate; + break; + } + } + + if (!$entity) { + return null; + } + + $entityId = (string)($entity['entity_id'] ?? ''); + $attr = $entity['attributes'] ?? []; + $candidate = $attr['current_temperature'] ?? $attr['temperature'] ?? $entity['state'] ?? null; + if ($candidate === null || !is_numeric($candidate)) { + return null; + } + + $domain = app_entity_domain($entityId); + $deviceClass = strtolower((string)($attr['device_class'] ?? '')); + $unit = strtolower(trim((string)($attr['unit_of_measurement'] ?? ''))); + if ($domain !== 'sensor') { + return null; + } + if ($domain === 'sensor' && $deviceClass !== 'temperature' && $unit !== '°c' && $unit !== 'c' && !str_ends_with($entityId, '_temperature')) { + return null; + } + + return (string)round((float)$candidate) . '°'; +} + +function app_main_entities(array $config, array $haData): array +{ + $states = $haData['states'] ?? []; + $registryMap = app_registry_map($haData['entity_registry'] ?? []); + $autoLabel = (string)($config['home_assistant']['auto_label'] ?? 'auto'); + $manualAuto = array_flip(array_map('strval', $config['home_assistant']['auto_entity_ids'] ?? [])); + $items = []; + + foreach ($states as $entity) { + $entityId = (string)($entity['entity_id'] ?? ''); + if ($entityId === '') { + continue; + } + $registryEntry = $registryMap[$entityId] ?? []; + $labels = app_labels_from_entity($entity, $registryEntry); + $isAuto = app_entity_has_auto_label($labels, $autoLabel) || isset($manualAuto[$entityId]); + $isHidden = !empty($registryEntry['hidden_by']) || !empty($registryEntry['disabled_by']); + $domain = app_entity_domain($entityId); + $isDoorContact = app_is_door_contact_entity($entity, $registryEntry); + if (!$isAuto || !app_is_active_entity($entity) || $isHidden) { + continue; + } + + if (!in_array($domain, ['light', 'switch', 'cover', 'fan', 'binary_sensor'], true)) { + continue; + } + + if ($domain === 'binary_sensor' && !$isDoorContact) { + continue; + } + $cardType = app_default_card_type($entityId); + $items[] = [ + 'entity_id' => $entityId, + 'domain' => $domain, + 'name' => app_entity_name($entity, $registryEntry, []), + 'icon' => app_entity_icon($entity, []), + 'state' => $entity['state'] ?? 'unknown', + 'attributes' => $entity['attributes'] ?? [], + 'labels' => $labels, + 'card_type' => $cardType, + 'is_door_contact' => $isDoorContact, + 'subtitle' => app_entity_subtitle($entity, $cardType), + 'last_changed' => (string)($entity['last_changed'] ?? $entity['last_updated'] ?? ''), + ]; + } + + usort($items, static function (array $a, array $b): int { + $timeA = strtotime((string)($a['last_changed'] ?? '')) ?: 0; + $timeB = strtotime((string)($b['last_changed'] ?? '')) ?: 0; + if ($timeA !== $timeB) { + return $timeA <=> $timeB; + } + + return strcasecmp($a['name'], $b['name']); + }); + + return $items; +} + +function app_weather_summary(?array $entity): ?array +{ + if (!$entity) { + return null; + } + + $attr = $entity['attributes'] ?? []; + + return [ + 'entity_id' => $entity['entity_id'] ?? null, + 'name' => $attr['friendly_name'] ?? 'Погода', + 'state' => $entity['state'] ?? null, + 'temperature' => $attr['temperature'] ?? null, + 'sensor_temperature' => null, + 'wind_speed' => $attr['wind_speed'] ?? null, + 'condition' => $attr['condition'] ?? ($entity['state'] ?? null), + ]; +} + +function app_find_state(array $states, string $entityId): ?array +{ + foreach ($states as $entity) { + if (($entity['entity_id'] ?? null) === $entityId) { + return $entity; + } + } + + return null; +} + +function app_room_summary(array $room, array $items, array $haData): array +{ + $activeCount = app_room_active_count($items); + $allItems = app_room_entities($room, $haData, true); + $temperatureBadge = app_room_temperature_candidate($room, $allItems); + if ($temperatureBadge === null) { + $temperatureBadge = app_room_temperature_badge($items); + } + if ($temperatureBadge === null) { + $explicitTemperatureIds = []; + foreach (($room['entity_overrides'] ?? []) as $entityId => $override) { + if (!is_array($override)) { + continue; + } + if (str_ends_with((string)$entityId, '_temperature') && !empty($override['visible'])) { + $explicitTemperatureIds[] = (string)$entityId; + } + if (str_ends_with((string)$entityId, '_temperature') && array_key_exists('visible', $override) && $override['visible'] === false) { + $explicitTemperatureIds[] = (string)$entityId; + } + } + + foreach ($explicitTemperatureIds as $temperatureEntityId) { + foreach (($haData['states'] ?? []) as $entity) { + if (($entity['entity_id'] ?? null) !== $temperatureEntityId) { + continue; + } + $candidate = $entity['attributes']['current_temperature'] ?? $entity['attributes']['temperature'] ?? $entity['state'] ?? null; + if ($candidate !== null && is_numeric($candidate)) { + $temperatureBadge = (string)round((float)$candidate) . '°'; + break 2; + } + } + } + + if ($temperatureBadge === null) { + $temperatureBadge = app_room_temperature_badge($allItems); + } + } + return [ + 'id' => $room['id'], + 'name' => $room['name'] ?? $room['id'], + 'icon' => $room['icon'] ?? 'mdi:home-variant', + 'floor_id' => $room['floor_id'] ?? null, + 'floor_name' => $room['floor_name'] ?? null, + 'floor_level' => isset($room['floor_level']) ? (int)$room['floor_level'] : null, + 'visible' => (bool)($room['visible'] ?? true), + 'order' => (int)($room['order'] ?? 9999), + 'temperature_sensor_entity_id' => trim((string)($room['temperature_sensor_entity_id'] ?? '')), + 'entity_count' => $activeCount, + 'active_entity_count' => $activeCount, + 'temperature_badge' => $temperatureBadge, + ]; +} + +function app_build_snapshot(array $config, HomeAssistantClient $client, ?string $selectedRoomId = null): array +{ + $haData = $client->fetchSnapshotData(); + $demo = $client->isDemo(); + $editMode = (bool)($config['app']['edit_mode'] ?? false); + $rooms = app_room_definitions($config, $haData); + $mainRoom = [ + 'id' => 'main', + 'name' => (string)($config['app']['main_room_name'] ?? 'Главная'), + 'icon' => (string)($config['app']['main_room_icon'] ?? 'mdi:home'), + 'visible' => true, + 'entity_count' => 0, + ]; + + $roomSummaries = [$mainRoom]; + $roomViews = []; + $spaceIndex = []; + $spaceEntities = []; + + foreach ($rooms as $room) { + $entities = app_room_entities($room, $haData, $editMode); + $summary = app_room_summary($room, $entities, $haData); + $roomSummaries[] = $summary; + $roomViews[$room['id']] = [ + 'id' => $room['id'], + 'name' => $room['name'] ?? $room['id'], + 'icon' => $room['icon'] ?? 'mdi:home-variant', + 'visible' => (bool)($room['visible'] ?? true), + 'order' => (int)($room['order'] ?? 9999), + 'entities' => $entities, + 'layout_items' => app_room_layout_items($room), + 'entity_count' => $summary['entity_count'], + 'active_entity_count' => $summary['active_entity_count'], + 'temperature_badge' => $summary['temperature_badge'], + ]; + $spaceIndex[$room['id']] = $roomViews[$room['id']]; + $spaceEntities[$room['id']] = $entities; + } + + $batteryRoom = app_battery_room($config, $haData, $rooms, $client, $selectedRoomId === 'batteries'); + + $states = $haData['states'] ?? []; + $entityIndex = app_entity_index($config, $haData); + $mainEntities = app_main_entities($config, $haData); + $mainActiveCount = count($mainEntities); + $mainRoom['entity_count'] = $mainActiveCount; + $mainRoom['active_entity_count'] = $mainActiveCount; + $mainRoom['temperature_badge'] = null; + $roomSummaries[0] = $mainRoom; + $weatherEntity = app_select_weather_entity($states, $config); + $weather = app_weather_summary($weatherEntity); + $weatherSensor = app_find_state($states, 'sensor.weather_temperature'); + if ($weather !== null) { + $weather['sensor_temperature'] = $weatherSensor['state'] ?? null; + } + + $selectedRoomId = $selectedRoomId ?: 'main'; + if (!isset($roomViews[$selectedRoomId]) && !in_array($selectedRoomId, ['main', 'batteries'], true)) { + $selectedRoomId = 'main'; + } + + $selectedRoom = $selectedRoomId === 'main' + ? [ + 'id' => 'main', + 'name' => $mainRoom['name'], + 'icon' => $mainRoom['icon'], + 'visible' => true, + 'entities' => $mainEntities, + 'layout_items' => [], + 'entity_count' => count($mainEntities), + 'active_entity_count' => count($mainEntities), + 'temperature_badge' => null, + ] + : ($selectedRoomId === 'batteries' + ? $batteryRoom + : ($spaceIndex[$selectedRoomId] ?? $roomViews[$selectedRoomId])); + + if ($selectedRoomId === 'main') { + $selectedRoom['weather'] = $weather; + } + + $popup = app_clear_expired_popup_state($config); + $camera = $config['camera'] ?? []; + + return [ + 'ok' => true, + 'demo' => $demo, + 'server_time' => time(), + 'settings' => [ + 'title' => $config['app']['title'] ?? 'Wall Panel', + 'poll_interval_ms' => (int)($config['app']['poll_interval_ms'] ?? 5000), + 'edit_mode' => (bool)($config['app']['edit_mode'] ?? false), + 'main_room_name' => $mainRoom['name'], + 'ha_connection' => [ + 'base_url' => (string)($config['home_assistant']['base_url'] ?? ''), + 'token' => (string)($config['home_assistant']['token'] ?? ''), + 'verify_ssl' => (bool)($config['home_assistant']['verify_ssl'] ?? true), + ], + 'camera' => [ + 'poster_url' => (string)($camera['poster_url'] ?? ''), + 'stream_url' => (string)($camera['stream_url'] ?? ''), + 'stream_mode' => (string)($camera['stream_mode'] ?? 'hls'), + 'popup_timeout_minutes' => (int)($camera['popup_timeout_minutes'] ?? 3), + 'trigger_entities' => app_trigger_entities($config), + ], + 'main_weather_actions' => app_main_weather_actions($config), + 'main_boiler' => app_main_boiler($config), + 'main_print' => app_main_print($config), + ], + 'spaces' => $roomSummaries, + 'selected_space' => $selectedRoom, + 'space_index' => $spaceIndex, + 'space_entities' => $spaceEntities, + 'entity_index' => $entityIndex, + 'weather' => $weather, + 'main_entities' => $mainEntities, + 'battery_room' => $batteryRoom, + 'temperature_sensor_entity_id' => $selectedRoom['temperature_sensor_entity_id'] ?? null, + 'popup' => [ + 'active' => (bool)($popup['active'] ?? false), + 'sensor_entity_id' => $popup['sensor_entity_id'] ?? null, + 'opened_at' => $popup['opened_at'] ?? null, + 'expires_at' => $popup['expires_at'] ?? null, + 'poster_url' => (string)($camera['poster_url'] ?? ''), + 'stream_url' => (string)($camera['stream_url'] ?? ''), + 'stream_mode' => (string)($camera['stream_mode'] ?? 'hls'), + 'title' => 'Камера', + ], + 'rooms' => $roomSummaries, + 'selected_room' => $selectedRoom, + ]; +} + +function app_update_entity_override(array $config, string $roomId, string $entityId, array $patch): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + $found = false; + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + $room['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : []; + $current = is_array($room['entity_overrides'][$entityId] ?? null) ? $room['entity_overrides'][$entityId] : []; + $room['entity_overrides'][$entityId] = array_replace_recursive($current, array_filter($patch, static fn($value) => $value !== null)); + $found = true; + break; + } + unset($room); + + if (!$found) { + $newRoom = [ + 'id' => $roomId, + 'visible' => true, + 'entity_ids' => [], + 'entity_overrides' => [ + $entityId => array_filter($patch, static fn($value) => $value !== null), + ], + ]; + if (array_key_exists('name', $patch) && trim((string)$patch['name']) !== '') { + $newRoom['name'] = (string)$patch['name']; + } + if (array_key_exists('icon', $patch) && trim((string)$patch['icon']) !== '') { + $newRoom['icon'] = (string)$patch['icon']; + } + $rooms[] = [ + ...$newRoom, + ]; + } + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_update_room_override(array $config, string $roomId, array $patch): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + $found = false; + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + + foreach (['visible', 'order', 'name', 'icon'] as $key) { + if (array_key_exists($key, $patch) && $patch[$key] !== null) { + $room[$key] = $key === 'visible' ? (bool)$patch[$key] : ($key === 'order' ? (int)$patch[$key] : (string)$patch[$key]); + } + } + if (array_key_exists('temperature_sensor_entity_id', $patch)) { + $room['temperature_sensor_entity_id'] = trim((string)($patch['temperature_sensor_entity_id'] ?? '')); + } + + if (!isset($room['entity_ids']) || !is_array($room['entity_ids'])) { + $room['entity_ids'] = []; + } + if (!isset($room['entity_overrides']) || !is_array($room['entity_overrides'])) { + $room['entity_overrides'] = []; + } + if (!isset($room['layout_items']) || !is_array($room['layout_items'])) { + $room['layout_items'] = []; + } + + $found = true; + break; + } + unset($room); + + if (!$found) { + $newRoom = [ + 'id' => $roomId, + 'visible' => array_key_exists('visible', $patch) ? (bool)$patch['visible'] : true, + 'order' => array_key_exists('order', $patch) ? (int)$patch['order'] : 9999, + 'entity_ids' => [], + 'entity_overrides' => [], + 'layout_items' => [], + 'temperature_sensor_entity_id' => array_key_exists('temperature_sensor_entity_id', $patch) + ? trim((string)($patch['temperature_sensor_entity_id'] ?? '')) + : '', + ]; + if (array_key_exists('name', $patch) && trim((string)$patch['name']) !== '') { + $newRoom['name'] = (string)$patch['name']; + } + if (array_key_exists('icon', $patch) && trim((string)$patch['icon']) !== '') { + $newRoom['icon'] = (string)$patch['icon']; + } + $rooms[] = $newRoom; + } + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_update_room_layout_item(array $config, string $roomId, string $layoutItemId, array $patch): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + $found = false; + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + + $room['layout_items'] = app_room_layout_items($room); + $current = null; + foreach ($room['layout_items'] as $index => $item) { + if ((string)($item['id'] ?? '') === $layoutItemId) { + $current = $item; + break; + } + } + + if ($current === null) { + $current = [ + 'id' => $layoutItemId, + 'type' => 'ghost', + 'order' => 9999, + ]; + } + + $next = array_replace($current, array_filter($patch, static fn($value) => $value !== null)); + $next['id'] = $layoutItemId; + $next['type'] = 'ghost'; + $next['order'] = isset($next['order']) ? (int)$next['order'] : 9999; + + $updated = []; + $replaced = false; + foreach ($room['layout_items'] as $item) { + if ((string)($item['id'] ?? '') === $layoutItemId) { + $updated[] = $next; + $replaced = true; + } else { + $updated[] = $item; + } + } + + if (!$replaced) { + $updated[] = $next; + } + + usort($updated, static function (array $a, array $b): int { + $orderA = (int)($a['order'] ?? 9999); + $orderB = (int)($b['order'] ?? 9999); + if ($orderA !== $orderB) { + return $orderA <=> $orderB; + } + return strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? '')); + }); + + $room['layout_items'] = $updated; + $found = true; + break; + } + unset($room); + + if (!$found) { + $rooms[] = [ + 'id' => $roomId, + 'visible' => true, + 'entity_ids' => [], + 'entity_overrides' => [], + 'layout_items' => [[ + 'id' => $layoutItemId, + 'type' => 'ghost', + 'order' => isset($patch['order']) ? (int)$patch['order'] : 9999, + ]], + ]; + } + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_delete_room_layout_item(array $config, string $roomId, string $layoutItemId): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + + $room['layout_items'] = array_values(array_filter( + app_room_layout_items($room), + static fn(array $item): bool => (string)($item['id'] ?? '') !== $layoutItemId + )); + break; + } + unset($room); + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_reorder_room_grid(array $config, string $roomId, array $entries): array +{ + $rooms = $config['rooms'] ?? []; + if (!is_array($rooms)) { + $rooms = []; + } + + $normalizedEntries = []; + foreach ($entries as $entry) { + if (!is_array($entry)) { + continue; + } + + $kind = trim((string)($entry['kind'] ?? '')); + $id = trim((string)($entry['id'] ?? '')); + if ($id === '' || !in_array($kind, ['entity', 'layout'], true)) { + continue; + } + + $normalizedEntries[] = [ + 'kind' => $kind, + 'id' => $id, + ]; + } + + foreach ($rooms as &$room) { + if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) { + continue; + } + + $room['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : []; + $room['layout_items'] = app_room_layout_items($room); + $layoutById = []; + foreach ($room['layout_items'] as $item) { + if (is_array($item) && !empty($item['id'])) { + $layoutById[(string)$item['id']] = $item; + } + } + + $order = 10000; + foreach ($normalizedEntries as $entry) { + if ($entry['kind'] === 'entity') { + $current = is_array($room['entity_overrides'][$entry['id']] ?? null) ? $room['entity_overrides'][$entry['id']] : []; + $room['entity_overrides'][$entry['id']] = array_replace($current, [ + 'order' => $order, + ]); + } else { + $current = is_array($layoutById[$entry['id']] ?? null) ? $layoutById[$entry['id']] : [ + 'id' => $entry['id'], + 'type' => 'ghost', + ]; + $layoutById[$entry['id']] = array_replace($current, [ + 'id' => $entry['id'], + 'type' => 'ghost', + 'order' => $order, + ]); + } + $order += 10; + } + + $room['layout_items'] = array_values($layoutById); + usort($room['layout_items'], static function (array $a, array $b): int { + $orderA = (int)($a['order'] ?? 9999); + $orderB = (int)($b['order'] ?? 9999); + if ($orderA !== $orderB) { + return $orderA <=> $orderB; + } + + return strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? '')); + }); + break; + } + unset($room); + + $config['rooms'] = $rooms; + app_save_config($config); + return $config; +} + +function app_handle_popup_event(array $config, array $payload): array +{ + $camera = $config['camera'] ?? []; + $watch = app_trigger_entities($config); + $sensor = (string)($payload['sensor_entity_id'] ?? $payload['entity_id'] ?? ''); + $state = strtolower((string)($payload['state'] ?? $payload['to'] ?? '')); + $command = strtolower((string)($payload['command'] ?? '')); + $popupState = app_load_popup_state(); + $timeoutMinutes = max(1, (int)($camera['popup_timeout_minutes'] ?? 3)); + $closeDelaySeconds = app_popup_close_delay_seconds(); + $timeoutSeconds = max($closeDelaySeconds, $timeoutMinutes * 60); + + if ($command === 'open') { + $popupState = [ + 'active' => true, + 'sensor_entity_id' => $sensor !== '' ? $sensor : 'debug', + 'opened_at' => time(), + 'expires_at' => time() + $timeoutSeconds, + ]; + app_save_popup_state($popupState); + } elseif (in_array($sensor, $watch, true) && $state === 'on') { + $popupState = [ + 'active' => true, + 'sensor_entity_id' => $sensor, + 'opened_at' => time(), + 'expires_at' => null, + ]; + app_save_popup_state($popupState); + } elseif ($state === 'off' && ($popupState['sensor_entity_id'] ?? null) === $sensor && ($popupState['active'] ?? false)) { + $popupState['active'] = true; + $popupState['expires_at'] = time() + $closeDelaySeconds; + if (empty($popupState['opened_at'])) { + $popupState['opened_at'] = time(); + } + app_save_popup_state($popupState); + } elseif (($payload['command'] ?? '') === 'close') { + $popupState['active'] = false; + $popupState['expires_at'] = null; + app_save_popup_state($popupState); + } + + return $popupState; +} + +function app_service_for_entity(string $entityId, string $command): array +{ + $domain = app_entity_domain($entityId); + + return match ($command) { + 'toggle' => [$domain, 'toggle', ['entity_id' => $entityId]], + 'turn_on' => [$domain, 'turn_on', ['entity_id' => $entityId]], + 'turn_off' => [$domain, 'turn_off', ['entity_id' => $entityId]], + 'open' => ['cover', 'open_cover', ['entity_id' => $entityId]], + 'close' => ['cover', 'close_cover', ['entity_id' => $entityId]], + 'stop' => ['cover', 'stop_cover', ['entity_id' => $entityId]], + 'set_position' => ['cover', 'set_cover_position', ['entity_id' => $entityId]], + 'set_temperature' => ['climate', 'set_temperature', ['entity_id' => $entityId]], + 'set_hvac_mode' => ['climate', 'set_hvac_mode', ['entity_id' => $entityId]], + 'set_fan_mode' => ['climate', 'set_fan_mode', ['entity_id' => $entityId]], + 'set_swing_mode' => ['climate', 'set_swing_mode', ['entity_id' => $entityId]], + 'set_preset_mode' => ['climate', 'set_preset_mode', ['entity_id' => $entityId]], + default => [$domain, $command, ['entity_id' => $entityId]], + }; +} diff --git a/wall_panel/lib/ha_client.php b/wall_panel/lib/ha_client.php new file mode 100755 index 0000000..888ba2f --- /dev/null +++ b/wall_panel/lib/ha_client.php @@ -0,0 +1,658 @@ +config = $config; + $baseUrl = trim((string)($config['home_assistant']['base_url'] ?? '')); + $token = trim((string)($config['home_assistant']['token'] ?? '')); + $this->demoMode = $baseUrl === '' || $token === '' || str_contains($baseUrl, 'example.com') || str_contains($token, 'PASTE_') || str_contains($token, 'YOUR_'); + } + + public function isDemo(): bool + { + return $this->demoMode; + } + + public function fetchSnapshotData(): array + { + if ($this->demoMode) { + return $this->demoData(); + } + $states = $this->request('GET', '/api/states'); + $areas = $this->fetchWsList('config/area_registry/list') ?? $this->tryRequest('GET', '/api/areas') ?? []; + $floors = $this->fetchWsList('config/floor_registry/list') ?? []; + $entities = $this->fetchWsList('config/entity_registry/list_for_display') ?? $this->tryRequest('GET', '/api/config/entity_registry/list') ?? []; + $devices = $this->fetchWsList('config/device_registry/list') ?? $this->tryRequest('GET', '/api/config/device_registry/list') ?? []; + + return [ + 'states' => is_array($states) ? $states : [], + 'areas' => is_array($areas) ? $areas : [], + 'floors' => is_array($floors) ? $floors : [], + 'entity_registry' => is_array($entities) ? $entities : [], + 'device_registry' => is_array($devices) ? $devices : [], + ]; + } + + public function fetchEntityHistory(string $entityId, int $hours = 24): array + { + $entityId = trim($entityId); + $hours = max(1, min(168, $hours)); + + if ($this->demoMode) { + return $this->demoHistory($entityId, $hours); + } + + if ($entityId === '') { + return []; + } + + $start = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) + ->modify('-' . $hours . ' hours') + ->format(DATE_ATOM); + + $path = '/api/history/period/' . rawurlencode($start) . '?' . http_build_query([ + 'filter_entity_id' => $entityId, + 'minimal_response' => 1, + 'no_attributes' => 1, + ]); + + $response = $this->request('GET', $path); + return is_array($response) ? $response : []; + } + + public function callService(string $domain, string $service, array $data): array + { + if ($this->demoMode) { + return [ + 'ok' => true, + 'demo' => true, + 'domain' => $domain, + 'service' => $service, + 'data' => $data, + ]; + } + + return $this->request('POST', '/api/services/' . rawurlencode($domain) . '/' . rawurlencode($service), $data); + } + + private function request(string $method, string $path, ?array $body = null): array + { + $response = $this->tryRequest($method, $path, $body, true); + if (!is_array($response)) { + throw new RuntimeException('Unexpected response from Home Assistant'); + } + + return $response; + } + + private function tryRequest(string $method, string $path, ?array $body = null, bool $throwOnHttpError = false): array|null + { + $baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/'); + $url = $baseUrl . $path; + $token = trim((string)$this->config['home_assistant']['token']); + + $ch = curl_init($url); + if ($ch === false) { + throw new RuntimeException('Failed to initialize HTTP client'); + } + + $headers = [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + 'Accept: application/json', + ]; + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 15, + CURLOPT_CONNECTTIMEOUT => 7, + CURLOPT_SSL_VERIFYPEER => (bool)($this->config['home_assistant']['verify_ssl'] ?? true), + CURLOPT_SSL_VERIFYHOST => (bool)($this->config['home_assistant']['verify_ssl'] ?? true) ? 2 : 0, + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + $raw = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($errno !== 0) { + if ($throwOnHttpError) { + throw new RuntimeException('Home Assistant request failed: ' . $error); + } + return null; + } + + if ($status >= 400) { + if ($throwOnHttpError) { + throw new RuntimeException('Home Assistant returned HTTP ' . $status); + } + return null; + } + + $decoded = json_decode((string)$raw, true); + return is_array($decoded) ? $decoded : []; + } + + private function fetchWsList(string $type): array|null + { + $response = $this->wsCall($type); + if (!is_array($response)) { + return null; + } + + if (isset($response['result']) && is_array($response['result'])) { + return $this->normalizeWsResultList($type, $response['result']); + } + + return null; + } + + private function normalizeWsResultList(string $type, array $result): array + { + if (array_is_list($result)) { + return match ($type) { + 'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result), + 'config/area_registry/list' => $this->normalizeAreaRegistry($result), + 'config/floor_registry/list' => $this->normalizeFloorRegistry($result), + 'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result), + default => $result, + }; + } + + return match ($type) { + 'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result['entities'] ?? []), + 'config/area_registry/list' => $this->normalizeAreaRegistry($result['areas'] ?? []), + 'config/floor_registry/list' => $this->normalizeFloorRegistry($result['floors'] ?? []), + 'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result['devices'] ?? $result['device_registry'] ?? $result['items'] ?? []), + default => $result, + }; + } + + private function wsCall(string $type): array|null + { + $baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/'); + $parts = parse_url($baseUrl); + if (!is_array($parts) || empty($parts['host'])) { + return null; + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'http')); + $secure = in_array($scheme, ['https', 'wss'], true); + $host = (string)$parts['host']; + $port = (int)($parts['port'] ?? ($secure ? 443 : 80)); + $path = '/api/websocket'; + $target = ($secure ? 'tls' : 'tcp') . '://' . $host . ':' . $port; + $contextOptions = [ + 'ssl' => [ + 'verify_peer' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true), + 'verify_peer_name' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true), + 'allow_self_signed' => !(bool)($this->config['home_assistant']['verify_ssl'] ?? true), + 'SNI_enabled' => true, + 'peer_name' => $host, + ], + ]; + + $stream = @stream_socket_client( + $target, + $errno, + $error, + 15, + STREAM_CLIENT_CONNECT, + stream_context_create($contextOptions) + ); + + if ($stream === false) { + return null; + } + + stream_set_timeout($stream, 15); + $key = base64_encode(random_bytes(16)); + $request = implode("\r\n", [ + 'GET ' . $path . ' HTTP/1.1', + 'Host: ' . $host . ':' . $port, + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Key: ' . $key, + 'Sec-WebSocket-Version: 13', + '', + '', + ]); + fwrite($stream, $request); + + $status = $this->readHttpHeader($stream); + if ($status === null || !str_contains($status, ' 101 ')) { + fclose($stream); + return null; + } + + $auth = $this->readWsJson($stream); + if (!is_array($auth) || ($auth['type'] ?? '') !== 'auth_required') { + fclose($stream); + return null; + } + + $this->writeWsJson($stream, [ + 'type' => 'auth', + 'access_token' => (string)$this->config['home_assistant']['token'], + ]); + + $authResult = $this->readWsJson($stream); + if (!is_array($authResult) || ($authResult['type'] ?? '') !== 'auth_ok') { + fclose($stream); + return null; + } + + $requestId = random_int(1, 1_000_000); + $this->writeWsJson($stream, [ + 'id' => $requestId, + 'type' => $type, + ]); + + while (($message = $this->readWsJson($stream)) !== null) { + if (($message['id'] ?? null) === $requestId) { + fclose($stream); + if (($message['success'] ?? false) !== true) { + return null; + } + return is_array($message) ? $message : null; + } + } + + fclose($stream); + return null; + } + + private function normalizeEntityRegistryForDisplay(array $result): array + { + $entityCategories = is_array($result['entity_categories'] ?? null) ? $result['entity_categories'] : []; + $entities = $result; + if (!array_is_list($entities)) { + $entities = $result['entities'] ?? []; + } + if (!is_array($entities)) { + return []; + } + + $normalized = []; + foreach ($entities as $entity) { + if (!is_array($entity)) { + continue; + } + + $normalized[] = [ + 'entity_id' => $entity['ei'] ?? null, + 'area_id' => $entity['ai'] ?? null, + 'labels' => $this->normalizeWsLabels($entity['lb'] ?? null), + 'device_id' => $entity['di'] ?? null, + 'icon' => $entity['ic'] ?? null, + 'translation_key' => $entity['tk'] ?? null, + 'entity_category' => isset($entity['ec']) ? ($entityCategories[$entity['ec']] ?? null) : null, + 'hidden_by' => !empty($entity['hb']) ? 'true' : null, + 'name' => $entity['en'] ?? null, + 'has_entity_name' => !empty($entity['hn']), + 'platform' => $entity['pl'] ?? null, + ]; + } + + return $normalized; + } + + private function normalizeAreaRegistry(array $areas): array + { + $normalized = []; + foreach ($areas as $area) { + if (!is_array($area)) { + continue; + } + + $normalized[] = [ + 'area_id' => $area['area_id'] ?? $area['id'] ?? null, + 'name' => $area['name'] ?? $area['en'] ?? null, + 'icon' => $area['icon'] ?? $area['ic'] ?? null, + 'picture' => $area['picture'] ?? $area['pi'] ?? null, + 'floor_id' => $area['floor_id'] ?? $area['fi'] ?? $area['floor'] ?? null, + ]; + } + + return $normalized; + } + + private function normalizeFloorRegistry(array $floors): array + { + $normalized = []; + foreach ($floors as $floor) { + if (!is_array($floor)) { + continue; + } + + $normalized[] = [ + 'floor_id' => $floor['floor_id'] ?? $floor['id'] ?? null, + 'name' => $floor['name'] ?? $floor['en'] ?? null, + 'icon' => $floor['icon'] ?? $floor['ic'] ?? null, + 'level' => $floor['level'] ?? $floor['lv'] ?? null, + ]; + } + + return $normalized; + } + + private function normalizeDeviceRegistryForDisplay(array $devices): array + { + $normalized = []; + foreach ($devices as $device) { + if (!is_array($device)) { + continue; + } + + $normalized[] = [ + 'device_id' => $device['device_id'] ?? $device['id'] ?? $device['di'] ?? null, + 'area_id' => $device['area_id'] ?? $device['ai'] ?? null, + 'labels' => $this->normalizeWsLabels($device['labels'] ?? null), + 'name' => $device['name'] ?? $device['en'] ?? null, + 'manufacturer' => $device['manufacturer'] ?? $device['mf'] ?? null, + 'model' => $device['model'] ?? $device['md'] ?? null, + ]; + } + + return $normalized; + } + + private function normalizeWsLabels(mixed $labels): array + { + if ($labels === null) { + return []; + } + + if (is_string($labels) || is_numeric($labels)) { + return [(string)$labels]; + } + + if (!is_array($labels)) { + return []; + } + + return array_values(array_unique(array_map('strval', $labels))); + } + + private function readHttpHeader($stream): ?string + { + $buffer = ''; + while (!feof($stream)) { + $chunk = fgets($stream); + if ($chunk === false) { + break; + } + $buffer .= $chunk; + if (str_contains($buffer, "\r\n\r\n")) { + break; + } + } + + return $buffer !== '' ? $buffer : null; + } + + private function writeWsJson($stream, array $payload): void + { + fwrite($stream, $this->encodeWsFrame(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}')); + } + + private function readWsJson($stream): array|null + { + $payload = $this->readWsFrame($stream); + if ($payload === null || $payload === '') { + return null; + } + + $decoded = json_decode($payload, true); + return is_array($decoded) ? $decoded : null; + } + + private function encodeWsFrame(string $payload): string + { + $finOpcode = chr(0x81); + $len = strlen($payload); + $maskBit = 0x80; + $mask = random_bytes(4); + $header = ''; + + if ($len <= 125) { + $header = chr($maskBit | $len); + } elseif ($len <= 65535) { + $header = chr($maskBit | 126) . pack('n', $len); + } else { + $header = chr($maskBit | 127) . pack('J', $len); + } + + $masked = ''; + for ($i = 0; $i < $len; $i++) { + $masked .= $payload[$i] ^ $mask[$i % 4]; + } + + return $finOpcode . $header . $mask . $masked; + } + + private function readWsFrame($stream): ?string + { + $first = fread($stream, 2); + if ($first === false || strlen($first) < 2) { + return null; + } + + $bytes = array_values(unpack('C2', $first)); + $opcode = $bytes[0] & 0x0f; + $isMasked = (bool)($bytes[1] & 0x80); + $len = $bytes[1] & 0x7f; + + if ($len === 126) { + $extended = fread($stream, 2); + if ($extended === false || strlen($extended) < 2) { + return null; + } + $len = unpack('n', $extended)[1]; + } elseif ($len === 127) { + $extended = fread($stream, 8); + if ($extended === false || strlen($extended) < 8) { + return null; + } + $parts = unpack('N2', $extended); + $len = ((int)$parts[1] << 32) | (int)$parts[2]; + } + + $mask = ''; + if ($isMasked) { + $mask = fread($stream, 4); + if ($mask === false || strlen($mask) < 4) { + return null; + } + } + + $payload = ''; + while (strlen($payload) < $len && !feof($stream)) { + $chunk = fread($stream, $len - strlen($payload)); + if ($chunk === false || $chunk === '') { + break; + } + $payload .= $chunk; + } + + if ($isMasked && $mask !== '') { + $unmasked = ''; + for ($i = 0, $payloadLen = strlen($payload); $i < $payloadLen; $i++) { + $unmasked .= $payload[$i] ^ $mask[$i % 4]; + } + $payload = $unmasked; + } + + if ($opcode === 0x9) { + $this->writeWsPong($stream, $payload); + return $this->readWsFrame($stream); + } + + if ($opcode === 0x8) { + return null; + } + + return $payload; + } + + private function writeWsPong($stream, string $payload): void + { + $len = strlen($payload); + $header = chr(0x8A); + if ($len <= 125) { + $header .= chr($len); + } elseif ($len <= 65535) { + $header .= chr(126) . pack('n', $len); + } else { + $header .= chr(127) . pack('J', $len); + } + fwrite($stream, $header . $payload); + } + + private function demoData(): array + { + return [ + 'states' => [ + [ + 'entity_id' => 'light.living_room_main', + 'state' => 'on', + 'attributes' => [ + 'friendly_name' => 'Основной свет', + 'icon' => 'mdi:ceiling-light', + 'labels' => ['auto'], + ], + ], + [ + 'entity_id' => 'switch.tv_power', + 'state' => 'off', + 'attributes' => [ + 'friendly_name' => 'ТВ', + 'icon' => 'mdi:television', + 'labels' => ['auto'], + ], + ], + [ + 'entity_id' => 'cover.living_room_curtain', + 'state' => 'open', + 'attributes' => [ + 'friendly_name' => 'Штора', + 'icon' => 'mdi:curtains', + 'current_position' => 82, + ], + ], + [ + 'entity_id' => 'climate.living_room_ac', + 'state' => 'cool', + 'attributes' => [ + 'friendly_name' => 'Кондиционер', + 'icon' => 'mdi:air-conditioner', + 'temperature' => 22, + 'current_temperature' => 24.5, + 'hvac_action' => 'cooling', + 'fan_mode' => 'auto', + ], + ], + [ + 'entity_id' => 'light.kitchen_counter', + 'state' => 'off', + 'attributes' => [ + 'friendly_name' => 'Подсветка', + 'icon' => 'mdi:lightbulb', + 'labels' => ['auto'], + ], + ], + [ + 'entity_id' => 'switch.coffee_machine', + 'state' => 'on', + 'attributes' => [ + 'friendly_name' => 'Кофемашина', + 'icon' => 'mdi:coffee-maker', + 'labels' => ['auto'], + ], + ], + [ + 'entity_id' => 'weather.yandex_weather', + 'state' => 'sunny', + 'attributes' => [ + 'friendly_name' => 'Yandex Weather', + 'temperature' => 18.3, + 'humidity' => 56, + 'wind_speed' => 4.8, + ], + ], + [ + 'entity_id' => 'sensor.weather_temperature', + 'state' => '17.8', + 'attributes' => [ + 'friendly_name' => 'Weather temperature', + ], + ], + [ + 'entity_id' => 'sensor.modeco_2_temperature', + 'state' => '57.0', + 'attributes' => [ + 'friendly_name' => 'ModEco 2 Temperature', + ], + ], + ], + 'areas' => [ + ['area_id' => 'living_room', 'name' => 'Гостиная', 'icon' => 'mdi:sofa'], + ['area_id' => 'kitchen', 'name' => 'Кухня', 'icon' => 'mdi:stove'], + ], + 'entity_registry' => [ + ['entity_id' => 'light.living_room_main', 'area_id' => 'living_room', 'labels' => ['auto']], + ['entity_id' => 'switch.tv_power', 'area_id' => 'living_room', 'labels' => ['auto']], + ['entity_id' => 'cover.living_room_curtain', 'area_id' => 'living_room', 'labels' => []], + ['entity_id' => 'climate.living_room_ac', 'area_id' => 'living_room', 'labels' => []], + ['entity_id' => 'light.kitchen_counter', 'area_id' => 'kitchen', 'labels' => ['auto']], + ['entity_id' => 'switch.coffee_machine', 'area_id' => 'kitchen', 'labels' => ['auto']], + ['entity_id' => 'weather.yandex_weather', 'area_id' => null, 'labels' => []], + ['entity_id' => 'sensor.weather_temperature', 'area_id' => null, 'labels' => []], + ['entity_id' => 'sensor.modeco_2_temperature', 'area_id' => null, 'labels' => []], + ], + 'device_registry' => [], + ]; + } + + private function demoHistory(string $entityId, int $hours): array + { + if ($entityId === '') { + return []; + } + + $now = time(); + $start = $now - ($hours * 3600); + $steps = max(24, min(96, $hours * 4)); + $points = []; + $base = 57.0; + + for ($index = 0; $index <= $steps; $index++) { + $ratio = $steps > 0 ? ($index / $steps) : 1; + $timestamp = $start + (int)(($now - $start) * $ratio); + $drift = sin($ratio * M_PI * 5.5) * 0.8 + cos($ratio * M_PI * 11) * 0.35; + $value = $base + $drift; + + $points[] = [ + 'entity_id' => $entityId, + 'state' => number_format($value, 1, '.', ''), + 'last_changed' => gmdate(DATE_ATOM, $timestamp), + 'last_updated' => gmdate(DATE_ATOM, $timestamp), + ]; + } + + return [$points]; + } +} diff --git a/wall_panel/lib/ha_sync.php b/wall_panel/lib/ha_sync.php new file mode 100755 index 0000000..bf910ba --- /dev/null +++ b/wall_panel/lib/ha_sync.php @@ -0,0 +1,4 @@ +