diff --git a/Makefile b/Makefile
index 5dfc9e6..de670c4 100644
--- a/Makefile
+++ b/Makefile
@@ -18,6 +18,10 @@ dependencies .dependencies:
arduino-cli lib install "MAVLink"@2.0.25
touch .dependencies
+upload_proxy: .dependencies
+ arduino-cli compile --fqbn $(BOARD) tools/espnow-proxy
+ arduino-cli upload --fqbn $(BOARD) -p "$(PORT)" tools/espnow-proxy
+
gazebo/build cmake: gazebo/CMakeLists.txt
mkdir -p gazebo/build
cd gazebo/build && cmake ..
diff --git a/README.md b/README.md
index 649c559..c7da620 100644
--- a/README.md
+++ b/README.md
@@ -21,8 +21,8 @@
* Dedicated for education and research.
* Made from general-purpose components.
* Simple and clean source code in Arduino (<2k lines firmware).
-* Connectivity using Wi-Fi and MAVLink protocol.
-* Control using USB gamepad, remote control or smartphone.
+* Communication using MAVLink protocol over Wi-Fi or ESP-NOW.
+* Control with USB gamepad, remote control or smartphone.
* Wireless command line interface and analyzing.
* Precise simulation with Gazebo.
* Python library for scripting and automatic flights.
diff --git a/docs/img/espnow-connection.jpg b/docs/img/espnow-connection.jpg
new file mode 100644
index 0000000..fdd6a0e
Binary files /dev/null and b/docs/img/espnow-connection.jpg differ
diff --git a/docs/usage.md b/docs/usage.md
index 39f9da4..5984a32 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -290,11 +290,8 @@ The Wi-Fi mode is chosen using `WIFI_MODE` parameter in QGroundControl or in the
* `0` — Wi-Fi is disabled.
* `1` — Access Point mode *(AP)* — the drone creates a Wi-Fi network.
-* `2` — Client mode *(STA)* — the drone connects to an existing Wi-Fi network.
-* `3` — *ESP-NOW (not implemented yet)*.
-
-> [!WARNING]
-> Tests showed that Client mode may cause **additional delays** in remote control (due to retranslations), so it's generally not recommended.
+* `2` — Client mode *(STA)* — the drone connects to an existing Wi-Fi network (may cause additional delays, so generally not recommended).
+* `3` — ESP-NOW mode — the drone uses ESP-NOW protocol for communication.
The SSID and password are configured using the `ap` and `sta` console commands:
@@ -316,6 +313,37 @@ Disabling Wi-Fi:
p WIFI_MODE 0
```
+### Using ESP-NOW
+
+[ESP-NOW](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/network/esp_now.html) is a low level wireless communication protocol. It can provide lower latency, better reliability, and longer range than Wi-Fi. However, it requires a second ESP32 board to be used as a proxy for the computer.
+
+
+
+To setup ESP-NOW communication:
+
+1. Flash the second ESP32 board with ESP-NOW proxy sketch: [`tools/espnow-proxy/espnow-proxy.ino`](../tools/espnow-proxy/espnow-proxy.ino). Use Arduino IDE or command line: `make upload_proxy`.
+
+2. Open Serial Monitor or use `make monitor` command. The ESP32 will print its MAC address and generated encryption key, for example:
+
+ ```
+ espnow 7a:c8:e3:eb:bf:e9 &PiuSysxP9+$L&5E
+ ```
+
+ Run this line as a console command on each drone you want to bind to this proxy board.
+
+3. Set the `WIFI_MODE` parameter to `3` on the drone:
+
+ ```
+ p WIFI_MODE 3
+ ```
+
+4. Go to the QGroundControl menu ⇒ *Application Settings* ⇒ *Comm Links*, add new link with the following settings:
+ * Name: ESP32.
+ * Type: Serial.
+ * Serial Port: choose the port of the proxy ESP32 board, e. g. `/dev/cu.usbserial-0001`.
+ * Baud Rate: 115200.
+5. Click *Save*. QGroundControl should connect to the drone using ESP-NOW and begin showing the telemetry.
+
## Flight log
After the flight, you can download the flight log for analysis wirelessly. Use the following command on your computer for that:
diff --git a/flix/cli.ino b/flix/cli.ino
index 6be66c0..7922f1a 100644
--- a/flix/cli.ino
+++ b/flix/cli.ino
@@ -10,6 +10,7 @@
extern const int MOTOR_REAR_LEFT, MOTOR_REAR_RIGHT, MOTOR_FRONT_RIGHT, MOTOR_FRONT_LEFT;
extern const int RAW, ACRO, STAB, AUTO;
+extern const int W_AP, W_STA, W_ESPNOW;
extern float t, dt, loopRate;
extern uint16_t channels[16];
extern float controlTime;
@@ -45,6 +46,7 @@ const char* motd =
"wifi - show Wi-Fi info\n"
"ap - setup Wi-Fi access point\n"
"sta - setup Wi-Fi client mode\n"
+"espnow [] - setup ESP-NOW peer\n"
"mot - show motor output\n"
"log [dump] - print log header [and data]\n"
"cr - calibrate RC\n"
@@ -143,9 +145,11 @@ void doCommand(String str, bool echo = false) {
} else if (command == "wifi") {
printWiFiInfo();
} else if (command == "ap") {
- configWiFi(true, arg0.c_str(), arg1.c_str());
+ configWiFi(W_AP, arg0.c_str(), arg1.c_str());
} else if (command == "sta") {
- configWiFi(false, arg0.c_str(), arg1.c_str());
+ configWiFi(W_STA, arg0.c_str(), arg1.c_str());
+ } else if (command == "espnow") {
+ configWiFi(W_ESPNOW, arg0.c_str(), arg1.c_str());
} else if (command == "mot") {
print("front-right %g front-left %g rear-right %g rear-left %g\n",
motors[MOTOR_FRONT_RIGHT], motors[MOTOR_FRONT_LEFT], motors[MOTOR_REAR_RIGHT], motors[MOTOR_REAR_LEFT]);
diff --git a/flix/parameters.ino b/flix/parameters.ino
index d0d88bd..6533110 100644
--- a/flix/parameters.ino
+++ b/flix/parameters.ino
@@ -10,7 +10,7 @@ extern int channelZero[16];
extern int channelMax[16];
extern int rollChannel, pitchChannel, throttleChannel, yawChannel, armedChannel, modeChannel;
extern int rcRxPin;
-extern int wifiMode, udpLocalPort, udpRemotePort;
+extern int wifiMode, wifiLongRange, udpLocalPort, udpRemotePort, espnowChannel;
extern float rcLossTimeout, descendTime;
extern int voltagePin;
extern float voltageScale;
@@ -112,6 +112,9 @@ Parameter parameters[] = {
{"WIFI_MODE", &wifiMode},
{"WIFI_PORT_LOC", &udpLocalPort},
{"WIFI_PORT_REM", &udpRemotePort},
+ {"WIFI_LONG_RANGE", &wifiLongRange},
+ // espnow
+ {"ESPNOW_CHANNEL", &espnowChannel},
// mavlink
{"MAV_SYS_ID", &mavlinkSysId},
{"MAV_RATE_SLOW", &telemetrySlow.rate},
diff --git a/flix/util.h b/flix/util.h
index cda50f3..f721f4a 100644
--- a/flix/util.h
+++ b/flix/util.h
@@ -6,6 +6,7 @@
#pragma once
#include
+#include
const float ONE_G = 9.80665;
extern float t;
@@ -46,6 +47,16 @@ void splitString(String& str, String& token0, String& token1, String& token2) {
if (token2.c_str() == NULL) token2 = "";
}
+// Simplified ESP-NOW Serial without tx buffering and resends
+class ESPNOWSerial : public ESP_NOW_Serial_Class {
+public:
+ using ESP_NOW_Serial_Class::ESP_NOW_Serial_Class;
+ void onSent(bool success) override {} // disable resends
+ size_t write(const uint8_t *data, size_t len) override {
+ return ESP_NOW_Peer::send(data, len); // pure send without buffering
+ }
+};
+
// Rate limiter
class Rate {
public:
diff --git a/flix/wifi.ino b/flix/wifi.ino
index c0aa119..5c87828 100644
--- a/flix/wifi.ino
+++ b/flix/wifi.ino
@@ -1,82 +1,129 @@
// Copyright (c) 2023 Oleg Kalachev
// Repository: https://github.com/okalachev/flix
-// Wi-Fi communication
+// Wi-Fi and ESP-NOW communication
#include
#include
#include
+#include
+#include
#include "Preferences.h"
+#include "util.h"
extern Preferences storage; // use the main preferences storage
-const int W_DISABLED = 0, W_AP = 1, W_STA = 2;
+const int W_DISABLED = 0, W_AP = 1, W_STA = 2, W_ESPNOW = 3;
int wifiMode = W_AP;
+
+int wifiLongRange = 0;
int udpLocalPort = 14550;
int udpRemotePort = 14550;
IPAddress udpRemoteIP = "255.255.255.255";
-
WiFiUDP udp;
+ESPNOWSerial espnow(NULL, 0, WIFI_IF_AP);
+ESPNOWSerial espnowBroadcast(ESP_NOW.BROADCAST_ADDR, 0, WIFI_IF_AP);
+int espnowChannel = 6;
+
void setupWiFi() {
print("Setup Wi-Fi\n");
+ WiFi.enableLongRange(wifiLongRange);
+
if (wifiMode == W_AP) {
WiFi.softAP(storage.getString("WIFI_AP_SSID", "flix").c_str(), storage.getString("WIFI_AP_PASS", "flixwifi").c_str());
+ udp.begin(udpLocalPort);
} else if (wifiMode == W_STA) {
WiFi.begin(storage.getString("WIFI_STA_SSID", "").c_str(), storage.getString("WIFI_STA_PASS", "").c_str());
- } else {
- return;
+ udp.begin(udpLocalPort);
+ } else if (wifiMode == W_ESPNOW) {
+ WiFi.mode(WIFI_AP);
+ WiFi.setChannel(espnowChannel);
+ espnow.addr(MacAddress(storage.getString("ESPNOW_PEER_MAC", "FF:FF:FF:FF:FF:FF").c_str()));
+ String key = storage.getString("ESPNOW_PEER_KEY", "");
+ espnow.setKey(key.isEmpty() ? nullptr : (const uint8_t *)key.c_str());
+ espnow.begin();
+ espnowBroadcast.begin();
}
+
WiFi.setSleep(false); // disable power save
- udp.begin(udpLocalPort);
}
void sendWiFi(const uint8_t *buf, int len) {
+ if (espnow) {
+ espnow.write(buf, len);
+ static Rate discovery(2);
+ if (discovery) espnowBroadcast.write((const uint8_t *)"flix", 4); // broadcast message to help finding this device
+ return;
+ }
+
if (WiFi.softAPgetStationNum() == 0 && !WiFi.isConnected()) return;
+
udp.beginPacket(udpRemoteIP, udpRemotePort);
udp.write(buf, len);
udp.endPacket();
}
int receiveWiFi(uint8_t *buf, int len) {
+ if (espnow) {
+ return espnow.read(buf, len);
+ }
+
if (WiFi.softAPgetStationNum() == 0 && !WiFi.isConnected()) return 0;
+
udp.parsePacket();
if (udp.remoteIP()) udpRemoteIP = udp.remoteIP();
return udp.read(buf, len);
}
void printWiFiInfo() {
- if (WiFi.getMode() == WIFI_MODE_AP) {
+ if (espnow) {
+ print("Mode: ESP-NOW\n");
+ print("ESP-NOW version: %d\n", ESP_NOW.getVersion());
+ print("Max packet size: %d\n", ESP_NOW.getMaxDataLen());
+ print("MAC: %s\n", WiFi.softAPmacAddress().c_str());
+ print("Peer MAC: %s\n", MacAddress(espnow.addr()).toString().c_str());
+ print("Encrypted: %d\n", espnow.isEncrypted());
+ print("Channel: %d\n", espnow.getChannel());
+ } else if (WiFi.getMode() == WIFI_MODE_AP) {
print("Mode: Access Point (AP)\n");
print("MAC: %s\n", WiFi.softAPmacAddress().c_str());
print("SSID: %s\n", WiFi.softAPSSID().c_str());
print("Password: ***\n");
+ print("Channel: %d\n", WiFi.channel());
print("Clients: %d\n", WiFi.softAPgetStationNum());
print("IP: %s\n", WiFi.softAPIP().toString().c_str());
+ print("Remote IP: %s\n", udpRemoteIP.toString().c_str());
} else if (WiFi.getMode() == WIFI_MODE_STA) {
print("Mode: Client (STA)\n");
print("Connected: %d\n", WiFi.isConnected());
print("MAC: %s\n", WiFi.macAddress().c_str());
print("SSID: %s\n", WiFi.SSID().c_str());
print("Password: ***\n");
- print("IP: %s\n", WiFi.localIP().toString().c_str());
+ print("Channel: %d\n", WiFi.channel());
print("RSSI: %d dBm\n", WiFi.RSSI());
+ print("IP: %s\n", WiFi.localIP().toString().c_str());
+ print("Remote IP: %s\n", udpRemoteIP.toString().c_str());
} else {
print("Mode: Disabled\n");
- return;
}
- print("Channel: %d\n", WiFi.channel());
- print("Remote IP: %s\n", udpRemoteIP.toString().c_str());
print("MAVLink connected: %d\n", mavlinkConnected);
}
-void configWiFi(bool ap, const char *ssid, const char *password) {
- if (ap) {
- storage.putString("WIFI_AP_SSID", ssid);
- storage.putString("WIFI_AP_PASS", password);
+void configWiFi(int mode, const char *first, const char *second) {
+ MacAddress mac;
+ if (mode == W_AP && strlen(first) > 0 && strlen(second) >= 8) {
+ storage.putString("WIFI_AP_SSID", first);
+ storage.putString("WIFI_AP_PASS", second);
+ } else if (mode == W_STA && strlen(first) > 0 && strlen(second) >= 8) {
+ storage.putString("WIFI_STA_SSID", first);
+ storage.putString("WIFI_STA_PASS", second);
+ } else if (mode == W_ESPNOW && mac.fromString(first)) {
+ storage.putString("ESPNOW_PEER_MAC", first);
+ storage.putString("ESPNOW_PEER_KEY", strlen(second) == ESP_NOW_KEY_LEN ? second : "");
} else {
- storage.putString("WIFI_STA_SSID", ssid);
- storage.putString("WIFI_STA_PASS", password);
+ print("Invalid configuration\n");
+ return;
}
print("✓ Reboot to apply new settings\n");
}
diff --git a/gazebo/ESP32_NOW_Serial.h b/gazebo/ESP32_NOW_Serial.h
new file mode 100644
index 0000000..1d56c35
--- /dev/null
+++ b/gazebo/ESP32_NOW_Serial.h
@@ -0,0 +1,12 @@
+// Dummy file for the simulator
+
+class ESP_NOW_Peer {
+protected:
+ size_t send(const uint8_t *data, int len) { return 0; }
+};
+
+class ESP_NOW_Serial_Class : public ESP_NOW_Peer {
+public:
+ virtual void onSent(bool success) {};
+ virtual size_t write(const uint8_t *data, size_t len) { return 0; };
+};
diff --git a/tools/espnow-proxy/README.md b/tools/espnow-proxy/README.md
new file mode 100644
index 0000000..0a83be2
--- /dev/null
+++ b/tools/espnow-proxy/README.md
@@ -0,0 +1,3 @@
+# ESPNOW-proxy
+
+Proxy sketch for using ESP-NOW connection with Flix drone.
diff --git a/tools/espnow-proxy/espnow-proxy.ino b/tools/espnow-proxy/espnow-proxy.ino
new file mode 100644
index 0000000..c42c914
--- /dev/null
+++ b/tools/espnow-proxy/espnow-proxy.ino
@@ -0,0 +1,88 @@
+// Copyright (c) 2026 Oleg Kalachev
+// Repository: https://github.com/okalachev/flix
+
+// Proxy for ESP-NOW connection
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include "../../flix/util.h"
+
+const int CHANNEL = 6;
+char key[ESP_NOW_KEY_LEN + 1] = {0}; // with trailing null
+
+Preferences storage;
+
+std::vector peers;
+
+void onNewPeer(const esp_now_recv_info_t *info, const uint8_t *data, int len, void *arg) {
+ if (len != 4 || memcmp(data, "flix", 4) != 0) return; // check if discovery message
+
+ Serial.printf("New peer: " MACSTR "\n", MAC2STR(info->src_addr));
+ ESPNOWSerial *link = new ESPNOWSerial(info->src_addr, CHANNEL, WIFI_IF_AP);
+ link->begin();
+ link->setKey((const uint8_t *)key);
+ peers.push_back(link);
+}
+
+void setup() {
+ Serial.begin(115200);
+ WiFi.mode(WIFI_AP);
+ WiFi.setSleep(false);
+ WiFi.setChannel(CHANNEL);
+
+ ESP_NOW.onNewPeer(onNewPeer, NULL);
+ ESP_NOW.begin();
+
+ storage.begin("espnow-proxy");
+ if (!storage.isKey("key")) {
+ generateRandomKey();
+ storage.putString("key", key);
+ }
+ strcpy(key, storage.getString("key").c_str());
+
+ // Discover the first peer
+ while (peers.empty()) {
+ Serial.printf("espnow %s %s\n", WiFi.softAPmacAddress().c_str(), key);
+ delay(500);
+ }
+}
+
+void generateRandomKey() {
+ const char chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_+=";
+ for (int i = 0; i < ESP_NOW_KEY_LEN; i++) {
+ key[i] = chars[random(0, strlen(chars))];
+ }
+}
+
+void loop() {
+ uint8_t buf[5000];
+
+ // Send from Serial to ESP-NOW
+ while (Serial.available() > 0) {
+ int b = Serial.read();
+ if (b < 0) {
+ break;
+ }
+
+ mavlink_message_t msg;
+ mavlink_status_t status;
+ if (mavlink_parse_char(MAVLINK_COMM_0, (uint8_t)b, &msg, &status)) {
+ int len = mavlink_msg_to_send_buffer(buf, &msg);
+ for (ESPNOWSerial *link : peers) {
+ link->write(buf, len);
+ }
+ }
+ }
+
+ // Send from ESP-NOW to Serial
+ for (ESPNOWSerial *link : peers) {
+ int len = link->read(buf, sizeof(buf));
+ if (len > 0) {
+ Serial.write(buf, len);
+ }
+ }
+}