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 }