feat(ARK_G431_CAN): 12S CAN target, factory BL image, DRV8350 sleep - #79
feat(ARK_G431_CAN): 12S CAN target, factory BL image, DRV8350 sleep#79AlexKlimaj wants to merge 53 commits into
Conversation
HWCI: no F051 regression vs today's
|
| 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.
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.
Free-run characterization of the ARK CAN ESC Rev 2.0 (STM32G491, DRV8350H) on a JS 2306 1800 KV article, no prop, showed startup stutter and mid-band ZC jitter that the ARK 4IN1 F051 does not exhibit. Root causes are G4 specific -- dual COMP instead of a single muxed COMP, and a 160 MHz wall-clock the F051-derived tuning constants do not scale to. All changes are behind MCU_G431; the ARK_4IN1_F051 image is byte-identical to before. Grid hump compensation (bemf_zc.c): the band was ci * 40 < arr (~2 PWM periods), carried over from the F051 scale. The measured jitter peak sits at ~2.3-3.2 PWM periods (50-70% throttle, ~150-210k eRPM), outside that band, so the compensator never armed. Widened to ci * 24 (~3.5 periods). ZC filter schedule (runtime_loop.c): split G431 off the shared F051 block. RUN_MIN/FAST go one step above F051 for the faster wall-clock (12/9 -- an A/B at 14/10 measurably hurt the clean 15-20% band), MAX to 48 for the noisier dual-COMP acquisition tier. Comparator hysteresis (comparator.c, peripherals.c): G4 ran HYSTERESIS_NONE unconditionally. Always-on 10 mV blocked genuine edges at free-run crawl, where BEMF sits near 10 mV, and latched stuck rotor at 5-6%. Now applied only when slow and already into a real drive (average_interval >= 400 && duty_cycle > 200); free-run crawl stays NONE. Poll -> interrupt handoff (commutation.c, esc_state.c): comparator noise could pull commutation_interval under POLLING_MODE_THRESHOLD before the rotor was really turning, arming closed loop on a false CI (~1.3k ticks vs a true ~3k+ free-run) and driving OPEN<->CLOSED thrash. Handoff now also requires zero_crosses >= 40, poll re-entry is more eager during acquisition, and COMP IRQs are masked whenever leaving closed loop so the interrupt and poll ZC paths cannot fight. Acquisition stall grace (faults.c): restart kicks reset zero_crosses, so bemf_timeout_happened accumulated across them and latched stuck_rotor after a few cycles. Acquisition-only stalls are now forgiven, but gated on adjusted_input < 400 -- a truly locked rotor never reaches zc >= 100, so an ungated clear would disable stuck rotor protection entirely during acquisition and let a jammed prop re-kick without bound. Observed thrash is all in the 5-20% tiers, below that gate. The soft budget likewise holds 100 out to input 400 instead of 150. Bench (hwci noprop_startup_matrix, 25 V / 3 A): CLOSED->OPEN transitions 55 -> 14 desync lines 31 -> 22 start successes 21/21 mid-band jitter @ t70 ~9.2% -> ~6.3% Starts at >=8% are now almost entirely closed loop; 6% remains rough on weak free-run BEMF. Loaded (prop) characterization is still outstanding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the free-run profiles used for the G431 12S CAN BEMF work: noprop_startup_matrix (repeated starts across throttle tiers), noprop_zc_map (zero-cross jitter vs throttle), noprop_low_rpm_crawl, and noprop_step_stress. bemf_campaign_summary.py rolls a campaign's runs into a markdown table of ZC jitter, eRPM, and open/closed transition counts. Also stops the debug UART host from hard-aborting on desync/stall console lines. Free-run acquisition and the crawl tiers emit those constantly and they are the measurement, not a failure -- aborting on them made the ZC map unrunnable. LiveDesyncWatch and stand safety remain authoritative; only an nFAULT gate-driver latch still aborts a run. noprop_step_stress is characterization only: fast free-run throttle snaps trip DRV8350H OCP on di/dt, so its runs are not valid for filter A/B. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The throttle-gated forgiveness added in 700683e used adjusted_input < 400, sized from an assumption that the startup matrix topped out near input 400. On the bench the 20% tier actually reaches input 447, so the gate landed inside the sweep instead of above it: at start20b the firmware reported bemf_timeout=1 with eRPM collapsing 8700 -> 3000 at zc=2, and the host LiveDesyncWatch aborted the run partway through. Any fixed throttle threshold has this problem -- it has to sit above the highest tier under characterization, which is exactly the region where a jam is most dangerous and least deserving of grace. Bound the grace in time instead. acq_grace_ms clocks at 1 kHz while throttle is commanded and the loop has not established; a pilot cut or a real acquire rearms it. A start attempt is short and separated by zero throttle, so it is always forgiven; a genuine jam holds throttle continuously and latches once the budget expires. Grace is additionally withheld above input 1000, where acquisition should be near instant. Also documents why the unbounded clear was worse than it looked: because ESC_STUCK_LATCH is itself carried in bemf_timeout_happened, clearing that variable every tick during acquisition wiped already-latched stuck faults as well -- the previous run logged 16 FAULT_STUCK entries that all recovered within a tick and never reached the host. Bench (noprop_startup_matrix, 25 V / 3 A, JS 2306 1800 KV, no prop), in-run lines only, vs the ungated baseline: starts succeeded 21/21 -> 21/21 CLOSED->OPEN 14 -> 12 fault: desync 22 -> 12 fault: stall 50 -> 48 fault: stuck / FAULT_STUCK 8/16 -> 8/16 aborted no -> no Full profile completes, 11950 samples, no reboots after arming. The grace budget is not reached by a 3.0 s start segment, so latch-after-expiry is reasoned from the timer rather than measured; a deliberate locked-rotor test would be needed to exercise it directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per-tier instrumentation of the free-run startup matrix puts the ARK 12S CAN ESC at 2.09% open-loop residency in the 8% tier against 0.77% for the ARK 4IN1 F051 on the same motor and bench, and at 20.6% in the 6% tier, where all eight stuck faults of the run occur. The 10-20% tiers are a flat 1.5-2.1%. Three successive tuning iterations (hump multiplier, filter schedule, handoff gating, hysteresis policy) moved that residency by less than counting noise, which is expected: they all act downstream of the comparator, and the BEMF dividers are 21:1 on both boards, so the signal presented to the comparator is not the difference either. What is left is comparator-input noise, and the G4 has a mechanism for it that the F051 physically lacks. Enable COMP output blanking from TIM1 OC5 on both comparators. TIM1 CH5 has no pin and exists only to generate OC5REF; in edge-aligned PWM1 the phase turns on at CNT==0, so CCR5=N gates the comparator over the first N ticks of every PWM period, which is exactly the switching transient. The window must be small. Crossings that physically occur during the PWM off-window are invisible to the comparator and are legitimately registered just after turn-on -- the turn-on-pileup compensation in bemf_zc.c exists for precisely those, and they land from arr>>5 (~650 ns at 48 kHz) onward with the bin-1 peak at ~1.3-2.6 us of comparator+ISR latency. Blanking into that range would suppress real crossings instead of noise. 80 ticks = 500 ns = DEAD_TIME covers the dead-time commutation edge and stops short of the pile-up window; under a variable_pwm ARR halving it is still under 5% of the period. COMP_BLANK_TICKS is a single knob for sweeping it. No target in the tree had ever selected a blanking source: g071 configures and enables TIM1 CH5 for this (Src/main.c changelog 1.92) but leaves the COMP source at NONE and never writes CCR5, so the feature was scaffolded and left inert. Verified in the built image: COMP_CSR_BLANKING_0 set on both comparators and CCR5=80 written to TIM1. LL_COMP_ConfigInputs and LL_COMP_SetInputHysteresis are both targeted MODIFY_REGs that do not touch COMP_CSR_BLANKING, so the per-commutation runtime path cannot clear it. ARK_4IN1_F051 remains byte-identical; format, cppcheck and codegen-check-ark all pass. Not yet bench-tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The campaign summary reported desync-line counts, which turned out not to track sync quality: three G431 tuning iterations scored 22/12/12 desync lines while their open-loop residency was identical to within counting noise. Two runs shipped before anyone noticed they were equivalent, because the metric that separates them had to be computed by hand. Add both metrics to the summary so an A/B is readable directly from the generated markdown: - Closed-loop retention: share of driving samples in OPEN_LOOP within each start* throttle tier. Per tier because the deficit is not uniform -- on the G431 the 6% tier sits at 20.6% while 8-20% are a flat 1.5-2.1%. The ARK 4IN1 F051 reference (0.77% at 8%, same motor and bench) is quoted in the section header so runs are read against the working board rather than against each other. - Faults by tier: every debug-UART "fault:" line mapped to the segment it landed in. samples.csv perf_host_t and the debug_uart.log timestamps are the same host monotonic clock, so this is an exact lookup, not a reconstruction from nominal segment timing -- which matters, because the reconstruction misattributed the stuck faults to the 8% tier when all eight are in fact at 6%. Pre-run signal_lost reboot backlog, which can run to dozens of lines buffered between flash and run start, is excluded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The blanking added in 680ab02 is wrong on half the commutation steps and, where it is right, the window ends at the transient it is meant to reject. Polarity. Blanking gates the comparator OUTPUT low; it does not gate COMPx_CSR.VALUE, which ST specifies is "taken before polarity and blanking are applied" (stm32g4xx_ll_comp.h) and is what getCompOutputLevel() reads. Zero crosses come from the EXTI edge on the gated output, so the question is what gating does to edges, and that depends on where the comparator sits before the crossing. On falling-BEMF steps (rising == 0) the raw output is LOW pre-crossing and the awaited edge is rising: forcing LOW changes nothing, and a crossing inside the window is delayed to the window close, never lost. On rising-BEMF steps the raw output is HIGH pre-crossing and the awaited edge is falling: forcing LOW manufactures a falling edge -- the armed polarity -- at every window open, once per PWM period, for the whole search window; and a real crossing inside the window is destroyed rather than delayed, because the output is already low and the raw level is low by the time the window closes, so no edge is ever produced and the step falls through to a blind step. Latched on in COMPx_CSR at init, that ran on steps 1/3/5. The manufactured edges were rejected downstream (the confirm loop reads the ungated CSR.VALUE and sees the pre-crossing level), so the visible cost was an ISR at the PWM rate plus a confirm-reject storm; the destroyed crossings were silent. Arm the blanking source in changeCompInput() alongside the existing per-step hysteresis instead, on the falling-BEMF steps only, and default both comparators to NONE at init. Sizing. The window ran [0, 500 ns). With complementary PWM the phase does not turn on at CNT == 0: OCxREF rises there and the low side turns off, but the high side turns on one DEAD_TIME later. The hard event -- 50 V in 55-81 ns measured, ~500 ns to settle, into low-side body-diode recovery -- is therefore at 500 ns, so the old window covered the quiet dead-time interval and un-gated the comparator at the instant of the edge. 160 ticks = 1.0 us = DEAD_TIME plus edge-and-settle is the smallest window that covers it, and it still clears the 1.3-2.6 us turn-on pile-up peak that the bemf_zc.c timestamp compensation keys off. The stated "must stay below arr>>5" constraint does not bind: arr>>5 is the bin-0/1 boundary, not the pile-up peak, and on the steps that blank a crossing inside the window is delayed, not suppressed. Headroom to watch on the bench: at the target's default 24 kHz (arr 6665) the 6% startup tier is 400 ticks of on-time, so the comparator is live over [1.0, 2.5] us, which only just contains the pile-up peak. That tier is where the stuck faults were; COMP_BLANK_TICKS is the knob to sweep. Also guard the TIM1 CH5 setup with COMP_BLANK_TICKS so the shared g431 driver still builds for a target that does not define it, and record that PHASE_A_INPUT_PLUS on PA3 is the same SENS_COMMON net as PA1 -- COMP2's IO1 is not PA1, so the differing IO index is a pin-mux artifact rather than a second divider, which is not obvious from the defines alone. Verified in the built image: CCR5 = 160 (TIM1+0x48); BLANKSEL (CSR[21:19]) written 0x80000 on both COMP1 and COMP2 on the falling-BEMF path and cleared to 0 on the rising path, inside commutate() in RAM. ARK_4IN1_F051 untouched. format, cppcheck, check-codegen-ark and check-size-ark pass. Not bench-tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127WzAAqiEj15f3LMP4tPQN
Review asks for "a longer software blank at low rpm" as the practical stand-in for the COMP power mode G4 does not have. It is a reasonable lever - rejecting noise in TIME is the only axis left once speed/consumption is fixed - but it has a ceiling that is easy to walk past, so expose it as a knob with the ceiling written down rather than pick a new number blind. COMP1_2_3_IRQHandler discards anything before interval/2 as freewheel demag on the phase just released. That constant is now ZC_SEARCH_BLANK_64THS, in 64ths of the commutation interval, defaulting to 32 - the value that has always been there. The ceiling: the schedule commutates waitTime = interval/2 - advance after a crossing, so the next crossing is due at interval/2 + advance, i.e. (32 + advance) 64ths after commutation. auto_advance runs 13..23, putting the expected crossing at 45..55/64. A blank past ~40 starts eating margin at the low-advance end and past 45 rejects the crossing it is waiting for. So there is real room between 32 and ~40, and none above it - which is worth knowing before a sweep rather than after a desync. The default is spelled as the shift it replaces, so leaving the knob alone is provably a no-op and the general form's intermediate product cannot overflow on an interval it never sees. ARK_G431_CAN is byte-identical at 32; at 40 it builds and costs 4 bytes. ARK_4IN1_F051 untouched. format, cppcheck, check-codegen-ark and check-size-ark pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127WzAAqiEj15f3LMP4tPQN
Raising comparator hysteresis at low rpm is a standing suggestion and the bench already refuted it once (2026-08: always-on 10 mV blocked real edges and latched stuck-rotor at 5-6 %). Record the datasheet numbers next to the policy so the argument does not have to be rediscovered from a stuck rotor. DS13122 Rev 4 Table 73: LL_COMP_HYSTERESIS_10MV is HYST = 1, 9 mV typical but up to 16 mV, on top of an input offset of -9..+3 mV. The next step up is 18 mV typical, up to 32 mV. Against ~132 mV at 1000 rpm falling linearly with speed, code 1 already eats a worst-case 25 mV of the crawl budget. There is no headroom above it, which is also where the 350-450 rpm detection floor comes from. Points at the two axes that are left: reject in time (ZC_SEARCH_BLANK_64THS, COMP_BLANK_TICKS), or read the signal with something whose floor is lower than the comparator's. Comment only - ARK_G431_CAN byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127WzAAqiEj15f3LMP4tPQN
CI pins clang-format 22.1.5; a local `make format` run under Ubuntu's 18.1.3 re-indented a preprocessor-guarded comment in SystemClock_Config that the two versions disagree about, breaking check_format. Reformatted with 22.1.5. Whitespace only, in a region untouched by the blanking work - ARK_G431_CAN byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127WzAAqiEj15f3LMP4tPQN
094c3ac to
76b35eb
Compare
Document Alka's ideal hold-last blanking vs ST G4 force-low behavior, why EXTI only blanks cleanly on falling-BEMF steps, and how ARK arms TIM1 OC5 blanking. Cross-link from README, targets.h, and G4 comparator paths.
DRV8350H/DRV8328 nFAULT is a single open-drain OR of UVLO, OCP, OTW, and GDF with no SPI status. On latch, guess a cause from MCU ADCs (priority UVLO → OTW → OCP → unknown) and report it on debug UART and DroneCAN instead of a generic nFAULT. Same stuck-latch / ENABLE-reset path.
Re-checked the G431 confirm schedule with TIM1-OC5 blanking on the 1980 KV free-run article (optical RPM). Neighbors 42/10/7, 48/13–16, and 56/12/9 do not beat 48/12/9 on mid-band jitter; MAX=56 cut map blinds but worsened startup time-to-run and open-loop residency. Keep 48/12/9, make the levels overridable for future A/B, and add the 1980 KV rig plus a high-current startup-matrix profile.
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>
HWCI: 154 s real-flight replay on the ARK 12S CAN ESCFirst non-stationary throttle test and first real thermal soak on 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 EEPROM: page sha MethodTrace: 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:
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 (
|
| 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.
Summary
Brings the ARK 12S CAN ESC (
ARK_G431_CAN) work ontoark-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 latestark-release).Changes
ARK_G431_CAN/ G491 12S CAN — dead time 500 ns, DroneCAN FDCAN pins, ARK F051-class vehicle policy + ZC handoffgate_driverhelpers (USE_DRV_ENABLE)make factory-image/factory-image-check(F051 + G431 CAN)Bootloaders/AM32_G431_BOOTLOADER_ARKG4_CAN_V18.bin(ENABLE held low in BL); pinBL_IMAGE_G431_CANBootloaders/README.md,factory/README.md; F051 default remains ARK4IN1 nSLEEP-off BLDepends on ARK32-bootloader product
AM32_G431_BOOTLOADER_ARKG4_CAN(dual-protocol + bl-params).Not in this PR
Src/bl_image.S)Test plan
make ARK_G431_CANbuildsmake factory-image-check— F051 + G431 layout/defaults gates pass0x08000000,0xFFpad to app, EEPROM defaults match JSONfactory-image/ static-analysis green.factory.binon ARK 12S CAN; idle VM current drops with ENABLE sleep; FAULT_N path still clears