From 79162a4b4effc6837f8b9bf27337d25837528290 Mon Sep 17 00:00:00 2001 From: Raffi1202 Date: Tue, 22 Sep 2026 18:29:50 +0200 Subject: [PATCH 1/5] Add an integrated multirotor motor direction wizard --- docs/development/esc-direction.md | 92 +++++++++ js/escDirection.js | 27 +++ js/escDirectionPanel.js | 222 ++++++++++++++++++++++ js/fc.js | 3 + js/msp.js | 1 + js/msp/MSPCodes.js | 3 + js/msp/MSPHelper.js | 9 + locale/de/messages.json | 102 ++++++++++ locale/en/messages.json | 102 ++++++++++ src/css/tabs/motors.css | 61 ++++++ tabs/outputs.html | 42 ++++ tabs/outputs.js | 5 + tests/esc-direction.test.mjs | 47 +++++ tests/msp-parse-failure-recovery.test.mjs | 5 + 14 files changed, 721 insertions(+) create mode 100644 docs/development/esc-direction.md create mode 100644 js/escDirection.js create mode 100644 js/escDirectionPanel.js create mode 100644 tests/esc-direction.test.mjs diff --git a/docs/development/esc-direction.md b/docs/development/esc-direction.md new file mode 100644 index 000000000..ad52662cb --- /dev/null +++ b/docs/development/esc-direction.md @@ -0,0 +1,92 @@ +# 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, 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. diff --git a/js/escDirection.js b/js/escDirection.js new file mode 100644 index 000000000..b959e6401 --- /dev/null +++ b/js/escDirection.js @@ -0,0 +1,27 @@ +// DShot configuration has no direction readback. This is operation status only. +export function parseEscDirection(data) { + if (!data || data.byteLength === 0 || !((data.getUint8(0) === 1 && [6, 7].includes(data.byteLength)) || (data.getUint8(0) === 2 && data.byteLength === 10))) return null; + const [count, phase, motor, reverse, token] = [1, 2, 3, 4, 5].map(i => data.getUint8(i)); + if (phase > 6 || reverse > 1 || (phase > 0 && (motor >= count || token === 0))) return null; + if (data.byteLength >= 7 && data.getUint8(6) > 1) return null; + const supportsTest = data.getUint8(0) === 2; + const testMotor = supportsTest ? data.getUint8(7) : 0; + const testActive = supportsTest && data.getUint8(8) === 1; + const testToken = supportsTest ? data.getUint8(9) : 0; + if (supportsTest && (data.getUint8(8) > 1 || (testActive && (testMotor >= count || testToken === 0 || (phase > 0 && phase < 6))))) return null; + return { count, phase, motor, reverse, token, simulated: data.byteLength >= 7 && data.getUint8(6) === 1, + supportsTest, testMotor, testActive, testToken }; +} + +export function canSetEscDirection({ status, acknowledged, armed, testing, busy, fresh, motor }) { + return Boolean(status && status.count > 0 && (status.phase === 0 || status.phase === 6) + && !status.testActive && acknowledged && !armed && !testing && !busy && fresh + && Number.isInteger(motor) && motor >= 0 && motor < status.count); +} + +export function escDirectionPayload(status, motor, reverse) { + if (!status || !Number.isInteger(motor) || motor < 0 || motor >= status.count || (reverse !== 0 && reverse !== 1)) { + throw new Error('Invalid ESC direction request'); + } + return [motor, reverse, (status.token % 255) + 1]; +} diff --git a/js/escDirectionPanel.js b/js/escDirectionPanel.js new file mode 100644 index 000000000..e1b6066e0 --- /dev/null +++ b/js/escDirectionPanel.js @@ -0,0 +1,222 @@ +import { mixer } from './model'; +import { canSetEscDirection, escDirectionPayload } from './escDirection'; + +export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed }) { + // Use INAV's platform classification, including tricopters. Motor count alone + // would also expose this workflow on multi-engine airplanes. + const multirotor = FC.isMultirotor(); + $('#esc-direction-panel').prop('hidden', !multirotor); + if (!multirotor) return () => {}; + + let disposed = false, status = null, updatedAt = 0, pending = null, requesting = false; + let selected = 0, mode = null, held = false, starting = false, stopping = false, lastTestToken = 0; + const known = [], checked = []; + const dialog = document.getElementById('esc-direction-dialog'); + const t = (key, args) => i18n.getMessage(key, args); + const message = key => $('#esc-direction-status').text(t(key)); + FC.ESC_DIRECTION = null; + + function permitted() { + return !starting && !stopping && !held && canSetEscDirection({ status, + acknowledged: $('#esc-direction-ack').prop('checked'), armed: isArmed(), + testing: $('#motorsEnableTestMode').prop('checked'), busy: Boolean(pending), + fresh: Date.now() - updatedAt < 1500, motor: selected }); + } + function refresh() { + const supported = status?.supportsTest && status.count > 0; + $('#esc-direction-open').prop('disabled', !supported); + $('#esc-direction-availability').text(supported ? '' : t('escWizardUnsupported')); + const ready = supported && permitted(); + $('#esc-mode-wizard, #esc-mode-individual').prop('disabled', !ready); + $('#esc-direction-normal, #esc-direction-reverse, #esc-direction-toggle, #esc-wizard-next').prop('disabled', !ready); + // Do not disable the held control: it must retain its pointer/key release handler. + $('#esc-direction-test').prop('disabled', !held && !ready).toggleClass('running', starting || Boolean(status?.testActive)); + $('#esc-wizard-motors button').prop('disabled', !ready || mode === 'wizard'); + $('#esc-wizard-progress').text(mode === 'wizard' ? t('escWizardStep', [selected + 1, status?.count]) : t('escWizardIndividual')); + $('#esc-wizard-motor').text(t('escWizardSelected', [selected + 1])); + $('#esc-wizard-next').text(t(selected === status?.count - 1 ? 'escWizardFinish' : 'escWizardNext')); + $('#esc-direction-normal, #esc-direction-reverse').prop('hidden', mode !== 'individual'); + $('#esc-direction-toggle, #esc-wizard-next').prop('hidden', mode !== 'wizard'); + $('#esc-direction-toggle').prop('disabled', !ready || known[selected] === undefined); + $('#esc-direction-normal').attr('aria-pressed', String(known[selected] === 0)); + $('#esc-direction-reverse').attr('aria-pressed', String(known[selected] === 1)); + $('#esc-wizard-motors button').each(function () { + const motor = Number($(this).data('motor')); + $(this).toggleClass('selected', motor === selected).toggleClass('checked', Boolean(checked[motor])) + .attr('aria-pressed', String(motor === selected)); + }); + } + function drawMotors() { + const image = $('#motor-mixer-preview-img').attr('src'); + $('#esc-wizard-image').attr('src', image || ''); + const rules = FC.MOTOR_RULES.get(); + // INAV's existing Outputs diagram labels only Quad X; do not invent + // motor positions for custom/stacked mixers. Those get numbered controls. + const spatial = status.count === 4 && mixer.getById(FC.MIXER_CONFIG.appliedMixerPreset)?.image === 'quad_x' && rules.length >= 4; + $('#esc-wizard-map').toggleClass('numbered', !spatial); + const $motors = $('#esc-wizard-motors').empty(); + for (let motor = 0; motor < status.count; motor++) { + const $button = $(' + + + +

+
+ +
+

+
+
+ + +
+

+
+ +

