Skip to content
Closed
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
104 changes: 104 additions & 0 deletions docs/crsf-wifi-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# CRSF Wi-Fi trainer bridge (experimental)

This opt-in TX Backpack service carries channel input from a PC over ordinary
Wi-Fi UDP and returns CRSF telemetry. It uses the existing ExpressLRS trainer
path: PC → Backpack → MSP trainer input → TX module → EdgeTX trainer/mixes →
normal ELRS RF → receiver. Receiver firmware and PWM behavior do not change.

This is a channel-input and telemetry bridge, not arbitrary bidirectional CRSF
configuration passthrough. The initial ingress supports only RC channels and
session release. Existing MAVLink, ESP-NOW and update services remain separate.

## Compatibility and configuration

The upstream trainer path requires ExpressLRS 4, TX Backpack 1.5.2 or later and
EdgeTX 2.11 or later. This Wi-Fi service additionally requires the modified
Backpack firmware; stock 1.5.9 does not accept these datagrams.

Keep the main TX in Normal link mode. Enable Backpack head tracking; select
`HT Start Channel: EdgeTX`. In EdgeTX select `Trainer: Master/CRSF` and map
trainer sources TR1…TR16 into the desired output channels. Configure a physical
switch to choose the PC input or local controls. Choose an RF switch mode that
actually carries the required output channels.

Build the modified Backpack with `CRSF_WIFI_CONTROL_ENABLED` explicitly defined
(disabled by default), and enable `Backpack → Telemetry: WiFi` in the TX Lua
menu. Join its access point or the same LAN. The bridge uses UDP port `8888`,
separate from MAVLink ports 14550/14555. There is no web configuration switch
for this experimental service.

The explicit ESP32-C3 build environment is
`pio run -e ESP32C3_TX_Backpack_via_CRSF_WIFI`. The ordinary
`ESP32C3_TX_Backpack` environment keeps this feature disabled.

RCSIM's existing raw CRSF UDP transport is not directly compatible with the
session envelope below. A client adapter or matching RCSIM change is required.

## Datagram format (CRWF version 1)

All multibyte integers are little endian. Each UDP datagram is one complete
message, with no trailing bytes.

| Offset | Bytes | Field |
| --- | --- | --- |
| 0 | 4 | ASCII `CRWF` |
| 4 | 1 | Version `1` |
| 5 | 1 | Kind: `1` RC input, `2` release, `3` telemetry |
| 6 | 2 | Reserved, zero |
| 8 | 4 | Nonzero session identifier |
| 12 | 4 | Sequence number |
| 16 | variable | Payload |

RC input contains exactly one 26-byte CRSF packed-channel frame: address `EA`
or `C8`, length `24`, type `16` hexadecimal, 22-byte packed sixteen-channel
payload, and CRC8 polynomial `D5` over type and payload. Total size is 42 bytes.
Channel values use raw 11-bit CRSF units, not microseconds.

Release has no payload (16 bytes total). Telemetry carries one complete,
CRC-valid CRSF frame after the header. It does not authorize remote commands.
Telemetry sequence numbers use a separate monotonically increasing counter.

The first valid RC packet claims ownership by source IP, source UDP port and
session identifier. Telemetry returns to that endpoint; clients must retain
the same socket. Valid fresh RC packets renew a 300 ms lease. Duplicate or
out-of-order sequence numbers do not renew it. Sequence freshness uses unsigned
32-bit modular subtraction: a delta from 1 through `7fffffff` is fresh.
Release must match the owner and have a fresh sequence number.
After release, lease expiry or disable, reconnect with a new session token.
The last sixteen retired tokens are rejected, preventing recent session
replay within this bounded history. This history is not persistent or unlimited
replay protection: reboot clears it and newer retired tokens can evict older
ones. The reference client creates a new token on each run.

A session identifier prevents accidental cross-client mixing; it is not an
authentication credential. Use a trusted network. There is no claim that this
prototype provides cryptographic authentication.

Backpack must also see enabled trainer input from the TX. A client lease does
not replace physical authorization. The existing TX trainer timeout is one
second, so a Backpack lease expiry alone must not be described as a 300 ms
end-to-end failsafe. A conservative budget can reach roughly 1.3 seconds for
the lease and TX stale interval, before considering EdgeTX mixing and scheduling.
There is no sender timestamp or packet age check; delayed but increasing
sequence numbers can still be accepted. This is not a complete guarantee against
stale input under network congestion: the bounded receive loop can process
queued old controls. A physical trainer-enable transition revokes the owner,
but actual output fallback is governed by the trainer path. Confirm the actual local-control fallback
and EdgeTX mixing behavior during hardware review.

