This commit is contained in:
Striker72rus 2026-03-19 21:27:01 +03:00
commit 0f7b410ede
41 changed files with 8720 additions and 0 deletions

BIN
@eaDir/.git@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/README.md@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/README.md@SynoResource Executable file

Binary file not shown.

BIN
@eaDir/api.php@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/api.php@SynoResource Executable file

Binary file not shown.

BIN
@eaDir/assets@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/config@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/favicon.ico@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/favicon.ico@SynoResource Executable file

Binary file not shown.

BIN
@eaDir/index.php@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/index.php@SynoResource Executable file

Binary file not shown.

BIN
@eaDir/lib@SynoEAStream Executable file

Binary file not shown.

BIN
@eaDir/storage@SynoEAStream Executable file

Binary file not shown.

70
README.md Executable file
View 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
View 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);
}

Binary file not shown.

Binary file not shown.

BIN
assets/@eaDir/app.js@SynoEAStream Executable file

Binary file not shown.

BIN
assets/@eaDir/app.js@SynoResource Executable file

Binary file not shown.

1893
assets/app.css Executable file

File diff suppressed because it is too large Load Diff

3566
assets/app.js Executable file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

1058
config/config.json Executable file

File diff suppressed because it is too large Load Diff

BIN
favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

123
index.php Executable file
View 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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
lib/bootstrap.php Executable file
View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
define('APP_ROOT', dirname(__DIR__));
require_once APP_ROOT . '/lib/config.php';
require_once APP_ROOT . '/lib/ha_client.php';
require_once APP_ROOT . '/lib/dashboard.php';

117
lib/config.php Executable file
View 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

File diff suppressed because it is too large Load Diff

657
lib/ha_client.php Executable file
View 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];
}
}

Binary file not shown.

Binary file not shown.

6
storage/popup_state.json Executable file
View File

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