-
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
|
# 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
24
api.php
@ -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']]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
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",
|
"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",
|
||||||
|
|||||||
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';
|
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>
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
201
lib/config.php
201
lib/config.php
@ -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
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,
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user