-
This commit is contained in:
parent
62514f7f52
commit
a765905d6c
11
README.md
11
README.md
@ -38,15 +38,16 @@ php -S 0.0.0.0:8080
|
|||||||
- хранит конфиг в add-on volume;
|
- хранит конфиг в add-on volume;
|
||||||
- не зависит от `custom_components`.
|
- не зависит от `custom_components`.
|
||||||
|
|
||||||
Файлы add-on лежат в корне репозитория:
|
Файлы add-on лежат в папке:
|
||||||
|
|
||||||
- [`config.yaml`](/Volumes/web/wallpanell/config.yaml)
|
- [`wall_panel/config.yaml`](/Volumes/web/wallpanell/wall_panel/config.yaml)
|
||||||
- [`Dockerfile`](/Volumes/web/wallpanell/Dockerfile)
|
- [`wall_panel/Dockerfile`](/Volumes/web/wallpanell/wall_panel/Dockerfile)
|
||||||
- [`run.sh`](/Volumes/web/wallpanell/run.sh)
|
- [`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.
|
2. Установите add-on.
|
||||||
3. Запустите его.
|
3. Запустите его.
|
||||||
4. Откройте панель через ingress или через проброшенный порт `8099/tcp`.
|
4. Откройте панель через ingress или через проброшенный порт `8099/tcp`.
|
||||||
|
|||||||
3
repository.yaml
Executable file
3
repository.yaml
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
name: Wall Panel Add-ons
|
||||||
|
url: https://git.striker72rus.ru/PHP/wallpanell.git
|
||||||
|
maintainer: Striker72rus
|
||||||
@ -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": 1774433119,
|
"opened_at": 1774434333,
|
||||||
"expires_at": null
|
"expires_at": null
|
||||||
}
|
}
|
||||||
|
|||||||
12
wall_panel/.dockerignore
Executable file
12
wall_panel/.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
wall_panel/@eaDir/README.md@SynoEAStream
Executable file
BIN
wall_panel/@eaDir/README.md@SynoEAStream
Executable file
Binary file not shown.
BIN
wall_panel/@eaDir/api.php@SynoEAStream
Executable file
BIN
wall_panel/@eaDir/api.php@SynoEAStream
Executable file
Binary file not shown.
BIN
wall_panel/@eaDir/assets@SynoEAStream
Executable file
BIN
wall_panel/@eaDir/assets@SynoEAStream
Executable file
Binary file not shown.
BIN
wall_panel/@eaDir/favicon.ico@SynoEAStream
Executable file
BIN
wall_panel/@eaDir/favicon.ico@SynoEAStream
Executable file
Binary file not shown.
BIN
wall_panel/@eaDir/index.php@SynoEAStream
Executable file
BIN
wall_panel/@eaDir/index.php@SynoEAStream
Executable file
Binary file not shown.
BIN
wall_panel/@eaDir/lib@SynoEAStream
Executable file
BIN
wall_panel/@eaDir/lib@SynoEAStream
Executable file
Binary file not shown.
19
wall_panel/Dockerfile
Executable file
19
wall_panel/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"]
|
||||||
141
wall_panel/README.md
Executable file
141
wall_panel/README.md
Executable 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
254
wall_panel/api.php
Executable 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);
|
||||||
|
}
|
||||||
BIN
wall_panel/assets/@eaDir/app.css@SynoEAStream
Executable file
BIN
wall_panel/assets/@eaDir/app.css@SynoEAStream
Executable file
Binary file not shown.
BIN
wall_panel/assets/@eaDir/app.js@SynoEAStream
Executable file
BIN
wall_panel/assets/@eaDir/app.js@SynoEAStream
Executable file
Binary file not shown.
2540
wall_panel/assets/app.css
Executable file
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
4553
wall_panel/assets/app.js
Executable file
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,8 @@
|
|||||||
name: Wall Panel
|
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"
|
version: "1.0.0"
|
||||||
slug: wall_panel
|
slug: wall_panel
|
||||||
|
url: https://git.striker72rus.ru/PHP/wallpanell.git
|
||||||
init: false
|
init: false
|
||||||
arch:
|
arch:
|
||||||
- aarch64
|
- aarch64
|
||||||
@ -20,5 +21,6 @@ ports_description:
|
|||||||
8099/tcp: Wall Panel web UI
|
8099/tcp: Wall Panel web UI
|
||||||
map:
|
map:
|
||||||
- addon_config:rw
|
- addon_config:rw
|
||||||
|
homeassistant_api: true
|
||||||
options: {}
|
options: {}
|
||||||
schema: {}
|
schema: {}
|
||||||
32
wall_panel/config/addon-default-config.json
Executable file
32
wall_panel/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": []
|
||||||
|
}
|
||||||
BIN
wall_panel/favicon.ico
Executable file
BIN
wall_panel/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
164
wall_panel/index.php
Executable file
164
wall_panel/index.php
Executable 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>
|
||||||
BIN
wall_panel/lib/@eaDir/bootstrap.php@SynoEAStream
Executable file
BIN
wall_panel/lib/@eaDir/bootstrap.php@SynoEAStream
Executable file
Binary file not shown.
BIN
wall_panel/lib/@eaDir/config.php@SynoEAStream
Executable file
BIN
wall_panel/lib/@eaDir/config.php@SynoEAStream
Executable file
Binary file not shown.
BIN
wall_panel/lib/@eaDir/dashboard.php@SynoEAStream
Executable file
BIN
wall_panel/lib/@eaDir/dashboard.php@SynoEAStream
Executable file
Binary file not shown.
BIN
wall_panel/lib/@eaDir/ha_client.php@SynoEAStream
Executable file
BIN
wall_panel/lib/@eaDir/ha_client.php@SynoEAStream
Executable file
Binary file not shown.
9
wall_panel/lib/bootstrap.php
Executable file
9
wall_panel/lib/bootstrap.php
Executable 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
316
wall_panel/lib/config.php
Executable 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
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
658
wall_panel/lib/ha_client.php
Executable 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
4
wall_panel/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
wall_panel/run.sh
Executable file
19
wall_panel/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"
|
||||||
Loading…
Reference in New Issue
Block a user