## Reference client and validation

Run `python tools/crsf_wifi_client.py` for offline checks; it sends no packets.
The client validates envelope lengths, version, reserved bits and CRSF CRC and
creates sixteen packed channels. Network transmission requires explicit
`--send IP`; it defaults to fifty channel frames per second for two seconds,
then sends a release. The optional `--channels` accepts sixteen raw CRSF values.

Neutral channels are not an assurance that an ESC will remain stopped. Hardware
bench testing requires a suitable model setup and a tested physical takeover.
No hardware test or firmware flash is implied by passing software checks.

Verify on hardware: trainer sources reach EdgeTX, configured mixes reach PWM,
telemetry returns to the same UDP socket, local takeover works, and loss of
Wi-Fi/PC/Backpack returns controls according to the configured policy. Also
verify duplicate, reordered and malformed datagrams cannot retain ownership.
55 changes: 55 additions & 0 deletions lib/CrsfWifi/CrsfWifi.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#pragma once
#include <WiFiUdp.h>
#include "CrsfWifiSession.h"
#include "msp.h"
#include "msptypes.h"

// Experimental trainer input. Disabled unless explicitly selected at build time.
class CrsfWifi {
WiFiUDP udp;
crsfwifi::Session session;
bool listening=false;
uint32_t telemetrySequence=0;
public:
void disable() {
session.clear();
if (listening) { udp.stop(); listening=false; }
}
void poll(uint32_t now, bool enabled, MSP &msp) {
if (!enabled || WiFi.getMode()==WIFI_OFF) {
disable();
return;
}
if (!listening) listening=udp.begin(8888)==1;
if (!listening) return;
session.active(now);
uint16_t channels[16];
bool updated=false;
// Bound work per loop so serial processing cannot be starved by Wi-Fi.
for (unsigned i=0; i<4; ++i) {
int size=udp.parsePacket();
if (!size) break;
uint8_t packet[crsfwifi::HeaderSize+crsfwifi::RcSize];
IPAddress remote=udp.remoteIP(); uint16_t port=udp.remotePort();
if (size!=int(sizeof(packet)) && size!=int(crsfwifi::HeaderSize)) { udp.flush(); continue; }
int count=udp.read(packet,sizeof(packet));
if (count!=size || !session.accept(packet,count,uint32_t(remote),port,now,channels)) continue;
updated=true;
}
// Release received after an RC packet cancels that packet as well.
if (updated && session.active(now)) {
mspPacket_t ptr; ptr.reset(); ptr.makeCommand(); ptr.function=MSP_ELRS_BACKPACK_SET_PTR;
for (uint16_t channel : channels) { ptr.addByte(uint8_t(channel)); ptr.addByte(uint8_t(channel>>8)); }
msp.sendPacket(&ptr,&Serial);
}
}
void telemetry(const uint8_t *frame,size_t size,uint32_t now) {
if (!listening || !session.active(now) || !crsfwifi::validFrame(frame,size)) return;
uint8_t packet[crsfwifi::HeaderSize+64];
crsfwifi::header(packet,3,session.id,telemetrySequence++);
memcpy(packet+crsfwifi::HeaderSize,frame,size);
if (udp.beginPacket(IPAddress(session.ip),session.port)) {
udp.write(packet,crsfwifi::HeaderSize+size); udp.endPacket();
}
}
};
74 changes: 74 additions & 0 deletions lib/CrsfWifi/CrsfWifiSession.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>

