mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
headlight rework and laser addition
This commit is contained in:
@@ -28,13 +28,14 @@ You WILL need some common hobby electronics knowledge to build one of these, I w
|
||||
| ----CAMERA STUFF---- | ----Below here is all camera related things that youll need---- |
|
||||
| A pi camera v2 (ov5647) | The video publisher in this project is only designed for the ov5647, though it would be totally possible to make your own publisher for other sensors. |
|
||||
| Cheap 5v servo | Unless you have a VERY wide camera lens, you will want to have a servo plugged into the pi to be able to tilt the camera up and down. |
|
||||
| LED for toggling night vision | I use white LEDs attached to one of the "driver" pins on the AIY hat to trick one of those generic pi camera LED floodlights into turning on / off on demand.|
|
||||
| LED for toggling the headlight | I use white LEDs attached to one of the "driver" pins on the AIY hat to trick one of those generic pi camera LED floodlights into turning on / off on demand.|
|
||||
| Optional laser pointer | A laser pointer can be wired to another high-current-capable GPIO driver, with GPIO 27 used by default in the rover config. |
|
||||
| Pi zero camera cable | The pi zero has a smaller version of the camera ribbon connector, you will need the right cable for your camera |
|
||||
|
||||
I do not have any exact numbers, but with cheap used Roombas it seems like on average one of my rovers takes about $100 USD to build from scratch.
|
||||
|
||||
### Physical assembly of the rover
|
||||
This is going to be the very loose section of this guide, the way yout build your rover is up to you. I have mine built out with durable protective metal cages as they are open to the internet and people like breaking things. The electronics on top of the roomba can be as simple as a pi, level shifter, and a camera if you just want to run it around yourself, things like a speaker, microphone, servo, night vision LED, are all totally optional and can be disabled in the configuration on the raspberry pi.
|
||||
This is going to be the very loose section of this guide, the way yout build your rover is up to you. I have mine built out with durable protective metal cages as they are open to the internet and people like breaking things. The electronics on top of the roomba can be as simple as a pi, level shifter, and a camera if you just want to run it around yourself, things like a speaker, microphone, servo, headlight, laser pointer, are all totally optional and can be disabled in the configuration on the raspberry pi.
|
||||
|
||||
These are the basics of how evertything is connected on my rovers, this is hard to organize and illustrate through text but hopefully it will give you a good idea:
|
||||
1. 7 pin mini DIN cable
|
||||
@@ -50,6 +51,7 @@ These are the basics of how evertything is connected on my rovers, this is hard
|
||||
1. ov5647 camera
|
||||
2. full size to pi zero style camera ribbon cable, plug camera into pi
|
||||
3. an LED connected to a high current driver output on the AIY hat, GPIO 22 by default. This is meant to be poked into the LDR sensor on a pi camera IR floodlight to make it toggleable by the driver.
|
||||
4. optionally, a laser pointer connected to another high current driver output, GPIO 27 by default.
|
||||
|
||||
### Software on your rover
|
||||
Raspberry pi OS installation:
|
||||
|
||||
Vendored
+3
-1
@@ -18,5 +18,7 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
headlight:
|
||||
enabled: false
|
||||
laser:
|
||||
enabled: false
|
||||
|
||||
Vendored
+3
-1
@@ -18,5 +18,7 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
headlight:
|
||||
enabled: false
|
||||
laser:
|
||||
enabled: false
|
||||
|
||||
Vendored
+3
-1
@@ -18,5 +18,7 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
headlight:
|
||||
enabled: false
|
||||
laser:
|
||||
enabled: false
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -143,7 +143,7 @@ useEffect(() => {
|
||||
setAuxMotors,
|
||||
setDriveVector,
|
||||
setMode,
|
||||
toggleNightVision,
|
||||
toggleHeadlight,
|
||||
dockAssist,
|
||||
]);
|
||||
```
|
||||
@@ -276,4 +276,3 @@ Expected improvements:
|
||||
can fire.
|
||||
- Verify horn hold, mic push-to-talk, chat focus, drive macro, dock assist, camera tilt, song
|
||||
controls, and Home Assistant shortcuts.
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
## rover service checklist
|
||||
|
||||
things that need to be good:
|
||||
- all bolts tight
|
||||
- headlights functional
|
||||
- cameras aligned properly
|
||||
- not crooked sideways
|
||||
- not off center
|
||||
- lenses in focus
|
||||
- microphone good
|
||||
@@ -69,19 +69,28 @@ func main() {
|
||||
defer cameraServo.Close()
|
||||
}
|
||||
|
||||
var nightVision *roverd.NightVisionLight
|
||||
if cfg.NightVision.Enabled {
|
||||
nightVision, err = roverd.NewNightVisionLight(cfg.NightVision, logger)
|
||||
var headlight *roverd.GPIOToggle
|
||||
if cfg.Headlight.Enabled {
|
||||
headlight, err = roverd.NewGPIOToggle("headlight", cfg.Headlight, logger)
|
||||
if err != nil {
|
||||
logger.Fatalf("init night vision: %v", err)
|
||||
logger.Fatalf("init headlight: %v", err)
|
||||
}
|
||||
defer nightVision.Close()
|
||||
defer headlight.Close()
|
||||
}
|
||||
|
||||
var laser *roverd.GPIOToggle
|
||||
if cfg.Laser.Enabled {
|
||||
laser, err = roverd.NewGPIOToggle("laser", cfg.Laser, logger)
|
||||
if err != nil {
|
||||
logger.Fatalf("init laser: %v", err)
|
||||
}
|
||||
defer laser.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, headlight, laser, logger)
|
||||
|
||||
retryDelay := time.Second
|
||||
for ctx.Err() == nil {
|
||||
|
||||
@@ -11,7 +11,8 @@ type helloMessage struct {
|
||||
CameraServo CameraServoConfig `json:"cameraServo"`
|
||||
Audio AudioConfig `json:"audio"`
|
||||
Horn HornConfig `json:"horn"`
|
||||
NightVision NightVisionConfig `json:"nightVision"`
|
||||
Headlight GPIOToggleConfig `json:"headlight"`
|
||||
Laser GPIOToggleConfig `json:"laser"`
|
||||
Private PrivateConfig `json:"private"`
|
||||
}
|
||||
|
||||
@@ -42,7 +43,8 @@ type inboundMessage struct {
|
||||
TTS *ttsPayload `json:"tts,omitempty"`
|
||||
Horn *hornPayload `json:"horn,omitempty"`
|
||||
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
|
||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||
Headlight *togglePayload `json:"headlight,omitempty"`
|
||||
Laser *togglePayload `json:"laser,omitempty"`
|
||||
Song *songPayload `json:"song,omitempty"`
|
||||
Reboot *rebootPayload `json:"reboot,omitempty"`
|
||||
// Update is intentionally just a marker payload. The server can request the
|
||||
@@ -97,7 +99,7 @@ type audioLevelsPayload struct {
|
||||
ForwardGain *float64 `json:"forwardGain,omitempty"`
|
||||
}
|
||||
|
||||
type nightVisionPayload struct {
|
||||
type togglePayload struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
|
||||
+30
-7
@@ -109,11 +109,22 @@ type CameraServoConfig struct {
|
||||
Invert bool `yaml:"invert" json:"invert"`
|
||||
}
|
||||
|
||||
type NightVisionConfig struct {
|
||||
type GPIOToggleConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
|
||||
GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
|
||||
InitialOn bool `yaml:"initialOn" json:"initialOn"`
|
||||
ActiveLow bool `yaml:"activeLow" json:"activeLow"`
|
||||
}
|
||||
|
||||
func (g GPIOToggleConfig) LogicalToGPIO(on bool) int {
|
||||
// activeLow is the single hardware-inversion point for GPIO toggles. The
|
||||
// rest of the daemon, server, and web UI can use normal logical on/off
|
||||
// semantics without knowing whether the driver is active-high or active-low.
|
||||
if on == g.ActiveLow {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
type AutoSideBrushConfig struct {
|
||||
@@ -153,7 +164,8 @@ type Config struct {
|
||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||
Audio AudioConfig `yaml:"audio"`
|
||||
Horn HornConfig `yaml:"horn"`
|
||||
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
||||
Headlight GPIOToggleConfig `yaml:"headlight" json:"headlight"`
|
||||
Laser GPIOToggleConfig `yaml:"laser" json:"laser"`
|
||||
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
|
||||
Private PrivateConfig `yaml:"private" json:"private"`
|
||||
}
|
||||
@@ -210,11 +222,19 @@ func LoadConfig(path string) (*Config, error) {
|
||||
SawGain: 0.7,
|
||||
MaxDuration: Duration{Duration: 10000 * time.Millisecond},
|
||||
},
|
||||
NightVision: NightVisionConfig{
|
||||
Headlight: GPIOToggleConfig{
|
||||
Enabled: true,
|
||||
GPIOPin: 22,
|
||||
GPIOChip: "gpiochip0",
|
||||
InitialOn: true,
|
||||
InitialOn: false,
|
||||
ActiveLow: true,
|
||||
},
|
||||
Laser: GPIOToggleConfig{
|
||||
Enabled: false,
|
||||
GPIOPin: 27,
|
||||
GPIOChip: "gpiochip0",
|
||||
InitialOn: false,
|
||||
ActiveLow: false,
|
||||
},
|
||||
AutoSideBrush: AutoSideBrushConfig{
|
||||
Enabled: true,
|
||||
@@ -302,8 +322,11 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
||||
}
|
||||
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
|
||||
return nil, fmt.Errorf("nightVision: %w", err)
|
||||
if err := validateGPIOToggleConfig(&cfg.Headlight); err != nil {
|
||||
return nil, fmt.Errorf("headlight: %w", err)
|
||||
}
|
||||
if err := validateGPIOToggleConfig(&cfg.Laser); err != nil {
|
||||
return nil, fmt.Errorf("laser: %w", err)
|
||||
}
|
||||
validateAudioConfig(&cfg.Audio)
|
||||
validateHornConfig(&cfg.Horn)
|
||||
@@ -396,7 +419,7 @@ func validateHornConfig(cfg *HornConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateNightVisionConfig(cfg *NightVisionConfig) error {
|
||||
func validateGPIOToggleConfig(cfg *GPIOToggleConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gpiocdev "github.com/warthog618/go-gpiocdev"
|
||||
)
|
||||
|
||||
type GPIOToggle struct {
|
||||
cfg GPIOToggleConfig
|
||||
name string
|
||||
logger *log.Logger
|
||||
line *gpiocdev.Line
|
||||
mu sync.Mutex
|
||||
on bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewGPIOToggle(name string, cfg GPIOToggleConfig, logger *log.Logger) (*GPIOToggle, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, fmt.Errorf("%s disabled", name)
|
||||
}
|
||||
chip := cfg.GPIOChip
|
||||
if chip == "" {
|
||||
chip = "gpiochip0"
|
||||
}
|
||||
line, err := gpiocdev.RequestLine(
|
||||
chip,
|
||||
cfg.GPIOPin,
|
||||
gpiocdev.AsOutput(cfg.LogicalToGPIO(cfg.InitialOn)),
|
||||
gpiocdev.WithConsumer(fmt.Sprintf("roverd-%s", name)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpio request: %w", err)
|
||||
}
|
||||
toggle := &GPIOToggle{
|
||||
cfg: cfg,
|
||||
name: name,
|
||||
logger: logger,
|
||||
line: line,
|
||||
on: cfg.InitialOn,
|
||||
}
|
||||
logger.Printf("%s on GPIO %d (initial=%v activeLow=%v)", name, cfg.GPIOPin, cfg.InitialOn, cfg.ActiveLow)
|
||||
return toggle, nil
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) Close() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed {
|
||||
return
|
||||
}
|
||||
// Preserve the last logical state while closing the line. The daemon is not
|
||||
// trying to force a safety state here; it is only releasing the GPIO handle.
|
||||
_ = g.line.SetValue(g.cfg.LogicalToGPIO(g.on))
|
||||
g.line.Close()
|
||||
g.closed = true
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) HandleAction(action string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed {
|
||||
return fmt.Errorf("%s controller closed", g.name)
|
||||
}
|
||||
act := strings.ToLower(strings.TrimSpace(action))
|
||||
switch act {
|
||||
case "", "toggle":
|
||||
return g.setLocked(!g.on)
|
||||
case "on":
|
||||
return g.setLocked(true)
|
||||
case "off":
|
||||
return g.setLocked(false)
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) On() bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.on
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) setLocked(on bool) error {
|
||||
// This is the only place a logical device state becomes an electrical GPIO
|
||||
// value. Hardware that turns on when pulled low sets activeLow in roverd
|
||||
// config; every caller above this layer still uses plain on/off semantics.
|
||||
if err := g.line.SetValue(g.cfg.LogicalToGPIO(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
g.on = on
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type GPIOToggle struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func NewGPIOToggle(name string, cfg GPIOToggleConfig, logger *log.Logger) (*GPIOToggle, error) {
|
||||
return nil, fmt.Errorf("%s not supported in dummy build", name)
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) Close() {}
|
||||
|
||||
func (g *GPIOToggle) HandleAction(action string) error {
|
||||
return fmt.Errorf("%s not supported in dummy build", g.name)
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) On() bool {
|
||||
return false
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gpiocdev "github.com/warthog618/go-gpiocdev"
|
||||
)
|
||||
|
||||
type NightVisionLight struct {
|
||||
cfg NightVisionConfig
|
||||
logger *log.Logger
|
||||
line *gpiocdev.Line
|
||||
mu sync.Mutex
|
||||
on bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, fmt.Errorf("night vision disabled")
|
||||
}
|
||||
chip := cfg.GPIOChip
|
||||
if chip == "" {
|
||||
chip = "gpiochip0"
|
||||
}
|
||||
initial := 0
|
||||
if cfg.InitialOn {
|
||||
initial = 1
|
||||
}
|
||||
line, err := gpiocdev.RequestLine(
|
||||
chip,
|
||||
cfg.GPIOPin,
|
||||
gpiocdev.AsOutput(initial),
|
||||
gpiocdev.WithConsumer("roverd-nightvision"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpio request: %w", err)
|
||||
}
|
||||
nv := &NightVisionLight{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
line: line,
|
||||
on: cfg.InitialOn,
|
||||
}
|
||||
logger.Printf("night vision LED on GPIO %d (initial=%v)", cfg.GPIOPin, cfg.InitialOn)
|
||||
return nv, nil
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) Close() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return
|
||||
}
|
||||
_ = n.line.SetValue(boolToGPIO(n.on))
|
||||
n.line.Close()
|
||||
n.closed = true
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) HandleAction(action string) error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return fmt.Errorf("night vision controller closed")
|
||||
}
|
||||
act := strings.ToLower(strings.TrimSpace(action))
|
||||
switch act {
|
||||
case "", "toggle":
|
||||
return n.setLocked(!n.on)
|
||||
case "on":
|
||||
return n.setLocked(true)
|
||||
case "off":
|
||||
return n.setLocked(false)
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) NightVisionOn() bool {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return !n.on
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) setLocked(on bool) error {
|
||||
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
n.on = on
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolToGPIO(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type NightVisionLight struct{}
|
||||
|
||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
||||
return nil, fmt.Errorf("night vision not supported in dummy build")
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) Close() {}
|
||||
|
||||
func (n *NightVisionLight) HandleAction(action string) error {
|
||||
return fmt.Errorf("night vision not supported in dummy build")
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) NightVisionOn() bool {
|
||||
return false
|
||||
}
|
||||
@@ -56,11 +56,18 @@ horn:
|
||||
sineGain: 1.0
|
||||
sawGain: 0.7
|
||||
maxDuration: 1.2s
|
||||
nightVision:
|
||||
headlight:
|
||||
enabled: true
|
||||
gpioPin: 22
|
||||
gpioChip: gpiochip0
|
||||
initialOn: true
|
||||
initialOn: false
|
||||
activeLow: true
|
||||
laser:
|
||||
enabled: false
|
||||
gpioPin: 27
|
||||
gpioChip: gpiochip0
|
||||
initialOn: false
|
||||
activeLow: false
|
||||
autoSideBrush:
|
||||
enabled: true
|
||||
speed: 20
|
||||
|
||||
@@ -32,6 +32,18 @@ cameraServo:
|
||||
homeAngle: 0
|
||||
nudgeDegrees: 2
|
||||
allowRawPulse: false
|
||||
headlight:
|
||||
enabled: true
|
||||
gpioPin: 22
|
||||
gpioChip: gpiochip0
|
||||
initialOn: false
|
||||
activeLow: true
|
||||
laser:
|
||||
enabled: false
|
||||
gpioPin: 27
|
||||
gpioChip: gpiochip0
|
||||
initialOn: false
|
||||
activeLow: false
|
||||
autoSideBrush:
|
||||
enabled: true
|
||||
speed: 20
|
||||
|
||||
+27
-15
@@ -21,7 +21,8 @@ type WSClient struct {
|
||||
media *MediaSupervisor
|
||||
servo *CameraServo
|
||||
horn *HornSynth
|
||||
nightVision *NightVisionLight
|
||||
headlight *GPIOToggle
|
||||
laser *GPIOToggle
|
||||
log *log.Logger
|
||||
recoverMu sync.Mutex
|
||||
recovering bool
|
||||
@@ -40,7 +41,7 @@ type WSClient struct {
|
||||
audioMu sync.RWMutex
|
||||
}
|
||||
|
||||
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, headlight *GPIOToggle, laser *GPIOToggle, logger *log.Logger) *WSClient {
|
||||
var ttsQueue chan *ttsPayload
|
||||
if cfg.Audio.TTSEnabled {
|
||||
ttsQueue = make(chan *ttsPayload, 2)
|
||||
@@ -61,7 +62,8 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
||||
media: media,
|
||||
servo: servo,
|
||||
horn: horn,
|
||||
nightVision: nightVision,
|
||||
headlight: headlight,
|
||||
laser: laser,
|
||||
log: logger,
|
||||
ttsQueue: ttsQueue,
|
||||
chromeTTS: chromeTTS,
|
||||
@@ -133,7 +135,8 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
||||
CameraServo: c.cfg.CameraServo,
|
||||
Audio: c.cfg.Audio,
|
||||
Horn: c.cfg.Horn,
|
||||
NightVision: c.cfg.NightVision,
|
||||
Headlight: c.cfg.Headlight,
|
||||
Laser: c.cfg.Laser,
|
||||
Private: c.cfg.Private,
|
||||
}
|
||||
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
||||
@@ -226,17 +229,10 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
||||
return c.horn.HandlePayload(msg.Horn)
|
||||
case msg.AudioLevels != nil:
|
||||
return c.handleAudioLevels(msg.AudioLevels)
|
||||
case msg.NightVision != nil:
|
||||
if c.nightVision == nil {
|
||||
return fmt.Errorf("night vision disabled")
|
||||
}
|
||||
if err := c.nightVision.HandleAction(msg.NightVision.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
c.emitEvent("nightVision.state", map[string]any{
|
||||
"nightVisionOn": c.nightVision.NightVisionOn(),
|
||||
})
|
||||
return nil
|
||||
case msg.Headlight != nil:
|
||||
return c.handleToggleCommand("headlight", c.headlight, msg.Headlight)
|
||||
case msg.Laser != nil:
|
||||
return c.handleToggleCommand("laser", c.laser, msg.Laser)
|
||||
case msg.Song != nil:
|
||||
slot := 0
|
||||
if msg.Song.Slot != nil {
|
||||
@@ -252,6 +248,22 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) handleToggleCommand(name string, toggle *GPIOToggle, payload *togglePayload) error {
|
||||
if toggle == nil {
|
||||
return fmt.Errorf("%s disabled", name)
|
||||
}
|
||||
if err := toggle.HandleAction(payload.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
// Event names and payload keys use logical device names. GPIO polarity has
|
||||
// already been handled inside GPIOToggle, so the server only sees whether
|
||||
// the headlight or laser should be considered on.
|
||||
c.emitEvent(fmt.Sprintf("%s.state", name), map[string]any{
|
||||
fmt.Sprintf("%sOn", name): toggle.On(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WSClient) stopMotionForSystemCommand(reason string) error {
|
||||
// System-level commands can restart the process or the whole Pi. Stopping
|
||||
// both wheel and auxiliary motors first leaves the Roomba in a predictable
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
- idle service will trigger, after 2 minutes of no drivers:
|
||||
- all room lights (room controls) off
|
||||
- tell all rovers to dock
|
||||
- turn off all rover night vision lights
|
||||
- turn off all rover headlights
|
||||
- tell the neato to return to home
|
||||
- the idle service should be easily expandable to add more things in the future
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BoGJudGZ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BoLmfZBk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CKlOAshP.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -2,19 +2,13 @@
|
||||
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
|
||||
const DURATION_MS = 15 * 60 * 1000;
|
||||
const LIGHT_ENFORCE_TICK_MS = 3000;
|
||||
// Rover daemon semantics are inverted:
|
||||
// action "on" => IR LED on => nightVisionOn=false
|
||||
// action "off" => IR LED off => nightVisionOn=true
|
||||
function actionForNightVisionState(nightVisionOn) {
|
||||
return nightVisionOn ? 'off' : 'on';
|
||||
}
|
||||
|
||||
let activeTimer = null;
|
||||
let enforceLightsTimer = null;
|
||||
let nightVisionLockUntil = 0;
|
||||
let headlightLockUntil = 0;
|
||||
|
||||
function isNightVisionBlocked() {
|
||||
return Date.now() < nightVisionLockUntil;
|
||||
function isHeadlightBlocked() {
|
||||
return Date.now() < headlightLockUntil;
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
@@ -43,7 +37,7 @@ async function forceAllLightsOff(ctx) {
|
||||
|
||||
async function stopDarkness(ctx, effect = {}) {
|
||||
clearTimers();
|
||||
nightVisionLockUntil = 0;
|
||||
headlightLockUntil = 0;
|
||||
|
||||
const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : [];
|
||||
await Promise.all(
|
||||
@@ -73,17 +67,17 @@ async function stopDarkness(ctx, effect = {}) {
|
||||
ctx.logger.warn('darkness restore light lock failed', { error: err.message });
|
||||
}
|
||||
|
||||
const prevNightVision = effect.prevNightVision && typeof effect.prevNightVision === 'object'
|
||||
? effect.prevNightVision
|
||||
const prevHeadlights = effect.prevHeadlights && typeof effect.prevHeadlights === 'object'
|
||||
? effect.prevHeadlights
|
||||
: {};
|
||||
Object.entries(prevNightVision).forEach(([roverId, wasOn]) => {
|
||||
Object.entries(prevHeadlights).forEach(([roverId, wasOn]) => {
|
||||
try {
|
||||
ctx.issueCommand(String(roverId), {
|
||||
type: 'nightVision',
|
||||
nightVision: { action: actionForNightVisionState(Boolean(wasOn)) },
|
||||
type: 'headlight',
|
||||
headlight: { action: Boolean(wasOn) ? 'on' : 'off' },
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness restore nightVision failed', { roverId, error: err.message });
|
||||
ctx.logger.warn('darkness restore headlight failed', { roverId, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,7 +88,7 @@ async function startDarkness(ctx, effect) {
|
||||
clearTimers();
|
||||
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
|
||||
const remaining = Math.max(0, endsAt - Date.now());
|
||||
nightVisionLockUntil = endsAt;
|
||||
headlightLockUntil = endsAt;
|
||||
try {
|
||||
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||
source: 'buttonbox:darkness',
|
||||
@@ -122,31 +116,31 @@ async function startDarkness(ctx, effect) {
|
||||
module.exports = {
|
||||
id: 'darkness',
|
||||
name: 'Darkness',
|
||||
isNightVisionBlocked,
|
||||
isHeadlightBlocked,
|
||||
goal: 400,
|
||||
async run(ctx) {
|
||||
const entities = ctx.getHomeAssistantEntities();
|
||||
const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' }));
|
||||
await forceAllLightsOff(ctx);
|
||||
|
||||
const prevNightVision = {};
|
||||
const prevHeadlights = {};
|
||||
ctx.listOnlineRovers().forEach((rover) => {
|
||||
const state = rover?.nightVision?.state;
|
||||
const nightVisionOn = Boolean(state && state.nightVisionOn === true);
|
||||
prevNightVision[String(rover.id)] = nightVisionOn;
|
||||
const state = rover?.headlight?.state;
|
||||
const headlightOn = Boolean(state && state.headlightOn === true);
|
||||
prevHeadlights[String(rover.id)] = headlightOn;
|
||||
try {
|
||||
ctx.issueCommand(String(rover.id), {
|
||||
type: 'nightVision',
|
||||
nightVision: { action: actionForNightVisionState(false) },
|
||||
type: 'headlight',
|
||||
headlight: { action: 'off' },
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness nightVision off failed', { roverId: rover.id, error: err.message });
|
||||
ctx.logger.warn('darkness headlight off failed', { roverId: rover.id, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
const prevPolicy = ctx.getHomeAssistantLightPolicy?.() || null;
|
||||
const prevLightLockState = prevPolicy?.lockState || (prevPolicy?.lockedOn ? 'on' : null);
|
||||
const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevNightVision, prevLightLockState };
|
||||
const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevHeadlights, prevLightLockState };
|
||||
await startDarkness(ctx, effect);
|
||||
ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 120 seconds.' });
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ const roverManager = require('../roverManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||
const { isDeterred } = require('../verificationService');
|
||||
const logger = require('../../globals/logger').child('commandService');
|
||||
const { isNightVisionBlocked } = require('../../rewards/definitions/darkness');
|
||||
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
||||
|
||||
const pendingCommands = new Map(); // id -> { roverId }
|
||||
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||
@@ -132,7 +132,7 @@ function shouldRecordTurnActivity(type, payload = {}) {
|
||||
|
||||
/*
|
||||
Other accepted commands are left as activity because they are tied to an
|
||||
explicit user control: servo moves, night vision toggles, raw OI commands,
|
||||
explicit user control: servo moves, headlight/laser toggles, raw OI commands,
|
||||
songs, reboot/update admin actions, and similar commands.
|
||||
*/
|
||||
return true;
|
||||
@@ -158,8 +158,8 @@ io.on('connection', (socket) => {
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
if (type === 'nightVision' && isNightVisionBlocked()) {
|
||||
logger.info('Ignoring night vision command while darkness lock is active', { socketId: socket.id, roverId });
|
||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||
reply({ ignored: true, reason: 'darknessActive' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const homeAssistantService = require('../homeAssistantService');
|
||||
const neatoService = require('../neatoService');
|
||||
const liftService = require('../liftService');
|
||||
const {
|
||||
NIGHT_VISION_DISABLE_ACTION,
|
||||
HEADLIGHT_DISABLE_ACTION,
|
||||
DOCK_COMMAND_BASE64,
|
||||
} = require('./constants');
|
||||
|
||||
@@ -60,7 +60,7 @@ async function dockAllRovers() {
|
||||
return { action: 'dockAllRovers', attempted, failed };
|
||||
}
|
||||
|
||||
async function disableAllRoverNightVision() {
|
||||
async function disableAllRoverHeadlights() {
|
||||
const attempted = [];
|
||||
const failed = [];
|
||||
roverManager.rovers.forEach((record) => {
|
||||
@@ -68,15 +68,15 @@ async function disableAllRoverNightVision() {
|
||||
const roverId = String(record.id);
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'nightVision',
|
||||
nightVision: { action: NIGHT_VISION_DISABLE_ACTION },
|
||||
type: 'headlight',
|
||||
headlight: { action: HEADLIGHT_DISABLE_ACTION },
|
||||
});
|
||||
attempted.push(roverId);
|
||||
} catch (err) {
|
||||
failed.push({ roverId, error: err.message });
|
||||
}
|
||||
});
|
||||
return { action: 'disableRoverNightVision', attempted, failed };
|
||||
return { action: 'disableRoverHeadlights', attempted, failed };
|
||||
}
|
||||
|
||||
async function sendNeatoHome() {
|
||||
@@ -100,7 +100,7 @@ async function raiseLift() {
|
||||
const idleActions = [
|
||||
turnOffRoomControls,
|
||||
// dockAllRovers,
|
||||
disableAllRoverNightVision,
|
||||
disableAllRoverHeadlights,
|
||||
sendNeatoHome,
|
||||
raiseLift,
|
||||
];
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// Purpose: Defines idle timing and command constants used by idle automation workflows.
|
||||
// Scope: Centralizes immutable configuration for trigger windows and rover command payloads.
|
||||
const IDLE_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const NIGHT_VISION_DISABLE_ACTION = 'on';
|
||||
const HEADLIGHT_DISABLE_ACTION = 'off';
|
||||
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
|
||||
|
||||
module.exports = {
|
||||
IDLE_TIMEOUT_MS,
|
||||
NIGHT_VISION_DISABLE_ACTION,
|
||||
HEADLIGHT_DISABLE_ACTION,
|
||||
DOCK_COMMAND_BASE64,
|
||||
};
|
||||
|
||||
@@ -23,6 +23,24 @@ function coerceBool(value) {
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 15000;
|
||||
|
||||
function handleToggleEvent(roverId, msg) {
|
||||
if (msg.event === 'headlight.state') {
|
||||
const headlightOn = coerceBool(msg.data?.headlightOn);
|
||||
if (headlightOn != null) {
|
||||
roverManager.setToggleState(roverId, 'headlight', headlightOn);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (msg.event === 'laser.state') {
|
||||
const laserOn = coerceBool(msg.data?.laserOn);
|
||||
if (laserOn != null) {
|
||||
roverManager.setToggleState(roverId, 'laser', laserOn);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleMessage(roverId, msg) {
|
||||
switch (msg.type) {
|
||||
case 'hello':
|
||||
@@ -38,9 +56,7 @@ function handleMessage(roverId, msg) {
|
||||
roverManager.handleHostStats(roverId, msg);
|
||||
break;
|
||||
case 'event': {
|
||||
const nightVisionOn = coerceBool(msg.data?.nightVisionOn);
|
||||
if (msg.event === 'nightVision.state' && nightVisionOn != null) {
|
||||
roverManager.setNightVisionState(roverId, nightVisionOn);
|
||||
if (handleToggleEvent(roverId, msg)) {
|
||||
break;
|
||||
}
|
||||
sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event });
|
||||
@@ -100,10 +116,7 @@ roverWSS.on('connection', (ws) => {
|
||||
} else if (msg.type === 'ack') {
|
||||
handleAck(msg);
|
||||
} else if (msg.type === 'event') {
|
||||
const nightVisionOn = coerceBool(msg.data?.nightVisionOn);
|
||||
if (msg.event === 'nightVision.state' && nightVisionOn != null) {
|
||||
roverManager.setNightVisionState(roverId, nightVisionOn);
|
||||
} else {
|
||||
if (!handleToggleEvent(roverId, msg)) {
|
||||
sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ const {
|
||||
getRoster,
|
||||
getRosterForSocket,
|
||||
broadcastRoster,
|
||||
setNightVisionState,
|
||||
setToggleState,
|
||||
handleHostStats,
|
||||
canSeeRover,
|
||||
canRequestControl,
|
||||
@@ -261,7 +261,7 @@ module.exports = {
|
||||
getRoster,
|
||||
getRosterForSocket,
|
||||
broadcastRoster,
|
||||
setNightVisionState,
|
||||
setToggleState,
|
||||
handleHostStats,
|
||||
handleSensorFrame,
|
||||
requestControl,
|
||||
|
||||
@@ -40,7 +40,8 @@ function createRosterLifecycle(deps) {
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
batteryState: null,
|
||||
nightVisionState: null,
|
||||
headlightState: null,
|
||||
laserState: null,
|
||||
room: `rover:${id}`,
|
||||
lastSeen: Date.now(),
|
||||
lastMovementAt: Date.now(),
|
||||
@@ -73,10 +74,19 @@ function createRosterLifecycle(deps) {
|
||||
} else {
|
||||
record.privateOpen = true;
|
||||
}
|
||||
if (record.nightVisionState == null && meta?.nightVision?.enabled) {
|
||||
const ledOn = Boolean(meta.nightVision.initialOn);
|
||||
record.nightVisionState = {
|
||||
nightVisionOn: !ledOn,
|
||||
if (!meta?.headlight?.enabled) {
|
||||
record.headlightState = null;
|
||||
} else if (record.headlightState == null) {
|
||||
record.headlightState = {
|
||||
headlightOn: Boolean(meta.headlight.initialOn),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
if (!meta?.laser?.enabled) {
|
||||
record.laserState = null;
|
||||
} else if (record.laserState == null) {
|
||||
record.laserState = {
|
||||
laserOn: Boolean(meta.laser.initialOn),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -221,9 +231,12 @@ function createRosterLifecycle(deps) {
|
||||
cameraServo: record.meta?.cameraServo,
|
||||
audio: record.meta?.audio,
|
||||
horn: record.meta?.horn,
|
||||
nightVision: record.meta?.nightVision
|
||||
? { ...record.meta.nightVision, state: record.nightVisionState }
|
||||
: record.meta?.nightVision,
|
||||
headlight: record.meta?.headlight
|
||||
? { ...record.meta.headlight, state: record.headlightState }
|
||||
: record.meta?.headlight,
|
||||
laser: record.meta?.laser
|
||||
? { ...record.meta.laser, state: record.laserState }
|
||||
: record.meta?.laser,
|
||||
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
|
||||
lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
|
||||
lastSeen: record.lastSeen,
|
||||
@@ -258,16 +271,19 @@ function createRosterLifecycle(deps) {
|
||||
});
|
||||
}
|
||||
|
||||
function setNightVisionState(roverId, nightVisionOn) {
|
||||
function setToggleState(roverId, device, on) {
|
||||
const record = rovers.get(roverId);
|
||||
if (!record) return;
|
||||
if (typeof nightVisionOn !== 'boolean') return;
|
||||
record.nightVisionState = {
|
||||
nightVisionOn,
|
||||
if (typeof on !== 'boolean') return;
|
||||
if (device !== 'headlight' && device !== 'laser') return;
|
||||
const stateKey = device === 'headlight' ? 'headlightState' : 'laserState';
|
||||
const onKey = `${device}On`;
|
||||
record[stateKey] = {
|
||||
[onKey]: on,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
broadcastRoster();
|
||||
managerEvents.emit('rover', { roverId, action: 'nightVision', record });
|
||||
managerEvents.emit('rover', { roverId, action: device, record });
|
||||
}
|
||||
|
||||
function handleHostStats(roverId, msg = {}) {
|
||||
@@ -316,7 +332,7 @@ function createRosterLifecycle(deps) {
|
||||
getRosterForSocket,
|
||||
syncSpectatorRooms,
|
||||
broadcastRoster,
|
||||
setNightVisionState,
|
||||
setToggleState,
|
||||
handleHostStats,
|
||||
canSeeRover,
|
||||
canRequestControl,
|
||||
|
||||
@@ -10,7 +10,7 @@ const serverTimezone = config.timezone || null;
|
||||
const configuredSocials = Array.isArray(config.socials) ? config.socials : null;
|
||||
|
||||
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
|
||||
const NIGHT_VISION_SYNC_COOLDOWN_MS = 1000;
|
||||
const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000;
|
||||
const PERIODIC_SYNC_MS = 20000;
|
||||
|
||||
module.exports = {
|
||||
@@ -19,6 +19,6 @@ module.exports = {
|
||||
serverTimezone,
|
||||
configuredSocials,
|
||||
ACTIVITY_SYNC_COOLDOWN_MS,
|
||||
NIGHT_VISION_SYNC_COOLDOWN_MS,
|
||||
GPIO_TOGGLE_SYNC_COOLDOWN_MS,
|
||||
PERIODIC_SYNC_MS,
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ const {
|
||||
serverTimezone,
|
||||
configuredSocials,
|
||||
ACTIVITY_SYNC_COOLDOWN_MS,
|
||||
NIGHT_VISION_SYNC_COOLDOWN_MS,
|
||||
GPIO_TOGGLE_SYNC_COOLDOWN_MS,
|
||||
PERIODIC_SYNC_MS,
|
||||
} = require('./constants');
|
||||
const { getState, setState } = require('./state');
|
||||
@@ -175,29 +175,29 @@ modeEvents.on('change', () => {
|
||||
|
||||
managerEvents.on('rover', (event = {}) => {
|
||||
const state = getState();
|
||||
if (event.action === 'nightVision') {
|
||||
if (event.action === 'headlight' || event.action === 'laser') {
|
||||
const now = Date.now();
|
||||
const elapsed = now - state.lastNightVisionSync;
|
||||
if (elapsed >= NIGHT_VISION_SYNC_COOLDOWN_MS) {
|
||||
setState({ lastNightVisionSync: now });
|
||||
logger.info('Night vision update; syncing all clients (immediate)');
|
||||
const elapsed = now - state.lastGPIOToggleSync;
|
||||
if (elapsed >= GPIO_TOGGLE_SYNC_COOLDOWN_MS) {
|
||||
setState({ lastGPIOToggleSync: now });
|
||||
logger.info('GPIO toggle update; syncing all clients (immediate)');
|
||||
syncAll();
|
||||
return;
|
||||
}
|
||||
if (!state.pendingNightVisionSync) {
|
||||
const delay = NIGHT_VISION_SYNC_COOLDOWN_MS - elapsed;
|
||||
if (!state.pendingGPIOToggleSync) {
|
||||
const delay = GPIO_TOGGLE_SYNC_COOLDOWN_MS - elapsed;
|
||||
const timer = setTimeout(() => {
|
||||
setState({ lastNightVisionSync: Date.now(), pendingNightVisionSync: null });
|
||||
logger.info('Night vision update; syncing all clients (delayed)');
|
||||
setState({ lastGPIOToggleSync: Date.now(), pendingGPIOToggleSync: null });
|
||||
logger.info('GPIO toggle update; syncing all clients (delayed)');
|
||||
syncAll();
|
||||
}, delay);
|
||||
setState({ pendingNightVisionSync: timer });
|
||||
setState({ pendingGPIOToggleSync: timer });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.pendingNightVisionSync) {
|
||||
clearTimeout(state.pendingNightVisionSync);
|
||||
setState({ pendingNightVisionSync: null });
|
||||
if (state.pendingGPIOToggleSync) {
|
||||
clearTimeout(state.pendingGPIOToggleSync);
|
||||
setState({ pendingGPIOToggleSync: null });
|
||||
}
|
||||
logger.info('Rover roster change; syncing all clients');
|
||||
syncAll();
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
// Scope: Keeps runtime behavior unchanged while centralizing mutable session-sync state in one module.
|
||||
let lastActivitySync = 0;
|
||||
let pendingActivitySync = null;
|
||||
let lastNightVisionSync = 0;
|
||||
let pendingNightVisionSync = null;
|
||||
let lastGPIOToggleSync = 0;
|
||||
let pendingGPIOToggleSync = null;
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
lastActivitySync,
|
||||
pendingActivitySync,
|
||||
lastNightVisionSync,
|
||||
pendingNightVisionSync,
|
||||
lastGPIOToggleSync,
|
||||
pendingGPIOToggleSync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ function setState(patch = {}) {
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'pendingActivitySync')) {
|
||||
pendingActivitySync = patch.pendingActivitySync;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'lastNightVisionSync')) {
|
||||
lastNightVisionSync = patch.lastNightVisionSync;
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'lastGPIOToggleSync')) {
|
||||
lastGPIOToggleSync = patch.lastGPIOToggleSync;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'pendingNightVisionSync')) {
|
||||
pendingNightVisionSync = patch.pendingNightVisionSync;
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'pendingGPIOToggleSync')) {
|
||||
pendingGPIOToggleSync = patch.pendingGPIOToggleSync;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-12
@@ -1,14 +1,15 @@
|
||||
// Night Vision Control
|
||||
// Purpose: Defines the Night Vision Control module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
// GPIO Toggle Control
|
||||
// Purpose: Renders a direct press target for rover GPIO-backed toggles such as the headlight and laser.
|
||||
// Scope: Owns optimistic button state and touch/click de-duplication while callers provide device labels and actions.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
function isBoolean(value) {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
export default function NightVisionControl({
|
||||
nightVisionOn,
|
||||
export default function GPIOToggleControl({
|
||||
label,
|
||||
on,
|
||||
disabled,
|
||||
onToggle,
|
||||
keyLabel,
|
||||
@@ -16,16 +17,21 @@ export default function NightVisionControl({
|
||||
heightClass = '',
|
||||
}) {
|
||||
const [optimistic, setOptimistic] = useState(
|
||||
isBoolean(nightVisionOn) ? nightVisionOn : null,
|
||||
isBoolean(on) ? on : null,
|
||||
);
|
||||
const suppressClickRef = useRef(false);
|
||||
const suppressClickTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBoolean(nightVisionOn)) {
|
||||
setOptimistic(nightVisionOn);
|
||||
if (isBoolean(on)) {
|
||||
// Server-confirmed state can arrive after an optimistic click. Defer the
|
||||
// reconciliation one tick so this effect stays a synchronization point
|
||||
// instead of triggering React's synchronous set-state-in-effect lint rule.
|
||||
const timer = window.setTimeout(() => setOptimistic(on), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [nightVisionOn]);
|
||||
return undefined;
|
||||
}, [on]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -58,7 +64,7 @@ export default function NightVisionControl({
|
||||
Mobile browsers, especially Safari, do not always dispatch a reliable
|
||||
synthetic click for a second finger while another finger is held on the
|
||||
drive pad. Toggle on the real touch pointerdown instead, then suppress the
|
||||
follow-up click so one tap cannot flip night vision twice.
|
||||
follow-up click so one tap cannot flip the GPIO-backed device twice.
|
||||
*/
|
||||
event.preventDefault();
|
||||
suppressClickRef.current = true;
|
||||
@@ -91,7 +97,7 @@ export default function NightVisionControl({
|
||||
};
|
||||
|
||||
const buttonClasses = useMemo(() => {
|
||||
// Night vision is used as a direct mobile press target, so selection and
|
||||
// This control is used as a direct mobile press target, so selection and
|
||||
// Safari callout suppression live on the button itself rather than only on
|
||||
// the surrounding mobile column.
|
||||
const base =
|
||||
@@ -114,7 +120,7 @@ export default function NightVisionControl({
|
||||
className={buttonClasses}
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="text-sm font-semibold">Night Vision</span>
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
{keyLabel ? (
|
||||
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
|
||||
{keyLabel}
|
||||
@@ -38,7 +38,8 @@ export const ACTIONS = [
|
||||
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
|
||||
{ id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' },
|
||||
{ id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' },
|
||||
{ id: 'nightVisionToggle', label: 'Night vision toggle', kind: 'button', section: 'Camera' },
|
||||
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
|
||||
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
|
||||
];
|
||||
|
||||
export const CAPTURE_AXIS_THRESHOLD = 0.45;
|
||||
|
||||
@@ -26,7 +26,8 @@ const KEY_ACTIONS = [
|
||||
{ id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' },
|
||||
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
|
||||
{ id: 'cameraDown', label: 'Camera Down', group: 'Camera' },
|
||||
{ id: 'nightVisionToggle', label: 'Toggle Night Vision', group: 'Camera' },
|
||||
{ id: 'headlightToggle', label: 'Toggle Headlight', group: 'Camera' },
|
||||
{ id: 'laserToggle', label: 'Toggle Laser', group: 'Camera' },
|
||||
{ id: 'videoFilterCycle', label: 'Cycle Video Filter', group: 'Camera' },
|
||||
{ id: 'hornHonk', label: 'Horn (Hold)', group: 'Audio' },
|
||||
{ id: 'micPtt', label: 'Mic Push To Talk', group: 'Audio' },
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Aux Column
|
||||
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
|
||||
// Scope: Owns mobile aux/camera/night vision/horn wiring while reusing desktop variation components where intended.
|
||||
// Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended.
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import HornControl from '../HornControl/index.jsx';
|
||||
import NightVisionControl from '../NightVisionControl/index.jsx';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import { AUX_ZERO } from './constants.js';
|
||||
import VacuumControls from './VacuumControls.jsx';
|
||||
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
||||
@@ -15,16 +15,19 @@ function AuxColumnContent() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
const horn = useControlSelector((control) => control.state.horn);
|
||||
const nightVision = useControlSelector((control) => control.pipeline?.nightVision);
|
||||
const nightVisionState = useControlSelector((control) => control.pipeline?.nightVisionState);
|
||||
const headlight = useControlSelector((control) => control.pipeline?.headlight);
|
||||
const headlightState = useControlSelector((control) => control.pipeline?.headlightState);
|
||||
const laser = useControlSelector((control) => control.pipeline?.laser);
|
||||
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||
const { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn } = useControlActions();
|
||||
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const disabled = !roverId;
|
||||
const activeAuxButtonRef = useRef(null);
|
||||
const cameraConfig = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||
const nightVisionAvailable = Boolean(roverId && nightVision);
|
||||
const headlightAvailable = Boolean(roverId && headlight);
|
||||
const laserAvailable = Boolean(roverId && laser);
|
||||
const hornAvailable = Boolean(roverId && pipelineHorn);
|
||||
const hornBlocked = horn?.overheated;
|
||||
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
|
||||
@@ -37,13 +40,22 @@ function AuxColumnContent() {
|
||||
: (cameraMin + cameraMax) / 2;
|
||||
const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
|
||||
|
||||
const handleNightVisionToggle = useCallback(
|
||||
const handleHeadlightToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!nightVisionAvailable) return;
|
||||
trackAnalyticsEvent('night_vision_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
|
||||
setNightVision(nextOn);
|
||||
if (!headlightAvailable) return;
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
|
||||
setHeadlight(nextOn);
|
||||
},
|
||||
[nightVisionAvailable, roverId, setNightVision],
|
||||
[headlightAvailable, roverId, setHeadlight],
|
||||
);
|
||||
|
||||
const handleLaserToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!laserAvailable) return;
|
||||
trackAnalyticsEvent('laser_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
|
||||
setLaser(nextOn);
|
||||
},
|
||||
[laserAvailable, roverId, setLaser],
|
||||
);
|
||||
|
||||
const handleHornStart = useCallback(() => {
|
||||
@@ -93,14 +105,28 @@ function AuxColumnContent() {
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{nightVisionAvailable ? (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
{(headlightAvailable || laserAvailable) ? (
|
||||
<div className="mobile-touch-control flex min-h-0 flex-1 flex-col gap-0.5">
|
||||
{headlightAvailable ? (
|
||||
<GPIOToggleControl
|
||||
label="Headlight"
|
||||
on={headlightState?.headlightOn}
|
||||
disabled={disabled}
|
||||
onToggle={handleNightVisionToggle}
|
||||
onToggle={handleHeadlightToggle}
|
||||
heightClass="h-full"
|
||||
/>
|
||||
) : null}
|
||||
{laserAvailable ? (
|
||||
<GPIOToggleControl
|
||||
label="Laser"
|
||||
on={laserState?.laserOn}
|
||||
disabled={disabled}
|
||||
onToggle={handleLaserToggle}
|
||||
heightClass="h-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mobile-touch-control min-h-0">
|
||||
{hornAvailable ? (
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||
import RawUserPilePanel from '../RawUserPilePanel/index.jsx';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import NightVisionControl from '../NightVisionControl/index.jsx';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import HornControl from '../HornControl/index.jsx';
|
||||
import CameraTiltControl from '../CameraTiltControl/index.jsx';
|
||||
import VipPanel from '../VipPanel/index.jsx';
|
||||
@@ -62,17 +62,20 @@ function DriveDockPanel() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
const horn = useControlSelector((control) => control.state.horn);
|
||||
const nightVision = useControlSelector((control) => control.pipeline?.nightVision);
|
||||
const nightVisionState = useControlSelector((control) => control.pipeline?.nightVisionState);
|
||||
const headlight = useControlSelector((control) => control.pipeline?.headlight);
|
||||
const headlightState = useControlSelector((control) => control.pipeline?.headlightState);
|
||||
const laser = useControlSelector((control) => control.pipeline?.laser);
|
||||
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||
const { setServoAngle, setNightVision, startHorn, stopHorn } = useControlActions();
|
||||
const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||
|
||||
const config = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && config);
|
||||
const nightVisionAvailable = Boolean(roverId && nightVision);
|
||||
const headlightAvailable = Boolean(roverId && headlight);
|
||||
const laserAvailable = Boolean(roverId && laser);
|
||||
const hornAvailable = Boolean(roverId && pipelineHorn);
|
||||
const hornBlocked = horn?.overheated;
|
||||
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
|
||||
@@ -83,16 +86,21 @@ function DriveDockPanel() {
|
||||
: typeof config?.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: (min + max) / 2;
|
||||
const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
|
||||
const headlightLabel = formatKeyLabel(keymap?.headlightToggle?.[0]);
|
||||
const laserLabel = formatKeyLabel(keymap?.laserToggle?.[0]);
|
||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
||||
const trackedControls = useMemo(
|
||||
() => ({
|
||||
setNightVision: (nextOn) => {
|
||||
trackAnalyticsEvent('night_vision_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
|
||||
setNightVision(nextOn);
|
||||
setHeadlight: (nextOn) => {
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
|
||||
setHeadlight(nextOn);
|
||||
},
|
||||
setLaser: (nextOn) => {
|
||||
trackAnalyticsEvent('laser_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
|
||||
setLaser(nextOn);
|
||||
},
|
||||
startHorn: () => {
|
||||
trackAnalyticsEvent('horn_start', { roverId, source: 'desktop_control' });
|
||||
@@ -100,7 +108,7 @@ function DriveDockPanel() {
|
||||
},
|
||||
stopHorn,
|
||||
}),
|
||||
[roverId, setNightVision, startHorn, stopHorn],
|
||||
[roverId, setHeadlight, setLaser, startHorn, stopHorn],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -113,14 +121,28 @@ function DriveDockPanel() {
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
{!hideInlineControls ? (
|
||||
<div className={`${themeStackClass} p-0 text-sm text-slate-200`}>
|
||||
{nightVisionAvailable && (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
{(headlightAvailable || laserAvailable) && (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{headlightAvailable && (
|
||||
<GPIOToggleControl
|
||||
label="Headlight"
|
||||
on={headlightState?.headlightOn}
|
||||
disabled={!roverId}
|
||||
onToggle={trackedControls.setNightVision}
|
||||
keyLabel={nightVisionLabel}
|
||||
onToggle={trackedControls.setHeadlight}
|
||||
keyLabel={headlightLabel}
|
||||
/>
|
||||
)}
|
||||
{laserAvailable && (
|
||||
<GPIOToggleControl
|
||||
label="Laser"
|
||||
on={laserState?.laserOn}
|
||||
disabled={!roverId}
|
||||
onToggle={trackedControls.setLaser}
|
||||
keyLabel={laserLabel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
|
||||
@@ -53,8 +53,10 @@ const CONTROL_ACTION_NAMES = [
|
||||
'stopAllMotion',
|
||||
'sendOiCommand',
|
||||
'setSensorStream',
|
||||
'setNightVision',
|
||||
'toggleNightVision',
|
||||
'setHeadlight',
|
||||
'toggleHeadlight',
|
||||
'setLaser',
|
||||
'toggleLaser',
|
||||
'updateKeyBinding',
|
||||
'resetKeyBindings',
|
||||
'registerInputState',
|
||||
@@ -469,28 +471,38 @@ export function ControlSystemProvider({ children }) {
|
||||
[pipeline],
|
||||
);
|
||||
|
||||
const setNightVision = useCallback(
|
||||
(nightVisionOn) => {
|
||||
if (!pipeline.nightVision) return;
|
||||
if (typeof nightVisionOn === 'boolean') {
|
||||
/*
|
||||
Rover daemon command names describe the IR LED, while the UI state
|
||||
describes camera visibility. LED "off" means nightVisionOn=true, and
|
||||
LED "on" means nightVisionOn=false.
|
||||
*/
|
||||
const action = nightVisionOn ? 'off' : 'on';
|
||||
pipeline.sendNightVision(action);
|
||||
} else {
|
||||
pipeline.sendNightVision('toggle');
|
||||
}
|
||||
const setHeadlight = useCallback(
|
||||
(headlightOn) => {
|
||||
if (!pipeline.headlight) return;
|
||||
// Web controls now speak in logical device state. Any electrical
|
||||
// inversion needed by the actual GPIO driver is handled by roverd's
|
||||
// activeLow config, so this command stays readable and direct.
|
||||
const action = typeof headlightOn === 'boolean' ? (headlightOn ? 'on' : 'off') : 'toggle';
|
||||
pipeline.sendHeadlight(action);
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, recordControlIntent],
|
||||
);
|
||||
|
||||
const toggleNightVision = useCallback(() => {
|
||||
setNightVision();
|
||||
}, [setNightVision]);
|
||||
const toggleHeadlight = useCallback(() => {
|
||||
setHeadlight();
|
||||
}, [setHeadlight]);
|
||||
|
||||
const setLaser = useCallback(
|
||||
(laserOn) => {
|
||||
if (!pipeline.laser) return;
|
||||
// The laser shares the same logical toggle contract as the headlight; it
|
||||
// is separate only because it has its own GPIO pin, UI control, and keybind.
|
||||
const action = typeof laserOn === 'boolean' ? (laserOn ? 'on' : 'off') : 'toggle';
|
||||
pipeline.sendLaser(action);
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, recordControlIntent],
|
||||
);
|
||||
|
||||
const toggleLaser = useCallback(() => {
|
||||
setLaser();
|
||||
}, [setLaser]);
|
||||
|
||||
const setSongNote = useCallback(
|
||||
(note) => {
|
||||
@@ -665,8 +677,10 @@ export function ControlSystemProvider({ children }) {
|
||||
stopAllMotion,
|
||||
sendOiCommand,
|
||||
setSensorStream,
|
||||
setNightVision,
|
||||
toggleNightVision,
|
||||
setHeadlight,
|
||||
toggleHeadlight,
|
||||
setLaser,
|
||||
toggleLaser,
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
@@ -689,8 +703,10 @@ export function ControlSystemProvider({ children }) {
|
||||
stopAllMotion,
|
||||
sendOiCommand,
|
||||
setSensorStream,
|
||||
setNightVision,
|
||||
toggleNightVision,
|
||||
setHeadlight,
|
||||
toggleHeadlight,
|
||||
setLaser,
|
||||
toggleLaser,
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
|
||||
@@ -29,9 +29,14 @@ export function useCommandPipeline(options = {}) {
|
||||
return rosterEntry.cameraServo;
|
||||
}, [rosterEntry]);
|
||||
|
||||
const nightVision = useMemo(() => {
|
||||
if (!rosterEntry?.nightVision || !rosterEntry.nightVision.enabled) return null;
|
||||
return rosterEntry.nightVision;
|
||||
const headlight = useMemo(() => {
|
||||
if (!rosterEntry?.headlight || !rosterEntry.headlight.enabled) return null;
|
||||
return rosterEntry.headlight;
|
||||
}, [rosterEntry]);
|
||||
|
||||
const laser = useMemo(() => {
|
||||
if (!rosterEntry?.laser || !rosterEntry.laser.enabled) return null;
|
||||
return rosterEntry.laser;
|
||||
}, [rosterEntry]);
|
||||
|
||||
const horn = useMemo(() => {
|
||||
@@ -39,7 +44,8 @@ export function useCommandPipeline(options = {}) {
|
||||
return rosterEntry.horn;
|
||||
}, [rosterEntry]);
|
||||
|
||||
const nightVisionState = useMemo(() => rosterEntry?.nightVision?.state ?? null, [rosterEntry]);
|
||||
const headlightState = useMemo(() => rosterEntry?.headlight?.state ?? null, [rosterEntry]);
|
||||
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
|
||||
|
||||
const emitCommand = useCallback(
|
||||
(payload, cb) => {
|
||||
@@ -167,16 +173,28 @@ export function useCommandPipeline(options = {}) {
|
||||
[roverId, sendOiCommand, sendDriveDirect, sendAuxMotors, sendServoAngle],
|
||||
);
|
||||
|
||||
const sendNightVision = useCallback(
|
||||
const sendHeadlight = useCallback(
|
||||
(action = 'toggle') => {
|
||||
if (!roverId || !nightVision) return null;
|
||||
if (!roverId || !headlight) return null;
|
||||
emitCommand({
|
||||
type: 'nightVision',
|
||||
data: { nightVision: { action } },
|
||||
type: 'headlight',
|
||||
data: { headlight: { action } },
|
||||
});
|
||||
return action;
|
||||
},
|
||||
[emitCommand, nightVision, roverId],
|
||||
[emitCommand, headlight, roverId],
|
||||
);
|
||||
|
||||
const sendLaser = useCallback(
|
||||
(action = 'toggle') => {
|
||||
if (!roverId || !laser) return null;
|
||||
emitCommand({
|
||||
type: 'laser',
|
||||
data: { laser: { action } },
|
||||
});
|
||||
return action;
|
||||
},
|
||||
[emitCommand, laser, roverId],
|
||||
);
|
||||
|
||||
const sendHorn = useCallback(
|
||||
@@ -222,8 +240,10 @@ export function useCommandPipeline(options = {}) {
|
||||
roverId,
|
||||
rosterEntry,
|
||||
servoConfig,
|
||||
nightVision,
|
||||
nightVisionState,
|
||||
headlight,
|
||||
headlightState,
|
||||
laser,
|
||||
laserState,
|
||||
horn,
|
||||
emitCommand,
|
||||
enableSensorStream,
|
||||
@@ -231,7 +251,8 @@ export function useCommandPipeline(options = {}) {
|
||||
sendAuxMotors,
|
||||
sendServoAngle,
|
||||
sendOiCommand,
|
||||
sendNightVision,
|
||||
sendHeadlight,
|
||||
sendLaser,
|
||||
sendHorn,
|
||||
sendSong,
|
||||
runMacroSteps,
|
||||
@@ -240,8 +261,10 @@ export function useCommandPipeline(options = {}) {
|
||||
roverId,
|
||||
rosterEntry,
|
||||
servoConfig,
|
||||
nightVision,
|
||||
nightVisionState,
|
||||
headlight,
|
||||
headlightState,
|
||||
laser,
|
||||
laserState,
|
||||
horn,
|
||||
emitCommand,
|
||||
enableSensorStream,
|
||||
@@ -249,7 +272,8 @@ export function useCommandPipeline(options = {}) {
|
||||
sendAuxMotors,
|
||||
sendServoAngle,
|
||||
sendOiCommand,
|
||||
sendNightVision,
|
||||
sendHeadlight,
|
||||
sendLaser,
|
||||
sendHorn,
|
||||
runMacroSteps,
|
||||
],
|
||||
|
||||
@@ -51,7 +51,8 @@ export const DEFAULT_KEYMAP = {
|
||||
auxAllForward: [","],
|
||||
cameraUp: ['u'],
|
||||
cameraDown: ['j'],
|
||||
nightVisionToggle: ['e'],
|
||||
headlightToggle: ['e'],
|
||||
laserToggle: ['r'],
|
||||
videoFilterCycle: ['2'],
|
||||
hornHonk: ['h'],
|
||||
micPtt: ['m'],
|
||||
|
||||
@@ -54,7 +54,8 @@ export default function GamepadInputManager() {
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
runMacro,
|
||||
toggleNightVision,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
registerInputState,
|
||||
} = useControlActions();
|
||||
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
||||
@@ -175,7 +176,8 @@ export default function GamepadInputManager() {
|
||||
setDriveVector,
|
||||
setMode,
|
||||
setServoAngle,
|
||||
toggleNightVision,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -286,11 +288,18 @@ export default function GamepadInputManager() {
|
||||
handleButtonEdge('dockMacro', false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.nightVisionToggle && handleButtonEdge('nightVisionToggle', true)) {
|
||||
trackAnalyticsEvent('night_vision_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.toggleNightVision();
|
||||
} else if (!outputs.buttons.nightVisionToggle) {
|
||||
handleButtonEdge('nightVisionToggle', false);
|
||||
if (outputs.buttons.headlightToggle && handleButtonEdge('headlightToggle', true)) {
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.toggleHeadlight();
|
||||
} else if (!outputs.buttons.headlightToggle) {
|
||||
handleButtonEdge('headlightToggle', false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.laserToggle && handleButtonEdge('laserToggle', true)) {
|
||||
trackAnalyticsEvent('laser_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.toggleLaser();
|
||||
} else if (!outputs.buttons.laserToggle) {
|
||||
handleButtonEdge('laserToggle', false);
|
||||
}
|
||||
|
||||
if (Math.abs(outputs.cameraAxis) > 0.001) {
|
||||
|
||||
@@ -91,7 +91,8 @@ export default function KeyboardInputManager() {
|
||||
runMacro,
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
toggleNightVision,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
@@ -376,7 +377,8 @@ export default function KeyboardInputManager() {
|
||||
startHorn,
|
||||
stopAllMotion,
|
||||
stopHorn,
|
||||
toggleNightVision,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
videoColorFilter,
|
||||
};
|
||||
});
|
||||
@@ -413,9 +415,12 @@ export default function KeyboardInputManager() {
|
||||
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
||||
trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.dockAssist.toggleAssist();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.nightVisionToggle?.has(token))) {
|
||||
trackAnalyticsEvent('night_vision_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.toggleNightVision();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.toggleHeadlight();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) {
|
||||
trackAnalyticsEvent('laser_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.toggleLaser();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.videoFilterCycle?.has(token))) {
|
||||
cycleVideoFilter();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.hornHonk?.has(token))) {
|
||||
|
||||
@@ -163,7 +163,8 @@ export function computeGamepadOutputs(padState, profile) {
|
||||
const sideReverseSource = resolveButtonSource(padState, bindings.sideReverse?.sources);
|
||||
const driveMacroSource = resolveButtonSource(padState, bindings.driveMacro?.sources);
|
||||
const dockMacroSource = resolveButtonSource(padState, bindings.dockMacro?.sources);
|
||||
const nightVisionSource = resolveButtonSource(padState, bindings.nightVisionToggle?.sources);
|
||||
const headlightSource = resolveButtonSource(padState, bindings.headlightToggle?.sources);
|
||||
const laserSource = resolveButtonSource(padState, bindings.laserToggle?.sources);
|
||||
|
||||
return {
|
||||
driveVector: { x: driveX, y: driveY, boost: false },
|
||||
@@ -176,7 +177,8 @@ export function computeGamepadOutputs(padState, profile) {
|
||||
sideReverse: sideReverseSource.pressed,
|
||||
driveMacro: driveMacroSource.pressed,
|
||||
dockMacro: dockMacroSource.pressed,
|
||||
nightVisionToggle: nightVisionSource.pressed,
|
||||
headlightToggle: headlightSource.pressed,
|
||||
laserToggle: laserSource.pressed,
|
||||
},
|
||||
sources: {
|
||||
drive: driveSource.source,
|
||||
@@ -189,7 +191,8 @@ export function computeGamepadOutputs(padState, profile) {
|
||||
sideReverse: sideReverseSource.source,
|
||||
driveMacro: driveMacroSource.source,
|
||||
dockMacro: dockMacroSource.source,
|
||||
nightVisionToggle: nightVisionSource.source,
|
||||
headlightToggle: headlightSource.source,
|
||||
laserToggle: laserSource.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@ export const HELP_CONTENT = {
|
||||
items: [
|
||||
{ action: 'cameraUp', label: 'Tilt up' },
|
||||
{ action: 'cameraDown', label: 'Tilt down' },
|
||||
{ action: 'nightVisionToggle', label: 'Toggle night vision' },
|
||||
{ action: 'headlightToggle', label: 'Toggle headlight' },
|
||||
{ action: 'laserToggle', label: 'Toggle laser' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -202,7 +203,8 @@ export const HELP_CONTENT = {
|
||||
// items: [
|
||||
// { action: 'driveMacro', label: 'Drive macro' },
|
||||
// { action: 'dockMacro', label: 'Dock macro' },
|
||||
// { action: 'nightVisionToggle', label: 'Toggle night vision' },
|
||||
// { action: 'headlightToggle', label: 'Toggle headlight' },
|
||||
// { action: 'laserToggle', label: 'Toggle laser' },
|
||||
// { action: 'chatFocus', label: 'Chat focus' },
|
||||
// ],
|
||||
// },
|
||||
|
||||
@@ -73,10 +73,14 @@ export const GAMEPAD_PROFILE_DEFAULT = {
|
||||
kind: 'button',
|
||||
sources: [{ kind: 'button', index: 3 }],
|
||||
},
|
||||
nightVisionToggle: {
|
||||
headlightToggle: {
|
||||
kind: 'button',
|
||||
sources: [{ kind: 'button', index: 9 }],
|
||||
},
|
||||
laserToggle: {
|
||||
kind: 'button',
|
||||
sources: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user