mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
chat history, better turn queue updates
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -78,10 +78,19 @@ func main() {
|
|||||||
defer nightVision.Close()
|
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)
|
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
||||||
go autoCharge.Run(ctx, sensorSamples)
|
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
|
retryDelay := time.Second
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type inboundMessage struct {
|
|||||||
TTS *ttsPayload `json:"tts,omitempty"`
|
TTS *ttsPayload `json:"tts,omitempty"`
|
||||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||||
Song *songPayload `json:"song,omitempty"`
|
Song *songPayload `json:"song,omitempty"`
|
||||||
|
IR *irPayload `json:"ir,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type driveDirectPayload struct {
|
type driveDirectPayload struct {
|
||||||
@@ -79,6 +80,11 @@ type songNote struct {
|
|||||||
Duration int `json:"duration"`
|
Duration int `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type irPayload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Repeat int `json:"repeat,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ackMessage struct {
|
type ackMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ type NightVisionConfig struct {
|
|||||||
InitialOn bool `yaml:"initialOn" json:"initialOn"`
|
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 {
|
type Config struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
ServerURL string `yaml:"serverUrl"`
|
ServerURL string `yaml:"serverUrl"`
|
||||||
@@ -114,6 +128,7 @@ type Config struct {
|
|||||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||||
Audio AudioConfig `yaml:"audio"`
|
Audio AudioConfig `yaml:"audio"`
|
||||||
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
||||||
|
IR IRConfig `yaml:"ir" json:"ir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
@@ -169,6 +184,19 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
GPIOChip: "gpiochip0",
|
GPIOChip: "gpiochip0",
|
||||||
InitialOn: true,
|
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 {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -232,6 +260,9 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
|
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
|
||||||
return nil, fmt.Errorf("nightVision: %w", err)
|
return nil, fmt.Errorf("nightVision: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := validateIRConfig(&cfg.IR); err != nil {
|
||||||
|
return nil, fmt.Errorf("ir: %w", err)
|
||||||
|
}
|
||||||
validateAudioConfig(&cfg.Audio)
|
validateAudioConfig(&cfg.Audio)
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
@@ -316,6 +347,37 @@ func validateNightVisionConfig(cfg *NightVisionConfig) error {
|
|||||||
return nil
|
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) {
|
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
||||||
if streamName == "" {
|
if streamName == "" {
|
||||||
return "", errors.New("missing stream name for publishUrl")
|
return "", errors.New("missing stream name for publishUrl")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -53,3 +53,15 @@ nightVision:
|
|||||||
gpioPin: 22
|
gpioPin: 22
|
||||||
gpioChip: gpiochip0
|
gpioChip: gpiochip0
|
||||||
initialOn: true
|
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
|
||||||
|
|||||||
@@ -31,3 +31,15 @@ cameraServo:
|
|||||||
homeAngle: 0
|
homeAngle: 0
|
||||||
nudgeDegrees: 2
|
nudgeDegrees: 2
|
||||||
allowRawPulse: false
|
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
@@ -20,6 +20,7 @@ type WSClient struct {
|
|||||||
media *MediaSupervisor
|
media *MediaSupervisor
|
||||||
servo *CameraServo
|
servo *CameraServo
|
||||||
nightVision *NightVisionLight
|
nightVision *NightVisionLight
|
||||||
|
ir *IRTransmitter
|
||||||
log *log.Logger
|
log *log.Logger
|
||||||
recoverMu sync.Mutex
|
recoverMu sync.Mutex
|
||||||
recovering bool
|
recovering bool
|
||||||
@@ -30,7 +31,7 @@ type WSClient struct {
|
|||||||
seekIssued bool
|
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
|
var ttsQueue chan *ttsPayload
|
||||||
if cfg.Audio.TTSEnabled {
|
if cfg.Audio.TTSEnabled {
|
||||||
ttsQueue = make(chan *ttsPayload, 2)
|
ttsQueue = make(chan *ttsPayload, 2)
|
||||||
@@ -43,6 +44,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
|||||||
media: media,
|
media: media,
|
||||||
servo: servo,
|
servo: servo,
|
||||||
nightVision: nightVision,
|
nightVision: nightVision,
|
||||||
|
ir: ir,
|
||||||
log: logger,
|
log: logger,
|
||||||
ttsQueue: ttsQueue,
|
ttsQueue: ttsQueue,
|
||||||
}
|
}
|
||||||
@@ -181,6 +183,13 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
slot = clampInt(*msg.Song.Slot, 0, 4)
|
slot = clampInt(*msg.Song.Slot, 0, 4)
|
||||||
}
|
}
|
||||||
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
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:
|
default:
|
||||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
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-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-BY_dmM98.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ const { httpServer } = require('./http');
|
|||||||
|
|
||||||
const io = new SocketIOServer(httpServer, {
|
const io = new SocketIOServer(httpServer, {
|
||||||
cors: { origin: '*' },
|
cors: { origin: '*' },
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
pingInterval: 5000,
|
||||||
|
pingTimeout: 7000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow more service listeners without warnings.
|
// Allow more service listeners without warnings.
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|||||||
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
||||||
roverWSS.emit('connection', ws, req);
|
roverWSS.emit('connection', ws, req);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const RATE_LIMIT_WINDOW_MS = 8000;
|
|||||||
const RATE_LIMIT_MAX = 5;
|
const RATE_LIMIT_MAX = 5;
|
||||||
const rateBuckets = new Map(); // socketId -> [timestamps]
|
const rateBuckets = new Map(); // socketId -> [timestamps]
|
||||||
|
|
||||||
|
const MAX_HISTORY = 100;
|
||||||
|
const history = [];
|
||||||
|
|
||||||
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick'];
|
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick'];
|
||||||
const DUPLICATE_WINDOW_MS = 15000;
|
const DUPLICATE_WINDOW_MS = 15000;
|
||||||
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
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) {
|
function broadcastMessage(message) {
|
||||||
|
pushHistory(message);
|
||||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +219,7 @@ function sendExternalMessage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
|
socket.emit('chat:init', history);
|
||||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -657,6 +657,10 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('subscribeAll', handleSubscribeAll);
|
socket.on('subscribeAll', handleSubscribeAll);
|
||||||
socket.on('session:subscribeAll', handleSubscribeAll);
|
socket.on('session:subscribeAll', handleSubscribeAll);
|
||||||
|
|
||||||
|
socket.on('disconnecting', () => {
|
||||||
|
logger.info('Socket disconnecting', socket.id);
|
||||||
|
removeSocket(socket);
|
||||||
|
});
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
logger.info('Socket disconnected', socket.id);
|
logger.info('Socket disconnected', socket.id);
|
||||||
removeSocket(socket);
|
removeSocket(socket);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const activeDrivers = new Map();
|
|||||||
const TURN_DURATION_MS = 60 * 1000;
|
const TURN_DURATION_MS = 60 * 1000;
|
||||||
const IDLE_TIMEOUT_MS = 7 * 1000;
|
const IDLE_TIMEOUT_MS = 7 * 1000;
|
||||||
const MAX_IDLE_SKIPS = 3;
|
const MAX_IDLE_SKIPS = 3;
|
||||||
|
const STALE_REAPER_MS = 5000;
|
||||||
const turnEvents = new EventEmitter();
|
const turnEvents = new EventEmitter();
|
||||||
const turnDeadlines = new Map(); // roverId -> timestamp when current driver expires
|
const turnDeadlines = new Map(); // roverId -> timestamp when current driver expires
|
||||||
const idleDeadlines = new Map(); // roverId -> timestamp when idle skip will happen
|
const idleDeadlines = new Map(); // roverId -> timestamp when idle skip will happen
|
||||||
@@ -268,16 +269,44 @@ function removeDriverCompletely(roverId, socketId) {
|
|||||||
function recordActivity(roverId, socketId) {
|
function recordActivity(roverId, socketId) {
|
||||||
const queue = driverQueues.get(roverId);
|
const queue = driverQueues.get(roverId);
|
||||||
if (!queue || queue.current !== socketId) return;
|
if (!queue || queue.current !== socketId) return;
|
||||||
|
if (idleDisarmed.get(roverId)) return;
|
||||||
idleDisarmed.set(roverId, true);
|
idleDisarmed.set(roverId, true);
|
||||||
|
const hadDeadline = idleDeadlines.has(roverId);
|
||||||
clearTimeout(idleTimers.get(roverId));
|
clearTimeout(idleTimers.get(roverId));
|
||||||
idleDeadlines.delete(roverId);
|
idleDeadlines.delete(roverId);
|
||||||
turnEvents.emit('queue', { roverId, reason: 'activity' });
|
if (hadDeadline) {
|
||||||
|
turnEvents.emit('queue', { roverId, reason: 'activity' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
modeEvents.on('change', (mode) => {
|
modeEvents.on('change', (mode) => {
|
||||||
driverQueues.forEach((_, roverId) => syncState(roverId));
|
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 = {
|
module.exports = {
|
||||||
driverAdded,
|
driverAdded,
|
||||||
driverRemoved,
|
driverRemoved,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useCommandPipeline } from '../controls/commandPipeline.js';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import RoverRoster from './RoverRoster.jsx';
|
import RoverRoster from './RoverRoster.jsx';
|
||||||
|
|
||||||
@@ -9,8 +10,11 @@ const MODES = [
|
|||||||
{ key: 'lockdown', label: 'Lockdown' },
|
{ key: 'lockdown', label: 'Lockdown' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const IR_SHOT_CODE = 200;
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const { session, lockRover, setMode, requestControl } = useSession();
|
const { session, lockRover, setMode, requestControl } = useSession();
|
||||||
|
const pipeline = useCommandPipeline();
|
||||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
const [lockStates, setLockStates] = useState({});
|
const [lockStates, setLockStates] = useState({});
|
||||||
const health = session?.health || null;
|
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 lockMap = useMemo(() => {
|
||||||
const map = {};
|
const map = {};
|
||||||
roster.forEach((rover) => {
|
roster.forEach((rover) => {
|
||||||
@@ -56,39 +71,51 @@ export default function AdminPanel() {
|
|||||||
return map;
|
return map;
|
||||||
}, [roster, lockStates]);
|
}, [roster, lockStates]);
|
||||||
|
|
||||||
if (!isAdmin) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<section className="panel-section space-y-0.5 text-base">
|
||||||
<div className="flex items-center justify-between gap-0.5 text-sm">
|
{isAdmin ? (
|
||||||
<span>Admin controls</span>
|
<div className="flex items-center justify-between gap-0.5 text-sm">
|
||||||
<select value={currentMode} onChange={handleModeChange} className="field-input text-sm">
|
<span>Admin controls</span>
|
||||||
{MODES.map((mode) => (
|
<select value={currentMode} onChange={handleModeChange} className="field-input text-sm">
|
||||||
<option key={mode.key} value={mode.key}>
|
{MODES.map((mode) => (
|
||||||
{mode.label}
|
<option key={mode.key} value={mode.key}>
|
||||||
</option>
|
{mode.label}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
</div>
|
</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
|
<RoverRoster
|
||||||
roster={roster}
|
roster={roster}
|
||||||
renderActions={(rover) => (
|
renderActions={(rover) => (
|
||||||
<div className="flex flex-wrap gap-0.5 text-xs">
|
<div className="flex flex-wrap gap-0.5 text-xs">
|
||||||
<button
|
{isAdmin ? (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
|
<button
|
||||||
className="button-dark"
|
type="button"
|
||||||
>
|
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
|
||||||
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
|
className="button-dark"
|
||||||
</button>
|
>
|
||||||
<button type="button" onClick={() => handleForceControl(rover.id)} className="button-dark">
|
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
|
||||||
Force
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ReplaySnapshotHealth health={health} />
|
{isAdmin ? <ReplaySnapshotHealth health={health} /> : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,24 @@ export function ChatProvider({ children }) {
|
|||||||
};
|
};
|
||||||
}, [playSound, session?.socketId, socket]);
|
}, [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(
|
const sendMessage = useCallback(
|
||||||
(text, tts = null) =>
|
(text, tts = null) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ export function useCommandPipeline() {
|
|||||||
}, [rosterEntry]);
|
}, [rosterEntry]);
|
||||||
|
|
||||||
const emitCommand = useCallback(
|
const emitCommand = useCallback(
|
||||||
(payload, cb) => {
|
(payload, cb, targetRoverId) => {
|
||||||
if (!roverId) return;
|
const roverTarget = targetRoverId ?? roverId;
|
||||||
socket.emit('command', { roverId, ...payload }, cb);
|
if (!roverTarget) return;
|
||||||
|
socket.emit('command', { roverId: roverTarget, ...payload }, cb);
|
||||||
},
|
},
|
||||||
[socket, roverId],
|
[socket, roverId],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user