From a71c99cedd8614a8dd507ff6fcd7d9c93007313e Mon Sep 17 00:00:00 2001 From: ArisMorgens Date: Wed, 25 Mar 2026 16:18:45 +0100 Subject: [PATCH 1/8] Added DisconnectedError handling --- src/cfclient/ui/main.py | 2 ++ src/cfclient/ui/pose_logger.py | 2 ++ src/cfclient/ui/tabs/FlightTab.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/cfclient/ui/main.py b/src/cfclient/ui/main.py index ea2f5d35f..e087a294b 100644 --- a/src/cfclient/ui/main.py +++ b/src/cfclient/ui/main.py @@ -598,6 +598,8 @@ async def _stream_battery(self, cf): self._battery_signal.emit( data.data["pm.vbat"], int(data.data["pm.state"]) ) + except DisconnectedError: + pass finally: try: await asyncio.shield(stream.stop()) diff --git a/src/cfclient/ui/pose_logger.py b/src/cfclient/ui/pose_logger.py index ab879c490..6dd7c3578 100644 --- a/src/cfclient/ui/pose_logger.py +++ b/src/cfclient/ui/pose_logger.py @@ -116,6 +116,8 @@ async def _stream_loop(self, cf): data.data[self.LOG_NAME_ESTIMATE_YAW], ) self.data_received_cb.call(self, self.pose) + except DisconnectedError: + pass finally: try: await asyncio.shield(stream.stop()) diff --git a/src/cfclient/ui/tabs/FlightTab.py b/src/cfclient/ui/tabs/FlightTab.py index 94a94e7ed..ff81d3d89 100644 --- a/src/cfclient/ui/tabs/FlightTab.py +++ b/src/cfclient/ui/tabs/FlightTab.py @@ -550,6 +550,8 @@ async def _stream_motors(self, cf): while True: data = await stream.next() self._log_data_received(data.timestamp, data.data) + except DisconnectedError: + pass finally: try: await asyncio.shield(stream.stop()) From a4b31fc0b0a28bf649eddf1cb114be9982243e48 Mon Sep 17 00:00:00 2001 From: ArisMorgens Date: Wed, 25 Mar 2026 16:19:24 +0100 Subject: [PATCH 2/8] Fixed annotations in main, pose_logger, FlightTab --- src/cfclient/ui/main.py | 54 +++++++++------ src/cfclient/ui/pose_logger.py | 15 +++-- src/cfclient/ui/tabs/FlightTab.py | 105 +++++++++++++++++------------- 3 files changed, 104 insertions(+), 70 deletions(-) diff --git a/src/cfclient/ui/main.py b/src/cfclient/ui/main.py index e087a294b..2d5276fdc 100644 --- a/src/cfclient/ui/main.py +++ b/src/cfclient/ui/main.py @@ -27,9 +27,14 @@ The main file for the Crazyflie control application. """ +from __future__ import annotations + import asyncio import logging import sys +from collections.abc import Callable, Coroutine +from concurrent.futures import Future +from typing import Any import cfclient from cfclient.gui import create_task @@ -40,6 +45,7 @@ from cfclient.utils.config import Config from cfclient.utils.config_manager import ConfigManager from cfclient.utils.input import JoystickReader +from cfclient.utils.input.inputreaderinterface import InputReaderInterface from cfclient.utils.ui import UiUtils from cfclient.ui.dialogs.inputconfigdialogue import InputConfigDialogue from cflib2 import Crazyflie, LinkContext @@ -353,19 +359,21 @@ def set_preferred_dock_area( # --- Event loop --- - def _on_event_loop_ready(self): + def _on_event_loop_ready(self) -> None: self._loop = asyncio.get_event_loop() self._scan(self._connectivity_manager.get_address()) # --- Commander pipeline --- - async def _safe_send(self, coro_fn): + async def _safe_send( + self, coro_fn: Callable[[], Coroutine[Any, Any, None]] + ) -> None: try: await coro_fn() except DisconnectedError: pass - def _commander_future_cb(self, future): + def _commander_future_cb(self, future: Future[None]) -> None: """Log unexpected exceptions from commander coroutines.""" try: future.result() @@ -374,7 +382,9 @@ def _commander_future_cb(self, future): except Exception: logger.error("Unhandled exception in commander coroutine", exc_info=True) - def _send_setpoint(self, roll, pitch, yaw, thrust): + def _send_setpoint( + self, roll: float, pitch: float, yaw: float, thrust: float + ) -> None: cf = self.cf if self._disable_input or cf is None or self._loop is None: return @@ -386,7 +396,9 @@ def _send_setpoint(self, roll, pitch, yaw, thrust): ) future.add_done_callback(self._commander_future_cb) - def _send_velocity_world(self, vx, vy, vz, yawrate): + def _send_velocity_world( + self, vx: float, vy: float, vz: float, yawrate: float + ) -> None: cf = self.cf if self._disable_input or cf is None or self._loop is None: return @@ -398,7 +410,9 @@ def _send_velocity_world(self, vx, vy, vz, yawrate): ) future.add_done_callback(self._commander_future_cb) - def _send_zdistance(self, roll, pitch, yawrate, zdistance): + def _send_zdistance( + self, roll: float, pitch: float, yawrate: float, zdistance: float + ) -> None: cf = self.cf if self._disable_input or cf is None or self._loop is None: return @@ -412,7 +426,9 @@ def _send_zdistance(self, roll, pitch, yawrate, zdistance): ) future.add_done_callback(self._commander_future_cb) - def _send_hover(self, vx, vy, yawrate, zdistance): + def _send_hover( + self, vx: float, vy: float, yawrate: float, zdistance: float + ) -> None: cf = self.cf if self._disable_input or cf is None or self._loop is None: return @@ -424,13 +440,13 @@ def _send_hover(self, vx, vy, yawrate, zdistance): ) future.add_done_callback(self._commander_future_cb) - def disable_input(self, disable): + def disable_input(self, disable: bool) -> None: """Disable gamepad input to allow a tab to send setpoints directly.""" self._disable_input = disable # --- Emergency stop --- - def _emergency_stop(self): + def _emergency_stop(self) -> None: if self.cf is not None: create_task(self.cf.localization().emergency().send_emergency_stop()) @@ -586,7 +602,7 @@ def _notify_tabs_disconnected(self) -> None: for tab_toolbox in self.loaded_tab_toolboxes.values(): tab_toolbox.disconnected() - async def _stream_battery(self, cf): + async def _stream_battery(self, cf: Crazyflie) -> None: log = cf.log() block = await log.create_block() await block.add_variable("pm.vbat") @@ -606,7 +622,7 @@ async def _stream_battery(self, cf): except (DisconnectedError, asyncio.CancelledError): pass - def _update_battery(self, vbat, state): + def _update_battery(self, vbat: float, state: int) -> None: self.batteryBar.setValue(int(vbat * 1000)) color = UiUtils.COLOR_BLUE @@ -682,16 +698,16 @@ def set_default_theme(self) -> None: # --- Input device menu --- - def _show_input_device_config_dialog(self): + def _show_input_device_config_dialog(self) -> None: self.inputConfig = InputConfigDialogue(self._joystick_reader) self.inputConfig.show() - def _display_input_device_error(self, error): + def _display_input_device_error(self, error: str) -> None: if self.cf is not None: create_task(self._async_disconnect()) QMessageBox.critical(self, "Input device error", error) - def _mux_selected(self, checked): + def _mux_selected(self, checked: bool) -> None: if not checked: (mux, sub_nodes) = self.sender().data() for s in sub_nodes: @@ -711,7 +727,7 @@ def _mux_selected(self, checked): self._update_input_device_footer() - def _get_dev_status(self, device): + def _get_dev_status(self, device: InputReaderInterface) -> str: msg = "{}".format(device.name) if device.supports_mapping: map_name = "No input mapping" @@ -720,7 +736,7 @@ def _get_dev_status(self, device): msg += " ({})".format(map_name) return msg - def _update_input_device_footer(self): + def _update_input_device_footer(self) -> None: msg = "" if len(self._joystick_reader.available_devices()) > 0: @@ -740,7 +756,7 @@ def _update_input_device_footer(self): msg = "No input device found" self._statusbar_label.setText(msg) - def _inputdevice_selected(self, checked): + def _inputdevice_selected(self, checked: bool) -> None: (map_menu, device, mux_menu) = self.sender().data() if not checked: if map_menu: @@ -769,7 +785,7 @@ def _inputdevice_selected(self, checked): ) self._update_input_device_footer() - def _inputconfig_selected(self, checked): + def _inputconfig_selected(self, checked: bool) -> None: if not checked: return @@ -778,7 +794,7 @@ def _inputconfig_selected(self, checked): self._joystick_reader.set_input_map(device.name, selected_mapping) self._update_input_device_footer() - def device_discovery(self, devs): + def device_discovery(self, devs: list[InputReaderInterface]) -> None: """Called when new devices have been added""" for menu in self._all_role_menus: role_menu = menu["rolemenu"] diff --git a/src/cfclient/ui/pose_logger.py b/src/cfclient/ui/pose_logger.py index 6dd7c3578..51810676d 100644 --- a/src/cfclient/ui/pose_logger.py +++ b/src/cfclient/ui/pose_logger.py @@ -29,10 +29,13 @@ Sets up logging for the the full pose of the Crazyflie """ +from __future__ import annotations + import asyncio import logging import math +from cflib2 import Crazyflie from cflib2.error import DisconnectedError from cfclient.gui import create_task @@ -64,17 +67,17 @@ def __init__(self) -> None: self._stream_task = None @property - def position(self): + def position(self) -> tuple[float, ...]: """Get the position part of the full pose""" return self.pose[0:3] @property - def rpy(self): + def rpy(self) -> tuple[float, ...]: """Get the roll, pitch and yaw of the full pose in degrees""" return self.pose[3:6] @property - def rpy_rad(self): + def rpy_rad(self) -> list[float]: """Get the roll, pitch and yaw of the full pose in radians""" return [ math.radians(self.pose[3]), @@ -82,18 +85,18 @@ def rpy_rad(self): math.radians(self.pose[5]), ] - def start(self, cf): + def start(self, cf: Crazyflie) -> None: """Start streaming pose data from the Crazyflie.""" self._stream_task = create_task(self._stream_loop(cf)) - def stop(self): + def stop(self) -> None: """Stop streaming pose data.""" if self._stream_task is not None: self._stream_task.cancel() self._stream_task = None self.pose = self.NO_POSE - async def _stream_loop(self, cf): + async def _stream_loop(self, cf: Crazyflie) -> None: log = cf.log() block = await log.create_block() await block.add_variable(self.LOG_NAME_ESTIMATE_X) diff --git a/src/cfclient/ui/tabs/FlightTab.py b/src/cfclient/ui/tabs/FlightTab.py index ff81d3d89..c5edb1e8e 100644 --- a/src/cfclient/ui/tabs/FlightTab.py +++ b/src/cfclient/ui/tabs/FlightTab.py @@ -29,6 +29,8 @@ The flight control tab shows telemetry data and flight settings. """ +from __future__ import annotations + import asyncio import logging from enum import Enum @@ -37,8 +39,10 @@ from PySide6.QtCore import Qt, Signal import cfclient +from cflib2 import Crazyflie from cflib2.error import DisconnectedError from cfclient.gui import create_task +from cfclient.ui.pose_logger import PoseLogger from cfclient.ui.widgets.ai import AttitudeIndicator from cfclient.utils.config import Config @@ -109,7 +113,7 @@ class FlightTab(TabToolbox, flight_tab_class): LOG_NAME_CAN_FLY = "sys.canfly" LOG_NAME_SUPERVISOR_INFO = "supervisor.info" - def __init__(self, helper): + def __init__(self, helper: object) -> None: super(FlightTab, self).__init__(helper, "Flight Control") self.setupUi(self) @@ -228,8 +232,11 @@ def __init__(self, helper): ) def _set_limiting_enabled( - self, rp_limiting_enabled, yaw_limiting_enabled, thrust_limiting_enabled - ): + self, + rp_limiting_enabled: bool, + yaw_limiting_enabled: bool, + thrust_limiting_enabled: bool, + ) -> None: self.targetCalRoll.setEnabled(rp_limiting_enabled) self.targetCalPitch.setEnabled(rp_limiting_enabled) @@ -243,10 +250,10 @@ def _set_limiting_enabled( thrust_limiting_enabled and advanced_is_enabled ) - def thrustToPercentage(self, thrust): + def thrustToPercentage(self, thrust: float) -> float: return (thrust / MAX_THRUST) * 100.0 - def uiSetupReady(self): + def uiSetupReady(self) -> None: flightComboIndex = self.flightModeCombo.findText( Config().get("flightmode"), Qt.MatchFlag.MatchFixedString ) @@ -257,11 +264,11 @@ def uiSetupReady(self): self.flightModeCombo.setCurrentIndex(flightComboIndex) self.flightModeCombo.currentIndexChanged.emit(flightComboIndex) - def _flight_command(self, action): + def _flight_command(self, action: CommanderAction) -> None: if self._cf is not None: create_task(self._async_flight_command(action)) - async def _async_flight_command(self, action): + async def _async_flight_command(self, action: CommanderAction) -> None: pose_logger = self._helper.pose_logger current_z = pose_logger.position[2] if pose_logger else 0.0 move_dist = 0.5 @@ -343,7 +350,7 @@ async def _async_flight_command(self, action): group_mask=None, ) - def _log_data_received(self, timestamp, data): + def _log_data_received(self, timestamp: int, data: dict[str, float]) -> None: if self.isVisible() and self._isConnected: self.actualM1.setValue(data[self.LOG_NAME_MOTOR_1]) self.actualM2.setValue(data[self.LOG_NAME_MOTOR_2]) @@ -364,7 +371,9 @@ def _log_data_received(self, timestamp, data): self._update_supervisor_and_arming(True) - def _pose_data_received(self, pose_logger, pose): + def _pose_data_received( + self, pose_logger: PoseLogger, pose: tuple[float, ...] + ) -> None: if self.isVisible(): estimated_z = pose[2] roll = pose[3] @@ -380,7 +389,9 @@ def _pose_data_received(self, pose_logger, pose): self.ai.setBaro(estimated_z, self.is_visible()) self.ai.setRollPitch(-roll, pitch, self.is_visible()) - def _heighthold_input_updated(self, roll, pitch, yaw, height): + def _heighthold_input_updated( + self, roll: float, pitch: float, yaw: float, height: float + ) -> None: if self._helper.inputDeviceReader is None: return if self.isVisible() and ( @@ -395,7 +406,9 @@ def _heighthold_input_updated(self, roll, pitch, yaw, height): self._change_input_labels(using_hover_assist=False) - def _hover_input_updated(self, vx, vy, yaw, height): + def _hover_input_updated( + self, vx: float, vy: float, yaw: float, height: float + ) -> None: if self._helper.inputDeviceReader is None: return if self.isVisible() and ( @@ -410,7 +423,7 @@ def _hover_input_updated(self, vx, vy, yaw, height): self._change_input_labels(using_hover_assist=True) - def _change_input_labels(self, using_hover_assist): + def _change_input_labels(self, using_hover_assist: bool) -> None: if using_hover_assist: pitch, roll, yaw = "Velocity X", "Velocity Y", "Velocity Z" else: @@ -420,7 +433,7 @@ def _change_input_labels(self, using_hover_assist): self.inputRollLabel.setText(roll) self.inputYawLabel.setText(yaw) - def _update_supervisor_and_arming(self, connected): + def _update_supervisor_and_arming(self, connected: bool) -> None: if not connected: self.armButton.setStyleSheet("") self.armButton.setText("Arm") @@ -481,7 +494,7 @@ def _update_supervisor_and_arming(self, connected): self.armButton.setStyleSheet("") self.armButton.setEnabled(False) - async def _update_flight_commander(self, cf): + async def _update_flight_commander(self, cf: Crazyflie) -> None: self.commanderBox.setToolTip(str()) self.commanderBox.setEnabled(False) @@ -525,13 +538,13 @@ async def _update_flight_commander(self, cf): self.commanderBox.setEnabled(True) - def connected(self, cf): + def connected(self, cf: Crazyflie) -> None: self._cf = cf self._isConnected = True self._log_task = create_task(self._stream_motors(cf)) self._setup_task = create_task(self._setup_after_connect(cf)) - async def _stream_motors(self, cf): + async def _stream_motors(self, cf: Crazyflie) -> None: log = cf.log() block = await log.create_block() await block.add_variable(self.LOG_NAME_THRUST) @@ -558,7 +571,7 @@ async def _stream_motors(self, cf): except (DisconnectedError, asyncio.CancelledError): pass - async def _setup_after_connect(self, cf): + async def _setup_after_connect(self, cf: Crazyflie) -> None: param = cf.param() platform = cf.platform() @@ -582,12 +595,12 @@ async def _setup_after_connect(self, cf): self._update_supervisor_and_arming(True) - def _enable_estimators(self, should_enable): + def _enable_estimators(self, should_enable: bool) -> None: self.estimateX.setEnabled(should_enable) self.estimateY.setEnabled(should_enable) self.estimateZ.setEnabled(should_enable) - def _set_available_sensors(self, name, available): + def _set_available_sensors(self, name: str, available: str) -> None: logger.debug("[%s]: %s", name, available) available = int(available) @@ -595,7 +608,7 @@ def _set_available_sensors(self, name, available): if self._helper.inputDeviceReader is not None: self._helper.inputDeviceReader.set_alt_hold_available(available) - def disconnected(self): + def disconnected(self) -> None: if self._log_task is not None: self._log_task.cancel() self._log_task = None @@ -642,31 +655,31 @@ def disconnected(self): self._supervisor_info_bitfield = 0 self._update_supervisor_and_arming(False) - def _can_arm(self): + def _can_arm(self) -> bool: return bool(self._supervisor_info_bitfield & 0x0001) - def _is_armed(self): + def _is_armed(self) -> bool: return bool(self._supervisor_info_bitfield & 0x0002) - def _auto_arming(self): + def _auto_arming(self) -> bool: return bool(self._supervisor_info_bitfield & 0x0004) - def _can_fly(self): + def _can_fly(self) -> bool: return bool(self._supervisor_info_bitfield & 0x0008) - def _is_flying(self): + def _is_flying(self) -> bool: return bool(self._supervisor_info_bitfield & 0x0010) - def _is_tumbled(self): + def _is_tumbled(self) -> bool: return bool(self._supervisor_info_bitfield & 0x0020) - def _is_locked(self): + def _is_locked(self) -> bool: return bool(self._supervisor_info_bitfield & 0x0040) - def _is_crashed(self): + def _is_crashed(self) -> bool: return bool(self._supervisor_info_bitfield & 0x0080) - def minMaxThrustChanged(self): + def minMaxThrustChanged(self) -> None: if self._helper.inputDeviceReader is None: return self._helper.inputDeviceReader.min_thrust = self.minThrust.value() @@ -675,7 +688,7 @@ def minMaxThrustChanged(self): Config().set("min_thrust", self.minThrust.value()) Config().set("max_thrust", self.maxThrust.value()) - def thrustLoweringSlewRateLimitChanged(self): + def thrustLoweringSlewRateLimitChanged(self) -> None: if self._helper.inputDeviceReader is None: return self._helper.inputDeviceReader.thrust_slew_rate = ( @@ -686,40 +699,42 @@ def thrustLoweringSlewRateLimitChanged(self): Config().set("slew_limit", self.slewEnableLimit.value()) Config().set("slew_rate", self.thrustLoweringSlewRateLimit.value()) - def maxYawRateChanged(self): + def maxYawRateChanged(self) -> None: logger.debug("MaxYawrate changed to %d", self.maxYawRate.value()) if self._helper.inputDeviceReader is not None: self._helper.inputDeviceReader.max_yaw_rate = self.maxYawRate.value() if self.isInCrazyFlightmode is True: Config().set("max_yaw", self.maxYawRate.value()) - def maxAngleChanged(self): + def maxAngleChanged(self) -> None: logger.debug("MaxAngle changed to %d", self.maxAngle.value()) if self._helper.inputDeviceReader is not None: self._helper.inputDeviceReader.max_rp_angle = self.maxAngle.value() if self.isInCrazyFlightmode is True: Config().set("max_rp", self.maxAngle.value()) - def _trim_pitch_changed(self, value): + def _trim_pitch_changed(self, value: float) -> None: logger.debug("Pitch trim updated to [%f]" % value) if self._helper.inputDeviceReader is not None: self._helper.inputDeviceReader.trim_pitch = value Config().set("trim_pitch", value) - def _trim_roll_changed(self, value): + def _trim_roll_changed(self, value: float) -> None: logger.debug("Roll trim updated to [%f]" % value) if self._helper.inputDeviceReader is not None: self._helper.inputDeviceReader.trim_roll = value Config().set("trim_roll", value) - def calUpdateFromInput(self, rollCal, pitchCal): + def calUpdateFromInput(self, rollCal: float, pitchCal: float) -> None: logger.debug( "Trim changed on joystick: roll=%.2f, pitch=%.2f", rollCal, pitchCal ) self.targetCalRoll.setValue(rollCal) self.targetCalPitch.setValue(pitchCal) - def updateInputControl(self, roll, pitch, yaw, thrust): + def updateInputControl( + self, roll: float, pitch: float, yaw: float, thrust: float + ) -> None: self.targetRoll.setText(("%0.2f deg" % roll)) self.targetPitch.setText(("%0.2f deg" % pitch)) self.targetYaw.setText(("%0.2f deg/s" % yaw)) @@ -728,20 +743,20 @@ def updateInputControl(self, roll, pitch, yaw, thrust): self._change_input_labels(using_hover_assist=False) - def setMotorLabelsEnabled(self, enabled): + def setMotorLabelsEnabled(self, enabled: bool) -> None: self.M1label.setEnabled(enabled) self.M2label.setEnabled(enabled) self.M3label.setEnabled(enabled) self.M4label.setEnabled(enabled) - def emergencyStopStringWithText(self, text): + def emergencyStopStringWithText(self, text: str) -> str: return ( "

" "{}" "

".format(text) ) - def updateEmergencyStop(self, emergencyStop): + def updateEmergencyStop(self, emergencyStop: bool) -> None: if emergencyStop: self.setMotorLabelsEnabled(False) if self._cf is not None: @@ -749,11 +764,11 @@ def updateEmergencyStop(self, emergencyStop): else: self.setMotorLabelsEnabled(True) - def updateArm(self, from_controller=False): + def updateArm(self, from_controller: bool = False) -> None: if self._cf is not None: create_task(self._async_update_arm(from_controller)) - async def _async_update_arm(self, from_controller): + async def _async_update_arm(self, from_controller: bool) -> None: if self._is_flying() and not from_controller: await self._cf.localization().emergency().send_emergency_stop() elif self._is_crashed(): @@ -764,7 +779,7 @@ async def _async_update_arm(self, from_controller): self.armButton.setStyleSheet("background-color: orange") await self._cf.platform().send_arming_request(True) - def flightmodeChange(self, item): + def flightmodeChange(self, item: int) -> None: Config().set("flightmode", str(self.flightModeCombo.itemText(item))) logger.debug("Changed flightmode to %s", self.flightModeCombo.itemText(item)) self.isInCrazyFlightmode = False @@ -795,7 +810,7 @@ def flightmodeChange(self, item): self.slewEnableLimit.setEnabled(newState) self.maxYawRate.setEnabled(newState) - def _assist_mode_changed(self, item): + def _assist_mode_changed(self, item: int) -> None: mode = None if item == 0: # Altitude hold @@ -811,7 +826,7 @@ def _assist_mode_changed(self, item): self._helper.inputDeviceReader.set_assisted_control(mode) Config().set("assistedControl", mode) - def _assisted_control_updated(self, enabled): + def _assisted_control_updated(self, enabled: bool) -> None: if self._helper.inputDeviceReader is None: return if ( @@ -834,7 +849,7 @@ def _assisted_control_updated(self, enabled): if self._cf is not None: create_task(self._cf.param().set("flightmode.althold", int(enabled))) - async def _populate_assisted_mode_dropdown(self, cf): + async def _populate_assisted_mode_dropdown(self, cf: Crazyflie) -> None: param = cf.param() self._assist_mode_combo.addItem("Altitude hold", 0) From 594e5f535482a3aefed5e26dc701d399dd1cb81a Mon Sep 17 00:00:00 2001 From: ArisMorgens Date: Thu, 26 Mar 2026 11:43:31 +0100 Subject: [PATCH 3/8] Widened the data_received value type --- src/cfclient/ui/tabs/FlightTab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cfclient/ui/tabs/FlightTab.py b/src/cfclient/ui/tabs/FlightTab.py index c5edb1e8e..a29241760 100644 --- a/src/cfclient/ui/tabs/FlightTab.py +++ b/src/cfclient/ui/tabs/FlightTab.py @@ -350,7 +350,7 @@ async def _async_flight_command(self, action: CommanderAction) -> None: group_mask=None, ) - def _log_data_received(self, timestamp: int, data: dict[str, float]) -> None: + def _log_data_received(self, timestamp: int, data: dict[str, float | int]) -> None: if self.isVisible() and self._isConnected: self.actualM1.setValue(data[self.LOG_NAME_MOTOR_1]) self.actualM2.setValue(data[self.LOG_NAME_MOTOR_2]) From 504279d4a696498763eee708f5a4ad6db24f90c2 Mon Sep 17 00:00:00 2001 From: ArisMorgens Date: Thu, 26 Mar 2026 11:45:08 +0100 Subject: [PATCH 4/8] Wrapped the whole setup/start sequence in try/except DisconnectedError --- src/cfclient/ui/main.py | 18 +++++++++------- src/cfclient/ui/pose_logger.py | 28 +++++++++++++----------- src/cfclient/ui/tabs/FlightTab.py | 36 ++++++++++++++++--------------- 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/cfclient/ui/main.py b/src/cfclient/ui/main.py index 2d5276fdc..59db0fcc0 100644 --- a/src/cfclient/ui/main.py +++ b/src/cfclient/ui/main.py @@ -604,11 +604,12 @@ def _notify_tabs_disconnected(self) -> None: async def _stream_battery(self, cf: Crazyflie) -> None: log = cf.log() - block = await log.create_block() - await block.add_variable("pm.vbat") - await block.add_variable("pm.state") - stream = await block.start(1000) + stream = None try: + block = await log.create_block() + await block.add_variable("pm.vbat") + await block.add_variable("pm.state") + stream = await block.start(1000) while True: data = await stream.next() self._battery_signal.emit( @@ -617,10 +618,11 @@ async def _stream_battery(self, cf: Crazyflie) -> None: except DisconnectedError: pass finally: - try: - await asyncio.shield(stream.stop()) - except (DisconnectedError, asyncio.CancelledError): - pass + if stream is not None: + try: + await asyncio.shield(stream.stop()) + except (DisconnectedError, asyncio.CancelledError): + pass def _update_battery(self, vbat: float, state: int) -> None: self.batteryBar.setValue(int(vbat * 1000)) diff --git a/src/cfclient/ui/pose_logger.py b/src/cfclient/ui/pose_logger.py index 51810676d..fa8302a37 100644 --- a/src/cfclient/ui/pose_logger.py +++ b/src/cfclient/ui/pose_logger.py @@ -98,16 +98,17 @@ def stop(self) -> None: async def _stream_loop(self, cf: Crazyflie) -> None: log = cf.log() - block = await log.create_block() - await block.add_variable(self.LOG_NAME_ESTIMATE_X) - await block.add_variable(self.LOG_NAME_ESTIMATE_Y) - await block.add_variable(self.LOG_NAME_ESTIMATE_Z) - await block.add_variable(self.LOG_NAME_ESTIMATE_ROLL) - await block.add_variable(self.LOG_NAME_ESTIMATE_PITCH) - await block.add_variable(self.LOG_NAME_ESTIMATE_YAW) - - stream = await block.start(40) # 40ms period + stream = None try: + block = await log.create_block() + await block.add_variable(self.LOG_NAME_ESTIMATE_X) + await block.add_variable(self.LOG_NAME_ESTIMATE_Y) + await block.add_variable(self.LOG_NAME_ESTIMATE_Z) + await block.add_variable(self.LOG_NAME_ESTIMATE_ROLL) + await block.add_variable(self.LOG_NAME_ESTIMATE_PITCH) + await block.add_variable(self.LOG_NAME_ESTIMATE_YAW) + + stream = await block.start(40) # 40ms period while True: data = await stream.next() self.pose = ( @@ -122,7 +123,8 @@ async def _stream_loop(self, cf: Crazyflie) -> None: except DisconnectedError: pass finally: - try: - await asyncio.shield(stream.stop()) - except (DisconnectedError, asyncio.CancelledError): - pass + if stream is not None: + try: + await asyncio.shield(stream.stop()) + except (DisconnectedError, asyncio.CancelledError): + pass diff --git a/src/cfclient/ui/tabs/FlightTab.py b/src/cfclient/ui/tabs/FlightTab.py index a29241760..5464c7a30 100644 --- a/src/cfclient/ui/tabs/FlightTab.py +++ b/src/cfclient/ui/tabs/FlightTab.py @@ -546,30 +546,32 @@ def connected(self, cf: Crazyflie) -> None: async def _stream_motors(self, cf: Crazyflie) -> None: log = cf.log() - block = await log.create_block() - await block.add_variable(self.LOG_NAME_THRUST) - await block.add_variable(self.LOG_NAME_MOTOR_1) - await block.add_variable(self.LOG_NAME_MOTOR_2) - await block.add_variable(self.LOG_NAME_MOTOR_3) - await block.add_variable(self.LOG_NAME_MOTOR_4) - await block.add_variable(self.LOG_NAME_CAN_FLY) - - if self.LOG_NAME_SUPERVISOR_INFO in log.names(): - await block.add_variable(self.LOG_NAME_SUPERVISOR_INFO) - - period_ms = Config().get("ui_update_period") - stream = await block.start(period_ms) + stream = None try: + block = await log.create_block() + await block.add_variable(self.LOG_NAME_THRUST) + await block.add_variable(self.LOG_NAME_MOTOR_1) + await block.add_variable(self.LOG_NAME_MOTOR_2) + await block.add_variable(self.LOG_NAME_MOTOR_3) + await block.add_variable(self.LOG_NAME_MOTOR_4) + await block.add_variable(self.LOG_NAME_CAN_FLY) + + if self.LOG_NAME_SUPERVISOR_INFO in log.names(): + await block.add_variable(self.LOG_NAME_SUPERVISOR_INFO) + + period_ms = Config().get("ui_update_period") + stream = await block.start(period_ms) while True: data = await stream.next() self._log_data_received(data.timestamp, data.data) except DisconnectedError: pass finally: - try: - await asyncio.shield(stream.stop()) - except (DisconnectedError, asyncio.CancelledError): - pass + if stream is not None: + try: + await asyncio.shield(stream.stop()) + except (DisconnectedError, asyncio.CancelledError): + pass async def _setup_after_connect(self, cf: Crazyflie) -> None: param = cf.param() From 85f394127644d156f808495c76b112bcc1b38d77 Mon Sep 17 00:00:00 2001 From: ArisMorgens Date: Thu, 26 Mar 2026 16:28:11 +0100 Subject: [PATCH 5/8] Added disconnect logger message --- src/cfclient/ui/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cfclient/ui/main.py b/src/cfclient/ui/main.py index 59db0fcc0..ea86961ae 100644 --- a/src/cfclient/ui/main.py +++ b/src/cfclient/ui/main.py @@ -582,6 +582,7 @@ async def _async_disconnect(self) -> None: self._disconnect_watch_task.cancel() self._disconnect_watch_task = None if self.cf is not None: + logger.info(f"Disconnected from {self.cf.uri}") self._notify_tabs_disconnected() await self.cf.disconnect() self.cf = None From 8ae2e894660a717942debb9e7d045ae638b9c5ce Mon Sep 17 00:00:00 2001 From: Rik Bouwmeester Date: Tue, 31 Mar 2026 15:12:39 +0200 Subject: [PATCH 6/8] Catch DisconnectedError in create_task wrapper instead of per-coroutine PySide6's QtAsyncio prints tracebacks for unhandled task exceptions before done callbacks run, so the existing _task_done_callback catch was too late. Wrap coroutines in create_task to catch DisconnectedError before it reaches PySide6, and remove the now-redundant per-coroutine except blocks. --- src/cfclient/gui.py | 11 ++++++++--- src/cfclient/ui/main.py | 2 -- src/cfclient/ui/pose_logger.py | 2 -- src/cfclient/ui/tabs/FlightTab.py | 2 -- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/cfclient/gui.py b/src/cfclient/gui.py index 31d622def..be53c4025 100644 --- a/src/cfclient/gui.py +++ b/src/cfclient/gui.py @@ -68,8 +68,6 @@ def _task_done_callback(task: asyncio.Task[object]) -> None: return try: task.result() - except DisconnectedError: - logger.debug("Task interrupted by disconnect: %s", task) except Exception: import traceback @@ -77,6 +75,13 @@ def _task_done_callback(task: asyncio.Task[object]) -> None: os._exit(1) +async def _wrap_disconnected(coro: Coroutine[object, object, object]) -> None: + try: + await coro + except DisconnectedError: + logger.debug("Task interrupted by disconnect") + + def create_task(coro: Coroutine[object, object, object]) -> asyncio.Task[object]: """Schedule a coroutine as a task with automatic exception logging. @@ -84,7 +89,7 @@ def create_task(coro: Coroutine[object, object, object]) -> asyncio.Task[object] are logged immediately rather than silently swallowed. """ logger.debug(f"create_task: {coro}") - task = asyncio.ensure_future(coro) + task = asyncio.ensure_future(_wrap_disconnected(coro)) task.add_done_callback(_task_done_callback) return task diff --git a/src/cfclient/ui/main.py b/src/cfclient/ui/main.py index ea86961ae..77dcc0063 100644 --- a/src/cfclient/ui/main.py +++ b/src/cfclient/ui/main.py @@ -616,8 +616,6 @@ async def _stream_battery(self, cf: Crazyflie) -> None: self._battery_signal.emit( data.data["pm.vbat"], int(data.data["pm.state"]) ) - except DisconnectedError: - pass finally: if stream is not None: try: diff --git a/src/cfclient/ui/pose_logger.py b/src/cfclient/ui/pose_logger.py index fa8302a37..9000f0dce 100644 --- a/src/cfclient/ui/pose_logger.py +++ b/src/cfclient/ui/pose_logger.py @@ -120,8 +120,6 @@ async def _stream_loop(self, cf: Crazyflie) -> None: data.data[self.LOG_NAME_ESTIMATE_YAW], ) self.data_received_cb.call(self, self.pose) - except DisconnectedError: - pass finally: if stream is not None: try: diff --git a/src/cfclient/ui/tabs/FlightTab.py b/src/cfclient/ui/tabs/FlightTab.py index 5464c7a30..2f8c4889b 100644 --- a/src/cfclient/ui/tabs/FlightTab.py +++ b/src/cfclient/ui/tabs/FlightTab.py @@ -564,8 +564,6 @@ async def _stream_motors(self, cf: Crazyflie) -> None: while True: data = await stream.next() self._log_data_received(data.timestamp, data.data) - except DisconnectedError: - pass finally: if stream is not None: try: From ef22cd35672d5fb59be08eb5cd19808c43d2cd0f Mon Sep 17 00:00:00 2001 From: Rik Bouwmeester Date: Wed, 6 May 2026 11:36:19 +0200 Subject: [PATCH 7/8] Include coroutine repr in disconnect debug log Makes it possible to identify which task was interrupted when multiple background tasks are torn down by a single disconnect. --- src/cfclient/gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cfclient/gui.py b/src/cfclient/gui.py index be53c4025..22bd5128b 100644 --- a/src/cfclient/gui.py +++ b/src/cfclient/gui.py @@ -79,7 +79,7 @@ async def _wrap_disconnected(coro: Coroutine[object, object, object]) -> None: try: await coro except DisconnectedError: - logger.debug("Task interrupted by disconnect") + logger.debug("Task interrupted by disconnect: %r", coro) def create_task(coro: Coroutine[object, object, object]) -> asyncio.Task[object]: From 9a037dfbf4b6b500bb5fd71521bdd86713041594 Mon Sep 17 00:00:00 2001 From: Rik Bouwmeester Date: Wed, 6 May 2026 11:36:28 +0200 Subject: [PATCH 8/8] Document DisconnectedError handling in create_task docstring DisconnectedError is intentionally swallowed by the wrapper as a normal shutdown case; the original docstring suggested all exceptions propagate. --- src/cfclient/gui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cfclient/gui.py b/src/cfclient/gui.py index 22bd5128b..19432a7a6 100644 --- a/src/cfclient/gui.py +++ b/src/cfclient/gui.py @@ -86,7 +86,8 @@ def create_task(coro: Coroutine[object, object, object]) -> asyncio.Task[object] """Schedule a coroutine as a task with automatic exception logging. Use this instead of asyncio.ensure_future() to ensure exceptions - are logged immediately rather than silently swallowed. + are logged immediately rather than silently swallowed. DisconnectedError + is treated as a normal shutdown case and is logged at debug level only. """ logger.debug(f"create_task: {coro}") task = asyncio.ensure_future(_wrap_disconnected(coro))