From 21e811055d8a19326044b97b3ea0e9d1020a2554 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Fri, 9 Oct 2026 19:48:39 +0200 Subject: [PATCH] Add experimental CRWF Wi-Fi trainer input and telemetry --- docs/crsf-wifi-bridge.md | 104 ++++++++++++++++++++++++++ lib/CrsfWifi/CrsfWifi.h | 55 ++++++++++++++ lib/CrsfWifi/CrsfWifiSession.h | 74 ++++++++++++++++++ src/Tx_main.cpp | 22 ++++-- targets/txbp_esp.ini | 7 ++ test/crsf_wifi_session.cpp | 41 ++++++++++ tools/crsf_wifi_client.py | 132 +++++++++++++++++++++++++++++++++ 7 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 docs/crsf-wifi-bridge.md create mode 100644 lib/CrsfWifi/CrsfWifi.h create mode 100644 lib/CrsfWifi/CrsfWifiSession.h create mode 100644 test/crsf_wifi_session.cpp create mode 100644 tools/crsf_wifi_client.py diff --git a/docs/crsf-wifi-bridge.md b/docs/crsf-wifi-bridge.md new file mode 100644 index 00000000..6dbb0fe5 --- /dev/null +++ b/docs/crsf-wifi-bridge.md @@ -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. diff --git a/lib/CrsfWifi/CrsfWifi.h b/lib/CrsfWifi/CrsfWifi.h new file mode 100644 index 00000000..b56af959 --- /dev/null +++ b/lib/CrsfWifi/CrsfWifi.h @@ -0,0 +1,55 @@ +#pragma once +#include +#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(); + } + } +}; diff --git a/lib/CrsfWifi/CrsfWifiSession.h b/lib/CrsfWifi/CrsfWifiSession.h new file mode 100644 index 00000000..c482bbb9 --- /dev/null +++ b/lib/CrsfWifi/CrsfWifiSession.h @@ -0,0 +1,74 @@ +#pragma once +#include +#include +#include + +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=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; + } +}; +} diff --git a/src/Tx_main.cpp b/src/Tx_main.cpp index c37911fc..23c07ceb 100644 --- a/src/Tx_main.cpp +++ b/src/Tx_main.cpp @@ -1,5 +1,4 @@ #include - #if defined(PLATFORM_ESP8266) #include #include @@ -8,7 +7,6 @@ #include #include #endif - #include "msp.h" #include "msptypes.h" #include "logging.h" @@ -16,22 +14,23 @@ #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 #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; @@ -39,7 +38,6 @@ unsigned long rebootTime = 0; bool cacheFull = false; bool sendCached = false; - device_t *ui_devices[] = { #ifdef PIN_LED &LED_device, @@ -218,6 +216,10 @@ 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); @@ -225,6 +227,9 @@ void ProcessMSPPacketFromTX(mspPacket_t *packet) 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); @@ -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. diff --git a/targets/txbp_esp.ini b/targets/txbp_esp.ini index 4e5b2c33..79f43f99 100644 --- a/targets/txbp_esp.ini +++ b/targets/txbp_esp.ini @@ -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 diff --git a/test/crsf_wifi_session.cpp b/test/crsf_wifi_session.cpp new file mode 100644 index 00000000..4818c11a --- /dev/null +++ b/test/crsf_wifi_session.cpp @@ -0,0 +1,41 @@ +#include "../lib/CrsfWifi/CrsfWifiSession.h" +#include + +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< 64 or frame[1] + 2 != len(frame): + raise ValueError('Invalid CRSF length') + if crc8(frame[2:-1]) != frame[-1]: + raise ValueError('Invalid CRSF CRC') + if kind == RC and (len(frame) != 26 or frame[0] not in (0xEA, 0xC8) + or frame[2] != 0x16): + raise ValueError('Invalid RC frame') + else: + raise ValueError('Unknown kind') + return kind, session, sequence, frame + + +def selftest(): + frame = channels_frame([992] * 16) + assert len(frame) == 26 + packet = encode(RC, 123, 0xFFFFFFFF, frame) + assert len(packet) == 42 + assert decode(packet) == (RC, 123, 0xFFFFFFFF, frame) + assert decode(encode(RELEASE, 123, 0))[3] == b'' + assert decode(encode(TELEMETRY, 123, 1, frame))[3] == frame + for invalid in (packet[:15], packet + b'\0', packet[:-1] + bytes([packet[-1] ^ 1]), + b'BAD!' + packet[4:], packet[:6] + b'\1' + packet[7:]): + try: + decode(invalid) + except ValueError: + continue + raise AssertionError('Malformed packet accepted') + print('CRWF offline checks passed; no network packets sent.') + + +def bench_demo(host, port, seconds, rate, values): + session = secrets.randbelow(0xFFFFFFFF) + 1 + sequence = 0 + frame = channels_frame(values) + destination = (host, port) + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(('', 0)) + sock.setblocking(False) + deadline = time.monotonic() + seconds + try: + while time.monotonic() < deadline: + sock.sendto(encode(RC, session, sequence, frame), destination) + sequence = (sequence + 1) & 0xFFFFFFFF + while True: + try: + packet, peer = sock.recvfrom(256) + except BlockingIOError: + break + if peer != destination: + continue + kind, remote_session, _, telemetry = decode(packet) + if kind == TELEMETRY and remote_session == session: + print('Telemetry:', telemetry.hex()) + time.sleep(1 / rate) + finally: + sock.sendto(encode(RELEASE, session, sequence), destination) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--send', metavar='IP', help='Explicitly send bench input') + parser.add_argument('--port', type=int, default=8888) + parser.add_argument('--seconds', type=float, default=2) + parser.add_argument('--rate', type=int, default=50) + parser.add_argument('--channels', default=','.join(['992'] * 16), + help='Sixteen raw CRSF values; defaults to all neutral') + args = parser.parse_args() + if not args.send: + selftest() + return + if not 1 <= args.port <= 65535 or not 1 <= args.rate <= 100 or not 0 < args.seconds <= 30: + parser.error('Invalid port, rate (1..100), or duration (0..30 seconds)') + try: + socket.inet_pton(socket.AF_INET, args.send) + values = [int(value) for value in args.channels.split(',')] + channels_frame(values) + except (ValueError, OSError) as error: + parser.error(str(error)) + bench_demo(args.send, args.port, args.seconds, args.rate, values) + + +if __name__ == '__main__': + main()