Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d4e07c5
msp_fc_version imitates the BF 2026.6.0
rtlopez Aug 2, 2026
9de893a
msp_mcu_info
rtlopez Aug 2, 2026
4040ec4
msp2_sensor_config_active & msp2_gyro_sensor_active
rtlopez Aug 2, 2026
f1236a0
msp_attitude_quaternion
rtlopez Aug 2, 2026
e0cf47e
msp2_get_text & msp2_set_text
rtlopez Aug 2, 2026
d51ea17
msp_status & msp_set_rtc & arming_disable_flags
rtlopez Aug 2, 2026
14b7e2b
simplified tuning init
rtlopez Aug 3, 2026
143d946
pid_advanced + rc_tuning + compass_config + pid defaults + move tpa
rtlopez Aug 3, 2026
81dc049
msp filter_config
rtlopez Aug 4, 2026
3cab675
msp_rx_config
rtlopez Aug 4, 2026
e805a42
msp_sensor_config
rtlopez Aug 4, 2026
0bd73f4
sensor_config + sensor_alignment + motor_config
rtlopez Aug 5, 2026
01029df
drop minthrottle + rename dshot_idle to motor_idle
rtlopez Aug 7, 2026
a78a95e
msp_reset_conf + msp_reboot
rtlopez Aug 10, 2026
e331637
bf cli sensors and mag_calibration
rtlopez Aug 10, 2026
1baf2a1
cli unit tests
rtlopez Aug 11, 2026
86e89a1
cli unittests more
rtlopez Aug 11, 2026
be6554a
msp_blackbox_config
rtlopez Aug 11, 2026
ceb2c07
cli - handle leaking garbage from msp
rtlopez Aug 11, 2026
960c524
msp_blackbox_config + test_input_crsf fix
rtlopez Aug 13, 2026
3e281af
code style
rtlopez Aug 13, 2026
2137080
drop input interpolation
rtlopez Aug 14, 2026
a50d2ad
msp_rc_config + throttle filter auto factor
rtlopez Aug 15, 2026
e71afb6
cli pid tuning params
rtlopez Aug 15, 2026
f7bda56
pid tuning fix
rtlopez Aug 16, 2026
b4e047d
cli format
rtlopez Aug 17, 2026
85bbf09
msp_pid + msp_pid_advanced fix
rtlopez Aug 17, 2026
a9f7b0f
pid defaults update
rtlopez Aug 17, 2026
53eafe9
live config update - part 1
rtlopez Aug 25, 2026
bfcb7bd
live config update - part 2
rtlopez Aug 25, 2026
196549a
live config update - part 3
rtlopez Aug 26, 2026
a4fd489
msp gyro_enabled_mask fix + msp.py debug script
rtlopez Aug 28, 2026
73d1e9e
sanitize data flow
rtlopez Aug 28, 2026
e324694
align debug modes
rtlopez Aug 28, 2026
b4e48b8
dev debug mode + throttle filter
rtlopez Aug 28, 2026
864582e
cli fixes
rtlopez Aug 28, 2026
bf53d7a
review fixes
rtlopez Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
358 changes: 358 additions & 0 deletions bin/msp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
#!/usr/bin/env python3
# Requires pyserial: install with `python3 -m pip install pyserial`
# On Ubuntu you can also install it with `sudo apt install python3-serial`
import argparse
from dataclasses import dataclass
import sys
import time

import serial

"""
MSP debug script
"""

MSP_BUF_SIZE = 192
TIMEOUT_SECONDS = 3.0

MSP_STATE_IDLE = 0
MSP_STATE_HEADER_START = 1
MSP_STATE_HEADER_M = 2
MSP_STATE_HEADER_V1 = 3
MSP_STATE_PAYLOAD_V1 = 4
MSP_STATE_CHECKSUM_V1 = 5
MSP_STATE_HEADER_X = 6
MSP_STATE_HEADER_V2 = 7
MSP_STATE_PAYLOAD_V2 = 8
MSP_STATE_CHECKSUM_V2 = 9
MSP_STATE_RECEIVED = 10

MSP_TYPE_CMD = 0
MSP_TYPE_REPLY = 1

MSP_V1 = 0
MSP_V2 = 1

@dataclass
class ParsedFrame:
version: int
direction: int
frame_type: int
flags: int
cmd: int
payload: bytes
checksum: int
raw: bytes

@property
def header_length(self) -> int:
return 5 if self.version == MSP_V1 else 8

@property
def header(self) -> bytes:
return self.raw[: self.header_length]


class MspParser:
def __init__(self) -> None:
self.reset(full=True)

