camera - (real) remote control, brandschutz - configurable course, dashboard - vbb licensed API (a real vbb license, how cool)
This commit is contained in:
@@ -425,7 +425,7 @@
|
||||
const age = now - departureData.updatedAt;
|
||||
// Der globale Feed-Zeitstempel kann älter sein; entscheidend sind die
|
||||
// Prognosen der einzelnen Fahrten und das Alter unseres letzten Abrufs.
|
||||
const fresh = age < 300000;
|
||||
const fresh = age < 300000 && !departureData.unavailable;
|
||||
const rows = (age < 7200000 ? departureData.departures : [])
|
||||
.map(d => ({ ...d, timestamp: Date.parse(d.when || d.plannedWhen) }))
|
||||
.filter(d => Number.isFinite(d.timestamp) && d.timestamp >= now)
|
||||
@@ -433,7 +433,7 @@
|
||||
const live = fresh && Number.isFinite(d.delay) && Boolean(d.when);
|
||||
const state = !fresh ? 'stale' : d.cancelled ? 'cancelled' : !live ? 'scheduled'
|
||||
: d.delay > 0 ? 'delayed' : d.delay < 0 ? 'early' : 'ontime';
|
||||
const status = { stale: 'veraltet', cancelled: 'entfällt', scheduled: 'pünktlich', ontime: 'pünktlich',
|
||||
const status = { stale: 'veraltet', cancelled: 'entfällt', scheduled: 'Fahrplaninfo', ontime: 'pünktlich',
|
||||
delayed: '+' + Math.ceil(d.delay / 60) + ' Min.', early: '−' + Math.ceil(-d.delay / 60) + ' Min.' }[state];
|
||||
const minutes = Math.ceil((d.timestamp - now) / 60000);
|
||||
const planned = Date.parse(d.plannedWhen);
|
||||
@@ -445,7 +445,7 @@
|
||||
departures.update(rows, fresh ? 'Keine Busabfahrten in den nächsten 120 Minuten.' : 'Live-Abfahrten derzeit nicht verfügbar.');
|
||||
}
|
||||
const refreshDepartures = createRefresh('departures_eich', data => {
|
||||
if (!Array.isArray(data.departures) || !Number.isFinite(data.updatedAt)) throw new Error('Ungültige BVG-Antwort');
|
||||
if (!Array.isArray(data.departures) || !Number.isFinite(data.updatedAt)) throw new Error('Ungültige VBB-Antwort');
|
||||
Object.assign(departureData, data);
|
||||
nextDepartureRequest = Number(data.retryAt) * 1000 || 0;
|
||||
renderDepartures(Date.now());
|
||||
|
||||
@@ -45,6 +45,7 @@ $config = require __DIR__ . '/config.php';
|
||||
<section class="dashboard-panel text-panel" id="departures-panel" aria-labelledby="departures-title">
|
||||
<div class="panel-heading">
|
||||
<h2 id="departures-title">Abfahrten: Eichborndamm/Antonienstr.</h2>
|
||||
<a class="departures-source" href="https://www.vbb.de/" target="_blank" rel="noopener">Fahrplandaten: VBB</a>
|
||||
</div>
|
||||
<div class="text-stage"><p class="empty-state">Abfahrten werden geladen …</p></div>
|
||||
</section>
|
||||
|
||||
@@ -29,7 +29,7 @@ try {
|
||||
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
|
||||
break;
|
||||
case 'departures_eich':
|
||||
require_once __DIR__ . '/util/bvg.php';
|
||||
require_once __DIR__ . '/util/vbb.php';
|
||||
$payload = dashboard_fetch_departures();
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -93,10 +93,11 @@ body.dashboard {
|
||||
#departures-panel { --panel-accent: var(--nak-darkblue); height: calc(161 * var(--dashboard-unit)); }
|
||||
#departures-panel .text-stage { margin: 0; }
|
||||
#departures-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(18 * var(--dashboard-unit)); }
|
||||
.departures-source { margin-left: auto; color: var(--dashboard-meta); font-size: calc(10 * var(--dashboard-unit)); white-space: nowrap; }
|
||||
.departures-list { max-width: none; margin: 0; padding: 0; list-style: none; }
|
||||
.departure-row { --departure-color: var(--gray-500); display: grid; grid-template-columns: calc(38 * var(--dashboard-unit)) minmax(0, 1fr) calc(52 * var(--dashboard-unit)) calc(80 * var(--dashboard-unit)); align-items: center; gap: calc(6 * var(--dashboard-unit)); height: calc(26 * var(--dashboard-unit)); padding: 0 calc(5 * var(--dashboard-unit)) 0 calc(8 * var(--dashboard-unit)); border-right: calc(6 * var(--dashboard-unit)) solid var(--departure-color); background: var(--panel-surface); font-size: var(--announcement-meta-font); line-height: 1.2; }
|
||||
.departure-row { --departure-color: var(--gray-500); display: grid; grid-template-columns: calc(38 * var(--dashboard-unit)) minmax(0, 1fr) calc(52 * var(--dashboard-unit)) calc(100 * var(--dashboard-unit)); align-items: center; gap: calc(6 * var(--dashboard-unit)); height: calc(26 * var(--dashboard-unit)); padding: 0 calc(5 * var(--dashboard-unit)) 0 calc(8 * var(--dashboard-unit)); border-right: calc(6 * var(--dashboard-unit)) solid var(--departure-color); background: var(--panel-surface); font-size: var(--announcement-meta-font); line-height: 1.2; }
|
||||
.departure-row:nth-child(even) { background: var(--gray-50); }
|
||||
.departure-row.is-ontime, .departure-row.is-scheduled { --departure-color: var(--nak-darkgreen); }
|
||||
.departure-row.is-ontime { --departure-color: var(--nak-darkgreen); }
|
||||
.departure-row.is-delayed { --departure-color: var(--nak-orange); }
|
||||
.departure-row.is-early, .departure-row.is-cancelled { --departure-color: var(--nak-red); }
|
||||
.departure-line { font-weight: 500; }
|
||||
|
||||
@@ -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®ional=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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user