diff --git a/README.md b/README.md
index 790a9b3..9ee5029 100755
--- a/README.md
+++ b/README.md
@@ -38,15 +38,16 @@ php -S 0.0.0.0:8080
- хранит конфиг в add-on volume;
- не зависит от `custom_components`.
-Файлы add-on лежат в корне репозитория:
+Файлы add-on лежат в папке:
-- [`config.yaml`](/Volumes/web/wallpanell/config.yaml)
-- [`Dockerfile`](/Volumes/web/wallpanell/Dockerfile)
-- [`run.sh`](/Volumes/web/wallpanell/run.sh)
+- [`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. Поместите репозиторий в локальные add-ons Home Assistant или соберите add-on как свой локальный пакет.
+1. Добавьте Git URL репозитория в Home Assistant как add-on repository.
2. Установите add-on.
3. Запустите его.
4. Откройте панель через ingress или через проброшенный порт `8099/tcp`.
diff --git a/repository.yaml b/repository.yaml
new file mode 100755
index 0000000..5f42115
--- /dev/null
+++ b/repository.yaml
@@ -0,0 +1,3 @@
+name: Wall Panel Add-ons
+url: https://git.striker72rus.ru/PHP/wallpanell.git
+maintainer: Striker72rus
diff --git a/storage/popup_state.json b/storage/popup_state.json
index 4cbbac1..5b993ef 100755
--- a/storage/popup_state.json
+++ b/storage/popup_state.json
@@ -1,6 +1,6 @@
{
"active": false,
"sensor_entity_id": "binary_sensor.barn_all_occupancy",
- "opened_at": 1774433119,
+ "opened_at": 1774434333,
"expires_at": null
}
diff --git a/wall_panel/.dockerignore b/wall_panel/.dockerignore
new file mode 100755
index 0000000..ec2e775
--- /dev/null
+++ b/wall_panel/.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/wall_panel/@eaDir/README.md@SynoEAStream b/wall_panel/@eaDir/README.md@SynoEAStream
new file mode 100755
index 0000000..71a85d9
Binary files /dev/null and b/wall_panel/@eaDir/README.md@SynoEAStream differ
diff --git a/wall_panel/@eaDir/api.php@SynoEAStream b/wall_panel/@eaDir/api.php@SynoEAStream
new file mode 100755
index 0000000..71a85d9
Binary files /dev/null and b/wall_panel/@eaDir/api.php@SynoEAStream differ
diff --git a/wall_panel/@eaDir/assets@SynoEAStream b/wall_panel/@eaDir/assets@SynoEAStream
new file mode 100755
index 0000000..71a85d9
Binary files /dev/null and b/wall_panel/@eaDir/assets@SynoEAStream differ
diff --git a/wall_panel/@eaDir/favicon.ico@SynoEAStream b/wall_panel/@eaDir/favicon.ico@SynoEAStream
new file mode 100755
index 0000000..71a85d9
Binary files /dev/null and b/wall_panel/@eaDir/favicon.ico@SynoEAStream differ
diff --git a/wall_panel/@eaDir/index.php@SynoEAStream b/wall_panel/@eaDir/index.php@SynoEAStream
new file mode 100755
index 0000000..71a85d9
Binary files /dev/null and b/wall_panel/@eaDir/index.php@SynoEAStream differ
diff --git a/wall_panel/@eaDir/lib@SynoEAStream b/wall_panel/@eaDir/lib@SynoEAStream
new file mode 100755
index 0000000..71a85d9
Binary files /dev/null and b/wall_panel/@eaDir/lib@SynoEAStream differ
diff --git a/wall_panel/Dockerfile b/wall_panel/Dockerfile
new file mode 100755
index 0000000..f34ea00
--- /dev/null
+++ b/wall_panel/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/wall_panel/README.md b/wall_panel/README.md
new file mode 100755
index 0000000..a7635c1
--- /dev/null
+++ b/wall_panel/README.md
@@ -0,0 +1,141 @@
+# 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 лежат в этой папке:
+
+- [`config.yaml`](/Volumes/web/wallpanell/wall_panel/config.yaml)
+- [`Dockerfile`](/Volumes/web/wallpanell/wall_panel/Dockerfile)
+- [`run.sh`](/Volumes/web/wallpanell/wall_panel/run.sh)
+
+### Как использовать
+
+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.
+
+### Старый 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/wall_panel/api.php b/wall_panel/api.php
new file mode 100755
index 0000000..0ed4b91
--- /dev/null
+++ b/wall_panel/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/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/config.yaml b/wall_panel/config.yaml
similarity index 71%
rename from config.yaml
rename to wall_panel/config.yaml
index 584ad7d..65e4c95 100755
--- a/config.yaml
+++ b/wall_panel/config.yaml
@@ -1,7 +1,8 @@
name: Wall Panel
-description: Wall Panel as a Home Assistant add-on
+description: Wall Panel PHP interface as a Home Assistant add-on
version: "1.0.0"
slug: wall_panel
+url: https://git.striker72rus.ru/PHP/wallpanell.git
init: false
arch:
- aarch64
@@ -20,5 +21,6 @@ ports_description:
8099/tcp: Wall Panel web UI
map:
- addon_config:rw
+homeassistant_api: true
options: {}
schema: {}
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 ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Camera poster]()
+
+
+
Поток загружается
+
Показываем poster, пока не доступен video bridge
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Это действие отправит команду закрытия.
+
+
+
+
+
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));
+
+ if (!file_exists($path)) {
+ $defaults = app_default_config();
+ file_put_contents($path, json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+ return $defaults;
+ }
+
+ $raw = file_get_contents($path);
+ if ($raw === false || trim($raw) === '') {
+ return app_default_config();
+ }
+
+ $decoded = json_decode($raw, true);
+ if (!is_array($decoded)) {
+ throw new RuntimeException('Invalid JSON in config/config.json');
+ }
+
+ return array_replace_recursive(app_default_config(), $decoded);
+}
+
+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 @@
+