Skip to content

feat(ARK_G431_CAN): 12S CAN target, factory BL image, DRV8350 sleep - #79

Open
AlexKlimaj wants to merge 66 commits into
ark-releasefrom
feat/embed-g431-can-bootloader
Open

feat(ARK_G431_CAN): 12S CAN target, factory BL image, DRV8350 sleep#79
AlexKlimaj wants to merge 66 commits into
ark-releasefrom
feat/embed-g431-can-bootloader

Conversation

@AlexKlimaj

Copy link
Copy Markdown
Member

Summary

Brings the ARK 12S CAN ESC (ARK_G431_CAN) work onto ark-release, including factory full-flash images with a committed G431 CAN bootloader and DRV8350 ENABLE sleep/wake (aligned with the existing ARK4IN1 nSLEEP gate-driver path from #78).

Supersedes #59 (was stacked on feat/12s-can-esc; retargeted here after merging latest ark-release).

Changes

  • Target: ARK_G431_CAN / G491 12S CAN — dead time 500 ns, DroneCAN FDCAN pins, ARK F051-class vehicle policy + ZC handoff
  • DRV8350H: wire ENABLE (PC9) + FAULT_N (PA12); sleep when idle via shared gate_driver helpers (USE_DRV_ENABLE)
  • Factory image: multi-product make factory-image / factory-image-check (F051 + G431 CAN)
  • Bootloader: commit Bootloaders/AM32_G431_BOOTLOADER_ARKG4_CAN_V18.bin (ENABLE held low in BL); pin BL_IMAGE_G431_CAN
  • Docs: Bootloaders/README.md, factory/README.md; F051 default remains ARK4IN1 nSLEEP-off BL

Depends on ARK32-bootloader product AM32_G431_BOOTLOADER_ARKG4_CAN (dual-protocol + bl-params).

Not in this PR

  • App-side BL rewrite for G431 (still F051-only via Src/bl_image.S)

Test plan

  • make ARK_G431_CAN builds
  • make factory-image-check — F051 + G431 layout/defaults gates pass
  • Factory G431 image: committed BL bytes at 0x08000000, 0xFF pad to app, EEPROM defaults match JSON
  • CI factory-image / static-analysis green
  • Hardware: production flash of .factory.bin on ARK 12S CAN; idle VM current drops with ENABLE sleep; FAULT_N path still clears

@AlexKlimaj

Copy link
Copy Markdown
Member Author

HWCI: no F051 regression vs today's ark-release

Ran the no-prop bench regression on the ARK 4IN1 (F051, channel 1) before starting ARK 12S CAN bring-up, since this branch touches shared code that compiles into the F051 — faults.c, runtime_loop.c, control_loop.c, esc_state.c, gate_driver.c, settings.c.

Rig: JS 2306 1800KV, no prop, 6S LiPo, Flight Stand 50, HWCI_PERF=1, profile noprop_baseline (10→50% staircase). Same EEPROM page on every leg (1800KV no-prop tune, sha c912035c7fd61da8).

A/B methodology

Interleaved A1 → B → A2, where A1 and A2 flashed the bit-identical branch binary — so the A1↔A2 spread is the measured noise floor for each metric, not an assumption.

  • A = f55a3ce (this branch)
  • B = 92155f5 (origin/ark-release HEAD)

Conditions were tightly controlled: pack drifted only 24.83 → 24.75 V across all three runs, and RPM matched within 15 of 21,000 (0.07%) at t50.

Results

metric A1 branch B ark-release A2 branch B − mean(A) A1↔A2 noise
idle_loop_rate_hz 41,807 41,918 42,408 −0.4% 1.4%
worst_ctrl_exec_us_steady 139 137 135 0.0% 2.9%
worst_main_loop_us_steady 292 294 302 −1.0% 3.4%
max_cpu_load_pct 84.6 80.7 93.0 −9.1% 9.9%
worst_zc_jitter_pct 5.43 5.40 5.47 −0.9% 0.6%
zc_blind_steps_total 13 12 12 −4.0% 7.7%
demag events / bemf timeouts 0 / 0 0 / 0 0 / 0

Every difference is smaller than the noise floor measured on the identical binary. Per-segment steady CPU load tracks within 0.65 points across all five throttle points, and ctrl_exec_us_max within ±2 µs with the sign flipping between segments.

Fault paths were identical on all three legs: 0 illegal edges, 0 bad DShot frames out of ~49k, state walk 2→4→5→4→2, and every run ending at state 2 / armed=1 / running=0 — no stall latch on commanded stop, on both firmwares. That exercises 2d2e425 (commanded stop is not a stall) and f55a3ce (clean arming) on real hardware.

Notes for reviewers

1. The committed baseline is stale. hwci ci reports FAIL against baselines/ARK_4IN1_F051.json, but that baseline is from Jul 16 and predates the main.c split into control_loop.c / runtime_loop.c / faults.c. The same three timing checks fail identically on ark-release, so it is not gating anything meaningful right now. Worth re-baselining on the current bench in a separate PR.

2. max_cpu_load_pct is not a usable gate metric on this profile. It read 84.6 vs 93.0 on the same binary, with ark-release landing below both. It is a 1–2 sample spin-up transient at t≈2.4 s, not steady-state load. max_current_a behaves the same way (2.28 / 2.84 / 4.12 across legs) — isolated stand hall-sensor spikes, 5–14 samples of 3700 against 0.69 A steady.

3. Idle loop rate is ~6% below the Jul 27 run (44,759 → ~42,000), but this branch is not the cause — today's ark-release measures the same ~41.9k. Whatever caused it landed on ark-release between Jul 27 and now, or is environmental.

4. HWCI_PERF=1 flash headroom. 2d2e425 adds HWCI_PERF to the .bl_image strip condition, so instrumented builds drop the embedded 4 KiB bootloader: text goes 27,564 B (ark-release) → 23,244 B (this branch). Before flashing the ark-release leg I dumped the on-chip BL at 0x08000000 and confirmed it is byte-identical to that build's embedded .bl_image, so the first-boot memcmp was a no-op and the bootloader was never rewritten. Anyone repeating this A/B against an ark-release carrying a different BL blob should redo that check first.

Unrelated to this PR, spotted while setting up: find_artifact in hwci/hwci/build.py documents itself as returning the "newest" artifact but actually returns sorted(...)[-1], which is lexicographic — 3.0.1-ark sorts after 3.0-ark. With two builds sharing obj/ that silently resolves to the wrong ELF/bin. I pinned elf_path per leg to work around it.

@AlexKlimaj

Copy link
Copy Markdown
Member Author

HWCI: 154 s real-flight replay on the ARK 12S CAN ESC

First non-stationary throttle test and first real thermal soak on ARK_G431_CAN. Every prior run on this target was a staircase of steady steps ≤8 s; this one plays back a recorded flight's motor-1 setpoint trace continuously for 154 s, so it exercises the ZC handoff, the ramp limiter and the thermal path against a load that never settles.

Verdict: clean. No abort, and 0 bemf timeouts across 485,077 commutations.

Rig: RCINPOWER GTS 4715-360KV (12N14P, 7 pole pairs, measured), HQProp 13X10X3, 12S LiPo (47.16 V at start), Flight Stand, ST-Link V3 SWD + PB3 debug UART.

Firmware: branch afac7cc, make ARK_G431_CAN HWCI_PERF=1. Verified rather than assumed — dumped 0x08004000 +49,589 B off the chip and it is byte-identical to obj/AM32_ARK_G431_CAN_3.0.1-ark.bin (md5 aa1c4183d27ce0871763cc7c3a2e7be3).

EEPROM: page sha 162bcc30dd19290b, unchanged across both runs (no writes during this session). Shipped defaults in play: max_ramp 5, minimum_duty_cycle 18 (9%), startup_power 120, advance_level 18, pwm_frequency 48 kHz, variable_pwm 1, limits.temperature 105 °C, limits.current 100 (= 200 A), low_voltage_cut_off 0.

Method

Trace: 43,565 samples / 154.13 s / 263 Hz median. Median setpoint 26.35%, p99 31.77%, peak 36.05%, worst 200 ms excursion +8.87%.

Two deliberate deviations from verbatim playback, both worth stating because they change what the run proves:

  1. The trace does not start at zero — its first sample is 26.72%, because the log begins in hover. Played verbatim from standstill that is a snap step into precisely the regime that reproducibly lost sync on the previous 365 mm prop (~370 rpm, 29–37 A). So the profile ramps to the trace's own initial value over 5 s and settles 3 s before playback opens. Measured effect: 1.28 A peak in the first 2 s of rotation, versus 15–24 A for the snap start. One start attempt, OPEN_LOOP → CLOSED_LOOP in 100 ms, closed loop held for 162 s.
  2. A 37 s load map ran first (prop13_charmap_12s) so the replay's abort limits came from measurement, not from prop theory. Going 14×5.5 → 13×10×3 nearly doubles P/D (0.383 → 0.769) and adds a blade; first principles landed within 6% on Ct but the Cp exponent band spanned 6 A to 15 A at hover — too wide to set a threshold from. Measured came in ~25% under the estimate.

Resample: the harness tops out near 100 Hz (p99 tick 21.8 ms), so the 263 Hz trace is linearly resampled onto the 100 Hz command grid. Cost, measured in-run against the source trace: mean 0.106% / p99 0.663% / max 2.03% throttle error, peak preserved 0.3605 → 0.3588. Nothing the plant can follow is lost — best cmd↔rpm correlation sits at a 70 ms lag.

Load map — HQProp 13X10X3 @ 12S (prop13_charmap_12s)

throttle rpm current A thrust N power W gf/W
0.12 2871 1.29 4.33 62 7.17
0.18 3563 2.28 7.13 108 6.72
0.22 4059 3.25 9.59 154 6.36
0.26 4540 4.48 12.52 212 6.04
0.30 4905 5.71 14.76 268 5.62
0.33 5235 6.92 17.00 323 5.37
0.36 5534 8.16 18.92 379 5.10
0.40 5929 10.09 21.78 464 4.78

Bold rows bracket the flight (hover / peak). Replay limits were then set at 18 A / 28 N / 7000 rpm — 2.2× the expected 8.2 A peak, and clear of the ~25–30 A rpm-collapse spiral.

Flight replay results (prop13_flight_replay, 15,413 samples over 154.12 s)

median p99 max
throttle 0.2634 0.3175 0.3588
rpm 4526 5035 5388
current A 4.55 6.62 9.83
thrust N 12.57 15.82 18.60
power W 212 454

8.99 Wh / 193 mAh drawn. Pack 47.16 → 45.84 V (3.930 → 3.820 V/cell). Peak current reached 55% of the abort limit.

Commutation / bemf — the point of the exercise:

metric flight segment
bemf_timeout nonzero samples 0 / 15,413
commutations 485,077 @ 3,147/s
demag events / comm spikes / eRPM-vs-stand mismatch 0 / 0 / 0
zc_blind_steps +0 (all 6 in the run land in the commanded coast-down)
zc jitter mean, ten 15 s chunks 3.17 – 3.45%, flat
worst_zc_jitter_max_pct 38.06

LiveDesyncWatch was armed on the firmware bemf flag for effectively the whole flight (throttle only dips below its 20% gate momentarily) and never tripped. Tick health: median 9.99 ms, p99 21.84, max 38.92, no gaps >50 ms. Steady main_loop_us_max 63 µs, steady CPU 22.4% (the 66.4% max is the spin-up transient). Onboard current sense agrees with the stand within 1.49% at the flight point, ±2.2% across 2.3–8.3 A on the load map.

ZC jitter vs the validated 14×5.5 baseline, at matched eRPM (not matched throttle):

eRPM 13×10×3 14×5.5 Δ
22k 1.89 2.37 −20%
25k 2.88 2.66 +8%
28k 3.57 2.87 +24%
31k 3.63 3.04 +19%
34k 3.53 3.22 +10%
37k 4.59 3.56 +29%
40k 5.09 3.98 +28%

The flight lives at p50 31,682 / p95 33,290 / max 37,717 eRPM — entirely inside the band this ESC+motor was already proven at (the 14×5.5 sweep went to 58,751). In-flight measured jitter of 3.17–3.45% against 3.09% interpolated for the 14×5.5 at 31.9k eRPM is a fraction of a point apart. The tri-blade does trend worse as eRPM climbs, which matters for coverage (note 3) but not for this flight.

Thermal — first sustained soak. First-order fits, rms ≤0.35 °C:

sensor start τ asymptote margin to the 105 °C derate
MCU die 40.0 68 s 57.3 °C 47.7 °C
FET 28.5 110 s 43.2 °C
motor 23.1 76 s 24.8 °C

The die reached 90% of its rise inside the 154 s, so the asymptote is measured rather than extrapolated: the thermal derate does not engage at this hover load however long it runs. Ambient was ~23 °C.

max_ramp = 5 is not limiting this flight. On the 100 Hz command grid the trace demands at most 236 %/s against the 500 %/s ceiling. (The raw 263 Hz log does contain single-sample 895 %/s spikes — an artifact of one 3.8 ms sample, well above both the grid rate and the rotor's ~1–3 Hz bandwidth.)

Notes for reviewers

1. The ESC rebooted once during arming, not during flight. On the load-map run the debug UART shows fault: signal_lost at +5.4 s followed immediately by the firmware banner — i.e. a restart — while still at zero throttle, before sampling began. perf_loop_iters has zero backward steps across both runs' sampled windows, so neither dataset is affected. But this is the same DShot-link fragility class as the rate mis-detection and resets seen on the 6S sweep, and mid-flight it stops the motor. Highest-value item out of this session.

2. low_voltage_cut_off is 0, so nothing protects the pack. Didn't bind here (193 mAh over 154 s), but a real flight is a different drain. There is no second layer behind this one.

3. Coverage: this flight only uses 0–36% throttle. The airframe hovers at ~26%, so the ESC's entire upper range is unexercised on this prop, and per the table above ZC jitter on the 13×10×3 degrades faster with eRPM than on the 14×5.5. Full throttle here would be ~11–12k rpm / 77–84k eRPM, untested — and the prop is past its own envelope well before that.

4. Host-side abort covers motor temperature only. enforce_safety() forwards stand.motor_temp_c and telem.temperature_c; FET and MCU die temperature are recorded but are not abort channels. On this bench the die runs ~16 °C above the FET thermocouple, so the hottest measured node is the unguarded one. Firmware's 105 °C derate is the real protection, so this is missing defence-in-depth rather than a hole — but the host should watch the hottest channel, not the coolest.

5. MCU identity confirmed in silicon. DBGMCU_IDCODE = 0x479 (G491/G4A1) and flash size register = 512 KB, so the fitted die is a G491RE — matching this PR's description, while g431makefile.mk still builds PART := STM32G431xx. The temperature path is unaffected: TEMPSENSOR_CAL1/2 addresses and their 30 °C / 110 °C points are common across G4, and both trims are programmed on this device (1045 @ 30 °C, 1378 @ 110 °C, 4.16 counts/°C). Worth noting that the 105 °C default sits 5 °C under the sensor's upper calibration point, so raising it would move the derate onset into extrapolation.

Prop note (not an ESC finding)

At matched static thrust the 13×10×3 costs +9.6% to +13.6% more power than the 14×5.5 — 213 W vs 194 W at this flight's 12.57 N hover, consistently across 8–21 N. Expected for 10" pitch at zero airspeed and it would likely reverse in forward flight, which a thrust stand cannot measure. Flagging only so the load map above isn't read as an efficiency endorsement.

Harness support for this test is not yet pushed — flight-trace playback (hwci/waveform.py, a Segment.waveform field, runner preload indexed off scheduled rather than wall-clock time so replays are reproducible), the two profiles, and a replay-specific analysis script are local pending a separate commit, so prop13_flight_replay will not resolve on this branch yet. Two small things found while building it: metrics.py reported throttle = 0.000 for a waveform segment because it reads the segment's declared field rather than what was commanded (fixed locally); and the comment in runtime_loop.c still says settings.c "coerces anything outside 70..140 to 255", which went stale for this target with 8bfb924 — it now coerces to 105.

AlexKlimaj and others added 25 commits August 13, 2026 14:58
Update firmware definitions and add RGB LED control functions for G431

Add CAN termination pin definitions and setup function for GPIO control

Make FDCAN1 TX/RX pins configurable for ARK CAN ESC

The ARK CAN ESC routes FDCAN1_TX to PB9 instead of PA12 used by all
other G431 DroneCAN targets. Add CAN_TX_PIN/PORT and CAN_RX_PIN/PORT
defines with defaults matching existing PA11/PA12 behavior, and set
the ARK target to use PB9 for TX.

Make ADC GPIO ports configurable for ARK CAN ESC

The ARK CAN ESC has voltage on PB11 (GPIOB) and current on PC3 (GPIOC),
unlike other G431 targets which use GPIOA for both. Add VOLTAGE_ADC_PORT
and CURRENT_ADC_PORT defines with GPIOA defaults, and use them in
ADC_Init() instead of the hardcoded GPIOA.

Enable current sensing for ARK CAN ESC
Add USE_CURRENT_SENSE to the target definition so the ADC scan sequence
includes the current channel. Fix the ADC sequencer length to 3 ranks
when USE_CURRENT_SENSE is defined, and set DMA transfer length to match.
Without this, only temperature and voltage were scanned, and current
telemetry would report garbage.

Switch ARK CAN ESC input capture to TIM16_CH1 on PB4

The ARK CAN ESC routes MTR_INPUT to PB4, which cannot be TIM15_CH1
(only PA2 supports that). PB4 AF1 = TIM16_CH1, so switch the G4_E
hardware group to USE_TIMER_16_CHANNEL_1.

Since TIM16 was the commutation timer (COM_TIMER) for all G431
targets, reassign COM_TIMER to TIM7 for the ARK target using the
existing #ifndef override pattern. Add MX_TIM7_Init() and
TIM7_IRQHandler for commutation, and wrap the existing TIM15/TIM16
init paths with appropriate #ifdefs.

Existing G431 targets are unaffected — they continue using TIM15 for
input capture and TIM16 as COM_TIMER.

Add CURRENT_OFFSET definition for improved current sensing accuracy

Refactor timer reset logic for Dshot DMA functions to improve clarity and maintainability

Add EEPROM_START_ADD definition for EEPROM memory management

Add RAMP_SPEED definitions and RAMP_DIVIDER macro for improved RPM control

dead time 65
Also re-run make format after rebase onto ark-release (trimmed targets.h).
Bring the 4IN1 control/production work onto the 12S CAN ESC:

- targets: max_ramp default 20 (2%/ms), ramp ceilings 3/8, POLLING_MODE_THRESHOLD 5000
- bemf_zc: glitch-tolerant confirm + turn-on grid compensation on MCU_G431
  with 160 MHz TIM1 / 2 MHz INTERVAL scale (hump mult 40, Q16 half-off 409)
- factory: ARK_G431_CAN eeprom defaults (same ramp/kV/timing, input_type=DroneCAN),
  128 KiB flash map, make factory-image-g431-can (BL optional / 0xFF pad)
- CI: factory-image-check covers both products; build-ark-g431-can job

App-side bootloader embed remains F051-only until a G431 CAN BL .bin is
committed under Bootloaders/.
Commit AM32_G431_BOOTLOADER_ARKG4_CAN_V18.bin from ARK32-bootloader
(dual-protocol + bl-params, 086755b) and wire BL_IMAGE_G431_CAN so
make factory-image-g431-can / factory-image-check place a real 16 KiB
bootloader at 0x08000000 instead of 0xFF padding.

Closes the gap called out in #36 for production full-flash images.
App-side BL embed remains F051-only.
Map schematic nets FAULT_N (PA12) and DRV_ENABLE (PC9). Drive ENABLE
high at boot, poll open-drain nFAULT in the main loop, latch
ESC_FAULT_STUCK on assert (VDS OCP and other gate-driver faults), and
pulse ENABLE at zero throttle so latched VDS trips can clear. VDS
threshold remains the board resistor network.
Gate the smart gate-driver run pin (G431 ENABLE, F051 nSLEEP) so the
driver is asleep whenever the bridge is not needed. That cuts ~0.2 W of
DRV8350 idle VM current on the 12S CAN ESC at typical pack voltage.

Policy:
- Boot asleep after GPIO init
- Wake (block ~1 ms for tWAKE) on first comStep / brake / allpwm
- Main-loop poll sleeps again when not running, sine-starting, or braking
- nFAULT poll ignored while intentionally asleep; DRV8350 fault clear
  still uses a short ENABLE reset pulse then re-wake

LEDs stay as before. F051 HWCI size gate still passes.
Refresh AM32_G431_BOOTLOADER_ARKG4_CAN_V18.bin from ARK32-bootloader
(20ebac2) so the factory image boots with PC9 ENABLE driven low.
Mirror the bootloader debug console from AM32-bootloader#60: USART2 TX on
PB3 at 115200. App uses polled TX (DMA1 CH1 is DShot capture).

ISR-safe event queue drains in the main loop:
- esc state transitions (armed/open/closed/fault/…)
- nFAULT, stuck, LVC, signal lost, desync, stall

Connect a 3.3 V serial adapter to PB3 (TX) and GND; 115200 8N1.
Print param: NAME=value on GetSet writes. Only log fault: stuck once
when entering FAULT_STUCK (was polled every main-loop tick).
Mark EEPROM dirty on DroneCAN GetSet param writes; coalesce to a single
flash save after 500 ms of quiet bus when safe (not running with
throttle). Explicit SAVE/ERASE still write immediately and clear dirty.
Event enum was nested under USE_DEBUG_UART, so F051 (stubs only) failed
to compile call sites in faults.c / runtime_loop.c. Move the enum outside
the ifdef so codes always exist and no-op stubs accept them.
Setting MOTOR_KV via GetSet updated motor_kv but left advance_erpm_scale
and RPM throttle envelopes at boot values (factory 1020 kV). That mis-set
the timing schedule for a 360 kV motor and contributed to desync→stuck.

Call applyMotorIdentitySettings() on MOTOR_KV/MOTOR_POLES sets and log
derived tables. Richer stall/desync UART lines for bench diagnosis.
Match F051 zero-cross filter levels on G431 (42/10/7) so low-BEMF
spool is less noise-sensitive. Acquisition jump desyncs (zc<=100)
re-enter open-loop without full stop; established desync/stall still
charge the episode rail. Drop duplicate desync/stall UART lines.
Embed latest G431 CAN bootloader binary.
Report CRITICAL/ERROR/WARNING on 1 Hz NodeStatus from stuck, nFAULT,
LVC, signal loss, and hard desync/stall. Emit a single ERROR LogMessage
on rising edge of stuck or nFAULT.

Also keep F051 under the size gate: macro no-op debug UART stubs, gate
desync/stall prints, compact soft-acq desync, and skip average reseed
on F051 only.
Enable regular-group oversampling (ratio 16, right-shift 4) so DR stays
12-bit and existing scale math is unchanged. Cuts broadband shunt noise
without PWM-sync sampling or F051 impact.
SITL builds with -Werror and no debug UART; the printf macros no longer
consume set_log_val, which failed unused-but-set. Keep logging only when
USE_DEBUG_UART is enabled.
Skip stall trip/log/episode charge when input < 48: zero throttle
leaves running set while BEMF dies, so INTERVAL_TIMER expiry is
expected. F051 HWCI_PERF=1 no longer embeds the 4 KiB bootloader
image (release still does) so size-check stays under the gate.
Acquisition jump desyncs (zc<=100) no longer increment desync_happened.
That kept NodeStatus at WARNING and esc.Status.error_count rising on
low-duty starts that never established. Early roughness still uses
faultNoteEarlyDesync / episode acq rail. SITL acq-rail test updated.
Map duty_cycle 0..2000 to Status.power_rating_pct 0..100 so the GUI
RAT % shows instant bridge demand instead of a hardcoded zero.
While the gate driver is asleep (ENABLE low), still clear the nFAULT
software latch at zero throttle so FAULT_STUCK can exit without a
power cycle. For DRONECAN_IN, treat a full second of zero throttle as
enough to arm without the DShot zero_input_count>30 gate, so 10 Hz
ESCRaw does not bounce DISARMED/ARMING.
desync_happened and fault_stall_trips gated on the live zero_crosses as a
proxy for "established run", but that counter is reset in ten places -
including by the desync and stall handling being measured. An established
run that desynced therefore re-entered the jump check with a count rebuilt
from zero (measured: 75 on a steady 900-throttle spool), was misfiled as a
low-duty acquisition kick, and dropped out of esc.Status.error_count and
NodeStatus WARNING. The gate under-reported real faults over DroneCAN -
the opposite of the over-reporting 44e3f6b set out to fix.

Latch fault_run_established once zero_crosses passes 100, sampled from
runtimeProcessDesyncCheck at 10 kHz so it always sees the peak before a
desync/stall reset clears it. Lifetime matches the counters it gates
(cleared in faultErrorCountReset on the armed 0->1 edge), so 44e3f6b's
intent is preserved: a start that never got going never sets the latch.

zc_at_desync is now read only by the debug-UART desync line, so scope its
declaration under USE_DEBUG_UART - otherwise non-UART targets fail
-Werror=unused-variable.

Fixes SITL test_steady_duty_desync_leaves_ramp_fixed. size-check-ark
passes at 96.67% flash (limit 99.8%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddccc01 put desync_happened and fault_stall_trips behind the arm-cycle
latch, which was right for reporting but wrongly took the episode charge
with them. The two gates answer different questions:

  reporting  - "did an established run fault this arm cycle?"  -> latch
  escalation - "is THIS event established-run, or acq thrash?" -> live zc

Charging on the latch escalates every event in a fault episode. Measured
on the SITL 80 ms zero-cross blackout, established spool at throttle 900:

  ark-release  stall zc=154 (charge), zc=12, zc=10 (no charge)
               -> loop re-acquires, adv_kerpm_hold arms, recovery works
  ddccc01      stall zc=179, zc=12, zc=0 (all charge, latch set)
               -> bucket fills, restart holdoff latches, never re-acquires

The holdoff coast then clears dcm_hold_ms / adv_kerpm_hold_ms, so the
throttle-ceiling and advance-schedule holds from #62 and #63 were armed
and destroyed inside one main-loop pass - exactly the window they exist
to cover.

Put faultDesyncEpisodeCharge back on the live zero_crosses > 100 test in
both rails (ark-release behavior, unchanged), keep the latch on the two
counters that feed esc.Status.error_count. zc_at_desync leaves the
USE_DEBUG_UART scope again since escalation reads it on every target.

Restores SITL to 46 passed / 1 skipped (stable over 3 repeats);
size-check-ark passes both bounds at 84.10% and 96.73%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wire the harness for ARK_G431_CAN: stm32g4x + 0x08004000 app base (vector
handoff), higher SWD clock, /dev/esc-debug-uart udev, and a debug-uart
backend that logs PB3 console lines and aborts on nFAULT/desync faults.
Factory and param default INPUT_SIGNAL_TYPE to AUTO so the first wire
protocol is detected while CAN stays available. When RawCommand is live,
wire inputs defer; after the ~250 ms CAN timeout DShot/PWM can take over
without reboot. Exclusive DRONECAN_IN still disables wire IRQs.
AlexKlimaj and others added 15 commits August 13, 2026 14:58
SITL and other non-DRV targets hit -Werror=undef on #if FAULT_HAS_DRV_NFAULT
after the nFAULT classifier landed with only the =1 branch defined.
…ed754f)

Ports "update dronecan params" (Huibean,
5ed754f). Not a cherry-pick: our eeprom
layout predates upstream's brake_on_zero_throttle, which is the one entry
that could not come across, and our table carries the ARK input_type and
advance_level handling.

Newly settable over DroneCAN: STALL_PROTECTION, DISABLE_STICK_CALIBRATION,
SERVO_LOW/HIGH_THRESHOLD, SERVO_NEUTRAL, SERVO_DEAD_BAND,
ABSOLUTE_VOLTAGE_CUTOFF, CURRENT_P/I/D, ACTIVE_BRAKE_POWER, RC_CAR_REVERSE,
SINE_MODE_CHANGEOVER_THROTTLE, SINE_MODE_POWER, USE_HALL_SENSORS,
SERIAL_TELEM_INTERVAL.

Three fixes come with it:

- CURRENT_P/D scale x2 on read and /2 on write, like CURRENT_LIMIT, because
  settings.c computes Kp = current_P * 2. The parameter now reads in the
  loop's own gain units (default 200 = Kp 200, stored 100 = default_settings[9]).
  max_value therefore needs 510, so load_settings' max_value widens from
  uint8 to uint16.

- LOW_VOLTAGE_CUTOFF and BRAKE_ON_STOP were exposed as T_BOOL, so mode 2 of
  each (absolute voltage cutoff; active brake) was unreachable over CAN even
  though settings.c implements it.

- STARTUP_POWER's default was 10, below its own minimum of 50: an
  out-of-range stored byte was "repaired" to another out-of-range value.

Verified: make ARK_G431_CAN (flash 42.3%), make sitl, SITL test_params.py +
test_dronecan.py green.

Co-authored-by: Huibean <huibean.luo@gmail.com>
…ARK_G431_CAN

Both protections shipped disabled, and the thermal one was a cliff rather
than a derate. This makes them two smooth ceilings that compose, and turns
them on for the 12S CAN board.

WHY THEY WERE OFF: default_settings[] in DroneCAN.c is the AM32 configurator
skeleton, and both factory JSONs were seeded from it. Its bytes 43/44 are
141 and 102 - each just outside the range settings.c arms (70..140 C,
1..100 raw = 2..200 A) - so every ARK ESC has shipped with the thermal
derate and the current limiter silently inert. A DroneCAN param ERASE
memcpy's that array back over the page, so the defaults had to move too or
"restore defaults" would quietly disarm a shipped ESC.

THERMAL: was map([limit-10, limit+10] -> [max/2, 1]) entered only at
temp > limit, so one degree of overshoot stepped the ceiling from full
authority to ~22% and one degree of recovery restored it - a bang-bang
across the whole derate range, cycling on the board's thermal time
constant. It also overwrote duty_cycle_maximum, discarding the low-rpm map
and the post-desync ceiling hold. Now runtimeThermalLimitTick() runs at
1 kHz off a Q12 IIR of the die sensor (tau ~64 ms - the raw reading is a
whole-degree unaveraged conversion that dithers +-1 C) and produces its own
ceiling: full authority at the limit, falling linearly to 10% over 20 C. A
proportional derate settles; a threshold cannot.

CURRENT: unchanged in mechanism, but the integrating ceiling is now
released whenever the loop that owns it is not driving. It had no other
reset, so a run that ended current-limited left it low and the next start
was capped below min_startup_duty until the PID walked it back up.

COMPOSITION: both ceilings are min()-combined in setInput() and applied
after the stall-protection boost, so a boost can no longer push duty back
through a protection ceiling. min() not a product - each is an independent
"do not exceed", and scaling them together would derate to 25% when each
asked for 50%. Neither can step thrust: the 20 kHz ramp limiter slews
applied duty in both directions.

CURRENT SENSE: actual_current now saturates instead of wrapping. It is
int16_t centiamps (327.67 A) while a 10 mV/A chain reads to 329.6 A, so
raw >= 4071 overflowed negative and hit the "< 0" clamp as ZERO amps -
inverting the limiter, the nFAULT OCP classifier and telemetry at >326 A.

TUNABLE: the foldback width is eeprom byte 184 / DroneCAN TEMP_DERATE_BAND
(5..40 C, default 15). There is no hard-cut response mode to select -
dropping a motor outright on a multirotor is worse than flying on a derated
one - so the slope is the knob. 0xFF on any page that never wrote the CAN
block is coerced to the default in settings.c.

ARK_G431_CAN defaults: foldback 105 -> 120 C, current limit 200 A (the most
settings.c arms, ~80% of the 250 A shunt rating, a backstop under the
DRV8350 VDS trip), current PID left at 100/0/50. 105 C matches professional
12S practice and sits inside the G4 die sensor's 30..110 C factory
calibration; the caveat to settle on the bench is that APD/T-Motor read an
NTC on the power stage while this reads the MCU die, so if the die-to-FET
delta is large the onset belongs lower.

GAINS: measured, not reasoned. Softening the current loop looks right and is
wrong: the ceiling moves by pid_output/10000 duty units per tick and that
integer divide is a DEAD ZONE - the loop is inert until the overshoot
exceeds 5000/Kp centiamps, so Kp 10 would need 10 A before the ceiling moved
at all. SITL heavy_13inch at an 8 A limit: P=100 holds 7.7 A, P=50 7.1 A,
P=25 6.5 A, P=5 14.7 A. Upstream's gains stay.

ARK_4IN1_F051 stays off: its shunt is shared across all four ESCs and its
thermal placement has not been benched.

Observability: ZC_STATS v7 publishes both ceilings, the applied one, raw
and filtered die temperature, and current, because these limiters only ever
LOWER duty - from outside, a derating ESC and a weak plant look identical.
test_duty_limits.py walks the derate band a degree at a time and fails if
any single-degree step exceeds 200 counts (the old map steps ~1550); asserts
the shipped defaults arm it, that 255 still disables it, that the band knob
changes the slope, and that the applied ceiling is min() of the two. The
current loop is asserted on REGULATED CURRENT against a plant pulling twice
the limit, because only that catches the gain mistake above - it reports
15.0 A against an 8 A limit at P=5. check-factory-image-ark.sh gates bytes
9/10/11/43/44/184 against the product JSON and prints the armed state.

Verified: make ARK_G431_CAN (flash 41.9%), make size-check-ark PASS (F051
27080/27592), make factory-image-check PASS for both products, SITL suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fj2YRWedfZ53FSwDTdsfLF
A param ERASE memcpy's default_settings[] over the whole eeprom page. Now
that both limiters ship armed, that array decides whether a field "restore
defaults" leaves an ESC protected - and it is the one config change nobody
re-checks afterwards.

Bytes 43/44 already carried the shipped 105/100. Two gaps remained:

- Byte 184 (foldback band) lives PAST the 48-byte configurator skeleton, so
  an erase left it 0xFF and settings.c coerced it to the compile-time
  default. That happens to equal what ARK_G431_CAN ships, so the behaviour
  was right by coincidence and would have broken silently the first time a
  product shipped a different band. apply_post_skeleton_defaults() now
  writes it explicitly from TARGET_DEFAULT_TEMP_DERATE_BAND.

- The three copies of these numbers (targets.h macros, default_settings[],
  the product JSON) had nothing keeping them in step.
  scripts/check-erase-defaults.py gates all three, and additionally fails if
  either default falls outside the range settings.c arms - so "erase leaves
  the limiters off" cannot return as a silent regression. Wired into
  make factory-image-check.

Bytes 43/44 stay hex literals in the array on purpose: Mcu/SITL/sitl_params.py
parses it to build eeprom images, so it has to stay pure hex.

test_param_erase_restores_armed_protections drives the real opcode over CAN:
clears all three params, erases, then requires (105, 200, 15) on read-back
plus the armed ranges. Restoring the upstream 0x8d/0x66 bytes makes it fail
with TEMPERATURE_LIMIT=255.

max_ramp is NOT fixed here: an erase still reverts it to upstream's 160
(16 %/ms) rather than the ARK 20 (2 %/ms). Same class of hole, wants its own
change.
The curve check compared the ceiling to the temperature the test ASKED for,
so a host slow enough to leave the filter a degree short failed as a wrong
derate. Check against degrees_celsius_filt instead - whatever degree the
firmware reports, the ceiling has to be on the curve for it - and require
the reading to persist across two samples so a mid-transition sample cannot
be mistaken for a settled one.

Net effect is a TIGHTER assertion: the old window was -110/+15 counts to
absorb the lag, the new one is a symmetric 75 for the rounding residue
alone. Three consecutive full runs green.
The gain is now settable over CAN, so the one thing an integrator needs to
know before turning it down belongs next to the parameter: the ceiling update
divides by 10000 with integer arithmetic, so lowering Kp widens a dead zone
rather than softening a response. Measured numbers are in control_loop.c.
… of it

The 12S CAN board now ramps at 0.5 %/ms (full scale in 200 ms), matching what
larger 12S ESCs ship - APD and Hargrave default to 50 % per 100 ms. The ARK
4IN1 keeps its bench-derived 2.0 %/ms; only the G431 product moves.

Stored as 5, which puts the firmware in FINE mode (ramp_divider 9, one step
every 500 us, so the number means a tenth of what it means in coarse mode).

That exposed a real bug, not just a slower ramp. Fine mode applied the eeprom
value to all three regimes, which handed the spool-up ramp to a cruise
setting: at 0.5 %/ms the racer plant could not start AT ALL in SITL
(test_acq_desync_rail, "motor never entered running", 12 s, deterministic -
clean at the old 16 %/ms). That contradicts the ramp schedule's own
documented intent, which is that RAMP_SPEED_STARTUP governs spool-up
reliability and is deliberately not a vehicle-tuning knob (targets.h).

settings.c now scales the coarse startup ceiling into fine-cadence units
(x10), so startup slews at exactly the rate it would in coarse mode while
low/high rpm honour the requested value. Nothing here lets the eeprom RAISE a
regime past its targets.h ceiling. test_fine_ramp_keeps_the_startup_rate
reads all four ramp fields straight out of ZC_STATS - no motor, no timing, so
it cannot go flaky or pass by accident.

NOT changed: default_settings[5] and the DroneCAN MAX_RAMP default stay at
upstream's 160, so a param erase still widens the ramp 32x on this board.
Moving them also moves the SITL seed image, and six fault-injection tests
provoke desync by slewing fast - at 0.5 %/ms they cannot create the condition
they assert about. Pinning the ramp inside those tests is the right fix and
wants its own change. check-erase-defaults.py now gates the macro against the
product JSON and prints the divergence explicitly rather than implying the
ramp is restored.

Verified: make ARK_G431_CAN (flash 42.4%), ARK_4IN1_F051, size-check-ark PASS,
factory-image-check PASS both products (G431 max_ramp=5, F051 max_ramp=20),
erase-defaults gate, 6 duty-limit tests. Full-suite counts on this host are
noise-dominated (base 2 and 5 failures, this branch 4 and 3, alternating runs
of the same two binaries, every failing test appearing on both) - CI is the
real gate.
Hold bridge inputs inactive before ENABLE rises and settle 3 ms on wake
(ARK 12S CAN only). Refresh the committed G431 CAN bootloader binary from
ARK32-bootloader 0a764a2 (PR #2) so PC9 is driven low for the whole BL stay.
A single corrupted frame could command full throttle at armed idle, and
neither integrity check in computeDshotDMA() can reject it.

The decoder times pulse width by pairing dma_buffer[2i] (rise) with
[2i+1] (fall). Lose or gain one edge and the pairing shifts, so it
measures the GAPS instead of the pulses - and since a DShot 0 is 37.5%
high while a 1 is 75%, every bit decision inverts against the same
threshold. DShot's CRC is the XOR of the three data nibbles, so
complementing all four leaves it balanced:

  (n3^F)^(n2^F)^(n1^F) = n3^n2^n1^F = n0^F

i.e. the codebook is closed under inversion - all 4096 legal frames stay
CRC-valid when inverted. Inversion maps throttle t to 2047-t, so the
mapping is worst exactly at rest: idle 0x0000 reads as 0xFFFF = 2047.
The +1.63% frame-span shift also sits inside the +-6.25% span gate, so
that check passes it too.

newinput then LATCHES - it is only overwritten by the next ACCEPTED
frame, and the marginal link that corrupted one frame tends to drop the
next several, so the bogus demand survives for the length of the
dropout. Measured on an ARK G431 CAN bench as a 30-90 ms, ~29% duty
spin-up kick at armed idle.

So hold off leaving zero until the demand repeats. Two discarded frames
costs 2.5 ms at 800 Hz on a genuine spool-up and nothing once moving
(the check short-circuits on newinput == 0); in-flight slew stays
governed by max_duty_cycle_change / max_ramp in the duty domain. A
frame-count debounce rather than a checksum because no checksum on 16
bits can separate idle from full throttle here - both are legal frames.

Bench result, 600 s at armed idle with the stand held at zero: 8 blocked
events (1 per 75 s, matching the observed kick rate), every one a step
of exactly 1 (so each was a lone frame - the second discard has never
been needed), newinput never left 0, no reboots. dshot_idle_exit_blocked
is exposed alongside good/badcounts so the rate is measurable rather
than inferred from audible kicks.

Costs 48 B of flash on F051 (26888 -> 26936 of 27424).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
computeDshotDMA() zeroes signaltimeout the instant the frame-span gate
passes, BEFORE the CRC compare, so a climbing signaltimeout proves the
frame never cleared the span gate - CRC is not involved and
dshot_badcounts cannot see it. Two very different faults share that
symptom: edges never arrived (wire/stand), or edges arrived and the
transfer completed but the span fell outside the learned window
(firmware gate).

This separates them without a scope and without touching firmware: it
samples the cheap scalars fast, then on a gap grabs the raw capture
buffer plus DMA1_Channel1->CNDTR and re-runs the firmware's own
arithmetic on those exact words. CNDTR at 32 with a frozen buffer means
no edges; stuck mid-count means edges stopped mid-frame; a moving buffer
with an out-of-window span means the gate rejected a completed transfer.

Also triggers on dshot_idle_exit_blocked changing, because with the
idle-exit holdoff in place newinput no longer reaches 2047 on this fault
- the event would otherwise go invisible exactly when it got fixed.

Symbols resolve from the ELF rather than being hardcoded (BSS moves
across builds), and the detection/window globals are re-read per trigger:
they are written by detectInput()/checkDshot() and the 8-frame learning
block AFTER attach, so sampling them once at t=0 reads post-boot
defaults and makes the gate window look wide open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bench sweep on the RCINPOWER GTS 4715-360KV + 14x5.5 (MRP 365x140) at
12S, 3 cold starts per rung (prop14_startup_matrix_12s):

  blank   peak start A @ 12/15/20%   mean ttr   max ttr   desync+stall
  32       16.9 / 21.6 / 30.9        18.6 ms     30 ms     14
  36       15.4 / 18.3 / 25.4        24.7 ms    133 ms     14
  40       15.6 / 17.9 / 23.7        20.1 ms     24 ms     10

40 wins on every axis that matters: lowest start current where current is
actually significant (-23% at the 20% rung, 11 of 13 attempts improved),
tightest worst-case acquisition, and the only value that reduces the
desync/stall count. The mechanism is the one the knob is for - at 20-37 A
the freewheel/demag decay after each commutation outlasts the default
interval/2 blank, so the clamp gets taken for a crossing and commutation
fires early.

36 is not the safe middle it looks like: it threw a 133 ms acquisition
outlier (5x typical) and gave no reduction in desync/stall events.

Margin: the ceiling documented above this knob is stated against
auto_advance sweeping 13..23 (crossing at 45..55/64). This article runs
auto_advance=0 with advance_level fixed, so the expected-crossing
position does not vary and 40 keeps a deterministic margin. Re-verify
before enabling auto_advance - the low-advance end is where 40 bites.

NOT fixed by any blank value: a 25% cold start stalls (rotor reaches
~370 rpm, loses sync, 29-37 A while barely turning). That is
torque-vs-inertia at the open-loop handoff, not crossing detection; the
lever there is startup_power / the startup duty ceiling.

Two enabling changes came with it. ZC_SEARCH_BLANK_64THS was a bare
#define, so -D collided with -Werror and the knob its own comment calls
sweepable could not be swept; it is #ifndef-guarded now like the
ZC_FILTER_* tiers. And EXTRA_CFLAGS is a new user hook for A/B builds -
xCFLAGS must NOT be used for this, as it carries the CAN include paths
for *_CAN targets and assigning it on the command line silently drops
them until the DroneCAN headers stop resolving.

Caveats: 3 starts per rung, one 25% attempt per config, and start
current is noisy. The 32->40 direction is solid; the per-rung
percentages are not precise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 32/36/40 comparison in 34d8cae is invalid and its conclusion does not
hold. The override was applied with

  make ARK_G431_CAN EXTRA_CFLAGS=-DZC_SEARCH_BLANK_64THS=N

and the board was then programmed with `hwci flash`, which calls
build_firmware() -> plain `make <target>`. That recompiles without the
override; verified directly - the .bin md5 goes back to the default
build. So all three runs executed the SAME default binary and the
differences I read as the knob's effect (peak start current 30.9 / 25.4 /
23.7 A at the 20% rung, max ttr 30 / 133 / 24 ms) were run-to-run
variance on identical firmware.

Reverting the default to 32. Nothing justified moving it.

Kept, because they are correct and independent of the bad comparison:
the #ifndef guard that makes the knob overridable at all, the
EXTRA_CFLAGS hook, and prop14_startup_matrix_12s.

Also kept, because it was measured on the default build and is unaffected:
13 cold starts at 10/12/15/20% all acquired in 10-30 ms, and a 25% cold
start stalls at ~370 rpm drawing 29-37 A with the rotor barely turning -
torque-vs-inertia at the open-loop handoff, not crossing detection.

To sweep this for real: flash with `hwci flash --bin <obj bin>`, which
skips the rebuild, and confirm the value took before comparing anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The thermal ceiling had two independent validators that disagreed, and
the disagreement resolved to "no protection".

loadEEpromSettings() coerced any limits.temperature outside 70..140 to
255. The DroneCAN parameter layer then re-validates the same byte in
load_settings(), which resets an out-of-range value to TEMPERATURE_LIMIT's
default (105) - a perfectly good safety net that never fired, because 255
sits INSIDE its accepted 70..255 window. So a garbled, mis-set or
configurator-written byte always came up as derate disabled rather than
falling back to the limit the product ships with.

Now: outside the window resolves to TARGET_DEFAULT_TEMPERATURE_LIMIT, and
255 is preserved as the explicit "deliberately disabled" sentinel.

Verified on an ARK G431 CAN bench: byte 43 written as 150 (out of window)
comes up as 105 in live RAM with the derate armed. Before, that path left
it 255 and the ESC ran unprotected.

No behaviour change for other products: TARGET_DEFAULT_TEMPERATURE_LIMIT
defaults to 255, so out-of-range still resolves to disabled exactly as
before. Only targets that ship a real thermal limit gain the fail-safe.

The window bounds now live in targets.h as THERMAL_LIMIT_MIN_C /
_MAX_C / THERMAL_LIMIT_DISABLED and BOTH validators use them, so they
cannot drift apart again - the DroneCAN entry carried duplicated literals
(70, 105) with no link to settings.c or to the target defaults.

THERMAL_LIMIT_MIN_C is also a build-time override for bench verification:
the derate runs from limits.temperature UPWARD to +temp_derate_band_c, so
exercising it at the shipped 70 C floor needs the die driven past 70 C,
which a propped ESC sitting in its own slipstream may never reach (an
earlier 5-inch/6S attempt plateaued at 57 C). Overriding only settings.c
is not enough - the DroneCAN table silently put a 30 C write back to 105,
which is how the second floor was found. Never lower it in a shipping
build; check-erase-defaults.py gates the shipped pair.

check-erase-defaults.py passes, ARK_G431_CAN and ARK_4IN1_F051 build
clean, SITL test_params.py + test_dronecan.py green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AlexKlimaj
AlexKlimaj force-pushed the feat/embed-g431-can-bootloader branch from afac7cc to a40c908 Compare August 13, 2026 20:58
Emit a PX4 APDescriptor-signed copy as 71-3.0.2.uavcan.bin so the
12S CAN ESC can be flashed from the flight-controller SD card.
Hardware 0.71 (board_id 71) is unique versus stock AM32 2.3 and
ARK/AP_Periph cannode IDs. GetNodeInfo reports image_crc so PX4
does not re-flash the same image every boot.
Refresh AM32_G431_BOOTLOADER_ARKG4_CAN_V18.bin from ARK32-bootloader
feat/ark-g431-can-dual-protocol (060c417) so a node sitting in the
bootloader matches PX4 /ufw/71.bin the same way the app does.
Bake AM32_G431_BOOTLOADER_ARKG4_CAN into every ARK_G431_CAN image
(including HWCI_PERF) so a manual or PX4 app flash rewrites the on-chip
BL to UAVCAN hw 0.71 on first boot. Fix G431 save_flash_nolib PNB so
multi-page BL program erases the right 2 KiB pages.
PX4 scans every *.bin to EOF for APDesc00, so a factory.bin would be
staged as /ufw/71.bin and written at app base. Emit .factory.img and
refuse a live-descriptor .bin. Close G431 .hex ALIGN holes that broke
the AM32 CRC, flush I/D flash cache around BL rewrite verify, and
grace-poll HWCI before reset_run when the ELF embeds a BL.
Name images <target>_<version> (e.g. ARK_G431_CAN_3.0.2-ark.hex).
The ARK32 configurator will look up that name. CI rejects leftover
AM32_*.hex so a dirty obj/ cannot publish the old URL. Run make clean
before rebuilding.
Emit <board_id>-<MAJOR.MINOR.PATCH>.<githash>.uavcan.bin so the SD-card
copy matches the APDescriptor commit already reported to PX4.
Workflows still pointed at obj/AM32_AM32_SITL_CAN_*.elf, so SITL,
calibration, and capture-GUI jobs failed FileNotFound after e207655.
GetNodeInfo must load git_hash and image_crc from the patched
APDescriptor. Those fields are 0 at compile time, so LTO replaced the
reads with literal zeros and PX4 treated every boot as a reflash.
Staging /ufw/71.bin is not the flash. PX4 only sends
BeginFirmwareUpdate when image_crc differs; the bootloader does not
scan the SD card; an assigned motor's RawCommand boots the app
before PX4 asks. Unassign the ESC and stay disarmed until OPERATIONAL.
set_input() assigned dshot = bi_direction, which is 0 when reverse
is off. After the first CAN command computeDshotDMA() never ran
again, so the 250 ms failsafe zeroed throttle and the wire path
stayed dead until reboot. Only set dshot=1 when reverse is on.

Bench (345 kV / 13x10): DShot 10% then CAN 20% then drop CAN
returns to 2442 RPM instead of sitting at 0.
find_artifact() globbed ARK_G431_CAN_*.bin and took the last
alphabetical hit, so hwci ci programmed the 2 KiB EEPROM sidecar
at 0x08004000. Skip factory/EEPROM/UAVCAN sidecars and pick by
mtime.

Copy link
Copy Markdown
Member Author

Bench follow-up on the stand (MAD Polar XC5000 345KV + HQProp 13×10, 12S, Korlan USB2CAN + Flight Stand DShot).

AUTO input handover (306da0e) — set_input() used to do dshot = bi_direction. On this board that is 0, so the first esc.RawCommand stopped computeDshotDMA() forever. Live CAN correctly overrode DShot, but after the 250 ms failsafe the wire path stayed dead (RPM sat at 0 with DShot still coming in).

Fix: only set dshot = 1 when reverse is on; never clear a detected wire protocol.

Retest, same 10% / 20% points:

Phase Sources RPM
A DShot 10% only 2442
B DShot 10% + CAN 20% 3628 (CAN wins)
C CAN dropped, DShot still 10% 2442 (wire resumes)
D CAN 10% only 2442
E DShot 20% + live CAN 10% 2442 (CAN keeps it)
F Both off 0

Phase C is the one that failed before the fix.

hwci artifact picker (8de1199) — find_artifact() globbed ARK_G431_CAN_*.bin and took the last alphabetical hit, so hwci ci programmed *.eeprom.bin (2 KiB) at 0x08004000. Sidecars are skipped now.

Also on this PR since last comment: PX4 SD-card update notes in the README (staging /ufw/71.bin is not the flash; unassign the motor or RawCommand boots the app before PX4 asks).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants