-
This commit is contained in:
commit
0f7b410ede
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.
70
README.md
Executable file
70
README.md
Executable file
@ -0,0 +1,70 @@
|
|||||||
|
# Wall Panel
|
||||||
|
|
||||||
|
Таблет-ориентированная панель для Home Assistant на `PHP + HTML + JS`.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -S 0.0.0.0:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте `http://localhost:8080`.
|
||||||
|
|
||||||
|
## Конфиг
|
||||||
|
|
||||||
|
Основной файл:
|
||||||
|
|
||||||
|
- [`config/config.json`](/Users/striker/SynologyDrive/developer/HomeAssistant/wallpanell/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 с тестовыми карточками.
|
||||||
|
|
||||||
|
## 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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
158
api.php
Executable file
158
api.php
Executable file
@ -0,0 +1,158 @@
|
|||||||
|
<?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 ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
|
||||||
|
$config = app_update_room_override($config, $roomId, $patch);
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
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.
1893
assets/app.css
Executable file
1893
assets/app.css
Executable file
File diff suppressed because it is too large
Load Diff
3566
assets/app.js
Executable file
3566
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.
1058
config/config.json
Executable file
1058
config/config.json
Executable file
File diff suppressed because it is too large
Load Diff
BIN
favicon.ico
Executable file
BIN
favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
123
index.php
Executable file
123
index.php
Executable file
@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
$config = app_load_config();
|
||||||
|
$client = new HomeAssistantClient($config);
|
||||||
|
$bootstrap = app_build_snapshot($config, $client, 'main');
|
||||||
|
$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.16">
|
||||||
|
<script src="assets/app.js?v=0.16" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</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="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/dashboard.php';
|
||||||
|
|
||||||
117
lib/config.php
Executable file
117
lib/config.php
Executable file
@ -0,0 +1,117 @@
|
|||||||
|
<?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,
|
||||||
|
],
|
||||||
|
'home_assistant' => [
|
||||||
|
'base_url' => '',
|
||||||
|
'token' => '',
|
||||||
|
'verify_ssl' => true,
|
||||||
|
'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
|
||||||
|
{
|
||||||
|
return APP_ROOT . '/config/config.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_storage_path(string $file): string
|
||||||
|
{
|
||||||
|
return APP_ROOT . '/storage/' . ltrim($file, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_ensure_directory(string $path): void
|
||||||
|
{
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
mkdir($path, 0775, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_load_config(): array
|
||||||
|
{
|
||||||
|
$path = app_config_path();
|
||||||
|
app_ensure_directory(dirname($path));
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
$defaults = app_default_config();
|
||||||
|
file_put_contents($path, json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
return $defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false || trim($raw) === '') {
|
||||||
|
return app_default_config();
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new RuntimeException('Invalid JSON in config/config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_replace_recursive(app_default_config(), $decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_save_config(array $config): void
|
||||||
|
{
|
||||||
|
$path = app_config_path();
|
||||||
|
app_ensure_directory(dirname($path));
|
||||||
|
|
||||||
|
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
throw new RuntimeException('Failed to encode config');
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_load_json_file(string $path, array $fallback = []): array
|
||||||
|
{
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false || trim($raw) === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
return is_array($decoded) ? $decoded : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_save_json_file(string $path, array $data): void
|
||||||
|
{
|
||||||
|
app_ensure_directory(dirname($path));
|
||||||
|
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
throw new RuntimeException('Failed to encode JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($path, $json . PHP_EOL, LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
1063
lib/dashboard.php
Executable file
1063
lib/dashboard.php
Executable file
File diff suppressed because it is too large
Load Diff
657
lib/ha_client.php
Executable file
657
lib/ha_client.php
Executable file
@ -0,0 +1,657 @@
|
|||||||
|
<?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,
|
||||||
|
'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];
|
||||||
|
}
|
||||||
|
}
|
||||||
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.
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": 1773938797,
|
||||||
|
"expires_at": null
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user