245 lines
16 KiB
PHP
245 lines
16 KiB
PHP
<?php
|
|
$config = require __DIR__ . '/config.php';
|
|
date_default_timezone_set('Europe/Copenhagen');
|
|
$dataDir = $config['data_dir'];
|
|
$stateFile = "$dataDir/state.json";
|
|
$eventsFile = "$dataDir/events.jsonl";
|
|
|
|
function default_state(): array {
|
|
return [
|
|
'odometer' => 0,
|
|
'updated_at' => null,
|
|
'last' => [
|
|
'oil_change' => null,
|
|
'front_tyre_change' => null,
|
|
'rear_tyre_change' => null,
|
|
'shaft_oil_change' => null,
|
|
'brake_pads' => null,
|
|
'registration' => null,
|
|
],
|
|
'latest_event' => null,
|
|
];
|
|
}
|
|
function load_state(string $file): array {
|
|
if (!is_file($file)) return default_state();
|
|
$s = json_decode(file_get_contents($file), true);
|
|
return is_array($s) ? array_replace_recursive(default_state(), $s) : default_state();
|
|
}
|
|
function save_state(string $file, array $state): void {
|
|
file_put_contents($file, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
|
}
|
|
function append_event(string $file, array $event): void {
|
|
file_put_contents($file, json_encode($event, JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND | LOCK_EX);
|
|
}
|
|
function recent_events(string $file, int $limit = 20): array {
|
|
if (!is_file($file)) return [];
|
|
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
|
$lines = array_slice($lines, -$limit);
|
|
$events = [];
|
|
foreach (array_reverse($lines) as $line) {
|
|
$e = json_decode($line, true);
|
|
if (is_array($e)) $events[] = $e;
|
|
}
|
|
return $events;
|
|
}
|
|
function km_since(array $state, string $key): ?int {
|
|
$last = $state['last'][$key] ?? null;
|
|
if (!is_array($last) || !isset($last['km'])) return null;
|
|
return max(0, (int)$state['odometer'] - (int)$last['km']);
|
|
}
|
|
function ownership_text(array $config): string {
|
|
$date = $config['purchase_date'] ?? null;
|
|
if (!$date) return '';
|
|
try { $start = new DateTime($date); } catch (Throwable $e) { return ''; }
|
|
$now = new DateTime('today');
|
|
$diff = $start->diff($now);
|
|
$parts = [];
|
|
if ($diff->y) $parts[] = $diff->y . 'y';
|
|
if ($diff->m) $parts[] = $diff->m . 'm';
|
|
if (!$parts || $diff->d) $parts[] = $diff->d . 'd';
|
|
return implode(' ', $parts);
|
|
}
|
|
function eu_date(?string $date): string {
|
|
if (!$date) return '';
|
|
try { return (new DateTime($date))->setTimezone(new DateTimeZone('Europe/Copenhagen'))->format('d.m.Y'); }
|
|
catch (Throwable $e) { return $date; }
|
|
}
|
|
function mqtt_pub(array $config, string $topic, string $payload, bool $retain = true): void {
|
|
$host = $config['mqtt_host'];
|
|
$port = (int)($config['mqtt_port'] ?? 1883);
|
|
$cmd = 'mosquitto_pub -h ' . escapeshellarg($host)
|
|
. ' -p ' . escapeshellarg((string)$port)
|
|
. ' -t ' . escapeshellarg($topic)
|
|
. ' -m ' . escapeshellarg($payload)
|
|
. ($retain ? ' -r' : '');
|
|
@shell_exec($cmd . ' 2>/dev/null');
|
|
}
|
|
function publish_discovery(array $config): void {
|
|
$p = trim($config['topic_prefix'], '/');
|
|
$dev = ['identifiers' => ['fjr_tracker'], 'name' => $config['bike_name'], 'manufacturer' => 'Yamaha', 'model' => 'FJR'];
|
|
$sensors = [
|
|
'odometer' => ['name' => 'Odometer', 'unit' => 'km', 'topic' => "$p/odometer"],
|
|
'km_since_bought' => ['name' => 'Km since bought', 'unit' => 'km', 'topic' => "$p/km_since_bought"],
|
|
|
|
'oil_last_km' => ['name' => 'Oil last change km', 'unit' => 'km', 'topic' => "$p/oil/last_change_km"],
|
|
'oil_last_date' => ['name' => 'Oil last change date', 'unit' => '', 'topic' => "$p/oil/last_change_date"],
|
|
'oil_km_since_change' => ['name' => 'Oil km since change', 'unit' => 'km', 'topic' => "$p/oil/km_since_change"],
|
|
|
|
'front_tyre_last_km' => ['name' => 'Front tyre last change km', 'unit' => 'km', 'topic' => "$p/tyres/front/last_change_km"],
|
|
'front_tyre_last_date' => ['name' => 'Front tyre last change date', 'unit' => '', 'topic' => "$p/tyres/front/last_change_date"],
|
|
'front_tyre_km_since_change' => ['name' => 'Front tyre km since change', 'unit' => 'km', 'topic' => "$p/tyres/front/km_since_change"],
|
|
|
|
'rear_tyre_last_km' => ['name' => 'Rear tyre last change km', 'unit' => 'km', 'topic' => "$p/tyres/rear/last_change_km"],
|
|
'rear_tyre_last_date' => ['name' => 'Rear tyre last change date', 'unit' => '', 'topic' => "$p/tyres/rear/last_change_date"],
|
|
'rear_tyre_km_since_change' => ['name' => 'Rear tyre km since change', 'unit' => 'km', 'topic' => "$p/tyres/rear/km_since_change"],
|
|
|
|
'shaft_oil_last_km' => ['name' => 'Drive shaft oil last change km', 'unit' => 'km', 'topic' => "$p/shaft_oil/last_change_km"],
|
|
'shaft_oil_last_date' => ['name' => 'Drive shaft oil last change date', 'unit' => '', 'topic' => "$p/shaft_oil/last_change_date"],
|
|
'shaft_oil_km_since_change' => ['name' => 'Drive shaft oil km since change', 'unit' => 'km', 'topic' => "$p/shaft_oil/km_since_change"],
|
|
|
|
'brake_pads_last_km' => ['name' => 'Brake pads last change km', 'unit' => 'km', 'topic' => "$p/brakes/last_pads_km"],
|
|
'brake_pads_last_date' => ['name' => 'Brake pads last change date', 'unit' => '', 'topic' => "$p/brakes/last_pads_date"],
|
|
'brake_pads_km_since_change' => ['name' => 'Brake pads km since change', 'unit' => 'km', 'topic' => "$p/brakes/km_since_pads"],
|
|
|
|
];
|
|
foreach ($sensors as $id => $sensor) {
|
|
$payload = ['name' => $sensor['name'], 'state_topic' => $sensor['topic'], 'unique_id' => "fjr_$id", 'device' => $dev];
|
|
if ($sensor['unit'] !== '') $payload['unit_of_measurement'] = $sensor['unit'];
|
|
mqtt_pub($config, "homeassistant/sensor/fjr_$id/config", json_encode($payload, JSON_UNESCAPED_SLASHES));
|
|
}
|
|
}
|
|
|
|
function publish_service_state(array $config, string $baseTopic, array $state, string $key, string $lastKmTopic, string $lastDateTopic, string $sinceTopic): void {
|
|
$p = trim($config['topic_prefix'], '/');
|
|
$last = $state['last'][$key] ?? null;
|
|
if (is_array($last) && isset($last['km'])) {
|
|
mqtt_pub($config, "$p/$lastKmTopic", (string)(int)$last['km']);
|
|
mqtt_pub($config, "$p/$lastDateTopic", eu_date($last['time'] ?? ''));
|
|
$v = km_since($state, $key);
|
|
mqtt_pub($config, "$p/$sinceTopic", $v === null ? '' : (string)$v);
|
|
} else {
|
|
mqtt_pub($config, "$p/$lastKmTopic", 'unknown');
|
|
mqtt_pub($config, "$p/$lastDateTopic", 'unknown');
|
|
mqtt_pub($config, "$p/$sinceTopic", 'unknown');
|
|
}
|
|
}
|
|
function publish_state(array $config, array $state): void {
|
|
$p = trim($config['topic_prefix'], '/');
|
|
publish_discovery($config);
|
|
$purchaseKm = (int)($config['purchase_km'] ?? 0);
|
|
$kmOwned = max(0, (int)$state['odometer'] - $purchaseKm);
|
|
|
|
mqtt_pub($config, "$p/odometer", (string)(int)$state['odometer']);
|
|
mqtt_pub($config, "$p/km_since_bought", (string)$kmOwned);
|
|
|
|
publish_service_state($config, $p, $state, 'oil_change', 'oil/last_change_km', 'oil/last_change_date', 'oil/km_since_change');
|
|
publish_service_state($config, $p, $state, 'front_tyre_change', 'tyres/front/last_change_km', 'tyres/front/last_change_date', 'tyres/front/km_since_change');
|
|
publish_service_state($config, $p, $state, 'rear_tyre_change', 'tyres/rear/last_change_km', 'tyres/rear/last_change_date', 'tyres/rear/km_since_change');
|
|
publish_service_state($config, $p, $state, 'shaft_oil_change', 'shaft_oil/last_change_km', 'shaft_oil/last_change_date', 'shaft_oil/km_since_change');
|
|
publish_service_state($config, $p, $state, 'brake_pads', 'brakes/last_pads_km', 'brakes/last_pads_date', 'brakes/km_since_pads');
|
|
|
|
mqtt_pub($config, "$p/state", json_encode($state, JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
|
|
$state = load_state($stateFile);
|
|
$message = '';
|
|
$types = [
|
|
'odometer' => 'Odometer update only',
|
|
'oil_change' => 'Oil change',
|
|
'front_tyre_change' => 'Front tyre changed',
|
|
'rear_tyre_change' => 'Rear tyre changed',
|
|
'shaft_oil_change' => 'Drive shaft oil change',
|
|
'brake_pads' => 'Brake pads',
|
|
'registration' => 'Registration / insurance',
|
|
'fuel' => 'Fuel',
|
|
'note' => 'Other note',
|
|
];
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$km = max(0, (int)($_POST['km'] ?? 0));
|
|
$type = $_POST['type'] ?? 'odometer';
|
|
if (!isset($types[$type])) $type = 'note';
|
|
$cost = trim($_POST['cost'] ?? '');
|
|
$note = trim($_POST['note'] ?? '');
|
|
$eventDate = trim($_POST['event_date'] ?? '');
|
|
$eventTime = date('c');
|
|
if ($eventDate !== '') {
|
|
$dt = DateTime::createFromFormat('Y-m-d', $eventDate) ?: DateTime::createFromFormat('Y-m-d\TH:i', $eventDate);
|
|
if ($dt instanceof DateTime) $eventTime = $dt->format('c');
|
|
}
|
|
if ($km >= (int)$state['odometer']) $state['odometer'] = $km;
|
|
$event = [
|
|
'time' => $eventTime,
|
|
'type' => $type,
|
|
'label' => $types[$type],
|
|
'km' => $km,
|
|
'cost' => $cost === '' ? null : $cost,
|
|
'note' => $note,
|
|
];
|
|
if (array_key_exists($type, $state['last'])) {
|
|
$prev = $state['last'][$type] ?? null;
|
|
if (!is_array($prev) || $km >= (int)($prev['km'] ?? 0)) $state['last'][$type] = $event;
|
|
}
|
|
$event['summary'] = $types[$type] . ' @ ' . $km . ' km' . ($note ? ' - ' . $note : '');
|
|
$state['latest_event'] = $event;
|
|
$state['updated_at'] = date('c');
|
|
save_state($stateFile, $state);
|
|
append_event($eventsFile, $event);
|
|
publish_state($config, $state);
|
|
$message = 'Saved and published to MQTT.';
|
|
}
|
|
$events = recent_events($eventsFile);
|
|
function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
|
?>
|
|
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>My <?=h($config['bike_name'])?></title>
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
<link rel="apple-touch-icon" href="/favicon.svg">
|
|
<style>
|
|
:root{color-scheme:dark;--bg:#0b0b0b;--card:#1b1b1b;--field:#101010;--line:#333;--text:#f2f2f2;--muted:#aaa;--green:#2f8f3a;--blue:#2d6cdf;--orange:#d9822b}
|
|
*{box-sizing:border-box}body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:0;background:linear-gradient(rgba(11,11,11,.82),rgba(11,11,11,.92)),url('/img/fjr.jpg') center top/cover fixed no-repeat,var(--bg);color:var(--text);font-size:16px;-webkit-text-size-adjust:100%}.wrap{max-width:850px;margin:auto;padding:14px;padding-bottom:34px}h1{font-size:26px;margin:4px 0 12px}.topbar{display:flex;align-items:center;gap:12px}.logo{width:64px;height:64px;object-fit:contain;border-radius:0;background:transparent;padding:0;border:0;box-shadow:none}h2{font-size:20px;margin:0 0 12px}.card{background:rgba(27,27,27,.88);backdrop-filter:blur(3px);border:1px solid var(--line);border-radius:18px;padding:16px;margin:12px 0;box-shadow:0 8px 24px #0004}label{display:block;margin-top:14px;margin-bottom:6px;color:#d0d0d0;font-weight:650}input,select,textarea,button{width:100%;font:inherit;font-size:18px;padding:15px 14px;border-radius:14px;border:1px solid #444;background:var(--field);color:var(--text)}select{min-height:54px}textarea{resize:vertical}button{background:var(--green);border:0;margin-top:16px;font-weight:800;min-height:56px;touch-action:manipulation}.bikePhoto{width:100%;max-height:330px;object-fit:cover;border-radius:18px;border:1px solid var(--line);box-shadow:0 8px 24px #0006;margin:0 0 12px}.hero{text-decoration:none;color:inherit;display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:10px;align-items:stretch}.heroBox{background:rgba(16,16,16,.84);border:1px solid #2a2a2a;border-radius:14px;padding:13px}.heroBox.main{border-color:#3b5f45}.icon{font-size:22px;margin-right:6px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.stat{background:rgba(16,16,16,.84);border:1px solid #2a2a2a;border-radius:14px;padding:13px;min-height:112px}.big{font-size:30px;line-height:1.05;font-weight:850}.ok{background:#17351a;border-color:var(--green)}.muted{color:var(--muted);font-size:13px}table{width:100%;border-collapse:collapse;font-size:14px}td,th{border-bottom:1px solid var(--line);padding:9px 6px;text-align:left;vertical-align:top}.pill{display:inline-block;background:#333;border-radius:999px;padding:4px 8px;white-space:nowrap}
|
|
@media (max-width:520px){.logo{width:52px;height:52px}.wrap{padding:10px}.card{border-radius:16px;padding:14px;margin:10px 0}h1{font-size:24px}.hero{grid-template-columns:1fr}.grid{grid-template-columns:1fr 1fr;gap:8px}.stat{padding:11px;min-height:104px}.stat b{font-size:15px}.big{font-size:26px}input,select,textarea,button{font-size:17px;padding:14px 12px}.card table{display:block;overflow-x:auto;white-space:nowrap}}
|
|
@media (max-width:360px){.grid{grid-template-columns:1fr}.big{font-size:28px}}
|
|
</style>
|
|
</head><body><div class="wrap">
|
|
<?php if ($message): ?><div class="card ok"><?=h($message)?></div><?php endif; ?>
|
|
<?php
|
|
$purchaseKm = (int)($config['purchase_km'] ?? 0);
|
|
$kmOwned = max(0, (int)$state['odometer'] - $purchaseKm);
|
|
$ownedFor = ownership_text($config);
|
|
$icons = [
|
|
'oil_change' => '🛢️',
|
|
'front_tyre_change' => '◉',
|
|
'rear_tyre_change' => '◉',
|
|
'shaft_oil_change' => '⚙️',
|
|
'brake_pads' => '🛑',
|
|
'registration' => '📄',
|
|
];
|
|
$statCards = [];
|
|
foreach ([ 'oil_change'=>'Oil', 'front_tyre_change'=>'Front tyre', 'rear_tyre_change'=>'Rear tyre', 'shaft_oil_change'=>'Drive shaft oil', 'brake_pads'=>'Brake pads', 'registration'=>'Registration'] as $key=>$label) {
|
|
$v = km_since($state, $key);
|
|
$last = $state['last'][$key]['km'] ?? null;
|
|
$statCards[$key] = ['icon'=>$icons[$key], 'label'=>$label, 'value'=>$v===null ? '—' : number_format($v), 'suffix'=>$v===null ? '' : 'km', 'last'=>'last: ' . ($last===null ? 'never' : number_format((int)$last).' km')];
|
|
}
|
|
?>
|
|
<a class="card hero" href="https://fjr.novosel.dk">
|
|
<div class="heroBox main"><div class="muted">🏍️ Current odometer</div><div class="big"><?=number_format((int)$state['odometer'])?> km</div><div class="muted">Yamaha FJR <?=h($config['production_year'] ?? '')?> · Reg: <?=h($config['registration_number'] ?? '')?></div></div>
|
|
<div class="heroBox"><div class="muted">📍 Since bought</div><div class="big"><?=number_format($kmOwned)?></div><div class="muted">km since <?=h(eu_date($config['purchase_date'] ?? ''))?></div></div>
|
|
<div class="heroBox"><div class="muted">📅 Owned for</div><div class="big"><?=h($ownedFor)?></div><div class="muted">bought @ <?=number_format($purchaseKm)?> km</div></div>
|
|
</a>
|
|
<div class="grid">
|
|
<?php foreach ($statCards as $card): ?>
|
|
<div class="stat"><b><span class="icon"><?=$card['icon']?></span><?=$card['label']?></b><br><span class="big"><?=$card['value']?></span> <span class="muted"><?=$card['suffix']?></span><br><span class="muted"><?=$card['last']?></span></div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<div class="card"><h2>Add update</h2><form method="post">
|
|
<label>Current km / event km</label><input name="km" type="number" inputmode="numeric" min="0" value="<?=h($state['odometer'])?>" required>
|
|
<label>Date</label><input name="event_date" type="date" value="<?=h(date('Y-m-d'))?>" required>
|
|
<label>Type</label><select name="type"><?php foreach ($types as $k=>$v): ?><option value="<?=h($k)?>"><?=h($v)?></option><?php endforeach; ?></select>
|
|
<label>Cost (optional)</label><input name="cost" placeholder="e.g. 42.50">
|
|
<label>Note (optional)</label><textarea name="note" rows="3" placeholder="brand, details, reminder..."></textarea>
|
|
<button>Save + publish MQTT</button>
|
|
</form></div>
|
|
<div class="card"><h2>Recent history</h2><table><tr><th>Date</th><th>Type</th><th>Km</th><th>Note</th></tr><?php foreach ($events as $e): ?><tr><td><?=h(eu_date($e['time'] ?? ''))?></td><td><span class="pill"><?=h($e['label']??$e['type']??'')?></span></td><td><?=number_format((int)($e['km']??0))?></td><td><?=h($e['note']??'')?></td></tr><?php endforeach; ?></table></div>
|
|
</div></body></html>
|