diff --git a/html/fmp-relay.html b/html/fmp-relay.html
new file mode 100644
index 00000000..f41de278
--- /dev/null
+++ b/html/fmp-relay.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+FMP - drone link
+
+
+
+
+FMP: drone link - keep this tab open while FMP is open (it closes with FMP).
+Connecting to the backpack...
+
+
+
diff --git a/lib/WIFI/devWIFI.cpp b/lib/WIFI/devWIFI.cpp
index 26b4013f..f52ebed7 100644
--- a/lib/WIFI/devWIFI.cpp
+++ b/lib/WIFI/devWIFI.cpp
@@ -117,6 +117,21 @@ WiFiUDP mavlinkUDP;
IPAddress gcsIP;
bool gcsIPSet = false;
+
+// MAVLink over WebSocket for browser GCS (Field Mission Planner): a phone browser can't do UDP,
+// so /fmp-relay.html (served from here) holds this socket. Uplink bytes are queued in the
+// async callback and written to the UART from the main loop, like the UDP path.
+static AsyncWebSocket mavWs("/mavlink-ws");
+static uint8_t mavWsUplink[1024];
+static uint16_t mavWsUplinkLen = 0;
+#if defined(PLATFORM_ESP32)
+static portMUX_TYPE mavWsMux = portMUX_INITIALIZER_UNLOCKED;
+#define MAVWS_LOCK() portENTER_CRITICAL(&mavWsMux)
+#define MAVWS_UNLOCK() portEXIT_CRITICAL(&mavWsMux)
+#else
+#define MAVWS_LOCK()
+#define MAVWS_UNLOCK()
+#endif
#endif
#if defined(TARGET_TX_BACKPACK)
@@ -210,6 +225,36 @@ static bool captivePortal(AsyncWebServerRequest *request)
return false;
}
+#if defined(MAVLINK_ENABLED)
+static void onMavWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len)
+{
+ if (type == WS_EVT_CONNECT)
+ {
+ // Browsers send Origin: only pages served by this backpack, opened by IP or mDNS name
+ // (a rebound DNS name or another site must not drive the drone). No Origin = not a browser.
+ AsyncWebServerRequest *request = (AsyncWebServerRequest *)arg;
+ String host = request->host();
+ bool hostOk = isIp(host) || host.endsWith(".local");
+ if (!hostOk || (request->hasHeader("Origin") && request->header("Origin") != "http://" + host))
+ {
+ client->close();
+ return;
+ }
+ server->cleanupClients(2);
+ }
+ else if (type == WS_EVT_DATA)
+ {
+ MAVWS_LOCK();
+ if (mavWsUplinkLen + len <= sizeof(mavWsUplink))
+ {
+ memcpy(mavWsUplink + mavWsUplinkLen, data, len);
+ mavWsUplinkLen += len;
+ }
+ MAVWS_UNLOCK();
+ }
+}
+#endif
+
static struct {
const char *url;
const char *contentType;
@@ -221,6 +266,9 @@ static struct {
{"/mui.js", "text/javascript", (uint8_t *)MUI_JS, sizeof(MUI_JS)},
{"/scan.js", "text/javascript", (uint8_t *)SCAN_JS, sizeof(SCAN_JS)},
{"/logo.svg", "image/svg+xml", (uint8_t *)LOGO_SVG, sizeof(LOGO_SVG)},
+#if defined(MAVLINK_ENABLED)
+ {"/fmp-relay.html", "text/html", (uint8_t *)FMP_RELAY_HTML, sizeof(FMP_RELAY_HTML)},
+#endif
#if defined(HAS_HEADTRACKING) || defined(SUPPORT_HEADTRACKING)
{"/airplane.obj", "text/plain", (uint8_t *)PLANE_OBJ, sizeof(PLANE_OBJ)},
{"/texture.gif", "image/gif", (uint8_t *)TEXTURE_GIF, sizeof(TEXTURE_GIF)},
@@ -769,6 +817,8 @@ static void startServices()
#endif
#if defined(MAVLINK_ENABLED)
server.on("/mavlink", HTTP_GET, WebMAVLinkHandler);
+ mavWs.onEvent(onMavWsEvent);
+ server.addHandler(&mavWs);
#endif
server.onNotFound(WebUpdateHandleNotFound);
@@ -908,10 +958,24 @@ static void HandleWebUpdate()
mavlinkUDP.beginPacket(remote, config.GetMavlinkSendPort());
mavlink_message_t* msgQueue = mavlink.GetQueuedMsgs();
+ // Same batch to WebSocket clients, coalesced; skipped (dropped) while their queue is full
+ static uint8_t wsBatch[1024];
+ uint16_t wsBatchLen = 0;
+ bool toWs = mavWs.count() > 0 && mavWs.availableForWriteAll();
for (uint8_t i = 0; i < mavlink.GetQueuedMsgCount(); i++)
{
uint8_t buf[MAVLINK_MAX_PACKET_LEN];
uint16_t len = mavlink_msg_to_send_buffer(buf, &msgQueue[i]);
+ if (toWs)
+ {
+ if (wsBatchLen + len > sizeof(wsBatch))
+ {
+ mavWs.binaryAll(wsBatch, wsBatchLen);
+ wsBatchLen = 0;
+ }
+ memcpy(wsBatch + wsBatchLen, buf, len);
+ wsBatchLen += len;
+ }
size_t sent = mavlinkUDP.write(buf, len);
if (sent < len)
{
@@ -919,6 +983,10 @@ static void HandleWebUpdate()
}
}
mavlinkUDP.endPacket();
+ if (wsBatchLen)
+ {
+ mavWs.binaryAll(wsBatch, wsBatchLen);
+ }
mavlink.ResetQueuedMsgCount();
last_mavlink_to_gcs_dump = millis();
}
@@ -935,6 +1003,18 @@ static void HandleWebUpdate()
mavlinkUDP.read(buf, packetSize);
mavlink.ProcessMAVLinkFromGCS(buf, packetSize);
}
+
+ // MAVLink from WebSocket clients (FMP in a browser)
+ if (mavWsUplinkLen)
+ {
+ static uint8_t wsBuf[sizeof(mavWsUplink)];
+ MAVWS_LOCK();
+ uint16_t n = mavWsUplinkLen;
+ memcpy(wsBuf, mavWsUplink, n);
+ mavWsUplinkLen = 0;
+ MAVWS_UNLOCK();
+ mavlink.ProcessMAVLinkFromGCS(wsBuf, n);
+ }
}
}
#endif
diff --git a/python/build_html.py b/python/build_html.py
index c9e505e9..d49995c6 100644
--- a/python/build_html.py
+++ b/python/build_html.py
@@ -57,6 +57,7 @@ def build_common(env, mainfile):
build_html("elrs.css", "ELRS_CSS", out, env)
build_html("mui.css", "MUI_CSS", out, env)
build_html("logo.svg", "LOGO_SVG", out, env)
+ build_html("fmp-relay.html", "FMP_RELAY_HTML", out, env)
build_html("p5.js", "P5_JS", out, env)
build_html("airplane.obj", "PLANE_OBJ", out, env)
build_binary("texture.gif", "TEXTURE_GIF", out, env)