From d12265e9760c65b830384368568e8b468a72a04c Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:27:52 +0200 Subject: [PATCH 01/40] io: add an SRXL2 bus master for Spektrum Smart ESCs Spektrum's "Smart Throttle" is SRXL2 on the throttle signal wire: the ESC is an SRXL2 slave (device type 4, IDs 0x40-0x4F) and the receiver, or here the flight controller, is the bus master. Reverse on an Avian is only reachable this way - over plain PWM the ESC has no way to be told - so this is the only route to thrust reverse on INAV. This commit adds the protocol layer alone: framing, CRC, handshake with baud negotiation, Control Data with a channel mask, failsafe, and decoding of STRU_TELE_ESC telemetry. The packet structures are the ones INAV already carries in rx/srxl2_types.h for the receiver side, so both ends of the protocol share a single definition. Two deliberate properties worth not undoing: - The master starts silent. Specification 7.2.1 requires a master sharing its UART with a possible throttle PWM line to wait for a slave handshake rather than speak first, because sending SRXL2 at a non-SRXL2 ESC can cause "unintended movement or erratic behavior". If nothing answers within 200 ms it concludes no SRXL2 device is present and stays quiet. - There is no fallback to PWM. The pin is a UART pin, not a timer output, so "degrade gracefully to PWM" is not available and must not be faked. Channel values use the exact inverse of the scaling rx/srxl2.c already applies when decoding, so 1500 us maps onto 0x8000, the specification's "Servo Center". Not yet wired to motorWritePtr, settings, or esc_sensor; those follow. Builds clean for SITL with -Wall -Wextra -Werror. --- src/main/CMakeLists.txt | 2 + src/main/io/motor_srxl2.c | 509 ++++++++++++++++++++++++++++++++++++++ src/main/io/motor_srxl2.h | 124 ++++++++++ src/main/io/serial.h | 1 + src/main/target/common.h | 2 + 5 files changed, 638 insertions(+) create mode 100644 src/main/io/motor_srxl2.c create mode 100644 src/main/io/motor_srxl2.h diff --git a/src/main/CMakeLists.txt b/src/main/CMakeLists.txt index 3320ada0a25..213c46146dc 100755 --- a/src/main/CMakeLists.txt +++ b/src/main/CMakeLists.txt @@ -389,6 +389,8 @@ main_sources(COMMON_SRC io/adsb.h io/beeper.c io/beeper.h + io/motor_srxl2.c + io/motor_srxl2.h io/servo_sbus.c io/servo_sbus.h io/frsky_osd.c diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c new file mode 100644 index 00000000000..fec3249cfed --- /dev/null +++ b/src/main/io/motor_srxl2.c @@ -0,0 +1,509 @@ +/* + * This file is part of INAV. + * + * INAV is free software. You can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +/* + * SRXL2 bus master for Spektrum Smart ESCs ("Smart Throttle"). + * + * Wire format and state machine follow "Specification for Spektrum SRXL2", + * Rev K (https://github.com/SpektrumRC/SRXL2). Packet structures are the ones + * INAV already carries in rx/srxl2_types.h for the receiver side, so the two + * ends of the protocol share one definition. + */ + +#include +#include +#include + +#include "platform.h" + +#ifdef USE_MOTOR_SRXL2 + +#include "build/debug.h" + +#include "common/crc.h" +#include "common/maths.h" +#include "common/utils.h" + +#include "drivers/time.h" + +#include "io/serial.h" +#include "io/motor_srxl2.h" + +#include "rx/srxl2_types.h" + +/*--------------------------------------------------------------------------- + * Wire constants + *-------------------------------------------------------------------------*/ + +#define SRXL2_MAGIC 0xA6 +#define SRXL2_MAX_FRAME 80 + +#define SRXL2_BAUD_LOW 115200 +#define SRXL2_BAUD_HIGH 400000 +#define SRXL2_BAUD_BIT_400K 0x01 /* baudSupported bit for 400000 */ + +#define SRXL2_PORT_OPTIONS (SERIAL_STOPBITS_1 | SERIAL_PARITY_NO | SERIAL_BIDIR) + +/* Device IDs. rx/srxl2_types.h names the flight controller range; the ESC range + * is the adjacent one in the same table of the specification. */ +#define SRXL2_ESC_ID_FIRST 0x40 +#define SRXL2_ESC_ID_LAST 0x4F +#define SRXL2_OUR_DEVICE_ID FlightControllerDefault /* 0x30 */ + +/* Control Data commands */ +#define SRXL2_CMD_CHANNEL_DATA 0x00 +#define SRXL2_CMD_CHANNEL_FAILSAFE 0x01 + +/* X-Bus telemetry sensor IDs */ +#define SRXL2_TELEM_SENSOR_ESC 0x20 + +/* + * Specification 7.2.1: a master that shares its UART line with a throttle PWM + * line must not speak first. It waits for a slave handshake, and if none + * arrives within this window it concludes there is no SRXL2 device present. + * Sending a handshake into a plain PWM ESC is explicitly called out in the spec + * as able to cause "unintended movement or erratic behavior", so this silence + * is a safety property, not an optimisation. + */ +#define SRXL2_LISTEN_WINDOW_MS 200 + +/* Spec 7.2.1 step 3: an unprompted slave repeats its handshake every 50 ms. */ +#define SRXL2_HANDSHAKE_RETRY_MS 50 + +/* Declare the link dead if the ESC stops answering for this long. */ +#define SRXL2_LINK_TIMEOUT_MS 500 + +/* Telemetry older than this is reported as stale rather than current. */ +#define SRXL2_TELEM_STALE_MS 1000 + +/* + * Channel value scaling, the exact inverse of what rx/srxl2.c applies when it + * decodes channel data: us = 988 + (value >> 6). 1500 us therefore maps onto + * 0x8000, which the specification calls "Servo Center". + */ +#define SRXL2_PULSE_OFFSET_US 988 +#define SRXL2_PULSE_SHIFT 6 + +/* Throttle is channel index 0 by Spektrum convention. */ +#define SRXL2_CHANNEL_THROTTLE 0 + +/*--------------------------------------------------------------------------- + * State + *-------------------------------------------------------------------------*/ + +typedef enum { + SRXL2_MOTOR_DISABLED = 0, + SRXL2_MOTOR_LISTENING, /* silent, waiting for a slave to announce itself */ + SRXL2_MOTOR_ABSENT, /* listen window expired, no SRXL2 device on the wire */ + SRXL2_MOTOR_HANDSHAKING, + SRXL2_MOTOR_RUNNING, +} srxl2MotorState_e; + +typedef struct { + uint8_t buf[SRXL2_MAX_FRAME]; + uint8_t len; + uint8_t expected; /* 0 until the length byte has been seen */ +} srxl2RxAssembler_t; + +static serialPort_t *srxl2Port = NULL; +static srxl2MotorState_e srxl2State = SRXL2_MOTOR_DISABLED; +static srxl2RxAssembler_t rxAsm; + +static timeMs_t stateEnteredMs; +static timeMs_t lastRxMs; +static timeMs_t lastHandshakeTxMs; + +static uint8_t escDeviceId; /* 0 until discovered */ +static uint8_t escBaudSupported; +static uint32_t negotiatedBaud = SRXL2_BAUD_LOW; + +static uint16_t channelValue[32]; +static uint32_t channelMask; +static bool failsafeActive; +static uint8_t reverseChannel1Based = 5; /* Avian default: "Thrust Rev." = CH5 */ + +static srxl2EscTelemetry_t escTelemetry; + +/* Diagnostics, surfaced through DEBUG_SET so a bench session can see the link + * without a debugger attached. */ +static uint32_t statTxFrames, statRxFrames, statCrcErrors, statHandshakes; + +/*--------------------------------------------------------------------------- + * Helpers + *-------------------------------------------------------------------------*/ + +static inline uint16_t srxl2UsToValue(uint16_t us) +{ + if (us < SRXL2_PULSE_OFFSET_US) { + us = SRXL2_PULSE_OFFSET_US; + } + const uint32_t v = ((uint32_t)(us - SRXL2_PULSE_OFFSET_US)) << SRXL2_PULSE_SHIFT; + return (v > 0xFFFC) ? 0xFFFC : (uint16_t)v; +} + +static void srxl2SetState(srxl2MotorState_e next) +{ + srxl2State = next; + stateEnteredMs = millis(); +} + +/* Append the CRC and push the frame. Payload length must already be written + * into buf[2] per the specification's framing. */ +static void srxl2SendFrame(uint8_t *buf, uint8_t len) +{ + if (!srxl2Port || len < 5 || len > SRXL2_MAX_FRAME) { + return; + } + + const uint16_t crc = crc16_ccitt_update(0, buf, len - 2); + buf[len - 2] = (uint8_t)(crc >> 8); + buf[len - 1] = (uint8_t)(crc & 0xFF); + + serialWriteBuf(srxl2Port, buf, len); + statTxFrames++; +} + +static void srxl2SendHandshake(uint8_t destinationId, uint8_t baudSupported) +{ + uint8_t buf[sizeof(Srxl2HandshakeFrame)]; + Srxl2HandshakeFrame *f = (Srxl2HandshakeFrame *)buf; + + f->header.id = SRXL2_MAGIC; + f->header.packetType = Handshake; + f->header.length = sizeof(Srxl2HandshakeFrame); + + f->payload.sourceDeviceId = SRXL2_OUR_DEVICE_ID; + f->payload.destinationDeviceId = destinationId; + f->payload.priority = 10; + f->payload.baudSupported = baudSupported; + f->payload.info = 0; /* non-RF device, no telemetry over RF */ + /* Unique ID only has to make a simultaneous-reply collision improbable. */ + f->payload.uniqueId = 0x494E4156; /* "INAV" */ + + srxl2SendFrame(buf, sizeof(Srxl2HandshakeFrame)); +} + +/*--------------------------------------------------------------------------- + * Telemetry decoding: STRU_TELE_ESC, big-endian on the wire + *-------------------------------------------------------------------------*/ + +static inline uint16_t be16(const uint8_t *p) +{ + return (uint16_t)((p[0] << 8) | p[1]); +} + +static void srxl2DecodeEscTelemetry(const uint8_t *payload) +{ + /* payload[0] is the sensor id, payload[1] a secondary id. */ + const uint16_t rpm = be16(&payload[2]); + const uint16_t voltsIn = be16(&payload[4]); + const uint16_t tempFet = be16(&payload[6]); + const uint16_t currentMot = be16(&payload[8]); + const uint16_t tempBec = be16(&payload[10]); + const uint8_t currentBec = payload[12]; + const uint8_t voltsBec = payload[13]; + const uint8_t throttle = payload[14]; + const uint8_t powerOut = payload[15]; + + memset(&escTelemetry, 0, sizeof(escTelemetry)); + + /* 0xFFFF / 0xFF mean "no data" and must not be mistaken for a reading. */ + if (rpm != 0xFFFF) { escTelemetry.rpm = (uint32_t)rpm * 10; } + if (voltsIn != 0xFFFF) { escTelemetry.voltage = voltsIn; } /* already 0.01 V */ + if (currentMot != 0xFFFF) { escTelemetry.current = currentMot; } /* 10 mA == 0.01 A */ + if (tempFet != 0xFFFF) { escTelemetry.temperatureFet = (int16_t)tempFet; } /* 0.1 degC */ + if (tempBec != 0xFFFF) { escTelemetry.temperatureBec = (int16_t)tempBec; } + if (currentBec != 0xFF) { escTelemetry.currentBec = (uint16_t)currentBec * 10; } /* 100 mA -> 0.01 A */ + if (voltsBec != 0xFF) { escTelemetry.voltageBec = (uint16_t)voltsBec * 5; } /* 0.05 V -> 0.01 V */ + if (throttle != 0xFF) { escTelemetry.throttlePercent = MIN((uint8_t)(throttle / 2), 100); } + if (powerOut != 0xFF) { escTelemetry.powerPercent = MIN((uint8_t)(powerOut / 2), 100); } + + escTelemetry.lastUpdateMs = millis(); + escTelemetry.valid = true; +} + +/*--------------------------------------------------------------------------- + * Received frame handling + *-------------------------------------------------------------------------*/ + +static void srxl2HandleHandshake(const uint8_t *buf) +{ + const Srxl2HandshakeFrame *f = (const Srxl2HandshakeFrame *)buf; + const uint8_t src = f->payload.sourceDeviceId; + + if (src < SRXL2_ESC_ID_FIRST || src > SRXL2_ESC_ID_LAST) { + /* Something else on the bus. We only drive ESCs here. */ + return; + } + + escDeviceId = src; + escBaudSupported = f->payload.baudSupported; + statHandshakes++; + + /* Answer the slave, then tell the whole bus which baud rate we settled on. + * Only claim 400000 if both ends offer it. */ + const uint8_t baud = (escBaudSupported & SRXL2_BAUD_BIT_400K) ? SRXL2_BAUD_BIT_400K : 0; + + srxl2SendHandshake(escDeviceId, baud); + srxl2SendHandshake(Broadcast, baud); + + negotiatedBaud = baud ? SRXL2_BAUD_HIGH : SRXL2_BAUD_LOW; + if (negotiatedBaud != SRXL2_BAUD_LOW) { + serialSetBaudRate(srxl2Port, negotiatedBaud); + } + + srxl2SetState(SRXL2_MOTOR_RUNNING); +} + +static void srxl2HandleTelemetry(const uint8_t *buf, uint8_t len) +{ + /* Telemetry packet: header(3) + destDeviceId(1) + 16 byte payload + crc(2) */ + if (len < 3 + 1 + 16 + 2) { + return; + } + const uint8_t *payload = &buf[4]; + if (payload[0] == SRXL2_TELEM_SENSOR_ESC) { + srxl2DecodeEscTelemetry(payload); + } +} + +static void srxl2HandleFrame(const uint8_t *buf, uint8_t len) +{ + const uint16_t crc = crc16_ccitt_update(0, buf, len - 2); + if (buf[len - 2] != (uint8_t)(crc >> 8) || buf[len - 1] != (uint8_t)(crc & 0xFF)) { + statCrcErrors++; + return; + } + + statRxFrames++; + lastRxMs = millis(); + + switch (buf[1]) { + case Handshake: + srxl2HandleHandshake(buf); + break; + case TelemetrySensorData: + srxl2HandleTelemetry(buf, len); + break; + default: + break; + } +} + +static void srxl2DrainRx(void) +{ + while (serialRxBytesWaiting(srxl2Port)) { + const uint8_t c = serialRead(srxl2Port); + + if (rxAsm.len == 0) { + if (c != SRXL2_MAGIC) { + continue; /* resynchronise on the magic byte */ + } + rxAsm.expected = 0; + } + + rxAsm.buf[rxAsm.len++] = c; + + if (rxAsm.len == 3) { + rxAsm.expected = rxAsm.buf[2]; + if (rxAsm.expected < 5 || rxAsm.expected > SRXL2_MAX_FRAME) { + rxAsm.len = 0; /* bogus length, drop and resynchronise */ + continue; + } + } + + if (rxAsm.expected && rxAsm.len >= rxAsm.expected) { + srxl2HandleFrame(rxAsm.buf, rxAsm.len); + rxAsm.len = 0; + rxAsm.expected = 0; + } + } +} + +/*--------------------------------------------------------------------------- + * Public API + *-------------------------------------------------------------------------*/ + +bool srxl2MotorInitialize(void) +{ + const serialPortConfig_t *portConfig = findSerialPortConfig(FUNCTION_ESC_SRXL2); + if (!portConfig) { + return false; + } + + srxl2Port = openSerialPort(portConfig->identifier, FUNCTION_ESC_SRXL2, NULL, NULL, + SRXL2_BAUD_LOW, MODE_RXTX, SRXL2_PORT_OPTIONS); + if (!srxl2Port) { + return false; + } + + memset(&rxAsm, 0, sizeof(rxAsm)); + memset(&escTelemetry, 0, sizeof(escTelemetry)); + memset(channelValue, 0, sizeof(channelValue)); + channelMask = 0; + escDeviceId = 0; + failsafeActive = false; + negotiatedBaud = SRXL2_BAUD_LOW; + + /* Silent until a slave speaks - see SRXL2_LISTEN_WINDOW_MS. */ + srxl2SetState(SRXL2_MOTOR_LISTENING); + lastRxMs = millis(); + + return true; +} + +void srxl2MotorUpdate(uint8_t index, uint16_t value) +{ + if (index >= SRXL2_ESC_MAX_MOTORS) { + return; + } + /* One ESC per bus, so motor 0 is the throttle channel. */ + channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(value); + channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); +} + +void srxl2MotorSetReverse(bool armed) +{ + if (reverseChannel1Based == 0) { + return; /* reverse not configured */ + } + const uint8_t idx = reverseChannel1Based - 1; + if (idx >= 32) { + return; + } + channelValue[idx] = srxl2UsToValue(armed ? 2000 : 1000); + channelMask |= (1u << idx); +} + +void srxl2MotorSetReverseChannel(uint8_t channel1Based) +{ + reverseChannel1Based = channel1Based; +} + +void srxl2MotorSetFailsafe(bool failsafe) +{ + failsafeActive = failsafe; +} + +void srxl2MotorSendUpdate(void) +{ + if (srxl2State != SRXL2_MOTOR_RUNNING || !escDeviceId) { + return; + } + + uint8_t buf[SRXL2_MAX_FRAME]; + uint8_t n = 0; + + buf[n++] = SRXL2_MAGIC; + buf[n++] = ControlData; + buf[n++] = 0; /* length, patched below */ + buf[n++] = failsafeActive ? SRXL2_CMD_CHANNEL_FAILSAFE : SRXL2_CMD_CHANNEL_DATA; + buf[n++] = escDeviceId; /* replyId: ask this ESC for telemetry */ + + buf[n++] = 0; /* rssi: we are not an RF device */ + buf[n++] = 0; /* frameLosses low */ + buf[n++] = 0; /* frameLosses high */ + + buf[n++] = (uint8_t)(channelMask & 0xFF); + buf[n++] = (uint8_t)((channelMask >> 8) & 0xFF); + buf[n++] = (uint8_t)((channelMask >> 16) & 0xFF); + buf[n++] = (uint8_t)((channelMask >> 24) & 0xFF); + + for (uint8_t ch = 0; ch < 32; ch++) { + if (channelMask & (1u << ch)) { + buf[n++] = (uint8_t)(channelValue[ch] & 0xFF); + buf[n++] = (uint8_t)(channelValue[ch] >> 8); + } + } + + n += 2; /* room for the CRC */ + buf[2] = n; + srxl2SendFrame(buf, n); +} + +void srxl2MotorProcess(void) +{ + if (!srxl2Port || srxl2State == SRXL2_MOTOR_DISABLED) { + return; + } + + srxl2DrainRx(); + + const timeMs_t now = millis(); + + switch (srxl2State) { + case SRXL2_MOTOR_LISTENING: + /* Deliberately transmit nothing. If the window closes without a slave + * handshake, there is no SRXL2 device here and we stay quiet for good + * rather than risk driving a PWM ESC with serial data. */ + if (now - stateEnteredMs >= SRXL2_LISTEN_WINDOW_MS) { + srxl2SetState(SRXL2_MOTOR_ABSENT); + } + break; + + case SRXL2_MOTOR_ABSENT: + /* A slave can still appear later - it repeats its handshake on reset - + * and handling that costs nothing, so keep listening without speaking. */ + break; + + case SRXL2_MOTOR_HANDSHAKING: + if (now - lastHandshakeTxMs >= SRXL2_HANDSHAKE_RETRY_MS) { + lastHandshakeTxMs = now; + srxl2SendHandshake(Broadcast, SRXL2_BAUD_BIT_400K); + } + break; + + case SRXL2_MOTOR_RUNNING: + if (now - lastRxMs >= SRXL2_LINK_TIMEOUT_MS) { + /* Lost the ESC. Drop back to 115200, which is where a slave that + * has just reset will be listening, and wait for it again. */ + serialSetBaudRate(srxl2Port, SRXL2_BAUD_LOW); + negotiatedBaud = SRXL2_BAUD_LOW; + escDeviceId = 0; + escTelemetry.valid = false; + srxl2SetState(SRXL2_MOTOR_LISTENING); + } + break; + + default: + break; + } + + DEBUG_SET(DEBUG_ALWAYS, 0, srxl2State); + DEBUG_SET(DEBUG_ALWAYS, 1, escDeviceId); + DEBUG_SET(DEBUG_ALWAYS, 2, statRxFrames); + DEBUG_SET(DEBUG_ALWAYS, 3, statCrcErrors); +} + +bool srxl2MotorIsConnected(void) +{ + return srxl2State == SRXL2_MOTOR_RUNNING && escDeviceId != 0; +} + +bool srxl2MotorGetTelemetry(uint8_t index, srxl2EscTelemetry_t *out) +{ + if (index >= SRXL2_ESC_MAX_MOTORS || !out || !escTelemetry.valid) { + return false; + } + if (millis() - escTelemetry.lastUpdateMs > SRXL2_TELEM_STALE_MS) { + return false; + } + *out = escTelemetry; + return true; +} + +#endif /* USE_MOTOR_SRXL2 */ diff --git a/src/main/io/motor_srxl2.h b/src/main/io/motor_srxl2.h new file mode 100644 index 00000000000..5b4442c0394 --- /dev/null +++ b/src/main/io/motor_srxl2.h @@ -0,0 +1,124 @@ +/* + * This file is part of INAV. + * + * INAV is free software. You can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +/* + * SRXL2 motor output: acts as the SRXL2 bus master towards a Spektrum Smart + * ESC, which is what "Smart Throttle" is on the wire. + * + * Unlike every other motor protocol in INAV, this one is a UART protocol rather + * than a timer waveform, so the ESC signal wire goes to a serial pin assigned + * FUNCTION_ESC_SRXL2 and not to a motor pad. The structure deliberately mirrors + * io/servo_sbus.c, which is the existing precedent for a serial protocol + * driving outputs. + * + * Protocol reference: "Specification for Spektrum SRXL2", Rev K + * (https://github.com/SpektrumRC/SRXL2, MIT). Half-duplex single wire, + * 115200 baud for the handshake, optionally negotiated up to 400000. + */ + +#pragma once + +#include +#include + +/* The spec assigns ESCs device IDs 0x40..0x4F, so a bus can carry several. + * In practice the unit ID has to be set on the ESC by physical means and the + * spec states that setting it over SRXL2 "is not implemented", so one ESC per + * bus is the realistic case. Kept as a constant rather than a literal so the + * limit is visible if that ever changes. */ +#define SRXL2_ESC_MAX_MOTORS 1 + +/* Decoded ESC telemetry, from STRU_TELE_ESC (X-Bus sensor ID 0x20). + * Units are INAV's, not the wire's: the wire sends 10 rpm, 0.01 V, 10 mA and + * 0.1 degree steps, and this struct is already converted. + * A field the ESC reports as "no data" (0xFFFF / 0xFF) is left at 0 and the + * matching valid bit is cleared. */ +typedef struct { + uint32_t rpm; /* electrical rpm */ + uint16_t voltage; /* 0.01 V */ + uint16_t current; /* 0.01 A */ + int16_t temperatureFet; /* 0.1 degC */ + int16_t temperatureBec; /* 0.1 degC */ + uint16_t currentBec; /* 0.01 A */ + uint16_t voltageBec; /* 0.01 V */ + uint8_t throttlePercent; /* 0..100 */ + uint8_t powerPercent; /* 0..100 */ + uint32_t lastUpdateMs; + bool valid; +} srxl2EscTelemetry_t; + +/* + * Open the serial port assigned FUNCTION_ESC_SRXL2 and start the handshake. + * Returns false if no port is assigned or it could not be opened, in which case + * the caller must fall back to leaving the motors unwritten - this protocol has + * no silent degradation to PWM, because the pin is not a timer output. + */ +bool srxl2MotorInitialize(void); + +/* + * Stage one motor value. Takes microseconds on INAV's usual 1000..2000 scale + * (or the reversible-motor scale, where the neutral sits in the middle), so it + * is interchangeable with pwmWriteMotor() as a motorWritePtr target. + * Staging only: nothing reaches the wire until srxl2MotorSendUpdate(). + */ +void srxl2MotorUpdate(uint8_t index, uint16_t value); + +/* + * Arm or release the Thrust Reverse channel. + * + * Reverse on an Avian is not a sub-neutral throttle value: the ESC's + * "Thrust Rev." parameter selects an auxiliary channel (named CH5..CH9 on a + * Spektrum transmitter) that arms the Reverse Brake, and Brake Type must be set + * to Reverse. So the master has to send that channel too, and which one it is + * has to match how the ESC was programmed. + */ +void srxl2MotorSetReverse(bool armed); + +/* + * Which auxiliary channel arms the Reverse Brake, given as the 1-based channel + * number the ESC is programmed with (the Avian offers CH5..CH9). Zero disables + * reverse entirely. Must match the ESC's "Thrust Rev." setting, because nothing + * on the wire advertises it. + */ +void srxl2MotorSetReverseChannel(uint8_t channel1Based); + +/* + * Emit the staged values as an SRXL2 Control Data packet. Intended to be called + * once per motor update from pwmCompleteMotorUpdate(). + */ +void srxl2MotorSendUpdate(void); + +/* + * Drain the receive buffer and advance the master state machine: handshake, + * baud negotiation, telemetry collection, timeout recovery. Must be called + * regularly from task context, not from an ISR. + */ +void srxl2MotorProcess(void); + +/* Mirror INAV's failsafe state onto the bus, so the ESC applies its own + * failsafe behaviour rather than continuing with the last value. */ +void srxl2MotorSetFailsafe(bool failsafe); + +/* True once an ESC has answered the handshake and is still responding. */ +bool srxl2MotorIsConnected(void); + +/* + * Latest telemetry for a motor. Returns false if no ESC is bound to that index + * or nothing has been received yet; the caller should not read *out in that + * case. Feeds sensors/esc_sensor.c. + */ +bool srxl2MotorGetTelemetry(uint8_t index, srxl2EscTelemetry_t *out); diff --git a/src/main/io/serial.h b/src/main/io/serial.h index 36f2e02328a..dbd0ba42e4d 100644 --- a/src/main/io/serial.h +++ b/src/main/io/serial.h @@ -59,6 +59,7 @@ typedef enum { FUNCTION_MSP_OSD = (1 << 25), // 33554432 FUNCTION_GIMBAL = (1 << 26), // 67108864 FUNCTION_GIMBAL_HEADTRACKER = (1 << 27), // 134217728 + FUNCTION_ESC_SRXL2 = (1 << 28), // 268435456: Spektrum Smart ESC (Smart Throttle) } serialPortFunction_e; #define FUNCTION_VTX_MSP FUNCTION_MSP_OSD diff --git a/src/main/target/common.h b/src/main/target/common.h index 5386acf7927..035c759ec99 100644 --- a/src/main/target/common.h +++ b/src/main/target/common.h @@ -74,6 +74,8 @@ #define USE_SERVO_SBUS #endif +#define USE_MOTOR_SRXL2 // Spektrum Smart ESC (Smart Throttle) motor output + #ifndef USE_ADC_AVERAGING #define USE_ADC_AVERAGING #endif From cb103f6a5173252fec1953e0e5e45b7a56763d8f Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:22:04 +0200 Subject: [PATCH 02/40] io: fix five SRXL2 master defects the specification does not warn about Reviewing how existing bus-master implementations behave against real Smart ESCs surfaced five things the specification leaves implicit, all of which the first version got wrong: 1. Device ID. We announced ourselves as 0x30, unit ID 0. Specification 7.1.1 gives unit 0 the meaning "send an unprompted handshake at startup", which is slave behaviour: a master doing it collides with the ESC's own announcements. Now 0x31. 2. Never finding a quiet ESC. The master listened and, hearing nothing, stayed silent for good. Only an ESC with unit ID 0 announces itself, so an ESC configured otherwise would never have been found. It now listens first, to keep out of the way of the common case, then polls the ESC device ID. 3. Baud switch losing a frame. serialSetBaudRate() was called immediately after queueing the broadcast that tells the bus to change rate, so the tail of that very frame would have been clocked out at the new rate. The change is now deferred until isSerialTransmitBufferEmpty(). 4. Control Data at the wrong rate. It was emitted per motor update, i.e. at loop rate. The specification has the master send at the rate RF frames arrive, tens of hertz, and 115200 baud could not carry loop rate anyway - one frame is about 1.6 ms on the wire. Now rate-limited to 50 Hz, independent of the mixer. 5. Telemetry requested on every frame. Telemetry is a reply, so asking every frame doubles bus occupancy and forces a half-duplex turnaround each time for values that barely change. Now every fifth frame, giving 10 Hz. Also handles the ESC powering up after the flight controller, which is the normal bench case: the board runs on USB and the ESC only boots with the battery, long after the listen window closed, so its handshake arrives mid-RUNNING and has to be honoured rather than ignored. Builds clean for SITL with -Wall -Wextra -Werror. --- src/main/io/motor_srxl2.c | 383 +++++++++++++++++++++++--------------- 1 file changed, 238 insertions(+), 145 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index fec3249cfed..36e9ca8a607 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -18,10 +18,17 @@ /* * SRXL2 bus master for Spektrum Smart ESCs ("Smart Throttle"). * - * Wire format and state machine follow "Specification for Spektrum SRXL2", + * Wire format and session handling follow "Specification for Spektrum SRXL2", * Rev K (https://github.com/SpektrumRC/SRXL2). Packet structures are the ones - * INAV already carries in rx/srxl2_types.h for the receiver side, so the two - * ends of the protocol share one definition. + * INAV already carries in rx/srxl2_types.h for the receiver side, so both ends + * of the protocol share a single definition. + * + * The specification describes the bus; it says nothing about any particular ESC. + * Everything an individual ESC decides for itself - which channel it reads as + * throttle, how an auxiliary channel arms reverse, whether it offers 400000 baud + * - is deliberately confined to the named constants and the reverse-channel + * setter below, so the places that need confirming against real hardware are + * countable rather than scattered. */ #include @@ -38,6 +45,7 @@ #include "common/maths.h" #include "common/utils.h" +#include "drivers/serial.h" #include "drivers/time.h" #include "io/serial.h" @@ -51,6 +59,7 @@ #define SRXL2_MAGIC 0xA6 #define SRXL2_MAX_FRAME 80 +#define SRXL2_MIN_FRAME 5 #define SRXL2_BAUD_LOW 115200 #define SRXL2_BAUD_HIGH 400000 @@ -58,31 +67,59 @@ #define SRXL2_PORT_OPTIONS (SERIAL_STOPBITS_1 | SERIAL_PARITY_NO | SERIAL_BIDIR) -/* Device IDs. rx/srxl2_types.h names the flight controller range; the ESC range - * is the adjacent one in the same table of the specification. */ +/* ESC device IDs. rx/srxl2_types.h names the flight controller range; this is + * the adjacent range in the same table of the specification. */ #define SRXL2_ESC_ID_FIRST 0x40 #define SRXL2_ESC_ID_LAST 0x4F -#define SRXL2_OUR_DEVICE_ID FlightControllerDefault /* 0x30 */ + +/* + * Our own device ID: flight controller type, unit ID 1. + * + * Not unit 0. Specification 7.1.1: a device whose lower nibble is 0 announces + * itself with an unprompted handshake at startup. That behaviour belongs to the + * slave, and a bus master that announced itself would both collide with the + * ESC's own announcements and break the silence the next comment describes. + */ +#define SRXL2_OUR_DEVICE_ID 0x31 /* Control Data commands */ #define SRXL2_CMD_CHANNEL_DATA 0x00 #define SRXL2_CMD_CHANNEL_FAILSAFE 0x01 +/* Reply ID 0x00 means "no reply wanted" (specification 7.1.1). */ +#define SRXL2_REPLY_NONE 0x00 + /* X-Bus telemetry sensor IDs */ #define SRXL2_TELEM_SENSOR_ESC 0x20 /* - * Specification 7.2.1: a master that shares its UART line with a throttle PWM - * line must not speak first. It waits for a slave handshake, and if none - * arrives within this window it concludes there is no SRXL2 device present. - * Sending a handshake into a plain PWM ESC is explicitly called out in the spec - * as able to cause "unintended movement or erratic behavior", so this silence - * is a safety property, not an optimisation. + * Specification 7.2.1: an ESC with unit ID 0 repeats an unprompted handshake + * every 50 ms for the first 200 ms after reset. Stay silent for slightly longer + * than that so the common case is discovered without contending with those + * announcements, then start polling - an ESC configured with a non-zero unit ID + * never announces itself and would otherwise never be found. */ -#define SRXL2_LISTEN_WINDOW_MS 200 +#define SRXL2_LISTEN_WINDOW_MS 250 +#define SRXL2_HANDSHAKE_INTERVAL_MS 50 -/* Spec 7.2.1 step 3: an unprompted slave repeats its handshake every 50 ms. */ -#define SRXL2_HANDSHAKE_RETRY_MS 50 +/* + * Rate at which Control Data goes out once the link is up. + * + * Not the motor update rate. The specification has the master emit one Control + * Data packet at the rate RF frames arrive, which is tens of hertz, and an ESC + * is not expecting kilohertz. 115200 baud would not carry it either: an + * eighteen-byte frame is about 1.6 ms on the wire. + */ +#define SRXL2_CONTROL_INTERVAL_MS 20 /* 50 Hz */ + +/* + * Ask for telemetry on every Nth Control Data packet rather than on all of them. + * Telemetry is a reply, so requesting it every frame doubles bus occupancy and + * forces a half-duplex turnaround each time, for values that change slowly. + * Every fifth frame at 50 Hz gives 10 Hz, which is in line with what INAV's + * other ESC telemetry backends deliver. + */ +#define SRXL2_TELEM_REQUEST_EVERY 5 /* Declare the link dead if the ESC stops answering for this long. */ #define SRXL2_LINK_TIMEOUT_MS 500 @@ -97,8 +134,10 @@ */ #define SRXL2_PULSE_OFFSET_US 988 #define SRXL2_PULSE_SHIFT 6 +#define SRXL2_VALUE_MAX 0xFFFC /* specification caps values here */ -/* Throttle is channel index 0 by Spektrum convention. */ +/* Throttle is channel index 0 by Spektrum convention. Unverified against an + * actual ESC: this is one of the constants to confirm on a bench. */ #define SRXL2_CHANNEL_THROTTLE 0 /*--------------------------------------------------------------------------- @@ -106,41 +145,39 @@ *-------------------------------------------------------------------------*/ typedef enum { - SRXL2_MOTOR_DISABLED = 0, - SRXL2_MOTOR_LISTENING, /* silent, waiting for a slave to announce itself */ - SRXL2_MOTOR_ABSENT, /* listen window expired, no SRXL2 device on the wire */ - SRXL2_MOTOR_HANDSHAKING, - SRXL2_MOTOR_RUNNING, -} srxl2MotorState_e; - -typedef struct { - uint8_t buf[SRXL2_MAX_FRAME]; - uint8_t len; - uint8_t expected; /* 0 until the length byte has been seen */ -} srxl2RxAssembler_t; - -static serialPort_t *srxl2Port = NULL; -static srxl2MotorState_e srxl2State = SRXL2_MOTOR_DISABLED; -static srxl2RxAssembler_t rxAsm; + SRXL2_DISABLED = 0, + SRXL2_LISTENING, /* silent, giving an auto-announcing ESC room to speak */ + SRXL2_POLLING, /* actively asking the ESC device ID to answer */ + SRXL2_FINALISING, /* broadcasting the agreed baud rate */ + SRXL2_RUNNING, +} srxl2State_e; + +static serialPort_t *srxl2Port = NULL; +static srxl2State_e srxl2State = SRXL2_DISABLED; + +static uint8_t rxBuf[SRXL2_MAX_FRAME]; +static uint8_t rxLen; +static uint8_t rxExpected; static timeMs_t stateEnteredMs; static timeMs_t lastRxMs; -static timeMs_t lastHandshakeTxMs; +static timeMs_t lastTxMs; +static timeMs_t lastControlMs; -static uint8_t escDeviceId; /* 0 until discovered */ +static uint8_t escDeviceId; /* 0 until discovered */ static uint8_t escBaudSupported; -static uint32_t negotiatedBaud = SRXL2_BAUD_LOW; +static uint8_t agreedBaudBits; +static bool baudSwitchPending; /* waiting for TX to drain */ static uint16_t channelValue[32]; static uint32_t channelMask; static bool failsafeActive; -static uint8_t reverseChannel1Based = 5; /* Avian default: "Thrust Rev." = CH5 */ +static uint8_t reverseChannel1Based = 5; /* Avian "Thrust Rev." default: CH5 */ +static uint8_t telemRequestCounter; static srxl2EscTelemetry_t escTelemetry; -/* Diagnostics, surfaced through DEBUG_SET so a bench session can see the link - * without a debugger attached. */ -static uint32_t statTxFrames, statRxFrames, statCrcErrors, statHandshakes; +static uint32_t statTxFrames, statRxFrames, statCrcErrors, statHandshakes; /*--------------------------------------------------------------------------- * Helpers @@ -152,20 +189,25 @@ static inline uint16_t srxl2UsToValue(uint16_t us) us = SRXL2_PULSE_OFFSET_US; } const uint32_t v = ((uint32_t)(us - SRXL2_PULSE_OFFSET_US)) << SRXL2_PULSE_SHIFT; - return (v > 0xFFFC) ? 0xFFFC : (uint16_t)v; + return (v > SRXL2_VALUE_MAX) ? SRXL2_VALUE_MAX : (uint16_t)v; +} + +static inline uint16_t be16(const uint8_t *p) +{ + return (uint16_t)((p[0] << 8) | p[1]); } -static void srxl2SetState(srxl2MotorState_e next) +static void srxl2SetState(srxl2State_e next) { srxl2State = next; stateEnteredMs = millis(); } -/* Append the CRC and push the frame. Payload length must already be written - * into buf[2] per the specification's framing. */ +/* Append the CRC and push the frame. buf[2] must already hold the frame length + * as the specification's framing requires. */ static void srxl2SendFrame(uint8_t *buf, uint8_t len) { - if (!srxl2Port || len < 5 || len > SRXL2_MAX_FRAME) { + if (!srxl2Port || len < SRXL2_MIN_FRAME || len > SRXL2_MAX_FRAME) { return; } @@ -174,10 +216,11 @@ static void srxl2SendFrame(uint8_t *buf, uint8_t len) buf[len - 1] = (uint8_t)(crc & 0xFF); serialWriteBuf(srxl2Port, buf, len); + lastTxMs = millis(); statTxFrames++; } -static void srxl2SendHandshake(uint8_t destinationId, uint8_t baudSupported) +static void srxl2SendHandshake(uint8_t destinationId, uint8_t baudField) { uint8_t buf[sizeof(Srxl2HandshakeFrame)]; Srxl2HandshakeFrame *f = (Srxl2HandshakeFrame *)buf; @@ -189,23 +232,32 @@ static void srxl2SendHandshake(uint8_t destinationId, uint8_t baudSupported) f->payload.sourceDeviceId = SRXL2_OUR_DEVICE_ID; f->payload.destinationDeviceId = destinationId; f->payload.priority = 10; - f->payload.baudSupported = baudSupported; - f->payload.info = 0; /* non-RF device, no telemetry over RF */ - /* Unique ID only has to make a simultaneous-reply collision improbable. */ - f->payload.uniqueId = 0x494E4156; /* "INAV" */ + /* When polling a specific device this advertises what we can do; in the + * broadcast it states what every device must switch to. */ + f->payload.baudSupported = baudField; + f->payload.info = 0; /* non-RF device, no RF telemetry */ + f->payload.uniqueId = 0x494E4156; /* "INAV"; only has to make a + * simultaneous-reply collision + * improbable */ srxl2SendFrame(buf, sizeof(Srxl2HandshakeFrame)); } +/* Tell the bus which rate everyone moves to, and arrange to follow once the + * frame has actually left the port. Switching immediately would clock the tail + * of that very frame out at the new rate and lose it. */ +static void srxl2Finalise(void) +{ + agreedBaudBits = SRXL2_BAUD_BIT_400K & escBaudSupported; + srxl2SendHandshake(Broadcast, agreedBaudBits); + baudSwitchPending = (agreedBaudBits & SRXL2_BAUD_BIT_400K) != 0; + srxl2SetState(SRXL2_FINALISING); +} + /*--------------------------------------------------------------------------- * Telemetry decoding: STRU_TELE_ESC, big-endian on the wire *-------------------------------------------------------------------------*/ -static inline uint16_t be16(const uint8_t *p) -{ - return (uint16_t)((p[0] << 8) | p[1]); -} - static void srxl2DecodeEscTelemetry(const uint8_t *payload) { /* payload[0] is the sensor id, payload[1] a secondary id. */ @@ -221,14 +273,15 @@ static void srxl2DecodeEscTelemetry(const uint8_t *payload) memset(&escTelemetry, 0, sizeof(escTelemetry)); - /* 0xFFFF / 0xFF mean "no data" and must not be mistaken for a reading. */ + /* 0xFFFF and 0xFF mean "no data" and must not be taken for readings - + * 0xFFFF volts at 0.01 V per count would otherwise look like 655 V. */ if (rpm != 0xFFFF) { escTelemetry.rpm = (uint32_t)rpm * 10; } if (voltsIn != 0xFFFF) { escTelemetry.voltage = voltsIn; } /* already 0.01 V */ if (currentMot != 0xFFFF) { escTelemetry.current = currentMot; } /* 10 mA == 0.01 A */ if (tempFet != 0xFFFF) { escTelemetry.temperatureFet = (int16_t)tempFet; } /* 0.1 degC */ if (tempBec != 0xFFFF) { escTelemetry.temperatureBec = (int16_t)tempBec; } - if (currentBec != 0xFF) { escTelemetry.currentBec = (uint16_t)currentBec * 10; } /* 100 mA -> 0.01 A */ - if (voltsBec != 0xFF) { escTelemetry.voltageBec = (uint16_t)voltsBec * 5; } /* 0.05 V -> 0.01 V */ + if (currentBec != 0xFF) { escTelemetry.currentBec = (uint16_t)currentBec * 10; } /* 100 mA -> 0.01 A */ + if (voltsBec != 0xFF) { escTelemetry.voltageBec = (uint16_t)voltsBec * 5; } /* 0.05 V -> 0.01 V */ if (throttle != 0xFF) { escTelemetry.throttlePercent = MIN((uint8_t)(throttle / 2), 100); } if (powerOut != 0xFF) { escTelemetry.powerPercent = MIN((uint8_t)(powerOut / 2), 100); } @@ -246,7 +299,7 @@ static void srxl2HandleHandshake(const uint8_t *buf) const uint8_t src = f->payload.sourceDeviceId; if (src < SRXL2_ESC_ID_FIRST || src > SRXL2_ESC_ID_LAST) { - /* Something else on the bus. We only drive ESCs here. */ + /* Something else on the bus. This driver only speaks to ESCs. */ return; } @@ -254,24 +307,20 @@ static void srxl2HandleHandshake(const uint8_t *buf) escBaudSupported = f->payload.baudSupported; statHandshakes++; - /* Answer the slave, then tell the whole bus which baud rate we settled on. - * Only claim 400000 if both ends offer it. */ - const uint8_t baud = (escBaudSupported & SRXL2_BAUD_BIT_400K) ? SRXL2_BAUD_BIT_400K : 0; - - srxl2SendHandshake(escDeviceId, baud); - srxl2SendHandshake(Broadcast, baud); - - negotiatedBaud = baud ? SRXL2_BAUD_HIGH : SRXL2_BAUD_LOW; - if (negotiatedBaud != SRXL2_BAUD_LOW) { - serialSetBaudRate(srxl2Port, negotiatedBaud); - } - - srxl2SetState(SRXL2_MOTOR_RUNNING); + /* Answer the slave so it knows who the master is, then finalise. + * + * This also covers the ESC being powered after the flight controller, which + * is the normal case on a bench: the board comes up on USB and the ESC only + * boots when the battery goes in, long after our listen window closed. Its + * handshake arrives while we are already RUNNING and has to be honoured, or + * the ESC is never found at all. */ + srxl2SendHandshake(escDeviceId, SRXL2_BAUD_BIT_400K); + srxl2Finalise(); } static void srxl2HandleTelemetry(const uint8_t *buf, uint8_t len) { - /* Telemetry packet: header(3) + destDeviceId(1) + 16 byte payload + crc(2) */ + /* header(3) + destDeviceId(1) + 16 byte payload + crc(2) */ if (len < 3 + 1 + 16 + 2) { return; } @@ -309,29 +358,72 @@ static void srxl2DrainRx(void) while (serialRxBytesWaiting(srxl2Port)) { const uint8_t c = serialRead(srxl2Port); - if (rxAsm.len == 0) { + if (rxLen == 0) { if (c != SRXL2_MAGIC) { - continue; /* resynchronise on the magic byte */ + continue; /* resynchronise on the magic byte */ } - rxAsm.expected = 0; + rxExpected = 0; } - rxAsm.buf[rxAsm.len++] = c; + rxBuf[rxLen++] = c; - if (rxAsm.len == 3) { - rxAsm.expected = rxAsm.buf[2]; - if (rxAsm.expected < 5 || rxAsm.expected > SRXL2_MAX_FRAME) { - rxAsm.len = 0; /* bogus length, drop and resynchronise */ + if (rxLen == 3) { + rxExpected = rxBuf[2]; + if (rxExpected < SRXL2_MIN_FRAME || rxExpected > SRXL2_MAX_FRAME) { + rxLen = 0; /* bogus length, drop and resynchronise */ continue; } } - if (rxAsm.expected && rxAsm.len >= rxAsm.expected) { - srxl2HandleFrame(rxAsm.buf, rxAsm.len); - rxAsm.len = 0; - rxAsm.expected = 0; + if (rxExpected && rxLen >= rxExpected) { + srxl2HandleFrame(rxBuf, rxLen); + rxLen = 0; + rxExpected = 0; + } + } +} + +/*--------------------------------------------------------------------------- + * Control data + *-------------------------------------------------------------------------*/ + +static void srxl2SendControlData(void) +{ + uint8_t buf[SRXL2_MAX_FRAME]; + uint8_t n = 0; + + /* Request telemetry only occasionally - see SRXL2_TELEM_REQUEST_EVERY. */ + uint8_t replyId = SRXL2_REPLY_NONE; + if (++telemRequestCounter >= SRXL2_TELEM_REQUEST_EVERY) { + telemRequestCounter = 0; + replyId = escDeviceId; + } + + buf[n++] = SRXL2_MAGIC; + buf[n++] = ControlData; + buf[n++] = 0; /* length, patched below */ + buf[n++] = failsafeActive ? SRXL2_CMD_CHANNEL_FAILSAFE : SRXL2_CMD_CHANNEL_DATA; + buf[n++] = replyId; + + buf[n++] = 0; /* rssi: we are not an RF device */ + buf[n++] = 0; /* frameLosses low */ + buf[n++] = 0; /* frameLosses high */ + + buf[n++] = (uint8_t)(channelMask & 0xFF); + buf[n++] = (uint8_t)((channelMask >> 8) & 0xFF); + buf[n++] = (uint8_t)((channelMask >> 16) & 0xFF); + buf[n++] = (uint8_t)((channelMask >> 24) & 0xFF); + + for (uint8_t ch = 0; ch < 32; ch++) { + if (channelMask & (1u << ch)) { + buf[n++] = (uint8_t)(channelValue[ch] & 0xFF); + buf[n++] = (uint8_t)(channelValue[ch] >> 8); } } + + n += 2; /* room for the CRC */ + buf[2] = n; + srxl2SendFrame(buf, n); } /*--------------------------------------------------------------------------- @@ -351,18 +443,29 @@ bool srxl2MotorInitialize(void) return false; } - memset(&rxAsm, 0, sizeof(rxAsm)); + rxLen = 0; + rxExpected = 0; memset(&escTelemetry, 0, sizeof(escTelemetry)); memset(channelValue, 0, sizeof(channelValue)); channelMask = 0; escDeviceId = 0; + escBaudSupported = 0; + agreedBaudBits = 0; + baudSwitchPending = false; failsafeActive = false; - negotiatedBaud = SRXL2_BAUD_LOW; + telemRequestCounter = 0; - /* Silent until a slave speaks - see SRXL2_LISTEN_WINDOW_MS. */ - srxl2SetState(SRXL2_MOTOR_LISTENING); - lastRxMs = millis(); + /* Start the throttle channel at its lowest value rather than zero, so the + * first frame after a handshake cannot be read as something unexpected. */ + channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(1000); + channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); + const timeMs_t now = millis(); + lastRxMs = now; + lastTxMs = now; + lastControlMs = now; + + srxl2SetState(SRXL2_LISTENING); return true; } @@ -371,20 +474,18 @@ void srxl2MotorUpdate(uint8_t index, uint16_t value) if (index >= SRXL2_ESC_MAX_MOTORS) { return; } - /* One ESC per bus, so motor 0 is the throttle channel. */ + /* Staging only. The wire is driven at its own rate from srxl2MotorProcess(), + * not at whatever rate the mixer happens to run. */ channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(value); channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); } void srxl2MotorSetReverse(bool armed) { - if (reverseChannel1Based == 0) { + if (reverseChannel1Based == 0 || reverseChannel1Based > 32) { return; /* reverse not configured */ } const uint8_t idx = reverseChannel1Based - 1; - if (idx >= 32) { - return; - } channelValue[idx] = srxl2UsToValue(armed ? 2000 : 1000); channelMask |= (1u << idx); } @@ -401,43 +502,14 @@ void srxl2MotorSetFailsafe(bool failsafe) void srxl2MotorSendUpdate(void) { - if (srxl2State != SRXL2_MOTOR_RUNNING || !escDeviceId) { - return; - } - - uint8_t buf[SRXL2_MAX_FRAME]; - uint8_t n = 0; - - buf[n++] = SRXL2_MAGIC; - buf[n++] = ControlData; - buf[n++] = 0; /* length, patched below */ - buf[n++] = failsafeActive ? SRXL2_CMD_CHANNEL_FAILSAFE : SRXL2_CMD_CHANNEL_DATA; - buf[n++] = escDeviceId; /* replyId: ask this ESC for telemetry */ - - buf[n++] = 0; /* rssi: we are not an RF device */ - buf[n++] = 0; /* frameLosses low */ - buf[n++] = 0; /* frameLosses high */ - - buf[n++] = (uint8_t)(channelMask & 0xFF); - buf[n++] = (uint8_t)((channelMask >> 8) & 0xFF); - buf[n++] = (uint8_t)((channelMask >> 16) & 0xFF); - buf[n++] = (uint8_t)((channelMask >> 24) & 0xFF); - - for (uint8_t ch = 0; ch < 32; ch++) { - if (channelMask & (1u << ch)) { - buf[n++] = (uint8_t)(channelValue[ch] & 0xFF); - buf[n++] = (uint8_t)(channelValue[ch] >> 8); - } - } - - n += 2; /* room for the CRC */ - buf[2] = n; - srxl2SendFrame(buf, n); + /* Nothing to do: values are staged by srxl2MotorUpdate() and transmitted on + * the protocol's own schedule. Kept so the motor output layer can call the + * same hook it calls for every other protocol. */ } void srxl2MotorProcess(void) { - if (!srxl2Port || srxl2State == SRXL2_MOTOR_DISABLED) { + if (!srxl2Port || srxl2State == SRXL2_DISABLED) { return; } @@ -445,37 +517,58 @@ void srxl2MotorProcess(void) const timeMs_t now = millis(); + /* A deferred baud change completes as soon as the broadcast has left. */ + if (baudSwitchPending && isSerialTransmitBufferEmpty(srxl2Port)) { + serialSetBaudRate(srxl2Port, SRXL2_BAUD_HIGH); + baudSwitchPending = false; + } + switch (srxl2State) { - case SRXL2_MOTOR_LISTENING: - /* Deliberately transmit nothing. If the window closes without a slave - * handshake, there is no SRXL2 device here and we stay quiet for good - * rather than risk driving a PWM ESC with serial data. */ + case SRXL2_LISTENING: + /* Silent on purpose; an auto-announcing ESC is handled in + * srxl2HandleHandshake(), which moves us on. */ if (now - stateEnteredMs >= SRXL2_LISTEN_WINDOW_MS) { - srxl2SetState(SRXL2_MOTOR_ABSENT); + srxl2SetState(SRXL2_POLLING); } break; - case SRXL2_MOTOR_ABSENT: - /* A slave can still appear later - it repeats its handshake on reset - - * and handling that costs nothing, so keep listening without speaking. */ + case SRXL2_POLLING: + if (now - lastTxMs >= SRXL2_HANDSHAKE_INTERVAL_MS) { + /* Poll the default ESC ID. An ESC with a non-zero unit ID never + * announces itself, so without this it would never be found. */ + srxl2SendHandshake(SRXL2_ESC_ID_FIRST, SRXL2_BAUD_BIT_400K); + } break; - case SRXL2_MOTOR_HANDSHAKING: - if (now - lastHandshakeTxMs >= SRXL2_HANDSHAKE_RETRY_MS) { - lastHandshakeTxMs = now; - srxl2SendHandshake(Broadcast, SRXL2_BAUD_BIT_400K); + case SRXL2_FINALISING: + /* Hold until the broadcast is out and any baud change has taken, then + * start driving the ESC. */ + if (!baudSwitchPending && isSerialTransmitBufferEmpty(srxl2Port)) { + lastRxMs = now; /* do not time out on the handshake gap */ + lastControlMs = now; + srxl2SetState(SRXL2_RUNNING); } break; - case SRXL2_MOTOR_RUNNING: + case SRXL2_RUNNING: + if (now - lastControlMs >= SRXL2_CONTROL_INTERVAL_MS) { + lastControlMs = now; + srxl2SendControlData(); + } + + if (escTelemetry.valid && (now - escTelemetry.lastUpdateMs) > SRXL2_TELEM_STALE_MS) { + escTelemetry.valid = false; + } + if (now - lastRxMs >= SRXL2_LINK_TIMEOUT_MS) { - /* Lost the ESC. Drop back to 115200, which is where a slave that - * has just reset will be listening, and wait for it again. */ + /* Lost the ESC. Go back to 115200, where a slave that has just reset + * will be listening, and look for it again. */ serialSetBaudRate(srxl2Port, SRXL2_BAUD_LOW); - negotiatedBaud = SRXL2_BAUD_LOW; + baudSwitchPending = false; + agreedBaudBits = 0; escDeviceId = 0; escTelemetry.valid = false; - srxl2SetState(SRXL2_MOTOR_LISTENING); + srxl2SetState(SRXL2_POLLING); } break; @@ -491,7 +584,7 @@ void srxl2MotorProcess(void) bool srxl2MotorIsConnected(void) { - return srxl2State == SRXL2_MOTOR_RUNNING && escDeviceId != 0; + return srxl2State == SRXL2_RUNNING && escDeviceId != 0; } bool srxl2MotorGetTelemetry(uint8_t index, srxl2EscTelemetry_t *out) From 203d81af2a6ec52df76c5672a55e0a4412a99b0f Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:56:25 +0200 Subject: [PATCH 03/40] io: report a healthy RSSI, and record why unused channels stay unsent Three more things from comparing against working bus-master implementations: - RSSI was sent as 0 because we are not an RF device. The field is defined as "best RSSI when sending channel data", and an ESC may reasonably read nothing as a dead link and apply its own failsafe, so 0 is the wrong answer even when technically true. Now 100. - Unused channels are still not padded with centred values, and there is now a comment saying why, because the opposite choice looks tidier. Padding with centre is right for a surface ESC, where centre means stopped. On an aircraft ESC 1500 us is half throttle, so if the ESC read throttle on an index we had not anticipated, padding would spin the motor at 50 percent while sending nothing there leaves it idle. A wrong guess should fail safe. - Documented that the channel scaling deliberately does not reach the ends of the 0..65532 range, since a receiver's full travel decodes to 988..2012 us rather than 1000..2000, and named it as the constant to revisit if an ESC cannot reach full throttle. --- src/main/io/motor_srxl2.c | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 36e9ca8a607..c0d67164103 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -130,7 +130,15 @@ /* * Channel value scaling, the exact inverse of what rx/srxl2.c applies when it * decodes channel data: us = 988 + (value >> 6). 1500 us therefore maps onto - * 0x8000, which the specification calls "Servo Center". + * 0x8000, which the specification calls "Servo Center", and the shift leaves the + * low two bits clear as the specification requires. + * + * Note this does not reach the extremes of the 0..65532 range: 1000 us lands on + * 768 and 2000 us on 64768, because a Spektrum receiver's full travel decodes to + * 988..2012 us rather than 1000..2000. Staying consistent with INAV's own + * decoder is worth more than the last 1.2 percent, but if an ESC calibrated its + * endpoints against a receiver at full stick and will not reach full throttle, + * this is the constant to revisit. */ #define SRXL2_PULSE_OFFSET_US 988 #define SRXL2_PULSE_SHIFT 6 @@ -405,7 +413,11 @@ static void srxl2SendControlData(void) buf[n++] = failsafeActive ? SRXL2_CMD_CHANNEL_FAILSAFE : SRXL2_CMD_CHANNEL_DATA; buf[n++] = replyId; - buf[n++] = 0; /* rssi: we are not an RF device */ + /* RSSI has to read as a healthy link. The field is defined as "best RSSI + * when sending channel data", and an ESC is entitled to treat nothing as a + * dead link and fall back to its own failsafe, so reporting 0 here would be + * actively wrong even though we are not an RF device. */ + buf[n++] = 100; buf[n++] = 0; /* frameLosses low */ buf[n++] = 0; /* frameLosses high */ @@ -414,6 +426,16 @@ static void srxl2SendControlData(void) buf[n++] = (uint8_t)((channelMask >> 16) & 0xFF); buf[n++] = (uint8_t)((channelMask >> 24) & 0xFF); + /* + * Only the channels we actually mean, little-endian, lowest index first. + * + * Deliberately not padded with centred values on the channels we do not + * use. Doing that is reasonable for a surface ESC, where centre means + * stopped, and dangerous for an aircraft one, where 1500 us is half + * throttle: if the ESC turned out to read throttle on an index we did not + * expect, padding would spin the motor at 50 percent, while sending nothing + * there simply leaves it idle. Wrong guess, safe outcome. + */ for (uint8_t ch = 0; ch < 32; ch++) { if (channelMask & (1u << ch)) { buf[n++] = (uint8_t)(channelValue[ch] & 0xFF); From b8c46d4d3fda27d14ab62422bbad5e3a20ba2b42 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:32:06 +0200 Subject: [PATCH 04/40] io: record why the channel scaling mirrors a receiver rather than the full range The previous comment had this backwards, suggesting the constant should be changed if an ESC cannot reach full throttle. Spektrum ESCs learn their throttle endpoints during the ESC/Radio calibration their manual describes, and INAV cannot run that procedure: it requires full throttle to be present as the battery is connected, and INAV outputs mincommand while disarmed. The calibration is therefore performed with a Spektrum transmitter, or left at the factory default, and the range the ESC remembers is a receiver's. Emulating a receiver is the reason this scaling is correct, not a compromise. The roughly 1.2 percent of travel lost at the top is the better side of the trade: stretching 1000..2000 us over the full 0..65532 range would move the centre, and the centre is where an ESC in Reverse brake mode takes zero thrust. A slightly low maximum costs throttle curve; a misplaced centre costs creeping thrust at neutral. --- src/main/io/motor_srxl2.c | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index c0d67164103..8065fd16118 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -133,12 +133,23 @@ * 0x8000, which the specification calls "Servo Center", and the shift leaves the * low two bits clear as the specification requires. * - * Note this does not reach the extremes of the 0..65532 range: 1000 us lands on - * 768 and 2000 us on 64768, because a Spektrum receiver's full travel decodes to - * 988..2012 us rather than 1000..2000. Staying consistent with INAV's own - * decoder is worth more than the last 1.2 percent, but if an ESC calibrated its - * endpoints against a receiver at full stick and will not reach full throttle, - * this is the constant to revisit. + * This deliberately does not reach the ends of the 0..65532 range: 1000 us lands + * on 768 and 2000 us on 64768, because a Spektrum receiver's full travel decodes + * to 988..2012 us rather than 1000..2000. That is the point - it makes us look + * like a receiver, which is what the ESC was calibrated against. + * + * Spektrum ESCs learn their endpoints from the signal during the ESC/Radio + * calibration in their manual, and INAV cannot perform that procedure: it wants + * full throttle present when the battery is connected, and INAV outputs + * mincommand while disarmed. So the calibration is done with a Spektrum + * transmitter, or left at the factory default, and the range the ESC remembers + * is a receiver's. Matching it is why this scaling is the right one. + * + * The cost is about 1.2 percent of travel at the top. That is the better half of + * the trade, because the alternative - stretching 1000..2000 us across the full + * range - moves the centre, and the centre is where an ESC in Reverse brake mode + * takes zero thrust. A slightly low maximum is a worse throttle curve; a + * misplaced centre is creeping thrust at neutral. */ #define SRXL2_PULSE_OFFSET_US 988 #define SRXL2_PULSE_SHIFT 6 From 79019558685a9d25a4ab6ab19f8b7c8c551f62af Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:43:18 +0200 Subject: [PATCH 05/40] io, cli: let INAV teach a Spektrum Smart ESC its throttle range A Spektrum ESC learns its endpoints from the signal present as it powers up: full throttle, then low within five seconds of the tones that acknowledge it. INAV could not produce that, because it outputs mincommand while disarmed, so the documented procedure needs a Spektrum transmitter - which leaves an INAV user who does not own one unable to calibrate the ESC at all. esc_calibrate start runs the sequence unattended. The five second window opens at tones only a person standing there can hear, so rather than ask the operator to type against a stopwatch, the sequence is timed from the moment the ESC gains power, which the flight controller can see: either the pack appearing on the voltage sensor, or the ESC announcing itself on the bus. esc_calibrate start battery disconnected; plug it in when told esc_calibrate off abort esc_calibrate high/low by hand, for boards with no voltage sensing One of these phases commands full throttle with the aircraft disarmed, so: - it refuses to start with the battery already connected. The ESC only reads its endpoints as it powers up, so starting with it live would achieve nothing, and it would mean presenting full throttle to an ESC able to act on it. Every other safeguard here is a latch; this one removes the hazard. - it refuses while armed, in the command and again in the driver rather than trusting the caller, and arming cancels a sequence in progress. - every phase leaves on a deadline, so nothing can strand the output high. - the override is applied where the packet is built, not where values are staged, so no mixer path can write over it in between. - the help leads with the propeller. The automatic mode declines up front when there is no battery voltage sensing, instead of starting a sequence that could never advance, and points at the manual phases. The three second settle after power-up is taken from the published tone timings rather than measured, and is the first thing to adjust if an ESC refuses the calibration. --- src/main/fc/cli.c | 86 ++++++++++++++++++++ src/main/io/motor_srxl2.c | 162 ++++++++++++++++++++++++++++++++++++++ src/main/io/motor_srxl2.h | 47 +++++++++++ 3 files changed, 295 insertions(+) diff --git a/src/main/fc/cli.c b/src/main/fc/cli.c index 718dadca046..64b250f8f09 100644 --- a/src/main/fc/cli.c +++ b/src/main/fc/cli.c @@ -95,6 +95,7 @@ bool cliMode = false; #include "io/ledstrip.h" #include "io/osd.h" #include "io/osd/custom_elements.h" +#include "io/motor_srxl2.h" #include "io/serial.h" #include "fc/fc_msp_box.h" @@ -4742,6 +4743,88 @@ static void cliDiff(char *cmdline) } #ifdef USE_USB_MSC +#ifdef USE_MOTOR_SRXL2 +static void cliEscCalibratePrintResult(srxl2CalResult_e r) +{ + switch (r) { + case SRXL2_CAL_ACCEPTED: + break; + case SRXL2_CAL_REJECT_ARMED: + cliPrintErrorLinef("Not while armed"); + break; + case SRXL2_CAL_REJECT_NO_PORT: + cliPrintErrorLinef("No SRXL2 ESC port. Assign one and set motor_pwm_protocol = SRXL2"); + break; + case SRXL2_CAL_REJECT_BATTERY_PRESENT: + cliPrintErrorLinef("Disconnect the battery first. The ESC only reads its"); + cliPrintErrorLinef("endpoints as it powers up, and full throttle must not be"); + cliPrintErrorLinef("presented to an ESC that can already act on it."); + break; + case SRXL2_CAL_REJECT_NO_VOLTAGE_SENSOR: + cliPrintErrorLinef("No battery voltage sensing, so the ESC powering up cannot be"); + cliPrintErrorLinef("detected. Use 'esc_calibrate high' and 'low' by hand instead."); + break; + } +} + +static void cliEscCalibrate(char *cmdline) +{ + static const char * const phaseName[] = { + "off", "waiting for battery", "holding high", "holding low", + "holding high (manual)", "holding low (manual)" + }; + + if (isEmpty(cmdline)) { + cliPrintLinef("Phase: %s", phaseName[srxl2MotorCalibrationPhase()]); + cliPrintLine(""); + cliPrintLine("Teaches a Spektrum Smart ESC its throttle endpoints. The ESC reads"); + cliPrintLine("them from the signal present as it powers up, so the sequence is"); + cliPrintLine("timed from the moment the battery goes in."); + cliPrintLine(""); + cliPrintLine("REMOVE THE PROPELLER. This commands full throttle."); + cliPrintLine(""); + cliPrintLine(" esc_calibrate start battery DISCONNECTED, then plug it in"); + cliPrintLine(" when told. The rest is automatic."); + cliPrintLine(" esc_calibrate off abort"); + cliPrintLine(""); + cliPrintLine("By hand, for boards without battery voltage sensing:"); + cliPrintLine(" esc_calibrate high then connect the battery"); + cliPrintLine(" esc_calibrate low within five seconds of the two short tones"); + cliPrintLine(""); + cliPrintLine("Every phase ends by itself. Arming cancels it."); + return; + } + + if (sl_strcasecmp(cmdline, "start") == 0) { + const srxl2CalResult_e r = srxl2MotorCalibrationBegin(); + cliEscCalibratePrintResult(r); + if (r == SRXL2_CAL_ACCEPTED) { + cliPrintLine("Propeller off? Full throttle is now on the wire."); + cliPrintLine("Connect the battery. The ESC will sound its tones, and the"); + cliPrintLine("throttle drops to minimum on its own about three seconds later."); + cliPrintLine("A long tone means the range was stored."); + } + } else if (sl_strcasecmp(cmdline, "high") == 0) { + const srxl2CalResult_e r = srxl2MotorCalibrationManual(SRXL2_CAL_HIGH_MANUAL); + cliEscCalibratePrintResult(r); + if (r == SRXL2_CAL_ACCEPTED) { + cliPrintLine("Full throttle on the wire. Connect the battery now."); + } + } else if (sl_strcasecmp(cmdline, "low") == 0) { + const srxl2CalResult_e r = srxl2MotorCalibrationManual(SRXL2_CAL_LOW_MANUAL); + cliEscCalibratePrintResult(r); + if (r == SRXL2_CAL_ACCEPTED) { + cliPrintLine("Low throttle on the wire. Listen for the cell count, then a long tone."); + } + } else if (sl_strcasecmp(cmdline, "off") == 0) { + srxl2MotorCalibrationAbort(); + cliPrintLine("Aborted."); + } else { + cliShowParseError(); + } +} +#endif + static void cliMsc(char *cmdline) { UNUSED(cmdline); @@ -5024,6 +5107,9 @@ const clicmd_t cmdTable[] = { CLI_COMMAND_DEF("mmix", "custom motor mixer", NULL, cliMotorMix), CLI_COMMAND_DEF("motor", "get/set motor", " []", cliMotor), #ifdef USE_USB_MSC +#ifdef USE_MOTOR_SRXL2 + CLI_COMMAND_DEF("esc_calibrate", "teach a Spektrum Smart ESC its throttle range", "[start|high|low|off]", cliEscCalibrate), +#endif CLI_COMMAND_DEF("msc", "switch into msc mode", NULL, cliMsc), #endif CLI_COMMAND_DEF("play_sound", NULL, "[]\r\n", cliPlaySound), diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 8065fd16118..0bc61533a93 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -48,6 +48,10 @@ #include "drivers/serial.h" #include "drivers/time.h" +#include "fc/runtime_config.h" + +#include "sensors/battery.h" + #include "io/serial.h" #include "io/motor_srxl2.h" @@ -127,6 +131,33 @@ /* Telemetry older than this is reported as stale rather than current. */ #define SRXL2_TELEM_STALE_MS 1000 +/* + * Calibration phases end themselves. The high phase has to outlast a human + * reaching for a battery lead; the low phase only has to outlast the ESC's + * cell-count tones. Neither may persist, because one of them commands full + * throttle. + */ +#define SRXL2_CAL_WAIT_TIMEOUT_MS 60000 /* time to walk over and plug the battery in */ +#define SRXL2_CAL_MANUAL_TIMEOUT_MS 30000 + +/* + * How long to keep holding full throttle after the ESC gains power. + * + * The manual's window opens at the two short tones and lasts five seconds. Those + * tones follow the power-up sequence by a second or so, so dropping three + * seconds after power-up lands inside it with room on both sides. This is the + * one number in the sequence taken from the published tone timings rather than + * measured, and the first thing to adjust if an ESC refuses the calibration. + */ +#define SRXL2_CAL_SETTLE_MS 3000 + +/* Long enough for the cell-count tones and the closing long tone. */ +#define SRXL2_CAL_LOW_MS 5000 + +/* Endpoints presented during calibration, on INAV's usual motor scale. */ +#define SRXL2_CAL_HIGH_US 2000 +#define SRXL2_CAL_LOW_US 1000 + /* * Channel value scaling, the exact inverse of what rx/srxl2.c applies when it * decodes channel data: us = 988 + (value >> 6). 1500 us therefore maps onto @@ -196,6 +227,9 @@ static uint8_t telemRequestCounter; static srxl2EscTelemetry_t escTelemetry; +static srxl2CalPhase_e calPhase = SRXL2_CAL_OFF; +static timeMs_t calPhaseMs; /* when the current phase began */ + static uint32_t statTxFrames, statRxFrames, statCrcErrors, statHandshakes; /*--------------------------------------------------------------------------- @@ -418,6 +452,17 @@ static void srxl2SendControlData(void) replyId = escDeviceId; } + /* Calibration overrides the throttle here rather than at staging time, so no + * mixer path can quietly write over it between the two. */ + if (calPhase != SRXL2_CAL_OFF) { + const bool high = (calPhase == SRXL2_CAL_WAIT_BATTERY) + || (calPhase == SRXL2_CAL_SETTLE) + || (calPhase == SRXL2_CAL_HIGH_MANUAL); + channelValue[SRXL2_CHANNEL_THROTTLE] = + srxl2UsToValue(high ? SRXL2_CAL_HIGH_US : SRXL2_CAL_LOW_US); + channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); + } + buf[n++] = SRXL2_MAGIC; buf[n++] = ControlData; buf[n++] = 0; /* length, patched below */ @@ -533,6 +578,121 @@ void srxl2MotorSetFailsafe(bool failsafe) failsafeActive = failsafe; } +/* Checked on both sides rather than trusting the caller, because one of these + * phases commands full throttle with the aircraft disarmed. */ +static srxl2CalResult_e srxl2CalCommonChecks(void) +{ + if (ARMING_FLAG(ARMED)) { + return SRXL2_CAL_REJECT_ARMED; + } + if (!srxl2Port) { + return SRXL2_CAL_REJECT_NO_PORT; + } + return SRXL2_CAL_ACCEPTED; +} + +srxl2CalResult_e srxl2MotorCalibrationBegin(void) +{ + const srxl2CalResult_e common = srxl2CalCommonChecks(); + if (common != SRXL2_CAL_ACCEPTED) { + return common; + } + + /* Detecting the ESC powering up is the whole mechanism, so say so plainly + * instead of starting a sequence that can never advance. */ + if (!isBatteryVoltageConfigured()) { + return SRXL2_CAL_REJECT_NO_VOLTAGE_SENSOR; + } + + /* + * Refuse if the pack is already in. The ESC only reads its endpoints as it + * powers up, so starting with it already running would achieve nothing - and + * it would mean presenting full throttle to an ESC that can act on it. + */ + if (getBatteryState() != BATTERY_NOT_PRESENT) { + return SRXL2_CAL_REJECT_BATTERY_PRESENT; + } + + calPhase = SRXL2_CAL_WAIT_BATTERY; + calPhaseMs = millis(); + return SRXL2_CAL_ACCEPTED; +} + +srxl2CalResult_e srxl2MotorCalibrationManual(srxl2CalPhase_e phase) +{ + const srxl2CalResult_e common = srxl2CalCommonChecks(); + if (common != SRXL2_CAL_ACCEPTED) { + return common; + } + + calPhase = phase; + calPhaseMs = millis(); + return SRXL2_CAL_ACCEPTED; +} + +void srxl2MotorCalibrationAbort(void) +{ + calPhase = SRXL2_CAL_OFF; +} + +srxl2CalPhase_e srxl2MotorCalibrationPhase(void) +{ + return calPhase; +} + +/* Advance the unattended sequence. Every phase leaves on a deadline, so nothing + * here can strand the output at full throttle. */ +static void srxl2CalProcess(timeMs_t now) +{ + if (calPhase == SRXL2_CAL_OFF) { + return; + } + + if (ARMING_FLAG(ARMED)) { + calPhase = SRXL2_CAL_OFF; + return; + } + + const timeMs_t elapsed = now - calPhaseMs; + + switch (calPhase) { + case SRXL2_CAL_WAIT_BATTERY: + /* The ESC gaining power is what opens the window. Either signal will do: + * the pack appearing, or the ESC announcing itself on the bus. */ + if (getBatteryState() != BATTERY_NOT_PRESENT || escDeviceId != 0) { + calPhase = SRXL2_CAL_SETTLE; + calPhaseMs = now; + } else if (elapsed >= SRXL2_CAL_WAIT_TIMEOUT_MS) { + calPhase = SRXL2_CAL_OFF; + } + break; + + case SRXL2_CAL_SETTLE: + if (elapsed >= SRXL2_CAL_SETTLE_MS) { + calPhase = SRXL2_CAL_LOW; + calPhaseMs = now; + } + break; + + case SRXL2_CAL_LOW: + if (elapsed >= SRXL2_CAL_LOW_MS) { + calPhase = SRXL2_CAL_OFF; + } + break; + + case SRXL2_CAL_HIGH_MANUAL: + case SRXL2_CAL_LOW_MANUAL: + if (elapsed >= SRXL2_CAL_MANUAL_TIMEOUT_MS) { + calPhase = SRXL2_CAL_OFF; + } + break; + + default: + calPhase = SRXL2_CAL_OFF; + break; + } +} + void srxl2MotorSendUpdate(void) { /* Nothing to do: values are staged by srxl2MotorUpdate() and transmitted on @@ -550,6 +710,8 @@ void srxl2MotorProcess(void) const timeMs_t now = millis(); + srxl2CalProcess(now); + /* A deferred baud change completes as soon as the broadcast has left. */ if (baudSwitchPending && isSerialTransmitBufferEmpty(srxl2Port)) { serialSetBaudRate(srxl2Port, SRXL2_BAUD_HIGH); diff --git a/src/main/io/motor_srxl2.h b/src/main/io/motor_srxl2.h index 5b4442c0394..0daa7d549c2 100644 --- a/src/main/io/motor_srxl2.h +++ b/src/main/io/motor_srxl2.h @@ -61,6 +61,53 @@ typedef struct { bool valid; } srxl2EscTelemetry_t; +/* + * ESC throttle-range calibration. + * + * Spektrum ESCs learn their endpoints from the signal present as the battery is + * connected: full throttle first, then low within five seconds. A Spektrum + * transmitter does this with the stick, and INAV otherwise cannot, because it + * outputs mincommand while disarmed - which leaves anyone without a Spektrum + * radio unable to calibrate the ESC at all. + * + * These phases override the throttle channel so the sequence can be driven from + * the flight controller. HIGH and LOW both abort on their own after a timeout, so + * the output cannot be left commanding full throttle. + */ +typedef enum { + SRXL2_CAL_OFF = 0, + SRXL2_CAL_WAIT_BATTERY, /* full throttle held, waiting for the ESC to power up */ + SRXL2_CAL_SETTLE, /* ESC powered: holding high while it accepts the endpoint */ + SRXL2_CAL_LOW, /* low throttle so it accepts the other endpoint */ + SRXL2_CAL_HIGH_MANUAL, /* fallback for boards with no voltage sensing */ + SRXL2_CAL_LOW_MANUAL, +} srxl2CalPhase_e; + +typedef enum { + SRXL2_CAL_ACCEPTED = 0, + SRXL2_CAL_REJECT_ARMED, + SRXL2_CAL_REJECT_NO_PORT, + SRXL2_CAL_REJECT_BATTERY_PRESENT, + SRXL2_CAL_REJECT_NO_VOLTAGE_SENSOR, +} srxl2CalResult_e; + +/* + * Run the whole sequence unattended: refuses to start unless the battery is + * disconnected, then holds full throttle, waits for the ESC to power up, and + * times the drop to low itself. The five second window in the manual starts at + * the ESC's tones, which only a person standing there can hear, so timing it + * from the moment the ESC gains power is the only way to hit it without asking + * the operator to type against a stopwatch. + */ +srxl2CalResult_e srxl2MotorCalibrationBegin(void); + +/* Drive one endpoint by hand. For boards that cannot sense battery voltage and + * therefore cannot detect the ESC powering up. */ +srxl2CalResult_e srxl2MotorCalibrationManual(srxl2CalPhase_e phase); + +void srxl2MotorCalibrationAbort(void); +srxl2CalPhase_e srxl2MotorCalibrationPhase(void); + /* * Open the serial port assigned FUNCTION_ESC_SRXL2 and start the handshake. * Returns false if no port is assigned or it could not be opened, in which case From 5b67d300cd22e7d6bad305f897a85ce74e00f2a5 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:03:58 +0200 Subject: [PATCH 06/40] io: record the process cadence the master state machine needs Spektrum's reference application advances its SRXL2 state machine on a 5 ms tick, and a master has to run several times faster than its own Control Data interval for replies to be collected promptly on a half-duplex wire. TASK_PWMDRIVER, which already exists to drive the SBUS servo output, runs at 200 Hz and matches that exactly - worth writing down before the task is wired up, because the number is not obvious from either end. Also confirms the device ID chosen here: Spektrum's own example configuration uses 0x31, flight controller type with unit ID 1. --- src/main/io/motor_srxl2.h | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/io/motor_srxl2.h b/src/main/io/motor_srxl2.h index 0daa7d549c2..8d28f07e86c 100644 --- a/src/main/io/motor_srxl2.h +++ b/src/main/io/motor_srxl2.h @@ -152,7 +152,13 @@ void srxl2MotorSendUpdate(void); /* * Drain the receive buffer and advance the master state machine: handshake, * baud negotiation, telemetry collection, timeout recovery. Must be called - * regularly from task context, not from an ISR. + * from task context, not from an ISR. + * + * Wants roughly a 5 ms cadence: that is the tick Spektrum's own reference + * application advances its state machine with, and it has to run several times + * faster than the Control Data interval for received frames to be picked up + * promptly on a half-duplex wire. TASK_PWMDRIVER, which already exists for the + * SBUS servo output, runs at 200 Hz and fits exactly. */ void srxl2MotorProcess(void); From 0efa3b9fb6ab503d0ca418b7f165b9b26d2226c9 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:10:26 +0200 Subject: [PATCH 07/40] io: stop requesting telemetry in failsafe, and cite the source for the RSSI value Both confirmed against Spektrum's own published code rather than reasoned about. The reference implementation sets the reply ID to zero when it emits failsafe channel data - a device being told the link is gone has nothing useful to answer with - so the telemetry request is now suppressed while failsafe is announced. The RSSI comment now carries its evidence. Spektrum's receiver code reads a received zero as loss of link: if (channelData->rssi == 0) { globalResult = RX_FRAME_FAILSAFE; } so sending 0, which this driver originally did on the grounds that we are not an RF device, would have announced a dead link on every frame. Also notes that the bus arbitrates mastership by device ID, lowest winning, and why this driver does not implement standing down: the port is a dedicated link to an ESC, and 0x40 cannot outrank 0x31. It would be wrong on a bus shared with a Spektrum receiver, which would be master at 0x21. --- src/main/io/motor_srxl2.c | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 0bc61533a93..2f954f6cf03 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -86,6 +86,15 @@ */ #define SRXL2_OUR_DEVICE_ID 0x31 +/* + * The bus arbitrates who is master by device ID, lowest wins: a device that sees + * a handshake from a lower ID stands down. We never implement that side of it, + * because this port is a dedicated link to an ESC rather than a shared bus, and + * an ESC at 0x40 cannot outrank 0x31. Worth knowing before anyone wires a + * Spektrum receiver onto the same pin, where it would be master at 0x21 and this + * driver would be wrong to keep polling. + */ + /* Control Data commands */ #define SRXL2_CMD_CHANNEL_DATA 0x00 #define SRXL2_CMD_CHANNEL_FAILSAFE 0x01 @@ -445,9 +454,12 @@ static void srxl2SendControlData(void) uint8_t buf[SRXL2_MAX_FRAME]; uint8_t n = 0; - /* Request telemetry only occasionally - see SRXL2_TELEM_REQUEST_EVERY. */ + /* Request telemetry only occasionally - see SRXL2_TELEM_REQUEST_EVERY - and + * never while announcing failsafe: the reference implementation sets the + * reply ID to zero for failsafe channel data, since a device being told the + * link is gone has nothing useful to answer with. */ uint8_t replyId = SRXL2_REPLY_NONE; - if (++telemRequestCounter >= SRXL2_TELEM_REQUEST_EVERY) { + if (!failsafeActive && ++telemRequestCounter >= SRXL2_TELEM_REQUEST_EVERY) { telemRequestCounter = 0; replyId = escDeviceId; } @@ -469,10 +481,16 @@ static void srxl2SendControlData(void) buf[n++] = failsafeActive ? SRXL2_CMD_CHANNEL_FAILSAFE : SRXL2_CMD_CHANNEL_DATA; buf[n++] = replyId; - /* RSSI has to read as a healthy link. The field is defined as "best RSSI - * when sending channel data", and an ESC is entitled to treat nothing as a - * dead link and fall back to its own failsafe, so reporting 0 here would be - * actively wrong even though we are not an RF device. */ + /* + * RSSI has to read as a healthy link, even though we are not an RF device. + * This is not a guess: Spektrum's own receiver code treats a received zero + * as loss of link - + * + * if (channelData->rssi == 0) { globalResult = RX_FRAME_FAILSAFE; } + * + * - so sending 0 would be telling the ESC that the link is gone on every + * frame. + */ buf[n++] = 100; buf[n++] = 0; /* frameLosses low */ buf[n++] = 0; /* frameLosses high */ From 7b4ae1dc3b03d6f255d74952bbee2787609e01e9 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:37:32 +0200 Subject: [PATCH 08/40] io: never announce failsafe to the ESC, because INAV owns failsafe The protocol has a failsafe channel-data command that a receiver sends when its RF link is gone, so each device applies its own failsafe. Transcribing it here was faithful to the protocol and wrong for this architecture. On this bus the master is the flight controller and there is no RF link - the link is a wire. INAV handles failsafe by substituting channel values and continuing to fly: LAND and RTH command the motors all the way down, and DROP_IT holds throttle at neutral. Telling the ESC the link had failed would hand throttle authority to the ESC's own behaviour in the middle of INAV's landing, so the two would fight over the motor during the one manoeuvre that most needs a single authority. What protects against this wire actually dying is the ESC's own receive timeout, which needs no cooperation from us: if the flight controller stops sending, the ESC falls back on its own. That is the case where the ESC's failsafe is the right authority, and it works without being told. Removes srxl2MotorSetFailsafe() rather than leaving it unused, and records the reasoning where the command constant used to be, since the protocol offering a failsafe command invites putting it back. --- src/main/io/motor_srxl2.c | 37 ++++++++++++++++++++++--------------- src/main/io/motor_srxl2.h | 4 ---- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 2f954f6cf03..c5fc4bda9c7 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -95,9 +95,26 @@ * driver would be wrong to keep polling. */ -/* Control Data commands */ +/* + * Control Data commands. + * + * The protocol also has a failsafe channel-data command, 0x01, which a receiver + * sends when its RF link is gone so each device applies its own failsafe. This + * driver never sends it, and that is deliberate. + * + * On this bus the master is the flight controller and there is no RF link: the + * link is a wire. INAV owns failsafe, and it handles it by substituting channel + * values and continuing to fly - LAND and RTH actively command the motors all the + * way down. Telling the ESC the link had failed would hand throttle authority to + * the ESC's own behaviour in the middle of INAV's landing, which is the opposite + * of helpful. + * + * What does protect against this wire dying is the ESC's own receive timeout, + * which needs no cooperation from us: if the flight controller stops sending, the + * ESC falls back on its own, and that is the case where its failsafe is the right + * authority. + */ #define SRXL2_CMD_CHANNEL_DATA 0x00 -#define SRXL2_CMD_CHANNEL_FAILSAFE 0x01 /* Reply ID 0x00 means "no reply wanted" (specification 7.1.1). */ #define SRXL2_REPLY_NONE 0x00 @@ -230,7 +247,6 @@ static bool baudSwitchPending; /* waiting for TX to drain */ static uint16_t channelValue[32]; static uint32_t channelMask; -static bool failsafeActive; static uint8_t reverseChannel1Based = 5; /* Avian "Thrust Rev." default: CH5 */ static uint8_t telemRequestCounter; @@ -454,12 +470,9 @@ static void srxl2SendControlData(void) uint8_t buf[SRXL2_MAX_FRAME]; uint8_t n = 0; - /* Request telemetry only occasionally - see SRXL2_TELEM_REQUEST_EVERY - and - * never while announcing failsafe: the reference implementation sets the - * reply ID to zero for failsafe channel data, since a device being told the - * link is gone has nothing useful to answer with. */ + /* Request telemetry only occasionally - see SRXL2_TELEM_REQUEST_EVERY. */ uint8_t replyId = SRXL2_REPLY_NONE; - if (!failsafeActive && ++telemRequestCounter >= SRXL2_TELEM_REQUEST_EVERY) { + if (++telemRequestCounter >= SRXL2_TELEM_REQUEST_EVERY) { telemRequestCounter = 0; replyId = escDeviceId; } @@ -478,7 +491,7 @@ static void srxl2SendControlData(void) buf[n++] = SRXL2_MAGIC; buf[n++] = ControlData; buf[n++] = 0; /* length, patched below */ - buf[n++] = failsafeActive ? SRXL2_CMD_CHANNEL_FAILSAFE : SRXL2_CMD_CHANNEL_DATA; + buf[n++] = SRXL2_CMD_CHANNEL_DATA; buf[n++] = replyId; /* @@ -548,7 +561,6 @@ bool srxl2MotorInitialize(void) escBaudSupported = 0; agreedBaudBits = 0; baudSwitchPending = false; - failsafeActive = false; telemRequestCounter = 0; /* Start the throttle channel at its lowest value rather than zero, so the @@ -591,11 +603,6 @@ void srxl2MotorSetReverseChannel(uint8_t channel1Based) reverseChannel1Based = channel1Based; } -void srxl2MotorSetFailsafe(bool failsafe) -{ - failsafeActive = failsafe; -} - /* Checked on both sides rather than trusting the caller, because one of these * phases commands full throttle with the aircraft disarmed. */ static srxl2CalResult_e srxl2CalCommonChecks(void) diff --git a/src/main/io/motor_srxl2.h b/src/main/io/motor_srxl2.h index 8d28f07e86c..3d7cf5d9cc6 100644 --- a/src/main/io/motor_srxl2.h +++ b/src/main/io/motor_srxl2.h @@ -162,10 +162,6 @@ void srxl2MotorSendUpdate(void); */ void srxl2MotorProcess(void); -/* Mirror INAV's failsafe state onto the bus, so the ESC applies its own - * failsafe behaviour rather than continuing with the last value. */ -void srxl2MotorSetFailsafe(bool failsafe); - /* True once an ESC has answered the handshake and is still responding. */ bool srxl2MotorIsConnected(void); From 2263ade33b4a480fd1c9760b599934893936d127 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:45:56 +0200 Subject: [PATCH 09/40] io: wait for the ESC powering up as an event, not as leftover state Caught by running the driver against Spektrum's own library acting as a Smart ESC. The calibration sequence left its wait-for-battery phase when either the pack appeared or escDeviceId was non-zero. The second test was meant to read as "the ESC just announced itself, so it has just powered up", but escDeviceId is persistent state held from the last time the ESC was seen, not an event. On any board whose ESC had already completed a handshake - which is every board in normal use - the wait was skipped immediately and the sequence ran through without the operator having connected anything. Both conditions are now events: the pack appearing, or the handshake count moving from where it stood when the phase began. --- src/main/io/motor_srxl2.c | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index c5fc4bda9c7..5d4ea0366d9 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -254,6 +254,7 @@ static srxl2EscTelemetry_t escTelemetry; static srxl2CalPhase_e calPhase = SRXL2_CAL_OFF; static timeMs_t calPhaseMs; /* when the current phase began */ +static uint32_t calHandshakeMark; /* handshake count when the phase began */ static uint32_t statTxFrames, statRxFrames, statCrcErrors, statHandshakes; @@ -640,6 +641,7 @@ srxl2CalResult_e srxl2MotorCalibrationBegin(void) calPhase = SRXL2_CAL_WAIT_BATTERY; calPhaseMs = millis(); + calHandshakeMark = statHandshakes; return SRXL2_CAL_ACCEPTED; } @@ -682,9 +684,15 @@ static void srxl2CalProcess(timeMs_t now) switch (calPhase) { case SRXL2_CAL_WAIT_BATTERY: - /* The ESC gaining power is what opens the window. Either signal will do: - * the pack appearing, or the ESC announcing itself on the bus. */ - if (getBatteryState() != BATTERY_NOT_PRESENT || escDeviceId != 0) { + /* + * What opens the window is the ESC *gaining* power, which is an event, so + * both signals have to be events too: the pack appearing, or a fresh + * handshake arriving. An earlier version tested escDeviceId != 0, which + * is persistent state left over from the last time the ESC was seen, so + * the wait was skipped outright on any board that had already talked to + * its ESC once. + */ + if (getBatteryState() != BATTERY_NOT_PRESENT || statHandshakes != calHandshakeMark) { calPhase = SRXL2_CAL_SETTLE; calPhaseMs = now; } else if (elapsed >= SRXL2_CAL_WAIT_TIMEOUT_MS) { From e75437f2f19eee6eba95f421427070b044b11454 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:11:09 +0200 Subject: [PATCH 10/40] Wire the SRXL2 ESC driver into motor output, telemetry and the RPM filter motor_pwm_protocol gains SRXL2, appended rather than inserted so existing stored values keep their meaning. Selecting it points motorWritePtr at the SRXL2 driver, the same hook DSHOT uses. If the port was never assigned the pointer is left null and the motor stays unwritten: the pin is a UART pin, so there is no PWM to fall back to and pretending otherwise would be worse than doing nothing. TASK_PWMDRIVER, which already exists for the SBUS servo output, gets enabled for this protocol too and runs the master. Its 200 Hz happens to be exactly the 5 ms cadence Spektrum's reference application advances its state machine on. Reverse follows the mixer rather than a mode of its own: when INAV has decided the motor should run backwards, the ESC's reverse channel is armed. A separate switch would let the two disagree about direction, which is the one thing that must not happen with thrust reverse. Which auxiliary channel to use is esc_srxl2_reverse_channel, because nothing on the wire advertises how the ESC was programmed. Telemetry is taken in esc_sensor, which means every existing consumer is fed without knowing where the numbers came from - including the RPM filter, which reads getEscTelemetry() and therefore now works on an ESC that is not DSHOT. The wire carries electrical rpm, so it is divided by motorPoleCount/2 exactly as computeRpm() does for the serial backends; getting that wrong would place the notch at the wrong frequency. There is no separate telemetry port to assign, since Smart Throttle carries telemetry on the throttle wire. Enabled by default only on H7 and AT32, where flash is plentiful. It costs about 3.5 KB, and AIKONF7 sits at 93.4% of its flash: measured, that target returns to 459091 bytes, byte-for-byte its size without this feature. A tighter target that wants the protocol can define USE_MOTOR_SRXL2 for itself. docs/Settings.md regenerated for the new setting. --- docs/Settings.md | 10 ++++++++ src/main/drivers/pwm_mapping.h | 4 ++++ src/main/drivers/pwm_output.c | 14 +++++++++++ src/main/fc/fc_tasks.c | 21 +++++++++++++++- src/main/fc/settings.yaml | 9 ++++++- src/main/flight/mixer.c | 6 +++++ src/main/flight/mixer.h | 6 +++++ src/main/sensors/esc_sensor.c | 44 ++++++++++++++++++++++++++++++++++ src/main/target/common.h | 10 +++++++- 9 files changed, 121 insertions(+), 3 deletions(-) diff --git a/docs/Settings.md b/docs/Settings.md index 0a4792476b7..c8add9fb411 100644 --- a/docs/Settings.md +++ b/docs/Settings.md @@ -1045,6 +1045,16 @@ Enable when BLHeli32 Auto Telemetry function is used. Disable in every other cas --- +### esc_srxl2_reverse_channel + +For an SRXL2 Smart ESC, the 1-based auxiliary channel its "Thrust Rev." setting selects to arm the Reverse Brake. Must match how the ESC was programmed, because nothing on the wire advertises it. 0 disables reverse. + +| Default | Min | Max | +| --- | --- | --- | +| 5 | 0 | 9 | + +--- + ### ez_aggressiveness EzTune aggressiveness diff --git a/src/main/drivers/pwm_mapping.h b/src/main/drivers/pwm_mapping.h index afe9301f7e7..158ab8ce6b5 100644 --- a/src/main/drivers/pwm_mapping.h +++ b/src/main/drivers/pwm_mapping.h @@ -47,6 +47,10 @@ typedef enum { PWM_TYPE_DSHOT150, PWM_TYPE_DSHOT300, PWM_TYPE_DSHOT600, + /* Appended, not inserted: the value is stored in configuration. Unlike + * everything above it this is a UART protocol rather than a timer waveform, + * so the ESC signal goes to a serial pin and not to a motor pad. */ + PWM_TYPE_SRXL2, } motorPwmProtocolTypes_e; typedef enum { diff --git a/src/main/drivers/pwm_output.c b/src/main/drivers/pwm_output.c index e6fcfaeeaf3..bc622ec4ea6 100644 --- a/src/main/drivers/pwm_output.c +++ b/src/main/drivers/pwm_output.c @@ -34,6 +34,7 @@ #include "drivers/timer.h" #include "drivers/pwm_mapping.h" #include "drivers/pwm_output.h" +#include "io/motor_srxl2.h" #include "io/servo_sbus.h" #include "sensors/esc_sensor.h" @@ -616,6 +617,19 @@ void pwmMotorPreconfigure(void) motorWritePtr = pwmWriteDigital; break; #endif + +#ifdef USE_MOTOR_SRXL2 + case PWM_TYPE_SRXL2: + /* Nothing to fall back on if this fails: the pin is a UART pin, not a + * timer output, so there is no PWM to degrade to. Leaving + * motorWritePtr null keeps the motor unwritten, which is the honest + * outcome of a port that was never assigned. */ + if (srxl2MotorInitialize()) { + srxl2MotorSetReverseChannel(motorConfig()->srxl2ReverseChannel); + motorWritePtr = srxl2MotorUpdate; + } + break; +#endif } } diff --git a/src/main/fc/fc_tasks.c b/src/main/fc/fc_tasks.c index 6d35c7cae4b..95e311638c0 100755 --- a/src/main/fc/fc_tasks.c +++ b/src/main/fc/fc_tasks.c @@ -73,6 +73,7 @@ #include "io/vtx_msp.h" #include "io/osd_dji_hd.h" #include "io/displayport_msp_osd.h" +#include "io/motor_srxl2.h" #include "io/servo_sbus.h" #include "io/adsb.h" @@ -317,6 +318,19 @@ void taskSyncServoDriver(timeUs_t currentTimeUs) sbusServoSendUpdate(); #endif +#if defined(USE_MOTOR_SRXL2) + /* 200 Hz is the cadence this wants: Spektrum's reference application advances + * its state machine on a 5 ms tick, and the master has to run several times + * faster than its own Control Data interval to collect replies promptly on a + * half-duplex wire. */ + if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { + /* Reverse follows the mixer rather than a mode of its own: when INAV has + * decided the motor should run backwards, the ESC's reverse channel is + * armed. Anything else would let the two disagree about direction. */ + srxl2MotorSetReverse(getReversibleMotorsThrottleState() == MOTOR_DIRECTION_BACKWARD); + srxl2MotorProcess(); + } +#endif } #ifdef USE_OSD @@ -418,7 +432,12 @@ void fcTasksInit(void) setTaskEnabled(TASK_STACK_CHECK, true); #endif #if defined(USE_SERVO_SBUS) - setTaskEnabled(TASK_PWMDRIVER, (servoConfig()->servo_protocol == SERVO_TYPE_SBUS) || (servoConfig()->servo_protocol == SERVO_TYPE_SBUS_PWM)); + setTaskEnabled(TASK_PWMDRIVER, (servoConfig()->servo_protocol == SERVO_TYPE_SBUS) + || (servoConfig()->servo_protocol == SERVO_TYPE_SBUS_PWM) +#ifdef USE_MOTOR_SRXL2 + || (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) +#endif + ); #endif #ifdef USE_CMS #ifdef USE_MSP_DISPLAYPORT diff --git a/src/main/fc/settings.yaml b/src/main/fc/settings.yaml index 78d8f20ca90..cc91ee4d018 100644 --- a/src/main/fc/settings.yaml +++ b/src/main/fc/settings.yaml @@ -27,7 +27,7 @@ tables: - name: blackbox_device values: ["SERIAL", "SPIFLASH", "SDCARD", "FILE"] - name: motor_pwm_protocol - values: ["STANDARD", "ONESHOT125", "MULTISHOT", "BRUSHED", "DSHOT150", "DSHOT300", "DSHOT600"] + values: ["STANDARD", "ONESHOT125", "MULTISHOT", "BRUSHED", "DSHOT150", "DSHOT300", "DSHOT600", "SRXL2"] - name: servo_protocol values: ["PWM", "SBUS", "SBUS_PWM"] - name: failsafe_procedure @@ -862,6 +862,13 @@ groups: default_value: "ONESHOT125" field: motorPwmProtocol table: motor_pwm_protocol + - name: esc_srxl2_reverse_channel + description: "For an SRXL2 Smart ESC, the 1-based auxiliary channel its \"Thrust Rev.\" setting selects to arm the Reverse Brake. Must match how the ESC was programmed, because nothing on the wire advertises it. 0 disables reverse." + default_value: 5 + field: srxl2ReverseChannel + condition: USE_MOTOR_SRXL2 + min: 0 + max: 9 - name: motor_poles field: motorPoleCount description: "The number of motor poles. Required to compute motor RPM" diff --git a/src/main/flight/mixer.c b/src/main/flight/mixer.c index 9743be74dd3..858c7e6d5a2 100644 --- a/src/main/flight/mixer.c +++ b/src/main/flight/mixer.c @@ -96,6 +96,7 @@ PG_RESET_TEMPLATE(motorConfig_t, motorConfig, .motorPwmRate = SETTING_MOTOR_PWM_RATE_DEFAULT, .mincommand = SETTING_MIN_COMMAND_DEFAULT, .motorPoleCount = SETTING_MOTOR_POLES_DEFAULT, // Most brushless motors that we use are 14 poles + .srxl2ReverseChannel = SETTING_ESC_SRXL2_REVERSE_CHANNEL_DEFAULT, ); PG_REGISTER_ARRAY_WITH_RESET_FN(timerOverride_t, HARDWARE_TIMER_DEFINITION_COUNT, timerOverrides, PG_TIMER_OVERRIDE_CONFIG, 0); @@ -595,6 +596,11 @@ void FAST_CODE writeMotors(void) #endif } +reversibleMotorsThrottleState_e getReversibleMotorsThrottleState(void) +{ + return reversibleMotorsThrottleState; +} + void writeAllMotors(int16_t mc) { // Sends commands to all motors diff --git a/src/main/flight/mixer.h b/src/main/flight/mixer.h index d6095cf2240..c3bda2b2a8b 100644 --- a/src/main/flight/mixer.h +++ b/src/main/flight/mixer.h @@ -95,6 +95,10 @@ typedef struct motorConfig_s { uint8_t motorPwmProtocol; uint16_t digitalIdleOffsetValue; uint8_t motorPoleCount; // Magnetic poles in the motors for calculating actual RPM from eRPM provided by ESC telemetry + /* Appended at the end on purpose: pgLoad() compares only the parameter group + * version, never the size, so a field added here is additive and existing + * saved settings keep their offsets. */ + uint8_t srxl2ReverseChannel; // 1-based aux channel an SRXL2 ESC uses to arm reverse; 0 disables } motorConfig_t; PG_DECLARE(motorConfig_t, motorConfig); @@ -111,6 +115,8 @@ typedef enum { MOTOR_DIRECTION_DEADBAND } reversibleMotorsThrottleState_e; +reversibleMotorsThrottleState_e getReversibleMotorsThrottleState(void); + extern int16_t motor[MAX_SUPPORTED_MOTORS]; extern int16_t motor_disarmed[MAX_SUPPORTED_MOTORS]; extern int mixerThrottleCommand; diff --git a/src/main/sensors/esc_sensor.c b/src/main/sensors/esc_sensor.c index a06814a6846..b43794b7c2f 100644 --- a/src/main/sensors/esc_sensor.c +++ b/src/main/sensors/esc_sensor.c @@ -43,6 +43,9 @@ #include "flight/mixer.h" #include "drivers/pwm_output.h" #include "sensors/esc_sensor.h" +#include "drivers/pwm_mapping.h" + +#include "io/motor_srxl2.h" #include "io/serial.h" #include "fc/config.h" #include "fc/runtime_config.h" @@ -212,6 +215,23 @@ bool escSensorInitialize(void) return false; } +#ifdef USE_MOTOR_SRXL2 + /* + * An SRXL2 ESC reports telemetry back over the same wire that carries its + * throttle, so there is no separate telemetry port to open. Taking that + * source here rather than anywhere else means every existing consumer - + * the RPM filter, OSD, Blackbox, current estimation - is fed without + * knowing where the numbers came from. + */ + if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { + for (int i = 0; i < MAX_SUPPORTED_MOTORS; i++) { + escSensorData[i].dataAge = ESC_DATA_INVALID; + } + ENABLE_STATE(ESC_SENSOR_ENABLED); + return true; + } +#endif + // FUNCTION_ESCSERIAL is shared between SERIALSHOT and ESC_SENSOR telemetry // They are mutually exclusive serialPortConfig_t * portConfig = findSerialPortConfig(FUNCTION_ESCSERIAL); @@ -235,6 +255,30 @@ bool escSensorInitialize(void) void escSensorUpdate(timeUs_t currentTimeUs) { +#ifdef USE_MOTOR_SRXL2 + if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { + srxl2EscTelemetry_t t; + if (srxl2MotorGetTelemetry(0, &t)) { + escSensorData[0].dataAge = 0; + escSensorData[0].temperature = t.temperatureFet / 10; /* 0.1 degC -> degC */ + escSensorData[0].voltage = t.voltage; /* both 0.01 V */ + escSensorData[0].current = t.current; /* both 0.01 A */ + /* + * The wire carries electrical rpm; everything downstream expects + * mechanical, which is what computeRpm() produces for the serial + * backends. Same division, done here because our value is already in + * rpm rather than the LSB units that function takes. + */ + const uint8_t poles = motorConfig()->motorPoleCount; + escSensorData[0].rpm = poles ? (t.rpm / (poles / 2)) : 0; + } else if (escSensorData[0].dataAge < ESC_DATA_INVALID) { + escSensorData[0].dataAge++; + } + escSensorDataNeedsUpdate = true; + return; + } +#endif + if (!escSensorPort) { return; } diff --git a/src/main/target/common.h b/src/main/target/common.h index 035c759ec99..5cf2a24ada3 100644 --- a/src/main/target/common.h +++ b/src/main/target/common.h @@ -74,7 +74,15 @@ #define USE_SERVO_SBUS #endif -#define USE_MOTOR_SRXL2 // Spektrum Smart ESC (Smart Throttle) motor output +/* + * Spektrum Smart ESC ("Smart Throttle") motor output. Costs around 3.5 KB, so it + * is on by default only where flash is plentiful; a tighter target that wants it + * can define it for itself. Needs a spare UART rather than a motor pad, and + * drives one ESC, so it suits single-motor aircraft. + */ +#if defined(STM32H7) || defined(AT32F43x) +#define USE_MOTOR_SRXL2 +#endif #ifndef USE_ADC_AVERAGING #define USE_ADC_AVERAGING From 3a0d0eb27c2c72bfd4c99131ffa93ff3d385b926 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:21:44 +0200 Subject: [PATCH 11/40] esc_sensor: let SRXL2 telemetry be switched off, and nothing else change The previous commit took telemetry from the SRXL2 link whenever that was the motor protocol, with no way to decline. Turning ESC telemetry off needs no setting for a conventional ESC - you leave the port unassigned - but an SRXL2 ESC returns telemetry on the throttle wire, so there is no port to leave unassigned and no other way to say no, which also makes the two halves impossible to separate when something looks wrong on a bench. esc_srxl2_telemetry, default ON, is that switch. It sits in motorConfig beside esc_srxl2_reverse_channel so the two SRXL2 settings live together, it is conditional on USE_MOTOR_SRXL2, and it gates only the SRXL2 path. escSensorConfig and the serial backend are left byte-identical to upstream: a conventional setup gains no setting, sees no new behaviour, and has nothing new to misconfigure. An earlier attempt at this offered AUTO/SERIAL/SRXL2/NONE. The explicit middle values described combinations no real hardware wants - a Smart ESC has no separate telemetry lead, and forcing SRXL2 under a different motor protocol does nothing - so they were settings that could only be set wrong. Also guards motorConfig's new fields behind USE_MOTOR_SRXL2. The settings generator only emits the SETTING_..._DEFAULT macros where the condition holds, so an unguarded initialiser broke every target without the feature. Caught by building AIKONF7, which has it disabled: neither SITL nor the H7 target would have shown it, since SITL excludes esc_sensor.c and the H7 build has the feature on. Measured after: AIKONF7 at 459075 bytes, the same as without this work at all, and TBS_LUCID_H7_WING at 36.15% of flash. --- docs/Settings.md | 10 ++++++++++ src/main/fc/settings.yaml | 6 ++++++ src/main/flight/mixer.c | 3 +++ src/main/flight/mixer.h | 3 +++ src/main/sensors/esc_sensor.c | 7 +++++++ src/main/target/common.h | 18 +++++++++++++----- 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/docs/Settings.md b/docs/Settings.md index c8add9fb411..e7e0d859ca5 100644 --- a/docs/Settings.md +++ b/docs/Settings.md @@ -1055,6 +1055,16 @@ For an SRXL2 Smart ESC, the 1-based auxiliary channel its "Thrust Rev." setting --- +### esc_srxl2_telemetry + +Read ESC telemetry off the SRXL2 link. Only applies when motor_pwm_protocol is SRXL2, where telemetry shares the throttle wire and so cannot be turned off by leaving a port unassigned as it would be for a conventional ESC. + +| Default | Min | Max | +| --- | --- | --- | +| ON | OFF | ON | + +--- + ### ez_aggressiveness EzTune aggressiveness diff --git a/src/main/fc/settings.yaml b/src/main/fc/settings.yaml index cc91ee4d018..ae86611ce05 100644 --- a/src/main/fc/settings.yaml +++ b/src/main/fc/settings.yaml @@ -862,6 +862,12 @@ groups: default_value: "ONESHOT125" field: motorPwmProtocol table: motor_pwm_protocol + - name: esc_srxl2_telemetry + description: "Read ESC telemetry off the SRXL2 link. Only applies when motor_pwm_protocol is SRXL2, where telemetry shares the throttle wire and so cannot be turned off by leaving a port unassigned as it would be for a conventional ESC." + default_value: ON + field: srxl2Telemetry + condition: USE_MOTOR_SRXL2 + type: bool - name: esc_srxl2_reverse_channel description: "For an SRXL2 Smart ESC, the 1-based auxiliary channel its \"Thrust Rev.\" setting selects to arm the Reverse Brake. Must match how the ESC was programmed, because nothing on the wire advertises it. 0 disables reverse." default_value: 5 diff --git a/src/main/flight/mixer.c b/src/main/flight/mixer.c index 858c7e6d5a2..0e86b1c43cd 100644 --- a/src/main/flight/mixer.c +++ b/src/main/flight/mixer.c @@ -96,7 +96,10 @@ PG_RESET_TEMPLATE(motorConfig_t, motorConfig, .motorPwmRate = SETTING_MOTOR_PWM_RATE_DEFAULT, .mincommand = SETTING_MIN_COMMAND_DEFAULT, .motorPoleCount = SETTING_MOTOR_POLES_DEFAULT, // Most brushless motors that we use are 14 poles +#ifdef USE_MOTOR_SRXL2 .srxl2ReverseChannel = SETTING_ESC_SRXL2_REVERSE_CHANNEL_DEFAULT, + .srxl2Telemetry = SETTING_ESC_SRXL2_TELEMETRY_DEFAULT, +#endif ); PG_REGISTER_ARRAY_WITH_RESET_FN(timerOverride_t, HARDWARE_TIMER_DEFINITION_COUNT, timerOverrides, PG_TIMER_OVERRIDE_CONFIG, 0); diff --git a/src/main/flight/mixer.h b/src/main/flight/mixer.h index c3bda2b2a8b..ab847e73bd7 100644 --- a/src/main/flight/mixer.h +++ b/src/main/flight/mixer.h @@ -95,10 +95,13 @@ typedef struct motorConfig_s { uint8_t motorPwmProtocol; uint16_t digitalIdleOffsetValue; uint8_t motorPoleCount; // Magnetic poles in the motors for calculating actual RPM from eRPM provided by ESC telemetry +#ifdef USE_MOTOR_SRXL2 /* Appended at the end on purpose: pgLoad() compares only the parameter group * version, never the size, so a field added here is additive and existing * saved settings keep their offsets. */ uint8_t srxl2ReverseChannel; // 1-based aux channel an SRXL2 ESC uses to arm reverse; 0 disables + uint8_t srxl2Telemetry; // read ESC telemetry off the SRXL2 link +#endif } motorConfig_t; PG_DECLARE(motorConfig_t, motorConfig); diff --git a/src/main/sensors/esc_sensor.c b/src/main/sensors/esc_sensor.c index b43794b7c2f..51e9a2678bf 100644 --- a/src/main/sensors/esc_sensor.c +++ b/src/main/sensors/esc_sensor.c @@ -222,8 +222,15 @@ bool escSensorInitialize(void) * source here rather than anywhere else means every existing consumer - * the RPM filter, OSD, Blackbox, current estimation - is fed without * knowing where the numbers came from. + * + * esc_srxl2_telemetry can turn it off, which for a conventional ESC needs no + * setting at all - you leave the port unassigned. This wire has no port to + * leave unassigned, so saying no needs somewhere to say it. */ if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { + if (!motorConfig()->srxl2Telemetry) { + return false; + } for (int i = 0; i < MAX_SUPPORTED_MOTORS; i++) { escSensorData[i].dataAge = ESC_DATA_INVALID; } diff --git a/src/main/target/common.h b/src/main/target/common.h index 5cf2a24ada3..e0276b6b085 100644 --- a/src/main/target/common.h +++ b/src/main/target/common.h @@ -75,12 +75,20 @@ #endif /* - * Spektrum Smart ESC ("Smart Throttle") motor output. Costs around 3.5 KB, so it - * is on by default only where flash is plentiful; a tighter target that wants it - * can define it for itself. Needs a spare UART rather than a motor pad, and - * drives one ESC, so it suits single-motor aircraft. + * Spektrum Smart ESC ("Smart Throttle") motor output. Needs a spare UART rather + * than a motor pad, and drives one ESC, so it suits single-motor aircraft. + * + * On by default only where flash is plentiful, because it costs about 3.5 KB and + * AIKONF7 for instance sits at 93.4% of its flash. Any other target can still + * have it with one line in its own target.h, which is included after this file: + * + * #define USE_MOTOR_SRXL2 + * + * Where the default should sit is a judgement call rather than a constraint - the + * alternative is enabling it everywhere and charging every tight target for a + * protocol it may never use. */ -#if defined(STM32H7) || defined(AT32F43x) +#if !defined(USE_MOTOR_SRXL2) && (defined(STM32H7) || defined(AT32F43x)) #define USE_MOTOR_SRXL2 #endif From 357340abce8e2872e4040ef0afb6b58c31cb184a Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:42:43 +0200 Subject: [PATCH 12/40] msp: expose the SRXL2 ESC calibration over MSP The calibration already existed as a CLI command, but a wizard cannot use it: the Configurator confines CLI to its own tab and no other tab sends CLI lines. MSP2_INAV_ESC_SRXL2_CALIBRATE starts or aborts the sequence. MSP2_INAV_ESC_SRXL2_STATUS reports the current phase and whether an ESC is answering, so a wizard can follow what is happening rather than describing what it hopes is happening. The refusals are not re-implemented here. One of these phases commands full throttle with the aircraft disarmed, and a second copy of the conditions is a second thing to forget to update, so the command calls into the driver and honours what it returns. --- src/main/fc/fc_msp.c | 34 +++++++++++++++++++++++++++++ src/main/msp/msp_protocol_v2_inav.h | 3 +++ 2 files changed, 37 insertions(+) diff --git a/src/main/fc/fc_msp.c b/src/main/fc/fc_msp.c index 79ee6da48bb..80b8117c735 100644 --- a/src/main/fc/fc_msp.c +++ b/src/main/fc/fc_msp.c @@ -98,6 +98,7 @@ #include "io/rangefinder.h" #include "io/ledstrip.h" #include "io/osd.h" +#include "io/motor_srxl2.h" #include "io/serial.h" #include "io/serial_4way.h" #include "io/vtx.h" @@ -1686,6 +1687,13 @@ static bool mspFcProcessOutCommand(uint16_t cmdMSP, sbuf_t *dst, mspPostProcessF #endif break; +#ifdef USE_MOTOR_SRXL2 + case MSP2_INAV_ESC_SRXL2_STATUS: + sbufWriteU8(dst, srxl2MotorCalibrationPhase()); + sbufWriteU8(dst, srxl2MotorIsConnected() ? 1 : 0); + break; +#endif + case MSP2_INAV_WIND: #ifdef USE_WIND_ESTIMATOR { @@ -3793,6 +3801,32 @@ static mspResult_e mspFcProcessInCommand(uint16_t cmdMSP, sbuf_t *src) } break; +#ifdef USE_MOTOR_SRXL2 + case MSP2_INAV_ESC_SRXL2_CALIBRATE: + /* + * One of these phases commands full throttle with the aircraft disarmed, + * so the refusals live in the driver and are not re-implemented here: a + * caller that skipped them would otherwise be trusted. + */ + if (!sbufReadU8Safe(&tmp_u8, src)) { + return MSP_RESULT_ERROR; + } + if (tmp_u8 == SRXL2_CAL_OFF) { + srxl2MotorCalibrationAbort(); + } else if (tmp_u8 == SRXL2_CAL_WAIT_BATTERY) { + if (srxl2MotorCalibrationBegin() != SRXL2_CAL_ACCEPTED) { + return MSP_RESULT_ERROR; + } + } else if (tmp_u8 == SRXL2_CAL_HIGH_MANUAL || tmp_u8 == SRXL2_CAL_LOW_MANUAL) { + if (srxl2MotorCalibrationManual(tmp_u8) != SRXL2_CAL_ACCEPTED) { + return MSP_RESULT_ERROR; + } + } else { + return MSP_RESULT_ERROR; + } + break; +#endif + case MSP2_INAV_SELECT_MIXER_PROFILE: if (!ARMING_FLAG(ARMED) && sbufReadU8Safe(&tmp_u8, src)) { setConfigMixerProfileAndWriteEEPROM(tmp_u8); diff --git a/src/main/msp/msp_protocol_v2_inav.h b/src/main/msp/msp_protocol_v2_inav.h index 68790f18e7c..7488af81716 100755 --- a/src/main/msp/msp_protocol_v2_inav.h +++ b/src/main/msp/msp_protocol_v2_inav.h @@ -157,3 +157,6 @@ #define MSP2_INAV_SET_AUX_RC 0x2230 #define MSP2_INAV_WIND 0x2231 + +#define MSP2_INAV_ESC_SRXL2_STATUS 0x2232 +#define MSP2_INAV_ESC_SRXL2_CALIBRATE 0x2233 From c1006c44a90c9049eb15260529264cf5810f6946 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:56:19 +0200 Subject: [PATCH 13/40] docs: document the Spektrum Smart ESC protocol, and enable it for SITL A page covering the wiring, which is the part most likely to be got wrong: the signal wire goes to a UART's TX pin rather than a motor pad, and nothing connects to the ESC telemetry pad, because Smart Throttle carries telemetry on the throttle wire and a Smart ESC has no separate telemetry lead. Also covers the calibration sequence, the reverse channel having to match how the ESC was programmed since nothing on the wire advertises it, and why the pole count matters more than usual: the wire carries electrical rpm, so a wrong pole count puts the RPM filter's notch at the wrong frequency, which is worse than no notch. On the single-ESC limit, the page says what is actually true rather than calling the protocol single-motor. A bus can address several ESCs but each needs a unit ID that the specification says cannot be set over SRXL2, so one per bus; this driver drives one, on one port; a motor per port would be reasonable for a twin fixed-wing; and what rules out multirotors is the update rate, tens of hertz by design against DSHOT's kilohertz, not the wiring. SITL now defines USE_MOTOR_SRXL2. It is not covered by the flash-size rule in common.h, and opting in explicitly is both the documented mechanism for any target and useful here: SITL exposes each UART on a TCP port, so a simulated ESC can be attached to the real driver and the path from the Configurator through MSP to the wire exercised without hardware. --- docs/ESC and servo outputs.md | 6 ++ docs/Spektrum Smart ESC.md | 148 ++++++++++++++++++++++++++++++++++ src/main/target/SITL/target.h | 9 +++ 3 files changed, 163 insertions(+) create mode 100644 docs/Spektrum Smart ESC.md diff --git a/docs/ESC and servo outputs.md b/docs/ESC and servo outputs.md index eba8187ca56..a3c4a39d199 100644 --- a/docs/ESC and servo outputs.md +++ b/docs/ESC and servo outputs.md @@ -33,3 +33,9 @@ INAV 7 introduced extra functionality that let you force only some outputs to be The main restrictions is that outputs are associated with timers, which can be shared between multiple outputs and two outputs on the same timer need to have the same function. The easiest way to modify outputs, is to use the Mixer tab in the Configurator, as it will clearly show you which timer is used by all outputs, but you can also use `timer_output_mode` on the cli. + +## ESCs that are not driven from an output pin + +A Spektrum Smart ESC is connected to a UART rather than to a motor output, because +its protocol is serial rather than a timer waveform. None of the output mapping +above applies to it. See [Spektrum Smart ESC](Spektrum%20Smart%20ESC.md). diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md new file mode 100644 index 00000000000..edeb48fc970 --- /dev/null +++ b/docs/Spektrum Smart ESC.md @@ -0,0 +1,148 @@ +# Spektrum Smart ESC (SRXL2) + +Spektrum's "Smart Throttle" is the SRXL2 protocol carried on the ESC's throttle +signal wire: the ESC is an SRXL2 device and the receiver, or here the flight +controller, is the bus master. The same single wire carries the throttle one way +and telemetry the other. + +INAV can take that master role, which makes two things available that a PWM +connection cannot give you: + +* **Thrust reverse.** On an Avian this is only reachable over Smart Throttle. Wired + as a conventional PWM ESC there is no way to tell it to reverse at all. +* **Telemetry with no extra wire.** Voltage, current, rpm and temperatures arrive on + the throttle wire. There is no telemetry lead to run and no ESC telemetry pad to + connect. + +## Wiring + +The ESC's normal three-wire servo lead goes to a **UART**, not to a motor pad: + +| Wire | Goes to | +|---|---| +| signal | the **TX pin** of a port assigned `Spektrum Smart ESC (SRXL2)` | +| +5 V (BEC) | as usual | +| ground | as usual | + +The signal goes to TX rather than RX because the protocol is half duplex: one +conductor carries both directions, and the UART is put into single-wire mode. + +Nothing connects to the flight controller's ESC telemetry pad. That pad is for ESCs +with a separate telemetry lead, such as BLHeli or HobbyWing; a Smart ESC has no such +lead. + +The motor pad that would normally have driven this ESC is simply left unused. + +## Setting it up + +1. **Ports tab** — assign `Spektrum Smart ESC (SRXL2)` to a spare UART. +2. **Outputs tab** — set the ESC protocol to `SRXL2`. +3. **Outputs tab** — set **Motor poles** correctly. This matters more than usual; see + the RPM filter section below. +4. If the ESC is programmed for reverse, set **Thrust Reverse channel** to the + channel its own `Thrust Rev.` parameter selects. + +Both steps 1 and 2 are needed. This protocol has **no fallback to PWM**: the pin is +a UART pin, not a timer output, so a motor protocol of `SRXL2` with no port assigned +means the motor is never driven. The Outputs tab warns when that is the case. + +If your firmware was not built with this support the protocol does not appear in the +list at all, and neither does the port function. It is enabled by default on H7 and +AT32 targets; other targets can add `#define USE_MOTOR_SRXL2` to their `target.h`. + +## One ESC per port + +A single SRXL2 bus can address several ESCs, at device IDs 0x40 to 0x43, but each +needs a distinct unit ID and the specification states that setting a unit ID over +SRXL2 "is not implemented" — it expects physical switches or jumpers, which Avian +ESCs do not have. So in practice one ESC per bus. + +This driver drives one ESC, on one port. Nothing in the protocol prevents a motor +per port, and for a twin-engine fixed-wing that would be reasonable, but note that +SRXL2 sends at tens of hertz by design, where DSHOT sends at kilohertz. That is +ample for an aircraft holding a cruise throttle and nowhere near enough for a +multirotor, whatever the wiring. + +## Throttle range calibration + +A Spektrum ESC learns its throttle endpoints from the signal present as it powers +up: full throttle first, then low within five seconds of the tones that acknowledge +it. That normally needs a Spektrum transmitter, so INAV can drive the sequence +itself for anyone who does not own one. + +**Outputs tab**, Throttle range calibration: + +1. Remove the propeller and disconnect the battery. The wizard refuses to start with + the battery connected, and the button stays inert until you confirm both. +2. Press **Start calibration**. Full throttle goes on the wire — with no battery, + nothing can spin. +3. Connect the battery. The ESC sounds its tones, and the throttle drops to minimum + by itself about three seconds later. +4. A long tone means the range was stored. + +From the CLI the same thing is `esc_calibrate start`, with `esc_calibrate high` and +`low` available for boards that cannot sense battery voltage and therefore cannot +detect the ESC powering up. + +Either phase ends on its own if left alone, and arming cancels a sequence in +progress. + +## Telemetry and the RPM filter + +Telemetry is read from the SRXL2 link and feeds everything that consumes ESC +telemetry: OSD, Blackbox, current estimation, and the gyro RPM filter. It can be +switched off with `esc_srxl2_telemetry`, which exists because telemetry shares the +throttle wire and so cannot be declined by leaving a port unassigned as it would be +for a conventional ESC. + +**Set Motor poles correctly before enabling the RPM filter.** The wire carries +electrical rpm, and INAV converts it to mechanical rpm using the pole count. A wrong +pole count puts the notch at the wrong frequency, which is worse than having no +notch at all. + +Two things to weigh before turning `rpm_gyro_filter_enabled` on: + +* Telemetry arrives at roughly 10 Hz, not at the loop rate. On a single-motor + aircraft holding a cruise throttle, rpm and the vibration peak both move slowly + and that is adequate. It is not equivalent to bidirectional DSHOT. +* INAV's own advice for this setting applies unchanged: turn it on only once ESC + telemetry is working and the reported rpm looks right. + +## Reverse + +Reverse on an Avian is not a throttle value below neutral. The ESC's `Thrust Rev.` +parameter selects an auxiliary channel, named CH5 to CH9 on a Spektrum transmitter, +that arms the Reverse Brake, and `Brake Type` must be set to `Reverse`. + +So two things have to agree: + +* the ESC, programmed with `Brake Type = Reverse` and a `Thrust Rev.` channel; +* INAV, with `esc_srxl2_reverse_channel` set to the same channel. + +Nothing on the wire advertises which channel the ESC is watching, so a mismatch +simply means reverse never engages. + +INAV arms that channel from the mixer: when it has decided the motor should run +backwards, reverse is armed. There is no separate switch to set, deliberately — a +switch of its own could disagree with the direction INAV had chosen, and thrust +reverse is the last place that should happen. + +Note that `Brake Type = Reverse` changes what the throttle range means to the ESC: +centre becomes zero thrust. Configure INAV for reversible motors to match, or at +minimum throttle the aircraft will push backwards. + +## Settings + +| Setting | Meaning | +|---|---| +| `motor_pwm_protocol = SRXL2` | drive motors over SRXL2 | +| `esc_srxl2_reverse_channel` | 1-based channel the ESC uses to arm reverse; 0 disables | +| `esc_srxl2_telemetry` | read telemetry from the SRXL2 link | +| `motor_poles` | required for correct rpm, see above | + +## Reference + +Protocol: [Specification for Spektrum SRXL2](https://github.com/SpektrumRC/SRXL2), +published by Horizon Hobby under the MIT licence. The library in that repository +implements the device side; the bus master side is not part of the open release, so +the master in INAV is written from the specification. diff --git a/src/main/target/SITL/target.h b/src/main/target/SITL/target.h index 7052b29e978..59d4149a354 100644 --- a/src/main/target/SITL/target.h +++ b/src/main/target/SITL/target.h @@ -54,6 +54,15 @@ #define USE_UART8 #define SERIAL_PORT_COUNT 8 + +/* + * Spektrum Smart ESC. Not enabled by the flash-size rule in common.h, which only + * covers H7 and AT32, so SITL opts in explicitly - which is also the mechanism any + * other target uses. Worth having here because SITL exposes each UART on a TCP + * port, so a simulated ESC can be attached to the real driver and the whole path + * exercised without hardware. + */ +#define USE_MOTOR_SRXL2 #define SITL_SERIAL_TASK_US (500) #define DEFAULT_RX_FEATURE FEATURE_RX_MSP From 9842137ee46f01a909448e1b02458d26f239c2ec Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:37:24 +0200 Subject: [PATCH 14/40] SRXL2: drive N ESCs on N ports One ESC per bus, several buses. The driver held a single set of statics and srxl2MotorUpdate() took a motor index it then ignored, so a twin with two Avians folded both motors onto the first port and dropped the second silently. The per-bus state is now an array, one instance per assigned port, each with its own handshake, baud negotiation, receive framing and telemetry. Motors map to ports in port order, which is the only mapping available: nothing on an SRXL2 bus says which motor an ESC drives. Fewer ports than motors is refused at init with PWM_INIT_ERROR_NOT_ENOUGH_MOTOR_OUTPUTS rather than absorbed, because a motor with no port has no timer output to fall back on. Reverse is armed on every ESC together: the mixer decides a direction for the aircraft, and reversing one side of a twin and not the other is worth engineering against. Calibration likewise runs on all of them, since they share a battery and so power up together. esc_sensor fills escSensorData[i] per port. Three defects fixed along the way: - motorProtocolProperties[] had no PWM_TYPE_SRXL2 entry, so asking for its properties read one past the end of the array. Added unconditionally, because the value is stored in configuration and a diff restored from a board that has the protocol would otherwise index out of bounds on one that does not. - the handshake was re-entered on every handshake frame received. Our finalise broadcasts, the slave answers the broadcast, and that answer restarted the sequence - so the two ping-ponged handshakes indefinitely. Control data is sent on a timer reset on entering RUNNING, so the bus never reached its first control frame: the link read as established and the motor never turned. On a slow port it was worse, each restart queueing another broadcast so the transmit buffer never drained. The negotiation is now entered only from the states that are still looking for an ESC. - handshakes were counted per frame, which the calibration uses as its "an ESC just gained power" event. Counted per negotiation now. Verified against Spektrum's own SRXL2 library as the counterpart, compiled twice under renamed symbols so the two simulated ESCs are genuinely independent devices rather than one library in hub mode: 54 checks, 2175 frames accepted, 0 rejected by their parser. The harness also models the transmit buffer draining at the wire's rate instead of claiming it is always empty, which is what exposed the handshake loop - the deferred baud switch depends on that answer being honest. --- docs/Spektrum Smart ESC.md | 60 ++-- src/main/drivers/pwm_mapping.c | 20 ++ src/main/io/motor_srxl2.c | 489 +++++++++++++++++++++------------ src/main/io/motor_srxl2.h | 28 +- src/main/sensors/esc_sensor.c | 40 +-- src/main/target/common.h | 3 +- 6 files changed, 415 insertions(+), 225 deletions(-) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index edeb48fc970..dbbde638a80 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -35,7 +35,8 @@ The motor pad that would normally have driven this ESC is simply left unused. ## Setting it up -1. **Ports tab** — assign `Spektrum Smart ESC (SRXL2)` to a spare UART. +1. **Ports tab** — assign `Spektrum Smart ESC (SRXL2)` to a spare UART, one per + motor. They are matched to motors in port order; see below. 2. **Outputs tab** — set the ESC protocol to `SRXL2`. 3. **Outputs tab** — set **Motor poles** correctly. This matters more than usual; see the RPM filter section below. @@ -50,18 +51,31 @@ If your firmware was not built with this support the protocol does not appear in list at all, and neither does the port function. It is enabled by default on H7 and AT32 targets; other targets can add `#define USE_MOTOR_SRXL2` to their `target.h`. -## One ESC per port +## One ESC per port, several ports -A single SRXL2 bus can address several ESCs, at device IDs 0x40 to 0x43, but each -needs a distinct unit ID and the specification states that setting a unit ID over -SRXL2 "is not implemented" — it expects physical switches or jumpers, which Avian -ESCs do not have. So in practice one ESC per bus. +A single SRXL2 bus can address several ESCs, at device IDs 0x40 to 0x4F, but each +would need a distinct unit ID and the specification states that setting a unit ID +over SRXL2 "is not implemented" — it expects physical switches or jumpers, which +Avian ESCs do not have. So it is one ESC per bus, and a model with several motors +needs a port for each. -This driver drives one ESC, on one port. Nothing in the protocol prevents a motor -per port, and for a twin-engine fixed-wing that would be reasonable, but note that -SRXL2 sends at tens of hertz by design, where DSHOT sends at kilohertz. That is -ample for an aircraft holding a cruise throttle and nowhere near enough for a -multirotor, whatever the wiring. +Up to four are supported. **Motors are matched to ports in order:** motor 1 is the +lowest-numbered assigned UART, motor 2 the next, and so on. Nothing on the wire +says which motor an ESC drives, so the wiring order is what carries that. + +If there are fewer ports than the mixer has motors, the board **refuses to arm** +and reports `Not enough motor outputs/timers`. A motor with no port has nowhere to +send its command and no timer output to fall back on, so a twin that can only +drive one side must not be allowed into the air. The Outputs tab says so before it +gets that far. + +Each port is an independent bus: its own handshake, its own baud negotiation, its +own telemetry. A twin with one ESC unplugged therefore reports the link as down +rather than partly up. + +Note that SRXL2 sends at tens of hertz by design, where DSHOT sends at kilohertz. +That is ample for an aircraft holding a cruise throttle and nowhere near enough +for a multirotor, whatever the wiring. ## Throttle range calibration @@ -87,10 +101,15 @@ detect the ESC powering up. Either phase ends on its own if left alone, and arming cancels a sequence in progress. +With more than one ESC all of them are calibrated together. They share a battery, +so they power up together and the window the sequence aims at is the same window +for all of them. + ## Telemetry and the RPM filter Telemetry is read from the SRXL2 link and feeds everything that consumes ESC -telemetry: OSD, Blackbox, current estimation, and the gyro RPM filter. It can be +telemetry: OSD, Blackbox, current estimation, and the gyro RPM filter. Each port's +ESC reports as its own motor, so motor 2's telemetry is motor 2's. It can be switched off with `esc_srxl2_telemetry`, which exists because telemetry shares the throttle wire and so cannot be declined by leaving a port unassigned as it would be for a conventional ESC. @@ -102,9 +121,9 @@ notch at all. Two things to weigh before turning `rpm_gyro_filter_enabled` on: -* Telemetry arrives at roughly 10 Hz, not at the loop rate. On a single-motor - aircraft holding a cruise throttle, rpm and the vibration peak both move slowly - and that is adequate. It is not equivalent to bidirectional DSHOT. +* Telemetry arrives at roughly 10 Hz, not at the loop rate. On an aircraft holding + a cruise throttle, rpm and the vibration peak both move slowly and that is + adequate. It is not equivalent to bidirectional DSHOT. * INAV's own advice for this setting applies unchanged: turn it on only once ESC telemetry is working and the reported rpm looks right. @@ -123,9 +142,14 @@ Nothing on the wire advertises which channel the ESC is watching, so a mismatch simply means reverse never engages. INAV arms that channel from the mixer: when it has decided the motor should run -backwards, reverse is armed. There is no separate switch to set, deliberately — a -switch of its own could disagree with the direction INAV had chosen, and thrust -reverse is the last place that should happen. +backwards, reverse is armed, on every ESC at once. There is no separate switch to +set, deliberately — a switch of its own could disagree with the direction INAV had +chosen, and thrust reverse is the last place that should happen. For the same +reason it is not per motor: reversing one side of a twin and not the other is +worth engineering against. + +`esc_srxl2_reverse_channel` applies to all of them, so every ESC on the model has +to be programmed with the same `Thrust Rev.` channel. Note that `Brake Type = Reverse` changes what the throttle range means to the ESC: centre becomes zero thrust. Configure INAV for reversible motors to match, or at diff --git a/src/main/drivers/pwm_mapping.c b/src/main/drivers/pwm_mapping.c index 61302832983..06cdf5ea279 100644 --- a/src/main/drivers/pwm_mapping.c +++ b/src/main/drivers/pwm_mapping.c @@ -42,6 +42,7 @@ #include "sensors/rangefinder.h" #include "io/serial.h" +#include "io/motor_srxl2.h" #include "io/servo_sbus.h" enum { @@ -70,6 +71,11 @@ static const motorProtocolProperties_t motorProtocolProperties[] = { [PWM_TYPE_DSHOT150] = { .usesHwTimer = true, .isDSHOT = true }, [PWM_TYPE_DSHOT300] = { .usesHwTimer = true, .isDSHOT = true }, [PWM_TYPE_DSHOT600] = { .usesHwTimer = true, .isDSHOT = true }, + /* Not a timer waveform at all: the ESC hangs off a UART. The entry has to be + * here even on a target built without the protocol, because the value is + * stored in configuration and a diff restored from a board that does have it + * would otherwise index past the end of this array. */ + [PWM_TYPE_SRXL2] = { .usesHwTimer = false, .isDSHOT = false }, }; pwmInitError_e getPwmInitError(void) @@ -429,6 +435,20 @@ static void pwmInitMotors(timMotorServoHardware_t * timOutputs) // Do the pre-configuration. For motors w/o hardware timers this should be sufficient pwmMotorPreconfigure(); +#ifdef USE_MOTOR_SRXL2 + /* + * SRXL2 carries one ESC per port, so the mixer's motor count has to be met by + * that many assigned ports. A motor with no port has nowhere to send its + * command and no timer output to fall back on, and dropping it silently would + * mean arming a twin that can only drive one side. + */ + if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2 && srxl2MotorCount() < motorCount) { + pwmInitError = PWM_INIT_ERROR_NOT_ENOUGH_MOTOR_OUTPUTS; + LOG_ERROR(PWM, "Not enough SRXL2 ports. Mixer requested %d, ports %d", motorCount, srxl2MotorCount()); + return; + } +#endif + // Now if we need to configure individual motor outputs - do that if (!motorsUseHardwareTimers()) { LOG_INFO(PWM, "Skipped timer init for motors"); diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 5d4ea0366d9..95c9d830c20 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -228,35 +228,50 @@ typedef enum { SRXL2_RUNNING, } srxl2State_e; -static serialPort_t *srxl2Port = NULL; -static srxl2State_e srxl2State = SRXL2_DISABLED; +/* + * One of these per ESC. + * + * Each instance is an independent bus with its own handshake, baud negotiation, + * receive framing and telemetry. Nothing is shared between them except our own + * device ID, which is allowed precisely because they are separate buses and + * never hear each other. + */ +typedef struct { + serialPort_t *port; + srxl2State_e state; -static uint8_t rxBuf[SRXL2_MAX_FRAME]; -static uint8_t rxLen; -static uint8_t rxExpected; + uint8_t rxBuf[SRXL2_MAX_FRAME]; + uint8_t rxLen; + uint8_t rxExpected; -static timeMs_t stateEnteredMs; -static timeMs_t lastRxMs; -static timeMs_t lastTxMs; -static timeMs_t lastControlMs; + timeMs_t stateEnteredMs; + timeMs_t lastRxMs; + timeMs_t lastTxMs; + timeMs_t lastControlMs; -static uint8_t escDeviceId; /* 0 until discovered */ -static uint8_t escBaudSupported; -static uint8_t agreedBaudBits; -static bool baudSwitchPending; /* waiting for TX to drain */ + uint8_t deviceId; /* 0 until discovered */ + uint8_t baudSupported; + uint8_t agreedBaudBits; + bool baudSwitchPending; /* waiting for TX to drain */ -static uint16_t channelValue[32]; -static uint32_t channelMask; -static uint8_t reverseChannel1Based = 5; /* Avian "Thrust Rev." default: CH5 */ -static uint8_t telemRequestCounter; + uint16_t channelValue[32]; + uint32_t channelMask; + uint8_t telemRequestCounter; -static srxl2EscTelemetry_t escTelemetry; + srxl2EscTelemetry_t telemetry; -static srxl2CalPhase_e calPhase = SRXL2_CAL_OFF; -static timeMs_t calPhaseMs; /* when the current phase began */ -static uint32_t calHandshakeMark; /* handshake count when the phase began */ + uint32_t statTxFrames, statRxFrames, statCrcErrors, statHandshakes; +} srxl2Esc_t; -static uint32_t statTxFrames, statRxFrames, statCrcErrors, statHandshakes; +static srxl2Esc_t esc[SRXL2_ESC_MAX_MOTORS]; +static uint8_t escCount; /* ports successfully opened */ + +/* Shared, because these describe the aircraft rather than one bus. */ +static uint8_t reverseChannel1Based = 5; /* Avian "Thrust Rev." default: CH5 */ + +static srxl2CalPhase_e calPhase = SRXL2_CAL_OFF; +static timeMs_t calPhaseMs; /* when the current phase began */ +static uint32_t calHandshakeMark; /* total handshakes when the phase began */ /*--------------------------------------------------------------------------- * Helpers @@ -276,17 +291,31 @@ static inline uint16_t be16(const uint8_t *p) return (uint16_t)((p[0] << 8) | p[1]); } -static void srxl2SetState(srxl2State_e next) +static void srxl2SetState(srxl2Esc_t *e, srxl2State_e next) { - srxl2State = next; - stateEnteredMs = millis(); + e->state = next; + e->stateEnteredMs = millis(); +} + +/* + * Handshakes seen across every bus. The calibration watches this to notice an + * ESC gaining power, and with more than one ESC the first to speak is signal + * enough - they are all being powered from the same pack. + */ +static uint32_t srxl2TotalHandshakes(void) +{ + uint32_t total = 0; + for (uint8_t i = 0; i < escCount; i++) { + total += esc[i].statHandshakes; + } + return total; } /* Append the CRC and push the frame. buf[2] must already hold the frame length * as the specification's framing requires. */ -static void srxl2SendFrame(uint8_t *buf, uint8_t len) +static void srxl2SendFrame(srxl2Esc_t *e, uint8_t *buf, uint8_t len) { - if (!srxl2Port || len < SRXL2_MIN_FRAME || len > SRXL2_MAX_FRAME) { + if (!e->port || len < SRXL2_MIN_FRAME || len > SRXL2_MAX_FRAME) { return; } @@ -294,12 +323,12 @@ static void srxl2SendFrame(uint8_t *buf, uint8_t len) buf[len - 2] = (uint8_t)(crc >> 8); buf[len - 1] = (uint8_t)(crc & 0xFF); - serialWriteBuf(srxl2Port, buf, len); - lastTxMs = millis(); - statTxFrames++; + serialWriteBuf(e->port, buf, len); + e->lastTxMs = millis(); + e->statTxFrames++; } -static void srxl2SendHandshake(uint8_t destinationId, uint8_t baudField) +static void srxl2SendHandshake(srxl2Esc_t *e, uint8_t destinationId, uint8_t baudField) { uint8_t buf[sizeof(Srxl2HandshakeFrame)]; Srxl2HandshakeFrame *f = (Srxl2HandshakeFrame *)buf; @@ -317,27 +346,29 @@ static void srxl2SendHandshake(uint8_t destinationId, uint8_t baudField) f->payload.info = 0; /* non-RF device, no RF telemetry */ f->payload.uniqueId = 0x494E4156; /* "INAV"; only has to make a * simultaneous-reply collision - * improbable */ + * improbable. The same value on + * every bus is fine, because each + * bus has exactly one master. */ - srxl2SendFrame(buf, sizeof(Srxl2HandshakeFrame)); + srxl2SendFrame(e, buf, sizeof(Srxl2HandshakeFrame)); } /* Tell the bus which rate everyone moves to, and arrange to follow once the * frame has actually left the port. Switching immediately would clock the tail * of that very frame out at the new rate and lose it. */ -static void srxl2Finalise(void) +static void srxl2Finalise(srxl2Esc_t *e) { - agreedBaudBits = SRXL2_BAUD_BIT_400K & escBaudSupported; - srxl2SendHandshake(Broadcast, agreedBaudBits); - baudSwitchPending = (agreedBaudBits & SRXL2_BAUD_BIT_400K) != 0; - srxl2SetState(SRXL2_FINALISING); + e->agreedBaudBits = SRXL2_BAUD_BIT_400K & e->baudSupported; + srxl2SendHandshake(e, Broadcast, e->agreedBaudBits); + e->baudSwitchPending = (e->agreedBaudBits & SRXL2_BAUD_BIT_400K) != 0; + srxl2SetState(e, SRXL2_FINALISING); } /*--------------------------------------------------------------------------- * Telemetry decoding: STRU_TELE_ESC, big-endian on the wire *-------------------------------------------------------------------------*/ -static void srxl2DecodeEscTelemetry(const uint8_t *payload) +static void srxl2DecodeEscTelemetry(srxl2Esc_t *e, const uint8_t *payload) { /* payload[0] is the sensor id, payload[1] a secondary id. */ const uint16_t rpm = be16(&payload[2]); @@ -350,29 +381,30 @@ static void srxl2DecodeEscTelemetry(const uint8_t *payload) const uint8_t throttle = payload[14]; const uint8_t powerOut = payload[15]; - memset(&escTelemetry, 0, sizeof(escTelemetry)); + srxl2EscTelemetry_t *t = &e->telemetry; + memset(t, 0, sizeof(*t)); /* 0xFFFF and 0xFF mean "no data" and must not be taken for readings - * 0xFFFF volts at 0.01 V per count would otherwise look like 655 V. */ - if (rpm != 0xFFFF) { escTelemetry.rpm = (uint32_t)rpm * 10; } - if (voltsIn != 0xFFFF) { escTelemetry.voltage = voltsIn; } /* already 0.01 V */ - if (currentMot != 0xFFFF) { escTelemetry.current = currentMot; } /* 10 mA == 0.01 A */ - if (tempFet != 0xFFFF) { escTelemetry.temperatureFet = (int16_t)tempFet; } /* 0.1 degC */ - if (tempBec != 0xFFFF) { escTelemetry.temperatureBec = (int16_t)tempBec; } - if (currentBec != 0xFF) { escTelemetry.currentBec = (uint16_t)currentBec * 10; } /* 100 mA -> 0.01 A */ - if (voltsBec != 0xFF) { escTelemetry.voltageBec = (uint16_t)voltsBec * 5; } /* 0.05 V -> 0.01 V */ - if (throttle != 0xFF) { escTelemetry.throttlePercent = MIN((uint8_t)(throttle / 2), 100); } - if (powerOut != 0xFF) { escTelemetry.powerPercent = MIN((uint8_t)(powerOut / 2), 100); } - - escTelemetry.lastUpdateMs = millis(); - escTelemetry.valid = true; + if (rpm != 0xFFFF) { t->rpm = (uint32_t)rpm * 10; } + if (voltsIn != 0xFFFF) { t->voltage = voltsIn; } /* already 0.01 V */ + if (currentMot != 0xFFFF) { t->current = currentMot; } /* 10 mA == 0.01 A */ + if (tempFet != 0xFFFF) { t->temperatureFet = (int16_t)tempFet; } /* 0.1 degC */ + if (tempBec != 0xFFFF) { t->temperatureBec = (int16_t)tempBec; } + if (currentBec != 0xFF) { t->currentBec = (uint16_t)currentBec * 10; } /* 100 mA -> 0.01 A */ + if (voltsBec != 0xFF) { t->voltageBec = (uint16_t)voltsBec * 5; } /* 0.05 V -> 0.01 V */ + if (throttle != 0xFF) { t->throttlePercent = MIN((uint8_t)(throttle / 2), 100); } + if (powerOut != 0xFF) { t->powerPercent = MIN((uint8_t)(powerOut / 2), 100); } + + t->lastUpdateMs = millis(); + t->valid = true; } /*--------------------------------------------------------------------------- * Received frame handling *-------------------------------------------------------------------------*/ -static void srxl2HandleHandshake(const uint8_t *buf) +static void srxl2HandleHandshake(srxl2Esc_t *e, const uint8_t *buf) { const Srxl2HandshakeFrame *f = (const Srxl2HandshakeFrame *)buf; const uint8_t src = f->payload.sourceDeviceId; @@ -382,22 +414,54 @@ static void srxl2HandleHandshake(const uint8_t *buf) return; } - escDeviceId = src; - escBaudSupported = f->payload.baudSupported; - statHandshakes++; + /* + * The negotiation is entered once, and only from the states that are still + * looking for an ESC. Re-entering it on every handshake that arrives is a trap: + * our own finalise broadcasts, the slave answers the broadcast with a + * handshake, and if that answer restarts the sequence the two ping-pong + * handshakes indefinitely. Two ways that bites - + * + * - control data is sent on a timer reset on entering RUNNING, so a bus + * looping back through FINALISING never reaches the first control frame: + * the link looks established and the motor never turns; + * - each restart queues another broadcast, so on a slow or busy port the + * transmit buffer never drains and the bus never leaves FINALISING at all. + * + * A slave that genuinely reset is not missed by this. It comes back at 115200 + * while we are at 400000, so nothing it says is intelligible, and the link + * timeout drops us to POLLING at the low rate to find it again. + */ + if (e->state == SRXL2_FINALISING || e->state == SRXL2_RUNNING) { + /* Still answer a running ESC, so it knows the master is there - but say + * nothing mid-negotiation, where another broadcast is what causes the + * loop. */ + if (e->state == SRXL2_RUNNING && e->deviceId == src) { + srxl2SendHandshake(e, src, SRXL2_BAUD_BIT_400K); + } + return; + } + + e->deviceId = src; + e->baudSupported = f->payload.baudSupported; + + /* Counted here rather than on every handshake frame, so it means "a + * negotiation started" and not merely "a handshake went past". The + * calibration uses it as the signal that an ESC has just gained power, and a + * running ESC answering our keepalive is not that. */ + e->statHandshakes++; /* Answer the slave so it knows who the master is, then finalise. * - * This also covers the ESC being powered after the flight controller, which - * is the normal case on a bench: the board comes up on USB and the ESC only - * boots when the battery goes in, long after our listen window closed. Its - * handshake arrives while we are already RUNNING and has to be honoured, or - * the ESC is never found at all. */ - srxl2SendHandshake(escDeviceId, SRXL2_BAUD_BIT_400K); - srxl2Finalise(); + * This also covers the ESC being powered after the flight controller, which is + * the normal case on a bench: the board comes up on USB and the ESC only boots + * when the battery goes in, long after the listen window closed. By then we are + * in POLLING, which is one of the states that accepts a handshake, so the ESC + * is still found. */ + srxl2SendHandshake(e, e->deviceId, SRXL2_BAUD_BIT_400K); + srxl2Finalise(e); } -static void srxl2HandleTelemetry(const uint8_t *buf, uint8_t len) +static void srxl2HandleTelemetry(srxl2Esc_t *e, const uint8_t *buf, uint8_t len) { /* header(3) + destDeviceId(1) + 16 byte payload + crc(2) */ if (len < 3 + 1 + 16 + 2) { @@ -405,59 +469,59 @@ static void srxl2HandleTelemetry(const uint8_t *buf, uint8_t len) } const uint8_t *payload = &buf[4]; if (payload[0] == SRXL2_TELEM_SENSOR_ESC) { - srxl2DecodeEscTelemetry(payload); + srxl2DecodeEscTelemetry(e, payload); } } -static void srxl2HandleFrame(const uint8_t *buf, uint8_t len) +static void srxl2HandleFrame(srxl2Esc_t *e, const uint8_t *buf, uint8_t len) { const uint16_t crc = crc16_ccitt_update(0, buf, len - 2); if (buf[len - 2] != (uint8_t)(crc >> 8) || buf[len - 1] != (uint8_t)(crc & 0xFF)) { - statCrcErrors++; + e->statCrcErrors++; return; } - statRxFrames++; - lastRxMs = millis(); + e->statRxFrames++; + e->lastRxMs = millis(); switch (buf[1]) { case Handshake: - srxl2HandleHandshake(buf); + srxl2HandleHandshake(e, buf); break; case TelemetrySensorData: - srxl2HandleTelemetry(buf, len); + srxl2HandleTelemetry(e, buf, len); break; default: break; } } -static void srxl2DrainRx(void) +static void srxl2DrainRx(srxl2Esc_t *e) { - while (serialRxBytesWaiting(srxl2Port)) { - const uint8_t c = serialRead(srxl2Port); + while (serialRxBytesWaiting(e->port)) { + const uint8_t c = serialRead(e->port); - if (rxLen == 0) { + if (e->rxLen == 0) { if (c != SRXL2_MAGIC) { continue; /* resynchronise on the magic byte */ } - rxExpected = 0; + e->rxExpected = 0; } - rxBuf[rxLen++] = c; + e->rxBuf[e->rxLen++] = c; - if (rxLen == 3) { - rxExpected = rxBuf[2]; - if (rxExpected < SRXL2_MIN_FRAME || rxExpected > SRXL2_MAX_FRAME) { - rxLen = 0; /* bogus length, drop and resynchronise */ + if (e->rxLen == 3) { + e->rxExpected = e->rxBuf[2]; + if (e->rxExpected < SRXL2_MIN_FRAME || e->rxExpected > SRXL2_MAX_FRAME) { + e->rxLen = 0; /* bogus length, drop and resynchronise */ continue; } } - if (rxExpected && rxLen >= rxExpected) { - srxl2HandleFrame(rxBuf, rxLen); - rxLen = 0; - rxExpected = 0; + if (e->rxExpected && e->rxLen >= e->rxExpected) { + srxl2HandleFrame(e, e->rxBuf, e->rxLen); + e->rxLen = 0; + e->rxExpected = 0; } } } @@ -466,27 +530,29 @@ static void srxl2DrainRx(void) * Control data *-------------------------------------------------------------------------*/ -static void srxl2SendControlData(void) +static void srxl2SendControlData(srxl2Esc_t *e) { uint8_t buf[SRXL2_MAX_FRAME]; uint8_t n = 0; /* Request telemetry only occasionally - see SRXL2_TELEM_REQUEST_EVERY. */ uint8_t replyId = SRXL2_REPLY_NONE; - if (++telemRequestCounter >= SRXL2_TELEM_REQUEST_EVERY) { - telemRequestCounter = 0; - replyId = escDeviceId; + if (++e->telemRequestCounter >= SRXL2_TELEM_REQUEST_EVERY) { + e->telemRequestCounter = 0; + replyId = e->deviceId; } /* Calibration overrides the throttle here rather than at staging time, so no - * mixer path can quietly write over it between the two. */ + * mixer path can quietly write over it between the two. Every ESC is + * calibrated at once: they share a battery, so they power up together, and + * the window the sequence aims at is the same window for all of them. */ if (calPhase != SRXL2_CAL_OFF) { const bool high = (calPhase == SRXL2_CAL_WAIT_BATTERY) || (calPhase == SRXL2_CAL_SETTLE) || (calPhase == SRXL2_CAL_HIGH_MANUAL); - channelValue[SRXL2_CHANNEL_THROTTLE] = + e->channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(high ? SRXL2_CAL_HIGH_US : SRXL2_CAL_LOW_US); - channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); + e->channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); } buf[n++] = SRXL2_MAGIC; @@ -509,10 +575,10 @@ static void srxl2SendControlData(void) buf[n++] = 0; /* frameLosses low */ buf[n++] = 0; /* frameLosses high */ - buf[n++] = (uint8_t)(channelMask & 0xFF); - buf[n++] = (uint8_t)((channelMask >> 8) & 0xFF); - buf[n++] = (uint8_t)((channelMask >> 16) & 0xFF); - buf[n++] = (uint8_t)((channelMask >> 24) & 0xFF); + buf[n++] = (uint8_t)(e->channelMask & 0xFF); + buf[n++] = (uint8_t)((e->channelMask >> 8) & 0xFF); + buf[n++] = (uint8_t)((e->channelMask >> 16) & 0xFF); + buf[n++] = (uint8_t)((e->channelMask >> 24) & 0xFF); /* * Only the channels we actually mean, little-endian, lowest index first. @@ -525,15 +591,15 @@ static void srxl2SendControlData(void) * there simply leaves it idle. Wrong guess, safe outcome. */ for (uint8_t ch = 0; ch < 32; ch++) { - if (channelMask & (1u << ch)) { - buf[n++] = (uint8_t)(channelValue[ch] & 0xFF); - buf[n++] = (uint8_t)(channelValue[ch] >> 8); + if (e->channelMask & (1u << ch)) { + buf[n++] = (uint8_t)(e->channelValue[ch] & 0xFF); + buf[n++] = (uint8_t)(e->channelValue[ch] >> 8); } } n += 2; /* room for the CRC */ buf[2] = n; - srxl2SendFrame(buf, n); + srxl2SendFrame(e, buf, n); } /*--------------------------------------------------------------------------- @@ -542,51 +608,59 @@ static void srxl2SendControlData(void) bool srxl2MotorInitialize(void) { + memset(esc, 0, sizeof(esc)); + escCount = 0; + + /* + * One ESC per port, so open every port that was assigned the function, up to + * the array size. The enumeration follows serialConfig's port order, which is + * UART order, so motor 1 is the lowest-numbered assigned UART, motor 2 the + * next, and so on. That is the only mapping available: nothing on an SRXL2 + * bus says which motor an ESC drives, so the wiring order has to carry it. + */ const serialPortConfig_t *portConfig = findSerialPortConfig(FUNCTION_ESC_SRXL2); - if (!portConfig) { - return false; - } - srxl2Port = openSerialPort(portConfig->identifier, FUNCTION_ESC_SRXL2, NULL, NULL, - SRXL2_BAUD_LOW, MODE_RXTX, SRXL2_PORT_OPTIONS); - if (!srxl2Port) { - return false; - } + while (portConfig && escCount < SRXL2_ESC_MAX_MOTORS) { + serialPort_t *port = openSerialPort(portConfig->identifier, FUNCTION_ESC_SRXL2, + NULL, NULL, SRXL2_BAUD_LOW, MODE_RXTX, + SRXL2_PORT_OPTIONS); + if (port) { + srxl2Esc_t *e = &esc[escCount++]; - rxLen = 0; - rxExpected = 0; - memset(&escTelemetry, 0, sizeof(escTelemetry)); - memset(channelValue, 0, sizeof(channelValue)); - channelMask = 0; - escDeviceId = 0; - escBaudSupported = 0; - agreedBaudBits = 0; - baudSwitchPending = false; - telemRequestCounter = 0; + e->port = port; - /* Start the throttle channel at its lowest value rather than zero, so the - * first frame after a handshake cannot be read as something unexpected. */ - channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(1000); - channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); + /* Start the throttle channel at its lowest value rather than zero, so + * the first frame after a handshake cannot be read as something + * unexpected. */ + e->channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(1000); + e->channelMask = (1u << SRXL2_CHANNEL_THROTTLE); - const timeMs_t now = millis(); - lastRxMs = now; - lastTxMs = now; - lastControlMs = now; + const timeMs_t now = millis(); + e->lastRxMs = now; + e->lastTxMs = now; + e->lastControlMs = now; - srxl2SetState(SRXL2_LISTENING); - return true; + srxl2SetState(e, SRXL2_LISTENING); + } + + portConfig = findNextSerialPortConfig(FUNCTION_ESC_SRXL2); + } + + return escCount > 0; } void srxl2MotorUpdate(uint8_t index, uint16_t value) { - if (index >= SRXL2_ESC_MAX_MOTORS) { + if (index >= escCount) { + /* No port for this motor. There is nothing sensible to do here - no timer + * output to fall back on - so the shortfall is reported through + * srxl2MotorCount() and caught at arming rather than absorbed. */ return; } /* Staging only. The wire is driven at its own rate from srxl2MotorProcess(), * not at whatever rate the mixer happens to run. */ - channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(value); - channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); + esc[index].channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(value); + esc[index].channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); } void srxl2MotorSetReverse(bool armed) @@ -595,8 +669,15 @@ void srxl2MotorSetReverse(bool armed) return; /* reverse not configured */ } const uint8_t idx = reverseChannel1Based - 1; - channelValue[idx] = srxl2UsToValue(armed ? 2000 : 1000); - channelMask |= (1u << idx); + const uint16_t v = srxl2UsToValue(armed ? 2000 : 1000); + + /* Every ESC, because the mixer decides a direction for the aircraft rather + * than for one motor. Reversing one side of a twin and not the other is the + * one outcome here worth engineering against. */ + for (uint8_t i = 0; i < escCount; i++) { + esc[i].channelValue[idx] = v; + esc[i].channelMask |= (1u << idx); + } } void srxl2MotorSetReverseChannel(uint8_t channel1Based) @@ -611,7 +692,7 @@ static srxl2CalResult_e srxl2CalCommonChecks(void) if (ARMING_FLAG(ARMED)) { return SRXL2_CAL_REJECT_ARMED; } - if (!srxl2Port) { + if (escCount == 0) { return SRXL2_CAL_REJECT_NO_PORT; } return SRXL2_CAL_ACCEPTED; @@ -641,7 +722,7 @@ srxl2CalResult_e srxl2MotorCalibrationBegin(void) calPhase = SRXL2_CAL_WAIT_BATTERY; calPhaseMs = millis(); - calHandshakeMark = statHandshakes; + calHandshakeMark = srxl2TotalHandshakes(); return SRXL2_CAL_ACCEPTED; } @@ -687,12 +768,12 @@ static void srxl2CalProcess(timeMs_t now) /* * What opens the window is the ESC *gaining* power, which is an event, so * both signals have to be events too: the pack appearing, or a fresh - * handshake arriving. An earlier version tested escDeviceId != 0, which - * is persistent state left over from the last time the ESC was seen, so - * the wait was skipped outright on any board that had already talked to - * its ESC once. + * handshake arriving on any bus. An earlier version tested + * escDeviceId != 0, which is persistent state left over from the last + * time the ESC was seen, so the wait was skipped outright on any board + * that had already talked to its ESC once. */ - if (getBatteryState() != BATTERY_NOT_PRESENT || statHandshakes != calHandshakeMark) { + if (getBatteryState() != BATTERY_NOT_PRESENT || srxl2TotalHandshakes() != calHandshakeMark) { calPhase = SRXL2_CAL_SETTLE; calPhaseMs = now; } else if (elapsed >= SRXL2_CAL_WAIT_TIMEOUT_MS) { @@ -733,97 +814,139 @@ void srxl2MotorSendUpdate(void) * same hook it calls for every other protocol. */ } -void srxl2MotorProcess(void) +/* One bus, advanced by one tick. */ +static void srxl2ProcessEsc(srxl2Esc_t *e, timeMs_t now) { - if (!srxl2Port || srxl2State == SRXL2_DISABLED) { - return; - } - - srxl2DrainRx(); - - const timeMs_t now = millis(); - - srxl2CalProcess(now); + srxl2DrainRx(e); /* A deferred baud change completes as soon as the broadcast has left. */ - if (baudSwitchPending && isSerialTransmitBufferEmpty(srxl2Port)) { - serialSetBaudRate(srxl2Port, SRXL2_BAUD_HIGH); - baudSwitchPending = false; + if (e->baudSwitchPending && isSerialTransmitBufferEmpty(e->port)) { + serialSetBaudRate(e->port, SRXL2_BAUD_HIGH); + e->baudSwitchPending = false; } - switch (srxl2State) { + switch (e->state) { case SRXL2_LISTENING: /* Silent on purpose; an auto-announcing ESC is handled in * srxl2HandleHandshake(), which moves us on. */ - if (now - stateEnteredMs >= SRXL2_LISTEN_WINDOW_MS) { - srxl2SetState(SRXL2_POLLING); + if (now - e->stateEnteredMs >= SRXL2_LISTEN_WINDOW_MS) { + srxl2SetState(e, SRXL2_POLLING); } break; case SRXL2_POLLING: - if (now - lastTxMs >= SRXL2_HANDSHAKE_INTERVAL_MS) { + if (now - e->lastTxMs >= SRXL2_HANDSHAKE_INTERVAL_MS) { /* Poll the default ESC ID. An ESC with a non-zero unit ID never - * announces itself, so without this it would never be found. */ - srxl2SendHandshake(SRXL2_ESC_ID_FIRST, SRXL2_BAUD_BIT_400K); + * announces itself, so without this it would never be found. + * + * Every bus is polled at the same ID, which is not a collision: each + * ESC is alone on its wire and hears only its own master. */ + srxl2SendHandshake(e, SRXL2_ESC_ID_FIRST, SRXL2_BAUD_BIT_400K); } break; case SRXL2_FINALISING: /* Hold until the broadcast is out and any baud change has taken, then * start driving the ESC. */ - if (!baudSwitchPending && isSerialTransmitBufferEmpty(srxl2Port)) { - lastRxMs = now; /* do not time out on the handshake gap */ - lastControlMs = now; - srxl2SetState(SRXL2_RUNNING); + if (!e->baudSwitchPending && isSerialTransmitBufferEmpty(e->port)) { + e->lastRxMs = now; /* do not time out on the handshake gap */ + e->lastControlMs = now; + srxl2SetState(e, SRXL2_RUNNING); } break; case SRXL2_RUNNING: - if (now - lastControlMs >= SRXL2_CONTROL_INTERVAL_MS) { - lastControlMs = now; - srxl2SendControlData(); + if (now - e->lastControlMs >= SRXL2_CONTROL_INTERVAL_MS) { + e->lastControlMs = now; + srxl2SendControlData(e); } - if (escTelemetry.valid && (now - escTelemetry.lastUpdateMs) > SRXL2_TELEM_STALE_MS) { - escTelemetry.valid = false; + if (e->telemetry.valid && (now - e->telemetry.lastUpdateMs) > SRXL2_TELEM_STALE_MS) { + e->telemetry.valid = false; } - if (now - lastRxMs >= SRXL2_LINK_TIMEOUT_MS) { + if (now - e->lastRxMs >= SRXL2_LINK_TIMEOUT_MS) { /* Lost the ESC. Go back to 115200, where a slave that has just reset * will be listening, and look for it again. */ - serialSetBaudRate(srxl2Port, SRXL2_BAUD_LOW); - baudSwitchPending = false; - agreedBaudBits = 0; - escDeviceId = 0; - escTelemetry.valid = false; - srxl2SetState(SRXL2_POLLING); + serialSetBaudRate(e->port, SRXL2_BAUD_LOW); + e->baudSwitchPending = false; + e->agreedBaudBits = 0; + e->deviceId = 0; + e->telemetry.valid = false; + srxl2SetState(e, SRXL2_POLLING); } break; default: break; } +} + +void srxl2MotorProcess(void) +{ + if (escCount == 0) { + return; + } + + const timeMs_t now = millis(); - DEBUG_SET(DEBUG_ALWAYS, 0, srxl2State); - DEBUG_SET(DEBUG_ALWAYS, 1, escDeviceId); - DEBUG_SET(DEBUG_ALWAYS, 2, statRxFrames); - DEBUG_SET(DEBUG_ALWAYS, 3, statCrcErrors); + srxl2CalProcess(now); + + for (uint8_t i = 0; i < escCount; i++) { + srxl2ProcessEsc(&esc[i], now); + } + + /* + * The first two words carry a nibble per ESC, so a twin can be diagnosed + * without a debug channel per bus: which bus is stuck, and which has found + * its ESC. Both fit: the state enum is small, and ESC device IDs run + * 0x40..0x4F, so the low nibble identifies the unit. Bit 3 is set alongside + * it to distinguish unit 0 from "nothing found". + */ + uint16_t states = 0, ids = 0; + uint32_t rxFrames = 0, crcErrors = 0; + for (uint8_t i = 0; i < escCount; i++) { + states |= (uint16_t)(esc[i].state & 0x07) << (4 * i); + if (esc[i].deviceId) { + ids |= (uint16_t)((esc[i].deviceId & 0x07) | 0x08) << (4 * i); + } + rxFrames += esc[i].statRxFrames; + crcErrors += esc[i].statCrcErrors; + } + + DEBUG_SET(DEBUG_ALWAYS, 0, states); + DEBUG_SET(DEBUG_ALWAYS, 1, ids); + DEBUG_SET(DEBUG_ALWAYS, 2, rxFrames); + DEBUG_SET(DEBUG_ALWAYS, 3, crcErrors); +} + +uint8_t srxl2MotorCount(void) +{ + return escCount; } bool srxl2MotorIsConnected(void) { - return srxl2State == SRXL2_RUNNING && escDeviceId != 0; + if (escCount == 0) { + return false; + } + for (uint8_t i = 0; i < escCount; i++) { + if (esc[i].state != SRXL2_RUNNING || esc[i].deviceId == 0) { + return false; + } + } + return true; } bool srxl2MotorGetTelemetry(uint8_t index, srxl2EscTelemetry_t *out) { - if (index >= SRXL2_ESC_MAX_MOTORS || !out || !escTelemetry.valid) { + if (index >= escCount || !out || !esc[index].telemetry.valid) { return false; } - if (millis() - escTelemetry.lastUpdateMs > SRXL2_TELEM_STALE_MS) { + if (millis() - esc[index].telemetry.lastUpdateMs > SRXL2_TELEM_STALE_MS) { return false; } - *out = escTelemetry; + *out = esc[index].telemetry; return true; } diff --git a/src/main/io/motor_srxl2.h b/src/main/io/motor_srxl2.h index 3d7cf5d9cc6..1603aeb019d 100644 --- a/src/main/io/motor_srxl2.h +++ b/src/main/io/motor_srxl2.h @@ -35,12 +35,19 @@ #include #include -/* The spec assigns ESCs device IDs 0x40..0x4F, so a bus can carry several. - * In practice the unit ID has to be set on the ESC by physical means and the - * spec states that setting it over SRXL2 "is not implemented", so one ESC per - * bus is the realistic case. Kept as a constant rather than a literal so the - * limit is visible if that ever changes. */ -#define SRXL2_ESC_MAX_MOTORS 1 +/* + * One ESC per bus, several buses. + * + * The specification assigns ESCs device IDs 0x40..0x4F so a single bus could + * carry several, but each would need a distinct unit ID and the specification + * states that setting one over SRXL2 "is not implemented" - it expects physical + * switches, which Avian ESCs do not have. So each ESC gets its own port, and a + * multi-motor model needs as many ports as motors. + * + * Four is a practical ceiling: every instance costs a UART, and the protocol's + * update rate suits aircraft rather than multirotors anyway. + */ +#define SRXL2_ESC_MAX_MOTORS 4 /* Decoded ESC telemetry, from STRU_TELE_ESC (X-Bus sensor ID 0x20). * Units are INAV's, not the wire's: the wire sends 10 rpm, 0.01 V, 10 mA and @@ -162,7 +169,14 @@ void srxl2MotorSendUpdate(void); */ void srxl2MotorProcess(void); -/* True once an ESC has answered the handshake and is still responding. */ +/* How many ports were found and opened. Fewer than the model has motors means + * some motor has nowhere to send its command, which the caller must treat as a + * configuration error rather than carrying on. */ +uint8_t srxl2MotorCount(void); + +/* True once every opened ESC has answered the handshake and is still + * responding. One silent ESC on a twin is asymmetric thrust, so this is + * deliberately all of them rather than any. */ bool srxl2MotorIsConnected(void); /* diff --git a/src/main/sensors/esc_sensor.c b/src/main/sensors/esc_sensor.c index 51e9a2678bf..ce9408b4ee6 100644 --- a/src/main/sensors/esc_sensor.c +++ b/src/main/sensors/esc_sensor.c @@ -264,23 +264,31 @@ void escSensorUpdate(timeUs_t currentTimeUs) { #ifdef USE_MOTOR_SRXL2 if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { - srxl2EscTelemetry_t t; - if (srxl2MotorGetTelemetry(0, &t)) { - escSensorData[0].dataAge = 0; - escSensorData[0].temperature = t.temperatureFet / 10; /* 0.1 degC -> degC */ - escSensorData[0].voltage = t.voltage; /* both 0.01 V */ - escSensorData[0].current = t.current; /* both 0.01 A */ - /* - * The wire carries electrical rpm; everything downstream expects - * mechanical, which is what computeRpm() produces for the serial - * backends. Same division, done here because our value is already in - * rpm rather than the LSB units that function takes. - */ - const uint8_t poles = motorConfig()->motorPoleCount; - escSensorData[0].rpm = poles ? (t.rpm / (poles / 2)) : 0; - } else if (escSensorData[0].dataAge < ESC_DATA_INVALID) { - escSensorData[0].dataAge++; + /* One ESC per port, so escSensorData[i] belongs to the i-th assigned + * port. Motors past the last assigned port have no telemetry for the + * same reason they have no throttle, and are left invalid. */ + const uint8_t count = MIN(srxl2MotorCount(), (uint8_t)MAX_SUPPORTED_MOTORS); + + for (uint8_t i = 0; i < count; i++) { + srxl2EscTelemetry_t t; + if (srxl2MotorGetTelemetry(i, &t)) { + escSensorData[i].dataAge = 0; + escSensorData[i].temperature = t.temperatureFet / 10; /* 0.1 degC -> degC */ + escSensorData[i].voltage = t.voltage; /* both 0.01 V */ + escSensorData[i].current = t.current; /* both 0.01 A */ + /* + * The wire carries electrical rpm; everything downstream expects + * mechanical, which is what computeRpm() produces for the serial + * backends. Same division, done here because our value is already + * in rpm rather than the LSB units that function takes. + */ + const uint8_t poles = motorConfig()->motorPoleCount; + escSensorData[i].rpm = poles ? (t.rpm / (poles / 2)) : 0; + } else if (escSensorData[i].dataAge < ESC_DATA_INVALID) { + escSensorData[i].dataAge++; + } } + escSensorDataNeedsUpdate = true; return; } diff --git a/src/main/target/common.h b/src/main/target/common.h index e0276b6b085..6a6eb90e687 100644 --- a/src/main/target/common.h +++ b/src/main/target/common.h @@ -76,7 +76,8 @@ /* * Spektrum Smart ESC ("Smart Throttle") motor output. Needs a spare UART rather - * than a motor pad, and drives one ESC, so it suits single-motor aircraft. + * than a motor pad - one ESC per port, so a model with several motors needs that + * many ports. * * On by default only where flash is plentiful, because it costs about 3.5 KB and * AIKONF7 for instance sits at 93.4% of its flash. Any other target can still From b4710d46db6a6939046e9ab7c5cad35327a73a05 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:39:12 +0200 Subject: [PATCH 15/40] SRXL2: make thrust reverse a mode, and fix two ways it misfired Reverse on a Smart ESC is a switch, not a throttle value. Spektrum say so in as many words - "flipping the designated switch reverses motor rotation, throttle will still control motor speed" - so the throttle goes on meaning throttle and the reverse channel is a binary arm. That maps onto a mode, which is how every other pilot-commanded action in INAV works, so there is now a THRUST REVERSE box. It is offered only when the protocol is SRXL2 and a reverse channel is set, so it never appears as a mode that silently does nothing. Deriving reverse from the reversible-motor mixer state, as this did before, was the wrong model for the aircraft the feature is for. It requires FEATURE_REVERSIBLE_MOTORS, which recentres the throttle stick so mid-stick is zero thrust: forward thrust then lives only in the top half of the stick, chopping the throttle on short final commands reverse in the air, arming needs the stick centred rather than down, and an ARM switch becomes mandatory. Reverse is also unreachable from every automatic throttle path, since they all clamp to at least idle. Fine for a 3D model, wrong for an aeroplane that wants reverse on the landing roll. The mixer state is still honoured for the models it does suit; either route arms reverse and neither masks the other. Two defects fixed: - a reverse channel of 1 landed on the throttle's own slot, and srxl2MotorSetReverse() writes its channel at task rate, so it overwrote the mixer's staged throttle several hundred times a second: the motor held at idle whenever reverse was released and at full throttle whenever it was armed. The setting's range cannot express "zero, or five to nine", so the driver refuses the collision itself. - reverse was never released on disarm. mixTable() returns early while disarmed and so stops updating the direction, while the task kept publishing it, leaving an aircraft that landed under reverse sitting on the ground with the ESC's reverse channel armed until the throttle next went forward. Default reverse channel moved from 5 to 7, which is what Spektrum ship: the Avian 160/200HV manual states "It's default setting is channel 7" and the 130 Pro table marks CH7 likewise. Sending on 5 while the ESC watched 7 meant reverse never engaged, silently, out of the box. Documentation rewritten to match: the setting selects a slot on the SRXL2 wire and not a transmitter channel, the switch is assigned in Modes, the channel must match the ESC's own Thrust Rev. parameter, and reverse cannot assist an automatic landing. Oracle: 57 checks, 0 failures, including a new one pinning the throttle-collision refusal. --- docs/Settings.md | 4 +- docs/Spektrum Smart ESC.md | 82 ++++++++++++++++++++++++++++---------- src/main/fc/fc_msp_box.c | 25 ++++++++++++ src/main/fc/fc_tasks.c | 25 ++++++++++-- src/main/fc/rc_modes.h | 1 + src/main/fc/settings.yaml | 4 +- src/main/io/motor_srxl2.c | 32 +++++++++++++-- 7 files changed, 139 insertions(+), 34 deletions(-) diff --git a/docs/Settings.md b/docs/Settings.md index e7e0d859ca5..97929e32975 100644 --- a/docs/Settings.md +++ b/docs/Settings.md @@ -1047,11 +1047,11 @@ Enable when BLHeli32 Auto Telemetry function is used. Disable in every other cas ### esc_srxl2_reverse_channel -For an SRXL2 Smart ESC, the 1-based auxiliary channel its "Thrust Rev." setting selects to arm the Reverse Brake. Must match how the ESC was programmed, because nothing on the wire advertises it. 0 disables reverse. +For an SRXL2 Smart ESC, the 1-based auxiliary channel its "Thrust Rev." setting selects to arm reverse. Spektrum allow channels 5 to 9 and ship channel 7 by default. Must match how the ESC was programmed, because nothing on the wire advertises it. 0 disables reverse. | Default | Min | Max | | --- | --- | --- | -| 5 | 0 | 9 | +| 7 | 0 | 9 | --- diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index dbbde638a80..a38c62ca6a9 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -40,8 +40,9 @@ The motor pad that would normally have driven this ESC is simply left unused. 2. **Outputs tab** — set the ESC protocol to `SRXL2`. 3. **Outputs tab** — set **Motor poles** correctly. This matters more than usual; see the RPM filter section below. -4. If the ESC is programmed for reverse, set **Thrust Reverse channel** to the - channel its own `Thrust Rev.` parameter selects. +4. If the ESC is programmed for reverse, set **Thrust Reverse: ESC channel** to the + channel its own `Thrust Rev.` parameter selects, then assign the **THRUST + REVERSE** mode to a switch in the Modes tab. See below. Both steps 1 and 2 are needed. This protocol has **no fallback to PWM**: the pin is a UART pin, not a timer output, so a motor protocol of `SRXL2` with no port assigned @@ -129,38 +130,75 @@ Two things to weigh before turning `rpm_gyro_filter_enabled` on: ## Reverse -Reverse on an Avian is not a throttle value below neutral. The ESC's `Thrust Rev.` -parameter selects an auxiliary channel, named CH5 to CH9 on a Spektrum transmitter, -that arms the Reverse Brake, and `Brake Type` must be set to `Reverse`. +Reverse on a Smart ESC is a switch, not a throttle value below neutral. The ESC's +`Thrust Rev.` parameter names an auxiliary channel; when that channel goes high the +ESC reverses, and Spektrum describe the effect plainly — *"flipping the designated +switch reverses motor rotation, throttle will still control motor speed"*. So the +throttle goes on meaning throttle. -So two things have to agree: +Three things have to agree: -* the ESC, programmed with `Brake Type = Reverse` and a `Thrust Rev.` channel; -* INAV, with `esc_srxl2_reverse_channel` set to the same channel. +* the ESC, programmed with a `Thrust Rev.` channel (and `Brake Type = Reverse` on + models that have it); +* `esc_srxl2_reverse_channel`, set to that same channel; +* a switch, assigned to the **THRUST REVERSE** mode in the Modes tab. -Nothing on the wire advertises which channel the ESC is watching, so a mismatch -simply means reverse never engages. +Spektrum allow channels **5 to 9** for this and ship **channel 7** as the factory +default. Nothing on the wire advertises which one the ESC is watching, so a +mismatch simply means reverse never engages — silently. Check the channel against +your own ESC's programming rather than trusting the default: the parameter is not +present on every Avian model, and where it is present the range and default have +varied. -INAV arms that channel from the mixer: when it has decided the motor should run -backwards, reverse is armed, on every ESC at once. There is no separate switch to -set, deliberately — a switch of its own could disagree with the direction INAV had -chosen, and thrust reverse is the last place that should happen. For the same -reason it is not per motor: reversing one side of a twin and not the other is -worth engineering against. +The channel setting is not a transmitter channel. It selects a slot on the SRXL2 +wire between the flight controller and the ESC. The transmitter switch is chosen in +the Modes tab like any other mode. -`esc_srxl2_reverse_channel` applies to all of them, so every ESC on the model has -to be programmed with the same `Thrust Rev.` channel. +`esc_srxl2_reverse_channel` applies to every ESC on the model, so a twin needs both +ESCs programmed with the same `Thrust Rev.` channel. Reverse is armed on all of +them together and never on one alone: asymmetric reverse thrust on a twin is the +outcome most worth engineering against. -Note that `Brake Type = Reverse` changes what the throttle range means to the ESC: -centre becomes zero thrust. Configure INAV for reversible motors to match, or at -minimum throttle the aircraft will push backwards. +Reverse is released whenever the aircraft is disarmed, so a machine that landed +under reverse does not sit on the ground with it still armed. + +### If your ESC uses a centred throttle instead + +Some Spektrum documentation shows a bipolar throttle scale for the reverse brake +mode, where centre is zero thrust and below centre is reverse. That is not what the +switch-and-normal-throttle wording above describes, and the two cannot both be true +of the same ESC, so this is worth checking on your own hardware before the first +flight. + +If yours behaves that way, enable INAV's own `FEATURE_REVERSIBLE_MOTORS`. The +driver honours that too: when the mixer decides the motor should run backwards, the +reverse channel is armed, exactly as the THRUST REVERSE mode would. Either route +works and neither masks the other. + +Be aware of what that feature does to an aeroplane, though, which is why it is not +the default route: + +* the throttle stick becomes centre-zero, so forward thrust lives only in the top + half of the stick; +* chopping the throttle on short final then commands reverse thrust in the air; +* arming requires the throttle stick **centred** rather than down, and an ARM + switch becomes mandatory — INAV disarms continuously without one. + +For an aeroplane that wants reverse only on the landing roll, the mode is the right +answer and the feature is not. + +### What reverse cannot do + +Reverse is a manual, stick-and-switch capability. INAV's automatic throttle paths — +RTH, autoland, failsafe, launch — all clamp throttle to at least idle, so none of +them can call for reverse thrust. An automatic landing will not use it. ## Settings | Setting | Meaning | |---|---| | `motor_pwm_protocol = SRXL2` | drive motors over SRXL2 | -| `esc_srxl2_reverse_channel` | 1-based channel the ESC uses to arm reverse; 0 disables | +| `esc_srxl2_reverse_channel` | SRXL2 channel the ESC watches for reverse, 5 to 9; Spektrum default 7, 0 disables | | `esc_srxl2_telemetry` | read telemetry from the SRXL2 link | | `motor_poles` | required for correct rpm, see above | diff --git a/src/main/fc/fc_msp_box.c b/src/main/fc/fc_msp_box.c index 0be7382ad07..885701f6797 100644 --- a/src/main/fc/fc_msp_box.c +++ b/src/main/fc/fc_msp_box.c @@ -29,11 +29,13 @@ #include "fc/config.h" #include "fc/fc_msp_box.h" #include "fc/runtime_config.h" + #include "flight/mixer.h" #include "flight/mixer_profile.h" #include "io/osd.h" +#include "drivers/pwm_mapping.h" #include "drivers/pwm_output.h" #include "sensors/diagnostics.h" @@ -117,6 +119,7 @@ static const box_t boxes[CHECKBOX_ITEM_COUNT + 1] = { { .boxId = BOXAUTOSPEED, .boxName = "AUTO SPEED", .permanentId = 69 }, { .boxId = BOXTERRAINAGLHOLD, .boxName = "TERRAIN AGL HOLD", .permanentId = 70 }, { .boxId = BOXINFLIGHTMENU, .boxName = "IN FLIGHT MENU", .permanentId = 71 }, + { .boxId = BOXTHRUSTREVERSE, .boxName = "THRUST REVERSE", .permanentId = 72 }, { .boxId = CHECKBOX_ITEM_COUNT, .boxName = NULL, .permanentId = 0xFF } }; @@ -393,6 +396,28 @@ void initActiveBoxIds(void) #ifdef USE_CMS ADD_ACTIVE_BOX(BOXINFLIGHTMENU); #endif + +#ifdef USE_MOTOR_SRXL2 + /* + * Thrust reverse on a Spektrum Smart ESC is a switch, not a throttle value: + * the ESC's "Thrust Rev." parameter names an auxiliary channel, and Spektrum + * describe the effect as "flipping the designated switch reverses motor + * rotation, throttle will still control motor speed". + * + * So it belongs on a mode, the way every other pilot-commanded action does. + * The alternative - deriving it from the reversible-motor mixer state - needs + * FEATURE_REVERSIBLE_MOTORS, which recentres the throttle stick so that mid + * stick is zero thrust. That suits a 3D model and is wrong for an aeroplane + * that wants reverse only on the landing roll, where chopping the throttle on + * short final would otherwise command reverse thrust in the air. + * + * Offered only when the protocol can act on it and a channel is set, so it + * does not appear as a mode that silently does nothing. + */ + if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2 && motorConfig()->srxl2ReverseChannel != 0) { + ADD_ACTIVE_BOX(BOXTHRUSTREVERSE); + } +#endif } #define IS_ENABLED(mask) ((mask) == 0 ? 0 : 1) diff --git a/src/main/fc/fc_tasks.c b/src/main/fc/fc_tasks.c index 95e311638c0..db77ad4ee90 100755 --- a/src/main/fc/fc_tasks.c +++ b/src/main/fc/fc_tasks.c @@ -324,10 +324,27 @@ void taskSyncServoDriver(timeUs_t currentTimeUs) * faster than its own Control Data interval to collect replies promptly on a * half-duplex wire. */ if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { - /* Reverse follows the mixer rather than a mode of its own: when INAV has - * decided the motor should run backwards, the ESC's reverse channel is - * armed. Anything else would let the two disagree about direction. */ - srxl2MotorSetReverse(getReversibleMotorsThrottleState() == MOTOR_DIRECTION_BACKWARD); + /* + * Two ways to ask for reverse, because there are two kinds of model. + * + * The THRUST REVERSE mode is the one an aeroplane uses: Spektrum describe + * the ESC's reverse channel as a switch that flips rotation while the + * throttle goes on meaning throttle, so a mode maps onto it exactly. The + * pilot arms it on the landing roll and the throttle still commands power. + * + * The mixer's reversible-motor state covers the other kind, where INAV + * itself decides direction from a recentred throttle stick. Honouring both + * costs nothing and neither can mask the other: either asking for reverse + * is reverse. + * + * Both are gated on being armed. The mixer stops updating its direction + * while disarmed - mixTable() returns early - so an aircraft that landed + * under reverse would otherwise sit on the ground with the ESC's reverse + * channel held armed until the throttle next went forward. + */ + const bool reverseAsked = IS_RC_MODE_ACTIVE(BOXTHRUSTREVERSE) + || getReversibleMotorsThrottleState() == MOTOR_DIRECTION_BACKWARD; + srxl2MotorSetReverse(ARMING_FLAG(ARMED) && reverseAsked); srxl2MotorProcess(); } #endif diff --git a/src/main/fc/rc_modes.h b/src/main/fc/rc_modes.h index 59de340bac9..a49733bfbe3 100644 --- a/src/main/fc/rc_modes.h +++ b/src/main/fc/rc_modes.h @@ -88,6 +88,7 @@ typedef enum { BOXAUTOSPEED = 60, BOXTERRAINAGLHOLD = 61, BOXINFLIGHTMENU = 62, + BOXTHRUSTREVERSE = 63, CHECKBOX_ITEM_COUNT } boxId_e; diff --git a/src/main/fc/settings.yaml b/src/main/fc/settings.yaml index ae86611ce05..717f925d942 100644 --- a/src/main/fc/settings.yaml +++ b/src/main/fc/settings.yaml @@ -869,8 +869,8 @@ groups: condition: USE_MOTOR_SRXL2 type: bool - name: esc_srxl2_reverse_channel - description: "For an SRXL2 Smart ESC, the 1-based auxiliary channel its \"Thrust Rev.\" setting selects to arm the Reverse Brake. Must match how the ESC was programmed, because nothing on the wire advertises it. 0 disables reverse." - default_value: 5 + description: "For an SRXL2 Smart ESC, the 1-based auxiliary channel its \"Thrust Rev.\" setting selects to arm reverse. Spektrum allow channels 5 to 9 and ship channel 7 by default. Must match how the ESC was programmed, because nothing on the wire advertises it. 0 disables reverse." + default_value: 7 field: srxl2ReverseChannel condition: USE_MOTOR_SRXL2 min: 0 diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 95c9d830c20..5442978c39b 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -267,7 +267,7 @@ static srxl2Esc_t esc[SRXL2_ESC_MAX_MOTORS]; static uint8_t escCount; /* ports successfully opened */ /* Shared, because these describe the aircraft rather than one bus. */ -static uint8_t reverseChannel1Based = 5; /* Avian "Thrust Rev." default: CH5 */ +static uint8_t reverseChannel1Based = 7; /* Spektrum ship "Thrust Rev." on CH7 */ static srxl2CalPhase_e calPhase = SRXL2_CAL_OFF; static timeMs_t calPhaseMs; /* when the current phase began */ @@ -663,10 +663,34 @@ void srxl2MotorUpdate(uint8_t index, uint16_t value) esc[index].channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); } +/* + * Whether a configured reverse channel can actually be used. + * + * Zero means the model has no reverse. Anything that would land on the throttle + * channel is refused outright: srxl2MotorSetReverse() runs at task rate and + * writes its channel unconditionally, so a reverse channel aliased onto the + * throttle would overwrite the mixer's staged throttle several hundred times a + * second - holding the motor at idle whenever reverse was released, and + * commanding full throttle whenever it was armed. The setting's own range + * (0..9) cannot express "zero, or five to nine", so the check belongs here. + * + * The upper bound is the width of the channel array and of the wire's mask. + * Spektrum documents Smart ESC reverse as available on channels 5 to 9 only, but + * that is the ESC's restriction rather than the protocol's, so it is enforced by + * the setting and the Configurator rather than refused here. + */ +static bool srxl2ReverseChannelUsable(uint8_t channel1Based) +{ + if (channel1Based == 0 || channel1Based > 32) { + return false; + } + return (channel1Based - 1) != SRXL2_CHANNEL_THROTTLE; +} + void srxl2MotorSetReverse(bool armed) { - if (reverseChannel1Based == 0 || reverseChannel1Based > 32) { - return; /* reverse not configured */ + if (!srxl2ReverseChannelUsable(reverseChannel1Based)) { + return; /* reverse not configured, or the channel is not usable */ } const uint8_t idx = reverseChannel1Based - 1; const uint16_t v = srxl2UsToValue(armed ? 2000 : 1000); @@ -682,7 +706,7 @@ void srxl2MotorSetReverse(bool armed) void srxl2MotorSetReverseChannel(uint8_t channel1Based) { - reverseChannel1Based = channel1Based; + reverseChannel1Based = srxl2ReverseChannelUsable(channel1Based) ? channel1Based : 0; } /* Checked on both sides rather than trusting the caller, because one of these From 58293bf62694985f1ece61406c1b99750a07173b Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:04:12 +0200 Subject: [PATCH 16/40] Do not let a DSHOT range check rewrite the SRXL2 protocol validateAndFixConfig() forced any protocol above BRUSHED to MULTISHOT on builds without USE_DSHOT. SRXL2 was appended after DSHOT600, so it fell inside that range and was silently rewritten on every boot: the setting could be saved, but the board came back up on MULTISHOT and nothing SRXL2 was reachable - no port function acted on, no THRUST REVERSE mode offered, no ESC block in the Outputs tab. The check is about DSHOT, so it now names DSHOT rather than testing "above BRUSHED". SRXL2 is a UART protocol and a build without DSHOT can drive it perfectly well. Found on SITL, which is built without DSHOT, once the whole path was exercised from the Configurator against a SITL binary carrying this branch. Verified there over MSP: the protocol survives a reboot and the firmware then reports THRUST REVERSE in its mode list. --- src/main/fc/config.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/fc/config.c b/src/main/fc/config.c index 5e1c87ac145..ae9888ee50a 100755 --- a/src/main/fc/config.c +++ b/src/main/fc/config.c @@ -266,7 +266,12 @@ void validateAndFixConfig(void) // Limitations of different protocols #if !defined(USE_DSHOT) - if (motorConfig()->motorPwmProtocol > PWM_TYPE_BRUSHED) { + // Named explicitly rather than tested as "above BRUSHED". This is a DSHOT + // check, and the enum has since grown a UART protocol above DSHOT600 that a + // build without DSHOT can still drive perfectly well - a range test would + // quietly rewrite it to MULTISHOT on every boot. + if (motorConfig()->motorPwmProtocol >= PWM_TYPE_DSHOT150 && + motorConfig()->motorPwmProtocol <= PWM_TYPE_DSHOT600) { motorConfigMutable()->motorPwmProtocol = PWM_TYPE_MULTISHOT; } #endif From a11f7668e2ef7e961685fab95eaa1f8a302a4ce5 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:25:54 +0200 Subject: [PATCH 17/40] SRXL2: report why a calibration was refused The throttle-range calibration has preconditions - disarmed, an open SRXL2 port, a voltage sensor, and the battery not yet connected - and MSP2_INAV_ESC_SRXL2_CALIBRATE is an IN command, so a refusal reached the caller as a bare error with nowhere to say which one failed. A wizard cannot act on that: it either has to guess or carry on regardless, and carrying on means telling the operator to connect a battery to a sequence that never started, then reporting it finished. The driver now remembers its verdict and MSP2_INAV_ESC_SRXL2_STATUS reports it, alongside the number of open ports. Both are appended, so a reader that expects the old two bytes is unaffected. --- src/main/fc/fc_msp.c | 5 +++++ src/main/io/motor_srxl2.c | 18 ++++++++++++------ src/main/io/motor_srxl2.h | 8 ++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/fc/fc_msp.c b/src/main/fc/fc_msp.c index 80b8117c735..7567372450e 100644 --- a/src/main/fc/fc_msp.c +++ b/src/main/fc/fc_msp.c @@ -1691,6 +1691,11 @@ static bool mspFcProcessOutCommand(uint16_t cmdMSP, sbuf_t *dst, mspPostProcessF case MSP2_INAV_ESC_SRXL2_STATUS: sbufWriteU8(dst, srxl2MotorCalibrationPhase()); sbufWriteU8(dst, srxl2MotorIsConnected() ? 1 : 0); + /* Why the last start was refused. MSP2_INAV_ESC_SRXL2_CALIBRATE is an IN + * command and so has nowhere to answer; without this a caller sees only + * that it failed, and can tell the operator nothing. */ + sbufWriteU8(dst, srxl2MotorCalibrationLastResult()); + sbufWriteU8(dst, srxl2MotorCount()); break; #endif diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 5442978c39b..8dedcf73b5c 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -270,6 +270,7 @@ static uint8_t escCount; /* ports successfully opened */ static uint8_t reverseChannel1Based = 7; /* Spektrum ship "Thrust Rev." on CH7 */ static srxl2CalPhase_e calPhase = SRXL2_CAL_OFF; +static srxl2CalResult_e calLastResult = SRXL2_CAL_ACCEPTED; static timeMs_t calPhaseMs; /* when the current phase began */ static uint32_t calHandshakeMark; /* total handshakes when the phase began */ @@ -726,13 +727,13 @@ srxl2CalResult_e srxl2MotorCalibrationBegin(void) { const srxl2CalResult_e common = srxl2CalCommonChecks(); if (common != SRXL2_CAL_ACCEPTED) { - return common; + return (calLastResult = common); } /* Detecting the ESC powering up is the whole mechanism, so say so plainly * instead of starting a sequence that can never advance. */ if (!isBatteryVoltageConfigured()) { - return SRXL2_CAL_REJECT_NO_VOLTAGE_SENSOR; + return (calLastResult = SRXL2_CAL_REJECT_NO_VOLTAGE_SENSOR); } /* @@ -741,25 +742,25 @@ srxl2CalResult_e srxl2MotorCalibrationBegin(void) * it would mean presenting full throttle to an ESC that can act on it. */ if (getBatteryState() != BATTERY_NOT_PRESENT) { - return SRXL2_CAL_REJECT_BATTERY_PRESENT; + return (calLastResult = SRXL2_CAL_REJECT_BATTERY_PRESENT); } calPhase = SRXL2_CAL_WAIT_BATTERY; calPhaseMs = millis(); calHandshakeMark = srxl2TotalHandshakes(); - return SRXL2_CAL_ACCEPTED; + return (calLastResult = SRXL2_CAL_ACCEPTED); } srxl2CalResult_e srxl2MotorCalibrationManual(srxl2CalPhase_e phase) { const srxl2CalResult_e common = srxl2CalCommonChecks(); if (common != SRXL2_CAL_ACCEPTED) { - return common; + return (calLastResult = common); } calPhase = phase; calPhaseMs = millis(); - return SRXL2_CAL_ACCEPTED; + return (calLastResult = SRXL2_CAL_ACCEPTED); } void srxl2MotorCalibrationAbort(void) @@ -772,6 +773,11 @@ srxl2CalPhase_e srxl2MotorCalibrationPhase(void) return calPhase; } +srxl2CalResult_e srxl2MotorCalibrationLastResult(void) +{ + return calLastResult; +} + /* Advance the unattended sequence. Every phase leaves on a deadline, so nothing * here can strand the output at full throttle. */ static void srxl2CalProcess(timeMs_t now) diff --git a/src/main/io/motor_srxl2.h b/src/main/io/motor_srxl2.h index 1603aeb019d..bf87147a582 100644 --- a/src/main/io/motor_srxl2.h +++ b/src/main/io/motor_srxl2.h @@ -115,6 +115,14 @@ srxl2CalResult_e srxl2MotorCalibrationManual(srxl2CalPhase_e phase); void srxl2MotorCalibrationAbort(void); srxl2CalPhase_e srxl2MotorCalibrationPhase(void); +/* + * Why the last request to start a calibration was refused, or SRXL2_CAL_ACCEPTED + * if it was not. MSP cannot carry a reason back on an IN command, so a caller that + * only saw the command fail could not tell the operator anything useful - and the + * useful thing here is precisely which precondition was not met. + */ +srxl2CalResult_e srxl2MotorCalibrationLastResult(void); + /* * Open the serial port assigned FUNCTION_ESC_SRXL2 and start the handshake. * Returns false if no port is assigned or it could not be opened, in which case From d378c8f1c9c44765aaae88af053717395a57b5b2 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:35:30 +0200 Subject: [PATCH 18/40] SRXL2: report the real motor count, and open the ports on SITL Two things a caller could not previously get right. MSP2_INAV_ESC_SRXL2_STATUS now reports the mixer's motor count alongside the number of open ports. These are exactly the two numbers pwmInitMotors() compares to decide whether the board may arm, so a caller no longer has to infer either. The obvious place to look, MSP2_INAV_MIXER, is a trap: its last two bytes are MAX_SUPPORTED_MOTORS and MAX_SUPPORTED_SERVOS - the compile-time ceilings, not the model - so reading a motor count from there reports 12 on any board. SITL now opens its SRXL2 ports. It has no motor output layer at all - the simulator reads the mixer's motor[] array directly, so pwmMotorPreconfigure() never runs and nothing opened them. Since SITL maps every UART onto a TCP port, opening them is what makes the target.h comment true: a simulated ESC can be attached to the real driver, and the handshake, telemetry and calibration paths exercised, with no hardware. Verified against a SITL carrying this branch: assign the function, set the protocol, reboot, and the status reports one open port and accepts a calibration. --- src/main/fc/fc_init.c | 15 +++++++++++++++ src/main/fc/fc_msp.c | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/src/main/fc/fc_init.c b/src/main/fc/fc_init.c index 8b6b03bf42c..5313732318d 100644 --- a/src/main/fc/fc_init.c +++ b/src/main/fc/fc_init.c @@ -117,6 +117,7 @@ #include "io/osd.h" #include "io/osd_dji_hd.h" #include "io/rcdevice_cam.h" +#include "io/motor_srxl2.h" #include "io/serial.h" #include "io/displayport_msp.h" #include "io/smartport_master.h" @@ -353,6 +354,20 @@ void init(void) } #else DISABLE_ARMING_FLAG(ARMING_DISABLED_PWM_OUTPUT_ERROR); +#ifdef USE_MOTOR_SRXL2 + /* + * SITL has no motor output layer - the simulator reads the mixer's motor[] + * array directly, so pwmMotorPreconfigure() never runs and nothing would open + * the SRXL2 ports. Open them here instead: SITL maps every UART onto a TCP + * port, so this is what lets a simulated ESC be attached to the real driver + * and the handshake, telemetry and calibration paths be exercised - and the + * Configurator show its ESC block - without any hardware. + */ + if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { + srxl2MotorInitialize(); + srxl2MotorSetReverseChannel(motorConfig()->srxl2ReverseChannel); + } +#endif #endif systemState |= SYSTEM_STATE_MOTORS_READY; diff --git a/src/main/fc/fc_msp.c b/src/main/fc/fc_msp.c index 7567372450e..ab06d2f5f18 100644 --- a/src/main/fc/fc_msp.c +++ b/src/main/fc/fc_msp.c @@ -1695,7 +1695,13 @@ static bool mspFcProcessOutCommand(uint16_t cmdMSP, sbuf_t *dst, mspPostProcessF * command and so has nowhere to answer; without this a caller sees only * that it failed, and can tell the operator nothing. */ sbufWriteU8(dst, srxl2MotorCalibrationLastResult()); + /* Ports opened, and motors the mixer wants. These are the two numbers + * pwmInitMotors() compares to decide whether the board may arm, so + * reporting both means a caller never has to infer either. In particular + * MSP2_INAV_MIXER does NOT carry the model motor count - its last two + * bytes are MAX_SUPPORTED_MOTORS and MAX_SUPPORTED_SERVOS, the ceilings. */ sbufWriteU8(dst, srxl2MotorCount()); + sbufWriteU8(dst, getMotorCount()); break; #endif From e232c5756d6b6d2fa75d97ea668f6c612b7c84ea Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:35:42 +0200 Subject: [PATCH 19/40] SRXL2: make the telemetry rate configurable How often the ESC is asked for telemetry was a compile-time constant. The reply shares the throttle wire with the control data, so the right answer depends on the model: the RPM filter wants it fast, everything else is a display and reads fine at a few hertz, and a busy bus wants it slow. esc_srxl2_telemetry_rate now offers 50, 25, 10, 5 and 2 Hz, derived from the 50 Hz control rate. The table lists the default first so that it is index 0, which is not cosmetic. The field is appended to motorConfig_t, but it landed inside the struct's existing padding, so sizeof did not grow and pgLoad()'s memcpy copies the old zero over the reset default - a configuration saved before this existed reads index 0 whatever the default says. Verified on SITL: with a pre-existing eeprom the field read 0 while a fresh one read the default, which with the natural fast-to-slow ordering would have silently moved every upgraded board to a telemetry request on every single control frame. With the default at index 0 both now agree. Worth remembering for the next field: appending to the end of a parameter group is necessary but not sufficient. It is only additive if it actually grows the struct past its padding. --- docs/Settings.md | 10 ++++++++++ src/main/drivers/pwm_output.c | 1 + src/main/fc/fc_init.c | 1 + src/main/fc/settings.yaml | 13 +++++++++++++ src/main/flight/mixer.c | 1 + src/main/flight/mixer.h | 1 + src/main/io/motor_srxl2.c | 24 +++++++++++++++++++++--- src/main/io/motor_srxl2.h | 19 +++++++++++++++++++ 8 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/Settings.md b/docs/Settings.md index 97929e32975..e4480dcb183 100644 --- a/docs/Settings.md +++ b/docs/Settings.md @@ -1065,6 +1065,16 @@ Read ESC telemetry off the SRXL2 link. Only applies when motor_pwm_protocol is S --- +### esc_srxl2_telemetry_rate + +How often an SRXL2 Smart ESC is asked for telemetry. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; a slower one is steadier. Only the RPM filter really benefits from the faster settings. + +| Default | Min | Max | +| --- | --- | --- | +| 10HZ | | | + +--- + ### ez_aggressiveness EzTune aggressiveness diff --git a/src/main/drivers/pwm_output.c b/src/main/drivers/pwm_output.c index bc622ec4ea6..891d59c23e7 100644 --- a/src/main/drivers/pwm_output.c +++ b/src/main/drivers/pwm_output.c @@ -626,6 +626,7 @@ void pwmMotorPreconfigure(void) * outcome of a port that was never assigned. */ if (srxl2MotorInitialize()) { srxl2MotorSetReverseChannel(motorConfig()->srxl2ReverseChannel); + srxl2MotorSetTelemetryRate(motorConfig()->srxl2TelemetryRate); motorWritePtr = srxl2MotorUpdate; } break; diff --git a/src/main/fc/fc_init.c b/src/main/fc/fc_init.c index 5313732318d..78a2586d568 100644 --- a/src/main/fc/fc_init.c +++ b/src/main/fc/fc_init.c @@ -366,6 +366,7 @@ void init(void) if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { srxl2MotorInitialize(); srxl2MotorSetReverseChannel(motorConfig()->srxl2ReverseChannel); + srxl2MotorSetTelemetryRate(motorConfig()->srxl2TelemetryRate); } #endif #endif diff --git a/src/main/fc/settings.yaml b/src/main/fc/settings.yaml index 717f925d942..5bb9132f659 100644 --- a/src/main/fc/settings.yaml +++ b/src/main/fc/settings.yaml @@ -28,6 +28,13 @@ tables: values: ["SERIAL", "SPIFLASH", "SDCARD", "FILE"] - name: motor_pwm_protocol values: ["STANDARD", "ONESHOT125", "MULTISHOT", "BRUSHED", "DSHOT150", "DSHOT300", "DSHOT600", "SRXL2"] + # The default is FIRST on purpose, so that it is index 0. This field was appended + # to motorConfig_t and landed inside the struct's existing padding, so sizeof did + # not grow and pgLoad()'s memcpy copies the old zero over the reset default: an + # existing configuration reads whatever index 0 happens to be. Putting the default + # there makes that harmless instead of silently selecting the fastest rate. + - name: esc_srxl2_telemetry_rate + values: ["10HZ", "50HZ", "25HZ", "5HZ", "2HZ"] - name: servo_protocol values: ["PWM", "SBUS", "SBUS_PWM"] - name: failsafe_procedure @@ -868,6 +875,12 @@ groups: field: srxl2Telemetry condition: USE_MOTOR_SRXL2 type: bool + - name: esc_srxl2_telemetry_rate + description: "How often an SRXL2 Smart ESC is asked for telemetry. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; a slower one is steadier. Only the RPM filter really benefits from the faster settings." + default_value: "10HZ" # must stay index 0, see the table comment + field: srxl2TelemetryRate + condition: USE_MOTOR_SRXL2 + table: esc_srxl2_telemetry_rate - name: esc_srxl2_reverse_channel description: "For an SRXL2 Smart ESC, the 1-based auxiliary channel its \"Thrust Rev.\" setting selects to arm reverse. Spektrum allow channels 5 to 9 and ship channel 7 by default. Must match how the ESC was programmed, because nothing on the wire advertises it. 0 disables reverse." default_value: 7 diff --git a/src/main/flight/mixer.c b/src/main/flight/mixer.c index 0e86b1c43cd..ccbb1bda588 100644 --- a/src/main/flight/mixer.c +++ b/src/main/flight/mixer.c @@ -98,6 +98,7 @@ PG_RESET_TEMPLATE(motorConfig_t, motorConfig, .motorPoleCount = SETTING_MOTOR_POLES_DEFAULT, // Most brushless motors that we use are 14 poles #ifdef USE_MOTOR_SRXL2 .srxl2ReverseChannel = SETTING_ESC_SRXL2_REVERSE_CHANNEL_DEFAULT, + .srxl2TelemetryRate = SETTING_ESC_SRXL2_TELEMETRY_RATE_DEFAULT, .srxl2Telemetry = SETTING_ESC_SRXL2_TELEMETRY_DEFAULT, #endif ); diff --git a/src/main/flight/mixer.h b/src/main/flight/mixer.h index ab847e73bd7..1c4cf41620e 100644 --- a/src/main/flight/mixer.h +++ b/src/main/flight/mixer.h @@ -101,6 +101,7 @@ typedef struct motorConfig_s { * saved settings keep their offsets. */ uint8_t srxl2ReverseChannel; // 1-based aux channel an SRXL2 ESC uses to arm reverse; 0 disables uint8_t srxl2Telemetry; // read ESC telemetry off the SRXL2 link + uint8_t srxl2TelemetryRate; // how often to ask the ESC for telemetry, as srxl2TelemetryRate_e #endif } motorConfig_t; diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 8dedcf73b5c..92daa91399f 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -149,7 +149,7 @@ * Every fifth frame at 50 Hz gives 10 Hz, which is in line with what INAV's * other ESC telemetry backends deliver. */ -#define SRXL2_TELEM_REQUEST_EVERY 5 +#define SRXL2_TELEM_REQUEST_DEFAULT 5 /* Declare the link dead if the ESC stops answering for this long. */ #define SRXL2_LINK_TIMEOUT_MS 500 @@ -269,6 +269,13 @@ static uint8_t escCount; /* ports successfully opened */ /* Shared, because these describe the aircraft rather than one bus. */ static uint8_t reverseChannel1Based = 7; /* Spektrum ship "Thrust Rev." on CH7 */ +/* + * Control frames between telemetry requests. The control interval is 20 ms, so a + * divisor of 5 asks at 10 Hz. Held as a divisor rather than a rate because that is + * what the transmit path actually counts. + */ +static uint8_t telemRequestEvery = SRXL2_TELEM_REQUEST_DEFAULT; + static srxl2CalPhase_e calPhase = SRXL2_CAL_OFF; static srxl2CalResult_e calLastResult = SRXL2_CAL_ACCEPTED; static timeMs_t calPhaseMs; /* when the current phase began */ @@ -536,9 +543,11 @@ static void srxl2SendControlData(srxl2Esc_t *e) uint8_t buf[SRXL2_MAX_FRAME]; uint8_t n = 0; - /* Request telemetry only occasionally - see SRXL2_TELEM_REQUEST_EVERY. */ + /* Request telemetry only every so often: the reply shares the wire with the + * control data, so asking on every frame halves the headroom for no gain on a + * sensor whose values move slowly. */ uint8_t replyId = SRXL2_REPLY_NONE; - if (++e->telemRequestCounter >= SRXL2_TELEM_REQUEST_EVERY) { + if (++e->telemRequestCounter >= telemRequestEvery) { e->telemRequestCounter = 0; replyId = e->deviceId; } @@ -710,6 +719,15 @@ void srxl2MotorSetReverseChannel(uint8_t channel1Based) reverseChannel1Based = srxl2ReverseChannelUsable(channel1Based) ? channel1Based : 0; } +void srxl2MotorSetTelemetryRate(srxl2TelemetryRate_e rate) +{ + /* Indexed by srxl2TelemetryRate_e, and derived from the 50 Hz control rate: + * every frame is 50 Hz, every 25th is 2 Hz. */ + static const uint8_t divisor[] = { 5, 1, 2, 10, 25 }; + + telemRequestEvery = (rate < ARRAYLEN(divisor)) ? divisor[rate] : SRXL2_TELEM_REQUEST_DEFAULT; +} + /* Checked on both sides rather than trusting the caller, because one of these * phases commands full throttle with the aircraft disarmed. */ static srxl2CalResult_e srxl2CalCommonChecks(void) diff --git a/src/main/io/motor_srxl2.h b/src/main/io/motor_srxl2.h index bf87147a582..b26ebb4760e 100644 --- a/src/main/io/motor_srxl2.h +++ b/src/main/io/motor_srxl2.h @@ -158,6 +158,25 @@ void srxl2MotorSetReverse(bool armed); */ void srxl2MotorSetReverseChannel(uint8_t channel1Based); +/* + * How often the ESC is asked for telemetry. Its reply shares the throttle wire, so + * this trades bus headroom against how promptly rpm and voltage move - which only + * matters to the RPM filter, everything else being a display. + */ +typedef enum { + /* 10 Hz is first so that it is zero. The setting lives in a field appended to + * motorConfig_t that fell inside existing padding, so a configuration saved + * before it existed loads index 0 rather than the reset default - which must + * therefore be the value we would have chosen anyway, not the fastest one. */ + SRXL2_TELEM_10HZ = 0, + SRXL2_TELEM_50HZ, + SRXL2_TELEM_25HZ, + SRXL2_TELEM_5HZ, + SRXL2_TELEM_2HZ, +} srxl2TelemetryRate_e; + +void srxl2MotorSetTelemetryRate(srxl2TelemetryRate_e rate); + /* * Emit the staged values as an SRXL2 Control Data packet. Intended to be called * once per motor update from pwmCompleteMotorUpdate(). From 30fd0f7fc3879d62ff0615a66e27226dcdbf67f7 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:41:40 +0200 Subject: [PATCH 20/40] esc_sensor: do not require a serial port to report telemetry escSensorGetData() returned NULL unless escSensorPort was open. That was the same question as "is there ESC telemetry" for as long as the only source was a dedicated telemetry lead, but a Spektrum Smart ESC reports over the throttle wire itself, handled by the motor driver, so the port stays NULL while the data is perfectly good. Everything that reads through that function therefore saw nothing on such a board: the OSD's ESC elements, the Blackbox ESC fields, current and voltage estimation, and the Jeti and SBUS2 telemetry backends. The OSD was inconsistent with itself as a result - it checks STATE(ESC_SENSOR_ENABLED), which was set, and then asks a function that answered NULL. It now asks the state flag, which is the question it meant. Nothing changes for a conventional ESC: there the flag is only set once the port has opened. The gyro RPM filter was unaffected throughout - it reads getEscTelemetry() directly and never went through this path - which is why the split was easy to miss. --- docs/Spektrum Smart ESC.md | 10 +++++++--- src/main/sensors/esc_sensor.c | 12 +++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index a38c62ca6a9..4a3a0e632d6 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -122,9 +122,12 @@ notch at all. Two things to weigh before turning `rpm_gyro_filter_enabled` on: -* Telemetry arrives at roughly 10 Hz, not at the loop rate. On an aircraft holding - a cruise throttle, rpm and the vibration peak both move slowly and that is - adequate. It is not equivalent to bidirectional DSHOT. +* Telemetry arrives at whatever `esc_srxl2_telemetry_rate` asks for, 10 Hz by + default, not at the loop rate. On an aircraft holding a cruise throttle, rpm and + the vibration peak both move slowly and that is adequate. Raising it to 50 Hz + tracks better and costs bus headroom, since the reply shares the throttle wire. + Even at 50 Hz this is not equivalent to bidirectional DSHOT, which reports every + loop. * INAV's own advice for this setting applies unchanged: turn it on only once ESC telemetry is working and the reported rpm looks right. @@ -200,6 +203,7 @@ them can call for reverse thrust. An automatic landing will not use it. | `motor_pwm_protocol = SRXL2` | drive motors over SRXL2 | | `esc_srxl2_reverse_channel` | SRXL2 channel the ESC watches for reverse, 5 to 9; Spektrum default 7, 0 disables | | `esc_srxl2_telemetry` | read telemetry from the SRXL2 link | +| `esc_srxl2_telemetry_rate` | how often the ESC is asked: 50, 25, 10, 5 or 2 Hz | | `motor_poles` | required for correct rpm, see above | ## Reference diff --git a/src/main/sensors/esc_sensor.c b/src/main/sensors/esc_sensor.c index ce9408b4ee6..1ca4425e214 100644 --- a/src/main/sensors/esc_sensor.c +++ b/src/main/sensors/esc_sensor.c @@ -159,7 +159,17 @@ escSensorData_t NOINLINE * getEscTelemetry(uint8_t esc) escSensorData_t * escSensorGetData(void) { - if (!escSensorPort) { + /* + * Asks whether there is ESC telemetry, not whether a serial port is open. The + * two used to be the same thing, but a Smart ESC reports over the throttle wire + * itself, handled by the motor driver, so escSensorPort stays NULL while the + * data is perfectly good. Testing the port left the OSD, Blackbox, current + * estimation and the telemetry backends with nothing on such a board - and the + * OSD inconsistent with itself, since it checks the state flag and then asks + * here. For a conventional ESC the state is only set once the port has opened, + * so nothing changes there. + */ + if (!STATE(ESC_SENSOR_ENABLED)) { return NULL; } From 78dae82dfd2e18ab471f32dc11700fd2dc25bc3e Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:11:10 +0200 Subject: [PATCH 21/40] SRXL2: drop the reversible-motors route to reverse Spektrum state the behaviour in words: "flipping the designated switch reverses motor rotation, throttle will still control motor speed". So on a Smart ESC reverse is a switch and the throttle goes on meaning throttle, and the THRUST REVERSE mode is the whole of it. Reversible motors models the other arrangement, where the stick centre is zero thrust. It was kept as a second route in case an Avian turned out to work that way, but supporting both means the wrong one is always one checkbox away - and enabling it on a switch-type ESC hands it roughly half throttle at the point the pilot expects the motor stopped, which is the worse of the two errors. FEATURE_REVERSIBLE_MOTORS is therefore cleared when the protocol is SRXL2, next to the line that already does the same for brushed, and the mixer-derived trigger is gone from the task. That also removes getReversibleMotorsThrottleState(), which this branch had added and nothing else needed. The documentation now describes the bench check that tells the two kinds of ESC apart, and says plainly that INAV drives only the switch kind. --- docs/Spektrum Smart ESC.md | 41 ++++++++++++++++++-------------------- src/main/fc/fc_init.c | 13 ++++++++++++ src/main/fc/fc_tasks.c | 27 +++++++++---------------- src/main/flight/mixer.c | 5 ----- src/main/flight/mixer.h | 1 - 5 files changed, 41 insertions(+), 46 deletions(-) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index 4a3a0e632d6..7792e28dbe1 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -165,30 +165,27 @@ outcome most worth engineering against. Reverse is released whenever the aircraft is disarmed, so a machine that landed under reverse does not sit on the ground with it still armed. -### If your ESC uses a centred throttle instead +### Check it on the bench before the first flight Some Spektrum documentation shows a bipolar throttle scale for the reverse brake -mode, where centre is zero thrust and below centre is reverse. That is not what the -switch-and-normal-throttle wording above describes, and the two cannot both be true -of the same ESC, so this is worth checking on your own hardware before the first -flight. - -If yours behaves that way, enable INAV's own `FEATURE_REVERSIBLE_MOTORS`. The -driver honours that too: when the mixer decides the motor should run backwards, the -reverse channel is armed, exactly as the THRUST REVERSE mode would. Either route -works and neither masks the other. - -Be aware of what that feature does to an aeroplane, though, which is why it is not -the default route: - -* the throttle stick becomes centre-zero, so forward thrust lives only in the top - half of the stick; -* chopping the throttle on short final then commands reverse thrust in the air; -* arming requires the throttle stick **centred** rather than down, and an ARM - switch becomes mandatory — INAV disarms continuously without one. - -For an aeroplane that wants reverse only on the landing roll, the mode is the right -answer and the feature is not. +mode, where centre is zero thrust and below centre is reverse. That contradicts the +switch-and-normal-throttle behaviour above, and the two cannot both be true of the +same ESC. INAV drives the switch arrangement, which is the one Spektrum state in +words, so it is worth confirming your ESC is that kind. + +**Propeller off.** Arm with THRUST REVERSE off and watch the motor at *minimum* +throttle: + +* **stopped**, or idling gently forward — the switch arrangement, which is what + this driver expects. Nothing more to do. +* **spinning backwards hard** — the other kind. Disarm. INAV does not drive that + arrangement over SRXL2: the throttle it sends would be read as reverse thrust + through most of the stick. + +`FEATURE_REVERSIBLE_MOTORS` is not the answer to the second case and is cleared +automatically when the protocol is SRXL2. It recentres INAV's own throttle output, +which on a switch-type ESC means roughly half throttle at the point the stick says +stop. ### What reverse cannot do diff --git a/src/main/fc/fc_init.c b/src/main/fc/fc_init.c index 78a2586d568..9ba5a8e442c 100644 --- a/src/main/fc/fc_init.c +++ b/src/main/fc/fc_init.c @@ -341,6 +341,19 @@ void init(void) if (motorConfig()->motorPwmProtocol == PWM_TYPE_BRUSHED) { featureClear(FEATURE_REVERSIBLE_MOTORS); } +#ifdef USE_MOTOR_SRXL2 + /* + * A Spektrum Smart ESC reverses on a switch and goes on reading the throttle + * normally - Spektrum put it plainly: "flipping the designated switch reverses + * motor rotation, throttle will still control motor speed". Reversible motors + * means the other arrangement, where the stick centre is zero thrust, and + * enabling it here would hand the ESC roughly half throttle at the point the + * pilot expects the motor stopped. Reverse is the THRUST REVERSE mode instead. + */ + if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { + featureClear(FEATURE_REVERSIBLE_MOTORS); + } +#endif if (!STATE(ALTITUDE_CONTROL)) { featureClear(FEATURE_AIRMODE); } diff --git a/src/main/fc/fc_tasks.c b/src/main/fc/fc_tasks.c index db77ad4ee90..31143e92706 100755 --- a/src/main/fc/fc_tasks.c +++ b/src/main/fc/fc_tasks.c @@ -325,26 +325,17 @@ void taskSyncServoDriver(timeUs_t currentTimeUs) * half-duplex wire. */ if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { /* - * Two ways to ask for reverse, because there are two kinds of model. + * Reverse is a mode, because on this ESC it is a switch: Spektrum describe + * the reverse channel as flipping rotation while the throttle goes on + * meaning throttle. There is deliberately no second route through the + * mixer's reversible-motor state - that models a centre-zero stick, which + * is a different kind of ESC, and FEATURE_REVERSIBLE_MOTORS is cleared for + * this protocol at startup for the same reason. * - * The THRUST REVERSE mode is the one an aeroplane uses: Spektrum describe - * the ESC's reverse channel as a switch that flips rotation while the - * throttle goes on meaning throttle, so a mode maps onto it exactly. The - * pilot arms it on the landing roll and the throttle still commands power. - * - * The mixer's reversible-motor state covers the other kind, where INAV - * itself decides direction from a recentred throttle stick. Honouring both - * costs nothing and neither can mask the other: either asking for reverse - * is reverse. - * - * Both are gated on being armed. The mixer stops updating its direction - * while disarmed - mixTable() returns early - so an aircraft that landed - * under reverse would otherwise sit on the ground with the ESC's reverse - * channel held armed until the throttle next went forward. + * Gated on being armed so that an aircraft which landed under reverse does + * not sit on the ground with the ESC's reverse channel still held. */ - const bool reverseAsked = IS_RC_MODE_ACTIVE(BOXTHRUSTREVERSE) - || getReversibleMotorsThrottleState() == MOTOR_DIRECTION_BACKWARD; - srxl2MotorSetReverse(ARMING_FLAG(ARMED) && reverseAsked); + srxl2MotorSetReverse(ARMING_FLAG(ARMED) && IS_RC_MODE_ACTIVE(BOXTHRUSTREVERSE)); srxl2MotorProcess(); } #endif diff --git a/src/main/flight/mixer.c b/src/main/flight/mixer.c index ccbb1bda588..bd5778b71be 100644 --- a/src/main/flight/mixer.c +++ b/src/main/flight/mixer.c @@ -600,11 +600,6 @@ void FAST_CODE writeMotors(void) #endif } -reversibleMotorsThrottleState_e getReversibleMotorsThrottleState(void) -{ - return reversibleMotorsThrottleState; -} - void writeAllMotors(int16_t mc) { // Sends commands to all motors diff --git a/src/main/flight/mixer.h b/src/main/flight/mixer.h index 1c4cf41620e..864efa4f15f 100644 --- a/src/main/flight/mixer.h +++ b/src/main/flight/mixer.h @@ -119,7 +119,6 @@ typedef enum { MOTOR_DIRECTION_DEADBAND } reversibleMotorsThrottleState_e; -reversibleMotorsThrottleState_e getReversibleMotorsThrottleState(void); extern int16_t motor[MAX_SUPPORTED_MOTORS]; extern int16_t motor_disarmed[MAX_SUPPORTED_MOTORS]; From 74efc809dfd1fbec5335a5de4dd154e3433ccc3b Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:54:58 +0200 Subject: [PATCH 22/40] SRXL2: take serial function bit 29, leaving 28 to the thermal camera Rebasing onto maintenance-10.x turned up two collisions that were invisible against the older base, both of which would have been silent. Serial function 28 is already spoken for: the Configurator assigns it to the MassZero thermal camera, whose firmware side is not on this branch yet. Two functions sharing a mask bit means assigning either one enables both. Deferring to the number already published is the safe direction; say so if the camera is going somewhere else and this can move back. The mode box had the same problem - BOXTHRUSTREVERSE at 60 with permanent id 69 collided with AUTO SPEED, TERRAIN AGL HOLD and IN FLIGHT MENU, which arrived on this branch after the work started. It is now 63 with permanent id 72, matching the Configurator. --- src/main/io/serial.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/io/serial.h b/src/main/io/serial.h index dbd0ba42e4d..f9ec6dbb4fd 100644 --- a/src/main/io/serial.h +++ b/src/main/io/serial.h @@ -59,7 +59,9 @@ typedef enum { FUNCTION_MSP_OSD = (1 << 25), // 33554432 FUNCTION_GIMBAL = (1 << 26), // 67108864 FUNCTION_GIMBAL_HEADTRACKER = (1 << 27), // 134217728 - FUNCTION_ESC_SRXL2 = (1 << 28), // 268435456: Spektrum Smart ESC (Smart Throttle) + /* 28 is left free: the Configurator already assigns it to the MassZero thermal + * camera, whose firmware side is not on this branch yet. */ + FUNCTION_ESC_SRXL2 = (1 << 29), // 536870912: Spektrum Smart ESC (Smart Throttle) } serialPortFunction_e; #define FUNCTION_VTX_MSP FUNCTION_MSP_OSD From 4096e0f9a0e6035c1d20a9db799c554bd9c905ad Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:42:34 +0200 Subject: [PATCH 23/40] Keep the reverse channel default on boards that already had settings motorConfig_t was nine bytes in use plus one of tail padding, and the new fields went straight after it, so srxl2ReverseChannel landed on that tenth byte. pgLoad() applies the reset defaults and then copies MIN(stored, current) bytes over them, which for an existing ten-byte record reaches offset nine - and the record holds zero there, because pgResetInstance() copies the reset template whole and a template's padding is zero. The result was the default of channel 7 being replaced by 0 - reverse disabled - on every board that had been configured before this firmware, while a freshly reset board worked. That is the wrong way round for a defect to present: it looks like the feature does not work, and erasing the configuration appears to fix it. A one-byte member now absorbs that overlap so the three real fields start past the end of the old record. Bumping the group version would also have worked, at the price of discarding everyone's motor settings - protocol, rates, pole count - to add an optional field. Verified by layout: the old record is 10 bytes, the group is now 14, and srxl2ReverseChannel moves from offset 9 to 10, past what pgLoad() copies. SITL builds clean. --- src/main/flight/mixer.h | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main/flight/mixer.h b/src/main/flight/mixer.h index 864efa4f15f..120b21f4d9b 100644 --- a/src/main/flight/mixer.h +++ b/src/main/flight/mixer.h @@ -96,9 +96,27 @@ typedef struct motorConfig_s { uint16_t digitalIdleOffsetValue; uint8_t motorPoleCount; // Magnetic poles in the motors for calculating actual RPM from eRPM provided by ESC telemetry #ifdef USE_MOTOR_SRXL2 - /* Appended at the end on purpose: pgLoad() compares only the parameter group - * version, never the size, so a field added here is additive and existing - * saved settings keep their offsets. */ + /* + * Appended at the end, but not immediately after motorPoleCount. + * + * pgLoad() applies the reset defaults and then copies MIN(stored, current) + * bytes over them, comparing only the parameter group version and never the + * size. Before these fields the group was ten bytes: nine in use and one of + * tail padding, the struct being two-byte aligned. That tenth byte is inside + * what an older configuration stored, and it stored it as zero, because + * pgResetInstance() copies the reset template whole and a template's padding + * is zero. So a field placed there has its default overwritten with zero by + * any configuration saved before this firmware, which for the reverse + * channel means the feature comes up disabled on exactly the boards that + * already had settings worth keeping. + * + * This byte absorbs that overlap, so the fields after it begin past the end + * of the old record and keep their defaults. The alternative is bumping the + * group version, which would work by discarding every user's motor settings + * - protocol, rates, pole count - in order to add an optional field. + */ + uint8_t srxl2PadOverlap; // absorbs the old tail padding; never read + uint8_t srxl2ReverseChannel; // 1-based aux channel an SRXL2 ESC uses to arm reverse; 0 disables uint8_t srxl2Telemetry; // read ESC telemetry off the SRXL2 link uint8_t srxl2TelemetryRate; // how often to ask the ESC for telemetry, as srxl2TelemetryRate_e @@ -119,7 +137,6 @@ typedef enum { MOTOR_DIRECTION_DEADBAND } reversibleMotorsThrottleState_e; - extern int16_t motor[MAX_SUPPORTED_MOTORS]; extern int16_t motor_disarmed[MAX_SUPPORTED_MOTORS]; extern int mixerThrottleCommand; From 5a9b942eadb33820fb54808de5de86053a6ccfa4 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:42:13 +0200 Subject: [PATCH 24/40] docs: say that reverse needs Brake Type as well as a channel The Reverse section treated Brake Type as a parenthetical that applied on some models. Spektrum's programming instructions put it the other way round: Brake Type = Reverse is what enables reversing, and Thrust Rev. only chooses the channel that arms it - "Reverse must set in the Brake Type menu". An ESC with the channel set and Brake Type left at Disabled does nothing, and nothing says why, which is the failure this page exists to prevent. Also carries across their warning that the channel must be one nothing else uses, because sharing it "can cause unexpected behavior in flight" - the reason the setting already refuses the throttle slot - and their recommendation of Brake Force 7 with reverse. Documentation only. --- docs/Spektrum Smart ESC.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index 7792e28dbe1..97be684af66 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -141,11 +141,24 @@ throttle goes on meaning throttle. Three things have to agree: -* the ESC, programmed with a `Thrust Rev.` channel (and `Brake Type = Reverse` on - models that have it); +* the ESC, with **both** of its own parameters set: `Brake Type` to `Reverse`, + which is what enables reversing at all, and `Thrust Rev.`, which only chooses + the channel that arms it. Spektrum's programming instructions are explicit that + one does not work without the other - *"Thrust Rev - Use this option to select + the channel used to activate motor reversing. Reverse must set in the Brake Type + menu"*. They also recommend `Brake Force` of 7 alongside `Brake Type = Reverse`; * `esc_srxl2_reverse_channel`, set to that same channel; * a switch, assigned to the **THRUST REVERSE** mode in the Modes tab. +Pick a channel nothing else uses. This is Spektrum's own warning about the +parameter, and it is about flight behaviour rather than tidiness: *"Reverse mode +needs to be assigned to an OPEN channel on your transmitter, this channel is +selected in ESC menu item #15 using in conjunction with another function can cause +unexpected behavior in flight."* On this link the channel is a slot between the +flight controller and the ESC rather than a transmitter channel, so the flight +controller is what has to leave it alone - which is why the setting refuses the +throttle slot. + Spektrum allow channels **5 to 9** for this and ship **channel 7** as the factory default. Nothing on the wire advertises which one the ESC is watching, so a mismatch simply means reverse never engages — silently. Check the channel against From c16aa51e1df09db92733640958f332687cabf3e1 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:32:53 +0200 Subject: [PATCH 25/40] docs: say what the bench measured, including what it left open Both additions come from a first session against a real Avian 70 A Smart Lite rather than from the specification. The calibration was described as something INAV offers. It turns out to be something an Avian needs: uncalibrated, that ESC ignored everything below 1238 us, so the bottom quarter of the throttle range did nothing and said nothing. Calibrated, it answered from 1050 us and tracked the commanded throttle to within a point from 10 % upwards. That is worth stating where someone will read it before flying rather than after. The current field is the open one. The ESC reported 2.85 A while the supply feeding it measured 0.999 A at the same instant at about 40 % throttle, which is what motor current rather than pack current looks like through a converter. A scale error in the ESC's sensor would look the same at one operating point, so the page says which reading to prefer meanwhile instead of picking a conclusion the measurement does not yet support. Documentation only. --- docs/Spektrum Smart ESC.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index 97be684af66..422b9469514 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -85,6 +85,14 @@ up: full throttle first, then low within five seconds of the tones that acknowle it. That normally needs a Spektrum transmitter, so INAV can drive the sequence itself for anyone who does not own one. +**Do this before the first flight.** It is not a convenience. Measured on an Avian +70 A Smart Lite that had been calibrated against a Spektrum radio, an uncalibrated +ESC ignored everything below 1238 us: the bottom quarter of INAV's throttle range +did nothing at all, and nothing said so. After the calibration the same ESC +responded from 1050 us, and the throttle it reported back matched the throttle +commanded to within a point from 10 % upwards. A pilot who skips this finds out +about it on the takeoff roll. + **Outputs tab**, Throttle range calibration: 1. Remove the propeller and disconnect the battery. The wizard refuses to start with @@ -115,6 +123,18 @@ switched off with `esc_srxl2_telemetry`, which exists because telemetry shares t throttle wire and so cannot be declined by leaving a port unassigned as it would be for a conventional ESC. +**Current needs care until this is settled.** On the bench the ESC reported 2.85 A +while the supply feeding it measured 0.999 A at the same instant, at about 40 % +throttle. That is the relationship expected if the field is motor current rather +than pack current, since an ESC is a converter and pack current is roughly motor +current times duty. It is not proven: a plain scale error in the ESC's own sensor +would look identical at a single operating point, and telling the two apart needs +readings at several throttle settings. Until then, prefer the board's own sensor +with `current_meter_type = ADC` wherever one exists. The two are alternatives +rather than additive - INAV takes current from one source - and a shunt in the +battery lead also sees what the servos and the video transmitter draw, which no +ESC can report. + **Set Motor poles correctly before enabling the RPM filter.** The wire carries electrical rpm, and INAV converts it to mechanical rpm using the pole count. A wrong pole count puts the notch at the wrong frequency, which is worse than having no From b8fed40f7cd5090b2f9343af4e60c4970df0f38c Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:55:18 +0200 Subject: [PATCH 26/40] Send a block of channels, because one does not arm an Avian Control Data carried a single channel, the throttle. An Avian parses such a frame - it reports the throttle back over telemetry, correctly - and then refuses to drive the motor for as long as they keep arriving. Once armed by a wider frame it accepts single-channel ones happily, so the requirement is on arming rather than on running: a link that comes up, reports plausible telemetry, and never turns the propeller. Measured on an Avian 70 A Smart Lite. Eight seconds of minimum throttle on one channel leaves the ESC reporting 0.0 % and the bench supply delivering 58 mA, its own electronics and nothing else. The same minimum on eight channels arms it, and 1300 us then gives 30.0 % and 26400 eRPM at 840 mA. Two channels were enough in every combination tried, so the eight here is margin rather than a threshold, chosen to look like what a receiver sends, and widened further when the reverse channel sits above it - that value has to travel in the frame anyway. The unused channels are filled at their minimum. The comment this replaces argued against padding, and its reasoning was right about centre - 1500 us is half throttle on an aircraft ESC, so a wrong guess about which index carries throttle would spin the motor - but wrong in concluding that sending nothing was therefore safer. Sending nothing means never arming. Minimum padding keeps the safety property and drops the one that was costing everything. The mask is now built rather than accumulated, so the frame does not change shape depending on what has been called since power-up. Two things came out of widening it. srxl2SendFrame() now checks there is room and reports whether the frame went out. A port that has backed up drops what does not fit, which for Control Data is of no consequence - another follows in 20 ms and the ESC tolerates 250 ms of silence - but for the broadcast that moves the bus to a new rate it is the difference between a working link and a dead one. srxl2Finalise() only arms the switch if that broadcast was actually queued. This failure mode is not hypothetical: forcing 400000 on an ESC that had not agreed to it left it unreachable on the bench until the battery came out. The oracle harness needed its starved-wire scenario relaxed from three bytes per tick to eight. At thirty-byte frames three bytes a tick is not a slow link but an impossible one - a real 115200 link has eight times the headroom the frame rate needs - and the scenario exists to hold the transmit buffer busy across ticks, which it still does. Adjusting a test to accommodate a change deserves suspicion, so: the two checks that scenario was written for, that the buffer really was busy and that the rate was never raised with bytes unsent, both still pass. 57 checks, 0 failures, 2371 frames accepted by Spektrum's parser. SITL builds clean. --- src/main/io/motor_srxl2.c | 111 ++++++++++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 21 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 92daa91399f..0eac9bf1988 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -119,6 +119,30 @@ /* Reply ID 0x00 means "no reply wanted" (specification 7.1.1). */ #define SRXL2_REPLY_NONE 0x00 +/* + * How many channels every Control Data frame carries. + * + * Not one, which is what this driver used to send. An Avian will not arm from a + * frame carrying a single channel. It parses such a frame - it reports the + * throttle back over telemetry, correctly - and then refuses to drive the motor + * for as long as they keep arriving. Once armed by a wider frame it accepts + * single-channel ones happily, so the requirement is on arming rather than on + * running, which is exactly the shape of fault that reaches a field and not a + * bench: a link that comes up, reports plausible telemetry, and never turns the + * propeller. + * + * Measured on a 70 A Smart Lite: eight seconds of minimum throttle on one + * channel leaves the ESC reporting 0.0 % and drawing 58 mA, its own electronics + * and nothing else; the same minimum on eight channels arms it, and 1300 us + * then gives 30.0 % and 26400 eRPM at 840 mA. Two channels were enough in + * every combination tried, so eight is not a measured threshold but a margin, + * chosen to look like what a receiver actually sends. + * + * The block also has to reach the reverse channel, because that value has to + * travel in the frame anyway. + */ +#define SRXL2_CHANNELS_MIN 8 + /* X-Bus telemetry sensor IDs */ #define SRXL2_TELEM_SENSOR_ESC 0x20 @@ -299,6 +323,21 @@ static inline uint16_t be16(const uint8_t *p) return (uint16_t)((p[0] << 8) | p[1]); } +/* + * The block of channels this frame carries: wide enough to arm, and wide enough + * to reach the reverse channel when one is configured. Rebuilt rather than + * accumulated, so the frame does not change shape depending on what has been + * called since power-up. + */ +static void srxl2BuildChannelMask(srxl2Esc_t *e) +{ + uint8_t count = SRXL2_CHANNELS_MIN; + if (reverseChannel1Based > count) { + count = reverseChannel1Based; + } + e->channelMask = (count >= 32) ? 0xFFFFFFFFu : ((1u << count) - 1u); +} + static void srxl2SetState(srxl2Esc_t *e, srxl2State_e next) { e->state = next; @@ -319,12 +358,28 @@ static uint32_t srxl2TotalHandshakes(void) return total; } -/* Append the CRC and push the frame. buf[2] must already hold the frame length - * as the specification's framing requires. */ -static void srxl2SendFrame(srxl2Esc_t *e, uint8_t *buf, uint8_t len) +/* + * Append the CRC and push the frame. buf[2] must already hold the frame length + * as the specification's framing requires. + * + * Returns false when the frame did not go out, which the caller has to care + * about for the one frame where it matters. A port that has backed up - a slow + * link, a stalled DMA - drops whatever does not fit, and for Control Data that + * is of no consequence, since another follows in 20 ms and the ESC tolerates + * 250 ms of silence. For the broadcast that moves the bus to a new rate it is + * the difference between a working link and a dead one: raise the rate having + * only believed that frame was sent, and the ESC is left behind at the old rate + * with no way back short of a power cycle. That failure has been seen on real + * hardware, and it is not recoverable in flight. + */ +static bool srxl2SendFrame(srxl2Esc_t *e, uint8_t *buf, uint8_t len) { if (!e->port || len < SRXL2_MIN_FRAME || len > SRXL2_MAX_FRAME) { - return; + return false; + } + + if (serialTxBytesFree(e->port) < len) { + return false; } const uint16_t crc = crc16_ccitt_update(0, buf, len - 2); @@ -334,9 +389,10 @@ static void srxl2SendFrame(srxl2Esc_t *e, uint8_t *buf, uint8_t len) serialWriteBuf(e->port, buf, len); e->lastTxMs = millis(); e->statTxFrames++; + return true; } -static void srxl2SendHandshake(srxl2Esc_t *e, uint8_t destinationId, uint8_t baudField) +static bool srxl2SendHandshake(srxl2Esc_t *e, uint8_t destinationId, uint8_t baudField) { uint8_t buf[sizeof(Srxl2HandshakeFrame)]; Srxl2HandshakeFrame *f = (Srxl2HandshakeFrame *)buf; @@ -358,7 +414,7 @@ static void srxl2SendHandshake(srxl2Esc_t *e, uint8_t destinationId, uint8_t bau * every bus is fine, because each * bus has exactly one master. */ - srxl2SendFrame(e, buf, sizeof(Srxl2HandshakeFrame)); + return srxl2SendFrame(e, buf, sizeof(Srxl2HandshakeFrame)); } /* Tell the bus which rate everyone moves to, and arrange to follow once the @@ -367,7 +423,14 @@ static void srxl2SendHandshake(srxl2Esc_t *e, uint8_t destinationId, uint8_t bau static void srxl2Finalise(srxl2Esc_t *e) { e->agreedBaudBits = SRXL2_BAUD_BIT_400K & e->baudSupported; - srxl2SendHandshake(e, Broadcast, e->agreedBaudBits); + + /* Only arm the switch if the broadcast is actually on its way. If the port + * had no room, stay where we are and try again on the next pass: a rate the + * ESC was never told about is worse than a slow negotiation. */ + if (!srxl2SendHandshake(e, Broadcast, e->agreedBaudBits)) { + return; + } + e->baudSwitchPending = (e->agreedBaudBits & SRXL2_BAUD_BIT_400K) != 0; srxl2SetState(e, SRXL2_FINALISING); } @@ -562,7 +625,6 @@ static void srxl2SendControlData(srxl2Esc_t *e) || (calPhase == SRXL2_CAL_HIGH_MANUAL); e->channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(high ? SRXL2_CAL_HIGH_US : SRXL2_CAL_LOW_US); - e->channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); } buf[n++] = SRXL2_MAGIC; @@ -591,14 +653,16 @@ static void srxl2SendControlData(srxl2Esc_t *e) buf[n++] = (uint8_t)((e->channelMask >> 24) & 0xFF); /* - * Only the channels we actually mean, little-endian, lowest index first. + * A contiguous block, little-endian, lowest index first. The channels this + * driver has nothing to say on are filled, and filled at their **minimum**. * - * Deliberately not padded with centred values on the channels we do not - * use. Doing that is reasonable for a surface ESC, where centre means + * Never at centre. That is reasonable for a surface ESC, where centre means * stopped, and dangerous for an aircraft one, where 1500 us is half - * throttle: if the ESC turned out to read throttle on an index we did not - * expect, padding would spin the motor at 50 percent, while sending nothing - * there simply leaves it idle. Wrong guess, safe outcome. + * throttle: were the ESC to read throttle on an index other than the one + * expected, centred padding would spin the motor at half power while + * minimum padding leaves it idle. Wrong guess, safe outcome - which is the + * same reasoning that used to argue for sending nothing at all, before an + * ESC made it clear that sending nothing means never arming. */ for (uint8_t ch = 0; ch < 32; ch++) { if (e->channelMask & (1u << ch)) { @@ -639,11 +703,13 @@ bool srxl2MotorInitialize(void) e->port = port; - /* Start the throttle channel at its lowest value rather than zero, so - * the first frame after a handshake cannot be read as something - * unexpected. */ - e->channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(1000); - e->channelMask = (1u << SRXL2_CHANNEL_THROTTLE); + /* Every channel starts at its lowest value rather than zero, so the + * first frame after a handshake cannot be read as something + * unexpected whichever index the ESC happens to care about. */ + for (uint8_t ch = 0; ch < 32; ch++) { + e->channelValue[ch] = srxl2UsToValue(1000); + } + srxl2BuildChannelMask(e); const timeMs_t now = millis(); e->lastRxMs = now; @@ -670,7 +736,6 @@ void srxl2MotorUpdate(uint8_t index, uint16_t value) /* Staging only. The wire is driven at its own rate from srxl2MotorProcess(), * not at whatever rate the mixer happens to run. */ esc[index].channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(value); - esc[index].channelMask |= (1u << SRXL2_CHANNEL_THROTTLE); } /* @@ -710,13 +775,17 @@ void srxl2MotorSetReverse(bool armed) * one outcome here worth engineering against. */ for (uint8_t i = 0; i < escCount; i++) { esc[i].channelValue[idx] = v; - esc[i].channelMask |= (1u << idx); } } void srxl2MotorSetReverseChannel(uint8_t channel1Based) { reverseChannel1Based = srxl2ReverseChannelUsable(channel1Based) ? channel1Based : 0; + + /* The block has to reach it, and this may be called after the ports opened. */ + for (uint8_t i = 0; i < escCount; i++) { + srxl2BuildChannelMask(&esc[i]); + } } void srxl2MotorSetTelemetryRate(srxl2TelemetryRate_e rate) From 5b265fba3d81a72fcf4ee5faeb1e318efe0bcf79 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:56:18 +0200 Subject: [PATCH 27/40] Measure the Avian instead of reading its manual to it A bench session against an Avian 70 A, with the ESC's own telemetry as the instrument, corrected four things this driver believed. Telemetry rate. The setting was named for how often INAV asks. The ESC answers about two requests in three and rotates its reply between a text page, a battery page and the ESC page, so what arrives is roughly a ninth of what is asked for: asking 25 times a second yields 2.7 ESC frames a second, 10 yields 1.1, 5 yields 0.5, 2 yields 0.2. The values are now named for what arrives, and 50 Hz is gone - asked on every frame, the ESC keeps the link, keeps answering, and stops obeying the throttle, reporting 0.0 % from every stick position. Every second frame restores it immediately, without a power cycle. A floor in the driver means a configuration saved by an older build cannot select it either. Channel count. The frame carried a block of eight because one channel appeared not to arm. That reading was confounded: the two runs also asked for telemetry at different rates. With the rate held fixed, masks of one, two adjacent, two spread, four and eight channels all gave the same 14.0 % and the same 6610 rpm from the same command; asking on every frame, all five gave nothing. So the frame now carries the throttle and the reverse channel, and nothing else - twelve bytes a frame less on a wire the telemetry reply has to share. Calibration timings. Three seconds high and five low, taken from the published tone timings, sound the tones and store nothing. Four and seven store it. The difference is not subtle: uncalibrated the ESC ignores everything below channel value 12220 and is saturated from 50820, so 41 % of the range does nothing and full power arrives at three quarters stick; calibrated it responds from 2687, saturates at 64307, and reports back what was commanded to within a point across the whole range. Finding an ESC. The polling this driver does finds nothing, and the comment now says so. An Avian announces itself six times in the 300 ms after it powers up and is mute from then on: 128 handshakes to its address, 128 broadcasts, 128 spread across the whole ID range and 319 control frames asking for telemetry all drew exactly nothing from a running one. --- docs/Settings.md | 4 +- docs/Spektrum Smart ESC.md | 85 ++++++++++++++++++++++---- src/main/fc/settings.yaml | 6 +- src/main/io/motor_srxl2.c | 120 ++++++++++++++++++++++++------------- src/main/io/motor_srxl2.h | 23 ++++--- 5 files changed, 170 insertions(+), 68 deletions(-) diff --git a/docs/Settings.md b/docs/Settings.md index e4480dcb183..894bbbd50d5 100644 --- a/docs/Settings.md +++ b/docs/Settings.md @@ -1067,11 +1067,11 @@ Read ESC telemetry off the SRXL2 link. Only applies when motor_pwm_protocol is S ### esc_srxl2_telemetry_rate -How often an SRXL2 Smart ESC is asked for telemetry. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; a slower one is steadier. Only the RPM filter really benefits from the faster settings. +How often ESC telemetry arrives from an SRXL2 Smart ESC, in readings per second. The ESC answers about two requests in three and rotates its reply between three sensors, so it delivers roughly a ninth of what is asked for - these are the delivered rates, measured, not the request rate. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; only the RPM filter benefits from it. Asking on every frame is not offered: an Avian keeps the link and stops obeying the throttle. | Default | Min | Max | | --- | --- | --- | -| 10HZ | | | +| 1HZ | | | --- diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index 422b9469514..c4d809934ef 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -86,12 +86,21 @@ it. That normally needs a Spektrum transmitter, so INAV can drive the sequence itself for anyone who does not own one. **Do this before the first flight.** It is not a convenience. Measured on an Avian -70 A Smart Lite that had been calibrated against a Spektrum radio, an uncalibrated -ESC ignored everything below 1238 us: the bottom quarter of INAV's throttle range -did nothing at all, and nothing said so. After the calibration the same ESC -responded from 1050 us, and the throttle it reported back matched the throttle -commanded to within a point from 10 % upwards. A pilot who skips this finds out -about it on the takeoff roll. +70 A, sweeping the channel value across its whole range and reading back the +throttle the ESC reports: + +| | uncalibrated | after calibration | +|---|---|---| +| starts responding at | 12220 (1178 us) | 2687 (1029 us) | +| saturates at | 50820 (1781 us) | 64307 (1993 us) | +| share of the channel used | 59 % | 94 % | + +Uncalibrated, the bottom sixth of the throttle does nothing and the top fifth is +already at full power, so the stick reaches everything it will ever reach at +about three quarters travel - and nothing says so. Calibrated, what the ESC +reports tracks what INAV commands to within a point across the whole range: +1050 us gives 5 %, 1500 gives 50 %, 2000 gives 100 %. A pilot who skips this +finds out about it on the takeoff roll. **Outputs tab**, Throttle range calibration: @@ -142,12 +151,13 @@ notch at all. Two things to weigh before turning `rpm_gyro_filter_enabled` on: -* Telemetry arrives at whatever `esc_srxl2_telemetry_rate` asks for, 10 Hz by - default, not at the loop rate. On an aircraft holding a cruise throttle, rpm and - the vibration peak both move slowly and that is adequate. Raising it to 50 Hz - tracks better and costs bus headroom, since the reply shares the throttle wire. - Even at 50 Hz this is not equivalent to bidirectional DSHOT, which reports every - loop. +* Telemetry arrives far slower than the loop rate, and slower than the request + rate too: the ESC answers about two requests in three and rotates its reply + between a text page, a battery page and the ESC page, so rpm reaches the flight + controller at roughly a ninth of what is asked for. `esc_srxl2_telemetry_rate` + is named for what arrives - 1 Hz by default, 3 Hz at the fastest. On an aircraft + holding a cruise throttle, rpm and the vibration peak both move slowly and that + is adequate. This is nowhere near bidirectional DSHOT, which reports every loop. * INAV's own advice for this setting applies unchanged: turn it on only once ESC telemetry is working and the reported rpm looks right. @@ -206,6 +216,13 @@ switch-and-normal-throttle behaviour above, and the two cannot both be true of t same ESC. INAV drives the switch arrangement, which is the one Spektrum state in words, so it is worth confirming your ESC is that kind. +An Avian 70 A with `Brake Type = Reverse` and the factory `Thrust Rev.` channel +was confirmed to be that kind: with channel 7 low and then high, against an +unchanged 1250 us throttle, the motor ran counter-clockwise, clockwise, +counter-clockwise, clockwise, reporting the same 14.0 % and about 6610 rpm in +every case. The throttle never changed meaning, and telemetry says nothing about +which way the shaft is turning - only an eye on the motor can tell you. + **Propeller off.** Arm with THRUST REVERSE off and watch the motor at *minimum* throttle: @@ -220,12 +237,54 @@ automatically when the protocol is SRXL2. It recentres INAV's own throttle outpu which on a switch-type ESC means roughly half throttle at the point the stick says stop. +### Engaging it with the motor running + +Nothing stops the switch being thrown at speed, on the ESC's side or INAV's: +`srxl2MotorSetReverse()` writes its channel whenever the mode is active, without +consulting the throttle. Tried deliberately on the bench, with the motor turning +at 2690 rpm under an unchanged 1200 us command, the Avian simply changed +direction - one telemetry sample caught it passing through zero rpm, and the next +had it back at the same speed the other way. No stall, no cutout, and no current +step large enough to read at that load. + +That is the ESC behaving well, not a licence to do it. With a propeller loaded in +flight the same reversal has to absorb the airflow driving the blades, which is +a different question from a bare motor on a bench, and it is the reason Spektrum +put reverse on a switch the pilot has to mean to throw. + ### What reverse cannot do Reverse is a manual, stick-and-switch capability. INAV's automatic throttle paths — RTH, autoland, failsafe, launch — all clamp throttle to at least idle, so none of them can call for reverse thrust. An automatic landing will not use it. +## If the motor does not come back after a reboot + +An Avian announces itself for about a third of a second after it powers up - six +handshakes in 300 milliseconds, measured on a 70 A - and then never speaks again +unless it is asked something it recognises. A flight controller that starts while +the ESC is already running has missed that window, and the ESC will not answer it +afterwards: polled by name, broadcast to, addressed on every ID from 0x40 to +0x4F, or spoken to as though the link already existed, it stayed silent through +every one. + +So the link is made at power-up or not at all. Connecting the battery powers both +together and the announcement lands while the flight controller is listening, +which is the normal case and needs nothing. The cases that bite are the other +ones: + +* the flight controller reboots - a firmware update, a brownout, the Configurator + asking for a restart - while the battery stays connected. **Unplug the battery + and plug it in again**, or the motor will not respond. +* bench work on USB with the ESC powered from a separate supply. Power the ESC + after the board has booted, not before. + +Nothing in the firmware can work around this, so it refuses to hide it instead: +arming is blocked while an SRXL2 link is missing, and the OSD says the hardware +is not there. Where ESC and board come up together the block clears in about a +second and is never seen; where it does not clear, the throttle would have done +nothing anyway. + ## Settings | Setting | Meaning | @@ -233,7 +292,7 @@ them can call for reverse thrust. An automatic landing will not use it. | `motor_pwm_protocol = SRXL2` | drive motors over SRXL2 | | `esc_srxl2_reverse_channel` | SRXL2 channel the ESC watches for reverse, 5 to 9; Spektrum default 7, 0 disables | | `esc_srxl2_telemetry` | read telemetry from the SRXL2 link | -| `esc_srxl2_telemetry_rate` | how often the ESC is asked: 50, 25, 10, 5 or 2 Hz | +| `esc_srxl2_telemetry_rate` | how often telemetry arrives: 3, 2, 1, 0.5 or 0.2 Hz | | `motor_poles` | required for correct rpm, see above | ## Reference diff --git a/src/main/fc/settings.yaml b/src/main/fc/settings.yaml index 5bb9132f659..127d757edc0 100644 --- a/src/main/fc/settings.yaml +++ b/src/main/fc/settings.yaml @@ -34,7 +34,7 @@ tables: # existing configuration reads whatever index 0 happens to be. Putting the default # there makes that harmless instead of silently selecting the fastest rate. - name: esc_srxl2_telemetry_rate - values: ["10HZ", "50HZ", "25HZ", "5HZ", "2HZ"] + values: ["1HZ", "3HZ", "2HZ", "0_5HZ", "0_2HZ"] - name: servo_protocol values: ["PWM", "SBUS", "SBUS_PWM"] - name: failsafe_procedure @@ -876,8 +876,8 @@ groups: condition: USE_MOTOR_SRXL2 type: bool - name: esc_srxl2_telemetry_rate - description: "How often an SRXL2 Smart ESC is asked for telemetry. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; a slower one is steadier. Only the RPM filter really benefits from the faster settings." - default_value: "10HZ" # must stay index 0, see the table comment + description: "How often ESC telemetry arrives from an SRXL2 Smart ESC, in readings per second. The ESC answers about two requests in three and rotates its reply between three sensors, so it delivers roughly a ninth of what is asked for - these are the delivered rates, measured, not the request rate. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; only the RPM filter benefits from it. Asking on every frame is not offered: an Avian keeps the link and stops obeying the throttle." + default_value: "1HZ" # must stay index 0, see the table comment field: srxl2TelemetryRate condition: USE_MOTOR_SRXL2 table: esc_srxl2_telemetry_rate diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 0eac9bf1988..3470d9b6079 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -120,28 +120,23 @@ #define SRXL2_REPLY_NONE 0x00 /* - * How many channels every Control Data frame carries. + * The frame carries the throttle, and the reverse channel when one is + * configured. Nothing else: the ESC reads CH1 for throttle and the channel its + * own Thrust Rev. parameter names, and has no use for the rest. * - * Not one, which is what this driver used to send. An Avian will not arm from a - * frame carrying a single channel. It parses such a frame - it reports the - * throttle back over telemetry, correctly - and then refuses to drive the motor - * for as long as they keep arriving. Once armed by a wider frame it accepts - * single-channel ones happily, so the requirement is on arming rather than on - * running, which is exactly the shape of fault that reaches a field and not a - * bench: a link that comes up, reports plausible telemetry, and never turns the - * propeller. + * An earlier version sent a block of eight, on the strength of a bench session + * where one channel gave 0.0 % and eight gave 30 % - read at the time as "an + * Avian will not arm from a single-channel frame". That reading was confounded: + * the two runs also asked for telemetry at different rates, and the rate was + * what mattered. Rerun with the request rate held fixed, masks of one, two + * adjacent, two spread, four and eight channels all gave the same 14.0 % and the + * same 6610 rpm from the same 1250 us command; rerun asking on every frame, all + * five gave 0.0 %, the single-channel mask included. See SRXL2_TELEM_REQUEST_MIN + * for what that rate does. * - * Measured on a 70 A Smart Lite: eight seconds of minimum throttle on one - * channel leaves the ESC reporting 0.0 % and drawing 58 mA, its own electronics - * and nothing else; the same minimum on eight channels arms it, and 1300 us - * then gives 30.0 % and 26400 eRPM at 840 mA. Two channels were enough in - * every combination tried, so eight is not a measured threshold but a margin, - * chosen to look like what a receiver actually sends. - * - * The block also has to reach the reverse channel, because that value has to - * travel in the frame anyway. + * Two channels rather than eight is twelve bytes a frame less on a wire the + * telemetry reply has to share. */ -#define SRXL2_CHANNELS_MIN 8 /* X-Bus telemetry sensor IDs */ #define SRXL2_TELEM_SENSOR_ESC 0x20 @@ -170,11 +165,22 @@ * Ask for telemetry on every Nth Control Data packet rather than on all of them. * Telemetry is a reply, so requesting it every frame doubles bus occupancy and * forces a half-duplex turnaround each time, for values that change slowly. - * Every fifth frame at 50 Hz gives 10 Hz, which is in line with what INAV's - * other ESC telemetry backends deliver. + * Every fifth frame at 50 Hz asks ten times a second, which reaches the flight + * controller as about 1.1 ESC readings a second once the ESC's own sensor + * rotation is accounted for. */ #define SRXL2_TELEM_REQUEST_DEFAULT 5 +/* + * Never ask on every frame. An Avian keeps the link up when asked at 50 Hz - it + * answers, and its telemetry is correct - but it stops obeying the throttle and + * reports zero per cent from every stick position. Dropping back to every second + * frame restores it immediately, without a power cycle. Measured repeatedly on an + * Avian 70A, against a fixed 1250 us command: every 2nd, 3rd and 4th frame all + * gave 14 % and about 6600 rpm, every frame gave nothing at all. + */ +#define SRXL2_TELEM_REQUEST_MIN 2 + /* Declare the link dead if the ESC stops answering for this long. */ #define SRXL2_LINK_TIMEOUT_MS 500 @@ -191,18 +197,30 @@ #define SRXL2_CAL_MANUAL_TIMEOUT_MS 30000 /* - * How long to keep holding full throttle after the ESC gains power. + * How long to keep holding full throttle after the ESC gains power, and then how + * long to hold minimum. + * + * These were three and five seconds, read off the published tone timings: the + * manual's window opens at the two short tones and lasts five, and the tones + * follow power-up by a second or so. Measured against an Avian 70 A, that is not + * enough. The same ESC, calibrated twice in a row from the same state: * - * The manual's window opens at the two short tones and lasts five seconds. Those - * tones follow the power-up sequence by a second or so, so dropping three - * seconds after power-up lands inside it with room on both sides. This is the - * one number in the sequence taken from the published tone timings rather than - * measured, and the first thing to adjust if an ESC refuses the calibration. + * 3 s high, 5 s low - the ESC sounds its tones and stores nothing. Throttle + * still ignored below channel value 12220, saturated from 50820, so 41 % + * of the range does nothing and the stick reaches full power at 78 %. + * 4 s high, 7 s low - stored. Responds from 2687 and saturates at 64307, + * 94 % of the channel used, and the throttle it reports back tracks the + * throttle commanded to within a point across the whole range: 1050 us + * gives 5 %, 1500 gives 50 %, 2000 gives 100 %. + * + * So the extra second either side is what lands inside the window rather than + * on its edge. They cost nothing - the sequence runs once, on a bench, with the + * propeller off. */ -#define SRXL2_CAL_SETTLE_MS 3000 +#define SRXL2_CAL_SETTLE_MS 4000 /* Long enough for the cell-count tones and the closing long tone. */ -#define SRXL2_CAL_LOW_MS 5000 +#define SRXL2_CAL_LOW_MS 7000 /* Endpoints presented during calibration, on INAV's usual motor scale. */ #define SRXL2_CAL_HIGH_US 2000 @@ -323,19 +341,21 @@ static inline uint16_t be16(const uint8_t *p) return (uint16_t)((p[0] << 8) | p[1]); } +static bool srxl2ReverseChannelUsable(uint8_t channel1Based); + /* - * The block of channels this frame carries: wide enough to arm, and wide enough - * to reach the reverse channel when one is configured. Rebuilt rather than - * accumulated, so the frame does not change shape depending on what has been - * called since power-up. + * Which channels this frame carries: the throttle, plus the reverse channel when + * one is configured. Rebuilt rather than accumulated, so the frame does not + * change shape depending on what has been called since power-up - and so that + * clearing the reverse channel actually stops sending it. */ static void srxl2BuildChannelMask(srxl2Esc_t *e) { - uint8_t count = SRXL2_CHANNELS_MIN; - if (reverseChannel1Based > count) { - count = reverseChannel1Based; + uint32_t mask = 1u << SRXL2_CHANNEL_THROTTLE; + if (srxl2ReverseChannelUsable(reverseChannel1Based)) { + mask |= 1u << (reverseChannel1Based - 1); } - e->channelMask = (count >= 32) ? 0xFFFFFFFFu : ((1u << count) - 1u); + e->channelMask = mask; } static void srxl2SetState(srxl2Esc_t *e, srxl2State_e next) @@ -790,11 +810,17 @@ void srxl2MotorSetReverseChannel(uint8_t channel1Based) void srxl2MotorSetTelemetryRate(srxl2TelemetryRate_e rate) { - /* Indexed by srxl2TelemetryRate_e, and derived from the 50 Hz control rate: - * every frame is 50 Hz, every 25th is 2 Hz. */ - static const uint8_t divisor[] = { 5, 1, 2, 10, 25 }; + /* Indexed by srxl2TelemetryRate_e. Each entry is how many 50 Hz control + * frames pass between requests; the setting is named for what comes back, + * which is roughly a ninth of what is asked for. */ + static const uint8_t divisor[] = { 5, 2, 3, 10, 25 }; - telemRequestEvery = (rate < ARRAYLEN(divisor)) ? divisor[rate] : SRXL2_TELEM_REQUEST_DEFAULT; + uint8_t every = (rate < ARRAYLEN(divisor)) ? divisor[rate] : SRXL2_TELEM_REQUEST_DEFAULT; + + /* Clamped here as well as in the table, so that no future entry - or a + * configuration written by an older build, where index 1 meant every + * frame - can ask at a rate the ESC answers but will not fly at. */ + telemRequestEvery = (every < SRXL2_TELEM_REQUEST_MIN) ? SRXL2_TELEM_REQUEST_MIN : every; } /* Checked on both sides rather than trusting the caller, because one of these @@ -957,7 +983,17 @@ static void srxl2ProcessEsc(srxl2Esc_t *e, timeMs_t now) * announces itself, so without this it would never be found. * * Every bus is polled at the same ID, which is not a collision: each - * ESC is alone on its wire and hears only its own master. */ + * ESC is alone on its wire and hears only its own master. + * + * What finds an Avian is not this, though: it is the ESC's own + * announcement at power-up, caught here because polling is what we + * happen to be doing when the ESC boots. A running Avian answers + * none of this - measured, with the ESC alive and the bus otherwise + * quiet: 128 handshakes to 0x40, 128 broadcasts, 128 spread across + * 0x40..0x4F and 319 control frames asking for telemetry all drew + * exactly nothing. It announces six times in the 300 ms after reset + * and is mute from then on. A board that reboots under a powered ESC + * therefore never links, and no amount of asking changes that. */ srxl2SendHandshake(e, SRXL2_ESC_ID_FIRST, SRXL2_BAUD_BIT_400K); } break; diff --git a/src/main/io/motor_srxl2.h b/src/main/io/motor_srxl2.h index b26ebb4760e..845c9be5f9c 100644 --- a/src/main/io/motor_srxl2.h +++ b/src/main/io/motor_srxl2.h @@ -159,20 +159,27 @@ void srxl2MotorSetReverse(bool armed); void srxl2MotorSetReverseChannel(uint8_t channel1Based); /* - * How often the ESC is asked for telemetry. Its reply shares the throttle wire, so - * this trades bus headroom against how promptly rpm and voltage move - which only - * matters to the RPM filter, everything else being a display. + * How often ESC telemetry arrives. Named after the rate the ESC actually delivers, + * not the rate we ask at: an Avian answers roughly two requests in three and + * rotates its reply between three sensors - a text page, a battery page and the + * ESC page - so rpm and current land at about a ninth of the request rate. + * Measured on an Avian 70A: asking 25 times a second yields 2.7 ESC frames a + * second, 10 yields 1.1, 5 yields 0.5, 2 yields 0.2. + * + * The reply shares the throttle wire, so this trades bus headroom against how + * promptly rpm and voltage move - which only matters to the RPM filter, + * everything else being a display. */ typedef enum { - /* 10 Hz is first so that it is zero. The setting lives in a field appended to + /* 1 Hz is first so that it is zero. The setting lives in a field appended to * motorConfig_t that fell inside existing padding, so a configuration saved * before it existed loads index 0 rather than the reset default - which must * therefore be the value we would have chosen anyway, not the fastest one. */ - SRXL2_TELEM_10HZ = 0, - SRXL2_TELEM_50HZ, - SRXL2_TELEM_25HZ, - SRXL2_TELEM_5HZ, + SRXL2_TELEM_1HZ = 0, + SRXL2_TELEM_3HZ, SRXL2_TELEM_2HZ, + SRXL2_TELEM_0_5HZ, + SRXL2_TELEM_0_2HZ, } srxl2TelemetryRate_e; void srxl2MotorSetTelemetryRate(srxl2TelemetryRate_e rate); From ed27a4f709381f9c78e1ae4d3c7de26e127ecd2f Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:56:28 +0200 Subject: [PATCH 28/40] Refuse to arm while the SRXL2 ESC has not linked An Avian announces itself for a third of a second after it gains power and is silent afterwards. Miss that window - a board rebooting under a battery that stayed connected, or a bench supply switched on before the flight controller - and the link never forms. Arming then commands a motor that is not listening: the model arms, the telemetry looks sane, and the propeller does not turn. So arming waits for the link rather than for a timer. Where ESC and board come up on the same battery this costs about a second at power-up and is never noticed; where it does not clear, the throttle would have done nothing anyway, and finding that out while disarmed is the point. Verified both ways in SITL against the real ESC: with the wire idle, arming is refused and the OSD reports the hardware; with the ESC linked, the same configuration arms and drives it. --- src/main/fc/fc_core.c | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/fc/fc_core.c b/src/main/fc/fc_core.c index 1990dc070a1..233f49035b9 100644 --- a/src/main/fc/fc_core.c +++ b/src/main/fc/fc_core.c @@ -37,6 +37,7 @@ #include "drivers/time.h" #include "drivers/system.h" #include "drivers/pwm_output.h" +#include "drivers/pwm_mapping.h" #include "sensors/sensors.h" #include "sensors/diagnostics.h" @@ -88,6 +89,8 @@ #include "flight/mixer_profile.h" #include "flight/mixer.h" + +#include "io/motor_srxl2.h" #include "flight/servos.h" #include "flight/pid.h" #include "flight/imu.h" @@ -301,7 +304,24 @@ static void updateArmingStatus(void) } /* CHECK: */ - if (!isHardwareHealthy()) { + bool escLinkMissing = false; +#ifdef USE_MOTOR_SRXL2 + /* + * An SRXL2 ESC announces itself in the third of a second after it gains + * power, and is silent from then on: if that announcement is missed the + * link never forms, and the throttle reaches nothing. Arming meanwhile + * commands a motor that is not listening - the model looks armed, the + * telemetry looks sane, and the propeller does not turn. + * + * Refuse to arm until the link is actually up. Where both come up on the + * same battery this costs about a second at power-up and is invisible; + * where it does not, it is the difference between finding out on the + * bench and finding out on the takeoff roll. + */ + escLinkMissing = (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) + && !srxl2MotorIsConnected(); +#endif + if (!isHardwareHealthy() || escLinkMissing) { ENABLE_ARMING_FLAG(ARMING_DISABLED_HARDWARE_FAILURE); } else { From b106d2f0b03b770003c936e949ec557cab9e57b4 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:35:26 +0200 Subject: [PATCH 29/40] Answer the review: ten findings, ten changes An automated review pass raised ten issues on this PR. Each was checked against the code before being acted on, and each turned out to be real. **The link timeout was coupled to the telemetry rate, and one setting guaranteed a disconnection.** Nothing but a telemetry reply refreshes the receive timer, because a running Avian never speaks unprompted - so the request rate *is* the link-liveness rate. The slowest setting asked once every 500 ms against a 500 ms timeout. Worse, the ESC answers only about two requests in three, and a dropped link is unrecoverable on this hardware: the driver returns to polling, where a running ESC answers nothing at all. The two slow settings are gone rather than papered over with an elastic timeout, which would have hidden the very failure the timeout exists to catch. What remains is one request every five frames at worst - a reply typically every 150 ms, three consecutive misses still inside the window - and the timeout constant now carries that reasoning. **Telemetry aged out faster than it arrived.** The stale window was one second while the ESC's own readings arrive about once a second at the default rate, so a healthy sensor flickered. Now three seconds; what notices a stopped ESC is the link timeout, which invalidates the reading anyway. **Manual calibration could command full throttle into a live ESC.** The unattended sequence refuses to start with a battery present; `esc_calibrate high` did not, which left the guard off the path a person drives by hand. Boards with no voltage sensing report the pack absent and are unaffected - which is the case that path exists for. **A short handshake was parsed.** Any CRC-valid frame five bytes or longer labelled Handshake had its device ID and baud read from whatever the buffer held last. Length is checked before the payload is touched. **Discovery polled one address.** The comment claimed polling was what finds an ESC with a non-zero unit ID; it only ever addressed 0x40, the one ID that announces itself. It now walks 0x40 to 0x4F. **Thrust reverse never reported active over MSP.** The mode was advertised in initActiveBoxIds() but never packed into the outgoing bitmask, so the Configurator showed it off while the driver was acting on it. **`esc_calibrate` was compiled out of most builds.** Both the command and its table entry sat inside `#ifdef USE_USB_MSC`, so the documented command did not exist on any build without mass storage - SITL included. Verified present there now. **A configuration could select a protocol the firmware cannot drive.** Restoring SRXL2 onto a build without the driver left the motor writer null and escaped the insufficient-outputs check, giving a model that arms and does nothing; it now falls back at boot. Reverse on channel 1 aliases the throttle and the driver refuses it, silently - that setting is now corrected to 0 where the user can see it. **Absent telemetry fields arrived as confident zeros.** The wire has a "no data" code per field and the struct carried one valid flag for the frame, so an ESC that does not report current delivered 0.00 A - indistinguishable from a motor at rest, and believed by the battery estimate. Fields are now tracked individually and a missing one leaves the previous reading in place. --- docs/Settings.md | 2 +- docs/Spektrum Smart ESC.md | 6 ++- src/main/fc/cli.c | 4 +- src/main/fc/config.c | 21 ++++++++++ src/main/fc/fc_msp_box.c | 5 +++ src/main/fc/settings.yaml | 4 +- src/main/io/motor_srxl2.c | 74 +++++++++++++++++++++++++++++------ src/main/io/motor_srxl2.h | 25 ++++++++++-- src/main/sensors/esc_sensor.c | 28 ++++++++++--- 9 files changed, 139 insertions(+), 30 deletions(-) diff --git a/docs/Settings.md b/docs/Settings.md index 894bbbd50d5..7f9e041e389 100644 --- a/docs/Settings.md +++ b/docs/Settings.md @@ -1067,7 +1067,7 @@ Read ESC telemetry off the SRXL2 link. Only applies when motor_pwm_protocol is S ### esc_srxl2_telemetry_rate -How often ESC telemetry arrives from an SRXL2 Smart ESC, in readings per second. The ESC answers about two requests in three and rotates its reply between three sensors, so it delivers roughly a ninth of what is asked for - these are the delivered rates, measured, not the request rate. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; only the RPM filter benefits from it. Asking on every frame is not offered: an Avian keeps the link and stops obeying the throttle. +How often ESC telemetry arrives from an SRXL2 Smart ESC, in readings per second. The ESC answers about two requests in three and rotates its reply between three sensors, so it delivers roughly a ninth of what is asked for - these are the delivered rates, measured, not the request rate. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; only the RPM filter benefits from it. The range is bounded at both ends by the ESC: asking on every frame makes an Avian keep the link and stop obeying the throttle, and asking slower than 1 Hz makes the link time out on a healthy ESC, because its reply is the only thing that proves it is still there. | Default | Min | Max | | --- | --- | --- | diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index c4d809934ef..78a208ac6fd 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -155,7 +155,9 @@ Two things to weigh before turning `rpm_gyro_filter_enabled` on: rate too: the ESC answers about two requests in three and rotates its reply between a text page, a battery page and the ESC page, so rpm reaches the flight controller at roughly a ninth of what is asked for. `esc_srxl2_telemetry_rate` - is named for what arrives - 1 Hz by default, 3 Hz at the fastest. On an aircraft + is named for what arrives - 1 Hz by default, 3 Hz at the fastest, and nothing + slower, because the ESC only speaks when asked and its reply is what tells the + flight controller the link is alive. On an aircraft holding a cruise throttle, rpm and the vibration peak both move slowly and that is adequate. This is nowhere near bidirectional DSHOT, which reports every loop. * INAV's own advice for this setting applies unchanged: turn it on only once ESC @@ -292,7 +294,7 @@ nothing anyway. | `motor_pwm_protocol = SRXL2` | drive motors over SRXL2 | | `esc_srxl2_reverse_channel` | SRXL2 channel the ESC watches for reverse, 5 to 9; Spektrum default 7, 0 disables | | `esc_srxl2_telemetry` | read telemetry from the SRXL2 link | -| `esc_srxl2_telemetry_rate` | how often telemetry arrives: 3, 2, 1, 0.5 or 0.2 Hz | +| `esc_srxl2_telemetry_rate` | how often telemetry arrives: 3, 2 or 1 Hz | | `motor_poles` | required for correct rpm, see above | ## Reference diff --git a/src/main/fc/cli.c b/src/main/fc/cli.c index 64b250f8f09..dca9420bf3c 100644 --- a/src/main/fc/cli.c +++ b/src/main/fc/cli.c @@ -4742,7 +4742,6 @@ static void cliDiff(char *cmdline) printConfig(cmdline, true); } -#ifdef USE_USB_MSC #ifdef USE_MOTOR_SRXL2 static void cliEscCalibratePrintResult(srxl2CalResult_e r) { @@ -4825,6 +4824,7 @@ static void cliEscCalibrate(char *cmdline) } #endif +#ifdef USE_USB_MSC static void cliMsc(char *cmdline) { UNUSED(cmdline); @@ -5106,10 +5106,10 @@ const clicmd_t cmdTable[] = { CLI_COMMAND_DEF("memory", "view memory usage", NULL, cliMemory), CLI_COMMAND_DEF("mmix", "custom motor mixer", NULL, cliMotorMix), CLI_COMMAND_DEF("motor", "get/set motor", " []", cliMotor), -#ifdef USE_USB_MSC #ifdef USE_MOTOR_SRXL2 CLI_COMMAND_DEF("esc_calibrate", "teach a Spektrum Smart ESC its throttle range", "[start|high|low|off]", cliEscCalibrate), #endif +#ifdef USE_USB_MSC CLI_COMMAND_DEF("msc", "switch into msc mode", NULL, cliMsc), #endif CLI_COMMAND_DEF("play_sound", NULL, "[]\r\n", cliPlaySound), diff --git a/src/main/fc/config.c b/src/main/fc/config.c index ae9888ee50a..5ef3ab34f09 100755 --- a/src/main/fc/config.c +++ b/src/main/fc/config.c @@ -276,6 +276,27 @@ void validateAndFixConfig(void) } #endif +#if !defined(USE_MOTOR_SRXL2) + // A configuration restored onto a build without the driver would keep SRXL2 + // selected, and nothing would drive the motors: the branch that installs the + // SRXL2 writer is compiled out, so the writer stays null, while the protocol + // is not timer-based and so escapes the "not enough outputs" check too. The + // result is a model that arms and does nothing, which is the one outcome + // worth spending a boot-time rewrite on. + if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) { + motorConfigMutable()->motorPwmProtocol = PWM_TYPE_STANDARD; + } +#else + // Reverse on the throttle's own channel cannot work - the driver refuses it, + // because writing that channel at task rate would overwrite the throttle + // several hundred times a second. Refusing it silently left a mode the + // Configurator offered and nothing acted on, so the setting is corrected + // where the user can see it instead. + if (motorConfig()->srxl2ReverseChannel == 1) { + motorConfigMutable()->srxl2ReverseChannel = 0; + } +#endif + // Call target-specific validation function validateAndFixTargetConfig(); diff --git a/src/main/fc/fc_msp_box.c b/src/main/fc/fc_msp_box.c index 885701f6797..742c90f4510 100644 --- a/src/main/fc/fc_msp_box.c +++ b/src/main/fc/fc_msp_box.c @@ -516,6 +516,11 @@ void packBoxModeFlags(boxBitmask_t * mspBoxModeFlags) #ifdef USE_CMS CHECK_ACTIVE_BOX(IS_ENABLED(IS_RC_MODE_ACTIVE(BOXINFLIGHTMENU)), BOXINFLIGHTMENU); #endif +#ifdef USE_MOTOR_SRXL2 + /* Advertised in initActiveBoxIds() but never reported back, so the mode + * showed as off in the Configurator while the driver was acting on it. */ + CHECK_ACTIVE_BOX(IS_ENABLED(IS_RC_MODE_ACTIVE(BOXTHRUSTREVERSE)), BOXTHRUSTREVERSE); +#endif memset(mspBoxModeFlags, 0, sizeof(boxBitmask_t)); for (uint32_t i = 0; i < activeBoxIdCount; i++) { diff --git a/src/main/fc/settings.yaml b/src/main/fc/settings.yaml index 127d757edc0..d5ffd13617f 100644 --- a/src/main/fc/settings.yaml +++ b/src/main/fc/settings.yaml @@ -34,7 +34,7 @@ tables: # existing configuration reads whatever index 0 happens to be. Putting the default # there makes that harmless instead of silently selecting the fastest rate. - name: esc_srxl2_telemetry_rate - values: ["1HZ", "3HZ", "2HZ", "0_5HZ", "0_2HZ"] + values: ["1HZ", "3HZ", "2HZ"] - name: servo_protocol values: ["PWM", "SBUS", "SBUS_PWM"] - name: failsafe_procedure @@ -876,7 +876,7 @@ groups: condition: USE_MOTOR_SRXL2 type: bool - name: esc_srxl2_telemetry_rate - description: "How often ESC telemetry arrives from an SRXL2 Smart ESC, in readings per second. The ESC answers about two requests in three and rotates its reply between three sensors, so it delivers roughly a ninth of what is asked for - these are the delivered rates, measured, not the request rate. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; only the RPM filter benefits from it. Asking on every frame is not offered: an Avian keeps the link and stops obeying the throttle." + description: "How often ESC telemetry arrives from an SRXL2 Smart ESC, in readings per second. The ESC answers about two requests in three and rotates its reply between three sensors, so it delivers roughly a ninth of what is asked for - these are the delivered rates, measured, not the request rate. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; only the RPM filter benefits from it. The range is bounded at both ends by the ESC: asking on every frame makes an Avian keep the link and stop obeying the throttle, and asking slower than 1 Hz makes the link time out on a healthy ESC, because its reply is the only thing that proves it is still there." default_value: "1HZ" # must stay index 0, see the table comment field: srxl2TelemetryRate condition: USE_MOTOR_SRXL2 diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 3470d9b6079..4005cd90e97 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -181,11 +181,35 @@ */ #define SRXL2_TELEM_REQUEST_MIN 2 -/* Declare the link dead if the ESC stops answering for this long. */ +/* + * Declare the link dead if the ESC stops answering for this long. + * + * This is the reason the telemetry table stops at one request every five frames. + * An Avian never speaks unprompted once it is running, so the only thing that + * refreshes this timer is a telemetry reply, and a request rate slower than the + * timeout disconnects a healthy ESC on schedule. Measured, it answers about two + * requests in three, so the margin has to cover consecutive misses as well: + * every fifth frame is a request every 100 ms and a reply typically every 150, + * so three misses in a row still land inside this window. + * + * Losing the link is not a recoverable event on this hardware - a running Avian + * answers no discovery of any kind - so a timeout that can fire on a healthy + * link would mean a motor that stops and cannot be brought back without + * removing power. + */ #define SRXL2_LINK_TIMEOUT_MS 500 -/* Telemetry older than this is reported as stale rather than current. */ -#define SRXL2_TELEM_STALE_MS 1000 +/* + * Telemetry older than this is reported as stale rather than current. + * + * Generous compared with the link timeout on purpose. The ESC rotates its reply + * between three sensors and answers about two requests in three, so at the + * default rate its own readings arrive about once a second even though the link + * is being exercised ten times as often: a one-second window made a healthy + * sensor flicker between valid and absent. What notices an ESC that has actually + * stopped is SRXL2_LINK_TIMEOUT_MS, which invalidates the reading anyway. + */ +#define SRXL2_TELEM_STALE_MS 3000 /* * Calibration phases end themselves. The high phase has to outlast a human @@ -293,6 +317,7 @@ typedef struct { uint8_t deviceId; /* 0 until discovered */ uint8_t baudSupported; + uint8_t pollId; /* offset from SRXL2_ESC_ID_FIRST, while polling */ uint8_t agreedBaudBits; bool baudSwitchPending; /* waiting for TX to drain */ @@ -477,11 +502,11 @@ static void srxl2DecodeEscTelemetry(srxl2Esc_t *e, const uint8_t *payload) /* 0xFFFF and 0xFF mean "no data" and must not be taken for readings - * 0xFFFF volts at 0.01 V per count would otherwise look like 655 V. */ - if (rpm != 0xFFFF) { t->rpm = (uint32_t)rpm * 10; } - if (voltsIn != 0xFFFF) { t->voltage = voltsIn; } /* already 0.01 V */ - if (currentMot != 0xFFFF) { t->current = currentMot; } /* 10 mA == 0.01 A */ - if (tempFet != 0xFFFF) { t->temperatureFet = (int16_t)tempFet; } /* 0.1 degC */ - if (tempBec != 0xFFFF) { t->temperatureBec = (int16_t)tempBec; } + if (rpm != 0xFFFF) { t->rpm = (uint32_t)rpm * 10; t->fields |= SRXL2_TELEM_FIELD_RPM; } + if (voltsIn != 0xFFFF) { t->voltage = voltsIn; t->fields |= SRXL2_TELEM_FIELD_VOLTAGE; } + if (currentMot != 0xFFFF) { t->current = currentMot; t->fields |= SRXL2_TELEM_FIELD_CURRENT; } + if (tempFet != 0xFFFF) { t->temperatureFet = (int16_t)tempFet; t->fields |= SRXL2_TELEM_FIELD_TEMP_FET; } + if (tempBec != 0xFFFF) { t->temperatureBec = (int16_t)tempBec; t->fields |= SRXL2_TELEM_FIELD_TEMP_BEC; } if (currentBec != 0xFF) { t->currentBec = (uint16_t)currentBec * 10; } /* 100 mA -> 0.01 A */ if (voltsBec != 0xFF) { t->voltageBec = (uint16_t)voltsBec * 5; } /* 0.05 V -> 0.01 V */ if (throttle != 0xFF) { t->throttlePercent = MIN((uint8_t)(throttle / 2), 100); } @@ -577,7 +602,12 @@ static void srxl2HandleFrame(srxl2Esc_t *e, const uint8_t *buf, uint8_t len) switch (buf[1]) { case Handshake: - srxl2HandleHandshake(e, buf); + /* Length checked before the payload is read: a short frame that happens + * to carry a valid CRC would otherwise have its device ID and baud + * fields taken from whatever the receive buffer held last. */ + if (len >= sizeof(Srxl2HandshakeFrame)) { + srxl2HandleHandshake(e, buf); + } break; case TelemetrySensorData: srxl2HandleTelemetry(e, buf, len); @@ -813,7 +843,7 @@ void srxl2MotorSetTelemetryRate(srxl2TelemetryRate_e rate) /* Indexed by srxl2TelemetryRate_e. Each entry is how many 50 Hz control * frames pass between requests; the setting is named for what comes back, * which is roughly a ninth of what is asked for. */ - static const uint8_t divisor[] = { 5, 2, 3, 10, 25 }; + static const uint8_t divisor[] = { 5, 2, 3 }; uint8_t every = (rate < ARRAYLEN(divisor)) ? divisor[rate] : SRXL2_TELEM_REQUEST_DEFAULT; @@ -871,6 +901,18 @@ srxl2CalResult_e srxl2MotorCalibrationManual(srxl2CalPhase_e phase) return (calLastResult = common); } + /* + * The high phase commands full throttle, so it may not start against an ESC + * that already has power. The unattended sequence refuses this and the + * manual one did not, which left the more dangerous of the two - a person + * typing a command, rather than a wizard that walks them through it - + * without the guard. Boards that cannot sense the pack report it absent and + * are unaffected, which is the case this manual path exists for. + */ + if (phase == SRXL2_CAL_HIGH_MANUAL && getBatteryState() != BATTERY_NOT_PRESENT) { + return (calLastResult = SRXL2_CAL_REJECT_BATTERY_PRESENT); + } + calPhase = phase; calPhaseMs = millis(); return (calLastResult = SRXL2_CAL_ACCEPTED); @@ -979,8 +1021,10 @@ static void srxl2ProcessEsc(srxl2Esc_t *e, timeMs_t now) case SRXL2_POLLING: if (now - e->lastTxMs >= SRXL2_HANDSHAKE_INTERVAL_MS) { - /* Poll the default ESC ID. An ESC with a non-zero unit ID never - * announces itself, so without this it would never be found. + /* Walk the whole ESC range rather than only the default ID. An ESC + * with a non-zero unit ID never announces itself, so polling is the + * only way it could be found - and polling one address was not + * that, it was polling the one address that does announce. * * Every bus is polled at the same ID, which is not a collision: each * ESC is alone on its wire and hears only its own master. @@ -994,7 +1038,11 @@ static void srxl2ProcessEsc(srxl2Esc_t *e, timeMs_t now) * exactly nothing. It announces six times in the 300 ms after reset * and is mute from then on. A board that reboots under a powered ESC * therefore never links, and no amount of asking changes that. */ - srxl2SendHandshake(e, SRXL2_ESC_ID_FIRST, SRXL2_BAUD_BIT_400K); + srxl2SendHandshake(e, SRXL2_ESC_ID_FIRST + e->pollId, SRXL2_BAUD_BIT_400K); + e->pollId++; + if (SRXL2_ESC_ID_FIRST + e->pollId > SRXL2_ESC_ID_LAST) { + e->pollId = 0; + } } break; diff --git a/src/main/io/motor_srxl2.h b/src/main/io/motor_srxl2.h index 845c9be5f9c..d7a04406cd5 100644 --- a/src/main/io/motor_srxl2.h +++ b/src/main/io/motor_srxl2.h @@ -49,11 +49,24 @@ */ #define SRXL2_ESC_MAX_MOTORS 4 +/* Which measurements a telemetry frame actually carried. The wire has a "no + * data" code per field (0xFFFF, or 0xFF for the byte-sized ones), and a missing + * measurement must not reach a consumer as a confident zero: nothing downstream + * can tell 0.00 A meaning "idle" from 0.00 A meaning "this ESC does not report + * current". */ +typedef enum { + SRXL2_TELEM_FIELD_RPM = (1 << 0), + SRXL2_TELEM_FIELD_VOLTAGE = (1 << 1), + SRXL2_TELEM_FIELD_CURRENT = (1 << 2), + SRXL2_TELEM_FIELD_TEMP_FET = (1 << 3), + SRXL2_TELEM_FIELD_TEMP_BEC = (1 << 4), +} srxl2TelemetryField_e; + /* Decoded ESC telemetry, from STRU_TELE_ESC (X-Bus sensor ID 0x20). * Units are INAV's, not the wire's: the wire sends 10 rpm, 0.01 V, 10 mA and * 0.1 degree steps, and this struct is already converted. - * A field the ESC reports as "no data" (0xFFFF / 0xFF) is left at 0 and the - * matching valid bit is cleared. */ + * A field the ESC reports as "no data" is left at 0 and its bit in `fields` is + * clear; `valid` says only that a frame arrived and is recent. */ typedef struct { uint32_t rpm; /* electrical rpm */ uint16_t voltage; /* 0.01 V */ @@ -65,6 +78,7 @@ typedef struct { uint8_t throttlePercent; /* 0..100 */ uint8_t powerPercent; /* 0..100 */ uint32_t lastUpdateMs; + uint8_t fields; /* srxl2TelemetryField_e bits actually reported */ bool valid; } srxl2EscTelemetry_t; @@ -169,6 +183,11 @@ void srxl2MotorSetReverseChannel(uint8_t channel1Based); * The reply shares the throttle wire, so this trades bus headroom against how * promptly rpm and voltage move - which only matters to the RPM filter, * everything else being a display. + * + * Nothing slower than 1 Hz is offered, and that is a link-liveness limit rather + * than a taste: the ESC only ever speaks when asked, so the request rate is also + * the rate at which the master learns the ESC is still there. See + * SRXL2_LINK_TIMEOUT_MS. */ typedef enum { /* 1 Hz is first so that it is zero. The setting lives in a field appended to @@ -178,8 +197,6 @@ typedef enum { SRXL2_TELEM_1HZ = 0, SRXL2_TELEM_3HZ, SRXL2_TELEM_2HZ, - SRXL2_TELEM_0_5HZ, - SRXL2_TELEM_0_2HZ, } srxl2TelemetryRate_e; void srxl2MotorSetTelemetryRate(srxl2TelemetryRate_e rate); diff --git a/src/main/sensors/esc_sensor.c b/src/main/sensors/esc_sensor.c index 1ca4425e214..0611dd0ad44 100644 --- a/src/main/sensors/esc_sensor.c +++ b/src/main/sensors/esc_sensor.c @@ -282,18 +282,34 @@ void escSensorUpdate(timeUs_t currentTimeUs) for (uint8_t i = 0; i < count; i++) { srxl2EscTelemetry_t t; if (srxl2MotorGetTelemetry(i, &t)) { - escSensorData[i].dataAge = 0; - escSensorData[i].temperature = t.temperatureFet / 10; /* 0.1 degC -> degC */ - escSensorData[i].voltage = t.voltage; /* both 0.01 V */ - escSensorData[i].current = t.current; /* both 0.01 A */ + escSensorData[i].dataAge = 0; + /* + * Only what the frame actually carried. An ESC that reports no + * current sends a "no data" code, and copying that through as + * 0.00 A would be indistinguishable from a motor at rest - which + * is a reading the battery estimate would happily believe. Where + * a field is absent the last known value is left in place and + * ages out with the rest. + */ + if (t.fields & SRXL2_TELEM_FIELD_TEMP_FET) { + escSensorData[i].temperature = t.temperatureFet / 10; /* 0.1 degC -> degC */ + } + if (t.fields & SRXL2_TELEM_FIELD_VOLTAGE) { + escSensorData[i].voltage = t.voltage; /* both 0.01 V */ + } + if (t.fields & SRXL2_TELEM_FIELD_CURRENT) { + escSensorData[i].current = t.current; /* both 0.01 A */ + } /* * The wire carries electrical rpm; everything downstream expects * mechanical, which is what computeRpm() produces for the serial * backends. Same division, done here because our value is already * in rpm rather than the LSB units that function takes. */ - const uint8_t poles = motorConfig()->motorPoleCount; - escSensorData[i].rpm = poles ? (t.rpm / (poles / 2)) : 0; + if (t.fields & SRXL2_TELEM_FIELD_RPM) { + const uint8_t poles = motorConfig()->motorPoleCount; + escSensorData[i].rpm = poles ? (t.rpm / (poles / 2)) : 0; + } } else if (escSensorData[i].dataAge < ESC_DATA_INVALID) { escSensorData[i].dataAge++; } From 40c2d80a2950e28f93c76444a662c4ff487d24cd Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:20:53 +0200 Subject: [PATCH 30/40] Say plainly that the RPM filter cannot track throttle on this link The guide weighed the slow telemetry and then concluded that on an aircraft holding cruise throttle it is adequate. That conclusion was reasoning, presented in the same voice as the measurements around it. The measurement is that rpm arrives 2.7 times a second at the fastest rate the link tolerates, and 1.1 at the default - two to three orders of magnitude below bidirectional DSHOT, with no margin to recover, since asking faster stops the ESC obeying the throttle and it advertises no support for 400000 baud. From that, on a multirotor the filter should be off: a notch updated twice a second is mostly in the wrong place, attenuating signal instead of noise. On a fixed wing at steady throttle it may well be fine, and the text now says that this is an argument rather than a flight result, and suggests logging it both ways. --- docs/Spektrum Smart ESC.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index 78a208ac6fd..b56c7353c66 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -149,17 +149,26 @@ electrical rpm, and INAV converts it to mechanical rpm using the pole count. A w pole count puts the notch at the wrong frequency, which is worse than having no notch at all. -Two things to weigh before turning `rpm_gyro_filter_enabled` on: - -* Telemetry arrives far slower than the loop rate, and slower than the request - rate too: the ESC answers about two requests in three and rotates its reply - between a text page, a battery page and the ESC page, so rpm reaches the flight - controller at roughly a ninth of what is asked for. `esc_srxl2_telemetry_rate` - is named for what arrives - 1 Hz by default, 3 Hz at the fastest, and nothing - slower, because the ESC only speaks when asked and its reply is what tells the - flight controller the link is alive. On an aircraft - holding a cruise throttle, rpm and the vibration peak both move slowly and that - is adequate. This is nowhere near bidirectional DSHOT, which reports every loop. +**The rpm this delivers is too slow for the filter to track a changing throttle, +and that is a hardware limit, not a tuning one.** Measured on an Avian 70 A: the +ESC answers about two requests in three and rotates its reply between a text +page, a battery page and the ESC page, so rpm arrives at roughly a ninth of the +request rate - 2.7 readings a second at the fastest setting the link tolerates, +1.1 at the default. There is no margin to recover, either: requesting on every +frame makes the ESC stop obeying the throttle, and this ESC advertises no support +for 400000 baud, so the wire cannot be made faster. + +For comparison, bidirectional DSHOT reports rpm every loop. Two to three orders +of magnitude separate the two, so: + +* **On a multirotor, leave `rpm_gyro_filter_enabled` off.** The vibration peak + moves with the throttle several times a second, and a notch updated twice a + second spends most of its time in the wrong place - which is worse than no + notch, because it attenuates signal rather than noise. +* **On a fixed wing holding a cruise throttle** the peak moves slowly and the + filter is arguably useful. Arguably: that the update rate is adequate there is + reasoning from how slowly cruise rpm changes, not something measured in flight. + If you try it, compare a logged flight with it on and off before trusting it. * INAV's own advice for this setting applies unchanged: turn it on only once ESC telemetry is working and the reported rpm looks right. From 8203fea9225bc84373b2304313553fd3297f3125 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:26:14 +0200 Subject: [PATCH 31/40] Do not abandon an ESC that has merely gone quiet A telemetry gap used to tear the link down: back to 115200, device ID forgotten, state to POLLING - which stops sending control data altogether. On this hardware that is unrecoverable, because a running Avian answers no discovery of any kind, so a gap of half a second cost the motor for the rest of the flight. Measured on the bench with the supply as the witness rather than the ESC's own rpm field, which turned out to freeze at its last value when commutation stops: - no telemetry requested for 3 s, then for 10 s, with control frames still going out: current held 0.29 to 0.31 A throughout, against 0.30 before. The motor never faltered. Telemetry has nothing to do with the throttle. - no frames at all: current falls to the ESC's own 58 mA within about half a second, and returns to 0.30 A as soon as frames resume - no re-arm, no power cycle, the throttle picked up where it was left. So the timeout was causing the outage it existed to detect. It now lets the telemetry go stale and keeps driving. The teardown remains for the one case that needs it: a slave that reset comes back at 115200 and announces for 300 ms, which cannot be heard from 400000 - so it fires only where the baud was actually raised, which no Avian tested allows. Arming keeps the old strictness. With the link no longer torn down, a board whose ESC was unplugged would sit in RUNNING and arm happily, so srxl2MotorIsConnected() now asks for a recent reply rather than for the state alone. Strict on the ground, forgiving in the air: two questions that used to share one answer. --- docs/Spektrum Smart ESC.md | 11 +++++++- src/main/io/motor_srxl2.c | 52 +++++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index b56c7353c66..1e708062289 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -292,7 +292,16 @@ ones: Nothing in the firmware can work around this, so it refuses to hide it instead: arming is blocked while an SRXL2 link is missing, and the OSD says the hardware -is not there. Where ESC and board come up together the block clears in about a +is not there. + +What the firmware does not do is give up on an ESC that has gone quiet while it +is being flown. Telemetry has nothing to do with the throttle on these ESCs: +measured with a bench supply as the witness, an Avian held 0.30 A through ten +seconds with no telemetry requested at all. What stops the motor is the absence +of control frames - the current falls to the ESC's own 58 mA within about half a +second - and it takes the throttle back up by itself when frames return, with no +re-arm and no power cycle. So a silent ESC keeps being commanded, and only the +telemetry goes stale. Where ESC and board come up together the block clears in about a second and is never seen; where it does not clear, the throttle would have done nothing anyway. diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 4005cd90e97..27e14057429 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -1067,14 +1067,36 @@ static void srxl2ProcessEsc(srxl2Esc_t *e, timeMs_t now) } if (now - e->lastRxMs >= SRXL2_LINK_TIMEOUT_MS) { - /* Lost the ESC. Go back to 115200, where a slave that has just reset - * will be listening, and look for it again. */ - serialSetBaudRate(e->port, SRXL2_BAUD_LOW); - e->baudSwitchPending = false; - e->agreedBaudBits = 0; - e->deviceId = 0; e->telemetry.valid = false; - srxl2SetState(e, SRXL2_POLLING); + + /* + * Silence from the ESC is not a reason to stop commanding it. + * + * An Avian ignores telemetry requests entirely as far as the + * throttle is concerned: measured with the bench supply as witness, + * the motor held 0.30 A through three seconds and then ten seconds + * with no request sent at all, never faltering. What does stop it is + * the absence of control frames - the current falls to the ESC's own + * 58 mA within about half a second - and it picks the throttle back + * up by itself as soon as frames return, with no re-arm. + * + * So giving up here would cause the outage it is meant to detect, + * and on this hardware it would be permanent: a running Avian + * answers no discovery, so POLLING never finds it again. Keep + * driving, and let the telemetry go stale. + * + * The one case that does need the teardown is a slave that reset: + * it comes back at 115200 and announces for 300 ms, which cannot be + * heard from 400000. That only applies where the baud was raised - + * which no Avian tested allows, since they advertise 115200 only. + */ + if (e->agreedBaudBits != 0) { + serialSetBaudRate(e->port, SRXL2_BAUD_LOW); + e->baudSwitchPending = false; + e->agreedBaudBits = 0; + e->deviceId = 0; + srxl2SetState(e, SRXL2_POLLING); + } } break; @@ -1126,15 +1148,31 @@ uint8_t srxl2MotorCount(void) return escCount; } +/* + * Whether every ESC is not merely being driven, but answering. + * + * Deliberately stricter than "the driver is in RUNNING". Since a telemetry gap + * no longer tears the link down - it would cause the outage it detects - a board + * whose ESC has been unplugged stays in RUNNING, commanding a motor that is not + * there. That is right for a machine already flying and wrong for one about to + * arm, so this asks for a recent reply and the arming check asks this. + * + * Strict on the ground, forgiving in the air: two different questions that used + * to share one answer. + */ bool srxl2MotorIsConnected(void) { if (escCount == 0) { return false; } + const timeMs_t now = millis(); for (uint8_t i = 0; i < escCount; i++) { if (esc[i].state != SRXL2_RUNNING || esc[i].deviceId == 0) { return false; } + if (now - esc[i].lastRxMs >= SRXL2_LINK_TIMEOUT_MS) { + return false; + } } return true; } From 86e4b3207f3849886db6e81c0703b35cf8edc579 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:43:50 +0200 Subject: [PATCH 32/40] Wait for the ESC's startup tones before reporting it ready An Avian announces itself within 300 ms of gaining power, and is not ready to turn the motor for about five seconds after that: it spends them playing its startup tones, the last of which is what a pilot hears as "connected". Timed on the bench with the announcement as the zero and the tones as the reference. Arming now waits six seconds from the link coming up rather than for the handshake alone - a second past the last tone. Where a person powers the aircraft and then arms it this costs nothing, since nobody arms within six seconds of connecting the battery; where something arms sooner, the alternative was commanding a motor that was not yet listening. It rides on srxl2MotorIsConnected(), which the arming check already consults, so this is one more reason for that predicate to say no rather than a new mechanism. --- src/main/io/motor_srxl2.c | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 27e14057429..b8e6aa07ab6 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -199,6 +199,22 @@ */ #define SRXL2_LINK_TIMEOUT_MS 500 +/* + * How long after the link comes up the ESC is actually ready to turn the motor. + * + * An Avian announces itself within 300 ms of gaining power, but it is not + * finished: it plays its startup tones for about five seconds afterwards, and + * only once the last of them - the one a pilot hears as "connected" - has + * sounded will it drive the motor. Timed on the bench against the tones + * themselves, with the announcement as the zero. + * + * Six seconds puts the flight controller a second past that. Arming waits for + * it rather than for the handshake alone, which costs nothing where a person + * powers the aircraft and then arms it: nobody arms within six seconds of + * connecting the battery. + */ +#define SRXL2_READY_DELAY_MS 6000 + /* * Telemetry older than this is reported as stale rather than current. * @@ -318,6 +334,7 @@ typedef struct { uint8_t deviceId; /* 0 until discovered */ uint8_t baudSupported; uint8_t pollId; /* offset from SRXL2_ESC_ID_FIRST, while polling */ + timeMs_t runningSinceMs; /* when the link came up, for SRXL2_READY_DELAY_MS */ uint8_t agreedBaudBits; bool baudSwitchPending; /* waiting for TX to drain */ @@ -1052,6 +1069,7 @@ static void srxl2ProcessEsc(srxl2Esc_t *e, timeMs_t now) if (!e->baudSwitchPending && isSerialTransmitBufferEmpty(e->port)) { e->lastRxMs = now; /* do not time out on the handshake gap */ e->lastControlMs = now; + e->runningSinceMs = now; srxl2SetState(e, SRXL2_RUNNING); } break; @@ -1173,6 +1191,9 @@ bool srxl2MotorIsConnected(void) if (now - esc[i].lastRxMs >= SRXL2_LINK_TIMEOUT_MS) { return false; } + if (now - esc[i].runningSinceMs < SRXL2_READY_DELAY_MS) { + return false; /* linked, but still sounding its startup tones */ + } } return true; } From 3f3f12980fdf4835aeeac0ce8f78465cd508c05b Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:15:38 +0200 Subject: [PATCH 33/40] Give the ESC's startup tones another half second Six seconds was timed against the tones themselves, which leaves very little margin once the announcement jitters. Commanding throttle exactly on that boundary does start the motor cleanly - measured on the bench, it turns 0.35 s after the command and holds - but half a second of headroom costs nothing on a delay nobody waits for in practice. --- src/main/io/motor_srxl2.c | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index b8e6aa07ab6..283a581bbeb 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -208,12 +208,13 @@ * sounded will it drive the motor. Timed on the bench against the tones * themselves, with the announcement as the zero. * - * Six seconds puts the flight controller a second past that. Arming waits for - * it rather than for the handshake alone, which costs nothing where a person - * powers the aircraft and then arms it: nobody arms within six seconds of - * connecting the battery. + * Six and a half seconds puts the flight controller comfortably past that, and + * a motor commanded on that boundary spins up cleanly. Arming waits for it + * rather than for the handshake alone, which costs nothing where a person + * powers the aircraft and then arms it: nobody arms that soon after connecting + * the battery. */ -#define SRXL2_READY_DELAY_MS 6000 +#define SRXL2_READY_DELAY_MS 6500 /* * Telemetry older than this is reported as stale rather than current. From 6b38a54b81416818f2eff5e4b5255ae3f7813677 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:59:29 +0200 Subject: [PATCH 34/40] Say what the reported current is, now that it has been measured The guide asked the reader to be careful "until this is settled" and quoted one operating point, 2.85 A against 0.999 A, which one point could never settle. It is settled now, and that 0.999 A turns out to have been a supply in constant current rather than a measurement: taken again with both readings at the same instant, the ratio between what the ESC reports and what the supply delivers follows 1/duty across the sweep instead of staying put, which is the converter relationship and not a scale error in the sensor. Says what follows from it: the figure runs high as pack current, worst at low throttle, and it reads 0.00 A outright below about 2 A, which at 15 % throttle meant calling 6900 rpm and 0.305 A zero. The driver still passes the number through unscaled, and the guide now says why. Two points are not a curve, the multiplier that would be correct is the ESC's own modulation duty rather than the throttle it reports, and a per-manufacturer scale belongs next to INAV's other current meters rather than inside one driver. --- docs/Spektrum Smart ESC.md | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index 1e708062289..4dce6532fd9 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -132,14 +132,35 @@ switched off with `esc_srxl2_telemetry`, which exists because telemetry shares t throttle wire and so cannot be declined by leaving a port unassigned as it would be for a conventional ESC. -**Current needs care until this is settled.** On the bench the ESC reported 2.85 A -while the supply feeding it measured 0.999 A at the same instant, at about 40 % -throttle. That is the relationship expected if the field is motor current rather -than pack current, since an ESC is a converter and pack current is roughly motor -current times duty. It is not proven: a plain scale error in the ESC's own sensor -would look identical at a single operating point, and telling the two apart needs -readings at several throttle settings. Until then, prefer the board's own sensor -with `current_meter_type = ADC` wherever one exists. The two are alternatives +**The current this ESC reports is the motor's, not the pack's.** Measured on the +bench with both readings taken at the same instant: + +| throttle | ESC reports | supply delivers | ratio | 1/duty | +| --- | --- | --- | --- | --- | +| 15 % | 0.00 A | 0.305 A | | 6.67 | +| 25 % | 2.62 A | 0.720 A | 3.64 | 4.00 | +| 35 % | 2.75 A | 0.915 A | 3.00 | 2.86 | + +The ratio follows 1/duty rather than staying put, which is the converter +relationship: an ESC trades voltage for current, so pack current is roughly motor +current times duty. A scale error in the sensor would give the same ratio at both +points. It shows without the arithmetic too, since what the ESC reports barely +moves while pack current rises by a quarter. + +Two consequences. The figure runs high as a measure of what the battery is +delivering, worst at low throttle and converging at full, so a capacity count fed +from it counts down too fast. And below roughly 2 A the field reports 0.00 A +outright: at 15 % throttle the motor was turning at 6900 rpm on 0.305 A from the +supply and the ESC still called it zero. + +The driver passes the number through unscaled. Two operating points are not a +curve, the multiplier that would be correct is the ESC's own modulation duty +rather than the throttle it reports, and this is one ESC family rather than the +protocol. A per-manufacturer scale belongs beside INAV's other current meters, +not inside a driver. + +So prefer the board's own sensor with `current_meter_type = ADC` wherever one +exists. The two are alternatives rather than additive - INAV takes current from one source - and a shunt in the battery lead also sees what the servos and the video transmitter draw, which no ESC can report. From 4a47ca38232650497921f067764bf41f2f6b6f59 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:00:09 +0200 Subject: [PATCH 35/40] Say the RPM filter is not recommended, rather than describing why The section led with the measurement and left the reader to draw the conclusion, and the fixed-wing case was called "arguably useful", which reads as a milder version of the same advice rather than as what it is. Nobody has flown it. So the heading now states the recommendation, and the fixed-wing paragraph says unproven instead of arguably useful, with what would settle it: a logged flight compared with the filter on and off. --- docs/Spektrum Smart ESC.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index 4dce6532fd9..67fed814841 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -170,11 +170,11 @@ electrical rpm, and INAV converts it to mechanical rpm using the pole count. A w pole count puts the notch at the wrong frequency, which is worse than having no notch at all. -**The rpm this delivers is too slow for the filter to track a changing throttle, -and that is a hardware limit, not a tuning one.** Measured on an Avian 70 A: the -ESC answers about two requests in three and rotates its reply between a text -page, a battery page and the ESC page, so rpm arrives at roughly a ninth of the -request rate - 2.7 readings a second at the fastest setting the link tolerates, +**The RPM filter is not recommended over this link.** The rpm it delivers is too +slow for the filter to track a changing throttle, and that is a hardware limit +rather than a tuning one. Measured on an Avian 70 A: the ESC answers about two +requests in three and rotates its reply between a text page, a battery page and +the ESC page, so rpm arrives at roughly a ninth of the request rate - 2.7 readings a second at the fastest setting the link tolerates, 1.1 at the default. There is no margin to recover, either: requesting on every frame makes the ESC stop obeying the throttle, and this ESC advertises no support for 400000 baud, so the wire cannot be made faster. @@ -186,10 +186,11 @@ of magnitude separate the two, so: moves with the throttle several times a second, and a notch updated twice a second spends most of its time in the wrong place - which is worse than no notch, because it attenuates signal rather than noise. -* **On a fixed wing holding a cruise throttle** the peak moves slowly and the - filter is arguably useful. Arguably: that the update rate is adequate there is - reasoning from how slowly cruise rpm changes, not something measured in flight. - If you try it, compare a logged flight with it on and off before trusting it. +* **On a fixed wing holding a cruise throttle** the peak moves slowly enough that + the update rate might be adequate. Might: that is reasoning from how slowly + cruise rpm changes, and nobody has flown it. Until somebody does and compares a + logged flight with the filter on and off, treat it as unproven rather than as a + milder version of the same recommendation. * INAV's own advice for this setting applies unchanged: turn it on only once ESC telemetry is working and the reported rpm looks right. From ff8fcd71e0724ff6bcf4ccb685f1d7d79e8c582c Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:04:23 +0200 Subject: [PATCH 36/40] Drop the em dashes from the Smart ESC guide --- docs/Spektrum Smart ESC.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/Spektrum Smart ESC.md b/docs/Spektrum Smart ESC.md index 67fed814841..02b7a4cfe76 100644 --- a/docs/Spektrum Smart ESC.md +++ b/docs/Spektrum Smart ESC.md @@ -35,10 +35,10 @@ The motor pad that would normally have driven this ESC is simply left unused. ## Setting it up -1. **Ports tab** — assign `Spektrum Smart ESC (SRXL2)` to a spare UART, one per +1. **Ports tab**: assign `Spektrum Smart ESC (SRXL2)` to a spare UART, one per motor. They are matched to motors in port order; see below. -2. **Outputs tab** — set the ESC protocol to `SRXL2`. -3. **Outputs tab** — set **Motor poles** correctly. This matters more than usual; see +2. **Outputs tab**: set the ESC protocol to `SRXL2`. +3. **Outputs tab**: set **Motor poles** correctly. This matters more than usual; see the RPM filter section below. 4. If the ESC is programmed for reverse, set **Thrust Reverse: ESC channel** to the channel its own `Thrust Rev.` parameter selects, then assign the **THRUST @@ -56,7 +56,7 @@ AT32 targets; other targets can add `#define USE_MOTOR_SRXL2` to their `target.h A single SRXL2 bus can address several ESCs, at device IDs 0x40 to 0x4F, but each would need a distinct unit ID and the specification states that setting a unit ID -over SRXL2 "is not implemented" — it expects physical switches or jumpers, which +over SRXL2 "is not implemented", because it expects physical switches or jumpers, which Avian ESCs do not have. So it is one ESC per bus, and a model with several motors needs a port for each. @@ -106,7 +106,7 @@ finds out about it on the takeoff roll. 1. Remove the propeller and disconnect the battery. The wizard refuses to start with the battery connected, and the button stays inert until you confirm both. -2. Press **Start calibration**. Full throttle goes on the wire — with no battery, +2. Press **Start calibration**. Full throttle goes on the wire, and with no battery nothing can spin. 3. Connect the battery. The ESC sounds its tones, and the throttle drops to minimum by itself about three seconds later. @@ -198,7 +198,7 @@ of magnitude separate the two, so: Reverse on a Smart ESC is a switch, not a throttle value below neutral. The ESC's `Thrust Rev.` parameter names an auxiliary channel; when that channel goes high the -ESC reverses, and Spektrum describe the effect plainly — *"flipping the designated +ESC reverses, and Spektrum describe the effect plainly, *"flipping the designated switch reverses motor rotation, throttle will still control motor speed"*. So the throttle goes on meaning throttle. @@ -224,7 +224,7 @@ throttle slot. Spektrum allow channels **5 to 9** for this and ship **channel 7** as the factory default. Nothing on the wire advertises which one the ESC is watching, so a -mismatch simply means reverse never engages — silently. Check the channel against +mismatch simply means reverse never engages, and does so silently. Check the channel against your own ESC's programming rather than trusting the default: the parameter is not present on every Avian model, and where it is present the range and default have varied. @@ -259,9 +259,9 @@ which way the shaft is turning - only an eye on the motor can tell you. **Propeller off.** Arm with THRUST REVERSE off and watch the motor at *minimum* throttle: -* **stopped**, or idling gently forward — the switch arrangement, which is what +* **stopped**, or idling gently forward, the switch arrangement, which is what this driver expects. Nothing more to do. -* **spinning backwards hard** — the other kind. Disarm. INAV does not drive that +* **spinning backwards hard**, the other kind. Disarm. INAV does not drive that arrangement over SRXL2: the throttle it sends would be read as reverse thrust through most of the stick. @@ -287,8 +287,8 @@ put reverse on a switch the pilot has to mean to throw. ### What reverse cannot do -Reverse is a manual, stick-and-switch capability. INAV's automatic throttle paths — -RTH, autoland, failsafe, launch — all clamp throttle to at least idle, so none of +Reverse is a manual, stick-and-switch capability. INAV's automatic throttle paths, +RTH, autoland, failsafe and launch, all clamp throttle to at least idle, so none of them can call for reverse thrust. An automatic landing will not use it. ## If the motor does not come back after a reboot From a844a2b0855df3bcfda8eb1b76cfeb63f0dff74b Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:14:13 +0200 Subject: [PATCH 37/40] Regenerate Settings.md, which never listed SRXL2 as a motor protocol --- docs/Settings.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/Settings.md b/docs/Settings.md index bf6c4d453c0..723ab6c48fe 100644 --- a/docs/Settings.md +++ b/docs/Settings.md @@ -1070,9 +1070,11 @@ Read ESC telemetry off the SRXL2 link. Only applies when motor_pwm_protocol is S How often ESC telemetry arrives from an SRXL2 Smart ESC, in readings per second. The ESC answers about two requests in three and rotates its reply between three sensors, so it delivers roughly a ninth of what is asked for - these are the delivered rates, measured, not the request rate. The reply shares the throttle wire, so a faster rate leaves the bus less headroom; only the RPM filter benefits from it. The range is bounded at both ends by the ESC: asking on every frame makes an Avian keep the link and stop obeying the throttle, and asking slower than 1 Hz makes the link time out on a healthy ESC, because its reply is the only thing that proves it is still there. -| Default | Min | Max | -| --- | --- | --- | -| 1HZ | | | +| Allowed Values | | +| --- | --- | +| 1HZ | Default | +| 3HZ | | +| 2HZ | | --- @@ -3550,6 +3552,7 @@ Protocol that is used to send motor updates to ESCs. Possible values - STANDARD, | DSHOT150 | | | DSHOT300 | | | DSHOT600 | | +| SRXL2 | | --- From 279624c96a75c2c2ac248e9ade718200bb787b17 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:10:04 +0200 Subject: [PATCH 38/40] Trim the driver to the house style, and the channel array to what can reach it Three things, none of which changes what goes on the wire. motor_srxl2.c was 40 % comment, against 7 to 21 % for the drivers around it, and most of that was prose about the ESC rather than about the code: how an Avian answers telemetry, where its calibration window really is, what it does when the link goes quiet. That belongs in docs/Spektrum Smart ESC.md, which carries the measurements already, so the file keeps a line or two of reason where the code alone would not give it and the rest is gone. Two headers got the same treatment, where a single #define had arrived with a paragraph. Two statements that had gone stale went with them, including one calling the throttle channel index unverified, which a bench has since verified. The per-ESC channel array was 32 wide because the control frame's mask is 32 bits, but the driver writes two of those channels: the throttle, and the reverse channel, which esc_srxl2_reverse_channel caps at 9. The other 22 entries were 44 bytes per ESC that no configuration could address, 176 across the four the driver allows, reserved on every H7 and AT32 board whether or not an SRXL2 ESC is configured. And a line-ending-only change to an unrelated target, which had ridden in on a merge, is dropped. --- src/main/io/motor_srxl2.c | 517 ++++++-------------- src/main/target/SITL/target.h | 10 +- src/main/target/SYNERDUINOH7/CMakeLists.txt | 2 +- src/main/target/common.h | 18 +- 4 files changed, 146 insertions(+), 401 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 283a581bbeb..40ff56825cf 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -16,19 +16,14 @@ */ /* - * SRXL2 bus master for Spektrum Smart ESCs ("Smart Throttle"). + * SRXL2 bus master for Spektrum Smart ESCs ("Smart Throttle"), following + * "Specification for Spektrum SRXL2" Rev K (https://github.com/SpektrumRC/SRXL2). + * Packet structures come from rx/srxl2_types.h, shared with the receiver side. * - * Wire format and session handling follow "Specification for Spektrum SRXL2", - * Rev K (https://github.com/SpektrumRC/SRXL2). Packet structures are the ones - * INAV already carries in rx/srxl2_types.h for the receiver side, so both ends - * of the protocol share a single definition. - * - * The specification describes the bus; it says nothing about any particular ESC. - * Everything an individual ESC decides for itself - which channel it reads as - * throttle, how an auxiliary channel arms reverse, whether it offers 400000 baud - * - is deliberately confined to the named constants and the reverse-channel - * setter below, so the places that need confirming against real hardware are - * countable rather than scattered. + * The specification describes the bus and says nothing about any particular ESC. + * What an ESC decides for itself is kept to the named constants and the + * reverse-channel setter below, so what needs confirming against real hardware + * is countable. docs/Spektrum Smart ESC.md has the bench measurements. */ #include @@ -76,188 +71,84 @@ #define SRXL2_ESC_ID_FIRST 0x40 #define SRXL2_ESC_ID_LAST 0x4F -/* - * Our own device ID: flight controller type, unit ID 1. - * - * Not unit 0. Specification 7.1.1: a device whose lower nibble is 0 announces - * itself with an unprompted handshake at startup. That behaviour belongs to the - * slave, and a bus master that announced itself would both collide with the - * ESC's own announcements and break the silence the next comment describes. - */ +// Flight controller type, unit ID 1. Not unit 0: specification 7.1.1 has a device whose +// lower nibble is 0 announce itself unprompted, which is the slave's behaviour, and a +// master doing it would collide with the ESC's own announcements #define SRXL2_OUR_DEVICE_ID 0x31 -/* - * The bus arbitrates who is master by device ID, lowest wins: a device that sees - * a handshake from a lower ID stands down. We never implement that side of it, - * because this port is a dedicated link to an ESC rather than a shared bus, and - * an ESC at 0x40 cannot outrank 0x31. Worth knowing before anyone wires a - * Spektrum receiver onto the same pin, where it would be master at 0x21 and this - * driver would be wrong to keep polling. - */ +// The bus picks its master by lowest device ID and we never implement standing down, since +// this port is a dedicated link to one ESC, which cannot outrank us from 0x40. It would be +// wrong on a bus shared with a Spektrum receiver, which is master at 0x21 -/* - * Control Data commands. - * - * The protocol also has a failsafe channel-data command, 0x01, which a receiver - * sends when its RF link is gone so each device applies its own failsafe. This - * driver never sends it, and that is deliberate. - * - * On this bus the master is the flight controller and there is no RF link: the - * link is a wire. INAV owns failsafe, and it handles it by substituting channel - * values and continuing to fly - LAND and RTH actively command the motors all the - * way down. Telling the ESC the link had failed would hand throttle authority to - * the ESC's own behaviour in the middle of INAV's landing, which is the opposite - * of helpful. - * - * What does protect against this wire dying is the ESC's own receive timeout, - * which needs no cooperation from us: if the flight controller stops sending, the - * ESC falls back on its own, and that is the case where its failsafe is the right - * authority. - */ +// The protocol's failsafe channel-data command, 0x01, is deliberately never sent: INAV owns +// failsafe and keeps commanding the motors all the way down, so handing throttle authority +// to the ESC mid-landing would be the opposite of helpful. A dead wire is covered by the +// ESC's own receive timeout, which needs nothing from us #define SRXL2_CMD_CHANNEL_DATA 0x00 /* Reply ID 0x00 means "no reply wanted" (specification 7.1.1). */ #define SRXL2_REPLY_NONE 0x00 -/* - * The frame carries the throttle, and the reverse channel when one is - * configured. Nothing else: the ESC reads CH1 for throttle and the channel its - * own Thrust Rev. parameter names, and has no use for the rest. - * - * An earlier version sent a block of eight, on the strength of a bench session - * where one channel gave 0.0 % and eight gave 30 % - read at the time as "an - * Avian will not arm from a single-channel frame". That reading was confounded: - * the two runs also asked for telemetry at different rates, and the rate was - * what mattered. Rerun with the request rate held fixed, masks of one, two - * adjacent, two spread, four and eight channels all gave the same 14.0 % and the - * same 6610 rpm from the same 1250 us command; rerun asking on every frame, all - * five gave 0.0 %, the single-channel mask included. See SRXL2_TELEM_REQUEST_MIN - * for what that rate does. - * - * Two channels rather than eight is twelve bytes a frame less on a wire the - * telemetry reply has to share. - */ +// The frame carries the throttle and, when configured, the reverse channel. Nothing else: +// the ESC reads CH1 and the channel its own Thrust Rev. parameter names. How many channels +// the mask holds makes no difference to the ESC, measured from one to eight; the telemetry +// request rate does, see SRXL2_TELEM_REQUEST_MIN /* X-Bus telemetry sensor IDs */ #define SRXL2_TELEM_SENSOR_ESC 0x20 -/* - * Specification 7.2.1: an ESC with unit ID 0 repeats an unprompted handshake - * every 50 ms for the first 200 ms after reset. Stay silent for slightly longer - * than that so the common case is discovered without contending with those - * announcements, then start polling - an ESC configured with a non-zero unit ID - * never announces itself and would otherwise never be found. - */ +// Specification 7.2.1: an ESC with unit ID 0 repeats an unprompted handshake every 50 ms +// for the first 200 ms after reset, so listen a little longer than that before polling. An +// ESC with a non-zero unit ID never announces itself and would otherwise never be found #define SRXL2_LISTEN_WINDOW_MS 250 #define SRXL2_HANDSHAKE_INTERVAL_MS 50 -/* - * Rate at which Control Data goes out once the link is up. - * - * Not the motor update rate. The specification has the master emit one Control - * Data packet at the rate RF frames arrive, which is tens of hertz, and an ESC - * is not expecting kilohertz. 115200 baud would not carry it either: an - * eighteen-byte frame is about 1.6 ms on the wire. - */ +// Rate at which Control Data goes out once the link is up, which is not the motor update +// rate: the specification has the master emit one packet per RF frame, tens of hertz. 115200 +// baud would not carry more anyway, an eighteen-byte frame being about 1.6 ms on the wire #define SRXL2_CONTROL_INTERVAL_MS 20 /* 50 Hz */ -/* - * Ask for telemetry on every Nth Control Data packet rather than on all of them. - * Telemetry is a reply, so requesting it every frame doubles bus occupancy and - * forces a half-duplex turnaround each time, for values that change slowly. - * Every fifth frame at 50 Hz asks ten times a second, which reaches the flight - * controller as about 1.1 ESC readings a second once the ESC's own sensor - * rotation is accounted for. - */ +// Ask for telemetry every Nth Control Data packet: a reply forces a half-duplex turnaround +// for values that change slowly. Every fifth frame at 50 Hz asks ten times a second, which +// arrives as about 1.1 ESC readings a second once the ESC's own sensor rotation is counted #define SRXL2_TELEM_REQUEST_DEFAULT 5 -/* - * Never ask on every frame. An Avian keeps the link up when asked at 50 Hz - it - * answers, and its telemetry is correct - but it stops obeying the throttle and - * reports zero per cent from every stick position. Dropping back to every second - * frame restores it immediately, without a power cycle. Measured repeatedly on an - * Avian 70A, against a fixed 1250 us command: every 2nd, 3rd and 4th frame all - * gave 14 % and about 6600 rpm, every frame gave nothing at all. - */ +// Never ask on every frame. An Avian asked at 50 Hz answers, and its telemetry is correct, +// but it stops obeying the throttle and reports zero per cent from every stick position. +// Every 2nd, 3rd and 4th frame all behave, and dropping back recovers it without a power +// cycle. Measured on an Avian 70A against a fixed 1250 us command #define SRXL2_TELEM_REQUEST_MIN 2 -/* - * Declare the link dead if the ESC stops answering for this long. - * - * This is the reason the telemetry table stops at one request every five frames. - * An Avian never speaks unprompted once it is running, so the only thing that - * refreshes this timer is a telemetry reply, and a request rate slower than the - * timeout disconnects a healthy ESC on schedule. Measured, it answers about two - * requests in three, so the margin has to cover consecutive misses as well: - * every fifth frame is a request every 100 ms and a reply typically every 150, - * so three misses in a row still land inside this window. - * - * Losing the link is not a recoverable event on this hardware - a running Avian - * answers no discovery of any kind - so a timeout that can fire on a healthy - * link would mean a motor that stops and cannot be brought back without - * removing power. - */ +// Declare the link dead if the ESC stops answering for this long. A running Avian never +// speaks unprompted, so only a telemetry reply refreshes this timer and a request rate +// slower than the timeout would disconnect a healthy ESC on schedule. It answers about two +// requests in three, so the margin also has to cover consecutive misses. This is why the +// telemetry rate table stops at one request every five frames #define SRXL2_LINK_TIMEOUT_MS 500 -/* - * How long after the link comes up the ESC is actually ready to turn the motor. - * - * An Avian announces itself within 300 ms of gaining power, but it is not - * finished: it plays its startup tones for about five seconds afterwards, and - * only once the last of them - the one a pilot hears as "connected" - has - * sounded will it drive the motor. Timed on the bench against the tones - * themselves, with the announcement as the zero. - * - * Six and a half seconds puts the flight controller comfortably past that, and - * a motor commanded on that boundary spins up cleanly. Arming waits for it - * rather than for the handshake alone, which costs nothing where a person - * powers the aircraft and then arms it: nobody arms that soon after connecting - * the battery. - */ +// How long after the link comes up before the ESC will actually turn the motor. An Avian +// announces itself within 300 ms of gaining power but then plays its startup tones for about +// five seconds, and only drives the motor once the last of them has sounded. Timed on the +// bench against the tones. Arming waits for this rather than for the handshake alone, which +// costs nothing: nobody arms that soon after connecting the battery #define SRXL2_READY_DELAY_MS 6500 -/* - * Telemetry older than this is reported as stale rather than current. - * - * Generous compared with the link timeout on purpose. The ESC rotates its reply - * between three sensors and answers about two requests in three, so at the - * default rate its own readings arrive about once a second even though the link - * is being exercised ten times as often: a one-second window made a healthy - * sensor flicker between valid and absent. What notices an ESC that has actually - * stopped is SRXL2_LINK_TIMEOUT_MS, which invalidates the reading anyway. - */ +// Telemetry older than this reads as stale. Generous next to the link timeout on purpose: +// the ESC rotates its reply between three sensors, so its own readings arrive about once a +// second and a tighter window made a healthy sensor flicker. An ESC that has actually +// stopped is caught by SRXL2_LINK_TIMEOUT_MS, which invalidates the reading anyway #define SRXL2_TELEM_STALE_MS 3000 -/* - * Calibration phases end themselves. The high phase has to outlast a human - * reaching for a battery lead; the low phase only has to outlast the ESC's - * cell-count tones. Neither may persist, because one of them commands full - * throttle. - */ +// Calibration phases end themselves: the high one has to outlast a person reaching for a +// battery lead, the low one only the ESC's tones. Neither may persist, one is full throttle #define SRXL2_CAL_WAIT_TIMEOUT_MS 60000 /* time to walk over and plug the battery in */ #define SRXL2_CAL_MANUAL_TIMEOUT_MS 30000 -/* - * How long to keep holding full throttle after the ESC gains power, and then how - * long to hold minimum. - * - * These were three and five seconds, read off the published tone timings: the - * manual's window opens at the two short tones and lasts five, and the tones - * follow power-up by a second or so. Measured against an Avian 70 A, that is not - * enough. The same ESC, calibrated twice in a row from the same state: - * - * 3 s high, 5 s low - the ESC sounds its tones and stores nothing. Throttle - * still ignored below channel value 12220, saturated from 50820, so 41 % - * of the range does nothing and the stick reaches full power at 78 %. - * 4 s high, 7 s low - stored. Responds from 2687 and saturates at 64307, - * 94 % of the channel used, and the throttle it reports back tracks the - * throttle commanded to within a point across the whole range: 1050 us - * gives 5 %, 1500 gives 50 %, 2000 gives 100 %. - * - * So the extra second either side is what lands inside the window rather than - * on its edge. They cost nothing - the sequence runs once, on a bench, with the - * propeller off. - */ +// How long to hold full throttle after the ESC gains power, then how long to hold minimum. +// The published tone timings suggest three and five seconds; measured on an Avian 70A that +// lands on the edge of the window and stores nothing, while four and seven store every time. +// The extra second either side costs nothing: the sequence runs once, on a bench, with the +// propeller off. docs/Spektrum Smart ESC.md has the before and after figures #define SRXL2_CAL_SETTLE_MS 4000 /* Long enough for the cell-count tones and the closing long tone. */ @@ -267,38 +158,25 @@ #define SRXL2_CAL_HIGH_US 2000 #define SRXL2_CAL_LOW_US 1000 -/* - * Channel value scaling, the exact inverse of what rx/srxl2.c applies when it - * decodes channel data: us = 988 + (value >> 6). 1500 us therefore maps onto - * 0x8000, which the specification calls "Servo Center", and the shift leaves the - * low two bits clear as the specification requires. - * - * This deliberately does not reach the ends of the 0..65532 range: 1000 us lands - * on 768 and 2000 us on 64768, because a Spektrum receiver's full travel decodes - * to 988..2012 us rather than 1000..2000. That is the point - it makes us look - * like a receiver, which is what the ESC was calibrated against. - * - * Spektrum ESCs learn their endpoints from the signal during the ESC/Radio - * calibration in their manual, and INAV cannot perform that procedure: it wants - * full throttle present when the battery is connected, and INAV outputs - * mincommand while disarmed. So the calibration is done with a Spektrum - * transmitter, or left at the factory default, and the range the ESC remembers - * is a receiver's. Matching it is why this scaling is the right one. - * - * The cost is about 1.2 percent of travel at the top. That is the better half of - * the trade, because the alternative - stretching 1000..2000 us across the full - * range - moves the centre, and the centre is where an ESC in Reverse brake mode - * takes zero thrust. A slightly low maximum is a worse throttle curve; a - * misplaced centre is creeping thrust at neutral. - */ +// Channel value scaling, the exact inverse of what rx/srxl2.c applies when it decodes: +// us = 988 + (value >> 6). 1500 us lands on 0x8000, which the specification calls Servo +// Center, and the shift leaves the low two bits clear as it requires. It deliberately stops +// short of the ends of the 0..65532 range, because a Spektrum receiver's full travel decodes +// to 988..2012 us, and looking like a receiver is what an ESC's stored endpoints expect. +// The 1.2 % of travel this costs at the top beats stretching 1000..2000 over the full range, +// which would move the centre, and the centre is where Reverse brake mode takes no thrust #define SRXL2_PULSE_OFFSET_US 988 #define SRXL2_PULSE_SHIFT 6 #define SRXL2_VALUE_MAX 0xFFFC /* specification caps values here */ -/* Throttle is channel index 0 by Spektrum convention. Unverified against an - * actual ESC: this is one of the constants to confirm on a bench. */ +// Throttle is channel index 0 by Spektrum convention, confirmed against an Avian 70A #define SRXL2_CHANNEL_THROTTLE 0 +// How many channels the driver keeps values for. The wire's mask is 32 bits wide, but only +// the throttle and the reverse channel are ever written, and esc_srxl2_reverse_channel +// cannot name anything above 9 +#define SRXL2_CHANNEL_COUNT 10 + /*--------------------------------------------------------------------------- * State *-------------------------------------------------------------------------*/ @@ -311,14 +189,9 @@ typedef enum { SRXL2_RUNNING, } srxl2State_e; -/* - * One of these per ESC. - * - * Each instance is an independent bus with its own handshake, baud negotiation, - * receive framing and telemetry. Nothing is shared between them except our own - * device ID, which is allowed precisely because they are separate buses and - * never hear each other. - */ +// One per ESC. Each is an independent bus with its own handshake, baud negotiation, framing +// and telemetry; nothing is shared but our own device ID, which is safe precisely because +// the buses never hear each other typedef struct { serialPort_t *port; srxl2State_e state; @@ -339,7 +212,7 @@ typedef struct { uint8_t agreedBaudBits; bool baudSwitchPending; /* waiting for TX to drain */ - uint16_t channelValue[32]; + uint16_t channelValue[SRXL2_CHANNEL_COUNT]; uint32_t channelMask; uint8_t telemRequestCounter; @@ -354,11 +227,8 @@ static uint8_t escCount; /* ports successfully opened */ /* Shared, because these describe the aircraft rather than one bus. */ static uint8_t reverseChannel1Based = 7; /* Spektrum ship "Thrust Rev." on CH7 */ -/* - * Control frames between telemetry requests. The control interval is 20 ms, so a - * divisor of 5 asks at 10 Hz. Held as a divisor rather than a rate because that is - * what the transmit path actually counts. - */ +// Control frames between telemetry requests: at a 20 ms interval, 5 asks at 10 Hz. Held as +// a divisor rather than a rate because that is what the transmit path counts static uint8_t telemRequestEvery = SRXL2_TELEM_REQUEST_DEFAULT; static srxl2CalPhase_e calPhase = SRXL2_CAL_OFF; @@ -386,12 +256,8 @@ static inline uint16_t be16(const uint8_t *p) static bool srxl2ReverseChannelUsable(uint8_t channel1Based); -/* - * Which channels this frame carries: the throttle, plus the reverse channel when - * one is configured. Rebuilt rather than accumulated, so the frame does not - * change shape depending on what has been called since power-up - and so that - * clearing the reverse channel actually stops sending it. - */ +// Rebuilt rather than accumulated, so the frame does not change shape depending on what has +// been called since power-up, and so that clearing the reverse channel stops sending it static void srxl2BuildChannelMask(srxl2Esc_t *e) { uint32_t mask = 1u << SRXL2_CHANNEL_THROTTLE; @@ -407,11 +273,8 @@ static void srxl2SetState(srxl2Esc_t *e, srxl2State_e next) e->stateEnteredMs = millis(); } -/* - * Handshakes seen across every bus. The calibration watches this to notice an - * ESC gaining power, and with more than one ESC the first to speak is signal - * enough - they are all being powered from the same pack. - */ +// Handshakes seen across every bus. The calibration watches this to notice an ESC gaining +// power, and the first to speak is signal enough: they all share a pack static uint32_t srxl2TotalHandshakes(void) { uint32_t total = 0; @@ -421,20 +284,11 @@ static uint32_t srxl2TotalHandshakes(void) return total; } -/* - * Append the CRC and push the frame. buf[2] must already hold the frame length - * as the specification's framing requires. - * - * Returns false when the frame did not go out, which the caller has to care - * about for the one frame where it matters. A port that has backed up - a slow - * link, a stalled DMA - drops whatever does not fit, and for Control Data that - * is of no consequence, since another follows in 20 ms and the ESC tolerates - * 250 ms of silence. For the broadcast that moves the bus to a new rate it is - * the difference between a working link and a dead one: raise the rate having - * only believed that frame was sent, and the ESC is left behind at the old rate - * with no way back short of a power cycle. That failure has been seen on real - * hardware, and it is not recoverable in flight. - */ +// Append the CRC and push the frame; buf[2] must already hold the length. Returns false when +// the frame did not go out, which matters for one frame only: a dropped Control Data is of +// no consequence, another follows in 20 ms, but raising the baud rate while only believing +// the broadcast was sent leaves the ESC behind at the old rate with no way back short of a +// power cycle. Seen on real hardware, and not recoverable in flight static bool srxl2SendFrame(srxl2Esc_t *e, uint8_t *buf, uint8_t len) { if (!e->port || len < SRXL2_MIN_FRAME || len > SRXL2_MAX_FRAME) { @@ -548,23 +402,11 @@ static void srxl2HandleHandshake(srxl2Esc_t *e, const uint8_t *buf) return; } - /* - * The negotiation is entered once, and only from the states that are still - * looking for an ESC. Re-entering it on every handshake that arrives is a trap: - * our own finalise broadcasts, the slave answers the broadcast with a - * handshake, and if that answer restarts the sequence the two ping-pong - * handshakes indefinitely. Two ways that bites - - * - * - control data is sent on a timer reset on entering RUNNING, so a bus - * looping back through FINALISING never reaches the first control frame: - * the link looks established and the motor never turns; - * - each restart queues another broadcast, so on a slow or busy port the - * transmit buffer never drains and the bus never leaves FINALISING at all. - * - * A slave that genuinely reset is not missed by this. It comes back at 115200 - * while we are at 400000, so nothing it says is intelligible, and the link - * timeout drops us to POLLING at the low rate to find it again. - */ + // Enter the negotiation once, and only from the states still looking for an ESC. + // Restarting it on every handshake makes the two ends ping-pong: our broadcast draws a + // handshake, which would restart the sequence, and a bus looping through FINALISING + // never reaches its first control frame. A slave that genuinely reset is not missed, + // since it comes back at 115200 and the link timeout drops us to POLLING to find it if (e->state == SRXL2_FINALISING || e->state == SRXL2_RUNNING) { /* Still answer a running ESC, so it knows the master is there - but say * nothing mid-negotiation, where another broadcast is what causes the @@ -578,19 +420,13 @@ static void srxl2HandleHandshake(srxl2Esc_t *e, const uint8_t *buf) e->deviceId = src; e->baudSupported = f->payload.baudSupported; - /* Counted here rather than on every handshake frame, so it means "a - * negotiation started" and not merely "a handshake went past". The - * calibration uses it as the signal that an ESC has just gained power, and a - * running ESC answering our keepalive is not that. */ + // Counted here rather than on every handshake, so it means "a negotiation started". The + // calibration reads it as an ESC gaining power, which a keepalive answer is not e->statHandshakes++; - /* Answer the slave so it knows who the master is, then finalise. - * - * This also covers the ESC being powered after the flight controller, which is - * the normal case on a bench: the board comes up on USB and the ESC only boots - * when the battery goes in, long after the listen window closed. By then we are - * in POLLING, which is one of the states that accepts a handshake, so the ESC - * is still found. */ + // Answer the slave so it knows who the master is, then finalise. This also covers the + // ESC being powered after the flight controller, the normal case on a bench: by then we + // are in POLLING, which accepts a handshake, so the ESC is still found srxl2SendHandshake(e, e->deviceId, SRXL2_BAUD_BIT_400K); srxl2Finalise(e); } @@ -701,16 +537,9 @@ static void srxl2SendControlData(srxl2Esc_t *e) buf[n++] = SRXL2_CMD_CHANNEL_DATA; buf[n++] = replyId; - /* - * RSSI has to read as a healthy link, even though we are not an RF device. - * This is not a guess: Spektrum's own receiver code treats a received zero - * as loss of link - - * - * if (channelData->rssi == 0) { globalResult = RX_FRAME_FAILSAFE; } - * - * - so sending 0 would be telling the ESC that the link is gone on every - * frame. - */ + // RSSI has to read as a healthy link even though we are not an RF device: Spektrum's own + // receiver code takes a received zero as loss of link, so sending 0 would announce a + // failed link on every frame buf[n++] = 100; buf[n++] = 0; /* frameLosses low */ buf[n++] = 0; /* frameLosses high */ @@ -720,19 +549,11 @@ static void srxl2SendControlData(srxl2Esc_t *e) buf[n++] = (uint8_t)((e->channelMask >> 16) & 0xFF); buf[n++] = (uint8_t)((e->channelMask >> 24) & 0xFF); - /* - * A contiguous block, little-endian, lowest index first. The channels this - * driver has nothing to say on are filled, and filled at their **minimum**. - * - * Never at centre. That is reasonable for a surface ESC, where centre means - * stopped, and dangerous for an aircraft one, where 1500 us is half - * throttle: were the ESC to read throttle on an index other than the one - * expected, centred padding would spin the motor at half power while - * minimum padding leaves it idle. Wrong guess, safe outcome - which is the - * same reasoning that used to argue for sending nothing at all, before an - * ESC made it clear that sending nothing means never arming. - */ - for (uint8_t ch = 0; ch < 32; ch++) { + // A contiguous block, little-endian, lowest index first. Channels the driver has nothing + // to say on are padded at their minimum and never at centre: on an aircraft ESC 1500 us + // is half throttle, so if the ESC read throttle on an unexpected index, centred padding + // would spin the motor while minimum padding leaves it idle + for (uint8_t ch = 0; ch < SRXL2_CHANNEL_COUNT; ch++) { if (e->channelMask & (1u << ch)) { buf[n++] = (uint8_t)(e->channelValue[ch] & 0xFF); buf[n++] = (uint8_t)(e->channelValue[ch] >> 8); @@ -753,13 +574,9 @@ bool srxl2MotorInitialize(void) memset(esc, 0, sizeof(esc)); escCount = 0; - /* - * One ESC per port, so open every port that was assigned the function, up to - * the array size. The enumeration follows serialConfig's port order, which is - * UART order, so motor 1 is the lowest-numbered assigned UART, motor 2 the - * next, and so on. That is the only mapping available: nothing on an SRXL2 - * bus says which motor an ESC drives, so the wiring order has to carry it. - */ + // One ESC per port, so open every port assigned the function. The enumeration follows + // UART order, so motor 1 is the lowest-numbered assigned UART: nothing on an SRXL2 bus + // says which motor an ESC drives, so the wiring order has to carry it const serialPortConfig_t *portConfig = findSerialPortConfig(FUNCTION_ESC_SRXL2); while (portConfig && escCount < SRXL2_ESC_MAX_MOTORS) { @@ -774,7 +591,7 @@ bool srxl2MotorInitialize(void) /* Every channel starts at its lowest value rather than zero, so the * first frame after a handshake cannot be read as something * unexpected whichever index the ESC happens to care about. */ - for (uint8_t ch = 0; ch < 32; ch++) { + for (uint8_t ch = 0; ch < SRXL2_CHANNEL_COUNT; ch++) { e->channelValue[ch] = srxl2UsToValue(1000); } srxl2BuildChannelMask(e); @@ -806,25 +623,14 @@ void srxl2MotorUpdate(uint8_t index, uint16_t value) esc[index].channelValue[SRXL2_CHANNEL_THROTTLE] = srxl2UsToValue(value); } -/* - * Whether a configured reverse channel can actually be used. - * - * Zero means the model has no reverse. Anything that would land on the throttle - * channel is refused outright: srxl2MotorSetReverse() runs at task rate and - * writes its channel unconditionally, so a reverse channel aliased onto the - * throttle would overwrite the mixer's staged throttle several hundred times a - * second - holding the motor at idle whenever reverse was released, and - * commanding full throttle whenever it was armed. The setting's own range - * (0..9) cannot express "zero, or five to nine", so the check belongs here. - * - * The upper bound is the width of the channel array and of the wire's mask. - * Spektrum documents Smart ESC reverse as available on channels 5 to 9 only, but - * that is the ESC's restriction rather than the protocol's, so it is enforced by - * the setting and the Configurator rather than refused here. - */ +// Whether a configured reverse channel can be used. Zero means the model has no reverse, and +// anything landing on the throttle channel is refused: srxl2MotorSetReverse() writes its +// channel unconditionally at task rate, so an aliased one would overwrite the staged throttle +// hundreds of times a second. The setting's own range cannot express the hole, so the check +// belongs here; the upper bound is the width of the channel array static bool srxl2ReverseChannelUsable(uint8_t channel1Based) { - if (channel1Based == 0 || channel1Based > 32) { + if (channel1Based == 0 || channel1Based > SRXL2_CHANNEL_COUNT) { return false; } return (channel1Based - 1) != SRXL2_CHANNEL_THROTTLE; @@ -919,14 +725,9 @@ srxl2CalResult_e srxl2MotorCalibrationManual(srxl2CalPhase_e phase) return (calLastResult = common); } - /* - * The high phase commands full throttle, so it may not start against an ESC - * that already has power. The unattended sequence refuses this and the - * manual one did not, which left the more dangerous of the two - a person - * typing a command, rather than a wizard that walks them through it - - * without the guard. Boards that cannot sense the pack report it absent and - * are unaffected, which is the case this manual path exists for. - */ + // The high phase commands full throttle, so it may not start against an ESC that already + // has power. Boards that cannot sense the pack report it absent and are unaffected, which + // is the case this manual path exists for if (phase == SRXL2_CAL_HIGH_MANUAL && getBatteryState() != BATTERY_NOT_PRESENT) { return (calLastResult = SRXL2_CAL_REJECT_BATTERY_PRESENT); } @@ -968,14 +769,10 @@ static void srxl2CalProcess(timeMs_t now) switch (calPhase) { case SRXL2_CAL_WAIT_BATTERY: - /* - * What opens the window is the ESC *gaining* power, which is an event, so - * both signals have to be events too: the pack appearing, or a fresh - * handshake arriving on any bus. An earlier version tested - * escDeviceId != 0, which is persistent state left over from the last - * time the ESC was seen, so the wait was skipped outright on any board - * that had already talked to its ESC once. - */ + // What opens the window is the ESC gaining power, which is an event, so both signals + // have to be events too: the pack appearing, or a fresh handshake on any bus. A test + // on persistent state instead would skip the wait on any board that had already + // talked to its ESC once if (getBatteryState() != BATTERY_NOT_PRESENT || srxl2TotalHandshakes() != calHandshakeMark) { calPhase = SRXL2_CAL_SETTLE; calPhaseMs = now; @@ -1039,23 +836,13 @@ static void srxl2ProcessEsc(srxl2Esc_t *e, timeMs_t now) case SRXL2_POLLING: if (now - e->lastTxMs >= SRXL2_HANDSHAKE_INTERVAL_MS) { - /* Walk the whole ESC range rather than only the default ID. An ESC - * with a non-zero unit ID never announces itself, so polling is the - * only way it could be found - and polling one address was not - * that, it was polling the one address that does announce. - * - * Every bus is polled at the same ID, which is not a collision: each - * ESC is alone on its wire and hears only its own master. - * - * What finds an Avian is not this, though: it is the ESC's own - * announcement at power-up, caught here because polling is what we - * happen to be doing when the ESC boots. A running Avian answers - * none of this - measured, with the ESC alive and the bus otherwise - * quiet: 128 handshakes to 0x40, 128 broadcasts, 128 spread across - * 0x40..0x4F and 319 control frames asking for telemetry all drew - * exactly nothing. It announces six times in the 300 ms after reset - * and is mute from then on. A board that reboots under a powered ESC - * therefore never links, and no amount of asking changes that. */ + // Walk the whole ESC range rather than the default ID alone: an ESC with a + // non-zero unit ID never announces itself, and polling is the only way to find + // it. Polling the same ID on every bus is not a collision, each ESC hearing only + // its own master. What finds an Avian, though, is its own announcement at + // power-up: a running one answered none of 128 handshakes, 128 broadcasts and + // 319 telemetry requests, so a board that reboots under a powered ESC never + // links, and no amount of asking changes that srxl2SendHandshake(e, SRXL2_ESC_ID_FIRST + e->pollId, SRXL2_BAUD_BIT_400K); e->pollId++; if (SRXL2_ESC_ID_FIRST + e->pollId > SRXL2_ESC_ID_LAST) { @@ -1088,27 +875,13 @@ static void srxl2ProcessEsc(srxl2Esc_t *e, timeMs_t now) if (now - e->lastRxMs >= SRXL2_LINK_TIMEOUT_MS) { e->telemetry.valid = false; - /* - * Silence from the ESC is not a reason to stop commanding it. - * - * An Avian ignores telemetry requests entirely as far as the - * throttle is concerned: measured with the bench supply as witness, - * the motor held 0.30 A through three seconds and then ten seconds - * with no request sent at all, never faltering. What does stop it is - * the absence of control frames - the current falls to the ESC's own - * 58 mA within about half a second - and it picks the throttle back - * up by itself as soon as frames return, with no re-arm. - * - * So giving up here would cause the outage it is meant to detect, - * and on this hardware it would be permanent: a running Avian - * answers no discovery, so POLLING never finds it again. Keep - * driving, and let the telemetry go stale. - * - * The one case that does need the teardown is a slave that reset: - * it comes back at 115200 and announces for 300 ms, which cannot be - * heard from 400000. That only applies where the baud was raised - - * which no Avian tested allows, since they advertise 115200 only. - */ + // Silence from the ESC is not a reason to stop commanding it. An Avian holds + // throttle indefinitely with no telemetry request sent at all; what stops it is + // the absence of control frames, and it picks the throttle back up by itself + // when they return. Tearing the link down here would cause the outage it means + // to detect, and permanently, since a running Avian answers no discovery. The + // one case that does need it is a slave that reset, which comes back at 115200 + // and cannot be heard from 400000 if (e->agreedBaudBits != 0) { serialSetBaudRate(e->port, SRXL2_BAUD_LOW); e->baudSwitchPending = false; @@ -1138,13 +911,9 @@ void srxl2MotorProcess(void) srxl2ProcessEsc(&esc[i], now); } - /* - * The first two words carry a nibble per ESC, so a twin can be diagnosed - * without a debug channel per bus: which bus is stuck, and which has found - * its ESC. Both fit: the state enum is small, and ESC device IDs run - * 0x40..0x4F, so the low nibble identifies the unit. Bit 3 is set alongside - * it to distinguish unit 0 from "nothing found". - */ + // The first two words carry a nibble per ESC, so a twin can be diagnosed without a debug + // channel per bus. Both fit: the state enum is small, and ESC device IDs run 0x40..0x4F, + // so the low nibble identifies the unit, with bit 3 marking "found" against unit 0 uint16_t states = 0, ids = 0; uint32_t rxFrames = 0, crcErrors = 0; for (uint8_t i = 0; i < escCount; i++) { @@ -1167,18 +936,10 @@ uint8_t srxl2MotorCount(void) return escCount; } -/* - * Whether every ESC is not merely being driven, but answering. - * - * Deliberately stricter than "the driver is in RUNNING". Since a telemetry gap - * no longer tears the link down - it would cause the outage it detects - a board - * whose ESC has been unplugged stays in RUNNING, commanding a motor that is not - * there. That is right for a machine already flying and wrong for one about to - * arm, so this asks for a recent reply and the arming check asks this. - * - * Strict on the ground, forgiving in the air: two different questions that used - * to share one answer. - */ +// Whether every ESC is not merely being driven, but answering. Deliberately stricter than +// being in RUNNING: since a telemetry gap no longer tears the link down, a board whose ESC +// was unplugged stays in RUNNING and commands a motor that is not there. Right for a machine +// already flying, wrong for one about to arm, so the arming check asks this instead bool srxl2MotorIsConnected(void) { if (escCount == 0) { diff --git a/src/main/target/SITL/target.h b/src/main/target/SITL/target.h index 59d4149a354..8156bbc8f7a 100644 --- a/src/main/target/SITL/target.h +++ b/src/main/target/SITL/target.h @@ -55,13 +55,9 @@ #define SERIAL_PORT_COUNT 8 -/* - * Spektrum Smart ESC. Not enabled by the flash-size rule in common.h, which only - * covers H7 and AT32, so SITL opts in explicitly - which is also the mechanism any - * other target uses. Worth having here because SITL exposes each UART on a TCP - * port, so a simulated ESC can be attached to the real driver and the whole path - * exercised without hardware. - */ +// Opted in explicitly, as any target outside the H7 and AT32 rule in common.h would. Worth +// having here because SITL puts each UART on a TCP port, so a simulated ESC can be attached +// to the real driver and the whole path exercised without hardware #define USE_MOTOR_SRXL2 #define SITL_SERIAL_TASK_US (500) diff --git a/src/main/target/SYNERDUINOH7/CMakeLists.txt b/src/main/target/SYNERDUINOH7/CMakeLists.txt index cbe6094b6bf..2b73ef8d8ea 100644 --- a/src/main/target/SYNERDUINOH7/CMakeLists.txt +++ b/src/main/target/SYNERDUINOH7/CMakeLists.txt @@ -1 +1 @@ -target_stm32h743xi(SYNERDUINOH7 HSE_MHZ 25 SKIP_RELEASES) +target_stm32h743xi(SYNERDUINOH7 HSE_MHZ 25 SKIP_RELEASES) diff --git a/src/main/target/common.h b/src/main/target/common.h index 6a6eb90e687..bbf7e9062b8 100644 --- a/src/main/target/common.h +++ b/src/main/target/common.h @@ -74,21 +74,9 @@ #define USE_SERVO_SBUS #endif -/* - * Spektrum Smart ESC ("Smart Throttle") motor output. Needs a spare UART rather - * than a motor pad - one ESC per port, so a model with several motors needs that - * many ports. - * - * On by default only where flash is plentiful, because it costs about 3.5 KB and - * AIKONF7 for instance sits at 93.4% of its flash. Any other target can still - * have it with one line in its own target.h, which is included after this file: - * - * #define USE_MOTOR_SRXL2 - * - * Where the default should sit is a judgement call rather than a constraint - the - * alternative is enabling it everywhere and charging every tight target for a - * protocol it may never use. - */ +// Spektrum Smart ESC, one per spare UART. Default-on only where flash is plentiful: it +// costs about 3.5 KB and AIKONF7 for one already sits at 93.4 %. Any other target can opt +// in with a #define USE_MOTOR_SRXL2 in its own target.h, which is included after this file #if !defined(USE_MOTOR_SRXL2) && (defined(STM32H7) || defined(AT32F43x)) #define USE_MOTOR_SRXL2 #endif From d89b7e5f406646be5ffd79991c7f9665938c0632 Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:40:35 +0200 Subject: [PATCH 39/40] Substitute the calibration throttle instead of storing it The sequence wrote its endpoint into channelValue[], the same slot the mixer stages the throttle into. That is correct only while something else keeps rewriting that slot: when a phase ends, the last value written stays there until the mixer next runs, so the frame after an abort carries whatever the calibration had been commanding. On a flight controller the mixer runs every loop and covers it long before the next frame goes out, but the correctness of ending a sequence should not depend on how often another subsystem runs. The substitution now happens as the frame is built and touches nothing, so the phase ending is by itself enough for the next frame to carry the staged value again. Found in SITL, where nothing installs the motor writer and the stale full-throttle value therefore stayed on the wire after an abort. --- src/main/io/motor_srxl2.c | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/io/motor_srxl2.c b/src/main/io/motor_srxl2.c index 40ff56825cf..ba4fe48d5bb 100644 --- a/src/main/io/motor_srxl2.c +++ b/src/main/io/motor_srxl2.c @@ -519,16 +519,17 @@ static void srxl2SendControlData(srxl2Esc_t *e) replyId = e->deviceId; } - /* Calibration overrides the throttle here rather than at staging time, so no - * mixer path can quietly write over it between the two. Every ESC is - * calibrated at once: they share a battery, so they power up together, and - * the window the sequence aims at is the same window for all of them. */ + // Calibration substitutes the throttle as the frame is built, rather than staging it, + // so no mixer path can write over it between the two. It substitutes rather than stores, + // so that the phase ending is enough to restore what the mixer staged: writing into + // channelValue would leave full throttle there until the mixer happened to run again. + // Every ESC is calibrated at once, sharing a battery and therefore a power-up window. + uint16_t throttle = e->channelValue[SRXL2_CHANNEL_THROTTLE]; if (calPhase != SRXL2_CAL_OFF) { const bool high = (calPhase == SRXL2_CAL_WAIT_BATTERY) || (calPhase == SRXL2_CAL_SETTLE) || (calPhase == SRXL2_CAL_HIGH_MANUAL); - e->channelValue[SRXL2_CHANNEL_THROTTLE] = - srxl2UsToValue(high ? SRXL2_CAL_HIGH_US : SRXL2_CAL_LOW_US); + throttle = srxl2UsToValue(high ? SRXL2_CAL_HIGH_US : SRXL2_CAL_LOW_US); } buf[n++] = SRXL2_MAGIC; @@ -555,8 +556,9 @@ static void srxl2SendControlData(srxl2Esc_t *e) // would spin the motor while minimum padding leaves it idle for (uint8_t ch = 0; ch < SRXL2_CHANNEL_COUNT; ch++) { if (e->channelMask & (1u << ch)) { - buf[n++] = (uint8_t)(e->channelValue[ch] & 0xFF); - buf[n++] = (uint8_t)(e->channelValue[ch] >> 8); + const uint16_t v = (ch == SRXL2_CHANNEL_THROTTLE) ? throttle : e->channelValue[ch]; + buf[n++] = (uint8_t)(v & 0xFF); + buf[n++] = (uint8_t)(v >> 8); } } From 01aaf376ba49ff4f9617e8a9aa8ea14b3686468b Mon Sep 17 00:00:00 2001 From: MrScothh <167884257+MrScothh@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:37:31 +0200 Subject: [PATCH 40/40] Let a simulator arm without an SRXL2 ESC on the wire The arming check refuses to arm while the protocol is SRXL2 and no ESC has answered, because a model that arms with its ESC not listening looks armed and does not turn. That reasoning stops where a simulator is flying the aircraft: HITL disables the motor and servo outputs itself, so there is no command to lose and nothing left to protect, and the check only prevents the aircraft from being flown in the simulator at all. --- src/main/fc/fc_core.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/fc/fc_core.c b/src/main/fc/fc_core.c index 233f49035b9..7e4060da4a6 100644 --- a/src/main/fc/fc_core.c +++ b/src/main/fc/fc_core.c @@ -320,6 +320,11 @@ static void updateArmingStatus(void) */ escLinkMissing = (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2) && !srxl2MotorIsConnected(); +#ifdef USE_SIMULATOR + // Not while a simulator flies the aircraft: HITL disables the outputs itself, so + // there is no motor to command and nothing this would protect + escLinkMissing = escLinkMissing && !ARMING_FLAG(SIMULATOR_MODE_HITL); +#endif #endif if (!isHardwareHealthy() || escLinkMissing) { ENABLE_ARMING_FLAG(ARMING_DISABLED_HARDWARE_FAILURE);