This page covers the platform’s physical ground hardware: the SkyHub Nexus airhub (an autonomous battery-swap and charging station drones dock to) and the ESP32 firmware for the two ground robots, skyhub_rover and skyhub_ugv. These are the edge of the fleet — the pieces you flash onto a microcontroller or bolt to a landing pad, not services that run in AWS.
None of the repos on this page are wired into the Gateway control plane in-repo. Nexus is standalone USB-tethered hardware. Rover and UGV are self-contained robot firmware that today speak their own HTTP/ESP-NOW protocol. Where they would attach to the platform is through the WS Proxy gamepad channel — described under Control-plane integration below. For the wider satellite map see the Ecosystem Overview.

Nexus AirHub — battery swap & charging

skyhub_nexus controls the airhub: the ground station a drone lands on to have its depleted LiPo swapped for a charged one. It is two loosely-coupled subsystems, both driven from a host PC over USB:

Swap actuation

A Python host talks to an Arduino Nano Every over USB serial (/dev/ttyACM0, 9600 bps) to switch which battery is bonded to the charger via relays. Sketch: battery_swapping/Battery_Swapping_Station.ino.

Charge cycling

A separate USB link drives an IMAX B6 Mini hobby charger (pyusb) to run LiPo charge/discharge/storage cycles. Logic in imax_usb.py, defaults in imaxconfig.ini.

Swap station serial protocol

The Arduino firmware (battery_swapping/Battery_Swapping_Station.ino) is a single-character command loop over serial at 9600 bps. It manages four relay-bonded battery slots — two 3-cell and two 4-cell — plus a voltage sensor and an error LED. Commands are sent as a bare ASCII digit (no quotes, line-ending optional), and every command echoes a status string back.
CmdActionRelay / pinSuccess reply
1Connect 3S battery #1battery_3s_1 (pin 4)1_OK_battery_3s_1
2Connect 4S battery #1battery_4s_1 (pin 5)2_OK_battery_4s_1
3Connect 4S battery #2battery_4s_2 (pin 6)3_OK_battery_4s_2
4Connect 3S battery #2battery_3s_2 (pin 7)4_OK_battery_3s_2
5Disconnect all batteriesall relays LOW5_OK_OFF_All_Batteries__Voltage_NOT_present
6Report current status(read-only)6_Connected_battery_3s_1 … / 6_All_Batteries_are_Disconnected
The reference driver main.py demonstrates the intended sequence — query status, then bond a slot:
skyhub_nexus/main.py
serialPort = serial.Serial(port="/dev/ttyACM0", baudrate=9600)
time.sleep(2)                      # let the Arduino reset settle
serialPort.write(str.encode('6'))  # status
serialPort.write(str.encode('1'))  # bond 3S battery #1 to the charger
The swap station enforces a hard electrical safety interlock in firmware (Battery_Swapping_Station.ino), and any change must preserve it:
  • One battery at a time. Before energizing a slot’s relay the sketch drives all other slots LOW and waits 250 ms for contacts to settle. To switch batteries you must issue 5 (disconnect all) first, then the new slot.
  • Voltage verification. Before bonding, voltage_sensor() enables the sense line (pin 2), waits a full 8000 ms for the charger’s output capacitor to discharge, then reads pin 3. If voltage is still present it refuses the command, returns a NOT_OK_Battery_connected_or_voltage_still_present reply, and lights the red error LED. Do not shorten that delay.
  • Never disconnect with 5 while a charge is running — stop the charge on the IMAX first.
  • Max charge current for this station revision is 7.5 A.

IMAX B6 charger control

imax_usb.py speaks the IMAX B6 Mini’s raw USB protocol via pyusb (device idVendor=0x0000, idProduct=0x0001). It builds a 64-byte settings packet (get_settings_packet()) encoding battery chemistry, cell count, charge mode, and current limits, then streams periodic reads back (energy, timer, voltage, current, temperatures, per-cell voltages). Battery chemistry and cell count come from imaxconfig.ini:
skyhub_nexus/imaxconfig.ini
[BatterySettings]
# allowable types: nimh, nicd, lipo, life, liion, lihv
bat_type = LiPO
cells = 3

