Skip to content

Add support for MAV_CMD_REQUEST_OPERATOR_CONTROL#33332

Draft
peterbarker wants to merge 5 commits into
ArduPilot:masterfrom
peterbarker:pr-claude/MAV_CMD_REQUEST_OPERATOR_CONTROL
Draft

Add support for MAV_CMD_REQUEST_OPERATOR_CONTROL#33332
peterbarker wants to merge 5 commits into
ArduPilot:masterfrom
peterbarker:pr-claude/MAV_CMD_REQUEST_OPERATOR_CONTROL

Conversation

@peterbarker

Copy link
Copy Markdown
Contributor

Summary

Adds support for development.xml messages (still under review) to pass control of the system from one GCS to another (or several)

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)
  • Automated test(s) verify changes (e.g. unit test, autotest)
  • Tested manually, description below (e.g. SITL)
  • Tested on hardware
  • Logs attached
  • Logs available on request

Description

Much discussion in various places, most notably in the mavlink PR.

There's a MAVProxy PR adding support

QGC has support in the codebase but (a) it is not enabled by default and (b) the message set that QGC compiles against by default has an older version of this message.

peterbarker and others added 5 commits June 5, 2026 20:50
Task #9 — takeover notification: when a takeover request is rejected
(allow_takeover=0), broadcast MAV_CMD_REQUEST_OPERATOR_CONTROL on all
active channels to notify the current owner so they can release or
grant permission.

Task #10 — heartbeat disconnect releases control: rate-limited check
(1 Hz) in GCS::update_receive() releases operator control when the
operator's heartbeat has been absent for GCS_OPERATOR_HEARTBEAT_TIMEOUT_MS
(5 s). Uses the global sysid_mygcs_last_seen_time_ms() timestamp which
is updated by any GCS in the operator range, so control is only
released when ALL owners in a range disconnect simultaneously.

Task #11 — gcs_secondary in CONTROL_STATUS: track secondary connected
GCS sysids (sysids in the operator range that are not gcs_main) via
heartbeats in a small per-GCS array. Populate gcs_secondary[] in
CONTROL_STATUS from recently-seen secondaries so multi-owner range
mode is correctly reflected.

Autotest additions: notification forwarded to owner on takeover reject,
heartbeat disconnect releases control after timeout, gcs_secondary
populated when range operator is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ation send

Restrict RC_CHANNELS_OVERRIDE and MANUAL_CONTROL to the primary operator
(gcs_main) only. Secondaries can send state-changing MAVLink commands but
not manual stick input, matching the spec: "only one GCS owner can control
manual input of the vehicle".

Fix operator control takeover notification: the original direct call to
mavlink_msg_command_long_send() was silently dropped when the TX buffer
was momentarily full (comm_send_lock sets chan_discard=true). Route the
notification through the queued send_message()/try_send_message() path
(MSG_OPERATOR_CONTROL_NOTIFICATION) so it goes out as soon as TX space
is available, like all other deferred messages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@Georacer Georacer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one's looking good, Peter! Thank you for your work!

I've left a couple of comments. I'll try to test it with MAVProxy next.

