658 lines
22 KiB
PHP
Executable File
658 lines
22 KiB
PHP
Executable File
<?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];
|
||
}
|
||
}
|