namespace crsfwifi {
constexpr size_t HeaderSize = 16;
constexpr size_t RcSize = 26;
constexpr uint32_t LeaseMs = 300;
inline uint32_t read32(const uint8_t *p) {
return uint32_t(p[0]) | uint32_t(p[1]) << 8 | uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24;
}
inline void write32(uint8_t *p, uint32_t n) {
for (unsigned i=0; i<4; ++i) p[i] = uint8_t(n >> (8*i));
}
inline uint8_t crc(const uint8_t *p, size_t n) {
uint8_t result=0;
while (n--) {
result ^= *p++;
for (unsigned i=0; i<8; ++i) result = result & 128 ? uint8_t((result << 1)^0xd5) : uint8_t(result << 1);
}
return result;
}
inline bool validFrame(const uint8_t *p, size_t n) {
return n>=4 && n<=64 && size_t(p[1])+2==n && crc(p+2,n-3)==p[n-1];
}
inline void header(uint8_t *p, uint8_t kind, uint32_t session, uint32_t sequence) {
memcpy(p,"CRWF",4); p[4]=1; p[5]=kind; p[6]=p[7]=0;
write32(p+8,session); write32(p+12,sequence);
}
class Session {
public:
uint32_t ip=0, id=0, sequence=0, last=0;
uint16_t port=0;
bool claimed=false;
uint32_t retired[16]={};
unsigned retiredNext=0;
void clear() {
if (claimed) { retired[retiredNext]=id; retiredNext=(retiredNext+1)%16; }
claimed=false;
}
bool active(uint32_t now) {
if (claimed && uint32_t(now-last)>=LeaseMs) clear();
return claimed;
}
bool accept(const uint8_t *p,size_t n,uint32_t peer,uint16_t peerPort,uint32_t now,uint16_t channels[16]) {
active(now);
if (n<HeaderSize || memcmp(p,"CRWF",4) || p[4]!=1 || p[6] || p[7]) return false;
uint32_t token=read32(p+8), seq=read32(p+12);
if (!token || !peerPort) return false;
for (uint32_t old : retired) if (old==token) return false;
if (p[5]!=1 && p[5]!=2) return false;
if (p[5]==1) {
if (n!=HeaderSize+RcSize) return false;
const uint8_t *f=p+HeaderSize;
if ((f[0]!=0xea && f[0]!=0xc8) || f[1]!=24 || f[2]!=0x16 || !validFrame(f,RcSize)) return false;
} else if (n!=HeaderSize || !claimed) return false;
if (claimed) {
uint32_t delta=seq-sequence;
if (ip!=peer || port!=peerPort || id!=token || !delta || delta>=0x80000000u) return false;
}
if (p[5]==2) { clear(); return false; }
const uint8_t *bits=p+HeaderSize+3;
for (unsigned ch=0; ch<16; ++ch) {
unsigned bit=ch*11, offset=bit/8, shift=bit%8;
uint32_t word=uint32_t(bits[offset]) | uint32_t(bits[offset+1])<<8;
if (offset+2<22) word |= uint32_t(bits[offset+2])<<16;
channels[ch]=uint16_t((word>>shift)&2047);
}
ip=peer; port=peerPort; id=token; sequence=seq; last=now; claimed=true;
return true;
}
};
}
22 changes: 15 additions & 7 deletions src/Tx_main.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#include <Arduino.h>

#if defined(PLATFORM_ESP8266)
#include <espnow.h>
#include <ESP8266WiFi.h>
Expand All @@ -8,38 +7,37 @@
#include <esp_wifi.h>
#include <WiFi.h>
#endif

#include "msp.h"
#include "msptypes.h"
#include "logging.h"
#include "config.h"
#include "common.h"
#include "options.h"
#include "helpers.h"

#include "device.h"
#include "devWIFI.h"
#include "devButton.h"
#include "devLED.h"
#if defined(CRSF_WIFI_CONTROL_ENABLED)
#include "CrsfWifi.h"
static CrsfWifi crsfWifi;
static bool wifiTrainerEnabled = false;
#endif

#if defined(MAVLINK_ENABLED)
#include <MAVLink.h>
#endif

/////////// GLOBALS ///////////

uint8_t bindingAddress[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

const uint8_t version[] = {LATEST_VERSION};

connectionState_e connectionState = starting;
// Assume we are in wifi update mode until we know otherwise
wifi_service_t wifiService = WIFI_SERVICE_UPDATE;
unsigned long rebootTime = 0;

bool cacheFull = false;
bool sendCached = false;

device_t *ui_devices[] = {
#ifdef PIN_LED
&LED_device,
Expand Down Expand Up @@ -218,13 +216,20 @@ void ProcessMSPPacketFromTX(mspPacket_t *packet)

case MSP_ELRS_BACKPACK_SET_HEAD_TRACKING:
DBGLN("Processing MSP_ELRS_BACKPACK_SET_HEAD_TRACKING...");
#if defined(CRSF_WIFI_CONTROL_ENABLED)
if (wifiTrainerEnabled != (packet->payloadSize == 1 && packet->payload[0] != 0)) crsfWifi.disable();
wifiTrainerEnabled = packet->payloadSize == 1 && packet->payload[0] != 0;
#endif
cachedHTPacket = *packet;
cacheFull = true;
sendMSPViaEspnow(packet);
break;

case MSP_ELRS_BACKPACK_CRSF_TLM:
DBGLN("Processing MSP_ELRS_BACKPACK_CRSF_TLM...");
#if defined(CRSF_WIFI_CONTROL_ENABLED)
crsfWifi.telemetry(packet->payload, packet->payloadSize, millis());
#endif
if (config.GetTelemMode() == BACKPACK_TELEM_MODE_WIFI)
{
sendMSPViaWiFiUDP(packet);
Expand Down Expand Up @@ -457,6 +462,9 @@ void loop()
uint32_t now = millis();

devicesUpdate(now);
#if defined(CRSF_WIFI_CONTROL_ENABLED)
crsfWifi.poll(now, wifiTrainerEnabled && wifiService == WIFI_SERVICE_MAVLINK_TX, msp);
#endif

#if defined(PLATFORM_ESP8266) || defined(PLATFORM_ESP32)
// If the reboot time is set and the current time is past the reboot time then reboot.
Expand Down
7 changes: 7 additions & 0 deletions targets/txbp_esp.ini
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ upload_command = python "$PROJECT_DIR/python/external/esptool/esptool.py" --pass
[env:ESP32C3_TX_Backpack_via_WIFI]
extends = env:ESP32C3_TX_Backpack_via_UART

; Experimental CRSF Wi-Fi trainer bridge; disabled in ordinary targets.
[env:ESP32C3_TX_Backpack_via_CRSF_WIFI]
extends = env:ESP32C3_TX_Backpack_via_UART
build_flags =
${env:ESP32C3_TX_Backpack_via_UART.build_flags}
-D CRSF_WIFI_CONTROL_ENABLED=1

[env:ESP32C3_TX_Backpack_via_PASSTHRU]
extends = env:ESP32C3_TX_Backpack_via_UART
upload_speed = 230400
Expand Down
41 changes: 41 additions & 0 deletions test/crsf_wifi_session.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#include "../lib/CrsfWifi/CrsfWifiSession.h"
#include <assert.h>

static void rc(uint8_t p[42],uint32_t token,uint32_t seq) {
crsfwifi::header(p,1,token,seq);
uint8_t *f=p+16; f[0]=0xea; f[1]=24; f[2]=0x16;
memset(f+3,0,22);
for (unsigned ch=0;ch<16;++ch) {
unsigned value=172+ch*100;
for (unsigned b=0;b<11;++b) if (value&(1u<<b)) f[3+(ch*11+b)/8] |= 1u<<((ch*11+b)%8);
}
f[25]=crsfwifi::crc(f+2,23);
}
int main() {
crsfwifi::Session s; uint16_t channels[16]; uint8_t p[42];
rc(p,123,1);
assert(s.accept(p,42,1,8888,10,channels));
for (unsigned ch=0;ch<16;++ch) assert(channels[ch]==172+ch*100);
assert(!s.accept(p,42,1,8888,20,channels)); // duplicate
rc(p,123,2);
assert(!s.accept(p,42,2,8888,20,channels)); // endpoint ownership
assert(!s.accept(p,42,1,9999,20,channels));
p[41]^=1;
assert(!s.accept(p,42,1,8888,20,channels)); // CRC
p[41]^=1;
assert(!s.accept(p,41,1,8888,20,channels));
assert(s.accept(p,42,1,8888,20,channels));
assert(!s.active(320));
assert(!s.accept(p,42,1,8888,321,channels)); // retired session
rc(p,321,1); assert(s.accept(p,42,1,8888,321,channels));
s.clear(); rc(p,123,3); assert(!s.accept(p,42,1,8888,321,channels)); // A/B/A replay
rc(p,124,0xffffffffu); assert(s.accept(p,42,1,8888,321,channels));
rc(p,124,0); assert(s.accept(p,42,1,8888,322,channels)); // sequence wrap
crsfwifi::header(p,2,124,1);
assert(!s.accept(p,16,1,8888,323,channels)); assert(!s.claimed);
rc(p,125,1); assert(s.accept(p,42,1,8888,0xfffffff0u,channels));
assert(s.active(5)); assert(!s.active(300)); // clock wrap
for (unsigned n=0;n<42;++n) assert(!s.accept(p,n,1,8888,301,channels));
p[6]=1; assert(!s.accept(p,42,1,8888,301,channels));
return 0;
}
Loading