/* Power - battery detail, rails, thermals. */ import { store, get, num, batteryTone, tempTone, duration } from '../core.js'; import { el, card, pageHead, stat, meter, badge, table, note, emptyState, kv } from '../ui.js'; import { timeSeries, sparkline } from '../charts.js'; export default { id: 'power', label: 'Power', icon: 'power', async render() { const spec = store.spec; const root = el('div.stack'); root.appendChild(pageHead( 'Power', 'Power management unit telemetry. Published at 0.2 Hz, so these values move slowly by design.', )); /* -- Headline --------------------------------------------------------- */ const tiles = el('div.grid.cols-4'); const refs = {}; for (const [key, label] of [ ['level', 'Charge'], ['voltage', 'Pack voltage'], ['current', 'Pack current'], ['cell_temp', 'Cell temp'], ['pmu_temp', 'PMU temp'], ['fan', 'Fan'], ['cycles', 'Cycles'], ['runtime', 'Est. remaining'], ]) { const node = stat(label, '—', { sub: ' ' }); refs[key] = node; tiles.appendChild(node); } root.appendChild(tiles); function setTile(key, value, sub, tone = 'default', unit = '') { const node = refs[key]; if (!node) return; node.dataset.tone = tone; const valueNode = node.querySelector('.stat-value'); valueNode.textContent = String(value); if (unit) valueNode.appendChild(el('span.unit', { text: unit })); node.querySelector('.stat-sub').textContent = sub || ''; } /* -- Charts ----------------------------------------------------------- */ const chargeChart = timeSeries( [{ key: 'battery_pct', label: 'Charge', points: [], colorIndex: 0, unit: '%' }], { height: 190, precision: 1, yMin: 0, yMax: 100 }, ); const electricalChart = timeSeries([ { key: 'battery_voltage', label: 'Voltage', points: [], colorIndex: 0, unit: ' V' }, ], { height: 190, precision: 2 }); const currentChart = timeSeries([ { key: 'battery_current', label: 'Current', points: [], colorIndex: 1, unit: ' A' }, ], { height: 190, precision: 2, zeroLine: true }); const thermalChart = timeSeries([ { key: 'battery_temp', label: 'Battery cell', points: [], colorIndex: 1, unit: ' °C' }, { key: 'pmu_temp', label: 'PMU', points: [], colorIndex: 3, unit: ' °C' }, { key: 'fan_pct', label: 'Fan duty', points: [], colorIndex: 2, unit: ' %' }, ], { height: 190, precision: 1 }); root.appendChild(el('div.grid.cols-2', {}, card('State of charge', { sub: 'Last 2 minutes' }, chargeChart), card('Thermals and cooling', { sub: 'Battery, PMU, fan duty' }, thermalChart), )); root.appendChild(el('div.grid.cols-2', {}, card('Pack voltage', {}, electricalChart), card('Pack current', { sub: 'Positive is charging' }, currentChart), )); /* -- Rails ------------------------------------------------------------ */ const railHost = el('div'); root.appendChild(card('Power rails', { sub: 'Per-rail voltage and draw' }, railHost)); /* -- Hardware identity ------------------------------------------------ */ const infoHost = el('div'); root.appendChild(card('Battery and PMU hardware', { sub: 'Reported by the pack itself', }, infoHost)); /* -- Raw -------------------------------------------------------------- */ const rawHost = el('div'); root.appendChild(card('Raw PmuState', { sub: 'Every field the message carried, unmapped', }, rawHost)); /* -- Painting --------------------------------------------------------- */ function paint() { const s = store.state; if (!s) return; const pct = s.battery_pct; const tone = batteryTone(pct); setTile('level', pct == null ? '—' : num(pct, 1), s.charging ? 'charging' : 'discharging', tone, '%'); setTile('voltage', num(s.battery_voltage, 2), 'pack terminal', 'default', ' V'); setTile('current', num(s.battery_current, 2), s.battery_current > 0 ? 'into pack' : 'out of pack', 'default', ' A'); setTile('cell_temp', num(s.battery_temp, 1), 'cell', tempTone(s.battery_temp, 45, 55), ' °C'); setTile('pmu_temp', num(s.pmu_temp, 1), 'controller', tempTone(s.pmu_temp, 60, 75), ' °C'); setTile('fan', num(s.fan_pct, 0), s.fan_rpm ? `${num(s.fan_rpm, 0)} rpm` : '', 'default', '%'); setTile('cycles', s.battery_cycles ?? '—', 'charge cycles'); // Runtime projection from the observed drain rate. const runtime = estimateRuntime(pct); setTile('runtime', runtime.label, runtime.sub, runtime.tone); /* Rails */ const rails = s.rails || {}; const railRows = (spec?.pmu_rails || []).map((definition) => { const live = rails[definition.key]; const voltage = live?.voltage; const nominal = definition.nominal; const deviation = voltage != null && nominal ? Math.abs(voltage - nominal) / nominal : null; const ok = live?.ok !== false && (deviation === null || deviation < 0.12); return { label: definition.label, nominal: `${num(nominal, 1)} V`, voltage: voltage == null ? '—' : `${num(voltage, 2)} V`, current: live?.current == null ? '—' : `${num(live.current, 2)} A`, power: voltage != null && live?.current != null ? `${num(voltage * live.current, 1)} W` : '—', status: live ? badge(ok ? 'nominal' : 'out of range', ok ? 'good' : 'critical') : badge('no data', 'default'), }; }); railHost.replaceChildren( railRows.length ? table([ { key: 'label', label: 'Rail' }, { key: 'nominal', label: 'Nominal', align: 'right' }, { key: 'voltage', label: 'Measured', align: 'right' }, { key: 'current', label: 'Current', align: 'right' }, { key: 'power', label: 'Power', align: 'right' }, { key: 'status', label: 'Status' }, ], railRows) : emptyState('No rail data', 'The PMU message has not been received yet.'), ); /* Hardware identity */ const info = s.custom?.pmu_info || {}; const infoPairs = (spec?.pmu_info_fields || []) .map(([field, label]) => [label, info[field]]) .filter(([, value]) => value); infoHost.replaceChildren( infoPairs.length ? el('div', { style: { columnWidth: '260px', columnGap: '28px' } }, kv(infoPairs)) : emptyState('No hardware identity reported', 'The PMU message has not arrived yet.'), ); /* Raw fields */ const raw = s.custom?.pmu_raw; if (raw && Object.keys(raw).length) { const pairs = Object.entries(raw) .filter(([, v]) => typeof v !== 'object') .map(([k, v]) => [k, typeof v === 'number' ? num(v, 3) : String(v)]); rawHost.replaceChildren(el('div', { style: { columnWidth: '260px', columnGap: '28px' } }, kv(pairs))); } else { rawHost.replaceChildren(emptyState( 'No raw message captured', store.bridge?.simulated ? 'The simulator publishes mapped values only; connect a real robot to see the full PmuState.' : 'Waiting for the first /aima/hal/pmu/state message.', )); } } function estimateRuntime(pct) { const history = seriesCache.battery_pct || []; if (pct == null || history.length < 20) { return { label: '—', sub: 'needs more history', tone: 'default' }; } const [t0, v0] = history[0]; const [t1, v1] = history.at(-1); const elapsed = t1 - t0; const drop = v0 - v1; if (elapsed < 10 || drop <= 0.005) { return { label: '—', sub: 'not discharging', tone: 'default' }; } const minutes = (pct / (drop / elapsed)) / 60; return { label: duration(minutes * 60), sub: `at ${num((drop / elapsed) * 3600, 1)} %/h`, tone: minutes < 15 ? 'critical' : minutes < 40 ? 'warning' : 'good', }; } const seriesCache = {}; async function refreshCharts() { try { const keys = 'battery_pct,battery_voltage,battery_current,battery_temp,pmu_temp,fan_pct'; const data = await get(`/api/series?keys=${keys}&limit=600`); Object.assign(seriesCache, data); chargeChart.update([ { key: 'battery_pct', label: 'Charge', points: data.battery_pct || [], colorIndex: 0, unit: '%' }, ]); electricalChart.update([ { key: 'battery_voltage', label: 'Voltage', points: data.battery_voltage || [], colorIndex: 0, unit: ' V' }, ]); currentChart.update([ { key: 'battery_current', label: 'Current', points: data.battery_current || [], colorIndex: 1, unit: ' A' }, ]); thermalChart.update([ { key: 'battery_temp', label: 'Battery cell', points: data.battery_temp || [], colorIndex: 1, unit: ' °C' }, { key: 'pmu_temp', label: 'PMU', points: data.pmu_temp || [], colorIndex: 3, unit: ' °C' }, { key: 'fan_pct', label: 'Fan duty', points: data.fan_pct || [], colorIndex: 2, unit: ' %' }, ]); } catch { /* best-effort */ } } paint(); await refreshCharts(); paint(); let last = 0; const unsubscribe = store.on('state', () => { const now = Date.now(); if (now - last < 1000) return; last = now; paint(); }); const timer = setInterval(() => { refreshCharts().then(paint); }, 4000); return { node: root, dispose: () => { unsubscribe(); clearInterval(timer); } }; }, };