Skip to content

Commit 858f292

Browse files
OmkarSarkar204amilcarlucas
authored andcommitted
feat(motor): implement GUI and tests for motor swap
Signed-off-by: Omkar Sarkar <omkarsarkar24@gmail.com>
1 parent ca776fa commit 858f292

4 files changed

Lines changed: 312 additions & 5 deletions

File tree

ardupilot_methodic_configurator/data_model_motor_test.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,58 @@ def set_parameter( # pylint: disable=too-many-arguments, too-many-positional-ar
457457
logging_error(error_msg)
458458
raise ParameterError(error_msg) from e
459459

460+
def swap_motor_functions(self, expected_label: str, detected_label: str) -> None:
461+
"""
462+
Swap the SERVOx_FUNCTION parameters between two motors.
463+
464+
Args:
465+
expected_label: The letter of the motor that was tested (e.g., 'A').
466+
detected_label: The letter of the motor that actually spun (e.g., 'C').
467+
468+
Raises:
469+
ValidationError: If the labels are invalid.
470+
ParameterError: If reading or writing the parameters fails.
471+
472+
"""
473+
if expected_label == detected_label:
474+
return # Nothing to swap
475+
476+
try:
477+
# 1. Convert the letters (A, B, C...) into their test sequence index (0, 1, 2...)
478+
expected_idx = self._motor_labels.index(expected_label)
479+
detected_idx = self._motor_labels.index(detected_label)
480+
481+
# 2. Get the actual physical motor numbers (1, 2, 3, 4...)
482+
expected_servo_num = self._motor_numbers[expected_idx]
483+
detected_servo_num = self._motor_numbers[detected_idx]
484+
485+
# 3. Construct the parameter names (e.g., "SERVO1_FUNCTION", "SERVO3_FUNCTION")
486+
expected_param = f"SERVO{expected_servo_num}_FUNCTION"
487+
detected_param = f"SERVO{detected_servo_num}_FUNCTION"
488+
489+
# 4. Read the current values from the flight controller
490+
expected_val = self.get_parameter(expected_param)
491+
detected_val = self.get_parameter(detected_param)
492+
493+
if expected_val is None or detected_val is None:
494+
raise ParameterError(
495+
_("Could not read parameters %(p1)s or %(p2)s from flight controller.")
496+
% {"p1": expected_param, "p2": detected_param}
497+
)
498+
499+
# 5. Swap them
500+
logging_info(
501+
_("Swapping motor %(exp)s (%(p1)s) with motor %(det)s (%(p2)s)"),
502+
{"exp": expected_label, "det": detected_label, "p1": expected_param, "p2": detected_param},
503+
)
504+
505+
# Set one at a time
506+
self.set_parameter(expected_param, detected_val)
507+
self.set_parameter(detected_param, expected_val)
508+
509+
except ValueError as e:
510+
raise ValidationError(_("Invalid motor labels provided for swapping.")) from e
511+
460512
def get_parameter(self, param_name: str) -> Optional[float]:
461513
"""
462514
Get a parameter value from the flight controller.

ardupilot_methodic_configurator/frontend_tkinter_motor_test.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@
2727
from logging import error as logging_error
2828
from logging import info as logging_info
2929
from logging import warning as logging_warning
30-
from tkinter import Frame, Label, ttk
31-
from tkinter.messagebox import askyesno, showerror, showwarning
30+
from tkinter import Event, Frame, Label, ttk
31+
from tkinter.messagebox import askyesno, showerror, showinfo, showwarning
3232
from tkinter.simpledialog import askfloat
33-
from typing import Callable, Optional, Union
33+
from typing import Callable, Optional, Union, cast
3434

3535
from ardupilot_methodic_configurator import _
3636
from ardupilot_methodic_configurator.__main__ import (
@@ -244,7 +244,18 @@ def _create_widgets(self) -> None: # pylint: disable=too-many-statements # noqa
244244
).pack(side="left", padx=5)
245245

246246
def _create_motor_buttons(self, parent: Union[Frame, ttk.Frame]) -> None:
247-
"""Create the motor test buttons and detection comboboxes."""
247+
"""
248+
Create per-motor test controls and configure detection selection handling.
249+
250+
For each configured motor, this builds the test button, motor information label,
251+
detection combobox, and status label. It also binds each detection combobox's
252+
selection event to `_on_combobox_selected` so swap/detection UI updates are
253+
handled when the user changes a detected motor assignment.
254+
255+
Args:
256+
parent: The container that will receive the per-motor control frames.
257+
258+
"""
248259
motor_labels = self.model.motor_labels
249260
motor_numbers = self.model.motor_numbers
250261
motor_directions = self.model.motor_directions
@@ -274,12 +285,65 @@ def make_test_command(test_sequence_nr: int, motor_output_nr: int) -> Callable[[
274285
combo = ttk.Combobox(motor_frame, values=self.model.motor_labels, width=5)
275286
combo.pack()
276287
self.detected_comboboxes.append(combo)
288+
# Bind the selection event to the new handler
289+
combo.bind("<<ComboboxSelected>>", self._on_combobox_selected)
277290

278291
# Add status label for visual feedback
279292
status_label = ttk.Label(motor_frame, text=_("Ready"), foreground="blue")
280293
status_label.pack()
281294
self.motor_status_labels.append(status_label)
282295

296+
def _on_combobox_selected(self, event: Event) -> None:
297+
"""Handle combobox selection by looking up the triggering widget."""
298+
# Cast the generic widget to a ttk.Combobox so the type checker knows it has a .get() method
299+
combobox = cast("ttk.Combobox", event.widget)
300+
301+
# Find which row this combobox belongs to in our saved list
302+
if combobox in self.detected_comboboxes:
303+
index = self.detected_comboboxes.index(combobox)
304+
expected_label = self.model.motor_labels[index]
305+
selected_label = combobox.get()
306+
307+
# Pass the data to the swap logic
308+
self._on_motor_swapped(expected_label, selected_label)
309+
310+
def _on_motor_swapped(self, expected_label: str, detected_label: str) -> None:
311+
"""Handle the user selecting a different motor from the dropdown."""
312+
if expected_label == detected_label:
313+
# If they select 'A' for motor 'A', just clear the box and idle
314+
for cb in self.detected_comboboxes:
315+
cb.set("")
316+
return
317+
318+
# Ask for confirmation
319+
confirm = askyesno(
320+
_("Swapping Motors?"),
321+
_("Are you sure you want to swap the functions of Motor %(exp)s and Motor %(det)s?")
322+
% {"exp": expected_label, "det": detected_label},
323+
)
324+
325+
if not confirm:
326+
for cb in self.detected_comboboxes:
327+
cb.set("")
328+
return
329+
330+
try:
331+
self.model.swap_motor_functions(expected_label, detected_label)
332+
333+
showinfo(
334+
_("Success"),
335+
_("Motor %(exp)s and Motor %(det)s have been successfully swapped.")
336+
% {"exp": expected_label, "det": detected_label},
337+
)
338+
339+
except (ParameterError, ValidationError) as e:
340+
showerror(_("Swap Failed"), str(e))
341+
342+
finally:
343+
# To make the UI looks clean always clear the comboboxes after the action is done
344+
for cb in self.detected_comboboxes:
345+
cb.set("")
346+
283347
def _update_view(self) -> None:
284348
"""Update the view with data from the model."""
285349
# Update diagram only when needed (not every second)

tests/test_data_model_motor_test.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3267,3 +3267,86 @@ def test_frame_options_include_all_defined_classes(self, motor_test_model) -> No
32673267
assert "Y6" in frame_options
32683268
assert "SCRIPTINGMATRIX" in frame_options
32693269
assert len(frame_options) == 3
3270+
3271+
3272+
class TestMotorTestDataModelMotorSwapping:
3273+
"""Test motor parameter swapping functionality."""
3274+
3275+
def test_swap_motor_functions_exits_early_when_labels_match(self, motor_test_model) -> None:
3276+
"""
3277+
Swapping a motor with itself does nothing.
3278+
3279+
GIVEN: A request to swap motor 'A' with 'A'
3280+
WHEN: Calling swap_motor_functions
3281+
THEN: Returns immediately without reading or writing parameters
3282+
"""
3283+
# Arrange: set parameter to ensure they aren't called
3284+
with (
3285+
patch.object(motor_test_model, "get_parameter") as mock_get,
3286+
patch.object(motor_test_model, "set_parameter") as mock_set,
3287+
):
3288+
# Act: Attempt to swap
3289+
motor_test_model.swap_motor_functions("A", "A")
3290+
3291+
# Assert: No backend interactions occurred
3292+
mock_get.assert_not_called()
3293+
mock_set.assert_not_called()
3294+
3295+
def test_swap_motor_functions_swaps_parameters_successfully(self, motor_test_model) -> None:
3296+
"""
3297+
Successfully swaps SERVO_FUNCTION parameters for two valid motors.
3298+
3299+
GIVEN: A request to swap motor 'A' (SERVO1) and 'C' (SERVO3)
3300+
WHEN: Calling swap_motor_functions and the FC returns valid data
3301+
THEN: The parameters are read and written back swapped
3302+
"""
3303+
3304+
# Arrange: Set up mock parameter reading
3305+
def mock_get_param(param_name: str) -> Optional[float]:
3306+
if param_name == "SERVO1_FUNCTION":
3307+
return 33.0 # e.g, Motor 1 output function code
3308+
if param_name == "SERVO3_FUNCTION":
3309+
return 35.0 # e.g, Motor 3 output function code
3310+
return None
3311+
3312+
with (
3313+
patch.object(motor_test_model, "get_parameter", side_effect=mock_get_param) as mock_get,
3314+
patch.object(motor_test_model, "set_parameter") as mock_set,
3315+
):
3316+
# Act: Execute swap
3317+
motor_test_model.swap_motor_functions("A", "C")
3318+
3319+
# Assert: Verify correct parameters were read
3320+
mock_get.assert_any_call("SERVO1_FUNCTION")
3321+
mock_get.assert_any_call("SERVO3_FUNCTION")
3322+
3323+
# Assert: Verify parameters were written back inverted
3324+
mock_set.assert_any_call("SERVO1_FUNCTION", 35.0)
3325+
mock_set.assert_any_call("SERVO3_FUNCTION", 33.0)
3326+
assert mock_set.call_count == 2
3327+
3328+
def test_swap_motor_functions_raises_validation_error_for_invalid_labels(self, motor_test_model) -> None:
3329+
"""
3330+
Raises ValidationError when an invalid motor label is provided.
3331+
3332+
GIVEN: An invalid motor label not in the current frame
3333+
WHEN: Calling swap_motor_functions
3334+
THEN: Raises ValidationError preventing the swap
3335+
"""
3336+
with pytest.raises(ValidationError, match="Invalid motor labels provided for swapping"):
3337+
motor_test_model.swap_motor_functions("A", "Z")
3338+
3339+
def test_swap_motor_functions_raises_parameter_error_on_read_failure(self, motor_test_model) -> None:
3340+
"""
3341+
Raises ParameterError when failing to read existing parameter values.
3342+
3343+
GIVEN: Valid labels but the flight controller returns None for a parameter
3344+
WHEN: Calling swap_motor_functions
3345+
THEN: Raises ParameterError indicating the read failure
3346+
"""
3347+
# Arrange, Act & Assert: Combine the mock and the exception check into one statement
3348+
with (
3349+
patch.object(motor_test_model, "get_parameter", return_value=None),
3350+
pytest.raises(ParameterError, match="Could not read parameters"),
3351+
):
3352+
motor_test_model.swap_motor_functions("A", "B")

tests/test_frontend_tkinter_motor_test.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from __future__ import annotations
1414

1515
from importlib import import_module
16-
from tkinter import ttk
16+
from tkinter import Event, ttk
1717
from types import SimpleNamespace
1818
from typing import TYPE_CHECKING, Any, Callable, cast # pylint: disable=unused-import
1919
from unittest.mock import MagicMock, patch
@@ -834,3 +834,111 @@ def register(self, name: str, creator: Callable) -> None:
834834
assert dummy_factory.calls
835835
finally:
836836
module_with_attr.plugin_factory = original_factory
837+
838+
839+
class TestMotorTestViewMotorSwapping:
840+
"""Test the frontend GUI logic specifically for motor swapping."""
841+
842+
def test_on_combobox_selected_routes_correctly(self, motor_view: MotorTestView, mocker) -> None:
843+
"""
844+
Combobox selection routes the expected and detected labels to the swap logic.
845+
846+
GIVEN: A combobox selection event triggered by the first combobox
847+
WHEN: _on_combobox_selected is called
848+
THEN: It looks up the correct labels and routes data to _on_motor_swapped
849+
"""
850+
cb = motor_view.detected_comboboxes[0]
851+
cb.set("B")
852+
853+
mock_event = MagicMock(spec=Event)
854+
mock_event.widget = cb
855+
856+
swap_spy = mocker.patch.object(motor_view, "_on_motor_swapped")
857+
858+
# Act
859+
motor_view._on_combobox_selected(mock_event)
860+
861+
swap_spy.assert_called_once_with("A", "B")
862+
863+
def test_on_motor_swapped_early_exit(
864+
self, motor_view: MotorTestView, fake_model: FakeMotorTestModel, dialog_spies: SimpleNamespace
865+
) -> None:
866+
"""
867+
Swapping a motor with itself exits early without prompting.
868+
869+
GIVEN: A swap request where expected and detected labels are identical
870+
WHEN: _on_motor_swapped is called
871+
THEN: It clears the comboboxes and returns immediately without prompting the user
872+
"""
873+
fake_model.swap_motor_functions = MagicMock()
874+
motor_view.detected_comboboxes[0].set("A")
875+
876+
motor_view._on_motor_swapped("A", "A")
877+
878+
dialog_spies.askyesno.assert_not_called()
879+
fake_model.swap_motor_functions.assert_not_called()
880+
assert motor_view.detected_comboboxes[0].get() == ""
881+
882+
def test_on_motor_swapped_cancelled_by_user(
883+
self, motor_view: MotorTestView, fake_model: FakeMotorTestModel, dialog_spies: SimpleNamespace
884+
) -> None:
885+
"""
886+
Swap requests cancelled by the user reset the UI safely.
887+
888+
GIVEN: A valid swap request
889+
WHEN: The user clicks 'No' on the confirmation dialog
890+
THEN: It clears the comboboxes and aborts the swap
891+
"""
892+
dialog_spies.askyesno.return_value = False
893+
fake_model.swap_motor_functions = MagicMock()
894+
motor_view.detected_comboboxes[0].set("B")
895+
896+
motor_view._on_motor_swapped("A", "B")
897+
898+
dialog_spies.askyesno.assert_called_once()
899+
fake_model.swap_motor_functions.assert_not_called()
900+
assert motor_view.detected_comboboxes[0].get() == ""
901+
902+
def test_on_motor_swapped_success(
903+
self, motor_view: MotorTestView, fake_model: FakeMotorTestModel, dialog_spies: SimpleNamespace, mocker
904+
) -> None:
905+
"""
906+
Successful swap requests apply parameters and show a success dialog.
907+
908+
GIVEN: A valid swap request confirmed by the user
909+
WHEN: The data model successfully swaps the parameters
910+
THEN: A success info dialog is shown and comboboxes are cleared
911+
"""
912+
dialog_spies.askyesno.return_value = True
913+
mock_showinfo = mocker.patch("ardupilot_methodic_configurator.frontend_tkinter_motor_test.showinfo")
914+
fake_model.swap_motor_functions = MagicMock()
915+
motor_view.detected_comboboxes[0].set("B")
916+
917+
motor_view._on_motor_swapped("A", "B")
918+
919+
fake_model.swap_motor_functions.assert_called_once_with("A", "B")
920+
mock_showinfo.assert_called_once()
921+
assert motor_view.detected_comboboxes[0].get() == ""
922+
923+
def test_on_motor_swapped_handles_exception(
924+
self, motor_view: MotorTestView, fake_model: FakeMotorTestModel, dialog_spies: SimpleNamespace
925+
) -> None:
926+
"""
927+
Swap requests that fail at the backend show an error dialog.
928+
929+
GIVEN: A valid swap request confirmed by the user
930+
WHEN: The data model raises an exception during the swap
931+
THEN: An error dialog is shown and comboboxes are cleared
932+
"""
933+
dialog_spies.askyesno.return_value = True
934+
fake_model.swap_motor_functions = MagicMock(side_effect=ParameterError("Mock FC read error"))
935+
motor_view.detected_comboboxes[0].set("B")
936+
937+
motor_view._on_motor_swapped("A", "B")
938+
939+
fake_model.swap_motor_functions.assert_called_once_with("A", "B")
940+
dialog_spies.showerror.assert_called_once()
941+
942+
# Verify the actual error message was passed to the UI
943+
assert "Mock FC read error" in dialog_spies.showerror.call_args[0][1]
944+
assert motor_view.detected_comboboxes[0].get() == ""

0 commit comments

Comments
 (0)