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