$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_history_hours(array $config): int { return max(24, (int)($config['app']['battery_history_hours'] ?? 4320)); } 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(); $historyHours = app_battery_history_hours($config); $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, ]; $cachedAt = (int)($cacheEntry['loaded_at'] ?? 0); $cacheMatchesWindow = (int)($cacheEntry['history_hours'] ?? 0) === $historyHours; $freshEnough = $cachedAt > 0 && ($now - $cachedAt) < 6 * 3600 && $cacheMatchesWindow; $shouldRefresh = ($refreshForecast || !$cacheMatchesWindow) && $percent !== null && !in_array($status, ['unavailable', 'unknown'], true); if ($shouldRefresh && (!$freshEnough || !isset($cacheEntry['forecast_minutes_left']))) { try { $history = $client->fetchEntityHistory($entityId, $historyHours); $points = app_battery_history_points($history); $forecast = app_battery_forecast_from_points($points, $percent); $cacheItems[$entityId] = [ 'loaded_at' => $now, 'history_hours' => $historyHours, '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 ($cacheMatchesWindow && 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]], }; }