setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE)); $today = $now->format('Y-m-d'); $payload = ['date' => $today, 'service' => null, 'announcements' => [], 'errors' => []]; // Unabhängige Abfragen: ein fehlerhafter Bereich blockiert den anderen nicht. try { $statement = $pdo->prepare( 'SELECT bibelwort, bibelvers FROM gottesdienst WHERE bibeldatum = ? ORDER BY id ASC LIMIT 1' ); $statement->execute([$today]); $service = $statement->fetch(PDO::FETCH_ASSOC); if ($service) { // Leitgedanken-Import: bibelwort = Referenz, bibelvers = eigentlicher Text. $payload['service'] = [ 'reference' => trim((string) ($service['bibelwort'] ?? '')), 'text' => trim((string) ($service['bibelvers'] ?? '')), ]; } } catch (Throwable $exception) { error_log('Dashboard Bibelwort: ' . $exception->getMessage()); $payload['errors'][] = 'service'; } try { $statement = $pdo->prepare( 'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ? ORDER BY ansage_datum_von ASC NULLS LAST, ansage_uhrzeit ASC NULLS LAST, id ASC' ); contentpanel_cleanup_expired_announcements($pdo, $today); $statement->execute([$today]); foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { $payload['announcements'][] = dashboard_prepare_announcement($row, $now); } } catch (Throwable $exception) { error_log('Dashboard Ansagen: ' . $exception->getMessage()); $payload['errors'][] = 'announcements'; } return $payload; } function dashboard_date(string $raw): ?DateTimeImmutable { $date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, new DateTimeZone(DASHBOARD_TIMEZONE)); return $date && $date->format('Y-m-d') === $raw ? $date : null; } function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $now): array { $now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE)); $start = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10); $end = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10); $today = $now->format('Y-m-d'); $startDate = dashboard_date($start); $endDate = dashboard_date($end); if ($start === $today) { $label = 'Heute'; } elseif ($start === $now->modify('+1 day')->format('Y-m-d')) { $label = 'Morgen'; } else { $days = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; $label = $startDate ? $days[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.') : ''; } $endLabel = $endDate && $end !== $start ? 'bis ' . $endDate->format('d.m.') : ''; $time = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5); $place = trim((string) ($announcement['ansage_ort'] ?? '')); return [ 'date_label' => $label, 'end_label' => $endLabel, 'time' => $time, 'place' => $place, 'title' => trim((string) ($announcement['ansage_titel'] ?? '')), 'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')), ]; } function dashboard_image(string $path, string $url): ?array { if (!is_file($path)) { return null; } $size = @getimagesize($path); if (!$size || $size[0] < 1 || $size[1] < 1) { return null; } // Neu erzeugte, aber identische PNGs dürfen keinen sichtbaren Austausch auslösen. $version = @hash_file('sha256', $path); if ($version === false) { return null; } return [ 'url' => $url . '?v=' . $version, 'width' => $size[0], 'height' => $size[1], ]; } function dashboard_list_posters(DateTimeImmutable $now, string $directory = CONTENTPANEL_NEWS_PNG_DIRECTORY): array { $today = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE))->format('Y-m-d'); $files = glob($directory . '/*.png') ?: []; natsort($files); $posters = []; foreach ($files as $file) { $name = basename($file); if (!preg_match('/^(.+)_page_([1-9][0-9]*)\\.png$/D', $name, $match) || !contentpanel_is_valid_news_file_base($match[1])) { continue; } // Dieselbe inklusive Ablauffrist wie im Contentpanel. Nur filtern, nicht löschen. $expiry = substr($match[1], 0, 10); if (!dashboard_date($expiry) || $expiry < $today) { continue; } $image = dashboard_image($file, DASHBOARD_NEWS_URL . rawurlencode($name)); if ($image) { $title = str_replace('_', ' ', substr($match[1], 11)); $posters[] = $image + [ 'document' => $match[1], 'expires' => $expiry, 'page' => (int) $match[2], 'alt' => $title . ' · Seite ' . (int) $match[2], ]; } } return $posters; } function dashboard_month_images(?string $directory = null): array { $directory ??= __DIR__ . '/../month'; $images = []; foreach ([1, 2] as $page) { $filename = 'terminuebersicht-' . $page . '.png'; $image = dashboard_image($directory . '/' . $filename, 'month/' . $filename); if ($image) { $images[] = $image + ['alt' => 'Monatsplan · Seite ' . $page]; } } return $images; } function dashboard_fetch_indoor_temperature(): array { $baseUrl = rtrim(intranet_env('HA_BASE_URL'), '/'); $token = intranet_env('HA_TOKEN'); $url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID); $data = dashboard_http_json($url, ['Authorization: Bearer ' . $token]); if (!isset($data['state']) || !is_numeric($data['state'])) { throw new RuntimeException('Unerwartete Home-Assistant-Antwort.'); } return [ 'value' => (int) ceil((float) $data['state'] - 0.4), 'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'), ]; } function dashboard_fetch_outdoor_weather(): array { $query = http_build_query([ 'lat' => DASHBOARD_OPENWEATHER_LATITUDE, 'lon' => DASHBOARD_OPENWEATHER_LONGITUDE, 'appid' => intranet_env('OPENWEATHER_API_KEY'), 'units' => 'metric', 'lang' => 'de', ], '', '&', PHP_QUERY_RFC3986); $data = dashboard_http_json('https://api.openweathermap.org/data/2.5/weather?' . $query); $temperature = $data['main']['temp'] ?? null; if (!is_numeric($temperature)) { throw new RuntimeException('Unerwartete OpenWeather-Antwort.'); } return [ 'temperature' => (int) round((float) $temperature), ]; } function dashboard_http_json(string $url, array $headers = []): array { $handle = curl_init($url); if ($handle === false) { throw new RuntimeException('HTTP-Anfrage konnte nicht initialisiert werden.'); } curl_setopt_array($handle, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers), CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 5, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, ]); try { $response = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); $error = curl_error($handle); } finally { curl_close($handle); } if ($response === false || $status < 200 || $status >= 300) { throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status); } try { $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { throw new RuntimeException('Ungültige JSON-Antwort.', 0, $exception); } if (!is_array($data)) { throw new RuntimeException('Unerwartete JSON-Antwort.'); } return $data; }