1549d4a73e
Sens'it v2 has a totally different payload layout than Discovery v3: - Bit order LSB→MSB (was MSB→LSB in v3) - Mode in bits 0-2 (was bits 7-3 in v3) - Battery split across bytes 0 & 1 (was 5 contiguous bits in v3) - Temperature always sent (in T° MSB byte 1) even outside temp mode Auto-detection uses byte 0 reserved bits: 0b110 = v3, otherwise = v2. Frontend updated to render new fields: move_events, reed_events, fw_major/minor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
217 lines
8.6 KiB
HTML
217 lines
8.6 KiB
HTML
<!doctype html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<title>SNEK Dashboard</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<style>
|
|
html,body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
.fade-in { animation: fade 0.4s ease-out; }
|
|
@keyframes fade { from { opacity:0; transform: translateY(4px); } to { opacity:1; transform: translateY(0); } }
|
|
</style>
|
|
</head>
|
|
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
|
<div class="max-w-6xl mx-auto p-4 md:p-8">
|
|
|
|
<header class="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 class="text-2xl md:text-3xl font-bold">🛰️ SNEK Dashboard</h1>
|
|
<p class="text-slate-400 text-sm">Sens'it Discovery — live decoding</p>
|
|
</div>
|
|
<div id="conn-badge" class="text-xs px-3 py-1 rounded-full bg-slate-800 text-slate-400">connecting…</div>
|
|
</header>
|
|
|
|
<!-- Devices -->
|
|
<section class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8" id="devices"></section>
|
|
|
|
<!-- Chart -->
|
|
<section class="bg-slate-900 rounded-2xl p-4 md:p-6 mb-8 border border-slate-800">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-semibold">Historique</h2>
|
|
<select id="chart-device" class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-sm"></select>
|
|
</div>
|
|
<canvas id="chart" height="120"></canvas>
|
|
</section>
|
|
|
|
<!-- Messages log -->
|
|
<section class="bg-slate-900 rounded-2xl p-4 md:p-6 border border-slate-800">
|
|
<h2 class="text-lg font-semibold mb-4">Messages</h2>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead class="text-slate-400 text-left">
|
|
<tr>
|
|
<th class="pb-2 pr-3">Time</th>
|
|
<th class="pb-2 pr-3">Device</th>
|
|
<th class="pb-2 pr-3">Mode</th>
|
|
<th class="pb-2 pr-3">Data</th>
|
|
<th class="pb-2 pr-3">RSSI</th>
|
|
<th class="pb-2 pr-3">Payload</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="msgs" class="divide-y divide-slate-800"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
const state = { messagesByDevice: {}, chart: null, selectedDevice: null };
|
|
|
|
const fmtTime = ts => new Date(ts * 1000).toLocaleString('fr-FR');
|
|
const fmtShortTime = ts => new Date(ts * 1000).toLocaleTimeString('fr-FR');
|
|
|
|
const modeIcon = mode => ({
|
|
'Temperature': '🌡️',
|
|
'Light': '💡',
|
|
'Door': '🚪',
|
|
'Vibration': '📳',
|
|
'Move': '📳',
|
|
'Magnet': '🧲',
|
|
'Reed Switch': '🧲',
|
|
'Standby / Button': '⏸️',
|
|
'Button': '🔘',
|
|
}[mode] || '❓');
|
|
|
|
function summaryLine(d) {
|
|
if (!d) return '-';
|
|
const parts = [];
|
|
if ('temperature_c' in d) parts.push(`${d.temperature_c}°C`);
|
|
if ('humidity_pct' in d) parts.push(`${d.humidity_pct}%`);
|
|
if ('brightness_lux' in d) parts.push(`${d.brightness_lux} lux`);
|
|
if ('door_status' in d) parts.push(`porte: ${d.door_status}`);
|
|
if ('vibration_status' in d) parts.push(`vibration: ${d.vibration_status}`);
|
|
if ('magnet_status' in d) parts.push(`aimant: ${d.magnet_status}`);
|
|
if ('event_count' in d && d.mode_id > 2) parts.push(`events: ${d.event_count}`);
|
|
if ('move_events' in d) parts.push(`move: ${d.move_events}`);
|
|
if ('reed_events' in d) parts.push(`reed: ${d.reed_events}`);
|
|
if ('fw_major' in d) parts.push(`fw v${d.fw_major}.${d.fw_minor}`);
|
|
if (parts.length === 0) parts.push('heartbeat');
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
function renderDevices() {
|
|
const el = document.getElementById('devices');
|
|
el.innerHTML = '';
|
|
const sel = document.getElementById('chart-device');
|
|
const currentSel = sel.value;
|
|
sel.innerHTML = '';
|
|
Object.entries(state.messagesByDevice).forEach(([dev, msgs]) => {
|
|
const last = msgs[0];
|
|
const d = last.decoded || {};
|
|
const card = document.createElement('div');
|
|
card.className = 'bg-slate-900 rounded-2xl p-4 border border-slate-800 fade-in';
|
|
card.innerHTML = `
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div>
|
|
<div class="text-xs text-slate-500">${dev.toUpperCase()}</div>
|
|
<div class="text-sm font-semibold">${modeIcon(d.mode)} ${d.mode || 'Unknown'}</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-xs text-slate-500">Battery</div>
|
|
<div class="text-sm font-mono">${d.battery ?? '?'} V</div>
|
|
</div>
|
|
</div>
|
|
<div class="text-2xl md:text-3xl font-bold text-blue-400 mb-1">${summaryLine(d)}</div>
|
|
<div class="text-xs text-slate-500">${fmtTime(last.ts)}</div>
|
|
${last.rssi != null ? `<div class="text-xs text-slate-500 mt-1">RSSI ${last.rssi.toFixed?.(1) ?? last.rssi} dBm</div>` : ''}
|
|
`;
|
|
el.appendChild(card);
|
|
|
|
const opt = document.createElement('option');
|
|
opt.value = dev; opt.textContent = dev.toUpperCase();
|
|
sel.appendChild(opt);
|
|
});
|
|
if (currentSel && state.messagesByDevice[currentSel]) sel.value = currentSel;
|
|
if (!state.selectedDevice && sel.options.length) state.selectedDevice = sel.value;
|
|
renderChart();
|
|
}
|
|
|
|
function renderMessages() {
|
|
const el = document.getElementById('msgs');
|
|
const all = Object.values(state.messagesByDevice).flat()
|
|
.sort((a,b) => b.ts - a.ts).slice(0, 50);
|
|
el.innerHTML = all.map(m => `
|
|
<tr class="hover:bg-slate-800/50 fade-in">
|
|
<td class="py-2 pr-3 text-slate-400">${fmtShortTime(m.ts)}</td>
|
|
<td class="py-2 pr-3 font-mono text-blue-400">${m.device.toUpperCase()}</td>
|
|
<td class="py-2 pr-3">${modeIcon(m.decoded?.mode)} ${m.decoded?.mode || '?'}</td>
|
|
<td class="py-2 pr-3 text-slate-300">${summaryLine(m.decoded)}</td>
|
|
<td class="py-2 pr-3 text-slate-500">${m.rssi?.toFixed?.(0) ?? '-'}</td>
|
|
<td class="py-2 pr-3 text-slate-500 font-mono text-xs">${m.raw}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function renderChart() {
|
|
const dev = state.selectedDevice || document.getElementById('chart-device').value;
|
|
if (!dev) return;
|
|
const msgs = (state.messagesByDevice[dev] || []).slice().reverse();
|
|
const labels = msgs.map(m => fmtShortTime(m.ts));
|
|
const temps = msgs.map(m => m.decoded?.temperature_c ?? null);
|
|
const humi = msgs.map(m => m.decoded?.humidity_pct ?? null);
|
|
|
|
const ctx = document.getElementById('chart').getContext('2d');
|
|
if (state.chart) state.chart.destroy();
|
|
state.chart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{ label: 'Temperature °C', data: temps, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.15)', yAxisID: 'y', tension: 0.3, fill: true, spanGaps: true },
|
|
{ label: 'Humidity %', data: humi, borderColor: '#34d399', backgroundColor: 'rgba(52,211,153,0.15)', yAxisID: 'y1', tension: 0.3, fill: true, spanGaps: true },
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: { legend: { labels: { color: '#e2e8f0' } } },
|
|
scales: {
|
|
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
|
|
y: { ticks: { color: '#60a5fa' }, grid: { color: '#1e293b' }, position: 'left', title: { text:'°C', display:true, color:'#60a5fa' } },
|
|
y1: { ticks: { color: '#34d399' }, grid: { drawOnChartArea: false }, position: 'right', title: { text:'%', display:true, color:'#34d399' } },
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function bootstrap() {
|
|
const r = await fetch('/api/messages?limit=200');
|
|
const msgs = await r.json();
|
|
msgs.forEach(m => {
|
|
if (!state.messagesByDevice[m.device]) state.messagesByDevice[m.device] = [];
|
|
state.messagesByDevice[m.device].push(m);
|
|
});
|
|
Object.values(state.messagesByDevice).forEach(a => a.sort((x,y) => y.ts - x.ts));
|
|
renderDevices();
|
|
renderMessages();
|
|
}
|
|
|
|
function connectSSE() {
|
|
const badge = document.getElementById('conn-badge');
|
|
const es = new EventSource('/events');
|
|
es.onopen = () => { badge.textContent = 'live'; badge.className = 'text-xs px-3 py-1 rounded-full bg-emerald-900/50 text-emerald-400'; };
|
|
es.onerror = () => { badge.textContent = 'reconnecting…'; badge.className = 'text-xs px-3 py-1 rounded-full bg-red-900/50 text-red-400'; };
|
|
es.onmessage = (ev) => {
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type !== 'message') return;
|
|
if (!state.messagesByDevice[msg.device]) state.messagesByDevice[msg.device] = [];
|
|
state.messagesByDevice[msg.device].unshift(msg);
|
|
state.messagesByDevice[msg.device] = state.messagesByDevice[msg.device].slice(0, 200);
|
|
renderDevices();
|
|
renderMessages();
|
|
} catch (e) { console.error(e); }
|
|
};
|
|
}
|
|
|
|
document.getElementById('chart-device').addEventListener('change', (e) => {
|
|
state.selectedDevice = e.target.value;
|
|
renderChart();
|
|
});
|
|
|
|
bootstrap().then(connectSSE);
|
|
</script>
|
|
</body>
|
|
</html>
|