Files
PX4-Autopilot/Tools/mavftp_param.py
Jacob Dahl dcfce4343a feat(mavlink): serve and accept packed parameters as @PARAM/param.pck (#28435)
* feat(mavlink): serve packed parameters as @PARAM/param.pck

QGC already downloads ArduPilot parameters as a packed FTP file, which
is much more reliable on lossy SiK links than the PARAM_VALUE firehose.
Serve the same format from PX4 so a hash miss can use that path.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* fix(mavlink): serialize the full message when resending a cached FTP reply

_last_reply caches only the header, PayloadHeader and four data bytes,
but the resend path handed that 19-byte array to the serializer as a
complete mavlink_file_transfer_protocol_t, which reads 254 bytes and
transmits whatever follows the cache. The decoded request is dead once
the resend decision is made, so expand the cache into it instead of
adding another message to the receiver thread's stack. Clear the unused
payload before caching so a resent NAK matches the original.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* test(mavsdk_tests): download parameters via MAVLink FTP

Fetch @PARAM/param.pck through MAVSDK's burst download on SIH and check
every entry against the PARAM_VALUE stream, so the packed format and the
FTP server's session, size and EOF handling stay covered by the SITL
tests that QGC and MAVSDK both rely on.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* fix(mavlink): cache FTP Open ACKs whose size LSB is 5

_reply skipped the compact cache whenever data[0] was kErrNoSessionsAvailable. On a NAK that is the error code; on an Open ACK it is the file size LSB. A lost Open ACK for param.pck of that length was retried as a second Open and failed.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* feat(tools): download packed parameters over MAVFTP

Host-side check of @PARAM/param.pck against the PARAM_VALUE stream.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* fix(mavlink): pad so a defaulted value does not straddle an FTP block

pack() kept the last 4 bytes of an entry in one chunk. With withdefaults those bytes are the default, so the value can still split. QGC always requests defaults; a hole-fill then retries one chunk with a live param_get().

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* fix(mavlink): snapshot param.pck at open so a param_set cannot corrupt it

Pack the used set once into a heap buffer. Reads memcpy that snapshot, so a value change during the download cannot shift later entries or splice two generations across a retried FTP block.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* fix(mavlink): freeze param.pck membership instead of snapshotting the file

Keep which parameters appear and whether each includes a default in bitsets (~1 KB). Pack values live. Layout cannot shift mid-download; a retried block cannot splice a number.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* style(mavlink): format MavlinkFtpParamTest.cpp

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* refactor(mavlink): keep FTP work buffers as members

@PARAM/param.pck is a connect-time path, not the rare path the lazy new[] was for. send() could free the buffers after 2 s of no request while a burst was still running. Fold them into the instance (495 B) and always idle-close the session at 30 s.

A new Open takes over the single session so a lost Terminate on a slow link does not NAK until that close.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* fix(tools): make mavftp_param.py reliable on a radio link

pymavlink applied FTP timings after ResetSessions, wrote param.pck?withdefaults=1 into cwd, and bumped the session id on Open retry (PX4 only ACKs session 0). Set radio timings at construction, pin session 0, retry a missed Open, and treat a complete decode as success.

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>

* feat(mavlink): accept packed parameter FTP uploads

CreateFile/Write/Terminate of @PARAM/param.pck applies the ArduPilot
packed stream so a GCS can bulk-load without a PARAM_SET round trip per
value. Unknown and read-only names are skipped; Terminate NAKs a
truncated file or the with-defaults magic.

* test(mavsdk_tests): upload MPC_XY_P via packed param FTP

Pin the SIH upload case to a known float instead of whatever
get_all_params() returns first.

---------

Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
2026-09-08 20:16:14 -06:00

360 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
############################################################################
#
# Copyright (c) 2026 PX4 Development Team. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in
# the documentation and/or other materials provided with the
# distribution.
# 3. Neither the name PX4 nor the names of its contributors may be
# used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
# OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
############################################################################
"""Download @PARAM/param.pck over MAVLink FTP and optionally check it against PARAM_VALUE.
Requires pymavlink. Firmware without the virtual file NAKs File Not Found.
Examples:
Tools/mavftp_param.py /dev/ttyACM0
Tools/mavftp_param.py /dev/ttyUSB0 -b 57600 --save param.pck
Tools/mavftp_param.py udp:127.0.0.1:14550 --ftp-only
"""
from __future__ import annotations
import argparse
import os
import struct
import sys
import tempfile
import time
try:
from pymavlink import mavftp, mavutil
except ImportError as e:
print("Failed to import pymavlink: " + str(e))
print("")
print("You may need to install it with:")
print(" pip3 install --user pymavlink")
print("")
sys.exit(2)
# pymavlink: add_message() can TypeError on FILE_TRANSFER_PROTOCOL sharing a
# link with instanced telemetry (BATTERY_STATUS, ...). Wrap it.
_orig_add_message = mavutil.add_message
def _safe_add_message(messages, mtype, msg):
try:
_orig_add_message(messages, mtype, msg)
except TypeError:
messages[mtype] = msg
mavutil.add_message = _safe_add_message
# PX4 has one FTP session and always ACKs session 0. pymavlink increments the
# session on an Open retry, then discards the ACK as the wrong session.
_orig_ftp_send = mavftp.MAVFTP._MAVFTP__send
def _ftp_send_session0(self, op):
self.session = 0
op.session = 0
return _orig_ftp_send(self, op)
mavftp.MAVFTP._MAVFTP__send = _ftp_send_session0
# packed type 3/4 vs MAV_PARAM_TYPE_INT32/REAL32
_MAV_TO_PCK = {6: 3, 9: 4}
def _param_name(msg) -> str:
raw = msg.param_id
if isinstance(raw, bytes):
return raw.split(b"\x00", 1)[0].decode("ascii", "replace")
return raw.split("\x00", 1)[0]
def collect_param_value(m, timeout: float) -> dict[str, tuple[int, int]]:
"""name -> (mav_type, uint32 bits). Skips _HASH_CHECK."""
t0 = time.time()
while time.time() - t0 < 0.3:
m.recv_match(type="PARAM_VALUE", blocking=True, timeout=0.05)
m.mav.param_request_list_send(m.target_system, m.target_component)
got: dict[str, tuple[int, int]] = {}
expected = None
deadline = time.time() + timeout
while time.time() < deadline:
msg = m.recv_match(type="PARAM_VALUE", blocking=True, timeout=0.5)
if msg is None:
if expected is not None and len(got) >= expected:
break
continue
name = _param_name(msg)
expected = int(msg.param_count)
if name == "_HASH_CHECK":
continue
bits = struct.unpack("<I", struct.pack("<f", msg.param_value))[0]
got[name] = (int(msg.param_type), bits)
if expected and len(got) >= expected:
extra_end = time.time() + 0.4
while time.time() < extra_end:
msg = m.recv_match(type="PARAM_VALUE", blocking=True, timeout=0.1)
if msg is None:
break
name = _param_name(msg)
if name == "_HASH_CHECK":
continue
bits = struct.unpack("<I", struct.pack("<f", msg.param_value))[0]
got[name] = (int(msg.param_type), bits)
break
return got, expected
def _radio_link(port: str, baud: int) -> bool:
p = port.lower()
if p.startswith(("udp", "tcp", "udpin", "tcpin")):
return False
if "ttyacm" in p or "fmu" in p or "cdc" in p:
return False
return baud <= 115200
def _ftp_settings(port: str, baud: int):
# MAVFTP.__init__ ResetSessions uses process_ftp_reply timeout=5, which asserts
# timeout > idle_detection_time. idle must also be > read_retry_time.
# On SiK, a 0.5s burst retry restarts the stream while radio frames are still
# in flight and blows a hole the gap-filler then has to walk.
radio = _radio_link(port, baud)
if radio:
idle, read_retry, retry = 2.5, 2.0, 2.0
else:
idle, read_retry, retry = 1.2, 1.0, 0.5
return mavftp.MAVFTPSettings([
("debug", int, 0),
("pkt_loss_tx", int, 0),
("pkt_loss_rx", int, 0),
("max_backlog", int, 5),
("burst_read_size", int, 239),
("write_size", int, 80),
("write_qsize", int, 5),
("idle_detection_time", float, idle),
("read_retry_time", float, read_retry),
("retry_time", float, retry),
]), radio
def _complete_pck(data) -> bool:
if not data or len(data) < 6:
return False
magic, num, _total = struct.unpack("<HHH", data[:6])
if magic not in (0x671B, 0x671C):
return False
packed = decode_pck(data)
return packed is not None and len(packed) == num
def _drain_ftp(m, quiet: float, cap: float) -> int:
"""Discard in-flight FILE_TRANSFER_PROTOCOL until the link is quiet."""
n = 0
deadline = time.time() + cap
quiet_since = time.time()
while time.time() < deadline:
msg = m.recv_match(type="FILE_TRANSFER_PROTOCOL", blocking=True,
timeout=min(0.2, quiet))
if msg is None:
if time.time() - quiet_since >= quiet:
break
continue
n += 1
quiet_since = time.time()
return n
def ftp_get(m, remote: str, timeout: float, settings, drain: bool):
ftp = mavftp.MAVFTP(m, target_system=m.target_system, target_component=m.target_component,
settings=settings)
if drain:
_drain_ftp(m, quiet=0.4, cap=4.0)
holder = {"data": None}
def cb(fh):
if fh is not None:
pos = fh.tell()
fh.seek(0)
holder["data"] = fh.read()
fh.seek(pos)
# pymavlink writes self.filename after the callback even when using BytesIO
fd, local = tempfile.mkstemp(prefix="mavftp-param-", suffix=".pck")
os.close(fd)
try:
ret = ftp.cmd_get([remote, local], callback=cb)
if ret.error_code != mavftp.FtpError.Success:
return None, ret
ret = ftp.process_ftp_reply("OpenFileRO", timeout=timeout)
data = holder["data"]
if not _complete_pck(data) and os.path.isfile(local):
with open(local, "rb") as f:
on_disk = f.read()
if _complete_pck(on_disk):
data = on_disk
return data, ret
finally:
try:
os.unlink(local)
except OSError:
pass
def decode_pck(data: bytes):
pdata = mavftp.MAVFTP.ftp_param_decode(data)
if pdata is None:
return None
values = {}
for name, value, ptype in pdata.params:
n = name.decode("utf-8") if isinstance(name, bytes) else name
if ptype == 3:
bits = struct.unpack("<I", struct.pack("<i", int(value)))[0]
else:
bits = struct.unpack("<I", struct.pack("<f", float(value)))[0]
values[n] = (ptype, bits)
return values
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("port", help="MAVLink connection (serial device, udp:HOST:PORT, tcp:HOST:PORT)")
p.add_argument("--baudrate", "-b", type=int, default=57600, help="serial baud rate (default: %(default)s)")
p.add_argument("--timeout", type=float, default=60, help="seconds for FTP and PARAM_VALUE (default: %(default)s)")
p.add_argument("--attempts", type=int, default=3, help="FTP Open/burst attempts (default: %(default)s)")
p.add_argument("--withdefaults", action="store_true", default=True,
help="request ?withdefaults=1 (default)")
p.add_argument("--no-withdefaults", action="store_false", dest="withdefaults")
p.add_argument("--ftp-only", action="store_true", help="skip the PARAM_VALUE comparison")
p.add_argument("--save", metavar="FILE", help="write the packed file to FILE")
args = p.parse_args()
remote = "@PARAM/param.pck?withdefaults=1" if args.withdefaults else "@PARAM/param.pck"
m = mavutil.mavlink_connection(args.port, baud=args.baudrate, autoreconnect=True, source_system=250)
hb = m.wait_heartbeat(timeout=8)
if hb is None:
print(f"no heartbeat on {args.port}")
return 1
print(f"heartbeat sys={hb.get_srcSystem()} comp={hb.get_srcComponent()}")
stream = {}
expected = None
if not args.ftp_only:
t0 = time.time()
stream, expected = collect_param_value(m, args.timeout)
print(f"PARAM_VALUE: {len(stream)} params" +
(f" (of {expected})" if expected else "") +
f" in {time.time() - t0:.2f}s")
settings, radio = _ftp_settings(args.port, args.baudrate)
if radio:
print(f"radio timings (baud={args.baudrate}): idle={settings.idle_detection_time}s "
f"open-retry={settings.read_retry_time}s burst-retry={settings.retry_time}s")
n = _drain_ftp(m, quiet=0.4, cap=4.0)
if n:
print(f"drained {n} leftover FTP frames")
t0 = time.time()
data, ret = None, None
for attempt in range(1, max(1, args.attempts) + 1):
data, ret = ftp_get(m, remote, args.timeout, settings, drain=radio)
if _complete_pck(data):
break
nbytes = 0 if data is None else len(data)
err = "?" if ret is None else f"error={ret.error_code} errno={ret.system_error}"
print(f"FTP attempt {attempt}/{args.attempts} failed: {err} bytes={nbytes}")
if attempt < args.attempts:
if radio:
_drain_ftp(m, quiet=0.4, cap=4.0)
else:
time.sleep(0.5)
t_ftp = time.time() - t0
nbytes = 0 if data is None else len(data)
err = "?" if ret is None else f"error={ret.error_code} errno={ret.system_error}"
print(f"FTP {remote}: {err} {t_ftp:.2f}s bytes={nbytes}")
m.close()
if not _complete_pck(data):
return 1
if args.save:
with open(args.save, "wb") as f:
f.write(data)
print(f"wrote {args.save}")
packed = decode_pck(data)
if packed is None:
print("failed to decode packed file")
return 1
magic, num, total = struct.unpack("<HHH", data[:6])
print(f"packed magic=0x{magic:x} num={num} total={total} decoded={len(packed)}")
if args.ftp_only:
return 0 if len(packed) == num else 1
only_stream = sorted(set(stream) - set(packed))
only_packed = sorted(set(packed) - set(stream))
mismatches = []
compared = 0
for name, (mav_type, bits) in stream.items():
if name not in packed:
continue
ptype, pbits = packed[name]
if _MAV_TO_PCK.get(mav_type) != ptype or bits != pbits:
mismatches.append(name)
compared += 1
print(f"compared {compared}; only_stream={len(only_stream)} only_packed={len(only_packed)} mismatches={len(mismatches)}")
if only_stream[:8]:
print(" only_stream", only_stream[:8])
if only_packed[:8]:
print(" only_packed", only_packed[:8])
if mismatches[:8]:
print(" mismatches", mismatches[:8])
stream_complete = expected is not None and len(stream) >= expected
if mismatches or only_stream:
return 1
if stream_complete and only_packed:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())