diff --git a/README.md b/README.md
index 4753fc8..0158803 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,21 @@ pio run -t uploadfs
`pack-localhost-dev-firmware.sh` bundles the build output into the site's local
web flasher for development.
+### ESP32-C3 Super Mini
+
+The firmware also builds for the ESP32-C3 Super Mini (Nologo / Tenstar Robot
+clones). The web flasher only ships ESP32-S3 images, so flash it from source:
+
+```bash
+pio run -e esp32c3-supermini -t upload
+pio run -e esp32c3-supermini -t uploadfs
+```
+
+Wire the flight controller UART to pins **20 (RX)** and **21 (TX)**. The board
+has a plain blue LED (GPIO8) instead of an RGB one, so status is shown by blink
+pattern: slow blink = searching for the camera, double flash = someone is on the
+config Wi-Fi, solid = camera connected, fast blink = recording.
+
Planning to contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup,
and how to submit changes.
diff --git a/firmware/data/app.js b/firmware/data/app.js
index 913a8bd..2ba87ea 100644
--- a/firmware/data/app.js
+++ b/firmware/data/app.js
@@ -1,6 +1,6 @@
const OSD_FIELDS = ["Off", "Rec status", "Battery %", "Mode", "SD free", "Time left", "Resolution", "FPS"];
-const CAM_TYPE_NAMES = ["DJI Osmo (Nano)", "DJI Action / 360", "GoPro"];
-const OSMO_TYPE = 0, ACTION_TYPE = 1, GOPRO_TYPE = 2;
+const CAM_TYPE_NAMES = ["DJI Osmo (Nano)", "DJI Action / 360", "GoPro", "DJI Action 2"];
+const OSMO_TYPE = 0, ACTION_TYPE = 1, GOPRO_TYPE = 2, ACTION2_TYPE = 3;
const CH_MIN = 900, CH_MAX = 2100, STEP = 25, GAP = 25;
const PIPS = [900, 1000, 1200, 1500, 1800, 2000, 2100];
@@ -34,8 +34,11 @@ let chVals = [];
let boundMac = "";
let boundType = 0;
-// Resolution and FPS aren't reported by the Osmo Nano
-const osdFieldAvail = (k) => !(boundType === OSMO_TYPE && (k === 6 || k === 7));
+// Resolution and FPS aren't reported by the Osmo Nano; the Action 2 reports only
+// record status (from our own commands), battery and a fixed video mode
+const osdFieldAvail = (k) =>
+ !(boundType === OSMO_TYPE && (k === 6 || k === 7)) &&
+ !(boundType === ACTION2_TYPE && k >= 4);
// ---- Tabs ----
document.querySelectorAll(".tab").forEach((t) =>
@@ -136,7 +139,8 @@ function renderBound() {
// Channels: hide functions the bound camera can't do.
if (cfg) cfg.modes.forEach((m, i) => {
if (m.name.startsWith("Preset")) $("mode" + i).hidden = boundType !== GOPRO_TYPE;
- if (m.name === "Camera Mode") $("mode" + i).hidden = boundType === OSMO_TYPE;
+ if (m.name === "Camera Mode")
+ $("mode" + i).hidden = boundType === OSMO_TYPE || boundType === ACTION2_TYPE;
});
}
@@ -166,7 +170,10 @@ async function wizScan() {
else await sleep(500);
} catch (e) { await sleep(500); }
}
- list = (list || []).filter((d) => d.type === wizType);
+ // All DJI backends see the same advert (the scan can't tell an Action 2 apart), so list
+ // every DJI camera for any DJI type
+ const isDji = (t) => t === OSMO_TYPE || t === ACTION_TYPE || t === ACTION2_TYPE;
+ list = (list || []).filter((d) => d.type === wizType || (isDji(d.type) && isDji(wizType)));
if (!list.length) {
$("wizList").innerHTML =
`
No matching camera found. Power it on, disconnect any phone app, then scan again.
`;
diff --git a/firmware/data/index.html b/firmware/data/index.html
index 9d78529..1233f0b 100644
--- a/firmware/data/index.html
+++ b/firmware/data/index.html
@@ -255,6 +255,7 @@ Device
+
diff --git a/firmware/platformio.ini b/firmware/platformio.ini
index ef0185c..e7049ca 100644
--- a/firmware/platformio.ini
+++ b/firmware/platformio.ini
@@ -4,20 +4,17 @@
[platformio]
default_envs = esp32s3-zero
-; ESP32-S3FH4R2: 4 MB flash, 2 MB QUAD PSRAM, native USB.
-[env:esp32s3-zero]
+; --- Shared by every board ---
+[env]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
-board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
-; --- Match the 4 MB FH4R2 chip (default board is 8 MB) ---
board_upload.flash_size = 4MB
board_build.flash_size = 4MB
board_build.partitions = huge_app.csv ; single 3 MB app (BLE+Wi-Fi is big; no OTA)
board_build.filesystem = littlefs ; web UI assets in data/ -> `pio run -t uploadfs`
board_build.flash_mode = dio
-board_build.arduino.memory_type = dio_qspi ; DIO flash + QUAD PSRAM (never opi here)
build_flags =
-std=gnu++17
@@ -27,4 +24,20 @@ build_flags =
build_unflags = -std=gnu++11
lib_deps =
h2zero/NimBLE-Arduino@^2.2.0
- bblanchon/ArduinoJson@^7.2.0
\ No newline at end of file
+ bblanchon/ArduinoJson@^7.2.0
+
+; ESP32-S3FH4R2: 4 MB flash, 2 MB QUAD PSRAM, native USB.
+[env:esp32s3-zero]
+board = esp32-s3-devkitc-1 ; default board is 8 MB - flash size overridden above
+board_build.arduino.memory_type = dio_qspi ; DIO flash + QUAD PSRAM (never opi here)
+build_flags =
+ ${env.build_flags}
+ -DBOARD_ESP32S3_ZERO
+
+; ESP32-C3 Super Mini (Nologo / Tenstar Robot clones): ESP32-C3FH4 - 4 MB flash, no PSRAM,
+; single RISC-V core, USB-Serial/JTAG on the USB-C port.
+[env:esp32c3-supermini]
+board = nologo_esp32c3_super_mini
+build_flags =
+ ${env.build_flags}
+ -DBOARD_ESP32C3_SUPERMINI
diff --git a/firmware/src/StatusLed.cpp b/firmware/src/StatusLed.cpp
index b0aeaf8..867ca4c 100644
--- a/firmware/src/StatusLed.cpp
+++ b/firmware/src/StatusLed.cpp
@@ -5,12 +5,15 @@
void StatusLed::begin() {
if (pin_ < 0)
return;
- write(0, 0, 0);
+ if (rgb_) {
+ write(0, 0, 0);
+ } else {
+ pinMode(pin_, OUTPUT);
+ writeMono(false);
+ }
}
void StatusLed::write(uint8_t r, uint8_t g, uint8_t b) {
- if (pin_ < 0)
- return;
if (r == lr_ && g == lg_ && b == lb_)
return;
lr_ = r;
@@ -19,11 +22,24 @@ void StatusLed::write(uint8_t r, uint8_t g, uint8_t b) {
neopixelWrite(pin_, g, r, b);
}
+void StatusLed::writeMono(bool on) {
+ if (lon_ == (int8_t)on)
+ return;
+ lon_ = on;
+ digitalWrite(pin_, on != activeLow_ ? HIGH : LOW);
+}
+
void StatusLed::update(bool camOnline, bool recording, bool wifiClient) {
if (pin_ < 0)
return;
const uint32_t now = millis();
+ if (rgb_)
+ updateRgb(camOnline, recording, wifiClient, now);
+ else
+ updateMono(camOnline, recording, wifiClient, now);
+}
+void StatusLed::updateRgb(bool camOnline, bool recording, bool wifiClient, uint32_t now) {
uint8_t r, g, b;
if (camOnline) {
if (recording) {
@@ -53,3 +69,21 @@ void StatusLed::update(bool camOnline, bool recording, bool wifiClient) {
write(r, g, b);
}
+
+// Same states as updateRgb(), mapped onto blink rhythms for a single-colour LED
+void StatusLed::updateMono(bool camOnline, bool recording, bool wifiClient, uint32_t now) {
+ bool on;
+ if (camOnline) {
+ // fast blink = recording, solid = camera connected, idle
+ on = recording ? (now / 125) % 2 : true;
+ } else if (wifiClient) {
+ // double flash = a device is connected to the config Wi-Fi (camera offline)
+ const uint32_t t = now % 1500;
+ on = t < 100 || (t >= 250 && t < 350);
+ } else {
+ // slow blink = searching for the camera
+ on = (now / 500) % 2;
+ }
+
+ writeMono(on);
+}
diff --git a/firmware/src/StatusLed.h b/firmware/src/StatusLed.h
index d54d93e..f8d6e15 100644
--- a/firmware/src/StatusLed.h
+++ b/firmware/src/StatusLed.h
@@ -4,14 +4,22 @@
class StatusLed {
public:
- explicit StatusLed(int pin) : pin_(pin) {
+ // rgb: addressable WS2812 (states told apart by colour). Otherwise a plain single-colour
+ // LED on a GPIO, where the states are told apart by blink pattern instead
+ StatusLed(int pin, bool rgb, bool activeLow) : pin_(pin), rgb_(rgb), activeLow_(activeLow) {
}
void begin();
void update(bool camOnline, bool recording, bool wifiClient);
private:
+ void updateRgb(bool camOnline, bool recording, bool wifiClient, uint32_t now);
+ void updateMono(bool camOnline, bool recording, bool wifiClient, uint32_t now);
void write(uint8_t r, uint8_t g, uint8_t b);
+ void writeMono(bool on);
int pin_;
+ bool rgb_;
+ bool activeLow_;
uint8_t lr_ = 1, lg_ = 1, lb_ = 1;
+ int8_t lon_ = -1;
};
diff --git a/firmware/src/camera/dji/DjiAction2Camera.cpp b/firmware/src/camera/dji/DjiAction2Camera.cpp
new file mode 100644
index 0000000..ccd568f
--- /dev/null
+++ b/firmware/src/camera/dji/DjiAction2Camera.cpp
@@ -0,0 +1,272 @@
+#include "camera/dji/DjiAction2Camera.h"
+
+#include
+
+#include
+
+#include "camera/BleTxPower.h"
+#include "net/RadioCoex.h"
+
+namespace {
+ constexpr uint16_t UUID_SERVICE = 0xFFF0;
+ constexpr uint16_t UUID_WRITE = 0xFFF5;
+ constexpr uint16_t UUID_NOTIFY = 0xFFF4;
+
+ constexpr uint8_t ADDR_WIFI = 0x07; // pairing is handled by the camera's Wi-Fi module
+
+ // The camera files its approval under this identifier - keep it constant so a reconnect
+ // takes the "already paired" path instead of asking again. The token is only displayed
+ const char PAIR_ID[] = "001749319286102";
+ const char PAIR_TOKEN[] = "osmo";
+
+ constexpr uint32_t kPairTimeoutMs = 30000; // time to tap approve on the camera
+ constexpr uint32_t kKeepAliveMs = 5000;
+} // namespace
+
+class DjiAction2Camera::ClientCB : public NimBLEClientCallbacks {
+ public:
+ explicit ClientCB(DjiAction2Camera* owner) : owner_(owner) {
+ }
+ void onDisconnect(NimBLEClient*, int reason) override {
+ owner_->connected_ = false;
+ owner_->approved_ = false;
+ owner_->chWrite_ = nullptr;
+ owner_->chNotify_ = nullptr;
+ owner_->status_ = CameraStatus{};
+ owner_->status_.paired = false;
+ Serial.printf("[a2] disconnected (reason 0x%02x)\n", reason);
+ }
+
+ private:
+ DjiAction2Camera* owner_;
+};
+
+DjiAction2Camera::DjiAction2Camera(const char* macAddress) : mac_(macAddress ? macAddress : "") {
+}
+
+bool DjiAction2Camera::begin() {
+ NimBLEDevice::init("ShutterBridge");
+ applyBleTxPower();
+ cb_ = new ClientCB(this);
+ return attempt();
+}
+
+bool DjiAction2Camera::attempt() {
+ if (mac_.empty())
+ return false;
+ if (!haveAddr_) {
+ addr_ = NimBLEAddress(mac_, BLE_ADDR_PUBLIC);
+ haveAddr_ = true;
+ Serial.printf("[a2] bound MAC %s\n", addr_.toString().c_str());
+ }
+ return connect();
+}
+
+bool DjiAction2Camera::connect() {
+ Serial.printf("[a2] connecting to %s ...\n", addr_.toString().c_str());
+ if (!client_)
+ client_ = NimBLEDevice::createClient();
+ client_->setClientCallbacks(cb_, /*deleteCallbacks=*/false);
+ client_->setConnectTimeout(g_apHasClient ? 2000 : 5000);
+
+ if (!client_->connect(addr_)) {
+ const int rc = client_->getLastError();
+ Serial.printf("[a2] connect failed (rc=%d %s)\n", rc, NimBLEUtils::returnCodeToString(rc));
+ return false;
+ }
+
+ NimBLERemoteService* svc = client_->getService(NimBLEUUID(UUID_SERVICE));
+ if (!svc) {
+ Serial.println("[a2] service fff0 not found");
+ client_->disconnect();
+ return false;
+ }
+ chNotify_ = svc->getCharacteristic(NimBLEUUID(UUID_NOTIFY));
+ chWrite_ = svc->getCharacteristic(NimBLEUUID(UUID_WRITE));
+ if (!chNotify_ || !chWrite_) {
+ Serial.println("[a2] fff4/fff5 not found");
+ client_->disconnect();
+ return false;
+ }
+
+ scanner_.reset();
+ status_ = CameraStatus{};
+ status_.paired = false;
+ status_.mode = CamMode::Video; // no mode readout - the shutter always drives recording
+ approved_ = false;
+
+ auto onData = [this](NimBLERemoteCharacteristic*, uint8_t* d, size_t n, bool) {
+ onNotify(d, n);
+ };
+ chNotify_->subscribe(true, onData);
+ if (chWrite_->canNotify())
+ chWrite_->subscribe(true, onData);
+
+ // Arm pairing: a plain write of 01 00 to the fff4 value itself (not its CCCD)
+ if (chNotify_->canWrite()) {
+ const uint8_t arm[] = {0x01, 0x00};
+ chNotify_->writeValue(arm, sizeof(arm), /*response=*/true);
+ }
+
+ connected_ = true;
+ status_.connected = true;
+ connectedAtMs_ = millis();
+ lastKeepMs_ = connectedAtMs_;
+ delay(200);
+ sendPairing();
+ return true;
+}
+
+void DjiAction2Camera::sendPairing() {
+ // SetPairingPIN (0x07/0x45): length-prefixed identifier, then length-prefixed token
+ constexpr size_t idLen = sizeof(PAIR_ID) - 1;
+ constexpr size_t tokLen = sizeof(PAIR_TOKEN) - 1;
+ uint8_t pl[2 + idLen + tokLen];
+ pl[0] = idLen;
+ memcpy(pl + 1, PAIR_ID, idLen);
+ pl[1 + idLen] = tokLen;
+ memcpy(pl + 2 + idLen, PAIR_TOKEN, tokLen);
+ Serial.println("[a2] pairing request sent");
+ writeCmd(ADDR_WIFI, 0x07, 0x45, pl, sizeof(pl));
+}
+
+void DjiAction2Camera::keepAlive() {
+ const uint8_t p = 0x00;
+ writeCmd(duml::ADDR_CAM, 0x00, 0xF1, &p, 1);
+}
+
+void DjiAction2Camera::setApproved(const char* how) {
+ approved_ = true;
+ status_.paired = true;
+ Serial.printf("[a2] session open (%s)\n", how);
+}
+
+void DjiAction2Camera::drop(const char* why) {
+ Serial.printf("[a2] link dead (%s) - dropping\n", why);
+ if (client_)
+ client_->disconnect();
+ connected_ = false;
+ approved_ = false;
+ status_ = CameraStatus{};
+ lastAttemptMs_ = millis(); // let the disconnect finish before the next connect attempt
+}
+
+void DjiAction2Camera::poll() {
+ if (connected_) {
+ const uint32_t t = millis();
+ if (!approved_) {
+ if (t - connectedAtMs_ > kPairTimeoutMs)
+ drop("not approved on the camera");
+ } else {
+ // Once paired the camera goes quiet - no status pushes, no reply to the keep-alive -
+ // so the BLE link itself (supervision timeout -> onDisconnect) is the liveness
+ // signal, and the record time runs from our own accepted start
+ status_.lastUpdateMs = t;
+ if (status_.isRecording())
+ status_.recElapsedS = (t - recStartMs_) / 1000;
+ if (t - lastKeepMs_ >= kKeepAliveMs) {
+ lastKeepMs_ = t;
+ keepAlive();
+ }
+ }
+ }
+
+ // Reconnect at exponential backoff, throttled while connected to the Web UI
+ const uint32_t now = millis();
+ const uint32_t kApRetryMs = 8000;
+ const uint32_t interval = (g_apHasClient && backoffMs_ < kApRetryMs) ? kApRetryMs : backoffMs_;
+ if (!connected_ && (now - lastAttemptMs_ > interval)) {
+ lastAttemptMs_ = now;
+ if (attempt()) {
+ backoffMs_ = kBackoffMinMs;
+ } else {
+ backoffMs_ = backoffMs_ < kBackoffMaxMs ? backoffMs_ * 2 : kBackoffMaxMs;
+ if (backoffMs_ > kBackoffMaxMs)
+ backoffMs_ = kBackoffMaxMs;
+ }
+ }
+}
+
+bool DjiAction2Camera::writeRaw(const uint8_t* data, size_t len) {
+ if (!connected_ || !chWrite_)
+ return false;
+ return chWrite_->writeValue(data, len, /*response=*/false);
+}
+
+bool DjiAction2Camera::writeCmd(uint8_t receiver, uint8_t cmdSet, uint8_t cmdId,
+ const uint8_t* payload, size_t payloadLen) {
+ auto f = duml::buildFrame(duml::ADDR_APP, receiver, cmdSet, cmdId, payload, payloadLen,
+ duml::FLAG_CMD, seq_++);
+ return writeRaw(f.data(), f.size());
+}
+
+bool DjiAction2Camera::recordCtrl(bool start) {
+ if (!approved_) {
+ Serial.println("[a2] record ignored - not paired (approve on the camera first)");
+ return false;
+ }
+ const uint8_t p = start ? 0x01 : 0x00;
+ lastCmdStart_ = start;
+ if (!writeCmd(duml::ADDR_CAM, 0x02, 0x02, &p, 1))
+ return false;
+ // Optimistic until the camera's reply (handleFrame) confirms or rejects it
+ if (start && !status_.isRecording()) {
+ recStartMs_ = millis();
+ status_.recElapsedS = 0;
+ }
+ status_.recState = start ? RecState::Recording : RecState::Idle;
+ return true;
+}
+
+bool DjiAction2Camera::startRecord() {
+ return recordCtrl(true);
+}
+bool DjiAction2Camera::stopRecord() {
+ return recordCtrl(false);
+}
+bool DjiAction2Camera::takePhoto() {
+ Serial.println("[a2] photo capture not supported");
+ return false;
+}
+
+void DjiAction2Camera::onNotify(const uint8_t* data, size_t len) {
+ scanner_.feed(data, len, [this](const duml::Frame& f) { handleFrame(f); });
+}
+
+void DjiAction2Camera::handleFrame(const duml::Frame& f) {
+ const uint8_t* p = f.payload;
+ const size_t n = f.payloadLen;
+
+ // Pairing status reply: 01 = already paired, 02 = waiting for approval on the camera
+ if (f.cmdSet == 0x07 && f.cmdId == 0x45 && f.flags == duml::FLAG_ACK && n >= 2) {
+ if (p[1] == 0x01)
+ setApproved("already paired");
+ else if (p[1] == 0x02)
+ Serial.println("[a2] approve the connection on the camera screen");
+ else
+ Serial.printf("[a2] pairing status %02x %02x\n", p[0], p[1]);
+ return;
+ }
+ // Approved on the camera screen - acknowledge it
+ if (f.cmdSet == 0x07 && f.cmdId == 0x46 && f.flags == duml::FLAG_CMD) {
+ const uint8_t ok = 0x00;
+ auto ack = duml::buildFrame(f.receiver, f.sender, 0x07, 0x46, &ok, 1,
+ duml::FLAG_ACK, f.seq);
+ writeRaw(ack.data(), ack.size());
+ setApproved("approved on the camera");
+ return;
+ }
+ // Record reply: 00 = OK, d8 busy, d9 wrong state, e0 not supported
+ if (f.cmdSet == 0x02 && f.cmdId == 0x02 && f.flags == duml::FLAG_ACK && n >= 1) {
+ if (p[0] == 0x00)
+ status_.recState = lastCmdStart_ ? RecState::Recording : RecState::Idle;
+ else
+ Serial.printf("[a2] record command rejected (0x%02x)\n", p[0]);
+ return;
+ }
+ // Battery push, same layout as on the other DJI cameras (unverified on the Action 2)
+ if (f.cmdSet == 0x0D && f.cmdId == 0x02 && n >= 21) {
+ status_.batteryPct = p[20];
+ return;
+ }
+}
diff --git a/firmware/src/camera/dji/DjiAction2Camera.h b/firmware/src/camera/dji/DjiAction2Camera.h
new file mode 100644
index 0000000..f0ff5f7
--- /dev/null
+++ b/firmware/src/camera/dji/DjiAction2Camera.h
@@ -0,0 +1,68 @@
+// DJI Osmo Action 2 backend: DUML over BLE with the app-level pairing the DJI Mimo app uses.
+// The Action 2 predates the R-SDK that DjiActionCamera speaks, and unlike the Osmo Nano it
+// asks for an on-screen approval on first connect. Pairing flow and record commands follow
+// shutterlink (https://github.com/rover1312/shutterlink, MIT, (c) 2026 rover1312).
+// No decoded record state/time comes back, so both track our own acknowledged start/stop
+#pragma once
+
+#include
+
+#include
+
+#include "camera/Camera.h"
+#include "duml.h"
+
+class DjiAction2Camera : public Camera {
+ public:
+ explicit DjiAction2Camera(const char* macAddress);
+
+ bool begin() override;
+ void poll() override;
+ bool startRecord() override;
+ bool stopRecord() override;
+ bool takePhoto() override;
+ const CameraStatus& status() const override {
+ return status_;
+ }
+ bool isConnected() const override {
+ return connected_;
+ }
+
+ private:
+ bool attempt();
+ bool connect();
+ void sendPairing();
+ void keepAlive();
+ void setApproved(const char* how);
+ void drop(const char* why);
+ bool recordCtrl(bool start);
+ bool writeRaw(const uint8_t* data, size_t len);
+ bool writeCmd(uint8_t receiver, uint8_t cmdSet, uint8_t cmdId, const uint8_t* payload,
+ size_t payloadLen);
+ void onNotify(const uint8_t* data, size_t len);
+ void handleFrame(const duml::Frame& f);
+
+ class ClientCB;
+
+ std::string mac_;
+ NimBLEAddress addr_;
+ bool haveAddr_ = false;
+ NimBLEClient* client_ = nullptr;
+ NimBLERemoteCharacteristic* chWrite_ = nullptr; // fff5 (write-no-response)
+ NimBLERemoteCharacteristic* chNotify_ = nullptr; // fff4 (pairing arm + notify)
+ ClientCB* cb_ = nullptr;
+
+ duml::FrameScanner scanner_;
+ CameraStatus status_;
+ volatile bool connected_ = false; // BLE link up
+ volatile bool approved_ = false; // pairing accepted, commands work
+ bool lastCmdStart_ = false;
+ uint32_t recStartMs_ = 0;
+ uint16_t seq_ = 1;
+ uint32_t lastAttemptMs_ = 0;
+ uint32_t connectedAtMs_ = 0;
+ uint32_t lastKeepMs_ = 0;
+ static constexpr uint32_t kBackoffMinMs = 3000;
+ static constexpr uint32_t kBackoffMaxMs = 30000;
+ uint32_t backoffMs_ = kBackoffMinMs;
+};
diff --git a/firmware/src/camera/gopro/GoProCamera.cpp b/firmware/src/camera/gopro/GoProCamera.cpp
index 3b3bdd3..d8f0138 100644
--- a/firmware/src/camera/gopro/GoProCamera.cpp
+++ b/firmware/src/camera/gopro/GoProCamera.cpp
@@ -273,7 +273,9 @@ bool GoProCamera::connect() {
client_->setConnectTimeout(3000);
if (!client_->connect(addr_)) {
- Serial.println("[gopro] connect failed");
+ const int rc = client_->getLastError();
+ Serial.printf("[gopro] connect failed (rc=%d %s)\n", rc,
+ NimBLEUtils::returnCodeToString(rc));
return false;
}
@@ -331,8 +333,12 @@ void GoProCamera::poll() {
lastKeepMs_ = now;
keepAlive();
}
- const bool stale = status_.lastUpdateMs != 0 && (now - status_.lastUpdateMs) > 2500;
- const bool noTelemetry = status_.lastUpdateMs == 0 && (now - connectedAtMs_) > 4000;
+ // Re-read the clock after the blocking writes above: a reply that lands during them
+ // stamps lastUpdateMs later than `now`, and the unsigned difference wraps to "stale"
+ const uint32_t last = status_.lastUpdateMs;
+ const uint32_t t = millis();
+ const bool stale = last != 0 && (t - last) > 2500;
+ const bool noTelemetry = last == 0 && (t - connectedAtMs_) > 4000;
if (stale || noTelemetry) {
Serial.printf("[gopro] link dead (%s) - dropping\n", stale ? "stale" : "no telemetry");
if (client_)
diff --git a/firmware/src/config.h b/firmware/src/config.h
index 084836c..7ecd270 100644
--- a/firmware/src/config.h
+++ b/firmware/src/config.h
@@ -2,15 +2,29 @@
#define FW_VERSION "0.1.0"
+// --- Board pin maps (BOARD_* is set per env in platformio.ini) ---
+#if defined(BOARD_ESP32C3_SUPERMINI)
+// ESP32-C3 Super Mini: FC UART on the pins silk-screened RX (20) / TX (21), and a plain
+// blue LED on GPIO8 wired to 3V3 (active low). GPIO2/8/9 are strapping pins - keep them free
+#define FC_RX_PIN 20
+#define FC_TX_PIN 21
+#define STATUS_LED_PIN 8
+#define STATUS_LED_RGB false
+#define STATUS_LED_ACTIVE_LOW true
+#else
+// Waveshare ESP32-S3-Zero: FC UART on GPIO43/44, WS2812 RGB LED on GPIO21
+#define FC_RX_PIN 44
+#define FC_TX_PIN 43
+#define STATUS_LED_PIN 21
+#define STATUS_LED_RGB true
+#define STATUS_LED_ACTIVE_LOW false
+#endif
+
#define FC_UART Serial1
#define FC_BAUD 115200
-#define FC_RX_PIN 44
-#define FC_TX_PIN 43
#define FC_RC_POLL_MS 50
#define OSD_UPDATE_MS 250
#define OSD_REFRESH_MS 1000
-#define STATUS_LED_PIN 21
-
#define CONSOLE_BAUD 115200
diff --git a/firmware/src/config/Settings.h b/firmware/src/config/Settings.h
index 046b3fa..c24c56c 100644
--- a/firmware/src/config/Settings.h
+++ b/firmware/src/config/Settings.h
@@ -35,7 +35,7 @@ struct Settings {
uint8_t bleTxPower = 0;
// --- Camera selection ---
- uint8_t camType = 0; // 0 = DJI Osmo Series, 1 = DJI Action Series, 2 = GoPro
+ uint8_t camType = 0; // 0 = DJI Osmo Series, 1 = DJI Action Series, 2 = GoPro, 3 = DJI Action 2
char camMac[18] = ""; // bound via Web UI scan; "" falls back to name scan
char camNamePrefix[16] = "OsmoNano";
diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp
index fd4164b..6bb07fc 100644
--- a/firmware/src/main.cpp
+++ b/firmware/src/main.cpp
@@ -2,6 +2,7 @@
#include "StatusLed.h"
#include "camera/BleTxPower.h"
+#include "camera/dji/DjiAction2Camera.h"
#include "camera/dji/DjiActionCamera.h"
#include "camera/dji/DjiOsmoCamera.h"
#include "camera/gopro/GoProCamera.h"
@@ -16,10 +17,10 @@ static BetaflightMsp fc(FC_UART, FC_BAUD, FC_RX_PIN, FC_TX_PIN, FC_RC_POLL_MS);
static ChannelActions actions;
static OsdRenderer osd(fc, g_settings, OSD_UPDATE_MS, OSD_REFRESH_MS);
static WebConfig web(g_settings);
-static StatusLed led(STATUS_LED_PIN);
+static StatusLed led(STATUS_LED_PIN, STATUS_LED_RGB, STATUS_LED_ACTIVE_LOW);
static Camera* cam = nullptr;
-enum CamType : uint8_t { CAM_DJI_OSMO = 0, CAM_DJI_ACTION = 1, CAM_GOPRO = 2 };
+enum CamType : uint8_t { CAM_DJI_OSMO = 0, CAM_DJI_ACTION = 1, CAM_GOPRO = 2, CAM_DJI_ACTION2 = 3 };
static Camera* makeCamera(const Settings& s) {
switch (s.camType) {
@@ -27,6 +28,8 @@ static Camera* makeCamera(const Settings& s) {
return new DjiActionCamera(s.camMac);
case CAM_GOPRO:
return new GoProCamera(s.camMac, "GoPro");
+ case CAM_DJI_ACTION2:
+ return new DjiAction2Camera(s.camMac);
case CAM_DJI_OSMO:
default:
return new DjiOsmoCamera(s.camMac, s.camNamePrefix);
@@ -110,4 +113,10 @@ void loop() {
(unsigned long)(s.recElapsedS / 60), (unsigned long)(s.recElapsedS % 60),
s.batteryPct, rc.aux(1), armed, rc.valid ? "ok" : "--");
}
+
+#if CONFIG_FREERTOS_UNICORE
+ // Single-core chips (ESP32-C3): loop() shares the only core with the camera task and
+ // the idle task - block for a tick so neither is starved
+ vTaskDelay(1);
+#endif
}