This commit is contained in:
legop3
2026-01-31 16:22:45 -05:00
parent 00d1edd11b
commit dc92785f19
12 changed files with 213 additions and 158 deletions
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-B4BVCZld.js"></script> <script type="module" crossorigin src="/assets/index-BaI6Xr6X.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQCz6Dh4.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bi3rvCda.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -2
View File
@@ -24,7 +24,7 @@ export default function CameraServoPanel() {
const hornAvailable = Boolean(roverId && pipeline?.horn); const hornAvailable = Boolean(roverId && pipeline?.horn);
const nightVisionKey = formatKeyLabel(keymap?.nightVisionToggle?.[0]); const nightVisionKey = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
const hornKey = formatKeyLabel(keymap?.hornHonk?.[0]); const hornKey = formatKeyLabel(keymap?.hornHonk?.[0]);
const hornCooldownActive = (horn?.cooldownUntil ?? 0) > Date.now(); const hornBlocked = horn?.overheated;
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30; const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30; const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value = const value =
@@ -107,11 +107,12 @@ export default function CameraServoPanel() {
)} )}
{hornAvailable && ( {hornAvailable && (
<HornControl <HornControl
disabled={!roverId || hornCooldownActive} disabled={!roverId || hornBlocked}
onStart={startHorn} onStart={startHorn}
onStop={stopHorn} onStop={stopHorn}
keyLabel={hornKey} keyLabel={hornKey}
active={horn?.active} active={horn?.active}
heat={horn?.heat}
/> />
)} )}
{enabled && ( {enabled && (
+3 -2
View File
@@ -85,7 +85,7 @@ export function InlineCameraTilt({ keymap }) {
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision); const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState; const nightVisionState = pipeline?.nightVisionState;
const hornAvailable = Boolean(roverId && pipeline?.horn); const hornAvailable = Boolean(roverId && pipeline?.horn);
const hornCooldownActive = (horn?.cooldownUntil ?? 0) > Date.now(); const hornBlocked = horn?.overheated;
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30; const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30; const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value = const value =
@@ -134,11 +134,12 @@ export function InlineCameraTilt({ keymap }) {
)} )}
{hornAvailable && ( {hornAvailable && (
<HornControl <HornControl
disabled={!roverId || hornCooldownActive} disabled={!roverId || hornBlocked}
onStart={startHorn} onStart={startHorn}
onStop={stopHorn} onStop={stopHorn}
keyLabel={hornLabel} keyLabel={hornLabel}
active={horn?.active} active={horn?.active}
heat={horn?.heat}
/> />
)} )}
{enabled && ( {enabled && (
+27 -3
View File
@@ -15,6 +15,7 @@ export default function HornControl({
onStop, onStop,
keyLabel, keyLabel,
active, active,
heat = 0,
className = '', className = '',
}) { }) {
const [pressed, setPressed] = useState(false); const [pressed, setPressed] = useState(false);
@@ -36,9 +37,10 @@ export default function HornControl({
}, [hornSettings?.freqs, hornSettings?.waveform]); }, [hornSettings?.freqs, hornSettings?.waveform]);
const isActive = Boolean(active); const isActive = Boolean(active);
const clampedHeat = Math.max(0, Math.min(1, Number(heat) || 0));
const buttonClasses = useMemo(() => { const buttonClasses = useMemo(() => {
const base = const base =
'group flex w-full flex-col gap-0.5 rounded-xl border-2 px-1 py-1.5 text-xs font-semibold'; 'group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold';
const active = 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50'; const active = 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50';
const inactive = 'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800'; const inactive = 'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800';
return [base, isActive ? active : inactive, 'disabled:opacity-50', className] return [base, isActive ? active : inactive, 'disabled:opacity-50', className]
@@ -61,6 +63,12 @@ export default function HornControl({
} }
}; };
useEffect(() => {
if (!isActive && pressed) {
setPressed(false);
}
}, [isActive, pressed]);
const formattedWaveform = waveform === 'sine' ? 'sine' : 'saw'; const formattedWaveform = waveform === 'sine' ? 'sine' : 'saw';
const updateWaveform = useCallback( const updateWaveform = useCallback(
@@ -111,7 +119,23 @@ export default function HornControl({
onBlur={stop} onBlur={stop}
className={buttonClasses} className={buttonClasses}
> >
<div className="flex items-center justify-between gap-0.5"> <div
className="pointer-events-none absolute inset-0 opacity-70"
style={{
background: `linear-gradient(90deg, rgba(14,116,144,0.7) ${clampedHeat * 100}%, rgba(0,0,0,0) ${
clampedHeat * 100
}%)`,
}}
/>
<div
className={`pointer-events-none absolute inset-0 transition-opacity ${
isActive ? 'opacity-40' : 'opacity-0'
}`}
style={{
background: 'linear-gradient(90deg, rgba(217,70,239,0.75), rgba(244,114,182,0.35))',
}}
/>
<div className="relative z-10 flex items-center justify-between gap-0.5">
<span className="flex items-center gap-0.5 text-sm font-semibold"> <span className="flex items-center gap-0.5 text-sm font-semibold">
<span>Horn</span> <span>Horn</span>
{keyLabel ? ( {keyLabel ? (
@@ -128,7 +152,7 @@ export default function HornControl({
{isActive ? 'Honk' : 'Hold'} {isActive ? 'Honk' : 'Hold'}
</span> </span>
</div> </div>
<div className="grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]"> <div className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]">
<label className="flex items-center gap-0.5 text-slate-200"> <label className="flex items-center gap-0.5 text-slate-200">
<span className="text-slate-300">Wave</span> <span className="text-slate-300">Wave</span>
<select <select
+3 -2
View File
@@ -146,7 +146,7 @@ function MobileJoystickPanel({ layout }) {
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision); const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState; const nightVisionState = pipeline?.nightVisionState;
const hornAvailable = Boolean(roverId && pipeline?.horn); const hornAvailable = Boolean(roverId && pipeline?.horn);
const hornCooldownActive = (horn?.cooldownUntil ?? 0) > Date.now(); const hornBlocked = horn?.overheated;
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 =
@@ -228,10 +228,11 @@ function MobileJoystickPanel({ layout }) {
)} )}
{hornAvailable && ( {hornAvailable && (
<HornControl <HornControl
disabled={disabled || hornCooldownActive} disabled={disabled || hornBlocked}
onStart={startHorn} onStart={startHorn}
onStop={stopHorn} onStop={stopHorn}
active={horn?.active} active={horn?.active}
heat={horn?.heat}
/> />
)} )}
{cameraEnabled && ( {cameraEnabled && (
+43 -23
View File
@@ -2,7 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useReducer,
import { controlReducer, initialControlState } from './controlReducer.js'; import { controlReducer, initialControlState } from './controlReducer.js';
import { computeDifferentialSpeeds, clamp } from './controlMath.js'; import { computeDifferentialSpeeds, clamp } from './controlMath.js';
import { useCommandPipeline } from './commandPipeline.js'; import { useCommandPipeline } from './commandPipeline.js';
import { DEFAULT_KEYMAP, DEFAULT_MACROS, HORN_COOLDOWN_RATIO, HORN_MAX_MS, SONG_DEFAULT_NOTE } from './constants.js'; import {
DEFAULT_KEYMAP,
DEFAULT_MACROS,
HORN_HEAT_COOL_PER_SEC,
HORN_HEAT_RESUME_THRESHOLD,
HORN_HEAT_UP_PER_SEC,
HORN_MAX_MS,
SONG_DEFAULT_NOTE,
} from './constants.js';
import { canonicalizeKeyInput } from './keymapUtils.js'; import { canonicalizeKeyInput } from './keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
@@ -34,8 +42,8 @@ export function ControlSystemProvider({ children }) {
const pendingLightsRef = useRef(false); const pendingLightsRef = useRef(false);
const servoAngleRef = useRef(initialControlState.camera.angle); const servoAngleRef = useRef(initialControlState.camera.angle);
const hornAutoStopRef = useRef(null); const hornAutoStopRef = useRef(null);
const hornCooldownRef = useRef(null);
const hornStartAtRef = useRef(0); const hornStartAtRef = useRef(0);
const hornHeatTickRef = useRef(0);
const { const {
value: controlSettings, value: controlSettings,
save: saveControlSettings, save: saveControlSettings,
@@ -367,10 +375,10 @@ export function ControlSystemProvider({ children }) {
const startHorn = useCallback(() => { const startHorn = useCallback(() => {
if (!pipeline.horn) return; if (!pipeline.horn) return;
const now = Date.now(); if (state.horn?.overheated) return false;
const cooldownUntil = state.horn?.cooldownUntil ?? 0; if ((state.horn?.heat ?? 0) >= 1) return false;
if (cooldownUntil && now < cooldownUntil) return false;
if (state.horn?.active) return false; if (state.horn?.active) return false;
const now = Date.now();
pipeline.sendHorn({ action: 'start', ...normalizedHornSettings }); pipeline.sendHorn({ action: 'start', ...normalizedHornSettings });
dispatch({ type: 'control/set-horn-active', payload: true }); dispatch({ type: 'control/set-horn-active', payload: true });
hornStartAtRef.current = now; hornStartAtRef.current = now;
@@ -383,7 +391,7 @@ export function ControlSystemProvider({ children }) {
}, HORN_MAX_MS); }, HORN_MAX_MS);
recordControlIntent(); recordControlIntent();
return true; return true;
}, [dispatch, normalizedHornSettings, pipeline, recordControlIntent, state.horn?.active, state.horn?.cooldownUntil]); }, [dispatch, normalizedHornSettings, pipeline, recordControlIntent, state.horn?.active, state.horn?.heat, state.horn?.overheated]);
const stopHorn = useCallback(() => { const stopHorn = useCallback(() => {
if (!pipeline.horn) return; if (!pipeline.horn) return;
@@ -393,26 +401,38 @@ export function ControlSystemProvider({ children }) {
clearTimeout(hornAutoStopRef.current); clearTimeout(hornAutoStopRef.current);
hornAutoStopRef.current = null; hornAutoStopRef.current = null;
} }
const now = Date.now();
const holdMs = Math.max(0, now - (hornStartAtRef.current || now));
hornStartAtRef.current = 0; hornStartAtRef.current = 0;
const cooldownMs = Math.max(0, Math.round(holdMs * HORN_COOLDOWN_RATIO));
const cooldownUntil = cooldownMs > 0 ? now + cooldownMs : 0;
dispatch({ type: 'control/set-horn-cooldown', payload: cooldownUntil });
if (hornCooldownRef.current) {
clearTimeout(hornCooldownRef.current);
}
if (cooldownMs > 0) {
hornCooldownRef.current = setTimeout(() => {
dispatch({ type: 'control/set-horn-cooldown', payload: 0 });
hornCooldownRef.current = null;
}, cooldownMs);
} else {
dispatch({ type: 'control/set-horn-cooldown', payload: 0 });
hornCooldownRef.current = null;
}
}, [dispatch, pipeline]); }, [dispatch, pipeline]);
useEffect(() => {
const tickMs = 100;
const interval = setInterval(() => {
const now = Date.now();
const last = hornHeatTickRef.current || now;
hornHeatTickRef.current = now;
const dt = Math.min(1000, Math.max(0, now - last)) / 1000;
const currentHeat = state.horn?.heat ?? 0;
const active = Boolean(state.horn?.active);
const overheated = Boolean(state.horn?.overheated);
const delta = dt * (active ? HORN_HEAT_UP_PER_SEC : -HORN_HEAT_COOL_PER_SEC);
let nextHeat = Math.max(0, Math.min(1, currentHeat + delta));
let nextOverheated = overheated;
if (nextHeat >= 1) {
nextHeat = 1;
nextOverheated = true;
if (active) {
stopHorn();
}
} else if (nextOverheated && nextHeat <= HORN_HEAT_RESUME_THRESHOLD) {
nextOverheated = false;
}
if (nextHeat !== currentHeat || nextOverheated !== overheated) {
dispatch({ type: 'control/set-horn-heat', payload: { heat: nextHeat, overheated: nextOverheated } });
}
}, tickMs);
return () => clearInterval(interval);
}, [dispatch, state.horn?.active, state.horn?.heat, state.horn?.overheated, stopHorn]);
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 } });
}, []); }, []);
+3 -1
View File
@@ -18,7 +18,9 @@ export const SONG_DEFAULT_DURATION = 8;
export const SONG_REPEAT_MS = 250; export const SONG_REPEAT_MS = 250;
export const HORN_MAX_MS = 1200; export const HORN_MAX_MS = 1200;
export const HORN_COOLDOWN_RATIO = 0.9; export const HORN_HEAT_UP_PER_SEC = 1.0;
export const HORN_HEAT_COOL_PER_SEC = 0.35;
export const HORN_HEAT_RESUME_THRESHOLD = 0.7;
export const OI_COMMANDS = { export const OI_COMMANDS = {
start: [128], start: [128],
+5 -3
View File
@@ -30,7 +30,8 @@ function createSongState() {
function createHornState() { function createHornState() {
return { return {
active: false, active: false,
cooldownUntil: 0, heat: 0,
overheated: false,
}; };
} }
@@ -145,12 +146,13 @@ export function controlReducer(state, action) {
active: Boolean(action.payload), active: Boolean(action.payload),
}, },
}; };
case 'control/set-horn-cooldown': case 'control/set-horn-heat':
return { return {
...state, ...state,
horn: { horn: {
...(state.horn || createHornState()), ...(state.horn || createHornState()),
cooldownUntil: typeof action.payload === 'number' ? action.payload : 0, heat: typeof action.payload?.heat === 'number' ? action.payload.heat : 0,
overheated: Boolean(action.payload?.overheated),
}, },
}; };
case 'control/record-intent': case 'control/record-intent':
@@ -443,6 +443,10 @@ export default function KeyboardInputManager() {
latestResetAllRef.current(); latestResetAllRef.current();
}, [state.roverId]); }, [state.roverId]);
useEffect(() => {
hornActiveRef.current = Boolean(state.horn?.active);
}, [state.horn?.active]);
useEffect( useEffect(
() => () => { () => () => {
stopServoLoop(); stopServoLoop();