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() 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) autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
go autoCharge.Run(ctx, sensorSamples) go autoCharge.Run(ctx, sensorSamples)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, logger) client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, logger)
retryDelay := time.Second retryDelay := time.Second
for ctx.Err() == nil { for ctx.Err() == nil {
+6
View File
@@ -8,6 +8,7 @@ type helloMessage struct {
Media MediaConfig `json:"media"` Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"` CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"` Audio AudioConfig `json:"audio"`
NightVision NightVisionConfig `json:"nightVision"`
} }
type sensorMessage struct { type sensorMessage struct {
@@ -26,6 +27,7 @@ type inboundMessage struct {
Media *mediaCommand `json:"media,omitempty"` Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"` Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"` TTS *ttsPayload `json:"tts,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
} }
type driveDirectPayload struct { type driveDirectPayload struct {
@@ -61,6 +63,10 @@ type ttsPayload struct {
Speak bool `json:"speak,omitempty"` Speak bool `json:"speak,omitempty"`
} }
type nightVisionPayload struct {
Action string `json:"action"`
}
type ackMessage struct { type ackMessage struct {
Type string `json:"type"` Type string `json:"type"`
ID string `json:"id"` ID string `json:"id"`
+30
View File
@@ -95,6 +95,13 @@ type CameraServoConfig struct {
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"` 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 { type Config struct {
Name string `yaml:"name"` Name string `yaml:"name"`
ServerURL string `yaml:"serverUrl"` ServerURL string `yaml:"serverUrl"`
@@ -105,6 +112,7 @@ type Config struct {
Media MediaConfig `yaml:"media"` Media MediaConfig `yaml:"media"`
CameraServo CameraServoConfig `yaml:"cameraServo"` CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"` Audio AudioConfig `yaml:"audio"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
@@ -154,6 +162,12 @@ func LoadConfig(path string) (*Config, error) {
DefaultVoice: "rms", DefaultVoice: "rms",
DefaultPitch: 50, DefaultPitch: 50,
}, },
NightVision: NightVisionConfig{
Enabled: true,
GPIOPin: 22,
GPIOChip: "gpiochip0",
InitialOn: true,
},
} }
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err return nil, err
@@ -214,6 +228,9 @@ func LoadConfig(path string) (*Config, error) {
if err := validateServoConfig(&cfg.CameraServo); err != nil { if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err) return nil, fmt.Errorf("cameraServo: %w", err)
} }
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
return nil, fmt.Errorf("nightVision: %w", err)
}
validateAudioConfig(&cfg.Audio) validateAudioConfig(&cfg.Audio)
return &cfg, nil 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) { func derivePublishURL(serverURL, streamName string, port int) (string, error) {
if streamName == "" { if streamName == "" {
return "", errors.New("missing stream name for publishUrl") 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 defaultEngine: flite
defaultVoice: rms defaultVoice: rms
defaultPitch: 50 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 events <-chan RoverEvent
media *MediaSupervisor media *MediaSupervisor
servo *CameraServo servo *CameraServo
nightVision *NightVisionLight
log *log.Logger 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{ return &WSClient{
cfg: cfg, cfg: cfg,
adapter: adapter, adapter: adapter,
@@ -29,6 +30,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
events: events, events: events,
media: media, media: media,
servo: servo, servo: servo,
nightVision: nightVision,
log: logger, log: logger,
} }
} }
@@ -72,6 +74,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Media: c.cfg.Media, Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo, CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio, 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) c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
return writeJSON(ctx, conn, msg) return writeJSON(ctx, conn, msg)
@@ -153,6 +156,11 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.handleServoCommand(msg.Servo) return c.handleServoCommand(msg.Servo)
case msg.TTS != nil: case msg.TTS != nil:
return c.handleTTSPayload(msg.TTS) 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: default:
return fmt.Errorf("unsupported command type: %s", msg.Type) 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-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CnvHZcbU.js"></script> <script type="module" crossorigin src="/assets/index-D8Lb2mUY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1UrCI1T.css"> <link rel="stylesheet" crossorigin href="/assets/index-DQ1jpCiS.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1
View File
@@ -119,6 +119,7 @@ function getRoster() {
media: record.meta?.media, media: record.meta?.media,
cameraServo: record.meta?.cameraServo, cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio, audio: record.meta?.audio,
nightVision: record.meta?.nightVision,
locked: record.locked, locked: record.locked,
lockReason: record.lockReason, lockReason: record.lockReason,
lastSeen: record.lastSeen, lastSeen: record.lastSeen,
+1
View File
@@ -19,6 +19,7 @@ const KEY_ACTIONS = [
{ id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' }, { id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' },
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' }, { id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
{ id: 'cameraDown', label: 'Camera Down', group: 'Camera' }, { id: 'cameraDown', label: 'Camera Down', group: 'Camera' },
{ id: 'nightVisionToggle', label: 'Toggle Night Vision', group: 'Camera' },
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' }, { id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' }, { id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' }, { 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 }) { function MobileJoystickPanel({ layout }) {
const { const {
state: { roverId, camera }, state: { roverId, camera },
actions: { setDriveVector, registerInputState, stopAllMotion, setServoAngle }, pipeline,
actions: { setDriveVector, registerInputState, stopAllMotion, setServoAngle, toggleNightVision },
} = useControlSystem(); } = useControlSystem();
const disabled = !roverId; const disabled = !roverId;
const cameraConfig = camera?.config; const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig); const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45; const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45; const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
const cameraValue = const cameraValue =
@@ -198,6 +200,16 @@ function MobileJoystickPanel({ layout }) {
<div className="flex flex-col gap-0.5 text-slate-100"> <div className="flex flex-col gap-0.5 text-slate-100">
<DriveModeToggle size="compact" /> <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 && ( {cameraEnabled && (
<div className="bg-zinc-950 p-0.5 text-xs"> <div className="bg-zinc-950 p-0.5 text-xs">
<div className="flex items-center justify-between text-[0.75rem] text-slate-400"> <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], [pipeline],
); );
const toggleNightVision = useCallback(() => {
pipeline.sendNightVision('toggle');
}, [pipeline]);
const registerInputState = useCallback((source, data) => { const registerInputState = useCallback((source, data) => {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } }); dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
}, []); }, []);
@@ -274,6 +278,7 @@ export function ControlSystemProvider({ children }) {
stopAllMotion, stopAllMotion,
sendOiCommand, sendOiCommand,
setSensorStream, setSensorStream,
toggleNightVision,
updateKeyBinding, updateKeyBinding,
resetKeyBindings, resetKeyBindings,
registerInputState, registerInputState,
@@ -292,6 +297,7 @@ export function ControlSystemProvider({ children }) {
stopAllMotion, stopAllMotion,
sendOiCommand, sendOiCommand,
setSensorStream, setSensorStream,
toggleNightVision,
updateKeyBinding, updateKeyBinding,
resetKeyBindings, resetKeyBindings,
registerInputState, registerInputState,
+21
View File
@@ -19,6 +19,11 @@ export function useCommandPipeline() {
return rosterEntry.cameraServo; return rosterEntry.cameraServo;
}, [rosterEntry]); }, [rosterEntry]);
const nightVision = useMemo(() => {
if (!rosterEntry?.nightVision || !rosterEntry.nightVision.enabled) return null;
return rosterEntry.nightVision;
}, [rosterEntry]);
const emitCommand = useCallback( const emitCommand = useCallback(
(payload, cb) => { (payload, cb) => {
if (!roverId) return; if (!roverId) return;
@@ -134,29 +139,45 @@ export function useCommandPipeline() {
[roverId, sendOiCommand, sendDriveDirect, sendAuxMotors, sendServoAngle], [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( return useMemo(
() => ({ () => ({
roverId, roverId,
rosterEntry, rosterEntry,
servoConfig, servoConfig,
nightVision,
emitCommand, emitCommand,
enableSensorStream, enableSensorStream,
sendDriveDirect, sendDriveDirect,
sendAuxMotors, sendAuxMotors,
sendServoAngle, sendServoAngle,
sendOiCommand, sendOiCommand,
sendNightVision,
runMacroSteps, runMacroSteps,
}), }),
[ [
roverId, roverId,
rosterEntry, rosterEntry,
servoConfig, servoConfig,
nightVision,
emitCommand, emitCommand,
enableSensorStream, enableSensorStream,
sendDriveDirect, sendDriveDirect,
sendAuxMotors, sendAuxMotors,
sendServoAngle, sendServoAngle,
sendOiCommand, sendOiCommand,
sendNightVision,
runMacroSteps, runMacroSteps,
], ],
); );
+1
View File
@@ -36,6 +36,7 @@ export const DEFAULT_KEYMAP = {
auxAllForward: [","], auxAllForward: [","],
cameraUp: ['u'], cameraUp: ['u'],
cameraDown: ['j'], cameraDown: ['j'],
nightVisionToggle: ['e'],
driveMacro: ['f'], driveMacro: ['f'],
dockMacro: ['g'], dockMacro: ['g'],
chatFocus: ['enter'], chatFocus: ['enter'],
@@ -86,6 +86,7 @@ export default function KeyboardInputManager() {
runMacro, runMacro,
stopAllMotion, stopAllMotion,
registerInputState, registerInputState,
toggleNightVision,
}, },
} = useControlSystem(); } = useControlSystem();
const { focusChat, blurChat, isChatFocused } = useChat(); const { focusChat, blurChat, isChatFocused } = useChat();
@@ -208,6 +209,8 @@ export default function KeyboardInputManager() {
} else if (newlyPressed.some((token) => keymap.dockMacro?.has(token))) { } else if (newlyPressed.some((token) => keymap.dockMacro?.has(token))) {
setMode('dock'); setMode('dock');
runMacro('seek-dock'); runMacro('seek-dock');
} else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) {
toggleNightVision();
} }
} }