def reset(self, full: bool = False) -> None:
self.state = MSP_STATE_IDLE
self.version = MSP_V1
self.direction = MSP_TYPE_CMD
self.frame_type = 0
self.flags = 0
self.cmd = 0
self.expected = 0
self.received = 0
self.checksum = 0
self.checksum2 = 0
self.buffer = bytearray()
self.raw = bytearray() if full else self.raw[:0]

def feed(self, byte: int):
c = byte & 0xFF

if self.state == MSP_STATE_IDLE:
if c == ord('$'):
self.raw = bytearray((c,))
self.state = MSP_STATE_HEADER_START
return None

if self.state == MSP_STATE_HEADER_START:
self.received = 0
self.checksum = 0
self.checksum2 = 0
self.buffer = bytearray()
self.raw.append(c)
if c == ord('M'):
self.version = MSP_V1
self.state = MSP_STATE_HEADER_M
elif c == ord('X'):
self.version = MSP_V2
self.state = MSP_STATE_HEADER_X
else:
self.reset()
return None

if self.state == MSP_STATE_HEADER_M:
self.raw.append(c)
if c == ord('>'):
self.direction = MSP_TYPE_REPLY
self.frame_type = c
self.state = MSP_STATE_HEADER_V1
elif c == ord('<'):
self.direction = MSP_TYPE_CMD
self.frame_type = c
self.state = MSP_STATE_HEADER_V1
elif c == ord('!'):
self.direction = MSP_TYPE_REPLY
self.frame_type = c
self.state = MSP_STATE_HEADER_V1
else:
self.reset()
return None

if self.state == MSP_STATE_HEADER_X:
self.raw.append(c)
if c == ord('>'):
self.direction = MSP_TYPE_REPLY
self.frame_type = c
self.state = MSP_STATE_HEADER_V2
elif c == ord('<'):
self.direction = MSP_TYPE_CMD
self.frame_type = c
self.state = MSP_STATE_HEADER_V2
elif c == ord('!'):
self.direction = MSP_TYPE_REPLY
self.frame_type = c
self.state = MSP_STATE_HEADER_V2
else:
self.reset()
return None

if self.state == MSP_STATE_HEADER_V1:
self.buffer.append(c)
self.raw.append(c)
self.received += 1
self.checksum ^= c
if self.received == 2:
size = self.buffer[0]
if size > MSP_BUF_SIZE:
self.reset()
else:
self.expected = size
self.cmd = self.buffer[1]
self.received = 0
self.buffer = bytearray()
self.state = MSP_STATE_PAYLOAD_V1 if self.expected > 0 else MSP_STATE_CHECKSUM_V1
return None

if self.state == MSP_STATE_PAYLOAD_V1:
self.buffer.append(c)
self.raw.append(c)
self.received += 1
self.checksum ^= c
if self.received == self.expected:
self.state = MSP_STATE_CHECKSUM_V1
return None

if self.state == MSP_STATE_CHECKSUM_V1:
self.raw.append(c)
if self.checksum != c:
self.reset()
return None
frame = ParsedFrame(
version=self.version,
direction=self.direction,
frame_type=self.frame_type,
flags=0,
cmd=self.cmd,
payload=bytes(self.buffer),
checksum=c,
raw=bytes(self.raw),
)
self.state = MSP_STATE_RECEIVED
self.reset()
return frame

if self.state == MSP_STATE_HEADER_V2:
self.buffer.append(c)
self.raw.append(c)
self.received += 1
self.checksum2 = crc8_dvb_s2(self.checksum2, c)
if self.received == 5:
flags = self.buffer[0]
cmd = self.buffer[1] | (self.buffer[2] << 8)
size = self.buffer[3] | (self.buffer[4] << 8)
if size > MSP_BUF_SIZE:
self.reset()
else:
self.flags = flags
self.cmd = cmd
self.expected = size
self.received = 0
self.buffer = bytearray()
self.state = MSP_STATE_PAYLOAD_V2 if self.expected > 0 else MSP_STATE_CHECKSUM_V2
return None

if self.state == MSP_STATE_PAYLOAD_V2:
self.buffer.append(c)
self.raw.append(c)
self.received += 1
self.checksum2 = crc8_dvb_s2(self.checksum2, c)
if self.received == self.expected:
self.state = MSP_STATE_CHECKSUM_V2
return None

