Initial commit
This commit is contained in:
commit
5f2b6b8efb
12
.dockerignore
Executable file
12
.dockerignore
Executable file
@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.DS_Store
|
||||||
|
ha-addon
|
||||||
|
custom_components
|
||||||
|
node_modules
|
||||||
|
storage/@eaDir
|
||||||
|
config/@eaDir
|
||||||
|
config/config.json
|
||||||
|
lib/@eaDir
|
||||||
|
assets/@eaDir
|
||||||
|
storage/*.json
|
||||||
3
.gitignore
vendored
Executable file
3
.gitignore
vendored
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
config/config.json
|
||||||
|
storage/
|
||||||
|
@eaDir/
|
||||||
BIN
@eaDir/.git@SynoEAStream
Executable file
BIN
@eaDir/.git@SynoEAStream
Executable file
Binary file not shown.
BIN
@eaDir/README.md@SynoEAStream
Executable file
BIN
@eaDir/README.md@SynoEAStream
Executable file
Binary file not shown.
BIN
@eaDir/README.md@SynoResource
Executable file
BIN
@eaDir/README.md@SynoResource
Executable file
Binary file not shown.
BIN
@eaDir/api.php@SynoEAStream
Executable file
BIN
@eaDir/api.php@SynoEAStream
Executable file
Binary file not shown.
BIN
@eaDir/api.php@SynoResource
Executable file
BIN
@eaDir/api.php@SynoResource
Executable file
Binary file not shown.
BIN
@eaDir/assets@SynoEAStream
Executable file
BIN
@eaDir/assets@SynoEAStream
Executable file
Binary file not shown.
BIN
@eaDir/config@SynoEAStream
Executable file
BIN
@eaDir/config@SynoEAStream
Executable file
Binary file not shown.
BIN
@eaDir/favicon.ico@SynoEAStream
Executable file
BIN
@eaDir/favicon.ico@SynoEAStream
Executable file
Binary file not shown.
BIN
@eaDir/favicon.ico@SynoResource
Executable file
BIN
@eaDir/favicon.ico@SynoResource
Executable file
Binary file not shown.
BIN
@eaDir/index.php@SynoEAStream
Executable file
BIN
@eaDir/index.php@SynoEAStream
Executable file
Binary file not shown.
BIN
@eaDir/index.php@SynoResource
Executable file
BIN
@eaDir/index.php@SynoResource
Executable file
Binary file not shown.
BIN
@eaDir/lib@SynoEAStream
Executable file
BIN
@eaDir/lib@SynoEAStream
Executable file
Binary file not shown.
BIN
@eaDir/storage@SynoEAStream
Executable file
BIN
@eaDir/storage@SynoEAStream
Executable file
Binary file not shown.
BIN
@eaDir/wallpanell.code-workspace@SynoEAStream
Executable file
BIN
@eaDir/wallpanell.code-workspace@SynoEAStream
Executable file
Binary file not shown.
19
Dockerfile
Executable file
19
Dockerfile
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
FROM php:8.2-cli-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl-dev && docker-php-ext-install curl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY index.php /app/index.php
|
||||||
|
COPY api.php /app/api.php
|
||||||
|
COPY favicon.ico /app/favicon.ico
|
||||||
|
COPY assets /app/assets
|
||||||
|
COPY lib /app/lib
|
||||||
|
COPY config/addon-default-config.json /app/config/config.json
|
||||||
|
COPY run.sh /run.sh
|
||||||
|
|
||||||
|
RUN chmod a+x /run.sh
|
||||||
|
|
||||||
|
EXPOSE 8099
|
||||||
|
|
||||||
|
CMD ["/run.sh"]
|
||||||
162
README.md
Executable file
162
README.md
Executable file
@ -0,0 +1,162 @@
|
|||||||
|
# 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 лежат в папке:
|
||||||
|
|
||||||
|
- [`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. Добавьте 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.
|
||||||
|
|
||||||
|
Частые настройки можно менять прямо из UI add-on в Home Assistant:
|
||||||
|
|
||||||
|
- `app.title`
|
||||||
|
- `app.poll_interval_ms`
|
||||||
|
- `app.main_room_name`
|
||||||
|
- `app.main_room_icon`
|
||||||
|
- `app.edit_mode`
|
||||||
|
- `app.battery_history_hours`
|
||||||
|
- `home_assistant.base_url`
|
||||||
|
- `home_assistant.token`
|
||||||
|
- `home_assistant.verify_ssl`
|
||||||
|
- `home_assistant.weather_entity_id`
|
||||||
|
- `camera.rtsp_url`
|
||||||
|
- `camera.stream_url`
|
||||||
|
- `camera.stream_mode`
|
||||||
|
- `camera.poster_url`
|
||||||
|
- `camera.popup_timeout_minutes`
|
||||||
|
|
||||||
|
Сложные структуры, вроде `rooms`, по-прежнему удобнее держать в JSON.
|
||||||
|
|
||||||
|
### Старый 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
api.php
Executable file
254
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
assets/@eaDir/app.css@SynoEAStream
Executable file
BIN
assets/@eaDir/app.css@SynoEAStream
Executable file
Binary file not shown.
BIN
assets/@eaDir/app.css@SynoResource
Executable file
BIN
assets/@eaDir/app.css@SynoResource
Executable file
Binary file not shown.
BIN
assets/@eaDir/app.js@SynoEAStream
Executable file
BIN
assets/@eaDir/app.js@SynoEAStream
Executable file
Binary file not shown.
BIN
assets/@eaDir/app.js@SynoResource
Executable file
BIN
assets/@eaDir/app.js@SynoResource
Executable file
Binary file not shown.
2540
assets/app.css
Executable file
2540
assets/app.css
Executable file
File diff suppressed because it is too large
Load Diff
4553
assets/app.js
Executable file
4553
assets/app.js
Executable file
File diff suppressed because it is too large
Load Diff
BIN
config/@eaDir/config.json@SynoEAStream
Executable file
BIN
config/@eaDir/config.json@SynoEAStream
Executable file
Binary file not shown.
BIN
config/@eaDir/config.json@SynoResource
Executable file
BIN
config/@eaDir/config.json@SynoResource
Executable file
Binary file not shown.
32
config/addon-default-config.json
Executable file
32
config/addon-default-config.json
Executable file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "Wall Panel",
|
||||||
|
"poll_interval_ms": 5000,
|
||||||
|
"main_room_name": "Главная",
|
||||||
|
"main_room_icon": "mdi:home",
|
||||||
|
"edit_mode": false,
|
||||||
|
"battery_history_hours": 4320
|
||||||
|
},
|
||||||
|
"home_assistant": {
|
||||||
|
"base_url": "",
|
||||||
|
"token": "",
|
||||||
|
"verify_ssl": true,
|
||||||
|
"sync_url": "",
|
||||||
|
"sync_token": "",
|
||||||
|
"sync_timeout": 10,
|
||||||
|
"sync_verify_ssl": true,
|
||||||
|
"sync_cache_seconds": 30,
|
||||||
|
"weather_entity_id": "",
|
||||||
|
"auto_label": "auto",
|
||||||
|
"auto_entity_ids": []
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"rtsp_url": "",
|
||||||
|
"stream_url": "",
|
||||||
|
"stream_mode": "hls",
|
||||||
|
"poster_url": "",
|
||||||
|
"popup_timeout_minutes": 3,
|
||||||
|
"trigger_entities": []
|
||||||
|
},
|
||||||
|
"rooms": []
|
||||||
|
}
|
||||||
48
custom_components/wall_panel/__init__.py
Executable file
48
custom_components/wall_panel/__init__.py
Executable file
@ -0,0 +1,48 @@
|
|||||||
|
"""Wall Panel integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import CONF_CONFIG, DOMAIN
|
||||||
|
from .frontend import async_setup_frontend
|
||||||
|
from .helpers import current_entry_config
|
||||||
|
from .views import WallPanelConfigView
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry) -> bool:
|
||||||
|
"""Set up Wall Panel from a config entry."""
|
||||||
|
|
||||||
|
state = hass.data.setdefault(DOMAIN, {})
|
||||||
|
state[entry.entry_id] = {
|
||||||
|
"entry": entry,
|
||||||
|
"config": current_entry_config(entry),
|
||||||
|
}
|
||||||
|
|
||||||
|
panel_url_path = await async_setup_frontend(hass, entry)
|
||||||
|
state[entry.entry_id]["panel_url_path"] = panel_url_path
|
||||||
|
|
||||||
|
if not state.get("_config_view_registered"):
|
||||||
|
hass.http.register_view(WallPanelConfigView)
|
||||||
|
state["_config_view_registered"] = True
|
||||||
|
|
||||||
|
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry) -> bool:
|
||||||
|
"""Unload Wall Panel."""
|
||||||
|
|
||||||
|
from homeassistant.components.frontend import async_remove_panel
|
||||||
|
|
||||||
|
state = hass.data.get(DOMAIN, {})
|
||||||
|
panel_url_path = str(state.get(entry.entry_id, {}).get("panel_url_path") or entry.options.get("frontend_url_path", "wall-panel") or "wall-panel").strip()
|
||||||
|
async_remove_panel(hass, panel_url_path)
|
||||||
|
state.pop(entry.entry_id, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_options_updated(hass: HomeAssistant, entry) -> None:
|
||||||
|
"""Reload the integration when options change."""
|
||||||
|
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
134
custom_components/wall_panel/config_flow.py
Executable file
134
custom_components/wall_panel/config_flow.py
Executable file
@ -0,0 +1,134 @@
|
|||||||
|
"""Config flow for Wall Panel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import CONF_NAME
|
||||||
|
from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, TextSelectorType
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_CONFIG,
|
||||||
|
CONF_FRONTEND_URL_PATH,
|
||||||
|
CONF_PANEL_URL,
|
||||||
|
CONF_REQUIRE_ADMIN,
|
||||||
|
CONF_SIDEBAR_ICON,
|
||||||
|
CONF_SIDEBAR_TITLE,
|
||||||
|
CONF_SYNC_TOKEN,
|
||||||
|
DEFAULT_FRONTEND_URL_PATH,
|
||||||
|
DEFAULT_PANEL_URL,
|
||||||
|
DEFAULT_SIDEBAR_ICON,
|
||||||
|
DEFAULT_SIDEBAR_TITLE,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from .helpers import config_to_json, normalize_config, parse_config_json
|
||||||
|
|
||||||
|
|
||||||
|
def _schema(defaults: dict[str, Any]) -> vol.Schema:
|
||||||
|
return vol.Schema({
|
||||||
|
vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, "Wall Panel")): str,
|
||||||
|
vol.Optional(CONF_PANEL_URL, default=defaults.get(CONF_PANEL_URL, DEFAULT_PANEL_URL)): str,
|
||||||
|
vol.Optional(CONF_SIDEBAR_TITLE, default=defaults.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE)): str,
|
||||||
|
vol.Optional(CONF_SIDEBAR_ICON, default=defaults.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON)): str,
|
||||||
|
vol.Optional(CONF_FRONTEND_URL_PATH, default=defaults.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH)): str,
|
||||||
|
vol.Optional(CONF_REQUIRE_ADMIN, default=bool(defaults.get(CONF_REQUIRE_ADMIN, False))): bool,
|
||||||
|
vol.Optional(CONF_SYNC_TOKEN, default=defaults.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24))): str,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_CONFIG,
|
||||||
|
default=defaults.get(CONF_CONFIG, config_to_json(normalize_config({}))),
|
||||||
|
): TextSelector(TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT)),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Wall Panel."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if self._async_current_entries():
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
try:
|
||||||
|
config = parse_config_json(user_input[CONF_CONFIG])
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
errors[CONF_CONFIG] = "invalid_json"
|
||||||
|
else:
|
||||||
|
data = {
|
||||||
|
CONF_NAME: user_input.get(CONF_NAME, "Wall Panel"),
|
||||||
|
CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""),
|
||||||
|
CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
|
||||||
|
CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
|
||||||
|
CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
|
||||||
|
CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
|
||||||
|
CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""),
|
||||||
|
CONF_CONFIG: config,
|
||||||
|
}
|
||||||
|
await self.async_set_unique_id(DOMAIN)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(title=data[CONF_SIDEBAR_TITLE], data={}, options=data)
|
||||||
|
|
||||||
|
defaults = {
|
||||||
|
CONF_NAME: "Wall Panel",
|
||||||
|
CONF_PANEL_URL: DEFAULT_PANEL_URL,
|
||||||
|
CONF_SIDEBAR_TITLE: DEFAULT_SIDEBAR_TITLE,
|
||||||
|
CONF_SIDEBAR_ICON: DEFAULT_SIDEBAR_ICON,
|
||||||
|
CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH,
|
||||||
|
CONF_REQUIRE_ADMIN: False,
|
||||||
|
CONF_SYNC_TOKEN: secrets.token_urlsafe(24),
|
||||||
|
CONF_CONFIG: config_to_json(normalize_config({})),
|
||||||
|
}
|
||||||
|
return self.async_show_form(step_id="user", data_schema=_schema(defaults), errors=errors)
|
||||||
|
|
||||||
|
async def async_step_import(self, user_input: dict[str, Any]):
|
||||||
|
return await self.async_step_user(user_input)
|
||||||
|
|
||||||
|
|
||||||
|
class WallPanelOptionsFlow(config_entries.OptionsFlow):
|
||||||
|
"""Handle options for Wall Panel."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry):
|
||||||
|
self.config_entry = config_entry
|
||||||
|
|
||||||
|
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
try:
|
||||||
|
config = parse_config_json(user_input[CONF_CONFIG])
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
errors[CONF_CONFIG] = "invalid_json"
|
||||||
|
else:
|
||||||
|
data = dict(self.config_entry.options)
|
||||||
|
data.update({
|
||||||
|
CONF_NAME: user_input.get(CONF_NAME, data.get(CONF_NAME, "Wall Panel")),
|
||||||
|
CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""),
|
||||||
|
CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
|
||||||
|
CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
|
||||||
|
CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
|
||||||
|
CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)),
|
||||||
|
CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""),
|
||||||
|
CONF_CONFIG: config,
|
||||||
|
})
|
||||||
|
return self.async_create_entry(title="", data=data)
|
||||||
|
|
||||||
|
defaults = {
|
||||||
|
CONF_NAME: self.config_entry.options.get(CONF_NAME, "Wall Panel"),
|
||||||
|
CONF_PANEL_URL: self.config_entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL),
|
||||||
|
CONF_SIDEBAR_TITLE: self.config_entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE),
|
||||||
|
CONF_SIDEBAR_ICON: self.config_entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON),
|
||||||
|
CONF_FRONTEND_URL_PATH: self.config_entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH),
|
||||||
|
CONF_REQUIRE_ADMIN: self.config_entry.options.get(CONF_REQUIRE_ADMIN, False),
|
||||||
|
CONF_SYNC_TOKEN: self.config_entry.options.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)),
|
||||||
|
CONF_CONFIG: config_to_json(normalize_config(self.config_entry.options.get(CONF_CONFIG, {}))),
|
||||||
|
}
|
||||||
|
return self.async_show_form(step_id="init", data_schema=_schema(defaults), errors=errors)
|
||||||
|
|
||||||
|
|
||||||
|
def async_get_options_flow(config_entry):
|
||||||
|
return WallPanelOptionsFlow(config_entry)
|
||||||
17
custom_components/wall_panel/const.py
Executable file
17
custom_components/wall_panel/const.py
Executable file
@ -0,0 +1,17 @@
|
|||||||
|
"""Constants for Wall Panel."""
|
||||||
|
|
||||||
|
DOMAIN = "wall_panel"
|
||||||
|
|
||||||
|
CONF_PANEL_URL = "panel_url"
|
||||||
|
CONF_CONFIG = "config"
|
||||||
|
CONF_SYNC_TOKEN = "sync_token"
|
||||||
|
CONF_SIDEBAR_TITLE = "sidebar_title"
|
||||||
|
CONF_SIDEBAR_ICON = "sidebar_icon"
|
||||||
|
CONF_FRONTEND_URL_PATH = "frontend_url_path"
|
||||||
|
CONF_REQUIRE_ADMIN = "require_admin"
|
||||||
|
|
||||||
|
DEFAULT_NAME = "Wall Panel"
|
||||||
|
DEFAULT_PANEL_URL = ""
|
||||||
|
DEFAULT_SIDEBAR_TITLE = "Wall Panel"
|
||||||
|
DEFAULT_SIDEBAR_ICON = "mdi:view-dashboard"
|
||||||
|
DEFAULT_FRONTEND_URL_PATH = "wall-panel"
|
||||||
68
custom_components/wall_panel/frontend.py
Executable file
68
custom_components/wall_panel/frontend.py
Executable file
@ -0,0 +1,68 @@
|
|||||||
|
"""Frontend registration for Wall Panel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from homeassistant.components.frontend import async_register_built_in_panel
|
||||||
|
from homeassistant.components.http import StaticPathConfig
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_FRONTEND_URL_PATH,
|
||||||
|
CONF_PANEL_URL,
|
||||||
|
CONF_REQUIRE_ADMIN,
|
||||||
|
CONF_SIDEBAR_ICON,
|
||||||
|
CONF_SIDEBAR_TITLE,
|
||||||
|
DEFAULT_FRONTEND_URL_PATH,
|
||||||
|
DEFAULT_SIDEBAR_ICON,
|
||||||
|
DEFAULT_SIDEBAR_TITLE,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_frontend(hass: HomeAssistant, entry) -> str:
|
||||||
|
"""Register the custom panel and static frontend assets."""
|
||||||
|
|
||||||
|
frontend_dir = Path(__file__).parent / "frontend"
|
||||||
|
state = hass.data.setdefault(DOMAIN, {})
|
||||||
|
if not state.get("_static_paths_registered"):
|
||||||
|
await hass.http.async_register_static_paths([
|
||||||
|
StaticPathConfig(
|
||||||
|
f"/api/{DOMAIN}/frontend",
|
||||||
|
str(frontend_dir),
|
||||||
|
cache_headers=False,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
state["_static_paths_registered"] = True
|
||||||
|
|
||||||
|
panel_url_path = str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH).strip()
|
||||||
|
sidebar_title = str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE).strip()
|
||||||
|
sidebar_icon = str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON).strip()
|
||||||
|
require_admin = bool(entry.options.get(CONF_REQUIRE_ADMIN, False))
|
||||||
|
panel_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip()
|
||||||
|
|
||||||
|
async_register_built_in_panel(
|
||||||
|
hass,
|
||||||
|
component_name="custom",
|
||||||
|
sidebar_title=sidebar_title,
|
||||||
|
sidebar_icon=sidebar_icon,
|
||||||
|
frontend_url_path=panel_url_path,
|
||||||
|
config={
|
||||||
|
"_panel_custom": {
|
||||||
|
"name": "wall-panel-panel",
|
||||||
|
"module_url": f"/api/{DOMAIN}/frontend/panel.js",
|
||||||
|
"embed_iframe": False,
|
||||||
|
"trust_external": False,
|
||||||
|
"config": {
|
||||||
|
"panel_url": panel_url,
|
||||||
|
"panel_url_path": panel_url_path,
|
||||||
|
"entry_id": entry.entry_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
require_admin=require_admin,
|
||||||
|
update=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return panel_url_path
|
||||||
133
custom_components/wall_panel/frontend/panel.js
Executable file
133
custom_components/wall_panel/frontend/panel.js
Executable file
@ -0,0 +1,133 @@
|
|||||||
|
class WallPanelPanel extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._hass = null;
|
||||||
|
this._panel = null;
|
||||||
|
this._narrow = false;
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
set hass(hass) {
|
||||||
|
this._hass = hass;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
set panel(panel) {
|
||||||
|
this._panel = panel;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
set narrow(narrow) {
|
||||||
|
this._narrow = Boolean(narrow);
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveUrl(rawUrl) {
|
||||||
|
const value = String(rawUrl || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value, window.location.href);
|
||||||
|
if (!url.searchParams.has('embed')) {
|
||||||
|
url.searchParams.set('embed', '1');
|
||||||
|
}
|
||||||
|
if (!url.searchParams.has('mode')) {
|
||||||
|
url.searchParams.set('mode', 'ha');
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
} catch (error) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
if (!this.shadowRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = this._panel?.config || {};
|
||||||
|
const customConfig = config._panel_custom || {};
|
||||||
|
const payload = customConfig.config || customConfig || config;
|
||||||
|
const panelUrl = this._resolveUrl(
|
||||||
|
payload.panel_url || config.panel_url || customConfig.panel_url || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--primary-background-color, #0d0f14);
|
||||||
|
color: var(--primary-text-color, #fff);
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--primary-background-color, #0d0f14);
|
||||||
|
}
|
||||||
|
iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--primary-font-family, sans-serif);
|
||||||
|
color: var(--secondary-text-color, #b3b8c2);
|
||||||
|
}
|
||||||
|
.empty strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--primary-text-color, #fff);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="wrap"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const wrap = this.shadowRoot.querySelector('.wrap');
|
||||||
|
if (!wrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!panelUrl) {
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="empty">
|
||||||
|
<div>
|
||||||
|
<strong>Wall Panel is not configured</strong>
|
||||||
|
<div>Set the PHP panel URL in the integration options.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = panelUrl;
|
||||||
|
iframe.loading = 'eager';
|
||||||
|
iframe.referrerPolicy = 'no-referrer';
|
||||||
|
iframe.allow = 'autoplay; fullscreen; picture-in-picture';
|
||||||
|
wrap.replaceChildren(iframe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('wall-panel-panel')) {
|
||||||
|
customElements.define('wall-panel-panel', WallPanelPanel);
|
||||||
|
}
|
||||||
283
custom_components/wall_panel/helpers.py
Executable file
283
custom_components/wall_panel/helpers.py
Executable file
@ -0,0 +1,283 @@
|
|||||||
|
"""Helper functions for Wall Panel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_CONFIG,
|
||||||
|
CONF_FRONTEND_URL_PATH,
|
||||||
|
CONF_PANEL_URL,
|
||||||
|
CONF_REQUIRE_ADMIN,
|
||||||
|
CONF_SIDEBAR_ICON,
|
||||||
|
CONF_SIDEBAR_TITLE,
|
||||||
|
CONF_SYNC_TOKEN,
|
||||||
|
DEFAULT_FRONTEND_URL_PATH,
|
||||||
|
DEFAULT_PANEL_URL,
|
||||||
|
DEFAULT_SIDEBAR_ICON,
|
||||||
|
DEFAULT_SIDEBAR_TITLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_config() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"app": {
|
||||||
|
"title": "Wall Panel",
|
||||||
|
"poll_interval_ms": 5000,
|
||||||
|
"main_room_name": "Главная",
|
||||||
|
"main_room_icon": "mdi:home",
|
||||||
|
"edit_mode": False,
|
||||||
|
"battery_history_hours": 4320,
|
||||||
|
},
|
||||||
|
"home_assistant": {
|
||||||
|
"base_url": "",
|
||||||
|
"token": "",
|
||||||
|
"verify_ssl": True,
|
||||||
|
"sync_url": "",
|
||||||
|
"sync_token": "",
|
||||||
|
"sync_timeout": 10,
|
||||||
|
"sync_verify_ssl": True,
|
||||||
|
"sync_cache_seconds": 30,
|
||||||
|
"weather_entity_id": "",
|
||||||
|
"auto_label": "auto",
|
||||||
|
"auto_entity_ids": [],
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"rtsp_url": "",
|
||||||
|
"stream_url": "",
|
||||||
|
"stream_mode": "hls",
|
||||||
|
"poster_url": "",
|
||||||
|
"popup_timeout_minutes": 3,
|
||||||
|
"trigger_entities": [],
|
||||||
|
},
|
||||||
|
"rooms": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config(value: Any) -> dict[str, Any]:
|
||||||
|
config = deepcopy(default_config())
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return config
|
||||||
|
|
||||||
|
_deep_merge(config, value)
|
||||||
|
if not isinstance(config.get("rooms"), list):
|
||||||
|
config["rooms"] = []
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def config_to_json(config: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(config, ensure_ascii=False, indent=2, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_config_json(raw: str) -> dict[str, Any]:
|
||||||
|
data = json.loads(raw)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("Config JSON must be an object")
|
||||||
|
return normalize_config(data)
|
||||||
|
|
||||||
|
|
||||||
|
def current_entry_config(entry) -> dict[str, Any]:
|
||||||
|
return normalize_config(entry.options.get(CONF_CONFIG))
|
||||||
|
|
||||||
|
|
||||||
|
def current_entry_panel(entry) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
CONF_PANEL_URL: str(entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL) or ""),
|
||||||
|
CONF_SYNC_TOKEN: str(entry.options.get(CONF_SYNC_TOKEN, "") or ""),
|
||||||
|
CONF_SIDEBAR_TITLE: str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE),
|
||||||
|
CONF_SIDEBAR_ICON: str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON),
|
||||||
|
CONF_FRONTEND_URL_PATH: str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH),
|
||||||
|
CONF_REQUIRE_ADMIN: bool(entry.options.get(CONF_REQUIRE_ADMIN, False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(config: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
app = config.setdefault("app", {})
|
||||||
|
if "edit_mode" in payload:
|
||||||
|
app["edit_mode"] = bool(payload["edit_mode"])
|
||||||
|
if isinstance(payload.get("title"), str) and payload["title"].strip():
|
||||||
|
app["title"] = payload["title"].strip()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def update_entity_override(config: dict[str, Any], room_id: str, entity_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
room = _ensure_room(config, room_id)
|
||||||
|
overrides = room.setdefault("entity_overrides", {})
|
||||||
|
current = overrides.get(entity_id, {})
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {}
|
||||||
|
|
||||||
|
merged = deepcopy(current)
|
||||||
|
for key, value in patch.items():
|
||||||
|
if value is not None:
|
||||||
|
merged[key] = value
|
||||||
|
overrides[entity_id] = merged
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def update_room_override(config: dict[str, Any], room_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
room = _ensure_room(config, room_id)
|
||||||
|
for key, value in patch.items():
|
||||||
|
if value is not None:
|
||||||
|
room[key] = value
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def update_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
room = _ensure_room(config, room_id)
|
||||||
|
items = room.setdefault("layout_items", [])
|
||||||
|
if not isinstance(items, list):
|
||||||
|
items = []
|
||||||
|
room["layout_items"] = items
|
||||||
|
|
||||||
|
current = None
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict) and str(item.get("id", "")) == layout_item_id:
|
||||||
|
current = item
|
||||||
|
break
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
current = {
|
||||||
|
"id": layout_item_id,
|
||||||
|
"type": "ghost",
|
||||||
|
}
|
||||||
|
items.append(current)
|
||||||
|
|
||||||
|
for key, value in patch.items():
|
||||||
|
if value is not None:
|
||||||
|
current[key] = value
|
||||||
|
|
||||||
|
_sort_room_layout_items(room)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def create_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str, order: int | None = None) -> dict[str, Any]:
|
||||||
|
return update_room_layout_item(config, room_id, layout_item_id, {
|
||||||
|
"order": order,
|
||||||
|
"type": "ghost",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def delete_room_layout_item(config: dict[str, Any], room_id: str, layout_item_id: str) -> dict[str, Any]:
|
||||||
|
room = _ensure_room(config, room_id)
|
||||||
|
items = room.get("layout_items", [])
|
||||||
|
if not isinstance(items, list):
|
||||||
|
room["layout_items"] = []
|
||||||
|
return config
|
||||||
|
|
||||||
|
room["layout_items"] = [
|
||||||
|
item for item in items
|
||||||
|
if not isinstance(item, dict) or str(item.get("id", "")) != layout_item_id
|
||||||
|
]
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_room_grid(config: dict[str, Any], room_id: str, entries: list[Any]) -> dict[str, Any]:
|
||||||
|
room = _ensure_room(config, room_id)
|
||||||
|
normalized: list[dict[str, str]] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
kind = str(entry.get("kind", "")).strip()
|
||||||
|
item_id = str(entry.get("id", "")).strip()
|
||||||
|
if kind not in {"entity", "layout"} or not item_id:
|
||||||
|
continue
|
||||||
|
normalized.append({"kind": kind, "id": item_id})
|
||||||
|
|
||||||
|
entity_overrides = room.setdefault("entity_overrides", {})
|
||||||
|
if not isinstance(entity_overrides, dict):
|
||||||
|
entity_overrides = {}
|
||||||
|
room["entity_overrides"] = entity_overrides
|
||||||
|
|
||||||
|
layout_items = room.setdefault("layout_items", [])
|
||||||
|
if not isinstance(layout_items, list):
|
||||||
|
layout_items = []
|
||||||
|
room["layout_items"] = layout_items
|
||||||
|
|
||||||
|
layout_by_id = {
|
||||||
|
str(item.get("id", "")): item
|
||||||
|
for item in layout_items
|
||||||
|
if isinstance(item, dict) and str(item.get("id", "")).strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
order = 10000
|
||||||
|
for entry in normalized:
|
||||||
|
if entry["kind"] == "entity":
|
||||||
|
current = entity_overrides.get(entry["id"], {})
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {}
|
||||||
|
merged = deepcopy(current)
|
||||||
|
merged["order"] = order
|
||||||
|
entity_overrides[entry["id"]] = merged
|
||||||
|
else:
|
||||||
|
current = layout_by_id.get(entry["id"], {
|
||||||
|
"id": entry["id"],
|
||||||
|
"type": "ghost",
|
||||||
|
})
|
||||||
|
merged = deepcopy(current)
|
||||||
|
merged["id"] = entry["id"]
|
||||||
|
merged["type"] = "ghost"
|
||||||
|
merged["order"] = order
|
||||||
|
layout_by_id[entry["id"]] = merged
|
||||||
|
order += 10
|
||||||
|
|
||||||
|
room["layout_items"] = sorted(
|
||||||
|
layout_by_id.values(),
|
||||||
|
key=lambda item: (int(item.get("order", 9999) or 9999), str(item.get("id", ""))),
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def build_patch_payload(payload: dict[str, Any], keys: list[str]) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key in keys:
|
||||||
|
if key in payload:
|
||||||
|
result[key] = payload[key]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
|
||||||
|
for key, value in source.items():
|
||||||
|
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
||||||
|
_deep_merge(target[key], value)
|
||||||
|
else:
|
||||||
|
target[key] = deepcopy(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_room(config: dict[str, Any], room_id: str) -> dict[str, Any]:
|
||||||
|
rooms = config.setdefault("rooms", [])
|
||||||
|
if not isinstance(rooms, list):
|
||||||
|
rooms = []
|
||||||
|
config["rooms"] = rooms
|
||||||
|
|
||||||
|
for room in rooms:
|
||||||
|
if isinstance(room, dict) and str(room.get("id", "")) == room_id:
|
||||||
|
room.setdefault("visible", True)
|
||||||
|
room.setdefault("entity_ids", [])
|
||||||
|
room.setdefault("entity_overrides", {})
|
||||||
|
room.setdefault("layout_items", [])
|
||||||
|
return room
|
||||||
|
|
||||||
|
room = {
|
||||||
|
"id": room_id,
|
||||||
|
"visible": True,
|
||||||
|
"entity_ids": [],
|
||||||
|
"entity_overrides": {},
|
||||||
|
"layout_items": [],
|
||||||
|
}
|
||||||
|
rooms.append(room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_room_layout_items(room: dict[str, Any]) -> None:
|
||||||
|
items = room.get("layout_items", [])
|
||||||
|
if not isinstance(items, list):
|
||||||
|
room["layout_items"] = []
|
||||||
|
return
|
||||||
|
|
||||||
|
room["layout_items"] = sorted(
|
||||||
|
[item for item in items if isinstance(item, dict)],
|
||||||
|
key=lambda item: (int(item.get("order", 9999) or 9999), str(item.get("id", ""))),
|
||||||
|
)
|
||||||
10
custom_components/wall_panel/manifest.json
Executable file
10
custom_components/wall_panel/manifest.json
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"domain": "wall_panel",
|
||||||
|
"name": "Wall Panel",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"documentation": "https://example.invalid/wall-panel",
|
||||||
|
"codeowners": [],
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"requirements": []
|
||||||
|
}
|
||||||
23
custom_components/wall_panel/strings.json
Executable file
23
custom_components/wall_panel/strings.json
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Wall Panel",
|
||||||
|
"description": "Connect Wall Panel to Home Assistant.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"panel_url": "PHP panel URL",
|
||||||
|
"sidebar_title": "Sidebar title",
|
||||||
|
"sidebar_icon": "Sidebar icon",
|
||||||
|
"frontend_url_path": "Frontend URL path",
|
||||||
|
"require_admin": "Require admin",
|
||||||
|
"sync_token": "Sync token",
|
||||||
|
"config": "Canonical config JSON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_json": "Invalid JSON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
custom_components/wall_panel/translations/en.json
Executable file
23
custom_components/wall_panel/translations/en.json
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Wall Panel",
|
||||||
|
"description": "Connect Wall Panel to Home Assistant.",
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"panel_url": "PHP panel URL",
|
||||||
|
"sidebar_title": "Sidebar title",
|
||||||
|
"sidebar_icon": "Sidebar icon",
|
||||||
|
"frontend_url_path": "Frontend URL path",
|
||||||
|
"require_admin": "Require admin",
|
||||||
|
"sync_token": "Sync token",
|
||||||
|
"config": "Canonical config JSON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_json": "Invalid JSON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
custom_components/wall_panel/views.py
Executable file
160
custom_components/wall_panel/views.py
Executable file
@ -0,0 +1,160 @@
|
|||||||
|
"""HTTP views for Wall Panel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import CONF_CONFIG, CONF_SYNC_TOKEN, DOMAIN
|
||||||
|
from .helpers import (
|
||||||
|
build_patch_payload,
|
||||||
|
config_to_json,
|
||||||
|
create_room_layout_item,
|
||||||
|
current_entry_config,
|
||||||
|
delete_room_layout_item,
|
||||||
|
normalize_config,
|
||||||
|
reorder_room_grid,
|
||||||
|
save_settings,
|
||||||
|
update_entity_override,
|
||||||
|
update_room_layout_item,
|
||||||
|
update_room_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_from_hass(hass: HomeAssistant, entry_id: str):
|
||||||
|
return hass.data.get(DOMAIN, {}).get(entry_id, {}).get("entry")
|
||||||
|
|
||||||
|
|
||||||
|
def _request_token(request: web.Request) -> str:
|
||||||
|
header = request.headers.get("X-Wall-Panel-Token", "").strip()
|
||||||
|
if header:
|
||||||
|
return header
|
||||||
|
|
||||||
|
auth = request.headers.get("Authorization", "").strip()
|
||||||
|
if auth.lower().startswith("bearer "):
|
||||||
|
return auth[7:].strip()
|
||||||
|
|
||||||
|
return request.query.get("token", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _authorized(entry, request: web.Request) -> bool:
|
||||||
|
expected = str(entry.options.get(CONF_SYNC_TOKEN, "") or "").strip()
|
||||||
|
if not expected:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return secrets.compare_digest(_request_token(request), expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _response(data: Any, status: int = 200) -> web.Response:
|
||||||
|
if isinstance(data, str):
|
||||||
|
return web.Response(text=data, status=status, content_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
return web.json_response(data, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_entry_config(hass: HomeAssistant, entry, config: dict[str, Any]) -> None:
|
||||||
|
options = dict(entry.options)
|
||||||
|
options[CONF_CONFIG] = config
|
||||||
|
hass.config_entries.async_update_entry(entry, options=options)
|
||||||
|
hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["config"] = config
|
||||||
|
|
||||||
|
|
||||||
|
class WallPanelConfigView(HomeAssistantView):
|
||||||
|
"""Serve and update the canonical Wall Panel config."""
|
||||||
|
|
||||||
|
url = "/api/wall_panel/config/{entry_id}"
|
||||||
|
name = "api:wall_panel:config"
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
async def async_get(self, request: web.Request, entry_id: str) -> web.Response:
|
||||||
|
hass = request.app["hass"]
|
||||||
|
entry = _entry_from_hass(hass, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
return _response({"ok": False, "error": "Unknown entry"}, 404)
|
||||||
|
if not _authorized(entry, request):
|
||||||
|
return _response({"ok": False, "error": "Unauthorized"}, 401)
|
||||||
|
|
||||||
|
config = current_entry_config(entry)
|
||||||
|
return _response(config)
|
||||||
|
|
||||||
|
async def async_post(self, request: web.Request, entry_id: str) -> web.Response:
|
||||||
|
hass = request.app["hass"]
|
||||||
|
entry = _entry_from_hass(hass, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
return _response({"ok": False, "error": "Unknown entry"}, 404)
|
||||||
|
if not _authorized(entry, request):
|
||||||
|
return _response({"ok": False, "error": "Unauthorized"}, 401)
|
||||||
|
|
||||||
|
payload = await request.json()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return _response({"ok": False, "error": "Invalid payload"}, 400)
|
||||||
|
|
||||||
|
config = current_entry_config(entry)
|
||||||
|
action = str(payload.get("action", "") or "").strip().lower()
|
||||||
|
action_payload = payload.get("payload")
|
||||||
|
if not isinstance(action_payload, dict):
|
||||||
|
action_payload = payload
|
||||||
|
|
||||||
|
if action == "save-settings":
|
||||||
|
config = save_settings(config, action_payload)
|
||||||
|
elif action == "save-entity-override":
|
||||||
|
room_id = str(action_payload.get("room_id", "") or "").strip()
|
||||||
|
entity_id = str(action_payload.get("entity_id", "") or "").strip()
|
||||||
|
if not room_id or not entity_id:
|
||||||
|
return _response({"ok": False, "error": "room_id and entity_id are required"}, 400)
|
||||||
|
patch = build_patch_payload(action_payload, ["visible", "order", "card_type", "title", "icon"])
|
||||||
|
config = update_entity_override(config, room_id, entity_id, patch)
|
||||||
|
elif action == "save-space-override":
|
||||||
|
room_id = str(action_payload.get("room_id", "") or "").strip()
|
||||||
|
if not room_id:
|
||||||
|
return _response({"ok": False, "error": "room_id is required"}, 400)
|
||||||
|
patch = build_patch_payload(action_payload, ["visible", "order", "name", "icon", "temperature_sensor_entity_id"])
|
||||||
|
config = update_room_override(config, room_id, patch)
|
||||||
|
elif action == "create-room-layout-item":
|
||||||
|
room_id = str(action_payload.get("room_id", "") or "").strip()
|
||||||
|
if not room_id:
|
||||||
|
return _response({"ok": False, "error": "room_id is required"}, 400)
|
||||||
|
layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip()
|
||||||
|
if not layout_item_id:
|
||||||
|
layout_item_id = f"slot_{secrets.token_hex(12)}"
|
||||||
|
order = action_payload.get("order")
|
||||||
|
config = create_room_layout_item(config, room_id, layout_item_id, int(order) if order is not None else None)
|
||||||
|
_save_entry_config(hass, entry, config)
|
||||||
|
return _response({
|
||||||
|
"ok": True,
|
||||||
|
"layout_item_id": layout_item_id,
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
elif action == "save-room-layout-item":
|
||||||
|
room_id = str(action_payload.get("room_id", "") or "").strip()
|
||||||
|
layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip()
|
||||||
|
if not room_id or not layout_item_id:
|
||||||
|
return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400)
|
||||||
|
patch = build_patch_payload(action_payload, ["order"])
|
||||||
|
config = update_room_layout_item(config, room_id, layout_item_id, patch)
|
||||||
|
elif action == "delete-room-layout-item":
|
||||||
|
room_id = str(action_payload.get("room_id", "") or "").strip()
|
||||||
|
layout_item_id = str(action_payload.get("layout_item_id", "") or "").strip()
|
||||||
|
if not room_id or not layout_item_id:
|
||||||
|
return _response({"ok": False, "error": "room_id and layout_item_id are required"}, 400)
|
||||||
|
config = delete_room_layout_item(config, room_id, layout_item_id)
|
||||||
|
elif action == "reorder-room-grid":
|
||||||
|
room_id = str(action_payload.get("room_id", "") or "").strip()
|
||||||
|
entries = action_payload.get("entries", [])
|
||||||
|
if not room_id or not isinstance(entries, list):
|
||||||
|
return _response({"ok": False, "error": "room_id and entries are required"}, 400)
|
||||||
|
config = reorder_room_grid(config, room_id, entries)
|
||||||
|
elif isinstance(payload.get("config"), dict):
|
||||||
|
config = normalize_config(payload["config"])
|
||||||
|
else:
|
||||||
|
return _response({"ok": False, "error": "Unknown action"}, 404)
|
||||||
|
|
||||||
|
_save_entry_config(hass, entry, config)
|
||||||
|
return _response({
|
||||||
|
"ok": True,
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
BIN
favicon.ico
Executable file
BIN
favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
164
index.php
Executable file
164
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
lib/@eaDir/bootstrap.php@SynoEAStream
Executable file
BIN
lib/@eaDir/bootstrap.php@SynoEAStream
Executable file
Binary file not shown.
BIN
lib/@eaDir/bootstrap.php@SynoResource
Executable file
BIN
lib/@eaDir/bootstrap.php@SynoResource
Executable file
Binary file not shown.
BIN
lib/@eaDir/config.php@SynoEAStream
Executable file
BIN
lib/@eaDir/config.php@SynoEAStream
Executable file
Binary file not shown.
BIN
lib/@eaDir/config.php@SynoResource
Executable file
BIN
lib/@eaDir/config.php@SynoResource
Executable file
Binary file not shown.
BIN
lib/@eaDir/dashboard.php@SynoEAStream
Executable file
BIN
lib/@eaDir/dashboard.php@SynoEAStream
Executable file
Binary file not shown.
BIN
lib/@eaDir/dashboard.php@SynoResource
Executable file
BIN
lib/@eaDir/dashboard.php@SynoResource
Executable file
Binary file not shown.
BIN
lib/@eaDir/ha_client.php@SynoEAStream
Executable file
BIN
lib/@eaDir/ha_client.php@SynoEAStream
Executable file
Binary file not shown.
BIN
lib/@eaDir/ha_client.php@SynoResource
Executable file
BIN
lib/@eaDir/ha_client.php@SynoResource
Executable file
Binary file not shown.
9
lib/bootstrap.php
Executable file
9
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';
|
||||||
348
lib/config.php
Executable file
348
lib/config.php
Executable file
@ -0,0 +1,348 @@
|
|||||||
|
<?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_runtime_mode(): string
|
||||||
|
{
|
||||||
|
return strtolower(trim((string)getenv('WALL_PANEL_RUNTIME_MODE')));
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_addon_options_path(): string
|
||||||
|
{
|
||||||
|
$override = trim((string)getenv('WALL_PANEL_OPTIONS_PATH'));
|
||||||
|
if ($override !== '') {
|
||||||
|
return $override;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/data/options.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));
|
||||||
|
|
||||||
|
$config = app_default_config();
|
||||||
|
$storedConfig = $config;
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
throw new RuntimeException('Failed to encode default config');
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
|
||||||
|
} else {
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false || trim($raw) !== '') {
|
||||||
|
$decoded = json_decode((string)$raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new RuntimeException('Invalid JSON in config/config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = array_replace_recursive($config, $decoded);
|
||||||
|
$storedConfig = $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$optionsPath = app_addon_options_path();
|
||||||
|
if (is_file($optionsPath)) {
|
||||||
|
$options = app_load_json_file($optionsPath, []);
|
||||||
|
if (is_array($options) && $options !== []) {
|
||||||
|
$config = array_replace_recursive($config, $options);
|
||||||
|
if (app_runtime_mode() === 'addon' && $config !== $storedConfig) {
|
||||||
|
app_save_config($config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
lib/dashboard.php
Executable file
2039
lib/dashboard.php
Executable file
File diff suppressed because it is too large
Load Diff
658
lib/ha_client.php
Executable file
658
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
lib/ha_sync.php
Executable file
4
lib/ha_sync.php
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Sync helpers are defined in lib/config.php to keep the PHP runtime self-contained.
|
||||||
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
|
||||||
19
run.sh
Executable file
19
run.sh
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DOCROOT="${WALL_PANEL_DOCROOT:-/app}"
|
||||||
|
PORT="${WALL_PANEL_PORT:-8099}"
|
||||||
|
CONFIG_PATH="${WALL_PANEL_CONFIG_PATH:-/config/config.json}"
|
||||||
|
STORAGE_DIR="${WALL_PANEL_STORAGE_DIR:-/config/storage}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$CONFIG_PATH")" "$STORAGE_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_PATH" ]; then
|
||||||
|
cp "${DOCROOT}/config/config.json" "$CONFIG_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export WALL_PANEL_CONFIG_PATH="$CONFIG_PATH"
|
||||||
|
export WALL_PANEL_STORAGE_DIR="$STORAGE_DIR"
|
||||||
|
export WALL_PANEL_RUNTIME_MODE="addon"
|
||||||
|
|
||||||
|
exec php -S "0.0.0.0:${PORT}" -t "$DOCROOT"
|
||||||
BIN
storage/@eaDir/battery_cache.json@SynoEAStream
Executable file
BIN
storage/@eaDir/battery_cache.json@SynoEAStream
Executable file
Binary file not shown.
BIN
storage/@eaDir/popup_state.json@SynoEAStream
Executable file
BIN
storage/@eaDir/popup_state.json@SynoEAStream
Executable file
Binary file not shown.
BIN
storage/@eaDir/popup_state.json@SynoResource
Executable file
BIN
storage/@eaDir/popup_state.json@SynoResource
Executable file
Binary file not shown.
884
storage/battery_cache.json
Executable file
884
storage/battery_cache.json
Executable file
@ -0,0 +1,884 @@
|
|||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"sensor.garage_motion_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.garage_light_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.garage_door_motion_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.stair_up_motion_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668367,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.stair_down_motion_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.stair_light_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.door_sensor_2_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668166,
|
||||||
|
"value": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 90
|
||||||
|
},
|
||||||
|
"sensor.wleak_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.0xa4c138433d675809_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 1
|
||||||
|
},
|
||||||
|
"sensor.0xa4c138997cb4fdd1_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668158,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668379,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669193,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.printer_knopka_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 74
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 74
|
||||||
|
},
|
||||||
|
"sensor.lestnitsa_dvizhenie_2_etazh_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.spalnia_knopka_girliand_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 29
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 29
|
||||||
|
},
|
||||||
|
"sensor.ulitsa_temperatura_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 63
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 62
|
||||||
|
},
|
||||||
|
"sensor.0x44e2f8fffeb65d8e_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773664955,
|
||||||
|
"value": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773664975,
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773680612,
|
||||||
|
"value": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773680632,
|
||||||
|
"value": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773726922,
|
||||||
|
"value": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773726944,
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773730991,
|
||||||
|
"value": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773739180,
|
||||||
|
"value": 55
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0.1639,
|
||||||
|
"forecast_reason": "Заряд не падает",
|
||||||
|
"percent": 45
|
||||||
|
},
|
||||||
|
"sensor.0x54ef4410009a6a11_battery": {
|
||||||
|
"loaded_at": 1774247175,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773642375,
|
||||||
|
"value": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773646522,
|
||||||
|
"value": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773647796,
|
||||||
|
"value": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773648329,
|
||||||
|
"value": 93
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773651401,
|
||||||
|
"value": 94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773654742,
|
||||||
|
"value": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773663946,
|
||||||
|
"value": 94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773673180,
|
||||||
|
"value": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773680014,
|
||||||
|
"value": 93
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773683229,
|
||||||
|
"value": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773686431,
|
||||||
|
"value": 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773689837,
|
||||||
|
"value": 93
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773692920,
|
||||||
|
"value": 91
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773696266,
|
||||||
|
"value": 93
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773699456,
|
||||||
|
"value": 94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773702777,
|
||||||
|
"value": 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773706218,
|
||||||
|
"value": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773709493,
|
||||||
|
"value": 91
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773712864,
|
||||||
|
"value": 92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773716073,
|
||||||
|
"value": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773719369,
|
||||||
|
"value": 93
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": 85442,
|
||||||
|
"forecast_text": "≈ 59д 8ч до разряда",
|
||||||
|
"forecast_slope_per_hour": -0.0646,
|
||||||
|
"forecast_reason": null,
|
||||||
|
"percent": 92
|
||||||
|
},
|
||||||
|
"sensor.0x00124b0035558456_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 82
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 82
|
||||||
|
},
|
||||||
|
"sensor.0xa4c13874f5fdfd2a_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 91.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 91.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 91.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669204,
|
||||||
|
"value": 91.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 93.5
|
||||||
|
},
|
||||||
|
"sensor.0x54ef44100119db20_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.door_sensor_spalnya_battery": {
|
||||||
|
"loaded_at": 1774008710,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": null,
|
||||||
|
"forecast_reason": "Недостаточно истории",
|
||||||
|
"percent": 100
|
||||||
|
},
|
||||||
|
"sensor.0x0ceff6fffe6cffc4_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773664975,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773680611,
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773680632,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773730991,
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773731013,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773735636,
|
||||||
|
"value": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773735657,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773739159,
|
||||||
|
"value": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0.0887,
|
||||||
|
"forecast_reason": "Заряд не падает",
|
||||||
|
"percent": 55
|
||||||
|
},
|
||||||
|
"sensor.0x0ceff6fffe6cdee0_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 60
|
||||||
|
},
|
||||||
|
"sensor.0x705464fffe43dee0_battery": {
|
||||||
|
"loaded_at": 1774247175,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773642375,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773646522,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773647796,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773726943,
|
||||||
|
"value": 40
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": 9734,
|
||||||
|
"forecast_text": "≈ 6д 18ч до разряда",
|
||||||
|
"forecast_slope_per_hour": -0.2157,
|
||||||
|
"forecast_reason": null,
|
||||||
|
"percent": 35
|
||||||
|
},
|
||||||
|
"sensor.0xa4c138259d164c22_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 88.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668171,
|
||||||
|
"value": 88.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 88.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 88.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 87
|
||||||
|
},
|
||||||
|
"sensor.0xa4c138fe1cdd2a21_battery": {
|
||||||
|
"loaded_at": 1774247175,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773642375,
|
||||||
|
"value": 87.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773646492,
|
||||||
|
"value": 87.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773647796,
|
||||||
|
"value": 87.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668172,
|
||||||
|
"value": 87.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 87.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 87.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773711258,
|
||||||
|
"value": 86
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": 67706,
|
||||||
|
"forecast_text": "≈ 47д до разряда",
|
||||||
|
"forecast_slope_per_hour": -0.0753,
|
||||||
|
"forecast_reason": null,
|
||||||
|
"percent": 85
|
||||||
|
},
|
||||||
|
"sensor.spalnya_temp_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": null,
|
||||||
|
"forecast_reason": "Батарея уже разряжена",
|
||||||
|
"percent": 0
|
||||||
|
},
|
||||||
|
"sensor.kukhnia_temperatura_battery": {
|
||||||
|
"loaded_at": 1774264856,
|
||||||
|
"history_hours": 4320,
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"timestamp": 1773660056,
|
||||||
|
"value": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668185,
|
||||||
|
"value": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773668401,
|
||||||
|
"value": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1773669214,
|
||||||
|
"value": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"forecast_minutes_left": null,
|
||||||
|
"forecast_text": null,
|
||||||
|
"forecast_slope_per_hour": 0,
|
||||||
|
"forecast_reason": "Нет заметного разряда",
|
||||||
|
"percent": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
storage/popup_state.json
Executable file
6
storage/popup_state.json
Executable file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"active": false,
|
||||||
|
"sensor_entity_id": "binary_sensor.barn_all_occupancy",
|
||||||
|
"opened_at": 1774435265,
|
||||||
|
"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"]
|
||||||
161
wall_panel/README.md
Executable file
161
wall_panel/README.md
Executable file
@ -0,0 +1,161 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
Частые настройки можно менять прямо из UI add-on в Home Assistant:
|
||||||
|
|
||||||
|
- `app.title`
|
||||||
|
- `app.poll_interval_ms`
|
||||||
|
- `app.main_room_name`
|
||||||
|
- `app.main_room_icon`
|
||||||
|
- `app.edit_mode`
|
||||||
|
- `app.battery_history_hours`
|
||||||
|
- `home_assistant.base_url`
|
||||||
|
- `home_assistant.token`
|
||||||
|
- `home_assistant.verify_ssl`
|
||||||
|
- `home_assistant.weather_entity_id`
|
||||||
|
- `camera.rtsp_url`
|
||||||
|
- `camera.stream_url`
|
||||||
|
- `camera.stream_mode`
|
||||||
|
- `camera.poster_url`
|
||||||
|
- `camera.popup_timeout_minutes`
|
||||||
|
|
||||||
|
Сложные структуры, вроде `rooms`, по-прежнему удобнее держать в JSON.
|
||||||
|
|
||||||
|
### Старый 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
62
wall_panel/config.yaml
Executable file
62
wall_panel/config.yaml
Executable file
@ -0,0 +1,62 @@
|
|||||||
|
name: Wall Panel
|
||||||
|
description: Wall Panel PHP interface as a Home Assistant add-on
|
||||||
|
version: "1.0.1"
|
||||||
|
slug: wall_panel
|
||||||
|
url: https://git.striker72rus.ru/PHP/wallpanell.git
|
||||||
|
init: false
|
||||||
|
arch:
|
||||||
|
- aarch64
|
||||||
|
- amd64
|
||||||
|
- armhf
|
||||||
|
- armv7
|
||||||
|
- i386
|
||||||
|
startup: services
|
||||||
|
ingress: true
|
||||||
|
ingress_port: 8099
|
||||||
|
panel_title: Wall Panel
|
||||||
|
panel_icon: mdi:view-dashboard
|
||||||
|
ports:
|
||||||
|
8099/tcp: 8099
|
||||||
|
ports_description:
|
||||||
|
8099/tcp: Wall Panel web UI
|
||||||
|
map:
|
||||||
|
- addon_config:rw
|
||||||
|
homeassistant_api: true
|
||||||
|
options:
|
||||||
|
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
|
||||||
|
weather_entity_id: ""
|
||||||
|
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
|
||||||
|
schema:
|
||||||
|
app:
|
||||||
|
title: str
|
||||||
|
poll_interval_ms: int
|
||||||
|
main_room_name: str
|
||||||
|
main_room_icon: str
|
||||||
|
edit_mode: bool
|
||||||
|
battery_history_hours: int
|
||||||
|
home_assistant:
|
||||||
|
base_url: str
|
||||||
|
token: str
|
||||||
|
verify_ssl: bool
|
||||||
|
weather_entity_id: str
|
||||||
|
camera:
|
||||||
|
rtsp_url: str
|
||||||
|
stream_url: str
|
||||||
|
stream_mode: str
|
||||||
|
poster_url: str
|
||||||
|
popup_timeout_minutes: int
|
||||||
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';
|
||||||
333
wall_panel/lib/config.php
Executable file
333
wall_panel/lib/config.php
Executable file
@ -0,0 +1,333 @@
|
|||||||
|
<?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));
|
||||||
|
|
||||||
|
$config = app_default_config();
|
||||||
|
$storedConfig = $config;
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
throw new RuntimeException('Failed to encode default config');
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
|
||||||
|
} else {
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false || trim($raw) !== '') {
|
||||||
|
$decoded = json_decode((string)$raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new RuntimeException('Invalid JSON in config/config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = array_replace_recursive($config, $decoded);
|
||||||
|
$storedConfig = $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$optionsPath = app_addon_options_path();
|
||||||
|
if (is_file($optionsPath)) {
|
||||||
|
$options = app_load_json_file($optionsPath, []);
|
||||||
|
if (is_array($options) && $options !== []) {
|
||||||
|
$config = array_replace_recursive($config, $options);
|
||||||
|
if (app_runtime_mode() === 'addon' && $config !== $storedConfig) {
|
||||||
|
app_save_config($config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
8
wallpanell.code-workspace
Executable file
8
wallpanell.code-workspace
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user