Files
MicrOBU/microbu-esp32c5/tools/v2x2map-0.3.0/bridge/dashboard.html
T

791 lines
41 KiB
HTML
Raw Normal View History

<!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>