chat history, better turn queue updates

This commit is contained in:
legop3
2026-01-15 02:55:46 -05:00
parent 23fe03ca54
commit 37927d82af
20 changed files with 585 additions and 36 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+10 -1
View File
@@ -78,10 +78,19 @@ func main() {
defer nightVision.Close()
}
var irTx *roverd.IRTransmitter
if cfg.IR.Enabled {
irTx, err = roverd.NewIRTransmitter(cfg.IR, logger)
if err != nil {
logger.Fatalf("init ir tx: %v", err)
}
defer irTx.Close()
}
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
go autoCharge.Run(ctx, sensorSamples)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, irTx, logger)
retryDelay := time.Second
for ctx.Err() == nil {
+6
View File
@@ -29,6 +29,7 @@ type inboundMessage struct {
TTS *ttsPayload `json:"tts,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"`
IR *irPayload `json:"ir,omitempty"`
}
type driveDirectPayload struct {
@@ -79,6 +80,11 @@ type songNote struct {
Duration int `json:"duration"`
}
type irPayload struct {
Code int `json:"code"`
Repeat int `json:"repeat,omitempty"`
}
type ackMessage struct {
Type string `json:"type"`
ID string `json:"id"`
+62
View File
@@ -103,6 +103,20 @@ type NightVisionConfig struct {
InitialOn bool `yaml:"initialOn" json:"initialOn"`
}
type IRConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Pin int `yaml:"pin" json:"pin"`
CarrierHz int `yaml:"carrierHz" json:"carrierHz"`
CycleLen int `yaml:"cycleLen" json:"cycleLen"`
DutyPercent int `yaml:"dutyPercent" json:"dutyPercent"`
Bit0OnMs int `yaml:"bit0OnMs" json:"bit0OnMs"`
Bit1OnMs int `yaml:"bit1OnMs" json:"bit1OnMs"`
BitTotalMs int `yaml:"bitTotalMs" json:"bitTotalMs"`
Repeat int `yaml:"repeat" json:"repeat"`
GapMs int `yaml:"gapMs" json:"gapMs"`
ActiveLow bool `yaml:"activeLow" json:"activeLow"`
}
type Config struct {
Name string `yaml:"name"`
ServerURL string `yaml:"serverUrl"`
@@ -114,6 +128,7 @@ type Config struct {
CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
IR IRConfig `yaml:"ir" json:"ir"`
}
func LoadConfig(path string) (*Config, error) {
@@ -169,6 +184,19 @@ func LoadConfig(path string) (*Config, error) {
GPIOChip: "gpiochip0",
InitialOn: true,
},
IR: IRConfig{
Enabled: false,
Pin: 17,
CarrierHz: 38000,
CycleLen: 100,
DutyPercent: 50,
Bit0OnMs: 1,
Bit1OnMs: 3,
BitTotalMs: 4,
Repeat: 3,
GapMs: 100,
ActiveLow: true,
},
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
@@ -232,6 +260,9 @@ func LoadConfig(path string) (*Config, error) {
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
return nil, fmt.Errorf("nightVision: %w", err)
}
if err := validateIRConfig(&cfg.IR); err != nil {
return nil, fmt.Errorf("ir: %w", err)
}
validateAudioConfig(&cfg.Audio)
return &cfg, nil
}
@@ -316,6 +347,37 @@ func validateNightVisionConfig(cfg *NightVisionConfig) error {
return nil
}
func validateIRConfig(cfg *IRConfig) error {
if !cfg.Enabled {
return nil
}
if cfg.Pin <= 0 {
return errors.New("pin must be > 0")
}
if cfg.CarrierHz <= 0 {
return errors.New("carrierHz must be > 0")
}
if cfg.CycleLen <= 0 {
return errors.New("cycleLen must be > 0")
}
if cfg.DutyPercent <= 0 || cfg.DutyPercent >= 100 {
return errors.New("dutyPercent must be 1-99")
}
if cfg.Bit0OnMs <= 0 || cfg.Bit1OnMs <= 0 {
return errors.New("bit0OnMs/bit1OnMs must be > 0")
}
if cfg.BitTotalMs < cfg.Bit0OnMs || cfg.BitTotalMs < cfg.Bit1OnMs {
return errors.New("bitTotalMs must be >= bit on durations")
}
if cfg.Repeat <= 0 {
cfg.Repeat = 1
}
if cfg.GapMs < 0 {
cfg.GapMs = 0
}
return nil
}
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
if streamName == "" {
return "", errors.New("missing stream name for publishUrl")
+327
View File
@@ -0,0 +1,327 @@
//go:build !dummy
package roverd
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"log"
"net"
"sync"
"time"
)
const (
pigpioAddr = "127.0.0.1:8888"
piCmdSetMode = 0
piCmdWrite = 4
piCmdWaveClear = 27
piCmdWaveAddGeneric = 28
piCmdWaveTxBusy = 32
piCmdWaveCreate = 49
piCmdWaveDelete = 50
piCmdWaveTxSend = 51
piOutput = 1
)
type pigpioCmd struct {
Cmd uint32
P1 uint32
P2 uint32
P3 uint32
}
type gpioPulse struct {
GpioOn uint32
GpioOff uint32
DelayUs uint32
}
type pigpioClient struct {
conn net.Conn
mu sync.Mutex
}
func newPigpioClient(addr string) (*pigpioClient, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, err
}
if tcpConn, ok := conn.(*net.TCPConn); ok {
_ = tcpConn.SetNoDelay(true)
}
return &pigpioClient{conn: conn}, nil
}
func (c *pigpioClient) Close() error {
if c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *pigpioClient) command(cmd, p1, p2, p3 uint32, ext []byte) (int32, error) {
c.mu.Lock()
defer c.mu.Unlock()
var buf [16]byte
binary.LittleEndian.PutUint32(buf[0:], cmd)
binary.LittleEndian.PutUint32(buf[4:], p1)
binary.LittleEndian.PutUint32(buf[8:], p2)
binary.LittleEndian.PutUint32(buf[12:], p3)
if _, err := c.conn.Write(buf[:]); err != nil {
return -1, err
}
if len(ext) > 0 {
if _, err := c.conn.Write(ext); err != nil {
return -1, err
}
}
if _, err := io.ReadFull(c.conn, buf[:]); err != nil {
return -1, err
}
res := int32(binary.LittleEndian.Uint32(buf[12:]))
return res, nil
}
type IRTransmitter struct {
cfg IRConfig
logger *log.Logger
gpioMask uint32
pigpio *pigpioClient
mu sync.Mutex
closed bool
activeLow bool
}
func NewIRTransmitter(cfg IRConfig, logger *log.Logger) (*IRTransmitter, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("ir disabled")
}
client, err := newPigpioClient(pigpioAddr)
if err != nil {
return nil, fmt.Errorf("connect pigpio: %w", err)
}
mask := uint32(1) << cfg.Pin
tx := &IRTransmitter{
cfg: cfg,
logger: logger,
gpioMask: mask,
pigpio: client,
activeLow: cfg.ActiveLow,
}
if err := tx.configureLine(); err != nil {
_ = client.Close()
return nil, err
}
logger.Printf("ir tx initialized on GPIO %d (%d Hz carrier, activeLow=%v)", cfg.Pin, cfg.CarrierHz, tx.activeLow)
return tx, nil
}
func (t *IRTransmitter) Close() {
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return
}
_ = t.setInactive()
_ = t.pigpio.Close()
t.closed = true
}
func (t *IRTransmitter) Send(code byte, repeat int) error {
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return fmt.Errorf("ir transmitter closed")
}
if repeat <= 0 {
repeat = t.cfg.Repeat
}
pulses, totalUs := t.buildWaveform(code, repeat)
if len(pulses) == 0 {
return nil
}
if err := t.writeWave(pulses, time.Duration(totalUs)*time.Microsecond); err != nil {
return err
}
return nil
}
func (t *IRTransmitter) configureLine() error {
if res, err := t.pigpio.command(piCmdSetMode, uint32(t.cfg.Pin), piOutput, 0, nil); err != nil {
return fmt.Errorf("pigpio set mode: %w", err)
} else if res < 0 {
return fmt.Errorf("pigpio set mode: %d", res)
}
return t.setInactive()
}
func (t *IRTransmitter) setInactive() error {
level := uint32(0)
if t.activeLow {
level = 1
}
res, err := t.pigpio.command(piCmdWrite, uint32(t.cfg.Pin), level, 0, nil)
if err != nil {
return fmt.Errorf("pigpio write: %w", err)
}
if res < 0 {
return fmt.Errorf("pigpio write: %d", res)
}
return nil
}
func (t *IRTransmitter) buildWaveform(code byte, repeat int) ([]gpioPulse, int) {
if repeat <= 0 {
return nil, 0
}
periodUs := int(1_000_000 / t.cfg.CarrierHz)
if periodUs <= 0 {
periodUs = 1
}
onDuty := int(float64(periodUs) * (float64(t.cfg.DutyPercent) / 100.0))
if onDuty <= 0 {
onDuty = 1
}
if onDuty >= periodUs {
onDuty = periodUs - 1
}
offDuty := periodUs - onDuty
if offDuty <= 0 {
offDuty = 1
}
var pulses []gpioPulse
totalUs := 0
onPulse := func(duration int) {
if duration <= 0 {
return
}
pulses = append(pulses, t.pulseOn(duration))
totalUs += duration
}
offPulse := func(duration int) {
if duration <= 0 {
return
}
pulses = append(pulses, t.pulseOff(duration))
totalUs += duration
}
addCarrier := func(onUs int) {
if onUs <= 0 {
return
}
cycles := onUs / periodUs
if onUs%periodUs != 0 {
cycles++
}
for i := 0; i < cycles; i++ {
onPulse(onDuty)
offPulse(offDuty)
}
}
for i := 0; i < repeat; i++ {
for mask := byte(0x80); mask > 0; mask >>= 1 {
onMs := t.cfg.Bit0OnMs
if code&mask != 0 {
onMs = t.cfg.Bit1OnMs
}
onUs := onMs * 1000
offUs := (t.cfg.BitTotalMs - onMs) * 1000
addCarrier(onUs)
offPulse(offUs)
}
if i < repeat-1 && t.cfg.GapMs > 0 {
offPulse(t.cfg.GapMs * 1000)
}
}
if totalUs > 0 {
pulses = append(pulses, t.pulseOff(1))
totalUs++
}
return pulses, totalUs
}
func (t *IRTransmitter) pulseOn(durationUs int) gpioPulse {
if t.activeLow {
return gpioPulse{GpioOn: 0, GpioOff: t.gpioMask, DelayUs: uint32(durationUs)}
}
return gpioPulse{GpioOn: t.gpioMask, GpioOff: 0, DelayUs: uint32(durationUs)}
}
func (t *IRTransmitter) pulseOff(durationUs int) gpioPulse {
if t.activeLow {
return gpioPulse{GpioOn: t.gpioMask, GpioOff: 0, DelayUs: uint32(durationUs)}
}
return gpioPulse{GpioOn: 0, GpioOff: t.gpioMask, DelayUs: uint32(durationUs)}
}
func (t *IRTransmitter) writeWave(pulses []gpioPulse, duration time.Duration) error {
if len(pulses) == 0 {
return nil
}
if res, err := t.pigpio.command(piCmdWaveClear, 0, 0, 0, nil); err != nil {
return fmt.Errorf("pigpio wave clear: %w", err)
} else if res < 0 {
return fmt.Errorf("pigpio wave clear: %d", res)
}
payload := make([]byte, 0, len(pulses)*12)
buf := bytes.NewBuffer(payload)
for _, pulse := range pulses {
_ = binary.Write(buf, binary.LittleEndian, pulse.GpioOn)
_ = binary.Write(buf, binary.LittleEndian, pulse.GpioOff)
_ = binary.Write(buf, binary.LittleEndian, pulse.DelayUs)
}
data := buf.Bytes()
if res, err := t.pigpio.command(piCmdWaveAddGeneric, 0, 0, uint32(len(data)), data); err != nil {
return fmt.Errorf("pigpio wave add: %w", err)
} else if res < 0 {
return fmt.Errorf("pigpio wave add: %d", res)
}
waveID, err := t.pigpio.command(piCmdWaveCreate, 0, 0, 0, nil)
if err != nil {
return fmt.Errorf("pigpio wave create: %w", err)
}
if waveID < 0 {
return fmt.Errorf("pigpio wave create: %d", waveID)
}
defer func() {
_, _ = t.pigpio.command(piCmdWaveDelete, uint32(waveID), 0, 0, nil)
}()
if res, err := t.pigpio.command(piCmdWaveTxSend, uint32(waveID), 0, 0, nil); err != nil {
return fmt.Errorf("pigpio wave tx: %w", err)
} else if res < 0 {
return fmt.Errorf("pigpio wave tx: %d", res)
}
if duration <= 0 {
return nil
}
timeout := duration + 250*time.Millisecond
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
busy, err := t.pigpio.command(piCmdWaveTxBusy, 0, 0, 0, nil)
if err != nil {
return fmt.Errorf("pigpio wave busy: %w", err)
}
if busy < 0 {
return fmt.Errorf("pigpio wave busy: %d", busy)
}
if busy == 0 {
return nil
}
time.Sleep(2 * time.Millisecond)
}
return fmt.Errorf("pigpio wave timeout after %s", timeout)
}
+20
View File
@@ -0,0 +1,20 @@
//go:build dummy
package roverd
import "log"
type IRTransmitter struct{}
func NewIRTransmitter(cfg IRConfig, logger *log.Logger) (*IRTransmitter, error) {
if cfg.Enabled {
logger.Printf("[dummy] IR TX enabled on pin %d", cfg.Pin)
}
return &IRTransmitter{}, nil
}
func (t *IRTransmitter) Close() {}
func (t *IRTransmitter) Send(code byte, repeat int) error {
return nil
}
+12
View File
@@ -53,3 +53,15 @@ nightVision:
gpioPin: 22
gpioChip: gpiochip0
initialOn: true
ir:
enabled: false
pin: 17
carrierHz: 38000
cycleLen: 100
dutyPercent: 50
bit0OnMs: 1
bit1OnMs: 3
bitTotalMs: 4
repeat: 3
gapMs: 100
activeLow: true
+12
View File
@@ -31,3 +31,15 @@ cameraServo:
homeAngle: 0
nudgeDegrees: 2
allowRawPulse: false
ir:
enabled: false
pin: 17
carrierHz: 38000
cycleLen: 100
dutyPercent: 50
bit0OnMs: 1
bit1OnMs: 3
bitTotalMs: 4
repeat: 3
gapMs: 100
activeLow: true
+10 -1
View File
@@ -20,6 +20,7 @@ type WSClient struct {
media *MediaSupervisor
servo *CameraServo
nightVision *NightVisionLight
ir *IRTransmitter
log *log.Logger
recoverMu sync.Mutex
recovering bool
@@ -30,7 +31,7 @@ type WSClient struct {
seekIssued bool
}
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, ir *IRTransmitter, logger *log.Logger) *WSClient {
var ttsQueue chan *ttsPayload
if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2)
@@ -43,6 +44,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
media: media,
servo: servo,
nightVision: nightVision,
ir: ir,
log: logger,
ttsQueue: ttsQueue,
}
@@ -181,6 +183,13 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
slot = clampInt(*msg.Song.Slot, 0, 4)
}
return c.adapter.PlaySong(slot, msg.Song.Notes)
case msg.IR != nil:
if c.ir == nil {
return fmt.Errorf("ir disabled")
}
code := clampInt(msg.IR.Code, 0, 255)
repeat := clampInt(msg.IR.Repeat, 0, 10)
return c.ir.Send(byte(code), repeat)
default:
return fmt.Errorf("unsupported command type: %s", msg.Type)
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CvfJ6hKE.js"></script>
<script type="module" crossorigin src="/assets/index-UA5S-yPp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BY_dmM98.css">
</head>
<body>
+3
View File
@@ -3,6 +3,9 @@ const { httpServer } = require('./http');
const io = new SocketIOServer(httpServer, {
cors: { origin: '*' },
transports: ['websocket', 'polling'],
pingInterval: 5000,
pingTimeout: 7000,
});
// Allow more service listeners without warnings.
-2
View File
@@ -9,8 +9,6 @@ httpServer.on('upgrade', (req, socket, head) => {
roverWSS.handleUpgrade(req, socket, head, (ws) => {
roverWSS.emit('connection', ws, req);
});
} else {
socket.destroy();
}
});
+12
View File
@@ -12,6 +12,9 @@ const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5;
const rateBuckets = new Map(); // socketId -> [timestamps]
const MAX_HISTORY = 100;
const history = [];
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick'];
const DUPLICATE_WINDOW_MS = 15000;
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
@@ -83,7 +86,15 @@ function buildMessage(socket, text, meta = {}) {
};
}
function pushHistory(message) {
history.push(message);
if (history.length > MAX_HISTORY) {
history.shift();
}
}
function broadcastMessage(message) {
pushHistory(message);
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
}
@@ -208,6 +219,7 @@ function sendExternalMessage({
}
io.on('connection', (socket) => {
socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
});
+4
View File
@@ -657,6 +657,10 @@ io.on('connection', (socket) => {
socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnecting', () => {
logger.info('Socket disconnecting', socket.id);
removeSocket(socket);
});
socket.on('disconnect', () => {
logger.info('Socket disconnected', socket.id);
removeSocket(socket);
+30 -1
View File
@@ -9,6 +9,7 @@ const activeDrivers = new Map();
const TURN_DURATION_MS = 60 * 1000;
const IDLE_TIMEOUT_MS = 7 * 1000;
const MAX_IDLE_SKIPS = 3;
const STALE_REAPER_MS = 5000;
const turnEvents = new EventEmitter();
const turnDeadlines = new Map(); // roverId -> timestamp when current driver expires
const idleDeadlines = new Map(); // roverId -> timestamp when idle skip will happen
@@ -268,16 +269,44 @@ function removeDriverCompletely(roverId, socketId) {
function recordActivity(roverId, socketId) {
const queue = driverQueues.get(roverId);
if (!queue || queue.current !== socketId) return;
if (idleDisarmed.get(roverId)) return;
idleDisarmed.set(roverId, true);
const hadDeadline = idleDeadlines.has(roverId);
clearTimeout(idleTimers.get(roverId));
idleDeadlines.delete(roverId);
turnEvents.emit('queue', { roverId, reason: 'activity' });
if (hadDeadline) {
turnEvents.emit('queue', { roverId, reason: 'activity' });
}
}
modeEvents.on('change', (mode) => {
driverQueues.forEach((_, roverId) => syncState(roverId));
});
function reapStaleDrivers() {
const staleIds = new Set();
driverQueues.forEach((queue) => {
queue.queue.forEach((socketId) => {
if (!io.sockets.sockets.has(socketId)) {
staleIds.add(socketId);
}
});
if (queue.current && !io.sockets.sockets.has(queue.current)) {
staleIds.add(queue.current);
}
});
if (staleIds.size === 0) return;
driverQueues.forEach((queue, roverId) => {
staleIds.forEach((socketId) => {
if (queue.current === socketId || queue.queue.includes(socketId)) {
driverRemoved(roverId, socketId);
}
});
});
}
setInterval(reapStaleDrivers, STALE_REAPER_MS);
module.exports = {
driverAdded,
driverRemoved,
+49 -22
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useCommandPipeline } from '../controls/commandPipeline.js';
import { useSession } from '../context/SessionContext.jsx';
import RoverRoster from './RoverRoster.jsx';
@@ -9,8 +10,11 @@ const MODES = [
{ key: 'lockdown', label: 'Lockdown' },
];
const IR_SHOT_CODE = 200;
export default function AdminPanel() {
const { session, lockRover, setMode, requestControl } = useSession();
const pipeline = useCommandPipeline();
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({});
const health = session?.health || null;
@@ -48,6 +52,17 @@ export default function AdminPanel() {
}
};
const handleIrShot = (roverId) => {
pipeline.emitCommand(
{
type: 'ir',
data: { ir: { code: IR_SHOT_CODE } },
},
null,
roverId,
);
};
const lockMap = useMemo(() => {
const map = {};
roster.forEach((rover) => {
@@ -56,39 +71,51 @@ export default function AdminPanel() {
return map;
}, [roster, lockStates]);
if (!isAdmin) return null;
return (
<section className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between gap-0.5 text-sm">
<span>Admin controls</span>
<select value={currentMode} onChange={handleModeChange} className="field-input text-sm">
{MODES.map((mode) => (
<option key={mode.key} value={mode.key}>
{mode.label}
</option>
))}
</select>
</div>
{isAdmin ? (
<div className="flex items-center justify-between gap-0.5 text-sm">
<span>Admin controls</span>
<select value={currentMode} onChange={handleModeChange} className="field-input text-sm">
{MODES.map((mode) => (
<option key={mode.key} value={mode.key}>
{mode.label}
</option>
))}
</select>
</div>
) : (
<div className="flex items-center justify-between gap-0.5 text-sm">
<span>Rover controls</span>
<span className="panel-muted text-xs">Limited access</span>
</div>
)}
<RoverRoster
roster={roster}
renderActions={(rover) => (
<div className="flex flex-wrap gap-0.5 text-xs">
<button
type="button"
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
className="button-dark"
>
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
</button>
<button type="button" onClick={() => handleForceControl(rover.id)} className="button-dark">
Force
{isAdmin ? (
<>
<button
type="button"
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
className="button-dark"
>
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
</button>
<button type="button" onClick={() => handleForceControl(rover.id)} className="button-dark">
Force
</button>
</>
) : null}
<button type="button" onClick={() => handleIrShot(rover.id)} className="button-dark">
IR Shot
</button>
</div>
)}
/>
<ReplaySnapshotHealth health={health} />
{isAdmin ? <ReplaySnapshotHealth health={health} /> : null}
</section>
);
}
+18
View File
@@ -50,6 +50,24 @@ export function ChatProvider({ children }) {
};
}, [playSound, session?.socketId, socket]);
useEffect(() => {
function handleInit(payload = []) {
if (!Array.isArray(payload)) return;
setMessages((prev) => {
if (prev.length === 0) {
return payload.slice(-100);
}
const seen = new Set(payload.map((entry) => entry?.id));
const merged = [...payload, ...prev.filter((entry) => entry?.id && !seen.has(entry.id))];
return merged.slice(-100);
});
}
socket.on('chat:init', handleInit);
return () => {
socket.off('chat:init', handleInit);
};
}, [socket]);
const sendMessage = useCallback(
(text, tts = null) =>
new Promise((resolve, reject) => {
+4 -3
View File
@@ -32,9 +32,10 @@ export function useCommandPipeline() {
}, [rosterEntry]);
const emitCommand = useCallback(
(payload, cb) => {
if (!roverId) return;
socket.emit('command', { roverId, ...payload }, cb);
(payload, cb, targetRoverId) => {
const roverTarget = targetRoverId ?? roverId;
if (!roverTarget) return;
socket.emit('command', { roverId: roverTarget, ...payload }, cb);
},
[socket, roverId],
);