From 21e811055d8a19326044b97b3ea0e9d1020a2554 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Fri, 9 Oct 2026 19:48:39 +0200 Subject: [PATCH 1/3] 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() From 875bfaeb497ada7efcfaf6a5ae7a47d29deb79b3 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Fri, 9 Oct 2026 21:41:44 +0200 Subject: [PATCH 2/3] docs: add pull request description for experimental CRSF Wi-Fi trainer input and telemetry --- docs/pr-description.md | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/pr-description.md diff --git a/docs/pr-description.md b/docs/pr-description.md new file mode 100644 index 00000000..5a5cd2df --- /dev/null +++ b/docs/pr-description.md @@ -0,0 +1,47 @@ +# Add experimental opt-in CRSF Wi-Fi trainer input and telemetry + +PC applications cannot currently send packed CRSF channel input to a TX +Backpack over ordinary Wi-Fi UDP. This prototype adds a separate, build-time +opt-in UDP service that converts validated channel frames into the existing +MSP trainer-input message and returns CRSF telemetry to the active client. + +The main ELRS module stays in Normal mode. Channels flow through its existing +EdgeTX trainer route: configure ExpressLRS 4 with `HT Start Channel: EdgeTX`, +and EdgeTX 2.11 or later with `Master/CRSF` trainer mode and appropriate mixes. +No receiver firmware change, custom RF protocol or direct PWM override is added. + +## Behavior + +- New ESP32-C3 environment `ESP32C3_TX_Backpack_via_CRSF_WIFI` enables UDP port 8888; + the standard environment leaves the service disabled. +- CRWF version 1 wraps one complete CRC-valid CRSF frame with a session and + sequence number. Ingress is limited to packed RC channels and release. +- One source IP/port/session owns a 300 ms renewable lease. Invalid, duplicate, + reordered and foreign-client packets cannot renew it. Recent retired tokens + are rejected using a bounded sixteen-token history. +- Trainer enable from the main TX gates forwarding. Telemetry returns to the + same client socket. Existing telemetry services remain available. +- Includes protocol documentation, native checks and a stdlib reference client + whose default invocation performs offline checks without sending packets. + +## Validation + +The standard `ESP32C3_TX_Backpack_via_UART` and opt-in +`ESP32C3_TX_Backpack_via_CRSF_WIFI` PlatformIO builds passed after review fixes. +The Python reference client's offline checks passed. The production C++ session +parser harness passed unit testing for decoding, CRC/length rejection, endpoint +ownership, duplicate sequences, wraparound, release, expiry, and retired-token rejection. + +**Hardware bench validation:** +- Flashed and tested on physical RadioMaster Nomad TX Backpack (ESP32-C3). +- Verified with RadioMaster Pocket / MT12 running EdgeTX 2.11+ (`Master/CRSF` trainer mode) and RadioMaster ER5C V2 receiver (PWM servo output). +- Verified full end-to-end control path over Wi-Fi UDP port 8888 using `CRWF` v1 datagrams. +- Local physical switch takeover in EdgeTX confirmed — toggling the switch instantly yields control back to local radio inputs. +- Required a radio reboot after enabling Lua settings (`Telemetry: WiFi`, `HT Start Channel: EdgeTX`) for the main TX to bring up the Backpack in Wi-Fi telemetry service mode. + +## Technical considerations and limitations + +- **Failsafe budget:** The existing EdgeTX trainer stale timeout is approximately 1 second; the 300 ms Backpack lease terminates forwarding, but the full pipeline budget before EdgeTX mixer fallback can take up to ~1.3 seconds. +- **Network security:** Session tokens and bounded retired history prevent accidental cross-client collisions and reordered packets, but are not cryptographic authentication. The service is intended for trusted networks and remains opt-in / disabled by default. +- **Ingress scope:** The service strictly accepts packed RC channel frames (type 0x16) and session release datagrams; it does not accept arbitrary CRSF configuration commands. +- Protocol review and maintainer feedback on the wire format are welcome. From c1658e1cabacf47586ed04d102c065593107289c Mon Sep 17 00:00:00 2001 From: Mateusz Date: Sat, 10 Oct 2026 02:34:43 +0200 Subject: [PATCH 3/3] ci: add GitHub Actions CI/CD workflows and automated firmware releases --- .github/workflows/build.yml | 154 ++++++++++++++----------- .github/workflows/release.yml | 208 ++++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2c869d4d..8b9ab827 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,27 @@ name: Build ExpressLRS Backpack -on: [push, pull_request] + +on: + push: + pull_request: + workflow_dispatch: + jobs: validate: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Validate targets.json - run: python3 .github/targets_validator.py + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Validate targets.json + run: python3 .github/targets_validator.py + + - name: Run CRWF offline protocol checks + run: python3 tools/crsf_wifi_client.py targets: needs: validate @@ -15,10 +29,17 @@ jobs: outputs: targets: ${{ steps.set-targets.outputs.targets }} steps: - - name: Checkout - uses: actions/checkout@v7 - - id: set-targets - run: echo "targets=[$(grep -r "\[env:" targets | sed 's/.*://' | sed s/.$// | egrep "UART" | tr '\n' ',' | sed 's/,$/"\n/' | sed 's/,/","/'g | sed 's/^/"/')]" >> $GITHUB_OUTPUT + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - id: set-targets + run: | + python3 -c "import glob, re, json; targets = [m.group(1) for f in sorted(glob.glob('targets/*.ini')) for line in open(f) for m in [re.match(r'^\s*\[env:([^\]]+)\]', line)] if m and re.search(r'UART|CRSF_WIFI', m.group(1)) and not m.group(1).startswith('DEBUG_')]; print('targets=' + json.dumps(targets))" >> $GITHUB_OUTPUT build: needs: targets @@ -28,70 +49,69 @@ jobs: target: ${{fromJSON(needs.targets.outputs.targets)}} runs-on: ubuntu-latest steps: + - name: Inject slug/short variables + uses: rlespinasse/github-slug-action@v4 - - name: Inject slug/short variables - uses: rlespinasse/github-slug-action@v4 - - - name: Checkout - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.10' - - - name: Cache pip - uses: actions/cache@v6 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ matrix.target }} - - - name: Install PlatformIO - run: | - python -m pip install --upgrade pip - pip install platformio - pip install wheel - - - name: Cache PlatformIO - uses: actions/cache@v6 - with: - path: ~/.platformio - key: ${{ runner.os }}-platformio - - - name: Run PlatformIO - run: | - platformio platform update - platformio platform install native - mkdir -p ~/artifacts/firmware - pio run -e ${{ matrix.target }} - mv .pio/build/${{ matrix.target }} ~/artifacts/firmware/`echo ${{ matrix.target }} | sed s/_via_UART//` - cp -r hardware ~/artifacts/firmware - - - name: Store Artifacts - uses: actions/upload-artifact@v7 - with: - name: temp-${{ matrix.target }} - path: | - ~/artifacts/**/*.bin - ~/artifacts/**/*.json - continue-on-error: true + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ matrix.target }} + + - name: Install PlatformIO + run: | + python -m pip install --upgrade pip + pip install platformio wheel + + - name: Cache PlatformIO + uses: actions/cache@v4 + with: + path: ~/.platformio + key: ${{ runner.os }}-platformio + + - name: Run PlatformIO + run: | + platformio platform update + platformio platform install native + mkdir -p ~/artifacts/firmware + pio run -e ${{ matrix.target }} + TARGET_DIR=$(echo "${{ matrix.target }}" | sed -e 's/_via_UART//' -e 's/_via_CRSF_WIFI/_CRSF_WIFI/') + mv .pio/build/${{ matrix.target }} ~/artifacts/firmware/$TARGET_DIR + cp -r hardware ~/artifacts/firmware + + - name: Store Artifacts + uses: actions/upload-artifact@v4 + with: + name: temp-${{ matrix.target }} + path: | + ~/artifacts/**/*.bin + ~/artifacts/**/*.json + continue-on-error: true firmware: needs: [build] runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v5 with: python-version: '3.10' - name: Get firmware artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v4 with: path: dist merge-multiple: true @@ -103,7 +123,7 @@ jobs: shiv -c flash -o ../dist/firmware/flasher.pyz pyserial . - name: Update firmware artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: firmware path: dist/**/* @@ -113,6 +133,12 @@ jobs: cd dist zip -r -9 ../firmware.zip . + - name: Upload firmware zip bundle + uses: actions/upload-artifact@v4 + with: + name: firmware-bundle + path: firmware.zip + - name: Upload firmware bundle to ExpressLRS artifactory env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -120,16 +146,16 @@ jobs: AWS_ENDPOINT_URL: ${{ secrets.AWS_ENDPOINT_URL }} AWS_REGION: "auto" run: | + if [[ -z $AWS_ACCESS_KEY_ID || -z $AWS_SECRET_ACCESS_KEY || -z $AWS_ENDPOINT_URL ]]; then + echo "Artifactory credentials are not defined. Most likely action is running from pull request or fork. Not a bug." + exit 0 + fi + curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.22.35.zip" -o "awscliv2.zip" unzip awscliv2.zip sudo ./aws/install --update aws --version - if [[ -z $AWS_ACCESS_KEY_ID || -z $AWS_SECRET_ACCESS_KEY || -z $AWS_ENDPOINT_URL ]]; then - echo "Artifactory credentials are not defined. Most likely action is running from pull request. Not a bug." - exit 0 - fi - echo "Uploading firmware to artifactory" aws s3 cp --endpoint-url $AWS_ENDPOINT_URL firmware.zip s3://expresslrs/Backpack/$GITHUB_SHA/firmware.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..4cc11506 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,208 @@ +name: Release Firmware + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag_name: + description: 'Release Tag (e.g. v0.1.0-crsf-wifi)' + required: true + default: 'v0.1.0-crsf-wifi' + release_name: + description: 'Release Title' + required: true + default: 'Release v0.1.0 - CRSF Wi-Fi Trainer Bridge' + prerelease: + description: 'Mark as Pre-release' + type: boolean + default: false + draft: + description: 'Create as Draft' + type: boolean + default: false + +permissions: + contents: write + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Validate targets.json + run: python3 .github/targets_validator.py + + - name: Run CRWF offline protocol checks + run: python3 tools/crsf_wifi_client.py + + build-and-release: + needs: validate + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-release + + - name: Install PlatformIO and packaging dependencies + run: | + python -m pip install --upgrade pip + pip install platformio wheel shiv pyserial + + - name: Cache PlatformIO + uses: actions/cache@v4 + with: + path: ~/.platformio + key: ${{ runner.os }}-platformio-release + + - name: Build Firmware Targets + run: | + platformio platform update + pio run -e ESP32C3_TX_Backpack_via_CRSF_WIFI + pio run -e ESP32C3_TX_Backpack_via_UART + pio run -e ESP_TX_Backpack_via_UART + + - name: Build Flasher Executable + run: | + mkdir -p dist + cd python + shiv -c flash -o ../dist/flasher.pyz pyserial . + cd .. + + - name: Prepare Release Assets + run: | + mkdir -p release-assets/Nomad-ESP32C3-CRSF-WiFi-bundle + + # Copy Nomad / ESP32-C3 CRSF Wi-Fi bundle + cp .pio/build/ESP32C3_TX_Backpack_via_CRSF_WIFI/firmware.bin release-assets/Nomad-ESP32C3-CRSF-WiFi-bundle/firmware.bin + cp .pio/build/ESP32C3_TX_Backpack_via_CRSF_WIFI/bootloader.bin release-assets/Nomad-ESP32C3-CRSF-WiFi-bundle/bootloader.bin + cp .pio/build/ESP32C3_TX_Backpack_via_CRSF_WIFI/partitions.bin release-assets/Nomad-ESP32C3-CRSF-WiFi-bundle/partitions.bin + cp .pio/build/ESP32C3_TX_Backpack_via_CRSF_WIFI/boot_app0.bin release-assets/Nomad-ESP32C3-CRSF-WiFi-bundle/boot_app0.bin + + cat << 'EOF' > release-assets/Nomad-ESP32C3-CRSF-WiFi-bundle/README_FLASHING.md + # RadioMaster Nomad (ESP32-C3) CRSF Wi-Fi Trainer Firmware + + ## 1. Web UI Update (OTA) + If your backpack is already running ExpressLRS backpack firmware: + 1. Connect to the TX Backpack Wi-Fi hotspot (`ExpressLRS TX Backpack`, password: `expresslrs`). + 2. Open http://192.168.4.1 in your browser. + 3. Upload `firmware.bin` in the Firmware Update tab. + 4. Wait for it to reboot. + + ## 2. Flashing via USB / Passthrough (esptool) + To write fresh firmware directly to an ESP32-C3: + ```bash + python -m esptool --chip esp32c3 write_flash \ + 0x0000 bootloader.bin \ + 0x8000 partitions.bin \ + 0xe000 boot_app0.bin \ + 0x10000 firmware.bin + ``` + + ## 3. Radio & EdgeTX Setup + - Radio firmware: EdgeTX 2.11 or later + - Model Setup -> Trainer -> Mode: `Master/CRSF` + - ExpressLRS Lua options: + - `HT Start Channel`: `EdgeTX` + - `Telemetry`: `WiFi` + - Reboot your transmitter once after changing these settings. + + ## 4. Testing & Sending Channels from PC + Use the included `crsf_wifi_client.py`: + ```bash + python crsf_wifi_client.py --help + ``` + EOF + + # Create zip bundle for Nomad ESP32-C3 + cd release-assets + zip -r Nomad-ESP32C3-CRSF-WiFi-bundle.zip Nomad-ESP32C3-CRSF-WiFi-bundle + rm -rf Nomad-ESP32C3-CRSF-WiFi-bundle + cd .. + + # Copy standalone binaries for convenient Web UI OTA updates + cp .pio/build/ESP32C3_TX_Backpack_via_CRSF_WIFI/firmware.bin release-assets/firmware-ESP32C3-CRSF-WiFi.bin + cp .pio/build/ESP32C3_TX_Backpack_via_UART/firmware.bin release-assets/firmware-ESP32C3-Standard.bin + cp .pio/build/ESP_TX_Backpack_via_UART/firmware.bin release-assets/firmware-ESP8285-Standard.bin + + # Copy client tool and flasher + cp tools/crsf_wifi_client.py release-assets/crsf_wifi_client.py + cp dist/flasher.pyz release-assets/flasher.pyz + + - name: Generate Release Body + id: release_body + run: | + cat << 'EOF' > release_notes.md + # ExpressLRS Backpack - CRSF Wi-Fi Trainer Bridge Release + + This release adds opt-in **CRSF Wi-Fi Trainer Input and Telemetry** (`CRWF` v1 protocol) for ExpressLRS TX Backpacks. + + ### Key Features + - **UDP Port 8888 Service:** Stream packed 16-channel CRSF RC input directly from a PC or simulator to the TX backpack over Wi-Fi. + - **Telemetry Return:** Real-time CRSF telemetry datagrams returned to the active client socket. + - **Safe EdgeTX Integration:** Channels flow through the standard EdgeTX trainer pipeline (`Master/CRSF`). Radio local switch instantly takes over control. + - **Session Ownership & Protection:** 300 ms renewable lease, sequence checks, duplicate rejection, and bounded retired-token history. + - **Hardware Validated:** Bench-tested on **RadioMaster Nomad TX Backpack (ESP32-C3)** with Pocket / MT12 transmitters and ER5C V2 receiver. + + --- + + ### Release Assets + | File | Description | + |---|---| + | `Nomad-ESP32C3-CRSF-WiFi-bundle.zip` | Complete bundle (`firmware.bin`, `bootloader.bin`, `partitions.bin`, `boot_app0.bin` + flash guide) | + | `firmware-ESP32C3-CRSF-WiFi.bin` | Direct OTA update binary for Web UI (ESP32-C3 / RadioMaster Nomad) | + | `firmware-ESP32C3-Standard.bin` | Baseline standard backpack firmware for ESP32-C3 | + | `firmware-ESP8285-Standard.bin` | Baseline standard backpack firmware for ESP8285 | + | `crsf_wifi_client.py` | Python reference client & test script | + | `flasher.pyz` | Standalone Python serial flasher | + + --- + + ### Quick Setup Guide + 1. **Flash Firmware:** + - Connect to backpack Web UI (`192.168.4.1`) and upload `firmware-ESP32C3-CRSF-WiFi.bin`, or flash the full bundle via USB using `esptool`. + 2. **Configure Radio (EdgeTX):** + - Set Trainer Mode: `Master/CRSF`. + - Assign your trainer channels in Model Setup / Special Functions. + - Configure a physical switch for trainer toggle (for instant takeover). + 3. **Configure Backpack via ExpressLRS Lua:** + - `HT Start Channel`: `EdgeTX` + - `Telemetry`: `WiFi` + - *Reboot your radio once after applying these Lua settings.* + 4. **Connect from PC:** + - Connect PC to backpack Wi-Fi network. + - Run `python crsf_wifi_client.py` to stream channels or test protocol connectivity. + EOF + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_type == 'tag' && github.ref_name || inputs.tag_name }} + name: ${{ inputs.release_name || (github.ref_type == 'tag' && github.ref_name) }} + body_path: release_notes.md + draft: ${{ inputs.draft || false }} + prerelease: ${{ inputs.prerelease || false }} + files: | + release-assets/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}