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