diff --git a/docs/development/msp/README.md b/docs/development/msp/README.md index 42634e951db..ef9d5c14091 100644 --- a/docs/development/msp/README.md +++ b/docs/development/msp/README.md @@ -471,6 +471,12 @@ When the MSP JSON specification changes, bump `msp_messages.json` version: [8744 - MSP2_INAV_TIMESYNC](#msp2_inav_timesync) [8752 - MSP2_INAV_SET_AUX_RC](#msp2_inav_set_aux_rc) [8753 - MSP2_INAV_WIND](#msp2_inav_wind) +[8754 - MSP2_INAV_MAG_UNALIGNED](#msp2_inav_mag_unaligned) +[8755 - MSP2_INAV_ESC_SRXL2_STATUS](#msp2_inav_esc_srxl2_status) +[8756 - MSP2_INAV_ESC_SRXL2_CALIBRATE](#msp2_inav_esc_srxl2_calibrate) +[8757 - MSP2_INAV_ESC_DIRECTION](#msp2_inav_esc_direction) +[8758 - MSP2_INAV_SET_ESC_DIRECTION](#msp2_inav_set_esc_direction) +[8759 - MSP2_INAV_SET_ESC_DIRECTION_TEST](#msp2_inav_set_esc_direction_test) [12288 - MSP2_BETAFLIGHT_BIND](#msp2_betaflight_bind) [12289 - MSP2_RX_BIND](#msp2_rx_bind) @@ -5002,6 +5008,95 @@ When the MSP JSON specification changes, bump `msp_messages.json` version: **Notes:** Requires `USE_WIND_ESTIMATOR`; returns zeroes when wind estimation is not compiled in or not yet valid. Check bit 0 of `flags` before using speed/angle values. +## `MSP2_INAV_MAG_UNALIGNED (8754 / 0x2232)` +**Description:** Reads the unaligned magnetometer vector. + +**Request Payload:** **None** + +**Reply Payload:** +|Field|C Type|Size (Bytes)|Description| +|---|---|---|---| +| `magADCUnaligned` | `int16_t[3]` | 6 | X, Y and Z components, rounded to signed 16-bit values. | + +**Notes:** Returns rounded mag.magADCUnaligned values before board alignment. Returns three zeroes without USE_MAG. + +## `MSP2_INAV_ESC_SRXL2_STATUS (8755 / 0x2233)` +**Description:** Reads SRXL2 ESC calibration and connection status. + +**Request Payload:** **None** + +**Reply Payload:** +|Field|C Type|Size (Bytes)|Description| +|---|---|---|---| +| `phase` | `uint8_t` | 1 | Calibration phase (srxl2CalPhase_e). | +| `connected` | `uint8_t` | 1 | 1 when every opened ESC is connected; otherwise 0. | +| `lastResult` | `uint8_t` | 1 | Last calibration start result (srxl2CalResult_e). | +| `portCount` | `uint8_t` | 1 | Number of opened SRXL2 motor ports. | +| `motorCount` | `uint8_t` | 1 | Number of motors in the current mixer. | + +**Notes:** Requires USE_MOTOR_SRXL2. Counts report opened motor ports and the current mixer motor count, not hardware capacity. + +## `MSP2_INAV_ESC_SRXL2_CALIBRATE (8756 / 0x2234)` +**Description:** Controls SRXL2 ESC throttle-range calibration. + +**Request Payload:** +|Field|C Type|Size (Bytes)|Description| +|---|---|---|---| +| `phase` | `uint8_t` | 1 | Requested calibration action (srxl2CalPhase_e): 0, 1, 4 or 5. | + +**Reply Payload:** **None** + +**Notes:** Requires USE_MOTOR_SRXL2 and at least one request byte. Accepted commands: 0 abort, 1 automatic start, 4 manual high, 5 manual low. Driver safety checks can reject start requests; read MSP2_INAV_ESC_SRXL2_STATUS for the reason. Other command values return an MSP error. Remove propellers before calibration. + +## `MSP2_INAV_ESC_DIRECTION (8757 / 0x2235)` +**Description:** Reads per-motor DShot direction configuration and bounded test status. + +**Request Payload:** **None** + +**Reply Payload:** +|Field|C Type|Size (Bytes)|Description| +|---|---|---|---| +| `version` | `uint8_t` | 1 | Protocol version, currently 2. | +| `motorCount` | `uint8_t` | 1 | Available motor count; 0 when direction configuration is unavailable. | +| `phase` | `uint8_t` | 1 | 0 idle/cancelled, 1 stopping, 2 direction, 3 gap, 4 save, 5 settling, 6 sent. | +| `motor` | `uint8_t` | 1 | Zero-based direction motor index. | +| `reverse` | `uint8_t` | 1 | 0 normal, 1 reversed relative to ESC configuration and wiring. | +| `token` | `uint8_t` | 1 | Current or retained direction request token. | +| `simulated` | `uint8_t` | 1 | 1 for SITL simulation, otherwise 0; not an ESC acknowledgement. | +| `testMotor` | `uint8_t` | 1 | Zero-based test motor index. | +| `testActive` | `uint8_t` | 1 | 1 while the bounded test pulse is active, otherwise 0. | +| `testToken` | `uint8_t` | 1 | Current or retained test request token. | + +**Notes:** Provisional ID. Requires USE_DSHOT_DIRECTION_CONFIG. Version 2 reply is ten bytes. Phase 6 means commands sent, not acknowledged ESC storage. See esc-direction.md for timing and safety requirements. + +## `MSP2_INAV_SET_ESC_DIRECTION (8758 / 0x2236)` +**Description:** Starts direction configuration for one DShot motor. + +**Request Payload:** +|Field|C Type|Size (Bytes)|Description| +|---|---|---|---| +| `motor` | `uint8_t` | 1 | Zero-based motor index. | +| `reverse` | `uint8_t` | 1 | 0 normal or 1 reversed. | +| `token` | `uint8_t` | 1 | Nonzero request token; reuse only to retry the same request. | + +**Reply Payload:** **None** + +**Notes:** Provisional ID. Requires USE_DSHOT_DIRECTION_CONFIG and exactly three request bytes. Refused while armed, during ordinary motor tests, with invalid arguments or conflicting operations. A duplicate retained token cannot repeat the save. ACK means accepted, not confirmed ESC persistence. Remove propellers; see esc-direction.md. + +## `MSP2_INAV_SET_ESC_DIRECTION_TEST (8759 / 0x2237)` +**Description:** Starts or stops a bounded single-motor DShot test pulse. + +**Request Payload:** +|Field|C Type|Size (Bytes)|Description| +|---|---|---|---| +| `motor` | `uint8_t` | 1 | Zero-based motor index; ignored for stop. | +| `run` | `uint8_t` | 1 | 0 stop, 1 start; other values rejected. | +| `token` | `uint8_t` | 1 | Nonzero start token; ignored for stop. Retained after expiry or cancellation. | + +**Reply Payload:** **None** + +**Notes:** Provisional ID. Requires USE_DSHOT_DIRECTION_CONFIG and exactly three request bytes. Run=1 requests DShot120 for at most 1.5 seconds and is refused while armed, during ordinary motor tests or conflicting operations. Run=0 stops unconditionally, ignoring motor and token. Duplicate tokens cannot extend or restart a pulse. Remove propellers; see esc-direction.md. + ## `MSP2_BETAFLIGHT_BIND (12288 / 0x3000)` **Description:** Initiates the receiver binding procedure for supported serial protocols (CRSF, SRXL2). diff --git a/docs/development/msp/esc-direction.md b/docs/development/msp/esc-direction.md new file mode 100644 index 00000000000..99ed919e7d2 --- /dev/null +++ b/docs/development/msp/esc-direction.md @@ -0,0 +1,110 @@ +# Per-motor ESC direction configuration + +Paired firmware/Configurator change for `maintenance-10.x`. Betaflight's Motor +Direction Wizard inspired the workflow; no wizard source was copied. + +## Scope + +The Outputs tab shows the integrated dialog only for INAV multirotor/tricopter +platforms (`FC.isMultirotor()`), not based on motor count. Airplanes, including +multi-engine models, rovers, boats and unsupported platforms keep the whole panel +hidden and do not start direction polling. + +The wizard initializes the selected ESC to Normal, offers a bounded hold-to-test +pulse, reverse and next-motor confirmation. Individual mode changes nothing on +selection; Normal/Reverse are explicit writes. The Quad X diagram uses the existing +INAV mixer image and motor-rule numbering. Other mixers use numbered controls. + +## Protocol and ESC storage + +Provisional MSP2 codes require maintainer agreement: + +- `0x2235` status, ten U8 fields: version=2, available motor count, direction phase, + motor index, reverse (0/1), direction token, simulated (0/1), test motor index, + test active (0/1), test token. Count=0 means unavailable. Direction phases are + 0 idle/cancelled, 1 stopping, 2 direction, 3 gap, 4 save, 5 settling, 6 sent. +- `0x2236` set direction: motor, reverse, nonzero token. Repeating the current + token does not repeat the operation, including after cancellation. +- `0x2237` test: motor, run (0/1), token. Run=1 requests DShot120 for at most + 1.5 seconds. Run=0 stops unconditionally. Repeated tokens cannot extend or + restart the pulse after stop, expiry or cancellation. + +The driver holds unselected motors at zero during the operation, first sends +zero for 1 second, then command 7/8 ten times at >=1ms intervals, waits 10ms, +sends SAVE_SETTINGS (12) ten times and waits >=35ms. Zero frames are suppressed +between repetitions so they cannot reset the ESC's repeat counter. + +Requires initialized/enabled STM32/AT32 DShot outputs, no reversible/3D mode and +compatible ESC firmware. RP2350 is unsupported. Direction/save support and +persistent storage cannot be read back; phase 6 means commands sent, not confirmed +ESC storage. Settings live in the ESC, not FC EEPROM. Normal/Reverse are relative +to ESC configuration and wiring, not absolute clockwise/counterclockwise. + +## Safety without changing existing arming behavior + +All propellers must be removed before powering ESCs and acknowledging the dialog. +Direction writes and tests are refused while armed or ordinary motor testing is +active. Only this operation's active phase temporarily owns the outputs. + +**No new arming flag or persistent arming lock is introduced.** The existing arming +rules remain unchanged. If the FC arms, the output driver cancels the operation +before preparing the next frame and returns control to the normal motor path. +Cancellation retains request tokens so delayed retries cannot restart an operation. +An interrupted direction/save sequence has an uncertain result and needs checking. + +Ordinary MSP motor-test writes are rejected only during an active direction +sequence or pulse; they work normally afterward. Reboot, EEPROM writes, reset and +passthrough are rejected during active operations because they interrupt the +sequence. Normal DShot/turtle-mode behavior resumes after completion/cancellation. + +Release, pointer cancellation/leave, keyboard release, blur, dialog close and tab +cleanup request pulse stop. Release racing with a queued start sends another stop +after that start completes. Firmware expiry does not depend on the UI or USB. + +## Validation and manual checks + +C tests cover sequence order/repetitions/timing, clock wrap, pulse expiry, explicit +stop, cancellation and token retention. Firmware builds: SITL, SPEEDYBEEF405V4, +IFLIGHT_BLITZ_ATF435. Configurator tests cover parser validity, freshness/safety +checks, tokens and simulated status. Native Electron/SITL checks cover wizard and +individual mode, release/timeout, unchanged ordinary motor-test availability and +platform visibility. + +SITL uses the same sequencer and publishes isolated test pulses in the simulator +motor-value range. It reports simulation explicitly and has no physical ESC storage. Hardware validation has not been performed. Before merging: + +1. Remove all propellers. Verify mapped output isolation and command timing at + DShot150/300/600, with burst and non-burst DMA, on STM32 and AT32. +2. Verify requests are refused while armed or during existing motor tests. + Arm during a direction sequence/pulse: verify cancellation and normal motor + output ownership without introducing any new arming restriction. +3. Release, close, disconnect USB and hold past the deadline; verify pulse stops. + Inject duplicate/delayed requests after cancellation: no restart or extra save. +4. Power-cycle ESCs and FC; verify selected direction persisted and other motors + are unchanged. Check normal motor testing and turtle mode afterward. +5. Check older firmware, disabled outputs, analog PWM, 3D, missing ESC power and + ESC firmware without direction/save support; do not claim confirmed storage. + +The protocol timings follow https://betaflight.com/docs/development/API/Dshot . +Upstream Configurator currently accepts 9.x only while the firmware development +branch now reports 10.0.0. Version-policy changes are deliberately outside this +feature PR. The local native validation package temporarily accepts 10.x; that +packaging-only override is not included in the PR source. + +## Review regression coverage + +Configurator probes capability once on mounting. Periodic status requests run only +while the dialog is open or an operation needs tracking; unsupported firmware stops +polling. Write acknowledgements have separate nullable state fields and never imply +ESC persistence. Queue drops and MSP errors are failures; unconditional stops retry +up to three times, including after cleanup. The firmware deadline remains the final +guarantee after a total connection loss. + +Quad diagrams are bundled directly, including reversed mixer direction, without +copying the asynchronously loaded Outputs preview. Dialog/map sizing is fluid and +text uses relative units; the icon close control has a localized accessible name. + +Firmware prepares configuration frames as an overlay without replacing cached +normal outputs. Stop/arming cancellation therefore cannot replay a previous test +value. Ordinary DShot commands remain queued and execute after configuration ends +or arming cancels it, including turtle-mode direction commands. diff --git a/docs/development/msp/msp_messages.json b/docs/development/msp/msp_messages.json index 97a8ab9f185..42ce63a2b9e 100644 --- a/docs/development/msp/msp_messages.json +++ b/docs/development/msp/msp_messages.json @@ -2,7 +2,7 @@ "version": { "major": 2, "minor": 1, - "patch": 1 + "patch": 2 }, "messages": { "MSP_API_VERSION": { @@ -11897,6 +11897,212 @@ "notes": "Requires `USE_WIND_ESTIMATOR`; returns zeroes when wind estimation is not compiled in or not yet valid. Check bit 0 of `flags` before using speed/angle values.", "description": "Retrieves the estimated horizontal wind speed and direction from the internal wind estimator." }, + "MSP2_INAV_MAG_UNALIGNED": { + "code": 8754, + "mspv": 2, + "request": null, + "reply": { + "payload": [ + { + "name": "magADCUnaligned", + "ctype": "int16_t", + "desc": "X, Y and Z components, rounded to signed 16-bit values.", + "units": "", + "array": true, + "array_size": 3 + } + ] + }, + "notes": "Returns rounded mag.magADCUnaligned values before board alignment. Returns three zeroes without USE_MAG.", + "description": "Reads the unaligned magnetometer vector." + }, + "MSP2_INAV_ESC_SRXL2_STATUS": { + "code": 8755, + "mspv": 2, + "request": null, + "reply": { + "payload": [ + { + "name": "phase", + "ctype": "uint8_t", + "desc": "Calibration phase (srxl2CalPhase_e).", + "units": "" + }, + { + "name": "connected", + "ctype": "uint8_t", + "desc": "1 when every opened ESC is connected; otherwise 0.", + "units": "" + }, + { + "name": "lastResult", + "ctype": "uint8_t", + "desc": "Last calibration start result (srxl2CalResult_e).", + "units": "" + }, + { + "name": "portCount", + "ctype": "uint8_t", + "desc": "Number of opened SRXL2 motor ports.", + "units": "" + }, + { + "name": "motorCount", + "ctype": "uint8_t", + "desc": "Number of motors in the current mixer.", + "units": "" + } + ] + }, + "notes": "Requires USE_MOTOR_SRXL2. Counts report opened motor ports and the current mixer motor count, not hardware capacity.", + "description": "Reads SRXL2 ESC calibration and connection status." + }, + "MSP2_INAV_ESC_SRXL2_CALIBRATE": { + "code": 8756, + "mspv": 2, + "request": { + "payload": [ + { + "name": "phase", + "ctype": "uint8_t", + "desc": "Requested calibration action (srxl2CalPhase_e): 0, 1, 4 or 5.", + "units": "" + } + ] + }, + "reply": null, + "notes": "Requires USE_MOTOR_SRXL2 and at least one request byte. Accepted commands: 0 abort, 1 automatic start, 4 manual high, 5 manual low. Driver safety checks can reject start requests; read MSP2_INAV_ESC_SRXL2_STATUS for the reason. Other command values return an MSP error. Remove propellers before calibration.", + "description": "Controls SRXL2 ESC throttle-range calibration." + }, + "MSP2_INAV_ESC_DIRECTION": { + "code": 8757, + "mspv": 2, + "request": null, + "reply": { + "payload": [ + { + "name": "version", + "ctype": "uint8_t", + "desc": "Protocol version, currently 2.", + "units": "" + }, + { + "name": "motorCount", + "ctype": "uint8_t", + "desc": "Available motor count; 0 when direction configuration is unavailable.", + "units": "" + }, + { + "name": "phase", + "ctype": "uint8_t", + "desc": "0 idle/cancelled, 1 stopping, 2 direction, 3 gap, 4 save, 5 settling, 6 sent.", + "units": "" + }, + { + "name": "motor", + "ctype": "uint8_t", + "desc": "Zero-based direction motor index.", + "units": "" + }, + { + "name": "reverse", + "ctype": "uint8_t", + "desc": "0 normal, 1 reversed relative to ESC configuration and wiring.", + "units": "" + }, + { + "name": "token", + "ctype": "uint8_t", + "desc": "Current or retained direction request token.", + "units": "" + }, + { + "name": "simulated", + "ctype": "uint8_t", + "desc": "1 for SITL simulation, otherwise 0; not an ESC acknowledgement.", + "units": "" + }, + { + "name": "testMotor", + "ctype": "uint8_t", + "desc": "Zero-based test motor index.", + "units": "" + }, + { + "name": "testActive", + "ctype": "uint8_t", + "desc": "1 while the bounded test pulse is active, otherwise 0.", + "units": "" + }, + { + "name": "testToken", + "ctype": "uint8_t", + "desc": "Current or retained test request token.", + "units": "" + } + ] + }, + "notes": "Provisional ID. Requires USE_DSHOT_DIRECTION_CONFIG. Version 2 reply is ten bytes. Phase 6 means commands sent, not acknowledged ESC storage. See esc-direction.md for timing and safety requirements.", + "description": "Reads per-motor DShot direction configuration and bounded test status." + }, + "MSP2_INAV_SET_ESC_DIRECTION": { + "code": 8758, + "mspv": 2, + "request": { + "payload": [ + { + "name": "motor", + "ctype": "uint8_t", + "desc": "Zero-based motor index.", + "units": "" + }, + { + "name": "reverse", + "ctype": "uint8_t", + "desc": "0 normal or 1 reversed.", + "units": "" + }, + { + "name": "token", + "ctype": "uint8_t", + "desc": "Nonzero request token; reuse only to retry the same request.", + "units": "" + } + ] + }, + "reply": null, + "notes": "Provisional ID. Requires USE_DSHOT_DIRECTION_CONFIG and exactly three request bytes. Refused while armed, during ordinary motor tests, with invalid arguments or conflicting operations. A duplicate retained token cannot repeat the save. ACK means accepted, not confirmed ESC persistence. Remove propellers; see esc-direction.md.", + "description": "Starts direction configuration for one DShot motor." + }, + "MSP2_INAV_SET_ESC_DIRECTION_TEST": { + "code": 8759, + "mspv": 2, + "request": { + "payload": [ + { + "name": "motor", + "ctype": "uint8_t", + "desc": "Zero-based motor index; ignored for stop.", + "units": "" + }, + { + "name": "run", + "ctype": "uint8_t", + "desc": "0 stop, 1 start; other values rejected.", + "units": "" + }, + { + "name": "token", + "ctype": "uint8_t", + "desc": "Nonzero start token; ignored for stop. Retained after expiry or cancellation.", + "units": "" + } + ] + }, + "reply": null, + "notes": "Provisional ID. Requires USE_DSHOT_DIRECTION_CONFIG and exactly three request bytes. Run=1 requests DShot120 for at most 1.5 seconds and is refused while armed, during ordinary motor tests or conflicting operations. Run=0 stops unconditionally, ignoring motor and token. Duplicate tokens cannot extend or restart a pulse. Remove propellers; see esc-direction.md.", + "description": "Starts or stops a bounded single-motor DShot test pulse." + }, "MSP2_BETAFLIGHT_BIND": { "code": 12288, "mspv": 2, diff --git a/src/main/drivers/dshot_direction.h b/src/main/drivers/dshot_direction.h new file mode 100644 index 00000000000..9a1dc6e5d79 --- /dev/null +++ b/src/main/drivers/dshot_direction.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include + +// Configuration commands 7/8 change the stored direction; 20/21 are temporary +// overrides used by turtle mode. Never use the latter to configure an ESC. +typedef struct { + uint32_t lastUs; + uint8_t phase; // 0 idle, 1 stopping, 2 direction, 3 gap, 4 save, 5 settling, 6 sent + uint8_t repeats; + uint8_t motor; + uint8_t reverse; + uint8_t token; + uint32_t testStartedUs; + uint8_t testMotor; + uint8_t testToken; + bool testActive; +} dshotDirection_t; + +static inline void dshotDirectionBegin(dshotDirection_t *s, uint32_t now, uint8_t motor, uint8_t reverse, uint8_t token) +{ + const uint8_t previousTestToken = s->testToken; + *s = (dshotDirection_t){ .lastUs = now, .phase = 1, .motor = motor, .reverse = reverse, + .token = token, .testToken = previousTestToken }; +} + +// Called only when the driver can transmit a frame. All unselected motors stay +// at zero during the operation, including between commands. +static inline int16_t dshotDirectionFrame(dshotDirection_t *s, uint32_t now) +{ + const uint32_t elapsed = now - s->lastUs; + switch (s->phase) { + case 1: + if (elapsed >= 1000000) { + s->phase = 2; + s->lastUs = now; + } + break; + case 2: + case 4: + if (elapsed >= 1000) { + const uint8_t command = s->phase == 2 ? (s->reverse ? 8 : 7) : 12; + s->lastUs = now; + if (++s->repeats == 10) { + s->phase++; + s->repeats = 0; + } + return command; + } + return -1; // Do not interrupt consecutive command repetitions with zero frames. + case 3: + if (elapsed >= 10000) { + s->phase = 4; + s->lastUs = now; + } + break; + case 5: + if (elapsed >= 35000) { + s->phase = 6; + } + break; + default: + break; + } + return 0; +} + +// Requests and status reads must observe the deadline even before the next frame. +static inline bool dshotDirectionExpireTest(dshotDirection_t *s, uint32_t now) +{ + if (!s->testActive || (uint32_t)(now - s->testStartedUs) < 1500000) return false; + s->testActive = false; + return true; +} + +// A finite pulse, not a latched throttle command: loss of USB/UI cannot leave +// a motor running. Duplicate requests never extend or restart a pulse. +static inline bool dshotDirectionTestBegin(dshotDirection_t *s, uint32_t now, uint8_t motor, uint8_t token) +{ + dshotDirectionExpireTest(s, now); + if (token == 0 || (s->phase > 0 && s->phase < 6)) return false; + if (token == s->testToken) return motor == s->testMotor; + if (s->testActive) return false; + s->testStartedUs = now; + s->testMotor = motor; + s->testToken = token; + s->testActive = true; + return true; +} + +static inline uint16_t dshotDirectionTestFrame(dshotDirection_t *s, uint32_t now) +{ + dshotDirectionExpireTest(s, now); + // Fixed low test output (~3.6% of the DShot throttle range); no arbitrary + // throttle value is accepted over this configuration-only interface. + return s->testActive ? 120 : 0; +} + +static inline bool dshotDirectionBusy(const dshotDirection_t *s) +{ + return s->testActive || (s->phase > 0 && s->phase < 6); +} + +// Arming remains governed by the existing flight-controller rules. Never let a +// configuration operation replace armed motor output; retain tokens so retries +// cannot restart the cancelled operation after disarming. +static inline void dshotDirectionCancel(dshotDirection_t *s) +{ + s->phase = 0; + s->repeats = 0; + s->testActive = false; +} + +typedef struct { + bool active; + bool ready; + uint8_t motor; + uint16_t value; + bool telemetry; +} dshotDirectionOutput_t; + +// A frame overlay, never a replacement for the cached normal mixer outputs. +// Stop/arming therefore cannot retransmit a stale test throttle or save command. +static inline dshotDirectionOutput_t dshotDirectionOutput(dshotDirection_t *s, uint32_t now, bool armed) +{ + if (armed) dshotDirectionCancel(s); + dshotDirectionOutput_t output = { .active = dshotDirectionBusy(s), .ready = true }; + if (!output.active) return output; + const uint16_t testValue = dshotDirectionTestFrame(s, now); + const int16_t command = dshotDirectionFrame(s, now); + output.ready = command >= 0; + output.motor = testValue ? s->testMotor : s->motor; + output.value = testValue ? testValue : (command > 0 ? command : 0); + output.telemetry = command > 0; + return output; +} + +static inline uint16_t dshotDirectionMotorValue(const dshotDirectionOutput_t *output, uint8_t motor, uint16_t normalValue) +{ + return output->active ? (motor == output->motor ? output->value : 0) : normalValue; +} diff --git a/src/main/drivers/pwm_output.c b/src/main/drivers/pwm_output.c index 2f3e0bb814d..ad4200a77a3 100644 --- a/src/main/drivers/pwm_output.c +++ b/src/main/drivers/pwm_output.c @@ -537,7 +537,60 @@ void pwmRequestMotorTelemetry(int motorIndex) } #ifdef USE_DSHOT +static dshotDirection_t directionConfig; +static dshotDirectionOutput_t directionOutput; + +const dshotDirection_t *pwmDshotDirectionStatus(void) +{ + dshotDirectionExpireTest(&directionConfig, micros()); + return &directionConfig; +} + +bool pwmDshotDirectionSupported(void) +{ + if (!isMotorProtocolDshot() || !pwmMotorsEnabled || !feature(FEATURE_PWM_OUTPUT_ENABLE) || feature(FEATURE_REVERSIBLE_MOTORS) || !getMotorCount()) { + return false; + } + for (uint8_t i = 0; i < getMotorCount(); i++) { + if (!motors[i].pwmPort || !motors[i].pwmPort->configured) { + return false; + } + } + return true; +} + +bool pwmDshotDirectionBegin(uint8_t motor, uint8_t reverse, uint8_t token) +{ + if (!pwmDshotDirectionSupported() || areMotorsRunning() || motor >= getMotorCount() || reverse > 1 || token == 0 || directionConfig.testActive) { + return false; + } + // A retried MSP write must not restart the sequence or rewrite ESC flash. + if (directionConfig.token && token == directionConfig.token) { + return motor == directionConfig.motor && reverse == directionConfig.reverse; + } + if ((directionConfig.phase && directionConfig.phase != 6) || currentExecutingCommand.remainingRepeats || !circularBufferIsEmpty(&commandsCircularBuffer)) { + return false; + } + dshotDirectionBegin(&directionConfig, micros(), motor, reverse, token); + return true; +} + +bool pwmDshotDirectionTest(uint8_t motor, uint8_t run, uint8_t token) +{ + if (run == 0) { + directionConfig.testActive = false; + return true; + } + if (run != 1 || !pwmDshotDirectionSupported() || areMotorsRunning() || motor >= getMotorCount() || currentExecutingCommand.remainingRepeats || !circularBufferIsEmpty(&commandsCircularBuffer)) { + return false; + } + if (!dshotDirectionTestBegin(&directionConfig, micros(), motor, token)) return false; + return true; +} + void sendDShotCommand(dshotCommands_e cmd) { + // Preserve normal requests, particularly turtle-mode direction commands. + // Arming cancels configuration before the scheduler drains this queue. circularBufferPushElement(&commandsCircularBuffer, (uint8_t *) &cmd); } @@ -562,9 +615,34 @@ static int getDShotCommandRepeats(dshotCommands_e cmd) { return repeats; } -static bool executeDShotCommands(void){ +// Keep the configuration-only sequencer out of the ITCM-resident scheduler +// when LTO inlines the normal motor-output path. +static bool NOINLINE executeDShotDirectionFrame(timeUs_t tNow) +{ + directionOutput = dshotDirectionOutput(&directionConfig, tNow, false); + return directionOutput.ready; +} + +static uint16_t NOINLINE prepareMotorDshotPacket(uint8_t index) +{ + const uint16_t value = dshotDirectionMotorValue(&directionOutput, index, motors[index].value); + const bool telemetry = directionOutput.active ? directionOutput.telemetry && index == directionOutput.motor : motors[index].requestTelemetry; + return prepareDshotPacket(value, telemetry); +} + +// LTO must not pull command sequencing into the ITCM-resident scheduler. +// Keep the normal and configuration command paths in flash on F7/H7. +static bool NOINLINE executeDShotCommands(void){ timeUs_t tNow = micros(); + directionOutput.active = false; + + if (ARMING_FLAG(ARMED) && dshotDirectionBusy(&directionConfig)) { + dshotDirectionCancel(&directionConfig); + } + if (dshotDirectionBusy(&directionConfig)) { + return executeDShotDirectionFrame(tNow); + } if(currentExecutingCommand.remainingRepeats == 0) { const int isTherePendingCommands = !circularBufferIsEmpty(&commandsCircularBuffer); @@ -626,7 +704,7 @@ void pwmCompleteMotorUpdate(void) { #ifdef USE_DSHOT_DMAR for (int index = 0; index < motorCount; index++) { if (motors[index].pwmPort && motors[index].pwmPort->configured) { - uint16_t packet = prepareDshotPacket(motors[index].value, motors[index].requestTelemetry); + uint16_t packet = prepareMotorDshotPacket(index); loadDmaBufferDshotStride(&motors[index].pwmPort->dmaBurstBuffer[motors[index].pwmPort->tch->timHw->channelIndex], 4, packet); motors[index].requestTelemetry = false; } @@ -640,7 +718,7 @@ void pwmCompleteMotorUpdate(void) { // Generate DMA buffers for (int index = 0; index < motorCount; index++) { if (motors[index].pwmPort && motors[index].pwmPort->configured) { - uint16_t packet = prepareDshotPacket(motors[index].value, motors[index].requestTelemetry); + uint16_t packet = prepareMotorDshotPacket(index); loadDmaBufferDshot(motors[index].pwmPort->dmaBuffer, packet); timerPWMPrepareDMA(motors[index].pwmPort->tch, DSHOT_DMA_BUFFER_SIZE); motors[index].requestTelemetry = false; diff --git a/src/main/drivers/pwm_output.h b/src/main/drivers/pwm_output.h index 0aba4b142ef..e1a7003e622 100644 --- a/src/main/drivers/pwm_output.h +++ b/src/main/drivers/pwm_output.h @@ -64,4 +64,16 @@ bool beeperPwmInit(ioTag_t tag, uint16_t frequency); void sendDShotCommand(dshotCommands_e cmd); void initDShotCommands(void); +#if (defined(USE_DSHOT) && !defined(RP2350)) || defined(SITL_BUILD) +#define USE_DSHOT_DIRECTION_CONFIG +#include "drivers/dshot_direction.h" +const dshotDirection_t *pwmDshotDirectionStatus(void); +bool pwmDshotDirectionBegin(uint8_t motor, uint8_t reverse, uint8_t token); +bool pwmDshotDirectionSupported(void); +bool pwmDshotDirectionTest(uint8_t motor, uint8_t run, uint8_t token); +#ifdef SITL_BUILD +void sitlDshotDirectionUpdate(void); +#endif +#endif + uint32_t getEscUpdateFrequency(void); \ No newline at end of file diff --git a/src/main/fc/fc_msp.c b/src/main/fc/fc_msp.c index d00984f2d85..3153a886303 100644 --- a/src/main/fc/fc_msp.c +++ b/src/main/fc/fc_msp.c @@ -1695,6 +1695,27 @@ static bool mspFcProcessOutCommand(uint16_t cmdMSP, sbuf_t *dst, mspPostProcessF #endif break; +#ifdef USE_DSHOT_DIRECTION_CONFIG + case MSP2_INAV_ESC_DIRECTION: { + const dshotDirection_t *s = pwmDshotDirectionStatus(); + sbufWriteU8(dst, 2); // Includes the bounded motor-test capability. + sbufWriteU8(dst, pwmDshotDirectionSupported() ? getMotorCount() : 0); + sbufWriteU8(dst, s->phase); + sbufWriteU8(dst, s->motor); + sbufWriteU8(dst, s->reverse); + sbufWriteU8(dst, s->token); +#ifdef SITL_BUILD + sbufWriteU8(dst, 1); // Explicit simulated-output capability, never an ESC acknowledgement. +#else + sbufWriteU8(dst, 0); +#endif + sbufWriteU8(dst, s->testMotor); + sbufWriteU8(dst, s->testActive); + sbufWriteU8(dst, s->testToken); + break; + } +#endif + #ifdef USE_MOTOR_SRXL2 case MSP2_INAV_ESC_SRXL2_STATUS: sbufWriteU8(dst, srxl2MotorCalibrationPhase()); @@ -2551,7 +2572,31 @@ static mspResult_e mspFcProcessInCommand(uint16_t cmdMSP, sbuf_t *src) return MSP_RESULT_ERROR; break; +#ifdef USE_DSHOT_DIRECTION_CONFIG + case MSP2_INAV_SET_ESC_DIRECTION_TEST: { + if (dataSize != 3) return MSP_RESULT_ERROR; + const uint8_t motor = sbufReadU8(src); + const uint8_t run = sbufReadU8(src); + const uint8_t token = sbufReadU8(src); + return pwmDshotDirectionTest(motor, run, token) ? MSP_RESULT_ACK : MSP_RESULT_ERROR; + } + case MSP2_INAV_SET_ESC_DIRECTION: { + if (dataSize != 3) { + return MSP_RESULT_ERROR; + } + const uint8_t motor = sbufReadU8(src); + const uint8_t reverse = sbufReadU8(src); + const uint8_t token = sbufReadU8(src); + return pwmDshotDirectionBegin(motor, reverse, token) ? MSP_RESULT_ACK : MSP_RESULT_ERROR; + } +#endif + case MSP_SET_MOTOR: +#ifdef USE_DSHOT_DIRECTION_CONFIG + if (dshotDirectionBusy(pwmDshotDirectionStatus())) { + return MSP_RESULT_ERROR; + } +#endif if (dataSize >= 8 * sizeof(uint16_t)) { for (int i = 0; i < 8; i++) { const int16_t disarmed = sbufReadU16(src); @@ -5214,6 +5259,14 @@ mspResult_e mspFcProcessCommand(mspPacket_t *cmd, mspPacket_t *reply, mspPostPro // initialize reply by default reply->cmd = cmd->cmd; +#ifdef USE_DSHOT_DIRECTION_CONFIG + const uint8_t directionPhase = pwmDshotDirectionStatus()->phase; + // Flash writes, reboot and passthrough interrupt consecutive ESC commands. + if (((directionPhase > 0 && directionPhase < 6) || pwmDshotDirectionStatus()->testActive) && + (cmdMSP == MSP_REBOOT || cmdMSP == MSP_EEPROM_WRITE || cmdMSP == MSP_RESET_CONF || cmdMSP == MSP_SET_PASSTHROUGH)) { + ret = MSP_RESULT_ERROR; + } else +#endif if (MSP2_IS_SENSOR_MESSAGE(cmdMSP)) { ret = mspProcessSensorCommand(cmdMSP, src); } else if (mspFcProcessOutCommand(cmdMSP, dst, mspPostProcessFn)) { diff --git a/src/main/flight/mixer.c b/src/main/flight/mixer.c index bd5778b71be..fc036a6631f 100644 --- a/src/main/flight/mixer.c +++ b/src/main/flight/mixer.c @@ -597,6 +597,8 @@ void FAST_CODE writeMotors(void) pwmWriteMotor(i, motorValue); } +#else + sitlDshotDirectionUpdate(); #endif } diff --git a/src/main/msp/msp_protocol_v2_inav.h b/src/main/msp/msp_protocol_v2_inav.h index eadd294a0ab..661e11ebb87 100755 --- a/src/main/msp/msp_protocol_v2_inav.h +++ b/src/main/msp/msp_protocol_v2_inav.h @@ -163,5 +163,10 @@ // 0x2232 was taken by MSP2_INAV_MAG_UNALIGNED upstream while this branch was // out of tree, so these moved up rather than the other way round: a released // command keeps its number, an unmerged one has no claim on it. +// Experimental allocation; coordinate with maintainers before merging. +#define MSP2_INAV_SET_ESC_DIRECTION_TEST 0x2237 +#define MSP2_INAV_ESC_DIRECTION 0x2235 +#define MSP2_INAV_SET_ESC_DIRECTION 0x2236 + #define MSP2_INAV_ESC_SRXL2_STATUS 0x2233 #define MSP2_INAV_ESC_SRXL2_CALIBRATE 0x2234 diff --git a/src/main/target/SITL/target.c b/src/main/target/SITL/target.c index 201977813d0..05a2d050b67 100644 --- a/src/main/target/SITL/target.c +++ b/src/main/target/SITL/target.c @@ -45,10 +45,14 @@ #include "target.h" #include "fc/runtime_config.h" +#include "fc/config.h" +#include "config/feature.h" #include "common/utils.h" #include "scheduler/scheduler.h" #include "drivers/system.h" #include "drivers/pwm_mapping.h" +#include "drivers/pwm_output.h" +#include "flight/mixer.h" #include "drivers/timer.h" #include "drivers/serial.h" #include "drivers/serial_tcp.h" @@ -582,3 +586,65 @@ char *prettyPrintAddress(struct sockaddr* p, char *outbuf, size_t buflen) } return NULL; } + +// Exercise the same command sequencer through real MSP in the built-in demo. +// SITL has no physical ESCs; it explicitly advertises simulated output. +static dshotDirection_t sitlDirectionConfig; + +const dshotDirection_t *pwmDshotDirectionStatus(void) +{ + if (dshotDirectionExpireTest(&sitlDirectionConfig, micros()) && !ARMING_FLAG(ARMED)) { + for (uint8_t i = 0; i < getMotorCount(); i++) motor[i] = motor_disarmed[i]; + } + return &sitlDirectionConfig; +} + +bool pwmDshotDirectionSupported(void) +{ + return getMotorCount() > 0 && !feature(FEATURE_REVERSIBLE_MOTORS); +} + +bool pwmDshotDirectionBegin(uint8_t motor, uint8_t reverse, uint8_t token) +{ + if (!pwmDshotDirectionSupported() || areMotorsRunning() || motor >= getMotorCount() || reverse > 1 || token == 0 || sitlDirectionConfig.testActive) { + return false; + } + if (sitlDirectionConfig.token && token == sitlDirectionConfig.token) { + return motor == sitlDirectionConfig.motor && reverse == sitlDirectionConfig.reverse; + } + if (sitlDirectionConfig.phase && sitlDirectionConfig.phase != 6) { + return false; + } + dshotDirectionBegin(&sitlDirectionConfig, micros(), motor, reverse, token); + return true; +} + +bool pwmDshotDirectionTest(uint8_t motorIndex, uint8_t run, uint8_t token) +{ + if (run == 0) { + if (sitlDirectionConfig.testActive && !ARMING_FLAG(ARMED)) { + for (uint8_t i = 0; i < getMotorCount(); i++) motor[i] = motor_disarmed[i]; + } + sitlDirectionConfig.testActive = false; + return true; + } + if (run != 1 || !pwmDshotDirectionSupported() || areMotorsRunning() || motorIndex >= getMotorCount()) { + return false; + } + if (!dshotDirectionTestBegin(&sitlDirectionConfig, micros(), motorIndex, token)) return false; + return true; +} + +void sitlDshotDirectionUpdate(void) +{ + const dshotDirectionOutput_t output = dshotDirectionOutput(&sitlDirectionConfig, micros(), ARMING_FLAG(ARMED)); + if (!output.active) return; // Keep normal mixer values, including armed output. + for (uint8_t i = 0; i < getMotorCount(); i++) { + // Simulator backends consume PWM-range motor[], not DShot values. + const uint16_t value = dshotDirectionMotorValue(&output, i, 0); + motor[i] = 1000 + (value >= 48 ? (value - 48) * 1000 / (2047 - 48) : 0); + } + if (output.ready && output.telemetry) { + fprintf(stderr, "[ESC DEMO] motor=%u command=%u (simulated, no hardware)\n", output.motor + 1, output.value); + } +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 4a659d0bc2f..4a959a202d3 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -14,3 +14,9 @@ include(cmake/gtest.cmake) enable_testing() include(GoogleTest) add_subdirectory(unit) + +add_executable(dshot_direction_test dshot_direction_test.c) +target_include_directories(dshot_direction_test PRIVATE ../main) +target_compile_options(dshot_direction_test PRIVATE -UNDEBUG) +add_test(NAME dshot_direction_test COMMAND dshot_direction_test) +add_dependencies(check dshot_direction_test) diff --git a/src/test/dshot_direction_test.c b/src/test/dshot_direction_test.c new file mode 100644 index 00000000000..cc699a1e9cd --- /dev/null +++ b/src/test/dshot_direction_test.c @@ -0,0 +1,142 @@ +#include +#include +#include "drivers/dshot_direction.h" + +static void sequence(uint32_t start, uint8_t reverse, uint32_t step) +{ + dshotDirection_t s = {0}; + assert(dshotDirectionFrame(&s, start) == 0); + dshotDirectionBegin(&s, start, 2, reverse, 3); + unsigned direction = 0, save = 0; + uint32_t lastDirection = 0, lastSave = 0; + for (uint32_t elapsed = 0; elapsed < 2000000; elapsed += step) { + int16_t frame = dshotDirectionFrame(&s, start + elapsed); + assert(frame == -1 || frame == 0 || frame == (reverse ? 8 : 7) || frame == 12); + if (frame == (reverse ? 8 : 7)) { + assert(elapsed >= 1000000); + assert(save == 0); + if (direction) assert(elapsed - lastDirection >= 1000); + lastDirection = elapsed; + direction++; + } + if (frame == 12) { + assert(direction == 10); + assert(elapsed - lastDirection >= 10000); + if (save) assert(elapsed - lastSave >= 1000); + lastSave = elapsed; + save++; + } + if (frame == 0) { + // No zero frame may break a group of repeated commands. + assert(direction == 0 || direction == 10); + assert(save == 0 || save == 10); + } + if (s.phase == 6) { + assert(elapsed - lastSave >= 35000); + } + } + assert(direction == 10 && save == 10 && s.phase == 6); + assert(s.motor == 2 && s.reverse == reverse && s.token == 3); + assert(dshotDirectionFrame(&s, start + 3000000) == 0); +} +static void pulse(uint32_t start) +{ + dshotDirection_t s = {0}; + assert(!dshotDirectionTestBegin(&s, start, 0, 0)); + assert(dshotDirectionTestBegin(&s, start, 2, 1)); + assert(dshotDirectionBusy(&s) && s.testActive); + assert(dshotDirectionTestFrame(&s, start) == 120); + assert(!dshotDirectionTestBegin(&s, start + 1000, 1, 2)); + assert(dshotDirectionTestBegin(&s, start + 1000000, 2, 1)); + assert(dshotDirectionTestFrame(&s, start + 1499999) == 120); + assert(dshotDirectionTestFrame(&s, start + 1500000) == 0); + assert(dshotDirectionTestBegin(&s, start + 1600000, 2, 1)); + assert(!s.testActive); // Retry cannot restart an expired pulse. + assert(dshotDirectionTestBegin(&s, start + 1700000, 1, 2)); + s.testActive = false; // Explicit release/close. + assert(dshotDirectionTestBegin(&s, start + 1800000, 1, 2)); + assert(!s.testActive); + dshotDirectionBegin(&s, start + 1800000, 1, 1, 1); + assert(!dshotDirectionTestBegin(&s, start + 1800000, 1, 3)); + assert(s.testToken == 2); + dshotDirectionCancel(&s); + assert(!dshotDirectionBusy(&s)); + assert(dshotDirectionFrame(&s, start + 1900000) == 0); + assert(dshotDirectionTestFrame(&s, start + 1900000) == 0); + assert(s.token == 1 && s.testToken == 2); + assert(dshotDirectionTestBegin(&s, start + 2000000, 0, 3)); + dshotDirectionCancel(&s); + assert(!dshotDirectionBusy(&s)); + assert(dshotDirectionTestBegin(&s, start + 2100000, 0, 3)); + assert(!s.testActive); // A delayed retry cannot restart an aborted pulse. +} +static void pulseRequestAtDeadline(uint32_t start) +{ + dshotDirection_t s = {0}; + assert(dshotDirectionTestBegin(&s, start, 2, 1)); + assert(!dshotDirectionTestBegin(&s, start + 1499999, 1, 2)); + // No output update occurs between the requests. + assert(dshotDirectionTestBegin(&s, start + 1500000, 1, 2)); + assert(s.testActive && s.testMotor == 1 && s.testToken == 2); + assert(s.testStartedUs == start + 1500000); + // A duplicate token after expiry must acknowledge without restarting. + assert(dshotDirectionTestBegin(&s, start + 3000000, 1, 2)); + assert(!s.testActive && s.testStartedUs == start + 1500000); + assert(!dshotDirectionTestBegin(&s, start + 3000001, 2, 2)); + assert(dshotDirectionTestBegin(&s, start + 3000002, 2, 3)); + // Status uses the same expiry helper and must retain the last token. + assert(!dshotDirectionExpireTest(&s, start + 4500001)); + assert(dshotDirectionExpireTest(&s, start + 4500002)); + assert(!dshotDirectionBusy(&s) && s.testToken == 3 && s.testMotor == 2); + assert(!dshotDirectionExpireTest(&s, start + 4500003)); + assert(dshotDirectionTestBegin(&s, start + 4500004, 2, 3)); + assert(!s.testActive); +} + +static void outputOwnership(void) +{ + dshotDirection_t s = {0}; + const uint16_t normal[4] = {0, 0, 0, 0}; + assert(dshotDirectionTestBegin(&s, 0, 2, 1)); + dshotDirectionOutput_t output = dshotDirectionOutput(&s, 1, false); + assert(output.active && output.ready && !output.telemetry); + for (uint8_t i = 0; i < 4; i++) { + assert(dshotDirectionMotorValue(&output, i, normal[i]) == (i == 2 ? 120 : 0)); + assert(normal[i] == 0); // Preparing a frame never overwrites normal output. + } + s.testActive = false; + output = dshotDirectionOutput(&s, 2, false); + assert(!output.active && dshotDirectionMotorValue(&output, 2, normal[2]) == 0); + assert(dshotDirectionTestBegin(&s, 3, 2, 2)); + output = dshotDirectionOutput(&s, 4, true); + assert(!output.active && !dshotDirectionBusy(&s)); + assert(dshotDirectionMotorValue(&output, 2, 456) == 456); // Fresh armed mixer wins. + assert(dshotDirectionTestBegin(&s, 5, 2, 3)); + output = dshotDirectionOutput(&s, 1500005, false); + assert(output.active && output.value == 0); // Expiry emits a final zero frame. + output = dshotDirectionOutput(&s, 1500006, false); + assert(!output.active); + dshotDirectionBegin(&s, 2000000, 1, 1, 1); + output = dshotDirectionOutput(&s, 3000000, false); + output = dshotDirectionOutput(&s, 3001000, false); + assert(output.telemetry && dshotDirectionMotorValue(&output, 1, 0) == 8); + output = dshotDirectionOutput(&s, 3001001, false); + assert(!output.ready); // Suppress intervening zero frames during repeats. + output = dshotDirectionOutput(&s, 3001002, true); + assert(!output.active && dshotDirectionMotorValue(&output, 1, 321) == 321); +} + +int main(void) +{ + outputOwnership(); + for (uint8_t reverse = 0; reverse < 2; reverse++) { + sequence(0, reverse, 125); + sequence(UINT32_MAX - 500000, reverse, 500); + sequence(0, reverse, 2300); + } + pulseRequestAtDeadline(0); + pulseRequestAtDeadline(UINT32_MAX - 500000); + pulse(0); + pulse(UINT32_MAX - 500000); + puts("DShot direction: sequence, timing, repetition, completion and clock wrap tests passed"); +}