diff --git a/ui/gimbal_panel.css b/ui/gimbal_panel.css
new file mode 100644
index 0000000..ddea1d1
--- /dev/null
+++ b/ui/gimbal_panel.css
@@ -0,0 +1,210 @@
+/* gimbal_panel.css — Saltybot Gimbal Control Panel (Issue #551) */
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 12px;
+ background: #050510;
+ color: #d1d5db;
+ height: 100dvh;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── Header ── */
+#header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 16px;
+ background: #070712;
+ border-bottom: 1px solid #083344;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+#header .logo { color: #f97316; font-weight: bold; letter-spacing: 0.15em; font-size: 13px; }
+#conn-bar { display: flex; align-items: center; gap: 6px; }
+#conn-dot { width: 8px; height: 8px; border-radius: 50%; background: #374151; flex-shrink: 0; }
+#conn-dot.connected { background: #4ade80; }
+#conn-dot.error { background: #f87171; }
+#ws-input {
+ background: #111827; border: 1px solid #1e3a5f; border-radius: 4px;
+ color: #67e8f9; padding: 2px 8px; font-family: monospace; font-size: 11px; width: 200px;
+}
+#ws-input:focus { outline: none; border-color: #0891b2; }
+.btn {
+ padding: 3px 10px; border-radius: 4px; border: 1px solid; cursor: pointer;
+ font-family: monospace; font-size: 10px; font-weight: bold; letter-spacing: 0.05em;
+ transition: background 0.15s;
+}
+.btn-cyan { background: #083344; border-color: #155e75; color: #67e8f9; }
+.btn-cyan:hover { background: #0e4f69; }
+.btn-green { background: #052e16; border-color: #166534; color: #4ade80; }
+.btn-green:hover { background: #0a4a24; }
+.btn-amber { background: #451a03; border-color: #92400e; color: #fcd34d; }
+.btn-amber:hover { background: #6b2b04; }
+.btn-red { background: #450a0a; border-color: #991b1b; color: #f87171; }
+.btn-red:hover { background: #6b1010; }
+.btn-red.active { background: #7f1d1d; border-color: #dc2626; color: #fca5a5; animation: pulse 1.5s infinite; }
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.65; }
+}
+
+/* ── Main layout ── */
+#main {
+ flex: 1;
+ display: grid;
+ grid-template-columns: 1fr 280px;
+ gap: 12px;
+ padding: 12px;
+ min-height: 0;
+}
+
+@media (max-width: 720px) {
+ #main { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
+ body { font-size: 11px; overflow-y: auto; height: auto; }
+}
+
+/* ── Camera feed ── */
+#camera-section {
+ display: flex; flex-direction: column; gap: 8px; min-height: 0;
+}
+#cam-toolbar {
+ display: flex; gap: 6px; align-items: center; flex-shrink: 0; flex-wrap: wrap;
+}
+#cam-toolbar span { color: #6b7280; font-size: 10px; margin-left: auto; }
+#camera-frame {
+ flex: 1;
+ background: #000;
+ border-radius: 8px;
+ border: 1px solid #083344;
+ display: flex; align-items: center; justify-content: center;
+ position: relative;
+ overflow: hidden;
+ min-height: 160px;
+}
+#camera-img {
+ max-width: 100%; max-height: 100%;
+ object-fit: contain;
+ display: none;
+}
+#camera-img.visible { display: block; }
+#no-signal {
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
+ color: #374151; user-select: none;
+}
+#no-signal .icon { font-size: 36px; }
+#fps-badge {
+ position: absolute; top: 8px; right: 8px;
+ background: rgba(0,0,0,0.7); color: #4ade80;
+ padding: 2px 6px; border-radius: 4px; font-size: 10px;
+ display: none;
+}
+#fps-badge.visible { display: block; }
+
+/* Angle overlay on camera */
+#angle-overlay {
+ position: absolute; bottom: 8px; left: 8px;
+ background: rgba(0,0,0,0.7);
+ padding: 4px 8px; border-radius: 4px;
+ color: #67e8f9; font-size: 10px; font-family: monospace;
+ display: none;
+}
+#angle-overlay.visible { display: block; }
+
+/* ── Controls sidebar ── */
+#controls {
+ display: flex; flex-direction: column; gap: 10px; overflow-y: auto; min-height: 0;
+}
+
+.card {
+ background: #070712; border: 1px solid #083344;
+ border-radius: 8px; padding: 10px;
+}
+.card-title {
+ font-size: 9px; font-weight: bold; letter-spacing: 0.15em;
+ color: #0891b2; margin-bottom: 8px; text-transform: uppercase;
+}
+
+/* ── Pan/Tilt Joystick ── */
+#pad-wrap {
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
+}
+#gimbal-pad {
+ cursor: crosshair;
+ border-radius: 6px;
+ border: 1px solid #1e3a5f;
+ touch-action: none;
+ user-select: none;
+}
+.pad-labels {
+ display: grid; grid-template-columns: 1fr auto 1fr;
+ width: 200px; font-size: 9px; color: #4b5563; text-align: center; align-items: center;
+}
+
+/* ── Angle display ── */
+#angle-display {
+ display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
+}
+.angle-box {
+ background: #0a0a1a; border: 1px solid #1e3a5f; border-radius: 6px;
+ padding: 6px; display: flex; flex-direction: column; gap: 2px;
+}
+.angle-label { font-size: 9px; color: #6b7280; }
+.angle-value { font-size: 18px; color: #67e8f9; font-family: monospace; }
+.angle-unit { font-size: 9px; color: #374151; }
+
+/* ── Presets ── */
+#presets {
+ display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px;
+}
+#presets button { padding: 6px 4px; text-align: center; line-height: 1.3; }
+#presets button .preset-name { display: block; font-size: 9px; font-weight: bold; letter-spacing: 0.05em; }
+#presets button .preset-val { display: block; font-size: 9px; color: #6b7280; }
+
+/* Home button */
+#btn-home {
+ width: 100%; padding: 8px;
+ background: #1c1c2e; border: 1px solid #374151; border-radius: 6px;
+ color: #9ca3af; font-family: monospace; font-size: 10px; font-weight: bold;
+ letter-spacing: 0.1em; cursor: pointer; transition: background 0.15s;
+}
+#btn-home:hover { background: #2d2d44; color: #d1d5db; }
+
+/* Tracking toggle */
+#tracking-row {
+ display: flex; align-items: center; justify-content: space-between;
+}
+.toggle-label { font-size: 10px; color: #9ca3af; }
+.toggle-switch {
+ position: relative; width: 40px; height: 20px;
+}
+.toggle-switch input { opacity: 0; width: 0; height: 0; }
+.toggle-slider {
+ position: absolute; inset: 0;
+ background: #1f2937; border-radius: 10px; cursor: pointer;
+ transition: background 0.2s;
+}
+.toggle-slider::before {
+ content: ''; position: absolute;
+ width: 14px; height: 14px; border-radius: 50%;
+ background: #6b7280; top: 3px; left: 3px;
+ transition: transform 0.2s, background 0.2s;
+}
+.toggle-switch input:checked + .toggle-slider { background: #0c4a6e; }
+.toggle-switch input:checked + .toggle-slider::before {
+ background: #38bdf8; transform: translateX(20px);
+}
+
+/* ── Footer ── */
+#footer {
+ background: #070712; border-top: 1px solid #083344;
+ padding: 4px 16px;
+ display: flex; align-items: center; justify-content: space-between;
+ flex-shrink: 0; font-size: 10px; color: #374151;
+}
diff --git a/ui/gimbal_panel.html b/ui/gimbal_panel.html
new file mode 100644
index 0000000..38e9d27
--- /dev/null
+++ b/ui/gimbal_panel.html
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /camera/front/image_raw/compressed
+
+
+
+
+
![gimbal camera feed]()
+
+
— fps
+
PAN 0.0° TILT 0.0°
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/gimbal_panel.js b/ui/gimbal_panel.js
new file mode 100644
index 0000000..473195a
--- /dev/null
+++ b/ui/gimbal_panel.js
@@ -0,0 +1,392 @@
+/**
+ * gimbal_panel.js — Saltybot Gimbal Control Panel (Issue #551)
+ *
+ * Connects to rosbridge via WebSocket (roslib.js).
+ * Publishes pan/tilt commands, subscribes to gimbal state + camera.
+ *
+ * ROS topics:
+ * /gimbal/cmd geometry_msgs/Vector3 publish (x=pan°, y=tilt°)
+ * /gimbal/state geometry_msgs/Vector3 subscribe (x=pan°, y=tilt°)
+ * /gimbal/tracking_enabled std_msgs/Bool publish
+ * /camera/front/image_raw/compressed sensor_msgs/CompressedImage subscribe
+ */
+
+'use strict';
+
+// ── Config ────────────────────────────────────────────────────────────────────
+
+const PAN_MIN = -180; // degrees
+const PAN_MAX = 180;
+const TILT_MIN = -45; // degrees
+const TILT_MAX = 90;
+const CMD_TOPIC = '/gimbal/cmd';
+const STATE_TOPIC = '/gimbal/state';
+const TRACKING_TOPIC = '/gimbal/tracking_enabled';
+const CAM_TOPIC = '/camera/front/image_raw/compressed';
+const CAM_TOPICS = {
+ front: '/camera/front/image_raw/compressed',
+ rear: '/camera/rear/image_raw/compressed',
+ left: '/camera/left/image_raw/compressed',
+ right: '/camera/right/image_raw/compressed',
+};
+
+const PRESETS = [
+ { name: 'CENTER', pan: 0, tilt: 0 },
+ { name: 'LEFT', pan: -45, tilt: 0 },
+ { name: 'RIGHT', pan: 45, tilt: 0 },
+ { name: 'UP', pan: 0, tilt: 45 },
+ { name: 'DOWN', pan: 0, tilt:-30 },
+ { name: 'HOME', pan: 0, tilt: 0 },
+];
+
+// ── State ─────────────────────────────────────────────────────────────────────
+
+let ros = null;
+let cmdTopic = null;
+let trackingTopic = null;
+let stateSub = null;
+let camSub = null;
+
+let currentPan = 0;
+let currentTilt = 0;
+let targetPan = 0;
+let targetTilt = 0;
+let trackingOn = false;
+let selectedCam = 'front';
+
+// Camera FPS tracking
+let fpsCount = 0;
+let fpsTime = Date.now();
+let fps = 0;
+
+// Pad dragging state
+let padDragging = false;
+
+// ── DOM refs ──────────────────────────────────────────────────────────────────
+
+const connDot = document.getElementById('conn-dot');
+const connStatus = document.getElementById('conn-status');
+const wsInput = document.getElementById('ws-input');
+const padCanvas = document.getElementById('gimbal-pad');
+const panVal = document.getElementById('pan-val');
+const tiltVal = document.getElementById('tilt-val');
+const camImg = document.getElementById('camera-img');
+const noSignal = document.getElementById('no-signal');
+const fpsBadge = document.getElementById('fps-badge');
+const angleOvl = document.getElementById('angle-overlay');
+
+// ── Connection ────────────────────────────────────────────────────────────────
+
+function connect() {
+ const url = wsInput.value.trim();
+ if (!url) return;
+ if (ros) { ros.close(); }
+
+ ros = new ROSLIB.Ros({ url });
+
+ ros.on('connection', () => {
+ connDot.className = 'connected';
+ connStatus.textContent = url;
+ document.getElementById('btn-connect').textContent = 'RECONNECT';
+ setupTopics();
+ });
+
+ ros.on('error', (err) => {
+ connDot.className = 'error';
+ connStatus.textContent = 'ERROR: ' + (err?.message || err);
+ });
+
+ ros.on('close', () => {
+ connDot.className = '';
+ connStatus.textContent = 'DISCONNECTED';
+ teardownTopics();
+ });
+}
+
+function setupTopics() {
+ // Publish: /gimbal/cmd
+ cmdTopic = new ROSLIB.Topic({
+ ros, name: CMD_TOPIC,
+ messageType: 'geometry_msgs/Vector3',
+ });
+
+ // Publish: /gimbal/tracking_enabled
+ trackingTopic = new ROSLIB.Topic({
+ ros, name: TRACKING_TOPIC,
+ messageType: 'std_msgs/Bool',
+ });
+
+ // Subscribe: /gimbal/state
+ stateSub = new ROSLIB.Topic({
+ ros, name: STATE_TOPIC,
+ messageType: 'geometry_msgs/Vector3',
+ throttle_rate: 100,
+ });
+ stateSub.subscribe((msg) => {
+ currentPan = msg.x ?? 0;
+ currentTilt = msg.y ?? 0;
+ updateAngleDisplay();
+ drawPad();
+ });
+
+ // Subscribe: camera
+ subscribeCamera(selectedCam);
+}
+
+function teardownTopics() {
+ if (stateSub) { stateSub.unsubscribe(); stateSub = null; }
+ if (camSub) { camSub.unsubscribe(); camSub = null; }
+ cmdTopic = trackingTopic = null;
+}
+
+function subscribeCamera(camId) {
+ if (camSub) { camSub.unsubscribe(); camSub = null; }
+ const topic = CAM_TOPICS[camId] ?? CAM_TOPICS.front;
+ if (!ros) return;
+ camSub = new ROSLIB.Topic({
+ ros, name: topic,
+ messageType: 'sensor_msgs/CompressedImage',
+ throttle_rate: 50,
+ });
+ camSub.subscribe((msg) => {
+ const fmt = msg.format?.includes('png') ? 'png' : 'jpeg';
+ camImg.src = `data:image/${fmt};base64,${msg.data}`;
+ camImg.classList.add('visible');
+ noSignal.style.display = 'none';
+ fpsBadge.classList.add('visible');
+ angleOvl.classList.add('visible');
+
+ // FPS
+ fpsCount++;
+ const now = Date.now();
+ if (now - fpsTime >= 1000) {
+ fps = Math.round(fpsCount * 1000 / (now - fpsTime));
+ fpsCount = 0; fpsTime = now;
+ fpsBadge.textContent = fps + ' fps';
+ }
+ });
+}
+
+// ── Publish helpers ───────────────────────────────────────────────────────────
+
+function publishCmd(pan, tilt) {
+ if (!cmdTopic) return;
+ cmdTopic.publish(new ROSLIB.Message({ x: pan, y: tilt, z: 0.0 }));
+}
+
+function publishTracking(enabled) {
+ if (!trackingTopic) return;
+ trackingTopic.publish(new ROSLIB.Message({ data: enabled }));
+}
+
+// ── Gimbal pad drawing ────────────────────────────────────────────────────────
+
+function drawPad() {
+ const canvas = padCanvas;
+ const ctx = canvas.getContext('2d');
+ const W = canvas.width;
+ const H = canvas.height;
+
+ ctx.clearRect(0, 0, W, H);
+
+ // Background
+ ctx.fillStyle = '#0a0a1a';
+ ctx.fillRect(0, 0, W, H);
+
+ // Grid lines
+ ctx.strokeStyle = '#1e3a5f';
+ ctx.lineWidth = 1;
+ // Vertical lines
+ for (let i = 1; i < 4; i++) {
+ ctx.beginPath();
+ ctx.moveTo(W * i / 4, 0); ctx.lineTo(W * i / 4, H);
+ ctx.stroke();
+ }
+ // Horizontal lines
+ for (let i = 1; i < 4; i++) {
+ ctx.beginPath();
+ ctx.moveTo(0, H * i / 4); ctx.lineTo(W, H * i / 4);
+ ctx.stroke();
+ }
+
+ // Center cross
+ ctx.strokeStyle = '#374151';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H);
+ ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2);
+ ctx.stroke();
+
+ // Helpers to map degrees ↔ canvas coords
+ const panToX = (p) => ((p - PAN_MIN) / (PAN_MAX - PAN_MIN)) * W;
+ const tiltToY = (t) => (1 - (t - TILT_MIN) / (TILT_MAX - TILT_MIN)) * H;
+
+ // Target position (what we're commanding)
+ const tx = panToX(targetPan);
+ const ty = tiltToY(targetTilt);
+
+ // Line from center to target
+ ctx.strokeStyle = 'rgba(6,182,212,0.3)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(W / 2, H / 2);
+ ctx.lineTo(tx, ty);
+ ctx.stroke();
+
+ // Target ring
+ ctx.strokeStyle = '#0891b2';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.arc(tx, ty, 10, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Current position (feedback)
+ const cx_ = panToX(currentPan);
+ const cy_ = tiltToY(currentTilt);
+ ctx.fillStyle = '#22d3ee';
+ ctx.beginPath();
+ ctx.arc(cx_, cy_, 6, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Labels: axis limits
+ ctx.fillStyle = '#374151';
+ ctx.font = '9px Courier New';
+ ctx.fillText(`${PAN_MIN}°`, 2, H / 2 - 3);
+ ctx.fillText(`${PAN_MAX}°`, W - 24, H / 2 - 3);
+ ctx.fillText(`${TILT_MAX}°`, W / 2 + 3, 11);
+ ctx.fillText(`${TILT_MIN}°`, W / 2 + 3, H - 3);
+
+ // Current angle text
+ ctx.fillStyle = '#0891b2';
+ ctx.font = 'bold 9px Courier New';
+ ctx.fillText(`PAN ${targetPan.toFixed(0)}° TILT ${targetTilt.toFixed(0)}°`, 4, H - 3);
+}
+
+// ── Pad interaction ───────────────────────────────────────────────────────────
+
+function padCoordsToAngles(clientX, clientY) {
+ const rect = padCanvas.getBoundingClientRect();
+ const rx = (clientX - rect.left) / rect.width;
+ const ry = (clientY - rect.top) / rect.height;
+ const pan = PAN_MIN + rx * (PAN_MAX - PAN_MIN);
+ const tilt = TILT_MAX - ry * (TILT_MAX - TILT_MIN);
+ return {
+ pan: Math.max(PAN_MIN, Math.min(PAN_MAX, pan)),
+ tilt: Math.max(TILT_MIN, Math.min(TILT_MAX, tilt)),
+ };
+}
+
+function padPointerDown(e) {
+ padDragging = true;
+ padCanvas.setPointerCapture(e.pointerId);
+ const { pan, tilt } = padCoordsToAngles(e.clientX, e.clientY);
+ setTarget(pan, tilt);
+}
+
+function padPointerMove(e) {
+ if (!padDragging) return;
+ const { pan, tilt } = padCoordsToAngles(e.clientX, e.clientY);
+ setTarget(pan, tilt);
+}
+
+function padPointerUp() {
+ padDragging = false;
+}
+
+padCanvas.addEventListener('pointerdown', padPointerDown);
+padCanvas.addEventListener('pointermove', padPointerMove);
+padCanvas.addEventListener('pointerup', padPointerUp);
+padCanvas.addEventListener('pointercancel', padPointerUp);
+
+// Touch passthrough
+padCanvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });
+padCanvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
+
+// ── Target update ─────────────────────────────────────────────────────────────
+
+function setTarget(pan, tilt) {
+ targetPan = Math.round(pan * 10) / 10;
+ targetTilt = Math.round(tilt * 10) / 10;
+ updateAngleDisplay();
+ drawPad();
+ publishCmd(targetPan, targetTilt);
+}
+
+function updateAngleDisplay() {
+ panVal.textContent = currentPan.toFixed(1);
+ tiltVal.textContent = currentTilt.toFixed(1);
+ angleOvl.textContent = `PAN ${currentPan.toFixed(1)}° TILT ${currentTilt.toFixed(1)}°`;
+}
+
+// ── Presets ───────────────────────────────────────────────────────────────────
+
+function applyPreset(pan, tilt) {
+ setTarget(pan, tilt);
+}
+
+function goHome() {
+ setTarget(0, 0);
+}
+
+// ── Tracking toggle ───────────────────────────────────────────────────────────
+
+document.getElementById('tracking-toggle').addEventListener('change', (e) => {
+ trackingOn = e.target.checked;
+ publishTracking(trackingOn);
+});
+
+// ── Camera selector ───────────────────────────────────────────────────────────
+
+document.querySelectorAll('.cam-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ selectedCam = btn.dataset.cam;
+ document.querySelectorAll('.cam-btn').forEach((b) => b.classList.remove('active'));
+ btn.classList.add('active');
+ subscribeCamera(selectedCam);
+ // Reset fps display
+ camImg.classList.remove('visible');
+ noSignal.style.display = '';
+ fpsBadge.classList.remove('visible');
+ angleOvl.classList.remove('visible');
+ fpsCount = 0; fpsTime = Date.now();
+ });
+});
+
+// ── Connect button ────────────────────────────────────────────────────────────
+
+document.getElementById('btn-connect').addEventListener('click', connect);
+wsInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') connect(); });
+
+// ── Preset buttons ────────────────────────────────────────────────────────────
+
+document.querySelectorAll('[data-preset]').forEach((btn) => {
+ const idx = parseInt(btn.dataset.preset, 10);
+ const p = PRESETS[idx];
+ btn.addEventListener('click', () => applyPreset(p.pan, p.tilt));
+});
+
+document.getElementById('btn-home').addEventListener('click', goHome);
+
+// ── Init ──────────────────────────────────────────────────────────────────────
+
+// Size canvas to fill its container initially
+function resizePad() {
+ const w = padCanvas.offsetWidth || 200;
+ padCanvas.width = w;
+ padCanvas.height = Math.round(w * 0.75); // 4:3 aspect
+ drawPad();
+}
+
+window.addEventListener('resize', resizePad);
+resizePad();
+
+// Auto-connect on load using stored URL or default
+const storedUrl = localStorage.getItem('gimbal_ws_url');
+if (storedUrl) wsInput.value = storedUrl;
+wsInput.addEventListener('change', () => {
+ localStorage.setItem('gimbal_ws_url', wsInput.value);
+});
+
+// Initial draw
+drawPad();
+updateAngleDisplay();
diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx
index e3eea94..0906b90 100644
--- a/ui/social-bot/src/App.jsx
+++ b/ui/social-bot/src/App.jsx
@@ -94,12 +94,16 @@ import { ParameterServer } from './components/ParameterServer.jsx';
// Teleop web interface (issue #534)
import { TeleopWebUI } from './components/TeleopWebUI.jsx';
+// Gimbal control panel (issue #551)
+import { GimbalPanel } from './components/GimbalPanel.jsx';
+
const TAB_GROUPS = [
{
label: 'TELEOP',
color: 'text-orange-600',
tabs: [
{ id: 'teleop-webui', label: 'Drive' },
+ { id: 'gimbal', label: 'Gimbal' },
],
},
{
@@ -296,9 +300,10 @@ export default function App() {
{/* ── Content ── */}