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

250 lines
9.2 KiB
JavaScript

/* Sensors - IMU, touch, LiDAR and topic liveness. */
import { store, get, num, clockTime, RAD2DEG } from '../core.js';
import { el, card, pageHead, badge, table, stat, note, emptyState, kv, button } from '../ui.js';
import { timeSeries, attitude } from '../charts.js';
export default {
id: 'sensors',
label: 'Sensors',
icon: 'sensors',
async render() {
const spec = store.spec;
const root = el('div.stack');
root.appendChild(pageHead(
'Sensors',
'Hardware abstraction layer feeds — IMU, touch, LiDAR — and how fresh each topic is.',
));
/* -- IMU -------------------------------------------------------------- */
const imuHosts = {
chest: { attitude: el('div.attitude'), readout: el('div') },
torso: { attitude: el('div.attitude'), readout: el('div') },
};
const imuChart = timeSeries([
{ key: 'imu_roll', label: 'Roll', points: [], colorIndex: 0, unit: '°' },
{ key: 'imu_pitch', label: 'Pitch', points: [], colorIndex: 1, unit: '°' },
], { height: 176, precision: 2, zeroLine: true });
root.appendChild(el('div.grid.cols-2', {},
card('Chest IMU', { sub: '/aima/hal/imu/chest/state · 500 Hz' },
el('div.row', { style: { gap: '18px', alignItems: 'flex-start' } },
imuHosts.chest.attitude,
el('div', { style: { flex: '1', minWidth: '160px' } }, imuHosts.chest.readout),
),
),
card('Torso IMU', { sub: '/aima/hal/imu/torso/state · 500 Hz' },
el('div.row', { style: { gap: '18px', alignItems: 'flex-start' } },
imuHosts.torso.attitude,
el('div', { style: { flex: '1', minWidth: '160px' } }, imuHosts.torso.readout),
),
),
));
root.appendChild(card('Attitude history', { sub: 'Chest IMU, degrees' }, imuChart));
/* -- Touch + LiDAR ---------------------------------------------------- */
const touchHost = el('div');
const odomHost = el('div');
root.appendChild(el('div.grid.cols-2', {},
card('Head touch', { sub: '/aima/hal/sensor/touch_head · 8 zones · 100 Hz' }, touchHost),
card('Odometry', { sub: '/aima/mc/leg_odometry' }, odomHost),
));
/* -- Topic health ----------------------------------------------------- */
const healthHost = el('div');
root.appendChild(card('Topic health', {
sub: 'Measured rate against the documented rate',
actions: [button('Refresh', () => refreshTopics(), { size: 'sm', style: 'ghost', iconName: 'refresh' })],
}, healthHost));
let tracked = [];
async function refreshTopics() {
try {
const data = await get('/api/topics');
tracked = data.tracked || [];
paintHealth();
} catch {
healthHost.replaceChildren(emptyState('Could not read topic statistics'));
}
}
function paintHealth() {
const documented = spec?.sensor_topics || [];
const byTopic = Object.fromEntries(tracked.map((t) => [t.topic, t]));
const now = Date.now() / 1000;
const rows = documented.map((doc) => {
const live = byTopic[doc.topic];
const age = live ? now - live.last : null;
const stale = age === null || age > 3;
const rateRatio = live && doc.rate_hz ? live.hz / doc.rate_hz : null;
let tone = 'critical';
let label = 'silent';
if (live && !stale) {
if (rateRatio === null || rateRatio > 0.6) { tone = 'good'; label = 'healthy'; }
else { tone = 'warning'; label = 'slow'; }
} else if (live) {
tone = 'warning'; label = 'stale';
}
return {
topic: doc.topic,
label: doc.label,
type: doc.type,
expected: doc.rate_hz ? `${doc.rate_hz} Hz` : '—',
actual: live ? `${num(live.hz, 1)} Hz` : '—',
count: live ? live.count.toLocaleString() : '0',
age: age === null ? '—' : `${num(age, 1)} s`,
status: badge(label, tone),
};
});
// Anything the bridge saw that is not in the documented list still matters.
for (const live of tracked) {
if (documented.some((d) => d.topic === live.topic)) continue;
rows.push({
topic: live.topic, label: '—', type: '—',
expected: '—', actual: `${num(live.hz, 1)} Hz`,
count: live.count.toLocaleString(),
age: `${num(now - live.last, 1)} s`,
status: badge('extra', 'accent'),
});
}
healthHost.replaceChildren(table([
{ key: 'label', label: 'Sensor' },
{ key: 'topic', label: 'Topic' },
{ key: 'type', label: 'Type' },
{ key: 'expected', label: 'Expected', align: 'right' },
{ key: 'actual', label: 'Measured', align: 'right' },
{ key: 'count', label: 'Messages', align: 'right' },
{ key: 'age', label: 'Last seen', align: 'right' },
{ key: 'status', label: 'Status' },
], rows, { empty: 'No topics observed yet' }));
}
/* -- Painting --------------------------------------------------------- */
function paintImu(key) {
const data = store.state?.imu?.[key];
const host = imuHosts[key];
if (!host) return;
if (!data) {
host.attitude.replaceChildren();
host.readout.replaceChildren(emptyState('No data', 'Nothing received on this IMU topic.'));
return;
}
host.attitude.replaceChildren(attitude(data.roll || 0, data.pitch || 0, { size: 132 }));
host.readout.replaceChildren(kv([
['Roll', `${num((data.roll || 0) * RAD2DEG, 2)}°`],
['Pitch', `${num((data.pitch || 0) * RAD2DEG, 2)}°`],
['Yaw', `${num((data.yaw || 0) * RAD2DEG, 2)}°`],
['Accel X', `${num(data.accel_x, 2)} m/s²`],
['Accel Y', `${num(data.accel_y, 2)} m/s²`],
['Accel Z', `${num(data.accel_z, 2)} m/s²`],
['Gyro Z', `${num(data.gyro_z, 3)} rad/s`],
]));
}
function paint() {
const s = store.state;
if (!s) return;
paintImu('chest');
paintImu('torso');
const touch = s.touch_head || {};
const zones = Array.isArray(touch.zones) ? touch.zones : [];
const raw = Array.isArray(touch.data) ? touch.data : [];
touchHost.replaceChildren(
el('div.row', { style: { marginBottom: '12px' } },
badge(touch.touched ? 'Contact' : 'No contact', touch.touched ? 'good' : 'default'),
badge(`${zones.filter(Boolean).length} of ${zones.length || 8} active`),
),
zones.length
? el('div', {
style: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px' },
},
...zones.map((active, index) => el('div', {
style: {
padding: '13px 6px', textAlign: 'center',
borderRadius: 'var(--radius-sm)',
background: active ? 'var(--accent-soft)' : 'var(--surface-2)',
border: `1px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
fontSize: '11px', fontWeight: '600',
color: active ? 'var(--accent)' : 'var(--text-3)',
transition: 'background .15s, border-color .15s',
},
},
el('div', { text: `Z${index + 1}` }),
raw.length > index
? el('div', {
text: String(raw[index]),
style: { fontSize: '9.5px', fontFamily: 'var(--mono)', opacity: '.75' },
})
: null,
)),
)
: note('No TouchState message received yet.', 'default'),
);
const odom = s.odom || {};
const vel = s.velocity || {};
odomHost.replaceChildren(
kv([
['Position X', `${num(odom.x, 3)} m`],
['Position Y', `${num(odom.y, 3)} m`],
['Heading', `${num((odom.yaw || 0) * RAD2DEG, 1)}°`],
['Forward speed', `${num(vel.forward, 3)} m/s`],
['Lateral speed', `${num(vel.lateral, 3)} m/s`],
['Yaw rate', `${num(vel.angular, 3)} rad/s`],
]),
el('div', { style: { marginTop: '12px' } },
note('Leg odometry publishes while the robot is walking. It sits still in Passive or '
+ 'Damping mode, which is why these values stop updating when the robot is parked.',
'info'),
),
);
}
async function refreshChart() {
try {
const data = await get('/api/series?keys=imu_roll,imu_pitch&limit=400');
const toDeg = (points) => (points || []).map(([t, v]) => [t, v * RAD2DEG]);
imuChart.update([
{ key: 'imu_roll', label: 'Roll', points: toDeg(data.imu_roll), colorIndex: 0, unit: '°' },
{ key: 'imu_pitch', label: 'Pitch', points: toDeg(data.imu_pitch), colorIndex: 1, unit: '°' },
]);
} catch { /* chart refresh is best-effort */ }
}
paint();
refreshTopics();
refreshChart();
let last = 0;
const unsubscribe = store.on('state', () => {
const now = Date.now();
if (now - last < 400) return;
last = now;
paint();
});
const timer = setInterval(() => { refreshTopics(); refreshChart(); }, 3000);
return { node: root, dispose: () => { unsubscribe(); clearInterval(timer); } };
},
};