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); + } + } +}