wallpanell/lib/dashboard.php
2026-03-20 14:31:39 +03:00

2033 lines
69 KiB
PHP
Executable File

<?php
declare(strict_types=1);
function app_is_placeholder(string $value): bool
{
$value = trim($value);
return $value === '' || str_contains($value, 'PASTE_') || str_contains($value, 'YOUR_');
}
function app_trigger_entities(array $config): array
{
return array_values(array_filter(array_map('strval', $config['camera']['trigger_entities'] ?? [])));
}
function app_main_weather_actions(array $config): array
{
$items = $config['app']['main_weather_actions'] ?? [];
if (!is_array($items)) {
return [];
}
$actions = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$entityId = trim((string)($item['entity_id'] ?? ''));
if ($entityId === '') {
continue;
}
$actions[] = [
'entity_id' => $entityId,
'state_entity_id' => trim((string)($item['state_entity_id'] ?? $entityId)) ?: $entityId,
'command' => trim((string)($item['command'] ?? 'set_temperature')) ?: 'set_temperature',
'value' => array_key_exists('value', $item) ? $item['value'] : null,
'active_value' => array_key_exists('active_value', $item) ? $item['active_value'] : (array_key_exists('value', $item) ? $item['value'] : null),
'label_active' => (string)($item['label_active'] ?? $item['active_label'] ?? $item['label'] ?? ''),
'label_inactive' => (string)($item['label_inactive'] ?? $item['inactive_label'] ?? $item['label'] ?? ''),
'icon' => (string)($item['icon'] ?? 'mdi:thermometer'),
'active_color' => (string)($item['active_color'] ?? '#4caf50'),
'inactive_color' => (string)($item['inactive_color'] ?? '#c8e6c9'),
'active_text_color' => (string)($item['active_text_color'] ?? 'white'),
'inactive_text_color' => (string)($item['inactive_text_color'] ?? 'black'),
'active_icon_color' => (string)($item['active_icon_color'] ?? 'white'),
'inactive_icon_color' => (string)($item['inactive_icon_color'] ?? 'gray'),
];
}
return array_values($actions);
}
function app_main_boiler(array $config): array
{
$boiler = $config['app']['main_boiler'] ?? [];
if (!is_array($boiler)) {
$boiler = [];
}
return [
'title' => (string)($boiler['title'] ?? 'Бойлер'),
'sensor_entity_id' => trim((string)($boiler['sensor_entity_id'] ?? '')),
'history_hours' => max(1, (int)($boiler['history_hours'] ?? 24)),
];
}
function app_main_print(array $config): array
{
$print = $config['app']['main_print'] ?? [];
if (!is_array($print)) {
$print = [];
}
return [
'title' => (string)($print['title'] ?? 'Печать'),
'current_stage_entity_id' => trim((string)($print['current_stage_entity_id'] ?? '')),
'print_progress_entity_id' => trim((string)($print['print_progress_entity_id'] ?? '')),
'start_time_entity_id' => trim((string)($print['start_time_entity_id'] ?? '')),
'end_time_entity_id' => trim((string)($print['end_time_entity_id'] ?? '')),
];
}
function app_state_file(): string
{
return app_storage_path('popup_state.json');
}
function app_load_popup_state(): array
{
return app_load_json_file(app_state_file(), [
'active' => false,
'sensor_entity_id' => null,
'opened_at' => null,
'expires_at' => null,
]);
}
function app_save_popup_state(array $state): void
{
app_save_json_file(app_state_file(), $state);
}
function app_clear_expired_popup_state(array $config): array
{
$state = app_load_popup_state();
if (!($state['active'] ?? false)) {
return $state;
}
$expiresAt = (int)($state['expires_at'] ?? 0);
if ($expiresAt > 0 && time() >= $expiresAt) {
$state['active'] = false;
$state['expires_at'] = null;
app_save_popup_state($state);
}
return $state;
}
function app_popup_close_delay_seconds(): int
{
return 30;
}
function app_default_card_type(string $entityId): string
{
$domain = explode('.', $entityId, 2)[0] ?? '';
return match ($domain) {
'cover' => 'cover',
'climate' => 'climate',
'light', 'switch' => 'toggle',
default => 'toggle',
};
}
function app_entity_domain(string $entityId): string
{
return explode('.', $entityId, 2)[0] ?? 'unknown';
}
function app_entity_name(array $entity, array $registryEntry = [], array $override = []): string
{
if (!empty($override['title'])) {
return (string)$override['title'];
}
$domain = app_entity_domain((string)($entity['entity_id'] ?? ''));
if ($domain === 'fan' && !empty($registryEntry['name'])) {
return (string)$registryEntry['name'];
}
if (!empty($entity['attributes']['friendly_name'])) {
return (string)$entity['attributes']['friendly_name'];
}
if (!empty($registryEntry['name'])) {
return (string)$registryEntry['name'];
}
return $entity['entity_id'];
}
function app_entity_icon(array $entity, array $override = []): string
{
if (!empty($override['icon'])) {
return (string)$override['icon'];
}
if (!empty($entity['attributes']['icon'])) {
return (string)$entity['attributes']['icon'];
}
return match (app_entity_domain((string)$entity['entity_id'])) {
'light' => 'mdi:lightbulb',
'switch' => 'mdi:toggle-switch',
'cover' => 'mdi:curtains',
'climate' => 'mdi:air-conditioner',
'weather' => 'mdi:weather-partly-cloudy',
'binary_sensor' => 'mdi:motion-sensor',
default => 'mdi:devices',
};
}
function app_labels_from_entity(array $entity, array $registryEntry = []): array
{
$labels = [];
foreach ([
$entity['attributes']['labels'] ?? null,
$entity['attributes']['label_ids'] ?? null,
$entity['labels'] ?? null,
$registryEntry['labels'] ?? null,
$registryEntry['label_ids'] ?? null,
] as $source) {
$labels = array_merge($labels, app_flatten_label_source($source));
}
return array_values(array_unique(array_map('strval', $labels)));
}
function app_entity_has_auto_label(array $labels, string $autoLabel): bool
{
$needle = strtolower(trim($autoLabel));
if ($needle === '') {
return false;
}
foreach ($labels as $label) {
$candidate = strtolower(trim((string)$label));
if ($candidate === '') {
continue;
}
if ($candidate === $needle || str_contains($candidate, $needle)) {
return true;
}
}
return false;
}
function app_flatten_label_source(mixed $source): array
{
if ($source === null) {
return [];
}
if (is_string($source) || is_numeric($source)) {
return [(string)$source];
}
if (!is_array($source)) {
return [];
}
$labels = [];
foreach ($source as $item) {
if (is_string($item) || is_numeric($item)) {
$labels[] = (string)$item;
continue;
}
if (is_array($item)) {
foreach (['name', 'label', 'id', 'label_id', 'entity_id'] as $key) {
if (!empty($item[$key])) {
$labels[] = (string)$item[$key];
}
}
}
}
return $labels;
}
function app_is_active_entity(array $entity): bool
{
$state = strtolower((string)($entity['state'] ?? ''));
$domain = app_entity_domain((string)$entity['entity_id']);
$deviceClass = strtolower((string)($entity['attributes']['device_class'] ?? ''));
if (in_array($state, ['unavailable', 'unknown', 'none'], true)) {
return false;
}
return match ($domain) {
'binary_sensor' => $deviceClass === 'door'
? in_array($state, ['on', 'open'], true)
: !in_array($state, ['off', 'false', '0', 'idle'], true),
'cover' => in_array($state, ['open', 'opening', 'closing'], true),
'climate' => !in_array($state, ['off', 'unavailable', 'unknown'], true),
default => !in_array($state, ['off', 'false', '0', 'idle'], true),
};
}
function app_is_door_contact_entity(array $entity, array $registryEntry = []): bool
{
$entityId = (string)($entity['entity_id'] ?? '');
if ($entityId === '' || app_entity_domain($entityId) !== 'binary_sensor') {
return false;
}
$deviceClass = strtolower((string)($entity['attributes']['device_class'] ?? ''));
if (in_array($deviceClass, ['door', 'garage_door'], true)) {
return true;
}
$name = strtolower((string)($entity['attributes']['friendly_name'] ?? $registryEntry['name'] ?? ''));
if ($name !== '' && (str_contains($name, 'door') || str_contains($name, 'двер'))) {
return true;
}
return false;
}
function app_select_weather_entity(array $states, array $config): ?array
{
$preferred = trim((string)($config['home_assistant']['weather_entity_id'] ?? ''));
if ($preferred !== '') {
foreach ($states as $entity) {
if (($entity['entity_id'] ?? null) === $preferred) {
return $entity;
}
}
}
foreach ($states as $entity) {
if (($entity['entity_id'] ?? null) === 'weather.yandex_weather') {
return $entity;
}
}
foreach ($states as $entity) {
if (app_entity_domain((string)($entity['entity_id'] ?? '')) === 'weather') {
return $entity;
}
}
return null;
}
function app_registry_map(array $entityRegistry): array
{
$map = [];
foreach ($entityRegistry as $item) {
if (!empty($item['entity_id'])) {
$map[(string)$item['entity_id']] = $item;
}
}
return $map;
}
function app_device_registry_map(array $deviceRegistry): array
{
$map = [];
foreach ($deviceRegistry as $item) {
if (!is_array($item)) {
continue;
}
$deviceId = (string)($item['device_id'] ?? $item['id'] ?? $item['di'] ?? '');
if ($deviceId === '') {
continue;
}
$map[$deviceId] = $item;
}
return $map;
}
function app_device_display_name(array $device, string $fallback = ''): string
{
$name = trim((string)($device['name_by_user'] ?? $device['name'] ?? $device['original_name'] ?? ''));
if ($name !== '') {
return $name;
}
$fallback = trim($fallback);
return $fallback !== '' ? $fallback : 'Без устройства';
}
function app_room_definitions(array $config, array $haData): array
{
$rooms = [];
$areasById = [];
$floorsById = [];
$roomOverrides = [];
foreach ($haData['floors'] ?? [] as $floor) {
$floorId = (string)($floor['floor_id'] ?? $floor['id'] ?? '');
if ($floorId === '') {
continue;
}
$floorsById[$floorId] = [
'floor_id' => $floorId,
'name' => (string)($floor['name'] ?? $floorId),
'icon' => (string)($floor['icon'] ?? 'mdi:stairs'),
'level' => isset($floor['level']) ? (int)$floor['level'] : 0,
];
}
foreach ($haData['areas'] ?? [] as $area) {
$areaId = (string)($area['area_id'] ?? $area['id'] ?? '');
if ($areaId === '') {
continue;
}
$area['area_id'] = $areaId;
$areasById[$areaId] = $area;
}
$configuredRooms = $config['rooms'] ?? [];
if (!is_array($configuredRooms)) {
$configuredRooms = [];
}
foreach ($configuredRooms as $room) {
if (!is_array($room) || empty($room['id'])) {
continue;
}
$room['visible'] = $room['visible'] ?? true;
$room['order'] = isset($room['order']) ? (int)$room['order'] : 9999;
$room['temperature_sensor_entity_id'] = trim((string)($room['temperature_sensor_entity_id'] ?? ''));
$room['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : [];
$room['layout_items'] = app_room_layout_items($room);
$roomOverrides[(string)$room['id']] = $room;
}
foreach ($areasById as $areaId => $area) {
$floorId = (string)($area['floor_id'] ?? $area['fi'] ?? $area['floor'] ?? '');
if ($floorId === '' || !isset($floorsById[$floorId])) {
continue;
}
$room = $roomOverrides[$areaId] ?? [];
$overrideName = trim((string)($room['name'] ?? ''));
if ($overrideName === '' || $overrideName === $areaId) {
$overrideName = '';
}
$rooms[$areaId] = [
'id' => $areaId,
'name' => $overrideName !== '' ? $overrideName : (string)($area['name'] ?? $areaId),
'icon' => (string)($room['icon'] ?? $area['icon'] ?? 'mdi:home-variant'),
'area_id' => $areaId,
'floor_id' => $floorId,
'floor_name' => (string)($room['floor_name'] ?? $floorsById[$floorId]['name'] ?? $floorId),
'floor_level' => (int)($room['floor_level'] ?? $floorsById[$floorId]['level'] ?? 0),
'visible' => array_key_exists('visible', $room) ? (bool)$room['visible'] : true,
'order' => isset($room['order']) ? (int)$room['order'] : 9999,
'entity_ids' => is_array($room['entity_ids'] ?? null) ? $room['entity_ids'] : [],
'entity_overrides' => is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : [],
'layout_items' => app_room_layout_items($room),
'temperature_sensor_entity_id' => trim((string)($room['temperature_sensor_entity_id'] ?? '')),
];
}
$rooms = array_values($rooms);
usort($rooms, static function (array $a, array $b): int {
$visibleA = (bool)($a['visible'] ?? true);
$visibleB = (bool)($b['visible'] ?? true);
if ($visibleA !== $visibleB) {
return $visibleA ? -1 : 1;
}
$orderA = (int)($a['order'] ?? 9999);
$orderB = (int)($b['order'] ?? 9999);
if ($orderA !== $orderB) {
return $orderA <=> $orderB;
}
$floorLevelA = (int)($a['floor_level'] ?? 0);
$floorLevelB = (int)($b['floor_level'] ?? 0);
if ($floorLevelA !== $floorLevelB) {
return $floorLevelA <=> $floorLevelB;
}
return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? ''));
});
return $rooms;
}
function app_room_layout_items(array $room): array
{
$items = $room['layout_items'] ?? [];
if (!is_array($items)) {
return [];
}
$normalized = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$itemId = trim((string)($item['id'] ?? $item['layout_item_id'] ?? ''));
$type = trim((string)($item['type'] ?? 'ghost')) ?: 'ghost';
if ($itemId === '' || $type !== 'ghost') {
continue;
}
$normalized[] = [
'id' => $itemId,
'type' => 'ghost',
'order' => isset($item['order']) ? (int)$item['order'] : 9999,
];
}
usort($normalized, static function (array $a, array $b): int {
$orderA = (int)($a['order'] ?? 9999);
$orderB = (int)($b['order'] ?? 9999);
if ($orderA !== $orderB) {
return $orderA <=> $orderB;
}
return strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? ''));
});
return $normalized;
}
function app_label_matches(string $label, string $needle): bool
{
$label = trim($label);
$needle = trim($needle);
if ($label === '' || $needle === '') {
return false;
}
$normalize = static function (string $value): string {
$value = preg_replace('/\s+/u', ' ', trim($value)) ?? trim($value);
return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value);
};
$labelNorm = $normalize($label);
$needleNorm = $normalize($needle);
return $labelNorm === $needleNorm || str_contains($labelNorm, $needleNorm);
}
function app_battery_label_matches(string $label): bool
{
return app_label_matches($label, 'Батарейка')
|| app_label_matches($label, 'Battery')
|| app_label_matches($label, 'batareika')
|| app_label_matches($label, 'batareyka');
}
function pluralizeRu(int $count, string $one, string $few, string $many): string
{
$count = abs($count);
$mod10 = $count % 10;
$mod100 = $count % 100;
if ($mod10 === 1 && $mod100 !== 11) {
return $one;
}
if ($mod10 >= 2 && $mod10 <= 4 && !($mod100 >= 12 && $mod100 <= 14)) {
return $few;
}
return $many;
}
function app_battery_cache_path(): string
{
return app_storage_path('battery_cache.json');
}
function app_load_battery_cache(): array
{
return app_load_json_file(app_battery_cache_path(), [
'items' => [],
]);
}
function app_save_battery_cache(array $cache): void
{
app_save_json_file(app_battery_cache_path(), $cache);
}
function app_battery_status_rank(string $status): int
{
return match ($status) {
'empty' => 0,
'critical' => 1,
'low' => 2,
'unavailable' => 3,
'unknown' => 4,
default => 5,
};
}
function app_battery_status_label(string $status): string
{
return match ($status) {
'empty' => 'Разряжена',
'critical' => 'Скоро разрядится',
'low' => 'Низкий заряд',
'unavailable' => 'Недоступно',
'unknown' => 'Неизвестно',
default => 'Норма',
};
}
function app_battery_percent_text(?float $percent): ?string
{
if ($percent === null) {
return null;
}
$value = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.');
return $value . '%';
}
function app_battery_percentage_from_entity(array $entity): ?float
{
$candidates = [
$entity['state'] ?? null,
$entity['attributes']['battery_level'] ?? null,
$entity['attributes']['battery_percentage'] ?? null,
$entity['attributes']['battery'] ?? null,
$entity['attributes']['percentage'] ?? null,
];
foreach ($candidates as $candidate) {
if ($candidate === null) {
continue;
}
$text = trim((string)$candidate);
if ($text === '') {
continue;
}
$text = str_replace(',', '.', $text);
$text = rtrim($text, '%');
if (!is_numeric($text)) {
continue;
}
$value = (float)$text;
if ($value < 0) {
continue;
}
if ($value <= 100) {
return round($value, 1);
}
}
return null;
}
function app_battery_status_from_entity(array $entity, ?float $percent): string
{
$state = strtolower(trim((string)($entity['state'] ?? '')));
if ($state === 'unavailable') {
return 'unavailable';
}
if ($state === 'unknown' || $state === '') {
return $percent === null ? 'unknown' : 'ok';
}
if ($percent === null) {
return 'unknown';
}
if ($percent <= 0) {
return 'empty';
}
if ($percent <= 10) {
return 'critical';
}
if ($percent <= 30) {
return 'low';
}
return 'ok';
}
function app_battery_icon_for_status(string $status, ?float $percent = null): string
{
return match ($status) {
'empty' => 'mdi:battery-outline',
'critical' => 'mdi:battery-alert',
'low' => 'mdi:battery-20',
'unavailable' => 'mdi:battery-off-outline',
'unknown' => 'mdi:battery-unknown',
default => $percent !== null && $percent >= 80 ? 'mdi:battery' : 'mdi:battery-medium',
};
}
function app_battery_forecast_duration_text(int $minutes): string
{
$minutes = max(0, $minutes);
$days = intdiv($minutes, 1440);
$hours = intdiv($minutes % 1440, 60);
$mins = $minutes % 60;
if ($days > 0) {
$pieces = [$days . 'д'];
if ($hours > 0) {
$pieces[] = $hours . 'ч';
}
return implode(' ', $pieces);
}
if ($hours > 0) {
$pieces = [$hours . 'ч'];
if ($mins > 0) {
$pieces[] = $mins . 'м';
}
return implode(' ', $pieces);
}
return $mins . 'м';
}
function app_battery_history_points(array $history): array
{
$points = [];
$groups = array_is_list($history) && isset($history[0]) && is_array($history[0]) ? $history : [$history];
foreach ($groups as $group) {
if (!is_array($group)) {
continue;
}
foreach ($group as $entry) {
if (!is_array($entry)) {
continue;
}
$rawValue = $entry['state'] ?? $entry['value'] ?? null;
if ($rawValue === null) {
continue;
}
$valueText = str_replace(',', '.', trim((string)$rawValue));
$valueText = rtrim($valueText, '%');
if (!is_numeric($valueText)) {
continue;
}
$value = (float)$valueText;
if ($value < 0 || $value > 100) {
continue;
}
$timestamp = null;
foreach (['last_changed', 'last_updated', 'ts', 'timestamp'] as $key) {
if (!empty($entry[$key])) {
$parsed = strtotime((string)$entry[$key]);
if ($parsed !== false) {
$timestamp = $parsed;
break;
}
}
}
if ($timestamp === null) {
continue;
}
$points[] = [
'timestamp' => $timestamp,
'value' => round($value, 1),
];
}
}
usort($points, static function (array $a, array $b): int {
return ($a['timestamp'] ?? 0) <=> ($b['timestamp'] ?? 0);
});
$deduped = [];
foreach ($points as $point) {
$last = $deduped[array_key_last($deduped)] ?? null;
if ($last && (int)($last['timestamp'] ?? 0) === (int)($point['timestamp'] ?? 0)) {
$deduped[array_key_last($deduped)] = $point;
continue;
}
$deduped[] = $point;
}
return $deduped;
}
function app_battery_forecast_from_points(array $points, ?float $currentPercent): array
{
$percent = $currentPercent !== null ? max(0.0, min(100.0, $currentPercent)) : null;
$count = count($points);
if ($count < 3 || $percent === null || $percent <= 0) {
return [
'forecast_minutes_left' => null,
'forecast_text' => null,
'forecast_slope_per_hour' => null,
'forecast_reason' => $count < 3
? 'Недостаточно истории'
: ($percent === null ? 'Нет числового значения' : 'Батарея уже разряжена'),
];
}
$subset = array_slice($points, max(0, $count - 20));
$firstTs = (int)($subset[0]['timestamp'] ?? 0);
$sumX = 0.0;
$sumY = 0.0;
$sumXX = 0.0;
$sumXY = 0.0;
$n = 0;
foreach ($subset as $point) {
$ts = (int)($point['timestamp'] ?? 0);
$value = (float)($point['value'] ?? 0);
if ($ts <= 0) {
continue;
}
$x = ($ts - $firstTs) / 3600;
$y = $value;
$sumX += $x;
$sumY += $y;
$sumXX += $x * $x;
$sumXY += $x * $y;
$n++;
}
if ($n < 3) {
return [
'forecast_minutes_left' => null,
'forecast_text' => null,
'forecast_slope_per_hour' => null,
'forecast_reason' => 'Недостаточно истории',
];
}
$denominator = ($n * $sumXX) - ($sumX * $sumX);
if (abs($denominator) < 1e-9) {
return [
'forecast_minutes_left' => null,
'forecast_text' => null,
'forecast_slope_per_hour' => null,
'forecast_reason' => 'Нет заметного разряда',
];
}
$slopePerHour = (($n * $sumXY) - ($sumX * $sumY)) / $denominator;
if ($slopePerHour >= -0.01) {
return [
'forecast_minutes_left' => null,
'forecast_text' => null,
'forecast_slope_per_hour' => round($slopePerHour, 4),
'forecast_reason' => $slopePerHour > 0.01 ? 'Заряд не падает' : 'Нет заметного разряда',
];
}
$minutesLeft = (int)ceil($percent / abs($slopePerHour) * 60);
if ($minutesLeft <= 0 || $minutesLeft > 525600) {
return [
'forecast_minutes_left' => null,
'forecast_text' => null,
'forecast_slope_per_hour' => round($slopePerHour, 4),
'forecast_reason' => 'Прогноз вне диапазона',
];
}
return [
'forecast_minutes_left' => $minutesLeft,
'forecast_text' => '≈ ' . app_battery_forecast_duration_text($minutesLeft) . ' до разряда',
'forecast_slope_per_hour' => round($slopePerHour, 4),
'forecast_reason' => null,
];
}
function app_battery_source_room_name(array $entityIndexItem, array $roomNameByAreaId): string
{
$areaId = trim((string)($entityIndexItem['area_id'] ?? ''));
if ($areaId !== '' && isset($roomNameByAreaId[$areaId])) {
return (string)$roomNameByAreaId[$areaId];
}
return $areaId !== '' ? $areaId : 'Без комнаты';
}
function app_battery_item_status_summary(array $items): array
{
$counts = [
'empty' => 0,
'critical' => 0,
'low' => 0,
'unavailable' => 0,
'unknown' => 0,
'ok' => 0,
];
foreach ($items as $item) {
$status = (string)($item['battery_status'] ?? 'unknown');
if (isset($counts[$status])) {
$counts[$status]++;
}
}
return $counts;
}
function app_battery_summary_text(array $counts, int $total): string
{
$problem = ($counts['empty'] ?? 0) + ($counts['critical'] ?? 0) + ($counts['low'] ?? 0);
$unavailable = (int)($counts['unavailable'] ?? 0);
$unknown = (int)($counts['unknown'] ?? 0);
$parts = [];
if ($problem > 0) {
$parts[] = $problem . ' ' . pluralizeRu($problem, 'проблемная', 'проблемных', 'проблемных');
}
if ($unavailable > 0) {
$parts[] = $unavailable . ' ' . pluralizeRu($unavailable, 'недоступная', 'недоступных', 'недоступных');
}
if ($unknown > 0) {
$parts[] = $unknown . ' ' . pluralizeRu($unknown, 'неизвестная', 'неизвестных', 'неизвестных');
}
if ($parts) {
return implode(' · ', $parts);
}
return $total > 0
? $total . ' ' . pluralizeRu($total, 'батарейка', 'батарейки', 'батареек')
: 'Нет батареек';
}
function app_battery_room(array $config, array $haData, array $rooms, HomeAssistantClient $client, bool $refreshForecast = false): array
{
$entityIndex = app_entity_index($config, $haData);
$registryMap = app_registry_map($haData['entity_registry'] ?? []);
$deviceMap = app_device_registry_map($haData['device_registry'] ?? []);
$roomNameByAreaId = [];
foreach ($rooms as $room) {
if (!is_array($room)) {
continue;
}
$areaId = trim((string)($room['area_id'] ?? $room['id'] ?? ''));
if ($areaId === '') {
continue;
}
$roomNameByAreaId[$areaId] = (string)($room['name'] ?? $areaId);
}
$cache = app_load_battery_cache();
$cacheItems = is_array($cache['items'] ?? null) ? $cache['items'] : [];
$cacheChanged = false;
$now = time();
$batteryEntities = [];
$deviceGroups = [];
foreach ($entityIndex as $entityId => $item) {
$registryEntry = is_array($registryMap[$entityId] ?? null) ? $registryMap[$entityId] : [];
$deviceId = trim((string)($item['device_id'] ?? $registryEntry['device_id'] ?? $registryEntry['di'] ?? ''));
$device = $deviceId !== '' && isset($deviceMap[$deviceId]) && is_array($deviceMap[$deviceId]) ? $deviceMap[$deviceId] : [];
$entityLabels = is_array($item['labels'] ?? null) ? $item['labels'] : [];
$deviceLabels = app_labels_from_entity([], $device);
$hasBatteryLabel = false;
foreach ($deviceLabels as $label) {
if (app_battery_label_matches((string)$label)) {
$hasBatteryLabel = true;
break;
}
}
if (!$hasBatteryLabel) {
continue;
}
$groupKey = $deviceId !== '' ? 'device:' . $deviceId : 'entity:' . $entityId;
if (!isset($deviceGroups[$groupKey])) {
$deviceGroups[$groupKey] = [
'device_id' => $deviceId,
'device' => $device,
'items' => [],
];
}
$deviceGroups[$groupKey]['items'][] = [
'entity' => $item,
'registry' => $registryEntry,
];
}
foreach ($deviceGroups as $group) {
$bestCandidate = null;
$bestScore = PHP_INT_MIN;
foreach ($group['items'] as $candidate) {
$candidateItem = is_array($candidate['entity'] ?? null) ? $candidate['entity'] : [];
$candidateEntityId = (string)($candidateItem['entity_id'] ?? '');
if ($candidateEntityId === '') {
continue;
}
$isBatterySensor = preg_match('/^sensor\..*_battery$/i', $candidateEntityId) === 1;
$percent = app_battery_percentage_from_entity($candidateItem);
$state = strtolower(trim((string)($candidateItem['state'] ?? '')));
$score = $isBatterySensor ? 1000 : 0;
if ($percent !== null) {
$score += 100;
}
if ($state !== '' && !in_array($state, ['unknown', 'unavailable', 'none'], true)) {
$score += 10;
}
if (($candidateItem['domain'] ?? '') === 'sensor') {
$score += 5;
}
if ($bestCandidate === null || $score > $bestScore || ($score === $bestScore && strcasecmp($candidateEntityId, (string)($bestCandidate['entity_id'] ?? '')) < 0)) {
$bestCandidate = [
'entity_id' => $candidateEntityId,
'item' => $candidateItem,
'is_battery_sensor' => $isBatterySensor,
];
$bestScore = $score;
}
}
if ($bestCandidate === null || !$bestCandidate['is_battery_sensor']) {
continue;
}
$bestItem = $bestCandidate['item'];
$entityId = (string)($bestItem['entity_id'] ?? '');
$percent = app_battery_percentage_from_entity($bestItem);
$status = app_battery_status_from_entity($bestItem, $percent);
$cacheEntry = is_array($cacheItems[$entityId] ?? null) ? $cacheItems[$entityId] : [];
$forecast = [
'forecast_minutes_left' => null,
'forecast_text' => null,
'forecast_slope_per_hour' => null,
'forecast_reason' => null,
];
$shouldRefresh = $refreshForecast
&& $percent !== null
&& !in_array($status, ['unavailable', 'unknown'], true);
$cachedAt = (int)($cacheEntry['loaded_at'] ?? 0);
$freshEnough = $cachedAt > 0 && ($now - $cachedAt) < 6 * 3600;
if ($shouldRefresh && (!$freshEnough || !isset($cacheEntry['forecast_minutes_left']))) {
try {
$history = $client->fetchEntityHistory($entityId, 168);
$points = app_battery_history_points($history);
$forecast = app_battery_forecast_from_points($points, $percent);
$cacheItems[$entityId] = [
'loaded_at' => $now,
'points' => $points,
'forecast_minutes_left' => $forecast['forecast_minutes_left'],
'forecast_text' => $forecast['forecast_text'],
'forecast_slope_per_hour' => $forecast['forecast_slope_per_hour'],
'forecast_reason' => $forecast['forecast_reason'] ?? null,
'percent' => $percent,
];
$cacheChanged = true;
} catch (Throwable $e) {
if (is_array($cacheEntry) && isset($cacheEntry['forecast_minutes_left'])) {
$forecast = [
'forecast_minutes_left' => isset($cacheEntry['forecast_minutes_left']) ? (int)$cacheEntry['forecast_minutes_left'] : null,
'forecast_text' => isset($cacheEntry['forecast_text']) ? (string)$cacheEntry['forecast_text'] : null,
'forecast_slope_per_hour' => isset($cacheEntry['forecast_slope_per_hour']) ? (float)$cacheEntry['forecast_slope_per_hour'] : null,
'forecast_reason' => isset($cacheEntry['forecast_reason']) ? (string)$cacheEntry['forecast_reason'] : null,
];
}
}
} elseif (is_array($cacheEntry) && isset($cacheEntry['forecast_minutes_left'])) {
$forecast = [
'forecast_minutes_left' => isset($cacheEntry['forecast_minutes_left']) ? (int)$cacheEntry['forecast_minutes_left'] : null,
'forecast_text' => isset($cacheEntry['forecast_text']) ? (string)$cacheEntry['forecast_text'] : null,
'forecast_slope_per_hour' => isset($cacheEntry['forecast_slope_per_hour']) ? (float)$cacheEntry['forecast_slope_per_hour'] : null,
'forecast_reason' => isset($cacheEntry['forecast_reason']) ? (string)$cacheEntry['forecast_reason'] : null,
];
}
$deviceId = (string)($group['device_id'] ?? '');
$device = is_array($group['device'] ?? null) ? $group['device'] : [];
$deviceName = app_device_display_name($device, $deviceId);
$sourceItem = $bestItem;
$sourceAreaId = trim((string)($sourceItem['area_id'] ?? ''));
if ($sourceAreaId === '' && !empty($device['area_id'])) {
$sourceAreaId = trim((string)$device['area_id']);
if ($sourceAreaId !== '') {
$sourceItem['area_id'] = $sourceAreaId;
}
}
$sourceRoomName = app_battery_source_room_name($sourceItem, $roomNameByAreaId);
$sourceText = trim($sourceRoomName);
if ($sourceText === '' || $sourceText === 'Без комнаты') {
$sourceText = $deviceName !== '' ? $deviceName : $sourceText;
} elseif ($deviceName !== '') {
$sourceText .= ' | ' . $deviceName;
}
$batteryEntities[] = [
'entity_id' => $entityId,
'name' => (string)($bestItem['name'] ?? $entityId),
'source_room_name' => $sourceRoomName,
'source_device_name' => $deviceName,
'source_text' => $sourceText,
'domain' => (string)($bestItem['domain'] ?? app_entity_domain($entityId)),
'battery_percent' => $percent,
'battery_percent_text' => app_battery_percent_text($percent),
'battery_status' => $status,
'battery_status_label' => app_battery_status_label($status),
'battery_icon' => app_battery_icon_for_status($status, $percent),
'forecast_minutes_left' => $forecast['forecast_minutes_left'],
'forecast_text' => $forecast['forecast_text'],
'forecast_reason' => $forecast['forecast_reason'] ?? null,
'last_seen_state' => (string)($bestItem['state'] ?? 'unknown'),
'labels' => $bestItem['labels'] ?? [],
'order' => (int)($bestItem['order'] ?? 9999),
];
}
if ($cacheChanged) {
app_save_battery_cache(['items' => $cacheItems]);
}
usort($batteryEntities, static function (array $a, array $b): int {
$rankA = app_battery_status_rank((string)($a['battery_status'] ?? 'ok'));
$rankB = app_battery_status_rank((string)($b['battery_status'] ?? 'ok'));
if ($rankA !== $rankB) {
return $rankA <=> $rankB;
}
$percentA = $a['battery_percent'];
$percentB = $b['battery_percent'];
if ($percentA !== null && $percentB !== null && $percentA !== $percentB) {
return $percentA <=> $percentB;
}
if ($percentA !== null && $percentB === null) {
return -1;
}
if ($percentA === null && $percentB !== null) {
return 1;
}
$forecastA = $a['forecast_minutes_left'] ?? null;
$forecastB = $b['forecast_minutes_left'] ?? null;
if ($forecastA !== null && $forecastB !== null && $forecastA !== $forecastB) {
return $forecastA <=> $forecastB;
}
if ($forecastA !== null && $forecastB === null) {
return -1;
}
if ($forecastA === null && $forecastB !== null) {
return 1;
}
return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? ''));
});
$counts = app_battery_item_status_summary($batteryEntities);
$total = count($batteryEntities);
$problem = (int)($counts['empty'] ?? 0) + (int)($counts['critical'] ?? 0) + (int)($counts['low'] ?? 0) + (int)($counts['unavailable'] ?? 0) + (int)($counts['unknown'] ?? 0);
$summaryText = app_battery_summary_text($counts, $total);
return [
'id' => 'batteries',
'name' => 'Батарейки',
'icon' => 'mdi:battery-outline',
'visible' => true,
'virtual' => true,
'layout_items' => [],
'entities' => $batteryEntities,
'entity_count' => $total,
'active_entity_count' => $problem,
'problem_count' => $problem,
'battery_counts' => $counts,
'battery_summary_text' => $summaryText,
'critical_count' => (int)($counts['empty'] ?? 0) + (int)($counts['critical'] ?? 0) + (int)($counts['low'] ?? 0),
'low_count' => (int)($counts['low'] ?? 0),
'unavailable_count' => (int)($counts['unavailable'] ?? 0),
'unknown_count' => (int)($counts['unknown'] ?? 0),
'order' => -1000,
];
}
function app_room_entities(array $room, array $haData, bool $includeHidden = false): array
{
$states = $haData['states'] ?? [];
$registryMap = app_registry_map($haData['entity_registry'] ?? []);
$deviceMap = app_device_registry_map($haData['device_registry'] ?? []);
$explicitIds = $room['entity_ids'] ?? [];
$areaId = $room['area_id'] ?? $room['id'] ?? null;
$entityOverrides = $room['entity_overrides'] ?? [];
$candidates = [];
foreach ($states as $entity) {
$entityId = (string)($entity['entity_id'] ?? '');
if ($entityId === '') {
continue;
}
$registryEntry = $registryMap[$entityId] ?? [];
$entityAreaId = (string)($registryEntry['area_id'] ?? '');
$matchesArea = $areaId !== null && $areaId !== '' && $entityAreaId === (string)$areaId;
$matchesExplicit = is_array($explicitIds) && in_array($entityId, $explicitIds, true);
$matchesDeviceArea = false;
if (!$matchesArea && !$matchesExplicit && $entityAreaId === '') {
$deviceId = (string)($registryEntry['device_id'] ?? $registryEntry['id'] ?? $registryEntry['di'] ?? '');
$deviceAreaId = $deviceId !== '' ? (string)($deviceMap[$deviceId]['area_id'] ?? '') : '';
$matchesDeviceArea = $areaId !== null && $areaId !== '' && $deviceAreaId === (string)$areaId;
}
if (!$matchesArea && !$matchesExplicit && !$matchesDeviceArea) {
continue;
}
if (!empty($registryEntry['hidden_by']) || !empty($registryEntry['disabled_by'])) {
continue;
}
$override = is_array($entityOverrides[$entityId] ?? null) ? $entityOverrides[$entityId] : [];
$isVisible = $override['visible'] ?? true;
if ($isVisible === false && !$includeHidden) {
continue;
}
$domain = app_entity_domain($entityId);
$cardType = (string)($override['card_type'] ?? app_default_card_type($entityId));
$labels = app_labels_from_entity($entity, $registryEntry);
$candidates[] = [
'entity_id' => $entityId,
'domain' => $domain,
'name' => app_entity_name($entity, $registryEntry, $override),
'icon' => app_entity_icon($entity, $override),
'state' => $entity['state'] ?? 'unknown',
'attributes' => $entity['attributes'] ?? [],
'labels' => $labels,
'area_id' => $entityAreaId ?: null,
'card_type' => $cardType,
'order' => (int)($override['order'] ?? 9999),
'subtitle' => app_entity_subtitle($entity, $cardType),
'override' => $override,
'visible' => (bool)$isVisible,
];
}
usort($candidates, static function (array $a, array $b): int {
if ($a['order'] === $b['order']) {
return strcasecmp($a['name'], $b['name']);
}
return $a['order'] <=> $b['order'];
});
return $candidates;
}
function app_entity_index(array $config, array $haData): array
{
$states = $haData['states'] ?? [];
$registryMap = app_registry_map($haData['entity_registry'] ?? []);
$autoLabel = (string)($config['home_assistant']['auto_label'] ?? 'auto');
$manualAuto = array_flip(array_map('strval', $config['home_assistant']['auto_entity_ids'] ?? []));
$items = [];
foreach ($states as $entity) {
$entityId = (string)($entity['entity_id'] ?? '');
if ($entityId === '') {
continue;
}
$registryEntry = $registryMap[$entityId] ?? [];
$labels = app_labels_from_entity($entity, $registryEntry);
$isHidden = !empty($registryEntry['hidden_by'])
|| !empty($registryEntry['disabled_by'])
|| !empty($registryEntry['entity_registry_enabled_default']) && empty($entity['state']);
$items[$entityId] = [
'entity_id' => $entityId,
'domain' => app_entity_domain($entityId),
'name' => app_entity_name($entity, $registryEntry, []),
'icon' => app_entity_icon($entity, []),
'state' => $entity['state'] ?? 'unknown',
'attributes' => $entity['attributes'] ?? [],
'labels' => $labels,
'area_id' => (string)($registryEntry['area_id'] ?? ''),
'device_id' => (string)($registryEntry['device_id'] ?? $registryEntry['di'] ?? ''),
'card_type' => app_default_card_type($entityId),
'is_door_contact' => app_is_door_contact_entity($entity, $registryEntry),
'is_auto' => app_entity_has_auto_label($labels, $autoLabel) || isset($manualAuto[$entityId]),
'is_hidden' => (bool)$isHidden,
];
}
return $items;
}
function app_entity_subtitle(array $entity, string $cardType): string
{
$state = (string)($entity['state'] ?? '');
$attr = $entity['attributes'] ?? [];
return match ($cardType) {
'cover' => isset($attr['current_position']) ? 'Позиция ' . $attr['current_position'] . '%' : match ($state) {
'open' => 'Открыто',
'closed' => 'Закрыто',
default => ucfirst($state),
},
'climate' => app_climate_subtitle($entity),
default => app_generic_subtitle($state, $attr),
};
}
function app_generic_subtitle(string $state, array $attr): string
{
if ($state === '') {
return 'Нет данных';
}
if (isset($attr['current_temperature']) && is_numeric($attr['current_temperature'])) {
return (string)$state . ' · ' . rtrim(rtrim((string)$attr['current_temperature'], '0'), '.') . '°';
}
return match ($state) {
'on' => 'Включено',
'off' => 'Выключено',
'open' => 'Открыто',
'closed' => 'Закрыто',
default => ucfirst($state),
};
}
function app_climate_subtitle(array $entity): string
{
$attr = $entity['attributes'] ?? [];
$pieces = [];
if (isset($attr['current_temperature']) && is_numeric($attr['current_temperature'])) {
$pieces[] = rtrim(rtrim((string)$attr['current_temperature'], '0'), '.') . '°';
}
if (isset($attr['temperature']) && is_numeric($attr['temperature'])) {
$pieces[] = 'Цель ' . rtrim(rtrim((string)$attr['temperature'], '0'), '.') . '°';
}
if (!empty($attr['hvac_action'])) {
$pieces[] = (string)$attr['hvac_action'];
}
return $pieces ? implode(' · ', $pieces) : 'Климат';
}
function app_room_active_count(array $items): int
{
$count = 0;
foreach ($items as $item) {
if (is_array($item) && app_is_active_entity($item)) {
$count++;
}
}
return $count;
}
function app_room_temperature_badge(array $items): ?string
{
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$attr = $item['attributes'] ?? [];
$candidate = $attr['current_temperature'] ?? $attr['temperature'] ?? null;
if ($candidate === null || !is_numeric($candidate)) {
continue;
}
return (string)round((float)$candidate) . '°';
}
return null;
}
function app_room_temperature_candidate(array $room, array $items): ?string
{
$selectedSensorId = trim((string)($room['temperature_sensor_entity_id'] ?? ''));
if ($selectedSensorId === '') {
return null;
}
$entity = null;
foreach ($items as $candidate) {
if (!is_array($candidate)) {
continue;
}
if (($candidate['entity_id'] ?? null) === $selectedSensorId) {
$entity = $candidate;
break;
}
}
if (!$entity) {
return null;
}
$entityId = (string)($entity['entity_id'] ?? '');
$attr = $entity['attributes'] ?? [];
$candidate = $attr['current_temperature'] ?? $attr['temperature'] ?? $entity['state'] ?? null;
if ($candidate === null || !is_numeric($candidate)) {
return null;
}
$domain = app_entity_domain($entityId);
$deviceClass = strtolower((string)($attr['device_class'] ?? ''));
$unit = strtolower(trim((string)($attr['unit_of_measurement'] ?? '')));
if ($domain !== 'sensor') {
return null;
}
if ($domain === 'sensor' && $deviceClass !== 'temperature' && $unit !== '°c' && $unit !== 'c' && !str_ends_with($entityId, '_temperature')) {
return null;
}
return (string)round((float)$candidate) . '°';
}
function app_main_entities(array $config, array $haData): array
{
$states = $haData['states'] ?? [];
$registryMap = app_registry_map($haData['entity_registry'] ?? []);
$autoLabel = (string)($config['home_assistant']['auto_label'] ?? 'auto');
$manualAuto = array_flip(array_map('strval', $config['home_assistant']['auto_entity_ids'] ?? []));
$items = [];
foreach ($states as $entity) {
$entityId = (string)($entity['entity_id'] ?? '');
if ($entityId === '') {
continue;
}
$registryEntry = $registryMap[$entityId] ?? [];
$labels = app_labels_from_entity($entity, $registryEntry);
$isAuto = app_entity_has_auto_label($labels, $autoLabel) || isset($manualAuto[$entityId]);
$isHidden = !empty($registryEntry['hidden_by']) || !empty($registryEntry['disabled_by']);
$domain = app_entity_domain($entityId);
$isDoorContact = app_is_door_contact_entity($entity, $registryEntry);
if (!$isAuto || !app_is_active_entity($entity) || $isHidden) {
continue;
}
if (!in_array($domain, ['light', 'switch', 'cover', 'fan', 'binary_sensor'], true)) {
continue;
}
if ($domain === 'binary_sensor' && !$isDoorContact) {
continue;
}
$cardType = app_default_card_type($entityId);
$items[] = [
'entity_id' => $entityId,
'domain' => $domain,
'name' => app_entity_name($entity, $registryEntry, []),
'icon' => app_entity_icon($entity, []),
'state' => $entity['state'] ?? 'unknown',
'attributes' => $entity['attributes'] ?? [],
'labels' => $labels,
'card_type' => $cardType,
'is_door_contact' => $isDoorContact,
'subtitle' => app_entity_subtitle($entity, $cardType),
'last_changed' => (string)($entity['last_changed'] ?? $entity['last_updated'] ?? ''),
];
}
usort($items, static function (array $a, array $b): int {
$timeA = strtotime((string)($a['last_changed'] ?? '')) ?: 0;
$timeB = strtotime((string)($b['last_changed'] ?? '')) ?: 0;
if ($timeA !== $timeB) {
return $timeA <=> $timeB;
}
return strcasecmp($a['name'], $b['name']);
});
return $items;
}
function app_weather_summary(?array $entity): ?array
{
if (!$entity) {
return null;
}
$attr = $entity['attributes'] ?? [];
return [
'entity_id' => $entity['entity_id'] ?? null,
'name' => $attr['friendly_name'] ?? 'Погода',
'state' => $entity['state'] ?? null,
'temperature' => $attr['temperature'] ?? null,
'sensor_temperature' => null,
'wind_speed' => $attr['wind_speed'] ?? null,
'condition' => $attr['condition'] ?? ($entity['state'] ?? null),
];
}
function app_find_state(array $states, string $entityId): ?array
{
foreach ($states as $entity) {
if (($entity['entity_id'] ?? null) === $entityId) {
return $entity;
}
}
return null;
}
function app_room_summary(array $room, array $items, array $haData): array
{
$activeCount = app_room_active_count($items);
$allItems = app_room_entities($room, $haData, true);
$temperatureBadge = app_room_temperature_candidate($room, $allItems);
if ($temperatureBadge === null) {
$temperatureBadge = app_room_temperature_badge($items);
}
if ($temperatureBadge === null) {
$explicitTemperatureIds = [];
foreach (($room['entity_overrides'] ?? []) as $entityId => $override) {
if (!is_array($override)) {
continue;
}
if (str_ends_with((string)$entityId, '_temperature') && !empty($override['visible'])) {
$explicitTemperatureIds[] = (string)$entityId;
}
if (str_ends_with((string)$entityId, '_temperature') && array_key_exists('visible', $override) && $override['visible'] === false) {
$explicitTemperatureIds[] = (string)$entityId;
}
}
foreach ($explicitTemperatureIds as $temperatureEntityId) {
foreach (($haData['states'] ?? []) as $entity) {
if (($entity['entity_id'] ?? null) !== $temperatureEntityId) {
continue;
}
$candidate = $entity['attributes']['current_temperature'] ?? $entity['attributes']['temperature'] ?? $entity['state'] ?? null;
if ($candidate !== null && is_numeric($candidate)) {
$temperatureBadge = (string)round((float)$candidate) . '°';
break 2;
}
}
}
if ($temperatureBadge === null) {
$temperatureBadge = app_room_temperature_badge($allItems);
}
}
return [
'id' => $room['id'],
'name' => $room['name'] ?? $room['id'],
'icon' => $room['icon'] ?? 'mdi:home-variant',
'floor_id' => $room['floor_id'] ?? null,
'floor_name' => $room['floor_name'] ?? null,
'floor_level' => isset($room['floor_level']) ? (int)$room['floor_level'] : null,
'visible' => (bool)($room['visible'] ?? true),
'order' => (int)($room['order'] ?? 9999),
'temperature_sensor_entity_id' => trim((string)($room['temperature_sensor_entity_id'] ?? '')),
'entity_count' => $activeCount,
'active_entity_count' => $activeCount,
'temperature_badge' => $temperatureBadge,
];
}
function app_build_snapshot(array $config, HomeAssistantClient $client, ?string $selectedRoomId = null): array
{
$haData = $client->fetchSnapshotData();
$demo = $client->isDemo();
$editMode = (bool)($config['app']['edit_mode'] ?? false);
$rooms = app_room_definitions($config, $haData);
$mainRoom = [
'id' => 'main',
'name' => (string)($config['app']['main_room_name'] ?? 'Главная'),
'icon' => (string)($config['app']['main_room_icon'] ?? 'mdi:home'),
'visible' => true,
'entity_count' => 0,
];
$roomSummaries = [$mainRoom];
$roomViews = [];
$spaceIndex = [];
$spaceEntities = [];
foreach ($rooms as $room) {
$entities = app_room_entities($room, $haData, $editMode);
$summary = app_room_summary($room, $entities, $haData);
$roomSummaries[] = $summary;
$roomViews[$room['id']] = [
'id' => $room['id'],
'name' => $room['name'] ?? $room['id'],
'icon' => $room['icon'] ?? 'mdi:home-variant',
'visible' => (bool)($room['visible'] ?? true),
'order' => (int)($room['order'] ?? 9999),
'entities' => $entities,
'layout_items' => app_room_layout_items($room),
'entity_count' => $summary['entity_count'],
'active_entity_count' => $summary['active_entity_count'],
'temperature_badge' => $summary['temperature_badge'],
];
$spaceIndex[$room['id']] = $roomViews[$room['id']];
$spaceEntities[$room['id']] = $entities;
}
$batteryRoom = app_battery_room($config, $haData, $rooms, $client, $selectedRoomId === 'batteries');
$states = $haData['states'] ?? [];
$entityIndex = app_entity_index($config, $haData);
$mainEntities = app_main_entities($config, $haData);
$mainActiveCount = count($mainEntities);
$mainRoom['entity_count'] = $mainActiveCount;
$mainRoom['active_entity_count'] = $mainActiveCount;
$mainRoom['temperature_badge'] = null;
$roomSummaries[0] = $mainRoom;
$weatherEntity = app_select_weather_entity($states, $config);
$weather = app_weather_summary($weatherEntity);
$weatherSensor = app_find_state($states, 'sensor.weather_temperature');
if ($weather !== null) {
$weather['sensor_temperature'] = $weatherSensor['state'] ?? null;
}
$selectedRoomId = $selectedRoomId ?: 'main';
if (!isset($roomViews[$selectedRoomId]) && !in_array($selectedRoomId, ['main', 'batteries'], true)) {
$selectedRoomId = 'main';
}
$selectedRoom = $selectedRoomId === 'main'
? [
'id' => 'main',
'name' => $mainRoom['name'],
'icon' => $mainRoom['icon'],
'visible' => true,
'entities' => $mainEntities,
'layout_items' => [],
'entity_count' => count($mainEntities),
'active_entity_count' => count($mainEntities),
'temperature_badge' => null,
]
: ($selectedRoomId === 'batteries'
? $batteryRoom
: ($spaceIndex[$selectedRoomId] ?? $roomViews[$selectedRoomId]));
if ($selectedRoomId === 'main') {
$selectedRoom['weather'] = $weather;
}
$popup = app_clear_expired_popup_state($config);
$camera = $config['camera'] ?? [];
return [
'ok' => true,
'demo' => $demo,
'server_time' => time(),
'settings' => [
'title' => $config['app']['title'] ?? 'Wall Panel',
'poll_interval_ms' => (int)($config['app']['poll_interval_ms'] ?? 5000),
'edit_mode' => (bool)($config['app']['edit_mode'] ?? false),
'main_room_name' => $mainRoom['name'],
'ha_connection' => [
'base_url' => (string)($config['home_assistant']['base_url'] ?? ''),
'token' => (string)($config['home_assistant']['token'] ?? ''),
'verify_ssl' => (bool)($config['home_assistant']['verify_ssl'] ?? true),
],
'camera' => [
'poster_url' => (string)($camera['poster_url'] ?? ''),
'stream_url' => (string)($camera['stream_url'] ?? ''),
'stream_mode' => (string)($camera['stream_mode'] ?? 'hls'),
'popup_timeout_minutes' => (int)($camera['popup_timeout_minutes'] ?? 3),
'trigger_entities' => app_trigger_entities($config),
],
'main_weather_actions' => app_main_weather_actions($config),
'main_boiler' => app_main_boiler($config),
'main_print' => app_main_print($config),
],
'spaces' => $roomSummaries,
'selected_space' => $selectedRoom,
'space_index' => $spaceIndex,
'space_entities' => $spaceEntities,
'entity_index' => $entityIndex,
'weather' => $weather,
'main_entities' => $mainEntities,
'battery_room' => $batteryRoom,
'temperature_sensor_entity_id' => $selectedRoom['temperature_sensor_entity_id'] ?? null,
'popup' => [
'active' => (bool)($popup['active'] ?? false),
'sensor_entity_id' => $popup['sensor_entity_id'] ?? null,
'opened_at' => $popup['opened_at'] ?? null,
'expires_at' => $popup['expires_at'] ?? null,
'poster_url' => (string)($camera['poster_url'] ?? ''),
'stream_url' => (string)($camera['stream_url'] ?? ''),
'stream_mode' => (string)($camera['stream_mode'] ?? 'hls'),
'title' => 'Камера',
],
'rooms' => $roomSummaries,
'selected_room' => $selectedRoom,
];
}
function app_update_entity_override(array $config, string $roomId, string $entityId, array $patch): array
{
$rooms = $config['rooms'] ?? [];
if (!is_array($rooms)) {
$rooms = [];
}
$found = false;
foreach ($rooms as &$room) {
if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) {
continue;
}
$room['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : [];
$current = is_array($room['entity_overrides'][$entityId] ?? null) ? $room['entity_overrides'][$entityId] : [];
$room['entity_overrides'][$entityId] = array_replace_recursive($current, array_filter($patch, static fn($value) => $value !== null));
$found = true;
break;
}
unset($room);
if (!$found) {
$newRoom = [
'id' => $roomId,
'visible' => true,
'entity_ids' => [],
'entity_overrides' => [
$entityId => array_filter($patch, static fn($value) => $value !== null),
],
];
if (array_key_exists('name', $patch) && trim((string)$patch['name']) !== '') {
$newRoom['name'] = (string)$patch['name'];
}
if (array_key_exists('icon', $patch) && trim((string)$patch['icon']) !== '') {
$newRoom['icon'] = (string)$patch['icon'];
}
$rooms[] = [
...$newRoom,
];
}
$config['rooms'] = $rooms;
app_save_config($config);
return $config;
}
function app_update_room_override(array $config, string $roomId, array $patch): array
{
$rooms = $config['rooms'] ?? [];
if (!is_array($rooms)) {
$rooms = [];
}
$found = false;
foreach ($rooms as &$room) {
if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) {
continue;
}
foreach (['visible', 'order', 'name', 'icon'] as $key) {
if (array_key_exists($key, $patch) && $patch[$key] !== null) {
$room[$key] = $key === 'visible' ? (bool)$patch[$key] : ($key === 'order' ? (int)$patch[$key] : (string)$patch[$key]);
}
}
if (array_key_exists('temperature_sensor_entity_id', $patch)) {
$room['temperature_sensor_entity_id'] = trim((string)($patch['temperature_sensor_entity_id'] ?? ''));
}
if (!isset($room['entity_ids']) || !is_array($room['entity_ids'])) {
$room['entity_ids'] = [];
}
if (!isset($room['entity_overrides']) || !is_array($room['entity_overrides'])) {
$room['entity_overrides'] = [];
}
if (!isset($room['layout_items']) || !is_array($room['layout_items'])) {
$room['layout_items'] = [];
}
$found = true;
break;
}
unset($room);
if (!$found) {
$newRoom = [
'id' => $roomId,
'visible' => array_key_exists('visible', $patch) ? (bool)$patch['visible'] : true,
'order' => array_key_exists('order', $patch) ? (int)$patch['order'] : 9999,
'entity_ids' => [],
'entity_overrides' => [],
'layout_items' => [],
'temperature_sensor_entity_id' => array_key_exists('temperature_sensor_entity_id', $patch)
? trim((string)($patch['temperature_sensor_entity_id'] ?? ''))
: '',
];
if (array_key_exists('name', $patch) && trim((string)$patch['name']) !== '') {
$newRoom['name'] = (string)$patch['name'];
}
if (array_key_exists('icon', $patch) && trim((string)$patch['icon']) !== '') {
$newRoom['icon'] = (string)$patch['icon'];
}
$rooms[] = $newRoom;
}
$config['rooms'] = $rooms;
app_save_config($config);
return $config;
}
function app_update_room_layout_item(array $config, string $roomId, string $layoutItemId, array $patch): array
{
$rooms = $config['rooms'] ?? [];
if (!is_array($rooms)) {
$rooms = [];
}
$found = false;
foreach ($rooms as &$room) {
if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) {
continue;
}
$room['layout_items'] = app_room_layout_items($room);
$current = null;
foreach ($room['layout_items'] as $index => $item) {
if ((string)($item['id'] ?? '') === $layoutItemId) {
$current = $item;
break;
}
}
if ($current === null) {
$current = [
'id' => $layoutItemId,
'type' => 'ghost',
'order' => 9999,
];
}
$next = array_replace($current, array_filter($patch, static fn($value) => $value !== null));
$next['id'] = $layoutItemId;
$next['type'] = 'ghost';
$next['order'] = isset($next['order']) ? (int)$next['order'] : 9999;
$updated = [];
$replaced = false;
foreach ($room['layout_items'] as $item) {
if ((string)($item['id'] ?? '') === $layoutItemId) {
$updated[] = $next;
$replaced = true;
} else {
$updated[] = $item;
}
}
if (!$replaced) {
$updated[] = $next;
}
usort($updated, static function (array $a, array $b): int {
$orderA = (int)($a['order'] ?? 9999);
$orderB = (int)($b['order'] ?? 9999);
if ($orderA !== $orderB) {
return $orderA <=> $orderB;
}
return strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? ''));
});
$room['layout_items'] = $updated;
$found = true;
break;
}
unset($room);
if (!$found) {
$rooms[] = [
'id' => $roomId,
'visible' => true,
'entity_ids' => [],
'entity_overrides' => [],
'layout_items' => [[
'id' => $layoutItemId,
'type' => 'ghost',
'order' => isset($patch['order']) ? (int)$patch['order'] : 9999,
]],
];
}
$config['rooms'] = $rooms;
app_save_config($config);
return $config;
}
function app_delete_room_layout_item(array $config, string $roomId, string $layoutItemId): array
{
$rooms = $config['rooms'] ?? [];
if (!is_array($rooms)) {
$rooms = [];
}
foreach ($rooms as &$room) {
if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) {
continue;
}
$room['layout_items'] = array_values(array_filter(
app_room_layout_items($room),
static fn(array $item): bool => (string)($item['id'] ?? '') !== $layoutItemId
));
break;
}
unset($room);
$config['rooms'] = $rooms;
app_save_config($config);
return $config;
}
function app_reorder_room_grid(array $config, string $roomId, array $entries): array
{
$rooms = $config['rooms'] ?? [];
if (!is_array($rooms)) {
$rooms = [];
}
$normalizedEntries = [];
foreach ($entries as $entry) {
if (!is_array($entry)) {
continue;
}
$kind = trim((string)($entry['kind'] ?? ''));
$id = trim((string)($entry['id'] ?? ''));
if ($id === '' || !in_array($kind, ['entity', 'layout'], true)) {
continue;
}
$normalizedEntries[] = [
'kind' => $kind,
'id' => $id,
];
}
foreach ($rooms as &$room) {
if (!is_array($room) || (string)($room['id'] ?? '') !== $roomId) {
continue;
}
$room['entity_overrides'] = is_array($room['entity_overrides'] ?? null) ? $room['entity_overrides'] : [];
$room['layout_items'] = app_room_layout_items($room);
$layoutById = [];
foreach ($room['layout_items'] as $item) {
if (is_array($item) && !empty($item['id'])) {
$layoutById[(string)$item['id']] = $item;
}
}
$order = 10000;
foreach ($normalizedEntries as $entry) {
if ($entry['kind'] === 'entity') {
$current = is_array($room['entity_overrides'][$entry['id']] ?? null) ? $room['entity_overrides'][$entry['id']] : [];
$room['entity_overrides'][$entry['id']] = array_replace($current, [
'order' => $order,
]);
} else {
$current = is_array($layoutById[$entry['id']] ?? null) ? $layoutById[$entry['id']] : [
'id' => $entry['id'],
'type' => 'ghost',
];
$layoutById[$entry['id']] = array_replace($current, [
'id' => $entry['id'],
'type' => 'ghost',
'order' => $order,
]);
}
$order += 10;
}
$room['layout_items'] = array_values($layoutById);
usort($room['layout_items'], static function (array $a, array $b): int {
$orderA = (int)($a['order'] ?? 9999);
$orderB = (int)($b['order'] ?? 9999);
if ($orderA !== $orderB) {
return $orderA <=> $orderB;
}
return strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? ''));
});
break;
}
unset($room);
$config['rooms'] = $rooms;
app_save_config($config);
return $config;
}
function app_handle_popup_event(array $config, array $payload): array
{
$camera = $config['camera'] ?? [];
$watch = app_trigger_entities($config);
$sensor = (string)($payload['sensor_entity_id'] ?? $payload['entity_id'] ?? '');
$state = strtolower((string)($payload['state'] ?? $payload['to'] ?? ''));
$command = strtolower((string)($payload['command'] ?? ''));
$popupState = app_load_popup_state();
$timeoutMinutes = max(1, (int)($camera['popup_timeout_minutes'] ?? 3));
$closeDelaySeconds = app_popup_close_delay_seconds();
$timeoutSeconds = max($closeDelaySeconds, $timeoutMinutes * 60);
if ($command === 'open') {
$popupState = [
'active' => true,
'sensor_entity_id' => $sensor !== '' ? $sensor : 'debug',
'opened_at' => time(),
'expires_at' => time() + $timeoutSeconds,
];
app_save_popup_state($popupState);
} elseif (in_array($sensor, $watch, true) && $state === 'on') {
$popupState = [
'active' => true,
'sensor_entity_id' => $sensor,
'opened_at' => time(),
'expires_at' => null,
];
app_save_popup_state($popupState);
} elseif ($state === 'off' && ($popupState['sensor_entity_id'] ?? null) === $sensor && ($popupState['active'] ?? false)) {
$popupState['active'] = true;
$popupState['expires_at'] = time() + $closeDelaySeconds;
if (empty($popupState['opened_at'])) {
$popupState['opened_at'] = time();
}
app_save_popup_state($popupState);
} elseif (($payload['command'] ?? '') === 'close') {
$popupState['active'] = false;
$popupState['expires_at'] = null;
app_save_popup_state($popupState);
}
return $popupState;
}
function app_service_for_entity(string $entityId, string $command): array
{
$domain = app_entity_domain($entityId);
return match ($command) {
'toggle' => [$domain, 'toggle', ['entity_id' => $entityId]],
'turn_on' => [$domain, 'turn_on', ['entity_id' => $entityId]],
'turn_off' => [$domain, 'turn_off', ['entity_id' => $entityId]],
'open' => ['cover', 'open_cover', ['entity_id' => $entityId]],
'close' => ['cover', 'close_cover', ['entity_id' => $entityId]],
'stop' => ['cover', 'stop_cover', ['entity_id' => $entityId]],
'set_position' => ['cover', 'set_cover_position', ['entity_id' => $entityId]],
'set_temperature' => ['climate', 'set_temperature', ['entity_id' => $entityId]],
'set_hvac_mode' => ['climate', 'set_hvac_mode', ['entity_id' => $entityId]],
'set_fan_mode' => ['climate', 'set_fan_mode', ['entity_id' => $entityId]],
'set_swing_mode' => ['climate', 'set_swing_mode', ['entity_id' => $entityId]],
'set_preset_mode' => ['climate', 'set_preset_mode', ['entity_id' => $entityId]],
default => [$domain, $command, ['entity_id' => $entityId]],
};
}