From d49ec34f0afc31588e1a2007f35a257c90874395 Mon Sep 17 00:00:00 2001 From: legop3 Date: Wed, 19 Nov 2025 16:49:53 -0500 Subject: [PATCH] rover manager improvements and event bus --- server/index.js | 1 + server/src/services/eventBus.js | 55 ++++++++++++++++++++++++++ server/src/services/roverManager.js | 61 ++++++++++++++++++++++++++--- 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 server/src/services/eventBus.js diff --git a/server/index.js b/server/index.js index 2f7bce17..411df740 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,7 @@ require('./src/helpers/sensorDecoder'); require('./src/services/alertService'); require('./src/services/authService'); +require('./src/services/eventBus'); require('./src/services/modeManager'); require('./src/services/lockdownGuard'); require('./src/services/roverManager'); diff --git a/server/src/services/eventBus.js b/server/src/services/eventBus.js new file mode 100644 index 00000000..bfeba3e4 --- /dev/null +++ b/server/src/services/eventBus.js @@ -0,0 +1,55 @@ +const EventEmitter = require('events'); +const logger = require('../globals/logger').child('eventBus'); + +const eventBus = new EventEmitter(); + +/** + * Publish a structured event onto the server-wide bus. + * @param {object} options + * @param {string} options.source - Origin module identifier. + * @param {string} options.type - Event type name. + * @param {any} [options.payload] - Optional event payload. + */ +function publishEvent({ source, type, payload = null }) { + if (!source) { + throw new Error('eventBus.publishEvent requires source'); + } + if (!type) { + throw new Error('eventBus.publishEvent requires type'); + } + const event = { + source, + type, + payload, + ts: Date.now(), + }; + logger.debug('Publishing event', { source, type }); + eventBus.emit(type, event); + eventBus.emit('*', event); +} + +/** + * Subscribe to events of a given type. + * @param {string} type + * @param {(event: object) => void} handler + */ +function subscribe(type, handler) { + eventBus.on(type, handler); + return () => eventBus.off(type, handler); +} + +/** + * Subscribe to all events on the bus. + * @param {(event: object) => void} handler + */ +function subscribeAll(handler) { + eventBus.on('*', handler); + return () => eventBus.off('*', handler); +} + +module.exports = { + eventBus, + publishEvent, + subscribe, + subscribeAll, +}; diff --git a/server/src/services/roverManager.js b/server/src/services/roverManager.js index 8e723d0d..8362943f 100644 --- a/server/src/services/roverManager.js +++ b/server/src/services/roverManager.js @@ -21,6 +21,8 @@ function ensureRecord(id) { lastSensor: null, drivers: new Set(), locked: false, + lockReason: null, + batteryState: null, room: `rover:${id}`, lastSeen: Date.now(), }); @@ -62,20 +64,32 @@ function removeRover(id) { managerEvents.emit('rover', { roverId: id, action: 'removed' }); } -function lockRover(id, locked, actorSocket) { +function lockRover(id, locked, options = {}) { const record = rovers.get(id); if (!record) { throw new Error('Unknown rover'); } + const reason = locked ? options.reason || 'manual' : null; + const silent = Boolean(options.silent); if (locked) { record.locked = true; - sendAlert({ color: COLORS.warn, title: 'Rover Locked', message: `${id} locked by admin.` }); + record.lockReason = reason; + if (!silent) { + sendAlert({ + color: COLORS.warn, + title: 'Rover Locked', + message: `${id} locked${record.lockReason ? ` (${record.lockReason})` : ''}.`, + }); + } } else { record.locked = false; - sendAlert({ color: COLORS.success, title: 'Rover Unlocked', message: `${id} unlocked.` }); + record.lockReason = null; + if (!silent) { + sendAlert({ color: COLORS.success, title: 'Rover Unlocked', message: `${id} unlocked.` }); + } } broadcastRoster(); - managerEvents.emit('lock', { roverId: id, locked: record.locked }); + managerEvents.emit('lock', { roverId: id, locked: record.locked, reason: record.lockReason }); return record.locked; } @@ -84,10 +98,12 @@ function getRoster() { id: record.id, name: record.meta?.name || record.id, battery: record.meta?.battery, + batteryState: record.batteryState, maxWheelSpeed: record.meta?.maxWheelSpeed, media: record.meta?.media, cameraServo: record.meta?.cameraServo, locked: record.locked, + lockReason: record.lockReason, lastSeen: record.lastSeen, })); } @@ -96,17 +112,52 @@ function broadcastRoster() { io.emit('rovers', getRoster()); } +function computeBatteryState(record, sensors) { + if (!record) return null; + if (!sensors) return record.batteryState; + const config = record.meta?.battery || null; + const charge = sensors?.batteryChargeMah ?? null; + const capacity = sensors?.batteryCapacityMah ?? null; + const full = typeof config?.Full === 'number' ? config.Full : null; + const warn = typeof config?.Warn === 'number' ? config.Warn : null; + const urgent = typeof config?.Urgent === 'number' ? config.Urgent : null; + let percent = null; + if (charge != null && warn != null && full != null && full > warn) { + const span = full - warn; + percent = (charge - warn) / span; + } else if (charge != null && capacity != null && capacity > 0) { + percent = charge / capacity; + } + if (percent != null) { + percent = Math.max(0, Math.min(1, percent)); + } + return { + charge, + capacity, + full, + warn, + urgent, + percent, + percentDisplay: percent == null ? null : Math.round(percent * 100), + warnActive: Boolean(warn != null && charge != null && charge <= warn), + urgentActive: Boolean(urgent != null && charge != null && charge <= urgent), + updatedAt: Date.now(), + }; +} + function handleSensorFrame(roverId, frame) { const record = rovers.get(roverId); if (!record) return; record.lastSeen = Date.now(); const decoded = parseSensorFrame(frame.data); record.lastSensor = { raw: frame, decoded }; + record.batteryState = computeBatteryState(record, decoded); io.to(record.room).emit('sensorFrame', { roverId, frame, sensors: decoded, }); + managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState }); } function removeSocket(socket) { @@ -274,7 +325,7 @@ io.on('connection', (socket) => { return; } try { - lockRover(roverId, locked); + lockRover(roverId, locked, { reason: 'manual' }); logger.info('Lock state changed', roverId, locked); cb({ success: true }); } catch (err) {