Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 51 additions & 0 deletions ardupilot_methodic_configurator/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,50 @@
from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor import ParameterEditorWindow
from ardupilot_methodic_configurator.frontend_tkinter_project_opener import VehicleProjectOpenerWindow
from ardupilot_methodic_configurator.frontend_tkinter_show import show_error_message
from ardupilot_methodic_configurator.plugin_constants import PLUGIN_MOTOR_TEST
from ardupilot_methodic_configurator.plugin_factory import plugin_factory


def register_plugins() -> None:
"""
Register all available plugins with the factory.

This function explicitly imports and registers plugins, avoiding
side-effect imports and potential race conditions.
"""
# Import and register motor test plugin
# pylint: disable=import-outside-toplevel, cyclic-import
from ardupilot_methodic_configurator.frontend_tkinter_motor_test import register_motor_test_plugin # noqa: PLC0415
# pylint: enable=import-outside-toplevel, cyclic-import

register_motor_test_plugin()

# Add more plugin registrations here in the future


def validate_plugin_registry(local_filesystem: LocalFilesystem) -> None:
"""
Validate that all plugins configured in configuration steps are registered.

Args:
local_filesystem: The filesystem interface to access configuration steps

"""
# Get all configured plugins
configured_plugins = set()
# configuration_steps is a dict, not an object with configuration_steps attribute
for file_info in local_filesystem.configuration_steps.values():
plugin = file_info.get("plugin")
if plugin and plugin.get("name"):
configured_plugins.add(plugin["name"])

# Verify each configured plugin is registered
for plugin_name in configured_plugins:
if not plugin_factory.is_registered(plugin_name):
logging_error(
_("Plugin '%(plugin_name)s' is configured but not registered. Available plugins: %(available)s"),
{"plugin_name": plugin_name, "available": PLUGIN_MOTOR_TEST},
)


class ApplicationState: # pylint: disable=too-few-public-methods
Expand Down Expand Up @@ -507,6 +551,9 @@ def main() -> None:
"""
args = create_argument_parser().parse_args()

# Register plugins early, before any UI creation
register_plugins()

# Create desktop icon if needed (only on first run in venv)
FreeDesktop.create_desktop_icon_if_needed()

Expand All @@ -518,6 +565,10 @@ def main() -> None:
if check_updates(state):
sys_exit(0) # user asked to update, exit the old version

# Validate that all configured plugins are registered
if state.local_filesystem:
validate_plugin_registry(state.local_filesystem)

if bool(ProgramSettings.get_setting("auto_open_doc_in_browser")):
display_first_use_documentation()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from logging import info as logging_info
from logging import warning as logging_warning
from os import path as os_path
from typing import TypedDict
from typing import Optional, TypedDict

# from sys import exit as sys_exit
# from logging import debug as logging_debug
Expand Down Expand Up @@ -293,3 +293,18 @@ def get_sorted_phases_with_end_and_weight(self, total_files: int) -> dict[str, P
sorted_phases[phase_name]["weight"] = max(2, phase_end - phase_start)

return sorted_phases

def get_plugin(self, selected_file: str) -> Optional[dict]:
"""
Get the plugin configuration for the selected file.

Args:
selected_file: The filename to get plugin info for

Returns:
The plugin dict with 'name' and 'placement' if exists, None otherwise

"""
if selected_file in self.configuration_steps:
return self.configuration_steps[selected_file].get("plugin")
return None
27 changes: 27 additions & 0 deletions ardupilot_methodic_configurator/configuration_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
from ardupilot_methodic_configurator.backend_internet import download_file_from_url
from ardupilot_methodic_configurator.data_model_ardupilot_parameter import ArduPilotParameter
from ardupilot_methodic_configurator.data_model_configuration_step import ConfigurationStepProcessor
from ardupilot_methodic_configurator.data_model_motor_test import MotorTestDataModel
from ardupilot_methodic_configurator.data_model_par_dict import Par, ParDict, is_within_tolerance
from ardupilot_methodic_configurator.plugin_constants import PLUGIN_MOTOR_TEST
from ardupilot_methodic_configurator.tempcal_imu import IMUfit

# Type aliases for callback functions used in workflow methods
Expand Down Expand Up @@ -1593,3 +1595,28 @@ def parse_mandatory_level_percentage(self, text: str) -> tuple[int, str]:
return 0, tooltip.format(current_file=current_file)

# frontend_tkinter_parameter_editor_documentation_frame.py API end

# plugin API begin

def get_plugin(self, filename: str) -> Optional[dict]:
return self._local_filesystem.get_plugin(filename)

def create_plugin_data_model(self, plugin_name: str) -> Optional[object]:

Copilot AI Nov 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type Optional[object] is too generic and provides no type safety for callers. Consider using a Union type of specific model classes (e.g., Optional[MotorTestDataModel]) or defining a base class/protocol for plugin data models.

Copilot uses AI. Check for mistakes.

Copilot AI Nov 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin_name parameter should be validated. Consider adding type checking or documentation about what happens when plugin_name is None or an empty string, since the tests check these cases but the implementation may not handle them explicitly.

Copilot uses AI. Check for mistakes.
"""
Create and return a data model for the specified plugin.

Args:
plugin_name: The name of the plugin to create a data model for

