### Summary
Have `AP_Mount` and `AP_Camera` establish their own RC auxiliary sw…itch positions on their first `update()`, once their backends exist. Replaces #34166, which fixed the same defect by deferring *all* aux initialisation until after backend creation.
### 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 copter plane rover replay` and `./waf AP_Periph` (`sitl_periph_universal`)
- `Tools/autotest/autotest.py test.Copter.MountAuxFunctionAtBoot test.Copter.CameraAuxFunctionAtBoot test.Copter.AuxFunctionAtBootWithoutRC test.Copter.MountAuxFunctionAtBootRCFailsafe test.Plane.MountAuxFunctionAtBoot` — all pass here; the mount/camera ones fail on the merge base (`Mode=3` in the first `MNT` sample; first logged application `source=RC`)
- `Copter.CameraAuxFunctionAtBoot` run 25 times consecutively, no failures (it was measured flaky at ~3% before the assertion was reworked); 10 more at this head with the announcement assertion added
- Mutation-tested: putting `MOUNT_YAW_LOCK` back in `AP_Mount`'s list makes `MountAuxFunctionAtBootRCFailsafe` fail with "MOUNT_YAW_LOCK first applied by source=0, want RC"
- Mutation-tested: removing the GCS announcement makes `CameraAuxFunctionAtBoot` fail with "Failed to receive text: rc6: camera record video high"
- Mutation-tested: removing the pending gate makes `MountAuxFunctionAtBootRCFailsafe` fail with "applied (source=0 pos=2) from a receiver in failsafe"; removing the recorded position makes the RC-sourced-AUXF assertions fail
- The same plus `test.Copter.Mount test.Copter.MountPOIFromAuxFunction test.Copter.AuxSwitchOptions test.Copter.AuxFunctionsInMission` — 8/8, repeated to check the new tests are not flaky
- `test.Rover.CameraMission` — Rover creates the mount and camera *before* `rc().init()`, so it exercises the opposite ordering
- `test.Copter.ModeLoiter` — the one red CI job; passes locally, no aux involvement
- Flash, both trees built the same way: **+200 B on MatekF405** (mount off, camera on; that board compiles the aux-function strings out, so the GCS announcement below is free there) and **+348 B on CubeOrange**, where both libraries and the strings are enabled. No RAM figure quoted: the `.bss` delta `size` reports is a remainder rather than a measurement of RAM
- Mutation-tested: removing the one-shot latch makes both boot tests fail on the at-most-once assertion (684 and 184 applications); recording the position without dispatching makes `CameraAuxFunctionAtBoot` fail with no AUXF logged
- `Tools/scripts/check_branch_conventions.py --base-branch upstream/master`, `git diff --check upstream/master...HEAD`
### Description
`RC_Channels::init()` runs the auxiliary functions once, and on most vehicles it does so before the vehicle has created the backends those functions act on. On Copter `rc().init()` is in `init_ardupilot()` well ahead of `camera_mount.init()` and `camera.init()`, and both of those allocate their backends (`NEW_NOTHROW`) inside themselves.
So `RETRACT_MOUNT1/2`, `MOUNT_YAW_LOCK`, `MOUNT_RP_LOCK` and `MOUNT_POI_LOCK` reach `AP_Mount`'s null-instance early return, and `CAMERA_REC_VIDEO`, `CAMERA_ZOOM`, `CAMERA_MANUAL_FOCUS`, `CAMERA_AUTO_FOCUS`, `CAMERA_LENS`, `RUNCAM_CONTROL` and `RUNCAM_OSD_CONTROL` reach the primary-instance overloads and return `false` on a null primary. A guaranteed silent no-op.
#### What this is worth, stated honestly
`debounce_position` and `current_position` both start at `-1`, so where RC becomes valid the first debounced `read_aux()` applies the switch about 200 ms later. **What this fixes is that window, while disarmed.** That is the whole of it. It deliberately does *not* cover the case where RC never becomes valid: with frames still arriving that is the failsafe case, which is exactly what must not be acted on.
It is *not* an explanation for [this 4.7.1-beta report](https://discuss.ardupilot.org/t/copter-4-7-1-beta1-released-for-beta-testing/145262/3), which #34166 cited as motivation and which I should have checked more carefully before repeating. That report is `MOUNT_RP_LOCK` HIGH at boot, and HIGH sets `_roll_lock`/`_pitch_lock` true — already their initialiser values in `AP_Mount_Backend.h`, re-derived every update in RC targeting. A lost boot position would not change anything there, with or without this PR. Whatever that user hit, this does not fix it.
I still think the defect is worth closing — a switch position that is silently discarded is a bug whatever its blast radius, and it is one of the few places in the codebase where initialisation is known to run against a null backend — but reviewers should weigh it at its real size.
#### Each library establishes its own positions
`RC_Channels::apply_aux_switch_positions()` takes a set of functions, finds the channel carrying each, reads the switch, and re-dispatches through `RC_Channel::run_aux_function()` — not through a second copy of the handler, so there remains one implementation of what each switch position means. `AP_Mount` and `AP_Camera` each call it once, from the top of their first `update()`, with their own list.
**Why the first `update()` and not the end of `init()`.** The switch cannot be read at the end of `init()`: nothing has polled RC by then, and on Rover `camera_mount.init()` runs *before* `rc().init()` at all. By the first `update()` the vehicle's `setup()` has finished and on most vehicles something has read the receiver during it — Copter's `esc_calibration_startup_check()` waits up to two seconds for first RC input, Plane's `failsafe_check()` reads it once the main loop has stalled. Where nothing has (TradHeli and brushed-PWM Copter skip that poll; Rover, Sub, Blimp and Tracker have no equivalent) nothing is applied until RC does come up, at which point the position applied is the one the switch is in then - the same one the first debounced `read_aux()` would have applied.
#### Why not `get_aux_cached()`
This was the obvious cheap fix and it does not work, which is worth recording because it was suggested more than once.
`RC_Channels` already caches the position each function was last *run* with, and the run this is recovering from is exactly the one that had nothing real to record. Nothing calls `rc().read_input()` before `rc().init()`, so `radio_in` is zero for every channel, `read_3pos_switch()` fails, and `RC_Channel::init_aux()` substitutes `LOW`. I instrumented master and confirmed `live=0 radio_in=0` on every Copter boot.
Replaying that cached `LOW` would not restore the pilot's switch position — it would act on a position the pilot never selected. `CAMERA_ZOOM` `LOW` is a continuous zoom-out rate command, `CAMERA_MANUAL_FOCUS` `LOW` a continuous focus-in, `MOUNT_POI_LOCK` `LOW` calls `clear_poi_lock()` and announces "POI: Cleared", `RETRACT_MOUNT1` `LOW` calls `set_mode_to_default()`, overriding the `RC_TARGETING` Sub deliberately sets right after creating the mount.
Reading the switch at first update gives the position it is really in, and **where it cannot be read nothing is applied at all**. `Copter.AuxFunctionAtBootWithoutRC` pins that: it boots with no RC protocol and asserts no position is invented.
#### Only channel-assigned functions
A function invoked solely over MAVLink (`MAV_CMD_DO_AUX_FUNCTION`), by a mission item, or by a script needs no `RCx_OPTION` at all — `GCS_MAVLINK::handle_command_do_aux_function()` calls `rc().run_aux_function()` directly. Those are deliberately not recovered here: each of those callers is told when a dispatch fails (`MAV_RESULT_FAILED`). The RC path has no way to report anything, which is precisely what makes it the one that loses silently.
#### The duplicate application
Neither master's `init_aux()` nor this touches `switch_state`, and `current_position` starts at `-1`, so without care the first debounced `read_aux()` would run every function a second time. That is not harmless: `set_poi_lock()` re-saves `saved_mount_mode`, a Siyi gimbal sends a second autofocus packet, and `AP_RunCam::osd_option()` latches a menu action the update loop consumes.
The helper therefore records an applied position as the channel's current switch position, under three conditions, each of which keeps an application the first debounced read is still owed:
- **Only a position that was really read.** There is no other kind here — nothing is invented.
- **Only when the dispatch succeeded.** `run_aux_function()` returning false (a camera backend that rejects commands until discovery completes) is not recorded, so the read retries it.
- **Only when a backend exists.** The mount handlers return `true` whether or not an instance is there, so `AP_Mount` skips the whole thing when `_num_instances == 0`; otherwise a switch configured against `MNT1_TYPE=0` would be recorded as applied and the read suppressed for a function that did nothing.
A switch that genuinely moves between the two is still picked up, since `debounce_completed()` compares against the recorded position. The tests assert at most one initialisation-sourced AUXF and no RC-sourced one, and go red if the recording or the one-shot latch is removed.
Functions in `RC_Channel::init_position_on_first_radio_read()` — `ARMDISARM`, `ARM_EMERGENCY_STOP`, `PARACHUTE_RELEASE` — are skipped outright by the helper. None are used by these two libraries, but reading a switch and acting on it is exactly what that list exists to prevent, and a generic helper must not become a way around it.
#### The GCS announcement is preserved
`read_aux()` announces each position it applies — `RC7: RetractMount1 HIGH` — but only once `debounce_completed()` returns true, so recording the applied position, which is what stops the function running twice, also suppresses that announcement. Left alone the switch would be applied and nothing said, and that message is exactly what a user checks when a switch appears to do nothing.
The helper therefore makes the same announcement itself, on a dispatch which took. One which did not is not recorded, so the first debounced read still announces it: one announcement either way, same format, same strings, same `AP_RC_CHANNEL_AUX_FUNCTION_STRINGS_ENABLED` guard. `Copter.CameraAuxFunctionAtBoot` waits for `RC6: Camera Record Video HIGH` and goes red without it.
#### `MOUNT_YAW_LOCK` is deliberately not recovered
`AP_Mount_Backend::set_yaw_lock()` does not just set a flag: while the axis is unlocked it *captures* `_yaw_lock_heading_rad` from the gimbal's attitude quaternion plus `AP::ahrs().get_yaw_rad()`, and it recaptures only from the unlocked state — so a heading taken at boot latches and stays until the pilot toggles the switch. Neither input is ready at the mount's first `update()`.
Measured, servo mount, `MOUNT_YAW_LOCK` HIGH at boot, instrumented `set_yaw_lock()` on both trees (three runs each, identical every time):
| | capture time | AHRS yaw read | heading captured |
|---|---|---|---|
| in the list | 2459 ms (first `AP_Mount::update()`) | 0.000° | **0.000°** |
| merge base `35356c656c` | 2739 ms (first debounced `read_aux()`) | −102.781° | −102.781° |
A ~103° error, from the AHRS half alone, on a backend that reports no attitude at all. It is consumed at `AP_Mount_Backend.cpp:159` under the default `MNT1_DEFLT_MODE=RC_TARGETING` and `MNT1_RC_RATE=0`, so it reaches the default configuration.
The other four functions do not do this — `RETRACT_MOUNT1/2` call `set_mode()`, `MOUNT_RP_LOCK` sets two bools, and `MOUNT_POI_LOCK` starts a POI search that times out identically on both trees (`AP::ahrs().get_location()` does not succeed until ~10 s). So `MOUNT_YAW_LOCK` is left out of `AP_Mount`'s list and keeps master's behaviour: the first debounced `read_aux()` applies it 200 ms later. `Copter.MountAuxFunctionAtBootRCFailsafe` asserts the split — `RETRACT_MOUNT1` first applied by `INIT`, `MOUNT_YAW_LOCK` first applied by `RC` — and goes red if it is put back.
A `healthy()` gate would not have been enough: `AP_Mount_Backend::healthy()` defaults to true, and it says nothing about AHRS yaw.
#### Known limitation
This covers backends usable as soon as they are allocated. `AP_Camera_MAVLinkCamV2` does not begin looking for its camera until ten seconds after boot and rejects commands until it has found it, so a switch position for one of those is still lost — as it is on master, since the first debounced `read_aux()` records the position whether or not its handler succeeded. Closing that needs a backend readiness protocol rather than a one-shot at first update, and is out of scope here.
#### Applying the position is held until RC input is valid
`RC_Channels::read_aux_all()` refuses to act when `!has_valid_input()`, and a receiver sitting in failsafe still streams frames with in-range PWM — so reading the switch at boot and acting on it immediately would apply the receiver's failsafe positions as though the pilot had selected them. That was the open trade-off in the first version of this PR, and it is now closed.
The one-shot is **held pending** until `has_valid_input()` is true, rather than consumed at the first `update()`. `Copter.MountAuxFunctionAtBootRCFailsafe` holds the vehicle in throttle failsafe from boot and asserts nothing is applied; it fails if the one-shot is taken early.
Returning early on `!has_valid_input()` instead of holding would *not* work, which is worth recording because it is the obvious version. Measured on the first `MNT` sample with `RETRACT_MOUNT1` in the retract position at boot:
| | Copter | Plane |
|---|---|---|
| held pending (this PR) | Mode=0 (retracted) | Mode=0 (retracted) |
| early return on invalid input | Mode=0 (retracted) | **Mode=3** — not applied until the RC read |
Plane's `has_valid_input()` is still false at the mount's first `update()`: `read_radio()` runs earlier in the same tick but has no fresh frame on it, so `failsafe.last_valid_rc_ms` is unset even though the startup failsafe path has already populated `radio_in`. `Plane.MountAuxFunctionAtBoot` exists so that difference cannot be lost by accident.
This does mean an aux function can no longer be recovered where RC never becomes valid — which is the point: with frames arriving, that case *is* the failsafe case.
#### Diffstat
501 insertions, 0 deletions. No parameter changes, no new feature defines, and no change to the aux position cache, which stays scripting-only.
This contribution was developed with Codex and Claude AI assistance. The AI helped analyse the initialisation ordering, implement the change and its regression tests, and prepare this description.