This commit is contained in:
Striker72rus 2026-03-25 13:27:13 +03:00
parent 62514f7f52
commit a765905d6c
31 changed files with 10773 additions and 7 deletions

View File

@ -38,15 +38,16 @@ php -S 0.0.0.0:8080
- хранит конфиг в add-on volume;
- не зависит от `custom_components`.
Файлы add-on лежат в корне репозитория:
Файлы add-on лежат в папке:
- [`config.yaml`](/Volumes/web/wallpanell/config.yaml)
- [`Dockerfile`](/Volumes/web/wallpanell/Dockerfile)
- [`run.sh`](/Volumes/web/wallpanell/run.sh)
- [`wall_panel/config.yaml`](/Volumes/web/wallpanell/wall_panel/config.yaml)
- [`wall_panel/Dockerfile`](/Volumes/web/wallpanell/wall_panel/Dockerfile)
- [`wall_panel/run.sh`](/Volumes/web/wallpanell/wall_panel/run.sh)
- [`repository.yaml`](/Volumes/web/wallpanell/repository.yaml)
### Как использовать
1. Поместите репозиторий в локальные add-ons Home Assistant или соберите add-on как свой локальный пакет.
1. Добавьте Git URL репозитория в Home Assistant как add-on repository.
2. Установите add-on.
3. Запустите его.
4. Откройте панель через ingress или через проброшенный порт `8099/tcp`.

3
repository.yaml Executable file
View File

@ -0,0 +1,3 @@
name: Wall Panel Add-ons
url: https://git.striker72rus.ru/PHP/wallpanell.git
maintainer: Striker72rus

View File

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

12
wall_panel/.dockerignore Executable file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

19
wall_panel/Dockerfile Executable file
View File

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

141
wall_panel/README.md Executable file
View File

@ -0,0 +1,141 @@
# Wall Panel
Таблет-ориентированная панель для Home Assistant на `PHP + HTML + JS`.
Основной HA-способ запуска теперь `Home Assistant Add-on` с ingress и отдельным портом.
## Запуск
```bash
php -S 0.0.0.0:8080
```
Откройте `http://localhost:8080`.
## Конфиг
Основной файл:
- [`config/config.json`](config/config.json)
В него кладутся:
- `home_assistant.base_url`
- `home_assistant.token`
- `camera.rtsp_url`
- `camera.stream_url`
- `camera.poster_url`
- `rooms`
Если `base_url` и `token` пустые, панель работает в demo mode с тестовыми карточками.
## Home Assistant Add-on
Для HA теперь рекомендован add-on-режим:
- запускает тот же PHP-интерфейс внутри Supervisor;
- открывается через ingress;
- доступен через отдельный порт;
- хранит конфиг в add-on volume;
- не зависит от `custom_components`.
Файлы add-on лежат в этой папке:
- [`config.yaml`](/Volumes/web/wallpanell/wall_panel/config.yaml)
- [`Dockerfile`](/Volumes/web/wallpanell/wall_panel/Dockerfile)
- [`run.sh`](/Volumes/web/wallpanell/wall_panel/run.sh)
### Как использовать
1. Добавьте Git URL репозитория в Home Assistant как add-on repository.
2. Установите add-on.
3. Запустите его.
4. Откройте панель через ingress или через проброшенный порт `8099/tcp`.
### Конфигурация
Add-on использует persistent config file:
- `/config/config.json` внутри контейнера
Этот файл является runtime source of truth для панели в HA-режиме и переживает рестарт add-on.
### Старый embed-режим
Отдельный PHP-доступ по-прежнему работает:
`http://YOUR_PANEL_HOST/index.php?embed=1`
Это полезно, если вы запускаете панель вне HA или хотите iframe-карточку вручную.
## Popup камеры
Для браузера нужен не прямой `rtsp://`, а bridge, который отдаёт `HLS` или `WebRTC`.
Popup открывается через endpoint:
```bash
POST /api.php?action=popup
{
"sensor_entity_id": "binary_sensor.doorbell_all_occupancy",
"state": "on"
}
```
Закрытие:
```bash
POST /api.php?action=popup
{
"sensor_entity_id": "binary_sensor.doorbell_all_occupancy",
"state": "off"
}
```
## Room overrides
Для комнаты можно сохранять overrides через:
```bash
POST /api.php?action=save-entity-override
{
"room_id": "living_room",
"entity_id": "light.living_room_main",
"visible": true,
"order": 10,
"card_type": "toggle",
"title": "Основной свет",
"icon": "mdi:ceiling-light"
}
```
## Empty room slots
Для desktop-раскладки можно создавать пустые слоты:
```bash
POST /api.php?action=create-room-layout-item
{
"room_id": "living_room"
}
```
Перемещение:
```bash
POST /api.php?action=save-room-layout-item
{
"room_id": "living_room",
"layout_item_id": "slot_xxx",
"order": 120
}
```
Удаление:
```bash
POST /api.php?action=delete-room-layout-item
{
"room_id": "living_room",
"layout_item_id": "slot_xxx"
}
```

254
wall_panel/api.php Executable file
View File

@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
function api_json(array $payload, int $status = 200): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function api_input(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return $_POST ?: [];
}
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
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);
$action = strtolower((string)($_GET['action'] ?? 'snapshot'));
if ($action === 'bootstrap') {
api_json(app_build_snapshot($config, $client, 'main'));
}
if ($action === 'snapshot') {
$spaceId = (string)($_GET['space_id'] ?? ($_GET['room_id'] ?? 'main'));
api_json(app_build_snapshot($config, $client, $spaceId));
}
if ($action === 'history') {
$entityId = trim((string)($_GET['entity_id'] ?? ''));
$hours = max(1, (int)($_GET['hours'] ?? 24));
if ($entityId === '') {
api_json(['ok' => false, 'error' => 'entity_id is required'], 400);
}
api_json([
'ok' => true,
'entity_id' => $entityId,
'hours' => $hours,
'history' => $client->fetchEntityHistory($entityId, $hours),
]);
}
if ($action === 'service') {
$payload = api_input();
$entityId = trim((string)($payload['entity_id'] ?? ''));
$command = trim((string)($payload['command'] ?? 'toggle'));
$value = $payload['value'] ?? null;
if ($entityId === '') {
api_json(['ok' => false, 'error' => 'entity_id is required'], 400);
}
[$domain, $service, $serviceData] = app_service_for_entity($entityId, $command);
if ($command === 'set_temperature' && $value !== null) {
$serviceData['temperature'] = $value;
}
if ($command === 'set_hvac_mode' && $value !== null) {
$serviceData['hvac_mode'] = $value;
}
if ($command === 'set_fan_mode' && $value !== null) {
$serviceData['fan_mode'] = $value;
}
if ($command === 'set_swing_mode' && $value !== null) {
$serviceData['swing_mode'] = $value;
}
if ($command === 'set_preset_mode' && $value !== null) {
$serviceData['preset_mode'] = $value;
}
if ($command === 'set_position' && $value !== null) {
$serviceData['position'] = $value;
}
$result = $client->callService($domain, $service, $serviceData);
api_json(['ok' => true, 'result' => $result]);
}
if ($action === 'save-entity-override') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$entityId = trim((string)($payload['entity_id'] ?? ''));
if ($roomId === '' || $entityId === '') {
api_json(['ok' => false, 'error' => 'room_id and entity_id are required'], 400);
}
$patch = [
'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null,
'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null,
'card_type' => array_key_exists('card_type', $payload) ? (string)$payload['card_type'] : null,
'title' => array_key_exists('title', $payload) ? (string)$payload['title'] : null,
'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null,
];
$config = app_update_entity_override($config, $roomId, $entityId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'save-space-override') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
if ($roomId === '') {
api_json(['ok' => false, 'error' => 'room_id is required'], 400);
}
$patch = [
'visible' => array_key_exists('visible', $payload) ? (bool)$payload['visible'] : null,
'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null,
'name' => array_key_exists('name', $payload) ? (string)$payload['name'] : null,
'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null,
];
if (array_key_exists('temperature_sensor_entity_id', $payload)) {
$patch['temperature_sensor_entity_id'] = (string)$payload['temperature_sensor_entity_id'];
}
$config = app_update_room_override($config, $roomId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'create-room-layout-item') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
if ($roomId === '') {
api_json(['ok' => false, 'error' => 'room_id is required'], 400);
}
$layoutItemId = trim((string)($payload['layout_item_id'] ?? ''));
if ($layoutItemId === '') {
$layoutItemId = 'slot_' . str_replace('.', '', uniqid('', true));
}
$order = array_key_exists('order', $payload) ? (int)$payload['order'] : null;
$config = app_update_room_layout_item($config, $roomId, $layoutItemId, [
'order' => $order,
]);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json([
'ok' => true,
'layout_item_id' => $layoutItemId,
'config' => ['rooms' => $config['rooms']],
]);
}
if ($action === 'save-room-layout-item') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$layoutItemId = trim((string)($payload['layout_item_id'] ?? ''));
if ($roomId === '' || $layoutItemId === '') {
api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400);
}
$patch = [
'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null,
];
$config = app_update_room_layout_item($config, $roomId, $layoutItemId, $patch);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'delete-room-layout-item') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$layoutItemId = trim((string)($payload['layout_item_id'] ?? ''));
if ($roomId === '' || $layoutItemId === '') {
api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400);
}
$config = app_delete_room_layout_item($config, $roomId, $layoutItemId);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'reorder-room-grid') {
$payload = api_input();
$roomId = trim((string)($payload['room_id'] ?? ''));
$entries = $payload['entries'] ?? [];
if ($roomId === '' || !is_array($entries)) {
api_json(['ok' => false, 'error' => 'room_id and entries are required'], 400);
}
$config = app_reorder_room_grid($config, $roomId, $entries);
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]);
}
if ($action === 'save-settings') {
$payload = api_input();
if (array_key_exists('edit_mode', $payload)) {
$config['app']['edit_mode'] = (bool)$payload['edit_mode'];
}
if (array_key_exists('title', $payload) && trim((string)$payload['title']) !== '') {
$config['app']['title'] = trim((string)$payload['title']);
}
$config = api_mirror_config_change($config, $action, $payload);
app_save_config($config);
api_json(['ok' => true, 'settings' => $config['app']]);
}
if ($action === 'popup') {
$payload = api_input();
$popup = app_handle_popup_event($config, $payload);
api_json(['ok' => true, 'popup' => $popup]);
}
api_json(['ok' => false, 'error' => 'Unknown action'], 404);
} catch (Throwable $e) {
api_json([
'ok' => false,
'error' => $e->getMessage(),
], 500);
}

