Skip to content

feat(dialect)!: address the messages that direct a specific system - #10

Open
TSC21 wants to merge 13 commits into
Dronecode:mainfrom
TSC21:rfc/sensor-tasking-addressing
Open

TSC21 wants to merge 13 commits into
Dronecode:mainfrom
TSC21:rfc/sensor-tasking-addressing

Conversation

@TSC21

@TSC21 TSC21 commented Jul 31, 2026 •

Copy link
Copy Markdown
Member

Eleven of the dialect's messages order one system to act, but every message is broadcast, so none of them can say which system it's for. This PR gives them MAVLink's standard target_system and target_component, so a router delivers each one to its recipient, and says what that means for stops, gateways and acknowledgments. It settles the points raised in the RFC discussion and the gaps an adversarial review found after it, gateways first among them.

This is a definitions change and adds no capability. It lets the directives the dialect already defines name their recipient, using the two field names core MAVLink reserves for that, and pins down the rules those definitions left open. The integration behind it is in QGC, which consumes the situational-awareness messages and originates sensor tasking and nothing else, so SENSOR_TASKING is the only one of these messages it exercises. The rest of the list is covered because a definitions change should treat every directive in the dialect the same way, not because I implement them or plan to.

FIRES, SPLASH_CORRECTION, TARGET_HANDOVER, ESAD_ARMING, ESAD_CONFIG, SENSOR_TASKING, CAS_9LINE, TERMINAL_CONTROL, ENGAGEMENT_DIRECTIVE, CALL_FOR_FIRE and LOITER_MUNITION_CONTROL carry the two fields; the messages that describe the world stay broadcast. MAVLINK_M_ACK goes back to the sender of the message it acknowledges and echoes that message's time_usec, so each directive gets its own answer, and every addressed directive is acknowledged. Sending to every system is valid only for actions that stop or hold, and a receiver acts only on the stop in such a message. A stop applies to every engagement it matches, with 0xFFFF meaning all of them, ENGAGEMENT_DIRECTIVE ABORT and CHECK_FIRE are the stop every system that can engage or guide a weapon honours, and a stop always wins over an earlier start. SENSOR_TASKING, TERMINAL_CONTROL and LOITER_MUNITION_CONTROL say how each direction is addressed, a sensor tells a requester when its task is interrupted, and a DISARM applies whatever the ESAD challenge holds. ESAD_ARMING, SENSOR_TASKING and CAS_9LINE gain track_uid, and CAS_9LINE gains the sequence TERMINAL_CONTROL refers to.

Addressing only works where routers can deliver. A router forwards an addressed message only on links where it has heard the recipient, so the dialect header states the rules for that: a stop for a recipient that may be silent, under emission control or behind a receive-only link, goes to every system and selects with its own fields. A gateway to another network or protocol (UCI, VMF, Link 16, or another MAVLink network) re-originates messages, presents each far-side participant as a proxy system ID with its own HEARTBEAT, returns acknowledgments and responses to it, and only reports what the far side reported. System IDs are unique across joined networks, and routers and gateways move to the new definition along with everything else.

This breaks the wire format for twelve messages, and the stop and DISARM rules change what receivers must do. Each of the twelve changes CRC_EXTRA; FIRES, for example, generates as {53020, 124, 64, 64, 3, 40, 41} and MAVLINK_M_ACK as {53004, 167, 77, 77, 3, 24, 25}, with flag 3 telling a router where the target sits. The target fields are base fields on purpose. As extensions, a sender built from the old definition would leave them out and the receiver would read zero, which means every system. As base fields, old and new definitions drop each other's messages instead, an ABORT included, so every system, routers and gateways too, has to move to this definition together.

@mrpollo, ryanjAA has reviewed the design and the wire format, and the review follow-ups are in this push. Could you review it and merge when you're happy? Merging republishes the C library with the twelve new CRC_EXTRA values, so anything built from the current headers has to regenerate at the same time.

I ran the checks from #11 locally with mavlink 87da370 and pymavlink 19880422 on each of the thirteen commits: the dialect validates, generates C and Python with strict units and compiles at -Werror, the ID checks pass, the wire report against main lists exactly the twelve messages, and the docs site builds with no new dead links.

Assisted-by: Claude-Code:claude-opus-5-5


RIIS


@TSC21
TSC21 force-pushed the rfc/sensor-tasking-addressing branch from 51e2e3f to 00bad7c Compare July 31, 2026 22:28
@TSC21 TSC21 changed the title RFC: optional target_system and target_component on SENSOR_TASKING RFC: address the MAVLink-M messages that command a specific system Jul 31, 2026
@TSC21
TSC21 requested a review from mrpollo July 31, 2026 22:34
@TSC21 TSC21 changed the title RFC: address the MAVLink-M messages that command a specific system RFC: addressing for the MAVLink-M messages that direct a specific system Aug 1, 2026
@ryanjAA

ryanjAA commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Q1 - not an oversight. The design assumes addressing.

The MAVLink-M swarm dataflow concept specifies "Addressed TARGET_HANDOVER per target to the chosen shooter's sysid", and lists "addressed handoff" among the things the dialect already provides.

It explicitly rules out the alternative: "What is explicitly not supported is a third, unsafe pattern - broadcasting one target to everyone and letting whoever arrives strike it. That produces convergence: multiple munitions wasted on one target, and multiple armed aircraft meeting at one point in space."
So addressed delivery is a design requirement with a stated safety rationale. The gap is that it was left to the frame's source sysid - which isn't something a router can act on. That's exactly what this PR fixes.
Addressing already exists, just below system level.

store_id, effector_id and esad_id all carry "0 = all" semantics.
The dialect addresses sub-components in-band, but not the system that owns them.
Related gap, probably a separate PR: several of the same command messages can't say what they're about.

No track_uid on ESAD_ARMING, ESAD_CONFIG, SENSOR_TASKING, TERMINAL_CONTROL, CAS_9LINE.
Sharpest case: ESAD_STATE reports track_uid ("armed against track X"), but ESAD_ARMING cannot specify it - the binding is set out-of-band and then assumed to hold.

Q2 - agree with the split; ESAD_ARMING especially.

Q3 - yes, use the reserved names on MAVLINK_M_ACK.

@TSC21

TSC21 commented Aug 2, 2026 •

Copy link
Copy Markdown
Member Author

Thanks @ryanjAA, that answers all three.

Q1: that settles it. Addressing was always the design, it just was not in a place a router can act on. Good point on store_id, effector_id and esad_id as well. Those pick the device inside an aircraft, these new fields pick the aircraft, and 0 still means all at both levels.

Q2: good, the split stays as posted and TARGET_CUE stays broadcast.

Q3: I will add it to this PR. Concretely, origin_sysid becomes target_system, since it already names the system the ack has to get back to, target_component goes next to it, and ack_sysid goes away because the frame header already says who is sending. That leaves MAVLINK_M_ACK looking like COMMAND_ACK in common. Tell me if ack_sysid is there for a reason I am missing, otherwise I will push that here.

On track_uid, agreed, and I think it belongs in its own PR because it answers a different question: these fields say who has to act, track_uid says what the message is about. Your ESAD_ARMING against ESAD_STATE example is the clearest one. It hits SENSOR_TASKING too, where you cannot tell a sensor which track to watch, so a ground station ends up sending coordinates and the task cannot follow a moving target. Happy to write that one up if nobody else is on it.

So: the ack change lands here, this PR covers who has to act, and what the message is about comes separately. If that works for you, an approval once the ack commit is up and this can stop being an RFC.

@TSC21

TSC21 commented Aug 2, 2026 •

Copy link
Copy Markdown
Member Author

@ryanjAA pushed as c55620e.

MAVLINK_M_ACK carries target_system and target_component, and ack_sysid is gone because the frame header already says who is sending. It generates as {53004, 224, 69, 69, 3, 16, 17}, so a router can place it, and the payload stays at 69 bytes since two fields go in and two come out. Every other message in the dialect generates a byte-identical entry row.

I left origin_sysid alone on TRACK_IDENTITY, TARGET_CUE, PARTICIPANT_POSITION and ENGAGEMENT_DIRECTIVE. Those say who a report is about, not where it goes, so they are not the same field wearing a different name.

@ryanjAA

ryanjAA commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@TSC21

I reviewed c55620e. The generated ACK entry is:

{53004, 224, 69, 69, 3, 16, 17}

CRC_EXTRA moves from 47 to 224, the payload remains 69 bytes, and target_system/target_component land at offsets 16 and 17. No wire-format objection.

Worth stating explicitly that the message ID and payload length are both unchanged. Offset 16 retains the same effective value, while offset 17 changes meaning from ack_sysid to target_component.CRC_EXTRA is therefore the only on-wire compatibility guard between the old and new definitions. That is correct and fail-closed, but it is not the usual case where a changed payload length makes the incompatibility obvious, and a reader will assume it is.