Comment on lines +2804 to +2810
if (now_ms - _operator_control_last_hb_check_ms >= 1000) {
_operator_control_last_hb_check_ms = now_ms;
if (_operator_control_sysid != 0) {
const uint32_t last_seen = sysid_mygcs_last_seen_time_ms();
if (last_seen != 0 &&
now_ms - last_seen > GCS_OPERATOR_HEARTBEAT_TIMEOUT_MS) {
set_operator_control(0, 0, false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read here:
"Every 1s, check _operator_control_sysid. If it's not 0 and I have ever seen any of my GCSs, then release the control.
That doesn't sound right. Am I reading this wrong?

I guess the intention is that if the controlling GCS goes away for more than 1s, then the operator control will be released?
And the sysid_is_gcs() logic will fall back to sysid ranges.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right to be wary here, we flagged the same thing. The check keys on "any GCS in the range seen recently", so a secondary's heartbeats can mask the primary disconnecting, and a primary that's configured but never connects is never timed out (with takeover off, nobody can then take control). I think the fix is to track the primary's own last-seen separately from the range, so the timeout reflects the controller specifically. Happy to put that together if you and Peter agree on the direction.

flags |= GCS_CONTROL_STATUS_FLAGS_TAKEOVER_ALLOWED;
}
uint8_t gcs_secondary[10] {};
gcs().get_secondary_gcs(gcs_secondary);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MAVLink spec part "It should only include IDs for connected GCS" must have been a major pain.

We're essentially maintaining this secondaries list just to report it, IIUC.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it's a real cost. Now that the operator configures the secondary set GCS-side and it rides in the request range, ArduPilot already knows the authorized set without separately tracking who's been "seen", which is the only thing that bookkeeping buys is the spec's "only include connected GCS" wording. If that relaxed to "authorized range", AP could just report the range (the catch: gcs_secondary[10] caps at 10 while a range can be wider).

@Georacer

Copy link
Copy Markdown
Contributor

I gave this a test today, using the modified MAVProxy.

I went through the 3 scenarios outlined in mavlink/mavlink#2158.

They all seem to work... most of the time.

Here are 3 successful .tlogs:
scenario1.tlog.txt
scenario2.tlog.txt
scenario3.tlog.txt

I haven't actually verified that both GCSs have been logged in there.

And some of the time I would get this. It would switch control and then immediately release it:
Screenshot from 2026-06-11 13-40-28

Here are two failed .tlogs with that issue:
failure.tlog.txt
failure2.tlog.txt

@Davidsastresas

Davidsastresas commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Hi @peterbarker @Georacer, I've been testing this against my QGC side mavlink/qgroundcontrol#14560 (multi-GCS SITL, 4 × QGC + MAVProxy to relay traffic) and the core request / grant / takeover handshake seems solid end-to-end. I needed a few fixes on the ArduPilot side, opened as a PR against Peter's branch (peterbarker#46). The fixes:

1. Separate gcs_main from the request range (c493dcf147)
gcs_main was reported as param4 (the range minimum), which need not be the requester; the spec defines it as the "GCS in control". Now tracks _operator_control_primary (msg.sysid) separately from the range.

2. Secondary GCS bypassed the takeover check (094b1ae3ab)
The "same GCS re-requesting" path used sysid_is_gcs(), so any in-range secondary was auto-granted control even with takeover off. Now compares against the primary. @hamishwillee you might want to be aware of this particular one.

3. Notification lifecycle (55117b5877)
_oc_notification.pending was write-only and clear_…() dead code, so notifications went stale / broadcast across ownership changes. Replaced with a per-channel pending mask (statustext pattern), keeping multi-link delivery.

4. Restrict release to the primary (930a4cce69)
Release was accepted from any in-range GCS, so a secondary could strip the primary. Now only the primary may release (uncontrolled still accepts a no-op release). Release is as privileged as takeover: a secondary that can't take control with takeover off shouldn't be able to force the primary out either.

5. Clamp request timeout (3abba2273b)
param3 (spec 3–60 s) was forwarded unchecked, including 0 from senders that omit it. Now constrain_float(param3, 3, 60).

Autotest (15ea302973)
Updates the operator-control test for points 4 and 5.

Design questions (not addressed):

  • Heartbeat timeout is one timestamp for the whole range — a secondary's heartbeats mask the primary disconnecting, so gcs_main keeps pointing at a gone GCS with no recovery. Track the primary separately, and on primary timeout fall to a secondary / go uncontrolled / failsafe?
  • Bootstrap lockout — with GCS_SYSID_ENFORCE on, init() pre-populates the primary from MAV_GCS_SYSID and the 5 s heartbeat timeout can't recover it (that path is guarded by last_seen != 0, so a never-seen primary never times out). If that GCS never connects and takeover defaults off, no one can acquire control. A recovery path might help (e.g. clear the pre-populated primary if never seen within the timeout) — though if this is intended, that's fine too; I don't mind that a GCS outside the configured range can't take control initially.
  • MAV_RESULT_NOT_IN_CONTROL never sent — the spec says non-controller commands "should be rejected with NOT_IN_CONTROL", but accept_packet() drops them silently. Worth ACKing rejected COMMAND_LONG/INT when the drop is operator-control specific?
  • Operator range replaces MAV_GCS_SYSID in sysid_is_gcs() — a GCS in the static params but outside the requested range is locked out. Keep the param range as a fallback?

QGC already ACKs the takeover notification (COMMAND_ACK/ACCEPTED), so the full handshake completes against this branch with fix 3.

@Davidsastresas

Copy link
Copy Markdown
Contributor

@Georacer The "switch and immediately release" bug you hit might be fixed by the notification lifecycle fix (peterbarker@55117b5) _oc_notification.pending was write-only so notifications went stale across ownership changes, which could explain the immediate release after a switch. Would be great if you could retest against the fix branch (peterbarker#46) once it lands.

Also, if you have cycles, would love your take on the open design questions from my comment above, especially the heartbeat timeout tracking the whole range instead of the primary specifically. That one's the most likely to bite us in real multi-GCS setups. @hamishwillee also said his thoughts on it in the last part of his comment ArduPilot/mavlink#503 (comment).

@Georacer

Georacer commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

I did some more, partial testing today:

  1. I launched sim_vehicle.sh -v ArduCopter
  2. I launched another MAVProxy instance to fan out telemetry to ports <windows_ip>:14551-3
  3. Opened 3 instances of QGC with SysIDs 249, 253, 254.
  • 249 was set as primary, with a GCS range of 249-253.
  • 253 was reporting as secondary.
  • 254 was not reporting as a controlling GCS.
Screenshot 2026-06-29 140321

I had not set any of

  • MAV_GCS_SYSID
  • MAV_GCS_SYSID_HIGH
  • MAV_OPTIONS:GCS_SYSID_ENFORCE

The control takeover between primary and secondary

All 3 GCS can do the following:

  • Arm/disarm.
  • Change modes.
  • Issue Go-To commands

Barring the new multi-operator control feature, I would expect this since I hadn't configured any SYSID ranges. But from a usability perspective, does this make sense?
What is the point of acquiring primary control if any out-of-range GCS can issue the above commands?
Should we run the GCS_SYSID_ENFORCE init() when a GCS takes control via this new MAVLink message maybe? Or am I missing something here?

Other than that, only the primary GCS can issue joystick controls, as expected.

Here are some logs from my testing: https://www.dropbox.com/scl/fo/m6aosis3eb8dre39j2t47/APxy7mjSnOChOygrPCqFwkM?rlkey=ycus3cc82zzmrctf22sc122pk&st=b7h0ho2d&dl=0


MAVProxy testing

I'm still getting the flickering control status issue in MAVProxy, while running the Scenario 3 from mavlink/mavlink#2158.
In the same logs folder there are two mav_sc3* tlogs that capture this issue:
Screenshot from 2026-06-29 12-00-18

While running Scenario 2, I also got a ACK:Failed that I didn't expect. It's captured in mav_sc2.tlog.
Screenshot from 2026-06-29 11-55-44


I know this isn't exhaustive testing, let me know if you want me to immediately test something more specific.

@Georacer

Copy link
Copy Markdown
Contributor

Regarding the question "What happens when our primary times out", my opinion is that for now it's okay to not do anything special, meaning that AP won't allow a primary change.

If AP wants, we can change this implementation in another PR, so that timeouts are individually tracked and special action is taken.
We could allocate another array with last_seen_heartbeat, as large as the secondary GCS IDs table. It's not THAT expensive.

@Davidsastresas

Davidsastresas commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Thanks for the testing George. I think we need MAV_OPTIONS:GCS_SYSID_ENFORCE enabled.

If a GCS is not on primary or secondary systemids it should not be able to send any command. Maybe with the parameter above enabled this works as expected.

Please note also that you can "override" secondary GCSs to other than MAV_GCS_SYSID and MAV_GCS_SYSID_HI if from QGC you send a control request, and you have configured some secondary GCSs there. If the control is accepted, the new secondary GCSs will be the ones set in QGC, not the ones in AP parameters.

@Georacer

Copy link
Copy Markdown
Contributor

Thanks for the testing George. I think we need MAV_OPTIONS:GCS_SYSID_ENFORCE enabled.

If a GCS is not on primary or secondary systemids it should not be able to send any command. Maybe with the parameter above enabled this works as expected.

It will probably behave correctly with MAV_GCS_SYSID* correctly set, but this means that we have to configure both these parameters AND use the multi-operator MAVLink messages. @peterbarker is this something we want? It seems overly complex to me.
I would prefer a roadmap where we have multi-operator control configure these parameters and we hopefully, eventually eclipse them altogether.


Please note also that you can "override" secondary GCSs to other than MAV_GCS_SYSID and MAV_GCS_SYSID_HI if from QGC you send a control request, and you have configured some secondary GCSs there. If the control is accepted, the new secondary GCSs will be the ones set in QGC, not the ones in AP parameters.

I'll test further the case where the MAV_GCS_SYSID* parameters are set.

@hamishwillee

Copy link
Copy Markdown
Contributor

What is the point of acquiring primary control if any out-of-range GCS can issue the above commands?

Absolutely : "If a GCS is not on primary or secondary systemids it should not be able to send any command. Maybe with the parameter above enabled this works as expected."

In range control the only special thing that the "operator in control" has (according to the spec) is ability to control sticks. But more generally, its up to the flight stack to decide the separation of powers in this mode.

Please note also that you can "override" secondary GCSs to other than MAV_GCS_SYSID and MAV_GCS_SYSID_HI if from QGC you send a control request, and you have configured some secondary GCSs there. If the control is accepted, the new secondary GCSs will be the ones set in QGC, not the ones in AP parameters.

This is of course something to agree with ardupilot. @Davidsastresas My thinking, as captured in the spec is that the modes are separate.

  • In range mode you cannot change the MAV_GCS_SYSID and MAV_GCS_SYSID_HI - all you can do is change the operator in charge within this range. You'd reject a request to set the operator in charge outside this range as invalid.
  • In GCS in control mode you can set the operator in control, and I'd expect that to set MAV_GCS_SYSID and MAV_GCS_SYSID_HI to the same indicated value.

@Davidsastresas

Copy link
Copy Markdown
Contributor

@hamishwillee Understood. In QGC-AP as it stands now we have a "hybrid" of both:

  • AP boots, and secondary GCS are the ones configured in parameters
  • From QGC, if requested control with secondary GCS configured within QGC, it can effectively change the secondary GCSs, but Ardupilot does not update its range parameters accordingly.
  • If control is released, AP gets the secondary GCS specified by its range parameters again.

Question for Ardupilot devs: How should we differenciate between these 2 modes? aditional options in MAV_OPTIONS?

@hamishwillee

Copy link
Copy Markdown
Contributor

From QGC, if requested control with secondary GCS configured within QGC, it can effectively change the secondary GCSs, but Ardupilot does not update its range parameters accordingly.
If control is released, AP gets the secondary GCS specified by its range parameters again.

I wasn't thinking this ^^^ (or at least I don't think so, based on how I interpet this). I think what you're saying here is that in exclusive mode AP ignores high and low parameter settings, but when you release control it reverts to whatever AP range parameters are. That's confusing to me. In exclusive mode, if you release exclusive control, then the first GCS that seizes control takes control in exclusive mode.

I was thinking that the modes are distinct.
In the range mode you cannot change the ranges (full stop) using the messages, you can only select the operator in charge from the range. To me it makes more sense to trust a parameter setting and reject a request to take control if it is outside the range.

In the operator in control mode though you're saying that you want to be the sole operator. So I was thinking you would have the command set the operator in control by setting both hi/lo parameters to the same value. You could even gate the modes like that - if syshi == sys_lo you are in the exclusive operator mode. If not, you're in the shared mode.
That doesn't have to happen, you could say "in exclusive control mode hi/lo values are ignored.

Upshot, I'm a little unclear on what's going on.

@Davidsastresas

Davidsastresas commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@hamishwillee apologies for the confusing back-and-forth on this, some of it was me muddying the water. I was thinking in a situation where we have several groups of primary-secondaries GCS. In this scenario, the only way to request control for the whole new primary-secondaries GCS group ( without changing AP parameters ) was the approach I did. But that was me being too creative and not re reading the message definition to make sure it was aligned with it.

Sorry about it, I will fix it soon to be again within spec. Just to be sure we are on the same page:

  • Owner/secondary membership is the flight stack's to decide (on ArduPilot, the MAV_GCS_SYSID/_HI params), exactly as the CONTROL_STATUS text says — gcs_secondary is set by the flight stack and can't be set by this mechanism. A GCS never reconfigures membership using these messages; it just reads gcs_secondary from CONTROL_STATUS to display it.
  • REQUEST_OPERATOR_CONTROL is only used to become the controlling owner (gcs_main), release control, or change the takeover behaviour if in control. The flight stack validates the requester against its configured owner set and rejects (MAV_RESULT_DENIED / NOT_IN_CONTROL) anyone outside it. The requester is the command's sender on a request, and is carried in param4 on the notification you forward to the current owner.
  • Single- vs multi-owner follows the existing param5 convention — param5 = 0 for single-GCS control, a range for multi-owner — with the understanding that in multi-owner mode the actual membership is the flight stack's config, and the range in the message serves to request/signal "between GCSs", never to redefine it.

In other words: the message picks who's in charge and which mode, and the flight stack owns who's allowed. Does that match how you intend it to work? If so we're aligned now and I'll implement against that; if I've mis-read any part, point me at it.

Sorry about the confusion!

@hamishwillee

Copy link
Copy Markdown
Contributor

Hi @Davidsastresas

The confusion is partially my fault. I thought I had removed MAV_CMD_REQUEST_OPERATOR_CONTROL.param5 for the SYS_HI.
This was from an earlier version where we were thinking we would allow setting the high range.

  1. Specific answers

Secondary membership is the flight stack's to decide (on ArduPilot, the MAV_GCS_SYSID/_HI params), exactly as the CONTROL_STATUS text says — gcs_secondary is set by the flight stack and can't be set by this mechanism. A GCS never reconfigures membership using these messages; it just reads gcs_secondary from CONTROL_STATUS to display it.

Yes, though gcs_secondary might be plural.

  • REQUEST_OPERATOR_CONTROL is only used to become the controlling owner (gcs_main), release control, or change the takeover behaviour if in control. The flight stack validates the requester against its configured owner set and rejects (MAV_RESULT_DENIED / NOT_IN_CONTROL) anyone outside it. The requester is the command's sender on a request, and is carried in param4 on the notification you forward to the current owner.

Yes, in multi-operator control mode.

The statement is true also in single-owner mode, except for this bit "The flight stack validates the requester against its configured owner set and rejects "

  • Single- vs multi-owner follows the existing param5 convention — param5 = 0 for single-GCS control, a range for multi-owner — with the understanding that in multi-owner mode the actual membership is the flight stack's config, and the range in the message serves to request/signal "between GCSs", never to redefine it.

I don't think so (see above - we should remove param4).

IMO it doesn't make sense to allow the command (at least as currently designed) to specify the ownership model: exclusive or shared control. To me that's something you set in the autopilot itself. I think it would be confusing, and possibly unsafe for a GCS to allow the sender of this message to decide "hey, we don't control the range of controlled GCS but we're going to turn them on now".

Within the autopilot we don't care how that is done - I thought maybe using SYS_HI not equal to SYS_LO might be good way to indicate shared mode, but it could be an exclusive param.

  1. The reason we prototype is to test that our theoretical solution is actually workable. It is perfectly fine to end up with a different solution that we specified as long as the needed changes propagate back through to a new update in developmnt.xml that can be reviewed and agreed.

I mention this because you might decide after prototyping that (say) using a range for the control status makes more sense than that array for the sysid in control. We changed it from an array because Tridge said that ArduPilot's implementation of a range should not constrain the design. The cost is that it is possilble to set a range of GCS in control that is smaller than the range we can advertise. It would be legitimate to say that is unacceptable - that we need a range, and that is what has fallen out of testing. This would mean an update of the spec.

@Davidsastresas

Copy link
Copy Markdown
Contributor

I think what you proposed makes sense @hamishwillee. The multi-operator setting is something that can completely live in the autopilot, and just be reported to the GCSs by CONTROL_STATUS.

And to be honest, that simplifies the situation a lot. I think we can settle on that and test how it looks like.

I will work on it in the next few days.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants