This commit is contained in:
Striker72rus 2026-03-25 13:12:07 +03:00
parent ab277bf13c
commit 62514f7f52
26 changed files with 1577 additions and 8 deletions

12
.dockerignore Executable file
View File

@ -0,0 +1,12 @@
.git
.gitignore
.DS_Store
ha-addon
custom_components
node_modules
storage/@eaDir
config/@eaDir
config/config.json
lib/@eaDir
assets/@eaDir
storage/*.json

Binary file not shown.

19
Dockerfile Executable file
View File

@ -0,0 +1,19 @@
FROM php:8.2-cli-alpine
RUN apk add --no-cache curl-dev && docker-php-ext-install curl
WORKDIR /app
COPY index.php /app/index.php
COPY api.php /app/api.php
COPY favicon.ico /app/favicon.ico
COPY assets /app/assets
COPY lib /app/lib
COPY config/addon-default-config.json /app/config/config.json
COPY run.sh /run.sh
RUN chmod a+x /run.sh
EXPOSE 8099
CMD ["/run.sh"]

View File

@ -1,6 +1,7 @@
# Wall Panel # Wall Panel
Таблет-ориентированная панель для Home Assistant на `PHP + HTML + JS`. Таблет-ориентированная панель для Home Assistant на `PHP + HTML + JS`.
Основной HA-способ запуска теперь `Home Assistant Add-on` с ingress и отдельным портом.
## Запуск ## Запуск
@ -14,7 +15,7 @@ php -S 0.0.0.0:8080
Основной файл: Основной файл:
- [`config/config.json`](/Users/striker/SynologyDrive/developer/HomeAssistant/wallpanell/config/config.json) - [`config/config.json`](config/config.json)
В него кладутся: В него кладутся:
@ -27,6 +28,45 @@ php -S 0.0.0.0:8080
Если `base_url` и `token` пустые, панель работает в demo mode с тестовыми карточками. Если `base_url` и `token` пустые, панель работает в demo mode с тестовыми карточками.
## Home Assistant Add-on
Для HA теперь рекомендован add-on-режим:
- запускает тот же PHP-интерфейс внутри Supervisor;
- открывается через ingress;
- доступен через отдельный порт;
- хранит конфиг в add-on volume;
- не зависит от `custom_components`.
Файлы add-on лежат в корне репозитория:
- [`config.yaml`](/Volumes/web/wallpanell/config.yaml)
- [`Dockerfile`](/Volumes/web/wallpanell/Dockerfile)
- [`run.sh`](/Volumes/web/wallpanell/run.sh)
### Как использовать
1. Поместите репозиторий в локальные add-ons Home Assistant или соберите add-on как свой локальный пакет.
2. Установите add-on.
3. Запустите его.
4. Откройте панель через ingress или через проброшенный порт `8099/tcp`.
### Конфигурация
Add-on использует persistent config file:
- `/config/config.json` внутри контейнера
Этот файл является runtime source of truth для панели в HA-режиме и переживает рестарт add-on.
### Старый embed-режим
Отдельный PHP-доступ по-прежнему работает:
`http://YOUR_PANEL_HOST/index.php?embed=1`
Это полезно, если вы запускаете панель вне HA или хотите iframe-карточку вручную.
## Popup камеры ## Popup камеры
Для браузера нужен не прямой `rtsp://`, а bridge, который отдаёт `HLS` или `WebRTC`. Для браузера нужен не прямой `rtsp://`, а bridge, который отдаёт `HLS` или `WebRTC`.

24
api.php
View File

@ -27,6 +27,17 @@ function api_input(): array
return $_POST ?: []; return $_POST ?: [];
} }
function api_mirror_config_change(array $config, string $action, array $payload): array
{
$response = app_sync_remote_action($config, $action, $payload);
if (is_array($response) && is_array($response['config'] ?? null)) {
$config = app_merge_synced_config($config, $response['config']);
app_save_config($config);
}
return $config;
}
try { try {
$config = app_load_config(); $config = app_load_config();
$client = new HomeAssistantClient($config); $client = new HomeAssistantClient($config);
@ -109,6 +120,8 @@ try {
]; ];
$config = app_update_entity_override($config, $roomId, $entityId, $patch); $config = app_update_entity_override($config, $roomId, $entityId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
} }
@ -132,6 +145,8 @@ try {
} }
$config = app_update_room_override($config, $roomId, $patch); $config = app_update_room_override($config, $roomId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
} }
@ -152,6 +167,8 @@ try {
$config = app_update_room_layout_item($config, $roomId, $layoutItemId, [ $config = app_update_room_layout_item($config, $roomId, $layoutItemId, [
'order' => $order, 'order' => $order,
]); ]);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json([ api_json([
'ok' => true, 'ok' => true,
@ -174,6 +191,8 @@ try {
]; ];
$config = app_update_room_layout_item($config, $roomId, $layoutItemId, $patch); $config = app_update_room_layout_item($config, $roomId, $layoutItemId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
} }
@ -187,6 +206,8 @@ try {
} }
$config = app_delete_room_layout_item($config, $roomId, $layoutItemId); $config = app_delete_room_layout_item($config, $roomId, $layoutItemId);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
} }
@ -200,6 +221,8 @@ try {
} }
$config = app_reorder_room_grid($config, $roomId, $entries); $config = app_reorder_room_grid($config, $roomId, $entries);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
} }
@ -211,6 +234,7 @@ try {
if (array_key_exists('title', $payload) && trim((string)$payload['title']) !== '') { if (array_key_exists('title', $payload) && trim((string)$payload['title']) !== '') {
$config['app']['title'] = trim((string)$payload['title']); $config['app']['title'] = trim((string)$payload['title']);
} }
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config); app_save_config($config);
api_json(['ok' => true, 'settings' => $config['app']]); api_json(['ok' => true, 'settings' => $config['app']]);
} }

View File

@ -45,6 +45,10 @@ body {
overflow: hidden; overflow: hidden;
} }
body.is-embedded {
overflow: auto;
}
button, button,
input, input,
select, select,
@ -55,9 +59,16 @@ textarea {
.app-shell { .app-shell {
display: grid; display: grid;
grid-template-columns: var(--sidebar-width) 1fr; grid-template-columns: var(--sidebar-width) 1fr;
min-height: 100vh;
height: 100vh; height: 100vh;
} }
.app-shell--embed {
grid-template-columns: clamp(250px, 22vw, 320px) minmax(0, 1fr);
height: auto;
min-height: 100vh;
}
.sidebar { .sidebar {
position: relative; position: relative;
padding: 24px 20px 20px 20px; padding: 24px 20px 20px 20px;
@ -69,6 +80,11 @@ textarea {
overflow: hidden; overflow: hidden;
} }
.app-shell--embed .sidebar {
padding: 18px 16px 16px;
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
.sidebar::after { .sidebar::after {
content: ""; content: "";
position: absolute; position: absolute;
@ -80,10 +96,18 @@ textarea {
mix-blend-mode: screen; mix-blend-mode: screen;
} }
.app-shell--embed .sidebar::after {
display: none;
}
.clock-panel { .clock-panel {
padding: 12px 8px 12px 8px; padding: 12px 8px 12px 8px;
} }
.app-shell--embed .clock-panel {
padding: 0 0 8px;
}
.clock-panel__time { .clock-panel__time {
font-family: var(--font-display); font-family: var(--font-display);
font-size: clamp(56px, 6.4vw, 82px); font-size: clamp(56px, 6.4vw, 82px);
@ -100,6 +124,15 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.app-shell--embed .clock-panel__time {
font-size: clamp(34px, 3.8vw, 56px);
}
.app-shell--embed .clock-panel__date {
font-size: 14px;
white-space: normal;
}
.rooms-panel { .rooms-panel {
margin-top: 12px; margin-top: 12px;
display: flex; display: flex;
@ -108,6 +141,10 @@ textarea {
flex: 1; flex: 1;
} }
.app-shell--embed .rooms-panel {
margin-top: 8px;
}
.panel-header, .panel-header,
.content-header, .content-header,
.camera-modal__header, .camera-modal__header,
@ -215,6 +252,13 @@ textarea {
padding-bottom: 6px; padding-bottom: 6px;
} }
.app-shell--embed .room-list {
gap: 10px;
overflow-y: auto;
padding-right: 4px;
padding-bottom: 4px;
}
.room-list__group { .room-list__group {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@ -239,6 +283,24 @@ textarea {
transition: border-color 180ms ease, transform 180ms ease, background 180ms ease, box-shadow 180ms ease; transition: border-color 180ms ease, transform 180ms ease, background 180ms ease, box-shadow 180ms ease;
} }
.app-shell--embed .room-item {
min-height: 94px;
padding: 7px 8px;
}
.app-shell--embed .room-item__icon {
width: 42px;
height: 42px;
}
.app-shell--embed .room-item__icon i {
font-size: 32px;
}
.app-shell--embed .room-item__name {
font-size: 14px;
}
.room-item.has-temp { .room-item.has-temp {
padding-right: 0px; padding-right: 0px;
} }
@ -443,12 +505,20 @@ textarea {
overflow: auto; overflow: auto;
} }
.app-shell--embed .content {
padding: 22px 16px 20px;
}
.content-top { .content-top {
display: none; display: none;
margin-bottom: 16px; margin-bottom: 16px;
margin-top: -30px; margin-top: -30px;
} }
.app-shell--embed .content-top {
margin-top: 0;
}
.content-top.is-main { .content-top.is-main {
display: block; display: block;
} }
@ -459,6 +529,11 @@ textarea {
justify-content: flex-start; justify-content: flex-start;
} }
.app-shell--embed .content-header {
min-height: auto;
margin-bottom: 12px;
}
.content-header.is-main .content-header__eyebrow, .content-header.is-main .content-header__eyebrow,
.content-header.is-main .content-header__meta { .content-header.is-main .content-header__meta {
display: none; display: none;
@ -483,6 +558,10 @@ textarea {
letter-spacing: -0.05em; letter-spacing: -0.05em;
} }
.app-shell--embed .content-header__title {
font-size: clamp(24px, 2.5vw, 40px);
}
.content-header__meta { .content-header__meta {
margin-top: 10px; margin-top: 10px;
color: var(--text-muted); color: var(--text-muted);

View File

@ -3,6 +3,7 @@
const MOBILE_BREAKPOINT = 920; const MOBILE_BREAKPOINT = 920;
const state = { const state = {
snapshot: bootstrap, snapshot: bootstrap,
embedMode: Boolean(bootstrap?.ui?.embed),
selectedRoomId: 'main', selectedRoomId: 'main',
isMobileViewport: false, isMobileViewport: false,
mobileView: 'spaces', mobileView: 'spaces',
@ -40,6 +41,7 @@
haReconnectDelay: 1000, haReconnectDelay: 1000,
haSubscribeId: 1, haSubscribeId: 1,
roomSelectionToken: 0, roomSelectionToken: 0,
snapshotPollTimer: null,
}; };
const els = {}; const els = {};
@ -68,6 +70,18 @@
return isMobileViewport() && state.mobileView === 'room'; return isMobileViewport() && state.mobileView === 'room';
} }
function detectEmbeddedContext() {
if (Boolean(bootstrap?.ui?.embed)) {
return true;
}
try {
return window.self !== window.top;
} catch (error) {
return true;
}
}
function setMobileView(nextView) { function setMobileView(nextView) {
if (!isMobileViewport()) { if (!isMobileViewport()) {
state.mobileView = 'room'; state.mobileView = 'room';
@ -897,9 +911,12 @@
if (!els.appShell) return; if (!els.appShell) return;
const mobile = isMobileViewport(); const mobile = isMobileViewport();
const embedded = Boolean(state.embedMode);
document.body.classList.toggle('is-mobile-ui', mobile); document.body.classList.toggle('is-mobile-ui', mobile);
document.body.classList.toggle('is-embedded', embedded);
els.appShell.classList.toggle('is-mobile', mobile); els.appShell.classList.toggle('is-mobile', mobile);
els.appShell.classList.toggle('is-desktop', !mobile); els.appShell.classList.toggle('is-desktop', !mobile);
els.appShell.classList.toggle('app-shell--embed', embedded);
els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room'); els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room');
els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room');
@ -4290,6 +4307,7 @@
clearTimeout(state.haReconnectTimer); clearTimeout(state.haReconnectTimer);
state.haReconnectTimer = null; state.haReconnectTimer = null;
state.haSocketState = 'disconnected'; state.haSocketState = 'disconnected';
stopSnapshotPolling();
} }
function scheduleReconnect() { function scheduleReconnect() {
@ -4302,6 +4320,29 @@
}, delay); }, delay);
} }
function stopSnapshotPolling() {
if (state.snapshotPollTimer) {
clearInterval(state.snapshotPollTimer);
state.snapshotPollTimer = null;
}
}
function startSnapshotPolling() {
const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000));
if (state.snapshotPollTimer) {
clearInterval(state.snapshotPollTimer);
}
state.snapshotPollTimer = window.setInterval(async () => {
try {
await loadSnapshot(state.selectedRoomId || 'main');
render();
} catch (error) {
console.warn(error);
}
}, interval);
}
function handleHaMessage(message) { function handleHaMessage(message) {
if (!message || typeof message !== 'object') { if (!message || typeof message !== 'object') {
return; return;
@ -4433,9 +4474,18 @@
if (!wsUrl || !token) { if (!wsUrl || !token) {
state.haSocketState = 'unavailable'; state.haSocketState = 'unavailable';
setStatus('Online', 'online'); setStatus('Online', 'online');
startSnapshotPolling();
return; return;
} }
if (window.location.protocol === 'https:' && wsUrl.startsWith('ws://')) {
state.haSocketState = 'unavailable';
setStatus('Polling mode', 'online');
startSnapshotPolling();
return;
}
stopSnapshotPolling();
stopRealtime(); stopRealtime();
state.haSocketState = 'connecting'; state.haSocketState = 'connecting';
setStatus('Connecting WS...', 'loading'); setStatus('Connecting WS...', 'loading');
@ -4455,6 +4505,7 @@
}; };
socket.onerror = () => { socket.onerror = () => {
setStatus('WS error', 'error'); setStatus('WS error', 'error');
startSnapshotPolling();
}; };
socket.onclose = () => { socket.onclose = () => {
state.haSocket = null; state.haSocket = null;
@ -4470,6 +4521,8 @@
async function start() { async function start() {
initRefs(); initRefs();
state.embedMode = detectEmbeddedContext();
syncLayoutState();
syncViewportState(); syncViewportState();
updateClock(); updateClock();
clearInterval(state.clockTimer); clearInterval(state.clockTimer);
@ -4491,6 +4544,9 @@
state.snapshot = initial; state.snapshot = initial;
render(); render();
connectRealtime(); connectRealtime();
if (!state.snapshotPollTimer) {
startSnapshotPolling();
}
} }
document.addEventListener('DOMContentLoaded', start); document.addEventListener('DOMContentLoaded', start);

24
config.yaml Executable file
View File

@ -0,0 +1,24 @@
name: Wall Panel
description: Wall Panel as a Home Assistant add-on
version: "1.0.0"
slug: wall_panel
init: false
arch:
- aarch64
- amd64
- armhf
- armv7
- i386
startup: services
ingress: true
ingress_port: 8099
panel_title: Wall Panel
panel_icon: mdi:view-dashboard
ports:
8099/tcp: 8099
ports_description:
8099/tcp: Wall Panel web UI
map:
- addon_config:rw
options: {}
schema: {}

View File

@ -0,0 +1,32 @@
{
"app": {
"title": "Wall Panel",
"poll_interval_ms": 5000,
"main_room_name": "Главная",
"main_room_icon": "mdi:home",
"edit_mode": false,
"battery_history_hours": 4320
},
"home_assistant": {
"base_url": "",
"token": "",
"verify_ssl": true,
"sync_url": "",
"sync_token": "",
"sync_timeout": 10,
"sync_verify_ssl": true,
"sync_cache_seconds": 30,
"weather_entity_id": "",
"auto_label": "auto",
"auto_entity_ids": []
},
"camera": {
"rtsp_url": "",
"stream_url": "",
"stream_mode": "hls",
"poster_url": "",
"popup_timeout_minutes": 3,
"trigger_entities": []
},
"rooms": []
}

View File

@ -73,6 +73,11 @@
"base_url": "http://10.0.6.5:8123", "base_url": "http://10.0.6.5:8123",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiYWQ2ZGE0OGYzOWU0MDFjOTA2OTZhZjVkYzA2M2U2MSIsImlhdCI6MTc3MzY0NjMwNywiZXhwIjoyMDg5MDA2MzA3fQ.u7kWa3ONMEo5UMi3suIBTocr_6FqOYphfYb7NJGZ3wI", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiYWQ2ZGE0OGYzOWU0MDFjOTA2OTZhZjVkYzA2M2U2MSIsImlhdCI6MTc3MzY0NjMwNywiZXhwIjoyMDg5MDA2MzA3fQ.u7kWa3ONMEo5UMi3suIBTocr_6FqOYphfYb7NJGZ3wI",
"verify_ssl": false, "verify_ssl": false,
"sync_url": "http://10.0.6.5:8123/api/wall_panel/config/01KMDWVSTKC9XK7RZWJ9QP9PC1",
"sync_token": "FD5i6G8JM_rwoEMqTZzSbVDSkz-1k67n",
"sync_timeout": 10,
"sync_verify_ssl": true,
"sync_cache_seconds": 30,
"weather_entity_id": "", "weather_entity_id": "",
"auto_label": "auto", "auto_label": "auto",
"auto_entity_ids": [] "auto_entity_ids": []
@ -1336,6 +1341,141 @@
}, },
"sensor.aqara_dimmer_switch_active_current": { "sensor.aqara_dimmer_switch_active_current": {
"visible": false "visible": false
},
"switch.0xc02cedfffef131dc_turbo_mode": {
"visible": false
},
"switch.0xc02cedfffef131dc_delayed_power_on_state": {
"visible": false
},
"number.0xc02cedfffef131dc_delayed_power_on_time": {
"visible": false
},
"switch.0xc02cedfffef131dc_detach_relay_mode": {
"visible": false
},
"select.0xc02cedfffef131dc_external_trigger_mode": {
"visible": false
},
"switch.0xc02cedfffef131dc_network_indicator": {
"visible": false
},
"select.0xc02cedfffef131dc_power_on_behavior": {
"visible": false
},
"sensor.0x54ef4410011cd300_power": {
"visible": false
},
"sensor.0x54ef4410011cd300_voltage": {
"visible": false
},
"sensor.0x54ef4410011cd300_device_temperature": {
"visible": false
},
"sensor.0x54ef4410011cd300_current": {
"visible": false
},
"sensor.0x54ef4410011cd300_energy": {
"visible": false
},
"sensor.0x54ef4410011cd300_action_rotation_angle": {
"visible": false
},
"sensor.0x54ef4410011cd300_action_rotation_angle_speed": {
"visible": false
},
"sensor.0x54ef4410011cd300_action_rotation_button_state": {
"visible": false
},
"sensor.0x54ef4410011cd300_action_rotation_percent": {
"visible": false
},
"sensor.0x54ef4410011cd300_action_rotation_percent_speed": {
"visible": false
},
"switch.0x54ef4410011cd300_flip_indicator_light": {
"visible": false
},
"switch.0x54ef4410011cd300_led_indicator": {
"visible": false
},
"number.0x54ef4410011cd300_max_brightness": {
"visible": false
},
"number.0x54ef4410011cd300_min_brightness": {
"visible": false
},
"switch.0x54ef4410011cd300_multi_click": {
"visible": false
},
"select.0x54ef4410011cd300_operation_mode": {
"visible": false
},
"select.0x54ef4410011cd300_phase": {
"visible": false
},
"select.0x54ef4410011cd300_power_on_behavior": {
"visible": false
},
"select.0x54ef4410011cd300_sensitivity": {
"visible": false
},
"sensor.0x54ef4410011cd300_action_rotation_time": {
"visible": false
},
"update.0x54ef4410011cd300": {
"visible": false
},
"update.0xc02cedfffef131dc": {
"visible": false
},
"climate.konditsioner": {
"order": 10000
},
"cover.0x0ceff6fffe6cffc4": {
"order": 10010
},
"cover.0x0ceff6fffe6cdee0": {
"order": 10020
},
"cover.0x705464fffe43dee0": {
"order": 10030
},
"cover.0x44e2f8fffeb65d8e": {
"order": 10040
},
"light.0x54ef4410011cd300": {
"order": 10050
},
"switch.spalnia_girlianda_levoe_okno": {
"order": 10070
},
"switch.spalnia_girlianda_pravoe_okno": {
"order": 10080
},
"light.umnoe_rele": {
"order": 10060
},
"switch.lab_switch": {
"order": 10090
},
"light.spalnia_nochnik": {
"order": 10100
},
"switch.0xa4c138a7af3bad18": {
"order": 10110
},
"switch.0xa4c138bd4cead630": {
"order": 10120
},
"switch.flower": {
"order": 10130
},
"humidifier.uvlazhnitel": {
"order": 10140
},
"media_player.lg_webos_smart_tv": {
"order": 10150
} }
}, },
"temperature_sensor_entity_id": "sensor.spalnya_temp_temperature", "temperature_sensor_entity_id": "sensor.spalnya_temp_temperature",

View File

@ -0,0 +1,48 @@
"""Wall Panel integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .const import CONF_CONFIG, DOMAIN
from .frontend import async_setup_frontend
from .helpers import current_entry_config
from .views import WallPanelConfigView
async def async_setup_entry(hass: HomeAssistant, entry) -> bool:
"""Set up Wall Panel from a config entry."""
state = hass.data.setdefault(DOMAIN, {})
state[entry.entry_id] = {
"entry": entry,
"config": current_entry_config(entry),
}
panel_url_path = await async_setup_frontend(hass, entry)
state[entry.entry_id]["panel_url_path"] = panel_url_path
if not state.get("_config_view_registered"):
hass.http.register_view(WallPanelConfigView)
state["_config_view_registered"] = True
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
return True
async def async_unload_entry(hass: HomeAssistant, entry) -> bool:
"""Unload Wall Panel."""
from homeassistant.components.frontend import async_remove_panel
state = hass.data.get(DOMAIN, {})
panel_url_path = str(state.get(entry.entry_id, {}).get("panel_url_path") or entry.options.get("frontend_url_path", "wall-panel") or "wall-panel").strip()
async_remove_panel(hass, panel_url_path)
state.pop(entry.entry_id, None)
return True
async def _async_options_updated(hass: HomeAssistant, entry) -> None:
"""Reload the integration when options change."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@ -0,0 +1,134 @@
"""Config flow for Wall Panel."""
from __future__ import annotations
import json
import secrets
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, TextSelectorType
from .const import (
CONF_CONFIG,
CONF_FRONTEND_URL_PATH,
CONF_PANEL_URL,
CONF_REQUIRE_ADMIN,
CONF_SIDEBAR_ICON,
CONF_SIDEBAR_TITLE,
CONF_SYNC_TOKEN,
DEFAULT_FRONTEND_URL_PATH,
DEFAULT_PANEL_URL,
DEFAULT_SIDEBAR_ICON,
DEFAULT_SIDEBAR_TITLE,
DOMAIN,
)
from .helpers import config_to_json, normalize_config, parse_config_json
def _schema(defaults: dict[str, Any]) -> vol.Schema:
return vol.Schema({
vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, "Wall Panel")): str,
vol.Optional(CONF_PANEL_URL, default=defaults.get(CONF_PANEL_URL, DEFAULT_PANEL_URL)): str,
vol.Optional(CONF_SIDEBAR_TITLE, default=defaults.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE)): str,
vol.Optional(CONF_SIDEBAR_ICON, default=defaults.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON)): str,
vol.Optional(CONF_FRONTEND_URL_PATH, default=defaults.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH)): str,
vol.Optional(CONF_REQUIRE_ADMIN, default=bool(defaults.get(CONF_REQUIRE_ADMIN, False))): bool,
vol.Optional(CONF_SYNC_TOKEN, default=defaults.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24))): str,
vol.Optional(
CONF_CONFIG,
default=defaults.get(CONF_CONFIG, config_to_json(normalize_config({}))),
): TextSelector(TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT)),
})
class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Wall Panel."""
VERSION = 1
async def async_step_user(self, user_input: dict[str, Any] | None = None):
errors: dict[str, str] = {}
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if user_input is not None:
try:
config = parse_config_json(user_input[CONF_CONFIG])
except (json.JSONDecodeError, ValueError):
errors[CONF_CONFIG] = "invalid_json"
else:
data = {
CONF_NAME: user_input.get(CONF_NAME, "Wall Panel"),
CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""),
CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""),
CONF_CONFIG: config,
}
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=data[CONF_SIDEBAR_TITLE], data={}, options=data)
defaults = {
CONF_NAME: "Wall Panel",
CONF_PANEL_URL: DEFAULT_PANEL_URL,
CONF_SIDEBAR_TITLE: DEFAULT_SIDEBAR_TITLE,
CONF_SIDEBAR_ICON: DEFAULT_SIDEBAR_ICON,
CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH,
CONF_REQUIRE_ADMIN: False,
CONF_SYNC_TOKEN: secrets.token_urlsafe(24),
CONF_CONFIG: config_to_json(normalize_config({})),
}
return self.async_show_form(step_id="user", data_schema=_schema(defaults), errors=errors)
async def async_step_import(self, user_input: dict[str, Any]):
return await self.async_step_user(user_input)
class WallPanelOptionsFlow(config_entries.OptionsFlow):
"""Handle options for Wall Panel."""
def __init__(self, config_entry):
self.config_entry = config_entry
async def async_step_init(self, user_input: dict[str, Any] | None = None):
errors: dict[str, str] = {}
if user_input is not None:
try:
config = parse_config_json(user_input[CONF_CONFIG])
except (json.JSONDecodeError, ValueError):
errors[CONF_CONFIG] = "invalid_json"
else:
data = dict(self.config_entry.options)
data.update({
CONF_NAME: user_input.get(CONF_NAME, data.get(CONF_NAME, "Wall Panel")),
CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""),
CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""),
CONF_CONFIG: config,
})
return self.async_create_entry(title="", data=data)
defaults = {
CONF_NAME: self.config_entry.options.get(CONF_NAME, "Wall Panel"),
CONF_PANEL_URL: self.config_entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL),
CONF_SIDEBAR_TITLE: self.config_entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE),
CONF_SIDEBAR_ICON: self.config_entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON),
CONF_FRONTEND_URL_PATH: self.config_entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH),
CONF_REQUIRE_ADMIN: self.config_entry.options.get(CONF_REQUIRE_ADMIN, False),
CONF_SYNC_TOKEN: self.config_entry.options.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)),
CONF_CONFIG: config_to_json(normalize_config(self.config_entry.options.get(CONF_CONFIG, {}))),
}
return self.async_show_form(step_id="init", data_schema=_schema(defaults), errors=errors)
def async_get_options_flow(config_entry):
return WallPanelOptionsFlow(config_entry)

View File

@ -0,0 +1,17 @@
"""Constants for Wall Panel."""
DOMAIN = "wall_panel"
CONF_PANEL_URL = "panel_url"
CONF_CONFIG = "config"
CONF_SYNC_TOKEN = "sync_token"
CONF_SIDEBAR_TITLE = "sidebar_title"
CONF_SIDEBAR_ICON = "sidebar_icon"
CONF_FRONTEND_URL_PATH = "frontend_url_path"
CONF_REQUIRE_ADMIN = "require_admin"
DEFAULT_NAME = "Wall Panel"
DEFAULT_PANEL_URL = ""
DEFAULT_SIDEBAR_TITLE = "Wall Panel"
DEFAULT_SIDEBAR_ICON = "mdi:view-dashboard"
DEFAULT_FRONTEND_URL_PATH = "wall-panel"

View File

@ -0,0 +1,68 @@
"""Frontend registration for Wall Panel."""
from __future__ import annotations
from pathlib import Path
from homeassistant.components.frontend import async_register_built_in_panel
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant
from .const import (
CONF_FRONTEND_URL_PATH,
CONF_PANEL_URL,
CONF_REQUIRE_ADMIN,
CONF_SIDEBAR_ICON,
CONF_SIDEBAR_TITLE,
DEFAULT_FRONTEND_URL_PATH,
DEFAULT_SIDEBAR_ICON,
DEFAULT_SIDEBAR_TITLE,
DOMAIN,
)
async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
"""Register the custom panel and static frontend assets."""
frontend_dir = Path(__file__).parent / "frontend"
state = hass.data.setdefault(DOMAIN, {})
if not state.get("_static_paths_registered"):
await hass.http.async_register_static_paths([
StaticPathConfig(
f"/api/{DOMAIN}/frontend",
str(frontend_dir),
cache_headers=False,
),
])
state["_static_paths_registered"] = True
panel_url_path = str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH).strip()
sidebar_title = str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE).strip()
sidebar_icon = str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON).strip()
require_admin = bool(entry.options.get(CONF_REQUIRE_ADMIN, False))
panel_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip()
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title=sidebar_title,
sidebar_icon=sidebar_icon,
frontend_url_path=panel_url_path,
config={
"_panel_custom": {
"name": "wall-panel-panel",
"module_url": f"/api/{DOMAIN}/frontend/panel.js",
"embed_iframe": False,
"trust_external": False,
"config": {
"panel_url": panel_url,
"panel_url_path": panel_url_path,
"entry_id": entry.entry_id,
},
}
},
require_admin=require_admin,
update=True,
)
return panel_url_path

View File

@ -0,0 +1,133 @@
class WallPanelPanel extends HTMLElement {
constructor() {
super();
this._hass = null;
this._panel = null;
this._narrow = false;
this.attachShadow({ mode: 'open' });
}
set hass(hass) {
this._hass = hass;
this._render();
}
set panel(panel) {
this._panel = panel;
this._render();
}
set narrow(narrow) {
this._narrow = Boolean(narrow);
this._render();
}
connectedCallback() {
this._render();
}
_resolveUrl(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) {
return '';
}
try {
const url = new URL(value, window.location.href);
if (!url.searchParams.has('embed')) {
url.searchParams.set('embed', '1');
}
if (!url.searchParams.has('mode')) {
url.searchParams.set('mode', 'ha');
}
return url.toString();
} catch (error) {
return value;
}
}
_render() {
if (!this.shadowRoot) {
return;
}
const config = this._panel?.config || {};
const customConfig = config._panel_custom || {};
const payload = customConfig.config || customConfig || config;
const panelUrl = this._resolveUrl(
payload.panel_url || config.panel_url || customConfig.panel_url || ''
);
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 100%;
height: 100%;
min-height: 100vh;
background: var(--primary-background-color, #0d0f14);
color: var(--primary-text-color, #fff);
}
.wrap {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
background: var(--primary-background-color, #0d0f14);
}
iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
background: transparent;
}
.empty {
display: grid;
place-items: center;
height: 100%;
padding: 24px;
box-sizing: border-box;
text-align: center;
font-family: var(--primary-font-family, sans-serif);
color: var(--secondary-text-color, #b3b8c2);
}
.empty strong {
display: block;
color: var(--primary-text-color, #fff);
margin-bottom: 8px;
font-size: 1.1rem;
}
</style>
<div class="wrap"></div>
`;
const wrap = this.shadowRoot.querySelector('.wrap');
if (!wrap) {
return;
}
if (!panelUrl) {
wrap.innerHTML = `
<div class="empty">
<div>
<strong>Wall Panel is not configured</strong>
<div>Set the PHP panel URL in the integration options.</div>
</div>
</div>
`;
return;
}
const iframe = document.createElement('iframe');
iframe.src = panelUrl;
iframe.loading = 'eager';
iframe.referrerPolicy = 'no-referrer';
iframe.allow = 'autoplay; fullscreen; picture-in-picture';
wrap.replaceChildren(iframe);
}
}
if (!customElements.get('wall-panel-panel')) {
customElements.define('wall-panel-panel', WallPanelPanel);
}

View File

@ -0,0 +1,283 @@
"""Helper functions for Wall Panel."""
from __future__ import annotations
import json
from copy import deepcopy
from typing import Any
from .const import (
CONF_CONFIG,
CONF_FRONTEND_URL_PATH,
CONF_PANEL_URL,
CONF_REQUIRE_ADMIN,
CONF_SIDEBAR_ICON,
CONF_SIDEBAR_TITLE,
CONF_SYNC_TOKEN,
DEFAULT_FRONTEND_URL_PATH,
DEFAULT_PANEL_URL,
DEFAULT_SIDEBAR_ICON,
DEFAULT_SIDEBAR_TITLE,
)
def default_config() -> dict[str, Any]:
return {
"app": {
"title": "Wall Panel",
"poll_interval_ms": 5000,
"main_room_name": "Главная",
"main_room_icon": "mdi:home",
"edit_mode": False,
"battery_history_hours": 4320,
},
"home_assistant": {
"base_url": "",
"token": "",
"verify_ssl": True,
"sync_url": "",
"sync_token": "",
"sync_timeout": 10,
"sync_verify_ssl": True,
"sync_cache_seconds": 30,
"weather_entity_id": "",
"auto_label": "auto",
"auto_entity_ids": [],
},
"camera": {
"rtsp_url": "",
"stream_url": "",
"stream_mode": "hls",
"poster_url": "",
"popup_timeout_minutes": 3,
"trigger_entities": [],
},
"rooms": [],
}
def normalize_config(value: Any) -> dict[str, Any]:
config = deepcopy(default_config())
if not isinstance(value, dict):
return config
_deep_merge(config, value)
if not isinstance(config.get("rooms"), list):
config["rooms"] = []
return config
def config_to_json(config: dict[str, Any]) -> str:
return json.dumps(config, ensure_ascii=False, indent=2, sort_keys=False)
def parse_config_json(raw: str) -> dict[str, Any]:
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("Config JSON must be an object")
return normalize_config(data)
def current_entry_config(entry) -> dict[str, Any]:
return normalize_config(entry.options.get(CONF_CONFIG))
def current_entry_panel(entry) -> dict[str, Any]:
return {
CONF_PANEL_URL: str(entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL) or ""),
CONF_SYNC_TOKEN: str(entry.options.get(CONF_SYNC_TOKEN, "") or ""),
CONF_SIDEBAR_TITLE: str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
CONF_SIDEBAR_ICON: str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
CONF_FRONTEND_URL_PATH: str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
CONF_REQUIRE_ADMIN: bool(entry.options.get(CONF_REQUIRE_ADMIN, False)),
}
def save_settings(config: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
app = config.setdefault("app", {})
if "edit_mode" in payload:
app["edit_mode"] = bool(payload["edit_mode"])
if isinstance(payload.get("title"), str) and payload["title"].strip():
app["title"] = payload["title"].strip()
return config
def update_entity_override(config: dict[str, Any], room_id: str, entity_id: str, patch: dict[str, Any]) -> dict[str, Any]:
room = _ensure_room(config, room_id)
overrides = room.setdefault("entity_overrides", {})
current = overrides.get(entity_id, {})
if not isinstance(current, dict):
current = {}
merged = deepcopy(current)
for key, value in patch.items():
if value is not None:
merged[key] = value
overrides[entity_id] = merged
return config
def update_room_override(config: dict[str, Any], room_id: str, patch: dict[str, Any]) -> dict[str, Any]:
room = _ensure_room(config, room_id)
for key, value in patch.items():
if value is not None:
room[key] = value
return config
def update_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str, patch: dict[str, Any]) -> dict[str, Any]:
room = _ensure_room(config, room_id)
items = room.setdefault("layout_items", [])
if not isinstance(items, list):
items = []
room["layout_items"] = items
current = None
for item in items:
if isinstance(item, dict) and str(item.get("id", "")) == layout_item_id:
current = item
break
if current is None:
current = {
"id": layout_item_id,
"type": "ghost",
}
items.append(current)
for key, value in patch.items():
if value is not None:
current[key] = value
_sort_room_layout_items(room)
return config
def create_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str, order: int | None = None) -> dict[str, Any]:
return update_room_layout_item(config, room_id, layout_item_id, {
"order": order,
"type": "ghost",
})
def delete_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str) -> dict[str, Any]:
room = _ensure_room(config, room_id)
items = room.get("layout_items", [])
if not isinstance(items, list):
room["layout_items"] = []
return config
room["layout_items"] = [
item for item in items
if not isinstance(item, dict) or str(item.get("id", "")) != layout_item_id
]
return config
def reorder_room_grid(config: dict[str, Any], room_id: str, entries: list[Any]) -> dict[str, Any]:
room = _ensure_room(config, room_id)
normalized: list[dict[str, str]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
kind = str(entry.get("kind", "")).strip()
item_id = str(entry.get("id", "")).strip()
if kind not in {"entity", "layout"} or not item_id:
continue
normalized.append({"kind": kind, "id": item_id})
entity_overrides = room.setdefault("entity_overrides", {})
if not isinstance(entity_overrides, dict):
entity_overrides = {}
room["entity_overrides"] = entity_overrides
layout_items = room.setdefault("layout_items", [])
if not isinstance(layout_items, list):
layout_items = []
room["layout_items"] = layout_items
layout_by_id = {
str(item.get("id", "")): item
for item in layout_items
if isinstance(item, dict) and str(item.get("id", "")).strip()
}
order = 10000
for entry in normalized:
if entry["kind"] == "entity":
current = entity_overrides.get(entry["id"], {})
if not isinstance(current, dict):
current = {}
merged = deepcopy(current)
merged["order"] = order
entity_overrides[entry["id"]] = merged
else:
current = layout_by_id.get(entry["id"], {
"id": entry["id"],
"type": "ghost",
})
merged = deepcopy(current)
merged["id"] = entry["id"]
merged["type"] = "ghost"
merged["order"] = order
layout_by_id[entry["id"]] = merged
order += 10
room["layout_items"] = sorted(
layout_by_id.values(),
key=lambda item: (int(item.get("order", 9999) or 9999), str(item.get("id", ""))),
)
return config
def build_patch_payload(payload: dict[str, Any], keys: list[str]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key in keys:
if key in payload:
result[key] = payload[key]
return result
def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
for key, value in source.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
_deep_merge(target[key], value)
else:
target[key] = deepcopy(value)
def _ensure_room(config: dict[str, Any], room_id: str) -> dict[str, Any]:
rooms = config.setdefault("rooms", [])
if not isinstance(rooms, list):
rooms = []
config["rooms"] = rooms
for room in rooms:
if isinstance(room, dict) and str(room.get("id", "")) == room_id:
room.setdefault("visible", True)
room.setdefault("entity_ids", [])
room.setdefault("entity_overrides", {})
room.setdefault("layout_items", [])
return room
room = {
"id": room_id,
"visible": True,
"entity_ids": [],
"entity_overrides": {},
"layout_items": [],
}
rooms.append(room)
return room
def _sort_room_layout_items(room: dict[str, Any]) -> None:
items = room.get("layout_items", [])
if not isinstance(items, list):
room["layout_items"] = []
return
room["layout_items"] = sorted(
[item for item in items if isinstance(item, dict)],
key=lambda item: (int(item.get("order", 9999) or 9999), str(item.get("id", ""))),
)

View File

@ -0,0 +1,10 @@
{
"domain": "wall_panel",
"name": "Wall Panel",
"version": "1.0.0",
"documentation": "https://example.invalid/wall-panel",
"codeowners": [],
"config_flow": true,
"iot_class": "local_polling",
"requirements": []
}

View File

@ -0,0 +1,23 @@
{
"config": {
"step": {
"user": {
"title": "Wall Panel",
"description": "Connect Wall Panel to Home Assistant.",
"data": {
"name": "Name",
"panel_url": "PHP panel URL",
"sidebar_title": "Sidebar title",
"sidebar_icon": "Sidebar icon",
"frontend_url_path": "Frontend URL path",
"require_admin": "Require admin",
"sync_token": "Sync token",
"config": "Canonical config JSON"
}
}
},
"error": {
"invalid_json": "Invalid JSON"
}
}
}

View File

@ -0,0 +1,23 @@
{
"config": {
"step": {
"user": {
"title": "Wall Panel",
"description": "Connect Wall Panel to Home Assistant.",
"data": {
"name": "Name",
"panel_url": "PHP panel URL",
"sidebar_title": "Sidebar title",
"sidebar_icon": "Sidebar icon",
"frontend_url_path": "Frontend URL path",
"require_admin": "Require admin",
"sync_token": "Sync token",
"config": "Canonical config JSON"
}
}
},
"error": {
"invalid_json": "Invalid JSON"
}
}
}

View File

@ -0,0 +1,160 @@
"""HTTP views for Wall Panel."""
from __future__ import annotations
import secrets
from typing import Any
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from .const import CONF_CONFIG, CONF_SYNC_TOKEN, DOMAIN
from .helpers import (
build_patch_payload,
config_to_json,
create_room_layout_item,
current_entry_config,
delete_room_layout_item,
normalize_config,
reorder_room_grid,
save_settings,
update_entity_override,
update_room_layout_item,
update_room_override,
)
def _entry_from_hass(hass: HomeAssistant, entry_id: str):
return hass.data.get(DOMAIN, {}).get(entry_id, {}).get("entry")
def _request_token(request: web.Request) -> str:
header = request.headers.get("X-Wall-Panel-Token", "").strip()
if header:
return header
auth = request.headers.get("Authorization", "").strip()
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return request.query.get("token", "").strip()
def _authorized(entry, request: web.Request) -> bool:
expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
if not expected:
return False
return secrets.compare_digest(_request_token(request), expected)
def _response(data: Any, status: int = 200) -> web.Response:
if isinstance(data, str):
return web.Response(text=data, status=status, content_type="text/plain; charset=utf-8")
return web.json_response(data, status=status)
def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None:
options = dict(entry.options)
options[CONF_CONFIG] = config
hass.config_entries.async_update_entry(entry, options=options)
hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["config"] = config
class WallPanelConfigView(HomeAssistantView):
"""Serve and update the canonical Wall Panel config."""
url = "/api/wall_panel/config/{entry_id}"
name = "api:wall_panel:config"
requires_auth = False
async def async_get(self, request: web.Request, entry_id: str) -> web.Response:
hass = request.app["hass"]
entry = _entry_from_hass(hass, entry_id)
if entry is None:
return _response({"ok": False, "error": "Unknown entry"}, 404)
if not _authorized(entry, request):
return _response({"ok": False, "error": "Unauthorized"}, 401)
config = current_entry_config(entry)
return _response(config)
async def async_post(self, request: web.Request, entry_id: str) -> web.Response:
hass = request.app["hass"]
entry = _entry_from_hass(hass, entry_id)
if entry is None:
return _response({"ok": False, "error": "Unknown entry"}, 404)
if not _authorized(entry, request):
return _response({"ok": False, "error": "Unauthorized"}, 401)
payload = await request.json()
if not isinstance(payload, dict):
return _response({"ok": False, "error": "Invalid payload"}, 400)
config = current_entry_config(entry)
action = str(payload.get("action", "") or "").strip().lower()
action_payload = payload.get("payload")
if not isinstance(action_payload, dict):
action_payload = payload
if action == "save-settings":
config = save_settings(config, action_payload)
elif action == "save-entity-override":
room_id = str(action_payload.get("room_id", "") or "").strip()
entity_id = str(action_payload.get("entity_id", "") or "").strip()
if not room_id or not entity_id:
return _response({"ok": False, "error": "room_id and entity_id are required"}, 400)
patch = build_patch_payload(action_payload, ["visible", "order", "card_type", "title", "icon"])
config = update_entity_override(config, room_id, entity_id, patch)
elif action == "save-space-override":
room_id = str(action_payload.get("room_id", "") or "").strip()
if not room_id:
return _response({"ok": False, "error": "room_id is required"}, 400)
patch = build_patch_payload(action_payload, ["visible", "order", "name", "icon", "temperature_sensor_entity_id"])
config = update_room_override(config, room_id, patch)
elif action == "create-room-layout-item":
room_id = str(action_payload.get("room_id", "") or "").strip()
if not room_id:
return _response({"ok": False, "error": "room_id is required"}, 400)
layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip()
if not layout_item_id:
layout_item_id = f"slot_{secrets.token_hex(12)}"
order = action_payload.get("order")
config = create_room_layout_item(config, room_id, layout_item_id, int(order) if order is not None else None)
_save_entry_config(hass, entry, config)
return _response({
"ok": True,
"layout_item_id": layout_item_id,
"config": config,
})
elif action == "save-room-layout-item":
room_id = str(action_payload.get("room_id", "") or "").strip()
layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip()
if not room_id or not layout_item_id:
return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400)
patch = build_patch_payload(action_payload, ["order"])
config = update_room_layout_item(config, room_id, layout_item_id, patch)
elif action == "delete-room-layout-item":
room_id = str(action_payload.get("room_id", "") or "").strip()
layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip()
if not room_id or not layout_item_id:
return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400)
config = delete_room_layout_item(config, room_id, layout_item_id)
elif action == "reorder-room-grid":
room_id = str(action_payload.get("room_id", "") or "").strip()
entries = action_payload.get("entries", [])
if not room_id or not isinstance(entries, list):
return _response({"ok": False, "error": "room_id and entries are required"}, 400)
config = reorder_room_grid(config, room_id, entries)
elif isinstance(payload.get("config"), dict):
config = normalize_config(payload["config"])
else:
return _response({"ok": False, "error": "Unknown action"}, 404)
_save_entry_config(hass, entry, config)
return _response({
"ok": True,
"config": config,
})

View File

@ -3,9 +3,31 @@ declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php'; require_once __DIR__ . '/lib/bootstrap.php';
function app_is_embed_request(): bool
{
$value = strtolower(trim((string)($_GET['embed'] ?? '')));
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
$mode = strtolower(trim((string)($_GET['mode'] ?? '')));
return in_array($mode, ['embed', 'panel', 'lovelace', 'ha'], true);
}
$config = app_load_config(); $config = app_load_config();
$client = new HomeAssistantClient($config); $client = new HomeAssistantClient($config);
$bootstrap = app_build_snapshot($config, $client, 'main'); $bootstrap = app_build_snapshot($config, $client, 'main');
$embedMode = app_is_embed_request();
$runtimeMode = trim((string)getenv('WALL_PANEL_RUNTIME_MODE'));
if ($runtimeMode === '') {
$runtimeMode = $embedMode ? 'ha' : 'standalone';
}
$bootstrap['ui'] = [
'embed' => $embedMode,
'mode' => $runtimeMode,
'shell' => $embedMode ? 'embed' : 'standalone',
'config_source' => app_remote_sync_enabled($config) ? 'ha' : 'file',
];
$appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?> ?>
<!doctype html> <!doctype html>
@ -23,11 +45,11 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'),
<script> <script>
window.APP_BOOTSTRAP = <?= json_encode($bootstrap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>; window.APP_BOOTSTRAP = <?= json_encode($bootstrap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
</script> </script>
<link rel="stylesheet" href="assets/app.css?v=0.26"> <link rel="stylesheet" href="assets/app.css?v=0.28">
<script src="assets/app.js?v=0.26" defer></script> <script src="assets/app.js?v=0.28" defer></script>
</head> </head>
<body> <body class="<?= $embedMode ? 'is-embedded' : '' ?>" data-ui-mode="<?= htmlspecialchars($runtimeMode, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
<div class="app-shell"> <div class="app-shell<?= $embedMode ? ' app-shell--embed' : '' ?>">
<aside class="sidebar"> <aside class="sidebar">
<section class="clock-panel"> <section class="clock-panel">
<div class="clock-panel__time" id="clock-time">--:--</div> <div class="clock-panel__time" id="clock-time">--:--</div>

View File

@ -5,5 +5,5 @@ define('APP_ROOT', dirname(__DIR__));
require_once APP_ROOT . '/lib/config.php'; require_once APP_ROOT . '/lib/config.php';
require_once APP_ROOT . '/lib/ha_client.php'; require_once APP_ROOT . '/lib/ha_client.php';
require_once APP_ROOT . '/lib/ha_sync.php';
require_once APP_ROOT . '/lib/dashboard.php'; require_once APP_ROOT . '/lib/dashboard.php';

View File

@ -16,6 +16,11 @@ function app_default_config(): array
'base_url' => '', 'base_url' => '',
'token' => '', 'token' => '',
'verify_ssl' => true, 'verify_ssl' => true,
'sync_url' => '',
'sync_token' => '',
'sync_timeout' => 10,
'sync_verify_ssl' => true,
'sync_cache_seconds' => 30,
'weather_entity_id' => '', 'weather_entity_id' => '',
'auto_label' => 'auto', 'auto_label' => 'auto',
'auto_entity_ids' => [], 'auto_entity_ids' => [],
@ -38,12 +43,19 @@ function app_default_config(): array
function app_config_path(): string function app_config_path(): string
{ {
$override = trim((string)getenv('WALL_PANEL_CONFIG_PATH'));
if ($override !== '') {
return $override;
}
return APP_ROOT . '/config/config.json'; return APP_ROOT . '/config/config.json';
} }
function app_storage_path(string $file): string function app_storage_path(string $file): string
{ {
return APP_ROOT . '/storage/' . ltrim($file, '/'); $override = trim((string)getenv('WALL_PANEL_STORAGE_DIR'));
$base = $override !== '' ? rtrim($override, '/') : APP_ROOT . '/storage';
return $base . '/' . ltrim($file, '/');
} }
function app_ensure_directory(string $path): void function app_ensure_directory(string $path): void
@ -115,3 +127,190 @@ function app_save_json_file(string $path, array $data): void
file_put_contents($path, $json . PHP_EOL, LOCK_EX); file_put_contents($path, $json . PHP_EOL, LOCK_EX);
} }
function app_remote_sync_enabled(array $config): bool
{
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
return $syncUrl !== '' && $syncToken !== '';
}
function app_remote_sync_cache_path(): string
{
return app_storage_path('ha_sync_cache.json');
}
function app_remote_sync_cache_ttl(array $config): int
{
return max(5, (int)($config['home_assistant']['sync_cache_seconds'] ?? 30));
}
function app_http_json_request(
string $method,
string $url,
array $headers = [],
?array $body = null,
int $timeout = 10,
bool $verifySsl = true,
bool $throwOnFailure = false
): array|null {
$ch = curl_init($url);
if ($ch === false) {
if ($throwOnFailure) {
throw new RuntimeException('Failed to initialize HTTP client');
}
return null;
}
$requestHeaders = array_merge([
'Accept: application/json',
], $headers);
if ($body !== null) {
$requestHeaders[] = 'Content-Type: application/json';
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $requestHeaders,
CURLOPT_TIMEOUT => max(1, $timeout),
CURLOPT_CONNECTTIMEOUT => max(1, min(7, $timeout)),
CURLOPT_SSL_VERIFYPEER => $verifySsl,
CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
if ($throwOnFailure) {
throw new RuntimeException('HTTP request failed: ' . $error);
}
return null;
}
if ($status >= 400) {
if ($throwOnFailure) {
throw new RuntimeException('HTTP request returned status ' . $status);
}
return null;
}
$decoded = json_decode((string)$raw, true);
return is_array($decoded) ? $decoded : [];
}
function app_merge_synced_config(array $baseConfig, array $syncedConfig): array
{
$syncFields = [
'sync_url' => (string)($baseConfig['home_assistant']['sync_url'] ?? ''),
'sync_token' => (string)($baseConfig['home_assistant']['sync_token'] ?? ''),
'sync_timeout' => (int)($baseConfig['home_assistant']['sync_timeout'] ?? 10),
'sync_verify_ssl' => (bool)($baseConfig['home_assistant']['sync_verify_ssl'] ?? true),
'sync_cache_seconds' => (int)($baseConfig['home_assistant']['sync_cache_seconds'] ?? 30),
];
$merged = array_replace_recursive($baseConfig, $syncedConfig);
foreach ($syncFields as $key => $value) {
$merged['home_assistant'][$key] = $value;
}
return $merged;
}
function app_load_remote_config(array $config, bool $refresh = false): array|null
{
if (!app_remote_sync_enabled($config)) {
return null;
}
$cachePath = app_remote_sync_cache_path();
$cache = app_load_json_file($cachePath, []);
$cachedConfig = is_array($cache['config'] ?? null) ? $cache['config'] : null;
$cachedAt = (int)($cache['fetched_at'] ?? 0);
$ttl = app_remote_sync_cache_ttl($config);
if (!$refresh && $cachedConfig !== null && $cachedAt > 0 && (time() - $cachedAt) < $ttl) {
return $cachedConfig;
}
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
$timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10));
$verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true);
$response = app_http_json_request('GET', $syncUrl, [
'X-Wall-Panel-Token: ' . $syncToken,
], null, $timeout, $verifySsl, false);
$remoteConfig = null;
if (is_array($response)) {
if (is_array($response['config'] ?? null)) {
$remoteConfig = $response['config'];
} elseif (!isset($response['ok']) || (bool)$response['ok'] !== false) {
$remoteConfig = $response;
}
}
if (is_array($remoteConfig)) {
app_save_json_file($cachePath, [
'fetched_at' => time(),
'config' => $remoteConfig,
]);
return $remoteConfig;
}
if ($cachedConfig !== null) {
return $cachedConfig;
}
return null;
}
function app_clear_remote_config_cache(): void
{
$path = app_remote_sync_cache_path();
if (file_exists($path)) {
@unlink($path);
}
}
function app_sync_remote_action(array $config, string $action, array $payload): array|null
{
if (!app_remote_sync_enabled($config)) {
return null;
}
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
$timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10));
$verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true);
$response = app_http_json_request('POST', $syncUrl, [
'X-Wall-Panel-Token: ' . $syncToken,
], [
'action' => $action,
'payload' => $payload,
], $timeout, $verifySsl, false);
if (!is_array($response)) {
return null;
}
if (is_array($response['config'] ?? null)) {
app_save_json_file(app_remote_sync_cache_path(), [
'fetched_at' => time(),
'config' => $response['config'],
]);
}
return $response;
}

4
lib/ha_sync.php Executable file
View File

@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
// Sync helpers are defined in lib/config.php to keep the PHP runtime self-contained.

19
run.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/sh
set -eu
DOCROOT="${WALL_PANEL_DOCROOT:-/app}"
PORT="${WALL_PANEL_PORT:-8099}"
CONFIG_PATH="${WALL_PANEL_CONFIG_PATH:-/config/config.json}"
STORAGE_DIR="${WALL_PANEL_STORAGE_DIR:-/config/storage}"
mkdir -p "$(dirname "$CONFIG_PATH")" "$STORAGE_DIR"
if [ ! -f "$CONFIG_PATH" ]; then
cp "${DOCROOT}/config/config.json" "$CONFIG_PATH"
fi
export WALL_PANEL_CONFIG_PATH="$CONFIG_PATH"
export WALL_PANEL_STORAGE_DIR="$STORAGE_DIR"
export WALL_PANEL_RUNTIME_MODE="addon"
exec php -S "0.0.0.0:${PORT}" -t "$DOCROOT"

View File

@ -1,6 +1,6 @@
{ {
"active": false, "active": false,
"sensor_entity_id": "binary_sensor.barn_all_occupancy", "sensor_entity_id": "binary_sensor.barn_all_occupancy",
"opened_at": 1774271086, "opened_at": 1774433119,
"expires_at": null "expires_at": null
} }