mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
headlight rework and laser addition
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
## rover service checklist
|
||||
|
||||
things that need to be good:
|
||||
- all bolts tight
|
||||
- headlights functional
|
||||
- cameras aligned properly
|
||||
- not crooked sideways
|
||||
- not off center
|
||||
- lenses in focus
|
||||
- microphone good
|
||||
@@ -69,19 +69,28 @@ func main() {
|
||||
defer cameraServo.Close()
|
||||
}
|
||||
|
||||
var nightVision *roverd.NightVisionLight
|
||||
if cfg.NightVision.Enabled {
|
||||
nightVision, err = roverd.NewNightVisionLight(cfg.NightVision, logger)
|
||||
var headlight *roverd.GPIOToggle
|
||||
if cfg.Headlight.Enabled {
|
||||
headlight, err = roverd.NewGPIOToggle("headlight", cfg.Headlight, logger)
|
||||
if err != nil {
|
||||
logger.Fatalf("init night vision: %v", err)
|
||||
logger.Fatalf("init headlight: %v", err)
|
||||
}
|
||||
defer nightVision.Close()
|
||||
defer headlight.Close()
|
||||
}
|
||||
|
||||
var laser *roverd.GPIOToggle
|
||||
if cfg.Laser.Enabled {
|
||||
laser, err = roverd.NewGPIOToggle("laser", cfg.Laser, logger)
|
||||
if err != nil {
|
||||
logger.Fatalf("init laser: %v", err)
|
||||
}
|
||||
defer laser.Close()
|
||||
}
|
||||
|
||||
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
||||
go autoCharge.Run(ctx, sensorSamples)
|
||||
|
||||
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
|
||||
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, headlight, laser, logger)
|
||||
|
||||
retryDelay := time.Second
|
||||
for ctx.Err() == nil {
|
||||
|
||||
@@ -11,7 +11,8 @@ type helloMessage struct {
|
||||
CameraServo CameraServoConfig `json:"cameraServo"`
|
||||
Audio AudioConfig `json:"audio"`
|
||||
Horn HornConfig `json:"horn"`
|
||||
NightVision NightVisionConfig `json:"nightVision"`
|
||||
Headlight GPIOToggleConfig `json:"headlight"`
|
||||
Laser GPIOToggleConfig `json:"laser"`
|
||||
Private PrivateConfig `json:"private"`
|
||||
}
|
||||
|
||||
@@ -42,7 +43,8 @@ type inboundMessage struct {
|
||||
TTS *ttsPayload `json:"tts,omitempty"`
|
||||
Horn *hornPayload `json:"horn,omitempty"`
|
||||
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
|
||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||
Headlight *togglePayload `json:"headlight,omitempty"`
|
||||
Laser *togglePayload `json:"laser,omitempty"`
|
||||
Song *songPayload `json:"song,omitempty"`
|
||||
Reboot *rebootPayload `json:"reboot,omitempty"`
|
||||
// Update is intentionally just a marker payload. The server can request the
|
||||
@@ -97,7 +99,7 @@ type audioLevelsPayload struct {
|
||||
ForwardGain *float64 `json:"forwardGain,omitempty"`
|
||||
}
|
||||
|
||||
type nightVisionPayload struct {
|
||||
type togglePayload struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
|
||||
+30
-7
@@ -109,11 +109,22 @@ type CameraServoConfig struct {
|
||||
Invert bool `yaml:"invert" json:"invert"`
|
||||
}
|
||||
|
||||
type NightVisionConfig struct {
|
||||
type GPIOToggleConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
|
||||
GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
|
||||
InitialOn bool `yaml:"initialOn" json:"initialOn"`
|
||||
ActiveLow bool `yaml:"activeLow" json:"activeLow"`
|
||||
}
|
||||
|
||||
func (g GPIOToggleConfig) LogicalToGPIO(on bool) int {
|
||||
// activeLow is the single hardware-inversion point for GPIO toggles. The
|
||||
// rest of the daemon, server, and web UI can use normal logical on/off
|
||||
// semantics without knowing whether the driver is active-high or active-low.
|
||||
if on == g.ActiveLow {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
type AutoSideBrushConfig struct {
|
||||
@@ -153,7 +164,8 @@ type Config struct {
|
||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||
Audio AudioConfig `yaml:"audio"`
|
||||
Horn HornConfig `yaml:"horn"`
|
||||
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
||||
Headlight GPIOToggleConfig `yaml:"headlight" json:"headlight"`
|
||||
Laser GPIOToggleConfig `yaml:"laser" json:"laser"`
|
||||
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
|
||||
Private PrivateConfig `yaml:"private" json:"private"`
|
||||
}
|
||||
@@ -210,11 +222,19 @@ func LoadConfig(path string) (*Config, error) {
|
||||
SawGain: 0.7,
|
||||
MaxDuration: Duration{Duration: 10000 * time.Millisecond},
|
||||
},
|
||||
NightVision: NightVisionConfig{
|
||||
Headlight: GPIOToggleConfig{
|
||||
Enabled: true,
|
||||
GPIOPin: 22,
|
||||
GPIOChip: "gpiochip0",
|
||||
InitialOn: true,
|
||||
InitialOn: false,
|
||||
ActiveLow: true,
|
||||
},
|
||||
Laser: GPIOToggleConfig{
|
||||
Enabled: false,
|
||||
GPIOPin: 27,
|
||||
GPIOChip: "gpiochip0",
|
||||
InitialOn: false,
|
||||
ActiveLow: false,
|
||||
},
|
||||
AutoSideBrush: AutoSideBrushConfig{
|
||||
Enabled: true,
|
||||
@@ -302,8 +322,11 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
||||
}
|
||||
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
|
||||
return nil, fmt.Errorf("nightVision: %w", err)
|
||||
if err := validateGPIOToggleConfig(&cfg.Headlight); err != nil {
|
||||
return nil, fmt.Errorf("headlight: %w", err)
|
||||
}
|
||||
if err := validateGPIOToggleConfig(&cfg.Laser); err != nil {
|
||||
return nil, fmt.Errorf("laser: %w", err)
|
||||
}
|
||||
validateAudioConfig(&cfg.Audio)
|
||||
validateHornConfig(&cfg.Horn)
|
||||
@@ -396,7 +419,7 @@ func validateHornConfig(cfg *HornConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateNightVisionConfig(cfg *NightVisionConfig) error {
|
||||
func validateGPIOToggleConfig(cfg *GPIOToggleConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gpiocdev "github.com/warthog618/go-gpiocdev"
|
||||
)
|
||||
|
||||
type GPIOToggle struct {
|
||||
cfg GPIOToggleConfig
|
||||
name string
|
||||
logger *log.Logger
|
||||
line *gpiocdev.Line
|
||||
mu sync.Mutex
|
||||
on bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewGPIOToggle(name string, cfg GPIOToggleConfig, logger *log.Logger) (*GPIOToggle, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, fmt.Errorf("%s disabled", name)
|
||||
}
|
||||
chip := cfg.GPIOChip
|
||||
if chip == "" {
|
||||
chip = "gpiochip0"
|
||||
}
|
||||
line, err := gpiocdev.RequestLine(
|
||||
chip,
|
||||
cfg.GPIOPin,
|
||||
gpiocdev.AsOutput(cfg.LogicalToGPIO(cfg.InitialOn)),
|
||||
gpiocdev.WithConsumer(fmt.Sprintf("roverd-%s", name)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpio request: %w", err)
|
||||
}
|
||||
toggle := &GPIOToggle{
|
||||
cfg: cfg,
|
||||
name: name,
|
||||
logger: logger,
|
||||
line: line,
|
||||
on: cfg.InitialOn,
|
||||
}
|
||||
logger.Printf("%s on GPIO %d (initial=%v activeLow=%v)", name, cfg.GPIOPin, cfg.InitialOn, cfg.ActiveLow)
|
||||
return toggle, nil
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) Close() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed {
|
||||
return
|
||||
}
|
||||
// Preserve the last logical state while closing the line. The daemon is not
|
||||
// trying to force a safety state here; it is only releasing the GPIO handle.
|
||||
_ = g.line.SetValue(g.cfg.LogicalToGPIO(g.on))
|
||||
g.line.Close()
|
||||
g.closed = true
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) HandleAction(action string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed {
|
||||
return fmt.Errorf("%s controller closed", g.name)
|
||||
}
|
||||
act := strings.ToLower(strings.TrimSpace(action))
|
||||
switch act {
|
||||
case "", "toggle":
|
||||
return g.setLocked(!g.on)
|
||||
case "on":
|
||||
return g.setLocked(true)
|
||||
case "off":
|
||||
return g.setLocked(false)
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) On() bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.on
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) setLocked(on bool) error {
|
||||
// This is the only place a logical device state becomes an electrical GPIO
|
||||
// value. Hardware that turns on when pulled low sets activeLow in roverd
|
||||
// config; every caller above this layer still uses plain on/off semantics.
|
||||
if err := g.line.SetValue(g.cfg.LogicalToGPIO(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
g.on = on
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type GPIOToggle struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func NewGPIOToggle(name string, cfg GPIOToggleConfig, logger *log.Logger) (*GPIOToggle, error) {
|
||||
return nil, fmt.Errorf("%s not supported in dummy build", name)
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) Close() {}
|
||||
|
||||
func (g *GPIOToggle) HandleAction(action string) error {
|
||||
return fmt.Errorf("%s not supported in dummy build", g.name)
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) On() bool {
|
||||
return false
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//go:build !dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gpiocdev "github.com/warthog618/go-gpiocdev"
|
||||
)
|
||||
|
||||
type NightVisionLight struct {
|
||||
cfg NightVisionConfig
|
||||
logger *log.Logger
|
||||
line *gpiocdev.Line
|
||||
mu sync.Mutex
|
||||
on bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, fmt.Errorf("night vision disabled")
|
||||
}
|
||||
chip := cfg.GPIOChip
|
||||
if chip == "" {
|
||||
chip = "gpiochip0"
|
||||
}
|
||||
initial := 0
|
||||
if cfg.InitialOn {
|
||||
initial = 1
|
||||
}
|
||||
line, err := gpiocdev.RequestLine(
|
||||
chip,
|
||||
cfg.GPIOPin,
|
||||
gpiocdev.AsOutput(initial),
|
||||
gpiocdev.WithConsumer("roverd-nightvision"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpio request: %w", err)
|
||||
}
|
||||
nv := &NightVisionLight{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
line: line,
|
||||
on: cfg.InitialOn,
|
||||
}
|
||||
logger.Printf("night vision LED on GPIO %d (initial=%v)", cfg.GPIOPin, cfg.InitialOn)
|
||||
return nv, nil
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) Close() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return
|
||||
}
|
||||
_ = n.line.SetValue(boolToGPIO(n.on))
|
||||
n.line.Close()
|
||||
n.closed = true
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) HandleAction(action string) error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return fmt.Errorf("night vision controller closed")
|
||||
}
|
||||
act := strings.ToLower(strings.TrimSpace(action))
|
||||
switch act {
|
||||
case "", "toggle":
|
||||
return n.setLocked(!n.on)
|
||||
case "on":
|
||||
return n.setLocked(true)
|
||||
case "off":
|
||||
return n.setLocked(false)
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) NightVisionOn() bool {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return !n.on
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) setLocked(on bool) error {
|
||||
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
n.on = on
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolToGPIO(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build dummy
|
||||
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
type NightVisionLight struct{}
|
||||
|
||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
||||
return nil, fmt.Errorf("night vision not supported in dummy build")
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) Close() {}
|
||||
|
||||
func (n *NightVisionLight) HandleAction(action string) error {
|
||||
return fmt.Errorf("night vision not supported in dummy build")
|
||||
}
|
||||
|
||||
func (n *NightVisionLight) NightVisionOn() bool {
|
||||
return false
|
||||
}
|
||||
@@ -56,11 +56,18 @@ horn:
|
||||
sineGain: 1.0
|
||||
sawGain: 0.7
|
||||
maxDuration: 1.2s
|
||||
nightVision:
|
||||
headlight:
|
||||
enabled: true
|
||||
gpioPin: 22
|
||||
gpioChip: gpiochip0
|
||||
initialOn: true
|
||||
initialOn: false
|
||||
activeLow: true
|
||||
laser:
|
||||
enabled: false
|
||||
gpioPin: 27
|
||||
gpioChip: gpiochip0
|
||||
initialOn: false
|
||||
activeLow: false
|
||||
autoSideBrush:
|
||||
enabled: true
|
||||
speed: 20
|
||||
|
||||
@@ -32,6 +32,18 @@ cameraServo:
|
||||
homeAngle: 0
|
||||
nudgeDegrees: 2
|
||||
allowRawPulse: false
|
||||
headlight:
|
||||
enabled: true
|
||||
gpioPin: 22
|
||||
gpioChip: gpiochip0
|
||||
initialOn: false
|
||||
activeLow: true
|
||||
laser:
|
||||
enabled: false
|
||||
gpioPin: 27
|
||||
gpioChip: gpiochip0
|
||||
initialOn: false
|
||||
activeLow: false
|
||||
autoSideBrush:
|
||||
enabled: true
|
||||
speed: 20
|
||||
|
||||
+27
-15
@@ -21,7 +21,8 @@ type WSClient struct {
|
||||
media *MediaSupervisor
|
||||
servo *CameraServo
|
||||
horn *HornSynth
|
||||
nightVision *NightVisionLight
|
||||
headlight *GPIOToggle
|
||||
laser *GPIOToggle
|
||||
log *log.Logger
|
||||
recoverMu sync.Mutex
|
||||
recovering bool
|
||||
@@ -40,7 +41,7 @@ type WSClient struct {
|
||||
audioMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, headlight *GPIOToggle, laser *GPIOToggle, logger *log.Logger) *WSClient {
|
||||
var ttsQueue chan *ttsPayload
|
||||
if cfg.Audio.TTSEnabled {
|
||||
ttsQueue = make(chan *ttsPayload, 2)
|
||||
@@ -61,7 +62,8 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
|
||||
media: media,
|
||||
servo: servo,
|
||||
horn: horn,
|
||||
nightVision: nightVision,
|
||||
headlight: headlight,
|
||||
laser: laser,
|
||||
log: logger,
|
||||
ttsQueue: ttsQueue,
|
||||
chromeTTS: chromeTTS,
|
||||
@@ -133,7 +135,8 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
||||
CameraServo: c.cfg.CameraServo,
|
||||
Audio: c.cfg.Audio,
|
||||
Horn: c.cfg.Horn,
|
||||
NightVision: c.cfg.NightVision,
|
||||
Headlight: c.cfg.Headlight,
|
||||
Laser: c.cfg.Laser,
|
||||
Private: c.cfg.Private,
|
||||
}
|
||||
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
||||
@@ -226,17 +229,10 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
||||
return c.horn.HandlePayload(msg.Horn)
|
||||
case msg.AudioLevels != nil:
|
||||
return c.handleAudioLevels(msg.AudioLevels)
|
||||
case msg.NightVision != nil:
|
||||
if c.nightVision == nil {
|
||||
return fmt.Errorf("night vision disabled")
|
||||
}
|
||||
if err := c.nightVision.HandleAction(msg.NightVision.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
c.emitEvent("nightVision.state", map[string]any{
|
||||
"nightVisionOn": c.nightVision.NightVisionOn(),
|
||||
})
|
||||
return nil
|
||||
case msg.Headlight != nil:
|
||||
return c.handleToggleCommand("headlight", c.headlight, msg.Headlight)
|
||||
case msg.Laser != nil:
|
||||
return c.handleToggleCommand("laser", c.laser, msg.Laser)
|
||||
case msg.Song != nil:
|
||||
slot := 0
|
||||
if msg.Song.Slot != nil {
|
||||
@@ -252,6 +248,22 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) handleToggleCommand(name string, toggle *GPIOToggle, payload *togglePayload) error {
|
||||
if toggle == nil {
|
||||
return fmt.Errorf("%s disabled", name)
|
||||
}
|
||||
if err := toggle.HandleAction(payload.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
// Event names and payload keys use logical device names. GPIO polarity has
|
||||
// already been handled inside GPIOToggle, so the server only sees whether
|
||||
// the headlight or laser should be considered on.
|
||||
c.emitEvent(fmt.Sprintf("%s.state", name), map[string]any{
|
||||
fmt.Sprintf("%sOn", name): toggle.On(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WSClient) stopMotionForSystemCommand(reason string) error {
|
||||
// System-level commands can restart the process or the whole Pi. Stopping
|
||||
// both wheel and auxiliary motors first leaves the Roomba in a predictable
|
||||
|
||||
Reference in New Issue
Block a user