headlight rework and laser addition

This commit is contained in:
legop3
2026-06-27 15:57:47 -04:00
parent 3c1b1dab6e
commit 8e322ed57b
45 changed files with 596 additions and 373 deletions
+4 -2
View File
@@ -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---- | | ----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. | | 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. | | 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 | | 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. 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 ### 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: 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 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 1. ov5647 camera
2. full size to pi zero style camera ribbon cable, plug camera into pi 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. 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 ### Software on your rover
Raspberry pi OS installation: Raspberry pi OS installation:
+3 -1
View File
@@ -18,5 +18,7 @@ media:
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s healthInterval: 30s
nightVision: headlight:
enabled: false
laser:
enabled: false enabled: false
+3 -1
View File
@@ -18,5 +18,7 @@ media:
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s healthInterval: 30s
nightVision: headlight:
enabled: false
laser:
enabled: false enabled: false
+3 -1
View File
@@ -18,5 +18,7 @@ media:
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s healthInterval: 30s
nightVision: headlight:
enabled: false
laser:
enabled: false enabled: false
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -143,7 +143,7 @@ useEffect(() => {
setAuxMotors, setAuxMotors,
setDriveVector, setDriveVector,
setMode, setMode,
toggleNightVision, toggleHeadlight,
dockAssist, dockAssist,
]); ]);
``` ```
@@ -276,4 +276,3 @@ Expected improvements:
can fire. can fire.
- Verify horn hold, mic push-to-talk, chat focus, drive macro, dock assist, camera tilt, song - Verify horn hold, mic push-to-talk, chat focus, drive macro, dock assist, camera tilt, song
controls, and Home Assistant shortcuts. controls, and Home Assistant shortcuts.
+10
View File
@@ -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
+15 -6
View File
@@ -69,19 +69,28 @@ func main() {
defer cameraServo.Close() defer cameraServo.Close()
} }
var nightVision *roverd.NightVisionLight var headlight *roverd.GPIOToggle
if cfg.NightVision.Enabled { if cfg.Headlight.Enabled {
nightVision, err = roverd.NewNightVisionLight(cfg.NightVision, logger) headlight, err = roverd.NewGPIOToggle("headlight", cfg.Headlight, logger)
if err != nil { 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) 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, headlight, laser, logger)
retryDelay := time.Second retryDelay := time.Second
for ctx.Err() == nil { for ctx.Err() == nil {
+5 -3
View File
@@ -11,7 +11,8 @@ type helloMessage struct {
CameraServo CameraServoConfig `json:"cameraServo"` CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"` Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"` Horn HornConfig `json:"horn"`
NightVision NightVisionConfig `json:"nightVision"` Headlight GPIOToggleConfig `json:"headlight"`
Laser GPIOToggleConfig `json:"laser"`
Private PrivateConfig `json:"private"` Private PrivateConfig `json:"private"`
} }
@@ -42,7 +43,8 @@ type inboundMessage struct {
TTS *ttsPayload `json:"tts,omitempty"` TTS *ttsPayload `json:"tts,omitempty"`
Horn *hornPayload `json:"horn,omitempty"` Horn *hornPayload `json:"horn,omitempty"`
AudioLevels *audioLevelsPayload `json:"audioLevels,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"` Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"` Reboot *rebootPayload `json:"reboot,omitempty"`
// Update is intentionally just a marker payload. The server can request the // Update is intentionally just a marker payload. The server can request the
@@ -97,7 +99,7 @@ type audioLevelsPayload struct {
ForwardGain *float64 `json:"forwardGain,omitempty"` ForwardGain *float64 `json:"forwardGain,omitempty"`
} }
type nightVisionPayload struct { type togglePayload struct {
Action string `json:"action"` Action string `json:"action"`
} }
+30 -7
View File
@@ -109,11 +109,22 @@ type CameraServoConfig struct {
Invert bool `yaml:"invert" json:"invert"` Invert bool `yaml:"invert" json:"invert"`
} }
type NightVisionConfig struct { type GPIOToggleConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `yaml:"enabled" json:"enabled"`
GPIOPin int `yaml:"gpioPin" json:"gpioPin"` GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
GPIOChip string `yaml:"gpioChip" json:"gpioChip"` GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
InitialOn bool `yaml:"initialOn" json:"initialOn"` 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 { type AutoSideBrushConfig struct {
@@ -153,7 +164,8 @@ type Config struct {
CameraServo CameraServoConfig `yaml:"cameraServo"` CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"` Audio AudioConfig `yaml:"audio"`
Horn HornConfig `yaml:"horn"` 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"` AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
Private PrivateConfig `yaml:"private" json:"private"` Private PrivateConfig `yaml:"private" json:"private"`
} }
@@ -210,11 +222,19 @@ func LoadConfig(path string) (*Config, error) {
SawGain: 0.7, SawGain: 0.7,
MaxDuration: Duration{Duration: 10000 * time.Millisecond}, MaxDuration: Duration{Duration: 10000 * time.Millisecond},
}, },
NightVision: NightVisionConfig{ Headlight: GPIOToggleConfig{
Enabled: true, Enabled: true,
GPIOPin: 22, GPIOPin: 22,
GPIOChip: "gpiochip0", GPIOChip: "gpiochip0",
InitialOn: true, InitialOn: false,
ActiveLow: true,
},
Laser: GPIOToggleConfig{
Enabled: false,
GPIOPin: 27,
GPIOChip: "gpiochip0",
InitialOn: false,
ActiveLow: false,
}, },
AutoSideBrush: AutoSideBrushConfig{ AutoSideBrush: AutoSideBrushConfig{
Enabled: true, Enabled: true,
@@ -302,8 +322,11 @@ func LoadConfig(path string) (*Config, error) {
if err := validateServoConfig(&cfg.CameraServo); err != nil { if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err) return nil, fmt.Errorf("cameraServo: %w", err)
} }
if err := validateNightVisionConfig(&cfg.NightVision); err != nil { if err := validateGPIOToggleConfig(&cfg.Headlight); err != nil {
return nil, fmt.Errorf("nightVision: %w", err) return nil, fmt.Errorf("headlight: %w", err)
}
if err := validateGPIOToggleConfig(&cfg.Laser); err != nil {
return nil, fmt.Errorf("laser: %w", err)
} }
validateAudioConfig(&cfg.Audio) validateAudioConfig(&cfg.Audio)
validateHornConfig(&cfg.Horn) validateHornConfig(&cfg.Horn)
@@ -396,7 +419,7 @@ func validateHornConfig(cfg *HornConfig) {
} }
} }
func validateNightVisionConfig(cfg *NightVisionConfig) error { func validateGPIOToggleConfig(cfg *GPIOToggleConfig) error {
if !cfg.Enabled { if !cfg.Enabled {
return nil return nil
} }
+99
View File
@@ -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
}
+26
View File
@@ -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
}
-103
View File
@@ -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
}
-24
View File
@@ -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
}
+9 -2
View File
@@ -56,11 +56,18 @@ horn:
sineGain: 1.0 sineGain: 1.0
sawGain: 0.7 sawGain: 0.7
maxDuration: 1.2s maxDuration: 1.2s
nightVision: headlight:
enabled: true enabled: true
gpioPin: 22 gpioPin: 22
gpioChip: gpiochip0 gpioChip: gpiochip0
initialOn: true initialOn: false
activeLow: true
laser:
enabled: false
gpioPin: 27
gpioChip: gpiochip0
initialOn: false
activeLow: false
autoSideBrush: autoSideBrush:
enabled: true enabled: true
speed: 20 speed: 20
+12
View File
@@ -32,6 +32,18 @@ cameraServo:
homeAngle: 0 homeAngle: 0
nudgeDegrees: 2 nudgeDegrees: 2
allowRawPulse: false 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: autoSideBrush:
enabled: true enabled: true
speed: 20 speed: 20
+27 -15
View File
@@ -21,7 +21,8 @@ type WSClient struct {
media *MediaSupervisor media *MediaSupervisor
servo *CameraServo servo *CameraServo
horn *HornSynth horn *HornSynth
nightVision *NightVisionLight headlight *GPIOToggle
laser *GPIOToggle
log *log.Logger log *log.Logger
recoverMu sync.Mutex recoverMu sync.Mutex
recovering bool recovering bool
@@ -40,7 +41,7 @@ type WSClient struct {
audioMu sync.RWMutex 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 var ttsQueue chan *ttsPayload
if cfg.Audio.TTSEnabled { if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2) ttsQueue = make(chan *ttsPayload, 2)
@@ -61,7 +62,8 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
media: media, media: media,
servo: servo, servo: servo,
horn: horn, horn: horn,
nightVision: nightVision, headlight: headlight,
laser: laser,
log: logger, log: logger,
ttsQueue: ttsQueue, ttsQueue: ttsQueue,
chromeTTS: chromeTTS, chromeTTS: chromeTTS,
@@ -133,7 +135,8 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
CameraServo: c.cfg.CameraServo, CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio, Audio: c.cfg.Audio,
Horn: c.cfg.Horn, Horn: c.cfg.Horn,
NightVision: c.cfg.NightVision, Headlight: c.cfg.Headlight,
Laser: c.cfg.Laser,
Private: c.cfg.Private, Private: c.cfg.Private,
} }
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin) 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) return c.horn.HandlePayload(msg.Horn)
case msg.AudioLevels != nil: case msg.AudioLevels != nil:
return c.handleAudioLevels(msg.AudioLevels) return c.handleAudioLevels(msg.AudioLevels)
case msg.NightVision != nil: case msg.Headlight != nil:
if c.nightVision == nil { return c.handleToggleCommand("headlight", c.headlight, msg.Headlight)
return fmt.Errorf("night vision disabled") case msg.Laser != nil:
} return c.handleToggleCommand("laser", c.laser, msg.Laser)
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.Song != nil: case msg.Song != nil:
slot := 0 slot := 0
if msg.Song.Slot != nil { 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 { func (c *WSClient) stopMotionForSystemCommand(reason string) error {
// System-level commands can restart the process or the whole Pi. Stopping // System-level commands can restart the process or the whole Pi. Stopping
// both wheel and auxiliary motors first leaves the Roomba in a predictable // both wheel and auxiliary motors first leaves the Roomba in a predictable
+1 -1
View File
@@ -4,6 +4,6 @@
- idle service will trigger, after 2 minutes of no drivers: - idle service will trigger, after 2 minutes of no drivers:
- all room lights (room controls) off - all room lights (room controls) off
- tell all rovers to dock - tell all rovers to dock
- turn off all rover night vision lights - turn off all rover headlights
- tell the neato to return to home - tell the neato to return to home
- the idle service should be easily expandable to add more things in the future - 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
+1 -1
View File
@@ -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/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> <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> <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"> <link rel="stylesheet" crossorigin href="/assets/index-CKlOAshP.css">
</head> </head>
<body> <body>
+20 -26
View File
@@ -2,19 +2,13 @@
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution. // 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 DURATION_MS = 15 * 60 * 1000;
const LIGHT_ENFORCE_TICK_MS = 3000; 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 activeTimer = null;
let enforceLightsTimer = null; let enforceLightsTimer = null;
let nightVisionLockUntil = 0; let headlightLockUntil = 0;
function isNightVisionBlocked() { function isHeadlightBlocked() {
return Date.now() < nightVisionLockUntil; return Date.now() < headlightLockUntil;
} }
function clearTimers() { function clearTimers() {
@@ -43,7 +37,7 @@ async function forceAllLightsOff(ctx) {
async function stopDarkness(ctx, effect = {}) { async function stopDarkness(ctx, effect = {}) {
clearTimers(); clearTimers();
nightVisionLockUntil = 0; headlightLockUntil = 0;
const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : []; const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : [];
await Promise.all( await Promise.all(
@@ -73,17 +67,17 @@ async function stopDarkness(ctx, effect = {}) {
ctx.logger.warn('darkness restore light lock failed', { error: err.message }); ctx.logger.warn('darkness restore light lock failed', { error: err.message });
} }
const prevNightVision = effect.prevNightVision && typeof effect.prevNightVision === 'object' const prevHeadlights = effect.prevHeadlights && typeof effect.prevHeadlights === 'object'
? effect.prevNightVision ? effect.prevHeadlights
: {}; : {};
Object.entries(prevNightVision).forEach(([roverId, wasOn]) => { Object.entries(prevHeadlights).forEach(([roverId, wasOn]) => {
try { try {
ctx.issueCommand(String(roverId), { ctx.issueCommand(String(roverId), {
type: 'nightVision', type: 'headlight',
nightVision: { action: actionForNightVisionState(Boolean(wasOn)) }, headlight: { action: Boolean(wasOn) ? 'on' : 'off' },
}); });
} catch (err) { } 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(); clearTimers();
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS); const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
const remaining = Math.max(0, endsAt - Date.now()); const remaining = Math.max(0, endsAt - Date.now());
nightVisionLockUntil = endsAt; headlightLockUntil = endsAt;
try { try {
await ctx.setHomeAssistantLightsLockedOn(true, { await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darkness', source: 'buttonbox:darkness',
@@ -122,31 +116,31 @@ async function startDarkness(ctx, effect) {
module.exports = { module.exports = {
id: 'darkness', id: 'darkness',
name: 'Darkness', name: 'Darkness',
isNightVisionBlocked, isHeadlightBlocked,
goal: 400, goal: 400,
async run(ctx) { async run(ctx) {
const entities = ctx.getHomeAssistantEntities(); const entities = ctx.getHomeAssistantEntities();
const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' })); const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' }));
await forceAllLightsOff(ctx); await forceAllLightsOff(ctx);
const prevNightVision = {}; const prevHeadlights = {};
ctx.listOnlineRovers().forEach((rover) => { ctx.listOnlineRovers().forEach((rover) => {
const state = rover?.nightVision?.state; const state = rover?.headlight?.state;
const nightVisionOn = Boolean(state && state.nightVisionOn === true); const headlightOn = Boolean(state && state.headlightOn === true);
prevNightVision[String(rover.id)] = nightVisionOn; prevHeadlights[String(rover.id)] = headlightOn;
try { try {
ctx.issueCommand(String(rover.id), { ctx.issueCommand(String(rover.id), {
type: 'nightVision', type: 'headlight',
nightVision: { action: actionForNightVisionState(false) }, headlight: { action: 'off' },
}); });
} catch (err) { } 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 prevPolicy = ctx.getHomeAssistantLightPolicy?.() || null;
const prevLightLockState = prevPolicy?.lockState || (prevPolicy?.lockedOn ? 'on' : 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); await startDarkness(ctx, effect);
ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 120 seconds.' }); ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 120 seconds.' });
}, },
+4 -4
View File
@@ -7,7 +7,7 @@ const roverManager = require('../roverManager');
const { isAdmin, isLockdownAdmin } = require('../roleService'); const { isAdmin, isLockdownAdmin } = require('../roleService');
const { isDeterred } = require('../verificationService'); const { isDeterred } = require('../verificationService');
const logger = require('../../globals/logger').child('commandService'); 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 pendingCommands = new Map(); // id -> { roverId }
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin } 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 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. songs, reboot/update admin actions, and similar commands.
*/ */
return true; return true;
@@ -158,8 +158,8 @@ io.on('connection', (socket) => {
if (type === 'audioLevels') { if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed'); throw new Error('audioLevels command is service-managed');
} }
if (type === 'nightVision' && isNightVisionBlocked()) { if (type === 'headlight' && isHeadlightBlocked()) {
logger.info('Ignoring night vision command while darkness lock is active', { socketId: socket.id, roverId }); logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
reply({ ignored: true, reason: 'darknessActive' }); reply({ ignored: true, reason: 'darknessActive' });
return; return;
} }
+6 -6
View File
@@ -8,7 +8,7 @@ const homeAssistantService = require('../homeAssistantService');
const neatoService = require('../neatoService'); const neatoService = require('../neatoService');
const liftService = require('../liftService'); const liftService = require('../liftService');
const { const {
NIGHT_VISION_DISABLE_ACTION, HEADLIGHT_DISABLE_ACTION,
DOCK_COMMAND_BASE64, DOCK_COMMAND_BASE64,
} = require('./constants'); } = require('./constants');
@@ -60,7 +60,7 @@ async function dockAllRovers() {
return { action: 'dockAllRovers', attempted, failed }; return { action: 'dockAllRovers', attempted, failed };
} }
async function disableAllRoverNightVision() { async function disableAllRoverHeadlights() {
const attempted = []; const attempted = [];
const failed = []; const failed = [];
roverManager.rovers.forEach((record) => { roverManager.rovers.forEach((record) => {
@@ -68,15 +68,15 @@ async function disableAllRoverNightVision() {
const roverId = String(record.id); const roverId = String(record.id);
try { try {
issueCommand(roverId, { issueCommand(roverId, {
type: 'nightVision', type: 'headlight',
nightVision: { action: NIGHT_VISION_DISABLE_ACTION }, headlight: { action: HEADLIGHT_DISABLE_ACTION },
}); });
attempted.push(roverId); attempted.push(roverId);
} catch (err) { } catch (err) {
failed.push({ roverId, error: err.message }); failed.push({ roverId, error: err.message });
} }
}); });
return { action: 'disableRoverNightVision', attempted, failed }; return { action: 'disableRoverHeadlights', attempted, failed };
} }
async function sendNeatoHome() { async function sendNeatoHome() {
@@ -100,7 +100,7 @@ async function raiseLift() {
const idleActions = [ const idleActions = [
turnOffRoomControls, turnOffRoomControls,
// dockAllRovers, // dockAllRovers,
disableAllRoverNightVision, disableAllRoverHeadlights,
sendNeatoHome, sendNeatoHome,
raiseLift, raiseLift,
]; ];
+2 -2
View File
@@ -2,11 +2,11 @@
// Purpose: Defines idle timing and command constants used by idle automation workflows. // Purpose: Defines idle timing and command constants used by idle automation workflows.
// Scope: Centralizes immutable configuration for trigger windows and rover command payloads. // Scope: Centralizes immutable configuration for trigger windows and rover command payloads.
const IDLE_TIMEOUT_MS = 2 * 60 * 1000; 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'); const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
module.exports = { module.exports = {
IDLE_TIMEOUT_MS, IDLE_TIMEOUT_MS,
NIGHT_VISION_DISABLE_ACTION, HEADLIGHT_DISABLE_ACTION,
DOCK_COMMAND_BASE64, DOCK_COMMAND_BASE64,
}; };
@@ -23,6 +23,24 @@ function coerceBool(value) {
const HEARTBEAT_INTERVAL_MS = 15000; 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) { function handleMessage(roverId, msg) {
switch (msg.type) { switch (msg.type) {
case 'hello': case 'hello':
@@ -38,9 +56,7 @@ function handleMessage(roverId, msg) {
roverManager.handleHostStats(roverId, msg); roverManager.handleHostStats(roverId, msg);
break; break;
case 'event': { case 'event': {
const nightVisionOn = coerceBool(msg.data?.nightVisionOn); if (handleToggleEvent(roverId, msg)) {
if (msg.event === 'nightVision.state' && nightVisionOn != null) {
roverManager.setNightVisionState(roverId, nightVisionOn);
break; break;
} }
sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event }); sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event });
@@ -100,10 +116,7 @@ roverWSS.on('connection', (ws) => {
} else if (msg.type === 'ack') { } else if (msg.type === 'ack') {
handleAck(msg); handleAck(msg);
} else if (msg.type === 'event') { } else if (msg.type === 'event') {
const nightVisionOn = coerceBool(msg.data?.nightVisionOn); if (!handleToggleEvent(roverId, msg)) {
if (msg.event === 'nightVision.state' && nightVisionOn != null) {
roverManager.setNightVisionState(roverId, nightVisionOn);
} else {
sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event }); sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event });
} }
} }
+2 -2
View File
@@ -127,7 +127,7 @@ const {
getRoster, getRoster,
getRosterForSocket, getRosterForSocket,
broadcastRoster, broadcastRoster,
setNightVisionState, setToggleState,
handleHostStats, handleHostStats,
canSeeRover, canSeeRover,
canRequestControl, canRequestControl,
@@ -261,7 +261,7 @@ module.exports = {
getRoster, getRoster,
getRosterForSocket, getRosterForSocket,
broadcastRoster, broadcastRoster,
setNightVisionState, setToggleState,
handleHostStats, handleHostStats,
handleSensorFrame, handleSensorFrame,
requestControl, requestControl,
@@ -40,7 +40,8 @@ function createRosterLifecycle(deps) {
locked: false, locked: false,
lockReason: null, lockReason: null,
batteryState: null, batteryState: null,
nightVisionState: null, headlightState: null,
laserState: null,
room: `rover:${id}`, room: `rover:${id}`,
lastSeen: Date.now(), lastSeen: Date.now(),
lastMovementAt: Date.now(), lastMovementAt: Date.now(),
@@ -73,10 +74,19 @@ function createRosterLifecycle(deps) {
} else { } else {
record.privateOpen = true; record.privateOpen = true;
} }
if (record.nightVisionState == null && meta?.nightVision?.enabled) { if (!meta?.headlight?.enabled) {
const ledOn = Boolean(meta.nightVision.initialOn); record.headlightState = null;
record.nightVisionState = { } else if (record.headlightState == null) {
nightVisionOn: !ledOn, 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(), updatedAt: Date.now(),
}; };
} }
@@ -221,9 +231,12 @@ function createRosterLifecycle(deps) {
cameraServo: record.meta?.cameraServo, cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio, audio: record.meta?.audio,
horn: record.meta?.horn, horn: record.meta?.horn,
nightVision: record.meta?.nightVision headlight: record.meta?.headlight
? { ...record.meta.nightVision, state: record.nightVisionState } ? { ...record.meta.headlight, state: record.headlightState }
: record.meta?.nightVision, : record.meta?.headlight,
laser: record.meta?.laser
? { ...record.meta.laser, state: record.laserState }
: record.meta?.laser,
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)), locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null), lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
lastSeen: record.lastSeen, lastSeen: record.lastSeen,
@@ -258,16 +271,19 @@ function createRosterLifecycle(deps) {
}); });
} }
function setNightVisionState(roverId, nightVisionOn) { function setToggleState(roverId, device, on) {
const record = rovers.get(roverId); const record = rovers.get(roverId);
if (!record) return; if (!record) return;
if (typeof nightVisionOn !== 'boolean') return; if (typeof on !== 'boolean') return;
record.nightVisionState = { if (device !== 'headlight' && device !== 'laser') return;
nightVisionOn, const stateKey = device === 'headlight' ? 'headlightState' : 'laserState';
const onKey = `${device}On`;
record[stateKey] = {
[onKey]: on,
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
broadcastRoster(); broadcastRoster();
managerEvents.emit('rover', { roverId, action: 'nightVision', record }); managerEvents.emit('rover', { roverId, action: device, record });
} }
function handleHostStats(roverId, msg = {}) { function handleHostStats(roverId, msg = {}) {
@@ -316,7 +332,7 @@ function createRosterLifecycle(deps) {
getRosterForSocket, getRosterForSocket,
syncSpectatorRooms, syncSpectatorRooms,
broadcastRoster, broadcastRoster,
setNightVisionState, setToggleState,
handleHostStats, handleHostStats,
canSeeRover, canSeeRover,
canRequestControl, canRequestControl,
@@ -10,7 +10,7 @@ const serverTimezone = config.timezone || null;
const configuredSocials = Array.isArray(config.socials) ? config.socials : null; const configuredSocials = Array.isArray(config.socials) ? config.socials : null;
const ACTIVITY_SYNC_COOLDOWN_MS = 3000; 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; const PERIODIC_SYNC_MS = 20000;
module.exports = { module.exports = {
@@ -19,6 +19,6 @@ module.exports = {
serverTimezone, serverTimezone,
configuredSocials, configuredSocials,
ACTIVITY_SYNC_COOLDOWN_MS, ACTIVITY_SYNC_COOLDOWN_MS,
NIGHT_VISION_SYNC_COOLDOWN_MS, GPIO_TOGGLE_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS, PERIODIC_SYNC_MS,
}; };
+14 -14
View File
@@ -41,7 +41,7 @@ const {
serverTimezone, serverTimezone,
configuredSocials, configuredSocials,
ACTIVITY_SYNC_COOLDOWN_MS, ACTIVITY_SYNC_COOLDOWN_MS,
NIGHT_VISION_SYNC_COOLDOWN_MS, GPIO_TOGGLE_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS, PERIODIC_SYNC_MS,
} = require('./constants'); } = require('./constants');
const { getState, setState } = require('./state'); const { getState, setState } = require('./state');
@@ -175,29 +175,29 @@ modeEvents.on('change', () => {
managerEvents.on('rover', (event = {}) => { managerEvents.on('rover', (event = {}) => {
const state = getState(); const state = getState();
if (event.action === 'nightVision') { if (event.action === 'headlight' || event.action === 'laser') {
const now = Date.now(); const now = Date.now();
const elapsed = now - state.lastNightVisionSync; const elapsed = now - state.lastGPIOToggleSync;
if (elapsed >= NIGHT_VISION_SYNC_COOLDOWN_MS) { if (elapsed >= GPIO_TOGGLE_SYNC_COOLDOWN_MS) {
setState({ lastNightVisionSync: now }); setState({ lastGPIOToggleSync: now });
logger.info('Night vision update; syncing all clients (immediate)'); logger.info('GPIO toggle update; syncing all clients (immediate)');
syncAll(); syncAll();
return; return;
} }
if (!state.pendingNightVisionSync) { if (!state.pendingGPIOToggleSync) {
const delay = NIGHT_VISION_SYNC_COOLDOWN_MS - elapsed; const delay = GPIO_TOGGLE_SYNC_COOLDOWN_MS - elapsed;
const timer = setTimeout(() => { const timer = setTimeout(() => {
setState({ lastNightVisionSync: Date.now(), pendingNightVisionSync: null }); setState({ lastGPIOToggleSync: Date.now(), pendingGPIOToggleSync: null });
logger.info('Night vision update; syncing all clients (delayed)'); logger.info('GPIO toggle update; syncing all clients (delayed)');
syncAll(); syncAll();
}, delay); }, delay);
setState({ pendingNightVisionSync: timer }); setState({ pendingGPIOToggleSync: timer });
} }
return; return;
} }
if (state.pendingNightVisionSync) { if (state.pendingGPIOToggleSync) {
clearTimeout(state.pendingNightVisionSync); clearTimeout(state.pendingGPIOToggleSync);
setState({ pendingNightVisionSync: null }); setState({ pendingGPIOToggleSync: null });
} }
logger.info('Rover roster change; syncing all clients'); logger.info('Rover roster change; syncing all clients');
syncAll(); syncAll();
+8 -8
View File
@@ -3,15 +3,15 @@
// Scope: Keeps runtime behavior unchanged while centralizing mutable session-sync state in one module. // Scope: Keeps runtime behavior unchanged while centralizing mutable session-sync state in one module.
let lastActivitySync = 0; let lastActivitySync = 0;
let pendingActivitySync = null; let pendingActivitySync = null;
let lastNightVisionSync = 0; let lastGPIOToggleSync = 0;
let pendingNightVisionSync = null; let pendingGPIOToggleSync = null;
function getState() { function getState() {
return { return {
lastActivitySync, lastActivitySync,
pendingActivitySync, pendingActivitySync,
lastNightVisionSync, lastGPIOToggleSync,
pendingNightVisionSync, pendingGPIOToggleSync,
}; };
} }
@@ -22,11 +22,11 @@ function setState(patch = {}) {
if (Object.prototype.hasOwnProperty.call(patch, 'pendingActivitySync')) { if (Object.prototype.hasOwnProperty.call(patch, 'pendingActivitySync')) {
pendingActivitySync = patch.pendingActivitySync; pendingActivitySync = patch.pendingActivitySync;
} }
if (Object.prototype.hasOwnProperty.call(patch, 'lastNightVisionSync')) { if (Object.prototype.hasOwnProperty.call(patch, 'lastGPIOToggleSync')) {
lastNightVisionSync = patch.lastNightVisionSync; lastGPIOToggleSync = patch.lastGPIOToggleSync;
} }
if (Object.prototype.hasOwnProperty.call(patch, 'pendingNightVisionSync')) { if (Object.prototype.hasOwnProperty.call(patch, 'pendingGPIOToggleSync')) {
pendingNightVisionSync = patch.pendingNightVisionSync; pendingGPIOToggleSync = patch.pendingGPIOToggleSync;
} }
} }
@@ -1,14 +1,15 @@
// Night Vision Control // GPIO Toggle Control
// Purpose: Defines the Night Vision Control module and the local helpers/components used in this file. // Purpose: Renders a direct press target for rover GPIO-backed toggles such as the headlight and laser.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Owns optimistic button state and touch/click de-duplication while callers provide device labels and actions.
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
function isBoolean(value) { function isBoolean(value) {
return typeof value === 'boolean'; return typeof value === 'boolean';
} }
export default function NightVisionControl({ export default function GPIOToggleControl({
nightVisionOn, label,
on,
disabled, disabled,
onToggle, onToggle,
keyLabel, keyLabel,
@@ -16,16 +17,21 @@ export default function NightVisionControl({
heightClass = '', heightClass = '',
}) { }) {
const [optimistic, setOptimistic] = useState( const [optimistic, setOptimistic] = useState(
isBoolean(nightVisionOn) ? nightVisionOn : null, isBoolean(on) ? on : null,
); );
const suppressClickRef = useRef(false); const suppressClickRef = useRef(false);
const suppressClickTimerRef = useRef(null); const suppressClickTimerRef = useRef(null);
useEffect(() => { useEffect(() => {
if (isBoolean(nightVisionOn)) { if (isBoolean(on)) {
setOptimistic(nightVisionOn); // 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( useEffect(
() => () => { () => () => {
@@ -58,7 +64,7 @@ export default function NightVisionControl({
Mobile browsers, especially Safari, do not always dispatch a reliable Mobile browsers, especially Safari, do not always dispatch a reliable
synthetic click for a second finger while another finger is held on the 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 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(); event.preventDefault();
suppressClickRef.current = true; suppressClickRef.current = true;
@@ -91,7 +97,7 @@ export default function NightVisionControl({
}; };
const buttonClasses = useMemo(() => { 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 // Safari callout suppression live on the button itself rather than only on
// the surrounding mobile column. // the surrounding mobile column.
const base = const base =
@@ -114,7 +120,7 @@ export default function NightVisionControl({
className={buttonClasses} className={buttonClasses}
> >
<span className="flex items-center gap-0.5"> <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 ? ( {keyLabel ? (
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200"> <span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
{keyLabel} {keyLabel}
@@ -38,7 +38,8 @@ export const ACTIONS = [
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' }, { id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' }, { id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' },
{ id: 'dockMacro', label: 'Dock 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; export const CAPTURE_AXIS_THRESHOLD = 0.45;
@@ -26,7 +26,8 @@ const KEY_ACTIONS = [
{ id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' }, { id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' },
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' }, { id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
{ id: 'cameraDown', label: 'Camera Down', 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: 'videoFilterCycle', label: 'Cycle Video Filter', group: 'Camera' },
{ id: 'hornHonk', label: 'Horn (Hold)', group: 'Audio' }, { id: 'hornHonk', label: 'Horn (Hold)', group: 'Audio' },
{ id: 'micPtt', label: 'Mic Push To Talk', group: 'Audio' }, { id: 'micPtt', label: 'Mic Push To Talk', group: 'Audio' },
@@ -1,11 +1,11 @@
// Aux Column // Aux Column
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default. // 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 { useCallback, useRef } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js'; import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js'; import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import HornControl from '../HornControl/index.jsx'; 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 { AUX_ZERO } from './constants.js';
import VacuumControls from './VacuumControls.jsx'; import VacuumControls from './VacuumControls.jsx';
import VerticalCameraTilt from './VerticalCameraTilt.jsx'; import VerticalCameraTilt from './VerticalCameraTilt.jsx';
@@ -15,16 +15,19 @@ function AuxColumnContent() {
const roverId = useControlSelector((control) => control.state.roverId); const roverId = useControlSelector((control) => control.state.roverId);
const camera = useControlSelector((control) => control.state.camera); const camera = useControlSelector((control) => control.state.camera);
const horn = useControlSelector((control) => control.state.horn); const horn = useControlSelector((control) => control.state.horn);
const nightVision = useControlSelector((control) => control.pipeline?.nightVision); const headlight = useControlSelector((control) => control.pipeline?.headlight);
const nightVisionState = useControlSelector((control) => control.pipeline?.nightVisionState); 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 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 dockAssist = useManualDockAssist();
const disabled = !roverId; const disabled = !roverId;
const activeAuxButtonRef = useRef(null); const activeAuxButtonRef = useRef(null);
const cameraConfig = camera?.config; const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig); 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 hornAvailable = Boolean(roverId && pipelineHorn);
const hornBlocked = horn?.overheated; const hornBlocked = horn?.overheated;
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45; const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
@@ -37,13 +40,22 @@ function AuxColumnContent() {
: (cameraMin + cameraMax) / 2; : (cameraMin + cameraMax) / 2;
const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked); const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
const handleNightVisionToggle = useCallback( const handleHeadlightToggle = useCallback(
(nextOn) => { (nextOn) => {
if (!nightVisionAvailable) return; if (!headlightAvailable) return;
trackAnalyticsEvent('night_vision_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) }); trackAnalyticsEvent('headlight_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
setNightVision(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(() => { const handleHornStart = useCallback(() => {
@@ -93,13 +105,27 @@ function AuxColumnContent() {
/> />
</div> </div>
) : null} ) : null}
{nightVisionAvailable ? ( {(headlightAvailable || laserAvailable) ? (
<NightVisionControl <div className="mobile-touch-control flex min-h-0 flex-1 flex-col gap-0.5">
nightVisionOn={nightVisionState?.nightVisionOn} {headlightAvailable ? (
disabled={disabled} <GPIOToggleControl
onToggle={handleNightVisionToggle} label="Headlight"
heightClass="h-full" on={headlightState?.headlightOn}
/> disabled={disabled}
onToggle={handleHeadlightToggle}
heightClass="h-full"
/>
) : null}
{laserAvailable ? (
<GPIOToggleControl
label="Laser"
on={laserState?.laserOn}
disabled={disabled}
onToggle={handleLaserToggle}
heightClass="h-full"
/>
) : null}
</div>
) : null} ) : null}
</div> </div>
<div className="mobile-touch-control min-h-0"> <div className="mobile-touch-control min-h-0">
+39 -17
View File
@@ -19,7 +19,7 @@ import { useControlActions, useControlSelector } from '../../controls/index.js';
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx'; import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
import RawUserPilePanel from '../RawUserPilePanel/index.jsx'; import RawUserPilePanel from '../RawUserPilePanel/index.jsx';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; 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 HornControl from '../HornControl/index.jsx';
import CameraTiltControl from '../CameraTiltControl/index.jsx'; import CameraTiltControl from '../CameraTiltControl/index.jsx';
import VipPanel from '../VipPanel/index.jsx'; import VipPanel from '../VipPanel/index.jsx';
@@ -62,17 +62,20 @@ function DriveDockPanel() {
const keymap = useControlSelector((control) => control.state.keymap); const keymap = useControlSelector((control) => control.state.keymap);
const camera = useControlSelector((control) => control.state.camera); const camera = useControlSelector((control) => control.state.camera);
const horn = useControlSelector((control) => control.state.horn); const horn = useControlSelector((control) => control.state.horn);
const nightVision = useControlSelector((control) => control.pipeline?.nightVision); const headlight = useControlSelector((control) => control.pipeline?.headlight);
const nightVisionState = useControlSelector((control) => control.pipeline?.nightVisionState); 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 pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
const { setServoAngle, setNightVision, startHorn, stopHorn } = useControlActions(); const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
const dockAssist = useManualDockAssist(); const dockAssist = useManualDockAssist();
const driveDockState = useDriveDockState(roverId); const driveDockState = useDriveDockState(roverId);
const hideInlineControls = driveDockState.docked && !driveDockState.driving; const hideInlineControls = driveDockState.docked && !driveDockState.driving;
const config = camera?.config; const config = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && 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 hornAvailable = Boolean(roverId && pipelineHorn);
const hornBlocked = horn?.overheated; const hornBlocked = horn?.overheated;
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30; const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
@@ -83,16 +86,21 @@ function DriveDockPanel() {
: typeof config?.homeAngle === 'number' : typeof config?.homeAngle === 'number'
? config.homeAngle ? config.homeAngle
: (min + max) / 2; : (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 hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]); const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]); const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked); const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
const trackedControls = useMemo( const trackedControls = useMemo(
() => ({ () => ({
setNightVision: (nextOn) => { setHeadlight: (nextOn) => {
trackAnalyticsEvent('night_vision_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) }); trackAnalyticsEvent('headlight_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
setNightVision(nextOn); setHeadlight(nextOn);
},
setLaser: (nextOn) => {
trackAnalyticsEvent('laser_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
setLaser(nextOn);
}, },
startHorn: () => { startHorn: () => {
trackAnalyticsEvent('horn_start', { roverId, source: 'desktop_control' }); trackAnalyticsEvent('horn_start', { roverId, source: 'desktop_control' });
@@ -100,7 +108,7 @@ function DriveDockPanel() {
}, },
stopHorn, stopHorn,
}), }),
[roverId, setNightVision, startHorn, stopHorn], [roverId, setHeadlight, setLaser, startHorn, stopHorn],
); );
return ( return (
@@ -113,13 +121,27 @@ function DriveDockPanel() {
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} /> <DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
{!hideInlineControls ? ( {!hideInlineControls ? (
<div className={`${themeStackClass} p-0 text-sm text-slate-200`}> <div className={`${themeStackClass} p-0 text-sm text-slate-200`}>
{nightVisionAvailable && ( {(headlightAvailable || laserAvailable) && (
<NightVisionControl <div className="grid grid-cols-2 gap-1">
nightVisionOn={nightVisionState?.nightVisionOn} {headlightAvailable && (
disabled={!roverId} <GPIOToggleControl
onToggle={trackedControls.setNightVision} label="Headlight"
keyLabel={nightVisionLabel} on={headlightState?.headlightOn}
/> disabled={!roverId}
onToggle={trackedControls.setHeadlight}
keyLabel={headlightLabel}
/>
)}
{laserAvailable && (
<GPIOToggleControl
label="Laser"
on={laserState?.laserOn}
disabled={!roverId}
onToggle={trackedControls.setLaser}
keyLabel={laserLabel}
/>
)}
</div>
)} )}
{hornAvailable && ( {hornAvailable && (
<HornControl <HornControl
+39 -23
View File
@@ -53,8 +53,10 @@ const CONTROL_ACTION_NAMES = [
'stopAllMotion', 'stopAllMotion',
'sendOiCommand', 'sendOiCommand',
'setSensorStream', 'setSensorStream',
'setNightVision', 'setHeadlight',
'toggleNightVision', 'toggleHeadlight',
'setLaser',
'toggleLaser',
'updateKeyBinding', 'updateKeyBinding',
'resetKeyBindings', 'resetKeyBindings',
'registerInputState', 'registerInputState',
@@ -469,28 +471,38 @@ export function ControlSystemProvider({ children }) {
[pipeline], [pipeline],
); );
const setNightVision = useCallback( const setHeadlight = useCallback(
(nightVisionOn) => { (headlightOn) => {
if (!pipeline.nightVision) return; if (!pipeline.headlight) return;
if (typeof nightVisionOn === 'boolean') { // Web controls now speak in logical device state. Any electrical
/* // inversion needed by the actual GPIO driver is handled by roverd's
Rover daemon command names describe the IR LED, while the UI state // activeLow config, so this command stays readable and direct.
describes camera visibility. LED "off" means nightVisionOn=true, and const action = typeof headlightOn === 'boolean' ? (headlightOn ? 'on' : 'off') : 'toggle';
LED "on" means nightVisionOn=false. pipeline.sendHeadlight(action);
*/
const action = nightVisionOn ? 'off' : 'on';
pipeline.sendNightVision(action);
} else {
pipeline.sendNightVision('toggle');
}
recordControlIntent(); recordControlIntent();
}, },
[pipeline, recordControlIntent], [pipeline, recordControlIntent],
); );
const toggleNightVision = useCallback(() => { const toggleHeadlight = useCallback(() => {
setNightVision(); setHeadlight();
}, [setNightVision]); }, [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( const setSongNote = useCallback(
(note) => { (note) => {
@@ -665,8 +677,10 @@ export function ControlSystemProvider({ children }) {
stopAllMotion, stopAllMotion,
sendOiCommand, sendOiCommand,
setSensorStream, setSensorStream,
setNightVision, setHeadlight,
toggleNightVision, toggleHeadlight,
setLaser,
toggleLaser,
updateKeyBinding, updateKeyBinding,
resetKeyBindings, resetKeyBindings,
registerInputState, registerInputState,
@@ -689,8 +703,10 @@ export function ControlSystemProvider({ children }) {
stopAllMotion, stopAllMotion,
sendOiCommand, sendOiCommand,
setSensorStream, setSensorStream,
setNightVision, setHeadlight,
toggleNightVision, toggleHeadlight,
setLaser,
toggleLaser,
updateKeyBinding, updateKeyBinding,
resetKeyBindings, resetKeyBindings,
registerInputState, registerInputState,
+39 -15
View File
@@ -29,9 +29,14 @@ export function useCommandPipeline(options = {}) {
return rosterEntry.cameraServo; return rosterEntry.cameraServo;
}, [rosterEntry]); }, [rosterEntry]);
const nightVision = useMemo(() => { const headlight = useMemo(() => {
if (!rosterEntry?.nightVision || !rosterEntry.nightVision.enabled) return null; if (!rosterEntry?.headlight || !rosterEntry.headlight.enabled) return null;
return rosterEntry.nightVision; return rosterEntry.headlight;
}, [rosterEntry]);
const laser = useMemo(() => {
if (!rosterEntry?.laser || !rosterEntry.laser.enabled) return null;
return rosterEntry.laser;
}, [rosterEntry]); }, [rosterEntry]);
const horn = useMemo(() => { const horn = useMemo(() => {
@@ -39,7 +44,8 @@ export function useCommandPipeline(options = {}) {
return rosterEntry.horn; return rosterEntry.horn;
}, [rosterEntry]); }, [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( const emitCommand = useCallback(
(payload, cb) => { (payload, cb) => {
@@ -167,16 +173,28 @@ export function useCommandPipeline(options = {}) {
[roverId, sendOiCommand, sendDriveDirect, sendAuxMotors, sendServoAngle], [roverId, sendOiCommand, sendDriveDirect, sendAuxMotors, sendServoAngle],
); );
const sendNightVision = useCallback( const sendHeadlight = useCallback(
(action = 'toggle') => { (action = 'toggle') => {
if (!roverId || !nightVision) return null; if (!roverId || !headlight) return null;
emitCommand({ emitCommand({
type: 'nightVision', type: 'headlight',
data: { nightVision: { action } }, data: { headlight: { action } },
}); });
return 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( const sendHorn = useCallback(
@@ -222,8 +240,10 @@ export function useCommandPipeline(options = {}) {
roverId, roverId,
rosterEntry, rosterEntry,
servoConfig, servoConfig,
nightVision, headlight,
nightVisionState, headlightState,
laser,
laserState,
horn, horn,
emitCommand, emitCommand,
enableSensorStream, enableSensorStream,
@@ -231,7 +251,8 @@ export function useCommandPipeline(options = {}) {
sendAuxMotors, sendAuxMotors,
sendServoAngle, sendServoAngle,
sendOiCommand, sendOiCommand,
sendNightVision, sendHeadlight,
sendLaser,
sendHorn, sendHorn,
sendSong, sendSong,
runMacroSteps, runMacroSteps,
@@ -240,8 +261,10 @@ export function useCommandPipeline(options = {}) {
roverId, roverId,
rosterEntry, rosterEntry,
servoConfig, servoConfig,
nightVision, headlight,
nightVisionState, headlightState,
laser,
laserState,
horn, horn,
emitCommand, emitCommand,
enableSensorStream, enableSensorStream,
@@ -249,7 +272,8 @@ export function useCommandPipeline(options = {}) {
sendAuxMotors, sendAuxMotors,
sendServoAngle, sendServoAngle,
sendOiCommand, sendOiCommand,
sendNightVision, sendHeadlight,
sendLaser,
sendHorn, sendHorn,
runMacroSteps, runMacroSteps,
], ],
+2 -1
View File
@@ -51,7 +51,8 @@ export const DEFAULT_KEYMAP = {
auxAllForward: [","], auxAllForward: [","],
cameraUp: ['u'], cameraUp: ['u'],
cameraDown: ['j'], cameraDown: ['j'],
nightVisionToggle: ['e'], headlightToggle: ['e'],
laserToggle: ['r'],
videoFilterCycle: ['2'], videoFilterCycle: ['2'],
hornHonk: ['h'], hornHonk: ['h'],
micPtt: ['m'], micPtt: ['m'],
@@ -54,7 +54,8 @@ export default function GamepadInputManager() {
setAuxMotors, setAuxMotors,
setServoAngle, setServoAngle,
runMacro, runMacro,
toggleNightVision, toggleHeadlight,
toggleLaser,
registerInputState, registerInputState,
} = useControlActions(); } = useControlActions();
const cameraAngle = useControlSelector((control) => control.state.camera?.angle); const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
@@ -175,7 +176,8 @@ export default function GamepadInputManager() {
setDriveVector, setDriveVector,
setMode, setMode,
setServoAngle, setServoAngle,
toggleNightVision, toggleHeadlight,
toggleLaser,
}; };
}); });
@@ -286,11 +288,18 @@ export default function GamepadInputManager() {
handleButtonEdge('dockMacro', false); handleButtonEdge('dockMacro', false);
} }
if (outputs.buttons.nightVisionToggle && handleButtonEdge('nightVisionToggle', true)) { if (outputs.buttons.headlightToggle && handleButtonEdge('headlightToggle', true)) {
trackAnalyticsEvent('night_vision_toggle', { roverId: latest.roverId || '', source: 'gamepad' }); trackAnalyticsEvent('headlight_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
latest.toggleNightVision(); latest.toggleHeadlight();
} else if (!outputs.buttons.nightVisionToggle) { } else if (!outputs.buttons.headlightToggle) {
handleButtonEdge('nightVisionToggle', false); 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) { if (Math.abs(outputs.cameraAxis) > 0.001) {
@@ -91,7 +91,8 @@ export default function KeyboardInputManager() {
runMacro, runMacro,
stopAllMotion, stopAllMotion,
registerInputState, registerInputState,
toggleNightVision, toggleHeadlight,
toggleLaser,
startHorn, startHorn,
stopHorn, stopHorn,
setMicPttActive, setMicPttActive,
@@ -376,7 +377,8 @@ export default function KeyboardInputManager() {
startHorn, startHorn,
stopAllMotion, stopAllMotion,
stopHorn, stopHorn,
toggleNightVision, toggleHeadlight,
toggleLaser,
videoColorFilter, videoColorFilter,
}; };
}); });
@@ -413,9 +415,12 @@ export default function KeyboardInputManager() {
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) { } else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'keyboard' }); trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
latest.dockAssist.toggleAssist(); latest.dockAssist.toggleAssist();
} else if (newlyPressed.some((token) => latest.keymap.nightVisionToggle?.has(token))) { } else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
trackAnalyticsEvent('night_vision_toggle', { roverId: latest.roverId || '', source: 'keyboard' }); trackAnalyticsEvent('headlight_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
latest.toggleNightVision(); 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))) { } else if (newlyPressed.some((token) => latest.keymap.videoFilterCycle?.has(token))) {
cycleVideoFilter(); cycleVideoFilter();
} else if (newlyPressed.some((token) => latest.keymap.hornHonk?.has(token))) { } else if (newlyPressed.some((token) => latest.keymap.hornHonk?.has(token))) {
+6 -3
View File
@@ -163,7 +163,8 @@ export function computeGamepadOutputs(padState, profile) {
const sideReverseSource = resolveButtonSource(padState, bindings.sideReverse?.sources); const sideReverseSource = resolveButtonSource(padState, bindings.sideReverse?.sources);
const driveMacroSource = resolveButtonSource(padState, bindings.driveMacro?.sources); const driveMacroSource = resolveButtonSource(padState, bindings.driveMacro?.sources);
const dockMacroSource = resolveButtonSource(padState, bindings.dockMacro?.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 { return {
driveVector: { x: driveX, y: driveY, boost: false }, driveVector: { x: driveX, y: driveY, boost: false },
@@ -176,7 +177,8 @@ export function computeGamepadOutputs(padState, profile) {
sideReverse: sideReverseSource.pressed, sideReverse: sideReverseSource.pressed,
driveMacro: driveMacroSource.pressed, driveMacro: driveMacroSource.pressed,
dockMacro: dockMacroSource.pressed, dockMacro: dockMacroSource.pressed,
nightVisionToggle: nightVisionSource.pressed, headlightToggle: headlightSource.pressed,
laserToggle: laserSource.pressed,
}, },
sources: { sources: {
drive: driveSource.source, drive: driveSource.source,
@@ -189,7 +191,8 @@ export function computeGamepadOutputs(padState, profile) {
sideReverse: sideReverseSource.source, sideReverse: sideReverseSource.source,
driveMacro: driveMacroSource.source, driveMacro: driveMacroSource.source,
dockMacro: dockMacroSource.source, dockMacro: dockMacroSource.source,
nightVisionToggle: nightVisionSource.source, headlightToggle: headlightSource.source,
laserToggle: laserSource.source,
}, },
}; };
} }
+4 -2
View File
@@ -67,7 +67,8 @@ export const HELP_CONTENT = {
items: [ items: [
{ action: 'cameraUp', label: 'Tilt up' }, { action: 'cameraUp', label: 'Tilt up' },
{ action: 'cameraDown', label: 'Tilt down' }, { 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: [ // items: [
// { action: 'driveMacro', label: 'Drive macro' }, // { action: 'driveMacro', label: 'Drive macro' },
// { action: 'dockMacro', label: 'Dock 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' }, // { action: 'chatFocus', label: 'Chat focus' },
// ], // ],
// }, // },
+5 -1
View File
@@ -73,10 +73,14 @@ export const GAMEPAD_PROFILE_DEFAULT = {
kind: 'button', kind: 'button',
sources: [{ kind: 'button', index: 3 }], sources: [{ kind: 'button', index: 3 }],
}, },
nightVisionToggle: { headlightToggle: {
kind: 'button', kind: 'button',
sources: [{ kind: 'button', index: 9 }], sources: [{ kind: 'button', index: 9 }],
}, },
laserToggle: {
kind: 'button',
sources: [],
},
}, },
}; };