Initial FJR webpage
This commit is contained in:
commit
1ac87c3788
8 changed files with 359 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
AGENTS.md
|
||||||
12
config.php
Normal file
12
config.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
return [
|
||||||
|
'bike_name' => 'Yamaha FJR',
|
||||||
|
'production_year' => '2018',
|
||||||
|
'registration_number' => 'DW83395',
|
||||||
|
'purchase_date' => '2024-03-02',
|
||||||
|
'purchase_km' => 48250,
|
||||||
|
'topic_prefix' => 'fjr',
|
||||||
|
'mqtt_host' => '192.168.0.203',
|
||||||
|
'mqtt_port' => 1883,
|
||||||
|
'data_dir' => __DIR__ . '/data',
|
||||||
|
];
|
||||||
32
data/events.jsonl
Normal file
32
data/events.jsonl
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{"time": "2024-03-02T12:00:00+00:00", "type": "note", "label": "Other note", "km": 48250, "cost": null, "note": "picked up from dealership!", "summary": "Other note @ 48250 km - picked up from dealership!"}
|
||||||
|
{"time": "2024-03-04T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 48500, "cost": null, "note": "Trip: 250 km; Total since pickup: 250 km", "summary": "Odometer update only @ 48500 km - Trip: 250 km; Total since pickup: 250 km"}
|
||||||
|
{"time": "2024-03-25T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 48900, "cost": null, "note": "Trip: 400 km; Total since pickup: 650 km", "summary": "Odometer update only @ 48900 km - Trip: 400 km; Total since pickup: 650 km"}
|
||||||
|
{"time": "2024-04-06T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 49427, "cost": null, "note": "Trip: 527 km; Total since pickup: 1177 km", "summary": "Odometer update only @ 49427 km - Trip: 527 km; Total since pickup: 1177 km"}
|
||||||
|
{"time": "2024-04-07T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 49584, "cost": null, "note": "Trip: 157 km; Total since pickup: 1334 km", "summary": "Odometer update only @ 49584 km - Trip: 157 km; Total since pickup: 1334 km"}
|
||||||
|
{"time": "2024-04-27T12:00:00+00:00", "type": "note", "label": "Other note", "km": 50286, "cost": null, "note": "Egeskov Castle — Trip: 702 km; Total since pickup: 2036 km", "summary": "Other note @ 50286 km - Egeskov Castle — Trip: 702 km; Total since pickup: 2036 km"}
|
||||||
|
{"time": "2024-04-28T12:00:00+00:00", "type": "note", "label": "Other note", "km": 50556, "cost": null, "note": "autobahn 220km/h (actually 202) — Trip: 270 km; Total since pickup: 2306 km", "summary": "Other note @ 50556 km - autobahn 220km/h (actually 202) — Trip: 270 km; Total since pickup: 2306 km"}
|
||||||
|
{"time": "2024-05-09T12:00:00+00:00", "type": "note", "label": "Other note", "km": 51133, "cost": null, "note": "Prison Ink — Trip: 577 km; Total since pickup: 2883 km", "summary": "Other note @ 51133 km - Prison Ink — Trip: 577 km; Total since pickup: 2883 km"}
|
||||||
|
{"time": "2024-05-18T12:00:00+00:00", "type": "note", "label": "Other note", "km": 51415, "cost": null, "note": "Ribe, Esbjerg — Trip: 282 km; Total since pickup: 3165 km", "summary": "Other note @ 51415 km - Ribe, Esbjerg — Trip: 282 km; Total since pickup: 3165 km"}
|
||||||
|
{"time": "2024-05-19T12:00:00+00:00", "type": "note", "label": "Other note", "km": 51875, "cost": null, "note": "fckin Århus — Trip: 460 km; Total since pickup: 3625 km", "summary": "Other note @ 51875 km - fckin Århus — Trip: 460 km; Total since pickup: 3625 km"}
|
||||||
|
{"time": "2024-05-23T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 52105, "cost": null, "note": "Trip: 230 km; Total since pickup: 3855 km", "summary": "Odometer update only @ 52105 km - Trip: 230 km; Total since pickup: 3855 km"}
|
||||||
|
{"time": "2024-06-08T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 52883, "cost": null, "note": "Trip: 778 km; Total since pickup: 4633 km", "summary": "Odometer update only @ 52883 km - Trip: 778 km; Total since pickup: 4633 km"}
|
||||||
|
{"time": "2024-06-15T12:00:00+00:00", "type": "note", "label": "Other note", "km": 53605, "cost": null, "note": "Metallica — Trip: 722 km; Total since pickup: 5355 km", "summary": "Other note @ 53605 km - Metallica — Trip: 722 km; Total since pickup: 5355 km"}
|
||||||
|
{"time": "2024-06-23T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 53861, "cost": null, "note": "Trip: 256 km; Total since pickup: 5611 km", "summary": "Odometer update only @ 53861 km - Trip: 256 km; Total since pickup: 5611 km"}
|
||||||
|
{"time": "2024-07-25T12:00:00+00:00", "type": "oil_change", "label": "Oil change", "km": 56712, "cost": null, "note": "Oil & filter change — Trip: 2851 km; Total since pickup: 8462 km", "summary": "Oil change @ 56712 km - Oil & filter change — Trip: 2851 km; Total since pickup: 8462 km"}
|
||||||
|
{"time": "2024-07-28T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 58376, "cost": null, "note": "Trip: 1664 km; Total since pickup: 10126 km", "summary": "Odometer update only @ 58376 km - Trip: 1664 km; Total since pickup: 10126 km"}
|
||||||
|
{"time": "2024-08-02T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 58565, "cost": null, "note": "Trip: 189 km; Total since pickup: 10315 km", "summary": "Odometer update only @ 58565 km - Trip: 189 km; Total since pickup: 10315 km"}
|
||||||
|
{"time": "2024-08-18T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 59639, "cost": null, "note": "Trip: 1074 km; Total since pickup: 11389 km", "summary": "Odometer update only @ 59639 km - Trip: 1074 km; Total since pickup: 11389 km"}
|
||||||
|
{"time": "2024-08-24T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 60067, "cost": null, "note": "Trip: 428 km; Total since pickup: 11817 km", "summary": "Odometer update only @ 60067 km - Trip: 428 km; Total since pickup: 11817 km"}
|
||||||
|
{"time": "2025-04-23T12:00:00+00:00", "type": "front_tyre_change", "label": "Front tyre changed", "km": 64183, "cost": null, "note": "Tires changed — Trip: 4116 km; Total since pickup: 15933 km", "summary": "Front tyre changed @ 64183 km - Tires changed — Trip: 4116 km; Total since pickup: 15933 km"}
|
||||||
|
{"time": "2025-04-23T12:00:00+00:00", "type": "rear_tyre_change", "label": "Rear tyre changed", "km": 64183, "cost": null, "note": "Tires changed — Trip: 4116 km; Total since pickup: 15933 km", "summary": "Rear tyre changed @ 64183 km - Tires changed — Trip: 4116 km; Total since pickup: 15933 km"}
|
||||||
|
{"time": "2025-04-26T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 64745, "cost": null, "note": "Trip: 562 km; Total since pickup: 16495 km", "summary": "Odometer update only @ 64745 km - Trip: 562 km; Total since pickup: 16495 km"}
|
||||||
|
{"time": "2025-06-05T12:00:00+00:00", "type": "oil_change", "label": "Oil change", "km": 66226, "cost": null, "note": "Oil & Filter & AirFilter changed — Trip: 1481 km; Total since pickup: 17976 km", "summary": "Oil change @ 66226 km - Oil & Filter & AirFilter changed — Trip: 1481 km; Total since pickup: 17976 km"}
|
||||||
|
{"time": "2025-07-05T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 70099, "cost": null, "note": "Trip: 3873 km; Total since pickup: 21849 km", "summary": "Odometer update only @ 70099 km - Trip: 3873 km; Total since pickup: 21849 km"}
|
||||||
|
{"time": "2025-07-12T12:00:00+00:00", "type": "odometer", "label": "Odometer update only", "km": 70196, "cost": null, "note": "Trip: 97 km; Total since pickup: 21946 km", "summary": "Odometer update only @ 70196 km - Trip: 97 km; Total since pickup: 21946 km"}
|
||||||
|
{"time": "2025-07-28T12:00:00+00:00", "type": "note", "label": "Other note", "km": 73964, "cost": null, "note": "London — Trip: 3768 km; Total since pickup: 25714 km", "summary": "Other note @ 73964 km - London — Trip: 3768 km; Total since pickup: 25714 km"}
|
||||||
|
{"time": "2024-02-23T12:00:00+01:00", "type": "registration", "label": "Registration / insurance", "km": 48250, "cost": null, "note": "Registration date", "summary": "Registration / insurance @ 48250 km - Registration date"}
|
||||||
|
{"time":"2026-04-23T12:53:38+02:00","type":"odometer","label":"Odometer update only","km":79458,"cost":null,"note":"","summary":"Odometer update only @ 79458 km"}
|
||||||
|
{"time":"2026-05-09T15:29:40+02:00","type":"oil_change","label":"Oil change","km":79964,"cost":null,"note":"","summary":"Oil change @ 79964 km"}
|
||||||
|
{"time":"2026-05-25T13:46:37+02:00","type":"odometer","label":"Odometer update only","km":80264,"cost":null,"note":"Pinsedag","summary":"Odometer update only @ 80264 km - Pinsedag"}
|
||||||
|
{"time":"2026-05-30T21:44:56+02:00","type":"odometer","label":"Odometer update only","km":80801,"cost":null,"note":"S\u00f8ndervig Strand","summary":"Odometer update only @ 80801 km - S\u00f8ndervig Strand"}
|
||||||
|
{"time":"2026-06-03T17:50:01+02:00","type":"odometer","label":"Odometer update only","km":81570,"cost":null,"note":"Iron Maiden Hannover","summary":"Odometer update only @ 81570 km - Iron Maiden Hannover"}
|
||||||
52
data/state.json
Normal file
52
data/state.json
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"odometer": 81570,
|
||||||
|
"updated_at": "2026-06-03T17:50:01+02:00",
|
||||||
|
"last": {
|
||||||
|
"oil_change": {
|
||||||
|
"time": "2026-05-09T15:29:40+02:00",
|
||||||
|
"type": "oil_change",
|
||||||
|
"label": "Oil change",
|
||||||
|
"km": 79964,
|
||||||
|
"cost": null,
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"front_tyre_change": {
|
||||||
|
"time": "2025-04-23T12:00:00+00:00",
|
||||||
|
"type": "front_tyre_change",
|
||||||
|
"label": "Front tyre changed",
|
||||||
|
"km": 64183,
|
||||||
|
"cost": null,
|
||||||
|
"note": "Tires changed \u2014 Trip: 4116 km; Total since pickup: 15933 km",
|
||||||
|
"summary": "Front tyre changed @ 64183 km - Tires changed \u2014 Trip: 4116 km; Total since pickup: 15933 km"
|
||||||
|
},
|
||||||
|
"rear_tyre_change": {
|
||||||
|
"time": "2025-04-23T12:00:00+00:00",
|
||||||
|
"type": "rear_tyre_change",
|
||||||
|
"label": "Rear tyre changed",
|
||||||
|
"km": 64183,
|
||||||
|
"cost": null,
|
||||||
|
"note": "Tires changed \u2014 Trip: 4116 km; Total since pickup: 15933 km",
|
||||||
|
"summary": "Rear tyre changed @ 64183 km - Tires changed \u2014 Trip: 4116 km; Total since pickup: 15933 km"
|
||||||
|
},
|
||||||
|
"shaft_oil_change": null,
|
||||||
|
"brake_pads": null,
|
||||||
|
"registration": {
|
||||||
|
"time": "2024-02-23T12:00:00+01:00",
|
||||||
|
"type": "registration",
|
||||||
|
"label": "Registration / insurance",
|
||||||
|
"km": 48250,
|
||||||
|
"cost": null,
|
||||||
|
"note": "Registration date",
|
||||||
|
"summary": "Registration / insurance @ 48250 km - Registration date"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"latest_event": {
|
||||||
|
"time": "2026-06-03T17:50:01+02:00",
|
||||||
|
"type": "odometer",
|
||||||
|
"label": "Odometer update only",
|
||||||
|
"km": 81570,
|
||||||
|
"cost": null,
|
||||||
|
"note": "Iron Maiden Hannover",
|
||||||
|
"summary": "Odometer update only @ 81570 km - Iron Maiden Hannover"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
favicon.svg
Normal file
17
favicon.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#111827"/>
|
||||||
|
<stop offset="0.55" stop-color="#0b0b0b"/>
|
||||||
|
<stop offset="1" stop-color="#14532d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#34d399"/>
|
||||||
|
<stop offset="1" stop-color="#60a5fa"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
|
||||||
|
<path d="M10 49h44" stroke="url(#accent)" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<text x="32" y="40" text-anchor="middle" font-family="Arial Black, Impact, Arial, sans-serif" font-size="25" font-style="italic" font-weight="900" fill="#f8fafc" letter-spacing="-2">FJR</text>
|
||||||
|
<path d="M12 15h40" stroke="url(#accent)" stroke-width="3" stroke-linecap="round" opacity=".9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 919 B |
BIN
img/fjr.jpg
Normal file
BIN
img/fjr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 994 KiB |
BIN
img/fjr.png
Normal file
BIN
img/fjr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
245
index.php
Normal file
245
index.php
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
<?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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue