horn pass

This commit is contained in:
legop3
2026-01-31 13:44:35 -05:00
parent 92ab763b27
commit b7d636c70a
26 changed files with 676 additions and 128 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+8
View File
@@ -8,6 +8,7 @@ type helloMessage struct {
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"`
NightVision NightVisionConfig `json:"nightVision"`
}
@@ -27,6 +28,7 @@ type inboundMessage struct {
Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"`
Horn *hornPayload `json:"horn,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"`
}
@@ -64,6 +66,12 @@ type ttsPayload struct {
Speak bool `json:"speak,omitempty"`
}
type hornPayload struct {
Action string `json:"action"`
Waveform string `json:"waveform,omitempty"`
Freqs []float64 `json:"freqs,omitempty"`
}
type nightVisionPayload struct {
Action string `json:"action"`
}
+31
View File
@@ -65,6 +65,14 @@ type AudioConfig struct {
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
}
type HornConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Volume float64 `yaml:"volume" json:"-"`
SampleRate int `yaml:"sampleRate" json:"-"`
Channels int `yaml:"channels" json:"-"`
Device string `yaml:"device" json:"-"`
}
type MediaConfig struct {
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
@@ -113,6 +121,7 @@ type Config struct {
Media MediaConfig `yaml:"media"`
CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"`
Horn HornConfig `yaml:"horn"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
}
@@ -160,6 +169,12 @@ func LoadConfig(path string) (*Config, error) {
DefaultVoice: "rms",
DefaultPitch: 50,
},
Horn: HornConfig{
Enabled: false,
Volume: 0.25,
SampleRate: 48000,
Channels: 1,
},
NightVision: NightVisionConfig{
Enabled: true,
GPIOPin: 22,
@@ -221,6 +236,7 @@ func LoadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("nightVision: %w", err)
}
validateAudioConfig(&cfg.Audio)
validateHornConfig(&cfg.Horn)
return &cfg, nil
}
@@ -291,6 +307,21 @@ func validateAudioConfig(cfg *AudioConfig) {
}
}
func validateHornConfig(cfg *HornConfig) {
if cfg.Volume <= 0 {
cfg.Volume = 0.25
}
if cfg.Volume > 1 {
cfg.Volume = 1
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000
}
if cfg.Channels <= 0 {
cfg.Channels = 1
}
}
func validateNightVisionConfig(cfg *NightVisionConfig) error {
if !cfg.Enabled {
return nil
+232
View File
@@ -0,0 +1,232 @@
package roverd
import (
"bufio"
"encoding/binary"
"fmt"
"log"
"math"
"os/exec"
"strings"
"sync"
"time"
)
const (
hornAttack = 20 * time.Millisecond
hornRelease = 60 * time.Millisecond
)
type HornSynth struct {
cfg HornConfig
log *log.Logger
mu sync.Mutex
stop chan struct{}
done chan struct{}
active bool
}
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
return &HornSynth{
cfg: cfg,
log: logger,
}
}
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
if payload == nil {
return fmt.Errorf("horn payload required")
}
action := strings.ToLower(strings.TrimSpace(payload.Action))
switch action {
case "start", "on", "honk":
waveform := strings.ToLower(strings.TrimSpace(payload.Waveform))
if waveform != "sine" && waveform != "saw" {
waveform = "saw"
}
freqs := sanitizeHornFreqs(payload.Freqs)
if len(freqs) == 0 {
h.Stop()
return nil
}
return h.Start(waveform, freqs)
case "stop", "off":
h.Stop()
return nil
default:
return fmt.Errorf("unsupported horn action: %s", payload.Action)
}
}
func (h *HornSynth) Start(waveform string, freqs []float64) error {
h.mu.Lock()
h.stopLocked()
stop := make(chan struct{})
done := make(chan struct{})
h.stop = stop
h.done = done
h.active = true
h.mu.Unlock()
go h.run(waveform, freqs, stop, done)
return nil
}
func (h *HornSynth) Stop() {
h.mu.Lock()
h.stopLocked()
h.mu.Unlock()
}
func (h *HornSynth) stopLocked() {
if !h.active {
return
}
if h.stop != nil {
close(h.stop)
}
if h.done != nil {
<-h.done
}
h.stop = nil
h.done = nil
h.active = false
}
func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{}, done chan<- struct{}) {
defer close(done)
rate := h.cfg.SampleRate
if rate <= 0 {
rate = 48000
}
channels := h.cfg.Channels
if channels <= 0 {
channels = 1
}
volume := h.cfg.Volume
if volume <= 0 {
volume = 0.25
}
if volume > 1 {
volume = 1
}
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
if h.cfg.Device != "" {
args = append(args, "-D", h.cfg.Device)
}
cmd := exec.Command("aplay", args...)
stdin, err := cmd.StdinPipe()
if err != nil {
h.log.Printf("horn: aplay stdin failed: %v", err)
return
}
if err := cmd.Start(); err != nil {
h.log.Printf("horn: aplay start failed: %v", err)
_ = stdin.Close()
return
}
writer := bufio.NewWriterSize(stdin, 32*1024)
if err := h.synthLoop(writer, waveform, freqs, rate, channels, volume, stop); err != nil {
h.log.Printf("horn: synth failed: %v", err)
}
_ = writer.Flush()
_ = stdin.Close()
if err := cmd.Wait(); err != nil {
h.log.Printf("horn: aplay exit: %v", err)
}
}
func (h *HornSynth) synthLoop(writer *bufio.Writer, waveform string, freqs []float64, rate, channels int, volume float64, stop <-chan struct{}) error {
phase := make([]float64, len(freqs))
increment := make([]float64, len(freqs))
for i, f := range freqs {
increment[i] = 2 * math.Pi * f / float64(rate)
}
attackFrames := int(float64(rate) * hornAttack.Seconds())
releaseFrames := int(float64(rate) * hornRelease.Seconds())
framesPerChunk := 512
buf := make([]byte, framesPerChunk*channels*2)
scale := volume / float64(len(freqs))
stopRequested := false
releaseStart := -1
sampleIndex := 0
for {
if !stopRequested {
select {
case <-stop:
stopRequested = true
releaseStart = sampleIndex
default:
}
}
for i := 0; i < framesPerChunk; i++ {
env := 1.0
if attackFrames > 0 && sampleIndex < attackFrames {
env = float64(sampleIndex) / float64(attackFrames)
} else if stopRequested && releaseFrames > 0 {
relIndex := sampleIndex - releaseStart
if relIndex >= releaseFrames {
return nil
}
env = float64(releaseFrames-relIndex) / float64(releaseFrames)
} else if stopRequested {
return nil
}
sample := 0.0
for j := range freqs {
switch waveform {
case "sine":
sample += math.Sin(phase[j])
default:
sample += sawFromPhase(phase[j])
}
phase[j] += increment[j]
if phase[j] > 2*math.Pi {
phase[j] -= 2 * math.Pi
}
}
sample *= scale * env
if sample > 1.0 {
sample = 1.0
} else if sample < -1.0 {
sample = -1.0
}
intSample := int16(sample * math.MaxInt16)
offset := i * channels * 2
for ch := 0; ch < channels; ch++ {
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
}
sampleIndex++
}
if _, err := writer.Write(buf); err != nil {
return err
}
}
}
func sanitizeHornFreqs(freqs []float64) []float64 {
if len(freqs) == 0 {
return nil
}
out := make([]float64, 0, 4)
for _, f := range freqs {
if len(out) >= 4 {
break
}
if f <= 0 {
continue
}
out = append(out, f)
}
return out
}
func sawFromPhase(phase float64) float64 {
return 2.0*(phase/(2*math.Pi)) - 1.0
}
+5
View File
@@ -45,6 +45,11 @@ audio:
defaultEngine: flite
defaultVoice: rms
defaultPitch: 50
horn:
enabled: false
volume: 0.25
sampleRate: 48000
channels: 1
nightVision:
enabled: true
gpioPin: 22
+12
View File
@@ -20,6 +20,7 @@ type WSClient struct {
events chan RoverEvent
media *MediaSupervisor
servo *CameraServo
horn *HornSynth
nightVision *NightVisionLight
log *log.Logger
recoverMu sync.Mutex
@@ -38,6 +39,10 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2)
}
var horn *HornSynth
if cfg.Horn.Enabled {
horn = NewHornSynth(cfg.Horn, logger)
}
return &WSClient{
cfg: cfg,
adapter: adapter,
@@ -45,6 +50,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
events: events,
media: media,
servo: servo,
horn: horn,
nightVision: nightVision,
log: logger,
ttsQueue: ttsQueue,
@@ -101,6 +107,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio,
Horn: c.cfg.Horn,
NightVision: c.cfg.NightVision,
}
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
@@ -180,6 +187,11 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.handleServoCommand(msg.Servo)
case msg.TTS != nil:
return c.enqueueTTS(msg.TTS)
case msg.Horn != nil:
if c.horn == nil {
return fmt.Errorf("horn disabled")
}
return c.horn.HandlePayload(msg.Horn)
case msg.NightVision != nil:
if c.nightVision == nil {
return fmt.Errorf("night vision disabled")
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-CJ2tCddN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BPUZmTmb.css">
<script type="module" crossorigin src="/assets/index-BCh_wcG5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B5cgjyeo.css">
</head>
<body>
<div id="root"></div>
+1
View File
@@ -151,6 +151,7 @@ function getRoster() {
media: record.meta?.media,
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,
+13 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useControlSystem } from '../controls/index.js';
import { formatKeyLabel } from '../controls/keymapUtils.js';
import NightVisionControl from './NightVisionControl.jsx';
import HornControl from './HornControl.jsx';
const SLIDER_THROTTLE_MS = 150;
@@ -14,13 +15,15 @@ export default function CameraServoPanel() {
const {
state: { roverId, camera, keymap },
pipeline,
actions: { setServoAngle, nudgeServo, goServoHome, setNightVision },
actions: { setServoAngle, nudgeServo, goServoHome, setNightVision, startHorn, stopHorn },
} = useControlSystem();
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState;
const hornAvailable = Boolean(roverId && pipeline?.horn);
const nightVisionKey = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
const hornKey = formatKeyLabel(keymap?.hornHonk?.[0]);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
@@ -30,7 +33,7 @@ export default function CameraServoPanel() {
? config.homeAngle
: (min + max) / 2;
if (!enabled && !nightVisionAvailable) return null;
if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
const [pendingAngle, setPendingAngle] = useState(value);
const throttleRef = useRef(null);
@@ -101,6 +104,14 @@ export default function CameraServoPanel() {
keyLabel={nightVisionKey}
/>
)}
{hornAvailable && (
<HornControl
disabled={!roverId}
onStart={startHorn}
onStop={stopHorn}
keyLabel={hornKey}
/>
)}
{enabled && (
<>
<div className="flex items-center justify-between text-sm text-slate-300">
+8 -2
View File
@@ -7,6 +7,7 @@ import TopDownMap from './TopDownMap.jsx';
import RoverRoster from './RoverRoster.jsx';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
import NightVisionControl from './NightVisionControl.jsx';
import HornControl from './HornControl.jsx';
export function RoverRosterPanel({ title = 'Rovers' }) {
const { session, requestControl } = useSession();
@@ -76,13 +77,14 @@ export function InlineCameraTilt({ keymap }) {
const {
state: { roverId, camera },
pipeline,
actions: { setServoAngle, setNightVision },
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
} = useControlSystem();
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState;
const hornAvailable = Boolean(roverId && pipeline?.horn);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
@@ -113,10 +115,11 @@ export function InlineCameraTilt({ keymap }) {
draggingRef.current = false;
};
if (!enabled && !nightVisionAvailable) return null;
if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
return (
<div className="surface space-y-0.5 p-0 text-sm text-slate-200">
@@ -128,6 +131,9 @@ export function InlineCameraTilt({ keymap }) {
keyLabel={nightVisionLabel}
/>
)}
{hornAvailable && (
<HornControl disabled={!roverId} onStart={startHorn} onStop={stopHorn} keyLabel={hornLabel} />
)}
{enabled && (
<div className="space-y-0.5 px-1 py-1">
<div className="flex items-center justify-between text-xs text-slate-300">
+72
View File
@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react';
export default function HornControl({
disabled,
onStart,
onStop,
keyLabel,
className = '',
}) {
const [pressed, setPressed] = useState(false);
const buttonClasses = useMemo(() => {
const base =
'group flex w-full items-center justify-between rounded-xl border-2 px-1 py-0.75 text-xs font-semibold';
const active = 'border-rose-300/70 bg-rose-800 text-rose-50 hover:bg-rose-700';
const inactive = 'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800';
return [base, pressed ? active : inactive, 'disabled:opacity-50', className]
.filter(Boolean)
.join(' ');
}, [className, pressed]);
const start = () => {
if (disabled) return;
if (!pressed) {
setPressed(true);
onStart?.();
}
};
const stop = () => {
if (pressed) {
setPressed(false);
onStop?.();
}
};
return (
<button
type="button"
onPointerDown={(event) => {
event.preventDefault();
start();
}}
onPointerUp={(event) => {
event.preventDefault();
stop();
}}
onPointerLeave={stop}
onPointerCancel={stop}
onBlur={stop}
disabled={disabled}
aria-pressed={pressed}
className={buttonClasses}
>
<span className="flex items-center gap-0.5">
<span>Horn</span>
{keyLabel ? (
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
{keyLabel}
</span>
) : null}
</span>
<span
className={`rounded px-1 py-0.5 text-[0.65rem] font-semibold ${
pressed ? 'bg-rose-400 text-rose-950' : 'bg-slate-700 text-slate-200'
}`}
>
{pressed ? 'HONK' : 'Hold'}
</span>
</button>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSettingsNamespace } from '../settings/index.js';
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
function clampFreq(value) {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
if (num <= 0) return 0;
return Math.min(5000, Math.round(num));
}
export default function HornSettings() {
const { value: hornSettings, save: saveHornSettings } = useSettingsNamespace(
'horn',
HORN_SETTINGS_DEFAULTS,
);
const [waveform, setWaveform] = useState(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform);
const [freqs, setFreqs] = useState(() => {
const base = Array.isArray(hornSettings?.freqs) ? hornSettings.freqs : HORN_SETTINGS_DEFAULTS.freqs;
return [...base, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f));
});
useEffect(() => {
setWaveform(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform);
if (Array.isArray(hornSettings?.freqs)) {
setFreqs([...hornSettings.freqs, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f)));
}
}, [hornSettings?.freqs, hornSettings?.waveform]);
const formattedWaveform = useMemo(
() => (waveform === 'sine' ? 'sine' : 'saw'),
[waveform],
);
const updateWaveform = useCallback(
(event) => {
const next = event.target.value === 'sine' ? 'sine' : 'saw';
setWaveform(next);
saveHornSettings((current) => ({ ...(current ?? {}), waveform: next }));
},
[saveHornSettings],
);
const updateFreq = useCallback(
(index, value) => {
setFreqs((prev) => {
const next = [...prev];
next[index] = clampFreq(value);
saveHornSettings((current) => ({ ...(current ?? {}), freqs: next }));
return next;
});
},
[saveHornSettings],
);
return (
<section className="panel-section space-y-0.5 text-sm">
<p className="text-slate-400">Horn</p>
<label className="flex items-center justify-between gap-0.5 text-slate-200">
<span>Waveform</span>
<select value={formattedWaveform} onChange={updateWaveform} className="field-input text-sm">
<option value="saw">Saw</option>
<option value="sine">Sine</option>
</select>
</label>
<div className="grid grid-cols-2 gap-0.5">
{freqs.map((freq, idx) => (
<label key={`horn-freq-${idx}`} className="surface-muted flex items-center justify-between gap-0.5">
<span className="text-[0.7rem] text-slate-300">Freq {idx + 1}</span>
<input
type="number"
min={0}
max={5000}
step={1}
value={freq}
onChange={(event) => updateFreq(idx, event.target.value)}
className="w-20 rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.75rem] font-mono text-slate-100"
/>
</label>
))}
</div>
<p className="text-xs text-slate-500">Set a frequency to 0 to disable that oscillator.</p>
</section>
);
}
+1
View File
@@ -22,6 +22,7 @@ const KEY_ACTIONS = [
{ id: 'cameraUp', label: 'Camera Up', group: 'Camera' },
{ id: 'cameraDown', label: 'Camera Down', group: 'Camera' },
{ id: 'nightVisionToggle', label: 'Toggle Night Vision', group: 'Camera' },
{ id: 'hornHonk', label: 'Horn (Hold)', group: 'Audio' },
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
+6 -1
View File
@@ -3,6 +3,7 @@ import { useControlSystem } from '../controls/index.js';
import { clampUnit } from '../controls/controlMath.js';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
import NightVisionControl from './NightVisionControl.jsx';
import HornControl from './HornControl.jsx';
const SOURCE = 'mobile-joystick';
const JOYSTICK_RADIUS = 80;
@@ -135,7 +136,7 @@ function MobileJoystickPanel({ layout }) {
const {
state: { roverId, camera },
pipeline,
actions: { setDriveVector, registerInputState, setServoAngle, setNightVision },
actions: { setDriveVector, registerInputState, setServoAngle, setNightVision, startHorn, stopHorn },
} = useControlSystem();
const driveDockState = useDriveDockState(roverId);
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
@@ -144,6 +145,7 @@ function MobileJoystickPanel({ layout }) {
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState;
const hornAvailable = Boolean(roverId && pipeline?.horn);
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
const cameraValue =
@@ -223,6 +225,9 @@ function MobileJoystickPanel({ layout }) {
onToggle={handleNightVisionToggle}
/>
)}
{hornAvailable && (
<HornControl disabled={disabled} onStart={startHorn} onStop={stopHorn} />
)}
{cameraEnabled && (
<div className="bg-zinc-950 p-0.5 text-xs">
<div className="flex items-center justify-between text-[0.75rem] text-slate-400">
+2
View File
@@ -8,6 +8,7 @@ import OvercurrentLimiterPanel from './OvercurrentLimiterPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
import SessionSnapshot from './SessionSnapshot.jsx';
import SocketLogPanel from './SocketLogPanel.jsx';
import HornSettings from './HornSettings.jsx';
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
import { useSettingsNamespace } from '../settings/index.js';
import { useSocket } from '../context/SocketContext.jsx';
@@ -67,6 +68,7 @@ export default function SettingsPanel() {
<TabPanel id="keybindings">
<div className="space-y-0.5">
<KeymapSettings />
<HornSettings />
</div>
</TabPanel>
<TabPanel id="controller">
+31
View File
@@ -6,6 +6,7 @@ import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.j
import { canonicalizeKeyInput } from './keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx';
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import {
applyAuxOvercurrentScale,
applyDriveOvercurrentScale,
@@ -36,6 +37,7 @@ export function ControlSystemProvider({ children }) {
value: controlSettings,
save: saveControlSettings,
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
const { value: hornSettings } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
const { session, homeAssistantSetState } = useSession();
const roverId = session?.assignment?.roverId ?? null;
const overcurrentLimiter = useOvercurrentLimiter(roverId);
@@ -346,6 +348,31 @@ export function ControlSystemProvider({ children }) {
[pipeline],
);
const normalizedHornSettings = useMemo(() => {
const base = hornSettings ?? HORN_SETTINGS_DEFAULTS;
const waveform = base.waveform === 'sine' ? 'sine' : 'saw';
const freqs = Array.isArray(base.freqs) ? base.freqs : HORN_SETTINGS_DEFAULTS.freqs;
const normalized = [...freqs, 0, 0, 0, 0]
.slice(0, 4)
.map((value) => {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
return num <= 0 ? 0 : Math.min(5000, Math.round(num));
});
return { waveform, freqs: normalized };
}, [hornSettings]);
const startHorn = useCallback(() => {
if (!pipeline.horn) return;
pipeline.sendHorn({ action: 'start', ...normalizedHornSettings });
recordControlIntent();
}, [normalizedHornSettings, pipeline, recordControlIntent]);
const stopHorn = useCallback(() => {
if (!pipeline.horn) return;
pipeline.sendHorn({ action: 'stop' });
}, [pipeline]);
const registerInputState = useCallback((source, data) => {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
}, []);
@@ -374,6 +401,8 @@ export function ControlSystemProvider({ children }) {
registerInputState,
setSongNote,
sendSong,
startHorn,
stopHorn,
},
}),
[
@@ -397,6 +426,8 @@ export function ControlSystemProvider({ children }) {
registerInputState,
setSongNote,
sendSong,
startHorn,
stopHorn,
],
);
+21
View File
@@ -32,6 +32,11 @@ export function useCommandPipeline(options = {}) {
return rosterEntry.nightVision;
}, [rosterEntry]);
const horn = useMemo(() => {
if (!rosterEntry?.horn || !rosterEntry.horn.enabled) return null;
return rosterEntry.horn;
}, [rosterEntry]);
const nightVisionState = useMemo(() => rosterEntry?.nightVision?.state ?? null, [rosterEntry]);
const emitCommand = useCallback(
@@ -172,6 +177,18 @@ export function useCommandPipeline(options = {}) {
[emitCommand, nightVision, roverId],
);
const sendHorn = useCallback(
(payload) => {
if (!roverId) return null;
emitCommand({
type: 'horn',
data: { horn: payload },
});
return payload;
},
[emitCommand, roverId],
);
const sendSong = useCallback(
(notes = [], options = {}) => {
if (!roverId) return null;
@@ -205,6 +222,7 @@ export function useCommandPipeline(options = {}) {
servoConfig,
nightVision,
nightVisionState,
horn,
emitCommand,
enableSensorStream,
sendDriveDirect,
@@ -212,6 +230,7 @@ export function useCommandPipeline(options = {}) {
sendServoAngle,
sendOiCommand,
sendNightVision,
sendHorn,
sendSong,
runMacroSteps,
}),
@@ -221,6 +240,7 @@ export function useCommandPipeline(options = {}) {
servoConfig,
nightVision,
nightVisionState,
horn,
emitCommand,
enableSensorStream,
sendDriveDirect,
@@ -228,6 +248,7 @@ export function useCommandPipeline(options = {}) {
sendServoAngle,
sendOiCommand,
sendNightVision,
sendHorn,
runMacroSteps,
],
);
+1
View File
@@ -42,6 +42,7 @@ export const DEFAULT_KEYMAP = {
cameraUp: ['u'],
cameraDown: ['j'],
nightVisionToggle: ['e'],
hornHonk: ['h'],
driveMacro: ['f'],
dockMacro: ['g'],
chatFocus: ['enter'],
@@ -126,6 +126,8 @@ export default function KeyboardInputManager() {
stopAllMotion,
registerInputState,
toggleNightVision,
startHorn,
stopHorn,
setSongNote,
sendSong,
},
@@ -174,6 +176,7 @@ export default function KeyboardInputManager() {
const lastAuxRef = useRef(ZERO_AUX);
const servoIntervalRef = useRef(null);
const songIntervalRef = useRef(null);
const hornActiveRef = useRef(false);
const driveFromKeys = useCallback(() => {
const tokensSnapshot = new Set(activeTokensRef.current);
@@ -299,9 +302,13 @@ export default function KeyboardInputManager() {
lastAuxRef.current = ZERO_AUX;
stopServoLoop();
stopSongLoop();
if (hornActiveRef.current) {
hornActiveRef.current = false;
stopHorn();
}
stopAllMotion();
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
}, [registerInputState, stopAllMotion, stopServoLoop, stopSongLoop]);
}, [registerInputState, stopAllMotion, stopHorn, stopServoLoop, stopSongLoop]);
const triggerHomeAssistantCycle = useCallback(
(targetState) => {
@@ -364,6 +371,11 @@ export default function KeyboardInputManager() {
runMacro('seek-dock');
} else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) {
toggleNightVision();
} else if (newlyPressed.some((token) => keymap.hornHonk?.has(token))) {
if (!hornActiveRef.current) {
hornActiveRef.current = true;
startHorn();
}
} else if (newlyPressed.some((token) => keymap.homeAssistantOn?.has(token))) {
triggerHomeAssistantCycle('on');
} else if (newlyPressed.some((token) => keymap.homeAssistantOff?.has(token))) {
@@ -379,6 +391,10 @@ export default function KeyboardInputManager() {
function handleKeyUp(event) {
const tokens = tokensForEvent(event);
tokens.forEach((token) => activeTokensRef.current.delete(token));
if (hornActiveRef.current && !bindingActive(keymap.hornHonk, activeTokensRef.current)) {
hornActiveRef.current = false;
stopHorn();
}
ensureServoLoop();
ensureSongLoop();
driveFromKeys();
@@ -407,11 +423,14 @@ export default function KeyboardInputManager() {
keymap.chatFocus,
keymap.dockMacro,
keymap.driveMacro,
keymap.hornHonk,
resetAll,
runMacro,
setMode,
stopAllMotion,
stopSongLoop,
startHorn,
stopHorn,
triggerHomeAssistantCycle,
]);
+5
View File
@@ -85,3 +85,8 @@ export const GAMEPAD_SETTINGS_DEFAULTS = {
profile: GAMEPAD_PROFILE_DEFAULT,
},
};
export const HORN_SETTINGS_DEFAULTS = {
waveform: 'saw',
freqs: [440, 550, 660, 0],
};