2026-08-13 16:23:18 +04:00

247 lines
9.5 KiB
JavaScript

/* 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); } };
},
};