From 3cba73fadad2e02e797943bac2d627ef7835ef33 Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Thu, 27 Aug 2026 13:44:19 +0300 Subject: [PATCH 01/10] Use new autopilot value for heartbeats - MAV_AUTOPILOT_FLIX MAVLink-Arduino@2.0.33 https://github.com/mavlink/mavlink/pull/2580 --- Makefile | 2 +- docs/usage.md | 2 +- flix/mavlink.ino | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 814e8fd..1ab02cf 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ core .core: libs .libs: arduino-cli lib update-index arduino-cli lib install "FlixPeriph" - arduino-cli lib install "MAVLink"@2.0.25 + arduino-cli lib install "MAVLink"@2.0.33 touch .libs upload_proxy: .core .libs diff --git a/docs/usage.md b/docs/usage.md index fee4381..c7ab7eb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -51,7 +51,7 @@ Beginners can [download the sources as a ZIP archive](https://github.com/okalach 3. Install ESP32 core, version 3.3.10. See the [official Espressif's instructions](https://docs.espressif.com/projects/arduino-esp32/en/latest/installing.html#installing-using-arduino-ide) on installing ESP32 Core in Arduino IDE. 4. Install the following libraries using [Library Manager](https://docs.arduino.cc/software/ide-v2/tutorials/ide-v2-installing-a-library): * `FlixPeriph`, the latest version. - * `MAVLink`, version 2.0.25. + * `MAVLink`, version 2.0.33. 5. Open the `flix/flix.ino` sketch from downloaded firmware sources in Arduino IDE. 6. Connect your ESP32 board to the computer and choose correct board type in Arduino IDE (*WEMOS D1 MINI ESP32* for ESP32 Mini, *ESP32S3 Dev Module* for ESP32-S3 Super Mini) and the port. 7. Set *Tools* ⇒ *Core Debug Level* to *Error* to see the errors in the serial console. diff --git a/flix/mavlink.ino b/flix/mavlink.ino index 51dd0a4..be90f9a 100644 --- a/flix/mavlink.ino +++ b/flix/mavlink.ino @@ -32,7 +32,7 @@ void sendMavlink() { uint32_t time = t * 1000; if (telemetrySlow) { - mavlink_msg_heartbeat_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &msg, MAV_TYPE_QUADROTOR, MAV_AUTOPILOT_GENERIC, + mavlink_msg_heartbeat_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &msg, MAV_TYPE_QUADROTOR, MAV_AUTOPILOT_FLIX, (armed ? MAV_MODE_FLAG_SAFETY_ARMED : 0) | ((mode == STAB) ? MAV_MODE_FLAG_STABILIZE_ENABLED : 0) | ((mode == AUTO) ? MAV_MODE_FLAG_AUTO_ENABLED : MAV_MODE_FLAG_MANUAL_INPUT_ENABLED), From dd2d185bc74c9199f2e5687e317fe923dd84f960 Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Fri, 28 Aug 2026 16:55:17 +0300 Subject: [PATCH 02/10] Fix pyflix examples reflecting control parameters rename --- tools/example.py | 6 +++--- tools/pyflix/README.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/example.py b/tools/example.py index 0738bda..0cb71be 100755 --- a/tools/example.py +++ b/tools/example.py @@ -24,11 +24,11 @@ print('> imu') print(flix.cli('imu')) print('=== Get parameter...') -pitch_p = flix.get_param('CTL_P_P') -print('CTL_P_P = ', pitch_p) +pitch_p = flix.get_param('CTL_ATT_P_P') +print('CTL_ATT_P_P = ', pitch_p) print('=== Set parameter...') -flix.set_param('CTL_P_P', pitch_p) +flix.set_param('CTL_ATT_P_P', pitch_p) print('=== Wait for gyro update...') print('Gyro: ', flix.wait('gyro')) diff --git a/tools/pyflix/README.md b/tools/pyflix/README.md index b5b61d8..963adad 100644 --- a/tools/pyflix/README.md +++ b/tools/pyflix/README.md @@ -121,8 +121,8 @@ Full list of events: Get and set firmware parameters using `get_param` and `set_param` methods: ```python -pitch_p = flix.get_param('CTL_P_P') # get parameter value -flix.set_param('CTL_P_P', 5) # set parameter value +pitch_p = flix.get_param('CTL_ATT_P_P') # get parameter value +flix.set_param('CTL_ATT_P_P', 5) # set parameter value ``` Execute console commands using `cli` method. This method returns the command response: From a77f21353845c3a1483700ccddbaf6d8104d62f7 Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Fri, 28 Aug 2026 19:26:35 +0300 Subject: [PATCH 03/10] Add timeout parameter for pyflix constructor --- tools/pyflix/flix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/pyflix/flix.py b/tools/pyflix/flix.py index b03bbcf..c9b3b1d 100644 --- a/tools/pyflix/flix.py +++ b/tools/pyflix/flix.py @@ -44,7 +44,7 @@ class Flix: _print_buffer: str = '' _modes = ['RAW', 'ACRO', 'STAB', 'AUTO'] - def __init__(self, system_id: int=1, wait_connection: bool=True, device=os.getenv('FLIX_DEVICE')): + def __init__(self, system_id: int=1, wait_connection: bool=True, timeout: Optional[float]=None, device=os.getenv('FLIX_DEVICE')): if not (0 <= system_id < 256): raise ValueError('system_id must be in range [0, 255]') self._setup_mavlink() @@ -74,7 +74,7 @@ class Flix: self._heartbeat_thread = Thread(target=self._send_heartbeat, daemon=True) self._heartbeat_thread.start() if wait_connection: - self.wait('mavlink.HEARTBEAT') + self.wait('mavlink.HEARTBEAT', timeout=timeout) time.sleep(0.6) # give some time to receive initial state def _init_state(self): From 68eb523bebdedff12a2c694a18d3dcd74a19f026 Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Fri, 28 Aug 2026 19:29:40 +0300 Subject: [PATCH 04/10] Add tests to ci Test pyflix and simulation using pytest. Fix running the sim in non-tty environment. Test presence of some essential files in the docs. --- .github/workflows/build.yml | 11 +++++---- .github/workflows/docs.yml | 5 ++++ gazebo/Arduino.h | 3 ++- tools/requirements.txt | 1 + tools/test.py | 47 +++++++++++++++++++++++++++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) create mode 100755 tools/test.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 743662c..0618eca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,15 +84,16 @@ jobs: run: sudo apt-get install -y libsdl2-dev - name: Build simulator run: make build_simulator - - name: Run simulator + - name: Install Python requirements + run: sudo apt install -y python3-pip && pip3 install -r tools/requirements.txt && pip3 install pytest + - name: Run simulator and tests env: GAZEBO_MODEL_PATH: ${{ github.workspace }}/gazebo/models GAZEBO_PLUGIN_PATH: ${{ github.workspace }}/gazebo/build + GAZEBO_MODEL_DATABASE_URI: '' # disable downloading models run: | - OUT=$(timeout -k 10s 120s gzserver --verbose gazebo/flix.world 2>&1 | tee /dev/stderr) - if echo "$OUT" | grep -Pq "\[Err\](?! \[RenderEngine)"; then - exit 1 - fi + gzserver --verbose gazebo/flix.world & + pytest --capture=no --verbose tools/test.py - uses: actions/upload-artifact@v7 with: name: gazebo-plugin-binary diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 67dcbfa..4e3178d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -83,6 +83,11 @@ jobs: ln -s "$FQBN/flix.ino.bin" "flix.$BOARD.bin" ln -s "$FQBN/flix.ino.bootloader.bin" "flix.$BOARD.bootloader.bin" done + - name: Test build artifacts + working-directory: docs/build + run: | + ls; ls index.html gyro.html geometry.html firmware.html flix.esp32.merged.bin flix.esp32c3.merged.bin \ + flix.esp32s3.merged.bin flix.esp32s3.opi.merged.bin flix.esp32s3.qspi.merged.bin flix.flix2.merged.bin - name: Upload artifact uses: actions/upload-pages-artifact@v5 with: diff --git a/gazebo/Arduino.h b/gazebo/Arduino.h index 6122225..862d243 100644 --- a/gazebo/Arduino.h +++ b/gazebo/Arduino.h @@ -134,8 +134,9 @@ public: int available() { // to implement for Windows, see https://stackoverflow.com/a/71992965/6850197 + if (!isatty(STDIN_FILENO)) return 0; struct pollfd pfd = { .fd = STDIN_FILENO, .events = POLLIN }; - return poll(&pfd, 1, 0) > 0; + return poll(&pfd, 1, 0) > 0 && (pfd.revents & POLLIN); } int read() { diff --git a/tools/requirements.txt b/tools/requirements.txt index 6b3338b..adff73d 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -1,3 +1,4 @@ +pymavlink docopt matplotlib mcap diff --git a/tools/test.py b/tools/test.py new file mode 100755 index 0000000..c23e4c4 --- /dev/null +++ b/tools/test.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +# Script for testing pyflix and the simulation. + +from pytest import approx +from math import isnan, isfinite +import time +from pyflix import Flix + +def test(): + print('=== Connect...') + flix = Flix(timeout=20) + + print('=== Check initial state') + time.sleep(1) # give more time for initial state + assert flix.connected + assert flix.mode == 'STAB' + assert not flix.armed + assert flix.landed + assert isnan(flix.voltage) or flix.voltage == approx(4.2) + assert flix.rates == approx((0, 0, 0), abs=0.01) + assert flix.attitude == approx((1, 0, 0, 0), abs=0.01) + assert flix.attitude_euler == approx((0, 0, 0), abs=0.01) + assert all(m == 0 for m in flix.motors) + assert flix.acc == approx((0, 0, 9.81), abs=0.1) + assert flix.gyro == approx((0, 0, 0), abs=0.01) + assert all(ch == 0 for ch in flix.channels) + + print('=== Check console commands') + assert 'Time: ' in flix.cli('time') + assert 'landed: 1' in flix.cli('imu') + + print('=== Check parameters') + assert isfinite(flix.get_param('CTL_ATT_P_P')) + flix.set_param('CTL_ATT_P_P', 10.0) + + print('=== Additional checks') + assert flix.wait('gyro') == approx((0, 0, 0), abs=0.01) + flix.wait('armed', False) + flix.wait('mode', 'STAB') + flix.wait('motors', lambda motors: not any(motors)) + flix.set_armed(True) + flix.wait('armed', True) + flix.set_mode('ACRO') + flix.wait('mode', 'ACRO') + flix.set_mode('AUTO') + flix.wait('mode', 'AUTO') From 2964e8fef7ba6c4f5a423bbc285090a79436ead3 Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Fri, 28 Aug 2026 19:35:44 +0300 Subject: [PATCH 05/10] Simplify makefile There is no reason to use := --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 1ab02cf..c137498 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ BOARD = esp32:esp32:esp32 -PORT := $(strip $(wildcard /dev/serial/by-id/usb-Silicon_Labs_CP21* /dev/serial/by-id/usb-1a86_USB_Single_Serial_* /dev/cu.usbserial-* /dev/cu.usbmodem*)) +PORT = $(strip $(wildcard /dev/serial/by-id/usb-Silicon_Labs_CP21* /dev/serial/by-id/usb-1a86_USB_Single_Serial_* /dev/cu.usbserial-* /dev/cu.usbmodem*)) -export ARDUINO_NETWORK_CONNECTION_TIMEOUT := 1h +export ARDUINO_NETWORK_CONNECTION_TIMEOUT = 1h build: .core .libs arduino-cli compile flix --fqbn $(BOARD) --build-property "build.core_debug_level=1" $(EXTRA) From 9465c94d66b175f48a215cd4c4412f8c95c8916e Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Fri, 28 Aug 2026 19:36:57 +0300 Subject: [PATCH 06/10] Add some missing emulated Arduino methods for future use * String::toUpperCase() * Serial.read(*buf, len) * Serial.write() --- gazebo/Arduino.h | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/gazebo/Arduino.h b/gazebo/Arduino.h index 862d243..813f735 100644 --- a/gazebo/Arduino.h +++ b/gazebo/Arduino.h @@ -60,6 +60,10 @@ public: std::transform(this->begin(), this->end(), this->begin(), [](unsigned char c) { return std::tolower(c); }); } + void toUpperCase() { + std::transform(this->begin(), this->end(), this->begin(), + [](unsigned char c) { return std::toupper(c); }); + } }; class Print; @@ -149,6 +153,17 @@ public: return -1; } + int read(uint8_t *buf, int len) { + if (available()) { + return ::read(STDIN_FILENO, buf, len); + } + return 0; + } + + int write(const uint8_t *data, int len) { + return ::write(STDOUT_FILENO, data, len); + } + void setRxInvert(bool invert) {}; }; From 5d670dcc69c162ba5155e0d3b3ec0dbbe60706b4 Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Sat, 29 Aug 2026 17:50:29 +0300 Subject: [PATCH 07/10] Move mavlink commands handlers to a separated function It's more convenient to handle error that way. --- flix/mavlink.ino | 52 +++++++++++++++++++++------------------- gazebo/flix.h | 1 + tools/pyflix/__init__.py | 2 +- tools/pyflix/flix.py | 4 +++- tools/test.py | 7 ++++-- 5 files changed, 38 insertions(+), 28 deletions(-) diff --git a/flix/mavlink.ino b/flix/mavlink.ino index be90f9a..6c15f1f 100644 --- a/flix/mavlink.ino +++ b/flix/mavlink.ino @@ -245,40 +245,44 @@ void handleMavlink(const void *_msg) { } } - // Handle commands if (msg.msgid == MAVLINK_MSG_ID_COMMAND_LONG) { mavlink_command_long_t m; mavlink_msg_command_long_decode(&msg, &m); if (m.target_system && m.target_system != mavlinkSysId) return; - mavlink_message_t response; - bool accepted = false; - if (m.command == MAV_CMD_REQUEST_MESSAGE && m.param1 == MAVLINK_MSG_ID_AUTOPILOT_VERSION) { - accepted = true; - mavlink_msg_autopilot_version_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &response, - MAV_PROTOCOL_CAPABILITY_PARAM_FLOAT | MAV_PROTOCOL_CAPABILITY_MAVLINK2, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0); - sendMessage(&response); - } - - if (m.command == MAV_CMD_COMPONENT_ARM_DISARM) { - if (m.param1 == 1 && controlThrottle > 0.05) return; // don't arm if throttle is not low - accepted = true; - armed = m.param1 == 1; - } - - if (m.command == MAV_CMD_DO_SET_MODE) { - if (m.param2 < 0 || m.param2 > AUTO) return; // incorrect mode - accepted = true; - mode = m.param2; - } - - // send command ack + int result = handleMavlinkCommand(&m); mavlink_message_t ack; - mavlink_msg_command_ack_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &ack, m.command, accepted ? MAV_RESULT_ACCEPTED : MAV_RESULT_UNSUPPORTED, UINT8_MAX, 0, msg.sysid, msg.compid); + mavlink_msg_command_ack_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &ack, m.command, result, UINT8_MAX, 0, msg.sysid, msg.compid); sendMessage(&ack); } } +int handleMavlinkCommand(const void *_m) { + const mavlink_command_long_t& m = *(mavlink_command_long_t *)_m; + + if (m.command == MAV_CMD_REQUEST_MESSAGE && m.param1 == MAVLINK_MSG_ID_AUTOPILOT_VERSION) { + mavlink_message_t response; + mavlink_msg_autopilot_version_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &response, + MAV_PROTOCOL_CAPABILITY_PARAM_FLOAT | MAV_PROTOCOL_CAPABILITY_MAVLINK2, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0); + sendMessage(&response); + return MAV_RESULT_ACCEPTED; + } + + if (m.command == MAV_CMD_COMPONENT_ARM_DISARM) { + if (m.param1 == 1 && controlThrottle > 0.05) return MAV_RESULT_DENIED; // don't arm if throttle is not low + armed = m.param1 == 1; + return MAV_RESULT_ACCEPTED; + } + + if (m.command == MAV_CMD_DO_SET_MODE) { + if (m.param2 < 0 || m.param2 > AUTO) return MAV_RESULT_DENIED; // incorrect mode + mode = m.param2; + return MAV_RESULT_ACCEPTED; + } + + return MAV_RESULT_UNSUPPORTED; +} + // Send shell output to GCS void mavlinkPrint(const char* str) { mavlinkPrintBuffer += str; diff --git a/gazebo/flix.h b/gazebo/flix.h index 351d371..d3034d2 100644 --- a/gazebo/flix.h +++ b/gazebo/flix.h @@ -58,6 +58,7 @@ void sendMavlink(); void sendMessage(const void *msg); void receiveMavlink(); void handleMavlink(const void *_msg); +int handleMavlinkCommand(const void *_m); void mavlinkPrint(const char* str); void sendMavlinkPrint(); inline Quaternion fluToFrd(const Quaternion &q); diff --git a/tools/pyflix/__init__.py b/tools/pyflix/__init__.py index 67ad6a7..411ab97 100644 --- a/tools/pyflix/__init__.py +++ b/tools/pyflix/__init__.py @@ -1 +1 @@ -from .flix import Flix +from .flix import Flix, mavlink diff --git a/tools/pyflix/flix.py b/tools/pyflix/flix.py index c9b3b1d..8321d67 100644 --- a/tools/pyflix/flix.py +++ b/tools/pyflix/flix.py @@ -262,7 +262,9 @@ class Flix: try: logger.debug(f'Send command {command} with params {params} (attempt #{attempt + 1})') self.mavlink.command_long_send(self.system_id, 0, command, 0, *params) # type: ignore - self.wait('mavlink.COMMAND_ACK', value=lambda msg: msg.command == command and msg.result == mavlink.MAV_RESULT_ACCEPTED, timeout=0.1) + ack = self.wait('mavlink.COMMAND_ACK', value=lambda msg: msg.command == command, timeout=0.1) + if ack.result != mavlink.MAV_RESULT_ACCEPTED: + raise RuntimeError(f'Command {command} failed with result {ack.result}') return except TimeoutError: continue diff --git a/tools/test.py b/tools/test.py index c23e4c4..dca8046 100755 --- a/tools/test.py +++ b/tools/test.py @@ -2,10 +2,10 @@ # Script for testing pyflix and the simulation. -from pytest import approx +from pytest import approx, raises from math import isnan, isfinite import time -from pyflix import Flix +from pyflix import Flix, mavlink def test(): print('=== Connect...') @@ -45,3 +45,6 @@ def test(): flix.wait('mode', 'ACRO') flix.set_mode('AUTO') flix.wait('mode', 'AUTO') + + raises(RuntimeError, lambda: flix._command_send(mavlink.MAV_CMD_DO_SET_MODE, [0, 99, 0, 0, 0, 0, 0])) # invalid mode + raises(RuntimeError, lambda: flix._command_send(mavlink.MAV_CMD_DO_PARACHUTE, [0, 0, 0, 0, 0, 0, 0])) # unsupported command From a2dd70654c54f612e17905fd7aa9714473f648ac Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Sat, 29 Aug 2026 17:52:14 +0300 Subject: [PATCH 08/10] Changes to method for sending mavlink commands in pymavlink Make the exception names more verbose. Make it public. --- tools/pyflix/flix.py | 36 +++++++++++++++++++----------------- tools/test.py | 7 +++++-- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/tools/pyflix/flix.py b/tools/pyflix/flix.py index 8321d67..6241ab3 100644 --- a/tools/pyflix/flix.py +++ b/tools/pyflix/flix.py @@ -255,21 +255,6 @@ class Flix: def _flu_to_mavlink(v: Sequence[float]) -> List[float]: return Flix._mavlink_to_flu(v) # flu to mavlink is the same as mavlink to flu - def _command_send(self, command: int, params: Sequence[float]): - if len(params) != 7: - raise ValueError('Command must have 7 parameters') - for attempt in range(3): - try: - logger.debug(f'Send command {command} with params {params} (attempt #{attempt + 1})') - self.mavlink.command_long_send(self.system_id, 0, command, 0, *params) # type: ignore - ack = self.wait('mavlink.COMMAND_ACK', value=lambda msg: msg.command == command, timeout=0.1) - if ack.result != mavlink.MAV_RESULT_ACCEPTED: - raise RuntimeError(f'Command {command} failed with result {ack.result}') - return - except TimeoutError: - continue - raise RuntimeError(f'Failed to send command {command} after 3 attempts') - def _connected(self): # Reset disconnection timer self._disconnected_timer.cancel() @@ -286,6 +271,23 @@ class Flix: self.connected = False self._trigger('disconnected') + def send_command(self, command: int, params: Sequence[float]): + if len(params) != 7: + raise ValueError('Command must have 7 parameters') + for attempt in range(3): + try: + logger.debug(f'Send command {command} with params {params} (attempt #{attempt + 1})') + self.mavlink.command_long_send(self.system_id, 0, command, 0, *params) # type: ignore + ack: mavlink.MAVLink_command_ack_message = self.wait('mavlink.COMMAND_ACK', value=lambda msg: msg.command == command, timeout=0.1) + if ack.result != mavlink.MAV_RESULT_ACCEPTED: + name = getattr(mavlink.enums['MAV_CMD'].get(command, {}), 'name', f'UNKNOWN({command})') + result = getattr(mavlink.enums['MAV_RESULT'].get(ack.result, {}), 'name', f'UNKNOWN({ack.result})') + raise RuntimeError(f'Command {name} failed with result {result}') + return + except TimeoutError: + continue + raise RuntimeError(f'Failed to send command {command} after 3 attempts') + def get_param(self, name: str) -> float: if len(name.encode('ascii')) > 16: raise ValueError('Parameter name must be 16 characters or less') @@ -317,10 +319,10 @@ class Flix: def set_mode(self, mode: Union[str, int]): if isinstance(mode, str): mode = self._modes.index(mode.upper()) - self._command_send(mavlink.MAV_CMD_DO_SET_MODE, (0, mode, 0, 0, 0, 0, 0)) + self.send_command(mavlink.MAV_CMD_DO_SET_MODE, (0, mode, 0, 0, 0, 0, 0)) def set_armed(self, armed: bool): - self._command_send(mavlink.MAV_CMD_COMPONENT_ARM_DISARM, (1 if armed else 0, 0, 0, 0, 0, 0, 0)) + self.send_command(mavlink.MAV_CMD_COMPONENT_ARM_DISARM, (1 if armed else 0, 0, 0, 0, 0, 0, 0)) def set_position(self, position: Sequence[float], yaw: Optional[float] = None, wait: bool = False, tolerance: float = 0.1): raise NotImplementedError('Position control is not implemented yet') diff --git a/tools/test.py b/tools/test.py index dca8046..582d92d 100755 --- a/tools/test.py +++ b/tools/test.py @@ -46,5 +46,8 @@ def test(): flix.set_mode('AUTO') flix.wait('mode', 'AUTO') - raises(RuntimeError, lambda: flix._command_send(mavlink.MAV_CMD_DO_SET_MODE, [0, 99, 0, 0, 0, 0, 0])) # invalid mode - raises(RuntimeError, lambda: flix._command_send(mavlink.MAV_CMD_DO_PARACHUTE, [0, 0, 0, 0, 0, 0, 0])) # unsupported command + print("=== Check command errors") + with raises(RuntimeError, match='MAV_RESULT_DENIED'): + flix.send_command(mavlink.MAV_CMD_DO_SET_MODE, [0, 99, 0, 0, 0, 0, 0]) # invalid mode + with raises(RuntimeError, match='MAV_RESULT_UNSUPPORTED'): + flix.send_command(mavlink.MAV_CMD_DO_PARACHUTE, [0, 0, 0, 0, 0, 0, 0]) # unsupported command From eb0018a3268409c15cb0e31759fcfd4e0b210738 Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Sun, 30 Aug 2026 21:32:30 +0300 Subject: [PATCH 09/10] Always disable blocking in the console output On USB connection, Serial.print may block the main loop if the USB is connected but isn't read, breaking the firmware logic. --- flix/console.ino | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flix/console.ino b/flix/console.ino index c69a950..4a0e539 100644 --- a/flix/console.ino +++ b/flix/console.ino @@ -67,6 +67,9 @@ HWCDC HWCDCSerial; void setupConsole() { Serial.begin(115200); +#if SOC_USB_SERIAL_JTAG_SUPPORTED + Serial.setTxTimeoutMs(0); // never block on usb write +#endif } void print(const char* format, ...) { From 756a400ce5133094aedfa87a5936c5d1447b7b0e Mon Sep 17 00:00:00 2001 From: Oleg Kalachev Date: Tue, 1 Sep 2026 13:02:28 +0300 Subject: [PATCH 10/10] Add parameters metadata for qgc Handle COMPONENT_METADATA requests from gcs, providing a manually written (for now) static metadata urls. --- .github/workflows/tools.yml | 6 ++ flix/mavlink.ino | 9 ++ tools/general.json | 10 ++ tools/metadata.py | 79 +++++++++++++++ tools/parameters.json | 192 ++++++++++++++++++++++++++++++++++++ 5 files changed, 296 insertions(+) create mode 100644 tools/general.json create mode 100755 tools/metadata.py create mode 100644 tools/parameters.json diff --git a/.github/workflows/tools.yml b/.github/workflows/tools.yml index 2adcd6c..e0c60dc 100644 --- a/.github/workflows/tools.yml +++ b/.github/workflows/tools.yml @@ -7,6 +7,12 @@ on: branches: [ master ] jobs: + metadata: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Check metadata + run: tools/metadata.py csv_to_ulog: runs-on: ubuntu-latest steps: diff --git a/flix/mavlink.ino b/flix/mavlink.ino index 6c15f1f..fd80fae 100644 --- a/flix/mavlink.ino +++ b/flix/mavlink.ino @@ -268,6 +268,15 @@ int handleMavlinkCommand(const void *_m) { return MAV_RESULT_ACCEPTED; } + if (m.command == MAV_CMD_REQUEST_MESSAGE && m.param1 == MAVLINK_MSG_ID_COMPONENT_METADATA) { + mavlink_message_t response; + mavlink_msg_component_metadata_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &response, t * 1000, + 2566517357, // crc + "https://quadcopter.dev/meta/2566517357/general.json"); + sendMessage(&response); + return MAV_RESULT_ACCEPTED; + } + if (m.command == MAV_CMD_COMPONENT_ARM_DISARM) { if (m.param1 == 1 && controlThrottle > 0.05) return MAV_RESULT_DENIED; // don't arm if throttle is not low armed = m.param1 == 1; diff --git a/tools/general.json b/tools/general.json new file mode 100644 index 0000000..eb21de3 --- /dev/null +++ b/tools/general.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "metadataTypes": [ + { + "type": 1, + "uri": "https://quadcopter.dev/meta/3307474483/parameters.json", + "fileCrc": 3307474483 + } + ] +} diff --git a/tools/metadata.py b/tools/metadata.py new file mode 100755 index 0000000..03172b2 --- /dev/null +++ b/tools/metadata.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +# Script for checking the metadata files + +import sys +import re +from pathlib import Path +import json +import zlib +import requests + + +def mavlink_crc323(data: bytes) -> int: + return (zlib.crc32(data, 0xFFFFFFFF) ^ 0xFFFFFFFF) & 0xFFFFFFFF + + +parameter_regexp = re.compile(r'^\s+\{"(?P[^"]+)",\s*&(?P[^,]+)(.*)\},?$', re.MULTILINE) + +parameters_source_file = Path(__file__).parent.parent / 'flix' / 'parameters.ino' +parameters_code = parameters_source_file.read_text() +parameters_meta_file = Path(__file__).parent / 'parameters.json' +parameters_meta = json.loads(parameters_meta_file.read_text()) +assert parameters_meta['version'] == 1 + +parameters = parameter_regexp.finditer(parameters_code) + +for parameter in parameters: + name = parameter.group('name') + variable = parameter.group('variable') + + for meta in parameters_meta['parameters']: + name_meta = meta['name'] + regex = re.sub(r'\{[^\}]+\}', r'.*', name_meta) # handle {n} wildcards + if re.fullmatch(regex, name): + break + else: + raise RuntimeError(f'Parameter {name} is missing in parameters.json') + +general_meta_file = Path(__file__).parent / 'general.json' +general_crc = mavlink_crc323(general_meta_file.read_bytes()) +general_data = json.loads(general_meta_file.read_text()) + +parameters_crc = mavlink_crc323(parameters_meta_file.read_bytes()) + +general_url = f'https://quadcopter.dev/meta/{general_crc}/general.json' +parameters_url = f'https://quadcopter.dev/meta/{parameters_crc}/parameters.json' + +print(f'General CRC: {general_crc}') +print(f'Parameters CRC: {mavlink_crc323(parameters_meta_file.read_bytes())}') +print(f'General URL: {general_url}') +print(f'Parameters URL: {parameters_url}') + +code = 0 + +assert general_data['version'] == 1 +for meta in general_data['metadataTypes']: + if meta['type'] == 1: + if meta['uri'] != parameters_url: + print(f'ERROR: Parameters URI should be {parameters_url}') + code = 1 + if meta['fileCrc'] != parameters_crc: + print(f'ERROR: Parameters CRC shoule be {parameters_crc}') + code = 1 + break +else: + print('ERROR: Parameters metadata not found in general.json') + code = 1 + +resp = requests.get(general_url, allow_redirects=True) +resp.raise_for_status() +assert resp.json() == general_data + +resp = requests.get(parameters_url, allow_redirects=True) +resp.raise_for_status() +assert resp.json() == parameters_meta + +if code == 0: + print('Everything is good') +sys.exit(code) diff --git a/tools/parameters.json b/tools/parameters.json new file mode 100644 index 0000000..6c796a2 --- /dev/null +++ b/tools/parameters.json @@ -0,0 +1,192 @@ +{ + "version": 1, + "parameters": [ + { "name": "CTL_RATE_R_P", "type": "Float", "group": "Control", "shortDesc": "Proportional gain for roll rate" }, + { "name": "CTL_RATE_R_I", "type": "Float", "group": "Control", "shortDesc": "Integral gain for roll rate" }, + { "name": "CTL_RATE_R_D", "type": "Float", "group": "Control", "shortDesc": "Derivative gain for roll rate" }, + { "name": "CTL_RATE_R_WU", "type": "Float", "group": "Control", "shortDesc": "Integral windup limit for roll rate" }, + { "name": "CTL_RATE_R_D_A", "type": "Float", "group": "Control", "shortDesc": "Low-pass filter alpha for roll rate" }, + { "name": "CTL_RATE_P_P", "type": "Float", "group": "Control", "shortDesc": "Proportional gain for pitch rate" }, + { "name": "CTL_RATE_P_I", "type": "Float", "group": "Control", "shortDesc": "Integral gain for pitch rate" }, + { "name": "CTL_RATE_P_D", "type": "Float", "group": "Control", "shortDesc": "Derivative gain for pitch rate" }, + { "name": "CTL_RATE_P_WU", "type": "Float", "group": "Control", "shortDesc": "Integral windup limit for pitch rate" }, + { "name": "CTL_RATE_P_D_A", "type": "Float", "group": "Control", "shortDesc": "Low-pass filter alpha for pitch rate" }, + { "name": "CTL_RATE_Y_P", "type": "Float", "group": "Control", "shortDesc": "Proportional gain for yaw rate" }, + { "name": "CTL_RATE_Y_I", "type": "Float", "group": "Control", "shortDesc": "Integral gain for yaw rate" }, + { "name": "CTL_RATE_Y_D", "type": "Float", "group": "Control", "shortDesc": "Derivative gain for yaw rate" }, + { "name": "CTL_RATE_Y_WU", "type": "Float", "group": "Control", "shortDesc": "Integral windup limit for yaw rate" }, + { "name": "CTL_RATE_Y_D_A", "type": "Float", "group": "Control", "shortDesc": "Low-pass filter alpha for yaw rate" }, + + { "name": "CTL_RATE_P_MAX", "type": "Float", "group": "Control", "units": "rad/s", "shortDesc": "Maximum pitch rate for ACRO mode" }, + { "name": "CTL_RATE_R_MAX", "type": "Float", "group": "Control", "units": "rad/s", "shortDesc": "Maximum roll rate for ACRO mode" }, + { "name": "CTL_RATE_Y_MAX", "type": "Float", "group": "Control", "units": "rad/s", "shortDesc": "Maximum yaw rate for ACRO mode" }, + + { "name": "CTL_ATT_R_P", "type": "Float", "group": "Control", "shortDesc": "Proportional gain for roll angle" }, + { "name": "CTL_ATT_P_P", "type": "Float", "group": "Control", "shortDesc": "Proportional gain for pitch angle" }, + { "name": "CTL_ATT_Y_P", "type": "Float", "group": "Control", "shortDesc": "Proportional gain for yaw angle" }, + { "name": "CTL_ATT_MAX", "type": "Float", "group": "Control", "units": "radians", "shortDesc": "Maximum tilt angle for STAB mode" }, + + { + "name": "CTL_FLT_MODE_{n}", + "type": "Float", + "group": "Control", + "shortDesc": "Flight mode for switch position {n}", + "values": [{ "value": 0, "description": "RAW" }, { "value": 1, "description": "ACRO" }, { "value": 2, "description": "STAB" }, { "value": 3, "description": "AUTO" }, { "value": 4, "description": "POS" }] + }, + + { + "name": "IMU_MODEL", + "type": "Float", + "shortDesc": "IMU sensor model", + "group": "IMU", + "increment": 1, + "decimalPlaces": 0, + "values": [{ "value": -1, "description": "Disabled"}, { "value": 1, "description": "MPU-9250"}, { "value": 2, "description": "ICM-20948"}, { "value": 3, "description": "MPU-6050"}, { "value": 4, "description": "ICM-40609-D"}], + "rebootRequired": true + }, + { + "name": "IMU_BUS", + "type": "Float", + "shortDesc": "IMU bus type", + "group": "IMU", + "values": [ + { "value": 0, "description": "SPI" }, + { "value": 1, "description": "I2C" } + ], + "rebootRequired": true + }, + { "name": "IMU_PIN_SCK", "type": "Float", "group": "IMU", "shortDesc": "IMU SPI SCK pin", "min": 1, "increment": 1, "decimalPlaces": 0 }, + { "name": "IMU_PIN_MISO", "type": "Float", "group": "IMU", "shortDesc": "IMU SPI MISO pin", "min": 1, "increment": 1, "decimalPlaces": 0 }, + { "name": "IMU_PIN_MOSI", "type": "Float", "group": "IMU", "shortDesc": "IMU SPI MOSI pin", "min": 1, "increment": 1, "decimalPlaces": 0 }, + { "name": "IMU_PIN_CS", "type": "Float", "group": "IMU", "shortDesc": "IMU SPI CS pin", "min": 1, "increment": 1, "decimalPlaces": 0 }, + { "name": "IMU_PIN_SDA", "type": "Float", "group": "IMU", "shortDesc": "IMU I2C SDA pin", "min": 1, "increment": 1, "decimalPlaces": 0 }, + { "name": "IMU_PIN_SCL", "type": "Float", "group": "IMU", "shortDesc": "IMU I2C SCL pin", "min": 1, "increment": 1, "decimalPlaces": 0 }, + { "name": "IMU_PIN_INT", "type": "Float", "group": "IMU", "shortDesc": "IMU interrupt pin (-1 for using timer)", "min": -1, "increment": 1, "decimalPlaces": 0 }, + + { "name": "IMU_ROT_ROLL", "type": "Float", "group": "IMU", "units": "radians", "shortDesc": "IMU roll rotation" }, + { "name": "IMU_ROT_PITCH", "type": "Float", "group": "IMU", "units": "radians", "shortDesc": "IMU pitch rotation" }, + { "name": "IMU_ROT_YAW", "type": "Float", "group": "IMU", "units": "radians", "shortDesc": "IMU yaw rotation" }, + + { "name": "IMU_ACC_BIAS_X", "type": "Float", "group": "IMU (Calibration)", "units": "m/s^2", "shortDesc": "Accelerometer bias X", "volatile": true }, + { "name": "IMU_ACC_BIAS_Y", "type": "Float", "group": "IMU (Calibration)", "units": "m/s^2", "shortDesc": "Accelerometer bias Y", "volatile": true }, + { "name": "IMU_ACC_BIAS_Z", "type": "Float", "group": "IMU (Calibration)", "units": "m/s^2", "shortDesc": "Accelerometer bias Z", "volatile": true }, + + { "name": "IMU_ACC_SCALE_X", "type": "Float", "group": "IMU (Calibration)", "shortDesc": "Accelerometer scale X", "volatile": true }, + { "name": "IMU_ACC_SCALE_Y", "type": "Float", "group": "IMU (Calibration)", "shortDesc": "Accelerometer scale Y", "volatile": true }, + { "name": "IMU_ACC_SCALE_Z", "type": "Float", "group": "IMU (Calibration)", "shortDesc": "Accelerometer scale Z", "volatile": true }, + + { "name": "IMU_GYRO_BIAS_A", "type": "Float", "group": "IMU", "shortDesc": "Alpha for gyroscope bias estimation" }, + + { "name": "EST_ACC_WEIGHT", "type": "Float", "group": "Estimation", "shortDesc": "Accelerometer weight" }, + { "name": "EST_LVL_WEIGHT", "type": "Float", "group": "Estimation", "shortDesc": "Level weight", "decimalPlaces": 5 }, + { "name": "EST_RATES_LPF_A", "type": "Float", "group": "Estimation", "shortDesc": "Low-pass filter alpha for rates" }, + { "name": "EST_RATES_NO_F", "type": "Float", "group": "Estimation", "units": "Hz", "shortDesc": "Notch filter center frequency for rates", "min": 0, "decimalPlaces": 0 }, + { "name": "EST_RATES_NO_BW", "type": "Float", "group": "Estimation", "units": "Hz", "shortDesc": "Notch filter bandwidth for rates", "min": 0, "decimalPlaces": 0 }, + + { "name": "MOT_PIN_FL", "type": "Float", "group": "Motors", "shortDesc": "Front-left motor pin (-1 to disabled)", "min": -1, "increment": 1, "decimalPlaces": 0 }, + { "name": "MOT_PIN_FR", "type": "Float", "group": "Motors", "shortDesc": "Front-right motor pin (-1 to disabled)", "min": -1, "increment": 1, "decimalPlaces": 0 }, + { "name": "MOT_PIN_RL", "type": "Float", "group": "Motors", "shortDesc": "Rear-left motor pin (-1 to disabled)", "min": -1, "increment": 1, "decimalPlaces": 0 }, + { "name": "MOT_PIN_RR", "type": "Float", "group": "Motors", "shortDesc": "Rear-right motor pin (-1 to disabled)", "min": -1, "increment": 1, "decimalPlaces": 0 }, + { "name": "MOT_PWM_FREQ", "type": "Float", "group": "Motors", "units": "Hz", "shortDesc": "PWM frequency", "min": 0, "increment": 1, "decimalPlaces": 0 }, + { "name": "MOT_PWM_RES", "type": "Float", "group": "Motors", "units": "bits", "shortDesc": "PWM resolution", "min": 1, "increment": 1, "decimalPlaces": 0 }, + { "name": "MOT_PWM_STOP", "type": "Float", "group": "Motors", "units": "μs", "shortDesc": "PWM for stopping the motors (brushless motors)", "min": 0, "decimalPlaces": 0 }, + { "name": "MOT_PWM_MIN", "type": "Float", "group": "Motors", "units": "μs", "shortDesc": "PWM for minimum throttle (brushless motors)", "min": 0, "decimalPlaces": 0 }, + { "name": "MOT_PWM_MAX", "type": "Float", "group": "Motors", "units": "μs", "shortDesc": "PWM for maximum throttle (-1 for brushed motors)", "min": -1, "decimalPlaces": 0 }, + + { "name": "RC_RX_PIN", "type": "Float", "group": "RC", "shortDesc": "RC receiver RX pin (-1 for disabled)", "min": -1, "increment": 1, "decimalPlaces": 0 }, + { + "name": "RC_ZERO_{n}", + "type": "Float", + "group": "RC (Calibration)", + "shortDesc": "RC channel {n} zero PWM", + "units": "μs", + "min": 0, + "decimalPlaces": 0 + }, + { + "name": "RC_MAX_{n}", + "type": "Float", + "group": "RC (Calibration)", + "shortDesc": "RC channel {n} maximum PWM", + "units": "μs", + "min": 0, + "decimalPlaces": 0 + }, + + { "name": "RC_ROLL", "type": "Float", "group": "RC (Calibration)", "shortDesc": "RC roll channel", "min": 0, "increment": 1, "decimalPlaces": 0 }, + { "name": "RC_PITCH", "type": "Float", "group": "RC (Calibration)", "shortDesc": "RC pitch channel", "min": 0, "increment": 1, "decimalPlaces": 0 }, + { "name": "RC_THROTTLE", "type": "Float", "group": "RC (Calibration)", "shortDesc": "RC throttle channel", "min": 0, "increment": 1, "decimalPlaces": 0 }, + { "name": "RC_YAW", "type": "Float", "group": "RC (Calibration)", "shortDesc": "RC yaw channel", "min": 0, "increment": 1, "decimalPlaces": 0 }, + { "name": "RC_MODE", "type": "Float", "group": "RC (Calibration)", "shortDesc": "RC mode switch channel", "min": 0, "increment": 1, "decimalPlaces": 0 }, + + { + "name": "WIFI_MODE", + "type": "Float", + "group": "Wi-Fi", + "shortDesc": "Wi-Fi mode", + "values": [ + { "value": 0, "description": "Disabled" }, + { "value": 1, "description": "Access point (AP)" }, + { "value": 2, "description": "Client (STA)" }, + { "value": 3, "description": "ESP-NOW" } + ] + }, + { "name": "WIFI_PORT_LOC", "type": "Float", "group": "Wi-Fi", "shortDesc": "Local UDP port", "min": 0, "max": 65535, "increment": 1, "decimalPlaces": 0 }, + { "name": "WIFI_PORT_REM", "type": "Float", "group": "Wi-Fi", "shortDesc": "Remote UDP port", "min": 0, "max": 65535, "increment": 1, "decimalPlaces": 0 }, + { + "name": "WIFI_LONG_RANGE", + "type": "Float", + "group": "Wi-Fi", + "shortDesc": "Long-range WiFi mode", + "values": [ + { "value": 0, "description": "Off" }, + { "value": 1, "description": "On" } + ] + }, + { + "name": "WIFI_BROADCAST", + "type": "Float", + "group": "Wi-Fi", + "shortDesc": "Always broadcast UDP", + "values": [ + { "value": 0, "description": "Off" }, + { "value": 1, "description": "On" } + ] + }, + + { "name": "ESPNOW_CHANNEL", "type": "Float", "group": "ESP-NOW", "shortDesc": "ESP-NOW channel", "min": 1, "max": 13, "increment": 1, "decimalPlaces": 0 }, + + { "name": "MAV_SYS_ID", "type": "Float", "group": "MAVLink", "shortDesc": "MAVLink system ID", "min": 1, "increment": 1, "decimalPlaces": 0 }, + { "name": "MAV_RATE_SLOW", "type": "Float", "group": "MAVLink", "units": "Hz", "shortDesc": "Rate for heartbeats and battery state", "min": 0, "decimalPlaces": 0 }, + { "name": "MAV_RATE_FAST", "type": "Float", "group": "MAVLink", "units": "Hz", "shortDesc": "Rate for attitude, RC channels and motors state", "min": 0, "decimalPlaces": 0 }, + { "name": "MAV_RATE_EXTRA", "type": "Float", "group": "MAVLink", "units": "Hz", "shortDesc": "Rate for IMU data and exposed log topic", "min": 0, "decimalPlaces": 0 }, + { "name": "MAV_RATE_ATT", "type": "Float", "group": "MAVLink", "units": "Hz", "shortDesc": "Rate for attitude", "min": 0, "decimalPlaces": 0 }, + { "name": "MAV_RATE_ATT_TG", "type": "Float", "group": "MAVLink", "units": "Hz", "shortDesc": "Rate for attitude target", "min": 0, "decimalPlaces": 0 }, + { "name": "MAV_RATE_RC", "type": "Float", "group": "MAVLink", "units": "Hz", "shortDesc": "Rate for RC channels", "min": 0, "decimalPlaces": 0 }, + { "name": "MAV_RATE_MOT", "type": "Float", "group": "MAVLink", "units": "Hz", "shortDesc": "Rate for motors state", "min": 0, "decimalPlaces": 0 }, + { "name": "MAV_RATE_IMU", "type": "Float", "group": "MAVLink", "units": "Hz", "shortDesc": "Rate for IMU data", "min": 0, "decimalPlaces": 0 }, + { "name": "MAV_RATE_TOPIC", "type": "Float", "group": "MAVLink", "units": "Hz", "shortDesc": "Rate for exposed log topic", "min": 0, "decimalPlaces": 0 }, + + { + "name": "LOG_MEMORY", + "type": "Float", + "group": "Log", + "shortDesc": "Memory type for logging", + "values": [ + { "value": -1, "description": "Disabled" }, + { "value": 0, "description": "RAM" }, + { "value": 1, "description": "PSRAM" } + ] + }, + { "name": "LOG_USAGE", "type": "Float", "group": "Log", "units": "norm", "shortDesc": "Percentage of memory to use for logging" }, + { "name": "LOG_RATE_{n}", "type": "Float", "group": "Log", "units": "Hz", "shortDesc": "Log topic {n} rate", "min": 0, "decimalPlaces": 0 }, + + { "name": "PWR_VOLT_PIN", "type": "Float", "group": "Power", "shortDesc": "Voltage ADC input pin (-1 for disabled)", "min": -1, "increment": 1, "decimalPlaces": 0 }, + { "name": "PWR_VOLT_SCALE", "type": "Float", "group": "Power", "shortDesc": "Voltage divider scale factor" }, + { "name": "PWR_VOLT_LPF_A", "type": "Float", "group": "Power", "shortDesc": "Low-pass filter alpha for battery voltage" }, + + { "name": "SF_RC_LOSS_TIME", "type": "Float", "group": "Safety", "units": "s", "shortDesc": "RC signal loss timeout in seconds", "min": 0, "decimalPlaces": 1 }, + { "name": "SF_DESCEND_TIME", "type": "Float", "group": "Safety", "units": "s", "shortDesc": "Automatic descent timeout in seconds", "min": 0, "decimalPlaces": 1 }, + { "name": "SF_DISARM_TILT", "type": "Float", "group": "Safety", "units": "radians", "shortDesc": "Tilt angle above which auto-disarm triggers" } + ] +}