Binary file not shown.

Binary file not shown.

2540
wall_panel/assets/app.css Executable file

File diff suppressed because it is too large Load Diff

4553
wall_panel/assets/app.js Executable file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
name: Wall Panel
description: Wall Panel as a Home Assistant add-on
description: Wall Panel PHP interface as a Home Assistant add-on
version: "1.0.0"
slug: wall_panel
url: https://git.striker72rus.ru/PHP/wallpanell.git
init: false
arch:
- aarch64
@ -20,5 +21,6 @@ ports_description:
8099/tcp: Wall Panel web UI
map:
- addon_config:rw
homeassistant_api: true
options: {}
schema: {}

View File

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

BIN
wall_panel/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

164
wall_panel/index.php Executable file
View File

@ -0,0 +1,164 @@
<?php
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>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0f14">
<title><?= $appTitle ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
<script src="https://home.striker72rus.ru/local/community/custom-brand-icons/custom-brand-icons.js" defer></script>
<script>
window.APP_BOOTSTRAP = <?= json_encode($bootstrap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
</script>
<link rel="stylesheet" href="assets/app.css?v=0.28">
<script src="assets/app.js?v=0.28" defer></script>
</head>
<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>
<div class="clock-panel__date" id="clock-date">---</div>
</section>
<section class="rooms-panel">
<div class="panel-header">
<div>
<div class="panel-header__label">Пространства</div>
<div class="panel-header__sub" id="rooms-count">0</div>
</div>
<button class="icon-button" id="edit-mode-toggle" type="button" aria-label="Edit mode">
<i class="mdi mdi-cog-outline"></i>
</button>
</div>
<div class="room-list" id="room-list"></div>
</section>
</aside>
<main class="content">
<div class="content-top" id="content-top">
<div class="main-print-strip-slot" id="main-print-strip-slot"></div>
</div>
<header class="content-header">
<button class="icon-button icon-button--ghost content-header__back" id="selected-room-back" type="button" aria-label="Back" hidden>
<i class="mdi mdi-arrow-left"></i>
</button>
<div>
<div class="content-header__eyebrow" id="selected-room-eyebrow"></div>
<h1 class="content-header__title" id="selected-room-title">Загрузка</h1>
<div class="content-header__meta" id="selected-room-meta"></div>
</div>
<div class="content-header__actions" id="selected-room-actions"></div>
</header>
<section class="dashboard-grid" id="dashboard-grid">
<div class="grid-surface" id="dashboard-surface">
<div class="loading-card">Загрузка панели...</div>
</div>
</section>
</main>
</div>
<div class="modal-backdrop" id="camera-modal" aria-hidden="true">
<div class="camera-modal" id="camera-modal-panel">
<button class="icon-button icon-button--ghost camera-modal__close" id="camera-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
<div class="camera-modal__body">
<div class="camera-stage" id="camera-stage">
<img class="camera-stage__poster" id="camera-poster" alt="Camera poster">
<div class="camera-stage__placeholder" id="camera-placeholder">
<div class="camera-stage__placeholder-icon"><i class="mdi mdi-cctv"></i></div>
<div class="camera-stage__placeholder-title">Поток загружается</div>
<div class="camera-stage__placeholder-subtitle">Показываем poster, пока не доступен video bridge</div>
</div>
</div>
<div class="camera-modal__footer">
<div class="camera-modal__countdown" id="camera-countdown"></div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" id="entity-modal" aria-hidden="true">
<div class="entity-modal" id="entity-modal-panel" role="dialog" aria-modal="true" aria-labelledby="entity-modal-title">
<div class="entity-modal__header">
<div>
<div class="entity-modal__eyebrow" id="entity-modal-eyebrow"></div>
<div class="entity-modal__title" id="entity-modal-title">Устройство</div>
</div>
<button class="icon-button icon-button--ghost" id="entity-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="entity-modal__body" id="entity-modal-body"></div>
</div>
</div>
<div class="modal-backdrop" id="temperature-sensor-modal" aria-hidden="true">
<div class="temperature-sensor-modal" id="temperature-sensor-modal-panel" role="dialog" aria-modal="true" aria-labelledby="temperature-sensor-modal-title">
<div class="temperature-sensor-modal__header">
<div>
<div class="temperature-sensor-modal__eyebrow">Настройка комнаты</div>
<div class="temperature-sensor-modal__title" id="temperature-sensor-modal-title">Выбрать датчик температуры</div>
</div>
<button class="icon-button icon-button--ghost" id="temperature-sensor-modal-close" type="button" aria-label="Close">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="temperature-sensor-modal__body" id="temperature-sensor-modal-body"></div>
</div>
</div>
<div class="modal-backdrop" id="confirm-modal" aria-hidden="true">
<div class="confirm-modal" id="confirm-modal-panel" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="confirm-modal__header">
<div>
<div class="confirm-modal__eyebrow">Подтверждение</div>
<div class="confirm-modal__title" id="confirm-modal-title">Хотите закрыть?</div>
</div>
</div>
<div class="confirm-modal__body" id="confirm-modal-message">Это действие отправит команду закрытия.</div>
<div class="confirm-modal__footer">
<button class="mushroom-button mushroom-button--small" id="confirm-modal-no" type="button">Нет</button>
<button class="mushroom-button mushroom-button--small is-on" id="confirm-modal-yes" type="button">Да</button>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
wall_panel/lib/bootstrap.php Executable file
View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
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';

316
wall_panel/lib/config.php Executable file
View File

@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
function app_default_config(): array
{
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' => 'rtsp://10.0.6.110:45321/feff99fa45f317e7',
'stream_url' => '',
'stream_mode' => 'hls',
'poster_url' => 'http://10.0.6.110:5000/api/doorbell/latest.jpg',
'popup_timeout_minutes' => 3,
'trigger_entities' => [
'binary_sensor.door_all_occupancy',
'binary_sensor.barn_all_occupancy',
'binary_sensor.doorbell_all_occupancy',
],
],
'rooms' => [],
];
}
function app_config_path(): string
{
$override = trim((string)getenv('WALL_PANEL_CONFIG_PATH'));
if ($override !== '') {
return $override;
}
return APP_ROOT . '/config/config.json';
}
function app_storage_path(string $file): string
{
$override = trim((string)getenv('WALL_PANEL_STORAGE_DIR'));
$base = $override !== '' ? rtrim($override, '/') : APP_ROOT . '/storage';
return $base . '/' . ltrim($file, '/');
}
function app_ensure_directory(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0775, true);
}
}
function app_load_config(): array
{
$path = app_config_path();
app_ensure_directory(dirname($path));
if (!file_exists($path)) {
$defaults = app_default_config();
file_put_contents($path, json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $defaults;
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
return app_default_config();
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Invalid JSON in config/config.json');
}
return array_replace_recursive(app_default_config(), $decoded);
}
function app_save_config(array $config): void
{
$path = app_config_path();
app_ensure_directory(dirname($path));
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode config');
}
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
}
function app_load_json_file(string $path, array $fallback = []): array
{
if (!file_exists($path)) {
return $fallback;
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
return $fallback;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : $fallback;
}
function app_save_json_file(string $path, array $data): void
{
app_ensure_directory(dirname($path));
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Failed to encode JSON');
}
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
}
function app_remote_sync_enabled(array $config): bool
{
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
return $syncUrl !== '' && $syncToken !== '';
}
function app_remote_sync_cache_path(): string
{
return app_storage_path('ha_sync_cache.json');
}
function app_remote_sync_cache_ttl(array $config): int
{
return max(5, (int)($config['home_assistant']['sync_cache_seconds'] ?? 30));
}
function app_http_json_request(
string $method,
string $url,
array $headers = [],
?array $body = null,
int $timeout = 10,
bool $verifySsl = true,
bool $throwOnFailure = false
): array|null {
$ch = curl_init($url);
if ($ch === false) {
if ($throwOnFailure) {
throw new RuntimeException('Failed to initialize HTTP client');
}
return null;
}
$requestHeaders = array_merge([
'Accept: application/json',
], $headers);
if ($body !== null) {
$requestHeaders[] = 'Content-Type: application/json';
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $requestHeaders,
CURLOPT_TIMEOUT => max(1, $timeout),
CURLOPT_CONNECTTIMEOUT => max(1, min(7, $timeout)),
CURLOPT_SSL_VERIFYPEER => $verifySsl,
CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
if ($throwOnFailure) {
throw new RuntimeException('HTTP request failed: ' . $error);
}
return null;
}
if ($status >= 400) {
if ($throwOnFailure) {
throw new RuntimeException('HTTP request returned status ' . $status);
}
return null;
}
$decoded = json_decode((string)$raw, true);
return is_array($decoded) ? $decoded : [];
}
function app_merge_synced_config(array $baseConfig, array $syncedConfig): array
{
$syncFields = [
'sync_url' => (string)($baseConfig['home_assistant']['sync_url'] ?? ''),
'sync_token' => (string)($baseConfig['home_assistant']['sync_token'] ?? ''),
'sync_timeout' => (int)($baseConfig['home_assistant']['sync_timeout'] ?? 10),
'sync_verify_ssl' => (bool)($baseConfig['home_assistant']['sync_verify_ssl'] ?? true),
'sync_cache_seconds' => (int)($baseConfig['home_assistant']['sync_cache_seconds'] ?? 30),
];
$merged = array_replace_recursive($baseConfig, $syncedConfig);
foreach ($syncFields as $key => $value) {
$merged['home_assistant'][$key] = $value;
}
return $merged;
}
function app_load_remote_config(array $config, bool $refresh = false): array|null
{
if (!app_remote_sync_enabled($config)) {
return null;
}
$cachePath = app_remote_sync_cache_path();
$cache = app_load_json_file($cachePath, []);
$cachedConfig = is_array($cache['config'] ?? null) ? $cache['config'] : null;
$cachedAt = (int)($cache['fetched_at'] ?? 0);
$ttl = app_remote_sync_cache_ttl($config);
if (!$refresh && $cachedConfig !== null && $cachedAt > 0 && (time() - $cachedAt) < $ttl) {
return $cachedConfig;
}
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
$timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10));
$verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true);
$response = app_http_json_request('GET', $syncUrl, [
'X-Wall-Panel-Token: ' . $syncToken,
], null, $timeout, $verifySsl, false);
$remoteConfig = null;
if (is_array($response)) {
if (is_array($response['config'] ?? null)) {
$remoteConfig = $response['config'];
} elseif (!isset($response['ok']) || (bool)$response['ok'] !== false) {
$remoteConfig = $response;
}
}
if (is_array($remoteConfig)) {
app_save_json_file($cachePath, [
'fetched_at' => time(),
'config' => $remoteConfig,
]);
return $remoteConfig;
}
if ($cachedConfig !== null) {
return $cachedConfig;
}
return null;
}
function app_clear_remote_config_cache(): void
{
$path = app_remote_sync_cache_path();
if (file_exists($path)) {
@unlink($path);
}
}
function app_sync_remote_action(array $config, string $action, array $payload): array|null
{
if (!app_remote_sync_enabled($config)) {
return null;
}
$syncUrl = trim((string)($config['home_assistant']['sync_url'] ?? ''));
$syncToken = trim((string)($config['home_assistant']['sync_token'] ?? ''));
$timeout = max(1, (int)($config['home_assistant']['sync_timeout'] ?? 10));
$verifySsl = (bool)($config['home_assistant']['sync_verify_ssl'] ?? true);
$response = app_http_json_request('POST', $syncUrl, [
'X-Wall-Panel-Token: ' . $syncToken,
], [
'action' => $action,
'payload' => $payload,
], $timeout, $verifySsl, false);
if (!is_array($response)) {
return null;
}
if (is_array($response['config'] ?? null)) {
app_save_json_file(app_remote_sync_cache_path(), [
'fetched_at' => time(),
'config' => $response['config'],
]);
}
return $response;
}

