mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
give up for now
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+10
-44
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user