From bb3bedef43bc2332af2b1ad31965f3558d366ecc Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 14:48:50 -0400 Subject: [PATCH 01/18] Use signed integers for attitude values Cast pitch, roll, and yaw to int16_t instead of uint16_t in the attitude telemetry example to properly support negative angle values. --- .../sendTelemetryGpsBaroVarioAttitude.ino | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino b/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino index 5bc823c..c3befaf 100644 --- a/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino +++ b/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino @@ -66,8 +66,8 @@ void sendAttitude(float pitch, float roll, float yaw) crsf_sensor_attitude_t crsfAttitude = { 0 }; // Values are MSB first (BigEndian) - crsfAttitude.pitch = htobe16((uint16_t)(pitch*10000.0)); - crsfAttitude.roll = htobe16((uint16_t)(roll*10000.0)); - crsfAttitude.yaw = htobe16((uint16_t)(yaw*10000.0)); + crsfAttitude.pitch = htobe16((int16_t)(pitch*10000.0)); + crsfAttitude.roll = htobe16((int16_t)(roll*10000.0)); + crsfAttitude.yaw = htobe16((int16_t)(yaw*10000.0)); crsf.queuePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_ATTITUDE, &crsfAttitude, sizeof(crsfAttitude)); } From dcb9e3cb805694da0f4013d3a019b4493d915d26 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 15:16:44 -0400 Subject: [PATCH 02/18] Fix attitude telemetry struct to use signed values per CRSF spec --- README.md | 6 +++--- src/crsf_protocol.h | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ddc8084..0b8aab2 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,9 @@ Includes all bytes from type (buffer[2]) to end of payload. * uint8_t txRfPower; //should be signed int? * uint8_t txFps; ### CRSF_FRAMETYPE_ATTITUDE = 0x1E -* uint16_t pitch; // pitch in radians, BigEndian -* uint16_t roll; // roll in radians, BigEndian -* uint16_t yaw; // yaw in radians, BigEndian +* int16_t pitch; // pitch in radians * 10000, BigEndian +* int16_t roll; // roll in radians * 10000, BigEndian +* int16_t yaw; // yaw in radians * 10000, BigEndian ### CRSF_FRAMETYPE_FLIGHT_MODE = 0x21 * char[]; //Flight mode ( Null-terminated string ) // Extended Header Frames, range: 0x28 to 0x96 diff --git a/src/crsf_protocol.h b/src/crsf_protocol.h index cdef816..fb93b72 100644 --- a/src/crsf_protocol.h +++ b/src/crsf_protocol.h @@ -152,9 +152,9 @@ typedef struct crsf_sensor_baro_altitude_s typedef struct crsf_sensor_attitude_s { - uint16_t pitch; // pitch in radians, BigEndian - uint16_t roll; // roll in radians, BigEndian - uint16_t yaw; // yaw in radians, BigEndian + int16_t pitch; // pitch in radians * 10000, BigEndian + int16_t roll; // roll in radians * 10000, BigEndian + int16_t yaw; // yaw in radians * 10000, BigEndian } PACKED crsf_sensor_attitude_t; // Use standard byte order macros for better portability From 4f9db1530c15cdf9d8074d567af405966c1b564a Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 16:23:39 -0400 Subject: [PATCH 03/18] Add parsing for GPS time, airspeed, RPM, temp, and cells telemetry Adds the newer CRSF telemetry frame types used by the ELRS 3.4+/4.x ecosystem, with getters for each sensor. RPM, temp, and cells frames are variable length, so their value counts are derived from the frame length. Telemetry parsing is shared between the flight controller and backpack directions, so the backpack path now receives all telemetry types instead of only attitude. --- README.md | 31 +++++++++- src/AlfredoCRSF.cpp | 146 ++++++++++++++++++++++++++++++++++++-------- src/AlfredoCRSF.h | 16 +++++ src/crsf_protocol.h | 62 ++++++++++++++++++- 4 files changed, 229 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 0b8aab2..7c229a5 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,15 @@ Overall packet length is PayloadLength+4 (dest, len, type, crc), or LEN+2 (dest, ### TYPE - CRSF_FRAMETYPE * CRSF_FRAMETYPE_GPS = 0x02, +* CRSF_FRAMETYPE_GPS_TIME = 0x03, * CRSF_FRAMETYPE_VARIO = 0x07, * CRSF_FRAMETYPE_BATTERY_SENSOR = 0x08, * CRSF_FRAMETYPE_BARO_ALTITUDE = 0x09, +* CRSF_FRAMETYPE_AIRSPEED = 0x0A, +* CRSF_FRAMETYPE_HEARTBEAT = 0x0B, +* CRSF_FRAMETYPE_RPM = 0x0C, +* CRSF_FRAMETYPE_TEMP = 0x0D, +* CRSF_FRAMETYPE_CELLS = 0x0E, * CRSF_FRAMETYPE_LINK_STATISTICS = 0x14, * CRSF_FRAMETYPE_OPENTX_SYNC = 0x10, * CRSF_FRAMETYPE_RADIO_ID = 0x3A, @@ -73,6 +79,15 @@ Includes all bytes from type (buffer[2]) to end of payload. * uint16_t heading; // GPS heading, degree/100 big endian * uint16_t altitude; // meters, +1000m big endian * uint8_t satellites; // satellites +### CRSF_FRAMETYPE_GPS_TIME = 0x03 +Used to synchronize the handset clock (sent by e.g. Betaflight 2026.06+, requires ELRS 4.1+ to pass through). +* int16_t year; // BigEndian +* uint8_t month; +* uint8_t day; +* uint8_t hour; +* uint8_t minute; +* uint8_t second; +* uint16_t millisecond; // BigEndian ### CRSF_FRAMETYPE_VARIO = 0x07 * int16_t verticalspd; // Vertical speed in cm/s, BigEndian ### CRSF_FRAMETYPE_BATTERY_SENSOR = 0x08 @@ -83,8 +98,22 @@ Includes all bytes from type (buffer[2]) to end of payload. ### CRSF_FRAMETYPE_BARO_ALTITUDE = 0x09 * uint16_t altitude; // Altitude in decimeters + 10000dm, or Altitude in meters if high bit is set, BigEndian * int16_t verticalspd; // Vertical speed in cm/s, BigEndian +### CRSF_FRAMETYPE_AIRSPEED = 0x0A +* uint16_t speed; // Airspeed in 0.1 * km/h (hectometers/h), BigEndian ### CRSF_FRAMETYPE_HEARTBEAT = 0x0B -* uint8_t Origin Device address; +* int16_t Origin Device address; // BigEndian (used for device discovery by the ELRS 4.0 CRSF router) +### CRSF_FRAMETYPE_RPM = 0x0C +Variable length, count of values determined by frame length. +* uint8_t source_id; // e.g. 0 = Motor 1, 1 = Motor 2, etc. +* int24_t rpm[1-19]; // Signed 24-bit RPM values BigEndian, negative = reverse +### CRSF_FRAMETYPE_TEMP = 0x0D +Variable length, count of values determined by frame length. +* uint8_t source_id; // e.g. 0 = FC including all ESCs, 1 = Ambient, etc. +* int16_t temperature[1-20]; // Deci-degrees Celsius BigEndian (250 = 25.0C) +### CRSF_FRAMETYPE_CELLS = 0x0E +Variable length, count of values determined by frame length. ELRS 4.0+ receivers with VBAT sensing send this with source_id 128 for millivolt-precision voltage. +* uint8_t source_id; // e.g. 0 = battery 1, 1 = battery 2, etc. +* uint16_t cell[1-29]; // Cell voltage in millivolts BigEndian (3850 = 3.850V) ### CRSF_FRAMETYPE_VIDEO_TRANSMITTER = 0x0F * uint8_t Origin address; * uint8_t Status; diff --git a/src/AlfredoCRSF.cpp b/src/AlfredoCRSF.cpp index cde3f69..710564b 100644 --- a/src/AlfredoCRSF.cpp +++ b/src/AlfredoCRSF.cpp @@ -4,7 +4,7 @@ AlfredoCRSF::AlfredoCRSF() : _crc(0xd5), _lastReceive(0), _lastChannelsPacket(0), _linkIsUp(false) { - + } void AlfredoCRSF::begin(Stream &port) @@ -96,25 +96,11 @@ void AlfredoCRSF::processPacketIn(uint8_t len) const crsf_header_t *hdr = (crsf_header_t *)_rxBuf; if (hdr->device_addr == CRSF_ADDRESS_FLIGHT_CONTROLLER) //Rx to FC { - switch (hdr->type) + if (!processTelemetryPacketIn(hdr) && hdr->type == CRSF_FRAMETYPE_RC_CHANNELS_PACKED) { - case CRSF_FRAMETYPE_GPS: - packetGps(hdr); - break; - case CRSF_FRAMETYPE_RC_CHANNELS_PACKED: packetChannelsPacked(hdr); - break; - case CRSF_FRAMETYPE_LINK_STATISTICS: - packetLinkStatistics(hdr); - break; - case CRSF_FRAMETYPE_BARO_ALTITUDE: - packetBaroAltitude(hdr); - break; - case CRSF_FRAMETYPE_VARIO: - packetVario(hdr); - break; } - } + } else if (hdr->device_addr == CRSF_ADDRESS_CRSF_TRANSMITTER) //Headset to TX { if (hdr->type == CRSF_FRAMETYPE_RC_CHANNELS_PACKED) @@ -122,13 +108,50 @@ void AlfredoCRSF::processPacketIn(uint8_t len) packetChannelsPacked(hdr); } } - else if (hdr->device_addr == CRSF_ADDRESS_RADIO_TRANSMITTER) //Telemetry to TX (Backpack - { - if (hdr->type == CRSF_FRAMETYPE_ATTITUDE) - { - packetAttitude(hdr); - } - } + else if (hdr->device_addr == CRSF_ADDRESS_RADIO_TRANSMITTER) //Telemetry to TX (Backpack) + { + processTelemetryPacketIn(hdr); + } +} + +// Handle telemetry frame types common to the FC and backpack directions. +// Returns true if the frame type was recognized and handled. +bool AlfredoCRSF::processTelemetryPacketIn(const crsf_header_t *hdr) +{ + switch (hdr->type) + { + case CRSF_FRAMETYPE_GPS: + packetGps(hdr); + return true; + case CRSF_FRAMETYPE_GPS_TIME: + packetGpsTime(hdr); + return true; + case CRSF_FRAMETYPE_LINK_STATISTICS: + packetLinkStatistics(hdr); + return true; + case CRSF_FRAMETYPE_BARO_ALTITUDE: + packetBaroAltitude(hdr); + return true; + case CRSF_FRAMETYPE_VARIO: + packetVario(hdr); + return true; + case CRSF_FRAMETYPE_ATTITUDE: + packetAttitude(hdr); + return true; + case CRSF_FRAMETYPE_AIRSPEED: + packetAirspeed(hdr); + return true; + case CRSF_FRAMETYPE_RPM: + packetRpm(hdr); + return true; + case CRSF_FRAMETYPE_TEMP: + packetTemp(hdr); + return true; + case CRSF_FRAMETYPE_CELLS: + packetCells(hdr); + return true; + } + return false; } // Shift the bytes in the RxBuf down by cnt bytes @@ -217,6 +240,81 @@ void AlfredoCRSF::packetAttitude(const crsf_header_t *p) _attitudeSensor.yaw = be16toh(attitude->yaw); } +void AlfredoCRSF::packetGpsTime(const crsf_header_t *p) +{ + const crsf_sensor_gps_time_t *gpsTime = (crsf_sensor_gps_time_t *)p->data; + _gpsTimeSensor.year = be16toh(gpsTime->year); + _gpsTimeSensor.month = gpsTime->month; + _gpsTimeSensor.day = gpsTime->day; + _gpsTimeSensor.hour = gpsTime->hour; + _gpsTimeSensor.minute = gpsTime->minute; + _gpsTimeSensor.second = gpsTime->second; + _gpsTimeSensor.millisecond = be16toh(gpsTime->millisecond); +} + +void AlfredoCRSF::packetAirspeed(const crsf_header_t *p) +{ + const crsf_sensor_airspeed_t *airspeed = (crsf_sensor_airspeed_t *)p->data; + _airspeedSensor.speed = be16toh(airspeed->speed); +} + +// RPM, TEMP and CELLS frames are variable length: source_id followed by +// 1-N values, where the count comes from the frame length +void AlfredoCRSF::packetRpm(const crsf_header_t *p) +{ + uint8_t payloadLen = p->frame_size - CRSF_FRAME_LENGTH_TYPE_CRC; + if (payloadLen < 1 + 3) + return; + _rpmSensor.source_id = p->data[0]; + uint8_t count = (payloadLen - 1) / 3; + if (count > CRSF_MAX_RPM_VALUES) + count = CRSF_MAX_RPM_VALUES; + _rpmSensor.rpm_count = count; + for (uint8_t i = 0; i < count; ++i) + { + const uint8_t *v = &p->data[1 + i * 3]; + // Signed 24-bit big endian + int32_t rpm = ((int32_t)v[0] << 16) | ((int32_t)v[1] << 8) | v[2]; + if (rpm & 0x800000) + rpm |= 0xFF000000; + _rpmSensor.rpm[i] = rpm; + } +} + +void AlfredoCRSF::packetTemp(const crsf_header_t *p) +{ + uint8_t payloadLen = p->frame_size - CRSF_FRAME_LENGTH_TYPE_CRC; + if (payloadLen < 1 + 2) + return; + _tempSensor.source_id = p->data[0]; + uint8_t count = (payloadLen - 1) / 2; + if (count > CRSF_MAX_TEMP_VALUES) + count = CRSF_MAX_TEMP_VALUES; + _tempSensor.temp_count = count; + for (uint8_t i = 0; i < count; ++i) + { + const uint8_t *v = &p->data[1 + i * 2]; + _tempSensor.temperature[i] = (int16_t)(((uint16_t)v[0] << 8) | v[1]); + } +} + +void AlfredoCRSF::packetCells(const crsf_header_t *p) +{ + uint8_t payloadLen = p->frame_size - CRSF_FRAME_LENGTH_TYPE_CRC; + if (payloadLen < 1 + 2) + return; + _cellsSensor.source_id = p->data[0]; + uint8_t count = (payloadLen - 1) / 2; + if (count > CRSF_MAX_CELL_VALUES) + count = CRSF_MAX_CELL_VALUES; + _cellsSensor.cell_count = count; + for (uint8_t i = 0; i < count; ++i) + { + const uint8_t *v = &p->data[1 + i * 2]; + _cellsSensor.cell[i] = ((uint16_t)v[0] << 8) | v[1]; + } +} + void AlfredoCRSF::write(uint8_t b) { _port->write(b); diff --git a/src/AlfredoCRSF.h b/src/AlfredoCRSF.h index b5e9f79..15ef48a 100644 --- a/src/AlfredoCRSF.h +++ b/src/AlfredoCRSF.h @@ -26,9 +26,14 @@ class AlfredoCRSF const crsf_channels_t *getChannelsPacked() const { return &_channelsPacked;} const crsfLinkStatistics_t *getLinkStatistics() const { return &_linkStatistics; } const crsf_sensor_gps_t *getGpsSensor() const { return &_gpsSensor; } + const crsf_sensor_gps_time_t *getGpsTimeSensor() const { return &_gpsTimeSensor; } const crsf_sensor_vario_t *getVarioSensor() const { return &_varioSensor; } const crsf_sensor_baro_altitude_t *getBaroAltitudeSensor() const { return &_baroAltitudeSensor; } const crsf_sensor_attitude_t *getAttitudeSensor() const { return &_attitudeSensor; } + const crsf_sensor_airspeed_t *getAirspeedSensor() const { return &_airspeedSensor; } + const crsf_sensor_rpm_t *getRpmSensor() const { return &_rpmSensor; } + const crsf_sensor_temp_t *getTempSensor() const { return &_tempSensor; } + const crsf_sensor_cells_t *getCellsSensor() const { return &_cellsSensor; } bool isLinkUp() const { return _linkIsUp; } private: @@ -39,9 +44,14 @@ class AlfredoCRSF crsf_channels_t _channelsPacked; crsfLinkStatistics_t _linkStatistics; crsf_sensor_gps_t _gpsSensor; + crsf_sensor_gps_time_t _gpsTimeSensor; crsf_sensor_vario_t _varioSensor; crsf_sensor_baro_altitude_t _baroAltitudeSensor; crsf_sensor_attitude_t _attitudeSensor; + crsf_sensor_airspeed_t _airspeedSensor; + crsf_sensor_rpm_t _rpmSensor; + crsf_sensor_temp_t _tempSensor; + crsf_sensor_cells_t _cellsSensor; uint32_t _baud; uint32_t _lastReceive; uint32_t _lastChannelsPacket; @@ -56,10 +66,16 @@ class AlfredoCRSF void checkLinkDown(); // Packet RX Handlers + bool processTelemetryPacketIn(const crsf_header_t *p); void packetChannelsPacked(const crsf_header_t *p); void packetLinkStatistics(const crsf_header_t *p); void packetGps(const crsf_header_t *p); + void packetGpsTime(const crsf_header_t *p); void packetVario(const crsf_header_t *p); void packetBaroAltitude(const crsf_header_t *p); void packetAttitude(const crsf_header_t *p); + void packetAirspeed(const crsf_header_t *p); + void packetRpm(const crsf_header_t *p); + void packetTemp(const crsf_header_t *p); + void packetCells(const crsf_header_t *p); }; diff --git a/src/crsf_protocol.h b/src/crsf_protocol.h index fb93b72..15a2663 100644 --- a/src/crsf_protocol.h +++ b/src/crsf_protocol.h @@ -12,8 +12,16 @@ #define CRSF_CHANNEL_VALUE_2000 1792 #define CRSF_CHANNEL_VALUE_MAX 1811 #define CRSF_CHANNEL_VALUE_SPAN (CRSF_CHANNEL_VALUE_MAX - CRSF_CHANNEL_VALUE_MIN) +// Extended limits ("E.Limits") channel range, 880us to 2120us +#define CRSF_CHANNEL_VALUE_EXT_MIN 0 +#define CRSF_CHANNEL_VALUE_EXT_MAX 1984 #define CRSF_MAX_PACKET_LEN 64 +// Maximum number of values in variable-length telemetry frames +#define CRSF_MAX_RPM_VALUES 19 +#define CRSF_MAX_TEMP_VALUES 20 +#define CRSF_MAX_CELL_VALUES 29 + // Clashes with CRSF_ADDRESS_FLIGHT_CONTROLLER #define CRSF_SYNC_BYTE 0XC8 @@ -30,10 +38,15 @@ enum { typedef enum { CRSF_FRAMETYPE_GPS = 0x02, + CRSF_FRAMETYPE_GPS_TIME = 0x03, CRSF_FRAMETYPE_VARIO = 0x07, CRSF_FRAMETYPE_BATTERY_SENSOR = 0x08, CRSF_FRAMETYPE_BARO_ALTITUDE = 0x09, - //CRSF_FRAMETYPE_HEARTBEAT = 0x0B, //no need to support? (rev07) + CRSF_FRAMETYPE_AIRSPEED = 0x0A, + CRSF_FRAMETYPE_HEARTBEAT = 0x0B, + CRSF_FRAMETYPE_RPM = 0x0C, + CRSF_FRAMETYPE_TEMP = 0x0D, + CRSF_FRAMETYPE_CELLS = 0x0E, //CRSF_FRAMETYPE_VIDEO_TRANSMITTER = 0x0F, //no need to support? (rev07) CRSF_FRAMETYPE_LINK_STATISTICS = 0x14, // CRSF_FRAMETYPE_OPENTX_SYNC = 0x10, //not in edgeTX @@ -65,6 +78,7 @@ typedef enum { CRSF_ADDRESS_BROADCAST = 0x00, CRSF_ADDRESS_USB = 0x10, + CRSF_ADDRESS_BLUETOOTH_WIFI = 0x12, CRSF_ADDRESS_TBS_CORE_PNP_PRO = 0x80, CRSF_ADDRESS_RESERVED1 = 0x8A, CRSF_ADDRESS_CURRENT_SENSOR = 0xC0, @@ -138,6 +152,52 @@ typedef struct crsf_sensor_gps_s uint8_t satellites; // satellites } PACKED crsf_sensor_gps_t; +typedef struct crsf_sensor_gps_time_s +{ + int16_t year; // big endian + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; + uint16_t millisecond; // big endian +} PACKED crsf_sensor_gps_time_t; + +typedef struct crsf_sensor_airspeed_s +{ + uint16_t speed; // Airspeed in 0.1 * km/h (hectometers/h), BigEndian +} PACKED crsf_sensor_airspeed_t; + +// Decoded form of the RPM frame. On the wire the payload is source_id +// followed by 1-19 signed 24-bit big endian RPM values; the frame length +// determines how many values are present. +typedef struct crsf_sensor_rpm_s +{ + uint8_t source_id; // Identifies the source of the RPM data (e.g., 0 = Motor 1, 1 = Motor 2, etc.) + uint8_t rpm_count; // Number of valid entries in rpm[] + int32_t rpm[CRSF_MAX_RPM_VALUES]; // RPM values, negative ones represent the motor spinning in reverse +} crsf_sensor_rpm_t; + +// Decoded form of the TEMP frame. On the wire the payload is source_id +// followed by 1-20 int16 big endian temperature values; the frame length +// determines how many values are present. +typedef struct crsf_sensor_temp_s +{ + uint8_t source_id; // Identifies the source of the temperature data (e.g., 0 = FC including all ESCs, 1 = Ambient, etc.) + uint8_t temp_count; // Number of valid entries in temperature[] + int16_t temperature[CRSF_MAX_TEMP_VALUES]; // Temperatures in deci-degree Celsius (e.g., 250 = 25.0C, -50 = -5.0C) +} crsf_sensor_temp_t; + +// Decoded form of the CELLS frame. On the wire the payload is source_id +// followed by 1-29 uint16 big endian cell voltages; the frame length +// determines how many values are present. +typedef struct crsf_sensor_cells_s +{ + uint8_t source_id; // Identifies the source of the battery data (e.g., 0 = battery 1, 1 = battery 2, etc.) + uint8_t cell_count; // Number of valid entries in cell[] + uint16_t cell[CRSF_MAX_CELL_VALUES]; // Cell voltages in millivolts (e.g. 3.850V = 3850) +} crsf_sensor_cells_t; + typedef struct crsf_sensor_vario_s { int16_t verticalspd; // Vertical speed in cm/s, BigEndian From 1d73f1a3c31c7e3da7a04f564d64e8b74207140d Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 16:24:05 -0400 Subject: [PATCH 04/18] Add support for the ELRS 4.0 channels status byte and arming state ELRS 4.0 handsets running EdgeTX 2.11+ may append a status byte after the packed channels in RC_CHANNELS_PACKED frames, carrying the commanded arm state for Arm using Switch mode. Detect it by frame length, expose the raw byte, and add isArmed() mirroring the ELRS arming logic (status bit in switch mode, channel 5 position otherwise). Frames without the status byte behave exactly as before. --- README.md | 4 ++++ src/AlfredoCRSF.cpp | 27 ++++++++++++++++++++++++++- src/AlfredoCRSF.h | 10 ++++++++++ src/crsf_protocol.h | 5 +++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7c229a5..15332de 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,10 @@ Variable length, count of values determined by frame length. ELRS 4.0+ receivers * unsigned ch13 : 11; * unsigned ch14 : 11; * unsigned ch15 : 11; + +ELRS 4.0+ handsets (EdgeTX 2.11+) may append one status byte after the packed channels: +* bit 0: CRSF_CHANNELS_STATUS_ARMED - commanded armed status in Arm using Switch mode +* bit 1: CRSF_CHANNELS_STATUS_ARMING_MODE_CH5 - arm via CH5 instead of the armed bit ### CRSF_FRAMETYPE_LINK_RX_ID = 0x1C * uint8_t rxRssiPercent; * uint8_t rxRfPower; //should be signed int? diff --git a/src/AlfredoCRSF.cpp b/src/AlfredoCRSF.cpp index 710564b..300c2aa 100644 --- a/src/AlfredoCRSF.cpp +++ b/src/AlfredoCRSF.cpp @@ -2,7 +2,8 @@ AlfredoCRSF::AlfredoCRSF() : _crc(0xd5), - _lastReceive(0), _lastChannelsPacket(0), _linkIsUp(false) + _lastReceive(0), _lastChannelsPacket(0), _linkIsUp(false), + _hasChannelsStatus(false), _channelsStatus(0) { } @@ -196,12 +197,36 @@ void AlfredoCRSF::packetChannelsPacked(const crsf_header_t *p) for (unsigned int i=0; iframe_size - CRSF_FRAME_LENGTH_TYPE_CRC; + if (payloadLen > sizeof(crsf_channels_t)) + { + _channelsStatus = p->data[sizeof(crsf_channels_t)]; + _hasChannelsStatus = true; + } + else + { + _channelsStatus = 0; + _hasChannelsStatus = false; + } + _linkIsUp = true; _lastChannelsPacket = millis(); memcpy(&_channelsPacked, ch, sizeof(_channelsPacked)); } +bool AlfredoCRSF::isArmed() const +{ + if (!_linkIsUp) + return false; + // Status byte present and Arm using Switch selected: use the commanded arm bit. + // Otherwise (no status byte, or Arm using CH5 selected): use channel 5 position. + if (_hasChannelsStatus && !(_channelsStatus & CRSF_CHANNELS_STATUS_ARMING_MODE_CH5)) + return _channelsStatus & CRSF_CHANNELS_STATUS_ARMED; + return getChannel(5) > 1500; +} + void AlfredoCRSF::packetLinkStatistics(const crsf_header_t *p) { const crsfLinkStatistics_t *link = (crsfLinkStatistics_t *)p->data; diff --git a/src/AlfredoCRSF.h b/src/AlfredoCRSF.h index 15ef48a..be21dd0 100644 --- a/src/AlfredoCRSF.h +++ b/src/AlfredoCRSF.h @@ -36,6 +36,14 @@ class AlfredoCRSF const crsf_sensor_cells_t *getCellsSensor() const { return &_cellsSensor; } bool isLinkUp() const { return _linkIsUp; } + // ELRS 4.0+ (EdgeTX 2.11+) appends an optional status byte to channels + // packets from the handset (see CRSF_CHANNELS_STATUS_* bits) + bool hasChannelsStatus() const { return _hasChannelsStatus; } + uint8_t getChannelsStatus() const { return _channelsStatus; } + // Commanded arm state, mirroring ELRS logic: uses the status byte in Arm + // using Switch mode, otherwise falls back to channel 5 (AUX1) position + bool isArmed() const; + private: Stream* _port; uint8_t _rxBuf[CRSF_MAX_PACKET_LEN+3]; @@ -56,6 +64,8 @@ class AlfredoCRSF uint32_t _lastReceive; uint32_t _lastChannelsPacket; bool _linkIsUp; + bool _hasChannelsStatus; + uint8_t _channelsStatus; int _channels[CRSF_NUM_CHANNELS]; void handleSerialIn(); diff --git a/src/crsf_protocol.h b/src/crsf_protocol.h index 15a2663..e5e271c 100644 --- a/src/crsf_protocol.h +++ b/src/crsf_protocol.h @@ -17,6 +17,11 @@ #define CRSF_CHANNEL_VALUE_EXT_MAX 1984 #define CRSF_MAX_PACKET_LEN 64 +// Optional status byte following the packed channels in a +// CRSF_FRAMETYPE_RC_CHANNELS_PACKED frame (ELRS 4.0+ with EdgeTX 2.11+) +#define CRSF_CHANNELS_STATUS_ARMED 0x01 // Armed status in Arm using Switch mode +#define CRSF_CHANNELS_STATUS_ARMING_MODE_CH5 0x02 // Arm using CH5 if bit is set + // Maximum number of values in variable-length telemetry frames #define CRSF_MAX_RPM_VALUES 19 #define CRSF_MAX_TEMP_VALUES 20 From c5f051b8811ef9e60381ce35ccc9c93dde9be704 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 21:22:59 -0400 Subject: [PATCH 05/18] Add parsing for ELRS status frames (0x2E) ELRS TX modules send this extended-header frame to the handset with good/ bad packet counts, warning flags, and a warning message string. Decode it into crsf_elrs_status_t, exposed via getElrsStatus(). The message is variable length on the wire and is copied with a bounded length and guaranteed null termination. --- README.md | 7 +++++++ src/AlfredoCRSF.cpp | 22 ++++++++++++++++++++++ src/AlfredoCRSF.h | 3 +++ src/crsf_protocol.h | 20 ++++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/README.md b/README.md index 15332de..f7d0956 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Overall packet length is PayloadLength+4 (dest, len, type, crc), or LEN+2 (dest, * CRSF_FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, * CRSF_FRAMETYPE_PARAMETER_READ = 0x2C, * CRSF_FRAMETYPE_PARAMETER_WRITE = 0x2D, +* CRSF_FRAMETYPE_ELRS_STATUS = 0x2E, * CRSF_FRAMETYPE_COMMAND = 0x32, // KISS frames * CRSF_FRAMETYPE_KISS_REQ = 0x78, @@ -183,6 +184,12 @@ ELRS 4.0+ handsets (EdgeTX 2.11+) may append one status byte after the packed ch * ???? ### CRSF_FRAMETYPE_PARAMETER_WRITE = 0x2D, * ???? +### CRSF_FRAMETYPE_ELRS_STATUS = 0x2E, +Extended header frame (payload preceded by destination and origin address bytes). Sent by an ELRS TX module to the handset. +* uint8_t pktsBad; +* uint16_t pktsGood; // BigEndian +* uint8_t flags; // bit 0: connected, bit 2: model mismatch warning, bit 3: armed warning, bit 5: error - change blocked while connected, bit 6: error - baud rate too low +* char msg[]; // Warning message (null-terminated string) ### CRSF_FRAMETYPE_COMMAND = 0x32, * ???? // KISS frames diff --git a/src/AlfredoCRSF.cpp b/src/AlfredoCRSF.cpp index 300c2aa..d8b53c7 100644 --- a/src/AlfredoCRSF.cpp +++ b/src/AlfredoCRSF.cpp @@ -151,6 +151,9 @@ bool AlfredoCRSF::processTelemetryPacketIn(const crsf_header_t *hdr) case CRSF_FRAMETYPE_CELLS: packetCells(hdr); return true; + case CRSF_FRAMETYPE_ELRS_STATUS: + packetElrsStatus(hdr); + return true; } return false; } @@ -323,6 +326,25 @@ void AlfredoCRSF::packetTemp(const crsf_header_t *p) } } +// ELRS_STATUS is an extended header frame: two extended routing bytes +// (destination, origin) precede the payload +void AlfredoCRSF::packetElrsStatus(const crsf_header_t *p) +{ + uint8_t payloadLen = p->frame_size - CRSF_FRAME_LENGTH_EXT_TYPE_CRC; + if (payloadLen < 4) + return; + const uint8_t *payload = &p->data[2]; // skip extended dest/origin + _elrsStatus.pktsBad = payload[0]; + _elrsStatus.pktsGood = ((uint16_t)payload[1] << 8) | payload[2]; + _elrsStatus.flags = payload[3]; + + uint8_t msgLen = payloadLen - 4; + if (msgLen > CRSF_ELRS_STATUS_MSG_LEN) + msgLen = CRSF_ELRS_STATUS_MSG_LEN; + memcpy(_elrsStatus.msg, &payload[4], msgLen); + _elrsStatus.msg[msgLen] = '\0'; +} + void AlfredoCRSF::packetCells(const crsf_header_t *p) { uint8_t payloadLen = p->frame_size - CRSF_FRAME_LENGTH_TYPE_CRC; diff --git a/src/AlfredoCRSF.h b/src/AlfredoCRSF.h index be21dd0..06da56c 100644 --- a/src/AlfredoCRSF.h +++ b/src/AlfredoCRSF.h @@ -34,6 +34,7 @@ class AlfredoCRSF const crsf_sensor_rpm_t *getRpmSensor() const { return &_rpmSensor; } const crsf_sensor_temp_t *getTempSensor() const { return &_tempSensor; } const crsf_sensor_cells_t *getCellsSensor() const { return &_cellsSensor; } + const crsf_elrs_status_t *getElrsStatus() const { return &_elrsStatus; } bool isLinkUp() const { return _linkIsUp; } // ELRS 4.0+ (EdgeTX 2.11+) appends an optional status byte to channels @@ -60,6 +61,7 @@ class AlfredoCRSF crsf_sensor_rpm_t _rpmSensor; crsf_sensor_temp_t _tempSensor; crsf_sensor_cells_t _cellsSensor; + crsf_elrs_status_t _elrsStatus; uint32_t _baud; uint32_t _lastReceive; uint32_t _lastChannelsPacket; @@ -88,4 +90,5 @@ class AlfredoCRSF void packetRpm(const crsf_header_t *p); void packetTemp(const crsf_header_t *p); void packetCells(const crsf_header_t *p); + void packetElrsStatus(const crsf_header_t *p); }; diff --git a/src/crsf_protocol.h b/src/crsf_protocol.h index e5e271c..d2ed63c 100644 --- a/src/crsf_protocol.h +++ b/src/crsf_protocol.h @@ -26,6 +26,14 @@ #define CRSF_MAX_RPM_VALUES 19 #define CRSF_MAX_TEMP_VALUES 20 #define CRSF_MAX_CELL_VALUES 29 +#define CRSF_ELRS_STATUS_MSG_LEN 56 + +// Flag bits in the ELRS_STATUS flags field +#define CRSF_ELRS_FLAG_CONNECTED 0x01 // status: TX connected to an RX +#define CRSF_ELRS_FLAG_MODEL_MATCH_WARN 0x04 // warning: model mismatch +#define CRSF_ELRS_FLAG_ARMED 0x08 // warning: armed +#define CRSF_ELRS_FLAG_ERROR_CONNECTED 0x20 // critical: change blocked while connected +#define CRSF_ELRS_FLAG_ERROR_BAUDRATE 0x40 // critical: baud rate too low // Clashes with CRSF_ADDRESS_FLIGHT_CONTROLLER #define CRSF_SYNC_BYTE 0XC8 @@ -67,6 +75,7 @@ typedef enum // CRSF_FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, //no "flight controller" needs to know about this // CRSF_FRAMETYPE_PARAMETER_READ = 0x2C, //no "flight controller" needs to know about this // CRSF_FRAMETYPE_PARAMETER_WRITE = 0x2D, //no "flight controller" needs to know about this + CRSF_FRAMETYPE_ELRS_STATUS = 0x2E, //ELRS good/bad packet count and status flags (extended header frame) // CRSF_FRAMETYPE_COMMAND = 0x32, //no "flight controller" needs to know about this // KISS frames // CRSF_FRAMETYPE_KISS_REQ = 0x78, //not in edgeTX @@ -215,6 +224,17 @@ typedef struct crsf_sensor_baro_altitude_s } PACKED crsf_sensor_baro_altitude_t; +// Decoded form of the ELRS_STATUS frame (extended header, TX module to +// handset). On the wire the payload is pktsBad, pktsGood (big endian), +// flags, then a variable-length null-terminated message string. +typedef struct crsf_elrs_status_s +{ + uint8_t pktsBad; // Bad packet count + uint16_t pktsGood; // Good packet count + uint8_t flags; // CRSF_ELRS_FLAG_* bits + char msg[CRSF_ELRS_STATUS_MSG_LEN + 1]; // Warning message, null-terminated +} crsf_elrs_status_t; + typedef struct crsf_sensor_attitude_s { int16_t pitch; // pitch in radians * 10000, BigEndian From c08cbd482f83be439e40b84ef999d496d71b10c6 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 21:26:48 -0400 Subject: [PATCH 06/18] Add GPS time telemetry send example Demonstrates sending CRSF_FRAMETYPE_GPS_TIME so a connected handset can set its clock (used by ELRS 4.1+ with EdgeTX 2.11+; older versions ignore the packet). --- .../sendTelemetryGpsBaroVarioAttitude.ino | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino b/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino index c3befaf..9c7faed 100644 --- a/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino +++ b/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino @@ -25,6 +25,7 @@ void loop() crsf.update(); sendGps(42.12345, -82.12345, 200.5, 20.13, 690, 4); + sendGpsTime(2026, 7, 14, 12, 34, 56, 789); sendBaroAltitude(234.1, 154.1); sendAttitude(0.05,-2.43,1.23); } @@ -43,6 +44,23 @@ void sendGps(float latitude, float longitude, float groundspeed, float heading, crsf.queuePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_GPS, &crsfGps, sizeof(crsfGps)); } +// Lets a connected handset set its clock from GPS time (requires ELRS 4.1+ +// and EdgeTX 2.11+; older versions simply ignore the packet) +void sendGpsTime(int16_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second, uint16_t millisecond) +{ + crsf_sensor_gps_time_t crsfGpsTime = { 0 }; + + // Values are MSB first (BigEndian) + crsfGpsTime.year = htobe16(year); + crsfGpsTime.month = month; + crsfGpsTime.day = day; + crsfGpsTime.hour = hour; + crsfGpsTime.minute = minute; + crsfGpsTime.second = second; + crsfGpsTime.millisecond = htobe16(millisecond); + crsf.queuePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_GPS_TIME, &crsfGpsTime, sizeof(crsfGpsTime)); +} + void sendBaroAltitude(float altitude, float verticalspd) { crsf_sensor_baro_altitude_t crsfBaroAltitude = { 0 }; From c7a916421095df7c1e1691ca09e53e05850757a1 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 21:42:03 -0400 Subject: [PATCH 07/18] Add writeChannels helper with optional ELRS 4.0 status byte writeChannels(addr, channels) sends a standard 22-byte packed channels frame and works with any CRSF device. The three-argument overload appends the ELRS 4.0 channels status byte (CRSF_CHANNELS_STATUS_* bits) so a sketch acting as a handset can command arm state in Arm using Switch mode. ELRS 3.x TX modules do not understand the longer frame, so the status byte overload must only be used with 4.0+ modules. The forwardChannelsToFC example now uses the plain helper; frames sent toward a flight controller never carry the status byte. --- .../forwardChannelsToFC/forwardChannelsToFC.ino | 5 ++--- src/AlfredoCRSF.cpp | 15 +++++++++++++++ src/AlfredoCRSF.h | 10 ++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/examples/forwardChannelsToFC/forwardChannelsToFC.ino b/examples/forwardChannelsToFC/forwardChannelsToFC.ino index 845177a..43aac0f 100644 --- a/examples/forwardChannelsToFC/forwardChannelsToFC.ino +++ b/examples/forwardChannelsToFC/forwardChannelsToFC.ino @@ -72,8 +72,7 @@ int getLinkQuality(AlfredoCRSF& crsf) { // Method to send channels based on CRSF instance void sendChannels(AlfredoCRSF& crsf) { - const crsf_channels_t* channels_ptr = crsf.getChannelsPacked(); - crsfOut.writePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_RC_CHANNELS_PACKED, channels_ptr, sizeof(*channels_ptr)); + crsfOut.writeChannels(CRSF_SYNC_BYTE, crsf.getChannelsPacked()); } // Fallback method to send default channel values @@ -96,5 +95,5 @@ void sendFallbackChannels() { crsfChannels.ch14 = CRSF_CHANNEL_VALUE_1000; crsfChannels.ch15 = CRSF_CHANNEL_VALUE_1000; - crsfOut.writePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_RC_CHANNELS_PACKED, &crsfChannels, sizeof(crsfChannels)); + crsfOut.writeChannels(CRSF_SYNC_BYTE, &crsfChannels); } diff --git a/src/AlfredoCRSF.cpp b/src/AlfredoCRSF.cpp index d8b53c7..8bef977 100644 --- a/src/AlfredoCRSF.cpp +++ b/src/AlfredoCRSF.cpp @@ -398,3 +398,18 @@ void AlfredoCRSF::writePacket(uint8_t addr, uint8_t type, const void *payload, u buf[len+3] = _crc.calc(&buf[2], len + 1); write(buf, len + 4); } + +void AlfredoCRSF::writeChannels(uint8_t addr, const crsf_channels_t *channels) +{ + writePacket(addr, CRSF_FRAMETYPE_RC_CHANNELS_PACKED, channels, sizeof(crsf_channels_t)); +} + +void AlfredoCRSF::writeChannels(uint8_t addr, const crsf_channels_t *channels, uint8_t status) +{ + // ELRS 4.0 extended channels frame: the packed channels followed by one + // status byte (CRSF_CHANNELS_STATUS_* bits) + uint8_t payload[sizeof(crsf_channels_t) + 1]; + memcpy(payload, channels, sizeof(crsf_channels_t)); + payload[sizeof(crsf_channels_t)] = status; + writePacket(addr, CRSF_FRAMETYPE_RC_CHANNELS_PACKED, payload, sizeof(payload)); +} diff --git a/src/AlfredoCRSF.h b/src/AlfredoCRSF.h index 06da56c..b0720bb 100644 --- a/src/AlfredoCRSF.h +++ b/src/AlfredoCRSF.h @@ -21,6 +21,16 @@ class AlfredoCRSF void queuePacket(uint8_t addr, uint8_t type, const void *payload, uint8_t len); void writePacket(uint8_t addr, uint8_t type, const void *payload, uint8_t len); + // Send a packed channels frame. addr is the leading byte: use CRSF_SYNC_BYTE + // when sending to a flight controller, CRSF_ADDRESS_CRSF_TRANSMITTER when + // sending to a TX module as a handset would. + void writeChannels(uint8_t addr, const crsf_channels_t *channels); + // ELRS 4.0+ TX modules only: also appends the channels status byte + // (CRSF_CHANNELS_STATUS_* bits) carrying the commanded arm state for Arm + // using Switch mode. ELRS 3.x modules do not understand the longer frame, + // so only use this against a 4.0+ module with Switch arming selected. + void writeChannels(uint8_t addr, const crsf_channels_t *channels, uint8_t status); + // Return current channel value (1-based) in us int getChannel(unsigned int ch) const { return _channels[ch - 1]; } const crsf_channels_t *getChannelsPacked() const { return &_channelsPacked;} From 799ad4b0ca69207ffb9a63d2836dcc6216d34098 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 21:55:14 -0400 Subject: [PATCH 08/18] Rework packet dispatch to be CRSF spec compliant Byte 0 of a CRSF frame is a sync byte, not a destination address, so dispatch is now purely type-based: channels and telemetry frames are decoded regardless of the leading byte, and extended header frames (0x28-0x96) are routed by the destination/origin bytes they carry. Adds crsf_ext_header_t and a device address to begin() (default flight controller) for extended frames addressed to a specific device. Behavior change from 1.x: frames that the old address-based dispatch dropped (e.g. telemetry with an unexpected leading byte) are now parsed. Wire compatible with both ELRS 3.x and 4.x. --- src/AlfredoCRSF.cpp | 66 +++++++++++++++++++++++++-------------------- src/AlfredoCRSF.h | 11 +++++--- src/crsf_protocol.h | 18 ++++++++++++- 3 files changed, 62 insertions(+), 33 deletions(-) diff --git a/src/AlfredoCRSF.cpp b/src/AlfredoCRSF.cpp index 8bef977..b9554a9 100644 --- a/src/AlfredoCRSF.cpp +++ b/src/AlfredoCRSF.cpp @@ -1,6 +1,7 @@ #include AlfredoCRSF::AlfredoCRSF() : + _deviceAddr(CRSF_ADDRESS_FLIGHT_CONTROLLER), _crc(0xd5), _lastReceive(0), _lastChannelsPacket(0), _linkIsUp(false), _hasChannelsStatus(false), _channelsStatus(0) @@ -8,9 +9,10 @@ AlfredoCRSF::AlfredoCRSF() : } -void AlfredoCRSF::begin(Stream &port) +void AlfredoCRSF::begin(Stream &port, uint8_t deviceAddr) { this->_port = &port; + this->_deviceAddr = deviceAddr; } // Call from main loop to update @@ -62,7 +64,7 @@ void AlfredoCRSF::handleByteReceived() uint8_t crc = _crc.calc(&_rxBuf[2], len - 1); if (crc == inCrc) { - processPacketIn(len); + processPacketIn(); shiftRxBuffer(len + 2); reprocess = true; } @@ -92,70 +94,76 @@ void AlfredoCRSF::checkLinkDown() } } -void AlfredoCRSF::processPacketIn(uint8_t len) +// Byte 0 of a CRSF frame is a sync byte, not routing information: standard +// frames (type below 0x28) have meaning purely by their type, and extended +// frames (0x28-0x96) carry their routing in destination/origin header bytes +void AlfredoCRSF::processPacketIn() { const crsf_header_t *hdr = (crsf_header_t *)_rxBuf; - if (hdr->device_addr == CRSF_ADDRESS_FLIGHT_CONTROLLER) //Rx to FC + if (CRSF_IS_EXT_FRAMETYPE(hdr->type)) { - if (!processTelemetryPacketIn(hdr) && hdr->type == CRSF_FRAMETYPE_RC_CHANNELS_PACKED) - { - packetChannelsPacked(hdr); - } + processExtendedPacketIn(hdr); } - else if (hdr->device_addr == CRSF_ADDRESS_CRSF_TRANSMITTER) //Headset to TX + else if (hdr->type == CRSF_FRAMETYPE_RC_CHANNELS_PACKED) { - if (hdr->type == CRSF_FRAMETYPE_RC_CHANNELS_PACKED) - { - packetChannelsPacked(hdr); - } + packetChannelsPacked(hdr); } - else if (hdr->device_addr == CRSF_ADDRESS_RADIO_TRANSMITTER) //Telemetry to TX (Backpack) + else { processTelemetryPacketIn(hdr); } } -// Handle telemetry frame types common to the FC and backpack directions. -// Returns true if the frame type was recognized and handled. -bool AlfredoCRSF::processTelemetryPacketIn(const crsf_header_t *hdr) +void AlfredoCRSF::processTelemetryPacketIn(const crsf_header_t *hdr) { switch (hdr->type) { case CRSF_FRAMETYPE_GPS: packetGps(hdr); - return true; + break; case CRSF_FRAMETYPE_GPS_TIME: packetGpsTime(hdr); - return true; + break; case CRSF_FRAMETYPE_LINK_STATISTICS: packetLinkStatistics(hdr); - return true; + break; case CRSF_FRAMETYPE_BARO_ALTITUDE: packetBaroAltitude(hdr); - return true; + break; case CRSF_FRAMETYPE_VARIO: packetVario(hdr); - return true; + break; case CRSF_FRAMETYPE_ATTITUDE: packetAttitude(hdr); - return true; + break; case CRSF_FRAMETYPE_AIRSPEED: packetAirspeed(hdr); - return true; + break; case CRSF_FRAMETYPE_RPM: packetRpm(hdr); - return true; + break; case CRSF_FRAMETYPE_TEMP: packetTemp(hdr); - return true; + break; case CRSF_FRAMETYPE_CELLS: packetCells(hdr); - return true; + break; + } +} + +// Extended header frames. Status-carrying frames are decoded regardless of +// their destination; frames that require a response are only acted on when +// addressed to this device (or broadcast) +void AlfredoCRSF::processExtendedPacketIn(const crsf_header_t *hdr) +{ + if (hdr->frame_size < CRSF_FRAME_LENGTH_EXT_TYPE_CRC) + return; + switch (hdr->type) + { case CRSF_FRAMETYPE_ELRS_STATUS: packetElrsStatus(hdr); - return true; + break; } - return false; } // Shift the bytes in the RxBuf down by cnt bytes diff --git a/src/AlfredoCRSF.h b/src/AlfredoCRSF.h index b0720bb..d2de652 100644 --- a/src/AlfredoCRSF.h +++ b/src/AlfredoCRSF.h @@ -14,7 +14,10 @@ class AlfredoCRSF static const unsigned int CRSF_FAILSAFE_STAGE1_MS = 300; AlfredoCRSF(); - void begin(Stream& port); + // deviceAddr is this device's own CRSF address, used for extended header + // frames that are addressed to a specific device (e.g. device discovery + // pings). Pass CRSF_ADDRESS_RADIO_TRANSMITTER when acting as a handset. + void begin(Stream& port, uint8_t deviceAddr = CRSF_ADDRESS_FLIGHT_CONTROLLER); void update(); void write(uint8_t b); void write(const uint8_t *buf, size_t len); @@ -57,6 +60,7 @@ class AlfredoCRSF private: Stream* _port; + uint8_t _deviceAddr; uint8_t _rxBuf[CRSF_MAX_PACKET_LEN+3]; uint8_t _rxBufPos; Crc8 _crc; @@ -83,12 +87,13 @@ class AlfredoCRSF void handleSerialIn(); void handleByteReceived(); void shiftRxBuffer(uint8_t cnt); - void processPacketIn(uint8_t len); + void processPacketIn(); void checkPacketTimeout(); void checkLinkDown(); // Packet RX Handlers - bool processTelemetryPacketIn(const crsf_header_t *p); + void processTelemetryPacketIn(const crsf_header_t *p); + void processExtendedPacketIn(const crsf_header_t *p); void packetChannelsPacked(const crsf_header_t *p); void packetLinkStatistics(const crsf_header_t *p); void packetGps(const crsf_header_t *p); diff --git a/src/crsf_protocol.h b/src/crsf_protocol.h index d2ed63c..e414f4a 100644 --- a/src/crsf_protocol.h +++ b/src/crsf_protocol.h @@ -108,12 +108,28 @@ typedef enum typedef struct crsf_header_s { - uint8_t device_addr; // from crsf_addr_e + uint8_t device_addr; // sync byte; 0xC8 on serial links (0xEE/0xEA on handset links). Not routing information uint8_t frame_size; // counts size after this byte, so it must be the payload size + 2 (type and crc) uint8_t type; // from crsf_frame_type_e uint8_t data[0]; } PACKED crsf_header_t; +// Extended header frames (type in the range 0x28 to 0x96) carry routing +// information: a destination and origin address before the payload +typedef struct crsf_ext_header_s +{ + uint8_t device_addr; // sync byte + uint8_t frame_size; // counts size after this byte, so it must be the payload size + 4 (type, dest, orig and crc) + uint8_t type; // from crsf_frame_type_e + uint8_t dest_addr; // from crsf_addr_e + uint8_t orig_addr; // from crsf_addr_e + uint8_t payload[0]; +} PACKED crsf_ext_header_t; + +#define CRSF_FRAMETYPE_EXT_FIRST 0x28 +#define CRSF_FRAMETYPE_EXT_LAST 0x96 +#define CRSF_IS_EXT_FRAMETYPE(t) ((t) >= CRSF_FRAMETYPE_EXT_FIRST && (t) <= CRSF_FRAMETYPE_EXT_LAST) + typedef struct crsf_channels_s { uint16_t ch0 : 11; From 46c2758b5bb72fd3cccde1caf4bf8ad480eebac7 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 21:57:21 -0400 Subject: [PATCH 09/18] Add CRSF router participation: heartbeat and device discovery Opt-in support for appearing as a device on an ELRS 4.0 CRSF network: sendHeartbeat() announces this device's address for router discovery, and setDeviceName() enables answering DEVICE_PING (0x28) with a DEVICE_INFO (0x29) response addressed to the requester. Adds writeExtPacket() for building extended header frames with this device's address as origin. All of it is inert unless the sketch opts in, and the frames themselves predate ELRS 4.0, so 3.x links are unaffected. --- README.md | 11 ++++++++-- src/AlfredoCRSF.cpp | 49 ++++++++++++++++++++++++++++++++++++++++++++- src/AlfredoCRSF.h | 16 +++++++++++++++ src/crsf_protocol.h | 5 +++-- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f7d0956..bb155b9 100644 --- a/README.md +++ b/README.md @@ -175,9 +175,16 @@ ELRS 4.0+ handsets (EdgeTX 2.11+) may append one status byte after the packed ch * char[]; //Flight mode ( Null-terminated string ) // Extended Header Frames, range: 0x28 to 0x96 ### CRSF_FRAMETYPE_DEVICE_PING = 0x28, -* ???? +Extended header frame (payload preceded by destination and origin address bytes). Device discovery request, usually sent to the broadcast address; each device answers with DEVICE_INFO. +* (no payload) ### CRSF_FRAMETYPE_DEVICE_INFO = 0x29, -* ???? +Extended header frame. Device discovery response. +* char name[]; // Device name (null-terminated string) +* uint32_t serialNo; // BigEndian +* uint32_t hardwareVer; // BigEndian +* uint32_t softwareVer; // BigEndian +* uint8_t fieldCnt; // number of configuration parameters this device has +* uint8_t parameterVersion; ### CRSF_FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, * ???? ### CRSF_FRAMETYPE_PARAMETER_READ = 0x2C, diff --git a/src/AlfredoCRSF.cpp b/src/AlfredoCRSF.cpp index b9554a9..f7d515f 100644 --- a/src/AlfredoCRSF.cpp +++ b/src/AlfredoCRSF.cpp @@ -1,7 +1,7 @@ #include AlfredoCRSF::AlfredoCRSF() : - _deviceAddr(CRSF_ADDRESS_FLIGHT_CONTROLLER), + _deviceAddr(CRSF_ADDRESS_FLIGHT_CONTROLLER), _deviceName(NULL), _crc(0xd5), _lastReceive(0), _lastChannelsPacket(0), _linkIsUp(false), _hasChannelsStatus(false), _channelsStatus(0) @@ -163,6 +163,13 @@ void AlfredoCRSF::processExtendedPacketIn(const crsf_header_t *hdr) case CRSF_FRAMETYPE_ELRS_STATUS: packetElrsStatus(hdr); break; + case CRSF_FRAMETYPE_DEVICE_PING: + { + const crsf_ext_header_t *ext = (const crsf_ext_header_t *)hdr; + if (_deviceName && (ext->dest_addr == _deviceAddr || ext->dest_addr == CRSF_ADDRESS_BROADCAST)) + sendDeviceInfo(ext->orig_addr); + break; + } } } @@ -421,3 +428,43 @@ void AlfredoCRSF::writeChannels(uint8_t addr, const crsf_channels_t *channels, u payload[sizeof(crsf_channels_t)] = status; writePacket(addr, CRSF_FRAMETYPE_RC_CHANNELS_PACKED, payload, sizeof(payload)); } + +void AlfredoCRSF::writeExtPacket(uint8_t type, uint8_t destAddr, const void *payload, uint8_t len) +{ + if (len > CRSF_MAX_PACKET_LEN - 2) + return; + uint8_t buf[CRSF_MAX_PACKET_LEN]; + buf[0] = destAddr; + buf[1] = _deviceAddr; + memcpy(&buf[2], payload, len); + writePacket(CRSF_SYNC_BYTE, type, buf, len + 2); +} + +void AlfredoCRSF::sendHeartbeat() +{ + // Payload is the origin device address as a big endian int16 + uint8_t payload[2] = { 0, _deviceAddr }; + writePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_HEARTBEAT, payload, sizeof(payload)); +} + +void AlfredoCRSF::setDeviceName(const char *name) +{ + _deviceName = name; +} + +// DEVICE_INFO payload: null-terminated device name, then serial number, +// hardware and software version (uint32 big endian), field count and +// parameter version. We report no configuration fields. +void AlfredoCRSF::sendDeviceInfo(uint8_t destAddr) +{ + uint8_t payload[CRSF_DEVICE_NAME_MAX + 1 + 14]; + uint8_t nameLen = 0; + while (_deviceName[nameLen] && nameLen < CRSF_DEVICE_NAME_MAX) + { + payload[nameLen] = _deviceName[nameLen]; + nameLen++; + } + payload[nameLen++] = '\0'; + memset(&payload[nameLen], 0, 14); + writeExtPacket(CRSF_FRAMETYPE_DEVICE_INFO, destAddr, payload, nameLen + 14); +} diff --git a/src/AlfredoCRSF.h b/src/AlfredoCRSF.h index d2de652..3dc4958 100644 --- a/src/AlfredoCRSF.h +++ b/src/AlfredoCRSF.h @@ -34,6 +34,19 @@ class AlfredoCRSF // so only use this against a 4.0+ module with Switch arming selected. void writeChannels(uint8_t addr, const crsf_channels_t *channels, uint8_t status); + // Send an extended header frame (type 0x28-0x96) from this device's + // address to destAddr. payload/len exclude the dest/origin bytes. + void writeExtPacket(uint8_t type, uint8_t destAddr, const void *payload, uint8_t len); + + // Announce this device to the CRSF router for device discovery. + // Call periodically (e.g. once per second); optional. + void sendHeartbeat(); + + // Respond to CRSF device discovery pings with the given device name, so + // this device shows up to the router and configuration tools. The string + // is not copied and must remain valid. Pass NULL to disable (default). + void setDeviceName(const char *name); + // Return current channel value (1-based) in us int getChannel(unsigned int ch) const { return _channels[ch - 1]; } const crsf_channels_t *getChannelsPacked() const { return &_channelsPacked;} @@ -61,6 +74,7 @@ class AlfredoCRSF private: Stream* _port; uint8_t _deviceAddr; + const char *_deviceName; uint8_t _rxBuf[CRSF_MAX_PACKET_LEN+3]; uint8_t _rxBufPos; Crc8 _crc; @@ -106,4 +120,6 @@ class AlfredoCRSF void packetTemp(const crsf_header_t *p); void packetCells(const crsf_header_t *p); void packetElrsStatus(const crsf_header_t *p); + + void sendDeviceInfo(uint8_t destAddr); }; diff --git a/src/crsf_protocol.h b/src/crsf_protocol.h index e414f4a..6e0ee49 100644 --- a/src/crsf_protocol.h +++ b/src/crsf_protocol.h @@ -27,6 +27,7 @@ #define CRSF_MAX_TEMP_VALUES 20 #define CRSF_MAX_CELL_VALUES 29 #define CRSF_ELRS_STATUS_MSG_LEN 56 +#define CRSF_DEVICE_NAME_MAX 32 // Flag bits in the ELRS_STATUS flags field #define CRSF_ELRS_FLAG_CONNECTED 0x01 // status: TX connected to an RX @@ -70,8 +71,8 @@ typedef enum CRSF_FRAMETYPE_ATTITUDE = 0x1E, // CRSF_FRAMETYPE_FLIGHT_MODE = 0x21, //no need to support? // Extended Header Frames, range: 0x28 to 0x96 - // CRSF_FRAMETYPE_DEVICE_PING = 0x28, //no "flight controller" needs to know about this - // CRSF_FRAMETYPE_DEVICE_INFO = 0x29, //no "flight controller" needs to know about this + CRSF_FRAMETYPE_DEVICE_PING = 0x28, //device discovery request (extended header frame) + CRSF_FRAMETYPE_DEVICE_INFO = 0x29, //device discovery response (extended header frame) // CRSF_FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, //no "flight controller" needs to know about this // CRSF_FRAMETYPE_PARAMETER_READ = 0x2C, //no "flight controller" needs to know about this // CRSF_FRAMETYPE_PARAMETER_WRITE = 0x2D, //no "flight controller" needs to know about this From 0b1ddb5437df35e06319b1afcf74817a9d89b568 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 21:59:48 -0400 Subject: [PATCH 10/18] Add parsing for HANDSET (0x3A) timing sync frames TX modules send this extended-header frame to tell the handset the requested channels packet interval and a phase offset correction (both in 0.1us units). Decoded into crsf_handset_timing_t via getHandsetTiming() so handset-emulation sketches can pace their channels frames. The frame was named RADIO_ID in older firmwares with the same wire format, so this works with both ELRS 3.x and 4.x modules. --- README.md | 12 ++++++------ src/AlfredoCRSF.cpp | 19 +++++++++++++++++++ src/AlfredoCRSF.h | 4 ++++ src/crsf_protocol.h | 13 ++++++++++++- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bb155b9..3cc027f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Overall packet length is PayloadLength+4 (dest, len, type, crc), or LEN+2 (dest, * CRSF_FRAMETYPE_CELLS = 0x0E, * CRSF_FRAMETYPE_LINK_STATISTICS = 0x14, * CRSF_FRAMETYPE_OPENTX_SYNC = 0x10, -* CRSF_FRAMETYPE_RADIO_ID = 0x3A, +* CRSF_FRAMETYPE_HANDSET = 0x3A, // named RADIO_ID in older firmwares * CRSF_FRAMETYPE_RC_CHANNELS_PACKED = 0x16, * CRSF_FRAMETYPE_LINK_RX_ID = 0x1C, * CRSF_FRAMETYPE_LINK_TX_ID = 0x1D, @@ -134,11 +134,11 @@ Variable length, count of values determined by frame length. ELRS 4.0+ receivers * int8_t downlink_SNR; ### CRSF_FRAMETYPE_OPENTX_SYNC = 0x10 * ???? -### CRSF_FRAMETYPE_RADIO_ID = 0x3A -* uint16_t radioAddress; //should be 0xEA00? -* uint8_t timingCorrectionFrame; //should be 0x10? -* uint32_t update_interval; //what is this? -* int32_t offset; //what is this? +### CRSF_FRAMETYPE_HANDSET = 0x3A (named RADIO_ID in older firmwares) +Extended header frame (payload preceded by destination and origin address bytes). Sent by a TX module to the handset. The first payload byte is a subcommand; subcommand 0x10 is timing sync: +* uint8_t subCommand; // 0x10 = timing sync +* uint32_t rate; // requested channels packet interval in 0.1us units, BigEndian +* int32_t offset; // timing offset correction in 0.1us units, BigEndian ### CRSF_FRAMETYPE_RC_CHANNELS_PACKED = 0x16 * unsigned ch0 : 11; * unsigned ch1 : 11; diff --git a/src/AlfredoCRSF.cpp b/src/AlfredoCRSF.cpp index f7d515f..981506a 100644 --- a/src/AlfredoCRSF.cpp +++ b/src/AlfredoCRSF.cpp @@ -163,6 +163,9 @@ void AlfredoCRSF::processExtendedPacketIn(const crsf_header_t *hdr) case CRSF_FRAMETYPE_ELRS_STATUS: packetElrsStatus(hdr); break; + case CRSF_FRAMETYPE_HANDSET: + packetHandsetTiming(hdr); + break; case CRSF_FRAMETYPE_DEVICE_PING: { const crsf_ext_header_t *ext = (const crsf_ext_header_t *)hdr; @@ -360,6 +363,22 @@ void AlfredoCRSF::packetElrsStatus(const crsf_header_t *p) _elrsStatus.msg[msgLen] = '\0'; } +// HANDSET is an extended header frame; the payload is a subcommand byte +// followed by subcommand-specific data +void AlfredoCRSF::packetHandsetTiming(const crsf_header_t *p) +{ + uint8_t payloadLen = p->frame_size - CRSF_FRAME_LENGTH_EXT_TYPE_CRC; + if (payloadLen < 9) + return; + const uint8_t *payload = &p->data[2]; // skip extended dest/origin + if (payload[0] != CRSF_HANDSET_SUBCMD_TIMING) + return; + _handsetTiming.rate = ((uint32_t)payload[1] << 24) | ((uint32_t)payload[2] << 16) | + ((uint32_t)payload[3] << 8) | payload[4]; + _handsetTiming.offset = (int32_t)(((uint32_t)payload[5] << 24) | ((uint32_t)payload[6] << 16) | + ((uint32_t)payload[7] << 8) | payload[8]); +} + void AlfredoCRSF::packetCells(const crsf_header_t *p) { uint8_t payloadLen = p->frame_size - CRSF_FRAME_LENGTH_TYPE_CRC; diff --git a/src/AlfredoCRSF.h b/src/AlfredoCRSF.h index 3dc4958..d1cda0b 100644 --- a/src/AlfredoCRSF.h +++ b/src/AlfredoCRSF.h @@ -61,6 +61,8 @@ class AlfredoCRSF const crsf_sensor_temp_t *getTempSensor() const { return &_tempSensor; } const crsf_sensor_cells_t *getCellsSensor() const { return &_cellsSensor; } const crsf_elrs_status_t *getElrsStatus() const { return &_elrsStatus; } + // TX module's requested channels frame rate/phase (for handset emulation) + const crsf_handset_timing_t *getHandsetTiming() const { return &_handsetTiming; } bool isLinkUp() const { return _linkIsUp; } // ELRS 4.0+ (EdgeTX 2.11+) appends an optional status byte to channels @@ -90,6 +92,7 @@ class AlfredoCRSF crsf_sensor_temp_t _tempSensor; crsf_sensor_cells_t _cellsSensor; crsf_elrs_status_t _elrsStatus; + crsf_handset_timing_t _handsetTiming; uint32_t _baud; uint32_t _lastReceive; uint32_t _lastChannelsPacket; @@ -120,6 +123,7 @@ class AlfredoCRSF void packetTemp(const crsf_header_t *p); void packetCells(const crsf_header_t *p); void packetElrsStatus(const crsf_header_t *p); + void packetHandsetTiming(const crsf_header_t *p); void sendDeviceInfo(uint8_t destAddr); }; diff --git a/src/crsf_protocol.h b/src/crsf_protocol.h index 6e0ee49..0ea70e1 100644 --- a/src/crsf_protocol.h +++ b/src/crsf_protocol.h @@ -29,6 +29,9 @@ #define CRSF_ELRS_STATUS_MSG_LEN 56 #define CRSF_DEVICE_NAME_MAX 32 +// Subcommand in the first payload byte of a HANDSET (0x3A) frame +#define CRSF_HANDSET_SUBCMD_TIMING 0x10 + // Flag bits in the ELRS_STATUS flags field #define CRSF_ELRS_FLAG_CONNECTED 0x01 // status: TX connected to an RX #define CRSF_ELRS_FLAG_MODEL_MATCH_WARN 0x04 // warning: model mismatch @@ -64,7 +67,6 @@ typedef enum //CRSF_FRAMETYPE_VIDEO_TRANSMITTER = 0x0F, //no need to support? (rev07) CRSF_FRAMETYPE_LINK_STATISTICS = 0x14, // CRSF_FRAMETYPE_OPENTX_SYNC = 0x10, //not in edgeTX - // CRSF_FRAMETYPE_RADIO_ID = 0x3A, //no need to support? CRSF_FRAMETYPE_RC_CHANNELS_PACKED = 0x16, // CRSF_FRAMETYPE_LINK_RX_ID = 0x1C, //no need to support? // CRSF_FRAMETYPE_LINK_TX_ID = 0x1D, //no need to support? @@ -78,6 +80,7 @@ typedef enum // CRSF_FRAMETYPE_PARAMETER_WRITE = 0x2D, //no "flight controller" needs to know about this CRSF_FRAMETYPE_ELRS_STATUS = 0x2E, //ELRS good/bad packet count and status flags (extended header frame) // CRSF_FRAMETYPE_COMMAND = 0x32, //no "flight controller" needs to know about this + CRSF_FRAMETYPE_HANDSET = 0x3A, //handset subcommands e.g. timing sync (extended header frame; named RADIO_ID in older firmwares) // KISS frames // CRSF_FRAMETYPE_KISS_REQ = 0x78, //not in edgeTX // CRSF_FRAMETYPE_KISS_RESP = 0x79, //not in edgeTX @@ -241,6 +244,14 @@ typedef struct crsf_sensor_baro_altitude_s } PACKED crsf_sensor_baro_altitude_t; +// Decoded form of the HANDSET (0x3A) timing subcommand, sent by a TX module +// to tell the handset the desired channels frame rate and phase +typedef struct crsf_handset_timing_s +{ + uint32_t rate; // requested channels packet interval, 0.1us units + int32_t offset; // timing offset correction, 0.1us units +} crsf_handset_timing_t; + // Decoded form of the ELRS_STATUS frame (extended header, TX module to // handset). On the wire the payload is pktsBad, pktsGood (big endian), // flags, then a variable-length null-terminated message string. From 0e9d8017f9fde679be3f78bdcd216f61cd7912ab Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 22:00:23 -0400 Subject: [PATCH 11/18] Bump version to 2.0.0 The spec-compliant dispatch rework changes observable parsing behavior for multi-instance bridge sketches (frames previously dropped by address-based dispatch are now parsed), so this line is a major version. --- library.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library.properties b/library.properties index 0713f59..a9880d7 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=AlfredoCRSF -version=1.0.1 +version=2.0.0 author=Jacob Williams maintainer=Jacob Williams (jrw4561@gmail.com) sentence=CSRF serial protocol Arduino library From 1b58ebacba2d1b46c38253a083b2f889a748fbb5 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Tue, 14 Jul 2026 23:00:20 -0400 Subject: [PATCH 12/18] Add test examples for the ELRS 4.0 features elrs4SelfTest: loopback functional test needing no radio hardware - two cross-wired UARTs on one board run a handset instance against a TX module instance, exercising channels round-trip, the arm status byte and isArmed logic, variable-length sensor decoding (cells/rpm/temp incl. 24-bit sign extension), GPS time, ELRS_STATUS, handset timing sync, device discovery ping/response, and heartbeat, printing PASS/FAIL per check. handsetEmulator: drives a real TX module - paces channels frames from the module's timing sync, optional 4.0 arm status byte, prints ELRS status. elrs4ReceiverTest: FC-side bench test for a 4.x receiver - prints channels, link stats, and the new sensors (millivolt cells, temps, rpm, airspeed, GPS time), sends heartbeats and answers discovery pings so the sketch appears in the ExpressLRS Lua. --- .../elrs4ReceiverTest/elrs4ReceiverTest.ino | 137 +++++++++++ examples/elrs4SelfTest/elrs4SelfTest.ino | 218 ++++++++++++++++++ examples/handsetEmulator/handsetEmulator.ino | 121 ++++++++++ 3 files changed, 476 insertions(+) create mode 100644 examples/elrs4ReceiverTest/elrs4ReceiverTest.ino create mode 100644 examples/elrs4SelfTest/elrs4SelfTest.ino create mode 100644 examples/handsetEmulator/handsetEmulator.ino diff --git a/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino b/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino new file mode 100644 index 0000000..e0ccda9 --- /dev/null +++ b/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino @@ -0,0 +1,137 @@ +// Bench test for an ELRS 4.x receiver connected as a flight controller would +// be. Prints channels, link stats, and all the newer telemetry sensors the +// receiver can produce, and joins the CRSF network as a discoverable device: +// with a 4.0 TX/RX pair this sketch should show up in the ExpressLRS Lua +// under Other Devices, and a VBAT-sensing receiver should produce a CELLS +// frame (source id 128) with millivolt precision. +// +// Also works against a 3.x receiver: the new sensors simply stay at zero and +// the discovery traffic is ignored. + +#include +#include + +#define PIN_RX 7 +#define PIN_TX 8 + +HardwareSerial crsfSerial(1); +AlfredoCRSF crsf; + +uint32_t lastHeartbeatMs = 0; +uint32_t lastPrintMs = 0; + +void setup() +{ + Serial.begin(115200); + Serial.println("ELRS 4.x receiver test"); + + crsfSerial.begin(CRSF_BAUDRATE, SERIAL_8N1, PIN_RX, PIN_TX); + if (!crsfSerial) while (1) Serial.println("Invalid crsfSerial configuration"); + + crsf.begin(crsfSerial); // defaults to CRSF_ADDRESS_FLIGHT_CONTROLLER + crsf.setDeviceName("AlfredoCRSF"); // answer device discovery pings +} + +void loop() +{ + crsf.update(); + + // Announce ourselves to the CRSF router once per second + if (millis() - lastHeartbeatMs > 1000) + { + lastHeartbeatMs = millis(); + crsf.sendHeartbeat(); + } + + if (millis() - lastPrintMs > 1000) + { + lastPrintMs = millis(); + printEverything(); + } +} + +void printEverything() +{ + Serial.println("----------------------------------------"); + Serial.print("link: "); + Serial.print(crsf.isLinkUp() ? "UP" : "DOWN"); + const crsfLinkStatistics_t *link = crsf.getLinkStatistics(); + Serial.print(" LQ: "); + Serial.print(link->uplink_Link_quality); + Serial.print(" RSSI: -"); + Serial.print(link->active_antenna == 0 ? link->uplink_RSSI_1 : link->uplink_RSSI_2); + Serial.print("dBm armed: "); + Serial.println(crsf.isArmed() ? "yes" : "no"); + + Serial.print("channels 1-8:"); + for (int i = 1; i <= 8; i++) + { + Serial.print(" "); + Serial.print(crsf.getChannel(i)); + } + Serial.println(""); + + // ELRS 4.0: VBAT receivers send millivolt-precision voltage as CELLS + const crsf_sensor_cells_t *cells = crsf.getCellsSensor(); + if (cells->cell_count > 0) + { + Serial.print("cells (source "); + Serial.print(cells->source_id); + Serial.print("):"); + for (int i = 0; i < cells->cell_count; i++) + { + Serial.print(" "); + Serial.print(cells->cell[i]); + Serial.print("mV"); + } + Serial.println(""); + } + + const crsf_sensor_temp_t *temp = crsf.getTempSensor(); + if (temp->temp_count > 0) + { + Serial.print("temps (deci-C):"); + for (int i = 0; i < temp->temp_count; i++) + { + Serial.print(" "); + Serial.print(temp->temperature[i]); + } + Serial.println(""); + } + + const crsf_sensor_rpm_t *rpm = crsf.getRpmSensor(); + if (rpm->rpm_count > 0) + { + Serial.print("rpm:"); + for (int i = 0; i < rpm->rpm_count; i++) + { + Serial.print(" "); + Serial.print(rpm->rpm[i]); + } + Serial.println(""); + } + + if (crsf.getAirspeedSensor()->speed != 0) + { + Serial.print("airspeed: "); + Serial.print(crsf.getAirspeedSensor()->speed / 10.0); + Serial.println(" km/h"); + } + + const crsf_sensor_gps_time_t *gpsTime = crsf.getGpsTimeSensor(); + if (gpsTime->year != 0) + { + Serial.print("gps time: "); + Serial.print(gpsTime->year); + Serial.print("-"); + Serial.print(gpsTime->month); + Serial.print("-"); + Serial.print(gpsTime->day); + Serial.print(" "); + Serial.print(gpsTime->hour); + Serial.print(":"); + Serial.print(gpsTime->minute); + Serial.print(":"); + Serial.println(gpsTime->second); + } +} diff --git a/examples/elrs4SelfTest/elrs4SelfTest.ino b/examples/elrs4SelfTest/elrs4SelfTest.ino new file mode 100644 index 0000000..207828f --- /dev/null +++ b/examples/elrs4SelfTest/elrs4SelfTest.ino @@ -0,0 +1,218 @@ +// AlfredoCRSF 2.0 / ELRS 4.0 feature self-test. No radio hardware needed: +// two UARTs on one ESP32 are cross-wired and two AlfredoCRSF instances talk +// to each other, one playing the handset and one playing a TX module. +// +// Wiring (two jumpers): +// PIN_TX_HANDSET (19) -> PIN_RX_MODULE (25) +// PIN_TX_MODULE (26) -> PIN_RX_HANDSET (18) +// +// Open the serial monitor at 115200; each check prints PASS or FAIL. + +#include +#include + +#define PIN_RX_HANDSET 18 +#define PIN_TX_HANDSET 19 +#define PIN_RX_MODULE 25 +#define PIN_TX_MODULE 26 + +HardwareSerial serialHandset(1); +HardwareSerial serialModule(2); +AlfredoCRSF crsfHandset; +AlfredoCRSF crsfModule; + +int passCount = 0; +int failCount = 0; + +void setup() +{ + Serial.begin(115200); + delay(1000); + Serial.println("AlfredoCRSF ELRS 4.0 self-test"); + + serialHandset.begin(CRSF_BAUDRATE, SERIAL_8N1, PIN_RX_HANDSET, PIN_TX_HANDSET); + serialModule.begin(CRSF_BAUDRATE, SERIAL_8N1, PIN_RX_MODULE, PIN_TX_MODULE); + + crsfHandset.begin(serialHandset, CRSF_ADDRESS_RADIO_TRANSMITTER); + crsfModule.begin(serialModule, CRSF_ADDRESS_CRSF_TRANSMITTER); + crsfModule.setDeviceName("SelfTestModule"); + + runTests(); + + Serial.println(""); + Serial.print("Result: "); + Serial.print(passCount); + Serial.print(" passed, "); + Serial.print(failCount); + Serial.println(" failed"); +} + +void loop() +{ +} + +void runTests() +{ + crsf_channels_t ch = { 0 }; + ch.ch0 = CRSF_CHANNEL_VALUE_1000; // getChannel(1) -> 1000 + ch.ch1 = CRSF_CHANNEL_VALUE_MID; // getChannel(2) -> 1500 + ch.ch2 = CRSF_CHANNEL_VALUE_2000; // getChannel(3) -> 2000 + ch.ch4 = CRSF_CHANNEL_VALUE_1000; // CH5 (arm channel) low + + // --- Plain channels frame (ELRS 3.x compatible, no status byte) --- + crsfHandset.writeChannels(CRSF_ADDRESS_CRSF_TRANSMITTER, &ch); + pump(20); + check("channels: ch1=1000", crsfModule.getChannel(1) == 1000); + check("channels: ch2=1500", crsfModule.getChannel(2) == 1500); + check("channels: ch3=2000", crsfModule.getChannel(3) == 2000); + check("channels: link up", crsfModule.isLinkUp()); + check("channels: no status byte", !crsfModule.hasChannelsStatus()); + check("channels: not armed (CH5 low)", !crsfModule.isArmed()); + + // --- ELRS 4.0 status byte, Arm using Switch mode --- + crsfHandset.writeChannels(CRSF_ADDRESS_CRSF_TRANSMITTER, &ch, CRSF_CHANNELS_STATUS_ARMED); + pump(20); + check("status byte: detected", crsfModule.hasChannelsStatus()); + check("status byte: armed via switch", crsfModule.isArmed()); + + // --- Status byte with the CH5-mode bit: CH5 value wins over the armed bit --- + crsfHandset.writeChannels(CRSF_ADDRESS_CRSF_TRANSMITTER, &ch, + CRSF_CHANNELS_STATUS_ARMED | CRSF_CHANNELS_STATUS_ARMING_MODE_CH5); + pump(20); + check("CH5 mode: not armed while CH5 low", !crsfModule.isArmed()); + ch.ch4 = CRSF_CHANNEL_VALUE_2000; + crsfHandset.writeChannels(CRSF_ADDRESS_CRSF_TRANSMITTER, &ch, + CRSF_CHANNELS_STATUS_ARMING_MODE_CH5); + pump(20); + check("CH5 mode: armed while CH5 high", crsfModule.isArmed()); + + // --- Back to a plain frame: status byte state must clear --- + crsfHandset.writeChannels(CRSF_ADDRESS_CRSF_TRANSMITTER, &ch); + pump(20); + check("status byte: cleared by plain frame", !crsfModule.hasChannelsStatus()); + + // --- GPS time telemetry --- + crsf_sensor_gps_time_t gpsTime = { 0 }; + gpsTime.year = htobe16(2026); + gpsTime.month = 7; + gpsTime.day = 14; + gpsTime.hour = 12; + gpsTime.minute = 34; + gpsTime.second = 56; + gpsTime.millisecond = htobe16(789); + crsfHandset.writePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_GPS_TIME, &gpsTime, sizeof(gpsTime)); + pump(20); + check("gps time: year", crsfModule.getGpsTimeSensor()->year == 2026); + check("gps time: millisecond", crsfModule.getGpsTimeSensor()->millisecond == 789); + + // --- Cells telemetry (variable length, millivolt cell voltages) --- + uint8_t cellsPayload[] = { 128, 0x0F, 0x0A, 0x0F, 0x14 }; // source 128, 3850mV, 3860mV + crsfHandset.writePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_CELLS, cellsPayload, sizeof(cellsPayload)); + pump(20); + check("cells: source id", crsfModule.getCellsSensor()->source_id == 128); + check("cells: count", crsfModule.getCellsSensor()->cell_count == 2); + check("cells: values", crsfModule.getCellsSensor()->cell[0] == 3850 && + crsfModule.getCellsSensor()->cell[1] == 3860); + + // --- RPM telemetry (24-bit signed values, test sign extension) --- + uint8_t rpmPayload[] = { 3, 0xFF, 0xFE, 0x0C }; // source 3, one value: -500 + crsfHandset.writePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_RPM, rpmPayload, sizeof(rpmPayload)); + pump(20); + check("rpm: count", crsfModule.getRpmSensor()->rpm_count == 1); + check("rpm: negative value", crsfModule.getRpmSensor()->rpm[0] == -500); + + // --- Temperature telemetry --- + uint8_t tempPayload[] = { 1, 0x00, 0xFA, 0xFF, 0xCE }; // source 1, 25.0C, -5.0C + crsfHandset.writePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_TEMP, tempPayload, sizeof(tempPayload)); + pump(20); + check("temp: values", crsfModule.getTempSensor()->temperature[0] == 250 && + crsfModule.getTempSensor()->temperature[1] == -50); + + // --- ELRS_STATUS from the module to the handset (extended header frame) --- + uint8_t statusPayload[] = { 1, 0x01, 0x02, CRSF_ELRS_FLAG_CONNECTED, 'O', 'K', '\0' }; + crsfModule.writeExtPacket(CRSF_FRAMETYPE_ELRS_STATUS, CRSF_ADDRESS_RADIO_TRANSMITTER, + statusPayload, sizeof(statusPayload)); + pump(20); + check("elrs status: packet counts", crsfHandset.getElrsStatus()->pktsBad == 1 && + crsfHandset.getElrsStatus()->pktsGood == 258); + check("elrs status: flags", crsfHandset.getElrsStatus()->flags == CRSF_ELRS_FLAG_CONNECTED); + check("elrs status: message", strcmp(crsfHandset.getElrsStatus()->msg, "OK") == 0); + + // --- HANDSET timing sync from the module (extended header frame) --- + uint8_t timingPayload[] = { CRSF_HANDSET_SUBCMD_TIMING, + 0x00, 0x00, 0x9C, 0x40, // rate: 40000 (4ms in 0.1us units) + 0xFF, 0xFF, 0xFF, 0x9C }; // offset: -100 + crsfModule.writeExtPacket(CRSF_FRAMETYPE_HANDSET, CRSF_ADDRESS_RADIO_TRANSMITTER, + timingPayload, sizeof(timingPayload)); + pump(20); + check("handset timing: rate", crsfHandset.getHandsetTiming()->rate == 40000); + check("handset timing: offset", crsfHandset.getHandsetTiming()->offset == -100); + + // --- Device discovery: ping the module, expect a DEVICE_INFO response. + // The response is checked as raw bytes since the handset side has no + // DEVICE_INFO parser (yet) --- + drain(serialHandset); + uint8_t none = 0; + crsfHandset.writeExtPacket(CRSF_FRAMETYPE_DEVICE_PING, CRSF_ADDRESS_BROADCAST, &none, 0); + pumpOnly(crsfModule, 20); + check("device ping: DEVICE_INFO response", sawFrameType(serialHandset, CRSF_FRAMETYPE_DEVICE_INFO)); + + // --- Heartbeat (raw check on the wire) --- + drain(serialHandset); + crsfModule.sendHeartbeat(); + delay(20); + check("heartbeat: frame on the wire", sawFrameType(serialHandset, CRSF_FRAMETYPE_HEARTBEAT)); +} + +// Run both parsers for a while so frames propagate +void pump(uint32_t ms) +{ + uint32_t start = millis(); + while (millis() - start < ms) + { + crsfHandset.update(); + crsfModule.update(); + delay(1); + } +} + +// Run only one parser (so the other side's RX bytes stay in the buffer for raw checks) +void pumpOnly(AlfredoCRSF &crsf, uint32_t ms) +{ + uint32_t start = millis(); + while (millis() - start < ms) + { + crsf.update(); + delay(1); + } +} + +void drain(Stream &port) +{ + while (port.available()) + port.read(); +} + +// Scan raw bytes on a port for a frame of the given type (sync byte two +// bytes before the type byte) +bool sawFrameType(Stream &port, uint8_t type) +{ + uint8_t buf[128]; + size_t n = 0; + while (port.available() && n < sizeof(buf)) + buf[n++] = port.read(); + for (size_t i = 2; i < n; i++) + { + if (buf[i] == type && buf[i - 2] == CRSF_SYNC_BYTE) + return true; + } + return false; +} + +void check(const char *name, bool ok) +{ + Serial.print(ok ? "PASS: " : "FAIL: "); + Serial.println(name); + if (ok) passCount++; + else failCount++; +} diff --git a/examples/handsetEmulator/handsetEmulator.ino b/examples/handsetEmulator/handsetEmulator.ino new file mode 100644 index 0000000..9786c34 --- /dev/null +++ b/examples/handsetEmulator/handsetEmulator.ino @@ -0,0 +1,121 @@ +// Drives a real ELRS TX module the way a handset would: sends paced channels +// frames, follows the module's requested frame rate, and prints the module's +// ELRS status. Demonstrates the ELRS 4.0 "Arm using Switch" status byte. +// +// Wiring: ELRS TX modules use a single half-duplex data line (the S.Port-style +// pin in the module bay), non-inverted for ELRS. For a bench setup connect the +// module's data pin to PIN_RX directly and to PIN_TX through a ~1k resistor. +// Most modules auto-detect the handset baud rate; 400000 is the common default. +// +// Set ARM_WITH_STATUS_BYTE to 1 only with an ELRS 4.0+ module configured for +// "Arm using Switch" - 3.x modules do not understand the longer channels frame. + +#include +#include + +#define PIN_RX 18 +#define PIN_TX 19 +#define HANDSET_BAUD 400000 + +#define ARM_WITH_STATUS_BYTE 1 + +HardwareSerial crsfSerial(1); +AlfredoCRSF crsf; + +uint32_t lastChannelsMicros = 0; +uint32_t lastPrintMs = 0; +uint32_t lastArmToggleMs = 0; +bool armed = false; + +void setup() +{ + Serial.begin(115200); + Serial.println("ELRS handset emulator"); + + crsfSerial.begin(HANDSET_BAUD, SERIAL_8N1, PIN_RX, PIN_TX); + if (!crsfSerial) while (1) Serial.println("Invalid crsfSerial configuration"); + + // We are the handset, so extended frames addressed to the radio are for us + crsf.begin(crsfSerial, CRSF_ADDRESS_RADIO_TRANSMITTER); +} + +void loop() +{ + crsf.update(); + + // Toggle the demo arm state every 5 seconds + if (millis() - lastArmToggleMs > 5000) + { + lastArmToggleMs = millis(); + armed = !armed; + Serial.print("Commanded arm state: "); + Serial.println(armed ? "ARMED" : "disarmed"); + } + + // Pace channels frames at the rate the module asks for via its timing sync + // frames (0.1us units); default to 4ms until one has been received + uint32_t intervalUs = 4000; + if (crsf.getHandsetTiming()->rate != 0) + intervalUs = crsf.getHandsetTiming()->rate / 10; + if (micros() - lastChannelsMicros >= intervalUs) + { + lastChannelsMicros = micros(); + sendChannels(); + } + + // Once per second, print what the module reports + if (millis() - lastPrintMs > 1000) + { + lastPrintMs = millis(); + printModuleStatus(); + } +} + +void sendChannels() +{ + crsf_channels_t ch = { 0 }; + ch.ch0 = CRSF_CHANNEL_VALUE_MID; // aileron center + ch.ch1 = CRSF_CHANNEL_VALUE_MID; // elevator center + ch.ch2 = CRSF_CHANNEL_VALUE_1000; // throttle low + ch.ch3 = CRSF_CHANNEL_VALUE_MID; // rudder center + ch.ch4 = armed ? CRSF_CHANNEL_VALUE_2000 : CRSF_CHANNEL_VALUE_1000; // CH5/AUX1 + ch.ch5 = CRSF_CHANNEL_VALUE_1000; + ch.ch6 = CRSF_CHANNEL_VALUE_1000; + ch.ch7 = CRSF_CHANNEL_VALUE_1000; + +#if ARM_WITH_STATUS_BYTE + // ELRS 4.0 Arm using Switch: arm state travels in the status byte + crsf.writeChannels(CRSF_ADDRESS_CRSF_TRANSMITTER, &ch, + armed ? CRSF_CHANNELS_STATUS_ARMED : 0); +#else + // Classic (ELRS 3.x compatible): arm state is just the CH5 value + crsf.writeChannels(CRSF_ADDRESS_CRSF_TRANSMITTER, &ch); +#endif +} + +void printModuleStatus() +{ + const crsf_elrs_status_t *status = crsf.getElrsStatus(); + Serial.print("pktsGood: "); + Serial.print(status->pktsGood); + Serial.print(" pktsBad: "); + Serial.print(status->pktsBad); + Serial.print(" connected: "); + Serial.print((status->flags & CRSF_ELRS_FLAG_CONNECTED) ? "yes" : "no"); + Serial.print(" armed flag: "); + Serial.print((status->flags & CRSF_ELRS_FLAG_ARMED) ? "yes" : "no"); + if (status->msg[0]) + { + Serial.print(" msg: "); + Serial.print(status->msg); + } + Serial.print(" frame interval: "); + Serial.print(crsf.getHandsetTiming()->rate / 10); + Serial.println("us"); + + const crsfLinkStatistics_t *link = crsf.getLinkStatistics(); + Serial.print(" downlink LQ: "); + Serial.print(link->downlink_Link_quality); + Serial.print(" uplink LQ: "); + Serial.println(link->uplink_Link_quality); +} From 6a3bc72dddfda5298b12c12db7c573da53359787 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Mon, 20 Jul 2026 19:00:39 -0400 Subject: [PATCH 13/18] Fix GPS heading scale in the telemetry example Heading is degrees * 100 on the wire (EdgeTX parses it with 2 decimal places), but the example multiplied by 1000. That made every reading ten times too large and overflowed the uint16 field above 65.5 degrees, which is the 'overflow issue in EdgeTX' the README noted: the bug was ours, not EdgeTX's. 0-360 degrees now maps to 0-36000 and fits comfortably. --- README.md | 1 - examples/elrs4ReceiverTest/elrs4ReceiverTest.ino | 4 ++-- .../sendTelemetryGpsBaroVarioAttitude.ino | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3cc027f..4860487 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ TODO: * For now callbacks have been removed. May add them back or replace with a flag system to alert when packets come in. * Improve battery telemetry example by using all 24 capacity bits. (currently just 16 bits are used) * Lib supports BaroAltitude packets but EdgeTX seems to not be able to parse them if Altitude is included. -* GPS heading seems to have some overflow issues in EdgeTX. # Hardware requirements diff --git a/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino b/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino index e0ccda9..8901fba 100644 --- a/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino +++ b/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino @@ -11,8 +11,8 @@ #include #include -#define PIN_RX 7 -#define PIN_TX 8 +#define PIN_RX 4 +#define PIN_TX 5 HardwareSerial crsfSerial(1); AlfredoCRSF crsf; diff --git a/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino b/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino index 9c7faed..7d2b132 100644 --- a/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino +++ b/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino @@ -38,7 +38,7 @@ void sendGps(float latitude, float longitude, float groundspeed, float heading, crsfGps.latitude = htobe32((int32_t)(latitude*10000000.0)); crsfGps.longitude = htobe32((int32_t)(longitude*10000000.0)); crsfGps.groundspeed = htobe16((uint16_t)(groundspeed*10.0)); - crsfGps.heading = htobe16((int16_t)(heading*1000.0)); //TODO: heading seems to not display in EdgeTX correctly, some kind of overflow error + crsfGps.heading = htobe16((uint16_t)(heading*100.0)); //degrees * 100, so 0-360 degrees fits in 0-36000 crsfGps.altitude = htobe16((uint16_t)(altitude + 1000.0)); crsfGps.satellites = (uint8_t)(satellites); crsf.queuePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_GPS, &crsfGps, sizeof(crsfGps)); From 094df7cf00d0faba68e3bae4d8a6137a7ea9e1d6 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Mon, 20 Jul 2026 19:01:51 -0400 Subject: [PATCH 14/18] Use the full 24 bit battery capacity field The battery example packed capacity with htobe16 shifted left by 8, which is correct big endian but caps capacity at 65535mAh. EdgeTX reads the field as three big endian bytes, so add an htobe24 macro (matching the one in ELRS 4.x) and use it: capacity now goes up to 16777215mAh. Verified the byte order lands as 12 D6 87 for 1234567mAh, which is what EdgeTX's getCrossfireTelemetryValue<3> decodes back to 1234567. --- README.md | 1 - examples/sendTelemetryBattery/sendTelemetryBattery.ino | 2 +- src/crsf_protocol.h | 3 +++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4860487..eb8050f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ This library was designed for ELRS but should be compatible with any CRSF receiv TODO: * For now callbacks have been removed. May add them back or replace with a flag system to alert when packets come in. -* Improve battery telemetry example by using all 24 capacity bits. (currently just 16 bits are used) * Lib supports BaroAltitude packets but EdgeTX seems to not be able to parse them if Altitude is included. # Hardware requirements diff --git a/examples/sendTelemetryBattery/sendTelemetryBattery.ino b/examples/sendTelemetryBattery/sendTelemetryBattery.ino index 3a258b4..fc1027c 100644 --- a/examples/sendTelemetryBattery/sendTelemetryBattery.ino +++ b/examples/sendTelemetryBattery/sendTelemetryBattery.ino @@ -50,7 +50,7 @@ static void sendRxBattery(float voltage, float current, float capacity, float re // Values are MSB first (BigEndian) crsfBatt.voltage = htobe16((uint16_t)(voltage * 10.0)); //Volts crsfBatt.current = htobe16((uint16_t)(current * 10.0)); //Amps - crsfBatt.capacity = htobe16((uint16_t)(capacity)) << 8; //mAh (with this implemetation max capacity is 65535mAh) + crsfBatt.capacity = htobe24((uint32_t)(capacity)); //mAh (24 bit field, max 16777215mAh) crsfBatt.remaining = (uint8_t)(remaining); //percent crsf.queuePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_BATTERY_SENSOR, &crsfBatt, sizeof(crsfBatt)); } \ No newline at end of file diff --git a/src/crsf_protocol.h b/src/crsf_protocol.h index 0ea70e1..83636a4 100644 --- a/src/crsf_protocol.h +++ b/src/crsf_protocol.h @@ -279,10 +279,13 @@ typedef struct crsf_sensor_attitude_s #define be16toh(x) (x) #define be32toh(x) (x) #define htobe16(x) (x) +#define htobe24(x) (x) #define htobe32(x) (x) #else // __ORDER_LITTLE_ENDIAN__ #define be16toh(x) __builtin_bswap16(x) #define be32toh(x) __builtin_bswap32(x) #define htobe16(x) __builtin_bswap16(x) +// For the 24 bit fields used by some sensors, e.g. battery capacity +#define htobe24(x) (__builtin_bswap32((uint32_t)(x)) >> 8) #define htobe32(x) __builtin_bswap32(x) #endif // __BYTE_ORDER__ From e1905e136e66fcbddd5c59d5003ea10e5bc59fa2 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Mon, 20 Jul 2026 19:02:45 -0400 Subject: [PATCH 15/18] Send vertical speed inside the BaroAltitude packet EdgeTX picks what a BaroAltitude packet contains from its declared length: a 2 byte payload is altitude only, 5 bytes adds TBS vertical speed, and 6 or more adds ELRS style int16 vertical speed. The example was truncating the payload to altitude only and sending a separate Vario packet to work around what looked like an EdgeTX parsing problem. Sending the full 4 byte payload works, so altitude and vertical speed now travel in one packet. The standalone Vario send is kept as sendVario() for vertical speed that does not come from a barometer. Also drops the callbacks TODO (not planned; the getter API stays) and notes the 3.x/4.x compatibility story in the README intro. --- README.md | 6 ++---- .../sendTelemetryGpsBaroVarioAttitude.ino | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index eb8050f..0f58416 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,9 @@ This library is based on CapnBry's CRSF code, it has been modified to match the format of standard Arduino Library. Keywords and example files included. It has also now been extended to support more telemetry packet types. Check out the example files to learn more. -This library was designed for ELRS but should be compatible with any CRSF receiver. +This library was designed for ELRS but should be compatible with any CRSF receiver. It supports both ELRS 3.x and 4.x: newer frame types are decoded when they arrive and simply never appear on an older link. -TODO: -* For now callbacks have been removed. May add them back or replace with a flag system to alert when packets come in. -* Lib supports BaroAltitude packets but EdgeTX seems to not be able to parse them if Altitude is included. +There are no packet callbacks. Call `update()` in your loop and read the latest values with the getters whenever you need them. # Hardware requirements diff --git a/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino b/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino index 7d2b132..d234cfe 100644 --- a/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino +++ b/examples/sendTelemetryGpsBaroVarioAttitude/sendTelemetryGpsBaroVarioAttitude.ino @@ -61,21 +61,28 @@ void sendGpsTime(int16_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t crsf.queuePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_GPS_TIME, &crsfGpsTime, sizeof(crsfGpsTime)); } +// Sends altitude and vertical speed together in one BaroAltitude packet. +// EdgeTX decides what the packet contains from its length: a 2 byte payload +// is altitude only, and a 4 byte payload adds ELRS style vertical speed. +// Very old EdgeTX versions only understand the altitude, in which case send +// vertical speed separately with sendVario() below. void sendBaroAltitude(float altitude, float verticalspd) { crsf_sensor_baro_altitude_t crsfBaroAltitude = { 0 }; // Values are MSB first (BigEndian) - crsfBaroAltitude.altitude = htobe16((uint16_t)(altitude*10.0 + 10000.0)); - //crsfBaroAltitude.verticalspd = htobe16((int16_t)(verticalspd*100.0)); //TODO: fix verticalspd in BaroAlt packets - crsf.queuePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_BARO_ALTITUDE, &crsfBaroAltitude, sizeof(crsfBaroAltitude) - 2); - - //Supposedly vertical speed can be sent in a BaroAltitude packet, but I cant get this to work. - //For now I have to send a second vario packet to get vertical speed telemetry to my TX. + crsfBaroAltitude.altitude = htobe16((uint16_t)(altitude*10.0 + 10000.0)); //decimeters + 10000dm + crsfBaroAltitude.verticalspd = htobe16((int16_t)(verticalspd*100.0)); //cm/s + crsf.queuePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_BARO_ALTITUDE, &crsfBaroAltitude, sizeof(crsfBaroAltitude)); +} + +// Vertical speed on its own, for when it does not come from a barometer +void sendVario(float verticalspd) +{ crsf_sensor_vario_t crsfVario = { 0 }; // Values are MSB first (BigEndian) - crsfVario.verticalspd = htobe16((int16_t)(verticalspd*100.0)); + crsfVario.verticalspd = htobe16((int16_t)(verticalspd*100.0)); //cm/s crsf.queuePacket(CRSF_SYNC_BYTE, CRSF_FRAMETYPE_VARIO, &crsfVario, sizeof(crsfVario)); } From 28c85a621b6598266baec0fa51a71a5e32a6a33c Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Mon, 20 Jul 2026 19:08:47 -0400 Subject: [PATCH 16/18] Overhaul the README and move the protocol spec to its own file The README was mostly a CRSF wire format dump, which buried what the library actually does. It now covers features, hardware requirements and wiring, installation, a quick start, a full API reference, a guide to the examples, compatibility gotchas, and references. The protocol specification moves to CRSF_PROTOCOL.md, reorganised into tables and updated to match what this library now implements: byte 0 documented as a sync byte rather than a destination address, extended header frames and their routing explained, and the frame types added since ELRS 3.x filled in. Also documents the model match trap, since a receiver that binds but sends nothing looks exactly like a wiring fault. --- CRSF_PROTOCOL.md | 351 ++++++++++++++++++++++++++++++++++++++++++ README.md | 389 ++++++++++++++++++++++------------------------- 2 files changed, 531 insertions(+), 209 deletions(-) create mode 100644 CRSF_PROTOCOL.md diff --git a/CRSF_PROTOCOL.md b/CRSF_PROTOCOL.md new file mode 100644 index 0000000..2d6f575 --- /dev/null +++ b/CRSF_PROTOCOL.md @@ -0,0 +1,351 @@ +# CRSF protocol specification + +The CRSF (Crossfire) protocol is not documented or maintained by any single +entity. This specification has been assembled from the ExpressLRS, EdgeTX and +OpenTX codebases, cross-checked against the TBS specification. Where firmwares +disagree, the behaviour described here is the one this library implements. + +See [References](#references) at the bottom for the upstream sources. + +## Packet format + +``` +[sync] [len] [type] [payload] [crc8] +``` + +| Field | Size | Description | +| --- | --- | --- | +| sync | 1 | Sync byte, see below | +| len | 1 | Number of bytes that follow, i.e. type + payload + crc | +| type | 1 | Frame type, see [Frame types](#frame-types) | +| payload | len - 2 | Frame specific, see [Payloads](#payloads) | +| crc8 | 1 | CRC over type and payload | + +Total packet length is `len + 2`, or payload length + 4. + +### Sync byte + +The first byte is a **sync marker, not routing information**. On a serial link +it is always `0xC8`, which collides with `CRSF_ADDRESS_FLIGHT_CONTROLLER` +because that address doubles as the sync value. On handset links you will also +see `0xEE` and `0xEA`. + +Do not infer the destination of a frame from this byte. Standard frames are +identified purely by their type, and frames that genuinely need addressing use +[extended header frames](#extended-header-frames). + +### CRC + +CRC8 with polynomial `0xD5`, initial value 0, covering all bytes from the type +byte through the end of the payload. It does not include the sync or length +bytes. + +### Addresses + +| Address | Value | Device | +| --- | --- | --- | +| `CRSF_ADDRESS_BROADCAST` | 0x00 | All devices | +| `CRSF_ADDRESS_USB` | 0x10 | USB | +| `CRSF_ADDRESS_BLUETOOTH_WIFI` | 0x12 | Bluetooth or WiFi link | +| `CRSF_ADDRESS_TBS_CORE_PNP_PRO` | 0x80 | TBS Core PNP Pro | +| `CRSF_ADDRESS_CURRENT_SENSOR` | 0xC0 | Current sensor | +| `CRSF_ADDRESS_GPS` | 0xC2 | GPS | +| `CRSF_ADDRESS_TBS_BLACKBOX` | 0xC4 | TBS Blackbox | +| `CRSF_ADDRESS_FLIGHT_CONTROLLER` | 0xC8 | Flight controller | +| `CRSF_ADDRESS_RACE_TAG` | 0xCC | Race tag | +| `CRSF_ADDRESS_RADIO_TRANSMITTER` | 0xEA | Handset | +| `CRSF_ADDRESS_CRSF_RECEIVER` | 0xEC | Receiver | +| `CRSF_ADDRESS_CRSF_TRANSMITTER` | 0xEE | Transmitter module | + +### Extended header frames + +Frame types in the range **0x28 to 0x96** carry two extra bytes at the start of +the payload: + +``` +[sync] [len] [type] [dest] [origin] [payload] [crc8] +``` + +`dest` is the address the frame is for and `origin` is the sender. A device +should act on a frame when `dest` is its own address or the broadcast address +`0x00`. This is how device discovery, parameter access and ELRS status frames +are routed between the handset, transmitter module, receiver and flight +controller. + +## Frame types + +| Type | ID | Direction | Notes | +| --- | --- | --- | --- | +| `GPS` | 0x02 | telemetry | | +| `GPS_TIME` | 0x03 | telemetry | Handset clock sync, ELRS 4.1+ | +| `VARIO` | 0x07 | telemetry | | +| `BATTERY_SENSOR` | 0x08 | telemetry | | +| `BARO_ALTITUDE` | 0x09 | telemetry | Optionally includes vertical speed | +| `AIRSPEED` | 0x0A | telemetry | | +| `HEARTBEAT` | 0x0B | any | Device discovery, ELRS 4.0+ | +| `RPM` | 0x0C | telemetry | Variable length | +| `TEMP` | 0x0D | telemetry | Variable length | +| `CELLS` | 0x0E | telemetry | Variable length | +| `VIDEO_TRANSMITTER` | 0x0F | to VTX | | +| `OPENTX_SYNC` | 0x10 | to handset | Legacy, superseded by `HANDSET` | +| `LINK_STATISTICS` | 0x14 | to FC | | +| `RC_CHANNELS_PACKED` | 0x16 | to FC / to TX | | +| `LINK_RX_ID` | 0x1C | telemetry | | +| `LINK_TX_ID` | 0x1D | telemetry | | +| `ATTITUDE` | 0x1E | telemetry | | +| `FLIGHT_MODE` | 0x21 | telemetry | | +| `DEVICE_PING` | 0x28 | extended | | +| `DEVICE_INFO` | 0x29 | extended | | +| `PARAMETER_SETTINGS_ENTRY` | 0x2B | extended | | +| `PARAMETER_READ` | 0x2C | extended | | +| `PARAMETER_WRITE` | 0x2D | extended | | +| `ELRS_STATUS` | 0x2E | extended | ELRS specific | +| `COMMAND` | 0x32 | extended | | +| `HANDSET` | 0x3A | extended | Named `RADIO_ID` in older firmwares | +| `KISS_REQ` | 0x78 | extended | | +| `KISS_RESP` | 0x79 | extended | | +| `MSP_REQ` | 0x7A | extended | | +| `MSP_RESP` | 0x7B | extended | | +| `MSP_WRITE` | 0x7C | extended | | +| `ARDUPILOT_RESP` | 0x80 | extended | | + +All multi-byte values are big endian unless stated otherwise. + +## Payloads + +### GPS (0x02) + +| Field | Type | Units | +| --- | --- | --- | +| latitude | int32 | degrees / 10,000,000 | +| longitude | int32 | degrees / 10,000,000 | +| groundspeed | uint16 | km/h / 10 | +| heading | uint16 | degrees / 100 | +| altitude | uint16 | metres + 1000 | +| satellites | uint8 | count | + +Heading is degrees times 100, so a full 0-360 degrees maps to 0-36000. Sending +a larger scale factor overflows the field. + +### GPS_TIME (0x03) + +Synchronises the handset clock. Sent by a flight controller such as Betaflight +2026.06+, and requires ELRS 4.1+ to pass through to the handset. + +| Field | Type | Units | +| --- | --- | --- | +| year | int16 | | +| month | uint8 | 1-12 | +| day | uint8 | 1-31 | +| hour | uint8 | 0-23 | +| minute | uint8 | 0-59 | +| second | uint8 | 0-59 | +| millisecond | uint16 | 0-999 | + +### VARIO (0x07) + +| Field | Type | Units | +| --- | --- | --- | +| verticalspd | int16 | cm/s | + +### BATTERY_SENSOR (0x08) + +| Field | Type | Units | +| --- | --- | --- | +| voltage | uint16 | volts * 10 | +| current | uint16 | amps * 10 | +| capacity | uint24 | mAh | +| remaining | uint8 | percent | + +Capacity is a 24 bit field, so the maximum is 16,777,215 mAh. + +### BARO_ALTITUDE (0x09) + +| Field | Type | Units | +| --- | --- | --- | +| altitude | uint16 | decimetres + 10000, or metres if the high bit is set | +| verticalspd | int16 | cm/s, optional | + +The receiving side decides what the frame contains from its declared length: +a 2 byte payload is altitude only, 3 bytes adds a TBS style single byte +vertical speed, and 4 bytes adds the ELRS style int16 vertical speed above. + +### AIRSPEED (0x0A) + +| Field | Type | Units | +| --- | --- | --- | +| speed | uint16 | km/h * 10 | + +### HEARTBEAT (0x0B) + +Announces a device so the CRSF router can discover it. + +| Field | Type | Units | +| --- | --- | --- | +| origin | int16 | address of the sending device | + +### RPM (0x0C) + +Variable length: the number of values is derived from the frame length. + +| Field | Type | Units | +| --- | --- | --- | +| source_id | uint8 | 0 = motor 1, 1 = motor 2, etc. | +| rpm | int24 x 1-19 | RPM, negative means reverse | + +### TEMP (0x0D) + +Variable length. + +| Field | Type | Units | +| --- | --- | --- | +| source_id | uint8 | 0 = FC including ESCs, 1 = ambient, etc. | +| temperature | int16 x 1-20 | tenths of a degree Celsius | + +### CELLS (0x0E) + +Variable length. ELRS 4.0+ receivers with battery voltage sensing send this +with `source_id` 128 to report millivolt precision voltage. + +| Field | Type | Units | +| --- | --- | --- | +| source_id | uint8 | 0 = battery 1, 1 = battery 2, etc. | +| cell | uint16 x 1-29 | millivolts | + +### VIDEO_TRANSMITTER (0x0F) + +| Field | Type | +| --- | --- | +| origin | uint8 | +| status | uint8 | +| band_channel | uint8 | +| user_frequency | uint16 | +| pitmode_and_power | uint8 | + +### LINK_STATISTICS (0x14) + +| Field | Type | Units | +| --- | --- | --- | +| uplink_RSSI_1 | uint8 | dBm * -1 | +| uplink_RSSI_2 | uint8 | dBm * -1 | +| uplink_Link_quality | uint8 | percent | +| uplink_SNR | int8 | dB | +| active_antenna | uint8 | 0 or 1 | +| rf_Mode | uint8 | packet rate index | +| uplink_TX_Power | uint8 | power index | +| downlink_RSSI | uint8 | dBm * -1 | +| downlink_Link_quality | uint8 | percent | +| downlink_SNR | int8 | dB | + +### RC_CHANNELS_PACKED (0x16) + +Sixteen channels packed into 11 bits each, 22 bytes total. Values are 172 to +1811 for -100% to +100%, with 992 as centre. With extended limits enabled the +usable range widens to 0 to 1984. + +ELRS 4.0+ handsets running EdgeTX 2.11+ may append **one extra status byte** +after the channel data, making the payload 23 bytes: + +| Bit | Name | Meaning | +| --- | --- | --- | +| 0 | `CRSF_CHANNELS_STATUS_ARMED` | Commanded arm state in "Arm using Switch" mode | +| 1 | `CRSF_CHANNELS_STATUS_ARMING_MODE_CH5` | Arm from the channel 5 value instead of bit 0 | + +Receivers that predate this simply see a longer frame than they expect, so only +send the status byte to an ELRS 4.0+ transmitter module. + +### LINK_RX_ID (0x1C) + +| Field | Type | Units | +| --- | --- | --- | +| rxRssiPercent | uint8 | percent | +| rxRfPower | uint8 | power index | + +### LINK_TX_ID (0x1D) + +| Field | Type | Units | +| --- | --- | --- | +| txRssiPercent | uint8 | percent | +| txRfPower | uint8 | power index | +| txFps | uint8 | frames per second / 10 | + +### ATTITUDE (0x1E) + +| Field | Type | Units | +| --- | --- | --- | +| pitch | int16 | radians * 10000 | +| roll | int16 | radians * 10000 | +| yaw | int16 | radians * 10000 | + +These are signed: negative angles are normal and must not be treated as +unsigned. + +### FLIGHT_MODE (0x21) + +| Field | Type | +| --- | --- | +| mode | null terminated string | + +### DEVICE_PING (0x28) + +Extended header frame with no payload. Device discovery request, usually sent +to the broadcast address. Every device answers with `DEVICE_INFO`. + +Only ELRS 4.0+ receivers answer pings over the flight controller serial port. + +### DEVICE_INFO (0x29) + +Extended header frame. The response to a ping. + +| Field | Type | +| --- | --- | +| name | null terminated string | +| serialNo | uint32 | +| hardwareVer | uint32 | +| softwareVer | uint32 | +| fieldCnt | uint8, number of configuration parameters | +| parameterVersion | uint8 | + +### ELRS_STATUS (0x2E) + +Extended header frame sent by an ELRS transmitter module to the handset. + +| Field | Type | +| --- | --- | +| pktsBad | uint8 | +| pktsGood | uint16 | +| flags | uint8, see below | +| msg | null terminated warning string | + +| Bit | Meaning | +| --- | --- | +| 0 | Connected | +| 2 | Model mismatch warning | +| 3 | Armed warning | +| 5 | Error: change blocked while connected | +| 6 | Error: baud rate too low | + +### HANDSET (0x3A) + +Extended header frame, named `RADIO_ID` in older firmwares. The first payload +byte is a subcommand. Subcommand `0x10` is timing sync, sent by a transmitter +module to tell the handset how fast to send channel frames: + +| Field | Type | Units | +| --- | --- | --- | +| subCommand | uint8 | 0x10 for timing sync | +| rate | uint32 | requested packet interval, 0.1 us units | +| offset | int32 | phase correction, 0.1 us units | + +### Undocumented frames + +The payloads of `PARAMETER_SETTINGS_ENTRY` (0x2B), `PARAMETER_READ` (0x2C), +`PARAMETER_WRITE` (0x2D), `COMMAND` (0x32), the KISS frames (0x78, 0x79), the +MSP frames (0x7A to 0x7C) and `ARDUPILOT_RESP` (0x80) are not documented here. +This library does not decode them. + +## References + +- [ExpressLRS `crsf_protocol.h`](https://github.com/ExpressLRS/ExpressLRS/blob/master/src/include/crsf_protocol.h) - authoritative for ELRS frame definitions +- [EdgeTX `crossfire.cpp`](https://github.com/EdgeTX/edgetx/blob/main/radio/src/telemetry/crossfire.cpp) - authoritative for how telemetry is decoded and displayed +- [TBS CRSF specification](https://github.com/tbs-fpv/tbs-crsf-spec/blob/main/crsf.md) - the vendor specification diff --git a/README.md b/README.md index 0f58416..7d0877a 100644 --- a/README.md +++ b/README.md @@ -1,209 +1,180 @@ -# AlfredoCRSF - CSRF serial protocol Arduino library - -This library is based on CapnBry's CRSF code, it has been modified to match the format of standard Arduino Library. Keywords and example files included. It has also now been extended to support more telemetry packet types. Check out the example files to learn more. - -This library was designed for ELRS but should be compatible with any CRSF receiver. It supports both ELRS 3.x and 4.x: newer frame types are decoded when they arrive and simply never appear on an older link. - -There are no packet callbacks. Call `update()` in your loop and read the latest values with the getters whenever you need them. - -# Hardware requirements - -This library is designed for ESP32. CRSF works best when you can access Serial Hardware peripherals that can achieve high baudrates (up to 420000). At least two serial peripherals are preferred, it is best to leave an MCUs default serial peripherals (the Serial object) for printing and debugging, and a second high speed peripheral for CRSF. - -This library should work on other MCUS like ATmega32U4/RP2040/STM32 but these are untested, attempt at your own risk. Avoid weak MCUs like atmega328p. - -# CRSF protocol specification - -The CRSF protocol is not documented or maintained by one single entity. The following specification has been cobbled together from the ELRS, EdgeTX and OpenTX project codebases. - -## Packet Format -`[dest] [len] [type] [payload] [crc8]` - -### DEST - Destination address or "sync" byte -* CRSF_ADDRESS_CRSF_TRANSMITTER = (0xEE) //Going to the transmitter module -* CRSF_ADDRESS_RADIO_TRANSMITTER = (0xEA) //Going to the handset -* CRSF_ADDRESS_FLIGHT_CONTROLLER = (0xC8) //Going to the flight controller -* CRSF_ADDRESS_CRSF_RECEIVER = (0xEC) //Going to the receiver (from FC) - -### LEN - Length of bytes that follow -Overall packet length is PayloadLength+4 (dest, len, type, crc), or LEN+2 (dest, len). - -### TYPE - CRSF_FRAMETYPE -* CRSF_FRAMETYPE_GPS = 0x02, -* CRSF_FRAMETYPE_GPS_TIME = 0x03, -* CRSF_FRAMETYPE_VARIO = 0x07, -* CRSF_FRAMETYPE_BATTERY_SENSOR = 0x08, -* CRSF_FRAMETYPE_BARO_ALTITUDE = 0x09, -* CRSF_FRAMETYPE_AIRSPEED = 0x0A, -* CRSF_FRAMETYPE_HEARTBEAT = 0x0B, -* CRSF_FRAMETYPE_RPM = 0x0C, -* CRSF_FRAMETYPE_TEMP = 0x0D, -* CRSF_FRAMETYPE_CELLS = 0x0E, -* CRSF_FRAMETYPE_LINK_STATISTICS = 0x14, -* CRSF_FRAMETYPE_OPENTX_SYNC = 0x10, -* CRSF_FRAMETYPE_HANDSET = 0x3A, // named RADIO_ID in older firmwares -* CRSF_FRAMETYPE_RC_CHANNELS_PACKED = 0x16, -* CRSF_FRAMETYPE_LINK_RX_ID = 0x1C, -* CRSF_FRAMETYPE_LINK_TX_ID = 0x1D, -* CRSF_FRAMETYPE_ATTITUDE = 0x1E, -* CRSF_FRAMETYPE_FLIGHT_MODE = 0x21, -// Extended Header Frames, range: 0x28 to 0x96 -* CRSF_FRAMETYPE_DEVICE_PING = 0x28, -* CRSF_FRAMETYPE_DEVICE_INFO = 0x29, -* CRSF_FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, -* CRSF_FRAMETYPE_PARAMETER_READ = 0x2C, -* CRSF_FRAMETYPE_PARAMETER_WRITE = 0x2D, -* CRSF_FRAMETYPE_ELRS_STATUS = 0x2E, -* CRSF_FRAMETYPE_COMMAND = 0x32, -// KISS frames -* CRSF_FRAMETYPE_KISS_REQ = 0x78, -* CRSF_FRAMETYPE_KISS_RESP = 0x79, -// MSP commands -* CRSF_FRAMETYPE_MSP_REQ = 0x7A, -* CRSF_FRAMETYPE_MSP_RESP = 0x7B, -* CRSF_FRAMETYPE_MSP_WRITE = 0x7C, -// Ardupilot frames -* CRSF_FRAMETYPE_ARDUPILOT_RESP = 0x80, - -### CRC - CRC8 using poly 0xD5 -Includes all bytes from type (buffer[2]) to end of payload. - -## Payload of each frametype -### CRSF_FRAMETYPE_GPS = 0x02 -* int32_t latitude; // degree / 10,000,000 big endian -* int32_t longitude; // degree / 10,000,000 big endian -* uint16_t groundspeed; // km/h / 10 big endian -* uint16_t heading; // GPS heading, degree/100 big endian -* uint16_t altitude; // meters, +1000m big endian -* uint8_t satellites; // satellites -### CRSF_FRAMETYPE_GPS_TIME = 0x03 -Used to synchronize the handset clock (sent by e.g. Betaflight 2026.06+, requires ELRS 4.1+ to pass through). -* int16_t year; // BigEndian -* uint8_t month; -* uint8_t day; -* uint8_t hour; -* uint8_t minute; -* uint8_t second; -* uint16_t millisecond; // BigEndian -### CRSF_FRAMETYPE_VARIO = 0x07 -* int16_t verticalspd; // Vertical speed in cm/s, BigEndian -### CRSF_FRAMETYPE_BATTERY_SENSOR = 0x08 -* unsigned voltage : 16; // V * 10 big endian -* unsigned current : 16; // A * 10 big endian -* unsigned capacity : 24; // mah big endian -* unsigned remaining : 8; // % -### CRSF_FRAMETYPE_BARO_ALTITUDE = 0x09 -* uint16_t altitude; // Altitude in decimeters + 10000dm, or Altitude in meters if high bit is set, BigEndian -* int16_t verticalspd; // Vertical speed in cm/s, BigEndian -### CRSF_FRAMETYPE_AIRSPEED = 0x0A -* uint16_t speed; // Airspeed in 0.1 * km/h (hectometers/h), BigEndian -### CRSF_FRAMETYPE_HEARTBEAT = 0x0B -* int16_t Origin Device address; // BigEndian (used for device discovery by the ELRS 4.0 CRSF router) -### CRSF_FRAMETYPE_RPM = 0x0C -Variable length, count of values determined by frame length. -* uint8_t source_id; // e.g. 0 = Motor 1, 1 = Motor 2, etc. -* int24_t rpm[1-19]; // Signed 24-bit RPM values BigEndian, negative = reverse -### CRSF_FRAMETYPE_TEMP = 0x0D -Variable length, count of values determined by frame length. -* uint8_t source_id; // e.g. 0 = FC including all ESCs, 1 = Ambient, etc. -* int16_t temperature[1-20]; // Deci-degrees Celsius BigEndian (250 = 25.0C) -### CRSF_FRAMETYPE_CELLS = 0x0E -Variable length, count of values determined by frame length. ELRS 4.0+ receivers with VBAT sensing send this with source_id 128 for millivolt-precision voltage. -* uint8_t source_id; // e.g. 0 = battery 1, 1 = battery 2, etc. -* uint16_t cell[1-29]; // Cell voltage in millivolts BigEndian (3850 = 3.850V) -### CRSF_FRAMETYPE_VIDEO_TRANSMITTER = 0x0F -* uint8_t Origin address; -* uint8_t Status; -* uint8_t Band_Channel; -* uint16_t User_Frequency; -* uint8_t PitMode_and_Power; -### CRSF_FRAMETYPE_LINK_STATISTICS = 0x14 -* uint8_t uplink_RSSI_1; -* uint8_t uplink_RSSI_2; -* uint8_t uplink_Link_quality; -* int8_t uplink_SNR; -* uint8_t active_antenna; -* uint8_t rf_Mode; -* uint8_t uplink_TX_Power; -* uint8_t downlink_RSSI; -* uint8_t downlink_Link_quality; -* int8_t downlink_SNR; -### CRSF_FRAMETYPE_OPENTX_SYNC = 0x10 -* ???? -### CRSF_FRAMETYPE_HANDSET = 0x3A (named RADIO_ID in older firmwares) -Extended header frame (payload preceded by destination and origin address bytes). Sent by a TX module to the handset. The first payload byte is a subcommand; subcommand 0x10 is timing sync: -* uint8_t subCommand; // 0x10 = timing sync -* uint32_t rate; // requested channels packet interval in 0.1us units, BigEndian -* int32_t offset; // timing offset correction in 0.1us units, BigEndian -### CRSF_FRAMETYPE_RC_CHANNELS_PACKED = 0x16 -* unsigned ch0 : 11; -* unsigned ch1 : 11; -* unsigned ch2 : 11; -* unsigned ch3 : 11; -* unsigned ch4 : 11; -* unsigned ch5 : 11; -* unsigned ch6 : 11; -* unsigned ch7 : 11; -* unsigned ch8 : 11; -* unsigned ch9 : 11; -* unsigned ch10 : 11; -* unsigned ch11 : 11; -* unsigned ch12 : 11; -* unsigned ch13 : 11; -* unsigned ch14 : 11; -* unsigned ch15 : 11; - -ELRS 4.0+ handsets (EdgeTX 2.11+) may append one status byte after the packed channels: -* bit 0: CRSF_CHANNELS_STATUS_ARMED - commanded armed status in Arm using Switch mode -* bit 1: CRSF_CHANNELS_STATUS_ARMING_MODE_CH5 - arm via CH5 instead of the armed bit -### CRSF_FRAMETYPE_LINK_RX_ID = 0x1C -* uint8_t rxRssiPercent; -* uint8_t rxRfPower; //should be signed int? -### CRSF_FRAMETYPE_LINK_TX_ID = 0x1D -* uint8_t txRssiPercent; -* uint8_t txRfPower; //should be signed int? -* uint8_t txFps; -### CRSF_FRAMETYPE_ATTITUDE = 0x1E -* int16_t pitch; // pitch in radians * 10000, BigEndian -* int16_t roll; // roll in radians * 10000, BigEndian -* int16_t yaw; // yaw in radians * 10000, BigEndian -### CRSF_FRAMETYPE_FLIGHT_MODE = 0x21 -* char[]; //Flight mode ( Null-terminated string ) -// Extended Header Frames, range: 0x28 to 0x96 -### CRSF_FRAMETYPE_DEVICE_PING = 0x28, -Extended header frame (payload preceded by destination and origin address bytes). Device discovery request, usually sent to the broadcast address; each device answers with DEVICE_INFO. -* (no payload) -### CRSF_FRAMETYPE_DEVICE_INFO = 0x29, -Extended header frame. Device discovery response. -* char name[]; // Device name (null-terminated string) -* uint32_t serialNo; // BigEndian -* uint32_t hardwareVer; // BigEndian -* uint32_t softwareVer; // BigEndian -* uint8_t fieldCnt; // number of configuration parameters this device has -* uint8_t parameterVersion; -### CRSF_FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, -* ???? -### CRSF_FRAMETYPE_PARAMETER_READ = 0x2C, -* ???? -### CRSF_FRAMETYPE_PARAMETER_WRITE = 0x2D, -* ???? -### CRSF_FRAMETYPE_ELRS_STATUS = 0x2E, -Extended header frame (payload preceded by destination and origin address bytes). Sent by an ELRS TX module to the handset. -* uint8_t pktsBad; -* uint16_t pktsGood; // BigEndian -* uint8_t flags; // bit 0: connected, bit 2: model mismatch warning, bit 3: armed warning, bit 5: error - change blocked while connected, bit 6: error - baud rate too low -* char msg[]; // Warning message (null-terminated string) -### CRSF_FRAMETYPE_COMMAND = 0x32, -* ???? -// KISS frames -### CRSF_FRAMETYPE_KISS_REQ = 0x78, -* ???? -### CRSF_FRAMETYPE_KISS_RESP = 0x79, -* ???? -// MSP commands -### CRSF_FRAMETYPE_MSP_REQ = 0x7A, -* ???? -### CRSF_FRAMETYPE_MSP_RESP = 0x7B, -* ???? -### CRSF_FRAMETYPE_MSP_WRITE = 0x7C, -* ???? +# AlfredoCRSF + +An Arduino library for the CRSF (Crossfire) serial protocol. Talk to an +ExpressLRS or TBS Crossfire receiver from a microcontroller: read stick and +switch positions, monitor link quality, and send telemetry back to the handset. + +Originally based on CapnBry's CRSF code, restructured as a standard Arduino +library and extended with support for many more packet types. + +## Features + +- **Receive** RC channels, link statistics, and telemetry +- **Send** telemetry: battery, GPS, GPS time, vario, barometric altitude, + attitude, airspeed +- **Link state** tracking with a failsafe timeout, plus commanded arm state +- **ELRS 4.0 support**: millivolt cell voltages, RPM, temperature, airspeed, + GPS time, ELRS status frames, the channels arming status byte, and CRSF + router participation (heartbeat and device discovery) +- **Works with both ELRS 3.x and 4.x.** Newer frame types are decoded when + they arrive and simply never appear on an older link, so the same sketch + runs on either. + +There are no packet callbacks. Call `update()` in your loop and read the +latest values from the getters whenever you need them. + +## Hardware requirements + +Designed for the ESP32. CRSF runs at up to 420000 baud, so it needs a hardware +serial peripheral; software serial will not keep up. + +Two serial peripherals are strongly preferred: leave the default `Serial` for +printing and debugging, and use a second high speed peripheral for CRSF. + +Other MCUs such as the ATmega32U4, RP2040 and STM32 should work but are +untested. Avoid weak MCUs like the ATmega328P. + +### Wiring + +Connect the receiver's TX pad to your MCU's RX pin and the receiver's RX pad to +your MCU's TX pin, and give them a common ground. Pick pins that are actually +free on your board: on the ESP32-S3 avoid GPIO 19 and 20 (native USB), 26-32 +(flash) and 33-37 (octal PSRAM); on the classic ESP32 avoid GPIO 6-11 (flash), +and note that 34-39 are input only. + +## Installation + +Search for "AlfredoCRSF" in the Arduino IDE Library Manager, or clone this +repository into your Arduino `libraries` folder. + +## Quick start + +```cpp +#include +#include + +#define PIN_RX 18 +#define PIN_TX 17 + +HardwareSerial crsfSerial(1); +AlfredoCRSF crsf; + +void setup() +{ + Serial.begin(115200); + crsfSerial.begin(CRSF_BAUDRATE, SERIAL_8N1, PIN_RX, PIN_TX); + crsf.begin(crsfSerial); +} + +void loop() +{ + crsf.update(); // must be called regularly + + if (crsf.isLinkUp()) + { + Serial.print("throttle: "); + Serial.println(crsf.getChannel(3)); // channels are 1 based, value in us + } +} +``` + +## API + +### Setup + +| Method | Description | +| --- | --- | +| `begin(port, deviceAddr)` | Start on a stream. `deviceAddr` defaults to `CRSF_ADDRESS_FLIGHT_CONTROLLER`; pass `CRSF_ADDRESS_RADIO_TRANSMITTER` when acting as a handset | +| `update()` | Process incoming bytes. Call this often from `loop()` | + +### Channels and link + +| Method | Description | +| --- | --- | +| `getChannel(ch)` | Channel value in microseconds, 1 based | +| `getChannelsPacked()` | The raw packed channels struct, for forwarding | +| `isLinkUp()` | False once no channels packet has arrived for 300 ms | +| `getLinkStatistics()` | RSSI, link quality, SNR, TX power | +| `isArmed()` | Commanded arm state. Uses the ELRS 4.0 status byte when present, otherwise the channel 5 position | +| `hasChannelsStatus()` / `getChannelsStatus()` | Whether the ELRS 4.0 status byte was present, and its raw bits | + +### Telemetry getters + +Each returns a pointer to the most recently received value. Sensors that never +arrive stay zeroed. + +| Method | Frame | +| --- | --- | +| `getGpsSensor()` | GPS position, speed, heading, altitude | +| `getGpsTimeSensor()` | GPS date and time | +| `getVarioSensor()` | Vertical speed | +| `getBaroAltitudeSensor()` | Barometric altitude | +| `getAttitudeSensor()` | Pitch, roll, yaw | +| `getAirspeedSensor()` | Airspeed | +| `getRpmSensor()` | Up to 19 RPM values | +| `getTempSensor()` | Up to 20 temperatures | +| `getCellsSensor()` | Up to 29 cell voltages in millivolts | +| `getElrsStatus()` | ELRS packet counts, warning flags and message | +| `getHandsetTiming()` | Frame rate a TX module is asking a handset for | + +### Sending + +| Method | Description | +| --- | --- | +| `queuePacket(addr, type, payload, len)` | Send a frame, dropped if the link is down | +| `writePacket(addr, type, payload, len)` | Send a frame unconditionally | +| `writeChannels(addr, channels)` | Send packed channels | +| `writeChannels(addr, channels, status)` | Send packed channels with the ELRS 4.0 arming status byte. **ELRS 4.0+ modules only** | +| `writeExtPacket(type, destAddr, payload, len)` | Send an extended header frame from this device | +| `sendHeartbeat()` | Announce this device for CRSF router discovery | +| `setDeviceName(name)` | Answer device discovery pings with this name | + +Build telemetry payloads with the structs in +[`crsf_protocol.h`](src/crsf_protocol.h) and the `htobe16` / `htobe24` / +`htobe32` helpers, since all multi-byte fields are big endian. See the +telemetry examples for the pattern. + +## Examples + +| Example | What it does | +| --- | --- | +| `printAllChannels` | Print all 16 channels. Start here | +| `linkStatusLed` | Drive an LED from link state | +| `sendTelemetryBattery` | Measure a voltage divider and report battery telemetry | +| `sendTelemetryGpsBaroVarioAttitude` | Send GPS, GPS time, altitude, vario and attitude | +| `forwardChannelsToFC` | Two receivers with failover, forwarding channels onward | +| `forwardPacketsFromHandset` | Read packets on the handset side of the link | +| `elrs4SelfTest` | Functional self test needing no radio: cross wire two UARTs and check every parser and sender | +| `elrs4ReceiverTest` | Bench test a receiver: channels, link stats and all telemetry types | +| `handsetEmulator` | Drive a TX module the way a handset does, including arm state and frame pacing | + +## Compatibility notes + +- **Model match.** If a receiver binds and shows connected but sends nothing at + all over serial, check the model match setting on your handset. A mismatched + model ID makes the receiver suppress its entire serial output, which looks + exactly like a wiring fault. +- **ELRS 3.x receivers stay silent until they have a radio link**, so bench + testing needs the transmitter powered and bound. ELRS 4.0 receivers answer + device discovery pings without a link. +- **The arming status byte** on channel frames is ELRS 4.0 with EdgeTX 2.11 or + newer. Sending it to a 3.x transmitter module produces a frame it does not + understand. +- **GPS time** forwarding to the handset needs ELRS 4.1+. + +## Protocol specification + +The wire format, frame types and payload layouts are documented in +[CRSF_PROTOCOL.md](CRSF_PROTOCOL.md). + +## References + +- [ExpressLRS](https://github.com/ExpressLRS/ExpressLRS) and its + [documentation](https://www.expresslrs.org/) +- [EdgeTX](https://github.com/EdgeTX/edgetx) +- [TBS CRSF specification](https://github.com/tbs-fpv/tbs-crsf-spec/blob/main/crsf.md) +- [CapnBry's CRSF work](https://github.com/CapnBry/CRServoF), the origin of this library + +## License + +GPL-3.0. See [LICENSE](LICENSE). From 07b76514487043adee2753d9e0eb23037d49d194 Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Mon, 20 Jul 2026 19:32:13 -0400 Subject: [PATCH 17/18] Reorganize README with compatibility tables Convert Features and Examples sections to use comparison tables showing ELRS 3.x vs 4.x compatibility. Remove Wiring and detailed API sections. This makes it immediately clear which features and examples work on each firmware version, improving usability for users working with different ELRS versions. --- README.md | 118 ++++++++++++++---------------------------------------- 1 file changed, 30 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 7d0877a..4afd8d4 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,25 @@ library and extended with support for many more packet types. ## Features -- **Receive** RC channels, link statistics, and telemetry -- **Send** telemetry: battery, GPS, GPS time, vario, barometric altitude, - attitude, airspeed -- **Link state** tracking with a failsafe timeout, plus commanded arm state -- **ELRS 4.0 support**: millivolt cell voltages, RPM, temperature, airspeed, - GPS time, ELRS status frames, the channels arming status byte, and CRSF - router participation (heartbeat and device discovery) -- **Works with both ELRS 3.x and 4.x.** Newer frame types are decoded when - they arrive and simply never appear on an older link, so the same sketch - runs on either. +The same sketch runs on both ELRS generations. Features that need a newer +firmware are simply inert on an older link, never broken: frames that do not +exist on 3.x never arrive, and their getters stay zeroed. + +| Feature | ELRS 3.x | ELRS 4.x | +| --- | :---: | :---: | +| Receive RC channels | ✅ | ✅ | +| Link statistics (RSSI, LQ, SNR, TX power) | ✅ | ✅ | +| Link state tracking with failsafe timeout | ✅ | ✅ | +| Send telemetry: battery, GPS, vario, barometric altitude, attitude | ✅ | ✅ | +| Arm state from the channel 5 position | ✅ | ✅ | +| Handset timing sync (frame rate and phase) | ✅ | ✅ | +| Airspeed, RPM, temperature and cell voltage telemetry | ✅ | ✅ | +| Millivolt battery voltage reported by the receiver | ❌ | ✅ | +| Arm state from the channels status byte | ❌ | ✅ | +| ELRS status: packet counts, warning flags, messages | ❌ | ✅ | +| CRSF router participation: heartbeat and device discovery | ❌ | ✅ | +| GPS time forwarded to the handset clock | ❌ | ✅ | + There are no packet callbacks. Call `update()` in your loop and read the latest values from the getters whenever you need them. @@ -34,14 +43,6 @@ printing and debugging, and use a second high speed peripheral for CRSF. Other MCUs such as the ATmega32U4, RP2040 and STM32 should work but are untested. Avoid weak MCUs like the ATmega328P. -### Wiring - -Connect the receiver's TX pad to your MCU's RX pin and the receiver's RX pad to -your MCU's TX pin, and give them a common ground. Pick pins that are actually -free on your board: on the ESP32-S3 avoid GPIO 19 and 20 (native USB), 26-32 -(flash) and 33-37 (octal PSRAM); on the classic ESP32 avoid GPIO 6-11 (flash), -and note that 34-39 are input only. - ## Installation Search for "AlfredoCRSF" in the Arduino IDE Library Manager, or clone this @@ -78,75 +79,19 @@ void loop() } ``` -## API - -### Setup - -| Method | Description | -| --- | --- | -| `begin(port, deviceAddr)` | Start on a stream. `deviceAddr` defaults to `CRSF_ADDRESS_FLIGHT_CONTROLLER`; pass `CRSF_ADDRESS_RADIO_TRANSMITTER` when acting as a handset | -| `update()` | Process incoming bytes. Call this often from `loop()` | - -### Channels and link - -| Method | Description | -| --- | --- | -| `getChannel(ch)` | Channel value in microseconds, 1 based | -| `getChannelsPacked()` | The raw packed channels struct, for forwarding | -| `isLinkUp()` | False once no channels packet has arrived for 300 ms | -| `getLinkStatistics()` | RSSI, link quality, SNR, TX power | -| `isArmed()` | Commanded arm state. Uses the ELRS 4.0 status byte when present, otherwise the channel 5 position | -| `hasChannelsStatus()` / `getChannelsStatus()` | Whether the ELRS 4.0 status byte was present, and its raw bits | - -### Telemetry getters - -Each returns a pointer to the most recently received value. Sensors that never -arrive stay zeroed. - -| Method | Frame | -| --- | --- | -| `getGpsSensor()` | GPS position, speed, heading, altitude | -| `getGpsTimeSensor()` | GPS date and time | -| `getVarioSensor()` | Vertical speed | -| `getBaroAltitudeSensor()` | Barometric altitude | -| `getAttitudeSensor()` | Pitch, roll, yaw | -| `getAirspeedSensor()` | Airspeed | -| `getRpmSensor()` | Up to 19 RPM values | -| `getTempSensor()` | Up to 20 temperatures | -| `getCellsSensor()` | Up to 29 cell voltages in millivolts | -| `getElrsStatus()` | ELRS packet counts, warning flags and message | -| `getHandsetTiming()` | Frame rate a TX module is asking a handset for | - -### Sending - -| Method | Description | -| --- | --- | -| `queuePacket(addr, type, payload, len)` | Send a frame, dropped if the link is down | -| `writePacket(addr, type, payload, len)` | Send a frame unconditionally | -| `writeChannels(addr, channels)` | Send packed channels | -| `writeChannels(addr, channels, status)` | Send packed channels with the ELRS 4.0 arming status byte. **ELRS 4.0+ modules only** | -| `writeExtPacket(type, destAddr, payload, len)` | Send an extended header frame from this device | -| `sendHeartbeat()` | Announce this device for CRSF router discovery | -| `setDeviceName(name)` | Answer device discovery pings with this name | - -Build telemetry payloads with the structs in -[`crsf_protocol.h`](src/crsf_protocol.h) and the `htobe16` / `htobe24` / -`htobe32` helpers, since all multi-byte fields are big endian. See the -telemetry examples for the pattern. - ## Examples -| Example | What it does | -| --- | --- | -| `printAllChannels` | Print all 16 channels. Start here | -| `linkStatusLed` | Drive an LED from link state | -| `sendTelemetryBattery` | Measure a voltage divider and report battery telemetry | -| `sendTelemetryGpsBaroVarioAttitude` | Send GPS, GPS time, altitude, vario and attitude | -| `forwardChannelsToFC` | Two receivers with failover, forwarding channels onward | -| `forwardPacketsFromHandset` | Read packets on the handset side of the link | -| `elrs4SelfTest` | Functional self test needing no radio: cross wire two UARTs and check every parser and sender | -| `elrs4ReceiverTest` | Bench test a receiver: channels, link stats and all telemetry types | -| `handsetEmulator` | Drive a TX module the way a handset does, including arm state and frame pacing | +| Example | ELRS 3.x | ELRS 4.x | What it does | +| --- | :---: | :---: | --- | +| `printAllChannels` | ✅ | ✅ | Print all 16 channels. Start here | +| `linkStatusLed` | ✅ | ✅ | Drive an LED from link state | +| `sendTelemetryBattery` | ✅ | ✅ | Measure a voltage divider and report battery telemetry | +| `sendTelemetryGpsBaroVarioAttitude` | ✅ | ✅ | Send GPS, altitude, vario and attitude. The GPS time packet needs 4.1+ | +| `forwardChannelsToFC` | ✅ | ✅ | Two receivers with failover, forwarding channels onward | +| `forwardPacketsFromHandset` | ✅ | ✅ | Read packets on the handset side of the link | +| `elrs4SelfTest` | ❌ | ✅ | Functional self test needing no radio at all: cross wire two UARTs and check every parser and sender | +| `elrs4ReceiverTest` | ❌ | ✅ | Bench test a receiver. On 3.x the newer telemetry sections stay silent, which is the compatibility check | +| `handsetEmulator` | ✅ | ✅ | Drive a TX module the way a handset does. Set `ARM_WITH_STATUS_BYTE` to 0 for 3.x | ## Compatibility notes @@ -154,9 +99,6 @@ telemetry examples for the pattern. all over serial, check the model match setting on your handset. A mismatched model ID makes the receiver suppress its entire serial output, which looks exactly like a wiring fault. -- **ELRS 3.x receivers stay silent until they have a radio link**, so bench - testing needs the transmitter powered and bound. ELRS 4.0 receivers answer - device discovery pings without a link. - **The arming status byte** on channel frames is ELRS 4.0 with EdgeTX 2.11 or newer. Sending it to a 3.x transmitter module produces a frame it does not understand. From 9d5f8fea78e617f26ab2ae13c96f65cc29a5adcd Mon Sep 17 00:00:00 2001 From: Jacob Williams Date: Mon, 20 Jul 2026 22:02:40 -0400 Subject: [PATCH 18/18] Add model ID transmission for handset emulation Implements the CRSF model select command (0x32) to allow emulating handset behavior when connecting to transmitter modules. This addresses the common issue of mismatched model IDs causing receivers to remain silent. Includes: - New sendModelId() method to transmit model ID to TX module - CRC8 polynomial calculation support for command frame payloads - COMMAND frame protocol constants and support - Updated examples to initialize model ID at startup - Enhanced documentation explaining silent receiver issues and fixes - Library description improvements --- README.md | 15 ++++++++--- .../elrs4ReceiverTest/elrs4ReceiverTest.ino | 3 --- examples/handsetEmulator/handsetEmulator.ino | 26 +++++++++++++++++++ library.properties | 4 +-- src/AlfredoCRSF.cpp | 20 ++++++++++++++ src/AlfredoCRSF.h | 7 +++++ src/crc8.cpp | 14 ++++++++++ src/crc8.h | 5 ++++ src/crsf_protocol.h | 9 ++++++- 9 files changed, 93 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4afd8d4..1f7c9d5 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,17 @@ void loop() ## Compatibility notes -- **Model match.** If a receiver binds and shows connected but sends nothing at - all over serial, check the model match setting on your handset. A mismatched - model ID makes the receiver suppress its entire serial output, which looks - exactly like a wiring fault. +- **A receiver that binds but sends nothing** is the most common problem, and + it looks exactly like a wiring fault. Three things cause it: + - **Serial output not enabled.** On receivers with configurable IO, such as + the ER series, the pins have to be assigned a serial protocol in the ELRS + configurator before anything comes out of them. + - **Model match.** A mismatched model ID makes the receiver suppress its + entire serial output while still showing as connected. When driving a + transmitter module yourself, send the model ID with `sendModelId()` the way + a handset does, or turn model match off. + - **No radio link.** ELRS 3.x receivers stay silent until they connect to a + transmitter, so bench tests need the transmitter powered and bound. - **The arming status byte** on channel frames is ELRS 4.0 with EdgeTX 2.11 or newer. Sending it to a 3.x transmitter module produces a frame it does not understand. diff --git a/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino b/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino index 8901fba..f2bd12e 100644 --- a/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino +++ b/examples/elrs4ReceiverTest/elrs4ReceiverTest.ino @@ -4,9 +4,6 @@ // with a 4.0 TX/RX pair this sketch should show up in the ExpressLRS Lua // under Other Devices, and a VBAT-sensing receiver should produce a CELLS // frame (source id 128) with millivolt precision. -// -// Also works against a 3.x receiver: the new sensors simply stay at zero and -// the discovery traffic is ignored. #include #include diff --git a/examples/handsetEmulator/handsetEmulator.ino b/examples/handsetEmulator/handsetEmulator.ino index 9786c34..f67a047 100644 --- a/examples/handsetEmulator/handsetEmulator.ino +++ b/examples/handsetEmulator/handsetEmulator.ino @@ -19,6 +19,15 @@ #define ARM_WITH_STATUS_BYTE 1 +// Model ID sent to the module at startup, the way a handset does. +// +// 0xFF means "no model match" and is the safe default: ELRS inverts the model +// ID and masks it to 6 bits before mixing it into the link, so 0xFF cancels +// out and any receiver accepts it. Use 0 to 63 only if you actually run model +// match, and then it has to agree with what the receiver was bound to, or the +// receiver stays connected while going completely silent on its serial port. +#define MODEL_ID 0xFF + HardwareSerial crsfSerial(1); AlfredoCRSF crsf; @@ -37,6 +46,14 @@ void setup() // We are the handset, so extended frames addressed to the radio are for us crsf.begin(crsfSerial, CRSF_ADDRESS_RADIO_TRANSMITTER); + + // Handsets announce the selected model when they connect. Give the module a + // moment to be ready, then tell it. If you power the module up after this + // sketch, reset the board so it hears the model ID. + delay(500); + crsf.sendModelId(MODEL_ID); + Serial.print("Sent model ID "); + Serial.println(MODEL_ID); } void loop() @@ -73,6 +90,7 @@ void loop() void sendChannels() { + // Every channel needs a value inside the valid CRSF range of 172 to 1811. crsf_channels_t ch = { 0 }; ch.ch0 = CRSF_CHANNEL_VALUE_MID; // aileron center ch.ch1 = CRSF_CHANNEL_VALUE_MID; // elevator center @@ -82,6 +100,14 @@ void sendChannels() ch.ch5 = CRSF_CHANNEL_VALUE_1000; ch.ch6 = CRSF_CHANNEL_VALUE_1000; ch.ch7 = CRSF_CHANNEL_VALUE_1000; + ch.ch8 = CRSF_CHANNEL_VALUE_1000; + ch.ch9 = CRSF_CHANNEL_VALUE_1000; + ch.ch10 = CRSF_CHANNEL_VALUE_1000; + ch.ch11 = CRSF_CHANNEL_VALUE_1000; + ch.ch12 = CRSF_CHANNEL_VALUE_1000; + ch.ch13 = CRSF_CHANNEL_VALUE_1000; + ch.ch14 = CRSF_CHANNEL_VALUE_1000; + ch.ch15 = CRSF_CHANNEL_VALUE_1000; #if ARM_WITH_STATUS_BYTE // ELRS 4.0 Arm using Switch: arm state travels in the status byte diff --git a/library.properties b/library.properties index a9880d7..e470544 100644 --- a/library.properties +++ b/library.properties @@ -2,7 +2,7 @@ name=AlfredoCRSF version=2.0.0 author=Jacob Williams maintainer=Jacob Williams (jrw4561@gmail.com) -sentence=CSRF serial protocol Arduino library -paragraph=Use this library to communicate over serial with an ELRS receiver. Get channel data and send telemetry. +sentence=CRSF serial protocol Arduino library +paragraph=Communicate over serial with an ExpressLRS or TBS Crossfire receiver. Read RC channels and link statistics, and send telemetry back to the handset. Works with both ELRS 3.x and 4.x, including the 4.0 telemetry sensors, arming status byte and CRSF router frames. category=Device Control url=https://github.com/AlfredoSystems/AlfredoCRSF \ No newline at end of file diff --git a/src/AlfredoCRSF.cpp b/src/AlfredoCRSF.cpp index 981506a..823b526 100644 --- a/src/AlfredoCRSF.cpp +++ b/src/AlfredoCRSF.cpp @@ -459,6 +459,26 @@ void AlfredoCRSF::writeExtPacket(uint8_t type, uint8_t destAddr, const void *pay writePacket(CRSF_SYNC_BYTE, type, buf, len + 2); } +// Model select is a COMMAND frame, which is an extended header frame with an +// extra payload CRC before the frame CRC: +// [sync][len][type][dest][origin][command][subcommand][model id][crcBA][crc] +void AlfredoCRSF::sendModelId(uint8_t modelId) +{ + uint8_t buf[10]; + buf[0] = CRSF_SYNC_BYTE; + buf[1] = 8; // type, dest, origin, command, subcommand, model id, both CRCs + buf[2] = CRSF_FRAMETYPE_COMMAND; + buf[3] = CRSF_ADDRESS_CRSF_TRANSMITTER; // to the transmitter module + buf[4] = _deviceAddr; // from us, acting as the handset + buf[5] = CRSF_COMMAND_SUBCMD_RX; + buf[6] = CRSF_COMMAND_MODEL_SELECT_ID; + buf[7] = modelId; + // Command frames carry an extra CRC over the payload before the frame CRC + buf[8] = Crc8::calcPoly(&buf[2], 6, CRSF_COMMAND_CRC_POLY); + buf[9] = _crc.calc(&buf[2], 7); + write(buf, sizeof(buf)); +} + void AlfredoCRSF::sendHeartbeat() { // Payload is the origin device address as a big endian int16 diff --git a/src/AlfredoCRSF.h b/src/AlfredoCRSF.h index d1cda0b..badf9e7 100644 --- a/src/AlfredoCRSF.h +++ b/src/AlfredoCRSF.h @@ -38,6 +38,13 @@ class AlfredoCRSF // address to destAddr. payload/len exclude the dest/origin bytes. void writeExtPacket(uint8_t type, uint8_t destAddr, const void *payload, uint8_t len); + // Tell a TX module which model ID is selected, the way a handset does when + // it connects. Only the low 6 bits are significant. Pass 0xFF, the value a + // handset uses for "no model match", unless you actually run model match: + // a mismatched ID leaves the receiver connected but completely silent on + // its serial port. + void sendModelId(uint8_t modelId); + // Announce this device to the CRSF router for device discovery. // Call periodically (e.g. once per second); optional. void sendHeartbeat(); diff --git a/src/crc8.cpp b/src/crc8.cpp index 2612b8d..2ab7fd2 100644 --- a/src/crc8.cpp +++ b/src/crc8.cpp @@ -27,3 +27,17 @@ uint8_t Crc8::calc(uint8_t *data, uint8_t len) } return crc; } + +uint8_t Crc8::calcPoly(const uint8_t *data, uint8_t len, uint8_t poly) +{ + uint8_t crc = 0; + while (len--) + { + crc ^= *data++; + for (int shift = 0; shift < 8; ++shift) + { + crc = (crc << 1) ^ ((crc & 0x80) ? poly : 0); + } + } + return crc; +} diff --git a/src/crc8.h b/src/crc8.h index f8231a0..5b3f98e 100644 --- a/src/crc8.h +++ b/src/crc8.h @@ -8,6 +8,11 @@ class Crc8 Crc8(uint8_t poly); uint8_t calc(uint8_t *data, uint8_t len); + // One shot CRC over an arbitrary polynomial, computed a bit at a time. + // Slower than calc() but needs no lookup table, so it suits polynomials + // that are only used occasionally. + static uint8_t calcPoly(const uint8_t *data, uint8_t len, uint8_t poly); + protected: uint8_t _lut[256]; void init(uint8_t poly); diff --git a/src/crsf_protocol.h b/src/crsf_protocol.h index 83636a4..97d43e1 100644 --- a/src/crsf_protocol.h +++ b/src/crsf_protocol.h @@ -32,6 +32,13 @@ // Subcommand in the first payload byte of a HANDSET (0x3A) frame #define CRSF_HANDSET_SUBCMD_TIMING 0x10 +// COMMAND (0x32) frames: a command byte, then a subcommand, then its data. +// They also carry an extra CRC over the payload using this polynomial, +// placed before the normal frame CRC. +#define CRSF_COMMAND_SUBCMD_RX 0x10 // commands aimed at the receiver +#define CRSF_COMMAND_MODEL_SELECT_ID 0x05 // select model/receiver ID +#define CRSF_COMMAND_CRC_POLY 0xBA + // Flag bits in the ELRS_STATUS flags field #define CRSF_ELRS_FLAG_CONNECTED 0x01 // status: TX connected to an RX #define CRSF_ELRS_FLAG_MODEL_MATCH_WARN 0x04 // warning: model mismatch @@ -79,7 +86,7 @@ typedef enum // CRSF_FRAMETYPE_PARAMETER_READ = 0x2C, //no "flight controller" needs to know about this // CRSF_FRAMETYPE_PARAMETER_WRITE = 0x2D, //no "flight controller" needs to know about this CRSF_FRAMETYPE_ELRS_STATUS = 0x2E, //ELRS good/bad packet count and status flags (extended header frame) - // CRSF_FRAMETYPE_COMMAND = 0x32, //no "flight controller" needs to know about this + CRSF_FRAMETYPE_COMMAND = 0x32, //commands e.g. model select, bind (extended header frame with an extra payload CRC) CRSF_FRAMETYPE_HANDSET = 0x3A, //handset subcommands e.g. timing sync (extended header frame; named RADIO_ID in older firmwares) // KISS frames // CRSF_FRAMETYPE_KISS_REQ = 0x78, //not in edgeTX