From 0f7b410ede5da1168f455936da6f1ae36ea2e177 Mon Sep 17 00:00:00 2001 From: Striker72rus Date: Thu, 19 Mar 2026 21:27:01 +0300 Subject: [PATCH] - --- @eaDir/.git@SynoEAStream | Bin 0 -> 163 bytes @eaDir/README.md@SynoEAStream | Bin 0 -> 163 bytes @eaDir/README.md@SynoResource | Bin 0 -> 82 bytes @eaDir/api.php@SynoEAStream | Bin 0 -> 163 bytes @eaDir/api.php@SynoResource | Bin 0 -> 82 bytes @eaDir/assets@SynoEAStream | Bin 0 -> 163 bytes @eaDir/config@SynoEAStream | Bin 0 -> 163 bytes @eaDir/favicon.ico@SynoEAStream | Bin 0 -> 163 bytes @eaDir/favicon.ico@SynoResource | Bin 0 -> 82 bytes @eaDir/index.php@SynoEAStream | Bin 0 -> 163 bytes @eaDir/index.php@SynoResource | Bin 0 -> 82 bytes @eaDir/lib@SynoEAStream | Bin 0 -> 163 bytes @eaDir/storage@SynoEAStream | Bin 0 -> 163 bytes README.md | 70 + api.php | 158 + assets/@eaDir/app.css@SynoEAStream | Bin 0 -> 163 bytes assets/@eaDir/app.css@SynoResource | Bin 0 -> 82 bytes assets/@eaDir/app.js@SynoEAStream | Bin 0 -> 163 bytes assets/@eaDir/app.js@SynoResource | Bin 0 -> 82 bytes assets/app.css | 1893 ++++++++++ assets/app.js | 3566 ++++++++++++++++++ config/@eaDir/config.json@SynoEAStream | Bin 0 -> 163 bytes config/@eaDir/config.json@SynoResource | Bin 0 -> 82 bytes config/config.json | 1058 ++++++ favicon.ico | Bin 0 -> 15086 bytes index.php | 123 + lib/@eaDir/bootstrap.php@SynoEAStream | Bin 0 -> 163 bytes lib/@eaDir/bootstrap.php@SynoResource | Bin 0 -> 82 bytes lib/@eaDir/config.php@SynoEAStream | Bin 0 -> 163 bytes lib/@eaDir/config.php@SynoResource | Bin 0 -> 82 bytes lib/@eaDir/dashboard.php@SynoEAStream | Bin 0 -> 163 bytes lib/@eaDir/dashboard.php@SynoResource | Bin 0 -> 82 bytes lib/@eaDir/ha_client.php@SynoEAStream | Bin 0 -> 163 bytes lib/@eaDir/ha_client.php@SynoResource | Bin 0 -> 82 bytes lib/bootstrap.php | 9 + lib/config.php | 117 + lib/dashboard.php | 1063 ++++++ lib/ha_client.php | 657 ++++ storage/@eaDir/popup_state.json@SynoEAStream | Bin 0 -> 163 bytes storage/@eaDir/popup_state.json@SynoResource | Bin 0 -> 82 bytes storage/popup_state.json | 6 + 41 files changed, 8720 insertions(+) create mode 100755 @eaDir/.git@SynoEAStream create mode 100755 @eaDir/README.md@SynoEAStream create mode 100755 @eaDir/README.md@SynoResource create mode 100755 @eaDir/api.php@SynoEAStream create mode 100755 @eaDir/api.php@SynoResource create mode 100755 @eaDir/assets@SynoEAStream create mode 100755 @eaDir/config@SynoEAStream create mode 100755 @eaDir/favicon.ico@SynoEAStream create mode 100755 @eaDir/favicon.ico@SynoResource create mode 100755 @eaDir/index.php@SynoEAStream create mode 100755 @eaDir/index.php@SynoResource create mode 100755 @eaDir/lib@SynoEAStream create mode 100755 @eaDir/storage@SynoEAStream create mode 100755 README.md create mode 100755 api.php create mode 100755 assets/@eaDir/app.css@SynoEAStream create mode 100755 assets/@eaDir/app.css@SynoResource create mode 100755 assets/@eaDir/app.js@SynoEAStream create mode 100755 assets/@eaDir/app.js@SynoResource create mode 100755 assets/app.css create mode 100755 assets/app.js create mode 100755 config/@eaDir/config.json@SynoEAStream create mode 100755 config/@eaDir/config.json@SynoResource create mode 100755 config/config.json create mode 100755 favicon.ico create mode 100755 index.php create mode 100755 lib/@eaDir/bootstrap.php@SynoEAStream create mode 100755 lib/@eaDir/bootstrap.php@SynoResource create mode 100755 lib/@eaDir/config.php@SynoEAStream create mode 100755 lib/@eaDir/config.php@SynoResource create mode 100755 lib/@eaDir/dashboard.php@SynoEAStream create mode 100755 lib/@eaDir/dashboard.php@SynoResource create mode 100755 lib/@eaDir/ha_client.php@SynoEAStream create mode 100755 lib/@eaDir/ha_client.php@SynoResource create mode 100755 lib/bootstrap.php create mode 100755 lib/config.php create mode 100755 lib/dashboard.php create mode 100755 lib/ha_client.php create mode 100755 storage/@eaDir/popup_state.json@SynoEAStream create mode 100755 storage/@eaDir/popup_state.json@SynoResource create mode 100755 storage/popup_state.json diff --git a/@eaDir/.git@SynoEAStream b/@eaDir/.git@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/@eaDir/api.php@SynoEAStream b/@eaDir/api.php@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/@eaDir/assets@SynoEAStream b/@eaDir/assets@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/@eaDir/index.php@SynoEAStream b/@eaDir/index.php@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/@eaDir/lib@SynoEAStream b/@eaDir/lib@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K 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); + 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, + ]; + + $config = app_update_room_override($config, $roomId, $patch); + 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']); + } + app_save_config($config); + api_json(['ok' => true, 'settings' => $config['app']]); + } + + if ($action === 'popup') { + $payload = api_input(); + $popup = app_handle_popup_event($config, $payload); + api_json(['ok' => true, 'popup' => $popup]); + } + + api_json(['ok' => false, 'error' => 'Unknown action'], 404); +} catch (Throwable $e) { + api_json([ + 'ok' => false, + 'error' => $e->getMessage(), + ], 500); +} diff --git a/assets/@eaDir/app.css@SynoEAStream b/assets/@eaDir/app.css@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/assets/@eaDir/app.js@SynoEAStream b/assets/@eaDir/app.js@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/assets/app.css b/assets/app.css new file mode 100755 index 0000000..dab8623 --- /dev/null +++ b/assets/app.css @@ -0,0 +1,1893 @@ +: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; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.app-shell { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + 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; +} + +.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; +} + +.clock-panel { + padding: 12px 8px 12px 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; +} + +.rooms-panel { + margin-top: 12px; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.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__actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: 16px; +} + +.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; +} + +.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; +} + +.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__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__status { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 5px; + flex-wrap: wrap; + margin-top: auto; +} + +.room-item__count { + min-width: 22px; + padding: 3px 6px; + border-radius: 999px; + background: rgba(255,255,255,0.04); + color: var(--text-subtle); + font-size: 10px; + font-weight: 700; + text-align: center; +} + +.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; +} + +.content-top { + display: none; + margin-bottom: 16px; + margin-top: -30px; +} + +.content-top.is-main { + display: block; +} + +.content-header { + min-height: 72px; + margin-bottom: 16px; + justify-content: flex-start; +} + +.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; +} + +.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); +} + +.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; +} + +.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; +} + +.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; +} + +.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__rail { + display: grid; + gap: 14px; +} + +.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 { + height: 10px; + border-radius: 999px; + background: rgba(255,255,255,0.08); + overflow: hidden; +} + +.entity-modal__cover-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #67d6ff, #88f0c7); + width: 0%; +} + +.entity-modal__slider { + width: 100%; + accent-color: var(--accent); +} + +.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__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); +} + +.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 { + 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: auto; + } + + .app-shell { + grid-template-columns: 1fr; + height: auto; + min-height: 100vh; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid rgba(255,255,255,0.05); + } + + .main-dashboard__hero { + grid-template-columns: 1fr; + } + + .main-dashboard__hero-stack { + gap: 12px; + } + + .main-dashboard__actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .main-quick-action { + min-height: 76px; + } + + .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: 1 / -1; + } + + .main-dashboard__actions { + grid-template-columns: 1fr; + } + + .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; + } +} diff --git a/assets/app.js b/assets/app.js new file mode 100755 index 0000000..a858d35 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,3566 @@ +(function () { + const bootstrap = window.APP_BOOTSTRAP || {}; + const state = { + snapshot: bootstrap, + selectedRoomId: 'main', + 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, + }, + lastPopupSignature: '', + lastEntityPopupSignature: '', + roomDrag: null, + confirmResolver: null, + haSocket: null, + haSocketState: 'disconnected', + haReconnectTimer: null, + haReconnectDelay: 1000, + haSubscribeId: 1, + roomSelectionToken: 0, + }; + + 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 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 || {}; + 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 || [] } + : 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 roomEntities(snapshot, roomId) { + return sortRoomEntities(roomEntityCollection(snapshot, roomId).filter((entity) => entity.visible !== false)); + } + + function roomEntitiesIncludingHidden(snapshot, roomId) { + return sortRoomEntities(roomEntityCollection(snapshot, roomId)); + } + + 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 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 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 order = new Map((snapshot.main_entities || []).map((entity, index) => [entity.entity_id, 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 = (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); + } + + const container = els.dashboardSurface; + const existing = q(`[data-entity-id="${CSS.escape(entityId)}"]`, container); + const entity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); + const shouldShow = entity && entity.visible !== false; + if (existing && !shouldShow) { + existing.remove(); + return true; + } + if (!existing || !shouldShow) return false; + const nextCard = renderEntityCard(entity); + existing.replaceWith(nextCard); + 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.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)); + } + + 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.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 || [], + } + : 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; + } + + 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') { + 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(); + patchSnapshotSelection(nextRoomId); + 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'; + + const currentPosition = Number(entity.attributes?.current_position); + const initialValue = Number.isFinite(currentPosition) + ? Math.max(0, Math.min(100, currentPosition)) + : (String(entity.state || '').toLowerCase() === 'open' ? 100 : 0); + + 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 progress = document.createElement('div'); + progress.className = 'entity-modal__cover-track'; + + const fill = document.createElement('div'); + fill.className = 'entity-modal__cover-fill'; + fill.style.width = `${initialValue}%`; + progress.appendChild(fill); + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = '0'; + slider.max = '100'; + slider.step = '1'; + slider.value = String(initialValue); + slider.className = 'entity-modal__slider'; + + const syncSlider = () => { + const nextValue = Number(slider.value || 0); + fill.style.width = `${nextValue}%`; + value.textContent = `${nextValue}%`; + }; + slider.addEventListener('input', syncSlider); + slider.addEventListener('change', () => { + const nextValue = Math.max(0, Math.min(100, Number(slider.value || 0))); + handleCoverPosition(entity, nextValue); + }); + + rail.append(valueRow, progress, slider); + + const buttons = document.createElement('div'); + buttons.className = 'entity-modal__actions'; + + const closeBtn = createButton('Закрыть', null, 'mdi:arrow-down', 'mushroom-button mushroom-button--small mushroom-button--square'); + closeBtn.addEventListener('click', () => handleEntityService(entity, 'close')); + + const stopBtn = createButton('Стоп', null, 'mdi:stop', 'mushroom-button mushroom-button--small mushroom-button--square'); + stopBtn.addEventListener('click', () => handleEntityService(entity, 'stop')); + + const openBtn = createButton('Открыть', null, 'mdi:arrow-up', 'mushroom-button mushroom-button--small mushroom-button--square'); + openBtn.addEventListener('click', () => handleEntityService(entity, 'open')); + + buttons.append(closeBtn, stopBtn, openBtn); + wrap.append(rail, buttons); + 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, ' '); + } + + 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 isOpen = ['open', 'opening'].includes(String(entity.state).toLowerCase()); + 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'; + + const progress = document.createElement('div'); + progress.className = 'cover-progress'; + const bar = document.createElement('div'); + bar.className = 'cover-progress__value'; + const pos = Number(entity.attributes?.current_position); + bar.style.width = Number.isFinite(pos) ? `${Math.max(0, Math.min(100, pos))}%` : (isOpen ? '100%' : '0%'); + 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); + } + + 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', () => saveOverridePatch(entity, { order: (entity.order ?? 9999) - 1 })); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'mushroom-button mushroom-button--small'; + downBtn.innerHTML = ' Вниз'; + downBtn.addEventListener('click', () => saveOverridePatch(entity, { order: (entity.order ?? 9999) + 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') 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 renderRoomButtons(rooms) { + 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' : ''} ${hidden ? 'is-hidden-room' : ''} ${state.editMode ? '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 = Number(room.active_entity_count ?? room.entity_count ?? 0) || 0; + const metaText = room.id === 'main' + ? 'Главный экран' + : activeCount > 0 + ? `${activeCount} ${pluralizeActiveEntities(activeCount)}` + : 'Нет активных'; + body.innerHTML = ` +
${esc(room.name)}
+
${metaText}
+ `; + content.append(icon, body); + + if (room.temperature_badge) { + item.classList.add('has-temp'); + const temp = document.createElement('div'); + temp.className = 'room-item__temp'; + temp.textContent = room.temperature_badge; + item.appendChild(temp); + } + + const status = document.createElement('div'); + status.className = 'room-item__status'; + + const count = document.createElement('div'); + count.className = 'room-item__count'; + count.textContent = `${activeCount}`; + status.appendChild(count); + + item.append(content, status); + if (state.editMode && room.id !== 'main') { + item.appendChild(renderRoomEditActions(room)); + } + wireRoomItemDragEvents(item, room, hidden ? hiddenGroup : visibleGroup, hidden); + return item; + }; + + visibleRooms.forEach((room) => { + visibleGroup.appendChild(renderItem(room, 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'); + } + updateMainPrintStrip(snapshot); + 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)}`; + return; + } + + const entities = roomEntities(snapshot, room.id || 'main'); + els.selectedRoomEyebrow.textContent = ''; + els.selectedRoomTitle.textContent = room.name || 'Панель'; + els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`; + } + + 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; + } + + const roomEntitiesList = roomEntities(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 = room.id === 'main' ? 'Объекты' : 'Объекты'; + visibleHeader.appendChild(visibleTitle); + visibleSection.appendChild(visibleHeader); + } + + const visibleGrid = document.createElement('div'); + visibleGrid.className = 'grid-surface room-entities-section__grid'; + roomEntitiesList.forEach((entity) => { + visibleGrid.appendChild(renderEntityCard(entity)); + }); + + if (!roomEntitiesList.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) { + 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 } = options; + if (suppressAutoOpen) { + state.popupAutoOpenBlockedUntil = Date.now() + 60000; + } + state.lastPopupSignature = ''; + state.snapshot = state.snapshot || bootstrap; + if (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; + } + + renderRoomButtons(snapshot.spaces || snapshot.rooms); + renderSelectedRoom(snapshot); + renderDashboard(snapshot); + renderPopup(snapshot); + renderEntityPopup(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; + renderSelectedRoom(snapshot); + renderDashboard(snapshot); + renderPopup(snapshot); + renderEntityPopup(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; + } + + 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.spaces || snapshot.rooms); + 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; + 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 { + await apiPost('save-space-override', { + room_id: room.id, + ...patch, + }); + patchSnapshotSpace(room.id, { + visible: patch.visible !== undefined ? Boolean(patch.visible) : undefined, + order: patch.order !== undefined ? patch.order : undefined, + name: patch.name !== undefined ? patch.name : undefined, + icon: patch.icon !== undefined ? patch.icon : undefined, + }); + try { + await loadSnapshot(state.selectedRoomId || room.id || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + function wireEvents() { + 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.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.clockTime = $('clock-time'); + els.clockDate = $('clock-date'); + els.roomsCount = $('rooms-count'); + els.roomList = $('room-list'); + els.editModeToggle = $('edit-mode-toggle'); + els.contentTop = q('.content-top'); + els.mainPrintStripSlot = $('main-print-strip-slot'); + els.contentHeader = q('.content-header'); + 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'); + + 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'; + } + + 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 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; + + 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); + } else if (affectsRoom) { + updateRoomEntityCard(entityId); + renderSelectedRoom(snapshot); + } + + 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'); + return; + } + + 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'); + }; + socket.onclose = () => { + state.haSocket = null; + if (state.haSocketState !== 'disconnected') { + scheduleReconnect(); + } + }; + } catch (error) { + console.error(error); + scheduleReconnect(); + } + } + + async function start() { + initRefs(); + updateClock(); + clearInterval(state.clockTimer); + state.clockTimer = setInterval(updateClock, 1000); + wireEvents(); + + const initial = window.APP_BOOTSTRAP || {}; + state.snapshot = initial; + render(); + connectRealtime(); + } + + document.addEventListener('DOMContentLoaded', start); +})(); diff --git a/config/@eaDir/config.json@SynoEAStream b/config/@eaDir/config.json@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/config/config.json b/config/config.json new file mode 100755 index 0000000..70bbdd4 --- /dev/null +++ b/config/config.json @@ -0,0 +1,1058 @@ +{ + "app": { + "title": "Wall Panel", + "poll_interval_ms": 5000, + "main_room_name": "Главная", + "main_room_icon": "mdi:home", + "edit_mode": false, + "main_boiler": { + "title": "Бойлер", + "sensor_entity_id": "sensor.modeco_2_temperature", + "history_hours": 24 + }, + "main_print": { + "title": "Печать", + "current_stage_entity_id": "sensor.p1s_01p00a430800583_current_stage", + "print_progress_entity_id": "sensor.p1s_01p00a430800583_print_progress", + "start_time_entity_id": "sensor.p1s_01p00a430800583_start_time", + "end_time_entity_id": "sensor.p1s_01p00a430800583_end_time" + }, + "main_weather_actions": [ + { + "entity_id": "climate.otoplenie_doma", + "state_entity_id": "select.otoplenie_temperatura_nagrevatelia", + "command": "set_temperature", + "value": 15, + "active_value": "15", + "label_active": "15°", + "label_inactive": "Установить 15°", + "icon": "mdi:thermometer", + "active_color": "#4caf50", + "inactive_color": "#c8e6c9", + "active_text_color": "white", + "inactive_text_color": "black", + "active_icon_color": "white", + "inactive_icon_color": "gray" + }, + { + "entity_id": "climate.otoplenie_doma", + "state_entity_id": "select.otoplenie_temperatura_nagrevatelia", + "command": "set_temperature", + "value": 30, + "active_value": "30", + "label_active": "30°", + "label_inactive": "Установить 30°", + "icon": "mdi:thermometer", + "active_color": "#2196f3", + "inactive_color": "#bbdefb", + "active_text_color": "white", + "inactive_text_color": "black", + "active_icon_color": "white", + "inactive_icon_color": "gray" + }, + { + "entity_id": "climate.otoplenie_doma", + "state_entity_id": "select.otoplenie_temperatura_nagrevatelia", + "command": "set_temperature", + "value": 45, + "active_value": "45", + "label_active": "45°", + "label_inactive": "Установить 45°", + "icon": "mdi:thermometer", + "active_color": "#f44336", + "inactive_color": "#ffcdd2", + "active_text_color": "white", + "inactive_text_color": "black", + "active_icon_color": "white", + "inactive_icon_color": "gray" + } + ] + }, + "home_assistant": { + "base_url": "http://10.0.6.5:8123", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiYWQ2ZGE0OGYzOWU0MDFjOTA2OTZhZjVkYzA2M2U2MSIsImlhdCI6MTc3MzY0NjMwNywiZXhwIjoyMDg5MDA2MzA3fQ.u7kWa3ONMEo5UMi3suIBTocr_6FqOYphfYb7NJGZ3wI", + "verify_ssl": false, + "weather_entity_id": "", + "auto_label": "auto", + "auto_entity_ids": [] + }, + "camera": { + "rtsp_url": "rtsp://10.0.6.110:45321/feff99fa45f317e7", + "stream_url": "http://10.0.6.110:1984/webrtc.html?src=doorbell_panel&media=video", + "stream_mode": "iframe", + "poster_url": "http://10.0.6.110:1984/api/frame.jpeg?src=doorbell_main", + "popup_timeout_minutes": 3, + "trigger_entities": [ + "binary_sensor.doorbell_person_occupancy" + ] + }, + "rooms": [ + { + "id": "garazh", + "name": "Гараж", + "visible": true, + "entity_ids": [], + "entity_overrides": { + "automation.vykliuchit_zariadku_kogda_moshchnost_stanet_0": { + "order": 10000, + "visible": false + }, + "switch.0xc02cedfffef131dc": { + "order": 9999 + }, + "sensor.garage_light_illuminance": { + "order": 10000, + "visible": false + }, + "sensor.door_sensor_garage_battery": { + "visible": false + }, + "sensor.door_sensor_garage_device_temperature": { + "visible": false + }, + "sensor.door_sensor_garage_power_outage_count": { + "visible": false + }, + "binary_sensor.garage_motion_occupancy": { + "visible": false + }, + "binary_sensor.garage_door_motion_occupancy": { + "visible": false + }, + "update.garazh_zariadka": { + "visible": false + }, + "sensor.garazh_zariadka_power": { + "visible": false + }, + "sensor.garazh_zariadka_voltage": { + "visible": false + }, + "sensor.garazh_zariadka_device_temperature": { + "visible": false + }, + "sensor.garazh_zariadka_current": { + "visible": false + }, + "binary_sensor.garazh_zariadka_consumer_connected": { + "visible": false + }, + "sensor.garazh_zariadka_energy": { + "visible": false + }, + "switch.garazh_zariadka_button_lock": { + "visible": false + }, + "switch.garazh_zariadka_led_disabled_night": { + "visible": false + }, + "number.garazh_zariadka_overload_protection": { + "visible": false + }, + "binary_sensor.door_sensor_garage_contact": { + "visible": false + }, + "update.garazh_vorota": { + "visible": false + }, + "number.garazh_vorota_calibration_time": { + "visible": false + }, + "switch.0x70ac08fffeadbe42_motor_reversal": { + "visible": false + }, + "sensor.0x70ac08fffeadbe42_moving": { + "visible": false + } + }, + "order": 8 + }, + { + "id": "sarai", + "name": "Сарай", + "visible": false, + "order": 90, + "entity_ids": [], + "entity_overrides": [] + }, + { + "id": "3692726c3154408e9fe32de78dc785af", + "name": "Детская", + "visible": true, + "order": 4, + "entity_ids": [], + "entity_overrides": { + "remote.detskaia": { + "visible": true + }, + "update.0x54ef441000d3a2ae": { + "visible": false + }, + "sensor.0x54ef441000d3a2ae_device_temperature": { + "visible": false + }, + "switch.0x54ef441000d3a2ae_flip_indicator_light": { + "visible": false + }, + "select.0x54ef441000d3a2ae_operation_mode": { + "visible": false + }, + "switch.0x54ef441000d3a2ae_power_outage_memory": { + "visible": false + }, + "media_player.detskaia": { + "visible": false + } + } + }, + { + "id": "kamery", + "name": "Камеры", + "visible": true, + "order": 9, + "entity_ids": [], + "entity_overrides": { + "automation.frigate_zavis": { + "visible": false + } + } + }, + { + "id": "bf2a6d5b37754f70a139c33ea42468cf", + "name": "Двор", + "visible": true, + "order": 6, + "entity_ids": [], + "entity_overrides": { + "switch.0x8c8b48fffed1ccf1_delayed_power_on_state": { + "visible": false + }, + "number.0x8c8b48fffed1ccf1_delayed_power_on_time": { + "visible": false + }, + "switch.0x8c8b48fffed1ccf1_detach_relay_mode": { + "visible": false + }, + "select.0x8c8b48fffed1ccf1_external_trigger_mode": { + "visible": false + }, + "switch.0x8c8b48fffed1ccf1_network_indicator": { + "visible": false + }, + "select.0x8c8b48fffed1ccf1_power_on_behavior": { + "visible": false + }, + "switch.0x8c8b48fffed1ccf1_turbo_mode": { + "visible": false + }, + "select.0x3425b4fffe367dd7_power_on_behavior": { + "visible": false + }, + "select.relay_2ch_power_outage_memory": { + "visible": false + }, + "select.unknown_switch_type": { + "visible": false + }, + "sensor.terrasa_temperatura_pressure": { + "visible": false + }, + "sensor.terrasa_temperatura_battery": { + "visible": false + }, + "sensor.terrasa_temperatura_humidity": { + "visible": false + }, + "sensor.terrasa_temperatura_temperature": { + "visible": false + }, + "device_tracker.reolinkgarden": { + "visible": false + }, + "device_tracker.camera1": { + "visible": false + }, + "binary_sensor.garden_motion_2": { + "visible": false + }, + "switch.garden_ftp_upload": { + "visible": false + }, + "switch.garden_record": { + "visible": false + }, + "switch.garden_record_audio": { + "visible": false + }, + "switch.garden_infrared_lights_in_night_mode": { + "visible": false + }, + "update.garden_firmware": { + "visible": false + }, + "camera.garden_fluent": { + "visible": false + }, + "select.garden_day_night_mode": { + "visible": false + }, + "sensor.garden_day_night_state": { + "visible": false + }, + "number.garden_motion_sensitivity": { + "visible": false + }, + "switch.garden_email_on_event": { + "visible": false + }, + "switch.garden_privacy_mask": { + "visible": false + }, + "switch.garden_push_notifications": { + "visible": false + }, + "update.0x8c8b48fffed1ccf1": { + "visible": false + } + } + }, + { + "id": "ulitsa", + "name": "Улица", + "visible": true, + "order": 7, + "entity_ids": [], + "entity_overrides": { + "number.cambarn_volume": { + "visible": false + }, + "binary_sensor.doorbell_vehicle": { + "visible": false + }, + "select.doorbell_play_quick_reply_message": { + "visible": false + }, + "number.doorbell_auto_quick_reply_time": { + "visible": false + }, + "number.doorbell_volume": { + "visible": false + }, + "binary_sensor.doorbell_motion_2": { + "visible": false + }, + "binary_sensor.zvonok_pet": { + "visible": false + }, + "switch.doorbell_ftp_upload": { + "visible": false + }, + "number.doorbell_ai_vehicle_delay": { + "visible": false + }, + "number.zvonok_ai_pet_delay": { + "visible": false + }, + "number.doorbell_ai_person_delay": { + "visible": false + }, + "switch.doorbell_record": { + "visible": false + }, + "switch.doorbell_record_audio": { + "visible": false + }, + "switch.doorbell_doorbell_button_sound": { + "visible": false + }, + "switch.doorbell_infrared_lights_in_night_mode": { + "visible": false + }, + "sensor.doorbell_cpu_usage": { + "visible": false + }, + "number.doorbell_image_contrast": { + "visible": false + }, + "number.doorbell_image_saturation": { + "visible": false + }, + "update.doorbell_firmware": { + "visible": false + }, + "button.doorbell_restart": { + "visible": false + }, + "select.doorbell_fluent_frame_rate": { + "visible": false + }, + "camera.doorbell_fluent": { + "visible": false + }, + "select.doorbell_fluent_bit_rate": { + "visible": false + }, + "number.doorbell_day_night_switch_threshold": { + "visible": false + }, + "binary_sensor.doorbell_visitor": { + "visible": false + }, + "select.doorbell_day_night_mode": { + "visible": false + }, + "switch.doorbell_privacy_mode": { + "visible": false + }, + "select.doorbell_doorbell_led": { + "visible": false + }, + "siren.doorbell_siren": { + "visible": false + }, + "switch.doorbell_siren_on_event": { + "visible": false + }, + "select.doorbell_auto_quick_reply_message": { + "visible": false + }, + "sensor.doorbell_day_night_state": { + "visible": false + }, + "sensor.zvonok_sd_0_storage": { + "visible": false + }, + "binary_sensor.doorbell_person": { + "visible": false + }, + "select.doorbell_clear_frame_rate": { + "visible": false + }, + "camera.doorbell_clear": { + "visible": false + }, + "select.doorbell_clear_bit_rate": { + "visible": false + }, + "number.doorbell_image_sharpness": { + "visible": false + }, + "number.doorbell_ai_vehicle_sensitivity": { + "visible": false + }, + "number.zvonok_ai_pet_sensitivity": { + "visible": false + }, + "number.doorbell_ai_person_sensitivity": { + "visible": false + }, + "number.doorbell_motion_sensitivity": { + "visible": false + }, + "switch.doorbell_email_on_event": { + "visible": false + }, + "number.doorbell_image_brightness": { + "visible": false + }, + "number.zvonok_doorbell_volume": { + "visible": false + }, + "select.zvonok_hdr": { + "visible": false + }, + "select.doorbell_post_recording_time": { + "visible": false + }, + "switch.zvonok_privacy_mask": { + "visible": false + }, + "switch.doorbell_push_notifications": { + "visible": false + }, + "number.zvonok_speak_volume": { + "visible": false + }, + "select.0x70b3d52b6002adfd_power_on_behavior": { + "visible": false + }, + "update.0x08b95ffffeb5171c": { + "visible": false + }, + "switch.0x08b95ffffeb5171c_delayed_power_on_state": { + "visible": false + }, + "number.0x08b95ffffeb5171c_delayed_power_on_time": { + "visible": false + }, + "switch.0x08b95ffffeb5171c_detach_relay_mode": { + "visible": false + }, + "select.0x08b95ffffeb5171c_external_trigger_mode": { + "visible": false + }, + "switch.0x08b95ffffeb5171c_network_indicator": { + "visible": false + }, + "select.0x08b95ffffeb5171c_power_on_behavior": { + "visible": false + }, + "switch.0x08b95ffffeb5171c_turbo_mode": { + "visible": false + }, + "sensor.weather_temperature": { + "visible": false + }, + "device_tracker.camdoor_2": { + "visible": false + }, + "device_tracker.f4_b1_c2_6c_f5_86": { + "visible": false + }, + "device_tracker.cambarn": { + "visible": false + }, + "device_tracker.cambarn_2": { + "visible": false + }, + "device_tracker.ec_71_db_f2_87_b2": { + "visible": false + }, + "device_tracker.reolink": { + "visible": false + }, + "binary_sensor.cambarn_vehicle": { + "visible": false + }, + "binary_sensor.cambarn_motion": { + "visible": false + }, + "switch.cambarn_ftp_upload": { + "visible": false + }, + "number.cambarn_ai_vehicle_delay": { + "visible": false + }, + "number.cambarn_ai_person_delay": { + "visible": false + }, + "sensor.cambarn_cpu_usage": { + "visible": false + }, + "number.cambarn_image_contrast": { + "visible": false + }, + "number.cambarn_image_saturation": { + "visible": false + }, + "update.cambarn_firmware": { + "visible": false + }, + "button.cambarn_restart": { + "visible": false + }, + "select.cambarn_fluent_frame_rate": { + "visible": false + }, + "camera.cambarn_snapshots_fluent": { + "visible": false + }, + "camera.cambarn_fluent": { + "visible": false + }, + "select.cambarn_fluent_bit_rate": { + "visible": false + }, + "number.cambarn_day_night_switch_threshold": { + "visible": false + }, + "select.cambarn_day_night_mode": { + "visible": false + }, + "sensor.cambarn_day_night_state": { + "visible": false + }, + "sensor.cambarn_sd_0_storage": { + "visible": false + }, + "binary_sensor.cambarn_person": { + "visible": false + }, + "select.cambarn_clear_frame_rate": { + "visible": false + }, + "camera.cambarn_snapshots_clear": { + "visible": false + }, + "camera.cambarn_clear": { + "visible": false + }, + "select.cambarn_clear_bit_rate": { + "visible": false + }, + "number.cambarn_image_sharpness": { + "visible": false + }, + "number.cambarn_ai_vehicle_sensitivity": { + "visible": false + }, + "number.cambarn_ai_person_sensitivity": { + "visible": false + }, + "number.cambarn_motion_sensitivity": { + "visible": false + }, + "switch.cambarn_email_on_event": { + "visible": false + }, + "number.cambarn_image_brightness": { + "visible": false + }, + "select.cambarn_post_recording_time": { + "visible": false + }, + "switch.cambarn_privacy_mask": { + "visible": false + }, + "switch.cambarn_push_notifications": { + "visible": false + } + } + }, + { + "id": "c7829e2bba524bc5aeb2662866058b57", + "name": "Кухня", + "visible": true, + "order": 3, + "entity_ids": [], + "entity_overrides": { + "sensor.kukhnia_temperatura_humidity": { + "visible": false + }, + "update.0x54ef441000ef8791": { + "visible": false + }, + "sensor.0x54ef441000ef8791_device_temperature": { + "visible": false + }, + "switch.0x54ef441000ef8791_flip_indicator_light": { + "visible": false + }, + "select.0x54ef441000ef8791_operation_mode_center": { + "visible": false + }, + "select.0x54ef441000ef8791_operation_mode_left": { + "visible": false + }, + "select.0x54ef441000ef8791_operation_mode_right": { + "visible": false + }, + "switch.0x54ef441000ef8791_power_outage_memory": { + "visible": false + }, + "sensor.kukhnia_temperatura_battery": { + "visible": false + }, + "sensor.kukhnia_temperatura_pressure": { + "visible": false + }, + "media_player.mitv_afmu0": { + "visible": false + }, + "sensor.kukhnia_temperatura_temperature": { + "visible": false + }, + "switch.air_conditioner_hsu_07hrm203_r3_in_tikhii": { + "visible": false + }, + "switch.air_conditioner_hsu_07hrm203_r3_in_turbo": { + "visible": false + }, + "media_player.televizor_v_gostinoi": { + "visible": false + } + } + }, + { + "id": "prikhozhaia", + "name": "Прихожая", + "visible": true, + "order": 5, + "entity_ids": [], + "entity_overrides": { + "automation.vykliuchenie_ekrana": { + "visible": false + }, + "update.hall_switch": { + "visible": false + }, + "sensor.hall_switch_device_temperature": { + "visible": false + }, + "select.hall_switch_operation_mode": { + "visible": false + }, + "number.reolink_chime_volume": { + "visible": false + }, + "select.reolink_chime_vehicle_ringtone": { + "visible": false + }, + "select.reolink_chime_motion_ringtone": { + "visible": false + }, + "select.reolink_chime_person_ringtone": { + "visible": false + }, + "select.reolink_chime_visitor_ringtone": { + "visible": false + }, + "switch.reolink_chime_led": { + "visible": false + }, + "select.reolink_chime_pet_ringtone": { + "visible": false + }, + "number.reolink_chime_silent_time": { + "visible": false + }, + "binary_sensor.reolink_silent_mode_active": { + "visible": true + } + } + }, + { + "id": "bf6b7f2827294fcea198262d5bf0a1b3", + "visible": false, + "order": 90, + "entity_ids": [], + "entity_overrides": [] + }, + { + "id": "44c82e44b797452b8f93e56ff416e989", + "name": "Игорь | Лена", + "visible": true, + "order": 2, + "entity_ids": [], + "entity_overrides": { + "sensor.0xa4c138fe1cdd2a21_humidity": { + "visible": false + }, + "update.0x54ef441001159617": { + "visible": false + }, + "sensor.0x54ef441001159617_device_temperature": { + "visible": false + }, + "switch.0x54ef441001159617_flip_indicator_light": { + "visible": false + }, + "select.0x54ef441001159617_operation_mode_left": { + "visible": false + }, + "select.0x54ef441001159617_operation_mode_right": { + "visible": false + }, + "switch.0x54ef441001159617_power_outage_memory": { + "visible": false + }, + "update.0xa4c138fe1cdd2a21": { + "visible": false + }, + "sensor.0xa4c138fe1cdd2a21_battery": { + "visible": false + }, + "number.0xa4c138fe1cdd2a21_comfort_humidity_max": { + "visible": false + }, + "number.0xa4c138fe1cdd2a21_comfort_humidity_min": { + "visible": false + }, + "number.0xa4c138fe1cdd2a21_comfort_temperature_max": { + "visible": false + }, + "number.0xa4c138fe1cdd2a21_comfort_temperature_min": { + "visible": false + }, + "switch.0xa4c138fe1cdd2a21_enable_display": { + "visible": false + }, + "number.0xa4c138fe1cdd2a21_humidity_calibration": { + "visible": false + }, + "switch.0xa4c138fe1cdd2a21_show_smiley": { + "visible": false + }, + "number.0xa4c138fe1cdd2a21_temperature_calibration": { + "visible": false + }, + "select.0xa4c138fe1cdd2a21_temperature_display_mode": { + "visible": false + }, + "update.smallroom_switch": { + "visible": false + }, + "select.smallroom_switch_operation_mode": { + "visible": false + }, + "media_player.shield": { + "visible": false + }, + "media_player.shield_2": { + "visible": false + }, + "sensor.0xa4c138fe1cdd2a21_temperature": { + "visible": false + } + } + }, + { + "id": "79c23414b5b840e1b83b09c0d970dcf3", + "name": "Серёжа | Настя", + "visible": true, + "order": 1, + "entity_ids": [], + "entity_overrides": { + "binary_sensor.uvlazhnitel_water_tank": { + "visible": false + }, + "switch.uvlazhnitel_child_lock": { + "visible": false + }, + "sensor.spalnya_temp_humidity": { + "visible": false + }, + "sensor.uvlazhnitel_humidity": { + "visible": false + }, + "sensor.uvlazhnitel_use_time": { + "visible": false + }, + "switch.uvlazhnitel_buzzer": { + "visible": false + }, + "sensor.0x0ceff6fffe6cffc4_battery": { + "visible": false + }, + "select.0x0ceff6fffe6cffc4_border": { + "visible": false + }, + "select.0x0ceff6fffe6cffc4_click_control": { + "visible": false + }, + "binary_sensor.0x0ceff6fffe6cffc4_motor_fault": { + "visible": false + }, + "select.0x0ceff6fffe6cffc4_reverse_direction": { + "visible": false + }, + "sensor.0x0ceff6fffe6cdee0_battery": { + "visible": false + }, + "select.0x0ceff6fffe6cdee0_border": { + "visible": false + }, + "select.0x0ceff6fffe6cdee0_click_control": { + "visible": false + }, + "sensor.0x0ceff6fffe6cdee0_linkquality": { + "visible": false + }, + "binary_sensor.0x0ceff6fffe6cdee0_motor_fault": { + "visible": false + }, + "select.0x0ceff6fffe6cdee0_reverse_direction": { + "visible": false + }, + "sensor.spalnia_levaia_rozetka_voltage": { + "visible": false + }, + "sensor.0x705464fffe43dee0_battery": { + "visible": false + }, + "select.0x705464fffe43dee0_border": { + "visible": false + }, + "select.0x705464fffe43dee0_click_control": { + "visible": false + }, + "binary_sensor.0x705464fffe43dee0_motor_fault": { + "visible": false + }, + "select.0x705464fffe43dee0_reverse_direction": { + "visible": false + }, + "sensor.0x44e2f8fffeb65d8e_battery": { + "visible": false + }, + "select.0x44e2f8fffeb65d8e_border": { + "visible": false + }, + "select.0x44e2f8fffeb65d8e_click_control": { + "visible": false + }, + "binary_sensor.0x44e2f8fffeb65d8e_motor_fault": { + "visible": false + }, + "select.0x44e2f8fffeb65d8e_reverse_direction": { + "visible": false + }, + "switch.uvlazhnitel_clean_mode": { + "visible": false + }, + "switch.uvlazhnitel_dry_mode": { + "visible": false + }, + "number.uvlazhnitel_motor_speed": { + "visible": false + }, + "update.spalnia_girlianda_levoe_okno": { + "visible": false + }, + "switch.spalnia_girlianda_levoe_okno_delayed_power_on_state": { + "visible": false + }, + "number.spalnia_girlianda_levoe_okno_delayed_power_on_time": { + "visible": false + }, + "switch.spalnia_girlianda_levoe_okno_detach_relay_mode": { + "visible": false + }, + "select.spalnia_girlianda_levoe_okno_external_trigger_mode": { + "visible": false + }, + "switch.spalnia_girlianda_levoe_okno_network_indicator": { + "visible": false + }, + "select.spalnia_girlianda_levoe_okno_power_on_behavior": { + "visible": false + }, + "switch.spalnia_girlianda_levoe_okno_turbo_mode": { + "visible": false + }, + "update.spalnia_girlianda_pravoe_okno": { + "visible": false + }, + "switch.spalnia_girlianda_pravoe_okno_delayed_power_on_state": { + "visible": false + }, + "number.spalnia_girlianda_pravoe_okno_delayed_power_on_time": { + "visible": false + }, + "switch.spalnia_girlianda_pravoe_okno_detach_relay_mode": { + "visible": false + }, + "select.spalnia_girlianda_pravoe_okno_external_trigger_mode": { + "visible": false + }, + "switch.spalnia_girlianda_pravoe_okno_network_indicator": { + "visible": false + }, + "select.spalnia_girlianda_pravoe_okno_power_on_behavior": { + "visible": false + }, + "switch.spalnia_girlianda_pravoe_okno_turbo_mode": { + "visible": false + }, + "number.spalnia_lbp_countdown": { + "visible": false + }, + "select.lab_switch_power_on_behavior": { + "visible": false + }, + "select.lab_switch_switch_type": { + "visible": false + }, + "switch.spalnia_levaia_rozetka_child_lock": { + "visible": false + }, + "number.spalnia_levaia_rozetka_countdown": { + "visible": false + }, + "select.spalnia_levaia_rozetka_indicator_mode": { + "visible": false + }, + "select.spalnia_levaia_rozetka_power_outage_memory": { + "visible": false + }, + "switch.spalnia_nochnik_child_lock": { + "visible": false + }, + "number.spalnia_nochnik_countdown": { + "visible": false + }, + "select.spalnia_nochnik_indicator_mode": { + "visible": false + }, + "select.spalnia_nochnik_power_outage_memory": { + "visible": false + }, + "switch.spalnia_pravaia_rozetka_child_lock": { + "visible": false + }, + "number.spalnia_pravaia_rozetka_countdown": { + "visible": false + }, + "select.spalnia_pravaia_rozetka_indicator_mode": { + "visible": false + }, + "select.spalnia_pravaia_rozetka_power_outage_memory": { + "visible": false + }, + "sensor.spalnya_temp_pressure": { + "visible": false + }, + "switch.spalnia_tsvety_komary_child_lock": { + "visible": false + }, + "number.spalnia_tsvety_komary_countdown": { + "visible": false + }, + "select.spalnia_tsvety_komary_indicator_mode": { + "visible": false + }, + "select.spalnia_tsvety_komary_power_outage_memory": { + "visible": false + }, + "sensor.spalnya_temp_temperature": { + "visible": false + }, + "sensor.uvlazhnitel_temperature": { + "visible": false + }, + "sensor.spalnya_temp_battery": { + "visible": false + }, + "sensor.spalnia_levaia_rozetka_current": { + "visible": false + }, + "device_tracker.zhimi_humidifier_ca4_mibt1854": { + "visible": false + }, + "sensor.uvlazhnitel_water_level": { + "visible": false + }, + "sensor.uvlazhnitel_actual_speed": { + "visible": false + }, + "device_tracker.spalna_home_network_2024_08_27_01_06": { + "visible": false + }, + "device_tracker.appletv": { + "visible": false + }, + "select.uvlazhnitel_led_brightness": { + "visible": false + } + } + }, + { + "id": "chulan", + "name": "Чулан", + "visible": false, + "order": 110, + "entity_ids": [], + "entity_overrides": [] + } + ] +} diff --git a/favicon.ico b/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..824b4340347feab9ec66b753d4fb442b39a583bb GIT binary patch literal 15086 zcmeI3dGM9f8^CXivb8AHSQ=jxS=vOlD2*w?7ui!Jp%Ak7MAbIy@7&pcCl_UtKr`t*^r&pumr+;PWJV1ZlGFBbpm)vL=v2OT8cyLVSy zz~Q*#j+0$?-L(LZefHT$UVQOIx$wdZg)t6jQQ2*`-Q?0sFO?HdJhA9oSbz7f()ib? zQA5r>_gtAeb*kKT*Ijb-(ML<&x^-ojU3QWE_S;X+JMTQDGiy8i@WW-!oHaV6tlzkYogI&`SqeDlp>^r=>@nv|E9%SRu5B>(>V zuWZ`1Nw#j?s%`K;>ZqgQaESc2`|i8Tl~-OV%pbAdlJE!LTW+~Ui{TExJpcUj^3Om2 z$d)ZzWX+m2^2;y3$c7CYWW|aVa??#WMZr$>{=fqdRJp1O{Leb;EaeO18E}91-FM~Z zpMRF$fB#(;Em|bsefOQLU%y_y|NeV<`|Y=-PMtcb@QlDu$D$LOHER|&Pip!%Y}in4 zyzxfWbHGA}Jonsl^5vIb%F2~1<%17CkWWATRB66&;XXUv!(AAb0u?!nTfOXZz+-YJspI2tE?+jrl6<>Zr34z4{F z{yXoyvs{1u_0qq8e;topefHUBGJE!HUBj!dzN+}M7rguGtFL6hfB`{R+x>jaHP=Y3 zTDAOnQsLjVYgg&fqlYwV)JVtPdh4yq_wT>|zRa36OJ0Bdb>($*{3oA$BCoviiU2E# zrlo#Ir=EK1ss6mF@bBBVui8c6VWZ%a$&)9`{Q2|cg%@5>y9(a$5HgFMjgU7VcBR_T z#nY#W`0ud84%zFjyH52E_~R!)b8Hg0KJmm8Qc+Q%YkBj{H)X+s1^QenZPWH|)25Bu z^IZQ#{9CnZmA&}li}Pc~jvXs6zx=XHn>I}*O`0Sxz4Vf<1sRw*bEfK`^UpuOWZIfN zNwW@ozqI(De){QIXwLlb&fK|kW#Yt%D!-3D`lvkh)KiK-d&0Ut|NL{6l{9$J=di;L zQ@sX{;)kAg+G#$27@h?+HaRE9iS&1Jo5g1YR{Qqtm1m|*nWFp;?$94OcIQ1>z zCFB>M%k(T`q6#t?rK9k*lXwt)m+p>6J^Bb!9*UbyVC%T#CB7{A88 z9C5@EiTJ}m>=pXwz4zWz+lroq&ftyCXHUQ#o~x&UqwG5e@;)R|K>Ug{?6l%Kc3e^;L9_# z$Nr+9umef_&s;hCsq#zCjhMP^+qQZ9$syqzIU4KUFeX@_t$P>ybM(n#7hNaKnSe7m z;sYVe=plSM-eb?O5ovM`1`Jv{PO`0Y6yWm_+eNa17d{71Vn=ildK9^)K!5UycieGDp09m)!8Ufq;#p|K9$?pyY2H)Vr{Fky zPQ(9BkE#E|eqs;t-Jm_Zj^2caz@2x<0egdtn|vCquo~WRIOOreUSVgfFML(6UcLOY zy|)_O?%GK|^`C7&@p*_}z#n|!d-NzVF*1P;C58#wQwJmzk0XyfGH(m~KKPy{cm8|7 zokst={>uN%1-;OX_&?x|tYX8^|Kx&@4fGIsQ2aRZ1wOo}Hns~{K%VJC{vEv;Ww&ge zH1^l^%|Z)!75%)&9($<&1D}v{fLEY7e8qaO5$GrUJM&u&UR({YW0R2qa-MO0?VFR0 zRQyAK_}}yex(2@ie**s>Ii`^RwAf(r;>EgVgH_cvI?Am#HU9Wn@D6&E7zrPac+2<( znV^kcf{)0DVjI%LGL>0d=gytUF{Q>I-o$4?j^RsiM26A*@Dcok@9@nx-{kW{@DY4h z8T^vQV3(Z@NsT}A5yzufusy{8%a$!u+q-1R64jyj&tHH2wdR0W%kt&R<^KEcm;LwO zzslFpyLWHqED3+)7=PAcS7emjGkn7ukYV;=-MV#}m&VSar+nL*MAtOm@r`ovPf7SQ zH+Bi0z(0ql(Dmf{Ij>+1@IUpcRjX8+d${q&QbzhKKahv6&Sp>JFtd_2#tm5e`gqo!D z6maJpiL)clzu1FcfBjW{`|UTCX>5eWEOGcHeG9}hmBJtS1sBdB&CkSl=iFlR=FNJJ zN6mWv{PRye%i~Ov7(bXRNy69ve!u|-$Vn%i(RNY=wU;_{IrM=H*o zqf$7Rw6oN0+qS7)MtA#orDT3~GoJI95hF%O@NA8BprbgK$G5}h!?&i! zXC>}T6QkL>@Jrci-#4ua{F#GuN!BoY_;5kC4Q5h5PmAzh?rMF zom@$IPpjN@joahUZ^UfmcWf;BCB^+9U>-^G3EUzm^*p3ZEPp_7z)fF*R`nvBQ&3KADe~N`-Cx@nPcNH)+yD z{VL)w_8fdT-yj!(EZ%wNoi;wShJM7*QN9FrF$%u(N6SrKp*?(vj1!w@uuBet8&tx5wqc#0Nv(@NXY_=poI6 zg9G2}jLl$JO}v7B_T`-XP?*1!^G%D^4}4-X{8-jYejC0+@30T#;-lg=;sMi%eq4lK z-=#~JJWaqC-}{6UPAGy8{mFTfCx+MA3v3wY0IVMz&_}jrzlNp;pK~+&#y&zzd#0wJ zJL@Te?{dcF&>S9R?eOB=ci*kKOz^S$Z2nxY;XiO-?cl`yPTOZUI1h(5IX7x_ z7d7^RI!qV3`9k4yhQM7Ea;BVvuz&Eq<@dPP$el;#4VExjvwMcYzK%|z@UcDcJ@%jd z2OnZxWDx#?mu!8(IeBKSwioCTUmsY1=^t)9@Qj4dnxO-GjvYkr!h78RMhAj}e?A`uBko-;=NSnfx&fOxpgZ|PcmaBl zZ=kO1J#^-TpIjXYpFZStp&>bA{ExM3*Q!n?pN2n%FAARYEu|Q5B*7;ZHob|@P5zqv zDteH-9y*1)Wu?HTuibS?f=^$30P;Vy|N84M&A}0SuqXKB*a~zb-_sUythv6i^tbsq zGclTN+_*8HLnBX1970UO-4^Q~SCgajzNdmue>?A_jg4U+{9iVk)&JdibNg_#9rB-# z)bN>?d(vV0&0vzlMpxRiUq_tVlmwsokcl6E{4uZ3u>SJckkpUjVUcd!+I^bfuUXI63JbKmmg z-I-AZcQ}B}nQxRo8@Mx@AMgC!3iyhg4}OisD~5ydx-V}|hZKwlF8&?o<$i7gU5EdJ zjN6?$=5_L)AMfU`z$d~+Qqac+n;L$wyHl1Yc4t)i@$Ua%734s%XXaD*nR%}yn zOyId-Jm1h)=x{q*3+U$je7Sp2MdR`Dkq_bylcBJ)l$;C|jRyzpx5+Qh + + + + + + + <?= $appTitle ?> + + + + + + + + + + +
+ + +
+
+
+
+
+
+
+

Загрузка

+
+
+
+ +
+
+
Загрузка панели...
+
+
+
+
+ + + + + + + + diff --git a/lib/@eaDir/bootstrap.php@SynoEAStream b/lib/@eaDir/bootstrap.php@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/lib/@eaDir/config.php@SynoEAStream b/lib/@eaDir/config.php@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/lib/@eaDir/dashboard.php@SynoEAStream b/lib/@eaDir/dashboard.php@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/lib/@eaDir/ha_client.php@SynoEAStream b/lib/@eaDir/ha_client.php@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/lib/bootstrap.php b/lib/bootstrap.php new file mode 100755 index 0000000..9649f6e --- /dev/null +++ b/lib/bootstrap.php @@ -0,0 +1,9 @@ + [ + 'title' => 'Wall Panel', + 'poll_interval_ms' => 5000, + 'main_room_name' => 'Главная', + 'main_room_icon' => 'mdi:home', + 'edit_mode' => false, + ], + 'home_assistant' => [ + 'base_url' => '', + 'token' => '', + 'verify_ssl' => true, + '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 +{ + return APP_ROOT . '/config/config.json'; +} + +function app_storage_path(string $file): string +{ + return APP_ROOT . '/storage/' . 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); +} + diff --git a/lib/dashboard.php b/lib/dashboard.php new file mode 100755 index 0000000..0b8bc49 --- /dev/null +++ b/lib/dashboard.php @@ -0,0 +1,1063 @@ + $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_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['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : []; + $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'] : [], + ]; + } + + $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_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'] ?? ''), + '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_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); + $temperatureBadge = app_room_temperature_badge($items); + if ($temperatureBadge === null) { + $allItems = app_room_entities($room, $haData, true); + $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), + '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, + '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; + } + + $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]) && $selectedRoomId !== 'main') { + $selectedRoomId = 'main'; + } + + $selectedRoom = $selectedRoomId === 'main' + ? [ + 'id' => 'main', + 'name' => $mainRoom['name'], + 'icon' => $mainRoom['icon'], + 'visible' => true, + 'entities' => $mainEntities, + 'entity_count' => count($mainEntities), + 'active_entity_count' => count($mainEntities), + 'temperature_badge' => null, + ] + : ($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, + '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 (!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'] = []; + } + + $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' => [], + ]; + 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_handle_popup_event(array $config, array $payload): array +{ + $camera = $config['camera'] ?? []; + $watch = app_trigger_entities($config); + $sensor = (string)($payload['sensor_entity_id'] ?? $payload['entity_id'] ?? ''); + $state = strtolower((string)($payload['state'] ?? $payload['to'] ?? '')); + $command = strtolower((string)($payload['command'] ?? '')); + $popupState = app_load_popup_state(); + $timeoutMinutes = max(1, (int)($camera['popup_timeout_minutes'] ?? 3)); + $closeDelaySeconds = app_popup_close_delay_seconds(); + $timeoutSeconds = max($closeDelaySeconds, $timeoutMinutes * 60); + + if ($command === 'open') { + $popupState = [ + 'active' => true, + 'sensor_entity_id' => $sensor !== '' ? $sensor : 'debug', + 'opened_at' => time(), + 'expires_at' => time() + $timeoutSeconds, + ]; + app_save_popup_state($popupState); + } elseif (in_array($sensor, $watch, true) && $state === 'on') { + $popupState = [ + 'active' => true, + 'sensor_entity_id' => $sensor, + 'opened_at' => time(), + 'expires_at' => null, + ]; + app_save_popup_state($popupState); + } elseif ($state === 'off' && ($popupState['sensor_entity_id'] ?? null) === $sensor && ($popupState['active'] ?? false)) { + $popupState['active'] = true; + $popupState['expires_at'] = time() + $closeDelaySeconds; + if (empty($popupState['opened_at'])) { + $popupState['opened_at'] = time(); + } + app_save_popup_state($popupState); + } elseif (($payload['command'] ?? '') === 'close') { + $popupState['active'] = false; + $popupState['expires_at'] = null; + app_save_popup_state($popupState); + } + + return $popupState; +} + +function app_service_for_entity(string $entityId, string $command): array +{ + $domain = app_entity_domain($entityId); + + return match ($command) { + 'toggle' => [$domain, 'toggle', ['entity_id' => $entityId]], + 'turn_on' => [$domain, 'turn_on', ['entity_id' => $entityId]], + 'turn_off' => [$domain, 'turn_off', ['entity_id' => $entityId]], + 'open' => ['cover', 'open_cover', ['entity_id' => $entityId]], + 'close' => ['cover', 'close_cover', ['entity_id' => $entityId]], + 'stop' => ['cover', 'stop_cover', ['entity_id' => $entityId]], + 'set_position' => ['cover', 'set_cover_position', ['entity_id' => $entityId]], + 'set_temperature' => ['climate', 'set_temperature', ['entity_id' => $entityId]], + 'set_hvac_mode' => ['climate', 'set_hvac_mode', ['entity_id' => $entityId]], + 'set_fan_mode' => ['climate', 'set_fan_mode', ['entity_id' => $entityId]], + 'set_swing_mode' => ['climate', 'set_swing_mode', ['entity_id' => $entityId]], + 'set_preset_mode' => ['climate', 'set_preset_mode', ['entity_id' => $entityId]], + default => [$domain, $command, ['entity_id' => $entityId]], + }; +} diff --git a/lib/ha_client.php b/lib/ha_client.php new file mode 100755 index 0000000..0964ba2 --- /dev/null +++ b/lib/ha_client.php @@ -0,0 +1,657 @@ +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, + '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/storage/@eaDir/popup_state.json@SynoEAStream b/storage/@eaDir/popup_state.json@SynoEAStream new file mode 100755 index 0000000000000000000000000000000000000000..71a85d9c30a4cbcd792759bc3cf03ac918af12c9 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqow~ElDg%%*j@O0+1L3ClDI}u>uf-_(4ES5C8y4GzAI( literal 0 HcmV?d00001 diff --git a/storage/popup_state.json b/storage/popup_state.json new file mode 100755 index 0000000..49f9973 --- /dev/null +++ b/storage/popup_state.json @@ -0,0 +1,6 @@ +{ + "active": false, + "sensor_entity_id": "binary_sensor.barn_all_occupancy", + "opened_at": 1773938797, + "expires_at": null +}