This commit is contained in:
legop3
2025-11-17 18:39:21 -05:00
parent 9b552fb857
commit 2a942ff55c
10 changed files with 193 additions and 20 deletions
+6 -5
View File
@@ -1,11 +1,12 @@
package roverd
type helloMessage struct {
Type string `json:"type"`
Name string `json:"name"`
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
Type string `json:"type"`
Name string `json:"name"`
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo,omitempty"`
}
type sensorMessage struct {
+1
View File
@@ -70,6 +70,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo,
}
return writeJSON(ctx, conn, msg)
}
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
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-BvgUoEqO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwQ38q3M.css">
<script type="module" crossorigin src="/assets/index-OLyWk2-K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dk59njim.css">
</head>
<body>
<div id="root"></div>
+88
View File
@@ -0,0 +1,88 @@
import { useDriveControl } from '../context/DriveControlContext.jsx';
function formatDegrees(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
}
export default function CameraServoPanel() {
const { roverId, servo } = useDriveControl();
const config = servo?.config;
const enabled = Boolean(roverId && servo?.enabled && config);
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
const value =
typeof servo?.angle === 'number'
? servo.angle
: typeof config?.homeAngle === 'number'
? config.homeAngle
: (min + max) / 2;
if (!enabled) return null;
const step =
typeof config?.nudgeDegrees === 'number' && config.nudgeDegrees > 0
? config.nudgeDegrees
: 1;
const handleSlider = (event) => {
const next = Number.parseFloat(event.target.value);
if (Number.isNaN(next)) return;
servo.setAngle(next);
};
const handleNudge = (direction) => {
const delta = step * direction;
servo.nudge(delta);
};
return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<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 className="mt-2">
<input
type="range"
className="w-full accent-emerald-400"
min={min}
max={max}
step={0.5}
value={value}
onChange={handleSlider}
/>
<div className="mt-1 flex justify-between text-xs text-slate-400">
<span>{formatDegrees(min)}</span>
<span>{formatDegrees(max)}</span>
</div>
</div>
<div className="mt-2 flex gap-1 text-sm">
<button
type="button"
className="flex-1 rounded bg-slate-800 px-1 py-0.5 text-slate-100 hover:bg-slate-700"
onClick={() => handleNudge(-1)}
>
Tilt Down
</button>
<button
type="button"
className="flex-1 rounded bg-slate-700 px-1 py-0.5 text-slate-100 hover:bg-slate-600"
onClick={() => servo.goHome()}
>
Center
</button>
<button
type="button"
className="flex-1 rounded bg-slate-800 px-1 py-0.5 text-slate-100 hover:bg-slate-700"
onClick={() => handleNudge(1)}
>
Tilt Up
</button>
</div>
<p className="mt-1 text-xs text-slate-400">
Step: {formatDegrees(step)} · Pin GPIO {config?.pin}
</p>
</section>
);
}
+2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import TelemetryPanel from './TelemetryPanel.jsx';
import DrivePanel from './DrivePanel.jsx';
import CameraServoPanel from './CameraServoPanel.jsx';
import RoomCameraPanel from './RoomCameraPanel.jsx';
import AdvancedSettings from './AdvancedSettings.jsx';
import HelpPanel from './HelpPanel.jsx';
@@ -51,6 +52,7 @@ export default function RightPaneTabs({ layout }) {
{active === 'telemetry' && (
<div className="space-y-1">
<DrivePanel />
<CameraServoPanel />
<TelemetryPanel />
</div>
)}
+82 -1
View File
@@ -1,6 +1,6 @@
/* global Buffer */
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
@@ -96,6 +96,7 @@ export function useDriveControls() {
const keysRef = useRef(new Set());
const lastSpeedsRef = useRef({ left: 0, right: 0 });
const [currentSpeeds, setCurrentSpeeds] = useState(() => ({ left: 0, right: 0 }));
const [servoAngle, setServoAngle] = useState(null);
const emitCommand = useCallback(
(payload, cb) => {
@@ -105,6 +106,18 @@ export function useDriveControls() {
[socket, roverId],
);
const currentRosterEntry = useMemo(() => {
if (!roverId || !Array.isArray(session?.roster)) return null;
return session.roster.find((entry) => String(entry.id) === String(roverId)) || null;
}, [roverId, session?.roster]);
const servoConfig = useMemo(() => {
if (!currentRosterEntry?.cameraServo || !currentRosterEntry.cameraServo.enabled) {
return null;
}
return currentRosterEntry.cameraServo;
}, [currentRosterEntry]);
const enableSensorStream = useCallback(() => {
if (!roverId) return;
emitCommand({
@@ -205,6 +218,60 @@ export function useDriveControls() {
[roverId, sendMotorPwm],
);
const sendServoCommand = useCallback(
(payload) => {
if (!roverId || !servoConfig) return;
emitCommand({
type: 'servo',
data: { servo: payload },
});
},
[emitCommand, roverId, servoConfig],
);
const applyServoAngle = useCallback(
(angle) => {
if (!servoConfig) return;
const min = typeof servoConfig.minAngle === 'number' ? servoConfig.minAngle : -45;
const max = typeof servoConfig.maxAngle === 'number' ? servoConfig.maxAngle : 45;
const clamped = clamp(angle, min, max);
setServoAngle(clamped);
sendServoCommand({ angle: clamped });
},
[sendServoCommand, servoConfig],
);
const nudgeServo = useCallback(
(delta) => {
if (!servoConfig) return;
const min = typeof servoConfig.minAngle === 'number' ? servoConfig.minAngle : -45;
const max = typeof servoConfig.maxAngle === 'number' ? servoConfig.maxAngle : 45;
const midpoint = (min + max) / 2;
const step =
typeof delta === 'number' && !Number.isNaN(delta) && delta !== 0
? delta
: servoConfig.nudgeDegrees || 1;
const baseline =
typeof servoAngle === 'number'
? servoAngle
: servoConfig.homeAngle ?? midpoint;
applyServoAngle(baseline + step);
},
[applyServoAngle, servoAngle, servoConfig],
);
useEffect(() => {
if (!servoConfig) {
setServoAngle(null);
return;
}
const min = typeof servoConfig.minAngle === 'number' ? servoConfig.minAngle : -45;
const max = typeof servoConfig.maxAngle === 'number' ? servoConfig.maxAngle : 45;
const initial =
typeof servoConfig.homeAngle === 'number' ? servoConfig.homeAngle : (min + max) / 2;
setServoAngle(clamp(initial, min, max));
}, [servoConfig, roverId]);
useEffect(() => {
if (!roverId) {
keysRef.current.clear();
@@ -249,5 +316,19 @@ export function useDriveControls() {
seekDock,
setAuxMotors,
driveWithVector,
servo: {
enabled: Boolean(roverId && servoConfig),
config: servoConfig,
angle:
typeof servoAngle === 'number'
? servoAngle
: servoConfig?.homeAngle ?? servoConfig?.minAngle ?? 0,
setAngle: applyServoAngle,
nudge: nudgeServo,
goHome: () => {
if (!servoConfig) return;
applyServoAngle(servoConfig.homeAngle ?? 0);
},
},
};
}