From 9d0dce20b5189031b2fae288f4d42c6aecdd5958 Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Mon, 5 Oct 2026 19:55:33 +0200 Subject: [PATCH 1/2] feat(rc-calibration): add staged RC mapping and live previews - Display all 16 RC channels with PWM readouts and MIN/TRIM/MAX markers - Add RC mapping presets and channel-function controls, staging edits until parameter upload - Drive stick and quadcopter previews from mapped RC inputs - Expose firmware enum choices through ParameterEditor and document the plugin - Add regression tests for the data model, UI, renderer Signed-off-by: Dr.-Ing. Amilcar do Carmo Lucas --- ARCHITECTURE_rc_calibration.md | 262 ++++-- .../configuration_steps_ArduCopter.json | 10 + .../data_model_parameter_editor.py | 7 +- .../plugins/data_model_rc_calibration.py | 356 +++++++- .../frontend_tkinter_rc_calibration.py | 673 ++++++++++---- .../plugins/renderer_3d_quadcopter.py | 160 ++-- tests/plugins/conftest.py | 54 ++ .../plugins/test_data_model_rc_calibration.py | 773 ++++++++++++++-- .../test_frontend_tkinter_rc_calibration.py | 861 ++++++++++++++++++ tests/plugins/test_renderer_3d_quadcopter.py | 86 ++ tests/test_data_model_parameter_editor.py | 13 + 11 files changed, 2869 insertions(+), 386 deletions(-) create mode 100755 tests/plugins/conftest.py create mode 100755 tests/plugins/test_frontend_tkinter_rc_calibration.py create mode 100755 tests/plugins/test_renderer_3d_quadcopter.py diff --git a/ARCHITECTURE_rc_calibration.md b/ARCHITECTURE_rc_calibration.md index 2370ab207..3c559681f 100644 --- a/ARCHITECTURE_rc_calibration.md +++ b/ARCHITECTURE_rc_calibration.md @@ -16,15 +16,32 @@ an additional optional renderer module for 3D vehicle attitude visualisation. **Key Features:** -- Live RC telemetry display: stick positions (roll/pitch/throttle/yaw), flight mode, raw channels +- Live RC telemetry display: stick positions (roll/pitch/throttle/yaw) and all 16 RC input channels +- Per-channel PWM labels to the left of status bars mapped across 800-2200 µs +- Green MIN, blue TRIM and red MAX markers on every channel; matching text appears only above channel 1 - 100 ms non-blocking polling via tkinter `after()` — same pattern as accelerometer calibration - Floating, draggable live-monitor popup (`RCCalibrationPopup`) for real-time feedback -- Stick preview using `ttk.Progressbar` bars mapped from the ±1000 µs PWM range to 0–100 % -- 3D vehicle attitude visualisation delegated to `renderer_3d_quadcopter.py` +- Remote controller stick preview with two side-by-side 126×126 white squares (30% smaller than the original) + and red circles whose diameter is 1/12 of the square side +- Channel-function column: channels selected by staged/cached `RCMAP_*` show the primary controls and are fixed; + the configured `FLTMODE_CH` is marked Mode and fixed; other channels select their firmware-supported `RCx_OPTION` +- Auxiliary-function choices come from the loaded ArduPilot parameter metadata; selections stage New values and + become flight-controller writes only through the existing parameter-upload action +- Selectable RC mapping presets 1-4; selection stages `RCMAP_ROLL/PITCH/THROTTLE/YAW` in the New-value table +- Existing matching mappings select their preset automatically; custom mappings are retained and shown as Custom +- Axis labels use horizontal ↔ and vertical ↕ direction symbols +- Axis positions respect staged `RCMAP_*` and `RCn_MIN/MAX/TRIM/REVERSED`, falling back to cached FC values +- Mapping and calibration edits never write to the controller until the existing parameter upload workflow is invoked +- Scrollable embedded and popup content keeps the 16 channel rows accessible in small windows +- Compact 4-pixel frame padding, section spacing and outer margins align with the documentation frame; + the scrollbar sits at the right edge inside that margin, without an extra canvas inset +- Compact RC-driven craft monitor: proportional roll/pitch lean, continuous yaw rotation and throttle lift +- Projected 3D craft drawing delegated to `renderer_3d_quadcopter.py`, using the existing Pillow dependency - Embedded plugin view (`RCCalibrationView`) + standalone dev window (`RCCalibrationWindow`) -> **Note:** The data model reads MAVLink `RC_CHANNELS` and `HEARTBEAT` messages when connected. -> The renderer is currently a PIL-based stub; OpenGL-based attitude rendering is planned for a future commit. +> **Note:** The data model reads MAVLink `RC_CHANNELS` and `HEARTBEAT` messages from the flight-controller +> connection. The craft illustrates interpreted RC inputs, not measured vehicle attitude or actual flight. +> It is a stylized Quad-X, not a vehicle-specific mesh or a physics simulation. ## Architecture @@ -36,9 +53,11 @@ an additional optional renderer module for 3D vehicle attitude visualisation. │ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ RCCalibrationView (ttk.Frame) — embedded in parameter editor │ │ -│ │ - Stick preview progress bars (ROLL / PITCH / THROTTLE / YAW│ │ -│ │ - Flight mode label │ │ -│ │ - Channel value list │ │ +│ │ - Two compact white gimbals; red circles show stick positions│ │ +│ │ - RC mapping preset selector; staged New-value edits │ │ +│ │ - 16 RC status bars with PWM labels and function selectors │ │ +│ │ - RCMAP / flight-mode roles locked; RCx_OPTION choices staged│ │ +│ │ - Green MIN / blue TRIM / red MAX; CH1-only text legend │ │ │ │ - 100 ms after() poll → get_rc_telemetry() │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ @@ -46,7 +65,7 @@ an additional optional renderer module for 3D vehicle attitude visualisation. │ │ RCCalibrationPopup (tk.Toplevel) — floating draggable window │ │ │ │ - Borderless, transient popup window │ │ │ │ - Custom title bar with drag-to-move │ │ -│ │ - Same stick / mode / channel widgets as the embedded view │ │ +│ │ - Same stick / mapping / channel widgets as embedded view │ │ │ │ - 3D attitude preview via QuadcopterRenderer │ │ │ │ - 100 ms after() poll → get_rc_telemetry() │ │ │ └──────────────────────────────────────────────────────────────┘ │ @@ -56,9 +75,9 @@ an additional optional renderer module for 3D vehicle attitude visualisation. │ ┌─────────────────────────────▼──────────────────────────────────────┐ │ Optional Renderer (renderer_3d_quadcopter.py) │ -│ - QuadcopterRenderer: renders a PIL Image from roll/pitch/yaw/ │ -│ throttle inputs │ -│ - Currently a PIL-based stub (real OpenGL rendering is TODO) │ +│ - QuadcopterRenderer: projects procedural Quad-X faces into PIL │ +│ - Roll/pitch lean, yaw heading, throttle vertical lift │ +│ - No OpenGL, external assets or additional runtime dependencies │ └────────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────▼──────────────────────────────────────┐ @@ -71,8 +90,12 @@ an additional optional renderer module for 3D vehicle attitude visualisation. └─────────────────────────────┬──────────────────────────────────────┘ │ ┌─────────────────────────────▼──────────────────────────────────────┐ +│ ParameterEditor (data_model_parameter_editor.py) │ +│ - Owns editable New values and parameter addition/removal APIs │ +│ - Validates metadata and serializes values/reasons for upload │ +│ - Only the explicit upload workflow calls FlightController.set_param│ │ FlightController facade (backend_flightcontroller.py) │ -│ - (Integration not yet wired — planned for future commits) │ +│ - Provides telemetry and downloaded parameter cache │ └────────────────────────────────────────────────────────────────────┘ ``` @@ -81,105 +104,223 @@ an additional optional renderer module for 3D vehicle attitude visualisation. | File | Role | | ------ | ------ | | `plugin_constants.py` | `PLUGIN_RC_CALIBRATION = "rc_calibration"` | -| `data_model_rc_calibration.py` | Business logic + stub telemetry | -| `frontend_tkinter_rc_calibration.py` | `RCCalibrationPopup`, `RCCalibrationView`, `RCCalibrationWindow`, factory & registration | -| `renderer_3d_quadcopter.py` | `QuadcopterRenderer` — PIL-based 3D stub | -| `__main__.py` | `register_plugins()` wires `register_rc_calibration_plugin()` | -| `data_model_parameter_editor.py` | `create_plugin_data_model()` instantiates `RCCalibrationDataModel` | +| `data_model_rc_calibration.py` | MAVLink parsing, extrema tracking, atomic staged calibration and mapping edits | +| `frontend_tkinter_rc_calibration.py` | Shared `RCCraftPreview`, `RCStickPreview`, `RCChannelBars`, popup/view/window & factories | +| `renderer_3d_quadcopter.py` | Pure Pillow projected 3D Quad-X renderer | +| `__main__.py` | Lazy plugin registration wires `register_rc_calibration_plugin()` | +| `plugin_factory.py` | Creates the registered view and model through their factories | +| `data_model_parameter_editor.py` | Shared dispatch injects editor; owns New values and explicit upload workflow | | `configuration_steps_schema.json` | `"rc_calibration"` added to plugin name enum | +| `vehicle_templates/ArduCopter/Holybro_X500_mig/configuration_steps_ArduCopter.json` | Example step wiring for the RC calibration plugin | +| `vehicle_templates/ArduCopter/empty_4.6.x_mig/configuration_steps_ArduCopter.json` | Empty migration template step wiring | +| Both migration templates' `07_remote_controller_controller.param` | Editable Mode-2 default mapping entries | +| `tests/plugins/conftest.py` | Real editor/parameter staging fixture with mocked external I/O | +| `tests/plugins/test_data_model_rc_calibration.py` | Telemetry, atomic staging, presets, schema and explicit-upload tests | +| `tests/plugins/test_frontend_tkinter_rc_calibration.py` | Channel markers, stick geometry/modes, and embedded telemetry-update tests | +| `tests/plugins/test_renderer_3d_quadcopter.py` | Rotation directions, responsive images, lift, clamping and heading wrapping | ## Requirements Analysis ### Functional Requirements 1. **Live RC Telemetry Display** - - 🟡 Stick position progress bars (ROLL, PITCH, THROTTLE, YAW) mapped ±1000 µs → 0–100 % - - 🟡 Current flight mode label - - 🟡 Per-channel PWM value list (CH1–CH8+) - - 🟡 3D attitude preview (PIL stub; real rendering TODO) - - ❌ MAVLink `RC_CHANNELS` / `HEARTBEAT` message parsing + - ✅ Two 126×126 white square stick boxes, each with a red circle of diameter 1/12 of the square side + - ✅ Centered axes put both circles at the square centers in all four transmitter modes + - ✅ Mode selector stages all four mapping parameters and immediately remaps the retained raw sample + - ✅ Direction symbols next to axis labels: yaw/roll ↔, throttle/pitch ↕ + - ✅ Custom mappings are not silently replaced on plugin creation + - ✅ No flight-mode readout in either view; heartbeat-only polls retain RC display state + - ✅ Sixteen persistent RC status bars with adjacent PWM labels + - ✅ Primary-control function labels and edit locks follow staged/cached RCMAP assignments, including custom channels + - ✅ The configured FLTMODE_CH is labelled Mode and cannot be edited in the channel list + - ✅ Other editable channels offer the loaded firmware's RCx_OPTION choices; selecting one stages that parameter + - ✅ Locked auxiliary channels retain their option labels; mapping and edit-lock changes reconcile labels/selectors on refresh + - ✅ Green MIN, blue TRIM and red MAX markers show configured calibration even before live input + - ✅ MIN/TRIM/MAX text appears above the CH1 track only; CH2–CH16 retain markers without repetitive text + - ✅ During calibration, MIN/MAX use observed extremes; TRIM continues to show the configured value + - ✅ Invalid/missing cached limits use display-only 1000/2000/1500 µs defaults, not parameter writes + - ✅ Float-valued parameter-cache entries produce visible markers + - ✅ Shared Live monitor replaces the static Preview Stages image in embedded and popup views + - ✅ Roll/pitch deflection commands ±35° visual lean; yaw deflection drives 120°/s maximum visual rotation + - ✅ Centered yaw holds the accumulated heading, with a 4% display deadband + - ✅ Throttle maps from centered travel -1..1 to visual lift 0..1 + - ✅ No-data/heartbeat-only polls retain the last pose; yaw animation stops after 500 ms without valid RC input + - ✅ Waiting state displays a neutral example craft without inventing live status + - ✅ MAVLink `RC_CHANNELS` / `HEARTBEAT` parsing is implemented and covered by data-model tests 2. **Live Monitor Popup** - - ✅ Draggable borderless `tk.Toplevel` window - - ✅ Custom title bar with `grab_set()` modal behaviour + - 🟡 Draggable borderless `tk.Toplevel` owned by the `CalibrationPopupBase`/`BaseWindow` wrapper + - ✅ Custom title bar; popup is non-modal - ✅ 100 ms polling via `after()` (no blocking thread) - ✅ `_stop_polling()` called on `destroy()` to cancel pending callbacks 3. **Calibration Control** - - 🟡 `start_calibration()` / `cancel_calibration()` / `finish_calibration()` stubs - - ❌ MAVLink `RC_CHANNELS_RAW` trim / deadzone calibration protocol + - ✅ `start_calibration()` tracks observed channel extremes; `cancel_calibration()` discards them + - ✅ Explicit finish stages observed `RCn_MIN`, `RCn_MAX`, and midpoint `RCn_TRIM` values in the parameter editor + - ✅ Missing calibration/mapping rows are added through the existing editor API + - ✅ Complete batches are validated on copies before mutation; read-only, forced/derived and range constraints are respected + - ✅ Rejected batches retain prior New values; failed calibration staging retains measurements for retry or cancellation + - ✅ Repeated preset selection is idempotent and preserves manual-override reason markers + - ✅ Parameter table refresh uses the shared helper without downloading, saving or uploading + - ✅ Standalone development monitoring cannot write parameters without a staging editor 4. **Plugin Registration** - ✅ `PLUGIN_RC_CALIBRATION` constant in `plugin_constants.py` - ✅ `register_rc_calibration_plugin()` wired into `register_plugins()` - - ✅ `create_plugin_data_model()` branch in `data_model_parameter_editor.py` + - ✅ model factory registered through `PluginFactory` and invoked by shared parameter-editor dispatch - ✅ Schema enum updated in `configuration_steps_schema.json` ### Non-Functional Requirements 1. **Safety** - - No motor or actuator commands are issued; display-only plugin + - No motor or actuator commands are issued. The plugin never sends parameters directly to the flight controller. + - Mapping and calibration only edit New values. Upload and readback use the existing parameter-editor workflow. + - Mapping changes require a controller reboot after upload; the selector tooltip and success status explain this. 2. **Usability** - ✅ Draggable popup for flexible screen placement - ✅ No-data warning after 50 consecutive empty polls (`_no_telemetry_warning_emitted`) - - ✅ Graceful degradation when not connected (data model returns stub data) + - ✅ Disconnected/no-input telemetry does not invent live PWM values; configured/default markers remain visible + - ✅ Heartbeat-only updates preserve the last channel bars and stick positions + - ✅ Live monitor, remote controller stick preview and Channels use 4-pixel padding and gaps with no extra scroll-canvas inset + - ✅ Embedded and popup scrollbar use compact 4-pixel outer margins 3. **Reliability** - ✅ `_stop_polling()` prevents dangling `after()` callbacks after widget destruction - - 🟡 Error handling for MAVLink communication (planned) + - ✅ MAVLink read exceptions are caught and logged; staging errors are reported in the view ## Data Flow -### Telemetry Polling (Current Stub) +### Telemetry Polling ```text RCCalibrationView / RCCalibrationPopup └─ after(100 ms) → _check_telemetry() └─ model.get_rc_telemetry() - └─ returns dict {roll, pitch, throttle, yaw, flight_mode, channels} - (stub: always returns dummy 1500 µs values) - ├─ Update stick progress bars (±1000 → 0–100 %) - ├─ Update flight mode label - ├─ Rebuild channel list widgets - └─ renderer.render(roll_norm, pitch_norm, yaw_norm, throttle_norm) - └─ returns PIL.Image → displayed in preview_label + └─ reads non-blocking RC_CHANNELS / HEARTBEAT messages from the MAVLink master + └─ returns roll, pitch, throttle, yaw, flight_mode, and CH1–CH16 + (invalid or unreported channel values are None) + ├─ Map RCMAP-selected axes using staged/cached endpoints and reversal to -1000..1000 + │ └─ Mode 1-4 maps axes to two square gimbals; draw circles inside square boundaries + ├─ Update 16 persistent status bars and numeric PWM labels + │ └─ draw configured/default MIN/MAX/TRIM; substitute observed MIN/MAX during calibration + │ └─ update RCMAP/FLTMODE_CH function labels and RCx_OPTION selectors + └─ craft_preview.update_telemetry(telemetry), including empty and heartbeat-only polls + ├─ retain latest axes, integrate yaw heading with monotonic elapsed time while RC is fresh + └─ renderer.render(roll_norm, pitch_norm, heading_degrees, throttle_lift) + └─ projected PIL craft → displayed in craft_preview.image_label ``` -### Planned MAVLink Integration +### Remote Controller Stick Preview Mapping + +The two 126×126 stick boxes are 30% smaller than the original 180×180 preview. The selector applies application +mapping presets by editing ArduPilot's `RCMAP_*` parameters +(not `RC_MAP_*`). The presets assume physical channel slots corresponding to a conventional Mode-2 +layout: right horizontal CH1, right vertical CH2, left vertical CH3, left horizontal CH4. +That convention is explicitly stated in the tooltip; verify channel movement against the transmitter before upload. +Physical transmitter mode cannot be inferred uniquely from RCMAP: a transmitter can emit any channel order. +Custom assignments remain editable directly in the parameter table and are shown as Custom in the selector. +Their preview uses the last selected physical layout (Mode 2 initially) with the custom channel mapping. +Arbitrary transmitter mixes cannot be reconstructed from four channel inputs. + +| Mode | Left horizontal | Left vertical | Right horizontal | Right vertical | +| ---- | --------------- | ------------- | ---------------- | -------------- | +| 1 | Yaw | Pitch | Roll | Throttle | +| 2 | Yaw | Throttle | Roll | Pitch | +| 3 | Roll | Pitch | Yaw | Throttle | +| 4 | Roll | Throttle | Yaw | Pitch | + +| Preset | `RCMAP_ROLL` | `RCMAP_PITCH` | `RCMAP_THROTTLE` | `RCMAP_YAW` | +| ------ | ------------ | ------------- | ---------------- | ----------- | +| 1 | 1 | 3 | 2 | 4 | +| 2 | 1 | 2 | 3 | 4 | +| 3 | 4 | 3 | 2 | 1 | +| 4 | 4 | 2 | 3 | 1 | + +The registered model factory injects `context.parameter_editor` alongside the flight controller. +Preview calculations overlay current-step New values on a copy of the downloaded FC cache. +Selection does not modify that cache or call `set_param()`. After staging, the frontend refreshes the +New-value table and recomputes axes from the retained raw sample without awaiting another RC packet. + +Positive yaw/roll moves the circle right. Positive throttle moves it up. Positive pitch moves it down +(pulling the pitch stick back). Roll/pitch/yaw use the configured neutral trim as center and normalise each +side independently. Throttle uses the endpoint midpoint for physical stick center: `RCn_TRIM` can be at low +throttle and is displayed as a separate marker rather than used as throttle-stick center. +Inputs are clamped to the square's travel range, keeping the entire red circle within its white box. + +### Calibration Data Flow ```text -RCCalibrationDataModel.get_rc_telemetry() - └─ flight_controller.master.recv_match(type="RC_CHANNELS", blocking=False) - └─ parse chan1_raw … chan18_raw (µs PWM values) - └─ flight_controller.master.recv_match(type="HEARTBEAT", blocking=False) - └─ parse custom_mode → flight mode string +Start Calibration + └─ clear prior extrema and enable tracking + └─ each valid RC_CHANNELS sample updates per-channel minimum and maximum + └─ view renders observed MIN in green and MAX in red; configured TRIM remains blue +Finish Calibration + └─ validate and stage RCn_MIN / RCn_MAX / RCn_TRIM in ParameterEditor.current_step_parameters + └─ refresh New values and markers; status asks the user to press parameter upload +Parameter upload (existing editor workflow) + └─ serialize selected New values and reasons → FlightController.set_param() + └─ existing reset/reconnect/readback verification workflow +Cancel Calibration + └─ disable tracking and discard the observed extrema without writing parameters ``` ## Renderer Module (`renderer_3d_quadcopter.py`) -`QuadcopterRenderer` is an **optional auxiliary module** specific to the RC calibration -plugin. Other plugins do not have a dedicated renderer; this pattern was introduced here -for future 3D attitude visualisation. +`QuadcopterRenderer` is a pure, UI-independent **auxiliary module** specific to the RC calibration plugin. +It projects independently generated Quad-X body/stack faces, arms, motor rings, propellers and a nose marker +from a low rear chase view (17° elevation). Depth-sorted polygons are drawn with Pillow. +Orange front accents, cyan rear accents, a dark background and a fixed floor/shadow provide direction and lift cues. +No Three.js, OpenGL context, GLTF files or new dependency is needed. -**Current implementation:** PIL-based stub that draws a schematic quadcopter shape -(rectangle body + four arm lines) ignoring the attitude inputs. +The visual behavior was compared with the local `arduconfigurator` receiver monitor: -**Planned implementation:** Off-screen OpenGL rendering capturing the framebuffer into a -`PIL.Image` to show real roll/pitch/yaw attitude. +- `apps/web/src/sections/ReceiverSection.tsx`: compact live craft card beside channel bars +- `apps/web/src/preview-components.tsx`: `StickCraftPreview` calibrated lean, yaw-rate integration and throttle lift +- `apps/web/src/flight-deck-preview.tsx`: low rear chase camera and compact craft styling + +This is a Tk/Pillow analogue, not a copy of its Three.js renderer or a full Receiver workbench port. +The existing vertically stacked plugin layout is preserved. Only the Preview Stages section is replaced. + +`RCCraftPreview` retains axes and heading, renders on the existing 100 ms polling loop, and uses monotonic time +for yaw integration. Elapsed time is capped at 250 ms to prevent large jumps after UI delays. +RC freshness expires after 500 ms; heartbeat packets do not refresh it. There is no additional timer or thread. +Destroying the embedded view/popup cancels the existing polling timer. ### API ```python renderer = QuadcopterRenderer(width=400, height=200) -img: PIL.Image.Image = renderer.render(roll, pitch, yaw, throttle) -# roll/pitch/yaw in normalised range -1.0 … 1.0; throttle 0.0 … 1.0 +img: PIL.Image.Image = renderer.render(roll, pitch, yaw_heading_degrees, throttle_lift) +# roll/pitch normalised -1.0 … 1.0; heading degrees; throttle lift 0.0 … 1.0 ``` +## Validation Notes + +- Unit tests cover telemetry, bounds, extrema tracking, cancellation, all four mapping presets, + protected/range-invalid batches, missing rows, manual overrides, idempotency and explicit-upload integration. +- Tk tests cover all 16 rows, float-valued MIN/MAX/TRIM markers, two white squares, centered circles, + the 1/12 diameter ratio, all four stick modes, staged combobox changes, direction symbols, + finish-button staging, custom-mapping errors, table refresh and heartbeat-only telemetry updates. +- Channel tests cover custom staged/cached mapping protection, the locked FLTMODE_CH row, metadata-driven RCx_OPTION choices, + locked auxiliary labels, live label/selector transitions, manual overrides, adding missing option rows, and staging without FC writes. +- Tests verify all four axis arrows, marker colors, CH1-only labels above the track, retained craft pose, + live/no-data status, yaw rate/deadband/stale-input behavior and pure renderer rotation directions. +- Layout tests verify compact margins, section padding, right-aligned scrollbar and removal of the flight-mode readout. +- The embedded view is instantiated with a real Tk widget hierarchy to verify initial markers and polling updates. +- The plugin is registered globally, but a configuration step must reference `rc_calibration` for the view to appear; + both `*mig` templates now provide step wiring and editable mapping entries. +- The empty migration template previously inherited the package step JSON. Its new local JSON preserves that + definition except for the RC controller step, which now includes the plugin and mapping declarations. +- Existing migrated projects acquire missing RCMAP rows from current FC values when the controller step opens; + filename/format migration does not invent mapping defaults or overwrite custom mappings. +- No live flight-controller or SITL validation is claimed; the craft remains an illustrative Quad-X rather than measured attitude. +- New UI strings use gettext. Catalog regeneration requires `pygettext3`, which is absent in this environment. + ## Popup Window (`RCCalibrationPopup`) -`RCCalibrationPopup` is a `tk.Toplevel` subclass that provides an always-on-top, -draggable monitoring window. It shares the same telemetry polling loop and widgets as +`RCCalibrationPopup` inherits `CalibrationPopupBase`, which owns a transient `tk.Toplevel` through `BaseWindow`. +It shares the same telemetry polling loop and widgets as `RCCalibrationView` but is designed to float above the main AMC window. **Key design points:** @@ -190,7 +331,7 @@ draggable monitoring window. It shares the same telemetry polling loop and widg - The popup is intentionally non-modal so the user can continue interacting with the main AMC window while monitoring RC input. - `_start_move` / `_do_move` bindings on the title bar implement drag-to-move via `winfo_x()` + event delta. -- `_stop_polling()` cancels the `after()` job before `super().destroy()` to prevent +- `_stop_polling()` cancels the `after()` job before the owned window is destroyed to prevent callbacks firing on a destroyed widget. ## Mission Planner Reference @@ -201,3 +342,10 @@ for telemetry and `PARAM_SET` / `PARAM_VALUE` for storing trim values - - + +### Verified Mapping Parameter Reference + +The upstream [ArduCopter 4.6.3 RCMapper implementation](https://github.com/ArduPilot/ardupilot/blob/Copter-4.6.3/libraries/AP_RCMapper/AP_RCMapper.cpp) +defines roll/pitch/throttle/yaw mapping defaults 1/2/3/4, range 1–16, and `RebootRequired: True`. +This verifies availability for the migration templates' 4.6.x firmware target; no new version gate is introduced. +The physical-slot presets above are application conventions, not ArduPilot-defined transmitter mode parameters. diff --git a/ardupilot_methodic_configurator/configuration_steps_ArduCopter.json b/ardupilot_methodic_configurator/configuration_steps_ArduCopter.json index 5aab80811..5e7b6b90d 100644 --- a/ardupilot_methodic_configurator/configuration_steps_ArduCopter.json +++ b/ardupilot_methodic_configurator/configuration_steps_ArduCopter.json @@ -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)" } }, diff --git a/ardupilot_methodic_configurator/data_model_parameter_editor.py b/ardupilot_methodic_configurator/data_model_parameter_editor.py index d7278fbd6..cf90f6ba5 100644 --- a/ardupilot_methodic_configurator/data_model_parameter_editor.py +++ b/ardupilot_methodic_configurator/data_model_parameter_editor.py @@ -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, @@ -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 diff --git a/ardupilot_methodic_configurator/plugins/data_model_rc_calibration.py b/ardupilot_methodic_configurator/plugins/data_model_rc_calibration.py index 6aeb4a047..0f4c90085 100644 --- a/ardupilot_methodic_configurator/plugins/data_model_rc_calibration.py +++ b/ardupilot_methodic_configurator/plugins/data_model_rc_calibration.py @@ -6,32 +6,313 @@ SPDX-License-Identifier: GPL-3.0-or-later """ +from collections.abc import Mapping +from copy import deepcopy from logging import debug as logging_debug from logging import info as logging_info from logging import warning as logging_warning -from typing import Any +from math import isfinite +from typing import TYPE_CHECKING, Any from ardupilot_methodic_configurator import _ from ardupilot_methodic_configurator.backend_flightcontroller import FlightController +from ardupilot_methodic_configurator.data_model_parameter_editor import ( + InvalidParameterNameError, + OperationNotPossibleError, + ParameterEditor, + ParameterValueUpdateStatus, +) + +if TYPE_CHECKING: + from ardupilot_methodic_configurator.data_model_ardupilot_parameter import ArduPilotParameter _RC_INVALID_PWM = 65535 # MAVLink sentinel: channel not available _RC_CENTER_PWM = 1500 # Default center PWM value -_RC_MAX_CHANNELS = 18 # ArduPilot supports up to 18 RC channels +_RC_DISPLAY_CHANNELS = 16 + +# Each pair is (horizontal axis, vertical axis), ordered left stick then right stick. +RC_STICK_MODES: dict[int, tuple[tuple[str, str], tuple[str, str]]] = { + 1: (("yaw", "pitch"), ("roll", "throttle")), + 2: (("yaw", "throttle"), ("roll", "pitch")), + 3: (("roll", "pitch"), ("yaw", "throttle")), + 4: (("roll", "throttle"), ("yaw", "pitch")), +} + +# Presets assume Mode-2 physical slots: right horizontal=1, right vertical=2, +# left vertical=3, left horizontal=4. RCMAP cannot identify a transmitter's physical mode. +RC_MAP_PRESETS: dict[int, dict[str, int]] = { + 1: {"RCMAP_ROLL": 1, "RCMAP_PITCH": 3, "RCMAP_THROTTLE": 2, "RCMAP_YAW": 4}, + 2: {"RCMAP_ROLL": 1, "RCMAP_PITCH": 2, "RCMAP_THROTTLE": 3, "RCMAP_YAW": 4}, + 3: {"RCMAP_ROLL": 4, "RCMAP_PITCH": 3, "RCMAP_THROTTLE": 2, "RCMAP_YAW": 1}, + 4: {"RCMAP_ROLL": 4, "RCMAP_PITCH": 2, "RCMAP_THROTTLE": 3, "RCMAP_YAW": 1}, +} class RCCalibrationDataModel: """Data model for RC calibration, backed by MAVLink RC_CHANNELS telemetry.""" - def __init__(self, flight_controller: FlightController) -> None: + def __init__(self, flight_controller: FlightController, parameter_editor: ParameterEditor | None = None) -> None: self.flight_controller = flight_controller + self._parameter_editor = parameter_editor self._is_calibrating = False self._channel_min: dict[int, int] = {} # 0-based channel index → observed minimum PWM self._channel_max: dict[int, int] = {} # 0-based channel index → observed maximum PWM + self._last_raw: list[int] = [] + self._last_channel_count = 0 + + def _preview_parameters(self) -> dict[str, float]: + """Overlay staged RC values without modifying the downloaded flight-controller cache.""" + parameters = dict(self.flight_controller.fc_parameters) + if self._parameter_editor is not None: + for name, parameter in self._parameter_editor.current_step_parameters.items(): + if name.startswith(("RCMAP_", "RC")) or name in ("FLTMODE_CH", "MODE_CH"): + parameters[name] = parameter.get_new_value() + return parameters + + def get_mapping_mode(self) -> int | None: + """Identify a matching mapping preset, or None for a custom channel assignment.""" + parameters = self._preview_parameters() + return next( + ( + mode + for mode, preset in RC_MAP_PRESETS.items() + if all(parameters.get(name) == value for name, value in preset.items()) + ), + None, + ) + + def set_mapping_mode(self, mode: int) -> tuple[bool, str]: + """Stage a complete RCMAP preset; never send PARAM_SET or alter the FC cache.""" + if mode not in RC_MAP_PRESETS: + return False, _("Select an RC mapping mode from 1 to 4.") + success, message = self._stage_parameters( + RC_MAP_PRESETS[mode], _("RC mapping preset Mode %(mode)d selected in the RC calibration plugin.") % {"mode": mode} + ) + if success: + message += " " + _("RC mapping changes require a flight-controller reboot after upload.") + return success, message + + def _stage_parameters(self, values: Mapping[str, int], reason: str) -> tuple[bool, str]: + """Validate the whole edit before changing New values, respecting metadata and edit locks.""" + editor = self._parameter_editor + if editor is None: + return False, _("Open RC calibration in the parameter editor to stage changes for upload.") + added: list[str] = [] + changes: list[tuple[ArduPilotParameter, int]] = [] + error = "" + for name, value in values.items(): + if name not in editor.current_step_parameters: + try: + if not editor.add_parameter_to_current_file(name): + error = _("Could not add parameter %(name)s to this step.") % {"name": name} + break + added.append(name) + except (InvalidParameterNameError, OperationNotPossibleError) as exc: + error = str(exc) + break + parameter = editor.current_step_parameters[name] + if not parameter.is_editable: + error = _("Parameter %(name)s is read-only, forced or derived; no values were changed.") % {"name": name} + break + result = ParameterEditor.update_parameter_object(deepcopy(parameter), str(value)) + if result.status not in (ParameterValueUpdateStatus.UPDATED, ParameterValueUpdateStatus.UNCHANGED): + error = result.message or _("Invalid value for parameter %(name)s.") % {"name": name} + break + if result.status == ParameterValueUpdateStatus.UPDATED: + changes.append((parameter, value)) + if error: + for name in added: + editor.delete_parameter_from_current_file(name) + return False, error + for parameter, value in changes: + parameter.set_new_value(str(value)) + parameter.set_change_reason(reason) + return True, _("Changes staged. Press parameter upload to write them to the flight controller.") + + def get_preview_axes(self) -> dict[str, float]: + """Remap the retained raw input immediately after staged mapping/calibration edits.""" + return { + axis: self._normalised_axis(self._last_raw, self._last_channel_count, axis, channel) + for channel, axis in enumerate(("roll", "pitch", "throttle", "yaw"), start=1) + } def is_connected(self) -> bool: """Return True when a MAVLink connection is available.""" return self.flight_controller.master is not None + @staticmethod + def channel_limits(parameters: Mapping[str, float], channel_number: int) -> tuple[int, int, int]: + """Return configured MIN/MAX/TRIM, using display-only defaults for missing or invalid parameters.""" + minimum = parameters.get(f"RC{channel_number}_MIN", 1000) + maximum = parameters.get(f"RC{channel_number}_MAX", 2000) + if not (isfinite(minimum) and isfinite(maximum) and 0 < minimum < maximum < _RC_INVALID_PWM): + minimum, maximum = 1000, 2000 + minimum, maximum = round(minimum), round(maximum) + if minimum >= maximum: + minimum, maximum = 1000, 2000 + trim = parameters.get(f"RC{channel_number}_TRIM", (minimum + maximum) / 2) + if not isfinite(trim) or not minimum <= trim <= maximum: + trim = (minimum + maximum) / 2 + return round(minimum), round(maximum), round(trim) + + def get_channel_calibration(self) -> list[dict[str, Any]]: + """Return channel markers, assigned functions, and available RC option choices.""" + parameters = self._preview_parameters() + channel_functions = self._channel_functions(parameters) + channels: list[dict[str, Any]] = [] + for index in range(_RC_DISPLAY_CHANNELS): + channel_number = index + 1 + minimum, maximum, trim = self.channel_limits(parameters, channel_number) + option_value, choices, option_editable, option_label = self._channel_option_state(channel_number, parameters) + channels.append( + { + "name": f"CH{channel_number}", + "value": None, + "min": self._channel_min.get(index, minimum), + "max": self._channel_max.get(index, maximum), + "trim": trim, + "function": channel_functions.get(channel_number, ""), + "function_editable": option_editable, + "option_label": option_label, + "option_value": option_value, + "option_choices": choices, + } + ) + return channels + + def _channel_option_state( + self, channel_number: int, parameters: Mapping[str, float] + ) -> tuple[float, dict[str, str], bool, str]: + """Return the live RCx_OPTION value, loaded choices, editability and display label.""" + option_name = f"RC{channel_number}_OPTION" + option_parameter = ( + self._parameter_editor.current_step_parameters.get(option_name) if self._parameter_editor is not None else None + ) + choices = self._channel_option_choices(option_name) + option_value = option_parameter.get_new_value() if option_parameter is not None else parameters.get(option_name, 0) + is_reserved = channel_number in self._reserved_channels(parameters) + option_editable = not is_reserved and (option_parameter is None or option_parameter.is_editable) and bool(choices) + option_label = next( + (label for key, label in choices.items() if self._option_key_matches(key, option_value)), + str(round(option_value)) if isfinite(option_value) else "", + ) + return option_value, choices, option_editable, option_label + + def _channel_option_choices(self, option_name: str) -> dict[str, str]: + """Return the same loaded firmware choices used for display and staging.""" + editor = self._parameter_editor + if editor is None: + return {} + parameter = editor.current_step_parameters.get(option_name) + if parameter is not None and parameter.choices_dict: + return parameter.choices_dict + return editor.get_parameter_choices(option_name) + + @staticmethod + def _flight_mode_channel(parameters: Mapping[str, float]) -> int: + """Return the configured flight-mode channel, honoring Rover's MODE_CH parameter.""" + parameter_name = "MODE_CH" if "MODE_CH" in parameters else "FLTMODE_CH" + default_channel = 8 if parameter_name == "MODE_CH" else 5 + value = parameters.get(parameter_name, default_channel) + if isfinite(value) and value == int(value) and 0 <= value <= _RC_DISPLAY_CHANNELS: + return int(value) + return default_channel + + @staticmethod + def _option_key_matches(key: str, option_value: float) -> bool: + """Compare a parameter metadata enum key to its numeric value.""" + try: + return float(key) == option_value + except (TypeError, ValueError): + return False + + @staticmethod + def _mapped_control_channels(parameters: Mapping[str, float]) -> dict[str, int]: + """Resolve valid primary-control channels from the effective mapping.""" + mapped_channels: dict[str, int] = {} + default_channels = {"ROLL": 1, "PITCH": 2, "THROTTLE": 3, "YAW": 4} + for axis, default_channel in default_channels.items(): + value = parameters.get(f"RCMAP_{axis}", default_channel) + if isfinite(value) and value == int(value) and 1 <= value <= _RC_DISPLAY_CHANNELS: + mapped_channels[axis] = int(value) + return mapped_channels + + @classmethod + def _reserved_channels(cls, parameters: Mapping[str, float]) -> set[int]: + """Share primary-control and flight-mode protection between display and staging.""" + channels = set(cls._mapped_control_channels(parameters).values()) + flight_mode_channel = cls._flight_mode_channel(parameters) + if flight_mode_channel: + channels.add(flight_mode_channel) + return channels + + @classmethod + def _channel_functions(cls, parameters: Mapping[str, float]) -> dict[int, str]: + """Map channels to their staged RCMAP function and identify the flight-mode channel.""" + channel_functions: dict[int, list[str]] = {} + for axis, channel in cls._mapped_control_channels(parameters).items(): + channel_functions.setdefault(channel, []).append(_(axis.title())) + + flight_mode_channel = cls._flight_mode_channel(parameters) + channel_functions.setdefault(flight_mode_channel, []).append(_("Mode")) + return {channel: " / ".join(functions) for channel, functions in channel_functions.items()} + + def set_channel_option(self, channel_number: int, option_value: str) -> tuple[bool, str]: + """Stage RCx_OPTION; primary-control and flight-mode channel functions stay locked.""" + parameters = self._preview_parameters() + if not 1 <= channel_number <= _RC_DISPLAY_CHANNELS: + return False, _("Select an RC channel from 1 to 16.") + if channel_number in self._reserved_channels(parameters): + return False, _("The primary-control and flight-mode channel functions cannot be changed here.") + try: + value = int(option_value) + except ValueError: + return False, _("Select a valid RC channel option.") + if self._parameter_editor is not None and not any( + self._option_key_matches(key, value) for key in self._channel_option_choices(f"RC{channel_number}_OPTION") + ): + return False, _("Select a valid RC channel option.") + return self._stage_parameters( + {f"RC{channel_number}_OPTION": value}, + _("RC channel %(channel)d option selected in the RC calibration plugin.") % {"channel": channel_number}, + ) + + @staticmethod + def stick_positions(axes: Mapping[str, float], mode: int) -> tuple[tuple[float, float], tuple[float, float]]: + """ + Return left/right stick positions in the range -1..1 for transmitter Mode 1-4. + + Positive horizontal values mean right and positive vertical values mean up. + Pitch is inverted because positive pitch is commanded by pulling the stick back. + """ + positions: list[tuple[float, float]] = [] + for horizontal_axis, vertical_axis in RC_STICK_MODES[mode]: + horizontal = axes.get(horizontal_axis, 0.0) / 1000 + vertical = axes.get(vertical_axis, 0.0) / 1000 + if vertical_axis == "pitch": + vertical = -vertical + positions.append((max(-1.0, min(1.0, horizontal)), max(-1.0, min(1.0, vertical)))) + return positions[0], positions[1] + + def _normalised_axis(self, raw: list[int], channel_count: int, axis: str, default_channel: int) -> float: + """Map RCMAP-selected PWM to stick travel, respecting configured endpoints and reversal.""" + parameters = self._preview_parameters() + channel = parameters.get(f"RCMAP_{axis.upper()}", default_channel) + if not isfinite(channel) or channel != int(channel) or not 1 <= channel <= channel_count: + return 0.0 + channel_number = int(channel) + value = raw[channel_number - 1] + if value in (0, _RC_INVALID_PWM): + return 0.0 + minimum, maximum, trim = self.channel_limits(parameters, channel_number) + # Physical throttle-stick center is the endpoint midpoint, not RCn_TRIM (which may be low throttle). + center = trim if axis != "throttle" and minimum < trim < maximum else (minimum + maximum) / 2 + span = maximum - center if value >= center else center - minimum + position = (value - center) / span * 1000 + if parameters.get(f"RC{channel_number}_REVERSED", 0) == 1: + position = -position + return max(-1000.0, min(1000.0, position)) + def start_calibration(self) -> tuple[bool, str]: """Start tracking per-channel min/max PWM values for calibration.""" if not self.is_connected(): @@ -51,39 +332,44 @@ def cancel_calibration(self) -> tuple[bool, str]: logging_info(_("RC calibration cancelled")) return True, "" - def finish_calibration(self) -> None: + def finish_calibration(self) -> tuple[bool, str]: """ - Write observed min/max values as RCn_MIN / RCn_MAX / RCn_TRIM to the FC. + Stage observed RCn_MIN / RCn_MAX / RCn_TRIM for the explicit parameter upload. The trim is computed as the midpoint between the observed extremes. - Only channels that received at least one valid reading are written. + Only channels that moved during calibration are staged. + Failed staging retains the observations for retry or cancellation. """ self._is_calibrating = False if not self._channel_min: logging_warning(_("No RC calibration data recorded — nothing to save")) - return + return False, _("No RC calibration data recorded — nothing to save") + values: dict[str, int] = {} for ch_idx, min_val in self._channel_min.items(): ch_num = ch_idx + 1 # convert to 1-based RC channel number max_val = self._channel_max.get(ch_idx, _RC_CENTER_PWM * 2 - min_val) + if ch_idx in self._channel_max and min_val == max_val: + logging_info(_("RC%(ch)d did not move; calibration values were not staged"), {"ch": ch_num}) + continue # Use a symmetric fallback only when the maximum was never observed. trim_val = (min_val + max_val) // 2 for suffix, value in (("MIN", min_val), ("MAX", max_val), ("TRIM", trim_val)): - result = self.flight_controller.set_param(f"RC{ch_num}_{suffix}", float(value)) - if isinstance(result, tuple) and len(result) == 2: - success, error_msg = result - else: - success, error_msg = True, "" - if not success: - logging_warning( - _("Failed to set RC%(ch)d_%(suffix)s: %(error)s"), - {"ch": ch_num, "suffix": suffix, "error": error_msg}, - ) + values[f"RC{ch_num}_{suffix}"] = value logging_info( _("RC%(ch)d: MIN=%(min)d MAX=%(max)d TRIM=%(trim)d"), {"ch": ch_num, "min": min_val, "max": max_val, "trim": trim_val}, ) - self._channel_min.clear() - self._channel_max.clear() + if not values: + self._channel_min.clear() + self._channel_max.clear() + message = _("No RC channels moved; no calibration parameters were staged.") + logging_info(message) + return True, message + success, message = self._stage_parameters(values, _("Measured in the RC calibration plugin.")) + if success: + self._channel_min.clear() + self._channel_max.clear() + return success, message def get_rc_telemetry(self) -> dict[str, Any]: """ @@ -95,10 +381,9 @@ def get_rc_telemetry(self) -> dict[str, Any]: Returns an empty dict when not connected or when no message is available yet, which signals the GUI to keep waiting. - The stick values (roll / pitch / throttle / yaw) are mapped from - the PWM range 1000-2000 us to the normalised range -1000 ... +1000 - using the default ArduPilot channel assignment: - CH1 = roll, CH2 = pitch, CH3 = throttle, CH4 = yaw. + Stick values use staged RCMAP_* and RCn_MIN/MAX/REVERSED, falling back to the parameter cache. + Trim maps to zero for roll/pitch/yaw; the endpoint midpoint does so for throttle. Defaults are + CH1=roll, CH2=pitch, CH3=throttle, CH4=yaw and a 1000-2000 us range. """ if self.flight_controller.master is None: return {} @@ -115,7 +400,7 @@ def get_rc_telemetry(self) -> dict[str, Any]: ) if latest_rc_msg: - n_channels = min(latest_rc_msg.chancount, _RC_MAX_CHANNELS) + n_channels = max(0, min(latest_rc_msg.chancount, _RC_DISPLAY_CHANNELS)) raw: list[int] = [ latest_rc_msg.chan1_raw, latest_rc_msg.chan2_raw, @@ -133,27 +418,24 @@ def get_rc_telemetry(self) -> dict[str, Any]: latest_rc_msg.chan14_raw, latest_rc_msg.chan15_raw, latest_rc_msg.chan16_raw, - latest_rc_msg.chan17_raw, - latest_rc_msg.chan18_raw, - ] - telemetry["channels"] = [ - {"name": f"CH{i + 1}", "value": raw[i]} for i in range(n_channels) if raw[i] != _RC_INVALID_PWM ] - # Default channel-to-axis mapping (CH1=roll, CH2=pitch, CH3=throttle, CH4=yaw) - for idx, axis in enumerate(["roll", "pitch", "throttle", "yaw"]): - if idx < n_channels and raw[idx] != _RC_INVALID_PWM: - # Map PWM 1000-2000 us -> -1000 ... +1000 - telemetry[axis] = float((raw[idx] - _RC_CENTER_PWM) * 2) - else: - telemetry[axis] = 0.0 + self._last_raw = raw + self._last_channel_count = n_channels + telemetry.update(self.get_preview_axes()) if self._is_calibrating: for i in range(n_channels): - if raw[i] != _RC_INVALID_PWM: + if raw[i] not in (0, _RC_INVALID_PWM): self._channel_min[i] = min(self._channel_min.get(i, raw[i]), raw[i]) self._channel_max[i] = max(self._channel_max.get(i, raw[i]), raw[i]) + channels = self.get_channel_calibration() + for index in range(n_channels): + if raw[index] not in (0, _RC_INVALID_PWM): + channels[index]["value"] = raw[index] + telemetry["channels"] = channels + if latest_hb: telemetry["flight_mode"] = str(latest_hb.custom_mode) except Exception as exc: # pylint: disable=broad-exception-caught diff --git a/ardupilot_methodic_configurator/plugins/frontend_tkinter_rc_calibration.py b/ardupilot_methodic_configurator/plugins/frontend_tkinter_rc_calibration.py index 0d00cf821..c379762a8 100644 --- a/ardupilot_methodic_configurator/plugins/frontend_tkinter_rc_calibration.py +++ b/ardupilot_methodic_configurator/plugins/frontend_tkinter_rc_calibration.py @@ -10,12 +10,17 @@ import tkinter as tk from argparse import ArgumentParser, Namespace +from collections.abc import Callable from contextlib import suppress +from functools import partial from logging import debug as logging_debug from logging import error as logging_error from logging import warning as logging_warning +from math import isfinite +from time import monotonic from tkinter import ttk from tkinter.messagebox import showerror +from typing import Any, Literal from PIL import ImageTk @@ -23,13 +28,433 @@ from ardupilot_methodic_configurator.common_arguments import add_common_arguments from ardupilot_methodic_configurator.frontend_tkinter_base_window import BaseWindow from ardupilot_methodic_configurator.frontend_tkinter_calibration_popup_base import CalibrationPopupBase -from ardupilot_methodic_configurator.plugins.data_model_rc_calibration import RCCalibrationDataModel +from ardupilot_methodic_configurator.frontend_tkinter_scroll_frame import ScrollFrame +from ardupilot_methodic_configurator.frontend_tkinter_show import show_tooltip +from ardupilot_methodic_configurator.plugins.data_model_rc_calibration import RC_STICK_MODES, RCCalibrationDataModel +from ardupilot_methodic_configurator.plugins.frontend_tkinter_helpers import refresh_parameter_editor_table from ardupilot_methodic_configurator.plugins.plugin_constants import PLUGIN_RC_CALIBRATION from ardupilot_methodic_configurator.plugins.plugin_factory import PluginModelContext, plugin_factory from ardupilot_methodic_configurator.plugins.renderer_3d_quadcopter import QuadcopterRenderer +_RC_DISPLAY_MIN_PWM = 800 +_RC_DISPLAY_MAX_PWM = 2200 +_RC_CHANNEL_COUNT = 16 +_RC_STICK_SQUARE_SIZE = 126 +_RC_CHANNEL_BAR_WIDTH = 200 +_RC_MARKER_COLORS = {"min": "green", "trim": "blue", "max": "red"} +_RC_FRAME_PADDING = 4 + + +class RCCraftPreview(ttk.LabelFrame): # pylint: disable=too-many-ancestors,too-many-instance-attributes + """Shared RC-driven craft preview with proportional lean, yaw rate and throttle lift.""" + + def __init__(self, parent: tk.Misc) -> None: + super().__init__(parent, text=_("Live monitor"), padding=_RC_FRAME_PADDING) + self.renderer = QuadcopterRenderer(width=360, height=200) + self._axes = dict.fromkeys(("roll", "pitch", "yaw", "throttle"), 0.0) + self._heading = 0.0 + self._last_frame_time = monotonic() + self._last_rc_time: float | None = None + self._channel_count = 0 + self.status_label = ttk.Label(self, text=_("No RC telemetry"), foreground="gray") + self.status_label.pack(anchor="w") + self.image_label = ttk.Label(self) + self.image_label.pack(pady=5) + ttk.Label(self, text=_("RC input preview — not measured attitude."), wraplength=360).pack() + show_tooltip( + self.image_label, + _( + "Roll and pitch tilt the example craft; yaw turns it while held; throttle raises or lowers it. " + "Uses staged RC mapping, calibration and reversal. No vehicle movement is commanded." + ), + ) + self.update_telemetry({}) + + def update_telemetry(self, telemetry: dict[str, Any]) -> None: + """Render one polling frame; retain RC inputs on heartbeat and stop yaw after telemetry becomes stale.""" + now = monotonic() + elapsed = max(0.0, min(0.25, now - self._last_frame_time)) + self._last_frame_time = now + for axis in self._axes: + value = telemetry.get(axis) + if isinstance(value, (int, float)) and isfinite(value): + self._axes[axis] = max(-1.0, min(1.0, value / 1000)) + if "channels" in telemetry: + self._channel_count = sum(channel.get("value") is not None for channel in telemetry["channels"]) + self._last_rc_time = now if self._channel_count else None + live = self._last_rc_time is not None and now - self._last_rc_time <= 0.5 + if live and abs(self._axes["yaw"]) > 0.04: + self._heading = (self._heading + self._axes["yaw"] * 120 * elapsed) % 360 + self.status_label.configure( + text=_("%(count)d channels live") % {"count": self._channel_count} if live else _("No RC telemetry"), + foreground="green" if live else "gray", + ) + image = self.renderer.render(self._axes["roll"], self._axes["pitch"], self._heading, (self._axes["throttle"] + 1) / 2) + self.image_label.image = ImageTk.PhotoImage(image, master=self) # type: ignore[attr-defined] + self.image_label.configure(image=self.image_label.image) # type: ignore[attr-defined] + + +class RCStickPreview(ttk.LabelFrame): # pylint: disable=too-many-ancestors,too-many-instance-attributes + """Show two white transmitter gimbals with red stick-position circles.""" + + def __init__( + self, + parent: tk.Misc, + model: RCCalibrationDataModel | None = None, + on_parameters_changed: Callable[[], None] | None = None, + ) -> None: + super().__init__(parent, text=_("Remote controller stick preview"), padding=_RC_FRAME_PADDING) + self._model = model + self._on_parameters_changed = on_parameters_changed + self._axes: dict[str, float] = {} + mapping_mode = model.get_mapping_mode() if model else 2 + self._mode = tk.IntVar(self, value=mapping_mode or 2) + self._selected_mode = tk.StringVar(self, value=str(mapping_mode or _("Custom"))) + mode_row = ttk.Frame(self) + mode_row.pack(fill="x", pady=(0, 6)) + ttk.Label(mode_row, text=_("RC mapping mode:")).pack(side="left") + self.mode_selector = ttk.Combobox( + mode_row, + textvariable=self._selected_mode, + values=tuple(str(mode) for mode in RC_STICK_MODES), + state="readonly", + width=8, + ) + self.mode_selector.pack(side="left", padx=8) + self.mode_selector.bind("<>", self._on_mode_changed) + show_tooltip( + self.mode_selector, + _( + "Stage RCMAP_ROLL/PITCH/THROTTLE/YAW for Mode 1-4; only parameter upload writes to the controller. " + "Presets assume right horizontal CH1, right vertical CH2, left vertical CH3, left horizontal CH4. " + "Verify the channel bars against your transmitter before uploading. Reboot after uploading mapping changes." + ), + ) + self._mapping_status = ttk.Label(self, wraplength=380) + self._mapping_status.pack(fill="x", pady=(0, 6)) + if model and model.get_mapping_mode() is None: + self._mapping_status.configure(text=_("Custom RCMAP mapping retained. Select a preset only to change it.")) + gimbals = ttk.Frame(self) + gimbals.pack() + self._canvases: list[tk.Canvas] = [] + self._axis_labels: list[ttk.Label] = [] + for column in range(2): + canvas = tk.Canvas( + gimbals, + width=_RC_STICK_SQUARE_SIZE, + height=_RC_STICK_SQUARE_SIZE, + background="white", + borderwidth=0, + highlightthickness=0, + ) + canvas.grid(row=0, column=column, padx=8) + canvas.bind("", self._on_resize) + self._canvases.append(canvas) + label = ttk.Label(gimbals) + label.grid(row=1, column=column, pady=(4, 0)) + self._axis_labels.append(label) + show_tooltip(canvas, _("The red circle shows the stick position; centered sticks appear in the square center.")) + self._draw_sticks() + + def update_axes(self, telemetry: dict[str, Any]) -> None: + """Retain the latest axis sample and redraw both sticks.""" + if self._model is not None: + mapping_mode = self._model.get_mapping_mode() + self._selected_mode.set(str(mapping_mode or _("Custom"))) + if mapping_mode is not None: + self._mode.set(mapping_mode) + for axis in ("roll", "pitch", "throttle", "yaw"): + value = telemetry.get(axis) + if isinstance(value, (int, float)) and isfinite(value): + self._axes[axis] = float(value) + self._draw_sticks() + + def _on_mode_changed(self, _event: tk.Event) -> None: + """Stage the mapping and immediately redraw the retained raw sample without FC writes.""" + mode = int(self._selected_mode.get()) + if self._model is not None: + success, message = self._model.set_mapping_mode(mode) + self._mapping_status.configure(text=message, foreground="green" if success else "red") + if not success: + self._selected_mode.set(str(self._model.get_mapping_mode() or _("Custom"))) + return + if self._on_parameters_changed: + self._on_parameters_changed() + self._axes.update(self._model.get_preview_axes()) + self._mode.set(mode) + self._draw_sticks() + + def _on_resize(self, _event: tk.Event) -> None: + """Preserve square gimbals and the circle-to-square ratio after layout.""" + self._draw_sticks() + + def _draw_sticks(self) -> None: + axis_names = {"roll": _("ROLL"), "pitch": _("PITCH"), "throttle": _("THROTTLE"), "yaw": _("YAW")} + positions = RCCalibrationDataModel.stick_positions(self._axes, self._mode.get()) + for index, (horizontal, vertical) in enumerate(positions): + canvas = self._canvases[index] + # These canvases are fixed squares; leave any excess allocated space around the square. + size = int(canvas.cget("width")) + left = max(0, (canvas.winfo_width() - size) / 2) + top = max(0, (canvas.winfo_height() - size) / 2) + radius = size / 24 # Diameter is exactly 1/12 of the square side. + travel = size / 2 - radius + center_x = left + size / 2 + horizontal * travel + center_y = top + size / 2 - vertical * travel + canvas.delete("all") + canvas.create_rectangle(left, top, left + size, top + size, fill="white", outline="#999999", tags="gimbal") + canvas.create_oval( + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + fill="red", + outline="", + tags="stick", + ) + self._axis_labels[index].configure( + text=f"{axis_names[RC_STICK_MODES[self._mode.get()][index][0]]} ↔ / " + f"{axis_names[RC_STICK_MODES[self._mode.get()][index][1]]} ↕" + ) + + +class RCChannelBars(ttk.LabelFrame): # pylint: disable=too-many-ancestors,too-many-instance-attributes + """Display PWM values, configurable channel functions, and calibration markers for all RC inputs.""" + + def __init__( + self, + parent: tk.Misc, + model: RCCalibrationDataModel | None = None, + on_parameters_changed: Callable[[], None] | None = None, + ) -> None: + super().__init__(parent, text=_("Channels"), padding=_RC_FRAME_PADDING) + self._model = model + self._on_parameters_changed = on_parameters_changed + self._value_labels: dict[int, ttk.Label] = {} + self._bar_canvases: dict[int, tk.Canvas] = {} + self._channel_values: dict[int, dict[str, int | None]] = {} + self._function_labels: dict[int, ttk.Label] = {} + self._option_selectors: dict[int, ttk.Combobox] = {} + self._option_display_values: dict[int, dict[str, str]] = {} + self._channel_rows: dict[int, ttk.Frame] = {} + self._canvas_background = ttk.Style(self).lookup("TFrame", "background") or "white" + + self._header = ttk.Frame(self) + self._header.pack(fill="x") + ttk.Label(self._header, text=_("Channel"), width=6, anchor="center").pack(side="left") + ttk.Label(self._header, text=_("Function"), width=16, anchor="center").pack(side="left") + ttk.Label(self._header, text=_("PWM"), width=9, anchor="center").pack(side="left", padx=(0, 8)) + self._feedback_label = ttk.Label(self) + channel_info = model.get_channel_calibration() if model else [] + + for channel_number in range(1, _RC_CHANNEL_COUNT + 1): + row = ttk.Frame(self) + row.pack(fill="x", pady=1) + self._channel_rows[channel_number] = row + + ttk.Label(row, text=f"CH{channel_number}", width=6, font=("TkDefaultFont", 9, "bold")).pack(side="left") + function_label = ttk.Label(row, width=16, anchor="center") + function_label.pack(side="left", padx=(0, 4)) + self._function_labels[channel_number] = function_label + + value_label = ttk.Label(row, text=_("No Data"), width=9, anchor="e") + value_label.pack(side="left", padx=(0, 8), pady=(16, 0) if channel_number == 1 else 0) + self._value_labels[channel_number] = value_label + + canvas = tk.Canvas( + row, + height=38 if channel_number == 1 else 24, + width=_RC_CHANNEL_BAR_WIDTH, + background=self._canvas_background, + borderwidth=0, + highlightthickness=0, + ) + canvas.pack(side="left", fill="x", expand=True) + canvas.bind("", self._on_channel_canvas_resize) + self._bar_canvases[channel_number] = canvas + self._channel_values[channel_number] = {"value": None, "min": 1000, "max": 2000, "trim": 1500} + self._draw_channel(channel_number) + show_tooltip( + canvas, + _( + "Green MIN, blue TRIM and red MAX markers show calibration. " + "MIN/MAX track observed extremes during calibration. " + "Missing calibration parameters use display defaults of 1000/2000/1500 µs." + ), + ) + if model: + self.update_channels(channel_info) + + def update_channels(self, channels: list[dict[str, Any]]) -> None: + """Update the 16 persistent rows from the latest model telemetry.""" + channels_by_name = {channel.get("name"): channel for channel in channels} + for channel_number in range(1, _RC_CHANNEL_COUNT + 1): + channel = channels_by_name.get(f"CH{channel_number}", {}) + self._update_channel_function(channel_number, channel) + value = self._optional_pwm_value(channel.get("value")) + minimum = self._optional_pwm_value(channel.get("min")) + maximum = self._optional_pwm_value(channel.get("max")) + trim = self._optional_pwm_value(channel.get("trim")) + self._channel_values[channel_number] = { + "value": value, + "min": minimum if minimum is not None else 1000, + "max": maximum if maximum is not None else 2000, + "trim": trim if trim is not None else 1500, + } + self._value_labels[channel_number].configure(text=f"{value} µs" if value is not None else _("No Data")) + self._draw_channel(channel_number) + + def _update_channel_function(self, channel_number: int, channel: dict[str, Any]) -> None: + """Reconcile the function control with the model's current editability.""" + row = self._channel_rows[channel_number] + if channel.get("function_editable", False) and self._model is not None: + function_label = self._function_labels.pop(channel_number, None) + if function_label is not None: + function_label.destroy() + selector = self._option_selectors.get(channel_number) + if selector is None: + selector = ttk.Combobox(row, state="disabled", width=16) + selector.pack(side="left", padx=(0, 4), before=self._value_labels[channel_number]) + selector.bind("<>", partial(self._on_option_selected, channel_number)) + self._option_selectors[channel_number] = selector + show_tooltip( + selector, + _("Select this channel's RCx_OPTION function. Changes are staged until parameter upload."), + ) + self._update_option_selector(channel_number, selector, channel) + else: + selector = self._option_selectors.pop(channel_number, None) + if selector is not None: + selector.destroy() + self._option_display_values.pop(channel_number, None) + function_label = self._function_labels.get(channel_number) + if function_label is None: + function_label = ttk.Label(row, width=16, anchor="center") + function_label.pack(side="left", padx=(0, 4), before=self._value_labels[channel_number]) + self._function_labels[channel_number] = function_label + function_label.configure(text=channel.get("function") or channel.get("option_label", "")) + + def _update_option_selector(self, channel_number: int, selector: ttk.Combobox, channel: dict[str, Any]) -> None: + """Fill a channel selector with loaded ArduPilot metadata while retaining the active choice.""" + choices = channel.get("option_choices", {}) + display_values: dict[str, str] = {} + if isinstance(choices, dict): + for key, description in choices.items(): + try: + option = int(float(key)) + except (TypeError, ValueError): + continue + display_values[f"{option}: {description}"] = str(option) + self._option_display_values[channel_number] = display_values + selected_key = self._option_value_key(channel.get("option_value")) + selected_display = next((display for display, value in display_values.items() if value == selected_key), "") + if selected_key and not selected_display: + selected_display = f"{selected_key}: {_('Unknown option')}" + display_values[selected_display] = selected_key + available_values = tuple(display_values) + if tuple(selector.cget("values")) != available_values: + selector.configure(values=available_values) + state = "readonly" if channel.get("function_editable", False) and choices and self._model is not None else "disabled" + if str(selector.cget("state")) != state: + selector.configure(state=state) + if selector.get() != selected_display and selector.focus_get() is not selector: + selector.set(selected_display) + + def _on_option_selected(self, channel_number: int, _event: tk.Event) -> None: + """Stage the selected RCx_OPTION value and refresh the visible New values.""" + selector = self._option_selectors.get(channel_number) + if selector is None: + return + option_value = self._option_display_values[channel_number].get(selector.get()) + if option_value is None or self._model is None: + return + success, message = self._model.set_channel_option(channel_number, option_value) + self._feedback_label.configure(text=message, foreground="green" if success else "red") + self._feedback_label.pack(fill="x", before=self._header) + if success and self._on_parameters_changed: + self._on_parameters_changed() + self.update_channels(self._model.get_channel_calibration()) + + @staticmethod + def _option_value_key(value: object) -> str: + """Format a finite integral enum value as a key from loaded parameter metadata.""" + if isinstance(value, (int, float)) and isfinite(value) and value == int(value): + return str(int(value)) + return "" + + def update_calibration(self, channels: list[dict[str, Any]]) -> None: + """Refresh marker positions without discarding the last live PWM values.""" + self.update_channels( + [{**channel, "value": self._channel_values[number]["value"]} for number, channel in enumerate(channels, start=1)] + ) -class RCCalibrationPopup(CalibrationPopupBase["RCCalibrationDataModel"]): # pylint: disable=too-many-instance-attributes + def _on_channel_canvas_resize(self, event: tk.Event) -> None: + """Redraw the channel whose canvas was resized.""" + for channel_number, canvas in self._bar_canvases.items(): + if canvas is event.widget: + self._draw_channel(channel_number, event.width) + return + + def _draw_channel(self, channel_number: int, width: int | None = None) -> None: + """Paint green MIN, blue TRIM and red MAX markers; labels appear only above channel 1.""" + canvas = self._bar_canvases[channel_number] + canvas.delete("all") + canvas_width = width if width is not None else canvas.winfo_width() + canvas_width = canvas_width if canvas_width > 1 else int(canvas.cget("width")) + left = 1 + right = canvas_width - 1 + bar_top = 18 if channel_number == 1 else 3 + canvas.create_rectangle(left, bar_top, right, bar_top + 16, fill="#d6d6d6", outline="", tags=("track",)) + + channel = self._channel_values[channel_number] + value = channel["value"] + if value is not None: + value_x = self._pwm_to_x(value, canvas_width) + canvas.create_rectangle(left, bar_top, value_x, bar_top + 16, fill="#4b91c5", outline="", tags=("live_value",)) + + for marker_name, marker_color in _RC_MARKER_COLORS.items(): + marker_value = channel[marker_name] + if marker_value is not None: + marker_x = self._pwm_to_x(marker_value, canvas_width) + canvas.create_line( + marker_x, + bar_top - 2, + marker_x, + bar_top + 18, + fill=marker_color, + width=3, + tags=(f"{marker_name}_marker", "calibration_marker"), + ) + if channel_number == 1: + canvas.create_text( + marker_x, + 8, + text=marker_name.upper(), + fill=marker_color, + anchor=self._marker_label_anchor(marker_name), + font=("TkDefaultFont", 7), + tags=(f"{marker_name}_label",), + ) + + @staticmethod + def _marker_label_anchor(marker_name: str) -> Literal["w", "e", "center"]: + """Keep the outer labels inside the canvas even at the track endpoints.""" + return "w" if marker_name == "min" else "e" if marker_name == "max" else "center" + + @staticmethod + def _optional_pwm_value(value: object) -> int | None: + """Accept finite numeric PWM values, including float-valued parameter cache entries.""" + return round(value) if isinstance(value, (int, float)) and isfinite(value) and 0 < value < 65535 else None + + @staticmethod + def _pwm_to_x(value: int, width: int) -> int: + """Map a PWM value to the bar's horizontal position, clamped to 800-2200 µs.""" + clamped_value = max(_RC_DISPLAY_MIN_PWM, min(_RC_DISPLAY_MAX_PWM, value)) + fraction = (clamped_value - _RC_DISPLAY_MIN_PWM) / (_RC_DISPLAY_MAX_PWM - _RC_DISPLAY_MIN_PWM) + return 1 + round(fraction * (width - 2)) + + +class RCCalibrationPopup(CalibrationPopupBase["RCCalibrationDataModel"]): """A modern, borderless popup window with a custom draggable title bar for RC monitoring.""" _MIN_WIDTH = 600 @@ -37,16 +462,7 @@ class RCCalibrationPopup(CalibrationPopupBase["RCCalibrationDataModel"]): # pyl def __init__(self, parent: tk.Widget, model: RCCalibrationDataModel) -> None: super().__init__(parent, model) - self.stick_data: dict[str, float] = { - "ROLL": 0.0, - "PITCH": 0.0, - "THROTTLE": 0.0, - "YAW": 0.0, - } - self.flight_mode: str = _("No Data") - self.channels: list[dict] = [] - - self.renderer = QuadcopterRenderer() + self._setup_style() self._setup_ui() self._resize_and_center() @@ -57,44 +473,35 @@ def __init__(self, parent: tk.Widget, model: RCCalibrationDataModel) -> None: def _setup_ui(self) -> None: content_frame = self._create_framed_ui(_("Live Monitor")) - - # Preview Stages Section - preview_frame = ttk.LabelFrame(content_frame, text=_("Preview Stages"), padding=10) - preview_frame.pack(fill="x", pady=(0, 15)) - - # 3D Model Preview using OpenGL - self.preview_label = ttk.Label(preview_frame, text=_("Waiting for live RC input..."), font=("TkDefaultFont", 10)) - self.preview_label.pack(pady=5) - - # Stick Preview Section - stick_preview_frame = ttk.LabelFrame(content_frame, text=_("Stick Preview"), padding=10) - stick_preview_frame.pack(fill="x", pady=5) - - self.stick_bars: dict[str, ttk.Progressbar] = {} - for stick in ["ROLL", "PITCH", "THROTTLE", "YAW"]: - row = ttk.Frame(stick_preview_frame) - row.pack(fill="x", pady=2) - ttk.Label(row, text=stick, width=10, font=("TkDefaultFont", 10, "bold")).pack(side="left") - pb = ttk.Progressbar(row, orient="horizontal", length=300, mode="determinate", maximum=100) - pb.pack(side="left", expand=True, fill="x", padx=10) - self.stick_bars[stick] = pb - - # Flight Mode Section - mode_frame = ttk.Frame(content_frame) - mode_frame.pack(fill="x", pady=10) - ttk.Label(mode_frame, text=_("Flight Mode:"), font=("TkDefaultFont", 11, "bold")).pack(side="left") - self.mode_label = ttk.Label(mode_frame, text=_("No Data"), font=("TkDefaultFont", 11)) - self.mode_label.pack(side="left", padx=10) - - # Channel List Section - channel_frame = ttk.LabelFrame(content_frame, text=_("Channels"), padding=10) - channel_frame.pack(fill="both", expand=True, pady=5) - - self.channel_list_frame = ttk.Frame(channel_frame) - self.channel_list_frame.pack(fill="both", expand=True) - - def _check_telemetry(self) -> None: # pylint: disable=too-many-locals + content_frame.pack_configure(padx=_RC_FRAME_PADDING, pady=_RC_FRAME_PADDING) + self._scroll_frame = ScrollFrame(content_frame) + self._scroll_frame.pack(fill="both", expand=True) + # Use the explicit outer margin instead of ScrollFrame's additional inset. + self._scroll_frame.canvas.coords(self._scroll_frame.canvas_window, 0, 0) + self._scroll_frame.on_frame_configure(None) + self._scroll_frame.canvas.xview_moveto(0) + self._scroll_frame.scroll_to_top() + content_frame = self._scroll_frame.view_port + + self.craft_preview = RCCraftPreview(content_frame) + self.craft_preview.pack(fill="x", pady=(0, _RC_FRAME_PADDING)) + + self.stick_preview = RCStickPreview(content_frame, self.model, self._refresh_preview) + self.stick_preview.pack(fill="x", pady=(0, _RC_FRAME_PADDING)) + + self.channel_bars = RCChannelBars(content_frame, self.model, self._refresh_preview) + self.channel_bars.pack(fill="x", expand=True, pady=(0, _RC_FRAME_PADDING)) + + def _refresh_preview(self) -> None: + """Refresh channel functions and preview after staging an RC mapping or auxiliary option.""" + axes = self.model.get_preview_axes() + self.stick_preview.update_axes(axes) + self.craft_preview.update_telemetry(axes) + self.channel_bars.update_channels(self.model.get_channel_calibration()) + + def _check_telemetry(self) -> None: telemetry = self.model.get_rc_telemetry() + self.craft_preview.update_telemetry(telemetry) if not telemetry: self._polls_without_updates += 1 @@ -109,47 +516,9 @@ def _check_telemetry(self) -> None: # pylint: disable=too-many-locals self._polls_without_updates = 0 self._no_telemetry_warning_emitted = False - # Update Flight Mode - self.flight_mode = telemetry.get("flight_mode", _("No Data")) - self.mode_label.configure(text=self.flight_mode) - - # Update Stick Preview - # Assuming telemetry has "roll", "pitch", "throttle", "yaw" in range -1000 to 1000 - for stick in ["ROLL", "PITCH", "THROTTLE", "YAW"]: - val = telemetry.get(stick.lower(), 0.0) - # Map -1000...1000 to 0...100 - pct = ((val + 1000) / 2000) * 100 - self.stick_bars[stick]["value"] = int(max(0, min(100, pct))) - - # Update Channel List - channels = telemetry.get("channels", []) - # Clear existing list - for widget in self.channel_list_frame.winfo_children(): - widget.destroy() - - # Rebuild list - for ch in channels: - row = ttk.Frame(self.channel_list_frame) - row.pack(fill="x", pady=1) - ttk.Label(row, text=ch.get("name", "?"), width=6, font=("TkDefaultFont", 9, "bold")).pack(side="left") - ttk.Label(row, text=f"{ch.get('value', 0)} µs", width=10).pack(side="left") - - # Update 3D Preview - roll = telemetry.get("roll", 0.0) - pitch = telemetry.get("pitch", 0.0) - yaw = telemetry.get("yaw", 0.0) - throttle = telemetry.get("throttle", 0.0) - - # Convert -1000...1000 to -1.0...1.0 - roll_norm = roll / 1000.0 - pitch_norm = pitch / 1000.0 - yaw_norm = yaw / 1000.0 - throttle_norm = throttle / 1000.0 - - pil_img = self.renderer.render(roll_norm, pitch_norm, yaw_norm, throttle_norm) - tk_img = ImageTk.PhotoImage(pil_img) - self.preview_label.configure(image=tk_img) - self.preview_label.image = tk_img # type: ignore[attr-defined] # Keep reference + self.stick_preview.update_axes(telemetry) + if "channels" in telemetry: + self.channel_bars.update_channels(telemetry["channels"]) self._timer_id = self.root.after(100, self._check_telemetry) @@ -169,8 +538,6 @@ def __init__( self._timer_id: str | None = None self._polls_without_updates = 0 self._no_telemetry_warning_emitted = False - self.flight_mode: str = _("No Data") - self.renderer = QuadcopterRenderer() self._setup_style() self._setup_ui() self._timer_id = self.after(100, self._check_telemetry) @@ -189,7 +556,7 @@ def _setup_ui(self) -> None: # Calibration control buttons control_frame = ttk.Frame(outer_frame) - control_frame.pack(fill="x", padx=10, pady=(10, 0)) + control_frame.pack(fill="x", padx=_RC_FRAME_PADDING, pady=(_RC_FRAME_PADDING, 0)) self._start_btn = ttk.Button( control_frame, @@ -217,47 +584,28 @@ def _setup_ui(self) -> None: self._status_label = ttk.Label(control_frame, text=_("Move sticks to monitor RC input."), foreground="gray") self._status_label.pack(side="left", padx=10) - content_frame = ttk.Frame(outer_frame) - content_frame.pack(fill="both", expand=True, padx=20, pady=20) - - # Preview Stages Section - preview_frame = ttk.LabelFrame(content_frame, text=_("Preview Stages"), padding=10) - preview_frame.pack(fill="x", pady=(0, 15)) - - # 3D Model Preview using OpenGL - self.preview_label = ttk.Label(preview_frame, text=_("Waiting for live RC input..."), font=("TkDefaultFont", 10)) - self.preview_label.pack(pady=5) - - # Stick Preview Section - stick_preview_frame = ttk.LabelFrame(content_frame, text=_("Stick Preview"), padding=10) - stick_preview_frame.pack(fill="x", pady=5) - - self.stick_bars: dict[str, ttk.Progressbar] = {} - for stick in ["ROLL", "PITCH", "THROTTLE", "YAW"]: - row = ttk.Frame(stick_preview_frame) - row.pack(fill="x", pady=2) - ttk.Label(row, text=stick, width=10, font=("TkDefaultFont", 10, "bold")).pack(side="left") - pb = ttk.Progressbar(row, orient="horizontal", length=300, mode="determinate", maximum=100) - pb.pack(side="left", expand=True, fill="x", padx=10) - self.stick_bars[stick] = pb + self._scroll_frame = ScrollFrame(outer_frame) + self._scroll_frame.pack(fill="both", expand=True, padx=_RC_FRAME_PADDING, pady=_RC_FRAME_PADDING) + # Match the documentation margin without adding ScrollFrame's default inset. + self._scroll_frame.canvas.coords(self._scroll_frame.canvas_window, 0, 0) + self._scroll_frame.on_frame_configure(None) + self._scroll_frame.canvas.xview_moveto(0) + self._scroll_frame.scroll_to_top() + content_frame = self._scroll_frame.view_port - # Flight Mode Section - mode_frame = ttk.Frame(content_frame) - mode_frame.pack(fill="x", pady=10) - ttk.Label(mode_frame, text=_("Flight Mode:"), font=("TkDefaultFont", 11, "bold")).pack(side="left") - self.mode_label = ttk.Label(mode_frame, text=_("No Data"), font=("TkDefaultFont", 11)) - self.mode_label.pack(side="left", padx=10) + self.craft_preview = RCCraftPreview(content_frame) + self.craft_preview.pack(fill="x", pady=(0, _RC_FRAME_PADDING)) - # Channel List Section - channel_frame = ttk.LabelFrame(content_frame, text=_("Channels"), padding=10) - channel_frame.pack(fill="both", expand=True, pady=5) + self.stick_preview = RCStickPreview(content_frame, self.model, self._refresh_parameters) + self.stick_preview.pack(fill="x", pady=(0, _RC_FRAME_PADDING)) - self.channel_list_frame = ttk.Frame(channel_frame) - self.channel_list_frame.pack(fill="both", expand=True) + self.channel_bars = RCChannelBars(content_frame, self.model, self._refresh_parameters) + self.channel_bars.pack(fill="x", expand=True, pady=(0, _RC_FRAME_PADDING)) def _on_start_calibration(self) -> None: success, error_msg = self.model.start_calibration() if success: + self.channel_bars.update_calibration(self.model.get_channel_calibration()) self._start_btn.configure(state="disabled") self._finish_btn.configure(state="normal") self._cancel_btn.configure(state="normal") @@ -269,17 +617,29 @@ def _on_start_calibration(self) -> None: self._status_label.configure(text=error_msg, foreground="red") def _on_finish_calibration(self) -> None: - self.model.finish_calibration() + success, message = self.model.finish_calibration() + if success: + self._refresh_parameters() + self.channel_bars.update_calibration(self.model.get_channel_calibration()) self._start_btn.configure(state="normal") - self._finish_btn.configure(state="disabled") - self._cancel_btn.configure(state="disabled") + self._finish_btn.configure(state="disabled" if success else "normal") + self._cancel_btn.configure(state="disabled" if success else "normal") self._status_label.configure( - text=_("Calibration saved. You may re-calibrate or close this panel."), - foreground="green", + text=message, + foreground="green" if success else "red", ) + def _refresh_parameters(self) -> None: + """Show staged edits in the existing New-value table without downloading or uploading.""" + refresh_parameter_editor_table(self.base_window) + axes = self.model.get_preview_axes() + self.stick_preview.update_axes(axes) + self.craft_preview.update_telemetry(axes) + self.channel_bars.update_channels(self.model.get_channel_calibration()) + def _on_cancel_calibration(self) -> None: self.model.cancel_calibration() + self.channel_bars.update_calibration(self.model.get_channel_calibration()) self._start_btn.configure(state="normal") self._finish_btn.configure(state="disabled") self._cancel_btn.configure(state="disabled") @@ -291,8 +651,9 @@ def _stop_polling(self) -> None: self.after_cancel(self._timer_id) self._timer_id = None - def _check_telemetry(self) -> None: # pylint: disable=too-many-locals + def _check_telemetry(self) -> None: telemetry = self.model.get_rc_telemetry() + self.craft_preview.update_telemetry(telemetry) if not telemetry: self._polls_without_updates += 1 @@ -307,47 +668,9 @@ def _check_telemetry(self) -> None: # pylint: disable=too-many-locals self._polls_without_updates = 0 self._no_telemetry_warning_emitted = False - # Update Flight Mode - self.flight_mode = telemetry.get("flight_mode", _("No Data")) - self.mode_label.configure(text=self.flight_mode) - - # Update Stick Preview - # Assuming telemetry has "roll", "pitch", "throttle", "yaw" in range -1000 to 1000 - for stick in ["ROLL", "PITCH", "THROTTLE", "YAW"]: - val = telemetry.get(stick.lower(), 0.0) - # Map -1000...1000 to 0...100 - pct = ((val + 1000) / 2000) * 100 - self.stick_bars[stick]["value"] = int(max(0, min(100, pct))) - - # Update Channel List - channels = telemetry.get("channels", []) - # Clear existing list - for widget in self.channel_list_frame.winfo_children(): - widget.destroy() - - # Rebuild list - for ch in channels: - row = ttk.Frame(self.channel_list_frame) - row.pack(fill="x", pady=1) - ttk.Label(row, text=ch.get("name", "?"), width=6, font=("TkDefaultFont", 9, "bold")).pack(side="left") - ttk.Label(row, text=f"{ch.get('value', 0)} µs", width=10).pack(side="left") - - # Update 3D Preview - roll = telemetry.get("roll", 0.0) - pitch = telemetry.get("pitch", 0.0) - yaw = telemetry.get("yaw", 0.0) - throttle = telemetry.get("throttle", 0.0) - - # Convert -1000...1000 to -1.0...1.0 - roll_norm = roll / 1000.0 - pitch_norm = pitch / 1000.0 - yaw_norm = yaw / 1000.0 - throttle_norm = throttle / 1000.0 - - pil_img = self.renderer.render(roll_norm, pitch_norm, yaw_norm, throttle_norm) - tk_img = ImageTk.PhotoImage(pil_img) - self.preview_label.configure(image=tk_img) - self.preview_label.image = tk_img # type: ignore[attr-defined] # Keep reference + self.stick_preview.update_axes(telemetry) + if "channels" in telemetry: + self.channel_bars.update_channels(telemetry["channels"]) self._timer_id = self.after(100, self._check_telemetry) @@ -382,7 +705,7 @@ def _create_rc_calibration_view( def _create_rc_calibration_model(context: PluginModelContext) -> RCCalibrationDataModel: """Create the plugin data model from registered application dependencies.""" - return RCCalibrationDataModel(context.flight_controller) + return RCCalibrationDataModel(context.flight_controller, context.parameter_editor) def register_rc_calibration_plugin() -> None: diff --git a/ardupilot_methodic_configurator/plugins/renderer_3d_quadcopter.py b/ardupilot_methodic_configurator/plugins/renderer_3d_quadcopter.py index f4fe8eed0..2e538a33e 100644 --- a/ardupilot_methodic_configurator/plugins/renderer_3d_quadcopter.py +++ b/ardupilot_methodic_configurator/plugins/renderer_3d_quadcopter.py @@ -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. @@ -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 diff --git a/tests/plugins/conftest.py b/tests/plugins/conftest.py new file mode 100755 index 000000000..6157b4c78 --- /dev/null +++ b/tests/plugins/conftest.py @@ -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 diff --git a/tests/plugins/test_data_model_rc_calibration.py b/tests/plugins/test_data_model_rc_calibration.py index 96491aa65..da6d91c7c 100755 --- a/tests/plugins/test_data_model_rc_calibration.py +++ b/tests/plugins/test_data_model_rc_calibration.py @@ -10,17 +10,24 @@ SPDX-License-Identifier: GPL-3.0-or-later """ +from math import nan +from typing import Any 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 InvalidParameterNameError, ParameterEditor from ardupilot_methodic_configurator.plugins.data_model_rc_calibration import ( _RC_CENTER_PWM, _RC_INVALID_PWM, RCCalibrationDataModel, ) +from ardupilot_methodic_configurator.plugins.frontend_tkinter_rc_calibration import _create_rc_calibration_model +from ardupilot_methodic_configurator.plugins.plugin_factory import PluginModelContext -# pylint: disable=protected-access,redefined-outer-name +# pylint: disable=protected-access,redefined-outer-name,too-many-lines def _make_rc_channels_msg(raw: list[int], chancount: int | None = None) -> MagicMock: @@ -45,6 +52,7 @@ def connected_flight_controller() -> MagicMock: flight_controller = MagicMock() flight_controller.master = MagicMock() flight_controller.master.recv_match.return_value = None + flight_controller.fc_parameters = {} return flight_controller @@ -161,95 +169,140 @@ def test_cancel_calibration_discards_state_without_writing(self, connected_fligh class TestRCCalibrationDataModelFinish: - """Test writing observed min/max values as RCn_MIN/MAX/TRIM parameters.""" + """Calibration measurements become editable New values, never direct controller writes.""" def test_finish_without_data_writes_no_parameters(self, connected_flight_controller) -> None: """ - Finishing with no recorded readings writes nothing. + Finishing with no recorded readings stages nothing. GIVEN: A calibration that recorded no channel data WHEN: finish_calibration is called - THEN: No parameter is written and tracking is stopped + THEN: It reports no data and tracking stops without writing parameters """ model = RCCalibrationDataModel(connected_flight_controller) model.start_calibration() - - model.finish_calibration() - + success, message = model.finish_calibration() + assert success is False + assert message assert model._is_calibrating is False connected_flight_controller.set_param.assert_not_called() - def test_finish_writes_min_max_trim_for_recorded_channel(self, connected_flight_controller) -> None: + @pytest.mark.parametrize(("index", "minimum", "maximum"), [(0, 1100, 1900), (4, 1000, 2000), (15, 1050, 1950)]) + def test_finish_stages_measured_min_max_trim_and_preserves_fc_cache( + self, rc_staging_context, index: int, minimum: int, maximum: int + ) -> None: """ - Finishing writes MIN, MAX and the midpoint TRIM for each channel. + Finishing stages MIN, MAX and midpoint TRIM for valid recorded channels. - GIVEN: A channel with observed extremes 1100..1900 (0-based index 0) + GIVEN: A real editor with cached calibration and an observed channel WHEN: finish_calibration is called - THEN: RC1_MIN=1100, RC1_MAX=1900 and RC1_TRIM=1500 are written as floats + THEN: Correctly numbered New values and markers change, tracking clears, and the FC cache is untouched """ - model = RCCalibrationDataModel(connected_flight_controller) - model._channel_min = {0: 1100} - model._channel_max = {0: 1900} - - model.finish_calibration() - - connected_flight_controller.set_param.assert_any_call("RC1_MIN", 1100.0) - connected_flight_controller.set_param.assert_any_call("RC1_MAX", 1900.0) - connected_flight_controller.set_param.assert_any_call("RC1_TRIM", 1500.0) + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + before = dict(controller.fc_parameters) + model._channel_min = {index: minimum} + model._channel_max = {index: maximum} + success, message = model.finish_calibration() + assert success is True + assert "upload" in message + for suffix, value in (("MIN", minimum), ("MAX", maximum), ("TRIM", (minimum + maximum) // 2)): + assert editor.current_step_parameters[f"RC{index + 1}_{suffix}"].get_new_value() == value + assert model.get_channel_calibration()[index]["min"] == minimum + assert controller.fc_parameters == before + assert not model._channel_min + assert not model._channel_max + controller.set_param.assert_not_called() - def test_finish_uses_symmetric_fallback_when_max_missing(self, connected_flight_controller) -> None: + def test_finish_uses_symmetric_fallback_when_max_missing(self, rc_staging_context) -> None: """ - A channel seen only at its minimum gets a symmetric max fallback. + A missing observed maximum retains the existing symmetric fallback behavior. - GIVEN: Channel index 0 has a min of 1100 but no recorded max + GIVEN: Channel index 0 has a minimum of 1100 but no recorded maximum WHEN: finish_calibration is called - THEN: MAX falls back to 2*center-min (1900) and TRIM is the midpoint (1500) + THEN: Only staged MAX/TRIM become 1900/1500; the FC remains unchanged """ - model = RCCalibrationDataModel(connected_flight_controller) + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) model._channel_min = {0: 1100} - model._channel_max = {} # max never recorded for this channel + assert model.finish_calibration()[0] is True + assert editor.current_step_parameters["RC1_MAX"].get_new_value() == _RC_CENTER_PWM * 2 - 1100 + assert editor.current_step_parameters["RC1_TRIM"].get_new_value() == 1500 + controller.set_param.assert_not_called() + + def test_finish_skips_stationary_channel_and_stages_only_channel_that_moved( + self, rc_staging_context: tuple[MagicMock, ParameterEditor] + ) -> None: + """ + A switch left untouched during calibration keeps its existing endpoints. - model.finish_calibration() + GIVEN: CH1 stays at 1500 while CH2 moves between 1100 and 1900 + WHEN: The user finishes calibration + THEN: Only CH2 receives staged endpoints and trim, without an FC write + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + model.start_calibration() + for readings in ([1500, 1100], [1500, 1900]): + controller.master.recv_match.side_effect = [_make_rc_channels_msg(readings), None] + model.get_rc_telemetry() - expected_max = float(_RC_CENTER_PWM * 2 - 1100) - connected_flight_controller.set_param.assert_any_call("RC1_MAX", expected_max) - connected_flight_controller.set_param.assert_any_call("RC1_TRIM", 1500.0) + success, message = model.finish_calibration() - def test_finish_converts_zero_based_index_to_one_based_channel(self, connected_flight_controller) -> None: + assert success is True + assert "upload" in message + for suffix, value in (("MIN", 1000), ("MAX", 2000), ("TRIM", 1500)): + assert editor.current_step_parameters[f"RC1_{suffix}"].get_new_value() == value + for suffix, value in (("MIN", 1100), ("MAX", 1900), ("TRIM", 1500)): + assert editor.current_step_parameters[f"RC2_{suffix}"].get_new_value() == value + assert not model._channel_min + assert not model._channel_max + assert model._is_calibrating is False + controller.set_param.assert_not_called() + + def test_finish_with_only_stationary_channels_stages_nothing( + self, rc_staging_context: tuple[MagicMock, ParameterEditor] + ) -> None: """ - Channel numbering exposed to the FC is 1-based. + Finishing a run with no stick or switch movement leaves every value alone. - GIVEN: Internal 0-based channel index 4 (the fifth channel) - WHEN: finish_calibration is called - THEN: The parameter name uses the 1-based number RC5_* + GIVEN: The user starts calibration and CH1 remains at 1500 + WHEN: The user finishes calibration + THEN: The run succeeds with a no-movement message and clears its readings """ - model = RCCalibrationDataModel(connected_flight_controller) - model._channel_min = {4: 1000} - model._channel_max = {4: 2000} + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + before = {name: parameter.get_new_value() for name, parameter in editor.current_step_parameters.items()} + model.start_calibration() + controller.master.recv_match.side_effect = [_make_rc_channels_msg([1500]), None] + model.get_rc_telemetry() - model.finish_calibration() + success, message = model.finish_calibration() - written = {call.args[0] for call in connected_flight_controller.set_param.call_args_list} - assert "RC5_MIN" in written - assert "RC5_MAX" in written - assert "RC5_TRIM" in written + assert success is True + assert "No RC channels moved" in message + assert {name: parameter.get_new_value() for name, parameter in editor.current_step_parameters.items()} == before + assert not model._channel_min + assert not model._channel_max + assert model._is_calibrating is False + controller.set_param.assert_not_called() - def test_finish_clears_state_after_writing(self, connected_flight_controller) -> None: + def test_standalone_finish_cannot_bypass_the_upload_workflow(self, connected_flight_controller) -> None: """ - Finishing clears recorded extremes so a later run starts fresh. + Standalone monitoring has no parameter-editor staging target. - GIVEN: A channel with recorded extremes - WHEN: finish_calibration is called - THEN: The min/max dictionaries are emptied afterwards + GIVEN: A standalone model with recorded extrema + WHEN: The user tries to finish calibration + THEN: It asks for the parameter editor, preserves the measurements and never sends parameters """ model = RCCalibrationDataModel(connected_flight_controller) model._channel_min = {0: 1100} model._channel_max = {0: 1900} - - model.finish_calibration() - - assert not model._channel_min - assert not model._channel_max + success, message = model.finish_calibration() + assert success is False + assert "parameter editor" in message + assert model._channel_min == {0: 1100} + connected_flight_controller.set_param.assert_not_called() class TestRCCalibrationDataModelTelemetry: @@ -299,13 +352,13 @@ def test_telemetry_maps_first_four_channels_to_axes(self, connected_flight_contr assert telemetry["throttle"] == 1000.0 assert telemetry["yaw"] == 0.0 - def test_telemetry_excludes_invalid_channels_from_channel_list(self, connected_flight_controller) -> None: + def test_telemetry_keeps_all_channels_and_marks_invalid_values_unavailable(self, connected_flight_controller) -> None: """ - Channels reporting the invalid-PWM sentinel are dropped from the list. + All 16 display rows remain present when a channel has no valid PWM value. GIVEN: CH2 reports the invalid sentinel while CH1 and CH3 are valid WHEN: get_rc_telemetry is called - THEN: The channels list omits CH2 but keeps CH1 and CH3 + THEN: CH1-CH16 are present and CH2 has no current value """ model = RCCalibrationDataModel(connected_flight_controller) msg = _make_rc_channels_msg([1500, _RC_INVALID_PWM, 1600], chancount=3) @@ -313,16 +366,20 @@ def test_telemetry_excludes_invalid_channels_from_channel_list(self, connected_f telemetry = model.get_rc_telemetry() - names = {ch["name"] for ch in telemetry["channels"]} - assert names == {"CH1", "CH3"} + channels = telemetry["channels"] + assert len(channels) == 16 + assert channels[0]["value"] == 1500 + assert channels[1]["value"] is None + assert channels[2]["value"] == 1600 + assert channels[15]["name"] == "CH16" def test_telemetry_respects_chancount_upper_bound(self, connected_flight_controller) -> None: """ - Only the number of channels reported by chancount is surfaced. + Values beyond the MAVLink channel count remain unavailable. GIVEN: A message with chancount=2 but valid data in later slots WHEN: get_rc_telemetry is called - THEN: Only CH1 and CH2 appear in the channels list + THEN: CH1 and CH2 have values while later display rows are empty """ model = RCCalibrationDataModel(connected_flight_controller) msg = _make_rc_channels_msg([1500, 1500, 1500, 1500], chancount=2) @@ -330,8 +387,27 @@ def test_telemetry_respects_chancount_upper_bound(self, connected_flight_control telemetry = model.get_rc_telemetry() - names = {ch["name"] for ch in telemetry["channels"]} - assert names == {"CH1", "CH2"} + channels = telemetry["channels"] + assert [channel["value"] for channel in channels[:4]] == [1500, 1500, None, None] + assert len(channels) == 16 + + def test_telemetry_caps_display_at_sixteen_channels(self, connected_flight_controller) -> None: + """ + The user-facing telemetry contains exactly channels CH1 through CH16. + + GIVEN: An RC_CHANNELS message reports 18 channels + WHEN: get_rc_telemetry is called + THEN: Exactly 16 channels are returned and CH16 is the last one + """ + model = RCCalibrationDataModel(connected_flight_controller) + msg = _make_rc_channels_msg(list(range(1100, 1118)), chancount=18) + connected_flight_controller.master.recv_match.side_effect = [msg, None] + + telemetry = model.get_rc_telemetry() + + assert len(telemetry["channels"]) == 16 + assert telemetry["channels"][-1]["name"] == "CH16" + assert telemetry["channels"][-1]["value"] == 1115 def test_invalid_axis_channel_maps_to_zero(self, connected_flight_controller) -> None: """ @@ -380,6 +456,122 @@ def test_telemetry_includes_flight_mode_from_heartbeat(self, connected_flight_co assert telemetry["flight_mode"] == "3" + def test_monitor_shows_configured_min_max_trim_without_starting_calibration(self, connected_flight_controller) -> None: + """ + Existing calibration markers are available immediately, including before RC input arrives. + + GIVEN: The parameter cache contains RC1_MIN/MAX/TRIM + WHEN: The initial channel calibration is queried and live RC data later arrives + THEN: The configured markers are returned in both cases without writing parameters + """ + connected_flight_controller.fc_parameters = {"RC1_MIN": 982.0, "RC1_MAX": 2018.0, "RC1_TRIM": 1495.0} + model = RCCalibrationDataModel(connected_flight_controller) + initial = model.get_channel_calibration() + connected_flight_controller.master.recv_match.side_effect = [_make_rc_channels_msg([1500]), None] + + channel = model.get_rc_telemetry()["channels"][0] + + assert {key: initial[0][key] for key in ("name", "value", "min", "max", "trim")} == { + "name": "CH1", + "value": None, + "min": 982, + "max": 2018, + "trim": 1495, + } + assert initial[0]["function"] == "Roll" + assert initial[0]["function_editable"] is False + assert (channel["min"], channel["max"], channel["trim"]) == (982, 2018, 1495) + connected_flight_controller.set_param.assert_not_called() + + def test_axes_use_channel_mapping_calibration_and_reversal(self, connected_flight_controller) -> None: + """ + The stick preview follows the flight controller's RC channel mapping and endpoints. + + GIVEN: Roll is mapped to reversed CH5 with endpoints 1100..1900 + WHEN: CH5 reports its low endpoint + THEN: Roll is full positive while the raw channel value remains unchanged + """ + connected_flight_controller.fc_parameters = { + "RCMAP_ROLL": 5.0, + "RC5_MIN": 1100.0, + "RC5_MAX": 1900.0, + "RC5_REVERSED": 1.0, + } + model = RCCalibrationDataModel(connected_flight_controller) + connected_flight_controller.master.recv_match.side_effect = [ + _make_rc_channels_msg([1500, 1500, 1500, 1500, 1100]), + None, + ] + + telemetry = model.get_rc_telemetry() + + assert telemetry["roll"] == 1000.0 + assert telemetry["channels"][4]["value"] == 1100 + + def test_centered_throttle_uses_endpoint_midpoint_not_low_throttle_trim(self, connected_flight_controller) -> None: + """ + A physically centered throttle is shown in the middle even when its configured trim is low. + + GIVEN: Throttle endpoints are 1100..1900 with a low-throttle trim of 1100 + WHEN: The throttle channel reports 1500 + THEN: The stick axis is centered and the trim marker remains at 1100 + """ + connected_flight_controller.fc_parameters = {"RC3_MIN": 1100.0, "RC3_MAX": 1900.0, "RC3_TRIM": 1100.0} + model = RCCalibrationDataModel(connected_flight_controller) + connected_flight_controller.master.recv_match.side_effect = [_make_rc_channels_msg([1500, 1500, 1500]), None] + + telemetry = model.get_rc_telemetry() + + assert telemetry["throttle"] == 0.0 + assert telemetry["channels"][2]["trim"] == 1100 + + def test_centered_roll_uses_the_configured_trim(self, connected_flight_controller) -> None: + """ + Calibrated neutral roll maps to the gimbal center even with an offset trim. + + GIVEN: RC1 has endpoints 1000..2000 and a neutral trim of 1480 + WHEN: RC1 reports its neutral value + THEN: The roll position is exactly centered + """ + connected_flight_controller.fc_parameters = {"RC1_TRIM": 1480.0} + model = RCCalibrationDataModel(connected_flight_controller) + connected_flight_controller.master.recv_match.side_effect = [_make_rc_channels_msg([1480]), None] + assert model.get_rc_telemetry()["roll"] == 0.0 + + @pytest.mark.parametrize( + ("parameters", "expected"), + [ + ({}, (1000, 2000, 1500)), + ({"RC1_MIN": nan, "RC1_MAX": 2000}, (1000, 2000, 1500)), + ({"RC1_MIN": 2000, "RC1_MAX": 1000}, (1000, 2000, 1500)), + ({"RC1_MIN": 1500.1, "RC1_MAX": 1500.2}, (1000, 2000, 1500)), + ({"RC1_MIN": 1100, "RC1_MAX": 1900, "RC1_TRIM": 3000}, (1100, 1900, 1500)), + ], + ) + def test_invalid_parameter_limits_use_display_only_defaults(self, parameters, expected) -> None: + """ + Missing or malformed calibration cache entries do not break the display. + + GIVEN: Missing, nonfinite, reversed, or out-of-range calibration parameters + WHEN: Channel display limits are computed + THEN: Finite ordered display defaults are returned + """ + assert RCCalibrationDataModel.channel_limits(parameters, 1) == expected + + @pytest.mark.parametrize("mapping", [0.0, 17.0, 1.5, nan]) + def test_invalid_channel_mapping_centers_the_axis(self, connected_flight_controller, mapping) -> None: + """ + Invalid RC mapping never selects the wrong channel. + + GIVEN: An invalid RCMAP_ROLL value + WHEN: Valid RC telemetry is parsed + THEN: The roll preview remains neutral + """ + connected_flight_controller.fc_parameters = {"RCMAP_ROLL": mapping} + model = RCCalibrationDataModel(connected_flight_controller) + connected_flight_controller.master.recv_match.side_effect = [_make_rc_channels_msg([1000]), None] + assert model.get_rc_telemetry()["roll"] == 0.0 + class TestRCCalibrationDataModelMinMaxTracking: """Test that min/max extremes are tracked only while calibrating.""" @@ -394,13 +586,43 @@ def test_extremes_are_recorded_while_calibrating(self, connected_flight_controll """ model = RCCalibrationDataModel(connected_flight_controller) model.start_calibration() + telemetry: dict = {} for value in (1500, 1200, 1800): msg = _make_rc_channels_msg([value], chancount=1) connected_flight_controller.master.recv_match.side_effect = [msg, None] - model.get_rc_telemetry() + telemetry = model.get_rc_telemetry() assert model._channel_min[0] == 1200 assert model._channel_max[0] == 1800 + assert telemetry["channels"][0]["min"] == 1200 + assert telemetry["channels"][0]["max"] == 1800 + + def test_calibration_extremes_preserve_configured_trim_and_cancel_restores_markers( + self, connected_flight_controller + ) -> None: + """ + Live calibration changes min/max markers but not the configured trim marker. + + GIVEN: Configured endpoints and trim plus an active calibration + WHEN: New extremes arrive and the user then cancels + THEN: Observed endpoints are shown during calibration and configured endpoints return on cancel + """ + connected_flight_controller.fc_parameters = {"RC1_MIN": 1000.0, "RC1_MAX": 2000.0, "RC1_TRIM": 1480.0} + model = RCCalibrationDataModel(connected_flight_controller) + model.start_calibration() + for value in (1200, 1800): + connected_flight_controller.master.recv_match.side_effect = [_make_rc_channels_msg([value]), None] + telemetry = model.get_rc_telemetry() + + assert (telemetry["channels"][0]["min"], telemetry["channels"][0]["max"], telemetry["channels"][0]["trim"]) == ( + 1200, + 1800, + 1480, + ) + model.cancel_calibration() + channel = model.get_channel_calibration()[0] + assert (channel["min"], channel["max"], channel["trim"]) == (1000, 2000, 1480) + connected_flight_controller.set_param.assert_not_called() def test_extremes_are_not_recorded_when_not_calibrating(self, connected_flight_controller) -> None: """ @@ -475,3 +697,428 @@ def test_flight_mode_swallows_recv_match_exception(self, connected_flight_contro connected_flight_controller.master.recv_match.side_effect = RuntimeError("link lost") assert model.get_flight_mode() != "" + + +class TestTransmitterStickModes: + """Check each transmitter mode with distinct axis values rather than identical stick movement.""" + + @pytest.mark.parametrize( + ("mode", "expected"), + [ + (1, ((0.4, -0.2), (0.1, 0.3))), + (2, ((0.4, 0.3), (0.1, -0.2))), + (3, ((0.1, -0.2), (0.4, 0.3))), + (4, ((0.1, 0.3), (0.4, -0.2))), + ], + ) + def test_each_axis_is_assigned_to_the_correct_physical_stick(self, mode: int, expected) -> None: + """ + The four modes each map individual control axes to the correct left/right gimbal coordinates. + + GIVEN: Distinct roll, pitch, throttle and yaw values + WHEN: Physical stick positions are computed for a selected transmitter mode + THEN: Horizontal/vertical coordinates match that mode and pitch is inverted + """ + axes = {"roll": 100.0, "pitch": 200.0, "throttle": 300.0, "yaw": 400.0} + + assert RCCalibrationDataModel.stick_positions(axes, mode) == expected + + def test_out_of_range_stick_inputs_are_clamped_to_the_gimbal_edges(self) -> None: + """ + Unusually large axis input cannot push a circle outside its square. + + GIVEN: Axis readings beyond normal transmitter travel + WHEN: Physical stick positions are computed + THEN: Both coordinates are clamped to the normalised -1..1 range + """ + positions = RCCalibrationDataModel.stick_positions( + {"roll": 5000.0, "pitch": 5000.0, "throttle": -5000.0, "yaw": -5000.0}, 2 + ) + assert positions == ((-1.0, -1.0), (1.0, -1.0)) + + +class TestRCMappingStaging: + """Mapping presets are atomic staged edits and preserve unrelated parameters.""" + + @pytest.mark.parametrize( + ("mode", "expected"), + [(1, (1, 3, 2, 4)), (2, (1, 2, 3, 4)), (3, (4, 3, 2, 1)), (4, (4, 2, 3, 1))], + ) + def test_mode_selection_stages_all_four_parameters_without_controller_writes( + self, rc_staging_context, mode: int, expected: tuple[int, int, int, int] + ) -> None: + """ + Each preset selects the four main RC channel assignments. + + GIVEN: A real editor with an existing Mode-2 assignment + WHEN: The mode is selected twice + THEN: All New values match the preset, repeats are idempotent, and unrelated values and FC cache are unchanged + """ + controller, editor = rc_staging_context + before = dict(controller.fc_parameters) + model = RCCalibrationDataModel(controller, editor) + assert model.set_mapping_mode(mode)[0] is True + reasons = {name: parameter.change_reason_for_file for name, parameter in editor.current_step_parameters.items()} + assert model.set_mapping_mode(mode)[0] is True + assert model.get_mapping_mode() == mode + names = ("RCMAP_ROLL", "RCMAP_PITCH", "RCMAP_THROTTLE", "RCMAP_YAW") + assert tuple(editor.current_step_parameters[name].get_new_value() for name in names) == expected + assert { + name: parameter.change_reason_for_file for name, parameter in editor.current_step_parameters.items() + } == reasons + assert editor.current_step_parameters["ARMING_RUDDER"].get_new_value() == 2 + assert controller.fc_parameters == before + controller.set_param.assert_not_called() + + @pytest.mark.parametrize("protection", ["readonly", "forced", "derived", "range"]) + def test_bulk_mapping_validation_prevents_partial_value_changes(self, rc_staging_context, protection: str) -> None: + """ + A protected or out-of-range mapping prevents the entire preset edit. + + GIVEN: The last mapping parameter rejects the desired value + WHEN: Mode 3 would change the preceding three mappings + THEN: None of the staged values or reasons change + """ + controller, editor = rc_staging_context + metadata: dict[str, Any] = ( + {"ReadOnly": True} if protection == "readonly" else {"min": 2} if protection == "range" else {} + ) + parameter = ArduPilotParameter( + "RCMAP_YAW", + Par(4, "Keep reason"), + metadata=metadata, + forced_par=Par(4, "Forced") if protection == "forced" else None, + derived_par=Par(4, "Derived") if protection == "derived" else None, + ) + editor.current_step_parameters["RCMAP_YAW"] = parameter + before = {name: (par.get_new_value(), par.change_reason) for name, par in editor.current_step_parameters.items()} + success, message = RCCalibrationDataModel(controller, editor).set_mapping_mode(3) + assert success is False + assert message + assert { + name: (par.get_new_value(), par.change_reason) for name, par in editor.current_step_parameters.items() + } == before + controller.set_param.assert_not_called() + + def test_missing_mapping_parameters_are_added_with_real_editor_api(self, rc_staging_context) -> None: + """ + Existing projects need not already contain RCMAP entries in their RC step. + + GIVEN: Only one mapping parameter is staged, with all four known to the FC + WHEN: A mapping preset is selected + THEN: Missing parameters are added as editable New values without controller writes + """ + controller, editor = rc_staging_context + editor.current_step_parameters = {"RCMAP_ROLL": editor.current_step_parameters["RCMAP_ROLL"]} + assert RCCalibrationDataModel(controller, editor).set_mapping_mode(1)[0] is True + assert len(editor.current_step_parameters) == 4 + assert editor.current_step_parameters["RCMAP_PITCH"].get_new_value() == 3 + controller.set_param.assert_not_called() + + def test_failed_parameter_addition_rolls_back_preceding_additions( + self, rc_staging_context: tuple[MagicMock, ParameterEditor], monkeypatch: pytest.MonkeyPatch + ) -> None: + """ + Unsupported mapping parameters cannot leave half-added rows. + + GIVEN: An empty step and a backend unable to add the second mapping + WHEN: A mode preset is selected + THEN: The first addition is removed and no values or FC parameters change + """ + controller, editor = rc_staging_context + editor.current_step_parameters = {} + real_add = editor.add_parameter_to_current_file + + def add(name: str) -> bool: + if name == "RCMAP_PITCH": + message = "Missing" + raise InvalidParameterNameError(message) + return real_add(name) + + monkeypatch.setattr(editor, "add_parameter_to_current_file", add) + success, _message = RCCalibrationDataModel(controller, editor).set_mapping_mode(3) + assert success is False + assert editor.current_step_parameters == {} + controller.set_param.assert_not_called() + + def test_manual_override_reason_marker_is_preserved_once(self, rc_staging_context) -> None: + """ + A permitted derived-parameter override keeps its serialization marker. + + GIVEN: A manually overridden derived mapping parameter + WHEN: A mapping mode is staged repeatedly + THEN: The override remains active and its reason contains one override marker + """ + controller, editor = rc_staging_context + parameter = ArduPilotParameter("RCMAP_PITCH", Par(2, "Original"), derived_par=Par(2, "Derived")) + parameter.set_manual_override(True) + editor.current_step_parameters["RCMAP_PITCH"] = parameter + model = RCCalibrationDataModel(controller, editor) + assert model.set_mapping_mode(1)[0] is True + assert model.set_mapping_mode(1)[0] is True + assert parameter.is_manual_override + assert parameter.change_reason_for_file.count("@manual_override") == 1 + + def test_invalid_mode_leaves_all_parameters_untouched(self, rc_staging_context) -> None: + """ + Invalid mode input is rejected safely. + + GIVEN: A valid staged mapping + WHEN: An unsupported mode number is submitted + THEN: The existing mode remains unchanged with no controller writes + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + assert model.set_mapping_mode(0)[0] is False + assert model.get_mapping_mode() == 2 + controller.set_param.assert_not_called() + + def test_factory_injects_parameter_editor_staging_dependency(self, rc_staging_context) -> None: + """ + Application plugin construction uses the same staging objects as the parameter table. + + GIVEN: The application plugin context + WHEN: The RC model factory runs + THEN: A mode change edits the context's current-step parameters + """ + controller, editor = rc_staging_context + model = _create_rc_calibration_model(PluginModelContext(controller, MagicMock(), editor)) + assert model.set_mapping_mode(4)[0] is True + assert editor.current_step_parameters["RCMAP_YAW"].get_new_value() == 1 + controller.set_param.assert_not_called() + + def test_staged_mapping_is_sent_only_by_the_existing_parameter_upload_workflow( + self, rc_staging_context, monkeypatch + ) -> None: + """ + Staged plugin edits flow through the application's explicit upload workflow. + + GIVEN: A selected mapping preset and completed calibration staged in New values + WHEN: The parameter editor upload workflow is explicitly invoked + THEN: The mapping and calibration are sent via the backend only at that point + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + assert model.set_mapping_mode(1)[0] is True + model._channel_min = {0: 1100} + model._channel_max = {0: 1900} + assert model.finish_calibration()[0] is True + controller.set_param.assert_not_called() + names = ["RCMAP_ROLL", "RCMAP_PITCH", "RCMAP_THROTTLE", "RCMAP_YAW", "RC1_MIN", "RC1_MAX", "RC1_TRIM"] + values = editor.get_parameters_as_par_dict(names) + + def set_param(name: str, value: float) -> tuple[bool, str]: + controller.fc_parameters[name] = value + return True, "" + + controller.set_param.side_effect = set_param + # Isolate only reset/readback I/O; run the real upload and parameter verification. + monkeypatch.setattr(editor, "upload_parameters_that_require_reset_workflow", lambda *_args: (False, set(), True)) + monkeypatch.setattr( + editor, "download_flight_controller_parameters", lambda *_args, **_kwargs: (controller.fc_parameters, {}) + ) + assert editor.upload_selected_params_workflow( + values, MagicMock(), MagicMock(return_value=False), MagicMock(), persist_project_state=False + ) + assert {call.args[0]: call.args[1] for call in controller.set_param.call_args_list} == { + "RCMAP_ROLL": 1, + "RCMAP_PITCH": 3, + "RCMAP_THROTTLE": 2, + "RCMAP_YAW": 4, + "RC1_MIN": 1100, + "RC1_MAX": 1900, + "RC1_TRIM": 1500, + } + + def test_failed_calibration_staging_retains_measurements_and_all_prior_values(self, rc_staging_context) -> None: + """ + Rejected calibration measurements can be retried without losing previous New values. + + GIVEN: Recorded extrema and a read-only trim parameter + WHEN: Finish Calibration attempts to stage the batch + THEN: No New values are changed and measurements remain available for retry or cancellation + """ + controller, editor = rc_staging_context + editor.current_step_parameters["RC1_TRIM"]._metadata["ReadOnly"] = True + model = RCCalibrationDataModel(controller, editor) + model._channel_min = {0: 1100} + model._channel_max = {0: 1900} + assert model.finish_calibration()[0] is False + assert editor.current_step_parameters["RC1_MIN"].get_new_value() == 1000 + assert editor.current_step_parameters["RC1_MAX"].get_new_value() == 2000 + assert model._channel_min == {0: 1100} + controller.set_param.assert_not_called() + + +class TestRCChannelOptionProtection: + """Channel-option protection follows the effective mapping, not fixed channel numbers.""" + + @pytest.mark.parametrize("channel_number", [7, 11]) + def test_unsupported_auxiliary_option_does_not_change_or_add_parameter_rows( + self, rc_staging_context: tuple[MagicMock, ParameterEditor], channel_number: int + ) -> None: + """ + Only options advertised by the loaded firmware metadata can be staged. + + GIVEN: A free channel with an existing or missing RCx_OPTION row + WHEN: An option value absent from its firmware choices is requested + THEN: The request fails without changing New values, adding rows, or writing to the FC + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + original_values = {name: parameter.get_new_value() for name, parameter in editor.current_step_parameters.items()} + original_cache = dict(controller.fc_parameters) + + success, message = model.set_channel_option(channel_number, "999") + + assert success is False + assert "valid RC channel option" in message + staged_values = {name: parameter.get_new_value() for name, parameter in editor.current_step_parameters.items()} + assert staged_values == original_values + assert controller.fc_parameters == original_cache + controller.set_param.assert_not_called() + + @pytest.mark.parametrize(("axis", "original_channel"), [("ROLL", 1), ("PITCH", 2), ("THROTTLE", 3), ("YAW", 4)]) + @pytest.mark.parametrize("mapped_channel", [6, 16]) + def test_user_cannot_assign_an_auxiliary_option_to_a_remapped_primary_control( + self, rc_staging_context: tuple[MagicMock, ParameterEditor], axis: str, original_channel: int, mapped_channel: int + ) -> None: + """ + Remapping a primary control moves its option protection to the new channel. + + GIVEN: A primary axis is staged on an auxiliary channel + WHEN: The user attempts to assign an option to that channel and to its former channel + THEN: The primary channel stays locked while the freed channel accepts a staged option without FC writes + """ + controller, editor = rc_staging_context + editor.current_step_parameters[f"RCMAP_{axis}"].set_new_value(str(mapped_channel)) + model = RCCalibrationDataModel(controller, editor) + + channels = model.get_channel_calibration() + success, message = model.set_channel_option(mapped_channel, "300") + + assert channels[mapped_channel - 1]["function"] == axis.title() + assert channels[mapped_channel - 1]["function_editable"] is False + assert success is False + assert message + assert editor.current_step_parameters[f"RC{mapped_channel}_OPTION"].get_new_value() == 0 + assert channels[original_channel - 1]["function_editable"] is True + assert model.set_channel_option(original_channel, "300")[0] is True + assert editor.current_step_parameters[f"RC{original_channel}_OPTION"].get_new_value() == 300 + assert controller.fc_parameters[f"RCMAP_{axis}"] == original_channel + controller.set_param.assert_not_called() + + def test_downloaded_mapping_protects_primary_channels_without_staged_mapping_rows( + self, rc_staging_context: tuple[MagicMock, ParameterEditor] + ) -> None: + """ + Downloaded custom mappings are respected when no mapping edit is staged. + + GIVEN: The FC maps roll to CH6 and the step has no RCMAP_ROLL row + WHEN: Channel functions are queried and a CH6 option is requested + THEN: CH6 is locked and CH1 remains available for an auxiliary option + """ + controller, editor = rc_staging_context + controller.fc_parameters["RCMAP_ROLL"] = 6 + del editor.current_step_parameters["RCMAP_ROLL"] + model = RCCalibrationDataModel(controller, editor) + + assert model.get_channel_calibration()[5]["function_editable"] is False + assert model.set_channel_option(6, "300")[0] is False + assert model.set_channel_option(1, "300")[0] is True + controller.set_param.assert_not_called() + + def test_rover_mode_channel_takes_priority_over_flight_mode_channel( + self, rc_staging_context: tuple[MagicMock, ParameterEditor] + ) -> None: + """ + Rover's MODE_CH determines which channel is shown and protected as Mode. + + GIVEN: MODE_CH selects CH8 and FLTMODE_CH still names CH5 + WHEN: The user views channels and tries to set auxiliary options + THEN: CH8 is Mode and locked, while CH5 is available + """ + controller, editor = rc_staging_context + controller.fc_parameters["MODE_CH"] = 8.0 + model = RCCalibrationDataModel(controller, editor) + + channels = model.get_channel_calibration() + + assert channels[7]["function"] == "Mode" + assert channels[7]["function_editable"] is False + assert channels[4]["function"] == "" + assert channels[4]["function_editable"] is True + assert model.set_channel_option(8, "300")[0] is False + assert model.set_channel_option(5, "300")[0] is True + assert editor.current_step_parameters["RC5_OPTION"].get_new_value() == 300 + controller.set_param.assert_not_called() + + def test_staged_rover_mode_channel_updates_display_and_option_protection( + self, rc_staging_context: tuple[MagicMock, ParameterEditor] + ) -> None: + """ + A pending MODE_CH edit immediately changes the protected channel. + + GIVEN: The FC uses CH8 for Mode and the editor stages MODE_CH=9 + WHEN: The user views channels and sets an auxiliary option + THEN: CH9 is protected and CH8 becomes available without an FC write + """ + controller, editor = rc_staging_context + controller.fc_parameters["MODE_CH"] = 8.0 + editor.current_step_parameters["MODE_CH"] = ArduPilotParameter("MODE_CH", Par(8.0, "Existing reason"), fc_value=8.0) + editor.current_step_parameters["MODE_CH"].set_new_value("9") + model = RCCalibrationDataModel(controller, editor) + + channels = model.get_channel_calibration() + + assert channels[8]["function"] == "Mode" + assert channels[8]["function_editable"] is False + assert channels[7]["function"] == "" + assert channels[7]["function_editable"] is True + assert model.set_channel_option(9, "300")[0] is False + assert model.set_channel_option(8, "300")[0] is True + assert controller.fc_parameters["MODE_CH"] == 8.0 + controller.set_param.assert_not_called() + + @pytest.mark.parametrize("mode_channel", [nan, float("inf"), -1.0, 17.0, 8.5]) + def test_invalid_rover_mode_channel_uses_channel_eight( + self, rc_staging_context: tuple[MagicMock, ParameterEditor], mode_channel: float + ) -> None: + """ + An invalid Rover mode setting falls back to the Rover default. + + GIVEN: MODE_CH is non-finite, out of range, or fractional + WHEN: Channel functions and editability are queried + THEN: CH8 is shown and protected as Mode + """ + controller, editor = rc_staging_context + controller.fc_parameters["MODE_CH"] = mode_channel + model = RCCalibrationDataModel(controller, editor) + + channels = model.get_channel_calibration() + + assert channels[7]["function"] == "Mode" + assert channels[7]["function_editable"] is False + assert model.set_channel_option(8, "300")[0] is False + controller.set_param.assert_not_called() + + def test_rover_mode_channel_zero_leaves_auxiliary_channels_available( + self, rc_staging_context: tuple[MagicMock, ParameterEditor] + ) -> None: + """ + Disabling Rover's mode channel leaves no auxiliary channel reserved for it. + + GIVEN: MODE_CH is zero + WHEN: The user views channels and assigns an option to CH8 + THEN: No channel is marked Mode and CH8 accepts the option + """ + controller, editor = rc_staging_context + controller.fc_parameters["MODE_CH"] = 0.0 + model = RCCalibrationDataModel(controller, editor) + + channels = model.get_channel_calibration() + + assert all("Mode" not in channel["function"] for channel in channels) + assert channels[7]["function_editable"] is True + assert model.set_channel_option(8, "300")[0] is True + controller.set_param.assert_not_called() diff --git a/tests/plugins/test_frontend_tkinter_rc_calibration.py b/tests/plugins/test_frontend_tkinter_rc_calibration.py new file mode 100755 index 000000000..1dd60e48c --- /dev/null +++ b/tests/plugins/test_frontend_tkinter_rc_calibration.py @@ -0,0 +1,861 @@ +#!/usr/bin/env python3 +""" +BDD tests for the RC calibration frontend. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2026 ArduPilot Contributors + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import tkinter as tk +from collections.abc import Generator +from tkinter import ttk +from typing import Any, cast +from unittest.mock import MagicMock, patch + +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 +from ardupilot_methodic_configurator.frontend_tkinter_base_window import BaseWindow +from ardupilot_methodic_configurator.frontend_tkinter_calibration_popup_base import CalibrationPopupBase +from ardupilot_methodic_configurator.plugins.data_model_rc_calibration import RCCalibrationDataModel +from ardupilot_methodic_configurator.plugins.frontend_tkinter_rc_calibration import ( + RCCalibrationPopup, + RCCalibrationView, + RCChannelBars, + RCCraftPreview, + RCStickPreview, +) + +# pylint: disable=protected-access,redefined-outer-name + + +def _label_texts(widget: tk.Misc) -> list[str]: + """Collect displayed label text throughout a monitor's real widget hierarchy.""" + texts = [str(widget.cget("text"))] if isinstance(widget, (ttk.Label, tk.Label)) else [] + for child in widget.winfo_children(): + texts.extend(_label_texts(child)) + return texts + + +@pytest.fixture +def rc_channel_bars(tk_root: tk.Tk) -> Generator[RCChannelBars, None, None]: + """Create and clean up the persistent 16-channel status display.""" + channel_bars = RCChannelBars(tk_root) + channel_bars.pack(fill="x", expand=True) + tk_root.update_idletasks() + yield channel_bars + channel_bars.destroy() + + +class TestRCChannelBars: + """Test the user's live channel-value and calibration-marker display.""" + + def test_user_sees_sixteen_channel_rows_and_pwm_values(self, rc_channel_bars: RCChannelBars) -> None: + """ + The status display always provides a row for each of the first 16 RC inputs. + + GIVEN: A connected receiver reports CH1 and CH16 but no values for other rows + WHEN: The live channel telemetry is displayed + THEN: All 16 rows remain visible with PWM values beside the matching bars + """ + channels = [ + { + "name": f"CH{channel_number}", + "value": 1525 if channel_number in (1, 16) else None, + "min": None, + "max": None, + } + for channel_number in range(1, 17) + ] + + rc_channel_bars.update_channels(channels) + + assert len(rc_channel_bars._value_labels) == 16 + assert rc_channel_bars._value_labels[1].cget("text") == "1525 µs" + assert rc_channel_bars._value_labels[2].cget("text") == "No Data" + assert rc_channel_bars._value_labels[16].cget("text") == "1525 µs" + assert rc_channel_bars._bar_canvases[1].find_withtag("live_value") + + def test_user_sees_primary_mapping_and_locked_flight_mode_function( + self, + tk_root: tk.Tk, + rc_staging_context: tuple[MagicMock, ParameterEditor], + ) -> None: + """ + Primary channels and the flight-mode channel are shown as fixed functions. + + GIVEN: A Mode-2 RC mapping and FLTMODE_CH set to channel 5 + WHEN: The channel monitor is constructed with loaded RCx_OPTION metadata + THEN: The first four show their mapped axes and CH5 shows Mode, with selectors only on other channels + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + channel_bars = RCChannelBars(tk_root, model) + try: + assert [channel_bars._function_labels[number].cget("text") for number in range(1, 5)] == [ + "Roll", + "Pitch", + "Throttle", + "Yaw", + ] + assert channel_bars._function_labels[5].cget("text") == "Mode" + assert set(channel_bars._option_selectors) == set(range(6, 17)) + assert str(channel_bars._option_selectors[6].cget("state")) == "readonly" + assert "6: AutoTune" in channel_bars._option_selectors[6].cget("values") + assert "300: QuickTune" in channel_bars._option_selectors[6].cget("values") + assert "300: QuickTune" in channel_bars._option_selectors[11].cget("values") + finally: + channel_bars.destroy() + + @pytest.mark.parametrize("protection", ["readonly", "forced", "derived"]) + def test_user_sees_a_locked_auxiliary_function_after_refresh( + self, tk_root: tk.Tk, rc_staging_context: tuple[MagicMock, ParameterEditor], protection: str + ) -> None: + """ + A locked auxiliary function remains visible through repeated channel refreshes. + + GIVEN: CH5 is a protected Arm/Disarm option and the flight-mode switch is on CH6 + WHEN: The monitor is created and refreshed + THEN: CH5 displays Arm/Disarm without an editable selector and no FC write occurs + """ + controller, editor = rc_staging_context + editor.current_step_parameters["FLTMODE_CH"].set_new_value("6") + editor.current_step_parameters["RC5_OPTION"] = ArduPilotParameter( + "RC5_OPTION", + Par(1, "ExpressLRS arming"), + metadata={"values": {"0": "Disabled", "1": "Arm/Disarm"}, "ReadOnly": protection == "readonly"}, + fc_value=1, + forced_par=Par(1, "Required") if protection == "forced" else None, + derived_par=Par(1, "Required") if protection == "derived" else None, + ) + model = RCCalibrationDataModel(controller, editor) + channel_bars = RCChannelBars(tk_root, model) + try: + assert channel_bars._function_labels[5].cget("text") == "Arm/Disarm" + + channel_bars.update_channels(model.get_channel_calibration()) + + assert channel_bars._function_labels[5].cget("text") == "Arm/Disarm" + assert 5 not in channel_bars._option_selectors + controller.set_param.assert_not_called() + finally: + channel_bars.destroy() + + @pytest.mark.parametrize("mapping", [("RCMAP_ROLL", 1, "Roll"), ("FLTMODE_CH", 5, "Mode")]) + def test_user_can_edit_the_freed_channel_after_control_mapping_changes( + self, + tk_root: tk.Tk, + rc_staging_context: tuple[MagicMock, ParameterEditor], + mapping: tuple[str, int, str], + ) -> None: + """ + Changing a control channel updates both locked labels and editable selectors. + + GIVEN: An existing monitor with CH6 available for an auxiliary function + WHEN: A primary control or flight-mode switch moves to CH6 and later moves back + THEN: The new control channel is locked, the freed channel can stage an option, and moving back restores the controls + """ + controller, editor = rc_staging_context + parameter_name, original_channel, function = mapping + model = RCCalibrationDataModel(controller, editor) + channel_bars = RCChannelBars(tk_root, model) + try: + original_canvas = channel_bars._bar_canvases[original_channel] + channel_six_canvas = channel_bars._bar_canvases[6] + editor.current_step_parameters[parameter_name].set_new_value("6") + + channel_bars.update_channels(model.get_channel_calibration()) + + assert 6 not in channel_bars._option_selectors + assert channel_bars._function_labels[6].cget("text") == function + assert original_channel not in channel_bars._function_labels + selector = channel_bars._option_selectors[original_channel] + assert str(selector.cget("state")) == "readonly" + assert list(channel_bars._channel_rows[original_channel].pack_slaves())[1:3] == [ + selector, + channel_bars._value_labels[original_channel], + ] + selector.set("300: QuickTune") + selector.event_generate("<>") + assert editor.current_step_parameters[f"RC{original_channel}_OPTION"].get_new_value() == 300 + + editor.current_step_parameters[parameter_name].set_new_value(str(original_channel)) + channel_bars.update_channels(model.get_channel_calibration()) + + assert channel_bars._function_labels[original_channel].cget("text") == function + assert original_channel not in channel_bars._option_selectors + assert str(channel_bars._option_selectors[6].cget("state")) == "readonly" + assert list(channel_bars._channel_rows[6].pack_slaves())[1:3] == [ + channel_bars._option_selectors[6], + channel_bars._value_labels[6], + ] + assert channel_bars._bar_canvases[original_channel] is original_canvas + assert channel_bars._bar_canvases[6] is channel_six_canvas + controller.set_param.assert_not_called() + finally: + channel_bars.destroy() + + def test_user_can_resume_editing_an_option_after_manual_override_is_enabled( + self, tk_root: tk.Tk, rc_staging_context: tuple[MagicMock, ParameterEditor] + ) -> None: + """ + Parameter edit locks are reconciled without rebuilding the monitor. + + GIVEN: CH6 initially has an editable option + WHEN: It becomes derived and the user later enables its manual override + THEN: A fixed function label replaces the selector while locked and editing works again after override + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + channel_bars = RCChannelBars(tk_root, model) + try: + parameter = ArduPilotParameter( + "RC6_OPTION", + Par(300, "Existing option"), + metadata={"values": {"0": "Disabled", "300": "QuickTune"}}, + fc_value=0, + derived_par=Par(300, "Derived option"), + ) + editor.current_step_parameters["RC6_OPTION"] = parameter + + channel_bars.update_channels(model.get_channel_calibration()) + + assert 6 not in channel_bars._option_selectors + assert channel_bars._function_labels[6].cget("text") == "QuickTune" + parameter.set_manual_override(True) + channel_bars.update_channels(model.get_channel_calibration()) + selector = channel_bars._option_selectors[6] + assert str(selector.cget("state")) == "readonly" + assert selector.get() == "300: QuickTune" + selector.set("0: Disabled") + selector.event_generate("<>") + assert parameter.get_new_value() == 0 + assert parameter.change_reason_for_file.count("@manual_override") == 1 + controller.set_param.assert_not_called() + finally: + channel_bars.destroy() + + def test_user_can_stage_auxiliary_option_without_writing_to_controller( + self, + tk_root: tk.Tk, + rc_staging_context: tuple[MagicMock, ParameterEditor], + ) -> None: + """ + Selecting an auxiliary-channel function edits New values only. + + GIVEN: A Mode-2 mapping and loaded RCx_OPTION choices + WHEN: The user selects QuickTune for channel 6 + THEN: RC6_OPTION is staged, the channel row refreshes, and no FC write occurs + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + refreshed = MagicMock() + channel_bars = RCChannelBars(tk_root, model, refreshed) + try: + selector = channel_bars._option_selectors[6] + selector.set("300: QuickTune") + selector.event_generate("<>") + + assert editor.current_step_parameters["RC6_OPTION"].get_new_value() == 300 + assert "upload" in channel_bars._feedback_label.cget("text") + refreshed.assert_called_once() + controller.set_param.assert_not_called() + finally: + channel_bars.destroy() + + def test_user_can_add_a_missing_option_parameter_to_the_current_step( + self, + tk_root: tk.Tk, + rc_staging_context: tuple[MagicMock, ParameterEditor], + ) -> None: + """ + A missing RCx_OPTION row is added only when the user chooses that channel's function. + + GIVEN: CH11 option choices from loaded metadata but no current-step RC11_OPTION row + WHEN: The user selects QuickTune for CH11 + THEN: The row is added and staged without issuing a flight-controller write + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + channel_bars = RCChannelBars(tk_root, model) + try: + assert "RC11_OPTION" not in editor.current_step_parameters + selector = channel_bars._option_selectors[11] + selector.set("300: QuickTune") + selector.event_generate("<>") + + assert editor.current_step_parameters["RC11_OPTION"].get_new_value() == 300 + controller.set_param.assert_not_called() + finally: + channel_bars.destroy() + + def test_user_sees_green_minimum_and_red_maximum_markers_over_the_live_bar( + self, rc_channel_bars: RCChannelBars, tk_root: tk.Tk + ) -> None: + """ + Calibration extremes are drawn as differently colored vertical markers on the channel bar. + + GIVEN: CH1 has a live value of 1500 µs and tracked extremes of 1200-1800 µs + WHEN: The channel status display is refreshed + THEN: Green MIN and red MAX markers bracket the live value + """ + channels: list[dict[str, Any]] = [ + {"name": f"CH{number}", "value": None, "min": None, "max": None} for number in range(1, 17) + ] + channels[0] = {"name": "CH1", "value": 1500, "min": 1200, "max": 1800} + + rc_channel_bars.update_channels(channels) + tk_root.update_idletasks() + + canvas = rc_channel_bars._bar_canvases[1] + minimum_marker = canvas.find_withtag("min_marker") + maximum_marker = canvas.find_withtag("max_marker") + + assert len(minimum_marker) == 1 + assert len(maximum_marker) == 1 + assert canvas.itemcget(minimum_marker[0], "fill") == "green" + assert canvas.itemcget(maximum_marker[0], "fill") == "red" + minimum_x = canvas.coords(minimum_marker[0])[0] + live_x = canvas.coords(canvas.find_withtag("live_value")[0])[2] + maximum_x = canvas.coords(maximum_marker[0])[0] + assert minimum_x < live_x < maximum_x + + def test_user_sees_min_max_trim_markers_before_starting_calibration(self, rc_channel_bars: RCChannelBars) -> None: + """ + Default MIN/MAX/TRIM markers remain visible before calibration starts. + + GIVEN: A channel has live telemetry but no recorded calibration or loaded parameters + WHEN: The status display is updated + THEN: It draws the live bar and labelled default markers + """ + channels: list[dict[str, Any]] = [ + {"name": f"CH{number}", "value": None, "min": None, "max": None} for number in range(1, 17) + ] + channels[0]["value"] = 1500 + + rc_channel_bars.update_channels(channels) + + canvas = rc_channel_bars._bar_canvases[1] + assert canvas.find_withtag("live_value") + assert len(canvas.find_withtag("calibration_marker")) == 3 + for name, color in (("min", "green"), ("trim", "blue"), ("max", "red")): + assert canvas.itemcget(canvas.find_withtag(f"{name}_marker")[0], "fill") == color + assert canvas.itemcget(canvas.find_withtag(f"{name}_label")[0], "text") == name.upper() + + def test_float_parameter_markers_are_drawn_above_the_live_bar(self, rc_channel_bars: RCChannelBars) -> None: + """ + Float-valued calibration parameters produce visible markers at their actual positions. + + GIVEN: A calibrated channel with float MIN/MAX/TRIM parameters + WHEN: The status display is refreshed + THEN: Three colored marker lines are drawn over the bar and ordered MIN < TRIM < MAX + """ + rc_channel_bars.update_channels([{"name": "CH1", "value": 1500, "min": 982.0, "max": 2018.0, "trim": 1480.0}]) + canvas = rc_channel_bars._bar_canvases[1] + markers = [canvas.find_withtag(f"{name}_marker")[0] for name in ("min", "trim", "max")] + positions = [canvas.coords(marker)[0] for marker in markers] + assert positions[0] < positions[1] < positions[2] + assert all( + canvas.find_all().index(marker) > canvas.find_all().index(canvas.find_withtag("live_value")[0]) + for marker in markers + ) + + def test_marker_labels_appear_only_above_the_first_channel(self, rc_channel_bars: RCChannelBars) -> None: + """ + A single marker legend leaves the other fifteen channel rows compact. + + GIVEN: All sixteen channels have MIN/TRIM/MAX markers + WHEN: The rows are drawn and then resized + THEN: Only CH1 has colored text above its track while every row retains all three markers + """ + for number, canvas in rc_channel_bars._bar_canvases.items(): + rc_channel_bars._draw_channel(number, width=450) + track_top = canvas.coords(canvas.find_withtag("track")[0])[1] + for name, color in (("min", "green"), ("trim", "blue"), ("max", "red")): + marker = canvas.find_withtag(f"{name}_marker") + assert len(marker) == 1 + assert canvas.itemcget(marker[0], "fill") == color + label = canvas.find_withtag(f"{name}_label") + if number == 1: + assert len(label) == 1 + assert canvas.coords(label[0])[1] < track_top + assert canvas.itemcget(label[0], "fill") == color + else: + assert not label + + +@pytest.fixture +def stick_preview(tk_root: tk.Tk) -> Generator[RCStickPreview, None, None]: + """Create a real stick preview without forcing the physical event loop.""" + preview = RCStickPreview(tk_root) + preview.pack() + tk_root.update_idletasks() + yield preview + preview.destroy() + + +class TestRCStickPreview: + """Check physical transmitter stick geometry, mode selection and retained telemetry.""" + + @pytest.mark.parametrize("mode", [1, 2, 3, 4]) + def test_centered_sticks_show_two_centered_red_circles_one_twelfth_the_square_size( + self, stick_preview: RCStickPreview, mode: int + ) -> None: + """ + Both stick circles are centered and correctly sized in every transmitter mode. + + GIVEN: Four centered axes and a selected transmitter Mode 1-4 + WHEN: The preview is updated + THEN: Two side-by-side white squares have centered red circles with a 1/12 diameter ratio + """ + stick_preview._mode.set(mode) + stick_preview.update_axes({"roll": 0.0, "pitch": 0.0, "throttle": 0.0, "yaw": 0.0}) + + assert len(stick_preview._canvases) == 2 + assert stick_preview.cget("text") == "Remote controller stick preview" + for column, canvas in enumerate(stick_preview._canvases): + square = canvas.coords(canvas.find_withtag("gimbal")[0]) + circle = canvas.coords(canvas.find_withtag("stick")[0]) + size = square[2] - square[0] + assert square[3] - square[1] == size + assert size == 126 + assert canvas.itemcget(canvas.find_withtag("gimbal")[0], "fill") == "white" + assert canvas.itemcget(canvas.find_withtag("stick")[0], "fill") == "red" + assert (circle[0] + circle[2]) / 2 == (square[0] + square[2]) / 2 + assert (circle[1] + circle[3]) / 2 == (square[1] + square[3]) / 2 + assert circle[2] - circle[0] == pytest.approx(size / 12) + assert int(canvas.grid_info()["column"]) == column + + @pytest.mark.parametrize( + ("mode", "left_axes", "right_axes"), + [ + (1, ("yaw", "pitch"), ("roll", "throttle")), + (2, ("yaw", "throttle"), ("roll", "pitch")), + (3, ("roll", "pitch"), ("yaw", "throttle")), + (4, ("roll", "throttle"), ("yaw", "pitch")), + ], + ) + def test_sticks_move_to_the_correct_corners_for_the_selected_mode( + self, stick_preview: RCStickPreview, mode: int, left_axes: tuple[str, str], right_axes: tuple[str, str] + ) -> None: + """ + Stick mode selects the physical axes on each gimbal. + + GIVEN: Full right/up input on the selected physical stick axes + WHEN: The user selects a transmitter mode + THEN: Both circles reach the top-right corner without crossing the white square boundaries + """ + stick_preview._mode.set(mode) + sample = dict.fromkeys((*left_axes, *right_axes), 1000.0) + sample["pitch"] = -1000.0 # Pushing forward commands negative pitch. + stick_preview.update_axes(sample) + + for canvas in stick_preview._canvases: + square = canvas.coords(canvas.find_withtag("gimbal")[0]) + circle = canvas.coords(canvas.find_withtag("stick")[0]) + assert circle[2] == pytest.approx(square[2]) + assert circle[1] == pytest.approx(square[1]) + + def test_changing_mode_remaps_the_last_live_sample_immediately(self, stick_preview: RCStickPreview) -> None: + """ + The selector remaps current sticks without waiting for another RC packet. + + GIVEN: Mode 2 with a raised throttle and centered other axes + WHEN: The user selects Mode 1 + THEN: The raised throttle moves from the left gimbal to the right gimbal + """ + stick_preview.update_axes({"roll": 0.0, "pitch": 0.0, "throttle": 1000.0, "yaw": 0.0}) + left, right = stick_preview._canvases + before_left = left.coords(left.find_withtag("stick")[0]) + before_right = right.coords(right.find_withtag("stick")[0]) + stick_preview.mode_selector.set("1") + stick_preview.mode_selector.event_generate("<>") + assert left.coords(left.find_withtag("stick")[0]) == before_right + assert right.coords(right.find_withtag("stick")[0]) == before_left + + def test_heartbeat_only_update_preserves_stick_positions(self, stick_preview: RCStickPreview) -> None: + """ + Heartbeat packets do not falsely center the transmitter sticks. + + GIVEN: The monitor has displayed a displaced stick + WHEN: Only a flight-mode update arrives + THEN: The last stick positions are unchanged + """ + stick_preview.update_axes({"yaw": 1000.0}) + canvas = stick_preview._canvases[0] + before = canvas.coords(canvas.find_withtag("stick")[0]) + stick_preview.update_axes({"flight_mode": "3"}) + assert canvas.coords(canvas.find_withtag("stick")[0]) == before + + +def test_embedded_monitor_initialises_all_markers_and_preserves_channels_on_heartbeat(tk_root: tk.Tk) -> None: + """ + The real embedded view displays configured markers immediately and keeps RC state on heartbeat-only polls. + + GIVEN: A connected backend and cached channel calibration + WHEN: The view opens, receives RC telemetry, and then receives only a heartbeat + THEN: Markers, sticks and live values are retained without displaying a flight-mode readout + """ + controller = MagicMock() + controller.fc_parameters = {"RC1_MIN": 990.0, "RC1_MAX": 2010.0, "RC1_TRIM": 1490.0} + controller.master.recv_match.return_value = None + parent = tk.Frame(tk_root) + view = RCCalibrationView(parent, RCCalibrationDataModel(controller), MagicMock(spec=BaseWindow)) + try: + assert len(view.stick_preview._canvases) == 2 + assert isinstance(view.craft_preview, RCCraftPreview) + assert view.craft_preview.cget("text") == "Live monitor" + canvas = view.channel_bars._bar_canvases[1] + assert len(canvas.find_withtag("calibration_marker")) == 3 + message = MagicMock(chancount=4) + for channel_number in range(1, 17): + setattr(message, f"chan{channel_number}_raw", 1600 if channel_number == 1 else 1500) + controller.master.recv_match.side_effect = [message, None] + view._stop_polling() + view._check_telemetry() + before = view.channel_bars._value_labels[1].cget("text") + heartbeat = MagicMock(custom_mode=3) + controller.master.recv_match.side_effect = [None, heartbeat] + view._stop_polling() + view._check_telemetry() + assert before == "1600 µs" + assert view.channel_bars._value_labels[1].cget("text") == before + assert len(canvas.find_withtag("calibration_marker")) == 3 + assert not any("Flight Mode:" in text for text in _label_texts(view)) + assert view.craft_preview._axes["roll"] != 0 + controller.set_param.assert_not_called() + finally: + view.destroy() + parent.destroy() + + +def test_embedded_monitor_aligns_sections_with_documentation_and_moves_scrollbar_to_pane_edge(tk_root: tk.Tk) -> None: + """ + The monitor uses the documentation margin rather than an oversized nested border. + + GIVEN: Documentation and an RC monitor in the same fixed-width editor area + WHEN: Tk lays out the embedded monitor without running the event loop + THEN: Sections share Documentation's outer margin with no extra inset and a scrollbar at the pane edge + """ + controller = MagicMock() + controller.fc_parameters = {} + controller.master.recv_match.return_value = None + host = ttk.Frame(tk_root) + # Place assigns deterministic geometry even when the test root is withdrawn. + host.place(x=0, y=0, width=700, height=500) + documentation = ttk.LabelFrame(host, text="Documentation") + documentation.pack(fill="x", padx=4, pady=2) + parent = ttk.Frame(host) + parent.pack(fill="both", expand=True) + view = RCCalibrationView(parent, RCCalibrationDataModel(controller), MagicMock(spec=BaseWindow)) + view.pack(fill="both", expand=True) + try: + tk_root.update_idletasks() + sections = (view.craft_preview, view.stick_preview, view.channel_bars) + scrollbar = view._scroll_frame.vsb + assert view._scroll_frame.canvas.coords(view._scroll_frame.canvas_window) == [0.0, 0.0] + assert view._scroll_frame.winfo_x() == documentation.winfo_x() == 4 + assert view._scroll_frame.winfo_width() == documentation.winfo_width() + assert view._scroll_frame.winfo_x() + scrollbar.winfo_x() + scrollbar.winfo_width() == view.winfo_width() - 4 + assert scrollbar.pack_info()["side"] == "right" + assert view._scroll_frame.view_port.winfo_x() == 0 + assert view._scroll_frame.view_port.winfo_y() == 0 + for section in sections: + assert section.pack_info()["padx"] == 0 + assert section.pack_info()["fill"] == "x" + assert tuple(int(str(value)) for value in section.cget("padding")) == (4,) + assert section.pack_info()["pady"] == (0, 4) + assert not any("Flight Mode:" in text for text in _label_texts(view)) + finally: + view.destroy() + host.destroy() + + +def test_floating_monitor_shares_the_stick_boxes_and_three_channel_markers(tk_root: tk.Tk) -> None: + """ + The floating live monitor presents the same markers and stick preview as the embedded view. + + GIVEN: A hidden test window and a model with channel calibration parameters + WHEN: The popup is created using its real UI construction + THEN: Compact sections show sticks and markers without a flight-mode readout, and polling is cancelled on destroy + """ + controller = MagicMock() + controller.fc_parameters = {"RC1_MIN": 990.0, "RC1_MAX": 2010.0, "RC1_TRIM": 1490.0} + controller.master.recv_match.return_value = None + root = tk.Toplevel(tk_root) + root.withdraw() + parent = tk.Toplevel(tk_root) + parent.withdraw() + + def init_hidden_base(window: BaseWindow, _root_tk: tk.Toplevel) -> None: + window.root = root + window.main_frame = ttk.Frame(root) + window.main_frame.pack(fill="both", expand=True) + + try: + with ( + patch.object(BaseWindow, "__init__", init_hidden_base), + patch.object(CalibrationPopupBase, "_resize_and_center"), + patch.object(root, "lift"), + patch.object(root, "focus_force"), + ): + popup = RCCalibrationPopup(cast("tk.Widget", parent), RCCalibrationDataModel(controller)) + assert len(popup.stick_preview._canvases) == 2 + assert isinstance(popup.craft_preview, RCCraftPreview) + assert len(popup.channel_bars._bar_canvases) == 16 + assert all(len(canvas.find_withtag("calibration_marker")) == 3 for canvas in popup.channel_bars._bar_canvases.values()) + content_frame = cast("ttk.Frame", popup._scroll_frame.master) + assert content_frame.pack_info()["padx"] == 4 + assert content_frame.pack_info()["pady"] == 4 + assert popup._scroll_frame.vsb.pack_info()["side"] == "right" + assert popup._scroll_frame.canvas.coords(popup._scroll_frame.canvas_window) == [0.0, 0.0] + for section in (popup.craft_preview, popup.stick_preview, popup.channel_bars): + assert tuple(int(str(value)) for value in section.cget("padding")) == (4,) + assert section.pack_info()["pady"] == (0, 4) + message = MagicMock(chancount=4) + for number in range(1, 17): + setattr(message, f"chan{number}_raw", 1700 if number == 3 else 1500) + controller.master.recv_match.side_effect = [message, None] + popup._stop_polling() + popup._check_telemetry() + assert popup.channel_bars._value_labels[3].cget("text") == "1700 µs" + controller.master.recv_match.side_effect = [None, MagicMock(custom_mode=5)] + popup._stop_polling() + popup._check_telemetry() + assert popup.channel_bars._value_labels[3].cget("text") == "1700 µs" + assert not any("Flight Mode:" in text for text in _label_texts(root)) + assert popup.craft_preview._axes["throttle"] == 0.4 + timer = popup._timer_id + popup.destroy() + assert popup._timer_id is None + assert timer not in tk_root.tk.call("after", "info") + finally: + if root.winfo_exists(): + root.destroy() + parent.destroy() + + +def test_cancel_calibration_restores_configured_markers_immediately(tk_root: tk.Tk) -> None: + """ + Calibration controls update the markers immediately rather than leaving stale observed values. + + GIVEN: A real view, configured RC1 limits, and live observed extremes + WHEN: The user starts calibration, moves the channel and then presses Cancel + THEN: Observed MIN/MAX are discarded, configured MIN/MAX/TRIM return, and no parameters are written + """ + controller = MagicMock() + controller.fc_parameters = {"RC1_MIN": 1000.0, "RC1_MAX": 2000.0, "RC1_TRIM": 1490.0} + controller.master.recv_match.return_value = None + parent = tk.Frame(tk_root) + view = RCCalibrationView(parent, RCCalibrationDataModel(controller), MagicMock(spec=BaseWindow)) + try: + view._start_btn.invoke() + assert str(view._cancel_btn.cget("state")) == "normal" + message = MagicMock(chancount=1) + for number in range(1, 17): + setattr(message, f"chan{number}_raw", 1300) + controller.master.recv_match.side_effect = [message, None] + view._stop_polling() + view._check_telemetry() + assert view.channel_bars._channel_values[1]["min"] == 1300 + view._cancel_btn.invoke() + assert view.channel_bars._channel_values[1] == {"value": 1300, "min": 1000, "max": 2000, "trim": 1490} + assert str(view._start_btn.cget("state")) == "normal" + assert str(view._finish_btn.cget("state")) == "disabled" + controller.set_param.assert_not_called() + finally: + view.destroy() + parent.destroy() + + +def test_mode_combobox_stages_mapping_and_refreshes_new_values_without_upload( + tk_root: tk.Tk, rc_staging_context: tuple[MagicMock, ParameterEditor] +) -> None: + """ + Mapping changes are visible edits, not silent FC writes. + + GIVEN: An embedded plugin using the real staged editor and a retained RC sample + WHEN: The user selects Mode 1 and then Mode 4 + THEN: All four RCMAP New values and direction labels update, the table refreshes, and no upload occurs + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + message = MagicMock(chancount=4) + for number in range(1, 17): + setattr(message, f"chan{number}_raw", 1800 if number == 3 else 1500) + controller.master.recv_match.side_effect = [message, None] + model.get_rc_telemetry() + base_window = MagicMock() + base_window.show_only_differences.get.return_value = False + base_window.gui_complexity = "simple" + parent = ttk.Frame(tk_root) + view = RCCalibrationView(parent, model, base_window) + try: + assert view.stick_preview.mode_selector.get() == "2" + assert [view.channel_bars._function_labels[number].cget("text") for number in range(1, 5)] == [ + "Roll", + "Pitch", + "Throttle", + "Yaw", + ] + view.stick_preview.mode_selector.set("1") + view.stick_preview.mode_selector.event_generate("<>") + assert editor.current_step_parameters["RCMAP_THROTTLE"].get_new_value() == 2 + assert editor.current_step_parameters["RCMAP_PITCH"].get_new_value() == 3 + assert view.stick_preview._axis_labels[0].cget("text") == "YAW ↔ / PITCH ↕" + assert view.stick_preview._axis_labels[1].cget("text") == "ROLL ↔ / THROTTLE ↕" + assert [view.channel_bars._function_labels[number].cget("text") for number in range(1, 5)] == [ + "Roll", + "Throttle", + "Pitch", + "Yaw", + ] + assert view.stick_preview._axes["pitch"] == 600 + assert view.stick_preview._axes["throttle"] == 0 + view.stick_preview.mode_selector.set("4") + view.stick_preview.mode_selector.event_generate("<>") + assert editor.current_step_parameters["RCMAP_ROLL"].get_new_value() == 4 + assert editor.current_step_parameters["RCMAP_YAW"].get_new_value() == 1 + assert view.stick_preview._axis_labels[0].cget("text") == "ROLL ↔ / THROTTLE ↕" + base_window.parameter_editor_table.repopulate_table.assert_called_with( + show_only_differences=False, gui_complexity="simple" + ) + assert "upload" in view.stick_preview._mapping_status.cget("text") + controller.set_param.assert_not_called() + assert controller.fc_parameters["RCMAP_THROTTLE"] == 3 + finally: + view.destroy() + parent.destroy() + + +def test_finish_button_only_stages_calibration_until_parameter_upload( + tk_root: tk.Tk, rc_staging_context: tuple[MagicMock, ParameterEditor] +) -> None: + """ + Finish Calibration stages measurements rather than writing the controller. + + GIVEN: A real embedded view tracking valid extrema + WHEN: The user presses Finish Calibration + THEN: New values and markers show the measurements, status requests upload, and no PARAM_SET is sent + """ + controller, editor = rc_staging_context + model = RCCalibrationDataModel(controller, editor) + parent = ttk.Frame(tk_root) + view = RCCalibrationView(parent, model, MagicMock()) + try: + view._start_btn.invoke() + model._channel_min = {0: 1100} + model._channel_max = {0: 1900} + view._finish_btn.invoke() + assert editor.current_step_parameters["RC1_MIN"].get_new_value() == 1100 + assert view.channel_bars._channel_values[1]["min"] == 1100 + assert "upload" in view._status_label.cget("text") + assert str(view._finish_btn.cget("state")) == "disabled" + controller.set_param.assert_not_called() + finally: + view.destroy() + parent.destroy() + + +def test_custom_mapping_and_failed_mode_change_do_not_silently_replace_parameters( + tk_root: tk.Tk, rc_staging_context: tuple[MagicMock, ParameterEditor] +) -> None: + """ + A custom or protected channel map is retained until a complete valid edit is possible. + + GIVEN: A custom mapping and a read-only RCMAP parameter + WHEN: The user selects a preset + THEN: The selector remains Custom, an error is shown, and no controller write occurs + """ + controller, editor = rc_staging_context + parameter = editor.current_step_parameters["RCMAP_ROLL"] + parameter.set_new_value("5") + parameter._metadata["ReadOnly"] = True + preview = RCStickPreview(tk_root, RCCalibrationDataModel(controller, editor)) + try: + assert preview.mode_selector.get() == "Custom" + preview.mode_selector.set("1") + preview.mode_selector.event_generate("<>") + assert preview.mode_selector.get() == "Custom" + assert str(preview._mapping_status.cget("foreground")) == "red" + assert parameter.get_new_value() == 5 + controller.set_param.assert_not_called() + finally: + preview.destroy() + + +def test_all_stick_modes_show_roll_and_pitch_direction_arrows(stick_preview: RCStickPreview) -> None: + """ + Every mode labels both horizontal and vertical axes with their direction symbols. + + GIVEN: The two-gimbal stick preview + WHEN: Each mode is drawn + THEN: ROLL and YAW have horizontal arrows, PITCH and THROTTLE have vertical arrows + """ + for mode in range(1, 5): + stick_preview._mode.set(mode) + stick_preview.update_axes({}) + labels = " ".join(label.cget("text") for label in stick_preview._axis_labels) + for text in ("ROLL ↔", "YAW ↔", "PITCH ↕", "THROTTLE ↕"): + assert text in labels + + +def test_craft_preview_yaw_turns_while_held_and_holds_when_centered_or_stale( + tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + The example craft behaves like the reference receiver monitor rather than real attitude telemetry. + + GIVEN: A clock-controlled craft preview and valid RC channel data + WHEN: Yaw is held, centered, then telemetry stops + THEN: Heading advances at the visual yaw rate, holds at center, and stops after a stale-input timeout + """ + clock = [10.0] + monkeypatch.setattr("ardupilot_methodic_configurator.plugins.frontend_tkinter_rc_calibration.monotonic", lambda: clock[0]) + preview = RCCraftPreview(tk_root) + channels = [{"value": 1500}] * 4 + try: + preview.update_telemetry({"yaw": 1000, "roll": 500, "pitch": -500, "throttle": 1000, "channels": channels}) + assert preview._heading == 0 + clock[0] += 0.1 + preview.update_telemetry({"flight_mode": "3"}) + assert preview._heading == pytest.approx(12) + assert preview._axes == {"roll": 0.5, "pitch": -0.5, "yaw": 1.0, "throttle": 1.0} + assert preview.status_label.cget("text") == "4 channels live" + clock[0] += 0.1 + preview.update_telemetry({"yaw": 0, "channels": channels}) + heading = preview._heading + clock[0] += 0.1 + preview.update_telemetry({"yaw": 30, "channels": channels}) # Within the visual yaw deadband. + assert preview._heading == heading + preview.update_telemetry({"yaw": 1000, "channels": channels}) + clock[0] += 1 + preview.update_telemetry({}) + assert preview._heading == heading + assert preview.status_label.cget("text") == "No RC telemetry" + finally: + preview.destroy() + + +def test_craft_preview_is_rendered_before_telemetry_without_inventing_live_status(tk_root: tk.Tk) -> None: + """ + The replacement for Preview Stages is visible even before the first RC sample. + + GIVEN: A newly opened live monitor + WHEN: No RC telemetry has arrived + THEN: It shows a neutral example craft with no live status and safely ignores nonfinite axis samples + """ + preview = RCCraftPreview(tk_root) + try: + assert preview.image_label.cget("image") + assert preview.status_label.cget("text") == "No RC telemetry" + preview.update_telemetry({"roll": float("nan"), "channels": [{"value": None}]}) + assert preview._axes["roll"] == 0 + assert preview.status_label.cget("text") == "No RC telemetry" + finally: + preview.destroy() diff --git a/tests/plugins/test_renderer_3d_quadcopter.py b/tests/plugins/test_renderer_3d_quadcopter.py new file mode 100755 index 000000000..a7b0eb7ee --- /dev/null +++ b/tests/plugins/test_renderer_3d_quadcopter.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +BDD tests for the RC-driven procedural craft renderer. + +SPDX-FileCopyrightText: 2026 ArduPilot Contributors +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from math import nan, radians + +import pytest + +from ardupilot_methodic_configurator.plugins.renderer_3d_quadcopter import QuadcopterRenderer + +# pylint: disable=protected-access + + +@pytest.mark.parametrize( + ("axis", "point", "expected"), + [ + ("roll", (1.0, 0.0, 0.0), (0.0, 0.0, -1.0)), + ("pitch", (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + ("yaw", (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)), + ], +) +def test_positive_control_axes_bank_right_pitch_up_and_turn_right(axis: str, point, expected) -> None: + """ + Control deflections orient the craft consistently in body coordinates. + + GIVEN: A point on the right wing or nose + WHEN: A positive ninety-degree rotation is applied + THEN: Roll lowers the right side, pitch raises the nose, and yaw turns the nose right + """ + angles = {name: radians(90) if name == axis else 0.0 for name in ("roll", "pitch", "yaw")} + rotated = QuadcopterRenderer._rotate(point, **angles) + assert rotated == pytest.approx(expected) + + +@pytest.mark.parametrize( + "pose", + [(1.0, 0.0, 0.0, 0.5), (0.0, 1.0, 0.0, 0.5), (0.0, 0.0, 45.0, 0.5), (0.0, 0.0, 0.0, 1.0)], +) +def test_each_stick_axis_changes_the_rendered_craft(pose) -> None: + """ + The live monitor is responsive rather than a static schematic. + + GIVEN: A neutral craft image + WHEN: Roll, pitch, yaw heading or throttle changes + THEN: The resulting image differs and retains the requested size and color mode + """ + renderer = QuadcopterRenderer(width=360, height=200) + neutral = renderer.render(0, 0, 0, 0.5) + moved = renderer.render(*pose) + assert moved.size == (360, 200) + assert moved.mode == "RGB" + assert moved.tobytes() != neutral.tobytes() + + +def test_throttle_raises_the_craft_without_moving_its_ground_reference() -> None: + """ + Throttle motion is visually distinct from the stationary floor. + + GIVEN: The projected center point and rendered low/high throttle poses + WHEN: Lift increases + THEN: The craft moves up and the floor/shadow pixels remain fixed + """ + renderer = QuadcopterRenderer() + assert renderer._project((0, 0, 0), lift=30)[1] < renderer._project((0, 0, 0), lift=-30)[1] + low = renderer.render(0, 0, 0, 0) + high = renderer.render(0, 0, 0, 1) + ground = round(renderer.height * 0.86) + assert low.getpixel((15, ground)) == high.getpixel((15, ground)) + assert low.getpixel((renderer.width // 2, ground)) == high.getpixel((renderer.width // 2, ground)) + + +def test_invalid_and_excessive_display_inputs_do_not_break_rendering() -> None: + """ + Display-only numeric boundaries remain safe. + + GIVEN: Nonfinite inputs or inputs beyond the visual ranges + WHEN: The renderer draws a frame + THEN: Invalid inputs fall back safely, lean/lift clamp, and headings wrap + """ + renderer = QuadcopterRenderer() + assert renderer.render(nan, nan, nan, nan).tobytes() == renderer.render(0, 0, 0, 0).tobytes() + assert renderer.render(5, -5, 405, 5).tobytes() == renderer.render(1, -1, 45, 1).tobytes() diff --git a/tests/test_data_model_parameter_editor.py b/tests/test_data_model_parameter_editor.py index 98dc91a4f..3bd84504b 100755 --- a/tests/test_data_model_parameter_editor.py +++ b/tests/test_data_model_parameter_editor.py @@ -7065,6 +7065,19 @@ def test_gui_knows_whether_parameter_documentation_is_available(self, parameter_ parameter_editor._local_filesystem.doc_dict = {} assert parameter_editor.parameter_documentation_available() is False + def test_gui_reads_enum_choices_from_parameter_documentation(self, parameter_editor: ParameterEditor) -> None: + """ + Plugin controls can populate choices without exposing filesystem internals. + + GIVEN: RC channel option values in the loaded parameter documentation + WHEN: The parameter editor's public enum-choice accessor is queried + THEN: The numeric choice labels are returned without changing the metadata + """ + parameter_editor._local_filesystem.doc_dict = {"RC6_OPTION": {"values": {"0": "Disabled", "300": "QuickTune"}}} + + assert parameter_editor.get_parameter_choices("RC6_OPTION") == {"0": "Disabled", "300": "QuickTune"} + assert parameter_editor.get_parameter_choices("UNKNOWN_PARAMETER") == {} + def test_gui_reads_the_configuration_phases_from_the_project(self, parameter_editor: ParameterEditor) -> None: """ Configuration phases come from the loaded project. From edc89d9ea30affd38e42836309b5e81e2f9d54f0 Mon Sep 17 00:00:00 2001 From: amilcarlucas <24453563+amilcarlucas@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:38:15 +0000 Subject: [PATCH 2/2] chore(configuration steps): Auto-update configuration steps translatable strings --- .../configuration_steps_strings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ardupilot_methodic_configurator/configuration_steps_strings.py b/ardupilot_methodic_configurator/configuration_steps_strings.py index 512e81431..f6ff5d0c6 100644 --- a/ardupilot_methodic_configurator/configuration_steps_strings.py +++ b/ardupilot_methodic_configurator/configuration_steps_strings.py @@ -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")