give up for now

This commit is contained in:
legop3
2026-01-15 12:23:03 -05:00
parent 655fed4326
commit 5b9167ab2a
17 changed files with 49 additions and 651 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+10 -44
View File
@@ -15,8 +15,6 @@ type CameraServo struct {
cfg CameraServoConfig
logger *log.Logger
pin rpio.Pin
pigpio *pigpioClient
usePigpio bool
mu sync.Mutex
currentAngle float64
closed bool
@@ -26,30 +24,6 @@ func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, er
if !cfg.Enabled {
return nil, fmt.Errorf("camera servo disabled")
}
servo := &CameraServo{
cfg: cfg,
logger: logger,
}
if client, err := newPigpioClient(defaultPigpioAddr); err == nil {
if err := setPigpioMode(client, cfg.Pin, piOutput); err == nil {
servo.pigpio = client
servo.usePigpio = true
} else {
_ = client.Close()
}
}
if servo.usePigpio {
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
_ = servo.pigpio.Close()
return nil, err
}
logger.Printf("camera servo initialized on GPIO %d (pigpio, %.1f..%.1f deg, %d..%d us, invert=%v)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle, cfg.MinPulseUs, cfg.MaxPulseUs, cfg.Invert)
return servo, nil
}
if err := rpio.Open(); err != nil {
return nil, fmt.Errorf("open gpio: %w", err)
}
@@ -58,8 +32,12 @@ func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, er
pin.Mode(rpio.Pwm)
targetClock := cfg.FreqHz * cfg.CycleLen
pin.Freq(targetClock)
servo.pin = pin
servo := &CameraServo{
cfg: cfg,
logger: logger,
pin: pin,
}
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
rpio.Close()
return nil, err
@@ -74,12 +52,8 @@ func (s *CameraServo) Close() {
if s.closed {
return
}
_ = s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
if s.usePigpio {
_ = s.pigpio.Close()
} else {
rpio.Close()
}
s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
rpio.Close()
s.closed = true
}
@@ -94,9 +68,7 @@ func (s *CameraServo) setAngleLocked(angle float64) error {
return fmt.Errorf("servo closed")
}
clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle)
if err := s.applyPulseLocked(s.angleToPulse(clamped)); err != nil {
return err
}
s.applyPulseLocked(s.angleToPulse(clamped))
s.currentAngle = clamped
return nil
}
@@ -123,9 +95,7 @@ func (s *CameraServo) SetPulseWidth(micros int) error {
if micros <= 0 {
return fmt.Errorf("pulse width must be > 0")
}
if err := s.applyPulseLocked(micros); err != nil {
return err
}
s.applyPulseLocked(micros)
s.currentAngle = s.pulseToAngle(micros)
return nil
}
@@ -136,13 +106,9 @@ func (s *CameraServo) CurrentAngle() float64 {
return s.currentAngle
}
func (s *CameraServo) applyPulseLocked(micros int) error {
func (s *CameraServo) applyPulseLocked(micros int) {
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
if s.usePigpio {
return setPigpioServo(s.pigpio, s.cfg.Pin, micros)
}
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
return nil
}
func (s *CameraServo) angleToPulse(angle float64) int {
+1 -10
View File
@@ -78,19 +78,10 @@ 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, irTx, logger)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
retryDelay := time.Second
for ctx.Err() == nil {
-6
View File
@@ -29,7 +29,6 @@ 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 {
@@ -80,11 +79,6 @@ 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"`
-67
View File
@@ -103,21 +103,6 @@ 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"`
PigpioAddr string `yaml:"pigpioAddr" json:"pigpioAddr"`
}
type Config struct {
Name string `yaml:"name"`
ServerURL string `yaml:"serverUrl"`
@@ -129,7 +114,6 @@ 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) {
@@ -185,20 +169,6 @@ 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,
PigpioAddr: "localhost:8888",
},
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
@@ -262,9 +232,6 @@ 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
}
@@ -349,40 +316,6 @@ 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.PigpioAddr == "" {
cfg.PigpioAddr = "localhost:8888"
}
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")
-246
View File
@@ -1,246 +0,0 @@
//go:build !dummy
package roverd
import (
"bytes"
"encoding/binary"
"fmt"
"log"
"sync"
"time"
)
type gpioPulse struct {
GpioOn uint32
GpioOff uint32
DelayUs uint32
}
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")
}
addr := ensurePigpioAddr(cfg.PigpioAddr)
client, err := connectPigpioWithRetry(addr, logger)
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
}
return t.writeWave(pulses, time.Duration(totalUs)*time.Microsecond)
}
func (t *IRTransmitter) configureLine() error {
if err := setPigpioMode(t.pigpio, t.cfg.Pin, piOutput); err != nil {
return err
}
return t.setInactive()
}
func (t *IRTransmitter) setInactive() error {
level := uint32(0)
if t.activeLow {
level = 1
}
return writePigpio(t.pigpio, t.cfg.Pin, level)
}
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(piCmdWaveAdd, 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
@@ -1,20 +0,0 @@
//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
}
-134
View File
@@ -1,134 +0,0 @@
//go:build !dummy
package roverd
import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
"sync"
"time"
)
const (
defaultPigpioAddr = "localhost:8888"
piCmdSetMode = 0
piCmdWrite = 4
piCmdServo = 8
piCmdWaveClear = 27
piCmdWaveAdd = 28
piCmdWaveTxBusy = 32
piCmdWaveCreate = 49
piCmdWaveDelete = 50
piCmdWaveTxSend = 51
piOutput = 1
pigpioConnectRetries = 20
pigpioConnectDelay = 250 * time.Millisecond
)
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 connectPigpioWithRetry(addr string, logger *log.Logger) (*pigpioClient, error) {
var lastErr error
for attempt := 1; attempt <= pigpioConnectRetries; attempt++ {
client, err := newPigpioClient(addr)
if err == nil {
return client, nil
}
lastErr = err
if attempt == 1 || attempt%4 == 0 {
logger.Printf("pigpio connect attempt %d/%d failed: %v", attempt, pigpioConnectRetries, err)
}
time.Sleep(pigpioConnectDelay)
}
return nil, lastErr
}
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
}
func ensurePigpioAddr(addr string) string {
if addr != "" {
return addr
}
return defaultPigpioAddr
}
func setPigpioMode(client *pigpioClient, pin int, mode uint32) error {
res, err := client.command(piCmdSetMode, uint32(pin), mode, 0, nil)
if err != nil {
return fmt.Errorf("pigpio set mode: %w", err)
}
if res < 0 {
return fmt.Errorf("pigpio set mode: %d", res)
}
return nil
}
func writePigpio(client *pigpioClient, pin int, level uint32) error {
res, err := client.command(piCmdWrite, uint32(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 setPigpioServo(client *pigpioClient, pin int, pulseWidth int) error {
res, err := client.command(piCmdServo, uint32(pin), uint32(pulseWidth), 0, nil)
if err != nil {
return fmt.Errorf("pigpio servo: %w", err)
}
if res < 0 {
return fmt.Errorf("pigpio servo: %d", res)
}
return nil
}
-13
View File
@@ -53,16 +53,3 @@ 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
pigpioAddr: localhost:8888
-13
View File
@@ -31,16 +31,3 @@ 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
pigpioAddr: localhost:8888
+1 -10
View File
@@ -20,7 +20,6 @@ type WSClient struct {
media *MediaSupervisor
servo *CameraServo
nightVision *NightVisionLight
ir *IRTransmitter
log *log.Logger
recoverMu sync.Mutex
recovering bool
@@ -31,7 +30,7 @@ type WSClient struct {
seekIssued bool
}
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, ir *IRTransmitter, logger *log.Logger) *WSClient {
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
var ttsQueue chan *ttsPayload
if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2)
@@ -44,7 +43,6 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
media: media,
servo: servo,
nightVision: nightVision,
ir: ir,
log: logger,
ttsQueue: ttsQueue,
}
@@ -183,13 +181,6 @@ 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)
}
+2 -2
View File
@@ -1,7 +1,7 @@
[Unit]
Description=Multi-Roomba rover control agent
After=network-online.target mediamtx.service pigpiod.service
Wants=network-online.target pigpiod.service
After=network-online.target mediamtx.service
Wants=network-online.target
[Service]
Type=simple
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-puYPJv33.js"></script>
<script type="module" crossorigin src="/assets/index-5t6wOuB4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BY_dmM98.css">
</head>
<body>
+22 -72
View File
@@ -1,7 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useCommandPipeline } from '../controls/commandPipeline.js';
import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import RoverRoster from './RoverRoster.jsx';
const MODES = [
@@ -11,16 +9,11 @@ const MODES = [
{ key: 'lockdown', label: 'Lockdown' },
];
const IR_SHOT_CODE = 200;
export default function AdminPanel() {
const { session, lockRover, setMode, requestControl } = useSession();
const socket = useSocket();
const pipeline = useCommandPipeline();
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({});
const health = session?.health || null;
const [transport, setTransport] = useState(null);
const isAdmin =
session?.role === 'admin' ||
@@ -55,17 +48,6 @@ 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) => {
@@ -74,71 +56,39 @@ export default function AdminPanel() {
return map;
}, [roster, lockStates]);
useEffect(() => {
if (!socket) return undefined;
const updateTransport = () => {
const name = socket.io?.engine?.transport?.name || null;
setTransport(name);
};
updateTransport();
socket.on('connect', updateTransport);
socket.on('disconnect', () => setTransport(null));
socket.io?.engine?.on('upgrade', updateTransport);
return () => {
socket.off('connect', updateTransport);
socket.off('disconnect');
socket.io?.engine?.off('upgrade', updateTransport);
};
}, [socket]);
if (!isAdmin) return null;
return (
<section className="panel-section space-y-0.5 text-base">
{isAdmin ? (
<div className="flex items-center justify-between gap-0.5 text-sm">
<span>Admin controls</span>
<div className="flex items-center gap-0.5">
<span className="panel-muted text-xs">{transport ? `Conn: ${transport}` : 'Conn: —'}</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>
) : (
<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>
)}
<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>
<RoverRoster
roster={roster}
renderActions={(rover) => (
<div className="flex flex-wrap gap-0.5 text-xs">
{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
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>
</div>
)}
/>
{isAdmin ? <ReplaySnapshotHealth health={health} /> : null}
<ReplaySnapshotHealth health={health} />
</section>
);
}
+3 -4
View File
@@ -32,10 +32,9 @@ export function useCommandPipeline() {
}, [rosterEntry]);
const emitCommand = useCallback(
(payload, cb, targetRoverId) => {
const roverTarget = targetRoverId ?? roverId;
if (!roverTarget) return;
socket.emit('command', { roverId: roverTarget, ...payload }, cb);
(payload, cb) => {
if (!roverId) return;
socket.emit('command', { roverId, ...payload }, cb);
},
[socket, roverId],
);