stateful night vision rework

This commit is contained in:
legop3
2026-01-28 13:45:01 -05:00
parent f3cc73f178
commit 7e850dab22
20 changed files with 384 additions and 176 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+6
View File
@@ -81,6 +81,12 @@ func (n *NightVisionLight) HandleAction(action string) error {
}
}
func (n *NightVisionLight) NightVisionOn() bool {
n.mu.Lock()
defer n.mu.Unlock()
return !n.on
}
func (n *NightVisionLight) setLocked(on bool) error {
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
return err
+4
View File
@@ -18,3 +18,7 @@ func (n *NightVisionLight) Close() {}
func (n *NightVisionLight) HandleAction(action string) error {
return fmt.Errorf("night vision not supported in dummy build")
}
func (n *NightVisionLight) NightVisionOn() bool {
return false
}
+7 -1
View File
@@ -184,7 +184,13 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
if c.nightVision == nil {
return fmt.Errorf("night vision disabled")
}
return c.nightVision.HandleAction(msg.NightVision.Action)
if err := c.nightVision.HandleAction(msg.NightVision.Action); err != nil {
return err
}
c.emitEvent("nightVision.state", map[string]any{
"nightVisionOn": c.nightVision.NightVisionOn(),
})
return nil
case msg.Song != nil:
slot := 0
if msg.Song.Slot != nil {
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-sxx9hCHI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-VF8LslAQ.css">
<script type="module" crossorigin src="/assets/index-DCabXYAC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-lg9md4T4.css">
</head>
<body>
<div id="root"></div>
@@ -17,6 +17,10 @@ function handleMessage(roverId, msg) {
roverManager.handleSensorFrame(roverId, msg);
break;
case 'event':
if (msg.event === 'nightVision.state' && typeof msg.data?.nightVisionOn === 'boolean') {
roverManager.setNightVisionState(roverId, msg.data.nightVisionOn);
break;
}
sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event });
break;
default:
@@ -69,7 +73,11 @@ roverWSS.on('connection', (ws) => {
} else if (msg.type === 'ack') {
handleAck(msg);
} else if (msg.type === 'event') {
sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event });
if (msg.event === 'nightVision.state' && typeof msg.data?.nightVisionOn === 'boolean') {
roverManager.setNightVisionState(roverId, msg.data.nightVisionOn);
} else {
sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event });
}
}
});
+24 -1
View File
@@ -37,6 +37,7 @@ function ensureRecord(id) {
locked: false,
lockReason: null,
batteryState: null,
nightVisionState: null,
room: `rover:${id}`,
lastSeen: Date.now(),
lastMovementAt: Date.now(),
@@ -57,6 +58,13 @@ function upsertRover(meta, ws) {
record.meta = meta;
record.ws = ws;
record.lastSeen = Date.now();
if (record.nightVisionState == null && meta?.nightVision?.enabled) {
const ledOn = Boolean(meta.nightVision.initialOn);
record.nightVisionState = {
nightVisionOn: !ledOn,
updatedAt: Date.now(),
};
}
rovers.set(id, record);
spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId);
@@ -143,7 +151,9 @@ function getRoster() {
media: record.meta?.media,
cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio,
nightVision: record.meta?.nightVision,
nightVision: record.meta?.nightVision
? { ...record.meta.nightVision, state: record.nightVisionState }
: record.meta?.nightVision,
locked: record.locked,
lockReason: record.lockReason,
lastSeen: record.lastSeen,
@@ -154,6 +164,18 @@ function broadcastRoster() {
io.emit('rovers', getRoster());
}
function setNightVisionState(roverId, nightVisionOn) {
const record = rovers.get(roverId);
if (!record) return;
if (typeof nightVisionOn !== 'boolean') return;
record.nightVisionState = {
nightVisionOn,
updatedAt: Date.now(),
};
broadcastRoster();
managerEvents.emit('rover', { roverId, action: 'nightVision', record });
}
function computeBatteryState(record, sensors) {
if (!record) return null;
if (!sensors) return record.batteryState;
@@ -557,6 +579,7 @@ module.exports = {
lockRover,
getRoster,
broadcastRoster,
setNightVisionState,
handleSensorFrame,
requestControl,
releaseControl,
+48 -24
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useControlSystem } from '../controls/index.js';
import { formatKeyLabel } from '../controls/keymapUtils.js';
import NightVisionControl from './NightVisionControl.jsx';
const SLIDER_THROTTLE_MS = 150;
@@ -10,11 +12,15 @@ function formatDegrees(value) {
export default function CameraServoPanel() {
const {
state: { roverId, camera },
actions: { setServoAngle, nudgeServo, goServoHome },
state: { roverId, camera, keymap },
pipeline,
actions: { setServoAngle, nudgeServo, goServoHome, setNightVision },
} = useControlSystem();
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState;
const nightVisionKey = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
@@ -24,7 +30,7 @@ export default function CameraServoPanel() {
? config.homeAngle
: (min + max) / 2;
if (!enabled) return null;
if (!enabled && !nightVisionAvailable) return null;
const [pendingAngle, setPendingAngle] = useState(value);
const throttleRef = useRef(null);
@@ -80,30 +86,48 @@ export default function CameraServoPanel() {
nudgeServo(delta);
};
const handleNightVisionToggle = (nextOn) => {
if (!nightVisionAvailable) return;
setNightVision(nextOn);
};
return (
<section className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-300">
<span>Camera Tilt</span>
<span className="font-mono text-sm text-slate-100">{formatDegrees(value)}</span>
</div>
<div>
<input
type="range"
className="w-full accent-emerald-400"
min={min}
max={max}
step={0.5}
value={pendingAngle}
onChange={handleSlider}
onMouseUp={commitSlider}
onTouchEnd={commitSlider}
onPointerUp={commitSlider}
{enabled && (
<>
<div className="flex items-center justify-between text-sm text-slate-300">
<span>Camera Tilt</span>
<span className="font-mono text-sm text-slate-100">{formatDegrees(value)}</span>
</div>
<div>
<input
type="range"
className="w-full accent-emerald-400"
min={min}
max={max}
step={0.5}
value={pendingAngle}
onChange={handleSlider}
onMouseUp={commitSlider}
onTouchEnd={commitSlider}
onPointerUp={commitSlider}
/>
<div className="mt-0 flex justify-between text-xs text-slate-400">
<span>{formatDegrees(min)}</span>
<span>{formatDegrees(max)}</span>
</div>
</div>
</>
)}
{nightVisionAvailable && (
<NightVisionControl
nightVisionOn={nightVisionState?.nightVisionOn}
disabled={!roverId}
onToggle={handleNightVisionToggle}
keyLabel={nightVisionKey}
className={enabled ? 'mt-1' : ''}
/>
<div className="mt-0 flex justify-between text-xs text-slate-400">
<span>{formatDegrees(min)}</span>
<span>{formatDegrees(max)}</span>
</div>
</div>
)}
{/* <div className="flex gap-0.5 text-sm">
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(-1)}>
Tilt Down
+19 -11
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useControlSystem } from '../controls/index.js';
import { clampUnit } from '../controls/controlMath.js';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
import NightVisionControl from './NightVisionControl.jsx';
const SOURCE = 'mobile-joystick';
const JOYSTICK_RADIUS = 80;
@@ -134,7 +135,7 @@ function MobileJoystickPanel({ layout }) {
const {
state: { roverId, camera },
pipeline,
actions: { setDriveVector, registerInputState, setServoAngle, toggleNightVision },
actions: { setDriveVector, registerInputState, setServoAngle, setNightVision },
} = useControlSystem();
const driveDockState = useDriveDockState(roverId);
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
@@ -142,6 +143,7 @@ function MobileJoystickPanel({ layout }) {
const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
const nightVisionState = pipeline?.nightVisionState;
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
const cameraValue =
@@ -198,6 +200,14 @@ function MobileJoystickPanel({ layout }) {
setServoAngle(next);
};
const handleNightVisionToggle = useCallback(
(nextOn) => {
if (!nightVisionAvailable) return;
setNightVision(nextOn);
},
[nightVisionAvailable, setNightVision],
);
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
@@ -206,16 +216,6 @@ function MobileJoystickPanel({ layout }) {
<DriveDockAction layout="mobile" expand={dockedNotDriving} driveDockState={driveDockState} />
{!dockedNotDriving ? (
<>
{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">
@@ -233,6 +233,14 @@ function MobileJoystickPanel({ layout }) {
/>
</div>
)}
{nightVisionAvailable && (
<NightVisionControl
nightVisionOn={nightVisionState?.nightVisionOn}
disabled={disabled}
onToggle={handleNightVisionToggle}
className="mt-0.5"
/>
)}
<FloatingJoystick
disabled={disabled}
layout={layout}
@@ -0,0 +1,71 @@
import { useEffect, useMemo, useState } from 'react';
function isBoolean(value) {
return typeof value === 'boolean';
}
export default function NightVisionControl({
nightVisionOn,
disabled,
onToggle,
keyLabel,
className = '',
}) {
const [optimistic, setOptimistic] = useState(
isBoolean(nightVisionOn) ? nightVisionOn : null,
);
useEffect(() => {
if (isBoolean(nightVisionOn)) {
setOptimistic(nightVisionOn);
}
}, [nightVisionOn]);
const hasState = isBoolean(optimistic);
const displayOn = hasState ? optimistic : false;
const statusLabel = hasState ? (displayOn ? 'On' : 'Off') : '—';
const statusClasses = displayOn
? 'bg-emerald-600 text-emerald-50'
: 'bg-slate-700 text-slate-200';
const handleToggle = () => {
if (disabled) return;
const next = hasState ? !displayOn : true;
setOptimistic(next);
onToggle?.(next);
};
const buttonClasses = useMemo(
() =>
[
'flex w-full items-center justify-between rounded bg-zinc-950 px-1 py-0.5 text-xs text-slate-300',
'transition hover:bg-zinc-900 disabled:opacity-50',
className,
]
.filter(Boolean)
.join(' '),
[className],
);
return (
<button
type="button"
onClick={handleToggle}
disabled={disabled}
aria-pressed={displayOn}
className={buttonClasses}
>
<span className="text-slate-400">Night Vision</span>
<span className="flex items-center gap-1">
<span className={`rounded px-1 py-0.5 text-[0.65rem] font-semibold ${statusClasses}`}>
{statusLabel}
</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>
</button>
);
}
+18 -3
View File
@@ -312,10 +312,23 @@ export function ControlSystemProvider({ children }) {
[pipeline],
);
const setNightVision = useCallback(
(nightVisionOn) => {
if (!pipeline.nightVision) return;
if (typeof nightVisionOn === 'boolean') {
const action = nightVisionOn ? 'off' : 'on';
pipeline.sendNightVision(action);
} else {
pipeline.sendNightVision('toggle');
}
recordControlIntent();
},
[pipeline, recordControlIntent],
);
const toggleNightVision = useCallback(() => {
pipeline.sendNightVision('toggle');
recordControlIntent();
}, [pipeline, recordControlIntent]);
setNightVision();
}, [setNightVision]);
const setSongNote = useCallback(
(note) => {
@@ -354,6 +367,7 @@ export function ControlSystemProvider({ children }) {
stopAllMotion,
sendOiCommand,
setSensorStream,
setNightVision,
toggleNightVision,
updateKeyBinding,
resetKeyBindings,
@@ -376,6 +390,7 @@ export function ControlSystemProvider({ children }) {
stopAllMotion,
sendOiCommand,
setSensorStream,
setNightVision,
toggleNightVision,
updateKeyBinding,
resetKeyBindings,
+4
View File
@@ -32,6 +32,8 @@ export function useCommandPipeline(options = {}) {
return rosterEntry.nightVision;
}, [rosterEntry]);
const nightVisionState = useMemo(() => rosterEntry?.nightVision?.state ?? null, [rosterEntry]);
const emitCommand = useCallback(
(payload, cb) => {
if (!roverId) return;
@@ -202,6 +204,7 @@ export function useCommandPipeline(options = {}) {
rosterEntry,
servoConfig,
nightVision,
nightVisionState,
emitCommand,
enableSensorStream,
sendDriveDirect,
@@ -217,6 +220,7 @@ export function useCommandPipeline(options = {}) {
rosterEntry,
servoConfig,
nightVision,
nightVisionState,
emitCommand,
enableSensorStream,
sendDriveDirect,
@@ -8,6 +8,7 @@ import {
getPadSignature,
} from './gamepadBindings.js';
import { subscribeGamepadHub } from './gamepadHub.js';
import { isTextEntryActive } from './inputFocusUtils.js';
const SOURCE = 'gamepad';
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
@@ -154,6 +155,21 @@ export default function GamepadInputManager() {
return;
}
if (isTextEntryActive()) {
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
lastVectorRef.current = ZERO_VECTOR;
setDriveVector(ZERO_VECTOR, { source: SOURCE });
}
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
lastAuxRef.current = ZERO_AUX;
setAuxMotors(ZERO_AUX);
}
buttonStateRef.current = new Map();
reverseStateRef.current = { main: false, side: false };
registerInputState(SOURCE, { connected: true, blocked: true });
return;
}
ensureProfile(activePad);
const signature = activePad.signature;
const profile =
@@ -3,6 +3,7 @@ import { useControlSystem } from '../ControlContext.jsx';
import { useChat } from '../../context/ChatContext.jsx';
import { useSession } from '../../context/SessionContext.jsx';
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
import { isTextInputElement } from './inputFocusUtils.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import {
@@ -55,15 +56,7 @@ function mapTiltIntervalToSpeed(interval) {
}
function shouldIgnoreEvent(event) {
const target = event.target;
if (!target) return false;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
target.isContentEditable ||
tag === 'SELECT'
);
return isTextInputElement(event?.target);
}
function bindingActive(bindingSet, keys) {
@@ -395,12 +388,12 @@ export default function KeyboardInputManager() {
resetAll();
}
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('keydown', handleKeyDown, { capture: true });
window.addEventListener('keyup', handleKeyUp, { capture: true });
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('keydown', handleKeyDown, { capture: true });
window.removeEventListener('keyup', handleKeyUp, { capture: true });
window.removeEventListener('blur', handleBlur);
};
}, [
@@ -0,0 +1,30 @@
const TEXT_INPUT_TYPES = new Set([
'',
'text',
'search',
'email',
'password',
'url',
'tel',
'number',
'date',
'datetime-local',
'month',
'time',
'week',
]);
export function isTextInputElement(target) {
if (!target || target.nodeType !== 1) return false;
const tag = target.tagName;
if (tag === 'TEXTAREA') return true;
if (target.isContentEditable) return true;
if (tag !== 'INPUT') return false;
const type = target.type ? target.type.toLowerCase() : '';
return TEXT_INPUT_TYPES.has(type);
}
export function isTextEntryActive() {
if (typeof document === 'undefined') return false;
return isTextInputElement(document.activeElement);
}