### Summary
Defer RC auxiliary-function initialization until vehicle and librar…y backends have been created, while keeping the small set of functions that gate the vehicle from boot in `RC_Channels::init()`. This is a long standing bug impacting several subsystems. Currently switch inits can occur before the backend exists and silently fail due to nullptr checks.
### Classification & Testing (check all that apply and add your own)
- [ ] Checked by a human programmer
- [ ] Non-functional change
- [ ] No-binary change
- [ ] Infrastructure change (e.g. unit tests, helper scripts)
- [x] Automated test(s) verify changes (e.g. unit test, autotest)
- [x] Tested manually, description below (e.g. SITL)
- [ ] Tested on hardware
- [ ] Logs attached
- [ ] Logs available on request
Testing performed:
- `./waf --targets tests/test_aux_initialisation`
- `build/sitl/tests/test_aux_initialisation`
- `./waf copter plane rover replay`
- `./waf --targets examples/RC_Channel,examples/AC_PID_test`
- `./waf --targets tests/test_aux_boot_position tests/test_aux_early_gating` and both binaries
- `valgrind build/sitl/tests/test_aux_initialisation` and `valgrind build/sitl/tests/test_aux_boot_position` - `0 errors from 0 contexts` (11, from the late phase's sweep of channels the test had not given a `radio_in`, before every channel was seeded)
- `Tools/autotest/autotest.py test.Copter.AuxSwitchOptions test.Copter.AuxFunctionsInMission test.Copter.MountPOIFromAuxFunction` - all pass
- `Tools/autotest/autotest.py test.QuadPlane.ICEngine test.QuadPlane.ICEngineMission test.QuadPlane.MAV_CMD_DO_ENGINE_CONTROL test.QuadPlane.ICEngineStartChanMinAtBoot` - all pass; the new test fails on the un-fixed engine (RPM reaches 70 with no command given, and `DO_ENGINE_CONTROL` returns `ACCEPTED` where it should be `FAILED`)
- SITL Copter with `RC9_OPTION=165` (`ARM_EMERGENCY_STOP`): arming is refused with `Arm: Motors Emergency Stopped`, confirming emergency-stop is engaged from boot. Same build without the switch configured arms normally.
- SITL Copter and `Tools/Replay` both run through `AP_Vehicle::setup()` without the null-deref that an unguarded `rc()` accessor introduced
- `Tools/scripts/check_branch_conventions.py --base-branch upstream/master`
- `git diff --check upstream/master...HEAD`
### Description
Some RC auxiliary functions depend on backends created during vehicle and library initialization. Calling `init_aux_all()` directly from `RC_Channels::init()` therefore applies the initial switch state before those dependencies exist, where it silently fails against a null backend.
**The concrete example.** On Copter, `rc().init()` is `ArduCopter/system.cpp:68`, while `camera_mount.init()` is `:113` and `camera.init()` is `:118` — both of which allocate their backends (`NEW_NOTHROW`) inside those calls. So the explicit-init mount functions (`RETRACT_MOUNT1/2`, `MOUNT_YAW_LOCK`, `MOUNT_RP_LOCK`, `MOUNT_POI_LOCK`) reach `AP_Mount::set_mode()`, which returns early on a null `get_instance()`, and the camera ones (`CAMERA_REC_VIDEO`, `CAMERA_ZOOM`, `CAMERA_MANUAL_FOCUS`, `CAMERA_AUTO_FOCUS`, `CAMERA_LENS`) reach the primary-instance overloads — `do_aux_function_record_video()` calls `AP_Camera::record_video(bool)`, which returns `false` on `primary == nullptr` (`AP_Camera.cpp:183`). A guaranteed silent no-op, 45–50 lines before the backend exists. See also https://discuss.ardupilot.org/t/copter-4-7-1-beta1-released-for-beta-testing/145262/3
**Scope of the fix.** `debounce_position` and `current_position` both default to `-1`, and `read_aux()` seeds-without-executing only for the `init_position_on_first_radio_read()` set — so for the mount/camera functions above, the first debounced RC read already re-fires them against live backends. What this genuinely fixes is (a) the window before the first valid RC read and (b) installations where RC never becomes valid — with the caveat in *Where the switch still cannot be read* below, which bounds what (b) is worth. That re-fire is now suppressed where the deferred phase managed a live read of its own - see *The late phase reads the switch* below.
### Two-phase initialization
Aux initialization is now split, with the split stated in code rather than falling out of which call site won:
- **`RC_Channels::init_aux_early()`**, still called from `RC_Channels::init()`, initializes the functions that establish a safe or gated state at boot and have no backend dependency of their own: `ARM_EMERGENCY_STOP`, `MOTOR_ESTOP` and `RC_OVERRIDE_ENABLE`. `RC_Channel::init_aux_function_early()` is the allowlist.
- **`RC_Channels::init_aux()`**, called near the end of `AP_Vehicle::setup()` after backend initialization, initializes everything else.
Deferring the first group would have been a real regression, because nothing re-establishes their state afterwards:
- `SRV_Channels::emergency_stop` is a plain zero-init static (`SRV_Channels.cpp:53`), so `false` at boot. With `RCx_OPTION=165` the `LOW` branch is what asserts it (`RC_Channel.cpp`, `set_emergency_stop(true)`), and `read_aux()` *seeds without executing* for exactly this function set — so had the assertion moved to the end of `setup()`, the interlock would have been disengaged across the whole startup window (through `AP::arming().init()`, with MAVLink live via `scheduler_delay_callback()`), and would not have been re-asserted until the pilot moved the switch. `AP_Arming::estop_checks()` and `arm_checks()` both key off `get_emergency_stop()`.
- `_gcs_overrides_enabled` defaults to **`true`** (`RC_Channel.h:819`), and `RC_OVERRIDE_ENABLE`'s `LOW` branch is what clears it. Deferring it would have left GCS RC overrides ungated (`RC_Channel::set_override()` returns early only when disabled) for the whole of startup for a user who deliberately gated them behind a switch.
Keeping these three early is byte-for-byte the master behaviour for them, so no new analysis of their handlers is required.
No vehicle-specific auxiliary function currently needs early initialization. I checked the three vehicle overrides: Plane's `REVERSE_THROTTLE` handler is order-sensitive but self-contained — it sets `plane.have_reverse_throttle_rc_option` **and** calls `channel_throttle->set_range(100)` itself, and the only other `set_control_channels()` caller is `Plane::one_second_loop()` (`ArduPlane/Plane.cpp:365`), which runs after `setup()` returns; Copter's `MOTOR_INTERLOCK` only sets `copter.ap.motor_interlock_switch`, whose zero-init value is the conservative one, so deferring it is if anything safer; Rover's only init-executed function is `SAILBOAT_MOTOR_3POS`. `RC_Channel::init_aux_function_early()` is `virtual` and `protected` so that one can: a vehicle may classify a function of its own as early either by overriding outright or by delegating to the base implementation, without re-enumerating the base allowlist.
### Other behaviour notes
`scripting.init()` now runs after aux initialization rather than ~25 subsystem inits earlier, so a script with a raised `SCR_THD_PRIORITY` cannot read aux state such as `get_emergency_stop()` before it is initialized.
Functions that must not act on whatever position a switch happens to be in the first time it is read — `ARM_EMERGENCY_STOP` and the rest of `init_position_on_first_radio_read()`'s set — are initialized to the safe LOW position instead of the live read, since deferred init means RC input may already be live. The scheduled `read_aux()` path remains responsible for picking up the pilot's actual switch position once it has been safely observed.
The deferred group's initial switch position is now applied near the end of `AP_Vehicle::setup()`, after backend and motor/ESC initialization — `FENCE`, `GPS_DISABLE`, `AVOID_PROXIMITY`, `AVOID_ADSB`, the mount/camera functions and the rest of the explicit-init list. Outputs remain safed and arming still requires explicit pilot or GCS action, and the functions which gate the vehicle are no longer among those deferred, but reviewers should note this small additional initialization window.
### The late phase reads the switch
`RC_Channel::init_aux()` determines its initial switch position the same way `RC_Channels::init()` did, with a live `read_3pos_switch()`. Running at the end of `AP_Vehicle::setup()` rather than the start, that read can now succeed where it previously failed and the position was defaulted to `LOW`. Which way it goes is per-vehicle, and two separate routes read RC during setup:
| | reads RC during `setup()` | late read |
|---|---|---|
| Plane / QuadPlane | `Plane::failsafe_check()` calls `rc().read_input()` once the main loop has stalled 200 ms, i.e. for the whole of `setup()` (`ArduPlane/failsafe.cpp:44,48`) | **succeeds** |
| Copter, multirotor | `esc_calibration_startup_check()` (`ArduCopter/system.cpp:74`, immediately after `rc().init()`) polls `read_radio()` for up to 2 s waiting for first RC input | **succeeds** |
| Copter, TradHeli or brushed PWM | that poll is skipped (`#if FRAME_CONFIG != HELI_FRAME`, and an early return for brushed motors, `esc_calibration.cpp:12-17`) | fails, defaulted |
| Rover, Sub, Blimp, Tracker | nothing reads RC during setup | fails, defaulted |
and on any vehicle with no working RC at all it fails regardless. So the live-read consequences below apply to Plane **and to multirotor Copter**, not to Plane alone.
> [!WARNING]
> **Known open defect at this head, for the dev call rather than for this description to resolve.** That boot read is not gated on RC input being *valid*, where the scheduled `RC_Channels::read_aux_all()` path refuses to act on auxiliary functions at all when it is not (`RC_Channels.cpp:243-248`). A receiver in failsafe still streams frames with in-range PWM, so its failsafe positions are read and applied as though the pilot had selected them. Reproduced on QuadPlane as an **engine start from a receiver in failsafe, while disarmed** (`ICE_OPTIONS=0`, `THR_FAILSAFE=1`, `THR_FS_VALUE=1100`, throttle 1000, engine switch 2000 → RPM 350 across a reboot; the merge base gives RPM 0).
>
> The obvious gate does not work: `has_valid_input()` is false on **every** Plane boot regardless of RC, because `Plane::rc_failsafe_active()` keys off `failsafe.last_valid_rc_ms`, written only inside the scheduled `Plane::read_radio()` (`ArduPlane/radio.cpp:132`), which has not run when `init_aux()` does — while on multirotor Copter it is true only because `copter.failsafe.radio` is zero-initialised. Neither vehicle has validated its RC at that point; they report opposite defaults. Gating on it would disable the live read entirely on Plane while leaving Copter exposed.
>
> Resolving this is a design decision, and the leading option — dropping the live read from the late phase altogether — would remove the `AP_ICEngine` commit and the boot-position recording along with it, and would materially shrink this PR. See [the discussion here](https://github.com/ArduPilot/ardupilot/pull/34166#issuecomment-5576690874). The reproduction exists as a working autotest and will be pushed with whichever fix is chosen.
That has one consequence beyond the ordering fix, addressed here rather than left to fall out.
**A switch held HIGH at boot would otherwise be applied twice.** Neither master's `init_aux()` nor this PR's touches `switch_state`, and `current_position` starts at `-1`, so the first debounced `read_aux()` re-runs the function. On master the pair was a defaulted `LOW` at init and then a live `HIGH` once RC came up - a genuine change, correctly acted on. With a successful late read the pair becomes `HIGH` then `HIGH`, running the function twice for a switch that never moved; on a mount that is visible, since `set_poi_lock()` re-saves `saved_mount_mode`, and `do_aux_function_mission_reset()` would reset the mission twice. This is a latent bug in master's `init_aux()` too - it reads the switch live and does not touch `switch_state` either - and deferring the phase is what makes it reachable.
`RC_Channel::run_aux_function()` therefore records `current_position`/`debounce_position` for its `Source::INIT` dispatch, so `debounce_completed()` suppresses the duplicate. Four conditions, each of which keeps an application that the first debounced read is still owed:
- **Only for a position that was really read.** A defaulted position is deliberately not recorded, so the pilot's real position is still acted upon by `read_aux()` once RC comes up, exactly as on master. `RC_Channel::init_aux_function_at_boot()` carries that fact into the dispatch.
- **Only for functions initialisation actually applied.** `init_aux_function()`'s first case group is the "do not need to be initialised" set, which falls through to a bare `break` precisely so that the first debounced `read_aux()` applies it - `RELAY`-`RELAY6`, `GRIPPER`, `LANDING_GEAR`, `EKF_SOURCE_SET`, `SCRIPTING_1`-`16` and the rest. Recording a position for those would suppress that read and the output would never follow the switch until the pilot toggled it. Doing the recording in `run_aux_function()` rather than in the caller is what draws that line, since it is reached only for the functions that were applied. (Rover applies `SAILBOAT_MOTOR_3POS` without going through `run_aux_function()`, so it keeps the old behaviour; it cannot reach a live boot position anyway.)
- **Only when the application succeeded.** `do_aux_function()` returning false means the function did not take - `AP_Camera_MAVLinkCamV2` rejects commands until camera discovery completes, for instance - so the position is not recorded and the first debounced read retries it.
- **Only while the function is still the channel's.** `init_aux_early()` latches the function for both phases deliberately, but `switch_state` is per-channel rather than per-function. A `PARAM_SET` arriving in the window between the phases - MAVLink is serviced there by `scheduler_delay_callback()` - leaves the late phase applying the latched function; recording its position would suppress the read which is the only thing that would ever apply the replacement, so an `RCx_OPTION` changed during boot would silently never take effect.
**`ICE_START_STOP` needs the same treatment on the other side of the interface,** which is what the `AP_ICEngine` commit does. `AP_ICEngine::do_aux_function()` applied the `ICE_STARTCHN_MIN` floor only to `Source::RC`. That did not matter while the initialisation dispatch always carried a fabricated `LOW`; with a live boot position it does, because the switch position is not a function of the PWM alone. A reversed channel with `RC_OPTIONS` bit 7 set reads a low PWM as `HIGH`, so a boot position the RC path would have filtered can reach `should_run = true` and start the engine with no command given. Rejecting the dispatch is necessary but not sufficient: `aux_pos` defaults to `MIDDLE`, which permits a commanded start, so a rejected initialisation also has to leave it at the `LOW` that initialisation has always established, or a `DO_ENGINE_CONTROL` or mission engine-start item would be accepted where it previously was not. Both are covered by `QuadPlane.ICEngineStartChanMinAtBoot`, and each fails independently if its half of the fix is removed.
This only diverges from master where `ICE_STARTCHN_MIN` is non-zero; it defaults to 0, which leaves the floor inactive out of the box.
### Where the switch still cannot be read, the defaulted LOW now reaches a live backend
The other side of the same coin, and the reason the row above matters in both directions. When `read_3pos_switch()` fails, `initial_aux_switch_position()` returns `LOW` and the late phase dispatches it, exactly as `RC_Channels::init()` did on master. What has changed is where that dispatch lands: on master it hit a **null** backend and no-op'd; now the backend exists, so any deferred function whose `LOW` branch is an action rather than a neutral state performs that action at boot. `CAMERA_ZOOM` `LOW` issues a continuous zoom-out rate command, `CAMERA_MANUAL_FOCUS` a continuous focus-in, `RETRACT_MOUNT1` calls `AP_Mount::set_mode_to_default()`, `MOUNT_POI_LOCK` calls `clear_poi_lock()` and emits a "POI: Cleared" statustext, and `RUNCAM_CONTROL` calls `stop_recording()`.
**This is deliberate, not an oversight, and it is the intended consequence of the change** — the whole complaint being fixed is that these initialisations silently failed against a null backend. Where RC does become valid the first debounced `read_aux()` corrects it about 200 ms later, since a defaulted position is not recorded and so that read still fires; `RCChannel.AuxFunctionDefaultedBootPosition` pins exactly that. Where RC never becomes valid, the defaulted `LOW` stands.
Who is newly affected, precisely: **TradHeli and brushed-PWM Copter frames**, **Sub** (whose `camera_mount.init()` at `ArduSub/system.cpp:91` is after `rc().init()` at `:52`, and which deliberately sets `RC_TARGETING` at `:95`), and any vehicle flying without working RC. **Rover is not affected** — its `camera_mount.init()` (`:84`) and `camera.init()` (`:89`) already run before `rc().init()` (`:139`), so master already delivered that defaulted `LOW` into live backends there.
The alternative is to skip the late dispatch entirely when the position was defaulted. That is worth stating because it is *not* simply "restore master": master does perform this dispatch, and skipping it would also remove the boot `LOW` for Rover and for every late function whose backend was never null, so it is its own behaviour change rather than the absence of one. Raising it here so the choice is explicit rather than implicit.
The new call site is guarded two ways: on the `RC_Channels` singleton being present, because `Tools/Replay`'s `ReplayVehicle` inherits the `final` `AP_Vehicle::setup()` but never constructs one; and on `AP_RC_CHANNEL_ENABLED`, since `RC_Channel.h` wraps the whole class in it. (There is no `AP::rc()` in tree — only the free `rc()`, which dereferences the singleton unchecked, so the null guard is required rather than stylistic.)
`tests/test_aux_boot_position.cpp` covers the boot position against the *real* `RC_Channel::init_aux_function()` and the real debounce path, because what is under test is the interaction between the two: a function applied from a live boot position is not applied again by the first debounced read; a function initialisation did not apply still is; a switch that genuinely moves between the two is still picked up; a defaulted boot position is re-applied when RC comes up in that same position, since nobody had read it; a failed application is retried; and an `RCx_OPTION` changed between the phases still takes effect. Each of those fails if the corresponding condition is removed.
`tests/test_aux_early_gating.cpp` mocks nothing at all, and pins the property the early allowlist exists for: after `RC_Channels::init()` alone, with no backend created and `init_aux()` not yet called, `SRV_Channels::get_emergency_stop()` is true for a channel carrying `ARM_EMERGENCY_STOP` (even with the switch physically HIGH) and `gcs_overrides_enabled()` is false for one carrying `RC_OVERRIDE_ENABLE`. Moving either function out of `init_aux_function_early()` turns it red; the other two test files mock `init_aux_function()` and `do_aux_function()` respectively, so neither of them would notice.
`tests/test_aux_initialisation.cpp` covers the phase split, and its test channel overrides `init_aux_function_early()` the way a vehicle subclass would — by delegating to the base list rather than re-enumerating it — to check that a subclass's own early function is initialised by the early phase: the boot-gating functions are initialized during `RC_Channels::init()` and not a second time afterwards, a backend-dependent function (`FENCE`) is not initialized until `RC_Channels::init_aux()`, `MOTOR_ESTOP` is initialized from its live boot position, and `ARM_EMERGENCY_STOP` is initialized LOW even with its switch physically HIGH at boot.
This contribution was developed with Codex and Claude AI assistance. The AI helped analyze the initialization ordering, implement the targeted changes and regression test, and prepare the PR description.