const socket = io(); // ── Map ──────────────────────────────────────────────────────────────────── var map = L.map('map', { minZoom: 8, maxZoom: 15 }).setView([48.505, -0.09], 12); const mapTiles = L.tileLayer('/static/vendor/tiles/{z}/{x}/{y}.png', { attribution: '© MapTiler © OpenStreetMap contributors', minZoom: 8, maxZoom: 15, }); var carMarker = L.marker(map.getCenter()).addTo(map).bindPopup('Your Yaris'); // ── Speed history chart ──────────────────────────────────────────────────── const MAX_SPEED_POINTS = 60; // ~5 min at typical OBD poll rate const ctx = document.getElementById('speedGraph').getContext('2d'); // Gradient fill (created after canvas is sized) function makeGradient() { const grad = ctx.createLinearGradient(0, 0, 0, ctx.canvas.clientHeight || 200); grad.addColorStop(0, 'rgba(78,115,223,0.35)'); grad.addColorStop(1, 'rgba(78,115,223,0.02)'); return grad; } const chart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [{ label: 'Speed (km/h)', data: [], borderColor: '#4e73df', backgroundColor: makeGradient(), borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, pointHoverBackgroundColor: '#4e73df', tension: 0.4, fill: true, }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 200 }, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: function (ctx) { return ctx.parsed.y != null ? ctx.parsed.y + ' km/h' : ''; } } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: 6, color: '#adb5bd', font: { size: 10 }, maxRotation: 0, } }, y: { beginAtZero: true, suggestedMax: 60, grid: { color: 'rgba(0,0,0,.05)' }, ticks: { color: '#adb5bd', font: { size: 10 }, callback: function (v) { return v + ' km/h'; }, maxTicksLimit: 5, } } } } }); // Session stats let sessionMaxSpeed = 0; let sessionSpeedSum = 0; let sessionSpeedCount = 0; function updateSpeedStats(speed) { if (speed == null || typeof speed !== 'number') return; // Update session max if (speed > sessionMaxSpeed) { sessionMaxSpeed = speed; const maxEl = document.getElementById('speed-max-badge'); if (maxEl) maxEl.textContent = speed.toFixed(0) + ' km/h'; } // Update session avg sessionSpeedSum += speed; sessionSpeedCount++; const avgEl = document.getElementById('speed-avg-badge'); if (avgEl) avgEl.textContent = (sessionSpeedSum / sessionSpeedCount).toFixed(1) + ' km/h'; // Points count const ptEl = document.getElementById('speed-points-badge'); if (ptEl) ptEl.textContent = sessionSpeedCount; } // ── WebSocket events ────────────────────────────────────────────────────── socket.on('connect', function () { console.log('socket.io connected'); }); socket.on('obd_status', function (data) { const el = document.getElementById('obd-status'); if (el) el.innerHTML = data; }); socket.on('obd_speed', function (data) { const statusEl = document.getElementById('obd-speed'); const nowBadge = document.getElementById('speed-now-badge'); const lastTime = document.getElementById('speed-last-time'); if (!Array.isArray(data)) { if (statusEl) statusEl.textContent = data; if (nowBadge) nowBadge.textContent = '—'; return; } const time = data[0]; // "HH:MM:SS" const speed = data[1]; // number, km/h // Update stat cards and badges if (statusEl) statusEl.textContent = speed != null ? speed + ' km/h' : '—'; if (nowBadge) nowBadge.textContent = speed != null ? speed + ' km/h' : '—'; if (lastTime) lastTime.textContent = 'Last: ' + time; updateSpeedStats(speed); // Push into chart (oldest at left, newest at right) const labels = chart.data.labels; const points = chart.data.datasets[0].data; labels.push(time); points.push(speed); if (labels.length > MAX_SPEED_POINTS) { labels.shift(); points.shift(); } // Refresh gradient in case canvas resized chart.data.datasets[0].backgroundColor = makeGradient(); // Dynamic Y max: 20% headroom above session max, min 60 const displayMax = Math.max(60, Math.ceil((sessionMaxSpeed * 1.2) / 10) * 10); chart.options.scales.y.suggestedMax = displayMax; chart.update('none'); // skip animation for live updates }); socket.on('obd_rpm', function (data) { const el = document.getElementById('obd-rpm'); if (el) el.textContent = (data != null && data !== '-') ? data + ' rpm' : '—'; }); socket.on('obd_coolant_temp', function (data) { const el = document.getElementById('obd-coolant'); if (el) el.textContent = (data != null && data !== '-') ? data + ' °C' : '—'; }); socket.on('obd_fuel_level', function (data) { const el = document.getElementById('obd-fuel'); if (el) el.textContent = (data != null && data !== '-') ? data + ' %' : '—'; }); socket.on('obd_throttle', function (data) { const el = document.getElementById('obd-throttle'); if (el) el.textContent = (data != null && data !== '-') ? data + ' %' : '—'; }); socket.on('obd_error', function (data) { const el = document.getElementById('obd-error'); if (el) el.innerHTML = data; }); let mapCentered = false; let _lastLat = null, _lastLon = null; let _geocodeTimer = null; const _recenterBtn = document.getElementById('btn-recenter'); _recenterBtn.addEventListener('click', function () { if (_lastLat != null && _lastLon != null) { map.setView([_lastLat, _lastLon], 15); } }); function reverseGeocode(lat, lon) { const el = document.getElementById('gps-location-name'); const fallback = `${lat.toFixed(5)}, ${lon.toFixed(5)}`; fetch(`/api/geocode?lat=${lat}&lon=${lon}`, { signal: AbortSignal.timeout(4000) }) .then(r => r.ok ? r.json() : null) .then(data => { el.textContent = (data && data.label) ? data.label : fallback; }) .catch(() => { el.textContent = fallback; }); } socket.on('gps_location', function(data) { console.log("location received") const LocationX = document.getElementById('gpsLocationX'); const LocationY = document.getElementById('gpsLocationY'); LocationX.innerHTML = data[0]; LocationY.innerHTML = data[1]; const lat = data[0]; const lon = data[1]; if (lat != null && lon != null) { if (!map.hasLayer(mapTiles)) { mapTiles.addTo(map); } carMarker.setLatLng([lat, lon]); if (!mapCentered) { map.setView([lat, lon], 15); mapCentered = true; } _lastLat = lat; _lastLon = lon; _recenterBtn.hidden = false; // Throttle geocode to at most once every 30s if (!_geocodeTimer) { reverseGeocode(lat, lon); _geocodeTimer = setTimeout(() => { _geocodeTimer = null; }, 30000); } } }); function formatUptime(seconds) { const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); return days ? `${days}d ${hours}h` : `${hours}h ${minutes}m`; } function setHealthValue(id, value) { document.getElementById(id).textContent = value; } function healthWarningLabel(warning) { return { under_voltage: "Power supply undervoltage", frequency_capped: "CPU frequency capped", throttled: "CPU currently throttled", soft_temperature_limit: "CPU temperature limit active", high_temperature: "CPU temperature is high", disk_nearly_full: "Disk is nearly full", memory_pressure: "Memory usage is high", }[warning] || warning; } function historicalHealthWarningLabel(warning) { return { under_voltage: "Power supply undervoltage", frequency_capped: "CPU frequency capping", throttled: "CPU throttling", soft_temperature_limit: "CPU temperature limiting", }[warning] || warning; } async function refreshHealth() { const response = await fetch("/api/health"); const health = await response.json(); if (!response.ok) { throw new Error(health.error || "Could not load Pi health."); } setHealthValue( "pi-temperature", health.temperature_c == null ? "Unavailable" : `${health.temperature_c.toFixed(1)} °C` ); setHealthValue( "pi-memory", health.memory == null ? "Unavailable" : `${health.memory.used_percent.toFixed(1)}%` ); setHealthValue( "pi-disk", health.disk == null ? "Unavailable" : `${health.disk.used_percent.toFixed(1)}% · ${YarisUI.formatBytes(health.disk.free_bytes)} free` ); setHealthValue( "pi-uptime", health.uptime_seconds == null ? "Unavailable" : formatUptime(health.uptime_seconds) ); const warning = document.getElementById("pi-health-warning"); const currentWarnings = health.warnings.map(healthWarningLabel); const historicalWarnings = health.historical_warnings.map( historicalHealthWarningLabel ); const warnings = currentWarnings.concat( historicalWarnings.length ? [`Previously detected: ${historicalWarnings.join(", ")}`] : [] ); warning.hidden = warnings.length === 0; warning.textContent = warnings.join(" · "); const error = document.getElementById("pi-health-error"); const unavailable = Object.keys(health.errors); error.hidden = unavailable.length === 0; error.textContent = unavailable.length ? `Unavailable health sources: ${unavailable.join(", ")}` : ""; document.getElementById("pi-health-updated").textContent = `Updated ${new Date().toLocaleTimeString()}`; } refreshHealth().catch(function (error) { const message = document.getElementById("pi-health-error"); message.textContent = error.message; message.hidden = false; }); window.setInterval(function () { refreshHealth().catch(function (error) { const message = document.getElementById("pi-health-error"); message.textContent = error.message; message.hidden = false; }); }, 30000); // ── Phone hotspot banner ─────────────────────────────────────────────────── (function () { const banner = document.getElementById('phone-hotspot-banner'); const ipEl = document.getElementById('phone-hotspot-ip'); function checkUplink() { fetch('/api/uplink/status') .then(r => r.ok ? r.json() : null) .then(s => { if (s && s.mode === 'phone' && s.ip_address) { ipEl.textContent = `http://${s.ip_address}/`; banner.hidden = false; } else { banner.hidden = true; } }) .catch(() => { banner.hidden = true; }); } checkUplink(); window.setInterval(checkUplink, 15000); })();