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
262 changes: 205 additions & 57 deletions ARCHITECTURE_rc_calibration.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,16 @@
"mandatory_text": "100% mandatory (0% optional)",
"component": "RC Controller",
"auto_changed_by": "",
"plugin": {
"name": "rc_calibration",
"placement": "left"
},
"add_parameters": {
"RCMAP_ROLL": { "if": "'RCMAP_ROLL' in fc_parameters", "Change Reason": "Current RC roll channel mapping; editable in the RC calibration plugin" },
"RCMAP_PITCH": { "if": "'RCMAP_PITCH' in fc_parameters", "Change Reason": "Current RC pitch channel mapping; editable in the RC calibration plugin" },
"RCMAP_THROTTLE": { "if": "'RCMAP_THROTTLE' in fc_parameters", "Change Reason": "Current RC throttle channel mapping; editable in the RC calibration plugin" },
"RCMAP_YAW": { "if": "'RCMAP_YAW' in fc_parameters", "Change Reason": "Current RC yaw channel mapping; editable in the RC calibration plugin" }
},
"derived_parameters": {
"RC5_OPTION": { "if": "vehicle_components['RC Receiver']['FC Connection']['Protocol'] == 'ExpressLRS'", "New Value": "1", "Change Reason": "ExpressLRS requires RC5 to be used for arming (Arm/Disarm function)" }
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ def configuration_steps_strings() -> None: # noqa: PLR0915 # pylint: disable=to
_config_steps_strings = _("Because ALTHOLD flight mode was used for more than 30 seconds to correctly learn the MOT_THST_HOVER value")
_config_steps_strings = _("Because ALTHOLD flight mode was used for more than 30 seconds to correctly learn the Q_M_THST_HOVER value")
_config_steps_strings = _("Critical failsafe voltage x nr. of cells")
_config_steps_strings = _("Current RC pitch channel mapping; editable in the RC calibration plugin")
_config_steps_strings = _("Current RC roll channel mapping; editable in the RC calibration plugin")
_config_steps_strings = _("Current RC throttle channel mapping; editable in the RC calibration plugin")
_config_steps_strings = _("Current RC yaw channel mapping; editable in the RC calibration plugin")
_config_steps_strings = _("Defined in ESC specifications above")
_config_steps_strings = _("Defined in component editor")
_config_steps_strings = _("Derived from vehicle component editor propeller size")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ def handle_write_changes_workflow(

return False

def handle_param_file_change_workflow( # pylint: disable=too-many-arguments, too-many-positional-arguments, too-many-locals # noqa: PLR0913, PLR0917
def handle_param_file_change_workflow( # pylint: disable=too-many-arguments, too-many-positional-arguments, too-many-locals # noqa: PLR0913, PLR0917, RUF100
self,
selected_file: str,
forced: bool,
Expand Down Expand Up @@ -2547,6 +2547,11 @@ def parameter_files(self) -> list[str]:
def parameter_documentation_available(self) -> bool:
return bool(self._local_filesystem.doc_dict)

def get_parameter_choices(self, parameter_name: str) -> dict[str, str]:
"""Return loaded enum choices without exposing filesystem internals."""
metadata = self._local_filesystem.doc_dict.get(parameter_name, {})
return dict(metadata.get("values", {}))

def configuration_phases(self) -> dict[str, PhaseData]:
return self._local_filesystem.configuration_phases

Expand Down
356 changes: 319 additions & 37 deletions ardupilot_methodic_configurator/plugins/data_model_rc_calibration.py

Large diffs are not rendered by default.

Large diffs are not rendered by default.

160 changes: 107 additions & 53 deletions ardupilot_methodic_configurator/plugins/renderer_3d_quadcopter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Quadcopter renderer (PIL stub).
Dependency-free, projected 3D quadcopter preview rendered with Pillow.

This module provides a class to render a simple schematic quadcopter image
based on roll, pitch, yaw, and throttle inputs.
Expand All @@ -9,76 +9,130 @@
SPDX-License-Identifier: GPL-3.0-or-later
"""

from collections.abc import Sequence
from math import cos, isfinite, radians, sin

from PIL import Image, ImageDraw

Point3D = tuple[float, float, float]
Point2D = tuple[float, float]


class QuadcopterRenderer: # pylint: disable=too-few-public-methods
"""Renders a 3D quadcopter model into a PIL Image."""
"""Render a stylized Quad-X from a low rear chase view; not a flight simulator."""

def __init__(self, width: int = 400, height: int = 200) -> None:
self.width = width
self.height = height

# Initialize OpenGL context (off-screen)
# Note: In a real app, we might need a proper context manager or a hidden window
# For this dummy, we'll assume a context is available or use a simple approach.
# Since we want to return a PIL Image, we'll use a buffer.

def render(self, roll: float, pitch: float, yaw: float, throttle: float) -> Image.Image: # noqa: ARG002 # pylint: disable=unused-argument
@staticmethod
def _clamp(value: float, minimum: float, maximum: float) -> float:
"""Keep display inputs finite and within their documented visual range."""
return max(minimum, min(maximum, value)) if isfinite(value) else 0.0

@staticmethod
def _rotate(point: Point3D, roll: float, pitch: float, yaw: float) -> Point3D:
"""Rotate body coordinates (right, forward, up): positive roll right, pitch nose up, yaw right."""
x, y, z = point
x, z = x * cos(roll) + z * sin(roll), -x * sin(roll) + z * cos(roll)
y, z = y * cos(pitch) - z * sin(pitch), y * sin(pitch) + z * cos(pitch)
return x * cos(yaw) + y * sin(yaw), -x * sin(yaw) + y * cos(yaw), z

def _project(self, point: Point3D, lift: float) -> Point2D:
"""Project from behind and 17 degrees above the craft with a mild perspective."""
x, y, z = point
elevation = radians(17)
depth = y * cos(elevation) - z * sin(elevation)
scale = min(self.width / 6.5, self.height / 3.8) * 7 / (7 + depth)
return self.width / 2 + x * scale, self.height * 0.53 + (y * sin(elevation) - z * cos(elevation)) * scale - lift

@staticmethod
def _box_faces(center: Point3D, size: Point3D) -> list[list[Point3D]]:
"""Build the six faces of a rectangular body or flight-controller stack."""
x, y, z = center
dx, dy, dz = (side / 2 for side in size)
corners = [
(x - dx, y - dy, z - dz),
(x + dx, y - dy, z - dz),
(x + dx, y + dy, z - dz),
(x - dx, y + dy, z - dz),
(x - dx, y - dy, z + dz),
(x + dx, y - dy, z + dz),
(x + dx, y + dy, z + dz),
(x - dx, y + dy, z + dz),
]
return [
[corners[index] for index in face]
for face in ((0, 1, 2, 3), (0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7), (4, 5, 6, 7))
]

@staticmethod
def _craft_surfaces() -> list[tuple[Sequence[Point3D], str]]:
"""Construct independent procedural Quad-X geometry without external meshes or assets."""
geometry: list[tuple[Sequence[Point3D], str]] = []

def add_surface(points: Sequence[Point3D], color: str) -> None:
geometry.append((points, color))

for x, y in ((-1.25, 1.1), (1.25, 1.1), (-1.25, -1.1), (1.25, -1.1)):
add_surface([(0, 0, -0.06), (x, y, -0.06), (x, y, 0.06), (0, 0, 0.06)], "#46576a")
accent = "#ff815f" if y > 0 else "#61dafb"
add_surface(
[(x + 0.48 * cos(radians(angle)), y + 0.48 * sin(radians(angle)), 0.16) for angle in range(0, 360, 15)], accent
)
add_surface(
[(x + 0.38 * cos(radians(angle)), y + 0.38 * sin(radians(angle)), 0.17) for angle in range(0, 360, 15)],
"#182536",
)
add_surface(
[(x - 0.4, y - 0.07, 0.19), (x + 0.4, y - 0.07, 0.19), (x + 0.4, y + 0.07, 0.19), (x - 0.4, y + 0.07, 0.19)],
accent,
)
for center, size, color in (
((0, 0, 0), (0.9, 1.1, 0.22), "#d8e3f3"),
((0, -0.05, 0.18), (0.65, 0.75, 0.12), "#29384a"),
((0, 0, 0.3), (0.3, 0.35, 0.1), "#61dafb"),
):
for face in QuadcopterRenderer._box_faces(center, size):
add_surface(face, color)
add_surface([(-0.2, 0.55, 0.14), (0.2, 0.55, 0.14), (0, 0.95, 0.14)], "#ff815f")
return geometry

def render(self, roll: float, pitch: float, yaw: float, throttle: float) -> Image.Image:
"""
Renders the quadcopter based on inputs.

Args:
roll: Normalised roll input (-1.0 to 1.0).
pitch: Normalised pitch input (-1.0 to 1.0).
yaw: Normalised yaw input (-1.0 to 1.0).
throttle: Normalised throttle input (-1.0 to 1.0).
yaw: Accumulated preview heading in degrees (not a yaw stick deflection).
throttle: Throttle lift (0.0 to 1.0), from bottom to top of the preview.

Returns:
PIL.Image: The rendered frame.

"""
# This is a placeholder for the actual OpenGL rendering logic.
# In a full implementation, we would:
# 1. Set up the projection and modelview matrices.
# 2. Draw the quadcopter body and arms.
# 3. Draw the rotors.
# 4. Capture the buffer into a PIL Image.

# For now, we return a dummy image with a colored quadcopter-like shape
# to satisfy the requirement of "displaying" something while we work on the GL logic.
img = Image.new("RGB", (self.width, self.height), color=(30, 30, 30))

draw = ImageDraw.Draw(img)

# Draw a simple "quadcopter" shape
# Body
draw.rectangle(
[self.width // 2 - 20, self.height // 2 - 10, self.width // 2 + 20, self.height // 2 + 10], fill=(100, 100, 100)
)
# Arms
draw.line(
[self.width // 2 - 20, self.height // 2, self.width // 2 - 60, self.height // 2 - 30],
fill=(150, 150, 150),
width=5,
)
draw.line(
[self.width // 2 - 20, self.height // 2, self.width // 2 - 60, self.height // 2 + 30],
fill=(150, 150, 150),
width=5,
angles = (
radians(self._clamp(roll, -1, 1) * 35),
radians(self._clamp(pitch, -1, 1) * 35),
radians(yaw % 360 if isfinite(yaw) else 0),
)
draw.line(
[self.width // 2 + 20, self.height // 2, self.width // 2 + 60, self.height // 2 - 30],
fill=(150, 150, 150),
width=5,
)
draw.line(
[self.width // 2 + 20, self.height // 2, self.width // 2 + 60, self.height // 2 + 30],
fill=(150, 150, 150),
width=5,
)

# Simple "movement" indicators
# Pitch/Roll/Yaw would affect the rotation of these lines in a real GL implementation.

return img
lift = (self._clamp(throttle, 0, 1) - 0.5) * self.height * 0.3
image = Image.new("RGB", (self.width, self.height), "#101a29")
draw = ImageDraw.Draw(image)
# Fixed floor/shadow makes throttle lift easy to see without suggesting actual altitude.
floor = round(self.height * 0.86)
draw.line((12, floor, self.width - 12, floor), fill="#29384a")
draw.ellipse((self.width * 0.31, floor - 5, self.width * 0.69, floor + 5), fill="#080e18")
surfaces: list[tuple[float, list[Point2D], str]] = []

def add_surface(points: Sequence[Point3D], color: str) -> None:
rotated = [self._rotate(point, *angles) for point in points]
depth = sum(point[1] * cos(radians(17)) - point[2] * sin(radians(17)) for point in rotated) / len(rotated)
surfaces.append((depth, [self._project(point, lift) for point in rotated], color))

for points, color in self._craft_surfaces():
add_surface(points, color)
for surface in sorted(surfaces, key=lambda surface: surface[0], reverse=True):
draw.polygon(surface[1], fill=surface[2], outline="#0a111c")
return image
54 changes: 54 additions & 0 deletions tests/plugins/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Shared fixtures for staged RC parameter editing.

SPDX-FileCopyrightText: 2026 ArduPilot Contributors
SPDX-License-Identifier: GPL-3.0-or-later
"""

from unittest.mock import MagicMock

import pytest

from ardupilot_methodic_configurator.data_model_ardupilot_parameter import ArduPilotParameter
from ardupilot_methodic_configurator.data_model_par_dict import Par
from ardupilot_methodic_configurator.data_model_parameter_editor import ParameterEditor


@pytest.fixture
def rc_staging_context() -> tuple[MagicMock, ParameterEditor]:
"""Use real parameter objects and editor APIs; mock only filesystem and FC I/O."""
controller = MagicMock()
controller.master.recv_match.return_value = None
controller.fc_parameters = {
"RCMAP_ROLL": 1.0,
"RCMAP_PITCH": 2.0,
"RCMAP_THROTTLE": 3.0,
"RCMAP_YAW": 4.0,
"FLTMODE_CH": 5.0,
"ARMING_RUDDER": 2.0,
}
for channel in range(1, 17):
for suffix, value in (("MIN", 1000.0), ("MAX", 2000.0), ("TRIM", 1500.0)):
controller.fc_parameters[f"RC{channel}_{suffix}"] = value
controller.fc_parameters[f"RC{channel}_OPTION"] = 0.0
filesystem = MagicMock()
filesystem.get_eval_variables.return_value = {}
option_choices = {"0": "Disabled", "6": "AutoTune", "17": "AutoTune", "300": "QuickTune"}
filesystem.doc_dict = {f"RC{channel}_OPTION": {"values": option_choices} for channel in range(1, 17)}
filesystem.param_default_dict = {}
filesystem.file_parameters = {}
filesystem.forced_parameters = {}
filesystem.derived_parameters = {}
editor = ParameterEditor("07_remote_controller_controller.param", controller, filesystem)
editor.current_step_parameters = {
name: ArduPilotParameter(
name,
Par(value, "Existing reason"),
metadata={"values": option_choices} if name.endswith("_OPTION") else None,
fc_value=value,
)
for name, value in controller.fc_parameters.items()
if name != "RC11_OPTION"
}
return controller, editor
Loading
Loading