There is also a stronger argument for keeping the target fields in the base definition than the one in the PR body. Extension fields are excluded from CRC_EXTRA. An old sender omits them, a new receiver decodes the omission as zero, and zero targets mean broadcast. Extension-placed addressing would therefore let mixed definitions stay CRC-compatible while silently degrading to the exact behavior this PR exists to prevent. Base fields force a CRC mismatch instead. That is the case to make.

Four immediate description clarifications beloww. As currently framed, none changes the wire format. The SENSOR_TASKING question, plus the separate ENGAGEMENT_DIRECTIVE.origin_sysid question below, could reveal that a field change is warranted, which is why I would rather settle both before merge.

  1. MAVLINK_M_ACK.target_system and target_component should explicitly say they are copied from the source sysid and compid in the frame header of the acknowledged message, and should not be zero. A conforming ACK has exactly one requester as its destination, and the current "0 addresses every system/component" text presents broadcast as a valid ACK construction. The current wording also leaves open the implementation mistake of copying the acknowledged message's own target fields instead of its source header.

  2. SENSOR_TASKING is both a task request and a task-status response, and this PR gives it one target pair without defining how that pair is populated in each direction. Please state that a request targets the sensor or payload, while a status response targets the source sysid and compid from the request frame. Otherwise one implementation reverses the address and another echoes the request destination and routes the response back to the responder. PING documents its request/response addressing explicitly and is good precedent. This is the message you are actually shipping in QGC, so it is the one most likely to get codified before anyone notices.

Please also define the direction discriminator, presumably status = UNSPECIFIED for a task request and a nonzero status for a response, and state which task fields are meaningful or ignored in a response. Otherwise a single frame can carry both a command and a status with no normative rule for interpreting it.

  1. Across the eleven newly addressed directive messages, "System that must act on this message" overstates what the field establishes. These fields select an intended recipient for routing and processing. They do not authorize the request or oblige the receiver to execute it. "SystemID of the intended recipient" and "Component ID of the intended recipient" are clearer, and target_component should be described as meaningful within a nonzero target_system.

  2. ESAD_ARMING's multi-target challenge rule needs a precise receiver scope now that system-level addressing exists. A receiver can only evaluate the ESADs it owns, so I suggest:
    For each receiving system selected by target_system, if esad_id = 0, the receiver applies the command only if every local ESAD selected by store_id advertises arming_challenge_hash; otherwise that receiver rejects the command.

For target_system = 0, each receiving system evaluates that rule against its own local ESADs. That is the only enforceable reading, and it exposes an important consequence for the broadcast-policy follow-up: a broadcastARM can partially apply. One system may accept and arm while another rejects because its local selected ESAD set does not share the supplied hash. No individual target system can determine the network-wide outcome; the originator would need to aggregate per-system ACKs and/or subsequentESAD_STATE reports. The follow-up should define that response obligation explicitly.

ESAD_CONFIG has the corresponding unresolved multi-device rule and should state how its one challenge hash is validated when esad_id = 0,store_id = 0, or both.

On ENGAGEMENT_DIRECTIVE.origin_sysid: the explanation for retaining origin_sysid fits TRACK_IDENTITY, TARGET_CUE and PARTICIPANT_POSITION, where the field records provenance or the subject of a report.ENGAGEMENT_DIRECTIVE instead describes origin_sysid as the system issuing the directive, which may duplicate the frame source. I am not asking to remove it and I am not asking for ack_sysid back. Please just say which meaning is intended. If it is the logical or original issuing authority and may differ from the immediate MAVLink sender after gateway translation, that is useful and the field should say so. If it intentionally duplicates the immediate frame source so that detached audit records stay self-contained, document that rationale. Otherwise it is accidental redundancy and worth removing while the definition is still mutable.

On broadcast validity generally: the hazard predates this PR, since these messages previously had no system-level destination at all. What this PR adds is the first explicit field against which the intended policy can be stated and enforced, so it is the right moment to open the question.

Please open and link a follow-up issue defining broadcast validity per operation and direction rather than per message ID. Several of these combine fail-safe actions such as DISARM, ABORT, CHECK_FIRE and WAVE_OFF, where network-wide delivery may be intentional, with affirmative actions such as ARM, RESUME, RETARGET and CLEARED_HOT, where it is not. To make the intended policy explicit: the convergence rationale I quoted earlier applies to affirmative multi-recipient directives as well. Broadcast ARM is not intended to be valid. The follow-up should codify broadcast ARM as invalid while separately and explicitly defining which fail-safe broadcasts, such as DISARM, ABORT, CHECK_FIRE or WAVE_OFF, are permitted.

On track_uid, agreed it belongs in a separate PR. ESAD_ARMING is the sharpest case because it has no track or engagement binding, whileESAD_STATE reports the track against which the store is armed. ThenSENSOR_TASKING, which cannot reference a persistent track: task_id identifies the transaction, not the subject, so following a moving target requires continually re-tasking with fresh coordinates or an out-of-band association. Then CAS_9LINE, which carries the 9-line geometry but no sequence and no persistent cross-message identity. ESAD_CONFIG andTERMINAL_CONTROL are weaker, and TERMINAL_CONTROL.sequence already gives an engagement binding.

For #11: check_wire_compat.py currently builds its map as{m.id: (m.name, m.crc_extra)} and compares only those tuples. Please extend it to report full generated metadata for changed messages, that isCRC_EXTRA, min_length, max_length, target flags, target_system offset and target_component offset. The #11 description also still says comparison against #10 reports eleven changed messages; after c55620e it is twelve.

No wire-format objection. Once the four descriptions and the ENGAGEMENT_DIRECTIVE.origin_sysid semantics are resolved, and the broadcast-policy issue is linked, I'd say it's ready to approve.

TSC21 added a commit that referenced this pull request Oct 2, 2026
README.md, IDMAPPING.md and the military.xml header document an ID
allocation, and peers rely on a wire contract: a message ID keeps its
meaning once assigned, and a peer drops any message whose CRC_EXTRA
differs from its own. Nothing checks either, so a pull request can
allocate outside 53000-53999, take an ID from the private 53900-53999
block, or change a message body without the wire effect showing in
review.

check_dialect_policy.py checks the documented allocation over both
dialect files: military.xml takes messages and MAV_CMD entries from
the shared 53000-53899 window, military_extensions.xml only from the
private block, names follow MAVLink's case conventions, and the
template reuses no shared ID or name. It needs only the Python
standard library, so it runs on a plain checkout. Schema conformance,
field types and clashes with common.xml stay with the schema check
and mavgen, which read the whole include tree.

check_wire_compat.py parses two versions of military.xml with
pymavlink's generator, which computes CRC_EXTRA the way every
consumer does, and lists removals, renames and CRC_EXTRA changes by
message ID. By default it only reports, because the dialect is still
changing and replacing a message body is a decision for review;
WIRE_COMPAT_ENFORCE=1 makes it fail once IDs are frozen. CRC_EXTRA
leaves out enum values and extension fields, so the report does not
see changes to them.

Validation: the policy check passes on main (26 messages and 4
MAV_CMD entries in the shared window, 2 template messages in the
private block), and a tree with 11 planted violations, one per rule,
reports all 11 and exits 1. The wire check reports 0 changes against
itself, and against #10 at c55620e it lists the 12 messages that PR
changes, FIRES at CRC_EXTRA 124; an independent diff of the field
lists finds the same 12.
TSC21 added a commit that referenced this pull request Oct 2, 2026
The wire report compares CRC_EXTRA, which covers the name, type and
order of the base fields and nothing else. A changed enum value, a
removed enum entry, or an extension field that is removed, retyped
or moved changes what peers decode but leaves CRC_EXTRA alone, so the
report shows no change for any of them.

The wire-compat job runs mavlink/scripts/check_api_break.py from the
mavlink checkout at the pinned MAVLINK_REF, the check upstream runs
on its own definitions. Running it from the checkout, not a copy kept
here, means a pin bump updates the checker with the definitions it
is matched to; the composite action adds scripts/ to that sparse
checkout. The checker takes the base from the pull request event and
diffs every XML file the pull request changes, and its output goes
to the log and the step summary. Like the CRC report it only
reports, and one job-level WIRE_COMPAT_ENFORCE switch covers both
checks. An exit without the checker's verdict line is the tool
failing, not an API break, so the step fails on it whatever the
switch says, and a check that never ran cannot pass.