+
+
+
+
diff --git a/tabs/outputs.js b/tabs/outputs.js index af587a979..02ce42b75 100644 --- a/tabs/outputs.js +++ b/tabs/outputs.js @@ -13,6 +13,7 @@ import features from './../js/feature_framework'; import { mixer, PLATFORM } from './../js/model'; import timeout from './../js/timeouts'; import interval from './../js/intervals'; +import { mountEscDirection } from '../js/escDirectionPanel'; /* Phase 0 of the firmware's calibration state machine, which is also how the * sequence is called off. Out here because cleanup() needs it too. */ @@ -110,6 +111,9 @@ outputsTab.initialize = function (callback) { process_motors(); process_servos(); processConfiguration(settingsPromise); + self.disposeEscDirection = mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, + isArmed: () => self.armed + }); finalize(); } @@ -1035,6 +1039,7 @@ outputsTab.initialize = function (callback) { }; outputsTab.cleanup = function (callback) { + this.disposeEscDirection?.(); /* * Leaving the tab takes the Abort button with it, so the sequence it would * have stopped is called off here instead. The firmware is holding full diff --git a/tests/esc-direction.test.mjs b/tests/esc-direction.test.mjs new file mode 100644 index 000000000..7457abbdc --- /dev/null +++ b/tests/esc-direction.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseEscDirection, canSetEscDirection, escDirectionPayload } from '../js/escDirection.js'; +const data = bytes => new DataView(Uint8Array.from(bytes).buffer); +const status = parseEscDirection(data([1, 4, 0, 0, 0, 0])); +const ready = { status, acknowledged: true, armed: false, testing: false, busy: false, fresh: true, motor: 2 }; +test('capability requires exact known response and valid operation fields', () => { + assert.equal(parseEscDirection(null), null); + for (const bytes of [[], [1], [2,4,0,0,0,0], [1,4,7,0,0,0], [1,4,2,4,0,1], [1,4,2,0,2,1], [1,4,2,0,0,0]]) { + assert.equal(parseEscDirection(data(bytes)), null); + } + assert.equal(status.count, 4); +}); +test('each safety gate independently blocks direction writes', () => { + assert.equal(canSetEscDirection(ready), true); + for (const override of [{ status: null }, { acknowledged: false }, { armed: true }, { testing: true }, + { busy: true }, { fresh: false }, { motor: -1 }, { motor: 4 }, { motor: 1.5 }, { status: { ...status, phase: 2 } }]) { + assert.equal(canSetEscDirection({ ...ready, ...override }), false, JSON.stringify(override)); + } +}); +test('motor is zero based, direction explicit, retry token advances and wraps', () => { + assert.deepEqual(escDirectionPayload(status, 2, 1), [2,1,1]); + assert.deepEqual(escDirectionPayload({ ...status, token: 255 }, 0, 0), [0,0,1]); + assert.throws(() => escDirectionPayload(status, 4, 0)); + assert.throws(() => escDirectionPayload(status, 0, 2)); +}); + +test('SITL is explicitly distinguished from real ESC output', () => { + assert.equal(parseEscDirection(data([1,4,0,0,0,0])).simulated, false); + assert.equal(parseEscDirection(data([1,4,0,0,0,0,0])).simulated, false); + assert.equal(parseEscDirection(data([1,4,0,0,0,0,1])).simulated, true); + assert.equal(parseEscDirection(data([1,4,0,0,0,0,2])), null); +}); + + +test('bounded pulse capability validates running motor, token and state', () => { + const active = parseEscDirection(data([2,4,6,0,1,1,1,2,1,3])); + assert.equal(active.supportsTest, true); + assert.equal(active.testActive, true); + assert.equal(active.testMotor, 2); + assert.equal(canSetEscDirection({...ready, status: active}), false); + for (const bytes of [[2,4,0,0,0,0,1,4,1,1], [2,4,0,0,0,0,1,0,2,1], + [2,4,0,0,0,0,1,0,1,0], [2,4,2,0,1,1,1,0,1,1]]) { + assert.equal(parseEscDirection(data(bytes)), null); + } + assert.equal(status.supportsTest, false); +}); diff --git a/tests/msp-parse-failure-recovery.test.mjs b/tests/msp-parse-failure-recovery.test.mjs index 2d5921dd1..db3d609db 100644 --- a/tests/msp-parse-failure-recovery.test.mjs +++ b/tests/msp-parse-failure-recovery.test.mjs @@ -116,7 +116,10 @@ const inertGeozoneUrl = dataModule(` export const GeozoneShapes = {}; `); +const realEscDirectionUrl = rewriteAndWrite('js/escDirection.js', [], 'esc-direction-generated'); + const realMspHelperUrl = rewriteAndWrite('js/msp/MSPHelper.js', [ + [/^import \{ parseEscDirection \} from '\.\.\/escDirection';$/m, `import { parseEscDirection } from '${realEscDirectionUrl}';`, 'import parseEscDirection'], [/^import semver from 'semver';$/m, `import semver from '${inertDefaultUrl}';`, "import semver"], [/^import '\.\/\.\.\/injected_methods';$/m, `import '${realInjectedMethodsUrl}';`, "import injected_methods"], [/^import GUI from '\.\/\.\.\/gui';$/m, `import GUI from '${guiStubUrl}';`, "import GUI"], @@ -416,6 +419,7 @@ test('reads, reboot and live commands are never blocked', () => { // stopMotors() stops a running motor test with this - refusing it would // strand the motors spinning. 'MSP_SET_MOTOR', + 'MSP2_INAV_SET_ESC_DIRECTION_TEST', 'MSP_SET_RAW_RC', // Only the user-selected preset index is sent, never parsed FC state. 'MSP2_SET_MZTC_PRESET', @@ -447,6 +451,7 @@ test('a write is recognised however its name spells SET', () => { // Codes named after SET that are reads, or writes carrying no FC-read data: const notGuarded = [ 'MSPV2_SETTING', 'MSP2_COMMON_SETTING_INFO', + 'MSP2_INAV_SET_ESC_DIRECTION_TEST', 'MSP_SET_REBOOT', 'MSP_SET_MOTOR', 'MSP_SET_RAW_RC', 'MSP_SET_RAW_GPS', 'MSP_SET_HEAD', 'MSP_SET_RTC', 'MSP_RESET_CONF', 'MSP_SET_RESET_CURR_PID', 'MSP_SELECT_SETTING', 'MSP_SET_BOX', 'MSP2_SET_MZTC_PRESET', From 4862b263191ceff78f305a4a17146bf310f963de Mon Sep 17 00:00:00 2001 From: Raffi1202 Date: Tue, 22 Sep 2026 23:20:32 +0200 Subject: [PATCH 2/5] Fix motor direction wizard lifecycle, command acknowledgements and layout --- docs/development/esc-direction.md | 22 ++++- js/escDirectionPanel.js | 66 +++++++++++-- js/fc.js | 4 + js/msp/MSPHelper.js | 4 + src/css/tabs/motors.css | 50 +++++----- tabs/outputs.html | 2 +- tabs/outputs.js | 2 +- tests/esc-direction-panel.test.mjs | 109 ++++++++++++++++++++++ tests/msp-parse-failure-recovery.test.mjs | 14 +++ 9 files changed, 234 insertions(+), 39 deletions(-) create mode 100644 tests/esc-direction-panel.test.mjs diff --git a/docs/development/esc-direction.md b/docs/development/esc-direction.md index ad52662cb..99ed919e7 100644 --- a/docs/development/esc-direction.md +++ b/docs/development/esc-direction.md @@ -70,8 +70,8 @@ checks, tokens and simulated status. Native Electron/SITL checks cover wizard an individual mode, release/timeout, unchanged ordinary motor-test availability and platform visibility. -SITL uses the same sequencer, reports simulation explicitly and has no physical -ESC storage. Hardware validation has not been performed. Before merging: +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. @@ -90,3 +90,21 @@ 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/js/escDirectionPanel.js b/js/escDirectionPanel.js index e1b6066e0..9654bb03e 100644 --- a/js/escDirectionPanel.js +++ b/js/escDirectionPanel.js @@ -1,7 +1,9 @@ import { mixer } from './model'; import { canSetEscDirection, escDirectionPayload } from './escDirection'; +import quadImage from '../resources/motor_order/quad_x.svg'; +import quadReverseImage from '../resources/motor_order/quad_x_reverse.svg'; -export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed }) { +export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed, isMotorDirectionInverted }) { // Use INAV's platform classification, including tricopters. Motor count alone // would also expose this workflow on multi-engine airplanes. const multirotor = FC.isMultirotor(); @@ -10,12 +12,28 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } let disposed = false, status = null, updatedAt = 0, pending = null, requesting = false; let selected = 0, mode = null, held = false, starting = false, stopping = false, lastTestToken = 0; + let polling = false, unsupported = false, stopPromise = null; const known = [], checked = []; const dialog = document.getElementById('esc-direction-dialog'); const t = (key, args) => i18n.getMessage(key, args); const message = key => $('#esc-direction-status').text(t(key)); FC.ESC_DIRECTION = null; + function updatePolling() { + const active = !disposed && !unsupported && Boolean(dialog.open || pending || starting || stopping || status?.testActive); + if (active === polling) return; + polling = active; + if (active) interval.add('esc_direction_poll', poll, 300); + else interval.remove('esc_direction_poll'); + } + async function write(code, payload, field) { + FC[field] = null; + const response = await MSP.promise(code, payload); + // Queue exhaustion resolves false; an MSP error also completes its callback. + // Neither is an acknowledgement, and neither confirms an ESC flash write. + if (!response || response.length !== 0 || FC[field] !== true) throw new Error('ESC command not acknowledged'); + } + function permitted() { return !starting && !stopping && !held && canSetEscDirection({ status, acknowledged: $('#esc-direction-ack').prop('checked'), armed: isArmed(), @@ -23,6 +41,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } fresh: Date.now() - updatedAt < 1500, motor: selected }); } function refresh() { + updatePolling(); const supported = status?.supportsTest && status.count > 0; $('#esc-direction-open').prop('disabled', !supported); $('#esc-direction-availability').text(supported ? '' : t('escWizardUnsupported')); @@ -47,12 +66,13 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } }); } function drawMotors() { - const image = $('#motor-mixer-preview-img').attr('src'); - $('#esc-wizard-image').attr('src', image || ''); const rules = FC.MOTOR_RULES.get(); // INAV's existing Outputs diagram labels only Quad X; do not invent // motor positions for custom/stacked mixers. Those get numbered controls. const spatial = status.count === 4 && mixer.getById(FC.MIXER_CONFIG.appliedMixerPreset)?.image === 'quad_x' && rules.length >= 4; + // Bundle the known diagram directly: Outputs loads its separate preview + // asynchronously, which may still show a placeholder when this opens. + $('#esc-wizard-image').attr('src', isMotorDirectionInverted() ? quadReverseImage : quadImage).prop('hidden', !spatial); $('#esc-wizard-map').toggleClass('numbered', !spatial); const $motors = $('#esc-wizard-motors').empty(); for (let motor = 0; motor < status.count; motor++) { @@ -83,6 +103,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } if (disposed) return; const previous = status; status = resp && [6, 7, 10].includes(resp.length) ? FC.ESC_DIRECTION : null; + unsupported = Boolean(resp && !status?.supportsTest); updatedAt = Date.now(); if (!status?.count) { refresh(); return; } $('#esc-direction-simulation').prop('hidden', !status.simulated); @@ -115,7 +136,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } known[selected] = undefined; message('escDirectionBusy'); refresh(); - MSP.promise(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION, operation.payload).then(() => { + write(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION, operation.payload, 'ESC_DIRECTION_WRITE_ACK').then(() => { if (disposed || pending !== operation) return; operation.writeReturned = true; poll(); @@ -128,10 +149,26 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } }); } function sendStop() { + if (stopPromise) return stopPromise; stopping = true; - return MSP.promise(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST, [255, 0, 0]).catch(() => { - if (!disposed) message('escDirectionUncertain'); - }).finally(() => { stopping = false; if (!disposed) { refresh(); poll(); } }); + refresh(); + // Bounded retries also survive dialog/tab cleanup. The firmware deadline + // remains the final stop guarantee if the connection is lost entirely. + stopPromise = (async () => { + for (let attempt = 0; attempt < 3; attempt++) { + try { + await write(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST, [255, 0, 0], 'ESC_DIRECTION_TEST_ACK'); + return; + } catch { + if (!disposed) message('escDirectionUncertain'); + } + } + })().finally(() => { + stopping = false; + stopPromise = null; + if (!disposed) { refresh(); poll(); } + }); + return stopPromise; } function stop() { if (!held && !starting && !status?.testActive) return; @@ -146,11 +183,16 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } lastTestToken = (Math.max(lastTestToken, status.testToken) % 255) + 1; message('escWizardRunning'); refresh(); - MSP.promise(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST, [selected, 1, lastTestToken]).then(() => { + write(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST, [selected, 1, lastTestToken], 'ESC_DIRECTION_TEST_ACK').then(async () => { starting = false; // Release/close may happen while the start is still queued. Stop // again after its acknowledgement, not just before it is delivered. - if (!held || disposed || !dialog.open) return sendStop(); + if (!held || disposed || !dialog.open) { + // A stop already in flight can precede the queued start. Wait for + // it, then issue a new stop after the start acknowledgement. + if (stopPromise) await stopPromise; + return sendStop(); + } poll(); }).catch(() => { starting = false; @@ -162,6 +204,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } function close() { stop(); dialog.close(); + refresh(); } function enter(nextMode) { if (!permitted()) return; @@ -181,6 +224,9 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } $('#esc-direction-status').empty(); refresh(); dialog.showModal(); + updatedAt = 0; + refresh(); + poll(); }); $('#esc-mode-wizard').on('click.escDirection', () => enter('wizard')); $('#esc-mode-individual').on('click.escDirection', () => enter('individual')); @@ -209,7 +255,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed } $('#esc-direction-close, #esc-wizard-done').on('click.escDirection', close); $(dialog).on('cancel.escDirection', event => { event.preventDefault(); close(); }); $('#esc-direction-ack, #motorsEnableTestMode').on('change.escDirection', refresh); - interval.add('esc_direction_poll', poll, 300, true); + poll(); // One capability probe; closed/unsupported dialogs generate no traffic. return () => { stop(); disposed = true; diff --git a/js/fc.js b/js/fc.js index d528a1d10..be5abe7e2 100644 --- a/js/fc.js +++ b/js/fc.js @@ -111,9 +111,13 @@ var FC = { return true; // Currently all platforms use D term }, ESC_DIRECTION: null, + ESC_DIRECTION_WRITE_ACK: null, + ESC_DIRECTION_TEST_ACK: null, resetState: function () { this.ESC_DIRECTION = null; + this.ESC_DIRECTION_WRITE_ACK = null; + this.ESC_DIRECTION_TEST_ACK = null; // MassZero thermal camera. MZTC_CONFIG mirrors the 15 byte // MSP2_MZTC_CONFIG payload. MZTC_STATUS stays null until the flight // controller answers MSP2_MZTC_STATUS. diff --git a/js/msp/MSPHelper.js b/js/msp/MSPHelper.js index c49a4d1be..ee770c1b6 100644 --- a/js/msp/MSPHelper.js +++ b/js/msp/MSPHelper.js @@ -1963,7 +1963,11 @@ var mspHelper = (function () { FC.ESC_DIRECTION = dataHandler.unsupported ? null : parseEscDirection(data); break; case MSPCodes.MSP2_INAV_SET_ESC_DIRECTION: + // An empty ACK accepts the request; it does not confirm ESC storage. + FC.ESC_DIRECTION_WRITE_ACK = !dataHandler.unsupported && data.byteLength === 0; + break; case MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST: + FC.ESC_DIRECTION_TEST_ACK = !dataHandler.unsupported && data.byteLength === 0; break; case MSPCodes.MSP2_INAV_ESC_SRXL2_STATUS: diff --git a/src/css/tabs/motors.css b/src/css/tabs/motors.css index 3d0d57457..5b7d7f46d 100644 --- a/src/css/tabs/motors.css +++ b/src/css/tabs/motors.css @@ -482,60 +482,60 @@ #esc-direction-panel[hidden] { display: none; } .esc-launcher { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .esc-launcher p { flex: 1; margin: 0; } -#esc-direction-panel button { cursor: pointer; padding: 10px 16px; border: 1px solid #cbd0d5; border-radius: 5px; background: #fff; color: #303842; font: 600 13px/1.4 "open_sans", sans-serif; transition: background-color .12s, border-color .12s; } +#esc-direction-panel button { cursor: pointer; padding: 10px 16px; border: 1px solid #cbd0d5; border-radius: 5px; background: #fff; color: #303842; font: 600 0.8125rem/1.4 "open_sans", sans-serif; transition: background-color .12s, border-color .12s; } #esc-direction-panel button:hover:not(:disabled) { background: #f0f3f5; border-color: #929ca6; } #esc-direction-panel button:disabled { color: #9299a0; cursor: default; } #esc-direction-panel button:focus-visible { outline: 3px solid #37a8db; outline-offset: 3px; } #esc-direction-panel #esc-direction-open { background: #ffcc00; border-color: #e2b600; color: #292e34; } -#esc-direction-dialog { --esc-ink: #303842; width: 720px; height: fit-content; min-height: 0; max-width: calc(100vw - 48px); max-height: calc(100vh - 48px); position: fixed; inset: 0; margin: auto; padding: 0; border: 1px solid #d3d7dc; border-radius: 8px; color: var(--esc-ink); background: #fff; box-shadow: 0 20px 70px #0005; overflow: auto; font: 13px/1.5 "open_sans", sans-serif; } +#esc-direction-dialog { --esc-ink: #303842; width: calc(100% - 3rem); height: fit-content; min-height: 0; max-width: 45rem; max-height: calc(100vh - 48px); position: fixed; inset: 0; margin: auto; padding: 0; border: 1px solid #d3d7dc; border-radius: 8px; color: var(--esc-ink); background: #fff; box-shadow: 0 20px 70px #0005; overflow: auto; font: 0.8125rem/1.5 "open_sans", sans-serif; } #esc-direction-dialog::backdrop { background: #14202d99; } #esc-direction-dialog [hidden] { display: none !important; } #esc-direction-dialog header { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; border-bottom: 1px solid #e4e7ea; background: #fafbfc; } -#esc-direction-dialog h2 { margin: 0; padding: 0; font-size: 19px; font-weight: 600; line-height: 1.4; color: var(--esc-ink); } -#esc-direction-dialog header #esc-direction-close { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; font: 24px/1 sans-serif; color: #78818a; border: 0; background: transparent; } +#esc-direction-dialog h2 { margin: 0; padding: 0; font-size: 1.1875rem; font-weight: 600; line-height: 1.4; color: var(--esc-ink); } +#esc-direction-dialog header #esc-direction-close { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; font: 1.5rem/1 sans-serif; color: #78818a; border: 0; background: transparent; } #esc-direction-dialog header #esc-direction-close:hover { background: #e9edf0; color: #303842; } .esc-wizard-body { padding: 20px 24px; } -#esc-direction-dialog p { font-size: 13px; line-height: 1.55; } -#esc-direction-dialog #esc-direction-simulation { margin: 0 0 20px; padding: 8px 12px; color: #34647a; background: #edf6fa; border: 1px solid #daebf3; border-radius: 4px; font-size: 12px; font-weight: normal; } +#esc-direction-dialog p { font-size: 0.8125rem; line-height: 1.55; } +#esc-direction-dialog #esc-direction-simulation { margin: 0 0 20px; padding: 8px 12px; color: #34647a; background: #edf6fa; border: 1px solid #daebf3; border-radius: 4px; font-size: 0.75rem; font-weight: normal; } #esc-direction-dialog .warning-box { padding: 14px 16px; margin: 0 0 16px; background: #fff7e6; color: #755516; border: 1px solid #efd8a5; border-left: 3px solid #d79b20; border-radius: 4px; font-weight: normal; } #esc-wizard-intro .checkbox { display: flex; gap: 9px; align-items: flex-start; margin: 0; } #esc-wizard-intro .checkbox input { flex: 0 0 auto; margin: 3px 0 0; accent-color: #303842; width: 16px; height: 16px; } -#esc-wizard-intro .checkbox label { padding: 0; margin: 0; font-size: 13px; line-height: 22px; } +#esc-wizard-intro .checkbox label { padding: 0; margin: 0; font-size: 0.8125rem; line-height: 1.7; } .esc-mode-options { display: flex; gap: 12px; margin: 22px 0 16px; } #esc-direction-panel .esc-mode-options button { position: relative; flex: 1; padding: 16px 18px; text-align: left; background: #fff; } #esc-direction-panel .esc-mode-options button:enabled { border-color: #87929b; } #esc-direction-panel #esc-mode-wizard:enabled { border-color: #d5ad00; background: #fffbea; } #esc-direction-panel .esc-mode-options button:disabled { border-color: #e1e5e8; background: #fafbfc; } -.esc-mode-options strong { display: block; font-size: 16px; font-weight: 600; margin-bottom: 5px; } -.esc-mode-options span { display: block; font-size: 12px; font-weight: normal; } -#esc-direction-dialog .esc-fine-print { margin: 8px 0 0; color: #77818b; font-size: 11px; line-height: 1.6; } -.esc-wizard-layout { display: grid; grid-template-columns: 280px 1fr; align-items: center; gap: 28px; } -#esc-wizard-map { width: 260px; height: 260px; position: relative; margin: auto; } -#esc-wizard-image { width: 100%; height: 100%; object-fit: contain; } +.esc-mode-options strong { display: block; font-size: 1.0rem; font-weight: 600; margin-bottom: 5px; } +.esc-mode-options span { display: block; font-size: 0.75rem; font-weight: normal; } +#esc-direction-dialog .esc-fine-print { margin: 8px 0 0; color: #77818b; font-size: 0.6875rem; line-height: 1.6; } +.esc-wizard-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); align-items: center; gap: 28px; } +#esc-wizard-map { width: 100%; max-width: 16.25rem; aspect-ratio: 1; position: relative; margin: auto; } +#esc-wizard-image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } #esc-wizard-motors { position: absolute; inset: 0; } -#esc-direction-panel #esc-wizard-motors button { position: absolute; transform: translate(-50%, -50%); width: 40px; height: 40px; padding: 0; border-radius: 50%; font-size: 17px; font-weight: 600; background: #fff; color: #46515d; border: 2px solid #98a3ad; opacity: 1; box-shadow: 0 1px 3px #0001; } +#esc-direction-panel #esc-wizard-motors button { position: absolute; transform: translate(-50%, -50%); width: 40px; height: 40px; padding: 0; border-radius: 50%; font-size: 1.0625rem; font-weight: 600; background: #fff; color: #46515d; border: 2px solid #98a3ad; opacity: 1; box-shadow: 0 1px 3px #0001; } #esc-direction-panel #esc-wizard-motors button.selected { background: #ffcc00; color: #292e34; border-color: #d6ad00; box-shadow: 0 0 0 4px #ffcc0029; } #esc-direction-panel #esc-wizard-motors button.checked:not(.selected) { border-color: #50a27a; background: #eaf6ef; color: #32724f; } -#esc-wizard-map.numbered #esc-wizard-image { opacity: .18; } +.esc-wizard-step { display: flex; flex-direction: column; min-width: 0; } +.esc-wizard-step button { white-space: normal; overflow-wrap: anywhere; } #esc-wizard-map.numbered #esc-wizard-motors { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; } #esc-direction-panel #esc-wizard-map.numbered button { position: static; transform: none; } -#esc-direction-dialog .esc-map-caption { margin: 10px 0 0; text-align: center; color: #77818b; font-size: 11px; } -#esc-direction-dialog #esc-wizard-progress { margin: 0 0 4px; font-size: 11px; color: #7d8790; letter-spacing: .04em; text-transform: uppercase; } -#esc-direction-dialog .esc-wizard-step h3 { font-size: 24px; font-weight: 600; line-height: 1.3; margin: 0 0 12px; color: var(--esc-ink); } +#esc-direction-dialog .esc-map-caption { margin: 10px 0 0; text-align: center; color: #77818b; font-size: 0.6875rem; } +#esc-direction-dialog #esc-wizard-progress { margin: 0 0 4px; font-size: 0.6875rem; color: #7d8790; letter-spacing: .04em; text-transform: uppercase; } +#esc-direction-dialog .esc-wizard-step h3 { font-size: 1.5rem; font-weight: 600; line-height: 1.3; margin: 0 0 12px; color: var(--esc-ink); } #esc-direction-dialog .esc-wizard-step > p:not(.esc-fine-print):not(#esc-wizard-progress) { margin: 0 0 16px; color: #5a6570; } -#esc-direction-panel #esc-direction-test { width: 100%; min-height: 42px; background: #303b45; color: #fff; border-color: #303b45; touch-action: none; user-select: none; } +#esc-direction-panel #esc-direction-test { min-height: 2.625rem; background: #303b45; color: #fff; border-color: #303b45; touch-action: none; user-select: none; } #esc-direction-panel #esc-direction-test:disabled { background: #e7ebee; border-color: #e7ebee; color: #9299a0; } #esc-direction-panel #esc-direction-test.running { background: #ba6333; border-color: #ba6333; color: #fff; } #esc-direction-panel .esc-direction-actions { display: flex; gap: 8px; margin: 20px 0 10px; } #esc-direction-panel .esc-direction-actions button { flex: 1; } -#esc-direction-panel #esc-wizard-next { width: 100%; min-height: 42px; background: #ffcc00; border-color: #e2b600; color: #292e34; } +#esc-direction-panel #esc-wizard-next { min-height: 2.625rem; background: #ffcc00; border-color: #e2b600; color: #292e34; } #esc-direction-panel #esc-wizard-next:disabled { background: #f7f2d8; border-color: #ede5be; color: #a49d7b; } #esc-direction-normal[aria-pressed="true"], #esc-direction-reverse[aria-pressed="true"] { box-shadow: inset 0 0 0 1px #37a8db; } -#esc-direction-dialog #esc-direction-status { min-height: 18px; margin: 20px 0 0; padding: 12px 0 0; border-top: 1px solid #e8ebee; color: #68737e; font-size: 12px; } +#esc-direction-dialog #esc-direction-status { min-height: 18px; margin: 20px 0 0; padding: 12px 0 0; border-top: 1px solid #e8ebee; color: #68737e; font-size: 0.75rem; } #esc-direction-status:empty { display: none; } #esc-direction-dialog footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 12px 24px; background: #fafbfc; border-top: 1px solid #e4e7ea; color: #77818b; } -#esc-direction-dialog footer span { font-size: 11px; line-height: 1.5; max-width: 380px; } -#esc-direction-dialog footer button { padding: 8px 12px; white-space: nowrap; font-size: 12px; } -@media (max-width: 760px) { #esc-direction-dialog { max-width: calc(100vw - 24px); } .esc-wizard-layout { grid-template-columns: 210px 1fr; gap: 20px; } #esc-wizard-map { width: 200px; height: 200px; } } -@media (max-width: 560px) { .esc-wizard-layout { grid-template-columns: 1fr; } .esc-mode-options { flex-direction: column; } } +#esc-direction-dialog footer span { font-size: 0.6875rem; line-height: 1.5; max-width: 24rem; } +#esc-direction-dialog footer button { padding: 8px 12px; white-space: normal; font-size: 0.75rem; } +@media (max-width: 35rem) { .esc-wizard-layout { grid-template-columns: 1fr; } .esc-mode-options { flex-direction: column; } } @media (prefers-reduced-motion: reduce) { #esc-direction-panel button { transition: none; } } diff --git a/tabs/outputs.html b/tabs/outputs.html index 85073c092..a978b4d43 100644 --- a/tabs/outputs.html +++ b/tabs/outputs.html @@ -218,7 +218,7 @@
-

+

diff --git a/tabs/outputs.js b/tabs/outputs.js index 02ce42b75..ed3df54a6 100644 --- a/tabs/outputs.js +++ b/tabs/outputs.js @@ -112,7 +112,7 @@ outputsTab.initialize = function (callback) { process_servos(); processConfiguration(settingsPromise); self.disposeEscDirection = mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, - isArmed: () => self.armed + isArmed: () => self.armed, isMotorDirectionInverted: () => self.motorDirectionInverted }); finalize(); diff --git a/tests/esc-direction-panel.test.mjs b/tests/esc-direction-panel.test.mjs new file mode 100644 index 000000000..0069cf175 --- /dev/null +++ b/tests/esc-direction-panel.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import * as direction from '../js/escDirection.js'; +import MSPCodes from '../js/msp/MSPCodes.js'; + +// Run the real controller with deterministic MSP responses and a minimal DOM +// adapter. No controller statements are changed; only module bindings differ. +const source = readFileSync(new URL('../js/escDirectionPanel.js', import.meta.url), 'utf8') + .replace(/^import .*;$/gm, '').replace('export function mountEscDirection', 'function mountEscDirection'); +const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; +function harness({ multirotor = true, inverted = false } = {}) { + const nodes = new Map(), timers = new Map(), calls = []; + const node = id => { + if (!nodes.has(id)) nodes.set(id, { props: {}, attrs: {}, events: {}, children: [] }); + return nodes.get(id); + }; + const dialog = node('#esc-direction-dialog'); + dialog.showModal = () => { dialog.open = true; }; + dialog.close = () => { dialog.open = false; }; + function $(selector) { + const elements = typeof selector !== 'string' ? [selector] : selector.startsWith('<') ? + [{ props: {}, attrs: {}, events: {}, children: [] }] : + selector.split(',').flatMap(s => s.trim() === '#esc-wizard-motors button' ? node('#esc-wizard-motors').children : [node(s.trim())]); + const api = { + prop(key, value) { if (arguments.length === 1) return elements[0]?.props[key]; elements.forEach(e => e.props[key] = value); return api; }, + attr(key, value) { if (arguments.length === 1) return elements[0]?.attrs[key]; elements.forEach(e => e.attrs[key] = value); return api; }, + data(key, value) { return arguments.length === 1 ? api.attr(key) : api.attr(key, value); }, + text(value) { if (!arguments.length) return elements[0]?.text; elements.forEach(e => e.text = value); return api; }, + empty() { elements.forEach(e => { e.text = ''; e.children = []; }); return api; }, + append(child) { elements[0].children.push(...child.elements); return api; }, + on(names, handler) { names.split(' ').forEach(name => elements.forEach(e => e.events[name.split('.')[0]] = handler)); return api; }, + off() { elements.forEach(e => e.events = {}); return api; }, + each(fn) { elements.forEach(e => fn.call(e)); return api; }, + toggleClass() { return api; }, css() { return api; }, elements, + }; + return api; + } + const FC = { isMultirotor: () => multirotor, MIXER_CONFIG: { appliedMixerPreset: 3 }, + MOTOR_RULES: { get: () => Array.from({length: 4}, () => ({ getRoll: () => -1, getPitch: () => 1 })) } }; + const status = { supportsTest: true, count: 4, phase: 0, token: 0, testToken: 0, testActive: false, simulated: true }; + const mount = vm.runInNewContext(source + '\nmountEscDirection', { ...direction, $, document: { ...node('document'), getElementById: () => dialog }, window: node('window'), + mixer: { getById: () => ({ image: 'quad_x' }) }, quadImage: 'quad_x.svg', quadReverseImage: 'quad_x_reverse.svg' }); + const dispose = mount({ MSPCodes, FC, isArmed: () => false, isMotorDirectionInverted: () => inverted, + i18n: { getMessage: key => key }, interval: { add: (name, fn) => timers.set(name, fn), remove: name => timers.delete(name) }, + MSP: { promise: (code, payload) => new Promise(resolve => calls.push({code, payload, resolve})) } }); + async function answer(call, result = {length: call.code === MSPCodes.MSP2_INAV_ESC_DIRECTION ? 10 : 0}, next = status) { + if (call.code === MSPCodes.MSP2_INAV_ESC_DIRECTION) FC.ESC_DIRECTION = next; + else FC[call.code === MSPCodes.MSP2_INAV_SET_ESC_DIRECTION ? 'ESC_DIRECTION_WRITE_ACK' : 'ESC_DIRECTION_TEST_ACK'] = result !== false; + call.resolve(result); await flush(); + } + const fire = (id, event = 'click') => node(id).events[event]?.({button: 0, preventDefault() {}}); + async function open() { await answer(calls[0]); fire('#esc-direction-open'); await answer(calls.at(-1)); node('#esc-direction-ack').props.checked = true; fire('#esc-mode-individual'); } + return { nodes, node, dialog, timers, calls, status, answer, fire, open, dispose, FC }; +} + +test('probe once; poll only for an open dialog and stop on close or unsupported firmware', async () => { + const h = harness(); + assert.equal(h.calls.length, 1); + await h.answer(h.calls[0]); + assert.equal(h.timers.size, 0); + await h.open(); assert.equal(h.timers.size, 1); + h.fire('#esc-direction-close'); assert.equal(h.timers.size, 0); + h.dispose(); + const old = harness(); await old.answer(old.calls[0], {length: 0}, null); + assert.equal(old.timers.size, 0); assert.equal(old.node('#esc-direction-open').props.disabled, true); + old.dispose(); + assert.equal(harness({multirotor: false}).calls.length, 0); +}); +test('a dropped direction write reports uncertainty and releases pending state', async () => { + const h = harness(); await h.open(); h.fire('#esc-direction-reverse'); + await h.answer(h.calls.at(-1), false); + assert.equal(h.node('#esc-direction-status').text, 'escDirectionUncertain'); + assert.equal(h.node('#esc-direction-test').props.disabled, true); + h.fire('#esc-direction-close'); assert.equal(h.timers.size, 0); h.dispose(); +}); +test('a dropped start sends a compensating stop; failed stop is retried', async () => { + const h = harness(); await h.open(); h.fire('#esc-direction-test', 'pointerdown'); + await h.answer(h.calls.at(-1), false); + const firstStop = h.calls.at(-1); assert.deepEqual(Array.from(firstStop.payload), [255,0,0]); + await h.answer(firstStop, false); + assert.notEqual(h.calls.at(-1), firstStop); + assert.deepEqual(Array.from(h.calls.at(-1).payload), [255,0,0]); + await h.answer(h.calls.at(-1)); await h.answer(h.calls.at(-1)); + assert.equal(h.node('#esc-direction-test').props.disabled, false); h.dispose(); +}); +test('release before a queued start ACK sends a fresh stop after that ACK', async () => { + const h = harness(); await h.open(); h.fire('#esc-direction-test', 'pointerdown'); + const start = h.calls.at(-1); h.fire('#esc-direction-test', 'pointerup'); + const stop = h.calls.at(-1); await h.answer(stop); await h.answer(start); + assert.deepEqual(Array.from(h.calls.at(-1).payload), [255,0,0]); + assert.notEqual(h.calls.at(-1), stop); await h.answer(h.calls.at(-1)); h.dispose(); +}); +test('cleanup retains the compensating stop after delayed start acknowledgement', async () => { + const h = harness(); await h.open(); h.fire('#esc-direction-test', 'pointerdown'); + const start = h.calls.at(-1); h.dispose(); const stop = h.calls.at(-1); + await h.answer(stop); await h.answer(start); + assert.deepEqual(Array.from(h.calls.at(-1).payload), [255,0,0]); + assert.equal(h.timers.size, 0); await h.answer(h.calls.at(-1)); +}); +test('quad diagram is independent of the asynchronous Outputs preview, including reverse', async () => { + for (const inverted of [false, true]) { + const h = harness({inverted}); h.node('#motor-mixer-preview-img').attrs.src = 'custom.svg'; + await h.open(); + assert.equal(h.node('#esc-wizard-image').attrs.src, inverted ? 'quad_x_reverse.svg' : 'quad_x.svg'); + h.dispose(); + } +}); diff --git a/tests/msp-parse-failure-recovery.test.mjs b/tests/msp-parse-failure-recovery.test.mjs index db3d609db..0047cb2cb 100644 --- a/tests/msp-parse-failure-recovery.test.mjs +++ b/tests/msp-parse-failure-recovery.test.mjs @@ -653,3 +653,17 @@ test('sendLedStripConfig() stops mid-chain when the queue drops one LED\'s write assert.equal(sentCodes.length, 1, 'a dropped write must not advance to the next LED'); assert.equal(completed, false, 'onCompleteCallback must not run when an LED write never landed'); }); + + +test('ESC write acknowledgement distinguishes accepted, unsupported and malformed responses', () => { + for (const [code, field] of [[MSPCodes.MSP2_INAV_SET_ESC_DIRECTION, 'ESC_DIRECTION_WRITE_ACK'], + [MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST, 'ESC_DIRECTION_TEST_ACK']]) { + for (const [payload, unsupported, accepted] of [[[], false, true], [[], true, false], [[1], false, false]]) { + FC[field] = null; + const handler = makeDataHandler(code, payload, () => {}); + handler.unsupported = unsupported; + mspHelper.handleResponse(handler); + assert.equal(FC[field], accepted); + } + } +}); From d31bbd76a0fc768cfef100d3342abab2040982aa Mon Sep 17 00:00:00 2001 From: Raffi1202 Date: Tue, 22 Sep 2026 23:29:24 +0200 Subject: [PATCH 3/5] Keep delayed motor stops within the original FC connection --- js/escDirectionPanel.js | 3 +++ tests/esc-direction-panel.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/js/escDirectionPanel.js b/js/escDirectionPanel.js index 9654bb03e..734182210 100644 --- a/js/escDirectionPanel.js +++ b/js/escDirectionPanel.js @@ -13,6 +13,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed, let disposed = false, status = null, updatedAt = 0, pending = null, requesting = false; let selected = 0, mode = null, held = false, starting = false, stopping = false, lastTestToken = 0; let polling = false, unsupported = false, stopPromise = null; + const connectionConfig = FC.CONFIG; // resetState replaces this on a new connection const known = [], checked = []; const dialog = document.getElementById('esc-direction-dialog'); const t = (key, args) => i18n.getMessage(key, args); @@ -27,6 +28,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed, else interval.remove('esc_direction_poll'); } async function write(code, payload, field) { + if (FC.CONFIG !== connectionConfig) throw new Error('FC connection changed'); FC[field] = null; const response = await MSP.promise(code, payload); // Queue exhaustion resolves false; an MSP error also completes its callback. @@ -149,6 +151,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed, }); } function sendStop() { + if (FC.CONFIG !== connectionConfig) return Promise.resolve(); if (stopPromise) return stopPromise; stopping = true; refresh(); diff --git a/tests/esc-direction-panel.test.mjs b/tests/esc-direction-panel.test.mjs index 0069cf175..e2b7058e8 100644 --- a/tests/esc-direction-panel.test.mjs +++ b/tests/esc-direction-panel.test.mjs @@ -107,3 +107,24 @@ test('quad diagram is independent of the asynchronous Outputs preview, including h.dispose(); } }); + +test('stop retry after tab cleanup cannot send into a new FC connection', async () => { + const h = harness(); await h.open(); h.fire('#esc-direction-test', 'pointerdown'); + const start = h.calls.at(-1); h.dispose(); const stop = h.calls.at(-1); + h.FC.CONFIG = {}; // reconnect replaces the FC state + const count = h.calls.length; + await h.answer(stop, false); await h.answer(start); + assert.equal(h.calls.length, count); + assert.equal(h.timers.size, 0); +}); +test('a late start failure cannot clear acknowledgements on a new connection', async () => { + const h = harness(); await h.open(); h.fire('#esc-direction-test', 'pointerdown'); + const start = h.calls.at(-1); h.dispose(); const stop = h.calls.at(-1); + await h.answer(stop); + h.FC.CONFIG = {}; + const count = h.calls.length; + await h.answer(start, false); + assert.equal(h.calls.length, count); + assert.equal(h.FC.ESC_DIRECTION_TEST_ACK, false); + assert.equal(h.timers.size, 0); +}); From 3fdf8105992153006b9ebb0f0e4be3cda3f807ee Mon Sep 17 00:00:00 2001 From: Raphael Hunziker Date: Wed, 23 Sep 2026 11:09:02 +0200 Subject: [PATCH 4/5] Encode the ESC direction test payloads next to the other encoder Start and stop of MSP2_INAV_SET_ESC_DIRECTION_TEST built their payloads by hand at the call sites. escDirectionTestPayload now validates motor and token against the same limits as the firmware, and the stop payload is a constant that cannot throw inside the stop retries. Both live in js/escDirection.js beside escDirectionPayload. The bytes sent are unchanged. --- js/escDirection.js | 14 ++++++++++++++ js/escDirectionPanel.js | 9 +++++---- tests/esc-direction-panel.test.mjs | 3 ++- tests/esc-direction.test.mjs | 12 +++++++++++- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/js/escDirection.js b/js/escDirection.js index b959e6401..0961e98eb 100644 --- a/js/escDirection.js +++ b/js/escDirection.js @@ -25,3 +25,17 @@ export function escDirectionPayload(status, motor, reverse) { } return [motor, reverse, (status.token % 255) + 1]; } + +// MSP2_INAV_SET_ESC_DIRECTION_TEST: motor, run (0/1), token. +export function escDirectionTestPayload(status, motor, token) { + if (!status?.supportsTest || !Number.isInteger(motor) || motor < 0 || motor >= status.count + || !Number.isInteger(token) || token < 1 || token > 255) { + throw new Error('Invalid ESC test request'); + } + return [motor, 1, token]; +} + +// Run=0 stops unconditionally; the firmware ignores motor and token. Never throws. +export function escDirectionStopPayload() { + return [255, 0, 0]; +} diff --git a/js/escDirectionPanel.js b/js/escDirectionPanel.js index 734182210..3a03ece8d 100644 --- a/js/escDirectionPanel.js +++ b/js/escDirectionPanel.js @@ -1,5 +1,5 @@ import { mixer } from './model'; -import { canSetEscDirection, escDirectionPayload } from './escDirection'; +import { canSetEscDirection, escDirectionPayload, escDirectionTestPayload, escDirectionStopPayload } from './escDirection'; import quadImage from '../resources/motor_order/quad_x.svg'; import quadReverseImage from '../resources/motor_order/quad_x_reverse.svg'; @@ -160,7 +160,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed, stopPromise = (async () => { for (let attempt = 0; attempt < 3; attempt++) { try { - await write(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST, [255, 0, 0], 'ESC_DIRECTION_TEST_ACK'); + await write(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST, escDirectionStopPayload(), 'ESC_DIRECTION_TEST_ACK'); return; } catch { if (!disposed) message('escDirectionUncertain'); @@ -181,12 +181,13 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed, } function start() { if (!dialog.open || !mode || !permitted() || !status.supportsTest) return; + lastTestToken = (Math.max(lastTestToken, status.testToken) % 255) + 1; + const payload = escDirectionTestPayload(status, selected, lastTestToken); held = true; starting = true; - lastTestToken = (Math.max(lastTestToken, status.testToken) % 255) + 1; message('escWizardRunning'); refresh(); - write(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST, [selected, 1, lastTestToken], 'ESC_DIRECTION_TEST_ACK').then(async () => { + write(MSPCodes.MSP2_INAV_SET_ESC_DIRECTION_TEST, payload, 'ESC_DIRECTION_TEST_ACK').then(async () => { starting = false; // Release/close may happen while the start is still queued. Stop // again after its acknowledgement, not just before it is delivered. diff --git a/tests/esc-direction-panel.test.mjs b/tests/esc-direction-panel.test.mjs index e2b7058e8..6fd17de2e 100644 --- a/tests/esc-direction-panel.test.mjs +++ b/tests/esc-direction-panel.test.mjs @@ -87,7 +87,8 @@ test('a dropped start sends a compensating stop; failed stop is retried', async }); test('release before a queued start ACK sends a fresh stop after that ACK', async () => { const h = harness(); await h.open(); h.fire('#esc-direction-test', 'pointerdown'); - const start = h.calls.at(-1); h.fire('#esc-direction-test', 'pointerup'); + const start = h.calls.at(-1); assert.deepEqual(Array.from(start.payload), [0,1,1]); + h.fire('#esc-direction-test', 'pointerup'); const stop = h.calls.at(-1); await h.answer(stop); await h.answer(start); assert.deepEqual(Array.from(h.calls.at(-1).payload), [255,0,0]); assert.notEqual(h.calls.at(-1), stop); await h.answer(h.calls.at(-1)); h.dispose(); diff --git a/tests/esc-direction.test.mjs b/tests/esc-direction.test.mjs index 7457abbdc..45393f52e 100644 --- a/tests/esc-direction.test.mjs +++ b/tests/esc-direction.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { parseEscDirection, canSetEscDirection, escDirectionPayload } from '../js/escDirection.js'; +import { parseEscDirection, canSetEscDirection, escDirectionPayload, escDirectionTestPayload, escDirectionStopPayload } from '../js/escDirection.js'; const data = bytes => new DataView(Uint8Array.from(bytes).buffer); const status = parseEscDirection(data([1, 4, 0, 0, 0, 0])); const ready = { status, acknowledged: true, armed: false, testing: false, busy: false, fresh: true, motor: 2 }; @@ -45,3 +45,13 @@ test('bounded pulse capability validates running motor, token and state', () => } assert.equal(status.supportsTest, false); }); + +test('pulse start is validated like the firmware; stop is unconditional', () => { + const pulse = parseEscDirection(data([2,4,0,0,0,0,0,0,0,0])); + assert.deepEqual(escDirectionTestPayload(pulse, 3, 255), [3,1,255]); + for (const [s, motor, token] of [[status, 0, 1], [null, 0, 1], [pulse, 4, 1], [pulse, -1, 1], + [pulse, 1.5, 1], [pulse, 0, 0], [pulse, 0, 256]]) { + assert.throws(() => escDirectionTestPayload(s, motor, token)); + } + assert.deepEqual(escDirectionStopPayload(), [255,0,0]); +}); From 30757eb82be20c43a7fcc6ab97610f53630ae25c Mon Sep 17 00:00:00 2001 From: Raffi1202 <250872901+Raffi1202@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:04:39 +0200 Subject: [PATCH 5/5] Address Sonar findings in the motor direction wizard - Move settling of the pending direction write out of the status poll callback into settlePendingWrite(); the logic is unchanged. - Use an optional chain for the acknowledgement check. - Darken four text colours to at least 4.5:1 against their background. - Use for the two status lines and give the dialog heading, motor heading and safety label fallback text; i18n replaces it on load. --- js/escDirectionPanel.js | 30 +++++++++++++++++------------- src/css/tabs/motors.css | 12 ++++++------ tabs/outputs.html | 10 +++++----- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/js/escDirectionPanel.js b/js/escDirectionPanel.js index 3a03ece8d..8ba0e3db4 100644 --- a/js/escDirectionPanel.js +++ b/js/escDirectionPanel.js @@ -33,7 +33,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed, const response = await MSP.promise(code, payload); // Queue exhaustion resolves false; an MSP error also completes its callback. // Neither is an acknowledgement, and neither confirms an ESC flash write. - if (!response || response.length !== 0 || FC[field] !== true) throw new Error('ESC command not acknowledged'); + if (response?.length !== 0 || FC[field] !== true) throw new Error('ESC command not acknowledged'); } function permitted() { @@ -89,6 +89,21 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed, $motors.append($button); } } + // Resolve the direction write this status request was sent for, if it is still pending. + function settlePendingWrite(operation) { + if (pending && pending === operation) { + const matches = status.token === pending.payload[2] && status.motor === pending.payload[0] + && status.reverse === pending.payload[1]; + if (matches && status.phase === 6) { + known[status.motor] = status.reverse; + pending = null; + message(status.simulated ? 'escWizardSimSaved' : 'escWizardSaved'); + } else if (pending.writeReturned && (!matches || status.phase === 0) && Date.now() - pending.started > 2000) { + pending = null; + message('escDirectionRefused'); + } + } + } function poll() { if (disposed) return; if (pending && Date.now() - pending.started > 8000) { @@ -110,18 +125,7 @@ export function mountEscDirection({ MSP, MSPCodes, FC, i18n, interval, isArmed, if (!status?.count) { refresh(); return; } $('#esc-direction-simulation').prop('hidden', !status.simulated); if (previous?.count !== status.count) drawMotors(); - if (pending && pending === operation) { - const matches = status.token === pending.payload[2] && status.motor === pending.payload[0] - && status.reverse === pending.payload[1]; - if (matches && status.phase === 6) { - known[status.motor] = status.reverse; - pending = null; - message(status.simulated ? 'escWizardSimSaved' : 'escWizardSaved'); - } else if (pending.writeReturned && (!matches || status.phase === 0) && Date.now() - pending.started > 2000) { - pending = null; - message('escDirectionRefused'); - } - } + settlePendingWrite(operation); if (previous?.testActive && !status.testActive && !pending) message('escWizardStopped'); refresh(); }).catch(() => { diff --git a/src/css/tabs/motors.css b/src/css/tabs/motors.css index 5b7d7f46d..f0dcc62cf 100644 --- a/src/css/tabs/motors.css +++ b/src/css/tabs/motors.css @@ -525,16 +525,16 @@ #esc-direction-dialog .esc-wizard-step h3 { font-size: 1.5rem; font-weight: 600; line-height: 1.3; margin: 0 0 12px; color: var(--esc-ink); } #esc-direction-dialog .esc-wizard-step > p:not(.esc-fine-print):not(#esc-wizard-progress) { margin: 0 0 16px; color: #5a6570; } #esc-direction-panel #esc-direction-test { min-height: 2.625rem; background: #303b45; color: #fff; border-color: #303b45; touch-action: none; user-select: none; } -#esc-direction-panel #esc-direction-test:disabled { background: #e7ebee; border-color: #e7ebee; color: #9299a0; } -#esc-direction-panel #esc-direction-test.running { background: #ba6333; border-color: #ba6333; color: #fff; } +#esc-direction-panel #esc-direction-test:disabled { background: #e7ebee; border-color: #e7ebee; color: #5a6570; } +#esc-direction-panel #esc-direction-test.running { background: #b15e31; border-color: #b15e31; color: #fff; } #esc-direction-panel .esc-direction-actions { display: flex; gap: 8px; margin: 20px 0 10px; } #esc-direction-panel .esc-direction-actions button { flex: 1; } #esc-direction-panel #esc-wizard-next { min-height: 2.625rem; background: #ffcc00; border-color: #e2b600; color: #292e34; } -#esc-direction-panel #esc-wizard-next:disabled { background: #f7f2d8; border-color: #ede5be; color: #a49d7b; } +#esc-direction-panel #esc-wizard-next:disabled { background: #f7f2d8; border-color: #ede5be; color: #746d50; } #esc-direction-normal[aria-pressed="true"], #esc-direction-reverse[aria-pressed="true"] { box-shadow: inset 0 0 0 1px #37a8db; } -#esc-direction-dialog #esc-direction-status { min-height: 18px; margin: 20px 0 0; padding: 12px 0 0; border-top: 1px solid #e8ebee; color: #68737e; font-size: 0.75rem; } -#esc-direction-status:empty { display: none; } -#esc-direction-dialog footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 12px 24px; background: #fafbfc; border-top: 1px solid #e4e7ea; color: #77818b; } +#esc-direction-dialog #esc-direction-status { display: block; min-height: 18px; margin: 20px 0 0; padding: 12px 0 0; border-top: 1px solid #e8ebee; color: #68737e; font-size: 0.75rem; line-height: 1.55; } +#esc-direction-dialog #esc-direction-status:empty { display: none; } +#esc-direction-dialog footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 12px 24px; background: #fafbfc; border-top: 1px solid #e4e7ea; color: #68737e; } #esc-direction-dialog footer span { font-size: 0.6875rem; line-height: 1.5; max-width: 24rem; } #esc-direction-dialog footer button { padding: 8px 12px; white-space: normal; font-size: 0.75rem; } @media (max-width: 35rem) { .esc-wizard-layout { grid-template-columns: 1fr; } .esc-mode-options { flex-direction: column; } } diff --git a/tabs/outputs.html b/tabs/outputs.html index a978b4d43..6557d2786 100644 --- a/tabs/outputs.html +++ b/tabs/outputs.html @@ -215,15 +215,15 @@

- +
-

+

Motor direction

-
+
@@ -234,7 +234,7 @@

-

+

Motor 1

@@ -247,7 +247,7 @@
-

+