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-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-B4BVCZld.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQCz6Dh4.css">
<script type="module" crossorigin src="/assets/index-BaI6Xr6X.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bi3rvCda.css">
</head>
<body>
<div id="root"></div>
+3 -2
View File
@@ -24,7 +24,7 @@ export default function CameraServoPanel() {
const hornAvailable = Boolean(roverId && pipeline?.horn);
const nightVisionKey = formatKeyLabel(keymap?.nightVisionToggle?.[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 max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
@@ -107,11 +107,12 @@ export default function CameraServoPanel() {
)}
{hornAvailable && (
<HornControl
disabled={!roverId || hornCooldownActive}
disabled={!roverId || hornBlocked}
onStart={startHorn}
onStop={stopHorn}
keyLabel={hornKey}
active={horn?.active}
heat={horn?.heat}
/>
)}
{enabled && (
+3 -2
View File
@@ -85,7 +85,7 @@ export function InlineCameraTilt({ keymap }) {
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState;
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 max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
@@ -134,11 +134,12 @@ export function InlineCameraTilt({ keymap }) {
)}
{hornAvailable && (
<HornControl
disabled={!roverId || hornCooldownActive}
disabled={!roverId || hornBlocked}
onStart={startHorn}
onStop={stopHorn}
keyLabel={hornLabel}
active={horn?.active}
heat={horn?.heat}
/>
)}
{enabled && (
+27 -3
View File
@@ -15,6 +15,7 @@ export default function HornControl({
onStop,
keyLabel,
active,
heat = 0,
className = '',
}) {
const [pressed, setPressed] = useState(false);
@@ -36,9 +37,10 @@ export default function HornControl({
}, [hornSettings?.freqs, hornSettings?.waveform]);
const isActive = Boolean(active);
const clampedHeat = Math.max(0, Math.min(1, Number(heat) || 0));
const buttonClasses = useMemo(() => {
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 inactive = 'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800';
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 updateWaveform = useCallback(
@@ -111,7 +119,23 @@ export default function HornControl({
onBlur={stop}
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>Horn</span>
{keyLabel ? (
@@ -128,7 +152,7 @@ export default function HornControl({
{isActive ? 'Honk' : 'Hold'}
</span>
</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">
<span className="text-slate-300">Wave</span>
<select
+3 -2
View File
@@ -146,7 +146,7 @@ function MobileJoystickPanel({ layout }) {
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState;
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 cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
const cameraValue =
@@ -228,10 +228,11 @@ function MobileJoystickPanel({ layout }) {
)}
{hornAvailable && (
<HornControl
disabled={disabled || hornCooldownActive}
disabled={disabled || hornBlocked}
onStart={startHorn}
onStop={stopHorn}
active={horn?.active}
heat={horn?.heat}
/>
)}
{cameraEnabled && (
+43 -23
View File
@@ -2,7 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useReducer,
import { controlReducer, initialControlState } from './controlReducer.js';
import { computeDifferentialSpeeds, clamp } from './controlMath.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 { useSettingsNamespace } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx';
@@ -34,8 +42,8 @@ export function ControlSystemProvider({ children }) {
const pendingLightsRef = useRef(false);
const servoAngleRef = useRef(initialControlState.camera.angle);
const hornAutoStopRef = useRef(null);
const hornCooldownRef = useRef(null);
const hornStartAtRef = useRef(0);
const hornHeatTickRef = useRef(0);
const {
value: controlSettings,
save: saveControlSettings,
@@ -367,10 +375,10 @@ export function ControlSystemProvider({ children }) {
const startHorn = useCallback(() => {
if (!pipeline.horn) return;
const now = Date.now();
const cooldownUntil = state.horn?.cooldownUntil ?? 0;
if (cooldownUntil && now < cooldownUntil) return false;
if (state.horn?.overheated) return false;
if ((state.horn?.heat ?? 0) >= 1) return false;
if (state.horn?.active) return false;
const now = Date.now();
pipeline.sendHorn({ action: 'start', ...normalizedHornSettings });
dispatch({ type: 'control/set-horn-active', payload: true });
hornStartAtRef.current = now;
@@ -383,7 +391,7 @@ export function ControlSystemProvider({ children }) {
}, HORN_MAX_MS);
recordControlIntent();
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(() => {
if (!pipeline.horn) return;
@@ -393,26 +401,38 @@ export function ControlSystemProvider({ children }) {
clearTimeout(hornAutoStopRef.current);
hornAutoStopRef.current = null;
}
const now = Date.now();
const holdMs = Math.max(0, now - (hornStartAtRef.current || now));
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]);
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) => {
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 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 = {
start: [128],
+5 -3
View File
@@ -30,7 +30,8 @@ function createSongState() {
function createHornState() {
return {
active: false,
cooldownUntil: 0,
heat: 0,
overheated: false,
};
}
@@ -145,12 +146,13 @@ export function controlReducer(state, action) {
active: Boolean(action.payload),
},
};
case 'control/set-horn-cooldown':
case 'control/set-horn-heat':
return {
...state,
horn: {
...(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':
@@ -443,6 +443,10 @@ export default function KeyboardInputManager() {
latestResetAllRef.current();
}, [state.roverId]);
useEffect(() => {
hornActiveRef.current = Boolean(state.horn?.active);
}, [state.horn?.active]);
useEffect(
() => () => {
stopServoLoop();