Validation, simulating the job's pull request environment with
mavlink 87da370 and main 1a7a00d as the base: a pull request that
changes no XML reports "No XML files changed." and passes. The
military.xml of #10 at c55620e reports 59 changed and 2 removed
fields in 12 messages, the same 12 the CRC report lists, as a
warning, and fails with WIRE_COMPAT_ENFORCE=1. Giving one
MAVLINK_M_TARGET_CLASS entry the value 40 in place of 4 raises the
warning here while the CRC report shows 0 wire-breaking changes. A
base commit missing from the history makes the checker raise, and
the step fails with "exited 1 without a verdict". actionlint 1.7.12,
with shellcheck, and zizmor 1.30.1, regular and auditor personas,
report nothing.
TSC21 added a commit that referenced this pull request Oct 2, 2026
IDMAPPING.md is the allocation record. README.md links it as the
message-ID allocation, the site publishes it as the ID Allocation
page, and the contributing guide asks for it to be updated in the
same pull request as a new ID. Nothing compares it with military.xml,
so a pull request can add, move or drop a message and leave the table
and the published page wrong. On main the two agree: 26 messages, 4
MAV_CMD entries, and no ID in use inside a reserved block.

The policy check reads the first table under each "## " heading of
IDMAPPING.md. Every military.xml message must be in "Shared
messages" and every MAV_CMD entry in "MAV_CMD entries", at the ID
military.xml gives it (the New ID and New value columns), and every
row must name something military.xml defines. No message or command
may use an ID inside a "Reserved blocks" range that lies in the
shared window 53000-53899; the private block belongs to the
allocation rule. A name is the cell's leading UPPER_SNAKE word, so
"TARGET (formerly TARGET_COORD)" is TARGET, and backticks are
accepted. The history table and the old ID columns are not checked.
A missing file, table or column is a finding in itself, so a
restructured document fails the check rather than passing it
unread. A finding about the XML points at the element, one about the
document at its row.

Validation: main's files pass. On copies of them, a message added at
53062 with no document change gives 2 findings (not in the table,
inside 53062-53089); FIRES listed at 53025 gives 1, at its row; a
stale row and a dropped MAV_CMD row give 1 each; a renamed "Shared
messages" heading gives 1; the military.xml of #10 at c55620e
passes. 21 policy tests pass on Python 3.10.12 and 3.14.5 with 100%
line and branch coverage (200 statements, 98 branches), and turning
off the reserved-block test, the ID comparison or the one table per
section rule each fails the suite. actionlint 1.7.12, zizmor 1.30.1
and ruff 0.14.0 report nothing.
TSC21 added a commit that referenced this pull request Oct 2, 2026
The wire report compares only each message's name and CRC_EXTRA, but a
router places a message from the row mavgen writes for it in the C
headers' MAVLINK_MESSAGE_CRCS table: {msgid, CRC_EXTRA, min_length,
max_length, flags, target_system offset, target_component offset}. A
reviewer of an addressing change needs the lengths, the target flags
and the offsets, and the report hides all of them. It also hides any
change that leaves CRC_EXTRA alone: extension fields, including a
target field placed among them, which moves the routing row while
older peers keep accepting the message.

wire_map reads the row straight off pymavlink's parse, the same
attributes mavgen_c.py prints into the table. Each wire-breaking line
(removed, renamed, or CRC_EXTRA changed) carries the old and the new
row. A message whose row changes with CRC_EXTRA unchanged is listed as
an extension-only INFO change with both rows, and its step summary
note says that a sender without the new extension fields sends zeros
there, which a router reads as broadcast. Additions carry their row.
WIRE_COMPAT_ENFORCE still fails on wire-breaking changes only.

Validation: against the military.xml of #10 at c55620e the report
lists 12 wire-breaking messages with their rows, among them
MAVLINK_M_ACK {53004, 47, 69, 69, 0, 0, 0} -> {53004, 224, 69, 69, 3,
16, 17}, FIRES {53020, 16, 62, 62, 0, 0, 0} -> {53020, 124, 64, 64, 3,
40, 41} and SENSOR_TASKING {53050, 244, 24, 24, 0, 0, 0} -> {53050,
107, 26, 26, 3, 22, 23}. For both files, all 26 rows match the
MAVLINK_MESSAGE_CRCS table mavgen generates. 13 wire report tests
pass, 42 script tests in all, on Python 3.10.12 and 3.14.5, with 100%
line and branch coverage of check_wire_compat.py (64 statements, 24
branches); breaking on any row change, dropping the flags, or
ignoring CRC-compatible changes each fail the suite. ruff 0.14.0,
actionlint 1.7.12 and zizmor 1.30.1 report nothing.
TSC21 added a commit that referenced this pull request Oct 2, 2026
The wire report compares CRC_EXTRA, which covers the name, type and
order of the base fields and nothing else. A changed enum value, a
removed enum entry, or an extension field that is removed, retyped
or moved changes what peers decode but leaves CRC_EXTRA alone, so the
report shows no change for any of them.

The wire-compat job runs mavlink/scripts/check_api_break.py from the
mavlink checkout at the pinned MAVLINK_REF, the check upstream runs
on its own definitions. Running it from the checkout, not a copy kept
here, means a pin bump updates the checker with the definitions it
is matched to; the composite action adds scripts/ to that sparse
checkout. The checker takes the base from the pull request event and
diffs every XML file the pull request changes, and its output goes
to the log and the step summary. Like the CRC report it only
reports, and one job-level WIRE_COMPAT_ENFORCE switch covers both
checks. An exit without the checker's verdict line is the tool
failing, not an API break, so the step fails on it whatever the
switch says, and a check that never ran cannot pass.

Validation, simulating the job's pull request environment with
mavlink 87da370 and main 1a7a00d as the base: a pull request that
changes no XML reports "No XML files changed." and passes. The
military.xml of #10 at c55620e reports 59 changed and 2 removed
fields in 12 messages, the same 12 the CRC report lists, as a
warning, and fails with WIRE_COMPAT_ENFORCE=1. Giving one
MAVLINK_M_TARGET_CLASS entry the value 40 in place of 4 raises the
warning here while the CRC report shows 0 wire-breaking changes. A
base commit missing from the history makes the checker raise, and
the step fails with "exited 1 without a verdict". actionlint 1.7.12,
with shellcheck, and zizmor 1.30.1, regular and auditor personas,
report nothing.
TSC21 added a commit that referenced this pull request Oct 2, 2026
IDMAPPING.md is the allocation record. README.md links it as the
message-ID allocation, the site publishes it as the ID Allocation
page, and the contributing guide asks for it to be updated in the
same pull request as a new ID. Nothing compares it with military.xml,
so a pull request can add, move or drop a message and leave the table
and the published page wrong. On main the two agree: 26 messages, 4
MAV_CMD entries, and no ID in use inside a reserved block.

The policy check reads the first table under each "## " heading of
IDMAPPING.md. Every military.xml message must be in "Shared
messages" and every MAV_CMD entry in "MAV_CMD entries", at the ID
military.xml gives it (the New ID and New value columns), and every
row must name something military.xml defines. No message or command
may use an ID inside a "Reserved blocks" range that lies in the
shared window 53000-53899; the private block belongs to the
allocation rule. A name is the cell's leading UPPER_SNAKE word, so
"TARGET (formerly TARGET_COORD)" is TARGET, and backticks are
accepted. The history table and the old ID columns are not checked.
A missing file, table or column is a finding in itself, so a
restructured document fails the check rather than passing it
unread. A finding about the XML points at the element, one about the
document at its row.

Validation: main's files pass. On copies of them, a message added at
53062 with no document change gives 2 findings (not in the table,
inside 53062-53089); FIRES listed at 53025 gives 1, at its row; a
stale row and a dropped MAV_CMD row give 1 each; a renamed "Shared
messages" heading gives 1; the military.xml of #10 at c55620e
passes. 21 policy tests pass on Python 3.10.12 and 3.14.5 with 100%
line and branch coverage (200 statements, 98 branches), and turning
off the reserved-block test, the ID comparison or the one table per
section rule each fails the suite. actionlint 1.7.12, zizmor 1.30.1
and ruff 0.14.0 report nothing.
TSC21 added a commit that referenced this pull request Oct 2, 2026
The wire report compares only each message's name and CRC_EXTRA, but a
router places a message from the row mavgen writes for it in the C
headers' MAVLINK_MESSAGE_CRCS table: {msgid, CRC_EXTRA, min_length,
max_length, flags, target_system offset, target_component offset}. A
reviewer of an addressing change needs the lengths, the target flags
and the offsets, and the report hides all of them. It also hides any
change that leaves CRC_EXTRA alone: extension fields, including a
target field placed among them, which moves the routing row while
older peers keep accepting the message.

wire_map reads the row straight off pymavlink's parse, the same
attributes mavgen_c.py prints into the table. Each wire-breaking line
(removed, renamed, or CRC_EXTRA changed) carries the old and the new
row. A message whose row changes with CRC_EXTRA unchanged is listed as
an extension-only INFO change with both rows, and its step summary
note says that a sender without the new extension fields sends zeros
there, which a router reads as broadcast. Additions carry their row.
WIRE_COMPAT_ENFORCE still fails on wire-breaking changes only.

