-
Notifications
You must be signed in to change notification settings - Fork 0
episode 5 the joystick
Taking control of ORION — one axis at a time / Tomando el control de ORION — un eje a la vez
Commit and release version: Release: Episode 5 - The joystick
- 🎥 Watch the Short
- 📖 Episode Description
- Before You Start
- Joystick Wiring to the ESP32
- Changes from Episode 4 (The Node)
- 💡 Lessons & Learnings
- 🎯 Challenge Posed
- ⏭️ Next Episode
In this episode the vambrace comes alive: we wire an analog joystick to the ESP32 and integrate it with the micro-ROS node to send velocity commands (cmd_vel) to ORION. We face real hardware challenges (power supply, calibration, deadzone) and software challenges (timestamps, modular architecture) to make the robot respond to joystick movements.
Important: Before proceeding with this episode, review the project Wiki to make sure your environment is properly configured.
-
Configure WiFi credentials — Copy
lib/vambrace_secret/vambrace_secret_ex.hpptolib/vambrace_secret/vambrace_secret.hppand fill in your details:#define WIFI_SSID "YOUR_NETWORK" #define WIFI_PASS "YOUR_PASSWORD" #define AGENT_IP_STR "192.168.X.X" // IP of agent's computer #define AGENT_PORT 8888
-
Connect the joystick to the ESP32 (see wiring section below).
-
Build and upload the firmware:
cd firmware && pio run --target upload
-
Start the micro-ROS agent on ORION:
ros2 run micro_ros_agent micro_ros_agent udp4 --port 8888
-
Verify connection:
# List topics — /mobile_base_controller/cmd_vel should appear ros2 topic list # Watch joystick messages in real time ros2 topic echo /mobile_base_controller/cmd_vel # Check publishing frequency (~20 Hz) ros2 topic hz /mobile_base_controller/cmd_vel
-
Serial monitor (to see calibration and debug output):
pio device monitor --baud 115200
Joystick Module ESP32 DevKit V1.0
┌──────────────┐ ┌──────────────────┐
│ VCC ─────────┼────────→│ 3.3V │
│ GND ─────────┼────────→│ GND │
│ VRx ─────────┼────────→│ GPIO 34 (ADC1) │
│ VRy ─────────┼────────→│ GPIO 35 (ADC1) │
│ SW ─────────┼────────→│ GPIO 32 │
└──────────────┘ └──────────────────┘
VRx (GPIO 34) and VRy (GPIO 35) — Analog axes:
- The joystick has two internal potentiometers that output an analog voltage proportional to the stick position.
- The ESP32 has two ADC blocks: ADC1 (GPIOs 32–39) and ADC2 (GPIOs 0, 2, 4, 12–15, 25–27).
- ADC2 cannot be used when WiFi is active — and our vambrace relies on WiFi to communicate with ORION via micro-ROS.
- Therefore, joystick axes must be on ADC1 pins.
- GPIOs 34 and 35 are input-only pins (no internal pull-up/pull-down), which is perfect for analog reading — we don't need output on these pins.
SW / Button (GPIO 32) — Joystick press button:
- The joystick button is a digital input activated by pressing down on the stick.
- GPIO 32 supports internal pull-up, so we configure
INPUT_PULLUPand need no external resistor. - Direct wiring:
GPIO 32 → Button → GND. When pressed, the pin reads LOW.
Discarded pins:
| Pins | Reason for discarding |
|---|---|
| GPIOs 0, 2, 5, 12, 15 | Strapping pins — affect the ESP32 boot mode. May prevent proper startup. |
| GPIOs 6–11 | Flash SPI — connected to the onboard flash memory chip. Using them causes immediate crash and boot loop. |
| GPIOs 0, 2, 4, 12–15, 25–27 | ADC2 — unusable when WiFi is active. Analog reads return erratic values. |
Power supply — 3.3V, NOT 5V:
- The ESP32 ADC reads in the 0–3.3V range. Feeding the joystick with 5V causes values above 3.3V to saturate the ADC at 4095, compressing the usable range and producing incorrect readings.
- With 3.3V, the joystick operates across the full 0–3.3V → 0–4095 range of the 12-bit ADC.
In Episode 4 only vambrace_app (data model) and vambrace_ros (communication) existed. There was no peripheral reading — published values were always defaults.
A new library lib/vambrace_hardware/ was created with the responsibility of reading all physical peripherals and updating the data model:
lib/
├── vambrace_app/ ← Data model (TeleoperationCmd) — Ep. 4
├── vambrace_ros/ ← micro-ROS communication — Ep. 4
├── vambrace_hardware/ ← Peripheral reading — Ep. 5 (NEW)
│ ├── vambrace_hardware.hpp
│ └── vambrace_hardware.cpp
└── vambrace_config/ ← Centralized configuration — Ep. 4 (UPDATED)
Simple interface:
void vambrace_hardware_init(); // Configure pins, calibrate joystick
void vambrace_hardware_update(TeleoperationCmd& cmd); // Read joystick, update cmdAll joystick parameters were added to the centralized configuration file:
// Joystick pins (ADC1 only — ADC2 conflicts with WiFi)
#define JOY_X_PIN 34
#define JOY_Y_PIN 35
#define JOY_BTN_PIN 32
// Joystick parameters
#define JOY_DEADZONE 200 // raw ADC units around center
#define JOY_CENTER 2048 // fallback center (12-bit ADC)
#define JOY_MAX_DEVIATION 2048 // max deviation from center
#define MAX_LINEAR_VEL 1.0f // m/s
#define MAX_ANGULAR_VEL 2.0f // rad/s
#define MAX_LINEAR_VEL_SPRINT 2.0f // m/s (sprint)
#define MAX_ANGULAR_VEL_SPRINT 4.0f // rad/s (sprint)
// Update intervals (ms)
#define JOY_INTERVAL_MS 50 // 20 HzThe joystick center varies between modules (it's not always 2048). An automatic calibration was implemented in vambrace_hardware_init() at startup:
- Takes 32 readings from each axis while the joystick is at rest
- Averages the readings to find the real center
- Validates the result — if the center is too far from the expected value (±500 from 2048), it warns via Serial and falls back to the default
- Prints calibrated values via Serial for verification
void vambrace_hardware_init()
{
pinMode(JOY_BTN_PIN, INPUT_PULLUP);
long sum_x = 0;
long sum_y = 0;
for (int i = 0; i < CALIBRATION_SAMPLES; i++)
{
sum_x += analogRead(JOY_X_PIN);
sum_y += analogRead(JOY_Y_PIN);
delay(5);
}
joy_center_x = sum_x / CALIBRATION_SAMPLES;
joy_center_y = sum_y / CALIBRATION_SAMPLES;
if (abs(joy_center_x - 2048) > 500 || abs(joy_center_y - 2048) > 500)
{
Serial.println("WARNING: Joystick center far from expected. Was the stick touched during boot?");
joy_center_x = JOY_CENTER;
joy_center_y = JOY_CENTER;
}
}The joystick has a dead zone around the center to prevent involuntary movement (drift). The implementation fixes a common error where the value jumps from 0.0 to a high value when leaving the deadzone:
float normalize(int raw, int center)
{
int centered = raw - center;
if (abs(centered) < JOY_DEADZONE) return 0.0f;
int sign = (centered > 0) ? 1 : -1;
int adjusted = abs(centered) - JOY_DEADZONE; // subtract the deadzone
int usable_range = JOY_MAX_DEVIATION - JOY_DEADZONE; // reduced usable range
return (float)sign * (float)adjusted / (float)usable_range;
}Without the fix: leaving the deadzone (±200) would cause the normalized value to jump from 0.0 to ~0.1 (discontinuity).
With the fix: leaving the deadzone starts at 0.0 and grows smoothly to 1.0.
Pressing the joystick button (SW) switches to sprint velocities (MAX_LINEAR_VEL_SPRINT and MAX_ANGULAR_VEL_SPRINT):
bool sprinting = (digitalRead(JOY_BTN_PIN) == LOW);
float max_lin = sprinting ? MAX_LINEAR_VEL_SPRINT : MAX_LINEAR_VEL;
float max_ang = sprinting ? MAX_ANGULAR_VEL_SPRINT : MAX_ANGULAR_VEL;
float linear_x = -normalize(raw_y, joy_center_y) * max_lin;
float angular_z = -normalize(raw_x, joy_center_x) * max_ang;- Button released (HIGH): normal speed (1.0 m/s linear, 2.0 rad/s angular)
- Button pressed (LOW): sprint speed (2.0 m/s linear, 4.0 rad/s angular)
- No debounce needed — it's not a toggle, it's a "hold to sprint" mechanic
In Episode 4, the TwistStamped header used millis() for the timestamp. The problem: millis() counts from ESP32 boot (seconds: 0, 1, 2...) while ROS 2 uses real Unix time (seconds: ~1774000000). ORION's controller rejected the messages due to invalid timestamps.
Solution: Sync the ESP32 clock via NTP after connecting to WiFi, with a fallback if sync hasn't completed:
// In vambrace_micro_ros_connect_wifi(), after connecting:
configTime(0, 0, "pool.ntp.org");
// In publish, use gettimeofday() only if NTP has synced:
if (ntp_synced) {
struct timeval tv;
gettimeofday(&tv, NULL);
msg_cmd_vel.header.stamp.sec = (int32_t)tv.tv_sec;
msg_cmd_vel.header.stamp.nanosec = (uint32_t)(tv.tv_usec * 1000);
} else {
msg_cmd_vel.header.stamp.sec = 0;
msg_cmd_vel.header.stamp.nanosec = 0;
}The header frame_id was changed from "base_link" to "teleop_twist_joy" to be consistent with the format ORION uses with other teleoperation controllers (such as the Xbox gamepad).
Joystick reading uses a millis() timer pattern instead of delay(). This avoids blocking the main loop and allows micro-ROS to keep processing:
void vambrace_hardware_update(TeleoperationCmd& cmd)
{
uint32_t now = millis();
if (now - last_joy_ms >= JOY_INTERVAL_MS) // every 50ms
{
last_joy_ms = now;
cmd.setMobileBaseVelocity(read_joystick());
}
}The main loop remained clean and modular:
#include "vambrace_ros.hpp"
#include "vambrace_app.hpp"
#include "vambrace_hardware.hpp"
TeleoperationCmd vambrace_cmds;
void setup()
{
vambrace_micro_ros_init();
vambrace_hardware_init();
}
void loop()
{
vambrace_hardware_update(vambrace_cmds);
vambrace_micro_ros_publish(vambrace_cmds);
vambrace_micro_ros_spin(10);
delay(20);
}Each module has a clear responsibility:
-
vambrace_hardware→ reads sensors → updatesvambrace_cmds -
vambrace_ros→ takesvambrace_cmds→ publishes to ROS 2 -
main.cpp→ orchestrates the flow
Connecting a peripheral to a microcontroller goes beyond wiring pins. It requires understanding hardware constraints (ADC1 vs ADC2, voltage ranges, restricted pins), implementing calibration to compensate for component variations, and designing software that is modular, non-blocking, and robust against analog imprecision.
- ADC1 vs ADC2: On the ESP32, ADC2 is unusable when WiFi is active. All analog inputs must use ADC1 (GPIOs 32–39).
- Power supply matters: A joystick fed with 5V saturates the ESP32's 3.3V ADC, producing compressed and incorrect readings. Always power analog peripherals with 3.3V.
- The center is not universal: Each joystick has a different mechanical center. Auto-calibration at startup removes the dependency on a hardcoded value.
- Deadzone without discontinuity: Subtracting the deadzone before normalizing avoids the abrupt jump from 0.0 to a high value when moving the joystick.
-
Real timestamps: micro-ROS with
TwistStampedrequires Unix timestamps synchronized via NTP, not boot-relativemillis(). -
Pointers vs copies: When a ROS message points to the same buffer that gets cleared after publishing, there is a risk of sending empty data if
rcl_publishis asynchronous.
"(ORION crashes) Well... next time will be the arms... I guess" — Danna
With basic velocity teleoperation solved, the next challenge is integrating the remaining peripherals: potentiometers for the arms, membrane keypad for emotions and commands, buttons for actions, and a toggle system to safely enable/disable publishing to prevent undesired actions when not using the vambrace.
➡️ Episode 6: The Potentiometers
Characters: Felipe, David, Danna, ORION | Date: 2026-04-05
⬅️ Previous: The Node | 🏠 Home | Next ➡️: The Potentiometers