Returns:
The data model instance, or None if plugin not supported or requirements not met

"""
if plugin_name == PLUGIN_MOTOR_TEST:
if not self.is_fc_connected:
return None
return MotorTestDataModel(self._flight_controller, self._local_filesystem)
# Add more plugins here in the future
return None

# plugin API end
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,11 @@
"external_tool_url": "https://firmware.ardupilot.org/Tools/WebTools/ThrustExpo/",
"mandatory_text": "40% mandatory (60% optional)",
"auto_changed_by": "",
"old_filenames": ["14_motor.param"]
"old_filenames": ["14_motor.param"],
"plugin": {
"name": "motor_test",
"placement": "left"
}
},
"16_pid_adjustment.param": {
"why": "With very large or very small vehicles the default PID values are not suitable for the first flight",
Expand Down
16 changes: 16 additions & 0 deletions ardupilot_methodic_configurator/configuration_steps_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@
"pattern": "^/APM/"
}
}
},
"plugin": {
"type": "object",
"required": ["name", "placement"],
"properties": {
"name": {
"type": "string",
"enum": ["motor_test"],
"description": "The name of the plugin to load"
},
"placement": {
"type": "string",
"enum": ["left", "top"],
"description": "Where to place the plugin: 'left' for left of scrollable frame, 'top' for above contents"
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,9 @@ def configuration_steps_descriptions() -> None:
_config_steps_descriptions = _("Short description for wiki reference")
_config_steps_descriptions = _("Starting step number of this phase")
_config_steps_descriptions = _("Text indicating if step is mandatory or optional with percentages")
_config_steps_descriptions = _("The name of the plugin to load")
_config_steps_descriptions = _("URL to blog documentation")
_config_steps_descriptions = _("URL to external tool")
_config_steps_descriptions = _("URL to wiki documentation")
_config_steps_descriptions = _("Where to place the plugin: 'left' for left of scrollable frame, 'top' for above contents")
_config_steps_descriptions = _("Whether this phase is optional")
69 changes: 69 additions & 0 deletions ardupilot_methodic_configurator/frontend_tkinter_motor_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
from ardupilot_methodic_configurator.frontend_tkinter_pair_tuple_combobox import PairTupleCombobox
from ardupilot_methodic_configurator.frontend_tkinter_progress_window import ProgressWindow
from ardupilot_methodic_configurator.frontend_tkinter_scroll_frame import ScrollFrame
from ardupilot_methodic_configurator.plugin_constants import PLUGIN_MOTOR_TEST
from ardupilot_methodic_configurator.plugin_factory import plugin_factory


class DelayedProgressCallback: # pylint: disable=too-few-public-methods
Expand Down Expand Up @@ -756,6 +758,42 @@ def _setup_keyboard_shortcuts(self) -> None:
# Focus root window to ensure it can capture key events
self.root_window.focus_set()

def on_activate(self) -> None:
"""
Called when the plugin becomes active (visible).

Refreshes the frame configuration from the flight controller
to ensure the display is up-to-date.
"""
# Refresh frame configuration when becoming active
if not self.model.refresh_from_flight_controller():
logging_warning(_("Could not refresh frame configuration from flight controller"))
self._update_view()

def on_deactivate(self) -> None:
"""
Called when the plugin becomes inactive (hidden).

Stops all running motor tests for safety when switching away from this plugin.
"""
# Critical safety requirement: stop all motors when user navigates away
# to prevent motors running unattended in the background
try:
self.model.stop_all_motors()
self._reset_all_motor_status()
except (MotorTestExecutionError, ParameterError) as e:
# Motor stop failed - this could indicate a communication issue or unsupported frame type.
# We log as warning (not debug) because failed motor stop is a safety concern
# that operators should be aware of, even if it's expected for some configurations.
logging_warning(
_("Motor stop failed during deactivation: %(error)s. Please verify motors are stopped."), {"error": str(e)}
)
except Exception as e:
# Unexpected errors during motor stop are critical safety issues.
# We log as error and re-raise to prevent silently continuing with motors potentially running.
logging_error(_("Critical error during motor stop at deactivation: %(error)s"), {"error": str(e)})
raise


class MotorTestWindow(BaseWindow):
"""
Expand Down Expand Up @@ -849,5 +887,36 @@ def main() -> None:
state.flight_controller.disconnect() # Disconnect from the flight controller


# Register this plugin with the factory for dependency injection
def _create_motor_test_view(
parent: Union[tk.Frame, ttk.Frame],
model: object,
base_window: object,
) -> MotorTestView:
"""
Factory function to create MotorTestView instances.

This function trusts that the caller provides the correct types
as per the plugin protocol (duck typing approach).

Args:
parent: The parent frame
model: The MotorTestDataModel instance (passed as object for protocol compliance)
base_window: The BaseWindow instance (passed as object for protocol compliance)

Returns:
A new MotorTestView instance

"""
# Trust the caller to provide correct types (protocol-based duck typing)
# Type checker will verify this at static analysis time
return MotorTestView(parent, model, base_window) # type: ignore[arg-type]


def register_motor_test_plugin() -> None:
"""Register the motor test plugin with the factory."""
plugin_factory.register(PLUGIN_MOTOR_TEST, _create_motor_test_view)


if __name__ == "__main__":
main()
Loading