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");
+}