wallpanell/lib/ha_client.php
2026-03-19 21:27:01 +03:00

658 lines
22 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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];
}
}