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---- |
|
| ----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:
|
||||||
|
|||||||
Vendored
+3
-1
@@ -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
|
||||||
|
|||||||
Vendored
+3
-1
@@ -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
|
||||||
|
|||||||
Vendored
+3
-1
@@ -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
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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.' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-12
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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))) {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
// ],
|
// ],
|
||||||
// },
|
// },
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user