camera - (real) remote control, brandschutz - configurable course, dashboard - vbb licensed API (a real vbb license, how cool)

This commit is contained in:
2026-09-08 18:21:51 +02:00
parent 3f98cf2663
commit ce90820094
24 changed files with 3044 additions and 2065 deletions
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
const DASHBOARD_BVG_STOP = '900086103';
function dashboard_fetch_departures(): array
{
$directory = __DIR__ . '/../cache';
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('BVG-Cache nicht beschreibbar.');
}
$file = fopen($directory . '/bvg-' . DASHBOARD_BVG_STOP . '.json', 'c+');
if (!$file) {
throw new RuntimeException('BVG-Cache nicht verfügbar.');
}
try {
// Gemeinsamer Cache: alle 12 Sekunden und zusätzlich zum Minutenwechsel.
if (!flock($file, LOCK_EX)) {
throw new RuntimeException('BVG-Cache konnte nicht gesperrt werden.');
}
$cache = json_decode(stream_get_contents($file), true) ?: [];
if (microtime(true) >= ($cache['retryAt'] ?? 0)) {
try {
$data = dashboard_http_json('https://v6.bvg.transport.rest/stops/' . DASHBOARD_BVG_STOP . '/departures?'
. 'duration=120&results=30&bus=true&suburban=false&subway=false&tram=false'
. '&ferry=false&express=false&regional=false&remarks=false&language=de&pretty=false');
if (!isset($data['departures']) || !is_array($data['departures'])) {
throw new RuntimeException('Unerwartete BVG-Antwort.');
}
$now = microtime(true);
$cache = ['departures' => array_values(array_filter($data['departures'], static fn(array $d): bool =>
($d['line']['product'] ?? '') === 'bus'
&& in_array($d['stop']['id'] ?? '', [DASHBOARD_BVG_STOP, '900000' . substr(DASHBOARD_BVG_STOP, 3)], true))),
'updatedAt' => (int) round($now * 1000), 'retryAt' => min($now + 12, (floor($now / 60) + 1) * 60),
'unavailable' => false];
} catch (Throwable $error) {
// Letzte Antwort behalten; auch bei 429/503 nicht sofort erneut anfragen.
error_log('Dashboard BVG: ' . $error->getMessage());
$cache['unavailable'] = true;
$cache['retryAt'] = microtime(true) + 30;
}
rewind($file);
$json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (!ftruncate($file, 0) || fwrite($file, $json) !== strlen($json) || !fflush($file)) {
throw new RuntimeException('BVG-Cache konnte nicht gespeichert werden.');
}
}
return $cache + ['departures' => [], 'updatedAt' => 0];
} finally {
fclose($file);
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
const DASHBOARD_VBB_STOP_ID = '900086103';
const DASHBOARD_VBB_BUS_PRODUCTS = 8;
function dashboard_vbb_datetime(array $departure, bool $realtime = false): ?DateTimeImmutable
{
$time = trim((string) ($departure[$realtime ? 'rtTime' : 'time'] ?? ''));
if ($time === '') {
return null;
}
$date = trim((string) ($departure[$realtime ? 'rtDate' : 'date'] ?? $departure['date'] ?? ''));
$value = DateTimeImmutable::createFromFormat(
'!Y-m-d H:i:s',
$date . ' ' . $time,
new DateTimeZone(DASHBOARD_TIMEZONE)
);
return $value ?: null;
}
function dashboard_vbb_normalize_departure(array $departure): ?array
{
$product = $departure['ProductAtStop'] ?? ($departure['Product'][0] ?? []);
if (!is_array($product) || stripos((string) ($product['catOut'] ?? $product['catOutL'] ?? ''), 'bus') === false) {
return null;
}
$planned = dashboard_vbb_datetime($departure);
if (!$planned) {
return null;
}
$actual = dashboard_vbb_datetime($departure, true);
$status = strtoupper(trim((string) ($departure['JourneyStatus'] ?? '')));
$cancelled = filter_var($departure['cancelled'] ?? false, FILTER_VALIDATE_BOOLEAN) || $status === 'C';
$line = trim((string) ($product['displayNumber'] ?? ''));
$line = $line !== '' ? $line : trim((string) ($product['line'] ?? $product['name'] ?? $departure['name'] ?? 'Bus'));
return [
'plannedWhen' => $planned->format(DATE_ATOM),
'when' => $actual?->format(DATE_ATOM),
'delay' => $actual ? $actual->getTimestamp() - $planned->getTimestamp() : null,
'cancelled' => $cancelled,
'line' => ['name' => $line],
'direction' => trim((string) ($departure['direction'] ?? '')),
];
}
function dashboard_fetch_departures(): array
{
$directory = __DIR__ . '/../cache';
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('VBB-Cache nicht beschreibbar.');
}
$file = fopen($directory . '/vbb-' . DASHBOARD_VBB_STOP_ID . '.json', 'c+');
if (!$file) {
throw new RuntimeException('VBB-Cache nicht verfügbar.');
}
try {
if (!flock($file, LOCK_EX)) {
throw new RuntimeException('VBB-Cache konnte nicht gesperrt werden.');
}
$cache = json_decode(stream_get_contents($file), true) ?: [];
if (microtime(true) >= ($cache['retryAt'] ?? 0)) {
try {
$query = http_build_query([
'id' => DASHBOARD_VBB_STOP_ID,
'duration' => 120,
'maxJourneys' => 30,
'products' => DASHBOARD_VBB_BUS_PRODUCTS,
'type' => 'DEP',
'format' => 'json',
'lang' => 'de',
], '', '&', PHP_QUERY_RFC3986);
$baseUrl = rtrim(intranet_env('VBB_API_BASE_URL'), '/');
$data = dashboard_http_json($baseUrl . '/departureBoard?' . $query, [
'Authorization: Bearer ' . intranet_env('VBB_ACCESS_ID'),
]);
if (!isset($data['Departure']) || !is_array($data['Departure'])) {
throw new RuntimeException('Unerwartete VBB-Antwort.');
}
$departures = [];
foreach ($data['Departure'] as $departure) {
if (is_array($departure) && ($normalized = dashboard_vbb_normalize_departure($departure))) {
$departures[] = $normalized;
}
}
$now = microtime(true);
$cache = [
'departures' => array_slice($departures, 0, 30),
'updatedAt' => (int) round($now * 1000),
'retryAt' => $now + 30,
'unavailable' => false,
];
} catch (Throwable $error) {
// Eine letzte erfolgreiche Antwort behalten und die API nach Fehlern nicht belasten.
error_log('Dashboard VBB: ' . $error->getMessage());
$cache['unavailable'] = true;
$cache['retryAt'] = microtime(true) + 60;
}
rewind($file);
$json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (!ftruncate($file, 0) || fwrite($file, $json) !== strlen($json) || !fflush($file)) {
throw new RuntimeException('VBB-Cache konnte nicht gespeichert werden.');
}
}
return $cache + ['departures' => [], 'updatedAt' => 0];
} finally {
fclose($file);
}
}