250 lines
9.2 KiB
JavaScript
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); } };
|
|
},
|
|
};
|