diff --git a/ui/gamepad_panel.css b/ui/gamepad_panel.css
new file mode 100644
index 0000000..76cf204
--- /dev/null
+++ b/ui/gamepad_panel.css
@@ -0,0 +1,314 @@
+/* gamepad_panel.css — Saltybot Gamepad Teleop (Issue #598) */
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg0: #050510;
+ --bg1: #070712;
+ --bg2: #0a0a1a;
+ --bd: #0c2a3a;
+ --bd2: #1e3a5f;
+ --dim: #374151;
+ --mid: #6b7280;
+ --base: #9ca3af;
+ --hi: #d1d5db;
+ --cyan: #06b6d4;
+ --green: #22c55e;
+ --amber: #f59e0b;
+ --red: #ef4444;
+}
+
+body {
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 12px;
+ background: var(--bg0);
+ color: var(--base);
+ height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* ── Header ── */
+#header {
+ display: flex;
+ align-items: center;
+ padding: 5px 12px;
+ background: var(--bg1);
+ border-bottom: 1px solid var(--bd);
+ flex-shrink: 0;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.logo { color: #f97316; font-weight: bold; letter-spacing: .15em; font-size: 13px; flex-shrink: 0; }
+
+#conn-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ background: var(--dim); flex-shrink: 0; transition: background .3s;
+}
+#conn-dot.connected { background: var(--green); }
+#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
+
+#gp-indicator {
+ display: flex; align-items: center; gap: 5px;
+ font-size: 10px; color: var(--mid);
+}
+#gp-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ background: var(--dim); flex-shrink: 0; transition: background .3s;
+}
+#gp-dot.active { background: var(--amber); }
+
+@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
+
+#ws-input {
+ background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
+ color: #67e8f9; padding: 2px 7px; font-family: monospace; font-size: 11px; width: 185px;
+}
+#ws-input:focus { outline: none; border-color: var(--cyan); }
+
+.hbtn {
+ padding: 2px 8px; border-radius: 4px; border: 1px solid var(--bd2);
+ background: var(--bg2); color: #67e8f9; font-family: monospace;
+ font-size: 10px; font-weight: bold; cursor: pointer; transition: background .15s;
+ white-space: nowrap;
+}
+.hbtn:hover { background: #0e4f69; }
+
+.estop-btn {
+ border-color: #7f1d1d; background: #1c0505; color: #fca5a5;
+ padding: 3px 12px; font-size: 11px;
+}
+.estop-btn:hover { background: #3b0606; }
+.estop-btn.active {
+ background: #7f1d1d; border-color: var(--red); color: #fff;
+ animation: blink .8s infinite;
+}
+
+.resume-btn {
+ border-color: #14532d; background: #052010; color: #86efac;
+ padding: 3px 12px; font-size: 11px;
+}
+.resume-btn:hover { background: #0a3a1a; }
+
+/* ── Toolbar ── */
+#toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 12px;
+ background: var(--bg1);
+ border-bottom: 1px solid var(--bd);
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ font-size: 10px;
+}
+.tsep { width: 1px; height: 16px; background: var(--bd2); }
+.tlbl { color: var(--mid); letter-spacing: .08em; }
+.tval { color: #67e8f9; min-width: 70px; font-family: monospace; }
+
+.tslider {
+ -webkit-appearance: none;
+ width: 100px; height: 4px; border-radius: 2px;
+ background: var(--bd2); outline: none; cursor: pointer;
+}
+.tslider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 12px; height: 12px; border-radius: 50%;
+ background: var(--cyan); cursor: pointer;
+}
+.tslider::-moz-range-thumb {
+ width: 12px; height: 12px; border-radius: 50%;
+ background: var(--cyan); cursor: pointer; border: none;
+}
+
+/* ── Main ── */
+#main {
+ flex: 1;
+ display: grid;
+ grid-template-columns: 1fr 220px;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ── Joystick area ── */
+#joystick-area {
+ display: flex;
+ align-items: center;
+ justify-content: space-evenly;
+ gap: 12px;
+ padding: 16px;
+ overflow: hidden;
+}
+
+.stick-wrap {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+.stick-label {
+ font-size: 9px; letter-spacing: .12em; color: var(--mid); text-transform: uppercase;
+}
+.stick-vals {
+ font-size: 10px; color: #67e8f9; font-family: monospace; min-width: 100px; text-align: center;
+}
+
+canvas {
+ display: block;
+ border-radius: 50%;
+ border: 1px solid var(--bd2);
+ touch-action: none;
+ cursor: grab;
+}
+canvas.active { cursor: grabbing; }
+
+/* ── Center panel ── */
+#center-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-width: 260px;
+ position: relative;
+}
+
+.cp-block {
+ background: var(--bg2);
+ border: 1px solid var(--bd2);
+ border-radius: 6px;
+ padding: 8px;
+}
+.cp-title {
+ font-size: 9px; font-weight: bold; letter-spacing: .12em;
+ color: #0891b2; text-transform: uppercase; margin-bottom: 5px;
+}
+.cp-row {
+ display: flex; justify-content: space-between;
+ padding: 2px 0; border-bottom: 1px solid var(--bd);
+ font-size: 10px;
+}
+.cp-row:last-child { border-bottom: none; }
+.cp-lbl { color: var(--mid); }
+.cp-val { color: var(--hi); font-family: monospace; }
+
+/* Keyboard diagram */
+.key-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 28px);
+ grid-template-rows: repeat(3, 22px);
+ gap: 3px;
+ justify-content: center;
+ margin: 4px 0;
+}
+.key {
+ background: var(--bg1);
+ border: 1px solid var(--bd2);
+ border-radius: 3px;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 9px; font-weight: bold; color: var(--mid);
+ transition: background .1s, color .1s, border-color .1s;
+}
+.key.pressed {
+ background: #0e4f69; border-color: var(--cyan); color: #fff;
+}
+.key-wide {
+ grid-column: 1 / 4;
+ width: 100%; height: 22px;
+}
+
+/* E-stop overlay */
+#estop-panel {
+ position: absolute; inset: 0;
+ background: rgba(127,0,0,0.9);
+ border: 2px solid var(--red);
+ border-radius: 8px;
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+ gap: 4px;
+ animation: blink .8s infinite;
+}
+.estop-text { font-size: 28px; font-weight: bold; color: #fca5a5; letter-spacing: .1em; }
+.estop-sub { font-size: 11px; color: #fca5a5; letter-spacing: .2em; }
+
+/* ── Sidebar ── */
+#sidebar {
+ background: var(--bg1);
+ border-left: 1px solid var(--bd);
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ padding: 8px;
+ gap: 8px;
+ font-size: 11px;
+}
+.sb-card {
+ background: var(--bg2);
+ border: 1px solid var(--bd2);
+ border-radius: 6px;
+ padding: 8px;
+}
+.sb-title {
+ font-size: 9px; font-weight: bold; letter-spacing: .12em;
+ color: #0891b2; text-transform: uppercase; margin-bottom: 6px;
+}
+.sb-row {
+ display: flex; justify-content: space-between;
+ padding: 2px 0; border-bottom: 1px solid var(--bd);
+ font-size: 10px;
+}
+.sb-row:last-child { border-bottom: none; }
+.sb-lbl { color: var(--mid); }
+.sb-val { color: var(--hi); font-family: monospace; }
+
+/* Gamepad axis bars */
+.gp-axis-row {
+ display: flex; align-items: center; gap: 4px;
+ font-size: 9px; color: var(--mid); margin-bottom: 3px;
+}
+.gp-axis-label { width: 30px; flex-shrink: 0; }
+.gp-bar-track {
+ flex: 1; height: 6px; background: var(--bg0);
+ border: 1px solid var(--bd); border-radius: 3px; overflow: hidden;
+ position: relative;
+}
+.gp-bar-center {
+ position: absolute; top: 0; bottom: 0;
+ left: 50%; width: 1px; background: var(--dim);
+}
+.gp-bar-fill {
+ position: absolute; top: 0; bottom: 0;
+ background: var(--cyan); transition: none;
+}
+.gp-axis-val { width: 32px; text-align: right; color: var(--hi); flex-shrink: 0; }
+
+.gp-btn-row {
+ display: flex; flex-wrap: wrap; gap: 3px;
+ margin-top: 6px;
+}
+.gp-btn-chip {
+ font-size: 8px; padding: 1px 5px; border-radius: 2px;
+ border: 1px solid var(--bd); background: var(--bg0); color: var(--dim);
+ transition: background .1s, color .1s;
+}
+.gp-btn-chip.pressed { background: var(--bd2); color: var(--hi); border-color: var(--cyan); }
+
+/* ── Footer ── */
+#footer {
+ background: var(--bg1); border-top: 1px solid var(--bd);
+ padding: 3px 12px;
+ display: flex; align-items: center; justify-content: space-between;
+ flex-shrink: 0; font-size: 10px; color: var(--dim);
+}
+
+/* ── Responsive ── */
+@media (max-width: 800px) {
+ #main { grid-template-columns: 1fr; }
+ #sidebar { display: none; }
+}
+@media (max-width: 560px) {
+ #right-wrap { display: none; }
+ #center-panel { max-width: 100%; }
+ .tslider { width: 70px; }
+}
diff --git a/ui/gamepad_panel.html b/ui/gamepad_panel.html
new file mode 100644
index 0000000..b922fbc
--- /dev/null
+++ b/ui/gamepad_panel.html
@@ -0,0 +1,197 @@
+
+
+
+
+
+Saltybot — Gamepad Teleop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
LEFT — DRIVE
+
+
↕ 0.00 m/s
+
+
+
+
+
+
COMMAND
+
+ Linear
+ 0.00 m/s
+
+
+ Angular
+ 0.00 rad/s
+
+
+
+
+
LIMITS
+
+ Max lin
+ 0.50 m/s
+
+
+ Max ang
+ 1.00 rad/s
+
+
+ Deadzone
+ 10%
+
+
+
+
+
KEYBOARD
+
+
+
W
+
+
A
+
S
+
D
+
+
SPC
+
+
+
+ W/S=fwd/rev · A/D=turn · SPC=stop
+
+
+
+
+
+
GAMEPAD MAPPING
+
+
L-stick ↕ → linear vel
+
R-stick ↔ → angular vel
+
LT/RT → fine speed ctrl
+
B/Circle → E-stop toggle
+
Start → Resume
+
+
+
+
+
+
+
+
RIGHT — STEER
+
+
↔ 0.00 rad/s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/gamepad_panel.js b/ui/gamepad_panel.js
new file mode 100644
index 0000000..8ba10d6
--- /dev/null
+++ b/ui/gamepad_panel.js
@@ -0,0 +1,548 @@
+/* gamepad_panel.js — Saltybot Gamepad Teleop (Issue #598) */
+'use strict';
+
+// ── Constants ──────────────────────────────────────────────────────────────
+const MAX_LINEAR = 1.0; // absolute max m/s
+const MAX_ANGULAR = 2.0; // absolute max rad/s
+const PUBLISH_HZ = 20;
+const PUBLISH_MS = 1000 / PUBLISH_HZ;
+
+// ── State ──────────────────────────────────────────────────────────────────
+const state = {
+ connected: false,
+ estop: false,
+ // Speed limits (0..1 fraction of max)
+ linLimit: 0.5,
+ angLimit: 0.5,
+ deadzone: 0.10,
+ // Current command
+ linVel: 0,
+ angVel: 0,
+ // Input source: 'none' | 'keyboard' | 'virtual' | 'gamepad'
+ inputSrc: 'none',
+ // Keyboard keys held
+ keys: { w: false, a: false, s: false, d: false, space: false },
+ // Virtual stick drags
+ sticks: {
+ left: { active: false, dx: 0, dy: 0 },
+ right: { active: false, dx: 0, dy: 0 },
+ },
+ // Gamepad
+ gpIndex: null,
+ gpPrevBtns: [],
+ // Stats
+ pubCount: 0,
+ pubTs: 0,
+ pubRate: 0,
+};
+
+// ── ROS ────────────────────────────────────────────────────────────────────
+let ros = null;
+let cmdVelTopic = null;
+
+function rosCmdVelSetup() {
+ cmdVelTopic = new ROSLIB.Topic({
+ ros,
+ name: '/cmd_vel',
+ messageType: 'geometry_msgs/Twist',
+ });
+}
+
+function publishTwist(lin, ang) {
+ if (!ros || !cmdVelTopic || !state.connected) return;
+ cmdVelTopic.publish(new ROSLIB.Message({
+ linear: { x: lin, y: 0, z: 0 },
+ angular: { x: 0, y: 0, z: ang },
+ }));
+ state.pubCount++;
+ const now = performance.now();
+ if (state.pubTs) {
+ const dt = (now - state.pubTs) / 1000;
+ state.pubRate = 1 / dt;
+ }
+ state.pubTs = now;
+}
+
+function sendStop() { publishTwist(0, 0); }
+
+// ── Connection ─────────────────────────────────────────────────────────────
+const $connDot = document.getElementById('conn-dot');
+const $connLabel = document.getElementById('conn-label');
+const $btnConn = document.getElementById('btn-connect');
+const $wsInput = document.getElementById('ws-input');
+
+function connect() {
+ const url = $wsInput.value.trim() || 'ws://localhost:9090';
+ if (ros) { try { ros.close(); } catch(_){} }
+
+ $connLabel.textContent = 'Connecting…';
+ $connLabel.style.color = '#d97706';
+ $connDot.className = '';
+
+ ros = new ROSLIB.Ros({ url });
+
+ ros.on('connection', () => {
+ state.connected = true;
+ $connDot.className = 'connected';
+ $connLabel.style.color = '#22c55e';
+ $connLabel.textContent = 'Connected';
+ rosCmdVelSetup();
+ localStorage.setItem('gp_ws_url', url);
+ });
+
+ ros.on('error', () => {
+ state.connected = false;
+ $connDot.className = 'error';
+ $connLabel.style.color = '#ef4444';
+ $connLabel.textContent = 'Error';
+ });
+
+ ros.on('close', () => {
+ state.connected = false;
+ $connDot.className = '';
+ $connLabel.style.color = '#6b7280';
+ $connLabel.textContent = 'Disconnected';
+ });
+}
+
+$btnConn.addEventListener('click', connect);
+
+// Restore saved URL
+const savedUrl = localStorage.getItem('gp_ws_url');
+if (savedUrl) {
+ $wsInput.value = savedUrl;
+ document.getElementById('footer-ws').textContent = savedUrl;
+}
+
+// ── E-stop ─────────────────────────────────────────────────────────────────
+const $btnEstop = document.getElementById('btn-estop');
+const $btnResume = document.getElementById('btn-resume');
+const $estopPanel = document.getElementById('estop-panel');
+const $sbEstop = document.getElementById('sb-estop');
+
+function activateEstop() {
+ state.estop = true;
+ sendStop();
+ $btnEstop.style.display = 'none';
+ $btnResume.style.display = '';
+ $btnEstop.classList.add('active');
+ $estopPanel.style.display = 'flex';
+ $sbEstop.textContent = 'ACTIVE';
+ $sbEstop.style.color = '#ef4444';
+}
+
+function resumeFromEstop() {
+ state.estop = false;
+ $btnEstop.style.display = '';
+ $btnResume.style.display = 'none';
+ $btnEstop.classList.remove('active');
+ $estopPanel.style.display = 'none';
+ $sbEstop.textContent = 'OFF';
+ $sbEstop.style.color = '#6b7280';
+}
+
+$btnEstop.addEventListener('click', activateEstop);
+$btnResume.addEventListener('click', resumeFromEstop);
+
+// ── Sliders ────────────────────────────────────────────────────────────────
+function setupSlider(id, valId, minVal, maxVal, onUpdate) {
+ const slider = document.getElementById(id);
+ const valEl = document.getElementById(valId);
+ slider.addEventListener('input', () => {
+ const frac = parseInt(slider.value) / 100;
+ onUpdate(frac, valEl);
+ });
+ // Init
+ const frac = parseInt(slider.value) / 100;
+ onUpdate(frac, valEl);
+}
+
+setupSlider('slider-linear', 'val-linear', 0, MAX_LINEAR, (frac, el) => {
+ state.linLimit = frac;
+ const ms = (frac * MAX_LINEAR).toFixed(2);
+ el.textContent = ms + ' m/s';
+ document.getElementById('lim-linear').textContent = ms + ' m/s';
+});
+
+setupSlider('slider-angular', 'val-angular', 0, MAX_ANGULAR, (frac, el) => {
+ state.angLimit = frac;
+ const rs = (frac * MAX_ANGULAR).toFixed(2);
+ el.textContent = rs + ' rad/s';
+ document.getElementById('lim-angular').textContent = rs + ' rad/s';
+});
+
+// Deadzone slider (0–40%)
+const $dzSlider = document.getElementById('slider-dz');
+const $dzVal = document.getElementById('val-dz');
+const $limDz = document.getElementById('lim-dz');
+$dzSlider.addEventListener('input', () => {
+ state.deadzone = parseInt($dzSlider.value) / 100;
+ $dzVal.textContent = parseInt($dzSlider.value) + '%';
+ $limDz.textContent = parseInt($dzSlider.value) + '%';
+});
+
+// ── Keyboard input ─────────────────────────────────────────────────────────
+const KEY_EL = {
+ w: document.getElementById('key-w'),
+ a: document.getElementById('key-a'),
+ s: document.getElementById('key-s'),
+ d: document.getElementById('key-d'),
+ space: document.getElementById('key-space'),
+};
+
+function setKeyState(key, down) {
+ if (!(key in state.keys)) return;
+ state.keys[key] = down;
+ if (KEY_EL[key]) KEY_EL[key].classList.toggle('pressed', down);
+}
+
+document.addEventListener('keydown', (e) => {
+ if (e.target.tagName === 'INPUT') return;
+ switch (e.code) {
+ case 'KeyW': case 'ArrowUp': setKeyState('w', true); break;
+ case 'KeyS': case 'ArrowDown': setKeyState('s', true); break;
+ case 'KeyA': case 'ArrowLeft': setKeyState('a', true); break;
+ case 'KeyD': case 'ArrowRight': setKeyState('d', true); break;
+ case 'Space':
+ e.preventDefault();
+ if (state.estop) { resumeFromEstop(); } else { activateEstop(); }
+ break;
+ }
+});
+
+document.addEventListener('keyup', (e) => {
+ switch (e.code) {
+ case 'KeyW': case 'ArrowUp': setKeyState('w', false); break;
+ case 'KeyS': case 'ArrowDown': setKeyState('s', false); break;
+ case 'KeyA': case 'ArrowLeft': setKeyState('a', false); break;
+ case 'KeyD': case 'ArrowRight': setKeyState('d', false); break;
+ }
+});
+
+function getKeyboardCommand() {
+ let lin = 0, ang = 0;
+ if (state.keys.w) lin += 1;
+ if (state.keys.s) lin -= 1;
+ if (state.keys.a) ang += 1;
+ if (state.keys.d) ang -= 1;
+ return { lin, ang };
+}
+
+// ── Virtual Joysticks ──────────────────────────────────────────────────────
+function setupVirtualStick(canvasId, stickKey, onValue) {
+ const canvas = document.getElementById(canvasId);
+ const R = canvas.width / 2;
+ const ctx = canvas.getContext('2d');
+ const stick = state.sticks[stickKey];
+ let pointerId = null;
+
+ function draw() {
+ const W = canvas.width, H = canvas.height;
+ ctx.clearRect(0, 0, W, H);
+
+ // Base circle
+ ctx.beginPath();
+ ctx.arc(R, R, R - 2, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(10,10,26,0.9)';
+ ctx.fill();
+ ctx.strokeStyle = '#1e3a5f';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+
+ // Crosshair
+ ctx.strokeStyle = '#374151';
+ ctx.lineWidth = 0.5;
+ ctx.beginPath(); ctx.moveTo(R, 4); ctx.lineTo(R, H - 4); ctx.stroke();
+ ctx.beginPath(); ctx.moveTo(4, R); ctx.lineTo(W - 4, R); ctx.stroke();
+
+ // Deadzone ring
+ const dzR = state.deadzone * (R - 10);
+ ctx.beginPath();
+ ctx.arc(R, R, dzR, 0, Math.PI * 2);
+ ctx.strokeStyle = '#374151';
+ ctx.lineWidth = 1;
+ ctx.setLineDash([3, 3]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ // Knob
+ const kx = R + stick.dx * (R - 16);
+ const ky = R + stick.dy * (R - 16);
+ const kColor = stick.active ? '#06b6d4' : '#1e3a5f';
+ ctx.beginPath();
+ ctx.arc(kx, ky, 16, 0, Math.PI * 2);
+ ctx.fillStyle = kColor + '44';
+ ctx.fill();
+ ctx.strokeStyle = kColor;
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ }
+
+ function getOffset(e) {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: (e.clientX - rect.left) / rect.width * canvas.width,
+ y: (e.clientY - rect.top) / rect.height * canvas.height,
+ };
+ }
+
+ function pointerdown(e) {
+ canvas.setPointerCapture(e.pointerId);
+ pointerId = e.pointerId;
+ stick.active = true;
+ canvas.classList.add('active');
+ update(e);
+ }
+
+ function pointermove(e) {
+ if (!stick.active || e.pointerId !== pointerId) return;
+ update(e);
+ }
+
+ function pointerup(e) {
+ if (e.pointerId !== pointerId) return;
+ stick.active = false;
+ stick.dx = 0;
+ stick.dy = 0;
+ pointerId = null;
+ canvas.classList.remove('active');
+ onValue(0, 0);
+ draw();
+ }
+
+ function update(e) {
+ const { x, y } = getOffset(e);
+ let dx = (x - R) / (R - 16);
+ let dy = (y - R) / (R - 16);
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist > 1) { dx /= dist; dy /= dist; }
+ stick.dx = dx;
+ stick.dy = dy;
+ // Apply deadzone
+ const norm = Math.sqrt(dx * dx + dy * dy);
+ if (norm < state.deadzone) { onValue(0, 0); }
+ else {
+ const scale = (norm - state.deadzone) / (1 - state.deadzone);
+ onValue((-dy / norm) * scale, (-dx / norm) * scale); // up=+lin, left=+ang
+ }
+ draw();
+ }
+
+ canvas.addEventListener('pointerdown', pointerdown);
+ canvas.addEventListener('pointermove', pointermove);
+ canvas.addEventListener('pointerup', pointerup);
+ canvas.addEventListener('pointercancel', pointerup);
+
+ draw();
+ return { draw };
+}
+
+let virtLeftLin = 0, virtLeftAng = 0;
+let virtRightLin = 0, virtRightAng = 0;
+
+const leftStickDraw = setupVirtualStick('left-stick', 'left', (fwd, _) => { virtLeftLin = fwd; updateSidebarInput(); }).draw;
+const rightStickDraw = setupVirtualStick('right-stick', 'right', (_, turn) => { virtRightAng = turn; updateSidebarInput(); }).draw;
+
+// Redraw sticks when deadzone changes (for dz ring)
+$dzSlider.addEventListener('input', () => {
+ if (leftStickDraw) leftStickDraw();
+ if (rightStickDraw) rightStickDraw();
+});
+
+// ── Gamepad API ────────────────────────────────────────────────────────────
+const $gpDot = document.getElementById('gp-dot');
+const $gpLabel = document.getElementById('gp-label');
+const $gpRaw = document.getElementById('gp-raw');
+
+window.addEventListener('gamepadconnected', (e) => {
+ state.gpIndex = e.gamepad.index;
+ $gpDot.classList.add('active');
+ $gpLabel.textContent = e.gamepad.id.substring(0, 28);
+ updateGpRaw();
+});
+
+window.addEventListener('gamepaddisconnected', (e) => {
+ if (e.gamepad.index === state.gpIndex) {
+ state.gpIndex = null;
+ state.gpPrevBtns = [];
+ $gpDot.classList.remove('active');
+ $gpLabel.textContent = 'No gamepad';
+ $gpRaw.innerHTML = 'No gamepad connected.
Press any button to activate.
';
+ }
+});
+
+function applyDeadzone(v, dz) {
+ const sign = v >= 0 ? 1 : -1;
+ const abs = Math.abs(v);
+ if (abs < dz) return 0;
+ return sign * (abs - dz) / (1 - dz);
+}
+
+function getGamepadCommand() {
+ if (state.gpIndex === null) return null;
+ const gp = navigator.getGamepads()[state.gpIndex];
+ if (!gp) return null;
+
+ // Standard mapping:
+ // Axes: 0=LX, 1=LY, 2=RX, 3=RY
+ // Buttons: 0=A/X, 1=B/Circle, 6=LT, 7=RT, 9=Start
+ const dz = state.deadzone;
+ const ly = applyDeadzone(gp.axes[1] || 0, dz);
+ const rx = applyDeadzone(gp.axes[2] || 0, dz);
+
+ // LT/RT fine speed override (reduce max by up to 50%)
+ const lt = gp.buttons[6] ? (gp.buttons[6].value || 0) : 0;
+ const rt = gp.buttons[7] ? (gp.buttons[7].value || 0) : 0;
+ const triggerScale = 1 - (lt * 0.5) - (rt * 0.5) + (rt * 0.5); // RT = full, LT = half
+ const speedScale = lt > 0.1 ? (1 - lt * 0.5) : 1.0;
+
+ // B/Circle = e-stop
+ const bBtn = gp.buttons[1] && gp.buttons[1].pressed;
+ const prevBBtn = state.gpPrevBtns[1] || false;
+ if (bBtn && !prevBBtn) {
+ if (!state.estop) activateEstop(); else resumeFromEstop();
+ }
+
+ // Start = resume
+ const startBtn = gp.buttons[9] && gp.buttons[9].pressed;
+ const prevStart = state.gpPrevBtns[9] || false;
+ if (startBtn && !prevStart && state.estop) resumeFromEstop();
+
+ state.gpPrevBtns = gp.buttons.map(b => b.pressed);
+
+ return { lin: -ly * speedScale, ang: -rx };
+}
+
+function updateGpRaw() {
+ if (state.gpIndex === null) return;
+ const gp = navigator.getGamepads()[state.gpIndex];
+ if (!gp) return;
+
+ const AXIS_NAMES = ['LX', 'LY', 'RX', 'RY'];
+ const BTN_NAMES = ['A','B','X','Y','LB','RB','LT','RT','Back','Start',
+ 'LS','RS','↑','↓','←','→','Home'];
+
+ let html = '';
+ // Axes
+ for (let i = 0; i < Math.min(gp.axes.length, 4); i++) {
+ const v = gp.axes[i];
+ const pct = ((v + 1) / 2 * 100).toFixed(0);
+ const fill = v >= 0
+ ? `left:50%;width:${(v/2*100).toFixed(0)}%`
+ : `left:${((v+1)/2*100).toFixed(0)}%;width:${(Math.abs(v)/2*100).toFixed(0)}%`;
+ html += `
+
${AXIS_NAMES[i]||'A'+i}
+
+
${v.toFixed(2)}
+
`;
+ }
+ // Buttons
+ html += '';
+ for (let i = 0; i < gp.buttons.length; i++) {
+ const pressed = gp.buttons[i].pressed;
+ const name = BTN_NAMES[i] || 'B'+i;
+ html += `${name}`;
+ }
+ html += '
';
+ $gpRaw.innerHTML = html;
+}
+
+// ── Velocity display helpers ───────────────────────────────────────────────
+const $dispLinear = document.getElementById('disp-linear');
+const $dispAngular = document.getElementById('disp-angular');
+const $leftVals = document.getElementById('left-vals');
+const $rightVals = document.getElementById('right-vals');
+const $sbInput = document.getElementById('sb-input');
+const $sbRate = document.getElementById('sb-rate');
+
+function updateSidebarInput() {
+ const src = state.inputSrc;
+ $sbInput.textContent = src === 'gamepad' ? 'Gamepad' :
+ src === 'keyboard' ? 'Keyboard' :
+ src === 'virtual' ? 'Virtual sticks' : '—';
+}
+
+// ── Main publish loop ──────────────────────────────────────────────────────
+let lastPublish = 0;
+
+function loop(ts) {
+ requestAnimationFrame(loop);
+
+ // Gamepad raw display (60fps)
+ if (state.gpIndex !== null) updateGpRaw();
+
+ // Publish at limited rate
+ if (ts - lastPublish < PUBLISH_MS) return;
+ lastPublish = ts;
+
+ // Determine command from highest-priority active input
+ let lin = 0, ang = 0;
+ let src = 'none';
+
+ // 1. Gamepad (highest priority if connected)
+ const gpCmd = getGamepadCommand();
+ if (gpCmd && (Math.abs(gpCmd.lin) > 0.001 || Math.abs(gpCmd.ang) > 0.001)) {
+ lin = gpCmd.lin;
+ ang = gpCmd.ang;
+ src = 'gamepad';
+ }
+ // 2. Keyboard
+ else {
+ const kbCmd = getKeyboardCommand();
+ if (kbCmd.lin !== 0 || kbCmd.ang !== 0) {
+ lin = kbCmd.lin;
+ ang = kbCmd.ang;
+ src = 'keyboard';
+ }
+ // 3. Virtual sticks
+ else if (state.sticks.left.active || state.sticks.right.active) {
+ lin = virtLeftLin;
+ ang = virtRightAng;
+ src = 'virtual';
+ }
+ }
+
+ // Apply speed limits
+ lin = lin * state.linLimit * MAX_LINEAR;
+ ang = ang * state.angLimit * MAX_ANGULAR;
+
+ // Clamp
+ lin = Math.max(-MAX_LINEAR, Math.min(MAX_LINEAR, lin));
+ ang = Math.max(-MAX_ANGULAR, Math.min(MAX_ANGULAR, ang));
+
+ state.linVel = lin;
+ state.angVel = ang;
+ state.inputSrc = src;
+
+ if (state.estop) { lin = 0; ang = 0; }
+
+ // Publish
+ publishTwist(lin, ang);
+
+ // Update displays
+ $dispLinear.textContent = lin.toFixed(2) + ' m/s';
+ $dispAngular.textContent = ang.toFixed(2) + ' rad/s';
+ $leftVals.textContent = '↕ ' + lin.toFixed(2) + ' m/s';
+ $rightVals.textContent = '↔ ' + ang.toFixed(2) + ' rad/s';
+
+ updateSidebarInput();
+ $sbRate.textContent = state.pubRate > 0 ? state.pubRate.toFixed(1) + ' Hz' : '—';
+
+ // Color command display
+ const linColor = Math.abs(lin) > 0.01 ? '#22c55e' : '#6b7280';
+ const angColor = Math.abs(ang) > 0.01 ? '#06b6d4' : '#6b7280';
+ $dispLinear.style.color = linColor;
+ $dispAngular.style.color = angColor;
+ $leftVals.style.color = linColor;
+ $rightVals.style.color = angColor;
+}
+
+requestAnimationFrame(loop);
+
+// ── Init auto-connect if URL saved ────────────────────────────────────────
+if (savedUrl) {
+ connect();
+}