Validation: against the military.xml of #10 at c55620e the report
lists 12 wire-breaking messages with their rows, among them
MAVLINK_M_ACK {53004, 47, 69, 69, 0, 0, 0} -> {53004, 224, 69, 69, 3,
16, 17}, FIRES {53020, 16, 62, 62, 0, 0, 0} -> {53020, 124, 64, 64, 3,
40, 41} and SENSOR_TASKING {53050, 244, 24, 24, 0, 0, 0} -> {53050,
107, 26, 26, 3, 22, 23}. For both files, all 26 rows match the
MAVLINK_MESSAGE_CRCS table mavgen generates. 13 wire report tests
pass, 42 script tests in all, on Python 3.10.12 and 3.14.5, with 100%
line and branch coverage of check_wire_compat.py (64 statements, 24
branches); breaking on any row change, dropping the flags, or
ignoring CRC-compatible changes each fail the suite. ruff 0.14.0,
actionlint 1.7.12 and zizmor 1.30.1 report nothing.
@TSC21
TSC21 force-pushed the rfc/sensor-tasking-addressing branch from c55620e to 9d332b4 Compare October 2, 2026 15:08
@TSC21 TSC21 changed the title RFC: addressing for the MAVLink-M messages that direct a specific system feat: address the MAVLink-M messages that direct a specific system Oct 2, 2026
@TSC21

TSC21 commented Oct 2, 2026 •

Copy link
Copy Markdown
Member Author

@ryanjAA thanks, it's all in 9d332b4. I checked each point against the devguide and against what mavgen generates before taking it, and they hold.

  1. MAVLINK_M_ACK: target_system and target_component are the source IDs in the frame header of the acknowledged message, never that message's own targets and never 0, the way COMMAND_ACK words it.
  2. SENSOR_TASKING: status MAVLINK_M_TASKING_UNSPECIFIED marks a request, which goes to the sensor. Any other status is a response, which goes back to the requester's source IDs the way a PING response does, echoes task_id and track_uid, and reports the mode in effect; receivers ignore its point-of-interest fields.
  3. The target fields say "intended recipient", and target_component counts only within a nonzero target_system, which is how the devguide's routing rules read it.
  4. ESAD_ARMING and ESAD_CONFIG: each receiving system applies esad_id = 0 (and store_id = 0 for config) only if every local ESAD it selects advertises the hash, and rejects it otherwise; the originator confirms each system from its ESAD_STATE. "Broadcast" is gone from the esad_id and store_id wording, so it only ever means target_system = 0.

origin_sysid on ENGAGEMENT_DIRECTIVE is provenance: the system that issued the directive, which a relay or gateway keeps. An ABORT forwarded across a gateway still says whose abort it is.

Broadcast validity is in this PR rather than a follow-up issue, per action and direction. 0 is valid only for actions that stop or hold: ABORT, CHECK_FIRE, CEASE_FIRE, DISARM, WAVE_OFF, a STANDBY sensor request, withheld or withdrawn consent, and the CHECK_FIRING, CEASE_LOADING and END_OF_MISSION calls. Anything that starts, arms, configures, grants, briefs or hands over names its recipient, and so does SELF_DESTRUCT because it can't be undone. A DISARM sent to every system still checks the hash per system, so it disarms only where the selected ESADs share it.

I folded track_uid in as well, since these messages break on the wire here anyway and consumers then take one break instead of two: ESAD_ARMING (the track ESAD_STATE reports back), SENSOR_TASKING (a task can follow a track) and CAS_9LINE, which also gets the sequence TERMINAL_CONTROL refers to. ESAD_CONFIG and TERMINAL_CONTROL get none, because one sets a device mode and the other binds through sequence.

Your base-field argument is in the first commit and the description, together with its other side: old and new definitions drop each other's ABORTs too, so every system has to move together.

For #11: the wire report prints each changed message's generated entry row, old and new, so against this PR it lists the twelve messages with rows like MAVLINK_M_ACK {53004, 47, 69, 69, 0, 0, 0} -> {53004, 224, 69, 69, 3, 16, 17}. A row that changes while CRC_EXTRA doesn't, such as a target field added as an extension, is reported separately, and #11's description says twelve.

@TSC21
TSC21 requested review from mrpollo and removed request for mrpollo October 2, 2026 15:27
TSC21 added a commit that referenced this pull request Oct 2, 2026
README.md, IDMAPPING.md and the military.xml header document an ID
allocation, and peers rely on a wire contract: a message ID keeps its
meaning once assigned, and a peer drops any message whose CRC_EXTRA
differs from its own. Nothing checks either, so a pull request can
allocate outside 53000-53999, take an ID from the private 53900-53999
block, or change a message body without the wire effect showing in
review.

check_dialect_policy.py checks the documented allocation over both
dialect files: military.xml takes messages and MAV_CMD entries from
the shared 53000-53899 window, military_extensions.xml only from the
private block, names follow MAVLink's case conventions, and the
template reuses no shared ID or name. It needs only the Python
standard library, so it runs on a plain checkout. Schema conformance,
field types and clashes with common.xml stay with the schema check
and mavgen, which read the whole include tree.

check_wire_compat.py parses two versions of military.xml with
pymavlink's generator, which computes CRC_EXTRA the way every
consumer does, and lists removals, renames and CRC_EXTRA changes by
message ID. By default it only reports, because the dialect is still
changing and replacing a message body is a decision for review;
WIRE_COMPAT_ENFORCE=1 makes it fail once IDs are frozen. CRC_EXTRA
leaves out enum values and extension fields, so the report does not
see changes to them.

Validation: the policy check passes on main (26 messages and 4
MAV_CMD entries in the shared window, 2 template messages in the
private block), and a tree with 11 planted violations, one per rule,
reports all 11 and exits 1. The wire check reports 0 changes against
itself, and against #10 at c55620e it lists the 12 messages that PR
changes, FIRES at CRC_EXTRA 124; an independent diff of the field
lists finds the same 12.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
TSC21 added a commit that referenced this pull request Oct 2, 2026
The wire report compares CRC_EXTRA, which covers the name, type and
order of the base fields and nothing else. A changed enum value, a
removed enum entry, or an extension field that is removed, retyped
or moved changes what peers decode but leaves CRC_EXTRA alone, so the
report shows no change for any of them.

The wire-compat job runs mavlink/scripts/check_api_break.py from the
mavlink checkout at the pinned MAVLINK_REF, the check upstream runs
on its own definitions. Running it from the checkout, not a copy kept
here, means a pin bump updates the checker with the definitions it
is matched to; the composite action adds scripts/ to that sparse
checkout. The checker takes the base from the pull request event and
diffs every XML file the pull request changes, and its output goes
to the log and the step summary. Like the CRC report it only
reports, and one job-level WIRE_COMPAT_ENFORCE switch covers both
checks. An exit without the checker's verdict line is the tool
failing, not an API break, so the step fails on it whatever the
switch says, and a check that never ran cannot pass.

Validation, simulating the job's pull request environment with
mavlink 87da370 and main 1a7a00d as the base: a pull request that
changes no XML reports "No XML files changed." and passes. The
military.xml of #10 at c55620e reports 59 changed and 2 removed
fields in 12 messages, the same 12 the CRC report lists, as a
warning, and fails with WIRE_COMPAT_ENFORCE=1. Giving one
MAVLINK_M_TARGET_CLASS entry the value 40 in place of 4 raises the
warning here while the CRC report shows 0 wire-breaking changes. A
base commit missing from the history makes the checker raise, and
the step fails with "exited 1 without a verdict". actionlint 1.7.12,
with shellcheck, and zizmor 1.30.1, regular and auditor personas,
report nothing.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
TSC21 added a commit that referenced this pull request Oct 2, 2026
IDMAPPING.md is the allocation record. README.md links it as the
message-ID allocation, the site publishes it as the ID Allocation
page, and the contributing guide asks for it to be updated in the
same pull request as a new ID. Nothing compares it with military.xml,
so a pull request can add, move or drop a message and leave the table
and the published page wrong. On main the two agree: 26 messages, 4
MAV_CMD entries, and no ID in use inside a reserved block.

The policy check reads the first table under each "## " heading of
IDMAPPING.md. Every military.xml message must be in "Shared
messages" and every MAV_CMD entry in "MAV_CMD entries", at the ID
military.xml gives it (the New ID and New value columns), and every
row must name something military.xml defines. No message or command
may use an ID inside a "Reserved blocks" range that lies in the
shared window 53000-53899; the private block belongs to the
allocation rule. A name is the cell's leading UPPER_SNAKE word, so
"TARGET (formerly TARGET_COORD)" is TARGET, and backticks are
accepted. The history table and the old ID columns are not checked.
A missing file, table or column is a finding in itself, so a
restructured document fails the check rather than passing it
unread. A finding about the XML points at the element, one about the
document at its row.

Validation: main's files pass. On copies of them, a message added at
53062 with no document change gives 2 findings (not in the table,
inside 53062-53089); FIRES listed at 53025 gives 1, at its row; a
stale row and a dropped MAV_CMD row give 1 each; a renamed "Shared
messages" heading gives 1; the military.xml of #10 at c55620e
passes. 21 policy tests pass on Python 3.10.12 and 3.14.5 with 100%
line and branch coverage (200 statements, 98 branches), and turning
off the reserved-block test, the ID comparison or the one table per
section rule each fails the suite. actionlint 1.7.12, zizmor 1.30.1
and ruff 0.14.0 report nothing.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
TSC21 added a commit that referenced this pull request Oct 2, 2026
The wire report compares only each message's name and CRC_EXTRA, but a
router places a message from the row mavgen writes for it in the C
headers' MAVLINK_MESSAGE_CRCS table: {msgid, CRC_EXTRA, min_length,
max_length, flags, target_system offset, target_component offset}. A
reviewer of an addressing change needs the lengths, the target flags
and the offsets, and the report hides all of them. It also hides any
change that leaves CRC_EXTRA alone: extension fields, including a
target field placed among them, which moves the routing row while
older peers keep accepting the message.

wire_map reads the row straight off pymavlink's parse, the same
attributes mavgen_c.py prints into the table. Each wire-breaking line
(removed, renamed, or CRC_EXTRA changed) carries the old and the new
row. A message whose row changes with CRC_EXTRA unchanged is listed as
an extension-only INFO change with both rows, and its step summary
note says that a sender without the new extension fields sends zeros
there, which a router reads as broadcast. Additions carry their row.
WIRE_COMPAT_ENFORCE still fails on wire-breaking changes only.

Validation: against the military.xml of #10 at c55620e the report
lists 12 wire-breaking messages with their rows, among them
MAVLINK_M_ACK {53004, 47, 69, 69, 0, 0, 0} -> {53004, 224, 69, 69, 3,
16, 17}, FIRES {53020, 16, 62, 62, 0, 0, 0} -> {53020, 124, 64, 64, 3,
40, 41} and SENSOR_TASKING {53050, 244, 24, 24, 0, 0, 0} -> {53050,
107, 26, 26, 3, 22, 23}. For both files, all 26 rows match the
MAVLINK_MESSAGE_CRCS table mavgen generates. 13 wire report tests
pass, 42 script tests in all, on Python 3.10.12 and 3.14.5, with 100%
line and branch coverage of check_wire_compat.py (64 statements, 24
branches); breaking on any row change, dropping the flags, or
ignoring CRC-compatible changes each fail the suite. ruff 0.14.0,
actionlint 1.7.12 and zizmor 1.30.1 report nothing.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
@TSC21
TSC21 force-pushed the rfc/sensor-tasking-addressing branch from 9d332b4 to 6987c44 Compare October 2, 2026 16:24
@TSC21 TSC21 changed the title feat: address the MAVLink-M messages that direct a specific system feat(dialect)!: address the messages that direct a specific system Oct 2, 2026
TSC21 added a commit that referenced this pull request Oct 2, 2026
README.md, IDMAPPING.md and the military.xml header document an ID
allocation, and peers rely on a wire contract: a message ID keeps its
meaning once assigned, and a peer drops any message whose CRC_EXTRA
differs from its own. Nothing checks either, so a pull request can
allocate outside 53000-53999, take an ID from the private 53900-53999
block, or change a message body without the wire effect showing in
review.

check_dialect_policy.py checks the documented allocation over both
dialect files: military.xml takes messages and MAV_CMD entries from
the shared 53000-53899 window, military_extensions.xml only from the
private block, names follow MAVLink's case conventions, and the
template reuses no shared ID or name. It needs only the Python
standard library, so it runs on a plain checkout. Schema conformance,
field types and clashes with common.xml stay with the schema check
and mavgen, which read the whole include tree.

check_wire_compat.py parses two versions of military.xml with
pymavlink's generator, which computes CRC_EXTRA the way every
consumer does, and lists removals, renames and CRC_EXTRA changes by
message ID. By default it only reports, because the dialect is still
changing and replacing a message body is a decision for review;
WIRE_COMPAT_ENFORCE=1 makes it fail once IDs are frozen. CRC_EXTRA
leaves out enum values and extension fields, so the report does not
see changes to them.

Validation: the policy check passes on main (26 messages and 4
MAV_CMD entries in the shared window, 2 template messages in the
private block), and a tree with 11 planted violations, one per rule,
reports all 11 and exits 1. The wire check reports 0 changes against
itself, and against #10 at c55620e it lists the 12 messages that PR
changes, FIRES at CRC_EXTRA 124; an independent diff of the field
lists finds the same 12.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
TSC21 added a commit that referenced this pull request Oct 2, 2026
The wire report compares CRC_EXTRA, which covers the name, type and
order of the base fields and nothing else. A changed enum value, a
removed enum entry, or an extension field that is removed, retyped
or moved changes what peers decode but leaves CRC_EXTRA alone, so the
report shows no change for any of them.

The wire-compat job runs mavlink/scripts/check_api_break.py from the
mavlink checkout at the pinned MAVLINK_REF, the check upstream runs
on its own definitions. Running it from the checkout, not a copy kept
here, means a pin bump updates the checker with the definitions it
is matched to; the composite action adds scripts/ to that sparse
checkout. The checker takes the base from the pull request event and
diffs every XML file the pull request changes, and its output goes
to the log and the step summary. Like the CRC report it only
reports, and one job-level WIRE_COMPAT_ENFORCE switch covers both
checks. An exit without the checker's verdict line is the tool
failing, not an API break, so the step fails on it whatever the
switch says, and a check that never ran cannot pass.

Validation, simulating the job's pull request environment with
mavlink 87da370 and main 1a7a00d as the base: a pull request that
changes no XML reports "No XML files changed." and passes. The
military.xml of #10 at c55620e reports 59 changed and 2 removed
fields in 12 messages, the same 12 the CRC report lists, as a
warning, and fails with WIRE_COMPAT_ENFORCE=1. Giving one
MAVLINK_M_TARGET_CLASS entry the value 40 in place of 4 raises the
warning here while the CRC report shows 0 wire-breaking changes. A
base commit missing from the history makes the checker raise, and
the step fails with "exited 1 without a verdict". actionlint 1.7.12,
with shellcheck, and zizmor 1.30.1, regular and auditor personas,
report nothing.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
TSC21 added a commit that referenced this pull request Oct 2, 2026
IDMAPPING.md is the allocation record. README.md links it as the
message-ID allocation, the site publishes it as the ID Allocation
page, and the contributing guide asks for it to be updated in the
same pull request as a new ID. Nothing compares it with military.xml,
so a pull request can add, move or drop a message and leave the table
and the published page wrong. On main the two agree: 26 messages, 4
MAV_CMD entries, and no ID in use inside a reserved block.

The policy check reads the first table under each "## " heading of
IDMAPPING.md. Every military.xml message must be in "Shared
messages" and every MAV_CMD entry in "MAV_CMD entries", at the ID
military.xml gives it (the New ID and New value columns), and every
row must name something military.xml defines. No message or command
may use an ID inside a "Reserved blocks" range that lies in the
shared window 53000-53899; the private block belongs to the
allocation rule. A name is the cell's leading UPPER_SNAKE word, so
"TARGET (formerly TARGET_COORD)" is TARGET, and backticks are
accepted. The history table and the old ID columns are not checked.
A missing file, table or column is a finding in itself, so a
restructured document fails the check rather than passing it
unread. A finding about the XML points at the element, one about the
document at its row.

Validation: main's files pass. On copies of them, a message added at
53062 with no document change gives 2 findings (not in the table,
inside 53062-53089); FIRES listed at 53025 gives 1, at its row; a
stale row and a dropped MAV_CMD row give 1 each; a renamed "Shared
messages" heading gives 1; the military.xml of #10 at c55620e
passes. 21 policy tests pass on Python 3.10.12 and 3.14.5 with 100%
line and branch coverage (200 statements, 98 branches), and turning
off the reserved-block test, the ID comparison or the one table per
section rule each fails the suite. actionlint 1.7.12, zizmor 1.30.1
and ruff 0.14.0 report nothing.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
TSC21 added a commit that referenced this pull request Oct 2, 2026
The wire report compares only each message's name and CRC_EXTRA, but a
router places a message from the row mavgen writes for it in the C
headers' MAVLINK_MESSAGE_CRCS table: {msgid, CRC_EXTRA, min_length,
max_length, flags, target_system offset, target_component offset}. A
reviewer of an addressing change needs the lengths, the target flags
and the offsets, and the report hides all of them. It also hides any
change that leaves CRC_EXTRA alone: extension fields, including a
target field placed among them, which moves the routing row while
older peers keep accepting the message.

wire_map reads the row straight off pymavlink's parse, the same
attributes mavgen_c.py prints into the table. Each wire-breaking line
(removed, renamed, or CRC_EXTRA changed) carries the old and the new
row. A message whose row changes with CRC_EXTRA unchanged is listed as
an extension-only INFO change with both rows, and its step summary
note says that a sender without the new extension fields sends zeros
there, which a router reads as broadcast. Additions carry their row.
WIRE_COMPAT_ENFORCE still fails on wire-breaking changes only.

Validation: against the military.xml of #10 at c55620e the report
lists 12 wire-breaking messages with their rows, among them
MAVLINK_M_ACK {53004, 47, 69, 69, 0, 0, 0} -> {53004, 224, 69, 69, 3,
16, 17}, FIRES {53020, 16, 62, 62, 0, 0, 0} -> {53020, 124, 64, 64, 3,
40, 41} and SENSOR_TASKING {53050, 244, 24, 24, 0, 0, 0} -> {53050,
107, 26, 26, 3, 22, 23}. For both files, all 26 rows match the
MAVLINK_MESSAGE_CRCS table mavgen generates. 13 wire report tests
pass, 42 script tests in all, on Python 3.10.12 and 3.14.5, with 100%
line and branch coverage of check_wire_compat.py (64 statements, 24
branches); breaking on any row change, dropping the flags, or
ignoring CRC-compatible changes each fail the suite. ruff 0.14.0,
actionlint 1.7.12 and zizmor 1.30.1 report nothing.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
TSC21 added 13 commits October 2, 2026 17:35
Every message in the dialect is broadcast: none carries
target_system or target_component, so a message that orders one
system to do something cannot say which system it is for. A station
tasking a sensor on one aircraft, or ordering an effector to engage,
puts that message where every system reading the dialect sees it.
With two aircraft on a shared bearer, or one aircraft carrying two
payloads that both speak the dialect, nothing in the message
separates them. MAVLink's own rule is that a point-to-point message
carries target_system, and target_component when components matter.

The eleven messages that command a system, or that hand something
to a named one, carry the two fields: FIRES, SPLASH_CORRECTION,
TARGET_HANDOVER, ESAD_ARMING, ESAD_CONFIG, SENSOR_TASKING,
CAS_9LINE, TERMINAL_CONTROL, ENGAGEMENT_DIRECTIVE, CALL_FOR_FIRE and
LOITER_MUNITION_CONTROL. The messages that describe the world keep
broadcasting, because a contact is a contact and anything listening
should draw it. The fields select the intended recipient for routing
and processing. They do not authorize a request or oblige anyone to
carry it out, and target_component counts only within a nonzero
target_system, as MAVLink routing reads it.

The names are the ones MAVLink reserves. mavgen fills the target
offsets in the message-entry table only for fields called
target_system and target_component, and that table is what a router
reads to place a message: FIRES becomes {53020, 124, 64, 64, 3, 40,
41}, where 3 is the have-target flag and 40 and 41 are the byte
offsets. A field named anything else leaves that row at zero and the
message keeps flooding every endpoint.

The fields are base fields, not extensions. CRC_EXTRA leaves out
extension fields, so a sender built from the old definition would
omit them, the receiver would read zero, and zero means every
system: addressing would fall back, unnoticed, to the broadcast it
exists to replace. A base field changes CRC_EXTRA instead, so old
and new definitions drop each other's messages, an ABORT included,
and every system has to move to the new definition together.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422, and the C headers compile at -Wall -Wextra
-Werror. The eleven messages change CRC_EXTRA, grow by two bytes and
carry the have-target flag; the other fifteen keep their rows.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
MAVLINK_M_ACK closes the loop on a message one system sent to
another, so it has exactly one correct destination: the sender of
the acknowledged message. A field of its own naming that sender
stays invisible to a router, which places messages from the
message-entry table mavgen generates and finds a zeroed target row
there, so an acknowledgment reaches every endpoint on the bearer.

The reserved names carry the destination, the way COMMAND_ACK
carries it for commands. target_system and target_component are the
source system and component IDs in the frame header of the
acknowledged message, never that message's own target fields, which
are 0 when it went to every system, and never 0 themselves. The
acknowledging system is the source of the frame, so the header names
it and the message body does not repeat it.

The dialect generates MAVLINK_M_ACK as {53004, 224, 69, 69, 3, 16,
17}: 3 is the have-target flag and 16 and 17 are the byte offsets of
the two fields, the bytes main's definition gives origin_sysid and
ack_sysid. The message ID and the 69-byte payload stay the same,
offset 16 carries the same system and offset 17 changes meaning, so
CRC_EXTRA, 47 on main and 224 here, is the only thing that tells the
two definitions apart on the wire. A peer that checks it drops the
other definition's acknowledgments.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422; the other twenty-five messages keep their rows.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
Three directive messages name who has to act but not what about.
ESAD_STATE reports the track a store is armed against, yet
ESAD_ARMING cannot carry it, so that binding is set out of band and
assumed to hold. SENSOR_TASKING can point a sensor only at a
coordinate, which a moving target leaves behind, so following it
means re-tasking with fresh coordinates. CAS_9LINE carries the brief
but no sequence and no track, so TERMINAL_CONTROL.sequence has no
brief to refer to.

ESAD_ARMING, SENSOR_TASKING and CAS_9LINE gain a track_uid, the
TRACK_IDENTITY handle FIRES, TARGET_HANDOVER and the other directives
already carry; all-zero means no track. An ARM's track is the one
ESAD_STATE.track_uid reports back. A task with a track follows it,
and its point of interest becomes a starting cue; when the sensor
designates or marks, the track only cues it and it lases or marks
what its own tracker holds. CAS_9LINE also gains the engagement
sequence TERMINAL_CONTROL refers to. ESAD_CONFIG and TERMINAL_CONTROL
get no track: a configuration sets a device mode, not an engagement,
and TERMINAL_CONTROL binds to its brief through sequence.

The three messages change on the wire with this pull request's
addressing anyway, so the new base fields add no second break for
consumers.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422, and the C headers compile at -Wall -Wextra
-Werror. Entry rows: ESAD_ARMING {53031, 3, 33, 33, 3, 12, 13},
SENSOR_TASKING {53050, 79, 42, 42, 3, 22, 23}, CAS_9LINE {53060,
151, 72, 72, 3, 50, 51}. The largest dialect payload stays 245
bytes, and against main the same 12 messages change, no others.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
SENSOR_TASKING is both a tasking request and the sensor's status
response, and it has one pair of target fields. Nothing says which
direction a frame is or whom each direction names, so one
implementation answers the requester while another echoes the
request's own target and sends the response back to the sensor.
Nothing says which fields a response carries either.

The status field tells the directions apart, the way TIMESYNC's
fields do for its request and response. A request carries UNSPECIFIED
and names the sensor or payload. A response carries ACK, REJECTED,
ACTIVE or COMPLETE and names the requester: the source system and
component IDs in the request's frame header. A response echoes
task_id and track_uid and reports the mode in effect in sensor_mode,
and receivers ignore its point-of-interest fields.
MAVLINK_M_TASKING_UNSPECIFIED says it marks a request.

Only descriptions change, so the wire format and CRC_EXTRA stay as
the addressing commits set them.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422; SENSOR_TASKING keeps the entry row {53050, 79,
42, 42, 3, 22, 23}.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
ESAD_ARMING and ESAD_CONFIG carry a target_system, so one command can
reach more than one system, but their multi-device rule reads as if
one receiver saw every selected ESAD: with esad_id = 0 "each targeted
ESAD must have advertised the same challenge hash; otherwise
receivers reject the broadcast command". A receiver can judge only
its own ESADs, so that rule has no reading across two systems, and
ESAD_CONFIG never says how its one hash is checked against several
ESADs. "Broadcast" also names two things: esad_id = 0 or store_id = 0
inside a system, and target_system = 0 across systems.

Each receiving system applies an ESAD_ARMING with esad_id = 0, or an
ESAD_CONFIG with esad_id = 0, store_id = 0 or both, only if every
local ESAD the command selects advertises arming_challenge_hash, and
rejects it otherwise. A command that reaches several systems can
apply on some and be rejected on others, so the originator confirms
each system from its ESAD_STATE. esad_id = 0 and store_id = 0 read
"every local ESAD" and "every store on the receiving system", here
and in the Store ID parameter of the four store commands, which
leaves "broadcast" to mean target_system = 0. ESAD_STATE.esad_id says
which ESADs must share the hash.

Only descriptions change, so every entry row stays as the addressing
commits set it.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422; the wire report against main lists the same 12
messages and rows, and "broadcast" appears nowhere in military.xml.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
ENGAGEMENT_DIRECTIVE.origin_sysid reads "System ID issuing the
directive", which the frame header also carries, so it is either a
duplicate of the header or a different system, and the definition
does not say which. The acknowledgment drops its ack_sysid for being
that duplicate, while TRACK_IDENTITY, TARGET_CUE and
PARTICIPANT_POSITION keep their origin_sysid for provenance.

origin_sysid takes the provenance meaning here too: the system that
first issued the directive. A system that passes the directive on as
a message of its own, a fire direction center forwarding an
observer's CHECK FIRE for example, keeps this value, so it can differ
from the source system ID in the frame header. Routers never change
either, since forwarded frames are not modified. The field is not
authenticated, so it serves display and audit, never a decision to
act or not to act.

Only the description changes; the wire format stays as the
addressing commits set it.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422, and ENGAGEMENT_DIRECTIVE keeps the entry row
{53023, 57, 46, 46, 3, 26, 27}.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
target_system = 0 sends a message to every system, and nothing in the
dialect says when that is allowed. Some of the addressed messages
carry fail-safe actions, where reaching everyone at once can be the
point: ABORT and CHECK_FIRE, DISARM, CEASE_FIRE, WAVE_OFF. Others
carry affirmative ones: a fire mission, a handover, ARM, RESUME,
CLEARED_HOT, a 9-line, a grant of consent. Sent to everyone, those let
several shooters converge on one target, or arm stores nobody meant
to arm. So the rule is per action and direction, not per message ID.

Each target_system description states the rule for its message. 0 is
valid only for actions that stop or hold: the ABORT and CHECK_FIRE
directives, DISARM, the ABORT and CEASE_FIRE clearances, the
CHECK_FIRING and CEASE_LOADING fire-mission commands, WAVE_OFF,
terminal ABORT, and withheld or withdrawn consent. From such a
message a receiver acts only on the stop, never drops it for anything
else the message carries, and ignores the rest, so a broadcast ABORT
does not send every munition to one loiter point and a broadcast
CHECK_FIRING does not hand its spotting to every cell. FIRES,
TARGET_HANDOVER, ESAD_CONFIG, CAS_9LINE, CALL_FOR_FIRE and
SENSOR_TASKING always name their recipient, SELF_DESTRUCT too because
it cannot be undone, and END_OF_MISSION because it ends a mission
rather than holding it. SENSOR_TASKING is never sent to every system,
not even as a STANDBY, since caging every sensor also cages a
designator guiding a weapon in flight. A 9-line goes to each aircraft
of a flight, and the store commands that arm, test or configure are
never sent to every system either. Receivers ignore a message sent
with 0 that the rule does not allow. Enum values are named in full,
so the generated reference links them.

Only descriptions change, so every entry row stays as the addressing
commits set it.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422; every enum entry the rules name exists in
military.xml, and the wire report against main lists the same 12
messages and rows.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
Addressing changes what reaches a recipient, and the dialect does not
say how. A router forwards a message whose target_system is not 0
only on links where it has heard that system, and drops it elsewhere
without telling the sender (mavlink-router endpoint.cpp:567-585), so
an ABORT addressed to a munition under emission control dies at the
first relay that has not heard it. Nothing says what target_system
holds for a participant beyond a gateway, so an acknowledgment or a
tasking response addressed to "the source of the request" stops at
the gateway, or a station cannot tell systems behind it apart. Two
networks joined with overlapping system IDs deliver one addressed
FIRES to two effectors. Nothing says what "every system" means at a
gateway boundary, and a router built from another definition drops
the changed messages, stops included.

A normative section in the dialect header states the rules once:
addressed delivery needs the recipient heard on the path, so a stop
for a silent recipient goes to every system and selects with its own
fields; an affirmative directive names its component when there is a
choice, a stop uses component 0; system IDs are network-local and
unique across joined networks, with remapping at a gateway;
identifiers are chosen by their originator and matched with its ID; a
gateway re-originates, gives each far-side participant a proxy system
ID with its own HEARTBEAT, returns responses, applies the receiver
rules before translating and only reports what the far side reported;
a stop to every system crosses a gateway as a stop, nothing else does;
and routers and gateways move to a new definition with everything
else. The per-message rules stay in the field descriptions, which the
generated reference shows.

Only the header comment changes, so the generated code and every entry
row stay the same.

Validation: the dialect validates against mavschema.xsd and generates
C and Python with strict units at mavlink 87da370 and pymavlink
19880422; the wire report against main lists the same 12 messages and
rows. The routing behavior cited is mavlink-router 2362c62 and the
devguide routing rules at master 7412790c2.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
MAVLINK_M_ACK names the message it answers by ack_msgid and
ack_instance, and that pair is not unique: an ABORT, a CHECK_FIRE and
a RESUME of mission 12 all come back as (53023, 12), CLEARED_HOT and
ABORT of one brief look the same, and LOITER_MUNITION_CONTROL and
TARGET_HANDOVER acknowledgments carry instance 0. A station cannot
tell which of its directives a system accepted. Nothing says which
messages are acknowledged at all, who answers a message sent to every
system, or what a receiver does with a repeat.

ack_time_usec carries the time_usec of the acknowledged message, the
field every message in the dialect starts with, so ack_msgid,
ack_instance and ack_time_usec name one message from one sender.
Every message that carries target_system is acknowledged, except
SENSOR_TASKING, whose status response answers it. A message sent to
every system is answered by every receiver, with the new
MAVLINK_M_ACK_NOT_APPLICABLE where nothing there is selected, so a
station can show one row per system after a broadcast stop. A repeat
with the same source, message ID and time_usec is a duplicate,
answered again and not acted on twice, which lets a sender retry and
a redundant path deliver twice. Receivers ignore an acknowledgment
sent to every system.

MAVLINK_M_ACK changes on the wire in this pull request anyway, so the
new base field adds no second break: the message generates as
{53004, 167, 77, 77, 3, 24, 25}, with target_system and
target_component at bytes 24 and 25. Implementations copy time_usec
into ack_time_usec and match pending directives on the three fields.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422, and the C headers compile at -Wall -Wextra
-Werror. The wire report against main lists the same 12 messages;
only MAVLINK_M_ACK's row differs from the commit before.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
A stop sent to every system has no defined reach. Nothing says
whether it matches on track or on sequence, sequence numbers repeat
across issuers, and no stop can say "every engagement", so a
network-wide CHECK FIRE can miss the mission it is meant for. The
dialect has four ways to say stop and requires no system to honour
any but its own, so a gateway that turns a CHECK FIRE into
ENGAGEMENT_DIRECTIVE does not stop a munition that only reads
LOITER_MUNITION_CONTROL. Stops travel to every link while starts
follow a learned route, so a late RESUME can arrive after the
CHECK_FIRE that should have held it, and nothing orders the two or
bounds how old a start may be. "Should be honored on receipt" also
leaves open whether message signing still applies.

An ABORT or CHECK_FIRE applies to every engagement its sequence or
nonzero track_uid matches, whoever issued it, so the stop errs toward
stopping more; a RESUME or RETARGET must match every selector it
carries. 0xFFFF in a stop's sequence means every engagement on the
receiver, and FIRES and CAS_9LINE reserve it. ENGAGEMENT_DIRECTIVE
ABORT and CHECK_FIRE become the common stop that every system able to
engage, release or guide a weapon honours, a loitering munition
mapping them to its own ABORT and WAVE_OFF. For one engagement a
start no later than the last stop is ignored, a start older than the
receiver's maximum age is answered MAVLINK_M_ACK_EXPIRED, and a stop
is never rejected for age. Safety stops are acted on once the link's
signing accepts them. FIELD CONVENTIONS notes that ABORT and DISARM
keep the value 0, so a zero-filled message is a stop, which fails
safe.

Breaking: 0xFFFF in a sequence takes a meaning it did not have, and
receivers must apply the matching and ordering rules. The messages
involved change on the wire in this pull request anyway.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422; the wire report against main lists the same 12
messages and rows as the commit before.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
TERMINAL_CONTROL and LOITER_MUNITION_CONTROL travel both ways, but
only SENSOR_TASKING says how each direction is addressed. An aircraft's
IN call or a munition's consent request has no stated recipient, and
sent to every system it is ignored under the rule for 0. An aircraft
report that echoes clearance ABORT to every system reads as an abort
for all of them, the human-authority wording reads as covering ABORT
and CEASE_FIRE too, and abort_authority_present reads as a gate on an
ABORT. consent_state is not defined as a change, so a station that
resends its state can grant consent again after a withdrawal.

In TERMINAL_CONTROL the controller sends clearance to the attacking
aircraft with aircraft_call NONE, and the aircraft sends aircraft_call
to the controller, the source of the brief, with clearance NONE.
CONTINUE and CLEARED_HOT stay human-authority instruments and count
only from the controller that sent the brief; ABORT and CEASE_FIRE
count on receipt from any sender, and abort_authority_present is a
precondition for CLEARED_HOT only. In LOITER_MUNITION_CONTROL a
consent request goes to the human authority, the source of the
tasking for that track, a grant counts only from that system and a
stop from anyone, and consent_state and terminate carry changes, with
NONE leaving the munition as it is.

Breaking: receivers must check the sender of a clearance or a grant
and treat consent_state and terminate as changes; the two messages
change on the wire in this pull request anyway.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422; the wire report against main lists the same 12
messages and rows as the commit before.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
A sensor answers only its current requester, so a requester whose
task another request replaces, or whose track the sensor loses, never
hears that its task ended: its station keeps showing the task as
active. task_id has no stated owner either, so two requesters can pick
the same one for one sensor.

MAVLINK_M_TASKING_STATUS gains MAVLINK_M_TASKING_INTERRUPTED. When a
task ends for any reason other than its own requester's request, the
sensor sends that requester a status response with it, echoing the
task's task_id and track_uid. task_id is chosen by the requester and
echoed unchanged, and a sensor tells tasks apart by the requester's
system and component IDs together with task_id. Status responses go
only to the requester.

Adding an enum entry and description text does not change the wire
format; SENSOR_TASKING keeps the entry row the addressing commits gave
it.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422; the wire report against main lists the same 12
messages and rows.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
ESAD_ARMING checks arming_challenge_hash for a DISARM as for an ARM,
and with esad_id = 0 the receiving system applies the command only if
every selected ESAD advertises the one hash it carries. One faulted
or reloaded ESAD therefore blocks disarming every other ESAD on the
system, and a DISARM sent to every system, which the rule for 0
allows, carries one hash and works on one system at most. The texts
also call the hash authentication: ESAD_CONFIG says an
unauthenticated sender cannot reconfigure the device, and the header
says the hash authenticates the MAVLink leg, while ESAD_STATE sends
the hash to every system, so any listener can copy it.

A DISARM applies to every selected local ESAD without checking the
hash, so no ESAD's state can hold it back; an ARM keeps the
all-or-nothing rule, so it never applies in part, and ESAD_CONFIG
keeps its check. The hash is described as what it is, a public value
that ties a command to the state the ESAD advertised, with the
sender authenticated by MAVLink message signing on the link, as for
any stop. A command that selects no local ESAD is rejected, and
ESAD_STATE clears its track_uid when the store is disarmed.

Breaking: a receiver accepts a DISARM whatever its hash, and rejects
a command that selects nothing. ESAD_ARMING changes on the wire in
this pull request anyway.

Validation: the dialect validates against mavschema.xsd and
generates C and Python with strict units at mavlink 87da370 and
pymavlink 19880422; ESAD_ARMING and ESAD_CONFIG keep the rows {53031,
3, 33, 33, 3, 12, 13} and {53032, 163, 17, 17, 3, 12, 13}.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
@TSC21
TSC21 force-pushed the rfc/sensor-tasking-addressing branch from 6987c44 to 1215ede Compare October 2, 2026 16:46
@TSC21

TSC21 commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

@ryanjAA one more round on top of your review. I ran an adversarial pass over this PR from four angles (MAVLink routing, tactical comms and gateways, weapons safety, operator UX), checked each finding against the devguide, mavlink-router and what mavgen generates, and the gaps it found are in 1215ede.

  • Gateways and routing: the dialect header says what addressing needs from routers. A router drops an addressed message for a recipient it hasn't heard on that link, so a stop for a silent recipient goes to every system and selects with its own fields. A gateway re-originates, gives each far-side participant a proxy system ID with its own HEARTBEAT, returns acknowledgments to it and only reports what the far side reported, and system IDs are unique across joined networks. origin_sysid reads as the system that first issued the directive, kept by a system that passes it on as its own message; routers never change a frame, so my earlier "a relay keeps this value" was wrong.
  • MAVLINK_M_ACK echoes the acknowledged message's time_usec in ack_time_usec, so a CHECK_FIRE and a RESUME of the same mission get different answers ({53004, 167, 77, 77, 3, 24, 25}). Every addressed directive is acknowledged, a message sent to every system by each receiver with a new NOT_APPLICABLE result where nothing is selected, and a repeat is a duplicate.
  • Stops: from a message sent to every system a receiver acts only on the stop, a stop applies to every engagement it matches, 0xFFFF means all of them, ENGAGEMENT_DIRECTIVE ABORT and CHECK_FIRE are the common stop, and a stop wins over an earlier start. SENSOR_TASKING is never sent to every system, since a STANDBY would cage a designator guiding a weapon in flight, and END_OF_MISSION stays addressed.
  • TERMINAL_CONTROL and LOITER_MUNITION_CONTROL say who sends what to whom, as SENSOR_TASKING does. CLEARED_HOT and a consent grant count only from the controller or authority that tasked the aircraft or munition; a stop counts from anyone.
  • A DISARM applies whatever the ESAD challenge holds, so one faulted ESAD can't block the rest and a DISARM to every system works. The texts stop calling the hash authentication, since ESAD_STATE sends it to everyone; signing authenticates the sender.
  • SENSOR_TASKING tells a requester when its task is interrupted.

The base-field choice stays, and the description says what it costs: across a definition boundary an ABORT is dropped too, so every system moves together.

TSC21 added a commit that referenced this pull request Oct 2, 2026
IDMAPPING.md is the allocation record. README.md links it as the
message-ID allocation, the site publishes it as the ID Allocation
page, and the contributing guide asks for it to be updated in the
same pull request as a new ID. Nothing compares it with military.xml,
so a pull request can add, move or drop a message and leave the table
and the published page wrong. On main the two agree: 26 messages, 4
MAV_CMD entries, and no ID in use inside a reserved block.

The policy check reads the first table under each "## " heading of
IDMAPPING.md. Every military.xml message must be in "Shared
messages" and every MAV_CMD entry in "MAV_CMD entries", at the ID
military.xml gives it (the New ID and New value columns), and every
row must name something military.xml defines. No message or command
may use an ID inside a "Reserved blocks" range that lies in the
shared window 53000-53899; the private block belongs to the
allocation rule. A name is the cell's leading UPPER_SNAKE word, so
"TARGET (formerly TARGET_COORD)" is TARGET, and backticks are
accepted. The history table and the old ID columns are not checked.
A missing file, table or column is a finding in itself, so a
restructured document fails the check rather than passing it
unread. A finding about the XML points at the element, one about the
document at its row.

Validation: main's files pass. On copies of them, a message added at
53062 with no document change gives 2 findings (not in the table,
inside 53062-53089); FIRES listed at 53025 gives 1, at its row; a
stale row and a dropped MAV_CMD row give 1 each; a renamed "Shared
messages" heading gives 1; the military.xml of #10 at c55620e
passes. 21 policy tests pass on Python 3.10.12 and 3.14.5 with 100%
line and branch coverage (200 statements, 98 branches), and turning
off the reserved-block test, the ID comparison or the one table per
section rule each fails the suite. actionlint 1.7.12, zizmor 1.30.1
and ruff 0.14.0 report nothing.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>
TSC21 added a commit that referenced this pull request Oct 2, 2026
The wire report compares only each message's name and CRC_EXTRA, but a
router places a message from the row mavgen writes for it in the C
headers' MAVLINK_MESSAGE_CRCS table: {msgid, CRC_EXTRA, min_length,
max_length, flags, target_system offset, target_component offset}. A
reviewer of an addressing change needs the lengths, the target flags
and the offsets, and the report hides all of them. It also hides any
change that leaves CRC_EXTRA alone: extension fields, including a
target field placed among them, which moves the routing row while
older peers keep accepting the message.

wire_map reads the row straight off pymavlink's parse, the same
attributes mavgen_c.py prints into the table. Each wire-breaking line
(removed, renamed, or CRC_EXTRA changed) carries the old and the new
row. A message whose row changes with CRC_EXTRA unchanged is listed as
an extension-only INFO change with both rows, and its step summary
note says that a sender without the new extension fields sends zeros
there, which a router reads as broadcast. Additions carry their row.
WIRE_COMPAT_ENFORCE still fails on wire-breaking changes only.

Validation: against the military.xml of #10 at c55620e the report
lists 12 wire-breaking messages with their rows, among them
MAVLINK_M_ACK {53004, 47, 69, 69, 0, 0, 0} -> {53004, 224, 69, 69, 3,
16, 17}, FIRES {53020, 16, 62, 62, 0, 0, 0} -> {53020, 124, 64, 64, 3,
40, 41} and SENSOR_TASKING {53050, 244, 24, 24, 0, 0, 0} -> {53050,
107, 26, 26, 3, 22, 23}. For both files, all 26 rows match the
MAVLINK_MESSAGE_CRCS table mavgen generates. 13 wire report tests
pass, 42 script tests in all, on Python 3.10.12 and 3.14.5, with 100%
line and branch coverage of check_wire_compat.py (64 statements, 24
branches); breaking on any row change, dropping the flags, or
ignoring CRC-compatible changes each fail the suite. ruff 0.14.0,
actionlint 1.7.12 and zizmor 1.30.1 report nothing.

Assisted-by: Claude-Code:claude-opus-5-5
Signed-off-by: TSC21 <nmarques@riis.com>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants