Files
MicrOBU/microbu-esp32c5/tools/v2x2map-0.3.0/bridge/dashboard.html
T
Ashin Walpola 0e9525162d Keep the colleague's microbu-esp32c5 tree in this repository
obu-firmware builds against vanetza-idf from microbu-esp32c5/external, but
that tree was gitignored, so a clone of this repository could not build the
firmware it ships. It is now committed here as ordinary files in its own
folder, microbu-esp32c5/: the colleague's commit cf4b99f plus the V2X2MAP
bridge's signature verification (--trust) used on the bench. Nothing is
fetched from or pushed to the colleague's repository; this repository and
its remotes carry everything. The folder's own .gitignore keeps build output,
downloaded components and private key material out, as it did there; the
committed file set is identical to that repository's tracked files.

The ESP32-C5 is still flashed from obu-firmware/, which only takes
vanetza-idf from microbu-esp32c5/, so the two stay separate folders.
FLASHING.md says how to take a newer version of the colleague's tree (copy
it over the folder, rebuild, test, commit).
2026-09-23 17:46:40 +02:00

791 lines
41 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>V2X2MAP — ITS-G5 Dashboard</title>
<meta name="description" content="V2X2MAP ITS-G5 live dashboard">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style>
* { box-sizing: border-box; }
html, body { margin:0; font-family:ui-monospace,Consolas,monospace; font-size:14px;
background:#0e1116; color:#dde; height:100%; }
/* ── Header ──────────────────────────────────────────────────────────────── */
header {
padding:7px 14px; background:#161b22; border-bottom:1px solid #30363d;
display:flex; gap:14px; align-items:center; flex-wrap:wrap; flex-shrink:0;
}
header h1 { font-size:15px; margin:0; color:#fff; font-weight:700; letter-spacing:.5px; }
.stat { font-size:12px; color:#8b949e; white-space:nowrap; }
.stat b { color:#58a6ff; }
#serial-dot { display:inline-block; width:9px; height:9px; border-radius:50%;
background:#444; margin-right:4px; vertical-align:middle; }
#serial-dot.up { background:#3fb950; }
#serial-dot.down { background:#da3633; }
#conn { font-size:12px; padding:3px 9px; border-radius:10px; }
#conn.up { background:#1f6feb; color:#fff; }
#conn.down { background:#da3633; color:#fff; }
.btn {
font-family:inherit; font-size:12px; cursor:pointer;
padding:3px 11px; border-radius:12px; border:1px solid #30363d;
background:#21262d; color:#8b949e;
}
.btn:hover:not([disabled]) { background:#30363d; color:#dde; }
.btn.on { background:#238636; color:#fff; border-color:#238636; }
.btn.on:hover { background:#2ea043; }
.btn.rec { background:#6e2020; color:#f47979; border-color:#6e2020; }
.btn.rec:hover { background:#8a2828; }
.btn[disabled] { opacity:.5; cursor:not-allowed; }
/* ── Layout ─────────────────────────────────────────────────────────────── */
main { display:grid; grid-template-columns:1fr 1fr; gap:1px;
background:#30363d; height:calc(100vh - 44px); min-height:0; }
#map { min-height:0; }
/* locate button */
.locate-btn { width:30px; height:30px; font-size:19px; line-height:28px; text-align:center;
background:#fff; border:none; cursor:pointer; display:block;
color:#333; border-radius:2px; }
.locate-btn:hover { background:#f4f4f4; }
/* ── Right panel ─────────────────────────────────────────────────────────── */
#rightpanel { background:#0d1117; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
/* Tab bar */
#tab-bar { display:flex; border-bottom:1px solid #21262d; flex-shrink:0; }
.tab {
font-family:inherit; font-size:12px; padding:7px 14px; cursor:pointer;
border:none; background:none; color:#8b949e; border-bottom:2px solid transparent;
}
.tab:hover { color:#dde; background:#161b22; }
.tab.active { color:#58a6ff; border-bottom-color:#58a6ff; }
/* Tab panes */
.pane { display:none; flex:1; min-height:0; flex-direction:column; overflow:hidden; }
.pane.active { display:flex; }
/* ── Log pane ────────────────────────────────────────────────────────────── */
#log-filters {
display:flex; align-items:center; gap:6px; flex-wrap:wrap;
padding:6px 10px; border-bottom:1px solid #161b22; flex-shrink:0;
}
.fchip {
font-family:inherit; font-size:12px; padding:2px 9px; border-radius:10px;
border:1px solid #30363d; background:#21262d; color:#8b949e; cursor:pointer;
}
.fchip:hover { background:#30363d; }
.fchip.on { color:#fff; border-color:transparent; }
.fchip[data-type="VAM"].on { background:#009688; }
.fchip[data-type="CAM"].on { background:#1565c0; }
.fchip[data-type="DENM"].on { background:#bf360c; }
.fchip[data-type="SPATEM"].on { background:#2e7d32; }
.fchip[data-type="MAPEM"].on { background:#6a1b9a; }
.fchip[data-type="OTHER"].on { background:#546e7a; }
#station-list { flex:1; overflow-y:auto; }
#empty-log { padding:20px; color:#555; text-align:center; }
.srow {
display:grid;
grid-template-columns:32px 58px 96px 1fr 70px 36px 20px 20px;
align-items:center; padding:5px 8px; border-bottom:1px solid #161b22;
cursor:pointer; user-select:none; gap:0 4px;
}
.srow:hover { background:#161b22; }
.srow.stale { opacity:.4; }
.srow-icon { display:flex; align-items:center; justify-content:center; }
.srow-badge { font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; }
.srow-id { font-size:11px; color:#79c0ff; }
.srow-sum { font-size:12px; color:#8b949e; overflow:hidden; text-overflow:ellipsis; }
.srow-time { font-size:11px; color:#555; text-align:right; }
.srow-count { font-size:11px; color:#555; text-align:right; }
.srow-lock { font-size:12px; text-align:center; }
.srow-chev { font-size:12px; color:#555; text-align:center; }
.frow {
display:grid; grid-template-columns:70px 60px 38px 1fr;
padding:3px 8px 3px 42px; background:#080c10;
border-bottom:1px solid #0d1117; gap:0 6px; cursor:default;
}
.frow-time { font-size:11px; color:#555; }
.frow-et { font-size:11px; font-weight:600; }
.frow-len { font-size:11px; color:#555; }
.frow-hex { font-size:11px; color:#79c0ff; overflow:hidden; text-overflow:ellipsis; }
.et-its { color:#7ee787; }
.et-wlan { color:#8b949e; }
.et-unk { color:#f0883e; }
/* ── Stats pane ──────────────────────────────────────────────────────────── */
#pane-stats { overflow-y:auto; padding:14px; gap:16px; }
.stats-card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:12px 16px; }
.stats-card h3 { margin:0 0 10px; font-size:13px; color:#8b949e; text-transform:uppercase; letter-spacing:.8px; }
.kv-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px 24px; }
.kv { display:flex; flex-direction:column; }
.kv-key { font-size:11px; color:#555; }
.kv-value { font-size:15px; color:#58a6ff; font-weight:600; }
#type-table { width:100%; border-collapse:collapse; font-size:13px; }
#type-table th { color:#8b949e; text-align:left; padding:4px 8px; font-weight:500;
border-bottom:1px solid #30363d; }
#type-table td { padding:5px 8px; border-bottom:1px solid #161b22; }
#type-table td:last-child { text-align:right; color:#555; }
.broker-row { display:flex; align-items:center; gap:8px; padding:5px 0;
border-bottom:1px solid #161b22; font-size:12px; }
.broker-dot { width:9px; height:9px; border-radius:50%; background:#555; flex-shrink:0; }
.broker-dot.up { background:#3fb950; }
/* rate sparkline */
#sparkline { display:flex; align-items:flex-end; gap:1px; height:48px;
margin-top:8px; }
.spark-bar { flex:1; background:#1f6feb; border-radius:1px 1px 0 0; min-height:2px; }
/* ── MAC pane ────────────────────────────────────────────────────────────── */
#pane-macs { overflow:hidden; }
#mac-toolbar { padding:8px 10px; border-bottom:1px solid #161b22; flex-shrink:0; display:flex; gap:8px; }
#mac-search {
font-family:inherit; font-size:13px; background:#161b22; border:1px solid #30363d;
color:#dde; padding:4px 10px; border-radius:6px; flex:1;
}
#mac-search::placeholder { color:#555; }
#mac-table-wrap { flex:1; overflow-y:auto; }
#mac-table { width:100%; border-collapse:collapse; font-size:12px; }
#mac-table th {
position:sticky; top:0; background:#161b22; color:#8b949e; padding:5px 8px;
text-align:left; font-weight:500; border-bottom:1px solid #30363d;
cursor:pointer; user-select:none; white-space:nowrap;
}
#mac-table th:hover { color:#dde; }
#mac-table td { padding:4px 8px; border-bottom:1px solid #161b22; white-space:nowrap; }
#mac-table tr:hover td { background:#161b22; }
.mac-addr { color:#79c0ff; font-family:inherit; }
.mac-types { color:#8b949e; font-size:11px; }
#mac-empty { padding:20px; color:#555; text-align:center; }
/* Leaflet popup */
.leaflet-popup-content { font-family:ui-monospace,Consolas,monospace; font-size:13px; line-height:1.6; }
.leaflet-popup-content .lbl { color:#666; display:inline-block; min-width:72px; }
.mi { background:none; border:none; }
.mi svg { display:block; filter:drop-shadow(0 0 2px rgba(0,0,0,.7)); }
</style>
</head>
<body>
<header>
<h1>V2X2MAP</h1>
<span class="stat">node <b id="node">?</b></span>
<span class="stat">total <b id="ntotal">0</b></span>
<span class="stat">60s <b id="nrate">0</b></span>
<span class="stat">B/s <b id="nbytes">—</b></span>
<span class="stat">last <b id="last">–</b></span>
<span class="stat"><span id="serial-dot"></span><span id="serial-lbl">—</span></span>
<span style="flex:1"></span>
<button id="rec-btn" class="btn" type="button" title="Start/stop PCAP recording">⏺ Record</button>
<button id="mqtt-toggle" class="btn" type="button">MQTT: <span id="mqtt-state-text">off</span></button>
<span id="conn" class="down">disconnected</span>
</header>
<main>
<div id="map"></div>
<div id="rightpanel">
<div id="tab-bar">
<button class="tab active" data-tab="log">Log</button>
<button class="tab" data-tab="stats">Statistics</button>
<button class="tab" data-tab="macs">MAC Analysis</button>
</div>
<!-- ── Log pane ─────────────────────────────────────────────────────── -->
<div id="pane-log" class="pane active">
<div id="log-filters">
<button class="fchip on" data-type="VAM">VAM</button>
<button class="fchip on" data-type="CAM">CAM</button>
<button class="fchip on" data-type="DENM">DENM</button>
<button class="fchip on" data-type="SPATEM">SPATEM</button>
<button class="fchip on" data-type="MAPEM">MAPEM</button>
<button class="fchip on" data-type="OTHER">Other</button>
</div>
<div id="station-list"><div id="empty-log">Waiting for frames...</div></div>
</div>
<!-- ── Stats pane ────────────────────────────────────────────────────── -->
<div id="pane-stats" class="pane">
<div class="stats-card">
<h3>Session</h3>
<div class="kv-grid">
<div class="kv"><span class="kv-key">Uptime</span><span class="kv-value" id="st-uptime">—</span></div>
<div class="kv"><span class="kv-key">Total frames</span><span class="kv-value" id="st-frames">0</span></div>
<div class="kv"><span class="kv-key">Rate (60 s)</span><span class="kv-value" id="st-rate">0 /min</span></div>
<div class="kv"><span class="kv-key">Frequency</span><span class="kv-value" id="st-freq">-</span></div>
<div class="kv"><span class="kv-key">Interval &plusmn; Jitter</span><span class="kv-value" id="st-interval">-</span></div>
<div class="kv"><span class="kv-key">Interval Range</span><span class="kv-value" id="st-range">-</span></div>
<div class="kv"><span class="kv-key">Total bytes</span><span class="kv-value" id="st-bytes">0</span></div>
</div>
<div style="margin-top:10px;font-size:11px;color:#555">Frames per second (last 60 s)</div>
<div id="sparkline"></div>
</div>
<div class="stats-card">
<h3>Message types</h3>
<table id="type-table">
<thead><tr><th>Type</th><th>Description</th><th>Frames</th></tr></thead>
<tbody id="type-tbody"></tbody>
</table>
</div>
<div class="stats-card">
<h3>MQTT Brokers</h3>
<div id="broker-list"><span style="color:#555;font-size:12px">No broker configured.</span></div>
</div>
<div class="stats-card">
<h3>Recording</h3>
<div id="rec-status" style="font-size:13px;color:#555">No active recording.</div>
<div style="margin-top:8px;display:flex;gap:8px;">
<button class="btn" id="rec-start-btn" onclick="startRecording()">Start</button>
<button class="btn" id="rec-stop-btn" onclick="stopRecording()" disabled>Stop</button>`r`n <a class="btn" id="rec-dl-btn" href="/api/record/download" download style="display:none;text-decoration:none;padding:5px 10px;font-size:13px;background:#2e7d32;color:#fff;">📥 Download PCAP</a>
</div>
</div>
</div>
<!-- ── MAC pane ──────────────────────────────────────────────────────── -->
<div id="pane-macs" class="pane">
<div id="mac-toolbar">
<input id="mac-search" type="search" placeholder="Filter MAC address..." oninput="renderMacTable()">
<button class="btn" onclick="exportMacCsv()">Export CSV</button>
</div>
<div id="mac-table-wrap">
<table id="mac-table">
<thead>
<tr>
<th onclick="sortMac('mac')">MAC ↕</th>
<th onclick="sortMac('type')">Type ↕</th>
<th onclick="sortMac('station')">Station ↕</th>
<th onclick="sortMac('first')">First seen ↕</th>
<th onclick="sortMac('last')">Last seen ↕</th>
<th onclick="sortMac('count')">Frames ↕</th>
<th>Position</th>
</tr>
</thead>
<tbody id="mac-tbody"><tr><td colspan="7" id="mac-empty">No MACs received yet.</td></tr></tbody>
</table>
</div>
</div>
</div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
'use strict';
// ─── Message type config ──────────────────────────────────────────────────
const MSG = {
VAM: { color:'#009688', short:'VAM', label:'Vulnerable road user (VAM)' },
CAM: { color:'#1976D2', short:'CAM', label:'Vehicle (CAM)' },
DENM: { color:'#E65100', short:'DENM', label:'Hazard warning (DENM)' },
MAPEM: { color:'#7B1FA2', short:'MAPEM', label:'Intersection map (MAPEM)' },
SPATEM: { color:'#388E3C', short:'SPATEM', label:'Signal phase (SPATEM)' },
IVIM: { color:'#C2185B', short:'IVIM', label:'In-vehicle info (IVIM)' },
SREM: { color:'#00838F', short:'SREM', label:'Signal request (SREM)' },
SSEM: { color:'#00838F', short:'SSEM', label:'Signal status (SSEM)' },
TLM: { color:'#AFB42B', short:'TLM', label:'Traffic light (TLM)' },
RTCMEM: { color:'#455A64', short:'RTCM', label:'Correction data (RTCMEM)' },
UNKNOWN:{ color:'#607D8B', short:'?', label:'Unknown' },
};
const PRIMARY = ['CAM','DENM','SPATEM','MAPEM','VAM'];
function typeConf(t) { return MSG[t] || MSG.UNKNOWN; }
// ─── SVG icons ────────────────────────────────────────────────────────────
function svg(w,h,vb,body){return`<svg width="${w}" height="${h}" viewBox="${vb}" xmlns="http://www.w3.org/2000/svg">${body}</svg>`;}
function carSvg(c,r){const rot=isFinite(r)?r:0;return svg(28,28,'-14 -14 28 28',`<g style="transform:rotate(${rot}deg);transform-origin:0 0"><path d="M0,-11 L8,8 L0,3 L-8,8 Z" fill="${c}" stroke="#fff" stroke-width="1.5"/></g>`);}
function tlSvg(c){return svg(22,30,'-11 -15 22 30',`<rect x="-7" y="-14" width="14" height="28" rx="4" fill="${c}"/><circle cx="0" cy="-7" r="4" fill="#fff" opacity=".35"/><circle cx="0" cy="0" r="4" fill="#fff" opacity=".35"/><circle cx="0" cy="7" r="4" fill="#fff" opacity=".35"/>`);}
function hazSvg(c){return svg(28,26,'-14 -13 28 26',`<path d="M0,-12 L12.5,10 L-12.5,10 Z" fill="${c}" stroke="#fff" stroke-width="1.5"/><text x="0" y="8" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold" font-family="sans-serif">!</text>`);}
function intSvg(c){return svg(26,26,'-13 -13 26 26',`<line x1="0" y1="-12" x2="0" y2="12" stroke="${c}" stroke-width="4" stroke-linecap="round"/><line x1="-12" y1="0" x2="12" y2="0" stroke="${c}" stroke-width="4" stroke-linecap="round"/><circle cx="0" cy="0" r="4.5" fill="${c}" stroke="#fff" stroke-width="1.5"/>`);}
function dotSvg(c){return svg(20,20,'-10 -10 20 20',`<circle r="8" fill="${c}" stroke="#fff" stroke-width="1.5"/>`);}
function typeSvg(t,c,h){switch(t){case'CAM':return carSvg(c,h);case'SPATEM':return tlSvg(c);case'DENM':return hazSvg(c);case'MAPEM':return intSvg(c);default:return dotSvg(c);}}
function mkIcon(t,c,h){const sizes={CAM:[28,28],SPATEM:[22,30],DENM:[28,26],MAPEM:[26,26]};const[w,sz]=sizes[t]||[20,20];return L.divIcon({className:'mi',html:typeSvg(t,c,h),iconSize:[w,sz||w],iconAnchor:[(w)/2,(sz||w)/2]});}
// ─── Utilities ────────────────────────────────────────────────────────────
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function fmtTime(ms){return new Date(ms).toLocaleTimeString();}
function fmtBytes(b){if(b>1e9)return(b/1e9).toFixed(2)+' GB';if(b>1e6)return(b/1e6).toFixed(1)+' MB';if(b>1e3)return(b/1e3).toFixed(1)+' KB';return b+' B';}
function fmtUptime(s){const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;return`${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;}
function stationKey(f){const sid=f.station_id||(f.gn_addr?f.gn_addr.replace(/:/g,''):'anon');return`${f.msg_type||'UNKNOWN'}|${sid}`;}
// ─── Map ──────────────────────────────────────────────────────────────────
const map = L.map('map').setView([51.0, 10.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{attribution:'© OpenStreetMap',maxZoom:19}).addTo(map);
// Location button
const LocCtrl = L.Control.extend({
onAdd(){
const d=L.DomUtil.create('div','leaflet-bar leaflet-control');
const b=L.DomUtil.create('button','locate-btn',d);
b.innerHTML='⊕'; b.title='Show my location';
L.DomEvent.on(b,'click',L.DomEvent.stopPropagation)
.on(b,'click',L.DomEvent.preventDefault)
.on(b,'click',()=>map.locate({setView:true,maxZoom:16}));
return d;
}
});
new LocCtrl({position:'topleft'}).addTo(map);
let locCircle = null;
map.on('locationfound', e => {
if(locCircle) locCircle.remove();
locCircle = L.circle(e.latlng,{radius:e.accuracy/2,color:'#58a6ff',fillOpacity:.15}).addTo(map);
});
map.on('locationerror', () => alert('Location not available.'));
// ─── Vehicle tracking ─────────────────────────────────────────────────────
const vehicles = new Map();
const TRACK_MAX = 500;
const STALE_MS = 60_000;
let firstFix = false;
function popupHtml(v){
const f=v.lastFrame, conf=typeConf(f.msg_type);
const kmh=f.speed_mps!==undefined?(f.speed_mps*3.6).toFixed(1)+' km/h':'—';
const hdg=f.heading_deg!==undefined?f.heading_deg.toFixed(1)+'°':'—';
return`<div>
<div style="font-weight:bold;color:${conf.color};margin-bottom:4px">${esc(conf.label)}</div>
<div><span class="lbl">ID</span>${esc(f.gn_addr||'—')}</div>
<div><span class="lbl">Station</span>${esc(f.station_type_label||'—')}</div>
<div><span class="lbl">Position</span>${f.lat!==undefined?f.lat.toFixed(6)+', '+f.lon.toFixed(6):'—'}</div>
<div><span class="lbl">Speed</span>${kmh}</div>
<div><span class="lbl">Heading</span>${hdg}</div>
${f.vam ? `<div><span class="lbl">VAM station</span>${esc(String(f.vam.station_id))}</div>
<div><span class="lbl">VRU type</span>${esc(String(f.vam.station_type))}</div>
<div><span class="lbl">Generation</span>${esc(String(f.vam.generation_delta_time))} ms</div>
<div><span class="lbl">Altitude</span>${f.vam.altitude_m === undefined ? '—' : esc(String(f.vam.altitude_m))+' m'}</div>
<div><span class="lbl">Acceleration</span>${f.vam.longitudinal_acceleration_mps2 === undefined ? '—' : esc(String(f.vam.longitudinal_acceleration_mps2))+' m/s²'}</div><details><summary>VAM containers</summary><pre style="max-height:220px;overflow:auto;white-space:pre-wrap">${esc(JSON.stringify(f.vam.containers || {},null,2))}</pre></details>` : ''}
<div><span class="lbl">Frames</span>${v.count}</div>
<div><span class="lbl">Last seen</span>${fmtTime(v.lastMs)}</div>
${f.secured?`<div><span class="lbl">Security</span>🔒 signed${f.security?` (${esc(String(f.security.hash))}, signer ${esc(String(f.security.signer))}${f.security.signer_id?' '+esc(String(f.security.signer_id)):''}, PSID ${esc(String(f.security.psid))}; ${f.security.verified===true?'<b style="color:#2e7d32">signature verified</b>':f.security.verified===false?'<b style="color:#c62828">SIGNATURE INVALID</b>':'not verified'}${f.security.verification?' – '+esc(String(f.security.verification)):''})`:''}</div>`:''}
</div>`;
}
function onPositionFrame(f){
if(f.lat===undefined||f.lon===undefined)return;
const conf=typeConf(f.msg_type), key=stationKey(f), ll=[f.lat,f.lon];
const track=(f.msg_type==='CAM');
let v=vehicles.get(key);
if(!v){
const line=track?L.polyline([],{color:conf.color,weight:3,opacity:.8}).addTo(map):null;
v={count:0,lastFrame:f,lastMs:Date.now(),points:[],line,
marker:L.marker(ll,{icon:mkIcon(f.msg_type,conf.color,f.heading_deg)}).addTo(map)};
v.marker.bindPopup('',{autoPan:true,maxWidth:360});
v.marker.on('popupopen',()=>v.marker.setPopupContent(popupHtml(v)));
vehicles.set(key,v);
if(!firstFix){firstFix=true;map.setView(ll,14);}
}
v.count++;v.lastFrame=f;v.lastMs=Date.now();
if(track){v.points.push(ll);if(v.points.length>TRACK_MAX)v.points.shift();v.line.setLatLngs(v.points);}
v.marker.setLatLng(ll);
v.marker.setIcon(mkIcon(f.msg_type,conf.color,f.heading_deg));
if(v.marker.isPopupOpen())v.marker.setPopupContent(popupHtml(v));
}
setInterval(()=>{
const now=Date.now();
for(const[k,v]of vehicles){
if(now-v.lastMs>STALE_MS){v.marker.remove();if(v.line)v.line.remove();vehicles.delete(k);}
}
},10_000);
// ─── Station log model ────────────────────────────────────────────────────
const stations = new Map();
const MAX_STATIONS = 150;
const FRAME_HISTORY = 20;
function updateStation(f){
const key=stationKey(f);
let s=stations.get(key);
if(!s){
s={key,msgType:f.msg_type||'UNKNOWN',stationId:f.station_id,gnAddr:f.gn_addr,
frameCount:0,lastSeen:0,lastFrame:f,recentFrames:[],expanded:false,secured:null};
stations.set(key,s);
if(stations.size>MAX_STATIONS){
let ok=null,ot=Infinity;
for(const[k,v]of stations)if(v.lastSeen<ot){ok=k;ot=v.lastSeen;}
if(ok)stations.delete(ok);
}
}
s.frameCount++;s.lastSeen=Date.now();s.lastFrame=f;
if(f.secured!==undefined)s.secured=f.secured;
s.recentFrames.unshift(f);
if(s.recentFrames.length>FRAME_HISTORY)s.recentFrames.pop();
}
// ─── MAC analysis model ───────────────────────────────────────────────────
const macEntries = new Map(); // mac → {mac, firstSeen, lastSeen, count, types, lastLat, lastLon, stationType}
let macSortCol='last', macSortAsc=false;
function updateMac(f){
const mac=f.gn_addr; if(!mac)return;
let e=macEntries.get(mac);
const now=Date.now();
if(!e){e={mac,firstSeen:now,lastSeen:now,count:0,types:new Set(),lastLat:null,lastLon:null,stationType:'—'};macEntries.set(mac,e);}
e.lastSeen=now;e.count++;
if(f.msg_type)e.types.add(f.msg_type);
if(f.lat!==undefined){e.lastLat=f.lat;e.lastLon=f.lon;}
if(f.station_type_label)e.stationType=f.station_type_label;
}
// ─── Sparkline (rate history, one value per second) ───────────────────────
const sparkData=[]; // last 60 per-second counts
let sparkBucket=0, lastSparkSec=Math.floor(Date.now()/1000);
function tickSparkline(now){
const s=Math.floor(now/1000);
if(s!==lastSparkSec){
sparkData.push(sparkBucket); sparkBucket=0; lastSparkSec=s;
if(sparkData.length>60)sparkData.shift();
renderSparkline();
} else sparkBucket++;
}
function renderSparkline(){
const el=document.getElementById('sparkline'); if(!el)return;
const max=Math.max(1,...sparkData);
el.innerHTML=sparkData.map(v=>`<div class="spark-bar" style="height:${Math.max(4,Math.round(v/max*44))}px" title="${v}/s"></div>`).join('');
}
// ─── Type counts ─────────────────────────────────────────────────────────
const typeCounts={};
function updateTypeCounts(t){typeCounts[t]=(typeCounts[t]||0)+1;}
function renderTypeCounts(){
const tbody=document.getElementById('type-tbody'); if(!tbody)return;
const total=Object.values(typeCounts).reduce((a,b)=>a+b,0)||1;
const rows=Object.entries(typeCounts).sort((a,b)=>b[1]-a[1]).map(([t,n])=>{
const conf=typeConf(t);
return`<tr>
<td style="color:${conf.color};font-weight:700">${esc(conf.short)}</td>
<td style="color:#8b949e">${esc(conf.label)}</td>
<td>${n} <span style="color:#555">(${(n/total*100).toFixed(1)}%)</span></td>
</tr>`;
}).join('');
tbody.innerHTML=rows||'<tr><td colspan="3" style="color:#555;padding:8px">Noch keine Daten.</td></tr>';
}
// ─── Filter ───────────────────────────────────────────────────────────────
const filterActive=new Set(['CAM','DENM','SPATEM','MAPEM','VAM','OTHER']);
function isVisible(t){return PRIMARY.includes(t)?filterActive.has(t):filterActive.has('OTHER');}
document.querySelectorAll('.fchip').forEach(b=>{
b.addEventListener('click',()=>{
const t=b.dataset.type;
filterActive.has(t)?filterActive.delete(t):filterActive.add(t);
b.classList.toggle('on',filterActive.has(t));
renderStationList();
});
});
// ─── Tab switching ────────────────────────────────────────────────────────
document.querySelectorAll('.tab').forEach(tab=>{
tab.addEventListener('click',()=>{
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.pane').forEach(p=>p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('pane-'+tab.dataset.tab).classList.add('active');
if(tab.dataset.tab==='stats'){renderTypeCounts();renderSparkline();}
if(tab.dataset.tab==='macs'){renderMacTable();}
});
});
// ─── Log rendering ────────────────────────────────────────────────────────
function buildSummary(s){
const f=s.lastFrame,cnt=`${s.frameCount}×`;
let d='';
switch(s.msgType){
case'VAM':d=f.vam ? `VRU ${f.station_type_label || f.vam.station_type} ${f.vam.speed_mps === undefined ? '—' : (f.vam.speed_mps*3.6).toFixed(1)+' km/h'} t=${f.vam.generation_delta_time} ms` : (f.vam_status || 'VAM payload unavailable');break;
case'CAM':{const ps=[];
if(f.speed_mps!==undefined&&f.speed_mps>0.5)ps.push((f.speed_mps*3.6).toFixed(0)+' km/h');
if(f.heading_deg!==undefined)ps.push(['N','NE','E','SE','S','SW','W','NW'][Math.round(f.heading_deg/45)%8]);
if(f.lat!==undefined)ps.push(f.lat.toFixed(4)+','+f.lon.toFixed(4));
d=ps.join(' ')||'—';break;}
case'SPATEM':d='Signal phase';break;
case'DENM':d='Hazard warning';break;
case'MAPEM':d='Intersection map';break;
default:d=f.len!==undefined?`len=${f.len}`:'—';
}
return`${cnt} ${d}`;
}
function etCls(et){if(et===0x8947)return'et-its';if(et==null)return'et-unk';return'et-wlan';}
function etLbl(et){if(et===0x8947)return'ITS-G5';if(et==null)return'?';return'0x'+et.toString(16).padStart(4,'0');}
const timeFmt=new Intl.DateTimeFormat(undefined,{hour:'2-digit',minute:'2-digit',second:'2-digit'});
function renderStationList(){
const now=Date.now();
const el=document.getElementById('station-list');
const sorted=[...stations.values()].filter(s=>isVisible(s.msgType)).sort((a,b)=>b.lastSeen-a.lastSeen);
if(!sorted.length){el.innerHTML='<div id="empty-log">Waiting for frames...</div>';return;}
let html='';
for(const s of sorted){
const conf=typeConf(s.msgType);
const stale=(now-s.lastSeen)>30_000;
const sid=s.stationId?'#'+s.stationId.slice(-8):'—';
const t=timeFmt.format(new Date(s.lastSeen));
const lock=s.secured===true?'🔒':'';
const chev=s.expanded?'▾':'▸';
html+=`<div class="srow${stale?' stale':''}" data-key="${esc(s.key)}">
<div class="srow-icon">${typeSvg(s.msgType,conf.color,s.lastFrame?.heading_deg)}</div>
<div class="srow-badge" style="color:${conf.color}">${esc(conf.short)}</div>
<div class="srow-id">${esc(sid)}</div>
<div class="srow-sum">${esc(buildSummary(s))}</div>
<div class="srow-time">${esc(t)}</div>
<div class="srow-count">${s.frameCount}×</div>
<div class="srow-lock">${lock}</div>
<div class="srow-chev">${chev}</div>
</div>`;
if(s.expanded){
for(const f of s.recentFrames){
const ft=timeFmt.format(new Date((f.sec||0)*1000+Math.floor((f.usec||0)/1000)));
html+=`<div class="frow">
<div class="frow-time">${esc(ft)}</div>
<div class="frow-et ${etCls(f.ether_type)}">${etLbl(f.ether_type)}</div>
<div class="frow-len">${f.len||0}</div>
<div class="frow-hex">${esc(f.hex||'')}</div>
</div>`;
}
}
}
el.innerHTML=html;
el.querySelectorAll('.srow[data-key]').forEach(row=>
row.addEventListener('click',()=>{
const s=stations.get(row.dataset.key);
if(s){s.expanded=!s.expanded;renderStationList();}
})
);
}
// ─── MAC table ────────────────────────────────────────────────────────────
function sortMac(col){
if(macSortCol===col)macSortAsc=!macSortAsc;else{macSortCol=col;macSortAsc=false;}
renderMacTable();
}
function renderMacTable(){
const filter=(document.getElementById('mac-search')?.value||'').toLowerCase();
const tbody=document.getElementById('mac-tbody'); if(!tbody)return;
let rows=[...macEntries.values()];
if(filter)rows=rows.filter(e=>e.mac.includes(filter)||[...e.types].join().toLowerCase().includes(filter));
rows.sort((a,b)=>{
let av,bv;
switch(macSortCol){
case'mac':av=a.mac;bv=b.mac;break;
case'type':av=[...a.types].join();bv=[...b.types].join();break;
case'station':av=a.stationType;bv=b.stationType;break;
case'first':av=a.firstSeen;bv=b.firstSeen;break;
case'count':av=a.count;bv=b.count;break;
default:av=a.lastSeen;bv=b.lastSeen;
}
return macSortAsc?(av>bv?1:-1):(av<bv?1:-1);
});
if(!rows.length){tbody.innerHTML='<tr><td colspan="7" id="mac-empty">No entries.</td></tr>';return;}
tbody.innerHTML=rows.map(e=>{
const types=[...e.types].map(t=>`<span style="color:${typeConf(t).color}">${typeConf(t).short}</span>`).join(' ');
const pos=e.lastLat!==null?`${e.lastLat.toFixed(5)}, ${e.lastLon.toFixed(5)}`:'—';
return`<tr>
<td class="mac-addr">${esc(e.mac)}</td>
<td class="mac-types">${types}</td>
<td style="color:#8b949e">${esc(e.stationType)}</td>
<td style="color:#555">${fmtTime(e.firstSeen)}</td>
<td style="color:#555">${fmtTime(e.lastSeen)}</td>
<td style="color:#58a6ff">${e.count}</td>
<td style="color:#555">${esc(pos)}</td>
</tr>`;
}).join('');
}
function exportMacCsv(){
const rows=[['MAC','Typen','Station','Erstes Signal','Letztes Signal','Frames','Lat','Lon']];
for(const e of macEntries.values()){
rows.push([e.mac,[...e.types].join(';'),e.stationType,
new Date(e.firstSeen).toISOString(),new Date(e.lastSeen).toISOString(),
e.count,e.lastLat??'',e.lastLon??'']);
}
const csv=rows.map(r=>r.map(v=>`"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
const a=document.createElement('a');
a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv);
a.download=`macs_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.csv`;
a.click();
}
// ─── Batch log refresh ────────────────────────────────────────────────────
const pendingFrames=[]; let logDirty=false;
function scheduleLog(){
if(logDirty)return; logDirty=true;
setTimeout(()=>{
logDirty=false;
if(pendingFrames.length){
const batch=pendingFrames.splice(0);
for(const f of batch){updateStation(f);updateMac(f);updateTypeCounts(f.msg_type||'UNKNOWN');}
}
renderStationList();
},300);
}
// ─── Header elements ──────────────────────────────────────────────────────
const elNode=document.getElementById('node');
const elTotal=document.getElementById('ntotal');
const elRate=document.getElementById('nrate');
const elBytes=document.getElementById('nbytes');
const elLast=document.getElementById('last');
const elSerial=document.getElementById('serial-dot');
const elSerLbl=document.getElementById('serial-lbl');
const connEl=document.getElementById('conn');
const mqttBtn=document.getElementById('mqtt-toggle');
const mqttTxt=document.getElementById('mqtt-state-text');
let total=0,lastBytesN=0,lastBytesT=Date.now();
const recent=[];
// ─── MQTT toggle ──────────────────────────────────────────────────────────
let mqttState={enabled:false,available:false};
function renderMqttState(){
const on=mqttState.enabled&&mqttState.available;
mqttBtn.classList.toggle('on',on);
mqttBtn.disabled=!mqttState.available;
mqttTxt.textContent=!mqttState.available?'—':mqttState.enabled?'on':'off';
mqttBtn.title=!mqttState.available?'No broker connected.':
mqttState.enabled?'Frames werden übertragen. Klicken zum Deaktivieren.':
'Frames bleiben lokal. Klicken zum Aktivieren.';
}
mqttBtn.addEventListener('click',()=>{
if(!mqttState.available)return;
mqttBtn.disabled=true;
fetch('/api/mqtt',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:!mqttState.enabled})})
.then(r=>r.json()).then(s=>{mqttState=s;renderMqttState();})
.catch(()=>{mqttBtn.disabled=false;});
});
// ─── Broker status ────────────────────────────────────────────────────────
function renderBrokers(list){
const el=document.getElementById('broker-list'); if(!el)return;
if(!list||!list.length){el.innerHTML='<span style="color:#555;font-size:12px">No broker configured.</span>';return;}
el.innerHTML=list.map(b=>`<div class="broker-row">
<div class="broker-dot${b.connected?' up':''}"></div>
<span style="font-size:12px">${esc(b.url||'?')}</span>
<span style="color:#555;font-size:11px">${b.connected?'connected':'disconnected'}</span>
</div>`).join('');
}
// ─── PCAP recording ───────────────────────────────────────────────────────
let recState={recording:false,file:'',frames:0};
function renderRecState(s){
recState=s||recState;
const el=document.getElementById('rec-status');
const recBtn=document.getElementById('rec-btn');
const startBtn=document.getElementById('rec-start-btn');
const stopBtn=document.getElementById('rec-stop-btn');
const dlBtn=document.getElementById('rec-dl-btn');
if(recState.recording){
if(el)el.innerHTML=`<span style="color:#f47979">⏺ Recording active</span> ${esc(recState.file)} <b>${recState.frames}</b> frames`;
if(recBtn){recBtn.classList.add('rec');recBtn.textContent='⏹ Recording...';}
if(startBtn)startBtn.disabled=true;
if(stopBtn)stopBtn.disabled=false;
if(dlBtn)dlBtn.style.display='none';
} else {
const info=recState.frames>0?`Last recording: ${esc(recState.file||'—')} (${recState.frames} frames)`:'No active recording.';
if(el)el.innerHTML=`<span style="color:#555">${info}</span>`;
if(recBtn){recBtn.classList.remove('rec');recBtn.textContent='⏺ Record';}
if(startBtn)startBtn.disabled=false;
if(stopBtn)stopBtn.disabled=true;
if(dlBtn)dlBtn.style.display=(recState.frames>0)?'inline-block':'none';
}
}
function startRecording(filename){
fetch('/api/record',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({action:'start',file:filename||''})})
.then(r=>r.json()).then(renderRecState).catch(console.error);
}
function stopRecording(){
fetch('/api/record',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({action:'stop'})})
.then(r=>r.json()).then(renderRecState).catch(console.error);
}
document.getElementById('rec-btn').addEventListener('click',()=>{
recState.recording?stopRecording():startRecording();
});
// ─── SSE ──────────────────────────────────────────────────────────────────
function connect(){
const es=new EventSource('/events');
es.onopen=()=>{connEl.textContent='connected';connEl.className='up';};
es.onerror=()=>{connEl.textContent='reconnecting...';connEl.className='down';};
es.addEventListener('hello',e=>{
const d=JSON.parse(e.data);
elNode.textContent=d.node_id||'?';
if(d.mqtt){mqttState=d.mqtt;renderMqttState();}
if(d.serial)applySerial(d.serial);
if(d.stats)applyStats(d.stats);
if(d.brokers)renderBrokers(d.brokers);
if(d.record)renderRecState(d.record);
});
es.addEventListener('frame',e=>{
const f=JSON.parse(e.data);
total++;
const now=Date.now();
elTotal.textContent=total;
elLast.textContent=new Date(now).toLocaleTimeString();
recent.push(now);
while(recent.length&&recent[0]<now-60_000)recent.shift();
elRate.textContent=recent.length;
tickSparkline(now);
if(f.lat!==undefined&&f.lon!==undefined)onPositionFrame(f);
pendingFrames.push(f);
scheduleLog();
});
es.addEventListener('stats',e=>applyStats(JSON.parse(e.data)));
es.addEventListener('mqtt_state',e=>{mqttState=JSON.parse(e.data);renderMqttState();});
es.addEventListener('serial_state',e=>applySerial(JSON.parse(e.data)));
es.addEventListener('broker_status',e=>renderBrokers(JSON.parse(e.data)));
es.addEventListener('record_state',e=>renderRecState(JSON.parse(e.data)));
}
function applyStats(s){
if(!s||!Object.keys(s).length)return;
document.getElementById('st-frames').textContent=s.frames||0;
document.getElementById('st-rate').textContent=(s.rate||0)+' /min';
if(s.freq_hz!==undefined&&s.freq_hz>0){
document.getElementById('st-freq').textContent=s.freq_hz.toFixed(2)+' Hz';
} else {
document.getElementById('st-freq').textContent='-';
}
if(s.mean_interval_ms!==undefined&&s.mean_interval_ms>0){
document.getElementById('st-interval').textContent=s.mean_interval_ms.toFixed(1)+' ms ± '+s.jitter_ms.toFixed(2)+' ms';
document.getElementById('st-range').textContent='['+s.min_interval_ms.toFixed(1)+' ms – '+s.max_interval_ms.toFixed(1)+' ms]';
} else {
document.getElementById('st-interval').textContent='-';
document.getElementById('st-range').textContent='-';
}
document.getElementById('st-bytes').textContent=fmtBytes(s.bytes||0);
if(s.uptime_s!==undefined)document.getElementById('st-uptime').textContent=fmtUptime(s.uptime_s);
const now=Date.now(),dt=(now-lastBytesT)/1000;
if(dt>0&&s.bytes!==undefined){
const bps=(s.bytes-lastBytesN)/dt;
elBytes.textContent=bps>1024?(bps/1024).toFixed(1)+' KB/s':bps.toFixed(0)+' B/s';
lastBytesN=s.bytes;lastBytesT=now;
}
}
function applySerial(s){
const up=s&&s.connected;
elSerial.className=up?'up':'down';
elSerLbl.textContent=up?(s.port||'USB'):'disconnected';
}
renderMqttState();
renderRecState();
connect();
</script>
</body>
</html>