First commit

This commit is contained in:
2026-09-20 18:55:16 +02:00
commit e8d61a1c98
26 changed files with 4351 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
// Serial link to a hoverboard mainboard running hoverboard-firmware-hack-FOC.
//
// Frames are the firmware's own structs (Inc/util.h, Src/main.c) — all int16,
// naturally packed, little-endian on both ends, XOR checksum.
#pragma once
#include <Arduino.h>
#include "robot_config.h"
constexpr uint16_t HOVER_START_FRAME = 0xABCD; // firmware SERIAL_START_FRAME
// What we send. Under TANK_STEERING the firmware assigns:
// command.steer -> cmdL (left wheel), command.speed -> cmdR (right wheel).
// The field names below say what they actually drive.
struct __attribute__((packed)) HoverCommand {
uint16_t start;
int16_t left;
int16_t right;
// Mainboard buzzer: low byte = pitch (0 = silent), high byte = pattern.
// Level-triggered, so we own the duration; the mainboard's own fault beeps
// take priority over anything requested here.
uint16_t buzzer;
uint16_t checksum;
};
// What we receive. This layout assumes ENABLE_ODOMETRY is defined in the
// firmware; without it the two wheel counters are absent and the frame is four
// bytes shorter, which shows up here as a permanent checksum failure.
struct __attribute__((packed)) HoverFeedback {
uint16_t start;
int16_t cmd1; // command echo, left
int16_t cmd2; // command echo, right
int16_t speedR_meas; // rpm
int16_t speedL_meas; // rpm
int16_t wheelR_cnt; // hall ticks, wraps at ODOM_MODULO
int16_t wheelL_cnt;
int16_t batVoltage; // volts * 100
int16_t boardTemp; // degrees C * 10
uint16_t cmdLed;
uint16_t checksum;
};
class HoverboardLink {
public:
void begin(HardwareSerial& port);
// Queue a command. Values are clamped to +/-CMD_MAX and wheel inversion from
// robot_config.h is applied here, so callers work in robot coordinates.
void send(int16_t left, int16_t right);
// Sound the mainboard buzzer for `ms`, then clear it automatically. Pitch
// and pattern use the firmware's own scale: lower pitch value = higher tone,
// pattern 0 = continuous. Non-blocking.
void beep(uint8_t pitch, uint8_t pattern = 0, uint16_t ms = 120);
void beepOff();
// Drain the receive buffer. Returns true if a valid frame arrived this call.
bool poll();
const HoverFeedback& feedback() const { return fb_; }
uint32_t lastFrameMs() const { return lastFrameMs_; }
bool linkUp() const;
uint32_t framesOk() const { return framesOk_; }
uint32_t framesBad() const { return framesBad_; }
// Raw bytes seen on RX. Separates "the wire is silent" from "bytes are
// arriving but never frame up" (wrong baud, swapped pins, wrong layout).
uint32_t rxBytes() const { return rxBytes_; }
float batteryVolts() const { return fb_.batVoltage / 100.0f; }
float boardTempC() const { return fb_.boardTemp / 10.0f; }
private:
HardwareSerial* port_ = nullptr;
HoverFeedback fb_{};
uint8_t buf_[sizeof(HoverFeedback)]{};
size_t have_ = 0;
uint32_t lastFrameMs_ = 0;
uint32_t framesOk_ = 0;
uint32_t framesBad_ = 0;
uint32_t rxBytes_ = 0;
uint16_t buzzer_ = 0;
uint32_t beepUntilMs_ = 0;
void dropByte();
};
// Signed difference between two readings of a counter that wraps at
// ODOM_MODULO, taking the short way round.
int16_t hoverTickDelta(int16_t prev, int16_t curr);
+121
View File
@@ -0,0 +1,121 @@
// Motion planning: jerk-limited body velocities, a speed-dependent steering
// curve, and closed-loop distance / rotation moves driven by wheel odometry.
#pragma once
#include <Arduino.h>
#include "robot_config.h"
// Wheel travel accumulated from the firmware's hall tick counters.
class Odometry {
public:
void reset(); // zero the accumulators
void update(int16_t rawLeft, int16_t rawRight);
double left() const { return left_m_; }
double right() const { return right_m_; }
double distance() const { return 0.5 * (left_m_ + right_m_); } // metres
double heading() const { return (right_m_ - left_m_) / TRACK_WIDTH_M; } // radians, CCW+
private:
bool started_ = false;
int16_t prevL_ = 0, prevR_ = 0;
double left_m_ = 0, right_m_ = 0;
};
// One axis of a jerk-limited profile: acceleration itself is rate-limited, so
// the command has no step in its second derivative and the load never lurches.
class JerkAxis {
public:
JerkAxis(float vmax, float amax, float jmax) : vmax_(vmax), amax_(amax), jmax_(jmax) {}
void update(float target, float dt);
void hardZero() { v_ = 0; a_ = 0; }
void setVMax(float vmax) { vmax_ = vmax; }
void setLimits(float amax, float jmax, float amaxDec, float jmaxDec) {
amax_ = amax; jmax_ = jmax; amaxDec_ = amaxDec; jmaxDec_ = jmaxDec;
}
float velocity() const { return v_; }
float accel() const { return a_; }
private:
float v_ = 0, a_ = 0;
float vmax_, amax_, jmax_;
float amaxDec_ = 0, jmaxDec_ = 0; // 0 until setLimits: fall back to accel
};
class Motion {
public:
enum class Mode { Idle, Drive, Velocity, MoveLinear, MoveAngular, EStop };
// Pushes the default limits into both axes. Without this the axes start with
// no braking limits set and fall back to the acceleration ones, while the
// move planner already assumes the boost — it then brakes too late.
Motion();
// Operator-style input, -1..1 each. `steer` is scaled by the speed-dependent
// curve, so the same stick deflection spins on the spot at a standstill and
// only leans the robot at speed.
void setDrive(float throttle, float steer);
// Programmatic input in physical units, no steering curve applied.
void setVelocity(float mps, float radps);
// Closed-loop moves. Both return to Idle on arrival; busy() reports progress.
void moveDistance(float metres, const Odometry& odo);
void rotate(float radians, const Odometry& odo);
// 0 = soft (long hauls), 1 = sharp (indoors). Scales acceleration and jerk
// on both axes; top speed is a separate setting.
void setResponse(float r);
float response() const { return response_; }
// How much firmer braking is than acceleration. 1.0 = symmetric.
void setDecelBoost(float x);
float decelBoost() const { return decelBoost_; }
void stop(); // ramp to zero through the jerk limits
void emergencyStop(); // zero the command this instant, then latch
void clearEStop();
void update(const Odometry& odo, float dt);
// True once per abandoned move, so the caller can report it.
bool takeAbort() { const bool a = aborted_; aborted_ = false; return a; }
int16_t cmdLeft() const { return cmdL_; }
int16_t cmdRight() const { return cmdR_; }
Mode mode() const { return mode_; }
const char* modeName() const;
bool busy() const { return mode_ == Mode::MoveLinear || mode_ == Mode::MoveAngular; }
float linear() const { return lin_.velocity(); }
float angular() const { return ang_.velocity(); }
float steerGain() const;
double remaining() const { return remaining_; }
private:
JerkAxis lin_{V_MAX, A_MAX_MPS2, J_MAX_MPS3};
JerkAxis ang_{W_MAX_RADS, ALPHA_MAX, J_MAX_ALPHA};
Mode mode_ = Mode::Idle;
float reqThrottle_ = 0, reqSteer_ = 0; // Drive mode, normalised
float reqV_ = 0, reqW_ = 0; // Velocity mode, physical units
double moveFrom_ = 0, moveTo_ = 0; // metres or radians
double holdRef_ = 0; // heading (linear move) or distance (rotation)
double remaining_ = 0;
double lastRemaining_ = 0; // for stall detection
float stallTimer_ = 0;
bool aborted_ = false;
int moveDir_ = 1; // sign of the move as commanded
float response_ = RESPONSE_DEFAULT;
float decelBoost_ = DECEL_BOOST_DEFAULT;
float aMax_ = A_MAX_MPS2, jMax_ = J_MAX_MPS3;
float alphaMax_ = ALPHA_MAX, jAlpha_ = J_MAX_ALPHA;
int16_t cmdL_ = 0, cmdR_ = 0;
void mixToWheels();
bool stalled(float dt);
bool overshot() const;
};
+151
View File
@@ -0,0 +1,151 @@
// Tuning and wiring for the TranspOtter drive controller.
//
// Everything a new chassis needs changing is in this file. The values marked
// MEASURE are geometry — the controller's distance and rotation commands are
// only as accurate as these.
#pragma once
#include <stdint.h>
// ---------------------------------------------------------------- wiring ---
// UART1 to the hoverboard's RIGHT sideboard cable (USART3).
//
// GPIO2/8/9 are strapping pins on the C3 and GPIO8 also drives the Supermini's
// LED, so all three are out. GPIO20 is UART0's default RX, but the GPIO matrix
// routes UART1 here happily — just never call Serial0.begin(), which would
// claim GPIO20/21 and fight for the pin. GPIO21 is deliberately left alone:
// the ROM bootloader logs there at every reset, which would otherwise land in
// the mainboard's receiver.
constexpr int PIN_HOVER_RX = 20; // ESP32 RX <-- mainboard TX (blue, PB10)
constexpr int PIN_HOVER_TX = 10; // ESP32 TX --> mainboard RX (green, PB11)
constexpr uint32_t HOVER_BAUD = 115200; // must match USART3_BAUD in the firmware
// ------------------------------------------------------------- geometry ---
constexpr float WHEEL_DIAMETER_M = 0.1651f; // 6.5" hub motor. MEASURE for other wheels.
constexpr float TRACK_WIDTH_M = 0.570f; // measured, wheel centre to wheel centre
constexpr int TICKS_PER_WHEEL_REV = 90; // 15 pole pairs x 6 hall states (firmware n_polePairs)
constexpr int ODOM_MODULO = 9000; // firmware wraps wheel_cnt at 9000 (= 100 revolutions)
// Set true if a wheel counts or drives backwards. Calibrate once, on blocks,
// with `fwd 1` and `status` — see the README.
constexpr bool INVERT_LEFT = false;
constexpr bool INVERT_RIGHT = false;
constexpr bool INVERT_ODOM_LEFT = false;
constexpr bool INVERT_ODOM_RIGHT = false;
// ------------------------------------------- agreement with the firmware ---
// These mirror Inc/config.h in hoverboard-firmware-hack-FOC. In SPD_MODE a
// command of +/-1000 is a closed-loop request for +/-N_MOT_MAX rpm, so the
// mapping from m/s to command units is exact. Change one side, change both.
constexpr float N_MOT_MAX_RPM = 200.0f; // firmware N_MOT_MAX
constexpr int CMD_MAX = 1000; // firmware INPUT_MAX
// -------------------------------------------------------- motion limits ---
// Body-frame limits. The linear and angular axes are each jerk-limited, so a
// wheel never sees a step in acceleration — this is what keeps a heavy load
// from lurching at the start and nodding at the stop.
constexpr float A_MAX_MPS2 = 0.50f; // linear acceleration
constexpr float J_MAX_MPS3 = 3.00f; // linear jerk: how fast acceleration itself may change.
// 1.0 was too low to stop on a mark: reversing acceleration
// took a full second, and a closed-loop move sailed 160 mm
// past its target. 3.0 still ramps in over ~170 ms, so there
// is no step in acceleration and no lurch.
// Braking may be firmer than acceleration — a gentle start is what stops a
// load lurching, but a lazy stop just feels like slack in the controls.
//
// The multiplier scales the deceleration limit by x and the deceleration JERK
// by x*x. That second part is the one you feel: the delay before braking bites
// is amax/jmax, so squaring the jerk shortens that dead time by a factor of x
// rather than leaving it unchanged.
constexpr float DECEL_BOOST_DEFAULT = 2.0f;
constexpr float W_MAX_RADS = 1.40f; // angular rate at full steer, standstill (~80 deg/s)
constexpr float ALPHA_MAX = 1.60f; // angular acceleration
constexpr float J_MAX_ALPHA = 9.60f; // angular jerk (same reasoning as J_MAX_MPS3)
// The four limits above are the SOFT end of the response range — the tuning
// validated on the bench, and the right feel for hauling a load a long way.
// The values below are the SHARP end, for indoor work where reacting matters
// more than smoothness. The `response` setting (0..1, live-adjustable and
// saved to flash) interpolates between the two, so nothing here is a rebuild.
constexpr float A_MAX_SHARP = 1.50f;
constexpr float J_MAX_SHARP = 9.00f;
constexpr float ALPHA_MAX_SHARP = 3.50f;
constexpr float J_ALPHA_SHARP = 21.0f;
// Startup defaults, overridden by whatever was last saved.
constexpr float RESPONSE_DEFAULT = 0.0f; // soft
constexpr float SPEED_LIMIT_DEFAULT = 0.4f; // 40% of V_MAX on the joystick
// --------------------------------------------------------- steering feel ---
// Angular authority as a function of how fast the robot is already going:
//
// gain(s) = HIGH + (LOW - HIGH) * exp(-s / KNEE), s = |v| / V_MAX
//
// LOW applies at standstill, decaying towards HIGH as speed builds. KNEE is the
// fraction of top speed over which most of the extra authority disappears, so a
// small KNEE means "sharp only when crawling".
constexpr float STEER_GAIN_LOW = 1.00f; // full W_MAX when stationary — spins on the spot
constexpr float STEER_GAIN_HIGH = 0.22f; // at top speed, a gentle curve instead of a swerve
constexpr float STEER_KNEE = 0.15f; // authority is ~halved by 10% of top speed
// ---------------------------------------------------- closed-loop moves ---
constexpr float MOVE_V_MAX = 0.35f; // m/s ceiling during `fwd` (leaves braking margin)
constexpr float MOVE_W_MAX = 0.70f; // rad/s ceiling during `rot`
constexpr float MOVE_TOL_M = 0.010f; // arrival tolerance, metres (~2 hall ticks)
constexpr float MOVE_TOL_RAD = 0.020f; // arrival tolerance, radians (~1.1 deg)
constexpr float MOVE_SETTLE_MPS = 0.02f; // below this a linear move counts as stopped
constexpr float MOVE_SETTLE_RADS = 0.05f; // below this a rotation counts as stopped
// Floor on the approach speed. Without it the braking profile decays towards
// zero as the target nears and the last millimetres take forever. Both values
// are slow enough to stop inside the tolerance above.
constexpr float MOVE_CREEP_MPS = 0.030f;
constexpr float MOVE_CREEP_RADS = 0.050f;
// ---------------------------------------------------------------- timing ---
constexpr uint32_t CONTROL_PERIOD_MS = 10; // 100 Hz command rate (firmware times out at 800 ms)
constexpr uint32_t FEEDBACK_TIMEOUT_MS = 500; // no valid frame for this long -> ramp down
constexpr uint32_t ARM_HOLD_MS = 1500; // hold zero at boot so the firmware arms the motors
// Derived: top wheel speed the firmware will honour, in m/s.
constexpr float PI_F = 3.14159265f;
constexpr float V_MAX = (N_MOT_MAX_RPM / 60.0f) * PI_F * WHEEL_DIAMETER_M;
// Trim gains for closed-loop moves: a straight `fwd` holds heading, a `rot`
// holds position. Both correct the small mismatch between the two wheels.
constexpr float MOVE_HEADING_KP = 1.5f; // rad/s per radian of heading error
constexpr float MOVE_POSITION_KP = 1.0f; // m/s per metre of drift
constexpr float MOVE_TRIM_W_MAX = 0.35f; // clamp on the heading correction
constexpr float MOVE_TRIM_V_MAX = 0.15f; // clamp on the position correction
// Metres of travel per hall tick — the resolution of every closed-loop move.
constexpr float METRES_PER_TICK = (PI_F * WHEEL_DIAMETER_M) / TICKS_PER_WHEEL_REV;
// ------------------------------------------------------------- remote UI ---
// The ESP32 hosts its own WiFi network and serves the control page at
// http://192.168.4.1 — no router and nothing to install. The password is WPA2
// and must be at least 8 characters; an open network would let anyone within
// range drive the machine.
constexpr char AP_SSID[] = "transpotter";
constexpr char AP_PASSWORD[] = "otterdrive";
constexpr int AP_CHANNEL = 6;
constexpr int AP_MAX_CLIENTS = 2;
// The browser sends a drive frame at 10 Hz, so it can afford a much tighter
// deadman than a human typing at the console. Walking out of radio range then
// ramps the machine down in well under a second.
constexpr uint32_t REMOTE_DEADMAN_MS = 400;
// A closed-loop move ends by arriving, so it is exempt from the deadman below.
// That leaves it with no time limit at all, which is dangerous if the odometry
// stops advancing — a dropped link, or a wheel jammed against an obstacle. If
// the remaining distance fails to change by PROGRESS_EPS within STALL_S, the
// move is abandoned.
constexpr float MOVE_PROGRESS_EPS = 0.005f; // metres or radians
constexpr float MOVE_STALL_S = 1.5f;
// A `drive` or `vel` command is a standing order; if nothing refreshes it
// within this long the controller ramps down. Closed-loop moves are exempt —
// they end by arriving.
constexpr uint32_t COMMAND_DEADMAN_MS = 2000;
+217
View File
@@ -0,0 +1,217 @@
// The control page, served from flash. Kept deliberately small and
// dependency-free: no frameworks, no CDN, nothing to fetch. It has to work
// when the phone is joined to the machine's own network with no internet.
#pragma once
static const char PAGE_HTML[] PROGMEM = R"HTML(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no,viewport-fit=cover">
<title>TranspOtter</title>
<style>
:root{
--bg:#0f1214; --panel:#181d21; --line:#2a3238; --text:#e6edf3;
--dim:#8b979f; --accent:#4cc2ff; --ok:#3fb950; --warn:#d29922; --bad:#f85149;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%}
body{
background:var(--bg);color:var(--text);
font:14px/1.4 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:flex;flex-direction:column;gap:10px;padding:10px;
padding-bottom:calc(10px + env(safe-area-inset-bottom));
overscroll-behavior:none;touch-action:none;
}
header{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.02em}
.pill{
display:inline-flex;align-items:center;gap:6px;background:var(--panel);
border:1px solid var(--line);border-radius:999px;padding:4px 10px;
font-variant-numeric:tabular-nums;font-size:12px;
}
.dot{width:8px;height:8px;border-radius:50%;background:var(--bad)}
.dot.on{background:var(--ok)}
.pad{
flex:1;min-height:210px;background:var(--panel);border:1px solid var(--line);
border-radius:16px;position:relative;overflow:hidden;touch-action:none;
}
.pad canvas{width:100%;height:100%;display:block}
.row{display:flex;gap:8px}
.row>*{flex:1}
button{
background:var(--panel);color:var(--text);border:1px solid var(--line);
border-radius:12px;padding:13px 8px;font-size:14px;font-weight:600;
font-family:inherit;cursor:pointer;
}
button:active{background:#232a30}
button.stop{border-color:#5a4a1f;color:#f0c674}
button.estop{background:#3d1519;border-color:#7d2b31;color:#ff9c96}
.grid{
display:grid;grid-template-columns:repeat(auto-fit,minmax(88px,1fr));gap:6px;
background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;
}
.cell{display:flex;flex-direction:column;gap:2px}
.k{color:var(--dim);font-size:10px;text-transform:uppercase;letter-spacing:.06em}
.val{font-variant-numeric:tabular-nums;font-size:15px;font-weight:600}
.limit{display:flex;align-items:center;gap:10px;background:var(--panel);
border:1px solid var(--line);border-radius:12px;padding:8px 12px}
input[type=range]{flex:1;accent-color:var(--accent)}
@media (min-width:720px){ body{max-width:760px;margin:0 auto} }
</style>
</head>
<body>
<header>
<h1>TranspOtter</h1>
<span class="pill"><span id="dot" class="dot"></span><span id="conn">offline</span></span>
<span class="pill" id="mode">idle</span>
<span class="pill" id="batt">— V</span>
<span class="pill" id="temp">— &deg;C</span>
</header>
<div class="pad" id="pad"><canvas id="cv"></canvas></div>
<div class="limit">
<span class="k">Speed</span>
<input type="range" id="lim" min="5" max="100" value="40">
<span class="val" id="limv" style="min-width:3.2em;text-align:right">40%</span>
</div>
<div class="limit">
<span class="k">Response</span>
<input type="range" id="rsp" min="0" max="100" value="0">
<span class="val" id="rspv" style="min-width:3.2em;text-align:right">soft</span>
</div>
<div class="row">
<button id="p0">HAUL</button>
<button id="p1">NORMAL</button>
<button id="p2">INDOOR</button>
</div>
<div class="row">
<button class="stop" id="bstop">STOP</button>
<button class="estop" id="bestop">E-STOP</button>
</div>
<div class="row">
<button id="barm">ARM</button>
<button id="bzero">ZERO ODO</button>
</div>
<div class="grid">
<div class="cell"><span class="k">speed</span><span class="val" id="v">0.00</span></div>
<div class="cell"><span class="k">turn</span><span class="val" id="w">0.00</span></div>
<div class="cell"><span class="k">steer gain</span><span class="val" id="g">1.00</span></div>
<div class="cell"><span class="k">cmd L/R</span><span class="val" id="c">0/0</span></div>
<div class="cell"><span class="k">distance</span><span class="val" id="d">0.00</span></div>
<div class="cell"><span class="k">heading</span><span class="val" id="h">0.0</span></div>
<div class="cell"><span class="k">frames</span><span class="val" id="f">0</span></div>
<div class="cell"><span class="k">link</span><span class="val" id="lk">down</span></div>
</div>
<script>
const $=id=>document.getElementById(id);
let ws=null, wsOpen=false;
let jx=0, jy=0, limit=0.4, active=false;
// ---- joystick -------------------------------------------------------------
const pad=$('pad'), cv=$('cv'), ctx=cv.getContext('2d');
let W=0,H=0,R=0,cx=0,cy=0,dpr=1;
function size(){
dpr=window.devicePixelRatio||1;
W=pad.clientWidth; H=pad.clientHeight;
cv.width=W*dpr; cv.height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0);
cx=W/2; cy=H/2; R=Math.min(W,H)/2-16;
draw();
}
function draw(){
ctx.clearRect(0,0,W,H);
ctx.strokeStyle='#2a3238'; ctx.lineWidth=1;
ctx.beginPath(); ctx.arc(cx,cy,R,0,6.2832); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx-R,cy); ctx.lineTo(cx+R,cy);
ctx.moveTo(cx,cy-R); ctx.lineTo(cx,cy+R); ctx.stroke();
ctx.strokeStyle='#212930';
ctx.beginPath(); ctx.arc(cx,cy,R*0.5,0,6.2832); ctx.stroke();
const kx=cx+jx*R, ky=cy-jy*R;
ctx.strokeStyle='#4cc2ff'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(kx,ky); ctx.stroke();
ctx.fillStyle=active?'#4cc2ff':'#5a666e';
ctx.beginPath(); ctx.arc(kx,ky,20,0,6.2832); ctx.fill();
}
function at(e){
const r=pad.getBoundingClientRect();
let x=(e.clientX-r.left-cx)/R, y=-(e.clientY-r.top-cy)/R;
const m=Math.hypot(x,y); if(m>1){x/=m;y/=m;}
jx=x; jy=y; draw();
}
pad.addEventListener('pointerdown',e=>{active=true;pad.setPointerCapture(e.pointerId);at(e);});
pad.addEventListener('pointermove',e=>{if(active)at(e);});
function release(){active=false;jx=0;jy=0;draw();}
pad.addEventListener('pointerup',release);
pad.addEventListener('pointercancel',release);
window.addEventListener('resize',size);
// Speed scales top end; response scales how hard it accelerates to get there.
// Both live on the device and are saved there, so a fresh phone picks up the
// settings you last used rather than imposing its own.
let response=0, synced=false;
function rspLabel(v){ return v<15?'soft':v<40?'easy':v<70?'brisk':v<90?'sharp':'max'; }
function pushSettings(){ send('s,'+limit.toFixed(2)+','+response.toFixed(2)); }
$('lim').addEventListener('input',e=>{
limit=e.target.value/100; $('limv').textContent=e.target.value+'%'; pushSettings();
});
$('rsp').addEventListener('input',e=>{
response=e.target.value/100; $('rspv').textContent=rspLabel(+e.target.value); pushSettings();
});
function preset(v){
response=v/100; $('rsp').value=v; $('rspv').textContent=rspLabel(v); pushSettings();
}
$('p0').onclick=()=>preset(0);
$('p1').onclick=()=>preset(45);
$('p2').onclick=()=>preset(100);
// ---- link -----------------------------------------------------------------
function connect(){
ws=new WebSocket('ws://'+location.host+'/ws');
ws.onopen =()=>{wsOpen=true;synced=false;$('dot').classList.add('on');$('conn').textContent='online';};
ws.onclose=()=>{wsOpen=false;$('dot').classList.remove('on');$('conn').textContent='offline';
setTimeout(connect,700);};
ws.onerror=()=>ws.close();
ws.onmessage=e=>{
let t; try{t=JSON.parse(e.data);}catch(_){return;}
$('mode').textContent=t.m;
$('batt').textContent=t.b.toFixed(1)+' V';
$('temp').textContent=t.t.toFixed(0)+' °C';
$('v').textContent=t.v.toFixed(2);
$('w').textContent=t.w.toFixed(2);
$('g').textContent=t.g.toFixed(2);
$('c').textContent=t.cl+'/'+t.cr;
$('d').textContent=t.d.toFixed(2);
$('h').textContent=t.h.toFixed(1);
$('f').textContent=t.fo+(t.fb?(' / '+t.fb+' bad'):'');
$('lk').textContent=t.k?'up':'down';
$('lk').style.color=t.k?'var(--ok)':'var(--bad)';
if(!synced && t.lim!==undefined){ // adopt the device's saved values once
synced=true;
limit=t.lim; $('lim').value=Math.round(t.lim*100); $('limv').textContent=Math.round(t.lim*100)+'%';
response=t.rsp; $('rsp').value=Math.round(t.rsp*100); $('rspv').textContent=rspLabel(Math.round(t.rsp*100));
}
};
}
function send(s){ if(wsOpen) try{ws.send(s);}catch(_){ } }
function cmd(w){ send('c,'+w); }
$('bstop').onclick =()=>{release();cmd('stop');};
$('bestop').onclick=()=>{release();cmd('estop');};
$('barm').onclick =()=>cmd('arm');
$('bzero').onclick =()=>cmd('zero');
// The drive frame is also the heartbeat: it goes out at 10 Hz whether or not
// the stick moved. Stop sending — background the tab, walk out of range — and
// the firmware's deadman ramps the machine down.
setInterval(()=>send('d,'+jy.toFixed(3)+','+jx.toFixed(3)),100);
size(); connect();
</script>
</body>
</html>)HTML";
+56
View File
@@ -0,0 +1,56 @@
// WiFi access point + web UI for hand-driving the machine.
//
// The ESP32 hosts its own network; a browser at http://192.168.4.1 gets a
// touch joystick and live telemetry over a WebSocket. The page sends a drive
// frame at 10 Hz and the server answers each one with telemetry, so the
// request/response pairing doubles as the heartbeat — stop sending and the
// deadman ramps the machine down.
//
// The HTTP server runs in its own FreeRTOS task, so everything shared with the
// control loop goes through a spinlock.
#pragma once
#include <Arduino.h>
#include "robot_config.h"
namespace webui {
// Snapshot the control loop publishes for the browser to read.
struct Telemetry {
const char* mode = "idle";
int16_t cmdL = 0, cmdR = 0;
float v = 0, w = 0, gain = 1;
float dist = 0, headDeg = 0;
float battery = 0, temp = 0;
bool link = false, armed = false;
uint32_t framesOk = 0, framesBad = 0;
float limit = SPEED_LIMIT_DEFAULT;
float response = RESPONSE_DEFAULT;
};
struct DriveInput {
float throttle = 0, steer = 0;
};
struct Settings {
float limit = SPEED_LIMIT_DEFAULT; // joystick throttle scale, 0..1
float response = RESPONSE_DEFAULT; // acceleration sharpness, 0..1
};
void begin();
// Called every control tick.
void publish(const Telemetry& t);
// True if a drive frame arrived since the last call.
bool takeDrive(DriveInput& out);
// True if a one-shot command word arrived ("stop", "estop", "arm", "zero").
bool takeCommand(char* buf, size_t len);
// True if the browser changed the tuning sliders.
bool takeSettings(Settings& out);
uint8_t clients();
} // namespace webui