Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions html/fmp-relay.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FMP - drone link</title>
<style>body{margin:0;padding:16px;font:14px/1.4 system-ui,sans-serif;background:#111827;color:#e5e7eb}b{color:#34d399}.err{color:#f87171}</style>
</head>
<body>
<!-- Opened as a popup by Field Mission Planner (GitHub Pages). A browser can't speak UDP and an
https page may not open ws:// to the LAN, but this page is served BY the backpack, so it holds
the MAVLink WebSocket and relays bytes through postMessage. Same protocol as FMP's relay.html. -->
<div><b>FMP: drone link</b> - keep this tab open while FMP is open (it closes with FMP).</div>
<div id="st">Connecting to the backpack...</div>
<script>
"use strict";
var ALLOWED = ["https://tecelskyyivan-alt.github.io", location.origin];
var st = document.getElementById("st");
var peer = null, ws = null, rx = 0, tx = 0;
function show(text, err) { st.textContent = text; st.className = err ? "err" : ""; }
function ready() { if (peer && ws && ws.readyState === 1) window.opener.postMessage({ fmp: "ready" }, peer); }
ws = new WebSocket("ws://" + location.host + "/mavlink-ws");
ws.binaryType = "arraybuffer";
ws.onopen = function () { show("Backpack connected, waiting for FMP..."); ready(); };
ws.onerror = function () { show("Backpack MAVLink WebSocket not answering. Backpack -> Telemetry must be WiFi.", true); };
ws.onclose = function () { show("Link to the backpack closed.", true); if (peer && window.opener) window.opener.postMessage({ fmp: "closed" }, peer); };
ws.onmessage = function (ev) {
rx++;
if (peer && window.opener) window.opener.postMessage({ fmp: "rx", data: ev.data }, peer, [ev.data]);
};
window.addEventListener("message", function (ev) {
if (ALLOWED.indexOf(ev.origin) < 0 || !ev.data) return;
if (ev.data.fmp === "hello") { peer = ev.origin; ready(); try { window.opener.focus(); } catch (e) {} }
else if (ev.data.fmp === "tx" && ev.origin === peer && ws.readyState === 1) { ws.send(ev.data.data); tx++; }
else if (ev.data.fmp === "close" && ev.origin === peer) window.close();
});
setInterval(function () {
if (!window.opener || window.opener.closed) { window.close(); return; }
/* Let a reloaded FMP find this window instead of opening another one. */
if (ws.readyState === 1) for (var i = 0; i < ALLOWED.length; i++) if (ALLOWED[i] !== location.origin) window.opener.postMessage({ fmp: "here" }, ALLOWED[i]);
if (peer && ws.readyState === 1) show("Drone <-> FMP: received " + rx + ", sent " + tx + " packets.");
}, 1000);
</script>
</body>
</html>
80 changes: 80 additions & 0 deletions lib/WIFI/devWIFI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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)},
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -908,17 +958,35 @@ 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)
{
break;
}
}
mavlinkUDP.endPacket();
if (wsBatchLen)
{
mavWs.binaryAll(wsBatch, wsBatchLen);
}
mavlink.ResetQueuedMsgCount();
last_mavlink_to_gcs_dump = millis();
}
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions python/build_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down