First commit
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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">— °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";
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user