2039
wall_panel/lib/dashboard.php Executable file

File diff suppressed because it is too large Load Diff

658
wall_panel/lib/ha_client.php Executable file
View File

@ -0,0 +1,658 @@
<?php
declare(strict_types=1);
final class HomeAssistantClient
{
private array $config;
private bool $demoMode;
public function __construct(array $config)
{
$this->config = $config;
$baseUrl = trim((string)($config['home_assistant']['base_url'] ?? ''));
$token = trim((string)($config['home_assistant']['token'] ?? ''));
$this->demoMode = $baseUrl === '' || $token === '' || str_contains($baseUrl, 'example.com') || str_contains($token, 'PASTE_') || str_contains($token, 'YOUR_');
}
public function isDemo(): bool
{
return $this->demoMode;
}
public function fetchSnapshotData(): array
{
if ($this->demoMode) {
return $this->demoData();
}
$states = $this->request('GET', '/api/states');
$areas = $this->fetchWsList('config/area_registry/list') ?? $this->tryRequest('GET', '/api/areas') ?? [];
$floors = $this->fetchWsList('config/floor_registry/list') ?? [];
$entities = $this->fetchWsList('config/entity_registry/list_for_display') ?? $this->tryRequest('GET', '/api/config/entity_registry/list') ?? [];
$devices = $this->fetchWsList('config/device_registry/list') ?? $this->tryRequest('GET', '/api/config/device_registry/list') ?? [];
return [
'states' => is_array($states) ? $states : [],
'areas' => is_array($areas) ? $areas : [],
'floors' => is_array($floors) ? $floors : [],
'entity_registry' => is_array($entities) ? $entities : [],
'device_registry' => is_array($devices) ? $devices : [],
];
}
public function fetchEntityHistory(string $entityId, int $hours = 24): array
{
$entityId = trim($entityId);
$hours = max(1, min(168, $hours));
if ($this->demoMode) {
return $this->demoHistory($entityId, $hours);
}
if ($entityId === '') {
return [];
}
$start = (new DateTimeImmutable('now', new DateTimeZone('UTC')))
->modify('-' . $hours . ' hours')
->format(DATE_ATOM);
$path = '/api/history/period/' . rawurlencode($start) . '?' . http_build_query([
'filter_entity_id' => $entityId,
'minimal_response' => 1,
'no_attributes' => 1,
]);
$response = $this->request('GET', $path);
return is_array($response) ? $response : [];
}
public function callService(string $domain, string $service, array $data): array
{
if ($this->demoMode) {
return [
'ok' => true,
'demo' => true,
'domain' => $domain,
'service' => $service,
'data' => $data,
];
}
return $this->request('POST', '/api/services/' . rawurlencode($domain) . '/' . rawurlencode($service), $data);
}
private function request(string $method, string $path, ?array $body = null): array
{
$response = $this->tryRequest($method, $path, $body, true);
if (!is_array($response)) {
throw new RuntimeException('Unexpected response from Home Assistant');
}
return $response;
}
private function tryRequest(string $method, string $path, ?array $body = null, bool $throwOnHttpError = false): array|null
{
$baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/');
$url = $baseUrl . $path;
$token = trim((string)$this->config['home_assistant']['token']);
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Failed to initialize HTTP client');
}
$headers = [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'Accept: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 7,
CURLOPT_SSL_VERIFYPEER => (bool)($this->config['home_assistant']['verify_ssl'] ?? true),
CURLOPT_SSL_VERIFYHOST => (bool)($this->config['home_assistant']['verify_ssl'] ?? true) ? 2 : 0,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
if ($throwOnHttpError) {
throw new RuntimeException('Home Assistant request failed: ' . $error);
}
return null;
}
if ($status >= 400) {
if ($throwOnHttpError) {
throw new RuntimeException('Home Assistant returned HTTP ' . $status);
}
return null;
}
$decoded = json_decode((string)$raw, true);
return is_array($decoded) ? $decoded : [];
}
private function fetchWsList(string $type): array|null
{
$response = $this->wsCall($type);
if (!is_array($response)) {
return null;
}
if (isset($response['result']) && is_array($response['result'])) {
return $this->normalizeWsResultList($type, $response['result']);
}
return null;
}
private function normalizeWsResultList(string $type, array $result): array
{
if (array_is_list($result)) {
return match ($type) {
'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result),
'config/area_registry/list' => $this->normalizeAreaRegistry($result),
'config/floor_registry/list' => $this->normalizeFloorRegistry($result),
'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result),
default => $result,
};
}
return match ($type) {
'config/entity_registry/list_for_display' => $this->normalizeEntityRegistryForDisplay($result['entities'] ?? []),
'config/area_registry/list' => $this->normalizeAreaRegistry($result['areas'] ?? []),
'config/floor_registry/list' => $this->normalizeFloorRegistry($result['floors'] ?? []),
'config/device_registry/list' => $this->normalizeDeviceRegistryForDisplay($result['devices'] ?? $result['device_registry'] ?? $result['items'] ?? []),
default => $result,
};
}
private function wsCall(string $type): array|null
{
$baseUrl = rtrim((string)$this->config['home_assistant']['base_url'], '/');
$parts = parse_url($baseUrl);
if (!is_array($parts) || empty($parts['host'])) {
return null;
}
$scheme = strtolower((string)($parts['scheme'] ?? 'http'));
$secure = in_array($scheme, ['https', 'wss'], true);
$host = (string)$parts['host'];
$port = (int)($parts['port'] ?? ($secure ? 443 : 80));
$path = '/api/websocket';
$target = ($secure ? 'tls' : 'tcp') . '://' . $host . ':' . $port;
$contextOptions = [
'ssl' => [
'verify_peer' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true),
'verify_peer_name' => (bool)($this->config['home_assistant']['verify_ssl'] ?? true),
'allow_self_signed' => !(bool)($this->config['home_assistant']['verify_ssl'] ?? true),
'SNI_enabled' => true,
'peer_name' => $host,
],
];
$stream = @stream_socket_client(
$target,
$errno,
$error,
15,
STREAM_CLIENT_CONNECT,
stream_context_create($contextOptions)
);
if ($stream === false) {
return null;
}
stream_set_timeout($stream, 15);
$key = base64_encode(random_bytes(16));
$request = implode("\r\n", [
'GET ' . $path . ' HTTP/1.1',
'Host: ' . $host . ':' . $port,
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Key: ' . $key,
'Sec-WebSocket-Version: 13',
'',
'',
]);
fwrite($stream, $request);
$status = $this->readHttpHeader($stream);
if ($status === null || !str_contains($status, ' 101 ')) {
fclose($stream);
return null;
}
$auth = $this->readWsJson($stream);
if (!is_array($auth) || ($auth['type'] ?? '') !== 'auth_required') {
fclose($stream);
return null;
}
$this->writeWsJson($stream, [
'type' => 'auth',
'access_token' => (string)$this->config['home_assistant']['token'],
]);
$authResult = $this->readWsJson($stream);
if (!is_array($authResult) || ($authResult['type'] ?? '') !== 'auth_ok') {
fclose($stream);
return null;
}
$requestId = random_int(1, 1_000_000);
$this->writeWsJson($stream, [
'id' => $requestId,
'type' => $type,
]);
while (($message = $this->readWsJson($stream)) !== null) {
if (($message['id'] ?? null) === $requestId) {
fclose($stream);
if (($message['success'] ?? false) !== true) {
return null;
}
return is_array($message) ? $message : null;
}
}
fclose($stream);
return null;
}
private function normalizeEntityRegistryForDisplay(array $result): array
{
$entityCategories = is_array($result['entity_categories'] ?? null) ? $result['entity_categories'] : [];
$entities = $result;
if (!array_is_list($entities)) {
$entities = $result['entities'] ?? [];
}
if (!is_array($entities)) {
return [];
}
$normalized = [];
foreach ($entities as $entity) {
if (!is_array($entity)) {
continue;
}
$normalized[] = [
'entity_id' => $entity['ei'] ?? null,
'area_id' => $entity['ai'] ?? null,
'labels' => $this->normalizeWsLabels($entity['lb'] ?? null),
'device_id' => $entity['di'] ?? null,
'icon' => $entity['ic'] ?? null,
'translation_key' => $entity['tk'] ?? null,
'entity_category' => isset($entity['ec']) ? ($entityCategories[$entity['ec']] ?? null) : null,
'hidden_by' => !empty($entity['hb']) ? 'true' : null,
'name' => $entity['en'] ?? null,
'has_entity_name' => !empty($entity['hn']),
'platform' => $entity['pl'] ?? null,
];
}
return $normalized;
}
private function normalizeAreaRegistry(array $areas): array
{
$normalized = [];
foreach ($areas as $area) {
if (!is_array($area)) {
continue;
}
$normalized[] = [
'area_id' => $area['area_id'] ?? $area['id'] ?? null,
'name' => $area['name'] ?? $area['en'] ?? null,
'icon' => $area['icon'] ?? $area['ic'] ?? null,
'picture' => $area['picture'] ?? $area['pi'] ?? null,
'floor_id' => $area['floor_id'] ?? $area['fi'] ?? $area['floor'] ?? null,
];
}
return $normalized;
}
private function normalizeFloorRegistry(array $floors): array
{
$normalized = [];
foreach ($floors as $floor) {
if (!is_array($floor)) {
continue;
}
$normalized[] = [
'floor_id' => $floor['floor_id'] ?? $floor['id'] ?? null,
'name' => $floor['name'] ?? $floor['en'] ?? null,
'icon' => $floor['icon'] ?? $floor['ic'] ?? null,
'level' => $floor['level'] ?? $floor['lv'] ?? null,
];
}
return $normalized;
}
private function normalizeDeviceRegistryForDisplay(array $devices): array
{
$normalized = [];
foreach ($devices as $device) {
if (!is_array($device)) {
continue;
}
$normalized[] = [
'device_id' => $device['device_id'] ?? $device['id'] ?? $device['di'] ?? null,
'area_id' => $device['area_id'] ?? $device['ai'] ?? null,
'labels' => $this->normalizeWsLabels($device['labels'] ?? null),
'name' => $device['name'] ?? $device['en'] ?? null,
'manufacturer' => $device['manufacturer'] ?? $device['mf'] ?? null,
'model' => $device['model'] ?? $device['md'] ?? null,
];
}
return $normalized;
}
private function normalizeWsLabels(mixed $labels): array
{
if ($labels === null) {
return [];
}
if (is_string($labels) || is_numeric($labels)) {
return [(string)$labels];
}
if (!is_array($labels)) {
return [];
}
return array_values(array_unique(array_map('strval', $labels)));
}
private function readHttpHeader($stream): ?string
{
$buffer = '';
while (!feof($stream)) {
$chunk = fgets($stream);
if ($chunk === false) {
break;
}
$buffer .= $chunk;
if (str_contains($buffer, "\r\n\r\n")) {
break;
}
}
return $buffer !== '' ? $buffer : null;
}
private function writeWsJson($stream, array $payload): void
{
fwrite($stream, $this->encodeWsFrame(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'));
}
private function readWsJson($stream): array|null
{
$payload = $this->readWsFrame($stream);
if ($payload === null || $payload === '') {
return null;
}
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : null;
}
private function encodeWsFrame(string $payload): string
{
$finOpcode = chr(0x81);
$len = strlen($payload);
$maskBit = 0x80;
$mask = random_bytes(4);
$header = '';
if ($len <= 125) {
$header = chr($maskBit | $len);
} elseif ($len <= 65535) {
$header = chr($maskBit | 126) . pack('n', $len);
} else {
$header = chr($maskBit | 127) . pack('J', $len);
}
$masked = '';
for ($i = 0; $i < $len; $i++) {
$masked .= $payload[$i] ^ $mask[$i % 4];
}
return $finOpcode . $header . $mask . $masked;
}
private function readWsFrame($stream): ?string
{
$first = fread($stream, 2);
if ($first === false || strlen($first) < 2) {
return null;
}
$bytes = array_values(unpack('C2', $first));
$opcode = $bytes[0] & 0x0f;
$isMasked = (bool)($bytes[1] & 0x80);
$len = $bytes[1] & 0x7f;
if ($len === 126) {
$extended = fread($stream, 2);
if ($extended === false || strlen($extended) < 2) {
return null;
}
$len = unpack('n', $extended)[1];
} elseif ($len === 127) {
$extended = fread($stream, 8);
if ($extended === false || strlen($extended) < 8) {
return null;
}
$parts = unpack('N2', $extended);
$len = ((int)$parts[1] << 32) | (int)$parts[2];
}
$mask = '';
if ($isMasked) {
$mask = fread($stream, 4);
if ($mask === false || strlen($mask) < 4) {
return null;
}
}
$payload = '';
while (strlen($payload) < $len && !feof($stream)) {
$chunk = fread($stream, $len - strlen($payload));
if ($chunk === false || $chunk === '') {
break;
}
$payload .= $chunk;
}
if ($isMasked && $mask !== '') {
$unmasked = '';
for ($i = 0, $payloadLen = strlen($payload); $i < $payloadLen; $i++) {
$unmasked .= $payload[$i] ^ $mask[$i % 4];
}
$payload = $unmasked;
}
if ($opcode === 0x9) {
$this->writeWsPong($stream, $payload);
return $this->readWsFrame($stream);
}
if ($opcode === 0x8) {
return null;
}
return $payload;
}
private function writeWsPong($stream, string $payload): void
{
$len = strlen($payload);
$header = chr(0x8A);
if ($len <= 125) {
$header .= chr($len);
} elseif ($len <= 65535) {
$header .= chr(126) . pack('n', $len);
} else {
$header .= chr(127) . pack('J', $len);
}
fwrite($stream, $header . $payload);
}
private function demoData(): array
{
return [
'states' => [
[
'entity_id' => 'light.living_room_main',
'state' => 'on',
'attributes' => [
'friendly_name' => 'Основной свет',
'icon' => 'mdi:ceiling-light',
'labels' => ['auto'],
],
],
[
'entity_id' => 'switch.tv_power',
'state' => 'off',
'attributes' => [
'friendly_name' => 'ТВ',
'icon' => 'mdi:television',
'labels' => ['auto'],
],
],
[
'entity_id' => 'cover.living_room_curtain',
'state' => 'open',
'attributes' => [
'friendly_name' => 'Штора',
'icon' => 'mdi:curtains',
'current_position' => 82,
],
],
[
'entity_id' => 'climate.living_room_ac',
'state' => 'cool',
'attributes' => [
'friendly_name' => 'Кондиционер',
'icon' => 'mdi:air-conditioner',
'temperature' => 22,
'current_temperature' => 24.5,
'hvac_action' => 'cooling',
'fan_mode' => 'auto',
],
],
[
'entity_id' => 'light.kitchen_counter',
'state' => 'off',
'attributes' => [
'friendly_name' => 'Подсветка',
'icon' => 'mdi:lightbulb',
'labels' => ['auto'],
],
],
[
'entity_id' => 'switch.coffee_machine',
'state' => 'on',
'attributes' => [
'friendly_name' => 'Кофемашина',
'icon' => 'mdi:coffee-maker',
'labels' => ['auto'],
],
],
[
'entity_id' => 'weather.yandex_weather',
'state' => 'sunny',
'attributes' => [
'friendly_name' => 'Yandex Weather',
'temperature' => 18.3,
'humidity' => 56,
'wind_speed' => 4.8,
],
],
[
'entity_id' => 'sensor.weather_temperature',
'state' => '17.8',
'attributes' => [
'friendly_name' => 'Weather temperature',
],
],
[
'entity_id' => 'sensor.modeco_2_temperature',
'state' => '57.0',
'attributes' => [
'friendly_name' => 'ModEco 2 Temperature',
],
],
],
'areas' => [
['area_id' => 'living_room', 'name' => 'Гостиная', 'icon' => 'mdi:sofa'],
['area_id' => 'kitchen', 'name' => 'Кухня', 'icon' => 'mdi:stove'],
],
'entity_registry' => [
['entity_id' => 'light.living_room_main', 'area_id' => 'living_room', 'labels' => ['auto']],
['entity_id' => 'switch.tv_power', 'area_id' => 'living_room', 'labels' => ['auto']],
['entity_id' => 'cover.living_room_curtain', 'area_id' => 'living_room', 'labels' => []],
['entity_id' => 'climate.living_room_ac', 'area_id' => 'living_room', 'labels' => []],
['entity_id' => 'light.kitchen_counter', 'area_id' => 'kitchen', 'labels' => ['auto']],
['entity_id' => 'switch.coffee_machine', 'area_id' => 'kitchen', 'labels' => ['auto']],
['entity_id' => 'weather.yandex_weather', 'area_id' => null, 'labels' => []],
['entity_id' => 'sensor.weather_temperature', 'area_id' => null, 'labels' => []],
['entity_id' => 'sensor.modeco_2_temperature', 'area_id' => null, 'labels' => []],
],
'device_registry' => [],
];
}
private function demoHistory(string $entityId, int $hours): array
{
if ($entityId === '') {
return [];
}
$now = time();
$start = $now - ($hours * 3600);
$steps = max(24, min(96, $hours * 4));
$points = [];
$base = 57.0;
for ($index = 0; $index <= $steps; $index++) {
$ratio = $steps > 0 ? ($index / $steps) : 1;
$timestamp = $start + (int)(($now - $start) * $ratio);
$drift = sin($ratio * M_PI * 5.5) * 0.8 + cos($ratio * M_PI * 11) * 0.35;
$value = $base + $drift;
$points[] = [
'entity_id' => $entityId,
'state' => number_format($value, 1, '.', ''),
'last_changed' => gmdate(DATE_ATOM, $timestamp),
'last_updated' => gmdate(DATE_ATOM, $timestamp),
];
}
return [$points];
}
}

4
wall_panel/lib/ha_sync.php Executable file
View File

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

19
wall_panel/run.sh Executable file
View File

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