[SelectorSettings]
slider_max = 4050
nominal_mah_start = 500
Supported chemistries and their mode maps live in the byte tables at the top of imax_usb.py (btype, LiXX_CD_Modes, NiXX_CD_Modes, sensitivity, limits). detach.py is a one-shot helper that detaches the kernel driver and claims the USB device — run it if pyusb reports the interface is busy.
The IMAX driver builds on the community imax_charger library (from imax_charger import imax) documented at GaryDyr/imaxcharger. It expects a USB-serial IMAX B6 clone that exposes the raw HID-style protocol; genuine units with only a balance port will not enumerate at 0x0000:0x0001.

How Nexus fits the docking workflow

Autonomous docking is a choreography of the two subsystems: land the drone, 6 to read station state, 5 to isolate, bond the target slot with 14, then run an IMAX cycle. There is no ROS or rosbridge involvement — a higher-level fleet automation layer (not present in this repo) would sequence Nexus alongside the drone’s landing. On-drone precision landing that gets a drone onto the pad is covered in YOLO Detection, ArUco Landing & Docking.

Ground vehicle firmware — Rover & UGV

skyhub_rover and skyhub_ugv are ESP32 firmware for WaveShare-class differential-drive ground robots. They are siblings with the same hardware DNA (dual DC motors + encoders, IMU, INA219 power monitor, SSD1306 OLED, SCServo bus servos) but different build systems and feature depth.
skyhub_roverskyhub_ugv
Build systemArduino IDE sketch (OUR_ROVER/OUR_ROVER.ino)PlatformIO (UGV/platformio.ini)
BoardESP32 (esp32dev)ESP32 (esp32dev), 4 MB flash, 240 MHz
Toolchain constraintESP32 Arduino core 2.0.17 — 3.x breaks the buildPlatformIO espressif32 platform
Web UI / control portHTTP WebServer on :80HTTP WebServer on :80
Command protocolJSON {"T":<cmd>,...} via /cmd & /jsJSON {"T":<cmd>,...} via /js
Peer linkESP-NOW (struct_message)ESP-NOW (leader/follower modes)
IMUQMI8658 + AK09918 magICM-20948 (SparkFun)
ExtrasRC PWM passthrough (RMT reader)RoArm-M2 arm + gimbal module, named missions
Default AP SSIDUGV01_BASE / 12345678UGV (or RoArm) / 12345678

Rover firmware (skyhub_rover)

The Rover is an Arduino IDE single-sketch project — OUR_ROVER.ino pulls in a dozen .h modules (config.h, connectionFuncs.h, motorCtrl.h, IMU.h, busServoCtrl.h, …). On boot it initializes the IMU, servos, OLED and WiFi, then spins two FreeRTOS tasks (serialCtrl, motorSpeedGet) and starts the web server.
The Rover must be built against ESP32 Arduino core exactly 2.0.17 — the 3.x core breaks the build. This is the single most common flashing failure and is called out in skyhub_rover/OUR_ROVER/README.md.
Networking (connectionFuncs.h, config.h): WiFi defaults to AP mode (DEFAULT_WIFI_MODE 1) advertising SSID UGV01_BASE (password 12345678); STA credentials for JSBZY-2.4G are compiled in, and a setTrySTA() fallback can try STA first and drop back to AP. The WebServer on port 80 exposes:
RoutePurpose
GET /Serves the control web UI (WebPage.h)
GET /deviceInfoJSON telemetry: voltage, roll/pitch/yaw, magnetometer, IP/MAC/RSSI, speed
GET /jsfbReturns buffered JSON feedback
GET /cmd?...Simple motor control (cmd 1 = L/R speed, cmd 2 = speed tier)
GET /js?...Raw JSON command (deserialized and dispatched to cmdHandler())
Command protocol — JSON objects keyed by a "T" type (enumerated in config.h):
// {"T":0}                              EMERGENCY_STOP
// {"T":1,"L":0.5,"R":0.5}              SPEED_INPUT   (left/right, -1..1)
// {"T":2,"P":170,"I":90}               PID_SET
// {"T":3,"lineNum":0,"Text":"..."}     OLED_SET
// {"T":40,"pos":90,"spd":30}           PWM_SERVO_CTRL
// {"T":50,"id":1,"pos":2047,"spd":500} BUS_SERVO_CTRL
// {"T":60} / {"T":65}                  WIFI_SCAN / WIFI_INFO
// {"T":70} {"T":71} {"T":73} {"T":74}  INA219 / IMU / ENCODER / DEVICE info
// {"T":901,"L":1.0,"R":1.0}            SET_SPD_RATE
Motor output combines a PI speed controller (per-wheel encoder feedback) with an optional RC PWM passthrough read on GPIO 16/27 via the RMT peripheral (esp32-rmt-pwm-reader). A dead-man heartbeat zeroes both setpoints if no command arrives within HEART_BEAT (3000 ms) — preserve this when editing loop().

