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 66 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.
094c3ac to
76b35eb
Compare
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.
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.
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>
afac7cc to
a40c908
Compare
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.
|
Bench follow-up on the stand (MAD Polar XC5000 345KV + HQProp 13×10, 12S, Korlan USB2CAN + Flight Stand DShot). AUTO input handover ( Fix: only set Retest, same 10% / 20% points:
Phase C is the one that failed before the fix. hwci artifact picker ( Also on this PR since last comment: PX4 SD-card update notes in the README (staging |
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