if self.state == MSP_STATE_CHECKSUM_V2:
self.raw.append(c)
if self.checksum2 != c:
self.reset()
return None
frame = ParsedFrame(
version=self.version,
direction=self.direction,
frame_type=self.frame_type,
flags=self.flags,
cmd=self.cmd,
payload=bytes(self.buffer),
checksum=c,
raw=bytes(self.raw),
)
self.state = MSP_STATE_RECEIVED
self.reset()
return frame

self.reset()
return None


def crc8_dvb_s2(crc: int, value: int) -> int:
crc ^= value & 0xFF
for _ in range(8):
if crc & 0x80:
crc = ((crc << 1) ^ 0xD5) & 0xFF
else:
crc = (crc << 1) & 0xFF
return crc


def parse_message_id(value: str) -> int:
try:
cmd = int(value, 0)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid message id: {value}") from exc
if not 0 <= cmd <= 0xFFFF:
raise argparse.ArgumentTypeError("message id must be in range 0..65535")
return cmd


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("port")
parser.add_argument("message_id", type=parse_message_id)
parser.add_argument("--baud", type=int, default=115200)
return parser.parse_args()


@dataclass
class RequestFrame:
version: int
header: bytes
payload: bytes
checksum: int

@property
def raw(self) -> bytes:
return self.header + self.payload + bytes((self.checksum,))


def build_request(cmd: int) -> RequestFrame:
if cmd <= 0xFF:
header = bytes((ord('$'), ord('M'), ord('<'), 0, cmd))
checksum = 0 ^ header[3] ^ header[4]
return RequestFrame(MSP_V1, header, b"", checksum)

header = bytes((ord('$'), ord('X'), ord('<'), 0, cmd & 0xFF, (cmd >> 8) & 0xFF, 0, 0))
checksum = 0
for value in header[3:8]:
checksum = crc8_dvb_s2(checksum, value)
return RequestFrame(MSP_V2, header, b"", checksum)


def read_response(ser: serial.Serial, expected_cmd: int, timeout: float) -> ParsedFrame:
parser = MspParser()
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"timeout waiting for response to message {expected_cmd}")
ser.timeout = max(0.0, min(remaining, 0.2))
chunk = ser.read(256)
if not chunk:
continue
for byte in chunk:
frame = parser.feed(byte)
if frame and frame.direction == MSP_TYPE_REPLY and frame.cmd == expected_cmd:
return frame


def open_serial(port: str, baud: int) -> serial.Serial:
try:
return serial.Serial(
port=port,
baudrate=baud,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=0.2,
write_timeout=1.0,
)
except (serial.SerialException, ValueError) as exc:
raise OSError(f"failed to open serial port {port}: {exc}") from exc


def format_bytes(data: bytes) -> str:
return " ".join(f"{byte:02X}" for byte in data)


def response_marker(response: ParsedFrame) -> str:
return "!" if response.frame_type == ord("!") else ">"


def format_header(header: bytes) -> str:
result = header[0:3].decode("ascii")
result += " " + " ".join(f"{byte:02X}" for byte in header[0:])
return result


def print_frame_parts(request: RequestFrame, response: ParsedFrame) -> None:
marker = response_marker(response)
print("<", format_header(request.header), "..", format_bytes(bytes((request.checksum,))))
print("<", format_bytes(request.payload))
print(marker, format_header(response.header), "..", format_bytes(bytes((response.checksum,))))
print(marker, format_bytes(response.payload))


def main() -> int:
args = parse_args()
request = build_request(args.message_id)
ser = open_serial(args.port, args.baud)
try:
ser.write(request.raw)
ser.flush()
response = read_response(ser, args.message_id, TIMEOUT_SECONDS)
finally:
ser.close()
print_frame_parts(request, response)
return 0


if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, TimeoutError, ValueError, serial.SerialException) as exc:
print(str(exc), file=sys.stderr)
raise SystemExit(1)
2 changes: 0 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,8 +450,6 @@ set input_deadband 3
set input_min 885
set input_mid 1500
set input_max 2115
set input_interpolation AUTO
set input_interpolation_interval 26
set input_filter_type FILTER
set input_lpf_type PT3
set input_lpf_freq 0
Expand Down
2 changes: 0 additions & 2 deletions docs/pyDrone_dump.txt
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,6 @@ set input_deadband 3
set input_min 885
set input_mid 1500
set input_max 2115
set input_interpolation AUTO
set input_interpolation_interval 26
set input_filter_type FILTER
set input_lpf_type PT3
set input_lpf_freq 0
Expand Down
7 changes: 6 additions & 1 deletion lib/Espfc/library.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"name": "Espfc",
"version": "0.2.0"
"version": "0.3.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/rtlopez/esp-fc.git"
}
}
Loading