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
+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