UGV firmware (skyhub_ugv)

The UGV is the more capable, PlatformIO-built sibling and the lower-level base controller. platformio.ini targets board = esp32dev, framework = arduino, 4 MB QIO flash at 80 MHz, f_cpu = 240 MHz, with all libraries vendored under UGV/lib/ (Adafruit SSD1306/GFX/BusIO, ArduinoJson, ESP32Encoder, ESP32Servo, INA219_WE, PID_v2, SCServo, SimpleKalmanFilter, SparkFun ICM-20948). ugv_config.h selects the platform variant at compile time:
  • mainType1 RaspRover, 2 UGV Rover (default), 3 UGV Beast
  • moduleType0 base only (default), 1 RoArm-M2 robotic arm, 2 gimbal
  • espNowMode3 follower (default), 1/2 leader modes; broadcast control on by default
WiFi/boot config is loaded from SPIFFS JSON in UGV/data/wifiConfig.json / devConfig.json default to {"wifi_mode_on_boot":3, "sta_ssid":"JSBZY-2.4G", "ap_ssid":"RoArm", ...}. The HTTP server (http_server.h) again binds :80 with / (web UI) and /js (JSON command dispatch through jsonCmdReceiveHandler()). The UGV’s "T" command vocabulary (src/json_cmd.h) is a superset of the Rover’s, adding differential-drive and arm/gimbal control:
// {"T":1,"L":0.5,"R":0.5}     CMD_SPEED_CTRL   (m/s per side)
// {"T":11,"L":164,"R":164}    CMD_PWM_INPUT    (raw PWM, +-255)
// {"T":13,"X":0.1,"Z":0.3}    CMD_ROS_CTRL     (linear m/s, angular rad/s)
// {"T":0} / {"T":999}         EMERGENCY_STOP / RESET_EMERGENCY
// {"T":126} {"T":127} {"T":128}  IMU read / calibrate steps
// {"T":222,"name":"...","step":"{...}"}  store a named mission step
// RoArm-M2 joint + gimbal + end-effector (EEMode) commands
UGV/src/main.cpp in the repo is a stripped RC-only build (reads RC PWM on GPIO 32/33 and drives the motors, nothing else). The full firmware — web server, ESP-NOW, arm/gimbal, JSON dispatch — lives in the module headers and main.cpp.bak. If you pio run the repo as-is you get the minimal RC bring-up, not the networked robot. Restore the full main.cpp before expecting the HTTP/JSON interface.

Control-plane integration (WS Proxy)

Today both robots are driven standalone: connect to the robot’s AP (UGV01_BASE / RoArm), open its web UI on http://<ap-ip>/, and drive it with /cmd and /js. ESP-NOW lets one board relay commands to another as leader/follower. To bring a ground robot onto the SkyHub control plane, the intended attach point is the WS Proxy gamepad relay, whose direct mode bridges a Dashboard client to ws://{drone_ip}:5001/gamepad (skyhub_ws_proxy/main.py:100). That :5001 gamepad-WebSocket convention is the one SkyCore’s onboard gamepad service exposes on a full drone.
The ESP32 firmware in skyhub_rover / skyhub_ugv currently exposes an HTTP web UI on :80 plus ESP-NOW — not a native WebSocket gamepad server on :5001. Wiring one of these robots into the WS Proxy path therefore needs a small bridge (a :5001 WebSocket that translates gamepad frames into the board’s {"T":...} JSON), or a firmware addition. Do not assume a freshly-flashed rover is reachable from the Dashboard. The manual-control transport itself is documented in Vehicle Commands & Gamepad and Guided Velocity Control & Safety Model.

Flashing quickstart

1

Install ESP32 core 2.0.17

In Arduino IDE Boards Manager install esp32 by Espressif Systems, version 2.0.17 — not 3.x.
2

Open the sketch

Open skyhub_rover/OUR_ROVER/OUR_ROVER.ino; the .h modules load automatically.
3

Select board & upload

Choose an ESP32 Dev Module, pick the serial port, and Upload.
4

Connect & drive

Join WiFi UGV01_BASE (12345678) and browse to the board’s AP IP; use the on-page controls or GET /js?....