This commit is contained in:
legop3
2025-12-02 14:43:56 -05:00
parent 9bc5d2d06d
commit ace39d5a70
21 changed files with 238 additions and 18 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+10 -1
View File
@@ -69,10 +69,19 @@ func main() {
defer cameraServo.Close()
}
var nightVision *roverd.NightVisionLight
if cfg.NightVision.Enabled {
nightVision, err = roverd.NewNightVisionLight(cfg.NightVision, logger)
if err != nil {
logger.Fatalf("init night vision: %v", err)
}
defer nightVision.Close()
}
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
go autoCharge.Run(ctx, sensorSamples)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, logger)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
retryDelay := time.Second
for ctx.Err() == nil {
+6
View File
@@ -8,6 +8,7 @@ type helloMessage struct {
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
NightVision NightVisionConfig `json:"nightVision"`
}
type sensorMessage struct {
@@ -26,6 +27,7 @@ type inboundMessage struct {
Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
}
type driveDirectPayload struct {
@@ -61,6 +63,10 @@ type ttsPayload struct {
Speak bool `json:"speak,omitempty"`
}
type nightVisionPayload struct {
Action string `json:"action"`
}
type ackMessage struct {
Type string `json:"type"`
ID string `json:"id"`
+30
View File
@@ -95,6 +95,13 @@ type CameraServoConfig struct {
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
}
type NightVisionConfig 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"`
}
type Config struct {
Name string `yaml:"name"`
ServerURL string `yaml:"serverUrl"`
@@ -105,6 +112,7 @@ type Config struct {
Media MediaConfig `yaml:"media"`
CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
}
func LoadConfig(path string) (*Config, error) {
@@ -154,6 +162,12 @@ func LoadConfig(path string) (*Config, error) {
DefaultVoice: "rms",
DefaultPitch: 50,
},
NightVision: NightVisionConfig{
Enabled: true,
GPIOPin: 22,
GPIOChip: "gpiochip0",
InitialOn: true,
},
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
@@ -214,6 +228,9 @@ 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)
}
validateAudioConfig(&cfg.Audio)
return &cfg, nil
}
@@ -278,6 +295,19 @@ func validateAudioConfig(cfg *AudioConfig) {
}
}
func validateNightVisionConfig(cfg *NightVisionConfig) error {
if !cfg.Enabled {
return nil
}
if cfg.GPIOPin <= 0 {
return errors.New("gpioPin must be > 0")
}
if cfg.GPIOChip == "" {
cfg.GPIOChip = "gpiochip0"
}
return nil
}
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
if streamName == "" {
return "", errors.New("missing stream name for publishUrl")
+97
View File
@@ -0,0 +1,97 @@
//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) 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
}
+20
View File
@@ -0,0 +1,20 @@
//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")
}
+5
View File
@@ -47,3 +47,8 @@ audio:
defaultEngine: flite
defaultVoice: rms
defaultPitch: 50
nightVision:
enabled: true
gpioPin: 22
gpioChip: gpiochip0
initialOn: true
+9 -1
View File
@@ -18,10 +18,11 @@ type WSClient struct {
events <-chan RoverEvent
media *MediaSupervisor
servo *CameraServo
nightVision *NightVisionLight
log *log.Logger
}
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, servo *CameraServo, logger *log.Logger) *WSClient {
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
return &WSClient{
cfg: cfg,
adapter: adapter,
@@ -29,6 +30,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
events: events,
media: media,
servo: servo,
nightVision: nightVision,
log: logger,
}
}
@@ -72,6 +74,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio,
NightVision: c.cfg.NightVision,
}
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
return writeJSON(ctx, conn, msg)
@@ -153,6 +156,11 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.handleServoCommand(msg.Servo)
case msg.TTS != nil:
return c.handleTTSPayload(msg.TTS)
case msg.NightVision != nil:
if c.nightVision == nil {
return fmt.Errorf("night vision disabled")
}
return c.nightVision.HandleAction(msg.NightVision.Action)
default:
return fmt.Errorf("unsupported command type: %s", msg.Type)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CnvHZcbU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1UrCI1T.css">
<script type="module" crossorigin src="/assets/index-D8Lb2mUY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DQ1jpCiS.css">
</head>
<body>
<div id="root"></div>
+1
View File
@@ -119,6 +119,7 @@ function getRoster() {
media: record.meta?.media,
cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio,
nightVision: record.meta?.nightVision,
locked: record.locked,
lockReason: record.lockReason,
lastSeen: record.lastSeen,
+1
View File
@@ -19,6 +19,7 @@ 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: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
+13 -1
View File
@@ -133,11 +133,13 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
function MobileJoystickPanel({ layout }) {
const {
state: { roverId, camera },
actions: { setDriveVector, registerInputState, stopAllMotion, setServoAngle },
pipeline,
actions: { setDriveVector, registerInputState, stopAllMotion, setServoAngle, toggleNightVision },
} = useControlSystem();
const disabled = !roverId;
const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
const cameraValue =
@@ -198,6 +200,16 @@ function MobileJoystickPanel({ layout }) {
<div className="flex flex-col gap-0.5 text-slate-100">
<DriveModeToggle size="compact" />
{nightVisionAvailable && (
<button
type="button"
onClick={() => toggleNightVision()}
disabled={disabled}
className="bg-amber-600 px-0.5 py-1 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-40"
>
Toggle Night Vision
</button>
)}
{cameraEnabled && (
<div className="bg-zinc-950 p-0.5 text-xs">
<div className="flex items-center justify-between text-[0.75rem] text-slate-400">
+6
View File
@@ -254,6 +254,10 @@ export function ControlSystemProvider({ children }) {
[pipeline],
);
const toggleNightVision = useCallback(() => {
pipeline.sendNightVision('toggle');
}, [pipeline]);
const registerInputState = useCallback((source, data) => {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
}, []);
@@ -274,6 +278,7 @@ export function ControlSystemProvider({ children }) {
stopAllMotion,
sendOiCommand,
setSensorStream,
toggleNightVision,
updateKeyBinding,
resetKeyBindings,
registerInputState,
@@ -292,6 +297,7 @@ export function ControlSystemProvider({ children }) {
stopAllMotion,
sendOiCommand,
setSensorStream,
toggleNightVision,
updateKeyBinding,
resetKeyBindings,
registerInputState,
+21
View File
@@ -19,6 +19,11 @@ export function useCommandPipeline() {
return rosterEntry.cameraServo;
}, [rosterEntry]);
const nightVision = useMemo(() => {
if (!rosterEntry?.nightVision || !rosterEntry.nightVision.enabled) return null;
return rosterEntry.nightVision;
}, [rosterEntry]);
const emitCommand = useCallback(
(payload, cb) => {
if (!roverId) return;
@@ -134,29 +139,45 @@ export function useCommandPipeline() {
[roverId, sendOiCommand, sendDriveDirect, sendAuxMotors, sendServoAngle],
);
const sendNightVision = useCallback(
(action = 'toggle') => {
if (!roverId || !nightVision) return null;
emitCommand({
type: 'nightVision',
data: { nightVision: { action } },
});
return action;
},
[emitCommand, nightVision, roverId],
);
return useMemo(
() => ({
roverId,
rosterEntry,
servoConfig,
nightVision,
emitCommand,
enableSensorStream,
sendDriveDirect,
sendAuxMotors,
sendServoAngle,
sendOiCommand,
sendNightVision,
runMacroSteps,
}),
[
roverId,
rosterEntry,
servoConfig,
nightVision,
emitCommand,
enableSensorStream,
sendDriveDirect,
sendAuxMotors,
sendServoAngle,
sendOiCommand,
sendNightVision,
runMacroSteps,
],
);
+1
View File
@@ -36,6 +36,7 @@ export const DEFAULT_KEYMAP = {
auxAllForward: [","],
cameraUp: ['u'],
cameraDown: ['j'],
nightVisionToggle: ['e'],
driveMacro: ['f'],
dockMacro: ['g'],
chatFocus: ['enter'],
@@ -86,6 +86,7 @@ export default function KeyboardInputManager() {
runMacro,
stopAllMotion,
registerInputState,
toggleNightVision,
},
} = useControlSystem();
const { focusChat, blurChat, isChatFocused } = useChat();
@@ -208,6 +209,8 @@ export default function KeyboardInputManager() {
} else if (newlyPressed.some((token) => keymap.dockMacro?.has(token))) {
setMode('dock');
runMacro('seek-dock');
} else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) {
toggleNightVision();
}
}