headlight rework and laser addition

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