-
This commit is contained in:
parent
ab277bf13c
commit
62514f7f52
12
.dockerignore
Executable file
12
.dockerignore
Executable 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
|
||||
BIN
@eaDir/wallpanell.code-workspace@SynoEAStream
Executable file
BIN
@eaDir/wallpanell.code-workspace@SynoEAStream
Executable file
Binary file not shown.
19
Dockerfile
Executable file
19
Dockerfile
Executable 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"]
|
||||
42
README.md
42
README.md
@ -1,6 +1,7 @@
|
||||
# Wall Panel
|
||||
|
||||
Таблет-ориентированная панель для 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 с тестовыми карточками.
|
||||
|
||||
## 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 камеры
|
||||
|
||||
Для браузера нужен не прямой `rtsp://`, а bridge, который отдаёт `HLS` или `WebRTC`.
|
||||
|
||||
24
api.php
24
api.php
@ -27,6 +27,17 @@ function api_input(): array
|
||||
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 {
|
||||
$config = app_load_config();
|
||||
$client = new HomeAssistantClient($config);
|
||||
@ -109,6 +120,8 @@ try {
|
||||
];
|
||||
|
||||
$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']]]);
|
||||
}
|
||||
|
||||
@ -132,6 +145,8 @@ try {
|
||||
}
|
||||
|
||||
$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']]]);
|
||||
}
|
||||
|
||||
@ -152,6 +167,8 @@ try {
|
||||
$config = app_update_room_layout_item($config, $roomId, $layoutItemId, [
|
||||
'order' => $order,
|
||||
]);
|
||||
$config = api_mirror_config_change($config, $action, $payload);
|
||||
app_save_config($config);
|
||||
|
||||
api_json([
|
||||
'ok' => true,
|
||||
@ -174,6 +191,8 @@ try {
|
||||
];
|
||||
|
||||
$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']]]);
|
||||
}
|
||||
|
||||
@ -187,6 +206,8 @@ try {
|
||||
}
|
||||
|
||||
$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']]]);
|
||||
}
|
||||
|
||||
@ -200,6 +221,8 @@ try {
|
||||
}
|
||||
|
||||
$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']]]);
|
||||
}
|
||||
|
||||
@ -211,6 +234,7 @@ try {
|
||||
if (array_key_exists('title', $payload) && trim((string)$payload['title']) !== '') {
|
||||
$config['app']['title'] = trim((string)$payload['title']);
|
||||
}
|
||||
$config = api_mirror_config_change($config, $action, $payload);
|
||||
app_save_config($config);
|
||||
api_json(['ok' => true, 'settings' => $config['app']]);
|
||||
}
|
||||
|
||||
@ -45,6 +45,10 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.is-embedded {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
@ -55,9 +59,16 @@ textarea {
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell--embed {
|
||||
grid-template-columns: clamp(250px, 22vw, 320px) minmax(0, 1fr);
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
padding: 24px 20px 20px 20px;
|
||||
@ -69,6 +80,11 @@ textarea {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell--embed .sidebar {
|
||||
padding: 18px 16px 16px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.sidebar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@ -80,10 +96,18 @@ textarea {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.app-shell--embed .sidebar::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clock-panel {
|
||||
padding: 12px 8px 12px 8px;
|
||||
}
|
||||
|
||||
.app-shell--embed .clock-panel {
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.clock-panel__time {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(56px, 6.4vw, 82px);
|
||||
@ -100,6 +124,15 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-shell--embed .clock-panel__time {
|
||||
font-size: clamp(34px, 3.8vw, 56px);
|
||||
}
|
||||
|
||||
.app-shell--embed .clock-panel__date {
|
||||
font-size: 14px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.rooms-panel {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
@ -108,6 +141,10 @@ textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-shell--embed .rooms-panel {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.content-header,
|
||||
.camera-modal__header,
|
||||
@ -215,6 +252,13 @@ textarea {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.app-shell--embed .room-list {
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.room-list__group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@ -239,6 +283,24 @@ textarea {
|
||||
transition: border-color 180ms ease, transform 180ms ease, background 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.app-shell--embed .room-item {
|
||||
min-height: 94px;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.app-shell--embed .room-item__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.app-shell--embed .room-item__icon i {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.app-shell--embed .room-item__name {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.room-item.has-temp {
|
||||
padding-right: 0px;
|
||||
}
|
||||
@ -443,12 +505,20 @@ textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-shell--embed .content {
|
||||
padding: 22px 16px 20px;
|
||||
}
|
||||
|
||||
.content-top {
|
||||
display: none;
|
||||
margin-bottom: 16px;
|
||||
margin-top: -30px;
|
||||
}
|
||||
|
||||
.app-shell--embed .content-top {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-top.is-main {
|
||||
display: block;
|
||||
}
|
||||
@ -459,6 +529,11 @@ textarea {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.app-shell--embed .content-header {
|
||||
min-height: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.content-header.is-main .content-header__eyebrow,
|
||||
.content-header.is-main .content-header__meta {
|
||||
display: none;
|
||||
@ -483,6 +558,10 @@ textarea {
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.app-shell--embed .content-header__title {
|
||||
font-size: clamp(24px, 2.5vw, 40px);
|
||||
}
|
||||
|
||||
.content-header__meta {
|
||||
margin-top: 10px;
|
||||
color: var(--text-muted);
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
const MOBILE_BREAKPOINT = 920;
|
||||
const state = {
|
||||
snapshot: bootstrap,
|
||||
embedMode: Boolean(bootstrap?.ui?.embed),
|
||||
selectedRoomId: 'main',
|
||||
isMobileViewport: false,
|
||||
mobileView: 'spaces',
|
||||
@ -40,6 +41,7 @@
|
||||
haReconnectDelay: 1000,
|
||||
haSubscribeId: 1,
|
||||
roomSelectionToken: 0,
|
||||
snapshotPollTimer: null,
|
||||
};
|
||||
|
||||
const els = {};
|
||||
@ -68,6 +70,18 @@
|
||||
return isMobileViewport() && state.mobileView === 'room';
|
||||
}
|
||||
|
||||
function detectEmbeddedContext() {
|
||||
if (Boolean(bootstrap?.ui?.embed)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function setMobileView(nextView) {
|
||||
if (!isMobileViewport()) {
|
||||
state.mobileView = 'room';
|
||||
@ -897,9 +911,12 @@
|
||||
if (!els.appShell) return;
|
||||
|
||||
const mobile = isMobileViewport();
|
||||
const embedded = Boolean(state.embedMode);
|
||||
document.body.classList.toggle('is-mobile-ui', mobile);
|
||||
document.body.classList.toggle('is-embedded', embedded);
|
||||
els.appShell.classList.toggle('is-mobile', mobile);
|
||||
els.appShell.classList.toggle('is-desktop', !mobile);
|
||||
els.appShell.classList.toggle('app-shell--embed', embedded);
|
||||
els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room');
|
||||
els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room');
|
||||
|
||||
@ -4290,6 +4307,7 @@
|
||||
clearTimeout(state.haReconnectTimer);
|
||||
state.haReconnectTimer = null;
|
||||
state.haSocketState = 'disconnected';
|
||||
stopSnapshotPolling();
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
@ -4302,6 +4320,29 @@
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function stopSnapshotPolling() {
|
||||
if (state.snapshotPollTimer) {
|
||||
clearInterval(state.snapshotPollTimer);
|
||||
state.snapshotPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startSnapshotPolling() {
|
||||
const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000));
|
||||
if (state.snapshotPollTimer) {
|
||||
clearInterval(state.snapshotPollTimer);
|
||||
}
|
||||
|
||||
state.snapshotPollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
await loadSnapshot(state.selectedRoomId || 'main');
|
||||
render();
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
function handleHaMessage(message) {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return;
|
||||
@ -4433,9 +4474,18 @@
|
||||
if (!wsUrl || !token) {
|
||||
state.haSocketState = 'unavailable';
|
||||
setStatus('Online', 'online');
|
||||
startSnapshotPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.location.protocol === 'https:' && wsUrl.startsWith('ws://')) {
|
||||
state.haSocketState = 'unavailable';
|
||||
setStatus('Polling mode', 'online');
|
||||
startSnapshotPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
stopSnapshotPolling();
|
||||
stopRealtime();
|
||||
state.haSocketState = 'connecting';
|
||||
setStatus('Connecting WS...', 'loading');
|
||||
@ -4455,6 +4505,7 @@
|
||||
};
|
||||
socket.onerror = () => {
|
||||
setStatus('WS error', 'error');
|
||||
startSnapshotPolling();
|
||||
};
|
||||
socket.onclose = () => {
|
||||
state.haSocket = null;
|
||||
@ -4470,6 +4521,8 @@
|
||||
|
||||
async function start() {
|
||||
initRefs();
|
||||
state.embedMode = detectEmbeddedContext();
|
||||
syncLayoutState();
|
||||
syncViewportState();
|
||||
updateClock();
|
||||
clearInterval(state.clockTimer);
|
||||
@ -4491,6 +4544,9 @@
|
||||
state.snapshot = initial;
|
||||
render();
|
||||
connectRealtime();
|
||||
if (!state.snapshotPollTimer) {
|
||||
startSnapshotPolling();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
|
||||
24
config.yaml
Executable file
24
config.yaml
Executable 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: {}
|
||||
32
config/addon-default-config.json
Executable file
32
config/addon-default-config.json
Executable 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": []
|
||||
}
|
||||
@ -73,6 +73,11 @@
|
||||
"base_url": "http://10.0.6.5:8123",
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiYWQ2ZGE0OGYzOWU0MDFjOTA2OTZhZjVkYzA2M2U2MSIsImlhdCI6MTc3MzY0NjMwNywiZXhwIjoyMDg5MDA2MzA3fQ.u7kWa3ONMEo5UMi3suIBTocr_6FqOYphfYb7NJGZ3wI",
|
||||
"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": "",
|
||||
"auto_label": "auto",
|
||||
"auto_entity_ids": []
|
||||
@ -1336,6 +1341,141 @@
|
||||
},
|
||||
"sensor.aqara_dimmer_switch_active_current": {
|
||||
"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",
|
||||
|
||||
48
custom_components/wall_panel/__init__.py
Executable file
48
custom_components/wall_panel/__init__.py
Executable 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)
|
||||
134
custom_components/wall_panel/config_flow.py
Executable file
134
custom_components/wall_panel/config_flow.py
Executable 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)
|
||||
17
custom_components/wall_panel/const.py
Executable file
17
custom_components/wall_panel/const.py
Executable 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"
|
||||
68
custom_components/wall_panel/frontend.py
Executable file
68
custom_components/wall_panel/frontend.py
Executable 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
|
||||
133
custom_components/wall_panel/frontend/panel.js
Executable file
133
custom_components/wall_panel/frontend/panel.js
Executable 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);
|
||||
}
|
||||
283
custom_components/wall_panel/helpers.py
Executable file
283
custom_components/wall_panel/helpers.py
Executable 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", ""))),
|
||||
)
|
||||
10
custom_components/wall_panel/manifest.json
Executable file
10
custom_components/wall_panel/manifest.json
Executable 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": []
|
||||
}
|
||||
23
custom_components/wall_panel/strings.json
Executable file
23
custom_components/wall_panel/strings.json
Executable 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
custom_components/wall_panel/translations/en.json
Executable file
23
custom_components/wall_panel/translations/en.json
Executable 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
160
custom_components/wall_panel/views.py
Executable file
160
custom_components/wall_panel/views.py
Executable 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,
|
||||
})
|
||||
30
index.php
30
index.php
@ -3,9 +3,31 @@ declare(strict_types=1);
|
||||
|
||||
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();
|
||||
$client = new HomeAssistantClient($config);
|
||||
$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');
|
||||
?>
|
||||
<!doctype html>
|
||||
@ -23,11 +45,11 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'),
|
||||
<script>
|
||||
window.APP_BOOTSTRAP = <?= json_encode($bootstrap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/app.css?v=0.26">
|
||||
<script src="assets/app.js?v=0.26" defer></script>
|
||||
<link rel="stylesheet" href="assets/app.css?v=0.28">
|
||||
<script src="assets/app.js?v=0.28" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<body class="<?= $embedMode ? 'is-embedded' : '' ?>" data-ui-mode="<?= htmlspecialchars($runtimeMode, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
|
||||
<div class="app-shell<?= $embedMode ? ' app-shell--embed' : '' ?>">
|
||||
<aside class="sidebar">
|
||||
<section class="clock-panel">
|
||||
<div class="clock-panel__time" id="clock-time">--:--</div>
|
||||
|
||||
@ -5,5 +5,5 @@ define('APP_ROOT', dirname(__DIR__));
|
||||
|
||||
require_once APP_ROOT . '/lib/config.php';
|
||||
require_once APP_ROOT . '/lib/ha_client.php';
|
||||
require_once APP_ROOT . '/lib/ha_sync.php';
|
||||
require_once APP_ROOT . '/lib/dashboard.php';
|
||||
|
||||
|
||||
201
lib/config.php
201
lib/config.php
@ -16,6 +16,11 @@ function app_default_config(): array
|
||||
'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' => [],
|
||||
@ -38,12 +43,19 @@ function app_default_config(): array
|
||||
|
||||
function app_config_path(): string
|
||||
{
|
||||
$override = trim((string)getenv('WALL_PANEL_CONFIG_PATH'));
|
||||
if ($override !== '') {
|
||||
return $override;
|
||||
}
|
||||
|
||||
return APP_ROOT . '/config/config.json';
|
||||
}
|
||||
|
||||
function app_storage_path(string $file): string
|
||||
{
|
||||
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
|
||||
@ -115,3 +127,190 @@ function app_save_json_file(string $path, array $data): void
|
||||
|
||||
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
4
lib/ha_sync.php
Executable 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
19
run.sh
Executable 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"
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"active": false,
|
||||
"sensor_entity_id": "binary_sensor.barn_all_occupancy",
|
||||
"opened_at": 1774271086,
|
||||
"opened_at": 1774433119,
|
||||
"expires_at": null
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user