diff --git a/.codespell-exclude-file b/.codespell-exclude-file index b47320c38..0ef94ee5a 100644 --- a/.codespell-exclude-file +++ b/.codespell-exclude-file @@ -4,3 +4,4 @@ "Version": "LAF" datas= [], ardupilot_methodic_configuratorAny.datas, + critical_param_prefixes = ["FRAME", "BATT", "COMPASS", "SERVO", "MOT"] diff --git a/.github/instructions/SITL_TESTING.md b/.github/instructions/SITL_TESTING.md new file mode 100644 index 000000000..38a9b0257 --- /dev/null +++ b/.github/instructions/SITL_TESTING.md @@ -0,0 +1,191 @@ +# SITL Testing Setup + +This document describes how to set up and run integration tests using ArduPilot SITL (Software In The Loop) for testing the `backend_flightcontroller.py` module. + +## Overview + +SITL testing provides real MAVLink communication validation instead of mocked tests. +This ensures the flight controller backend works correctly with actual ArduPilot firmware. + +## Architecture + +The SITL testing setup consists of: + +1. **Direct Download**: Tests download pre-built ArduCopter SITL binaries directly from the official ArduPilot firmware server (`firmware.ardupilot.org`) +2. **Pytest Fixtures**: Session-scoped SITLManager class manages SITL process lifecycle +3. **TCP Connection**: SITL runs on TCP port 5760 with MAVLink protocol +4. **Parameter Configuration**: SITL uses `sitl/copter.parm` with battery monitoring enabled + +## Prerequisites + +### For CI/CD (GitHub Actions) + +- No additional setup required - SITL binaries are downloaded automatically during tests + +### For Local Development + +#### Download Pre-built SITL (Recommended) + +Download the latest pre-built SITL binary directly from the official ArduPilot firmware server: + +```bash +./scripts/run_sitl_tests.sh download +``` + +This downloads ArduCopter SITL from `https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/arducopter` + +## Usage + +### CI/CD Testing + +SITL tests run automatically in GitHub Actions when SITL artifacts are available. The test workflow: + +1. Downloads the latest SITL artifact +2. Extracts and sets up SITL binary +3. Runs tests marked with `@pytest.mark.sitl` +4. Falls back to mocked tests if SITL is unavailable + +### Local Development + +Use the provided script for local SITL testing. You can either download pre-built SITL or use a locally built version: + +#### Using Downloaded SITL (Recommended) + +```bash +# Download ArduCopter SITL from official firmware server +./scripts/run_sitl_tests.sh download + +# Download and run tests in one command +./scripts/run_sitl_tests.sh download-test + +# Check if downloaded SITL is available +./scripts/run_sitl_tests.sh check +``` + +#### Using Locally Built SITL + +```bash +# Set up environment for locally built SITL +export ARDUPILOT_DIR="$HOME/ardupilot-sitl" + +# Check if locally built SITL is available +./scripts/run_sitl_tests.sh check + +# Set up SITL for testing +./scripts/run_sitl_tests.sh setup + +# Run SITL integration tests +./scripts/run_sitl_tests.sh test +``` + +#### General Commands + +```bash +# Clean up SITL processes and cache +./scripts/run_sitl_tests.sh cleanup + +# Show help +./scripts/run_sitl_tests.sh help +``` + +### Manual Testing + +Run specific SITL tests: + +```bash +# Run all SITL tests +python -m pytest tests/test_backend_flightcontroller_sitl.py -v + +# Run only SITL tests (skip if SITL unavailable) +python -m pytest -m sitl -v + +# Run SITL tests or fallback to mocked tests +python -m pytest -m "sitl or not sitl" -v +``` + +## Test Coverage + +SITL tests cover: + +- **Real MAVLink Connection**: Validates actual protocol communication on TCP port 5760 +- **Parameter Management**: Download, set, and verify parameters with real firmware +- **Motor Testing**: Test motor commands against actual ArduPilot firmware +- **Battery Monitoring**: Test battery status reporting with enabled monitoring +- **Frame Information**: Validate vehicle configuration queries + +## Implementation Details + +### SITL Configuration + +SITL runs with the following command line parameters: + +```bash +arducopter --model quad --home "40.071374,-105.229930,1440,0" --defaults sitl/copter.parm --sysid 1 --speedup 10 +``` + +### Connection Details + +- **Protocol**: MAVLink over TCP +- **Port**: 5760 +- **Connection String**: "tcp:127.0.0.1:5760" +- **Vehicle Type**: ArduCopter (Quadcopter) +- **System ID**: 1 + +### Parameter Requirements + +Some tests require specific parameters to be set in `sitl/copter.parm`: + +- `BATT_MONITOR = 4` (Analog voltage and current) +- `BATT_VOLT_PIN = 1` +- `BATT_CURR_PIN = 2` +- `BATT_VOLT_MULT = 10.0` +- `BATT_AMP_PERVOLT = 17.0` + +## Configuration + +### Environment Variables + +- `SITL_BINARY`: Path to ArduCopter SITL binary (auto-detected in CI) +- `ARDUPILOT_DIR`: Path to ArduPilot directory for local development + +### Test Markers + +- `@pytest.mark.sitl`: Marks tests requiring SITL +- Tests automatically skip if SITL is unavailable + +## Troubleshooting + +### SITL Not Found + +- **For downloaded SITL**: Run `./scripts/run_sitl_tests.sh download` to download from ArduPilot website +- **For locally built SITL**: Ensure ArduPilot is built with `./waf configure --board=sitl && ./waf copter` +- Check `ARDUPILOT_DIR` environment variable for locally built SITL +- Verify SITL binary exists at expected path + +### Connection Failures + +- SITL may take time to start - tests include startup delays +- Check for port conflicts on TCP port 5760 +- Verify MAVLink heartbeat detection +- Ensure connection string format is "tcp:127.0.0.1:5760" + +### Test Timeouts + +- SITL tests are slower than mocked tests +- Increase timeout values if needed +- Check system performance for SITL simulation + +## Benefits + +1. **Real Validation**: Tests actual MAVLink protocol implementation +2. **Regression Detection**: Catches firmware compatibility issues +3. **CI/CD Integration**: Automated testing with pre-built artifacts +4. **Development Flexibility**: Local testing with fallback to mocks +5. **Cost Efficiency**: Monthly builds reduce CI resource usage + +## Future Enhancements + +- Multiple vehicle types (ArduPlane, Rover, etc.) +- SITL version pinning for reproducible tests +- Performance optimization for faster test execution +- Multi-SITL instance testing for complex scenarios diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7f59899ac..e70613290 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -60,6 +60,53 @@ jobs: run: | uv pip install --editable .[dev,ci_headless_tests] + - name: Download ArduCopter SITL (if available) + run: | + # Create cache key based on current quarter (YYYY-Q) + CURRENT_YEAR=$(date +%Y) + CURRENT_MONTH=$(date +%m) + QUARTER=$(( (CURRENT_MONTH-1)/3 + 1 )) + CACHE_KEY="${CURRENT_YEAR}-Q${QUARTER}" + + echo "Cache key: ${CACHE_KEY}" + + # Check if we have cached SITL files for this quarter + if [ -d "sitl-cache/${CACHE_KEY}" ] && [ -f "sitl-cache/${CACHE_KEY}/arducopter" ]; then + echo "Using cached SITL files from ${CACHE_KEY}" + mkdir -p sitl/ + cp sitl-cache/${CACHE_KEY}/* sitl/ + else + echo "Downloading fresh SITL files for ${CACHE_KEY}" + mkdir -p sitl/ sitl-cache/${CACHE_KEY}/ + + # Download latest ArduCopter SITL from official firmware server + curl -L -o sitl/arducopter https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/arducopter + curl -L -o sitl/firmware-version.txt https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/firmware-version.txt + curl -L -o sitl/git-version.txt https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/git-version.txt + + # Cache the downloaded files + cp sitl/* sitl-cache/${CACHE_KEY}/ + fi + + # Make executable and verify + chmod +x sitl/arducopter + ls -la sitl/ + + # Set environment variables + echo "SITL_BINARY=$(pwd)/sitl/arducopter" >> $GITHUB_ENV + echo "SITL_AVAILABLE=true" >> $GITHUB_ENV + echo "SITL version: $(cat sitl/git-version.txt)" + echo "Firmware version: $(cat sitl/firmware-version.txt)" + continue-on-error: true + + - name: Cache SITL files + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: sitl-cache/ + key: sitl-cache-${{ github.run_id }} + restore-keys: | + sitl-cache- + - name: Test with pytest id: pytest continue-on-error: false @@ -72,7 +119,13 @@ jobs: Xvfb :99 -screen 0 1024x768x16 -ac & # ensure Xvfb is fully started before running tests sleep 2 - uv run pytest --cov=ardupilot_methodic_configurator --cov-report=xml:tests/coverage.xml --md=tests/results-${{ matrix.python-version }}.md --junit-xml=tests/results-junit.xml + if [ "$SITL_AVAILABLE" = "true" ]; then + echo "Running tests with SITL support" + uv run pytest --cov=ardupilot_methodic_configurator --cov-report=xml:tests/coverage.xml --md=tests/results-${{ matrix.python-version }}.md --junit-xml=tests/results-junit.xml -m "sitl or not sitl" + else + echo "Running tests without SITL (mocked tests only)" + uv run pytest --cov=ardupilot_methodic_configurator --cov-report=xml:tests/coverage.xml --md=tests/results-${{ matrix.python-version }}.md --junit-xml=tests/results-junit.xml -m "not sitl" + fi - name: Fix coverage paths run: | diff --git a/.gitignore b/.gitignore index e4cbf0d80..75d60b9eb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ venv/ .venv/ test.txt test.xml + +sitl/arducopter +sitl/firmware-version.txt +sitl/git-version.txt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e8da2bb65..45f675a32 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -76,7 +76,13 @@ Each sub-application has detailed architecture documentation covering requiremen 2. **[Flight Controller Communication](ARCHITECTURE_2_flight_controller_communication.md)** - Establishes FC connection, downloads parameters and metadata - [`frontend_tkinter_connection_selection.py`](ardupilot_methodic_configurator/frontend_tkinter_connection_selection.py) - [`frontend_tkinter_flightcontroller_info.py`](ardupilot_methodic_configurator/frontend_tkinter_flightcontroller_info.py) - - [`backend_flightcontroller.py`](ardupilot_methodic_configurator/backend_flightcontroller.py) + - [`backend_flightcontroller.py`](ardupilot_methodic_configurator/backend_flightcontroller.py) - Main facade class using delegation pattern + - [`backend_flightcontroller_connection.py`](ardupilot_methodic_configurator/backend_flightcontroller_connection.py) - Connection management + - [`backend_flightcontroller_params.py`](ardupilot_methodic_configurator/backend_flightcontroller_params.py) - Parameter operations + - [`backend_flightcontroller_commands.py`](ardupilot_methodic_configurator/backend_flightcontroller_commands.py) - Command execution + - [`backend_flightcontroller_files.py`](ardupilot_methodic_configurator/backend_flightcontroller_files.py) - File operations + - [`data_model_flightcontroller_info.py`](ardupilot_methodic_configurator/data_model_flightcontroller_info.py) - FC information + - [`backend_flightcontroller_protocols.py`](ardupilot_methodic_configurator/backend_flightcontroller_protocols.py) - Protocol definitions - [`backend_mavftp.py`](ardupilot_methodic_configurator/backend_mavftp.py) 3. **[Directory and Project Selection](ARCHITECTURE_3_directory_selection.md)** - Creates new projects or opens existing ones @@ -154,9 +160,63 @@ All applications use one or more of the following shared libraries: 4. [`backend_filesystem_program_settings.py`](ardupilot_methodic_configurator/backend_filesystem_program_settings.py) 1. the internet backend communicates with the internet 1. [`backend_internet.py`](ardupilot_methodic_configurator/backend_internet.py) -1. the flight controller backend communicates with the flight controller - 1. [`backend_flightcontroller.py`](ardupilot_methodic_configurator/backend_flightcontroller.py) - 2. [`backend_mavftp.py`](ardupilot_methodic_configurator/backend_mavftp.py) +1. the flight controller backend communicates with the flight controller using a **delegation pattern** with specialized managers: + 1. [`backend_flightcontroller.py`](ardupilot_methodic_configurator/backend_flightcontroller.py) - + Main facade class that delegates to specialized managers + 2. [`backend_flightcontroller_connection.py`](ardupilot_methodic_configurator/backend_flightcontroller_connection.py) - + Handles connection establishment, port discovery, and heartbeat detection + 3. [`backend_flightcontroller_params.py`](ardupilot_methodic_configurator/backend_flightcontroller_params.py) - + Manages parameter download, upload, and querying + 4. [`backend_flightcontroller_commands.py`](ardupilot_methodic_configurator/backend_flightcontroller_commands.py) - + Executes MAVLink commands (motor tests, battery status, etc.) + 5. [`backend_flightcontroller_files.py`](ardupilot_methodic_configurator/backend_flightcontroller_files.py) - + Handles file upload/download via MAVFTP + 6. [`data_model_flightcontroller_info.py`](ardupilot_methodic_configurator/data_model_flightcontroller_info.py) - + Stores flight controller metadata and capabilities + 7. [`backend_flightcontroller_protocols.py`](ardupilot_methodic_configurator/backend_flightcontroller_protocols.py) - + Protocol definitions for dependency injection and testing + 8. [`backend_mavftp.py`](ardupilot_methodic_configurator/backend_mavftp.py) - MAVFTP protocol implementation + + **Flight Controller Backend Architecture:** + - The `FlightController` class acts as a facade, delegating operations to specialized managers + - Each manager handles a specific concern (connection, parameters, commands, files) + - Managers can reference each other (e.g., params manager holds reference to connection manager) + - Protocol definitions enable dependency injection for testing + - Connection manager is the source of truth for connection state (`master`, `comport`, `info`) + - Other managers query connection manager for current state rather than caching it + + **Error Handling Standards:** + + To maintain consistency across the flight controller backend, the following error handling patterns are used: + + 1. **Connection and I/O Operations** - Return `str` (empty string on success, error message on failure): + - `connect()`, `disconnect()`, `register_and_try_connect()` + - Rationale: Allows user-friendly error messages to be displayed directly + + 2. **Command Operations** - Return `tuple[bool, str]` (success flag, error message): + - `test_motor()`, `test_all_motors()`, `reset_all_parameters_to_default()` + - Rationale: Separates success/failure from error details for better control flow + + 3. **Query Operations** - Return `Optional[T]` or raise exceptions: + - `fetch_param()` - raises `TimeoutError` on timeout + - `get_battery_status()` - returns `tuple[Optional[tuple[float, float]], str]` + - Rationale: Distinguishes between "not found" (None) and "error" (exception) + + 4. **Bulk Operations** - Return data structures or tuples: + - `download_params()` - returns `tuple[dict[str, float], ParDict]` + - Rationale: Success implied by returned data, errors logged but not returned + + **Testing Hacks and Violations:** + + The following methods exist for testing purposes only and violate architectural principles: + + - `FlightController.set_master_for_testing()` - Directly mutates connection manager's internal state, + violating the principle that connection manager should be the sole mutator of connection state. + **Never use in production code.** + + - Test parameter loading via `device="test"` in `backend_flightcontroller_params.py` - Bypasses normal + parameter download flow to load from local file. **Marked with FIXME for future removal.** + 1. the tkinter frontend, which is the GUI the user interacts with 1. [`frontend_tkinter_autoresize_combobox.py`](ardupilot_methodic_configurator/frontend_tkinter_autoresize_combobox.py) 1. [`frontend_tkinter_base_window.py`](ardupilot_methodic_configurator/frontend_tkinter_base_window.py) diff --git a/ARCHITECTURE_2_flight_controller_communication.md b/ARCHITECTURE_2_flight_controller_communication.md index 9768b62a8..7c787092a 100644 --- a/ARCHITECTURE_2_flight_controller_communication.md +++ b/ARCHITECTURE_2_flight_controller_communication.md @@ -74,25 +74,185 @@ ArduPilot-based flight controllers using the MAVLink protocol. ## Architecture +### Architectural Pattern - Delegation with Specialized Managers + +The flight controller communication system uses a **delegation pattern** where the main +`FlightController` class acts as a facade, delegating operations to specialized manager classes. +This architecture provides: + +- **Clear separation of concerns**: Each manager handles one specific aspect +- **Better testability**: Managers can be independently tested and mocked +- **Dependency injection support**: Protocol definitions enable test doubles +- **Single source of truth**: Connection manager owns connection state +- **No shared mutable state**: Managers query each other rather than caching references + ### Components - Implementation Status -#### Flight Controller Backend +#### Flight Controller Facade - **File**: `backend_flightcontroller.py` ✅ **IMPLEMENTED** -- **Purpose**: Core MAVLink communication and parameter management +- **Purpose**: Main entry point that delegates to specialized managers +- **Key Classes**: + - `FlightController`: Facade class coordinating all operations +- **Key Methods**: + - `connect()`: Delegates to connection manager + - `download_params()`: Delegates to params manager + - `set_param()`: Delegates to params manager (returns `tuple[bool, str]`) + - `test_motor()`: Delegates to commands manager + - `upload_file()`: Delegates to files manager +- **Delegation Pattern**: + - Connection operations → `_connection_manager` + - Parameter operations → `_params_manager` + - Command execution → `_commands_manager` + - File operations → `_files_manager` +- **Actual Dependencies**: + - `FlightControllerConnection` for connection management ✅ + - `FlightControllerParams` for parameter operations ✅ + - `FlightControllerCommands` for command execution ✅ + - `FlightControllerFiles` for file operations ✅ + - Protocol definitions for dependency injection ✅ + +#### Connection Manager + +- **File**: `backend_flightcontroller_connection.py` ✅ **IMPLEMENTED** +- **Purpose**: Manages flight controller connection lifecycle - **Key Classes**: - - `FlightController`: Main interface class with connection management + - `FlightControllerConnection`: Connection establishment and management - `FakeSerialForTests`: Mock serial class for unit testing - **Key Methods**: - `connect()`: Establishes connection with retry logic - - `download_params()`: Downloads parameters via MAVLink or MAVFTP - - `set_param()`: Uploads individual parameters with verification - - `discover_connections()`: Auto-detects available connections + - `disconnect()`: Closes connection gracefully + - `discover_connections()`: Auto-detects available ports + - `register_and_try_connect()`: Registers and connects to port + - `create_connection_with_retry()`: Connection with retry logic +- **Responsibilities**: + - Port discovery (serial and network) + - Connection establishment and retries + - Heartbeat detection and vehicle identification + - Autopilot version and banner retrieval + - **Sole mutator of `FlightControllerInfo`** (single source of truth) +- **Actual Dependencies**: + - `pymavlink.mavutil` for MAVLink protocol ✅ + - `serial.tools.list_ports` for port discovery ✅ + - `FlightControllerInfo` for metadata storage ✅ + - `time` and `logging` for operations ✅ + +#### Parameters Manager + +- **File**: `backend_flightcontroller_params.py` ✅ **IMPLEMENTED** +- **Purpose**: Manages all parameter-related operations +- **Key Classes**: + - `FlightControllerParams`: Parameter download, set, and fetch +- **Key Methods**: + - `download_params()`: Downloads via MAVLink or MAVFTP + - `set_param()`: Sets parameter (returns `tuple[bool, str]`) + - `fetch_param()`: Fetches single parameter with timeout + - `get_param()`: Gets parameter from cache with default +- **Responsibilities**: + - Parameter downloads (MAVLink and MAVFTP) + - Parameter cache management + - Individual parameter operations + - Default parameter handling +- **Query Pattern**: Queries connection manager for `master`, `info`, `comport_device` - **Actual Dependencies**: - - `pymavlink.mavutil` for MAVLink protocol implementation ✅ - - `serial.tools.list_ports` for serial port discovery ✅ - - `time` for timeout and retry logic ✅ - - `logging` for comprehensive error and debug logging ✅ + - `FlightControllerConnectionProtocol` for connection state ✅ + - `MAVFTP` for efficient parameter downloads ✅ + - `ParDict` for parameter storage ✅ + +#### Commands Manager + +- **File**: `backend_flightcontroller_commands.py` ✅ **IMPLEMENTED** +- **Purpose**: Executes MAVLink commands and queries status +- **Key Classes**: + - `FlightControllerCommands`: Command execution and status queries +- **Key Methods**: + - `send_command_and_wait_ack()`: Sends command and waits for ACK + - `test_motor()`: Tests individual motor + - `test_all_motors()`: Tests all motors simultaneously + - `stop_all_motors()`: Emergency stop + - `get_battery_status()`: Queries battery telemetry + - `reset_all_parameters_to_default()`: Resets parameters +- **Responsibilities**: + - Motor testing operations + - Battery status monitoring + - Command acknowledgment handling + - Parameter reset operations +- **Query Pattern**: + - Queries params manager for parameter values (no caching) + - Queries connection manager for `master` connection +- **Actual Dependencies**: + - `FlightControllerConnectionProtocol` for connection ✅ + - `FlightControllerParamsProtocol` for parameters ✅ + - Business logic functions for calculations ✅ + +#### Files Manager + +- **File**: `backend_flightcontroller_files.py` ✅ **IMPLEMENTED** +- **Purpose**: Handles file operations via MAVFTP +- **Key Classes**: + - `FlightControllerFiles`: File upload/download operations +- **Key Methods**: + - `upload_file()`: Uploads file to flight controller + - `download_last_flight_log()`: Downloads latest log file +- **Responsibilities**: + - File upload via MAVFTP + - File download via MAVFTP + - Directory scanning and log detection +- **Query Pattern**: Queries connection manager for `master` and `info` +- **Actual Dependencies**: + - `FlightControllerConnectionProtocol` for connection ✅ + - `MAVFTP` for file transfer protocol ✅ + +#### Protocol Definitions + +- **File**: `backend_flightcontroller_protocols.py` ✅ **IMPLEMENTED** +- **Purpose**: Protocol interfaces for dependency injection and testing +- **Key Protocols**: + - `FlightControllerConnectionProtocol`: Connection manager contract + - `FlightControllerParamsProtocol`: Parameters manager contract + - `FlightControllerCommandsProtocol`: Commands manager contract + - `FlightControllerFilesProtocol`: Files manager contract +- **Type Checking Pattern**: + + ```python + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from backend_flightcontroller_protocols import FlightControllerConnectionProtocol + ``` + + This prevents circular imports while enabling type hints +- **Benefits**: + - Enables dependency injection for testing + - Documents contracts between components + - Supports mock implementations + - Prevents circular import issues + +#### Business Logic Functions + +- **File**: `backend_flightcontroller_business_logic.py` ✅ **IMPLEMENTED** +- **Purpose**: Pure functions for calculations and validations +- **Key Functions**: + - `calculate_voltage_thresholds()`: Battery voltage limits + - `is_battery_monitoring_enabled()`: Battery monitoring check + - `get_frame_info()`: Frame class and type extraction + - `validate_battery_voltage()`: Voltage safety validation +- **Benefits**: + - Stateless and side-effect-free + - Easily testable without mocks + - Reusable across components + - Clear business rules + +#### MAVFTP Utilities + +- **File**: `backend_flightcontroller_factory_mavftp.py` ✅ **IMPLEMENTED** +- **Purpose**: Factory functions for MAVFTP instances +- **Key Functions**: + - `create_mavftp()`: Creates MAVFTP instance with error handling + - `create_mavftp_safe()`: Safe creation returning None on failure +- **Benefits**: + - Centralized MAVFTP creation + - Consistent error handling + - Mockable for testing #### MAVLink FTP Backend @@ -113,7 +273,7 @@ ArduPilot-based flight controllers using the MAVLink protocol. #### Flight Controller Info Backend -- **File**: `backend_flightcontroller_info.py` ✅ **IMPLEMENTED** +- **File**: `data_model_flightcontroller_info.py` ✅ **IMPLEMENTED** - **Purpose**: Hardware information management and processing - **Key Classes**: - `BackendFlightcontrollerInfo`: Processes and stores FC information @@ -173,33 +333,52 @@ ArduPilot-based flight controllers using the MAVLink protocol. 1. **Application Startup and Connection Initialization** ✅ **IMPLEMENTED** - Called from `__main__.py` via `connect_to_fc_and_set_vehicle_type()` function - - `FlightController` object created with configurable reboot_time and baudrate - - `discover_connections()` automatically detects available serial and network ports + - `FlightController` facade created with dependency injection support + - Connection manager initialized with `FlightControllerInfo` instance + - Params, commands, and files managers initialized with protocol references + - `discover_connections()` delegates to connection manager for port detection - If connection fails, `ConnectionSelectionWindow` is displayed for manual selection 2. **Connection Establishment Phase** ✅ **IMPLEMENTED** - - User selects connection via GUI combobox or auto-detection attempts first available - - `connect()` method attempts connection with retry logic in `__create_connection_with_retry()` - - Protocol negotiation handled by pymavlink.mavutil.mavlink_connection + - User selects connection via GUI or auto-detection attempts first available + - `FlightController.connect()` delegates to `connection_manager.connect()` + - Connection manager handles retry logic via `create_connection_with_retry()` + - Heartbeat detection via `_detect_vehicles_from_heartbeats()` + - Autopilot selection via `_select_supported_autopilot()` + - **Connection manager mutates `FlightControllerInfo`** (sole mutator) - Connection validation via heartbeat and banner text reception 3. **Hardware Information Gathering** ✅ **IMPLEMENTED** - - Flight controller identification via `__request_message(AUTOPILOT_VERSION)` - - Hardware capabilities extracted via `__process_autopilot_version()` method - - Firmware version and vehicle type detection stored in `BackendFlightcontrollerInfo` - - Banner text retrieval via `__receive_banner_text()` for additional info + - Connection manager requests `AUTOPILOT_VERSION` message + - `_retrieve_autopilot_version_and_banner()` processes responses + - Hardware capabilities extracted via `_process_autopilot_version()` + - Firmware version, board type, and capabilities stored in `FlightControllerInfo` + - Banner text parsed for firmware type via `_extract_firmware_type_from_banner()` + - ChibiOS version validated via `_extract_chibios_version_from_banner()` 4. **Parameter Operations Phase** ✅ **IMPLEMENTED** - - Parameter download via `download_params()` supporting both MAVLink and MAVFTP methods - - Progress tracking through callback functions to update GUI progress bars - - Default parameter values downloaded via `download_params_via_mavftp()` when available - - Parameter validation and storage using `annotate_params.Par` objects - -5. **UI Updates and Status Reporting** ✅ **IMPLEMENTED** - - Real-time progress updates via `ProgressWindow` during connection and download + - `FlightController.download_params()` delegates to params manager + - Params manager checks MAVFTP support via `info.is_mavftp_supported` + - Attempts MAVFTP download first, falls back to MAVLink if unavailable + - Progress tracking through callbacks to update GUI + - Parameters stored in `params_manager.fc_parameters` dictionary + - Default parameters downloaded separately when MAVFTP available + - Commands manager queries params manager for fresh parameter values (no caching) + +5. **Command Execution Flow** ✅ **IMPLEMENTED** + - `FlightController.test_motor()` delegates to commands manager + - Commands manager queries params manager for battery parameters + - `send_command_and_wait_ack()` handles MAVLink command protocol + - Battery status retrieved via `get_battery_status()` with caching + - Voltage thresholds calculated via business logic functions + - All operations check `master is not None` before execution + +6. **UI Updates and Status Reporting** ✅ **IMPLEMENTED** + - Real-time progress updates via `ProgressWindow` during operations - Error reporting through `show_no_connection_error()` and message boxes - Connection status feedback via GUI state changes and tooltips - Final status display in `FlightControllerInfoWindow` with formatted information + - `set_param()` now returns `tuple[bool, str]` for explicit error handling ### Integration Points - Implementation Status @@ -235,20 +414,131 @@ ArduPilot-based flight controllers using the MAVLink protocol. - **Protocol Errors**: Log details, attempt recovery, fall back to basic operations - **Parameter Errors**: Validate and sanitize, report specific parameter issues -### Testing Strategy - -- Unit tests for protocol message handling -- Integration tests with simulated flight controllers -- Hardware-in-the-loop testing with real flight controllers -- Network simulation for connection reliability testing -- Parameter validation testing with edge cases +## Testing Strategy + +### Test Organization and Coverage + +The flight controller communication system has comprehensive test coverage organized by testing approach: + +#### Unit Tests (Mocked Dependencies) + +1. **test_backend_flightcontroller.py** - Main facade integration tests + - Connection lifecycle workflows (initialization, connection, disconnection) + - Parameter management workflows (download, modify, verify, reset) + - Motor testing workflows (individual, all motors, emergency stop) + - Battery monitoring workflows (enable, check status, verify configuration) + - Error handling and recovery scenarios + - All tests use `@pytest.mark.integration` for integration test scenarios + - Uses BDD (Behavior-Driven Development) naming: `test_user_can_*` + - GIVEN/WHEN/THEN structure in all test docstrings + +2. **test_backend_flightcontroller_business_logic.py** - Pure business logic tests + - Voltage threshold calculations and battery monitoring detection + - Frame information extraction and battery voltage validation + - Battery telemetry conversions and throttle validation + - Motor test duration validation and sequence number calculations + - Zero mocking (pure functions) - fastest test execution + - Comprehensive edge case coverage (boundaries, invalid inputs, missing data) + +3. **test_backend_flightcontroller_connection.py** - Connection manager tests + - Connection manager initialization and port discovery + - Connection lifecycle (connect/disconnect/reconnect) + - Baudrate configuration and custom connection strings + - Flight controller info management and property delegation + - 12 tests covering all connection management scenarios + +4. **test_backend_flightcontroller_params.py** - Parameters manager tests + - Parameter initialization and setting (with/without connection) + - Parameter fetching from flight controller and cache retrieval + - Cache clearing and constants validation (PARAM_FETCH_POLL_DELAY) + - Property delegation and parameter downloads + - File operations and error handling + - 18 tests covering all parameter operations + +5. **test_backend_flightcontroller_commands.py** - Commands manager tests + - Command manager initialization and motor testing + - Battery status requests and parameter reset commands + - Command acknowledgment waiting and timeout handling + - Property delegation to connection manager + - 10 tests covering all command execution scenarios + +6. **test_backend_flightcontroller_files.py** - Files manager tests + - Files manager initialization and file uploads via MAVFTP + - Log file downloads and MAVFTP availability handling + - Progress callback support and constants validation + - Property delegation and error handling + - 11 tests covering all file operation scenarios + +#### Integration Tests (Real SITL) + +1. **test_backend_flightcontroller_sitl.py** - Real MAVLink protocol tests + - Uses `@pytest.mark.integration` and `@pytest.mark.sitl` markers + - Real TCP connection to ArduCopter SITL simulation + - Actual MAVLink protocol behavior (not mocked) + - Tests validate: + - Real parameter downloads via MAVLink PARAM_REQUEST_LIST/PARAM_VALUE + - Authentic command acknowledgments (COMMAND_ACK with real timing) + - True async communication patterns and timeout behavior + - Actual telemetry streaming (BATTERY_STATUS messages) + - Real parameter persistence in SITL memory + - Genuine retry logic and error conditions + - 12 tests exercising real protocol that mocks cannot simulate + - Comprehensive module docstring explains "why SITL matters" + - Each test documents what real behavior is validated vs mocked tests + +### Test Quality Metrics + +- **BDD Compliance**: All tests follow GIVEN/WHEN/THEN structure +- **User-Centric Naming**: Tests named `test_user_can_*` describing user workflows +- **Minimal Mocking**: Only external dependencies mocked, system under test is real +- **Test Independence**: Each test can run standalone, no shared mutable state +- **Integration Markers**: Tests marked with `@pytest.mark.integration` and/or `@pytest.mark.sitl` + +### Running Tests Selectively + +```bash +# Run all flight controller tests +pytest tests/test_*flightcontroller*.py -v + +# Run only unit tests (skip SITL integration tests) +pytest tests/test_*flightcontroller*.py -m "not sitl" -v + +# Run only integration tests +pytest -m integration tests/ -v + +# Run only SITL integration tests +pytest -m sitl tests/ -v + +# Run with coverage for backend flight controller modules +pytest tests/test_*flightcontroller*.py --cov=ardupilot_methodic_configurator.backend_flightcontroller --cov-report=html + +# Run specific test file +pytest tests/test_backend_flightcontroller_params.py -v +``` ## File Structure ```text -backend_flightcontroller.py # Core MAVLink communication +# Facade and coordination +backend_flightcontroller.py # Main facade delegating to managers + +# Specialized managers (delegation pattern) +backend_flightcontroller_connection.py # Connection management +backend_flightcontroller_params.py # Parameter operations +backend_flightcontroller_commands.py # Command execution +backend_flightcontroller_files.py # File operations via MAVFTP + +# Protocol definitions and utilities +backend_flightcontroller_protocols.py # Protocol interfaces for DI +backend_flightcontroller_business_logic.py # Pure business logic functions +backend_flightcontroller_factory_mavftp.py # MAVFTP factory functions + +# Data models and supporting files +data_model_flightcontroller_info.py # Flight controller metadata backend_mavftp.py # FTP-over-MAVLink implementation data_model_fc_ids.py # Hardware identification (auto-generated) + +# User interface frontend_tkinter_connection_selection.py # Connection selection GUI frontend_tkinter_flightcontroller_info.py # Information display GUI ``` @@ -277,24 +567,27 @@ frontend_tkinter_flightcontroller_info.py # Information display GUI ### Strengths ✅ -- **Modular Design**: Clear separation between backend communication logic, frontend GUI, and data_model data -- **Type Hints**: Comprehensive type annotations throughout codebase +- **Delegation Pattern**: Clean separation via specialized manager classes +- **Protocol-Based Design**: Dependency injection support via protocol definitions +- **Type Hints**: Comprehensive type annotations with protocol contracts - **Exception Handling**: Robust exception handling with specific error types -- **Documentation**: Well-documented classes and methods with docstrings -- **Testing**: Good test coverage for core functionality +- **Single Source of Truth**: Connection manager owns connection state, params manager owns parameters +- **No Shared Mutable State**: Managers query each other rather than caching references +- **Pure Business Logic**: Stateless functions separated for easy testing +- **Documentation**: Well-documented classes, methods, and architectural patterns +- **Testing Support**: Protocol definitions enable mock implementations +- **Explicit Test APIs**: `set_master_for_testing()` clearly marks test-only code ### Areas for Improvement ⚠️ -- **Code Duplication**: Some MAVLink message handling patterns repeated across files -- **Complex Methods**: Some methods in `backend_flightcontroller.py` exceed recommended length -- **Magic Numbers**: Hardcoded timeout values and retry counts scattered throughout -- **Logging Consistency**: Inconsistent logging levels and message formats +- **Magic Numbers as Class Constants**: Timeout values are now class constants but could be configurable +- **Logging Consistency**: Could benefit from structured logging with consistent formats +- **Configuration Management**: Connection parameters could use centralized config system ### Technical Debt ❌ -- **TODO Comments**: Several unimplemented features marked with TODO comments -- **Deprecated Methods**: Some legacy MAVLink handling code needs modernization -- **Configuration Management**: Connection parameters hardcoded in multiple places +- **TODO Comments**: Some edge cases and optimizations marked with TODO +- **Resumable Operations**: No support for resuming interrupted downloads (requires state persistence) ## Security Analysis @@ -402,18 +695,18 @@ frontend_tkinter_flightcontroller_info.py # Information display GUI ### High Priority 🔴 -1. **Complete MAV-FTP Implementation**: Finish file transfer functionality +1. **Add Resumable Operations**: Implement state persistence for interrupted downloads 2. **Improve Error Recovery**: Add robust recovery from partial failures -3. **Add Comprehensive Logging**: Implement consistent logging throughout +3. **Add Comprehensive Tests**: Test manager interactions and delegation patterns ### Medium Priority 🟡 -1. **Refactor Large Methods**: Break down complex methods into smaller functions -2. **Add Performance Monitoring**: Track communication performance metrics -3. **Improve Test Coverage**: Add more comprehensive test scenarios +1. **Add Performance Monitoring**: Track communication performance metrics +2. **Configuration System**: Centralized configuration for timeouts and retry counts +3. **Structured Logging**: Implement consistent logging with context ### Low Priority 🟢 -1. **Code Documentation**: Expand inline documentation and examples -2. **Configuration Management**: Centralize configuration parameters -3. **UI Polish**: Improve user experience and error message clarity +1. **Code Documentation**: Expand examples showing dependency injection +2. **UI Polish**: Improve user experience and error message clarity +3. **Metrics Collection**: Add telemetry for operation success rates diff --git a/REUSE.toml b/REUSE.toml index 6c182a5d4..89ee36f66 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -58,7 +58,7 @@ SPDX-FileCopyrightText = "2024-2025 Amilcar Lucas, 2025 Jonas Vermeulen" SPDX-License-Identifier = "GPL-3.0-or-later" [[annotations]] -path = ["pyrightconfig.json", "pyproject.toml", "pytest.ini", "test_pip_package.sh", "ardupilot_methodic_configurator_command_line_completion.psm1"] +path = ["pyrightconfig.json", "pyproject.toml", "pytest.ini", "test_pip_package.sh", "ardupilot_methodic_configurator_command_line_completion.psm1", "sitl/copter.parm"] SPDX-FileCopyrightText = "2024-2025 Amilcar Lucas" SPDX-License-Identifier = "GPL-3.0-or-later" diff --git a/SetupDeveloperPC.sh b/SetupDeveloperPC.sh index 8defa0362..a757c2866 100755 --- a/SetupDeveloperPC.sh +++ b/SetupDeveloperPC.sh @@ -154,10 +154,42 @@ ConfigureVSCode() { fi } +SetupSITL() { + echo "" + read -r -p "Do you want to download ArduCopter SITL for integration testing? (y/N) " response + response=${response,,} + + if [[ $response =~ ^y(es)?$ ]]; then + echo "Downloading ArduCopter SITL from official firmware server..." + + # Use the run_sitl_tests.sh script if available + if [ -f "scripts/run_sitl_tests.sh" ]; then + bash scripts/run_sitl_tests.sh download + else + # Fallback to direct download if script not found + mkdir -p sitl + curl -L -o sitl/arducopter https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/arducopter + curl -L -o sitl/firmware-version.txt https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/firmware-version.txt + curl -L -o sitl/git-version.txt https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/git-version.txt + curl -L -o sitl/copter.parm https://raw.githubusercontent.com/ArduPilot/ardupilot/master/Tools/autotest/default_params/copter.parm + chmod +x sitl/arducopter + echo "✓ ArduCopter SITL downloaded successfully" + fi + + # Set environment variable in shell profile + echo "" + echo "To enable SITL tests permanently, add this to your ~/.bashrc or ~/.zshrc:" + echo " export SITL_BINARY=$(pwd)/sitl/arducopter" + else + echo "Skipping SITL download. You can download it later with: ./scripts/run_sitl_tests.sh download" + fi +} + # Call configuration functions InstallDependencies ConfigureGit ConfigureVSCode +SetupSITL activate-global-python-argcomplete @@ -176,6 +208,11 @@ echo "" echo "source .venv/bin/activate" echo "python3 -m ardupilot_methodic_configurator" echo "" +echo "To run SITL integration tests (if SITL was downloaded):" +echo "" +echo "export SITL_BINARY=\$(pwd)/sitl/arducopter" +echo "pytest -m sitl -v" +echo "" echo "For more detailed usage instructions, please refer to the USERMANUAL.md file." echo "" diff --git a/ardupilot_methodic_configurator/backend_flightcontroller.py b/ardupilot_methodic_configurator/backend_flightcontroller.py index 91e21d267..5e2acf095 100644 --- a/ardupilot_methodic_configurator/backend_flightcontroller.py +++ b/ardupilot_methodic_configurator/backend_flightcontroller.py @@ -8,815 +8,239 @@ SPDX-License-Identifier: GPL-3.0-or-later """ -import os from argparse import ArgumentParser -from logging import debug as logging_debug -from logging import error as logging_error from logging import info as logging_info from logging import warning as logging_warning -from os import name as os_name from os import path as os_path -from os import readlink as os_readlink from pathlib import Path from time import sleep as time_sleep -from time import time as time_time -from typing import Callable, NoReturn, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast -import serial.tools.list_ports -import serial.tools.list_ports_common from pymavlink import mavutil -from pymavlink.dialects.v20.ardupilotmega import MAVLink_autopilot_version_message -from serial.serialutil import SerialException +from serial.tools.list_ports_common import ListPortInfo from ardupilot_methodic_configurator import _ from ardupilot_methodic_configurator.argparse_check_range import CheckRange -from ardupilot_methodic_configurator.backend_flightcontroller_info import BackendFlightcontrollerInfo -from ardupilot_methodic_configurator.backend_mavftp import MAVFTP +from ardupilot_methodic_configurator.backend_flightcontroller_commands import FlightControllerCommands +from ardupilot_methodic_configurator.backend_flightcontroller_connection import ( + DEFAULT_BAUDRATE, + SUPPORTED_BAUDRATES, + FlightControllerConnection, +) +from ardupilot_methodic_configurator.backend_flightcontroller_files import FlightControllerFiles +from ardupilot_methodic_configurator.backend_flightcontroller_params import FlightControllerParams +from ardupilot_methodic_configurator.backend_flightcontroller_protocols import ( + FlightControllerCommandsProtocol, + FlightControllerConnectionProtocol, + FlightControllerFilesProtocol, + FlightControllerParamsProtocol, + MavlinkConnection, +) +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo from ardupilot_methodic_configurator.data_model_par_dict import ParDict -# pylint: disable=too-many-lines +if TYPE_CHECKING: + from pymavlink.dialects.v20.ardupilotmega import MAVLink_autopilot_version_message - -class FakeSerialForTests: - """ - A mock serial class for unit testing purposes. - - This class simulates the behavior of a serial connection for testing purposes, - allowing for the testing of serial communication without needing a physical - serial device. It includes methods for reading, writing, and checking the - number of bytes in the input buffer, as well as closing the connection. - """ - - def __init__(self, device: str) -> None: - self.device = device - - def read(self, _len) -> str: # noqa: ANN001 - return "" - - def write(self, _buf) -> NoReturn: # noqa: ANN001 - msg = "write always fails" - raise Exception(msg) # pylint: disable=broad-exception-raised - - def inWaiting(self) -> int: # noqa: N802, pylint: disable=invalid-name - return 0 - - def close(self) -> None: - pass - - -DEFAULT_BAUDRATE: int = 115200 DEFAULT_REBOOT_TIME: int = 7 -# https://github.com/ArduPilot/ardupilot/blob/master/libraries/AP_SerialManager/AP_SerialManager.cpp#L741C1-L757C32 -SUPPORTED_BAUDRATES: list[str] = [ - "1200", - "2400", - "4800", - "9600", - "19200", - "38400", - "57600", - "100000", - "111100", - "115200", - "230400", - "256000", - "460800", - "500000", - "921600", - "1500000", - "2000000", + +# Re-export constants for backwards compatibility +__all__ = [ + "DEFAULT_BAUDRATE", + "DEFAULT_REBOOT_TIME", + "SUPPORTED_BAUDRATES", + "FlightController", + "FlightControllerInfo", + "ParDict", ] -class FlightController: # pylint: disable=too-many-public-methods,too-many-instance-attributes +class FlightController: # pylint: disable=too-many-public-methods """ - A class to manage the connection and parameters of a flight controller. - - Attributes: - device (str): The connection string to the flight controller. - master (mavutil.mavlink_connection): The MAVLink connection object. - fc_parameters (dict[str, float]): A dictionary of flight controller parameters. + Facade for flight controller operations using delegation pattern. + + This class delegates to specialized managers for different concerns: + - Connection operations → FlightControllerConnection (connection_manager) + - Parameter operations → FlightControllerParams (params_manager) + - Command execution → FlightControllerCommands (commands_manager) + - File operations → FlightControllerFiles (files_manager) + + The connection manager is the single source of truth for connection state + (master, comport, info). Other managers query the connection manager for + current state rather than caching it. + + Properties (delegated to managers): + master: MAVLink connection object (delegates to connection_manager) + comport: Current serial/network port (delegates to connection_manager) + comport_device: Device string of current port (delegates to connection_manager) + info: Flight controller metadata (delegates to connection_manager) + fc_parameters: Parameter dictionary (delegates to params_manager) + reboot_time: Time to wait after reboot before reconnecting + baudrate: Default baud rate for serial connections + + Note on Manager Creation Order: + Managers must be created in this order due to dependencies: + 1. FlightControllerInfo (shared state object) + 2. FlightControllerConnection (owns master, comport, info) + 3. FlightControllerParams (depends on connection_manager) + 4. FlightControllerCommands (depends on params_manager and connection_manager) + 5. FlightControllerFiles (depends on connection_manager) """ - def __init__(self, reboot_time: int = DEFAULT_REBOOT_TIME, baudrate: int = DEFAULT_BAUDRATE) -> None: - """Initialize the FlightController communication object.""" - # warn people about ModemManager which interferes badly with ArduPilot - if os_path.exists("/usr/sbin/ModemManager"): - logging_warning(_("You should uninstall ModemManager as it conflicts with ArduPilot")) - - self.__reboot_time = reboot_time - self.__baudrate = baudrate - self.__connection_tuples: list[tuple[str, str]] = [] - self.discover_connections() - self.master: Union[mavutil.mavlink_connection, None] = None # pyright: ignore[reportGeneralTypeIssues] - self.comport: Union[mavutil.SerialPort, None] = None - self.fc_parameters: dict[str, float] = {} - self.info = BackendFlightcontrollerInfo() - - # Battery status tracking - self._last_battery_message_time: float = 0.0 - self._last_battery_status: Union[tuple[float, float], None] = None - - def discover_connections(self) -> None: - comports = FlightController.__list_serial_ports() - netports = FlightController.__list_network_ports() - # list of tuples with the first element being the port name and the second element being the port description - self.__connection_tuples = [(port.device, port.description) for port in comports] + [(port, port) for port in netports] - logging_info(_("Available connection ports are:")) - for port in self.__connection_tuples: - logging_info("%s - %s", port[0], port[1]) - # now that it is logged, add the 'Add another' tuple - self.__connection_tuples += [(_("Add another"), _("Add another"))] - - def disconnect(self) -> None: - """Close the connection to the flight controller.""" - if self.master is not None: - self.master.close() - self.master = None - self.fc_parameters = {} - self.info = BackendFlightcontrollerInfo() - - def add_connection(self, connection_string: str) -> bool: - """Add a new connection to the list of available connections.""" - if connection_string: - # Check if connection_string is not the first element of any tuple in self.other_connection_tuples - if all(connection_string != t[0] for t in self.__connection_tuples): - self.__connection_tuples.insert(-1, (connection_string, connection_string)) - logging_debug(_("Added connection %s"), connection_string) - return True - logging_debug(_("Did not add duplicated connection %s"), connection_string) - else: - logging_debug(_("Did not add empty connection")) - return False - - def _register_and_try_connect( - self, - comport: mavutil.SerialPort, - progress_callback: Union[None, Callable[[int, int], None]], - baudrate: int, - log_errors: bool, - ) -> str: - """ - Register a device in the connection list (if missing) and attempt connection. - - Returns: - str: empty string on success, or error message. - - """ - # set comport for subsequent calls - self.comport = comport - # Add the detected port to the list of available connections if it is not there - if self.comport and self.comport.device not in [t[0] for t in self.__connection_tuples]: - self.__connection_tuples.insert(-1, (self.comport.device, getattr(self.comport, "description", ""))) - # Try to connect - return self.__create_connection_with_retry( - progress_callback=progress_callback, baudrate=baudrate, log_errors=log_errors, timeout=2 - ) - - def connect( + def __init__( # pylint: disable=too-many-arguments, too-many-positional-arguments self, - device: str, - progress_callback: Union[None, Callable[[int, int], None]] = None, - log_errors: bool = True, - baudrate: Optional[int] = None, - ) -> str: - """ - Establishes a connection to the FlightController using a specified device. - - This method attempts to connect to the FlightController using the provided device - connection string. If no device is specified, it attempts to auto-detect a serial - port that matches the preferred ports list. If no serial device is found it tries - the "standard" ArduPilot UDP and TCP connections. If a device is specified as 'test', - it sets some test parameters for debugging purposes. - - Args: - device (str): The connection string to the flight controller. If an empty string - is provided, the method attempts to auto-detect a serial port. - progress_callback (callable, optional): A callback function to report the progress - of the connection attempt. Default is None. - log_errors: log errors - baudrate (int, optional): The baudrate to use for the connection. If None, - uses the default baudrate from initialization. - - Returns: - str: An error message if the connection fails, otherwise an empty string indicating - a successful connection. - - """ - connection_baudrate = baudrate - if connection_baudrate is None: - connection_baudrate = self.__baudrate - - if device: - if device == "none": - return "" - self.add_connection(device) - self.comport = mavutil.SerialPort(device=device, description=device) - return self.__create_connection_with_retry( - progress_callback=progress_callback, baudrate=connection_baudrate, log_errors=log_errors - ) - - # Try to autodetect serial ports - autodetect_serial = self.__auto_detect_serial() - if autodetect_serial: - # Resolve the soft link if it's a Linux system - if os_name == "posix": - try: - dev = autodetect_serial[0].device - logging_debug(_("Auto-detected device %s"), dev) - # Get the directory part of the soft link - softlink_dir = os_path.dirname(dev) - # Resolve the soft link and join it with the directory part - resolved_path = os_path.abspath(os_path.join(softlink_dir, os_readlink(dev))) - autodetect_serial[0].device = resolved_path - logging_debug(_("Resolved soft link %s to %s"), dev, resolved_path) - except OSError: - pass # Not a soft link, proceed with the original device path - err = self._register_and_try_connect( - comport=autodetect_serial[-1], - progress_callback=progress_callback, - baudrate=connection_baudrate, - log_errors=False, - ) - if err == "": - return "" - - # Try to autodetect network ports - netports = FlightController.__list_network_ports() - for port in netports: - # try to connect to each "standard" ArduPilot UDP and TCP ports - logging_debug(_("Trying network port %s"), port) - err = self._register_and_try_connect( - comport=mavutil.SerialPort(device=port, description=port), - progress_callback=progress_callback, - baudrate=self.__baudrate, - log_errors=False, - ) - if err == "": - return "" - - return _("No auto-detected ports responded. Please connect a flight controller and try again.") - - def __request_banner(self) -> None: - """Request banner information from the flight controller.""" - # https://mavlink.io/en/messages/ardupilotmega.html#MAV_CMD_DO_SEND_BANNER - if self.master is not None: - # Note: Don't wait for ACK here as banner requests are fire-and-forget - # and we handle the response via STATUS_TEXT messages - self.master.mav.command_long_send( - self.master.target_system, - self.master.target_component, - mavutil.mavlink.MAV_CMD_DO_SEND_BANNER, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ) - - def __receive_banner_text(self) -> list[str]: - """Starts listening for STATUS_TEXT MAVLink messages.""" - start_time = time_time() - banner_msgs: list[str] = [] - while self.master: - msg = self.master.recv_match(type="STATUSTEXT", blocking=False) - if msg: - if banner_msgs: - banner_msgs.append(msg.text) - else: - banner_msgs = [msg.text] - time_sleep(0.1) # Sleep briefly to reduce CPU usage - if time_time() - start_time > 1: # Check if 1 seconds have passed since the start of the loop - break # Exit the loop if 1 seconds have elapsed - return banner_msgs - - def __request_message(self, message_id: int) -> None: - """Request a specific message from the flight controller.""" - if self.master is not None: - # Note: Don't wait for ACK here as this is used internally for autopilot version requests - # and the response comes as the requested message itself - self.master.mav.command_long_send( - self.info.system_id, - self.info.component_id, - mavutil.mavlink.MAV_CMD_REQUEST_MESSAGE, - 0, # confirmation - message_id, - 0, - 0, - 0, - 0, - 0, - 0, - ) - - def _send_command_and_wait_ack( # pylint: disable=too-many-arguments,too-many-positional-arguments, too-many-locals - self, - command: int, - param1: float = 0, - param2: float = 0, - param3: float = 0, - param4: float = 0, - param5: float = 0, - param6: float = 0, - param7: float = 0, - timeout: float = 5.0, - ) -> tuple[bool, str]: - """ - Send a MAVLink command and wait for acknowledgment. - - Args: - command: The MAVLink command ID - param1: Command parameter 1 - param2: Command parameter 2 - param3: Command parameter 3 - param4: Command parameter 4 - param5: Command parameter 5 - param6: Command parameter 6 - param7: Command parameter 7 - timeout: Timeout in seconds to wait for acknowledgment - - Returns: - tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, - error_message is empty string on success or contains error description on failure - - """ - if self.master is None: - error_msg = _("No flight controller connection available for command") - logging_error(error_msg) - return False, error_msg - - try: - # Send the command - self.master.mav.command_long_send( - self.master.target_system, - self.master.target_component, - command, - 0, # confirmation - param1, - param2, - param3, - param4, - param5, - param6, - param7, - ) - - # Wait for acknowledgment - start_time = time_time() - while time_time() - start_time < timeout: - msg = self.master.recv_match(type="COMMAND_ACK", blocking=False) - if msg and msg.command == command: - # Map result codes to error messages - result_messages = { - mavutil.mavlink.MAV_RESULT_ACCEPTED: ("", True), - mavutil.mavlink.MAV_RESULT_TEMPORARILY_REJECTED: (_("Command temporarily rejected"), False), - mavutil.mavlink.MAV_RESULT_DENIED: (_("Command denied"), False), - mavutil.mavlink.MAV_RESULT_UNSUPPORTED: (_("Command unsupported"), False), - mavutil.mavlink.MAV_RESULT_FAILED: (_("Command failed"), False), - } - - if msg.result in result_messages: - error_msg, success = result_messages[msg.result] - if not success: - logging_error(error_msg) - return success, error_msg - - if msg.result == mavutil.mavlink.MAV_RESULT_IN_PROGRESS: - # Command is still in progress, continue waiting - if msg.progress is not None and msg.progress > 0: - logging_debug(_("Command in progress: %(progress)d%%"), {"progress": msg.progress}) - continue - - # Unknown result code - error_msg = _("Command acknowledgment with unknown result: %(result)d") % {"result": msg.result} - logging_error(error_msg) - return False, error_msg - - time_sleep(0.1) # Sleep briefly to reduce CPU usage - - # Timeout occurred - error_msg = _("Command acknowledgment timeout after %(timeout).1f seconds") % {"timeout": timeout} - logging_error(error_msg) - return False, error_msg - - except Exception as e: # pylint: disable=broad-exception-caught - error_msg = _("Failed to send command: %(error)s") % {"error": str(e)} - logging_error(error_msg) - return False, error_msg - - def __create_connection_with_retry( # pylint: disable=too-many-arguments, too-many-positional-arguments, too-many-locals, too-many-branches - self, - progress_callback: Union[None, Callable[[int, int], None]], - retries: int = 3, - timeout: int = 5, + reboot_time: int = DEFAULT_REBOOT_TIME, baudrate: int = DEFAULT_BAUDRATE, - log_errors: bool = True, - ) -> str: - """ - Attempts to create a connection to the flight controller with retries. - - This method attempts to establish a connection to the flight controller using the - provided device connection string. It will retry the connection attempt up to the - specified number of retries if the initial attempt fails. The method also supports - a progress callback to report the progress of the connection attempt. - - Args: - progress_callback (callable, optional): A callback function to report the progress - of the connection attempt. Default is None. - retries (int, optional): The number of retries before giving up. Default is 3. - timeout (int, optional): The timeout in seconds for each connection attempt. Default is 5. - baudrate (int, optional): The baud rate for the connection. Default is DEFAULT_BAUDRATE. - log_errors (bool): log errors. - - Returns: - str: An error message if the connection fails after all retries, otherwise an empty string - indicating a successful connection. - - """ - if self.comport is None or self.comport.device == "test": # FIXME for testing only pylint: disable=fixme - return "" - if self.comport.device.startswith("udp") or self.comport.device.startswith("tcp"): - logging_info(_("Will connect to %s"), self.comport.device) - else: - logging_info(_("Will connect to %s @ %u baud"), self.comport.device, baudrate) - try: - # Create the connection - self.master = mavutil.mavlink_connection( - device=self.comport.device, - baud=baudrate, - timeout=timeout, - retries=retries, - progress_callback=progress_callback, - ) - logging_debug(_("Waiting for MAVLink heartbeats...")) - if not self.master: - msg = f"Failed to create mavlink connect to {self.comport.device}" - raise ConnectionError(msg) - # --- NEW: collect all vehicles detected within timeout --- - start_time = time_time() - detected_vehicles = {} # (sysid, compid) -> last HEARTBEAT - - while time_time() - start_time < timeout: - m = self.master.recv_match(type="HEARTBEAT", blocking=False) - if m is None: - time_sleep(0.1) - continue - sysid = m.get_srcSystem() - compid = m.get_srcComponent() - detected_vehicles[(sysid, compid)] = m - logging_debug(_("Detected vehicle %u:%u (autopilot=%u, type=%u)"), sysid, compid, m.autopilot, m.type) - - if not detected_vehicles: - return _("No MAVLink heartbeat received, connection failed.") - - for (sysid, compid), m in detected_vehicles.items(): - self.info.set_system_id_and_component_id(sysid, compid) - logging_debug( - _("Connection established with systemID %d, componentID %d."), self.info.system_id, self.info.component_id - ) - self.info.set_autopilot(m.autopilot) - if self.info.is_supported: - msg = _("Autopilot type {self.info.autopilot}") - logging_info(msg.format(**locals())) - self.info.set_type(m.type) - msg = _("Vehicle type: {self.info.mav_type} running {self.info.vehicle_type} firmware") - logging_info(msg.format(**locals())) - break - msg = _("Unsupported autopilot type {self.info.autopilot}") - logging_info(msg.format(**locals())) - - if not self.info.is_supported: - return _("No supported autopilots found") - - self.__request_banner() - banner_msgs = self.__receive_banner_text() - - self.__request_message(mavutil.mavlink.MAVLINK_MSG_ID_AUTOPILOT_VERSION) - m = self.master.recv_match(type="AUTOPILOT_VERSION", blocking=True, timeout=timeout) - return self.__process_autopilot_version(m, banner_msgs) - - except (ConnectionError, SerialException, PermissionError, ConnectionRefusedError) as e: - if log_errors: - logging_warning(_("Connection failed: %s"), e) - logging_error(_("Failed to connect after %d attempts."), retries) - error_message = str(e) - guidance = self.__get_connection_error_guidance(e, self.comport.device if self.comport else "") - if guidance: - error_message = f"{error_message}\n\n{guidance}" - return error_message - - def __get_connection_error_guidance(self, error: Exception, device: str) -> str: - """ - Provides guidance based on the type of connection error. - - Args: - error (Exception): The exception that occurred during connection. - device (str): The device path or connection string. - - Returns: - str: Guidance message specific to the error type, or empty string if no specific guidance. - - """ - # Check for permission denied errors on Linux - if isinstance(error, PermissionError) and os_name == "posix" and "/dev/" in device: - return _( - "Permission denied accessing the serial port. This is common on Linux systems.\n" - "To fix this issue, add your user to the 'dialout' group with the following command:\n" - " sudo adduser $USER dialout\n" - "Then log out and log back in for the changes to take effect." - ) - - # Add more specific guidance for other error types as needed - - return "" - - def __process_autopilot_version(self, m: MAVLink_autopilot_version_message, banner_msgs: list[str]) -> str: - if m is None: - return _( - "No AUTOPILOT_VERSION MAVLink message received, connection failed.\n" - "Only ArduPilot versions newer than 4.3.8 are supported.\n" - "Make sure parameter SERIAL0_PROTOCOL is set to 2" - ) - self.info.set_capabilities(m.capabilities) - self.info.set_flight_sw_version(m.flight_sw_version) - self.info.set_usb_vendor_and_product_ids(m.vendor_id, m.product_id) # must be done before set_board_version() - self.info.set_board_version(m.board_version) - self.info.set_flight_custom_version(m.flight_custom_version) - self.info.set_os_custom_version(m.os_custom_version) - - os_custom_version = "" - os_custom_version_index = None - for i, msg in enumerate(banner_msgs): - if "ChibiOS:" in msg: - os_custom_version = msg.split(" ")[1].strip() - hash_len1 = max(7, len(os_custom_version) - 1) - hash_len2 = max(7, len(self.info.os_custom_version) - 1) - hash_len = min(hash_len1, hash_len2) - if os_custom_version[:hash_len] != self.info.os_custom_version[:hash_len]: - logging_warning( - _("ChibiOS version mismatch: %s (BANNER) != % s (AUTOPILOT_VERSION)"), - os_custom_version, - self.info.os_custom_version, - ) - os_custom_version_index = i - continue - logging_debug("FC banner %s", msg) - - # the banner message after the ChibiOS one contains the FC type - firmware_type = "" - if os_custom_version_index is not None and os_custom_version_index + 1 < len(banner_msgs): - firmware_type_banner_substrings = banner_msgs[os_custom_version_index + 1].split(" ") - if len(firmware_type_banner_substrings) >= 3: - firmware_type = firmware_type_banner_substrings[0] - if firmware_type and firmware_type != self.info.firmware_type: - logging_debug( - _("FC firmware type mismatch: %s (BANNER) != %s (AUTOPILOT_VERSION)"), firmware_type, self.info.firmware_type - ) - self.info.firmware_type = firmware_type # force the one from the banner because it is more reliable - return "" - - def download_params( - self, - progress_callback: Union[None, Callable[[int, int], None]] = None, - parameter_values_filename: Optional[Path] = None, - parameter_defaults_filename: Optional[Path] = None, - ) -> tuple[dict[str, float], ParDict]: + network_ports: Optional[list[str]] = None, + info: Optional[FlightControllerInfo] = None, + connection_manager: Optional[FlightControllerConnectionProtocol] = None, + params_manager: Optional[FlightControllerParamsProtocol] = None, + commands_manager: Optional[FlightControllerCommandsProtocol] = None, + files_manager: Optional[FlightControllerFilesProtocol] = None, + ) -> None: """ - Requests all flight controller parameters from a MAVLink connection. + Initialize the FlightController communication object. Args: - progress_callback (Union[None, Callable[[int, int], None]]): A callback function to report download progress. - parameter_values_filename (Optional[Path]): The filename to save the parameter values. - parameter_defaults_filename (Optional[Path]): The filename to save the parameter defaults. + reboot_time: Time to wait after reboot before reconnecting + baudrate: Default baud rate for serial connections + network_ports: Optional list of network ports to try + info: Optional FlightControllerInfo instance (for dependency injection in tests) + connection_manager: Optional connection manager (for dependency injection in tests) + params_manager: Optional params manager (for dependency injection in tests) + commands_manager: Optional commands manager (for dependency injection in tests) + files_manager: Optional files manager (for dependency injection in tests) - Returns: - dict[str, float]: A dictionary of flight controller parameters. - ParDict: A dictionary of flight controller default parameters. + Note: + If not provided, managers are created in dependency order: + info → connection_manager → params_manager → commands_manager → files_manager + All managers require their dependencies to be created first. """ - # FIXME this entire if statement is for testing only, remove it later pylint: disable=fixme - if self.master is None and self.comport is not None and self.comport.device == "test": - filename = "params.param" - logging_warning(_("Testing active, will load all parameters from the %s file"), filename) - par_dict_with_comments = ParDict.from_file(filename) - return {k: v.value for k, v in par_dict_with_comments.items()}, ParDict() - - if self.master is None: - return {}, ParDict() + # warn people about ModemManager which interferes badly with ArduPilot + if os_path.exists("/usr/sbin/ModemManager"): + logging_warning(_("You should uninstall ModemManager as it conflicts with ArduPilot")) - # Check if MAVFTP is supported - comport_device = getattr(self.comport, "device", "") - if self.info.is_mavftp_supported: - logging_info(_("MAVFTP is supported by the %s flight controller"), comport_device) + self._reboot_time = reboot_time + self._network_ports = network_ports if network_ports is not None else FlightControllerConnection.DEFAULT_NETWORK_PORTS - param_dict, default_param_dict = self._download_params_via_mavftp( - progress_callback, parameter_values_filename, parameter_defaults_filename - ) - if param_dict: - return param_dict, default_param_dict + # Component managers (delegation pattern with dependency injection support) + # If managers are provided via DI, use them; otherwise create default instances + # Connection manager is created first as it owns master, comport, and info (accessed via properties) + # Share the same FlightControllerInfo instance across all managers + _info = info or FlightControllerInfo() + self._connection_manager: FlightControllerConnectionProtocol = connection_manager or FlightControllerConnection( + info=_info, baudrate=baudrate, network_ports=self._network_ports + ) - logging_info(_("MAVFTP is not supported by the %s flight controller, fallback to MAVLink"), comport_device) - return self._download_params_via_mavlink(progress_callback), ParDict() + self._params_manager: FlightControllerParamsProtocol = params_manager or FlightControllerParams( + connection_manager=self._connection_manager, + fc_parameters=None, # Let params_manager create its own fc_parameters dict + ) - def _download_params_via_mavlink( - self, progress_callback: Union[None, Callable[[int, int], None]] = None - ) -> dict[str, float]: - """ - Requests all flight controller parameters from a MAVLink connection. + self._commands_manager: FlightControllerCommandsProtocol = cast( + "FlightControllerCommandsProtocol", + commands_manager + or FlightControllerCommands( + params_manager=self._params_manager, + connection_manager=self._connection_manager, + ), + ) - Gets parameters via PARAM_REQUEST_LIST and PARAM_VALUE messages + self._files_manager: FlightControllerFilesProtocol = files_manager or FlightControllerFiles( + connection_manager=self._connection_manager + ) - Args: - progress_callback (Union[None, Callable[[int, int], None]]): A callback function to report download progress. + # Discover available connections + self.discover_connections() - Returns: - dict[str, float]: A dictionary of flight controller parameters. - ParDict: A dictionary of flight controller default parameters. + @property + def master(self) -> Optional[MavlinkConnection]: + """Get the MAVLink connection - delegates to connection manager.""" + return self._connection_manager.master + def set_master_for_testing(self, value: Optional[MavlinkConnection]) -> None: """ - comport_device = getattr(self.comport, "device", "") - logging_debug(_("Will fetch all parameters from the %s flight controller"), comport_device) + Set the MAVLink connection - FOR TESTING PURPOSES ONLY. - # Dictionary to store parameters - parameters: dict[str, float] = {} + **WARNING: This is a testing-only method.** - # Request all parameters - if self.master is None: - return parameters - - self.master.mav.param_request_list_send(self.master.target_system, self.master.target_component) - - # Loop to receive all parameters - while True: - try: - m = self.master.recv_match(type="PARAM_VALUE", blocking=True, timeout=10) - if m is None: - break - message = m.to_dict() - param_id = message["param_id"] # .decode("utf-8") - param_value = message["param_value"] - parameters[param_id] = param_value - logging_debug(_("Received parameter: %s = %s"), param_id, param_value) - # Call the progress callback with the current progress - if progress_callback: - progress_callback(len(parameters), m.param_count) - if m.param_count == len(parameters): - logging_debug( - _("Fetched %d parameter values from the %s flight controller"), m.param_count, comport_device - ) - break - except Exception as error: # pylint: disable=broad-except - logging_error(_("Error: %s"), error) - break - return parameters - - def _download_params_via_mavftp( - self, - progress_callback: Union[None, Callable[[int, int], None]] = None, - parameter_values_filename: Optional[Path] = None, - parameter_defaults_filename: Optional[Path] = None, - ) -> tuple[dict[str, float], ParDict]: - """ - Requests all flight controller parameters from a MAVLink connection. + This method delegates to the connection manager's set_master_for_testing() + which properly initializes connection state. While still a testing hack, + this is better than direct property assignment as it allows the connection + manager to maintain state consistency. - Gets parameters via MAVFTP protocol + **NEVER use this method in production code - use connect() instead.** Args: - progress_callback (Union[None, Callable[[int, int], None]]): A callback function to report download progress. - parameter_values_filename (Optional[Path]): The filename to save the parameter values. - parameter_defaults_filename (Optional[Path]): The filename to save the parameter defaults. + value: The MAVLink connection object or None - Returns: - dict[str, float]: A dictionary of flight controller parameters. - ParDict: A dictionary of flight controller default parameters. + Note: + See ARCHITECTURE.md for details on testing patterns and architectural + violations. """ - if self.master is None: - return {}, ParDict() - mavftp = MAVFTP(self.master, target_system=self.master.target_system, target_component=self.master.target_component) - - def get_params_progress_callback(completion: float) -> None: - if progress_callback is not None and completion is not None: - progress_callback(int(completion * 100), 100) - - complete_param_filename = str(parameter_values_filename) if parameter_values_filename else "complete.param" - default_param_filename = str(parameter_defaults_filename) if parameter_defaults_filename else "00_default.param" - mavftp.cmd_getparams([complete_param_filename, default_param_filename], progress_callback=get_params_progress_callback) - ret = mavftp.process_ftp_reply("getparams", timeout=40) # on slow links it might take a long time - pdict: dict[str, float] = {} - defdict: ParDict = ParDict() - - # add a file sync operation to ensure the file is completely written - time_sleep(0.3) - if ret.error_code == 0: - # load the parameters from the file - par_dict = ParDict.from_file(complete_param_filename) - pdict = {name: data.value for name, data in par_dict.items()} - defdict = ParDict.from_file(default_param_filename) - else: - ret.display_message() - - return pdict, defdict - - def set_param(self, param_name: str, param_value: float) -> None: - """ - Set a parameter on the flight controller. + self._connection_manager.set_master_for_testing(value) - Args: - param_name (str): The name of the parameter to set. - param_value (float): The value to set the parameter to. + @property + def comport(self) -> Union[mavutil.SerialPort, ListPortInfo, None]: + """Get the current comport - delegates to connection manager.""" + return self._connection_manager.comport - """ - if self.master is None: # FIXME for testing only pylint: disable=fixme - return - self.master.param_set_send(param_name, param_value) + @property + def comport_device(self) -> str: + """Get the current comport device string - delegates to connection manager (single source of truth).""" + return self._connection_manager.comport_device - def fetch_param(self, param_name: str, timeout: int = 5) -> Optional[float]: + @property + def info(self) -> FlightControllerInfo: """ - Fetch a parameter from the flight controller using MAVLink PARAM_REQUEST_READ message. - - Args: - param_name (str): The name of the parameter to fetch. - timeout (int): Timeout in seconds to wait for the response. Default is 5. - - Returns: - float: The value of the parameter, or None if not found or timeout occurred. + Get flight controller info - delegates to connection manager (single source of truth). + Note: Connection manager is the sole mutator of this object to maintain consistency. """ - if self.master is None: # FIXME for testing only pylint: disable=fixme - return None - - # Send PARAM_REQUEST_READ message - self.master.mav.param_request_read_send( - self.master.target_system, - self.master.target_component, - param_name.encode("utf-8"), - -1, # param_index: -1 means use param_id instead - ) - - # Wait for PARAM_VALUE response - start_time = time_time() - while time_time() - start_time < timeout: - msg = self.master.recv_match(type="PARAM_VALUE", blocking=False) - if msg is not None: - # Check if this is the parameter we requested - received_param_name = msg.param_id.rstrip("\x00") - if received_param_name == param_name: - logging_debug(_("Received parameter: %s = %s"), param_name, msg.param_value) - return float(msg.param_value) - time_sleep(0.01) # Small sleep to prevent busy waiting + return self._connection_manager.info - raise TimeoutError(_("Timeout waiting for parameter %s") % param_name) + @property + def reboot_time(self) -> int: + """Get the reboot time setting.""" + return self._reboot_time - def reset_all_parameters_to_default(self) -> tuple[bool, str]: - """ - Reset all parameters to their factory default values. + @property + def baudrate(self) -> int: + """Get the baudrate setting - delegates to connection manager.""" + return self._connection_manager.baudrate - This function sends a MAV_CMD_PREFLIGHT_STORAGE command to reset all parameters - to their factory defaults and waits for acknowledgment from the flight controller. - The flight controller will need to be rebooted after this operation to apply the changes. + @property + def PARAM_FETCH_POLL_DELAY(self) -> float: # noqa: N802 # pylint: disable=invalid-name + """Get parameter fetch poll delay - delegates to params manager.""" + return self._params_manager.PARAM_FETCH_POLL_DELAY - Returns: - tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, - error_message is empty string on success or contains error description on failure + @property + def BATTERY_STATUS_CACHE_TIME(self) -> float: # noqa: N802 # pylint: disable=invalid-name + """Get battery status cache time - delegates to commands manager.""" + return self._commands_manager.BATTERY_STATUS_CACHE_TIME - Note: - After calling this method, the flight controller should be rebooted to - apply the parameter reset. The reset operation will take effect only - after the reboot. + @property + def BATTERY_STATUS_TIMEOUT(self) -> float: # noqa: N802 # pylint: disable=invalid-name + """Get battery status timeout - delegates to commands manager.""" + return self._commands_manager.BATTERY_STATUS_TIMEOUT - """ - if self.master is None: - error_msg = _("No flight controller connection available for parameter reset") - logging_warning(error_msg) - return False, error_msg - - # MAV_CMD_PREFLIGHT_STORAGE command - # https://mavlink.io/en/messages/common.html#MAV_CMD_PREFLIGHT_STORAGE - # param1 = 2: Erase all parameters - success, error_msg = self._send_command_and_wait_ack( - mavutil.mavlink.MAV_CMD_PREFLIGHT_STORAGE, - param1=2, # Storage action (2 = erase all parameters) - param2=0, # Parameter reset (0 = No parameter reset) - param3=0, # Mission reset (not used) - param4=0, # unused - param5=0, # unused - param6=0, # unused - param7=0, # unused - timeout=10.0, # Give more time for parameter reset - ) + @property + def COMMAND_ACK_TIMEOUT(self) -> float: # noqa: N802 # pylint: disable=invalid-name + """Get command acknowledgment timeout - delegates to commands manager.""" + return self._commands_manager.COMMAND_ACK_TIMEOUT - if success: - logging_info(_("Parameter reset to defaults command confirmed by flight controller")) - else: - error_msg = _("Parameter reset command failed: %(error)s") % {"error": error_msg} - logging_error(error_msg) + @property + def fc_parameters(self) -> dict[str, float]: + """Get flight controller parameters - delegates to params manager.""" + return self._params_manager.fc_parameters - return success, error_msg + @fc_parameters.setter + def fc_parameters(self, value: dict[str, float]) -> None: + """Set flight controller parameters - delegates to params manager.""" + self._params_manager.fc_parameters = value def reset_and_reconnect( self, @@ -833,12 +257,14 @@ def reset_and_reconnect( extra_sleep_time (int, optional): The time in seconds to wait before reconnecting. """ - if self.master is None: # FIXME for testing only pylint: disable=fixme + if self.master is None: + logging_warning(_("Cannot reset flight controller: not connected")) return "" # Issue a reset - self.master.reboot_autopilot() + # Type ignore needed because MavlinkConnection is a Union including object fallback + self.master.reboot_autopilot() # type: ignore[union-attr] logging_info(_("Reset command sent to ArduPilot.")) - time_sleep(0.3) + time_sleep(0.3) # Short delay for command to be sent self.disconnect() @@ -847,7 +273,7 @@ def reset_and_reconnect( if extra_sleep_time is None or extra_sleep_time < 0: extra_sleep_time = 0 - sleep_time = self.__reboot_time + extra_sleep_time + sleep_time = self._reboot_time + extra_sleep_time while current_step != sleep_time: # Call the progress callback with the current progress @@ -863,550 +289,209 @@ def reset_and_reconnect( reset_progress_callback(current_step, sleep_time) # Reconnect to the flight controller - return self.__create_connection_with_retry(connection_progress_callback, baudrate=self.__baudrate) + return self.create_connection_with_retry(connection_progress_callback, baudrate=self.baudrate) - @staticmethod - def __list_serial_ports() -> list[serial.tools.list_ports_common.ListPortInfo]: - """List all available serial ports.""" - comports = serial.tools.list_ports.comports() - for port in comports: - logging_debug("ComPort - %s, Description: %s", port.device, port.description) - return comports # type: ignore[no-any-return] + def discover_connections(self) -> None: + """Discover available connections - delegates to connection manager.""" + self._connection_manager.discover_connections() - # Motor Test Functionality + def disconnect(self) -> None: + """Close the connection to the flight controller - delegates to connection manager.""" + self._connection_manager.disconnect() + # Clear parameter cache via params manager + self._params_manager.clear_parameters() - def test_motor( # pylint: disable=too-many-arguments, too-many-positional-arguments - self, test_sequence_nr: int, motor_letters: str, motor_output_nr: int, throttle_percent: int, timeout_seconds: int - ) -> tuple[bool, str]: - """ - Test a specific motor. + def add_connection(self, connection_string: str) -> bool: + """Add a new connection to the list of available connections - delegates to connection manager.""" + return self._connection_manager.add_connection(connection_string) + + # Testing-only methods (protected methods exposed for SITL integration tests) + def _detect_vehicles_from_heartbeats(self, timeout: int) -> dict[tuple[int, int], Any]: + """Detect vehicles from heartbeats - delegates to connection manager (testing only).""" + return self._connection_manager._detect_vehicles_from_heartbeats(timeout) # noqa: SLF001 # pylint: disable=protected-access + + def _extract_firmware_type_from_banner(self, banner_msgs: list[str], os_custom_version_index: Optional[int]) -> str: + """Extract firmware type from banner - delegates to connection manager (testing only).""" + return self._connection_manager._extract_firmware_type_from_banner( # noqa: SLF001 # pylint: disable=protected-access + banner_msgs, os_custom_version_index + ) - Args: - test_sequence_nr: Motor test number, this is not the same as the output number! - motor_letters: Motor letters (for logging purposes only) - motor_output_nr: Motor output number (for logging purposes only) - throttle_percent: Throttle percentage (0-100) - timeout_seconds: Test duration in seconds + def _extract_chibios_version_from_banner(self, banner_msgs: list[str]) -> tuple[str, Optional[int]]: + """Extract ChibiOS version from banner - delegates to connection manager (testing only).""" + return self._connection_manager._extract_chibios_version_from_banner(banner_msgs) # noqa: SLF001 # pylint: disable=protected-access - Returns: - tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, - error_message is empty string on success or contains error description on failure + def _select_supported_autopilot(self, detected_vehicles: dict[tuple[int, int], Any]) -> str: + """Select supported autopilot from detected vehicles - delegates to connection manager (testing only).""" + return self._connection_manager._select_supported_autopilot(detected_vehicles) # noqa: SLF001 # pylint: disable=protected-access - """ - if self.master is None: - error_msg = _("No flight controller connection available for motor test") - logging_error(error_msg) - return False, error_msg - - # MAV_CMD_DO_MOTOR_TEST command - # https://mavlink.io/en/messages/common.html#MAV_CMD_DO_MOTOR_TEST - success, error_msg = self._send_command_and_wait_ack( - mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, - param1=test_sequence_nr + 1, # motor test number, this is not the same as the output number! - param2=mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT, # throttle type - param3=throttle_percent, # throttle value - param4=timeout_seconds, # timeout - param5=0, # motor count (0=test just the motor specified in param1) - param6=0, # test order (0=default/board order) - param7=0, # unused - ) + def _populate_flight_controller_info(self, m: "MAVLink_autopilot_version_message") -> None: + """Populate flight controller info from autopilot version - delegates to connection manager (testing only).""" + self._connection_manager._populate_flight_controller_info(m) # noqa: SLF001 # pylint: disable=protected-access - if success: - logging_info( - _( - "Motor test command acknowledged: Motor %(seq)s on output %(output)d at %(throttle)d%% thrust" - " for %(duration)d seconds" - ), - { - "seq": motor_letters, - "output": motor_output_nr, - "throttle": throttle_percent, - "duration": timeout_seconds, - }, - ) - else: - error_msg = _("Motor test command failed: %(error)s") % {"error": error_msg} - logging_error(error_msg) - - return success, error_msg + def _retrieve_autopilot_version_and_banner(self, timeout: int) -> str: + """Retrieve autopilot version and banner - delegates to connection manager (testing only).""" + return self._connection_manager._retrieve_autopilot_version_and_banner(timeout) # noqa: SLF001 # pylint: disable=protected-access - def test_all_motors(self, nr_of_motors: int, throttle_percent: int, timeout_seconds: int) -> tuple[bool, str]: + def connect( + self, + device: str, + progress_callback: Union[None, Callable[[int, int], None]] = None, + log_errors: bool = True, + baudrate: Optional[int] = None, + ) -> str: """ - Test all motors simultaneously. + Establishes a connection to the FlightController - delegates to connection manager. Args: - nr_of_motors: Number of motors to test - throttle_percent: Throttle percentage (0-100) - timeout_seconds: Test duration in seconds + device (str): The connection string to the flight controller. If an empty string + is provided, the method attempts to auto-detect a serial port. + progress_callback (callable, optional): A callback function to report the progress + of the connection attempt. Default is None. + log_errors: log errors + baudrate (int, optional): The baudrate to use for the connection. If None, + uses the default baudrate from initialization. Returns: - tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, - error_message is empty string on success or contains error description on failure + str: An error message if the connection fails, otherwise an empty string indicating + a successful connection. """ - if self.master is None: - error_msg = _("No flight controller connection available for motor test") - logging_error(error_msg) - return False, error_msg - - for i in range(nr_of_motors): - # MAV_CMD_DO_MOTOR_TEST command for all motors - self.master.mav.command_long_send( - self.master.target_system, - self.master.target_component, - mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, - 0, # confirmation - param1=i + 1, # motor number (1-based) - param2=mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT, # throttle type - param3=throttle_percent, # throttle value - param4=timeout_seconds, # timeout - param5=0, # motor count (0=all motors when param1=0) - param6=0, # test order (0=default/board order) - param7=0, # unused - ) - time_sleep(0.01) # to let the FC parse each command individually - - return True, "" + return self._connection_manager.connect( + device=device, + progress_callback=progress_callback, + log_errors=log_errors, + baudrate=baudrate, + ) - def test_motors_in_sequence( - self, start_motor: int, motor_count: int, throttle_percent: int, timeout_seconds: int - ) -> tuple[bool, str]: + def create_connection_with_retry( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + progress_callback: Union[None, Callable[[int, int], None]], + retries: int = 3, + timeout: int = 5, + baudrate: int = DEFAULT_BAUDRATE, + log_errors: bool = True, + ) -> str: """ - Test motors in sequence (A, B, C, D, etc.). + Attempts to create a connection to the flight controller - delegates to connection manager. Args: - start_motor: The first motor to test (1-based index) - motor_count: Number of motors to test in sequence - throttle_percent: Throttle percentage (1-100) - timeout_seconds: Test duration per motor in seconds + progress_callback: A callback function to report progress + retries: The number of retries before giving up + timeout: The timeout in seconds for each connection attempt + baudrate: The baud rate for the connection + log_errors: Whether to log errors Returns: - tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, - error_message is empty string on success or contains error description on failure + str: An error message if connection fails, otherwise empty string """ - if self.master is None: - error_msg = _("No flight controller connection available for motor test") - logging_error(error_msg) - return False, error_msg - - # MAV_CMD_DO_MOTOR_TEST command for sequence test - success, error_msg = self._send_command_and_wait_ack( - mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, - param1=start_motor, # starting motor number (1-based) - param2=mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT, # throttle type - param3=throttle_percent, # throttle value - param4=timeout_seconds, # timeout per motor - param5=motor_count, # number of motors to test in sequence - param6=mavutil.mavlink.MOTOR_TEST_ORDER_SEQUENCE, # test order (sequence) - param7=0, # unused + return self._connection_manager.create_connection_with_retry( + progress_callback=progress_callback, + retries=retries, + timeout=timeout, + baudrate=baudrate, + log_errors=log_errors, ) - if success: - logging_info( - _("Sequential motor test command confirmed at %(throttle)d%% for %(duration)d seconds per motor"), - { - "throttle": throttle_percent, - "duration": timeout_seconds, - }, - ) - else: - error_msg = _("Sequential motor test command failed: %(error)s") % {"error": error_msg} - logging_error(error_msg) + @staticmethod + def get_serial_ports() -> list[ListPortInfo]: + """Get all available serial ports - delegates to connection manager.""" + return FlightControllerConnection.get_serial_ports() # type: ignore[no-any-return] - return success, error_msg + def get_network_ports(self) -> list[str]: + """Get all available network ports - delegates to connection manager.""" + return self._connection_manager.get_network_ports() - def stop_all_motors(self) -> tuple[bool, str]: - """ - Emergency stop for all motors. + def get_connection_tuples(self) -> list[tuple[str, str]]: + """Get all available connections - delegates to connection manager.""" + return self._connection_manager.get_connection_tuples() - Returns: - tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, - error_message is empty string on success or contains error description on failure + # Parameters interface - Delegated to params manager - """ - if self.master is None: - error_msg = _("No flight controller connection available for motor stop") - logging_error(error_msg) - return False, error_msg - - # Send motor test command with 0% throttle to stop all motors - success, error_msg = self._send_command_and_wait_ack( - mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, - param1=0, # motor number (0 = all motors) - param2=mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT, # throttle type - param3=0, # throttle value (0% = stop) - param4=0, # timeout (0 = immediate stop) - param5=0, # motor count (0 = all motors when param1=0) - param6=0, # test order (0 = default/board order) - param7=0, # unused + def download_params( + self, + progress_callback: Union[None, Callable[[int, int], None]] = None, + parameter_values_filename: Optional[Path] = None, + parameter_defaults_filename: Optional[Path] = None, + ) -> tuple[dict[str, float], ParDict]: + """Download all parameters from flight controller - delegates to params manager.""" + params, defaults = self._params_manager.download_params( + progress_callback, parameter_values_filename, parameter_defaults_filename ) + # params_manager updates its fc_parameters internally, which we access via property + return params, defaults - if success: - logging_info(_("Motor stop command confirmed")) - else: - error_msg = _("Motor stop command failed: %(error)s") % {"error": error_msg} - logging_error(error_msg) + def set_param(self, param_name: str, param_value: float) -> tuple[bool, str]: + """Set a parameter on the flight controller - delegates to params manager.""" + return self._params_manager.set_param(param_name, param_value) - return success, error_msg - - def request_periodic_battery_status(self, interval_microseconds: int = 1000000) -> tuple[bool, str]: - """ - Request periodic BATTERY_STATUS messages from the flight controller. + def fetch_param(self, param_name: str, timeout: int = 5) -> Optional[float]: + """Fetch a parameter from the flight controller - delegates to params manager.""" + return self._params_manager.fetch_param(param_name, timeout) - Args: - interval_microseconds: Message interval in microseconds (default: 1 second = 1,000,000 microseconds) + def reset_all_parameters_to_default(self) -> tuple[bool, str]: + """Reset all parameters to their factory default values - delegates to commands manager.""" + return self._commands_manager.reset_all_parameters_to_default() - Returns: - tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, - error_message is empty string on success or contains error description on failure + # Motor Test Functionality - Delegated to commands manager - """ - if self.master is None: - error_msg = _("No flight controller connection available for battery status request") - logging_debug(error_msg) - return False, error_msg - - # MAV_CMD_SET_MESSAGE_INTERVAL command to request periodic BATTERY_STATUS messages - # https://mavlink.io/en/messages/common.html#MAV_CMD_SET_MESSAGE_INTERVAL - success, error_msg = self._send_command_and_wait_ack( - mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL, - param1=mavutil.mavlink.MAVLINK_MSG_ID_BATTERY_STATUS, # message ID (BATTERY_STATUS) - param2=interval_microseconds, # interval in microseconds - param3=0, # unused - param4=0, # unused - param5=0, # unused - param6=0, # unused - param7=0, # unused - timeout=0.8, # shorter timeout for battery status requests + def test_motor( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, test_sequence_nr: int, motor_letters: str, motor_output_nr: int, throttle_percent: int, timeout_seconds: int + ) -> tuple[bool, str]: + """Test a specific motor - delegates to commands manager.""" + return self._commands_manager.test_motor( + test_sequence_nr, motor_letters, motor_output_nr, throttle_percent, timeout_seconds ) - if success: - logging_debug( - _("Periodic BATTERY_STATUS messages confirmed every %(interval)d microseconds"), - {"interval": interval_microseconds}, - ) - else: - error_msg = _("Failed to request periodic battery status: %(error)s") % {"error": error_msg} - logging_debug(error_msg) + def test_all_motors(self, nr_of_motors: int, throttle_percent: int, timeout_seconds: int) -> tuple[bool, str]: + """Test all motors simultaneously - delegates to commands manager.""" + return self._commands_manager.test_all_motors(nr_of_motors, throttle_percent, timeout_seconds) - return success, error_msg + def test_motors_in_sequence( + self, start_motor: int, motor_count: int, throttle_percent: int, timeout_seconds: int + ) -> tuple[bool, str]: + """Test motors in sequence - delegates to commands manager.""" + return self._commands_manager.test_motors_in_sequence(start_motor, motor_count, throttle_percent, timeout_seconds) - def get_battery_status(self) -> tuple[Union[tuple[float, float], None], str]: - """ - Get current battery voltage and current. + def stop_all_motors(self) -> tuple[bool, str]: + """Emergency stop for all motors - delegates to commands manager.""" + return self._commands_manager.stop_all_motors() - Returns: - tuple[Union[tuple[float, float], None], str]: ((voltage, current), error_message) - - voltage and current in volts and amps, - or None if not available with error message + def request_periodic_battery_status(self, interval_microseconds: int = 1000000) -> tuple[bool, str]: + """Request periodic BATTERY_STATUS messages - delegates to commands manager.""" + return self._commands_manager.request_periodic_battery_status(interval_microseconds) - """ - if not self.fc_parameters or self.master is None: - error_msg = _("No flight controller connection or parameters available") - return None, error_msg - - # Check if battery monitoring is enabled - if not self.is_battery_monitoring_enabled(): - error_msg = _("Battery monitoring is not enabled (BATT_MONITOR=0)") - return None, error_msg - - try: - # Try to get real telemetry data - battery_status = self.master.recv_match(type="BATTERY_STATUS", blocking=False, timeout=0.3) - if battery_status: - # Convert from millivolts to volts, and centiamps to amps - voltage = battery_status.voltages[0] / 1000.0 if battery_status.voltages[0] != -1 else 0.0 - current = battery_status.current_battery / 100.0 if battery_status.current_battery != -1 else 0.0 - self._last_battery_status = (voltage, current) - self._last_battery_message_time = time_time() - return (voltage, current), "" - except Exception as e: # pylint: disable=broad-exception-caught - logging_debug(_("Failed to get battery status from telemetry: %(error)s"), {"error": str(e)}) - - if self._last_battery_message_time and (time_time() - self._last_battery_message_time) < 3: - # If we received a battery message recently, don't log an error - return self._last_battery_status, "" - self._last_battery_status = None - error_msg = _("Battery status not available from telemetry") - return None, error_msg + def get_battery_status(self) -> tuple[Union[tuple[float, float], None], str]: + """Get current battery voltage and current - delegates to commands manager.""" + return self._commands_manager.get_battery_status() def get_voltage_thresholds(self) -> tuple[float, float]: - """ - Get battery voltage thresholds for motor testing safety. - - Returns: - tuple[float, float]: (min_voltage, max_voltage) for safe motor testing - - """ - min_voltage = self.fc_parameters.get("BATT_ARM_VOLT", 0.0) - max_voltage = self.fc_parameters.get("MOT_BAT_VOLT_MAX", 0.0) - return (min_voltage, max_voltage) + """Get battery voltage thresholds - delegates to commands manager.""" + return self._commands_manager.get_voltage_thresholds() def is_battery_monitoring_enabled(self) -> bool: - """ - Check if battery monitoring is enabled. - - Returns: - bool: True if BATT_MONITOR != 0, False otherwise - - """ - return self.fc_parameters.get("BATT_MONITOR", 0) != 0 + """Check if battery monitoring is enabled - delegates to commands manager.""" + return self._commands_manager.is_battery_monitoring_enabled() def get_frame_info(self) -> tuple[int, int]: - """ - Get frame class and frame type from flight controller parameters. + """Get frame class and frame type - delegates to commands manager.""" + return self._commands_manager.get_frame_info() - Returns: - tuple[int, int]: (frame_class, frame_type) - - """ - frame_class = int(self.fc_parameters.get("FRAME_CLASS", 1)) # Default to QUAD - frame_type = int(self.fc_parameters.get("FRAME_TYPE", 1)) # Default to X - return (frame_class, frame_type) - - @staticmethod - def __list_network_ports() -> list[str]: - """List all available network ports.""" - return ["tcp:127.0.0.1:5760", "udp:0.0.0.0:14550"] - - # pylint: disable=duplicate-code - def __auto_detect_serial(self) -> list[mavutil.SerialPort]: - preferred_ports = [ - "*FTDI*", - "*3D*", - "*USB_to_UART*", - "*Ardu*", - "*PX4*", - "*Hex_*", - "*ProfiCNC*", - "*Holybro_*", - "*mRo*", - "*FMU*", - "*Swift-Flyer*", - "*Serial*", - "*CubePilot*", - "*Qiotek*", - ] - serial_list: list[mavutil.SerialPort] = [ - mavutil.SerialPort(device=connection[0], description=connection[1]) - for connection in self.__connection_tuples - if connection[1] and "mavlink" in connection[1].lower() - ] - if len(serial_list) == 1: - # selected automatically if unique - return serial_list - - serial_list = mavutil.auto_detect_serial(preferred_list=preferred_ports) - serial_list.sort(key=lambda x: x.device) - - # remove OTG2 ports for dual CDC - if ( - len(serial_list) == 2 - and serial_list[0].device.startswith("/dev/serial/by-id") - and serial_list[0].device[:-1] == serial_list[1].device[0:-1] - ): - serial_list.pop(1) - - return serial_list - - # pylint: enable=duplicate-code - - def get_connection_tuples(self) -> list[tuple[str, str]]: - """Get all available connections.""" - return self.__connection_tuples + # File operations - Delegated to files manager def upload_file( self, local_filename: str, remote_filename: str, progress_callback: Union[None, Callable[[int, int], None]] = None ) -> bool: - """Upload a file to the flight controller.""" - if self.master is None: - return False - mavftp = MAVFTP(self.master, target_system=self.master.target_system, target_component=self.master.target_component) - - def put_progress_callback(completion: float) -> None: - if progress_callback is not None and completion is not None: - progress_callback(int(completion * 100), 100) - - mavftp.cmd_put([local_filename, remote_filename], progress_callback=put_progress_callback) - ret = mavftp.process_ftp_reply("CreateFile", timeout=10) - if ret.error_code != 0: - ret.display_message() - return ret.error_code == 0 + """Upload a file to the flight controller - delegates to files manager.""" + return self._files_manager.upload_file(local_filename, remote_filename, progress_callback) def download_last_flight_log( self, local_filename: str, progress_callback: Union[None, Callable[[int, int], None]] = None ) -> bool: - """Download the last flight log from the flight controller.""" - if self.master is None: - error_msg = _("No flight controller connected") - logging_error(error_msg) - return False - if not self.info.is_mavftp_supported: - error_msg = _("MAVFTP is not supported by the flight controller") - logging_error(error_msg) - return False - - mavftp = MAVFTP(self.master, target_system=self.master.target_system, target_component=self.master.target_component) - - def get_progress_callback(completion: float) -> None: - if progress_callback is not None and completion is not None: - progress_callback(int(completion * 100), 100) - - try: - # Try to get the last log number using different methods - remote_filenumber = self._get_last_log_number(mavftp) - if remote_filenumber is None: - return False - - # We want the previous log, not the current one (which might be incomplete) - # remote_filenumber -= 1 - # if remote_filenumber < 1: - # logging_error(_("No previous flight log available")) - # return False - - return self._download_log_file(mavftp, remote_filenumber, local_filename, get_progress_callback) - - except Exception as e: # pylint: disable=broad-exception-caught - logging_error(_("Error during flight log download: %s"), str(e)) - return False - - def _get_last_log_number(self, mavftp: MAVFTP) -> Union[int, None]: - """Get the last log number using multiple fallback methods.""" - # Method 1: Try to get LASTLOG.TXT - log_number = self._get_log_number_from_lastlog_txt(mavftp) - if log_number is not None: - return log_number - - # Method 2: Try to list the logs directory and find the highest numbered log - log_number = self._get_log_number_from_directory_listing(mavftp) - if log_number is not None: - return log_number - - # Method 3: Try common log numbers (scan backwards from a reasonable max) - log_number = self._get_log_number_by_scanning(mavftp) - if log_number is not None: - return log_number - - logging_error(_("Could not determine the last log number using any method")) - return None - - def _get_log_number_from_lastlog_txt(self, mavftp: MAVFTP) -> Union[int, None]: - """Try to get the log number from LASTLOG.TXT file.""" - logging_info(_("Trying to get log number from LASTLOG.TXT")) - try: - temp_lastlog_file = "temp_lastlog.txt" - mavftp.cmd_get(["/APM/LOGS/LASTLOG.TXT", temp_lastlog_file]) - ret = mavftp.process_ftp_reply("OpenFileRO", timeout=10) - if ret.error_code != 0: - logging_warning(_("LASTLOG.TXT not available, trying alternative methods")) - return None - - return self._extract_log_number_from_file(temp_lastlog_file) - except Exception as e: # pylint: disable=broad-exception-caught - logging_warning(_("Failed to get log number from LASTLOG.TXT: %s"), str(e)) - return None - - def _get_log_number_from_directory_listing(self, _mavftp: MAVFTP) -> Union[int, None]: - """Try to get the highest log number by listing the logs directory using MAVFTP.""" - logging_info(_("Trying to get log number from directory listing")) - try: - result = _mavftp.cmd_list(["/APM/LOGS/"]) - if not hasattr(result, "directory_listing") or not isinstance(result.directory_listing, dict): - logging_error(_("No directory listing found in MAVFTPReturn")) - return None - highest = -1 - for name in result.directory_listing: - # Typical log file names: 00000036.BIN, 00000037.BIN, etc. - if name.endswith(".BIN") and name[:8].isdigit(): - try: - log_num = int(name[:8]) - highest = max(highest, log_num) - except ValueError: - continue - if highest != -1: - logging_info(_("Highest log number found: %d"), highest) - return highest - logging_error(_("No log files found in directory listing")) - return None - except Exception as e: # pylint: disable=broad-exception-caught - logging_warning(_("Failed to get log number from directory listing: %s"), str(e)) - return None - - def _get_log_number_by_scanning(self, mavftp: MAVFTP) -> Union[int, None]: - """Try to find the last log using binary search for efficiency.""" - logging_info(_("Trying to find log number using binary search")) - try: - # Binary search to find the highest log number - low = 1 - high = 9999 # Reasonable upper bound for log numbers - last_found = None - - while low <= high: - mid = (low + high) // 2 - remote_filename = f"/APM/LOGS/{mid:08}.BIN" - - # Test if this log file exists - temp_test_file = f"temp_test_{mid}.tmp" - mavftp.cmd_get([remote_filename, temp_test_file]) - ret = mavftp.process_ftp_reply("OpenFileRO", timeout=5) # Must be > idle_detection_time (3.7s) - - # Clean up the temp file if it was created - if os.path.exists(temp_test_file): - os.remove(temp_test_file) - - if ret.error_code == 0: - # File exists, search in upper half - last_found = mid - low = mid + 1 - logging_debug(_("Log %d exists, searching higher"), mid) - else: - # File doesn't exist, search in lower half - high = mid - 1 - logging_debug(_("Log %d doesn't exist, searching lower"), mid) - - if last_found is not None: - logging_info(_("Found highest log number using binary search: %d"), last_found) - return last_found - - logging_warning(_("No log files found using binary search")) - return None - - except Exception as e: # pylint: disable=broad-exception-caught - logging_warning(_("Failed to scan for log numbers using binary search: %s"), str(e)) - return None - - def _download_log_file( - self, mavftp: MAVFTP, remote_filenumber: int, local_filename: str, get_progress_callback: Callable - ) -> bool: - """Download the actual log file from the flight controller.""" - remote_filename = f"/APM/LOGS/{remote_filenumber:08}.BIN" - logging_info(_("Downloading flight log %s to %s"), remote_filename, local_filename) - - # Download the actual log file - mavftp.cmd_get([remote_filename, local_filename], progress_callback=get_progress_callback) - ret = mavftp.process_ftp_reply("OpenFileRO", timeout=0) # No timeout for large log files - if ret.error_code != 0: - logging_error(_("Failed to download flight log %s"), remote_filename) - ret.display_message() - return False - - logging_info(_("Successfully downloaded flight log to %s"), local_filename) - return True - - def _extract_log_number_from_file(self, temp_lastlog_file: str) -> Union[int, None]: - """Extract log number from LASTLOG.TXT file and clean up the temporary file.""" - try: - with open(temp_lastlog_file, encoding="UTF-8") as file: - file_contents = file.readline() - return int(file_contents.strip()) - except (FileNotFoundError, ValueError) as e: - logging_error(_("Could not extract last log file number from LASTLOG.TXT: %s"), e) - return None - finally: - # Clean up the temporary file - if os.path.exists(temp_lastlog_file): - os.remove(temp_lastlog_file) + """Download the last flight log from the flight controller - delegates to files manager.""" + return self._files_manager.download_last_flight_log(local_filename, progress_callback) + + # Static methods and properties @staticmethod def add_argparse_arguments(parser: ArgumentParser) -> ArgumentParser: @@ -1422,10 +507,11 @@ def add_argparse_arguments(parser: ArgumentParser) -> ArgumentParser: default="", help=_( "MAVLink connection string to the flight controller. " - 'If set to "none" no connection is made.' - " Default is autodetection" + 'If set to "none" no connection is made. ' + 'If set to "file" the file params.param is used. ' + "Default is autodetection" ), - ).completer = lambda **_: FlightController.__list_serial_ports() # pyright: ignore[reportAttributeAccessIssue] + ).completer = lambda **_: FlightController.get_serial_ports() # pyright: ignore[reportAttributeAccessIssue] parser.add_argument( "-r", "--reboot-time", @@ -1437,10 +523,3 @@ def add_argparse_arguments(parser: ArgumentParser) -> ArgumentParser: help=_("Flight controller reboot time. Default is %(default)s"), ) return parser - - @property - def comport_device(self) -> str: - """Get the current self.comport.device string.""" - if self.comport is not None: - return str(getattr(self.comport, "device", "")) - return "" diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py b/ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py new file mode 100644 index 000000000..b9251041f --- /dev/null +++ b/ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py @@ -0,0 +1,249 @@ +""" +Pure business logic functions for flight controller operations. + +This module contains stateless, side-effect-free functions that implement business rules +and calculations. These functions are easily testable without needing hardware or mocks. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from typing import Optional + + +def calculate_voltage_thresholds(fc_parameters: dict[str, float]) -> tuple[float, float]: + """ + Calculate battery voltage thresholds for motor testing safety. + + This is a pure function that extracts the minimum and maximum voltage thresholds + from flight controller parameters. + + Args: + fc_parameters: Dictionary of flight controller parameters + + Returns: + tuple[float, float]: (min_voltage, max_voltage) for safe motor testing + + Examples: + >>> params = {"BATT_ARM_VOLT": 10.5, "MOT_BAT_VOLT_MAX": 25.2} + >>> calculate_voltage_thresholds(params) + (10.5, 25.2) + + >>> calculate_voltage_thresholds({}) + (0.0, 0.0) + + """ + min_voltage = fc_parameters.get("BATT_ARM_VOLT", 0.0) + max_voltage = fc_parameters.get("MOT_BAT_VOLT_MAX", 0.0) + return (min_voltage, max_voltage) + + +def is_battery_monitoring_enabled(fc_parameters: dict[str, float]) -> bool: + """ + Check if battery monitoring is enabled in flight controller parameters. + + Args: + fc_parameters: Dictionary of flight controller parameters + + Returns: + bool: True if BATT_MONITOR != 0, False otherwise + + Examples: + >>> is_battery_monitoring_enabled({"BATT_MONITOR": 4.0}) + True + + >>> is_battery_monitoring_enabled({"BATT_MONITOR": 0.0}) + False + + >>> is_battery_monitoring_enabled({}) + False + + """ + return fc_parameters.get("BATT_MONITOR", 0) != 0 + + +def get_frame_info(fc_parameters: dict[str, float]) -> tuple[int, int]: + """ + Extract frame class and frame type from flight controller parameters. + + Args: + fc_parameters: Dictionary of flight controller parameters + + Returns: + tuple[int, int]: (frame_class, frame_type) + frame_class: Frame class (default: 1 = QUAD) + frame_type: Frame type (default: 1 = X) + + Examples: + >>> get_frame_info({"FRAME_CLASS": 1.0, "FRAME_TYPE": 3.0}) + (1, 3) + + >>> get_frame_info({}) + (1, 1) + + """ + frame_class = int(fc_parameters.get("FRAME_CLASS", 1)) # Default to QUAD + frame_type = int(fc_parameters.get("FRAME_TYPE", 1)) # Default to X + return (frame_class, frame_type) + + +def validate_battery_voltage( + voltage: float, + min_voltage: float, + max_voltage: float, +) -> tuple[bool, Optional[str]]: + """ + Validate if battery voltage is within safe operating range for motor testing. + + Args: + voltage: Current battery voltage in volts + min_voltage: Minimum safe voltage (BATT_ARM_VOLT) + max_voltage: Maximum safe voltage (MOT_BAT_VOLT_MAX) + + Returns: + tuple[bool, Optional[str]]: (is_valid, error_message) + is_valid: True if voltage is within safe range + error_message: None if valid, descriptive error message if invalid + + Examples: + >>> validate_battery_voltage(12.6, 10.5, 25.2) + (True, None) + + >>> validate_battery_voltage(9.0, 10.5, 25.2) + (False, 'Battery voltage 9.00V is below minimum safe voltage 10.50V') + + >>> validate_battery_voltage(26.0, 10.5, 25.2) + (False, 'Battery voltage 26.00V is above maximum safe voltage 25.20V') + + """ + if voltage < min_voltage: + return False, f"Battery voltage {voltage:.2f}V is below minimum safe voltage {min_voltage:.2f}V" + if voltage > max_voltage: + return False, f"Battery voltage {voltage:.2f}V is above maximum safe voltage {max_voltage:.2f}V" + return True, None + + +def convert_battery_telemetry_units( + voltage_millivolts: int, + current_centiamps: int, +) -> tuple[float, float]: + """ + Convert battery telemetry from MAVLink units to standard units. + + Args: + voltage_millivolts: Battery voltage in millivolts (MAVLink BATTERY_STATUS.voltages) + current_centiamps: Battery current in centiamps (MAVLink BATTERY_STATUS.current_battery) + + Returns: + tuple[float, float]: (voltage_volts, current_amps) + voltage_volts: Voltage in volts + current_amps: Current in amperes + + Examples: + >>> convert_battery_telemetry_units(12600, 1050) + (12.6, 10.5) + + >>> convert_battery_telemetry_units(-1, -1) # Invalid/unavailable readings + (0.0, 0.0) + + """ + voltage = voltage_millivolts / 1000.0 if voltage_millivolts != -1 else 0.0 + current = current_centiamps / 100.0 if current_centiamps != -1 else 0.0 + return (voltage, current) + + +def validate_throttle_percentage(throttle_percent: int) -> tuple[bool, Optional[str]]: + """ + Validate throttle percentage is within safe range for motor testing. + + Args: + throttle_percent: Throttle percentage (0-100) + + Returns: + tuple[bool, Optional[str]]: (is_valid, error_message) + is_valid: True if throttle is valid + error_message: None if valid, descriptive error message if invalid + + Examples: + >>> validate_throttle_percentage(50) + (True, None) + + >>> validate_throttle_percentage(0) + (True, None) + + >>> validate_throttle_percentage(-10) + (False, 'Throttle percentage -10 is below minimum (0)') + + >>> validate_throttle_percentage(150) + (False, 'Throttle percentage 150 is above maximum (100)') + + """ + if throttle_percent < 0: + return False, f"Throttle percentage {throttle_percent} is below minimum (0)" + if throttle_percent > 100: + return False, f"Throttle percentage {throttle_percent} is above maximum (100)" + return True, None + + +def validate_motor_test_duration(timeout_seconds: int) -> tuple[bool, Optional[str]]: + """ + Validate motor test duration is within safe limits. + + Args: + timeout_seconds: Test duration in seconds + + Returns: + tuple[bool, Optional[str]]: (is_valid, error_message) + is_valid: True if duration is valid + error_message: None if valid, descriptive error message if invalid + + Examples: + >>> validate_motor_test_duration(5) + (True, None) + + >>> validate_motor_test_duration(0) + (False, 'Motor test duration 0 seconds is too short (minimum: 1 second)') + + >>> validate_motor_test_duration(35) + (False, 'Motor test duration 35 seconds is too long (maximum: 30 seconds)') + + """ + min_duration = 1 + max_duration = 30 + + if timeout_seconds < min_duration: + return False, f"Motor test duration {timeout_seconds} seconds is too short (minimum: {min_duration} second)" + if timeout_seconds > max_duration: + return False, f"Motor test duration {timeout_seconds} seconds is too long (maximum: {max_duration} seconds)" + return True, None + + +def calculate_motor_sequence_number(motor_index: int, zero_based: bool = True) -> int: + """ + Calculate MAVLink motor sequence number from motor index. + + ArduPilot motor test command uses 1-based sequence numbers, + but motor indices are often 0-based in user interfaces. + + Args: + motor_index: Motor index (0-based or 1-based depending on zero_based parameter) + zero_based: If True, motor_index is 0-based; if False, motor_index is already 1-based + + Returns: + int: MAVLink motor sequence number (1-based) + + Examples: + >>> calculate_motor_sequence_number(0, zero_based=True) + 1 + + >>> calculate_motor_sequence_number(3, zero_based=True) + 4 + + >>> calculate_motor_sequence_number(1, zero_based=False) + 1 + + """ + return motor_index + 1 if zero_based else motor_index diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_commands.py b/ardupilot_methodic_configurator/backend_flightcontroller_commands.py new file mode 100644 index 000000000..3513ca59d --- /dev/null +++ b/ardupilot_methodic_configurator/backend_flightcontroller_commands.py @@ -0,0 +1,538 @@ +""" +Flight controller command execution and status queries. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from logging import debug as logging_debug +from logging import error as logging_error +from logging import info as logging_info +from time import sleep as time_sleep +from time import time as time_time +from typing import ClassVar, Optional, Union + +from pymavlink import mavutil + +from ardupilot_methodic_configurator import _ +from ardupilot_methodic_configurator.backend_flightcontroller_business_logic import ( + calculate_voltage_thresholds, + convert_battery_telemetry_units, + get_frame_info, + is_battery_monitoring_enabled, +) +from ardupilot_methodic_configurator.backend_flightcontroller_protocols import ( + FlightControllerConnectionProtocol, + FlightControllerParamsProtocol, + MavlinkConnection, +) + + +class FlightControllerCommands: + """ + Handles MAVLink command execution and status queries. + + This class manages all command-related operations: + - Motor testing (individual, all, sequence) + - Battery status monitoring + - Frame information retrieval + - Command acknowledgment handling + + Note: Commands manager queries params_manager for parameter values + rather than caching references, ensuring fresh data. + """ + + # Command timeout constants + COMMAND_ACK_TIMEOUT: ClassVar[float] = 5.0 + COMMAND_ACK_TIMEOUT_BATTERY: ClassVar[float] = 0.8 + MOTOR_TEST_COMMAND_DELAY: ClassVar[float] = 0.01 + BATTERY_STATUS_TIMEOUT: ClassVar[float] = 1.5 + BATTERY_STATUS_CACHE_TIME: ClassVar[float] = 3.0 + BATTERY_STATUS_REQUEST_ATTEMPTS: ClassVar[int] = 3 + BATTERY_STATUS_REQUEST_DELAY: ClassVar[float] = 0.3 + BATTERY_STATUS_ACTIVATION_WAIT: ClassVar[float] = 1.0 + + def __init__( + self, + params_manager: Optional[FlightControllerParamsProtocol] = None, + connection_manager: Optional[FlightControllerConnectionProtocol] = None, + ) -> None: + """ + Initialize the command manager. + + Args: + params_manager: Parameters manager to query for parameter values (recommended) + connection_manager: Connection manager to get master from (recommended) + + """ + if params_manager is None: + msg = "params_manager is required" + raise ValueError(msg) + if connection_manager is None: + msg = "connection_manager is required" + raise ValueError(msg) + self._params_manager: FlightControllerParamsProtocol = params_manager + self._connection_manager: FlightControllerConnectionProtocol = connection_manager + self._last_battery_status: Optional[tuple[float, float]] = None + self._last_battery_message_time: float = 0.0 + + @property + def master(self) -> Optional[MavlinkConnection]: + """Get master connection - delegates to connection manager.""" + return self._connection_manager.master + + def send_command_and_wait_ack( # pylint: disable=too-many-arguments,too-many-positional-arguments, too-many-locals + self, + command: int, + param1: float = 0, + param2: float = 0, + param3: float = 0, + param4: float = 0, + param5: float = 0, + param6: float = 0, + param7: float = 0, + timeout: float = 5.0, + ) -> tuple[bool, str]: + """ + Send a MAVLink command and wait for acknowledgment. + + Args: + command: The MAVLink command ID + param1: Command parameter 1 + param2: Command parameter 2 + param3: Command parameter 3 + param4: Command parameter 4 + param5: Command parameter 5 + param6: Command parameter 6 + param7: Command parameter 7 + timeout: Timeout in seconds to wait for acknowledgment + + Returns: + tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, + error_message is empty string on success or contains error description on failure + + """ + if self.master is None: + error_msg = _("No flight controller connection available for command") + logging_error(error_msg) + return False, error_msg + + try: + # Send the command + self.master.mav.command_long_send( # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + self.master.target_system, # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + self.master.target_component, # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + command, + 0, # confirmation + param1, + param2, + param3, + param4, + param5, + param6, + param7, + ) + + # Wait for acknowledgment + start_time = time_time() + while time_time() - start_time < timeout: + msg = self.master.recv_match( # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + type="COMMAND_ACK", blocking=False + ) + if msg and msg.command == command: + # Map result codes to error messages + result_messages = { + mavutil.mavlink.MAV_RESULT_ACCEPTED: ("", True), + mavutil.mavlink.MAV_RESULT_TEMPORARILY_REJECTED: (_("Command temporarily rejected"), False), + mavutil.mavlink.MAV_RESULT_DENIED: (_("Command denied"), False), + mavutil.mavlink.MAV_RESULT_UNSUPPORTED: (_("Command unsupported"), False), + mavutil.mavlink.MAV_RESULT_FAILED: (_("Command failed"), False), + } + + if msg.result in result_messages: + error_msg, success = result_messages[msg.result] + if not success: + logging_error(error_msg) + return success, error_msg + + if msg.result == mavutil.mavlink.MAV_RESULT_IN_PROGRESS: + # Command is still in progress, continue waiting + if msg.progress is not None and msg.progress > 0: + logging_debug(_("Command in progress: %(progress)d%%"), {"progress": msg.progress}) + continue + + # Unknown result code + error_msg = _("Command acknowledgment with unknown result: %(result)d") % {"result": msg.result} + logging_error(error_msg) + return False, error_msg + + time_sleep(0.1) # Sleep briefly to reduce CPU usage + + # Timeout occurred + error_msg = _("Command acknowledgment timeout after %(timeout).1f seconds") % {"timeout": timeout} + logging_error(error_msg) + return False, error_msg + + except Exception as e: # pylint: disable=broad-exception-caught + error_msg = _("Failed to send command: %(error)s") % {"error": str(e)} + logging_error(error_msg) + return False, error_msg + + def reset_all_parameters_to_default(self) -> tuple[bool, str]: + """ + Reset all parameters to their factory default values. + + This function sends a MAV_CMD_PREFLIGHT_STORAGE command to reset all parameters + to their factory defaults and waits for acknowledgment from the flight controller. + The flight controller will need to be rebooted after this operation to apply the changes. + + Returns: + tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, + error_message is empty string on success or contains error description on failure + + Note: + After calling this method, the flight controller should be rebooted to + apply the parameter reset. The reset operation will take effect only + after the reboot. + + """ + if self.master is None: + error_msg = _("No flight controller connection available for parameter reset") + logging_error(error_msg) + return False, error_msg + + # MAV_CMD_PREFLIGHT_STORAGE command + # https://mavlink.io/en/messages/common.html#MAV_CMD_PREFLIGHT_STORAGE + # param1 = 2: Erase all parameters + success, error_msg = self.send_command_and_wait_ack( + mavutil.mavlink.MAV_CMD_PREFLIGHT_STORAGE, + param1=2, # Storage action (2 = erase all parameters) + param2=0, # Parameter reset (0 = No parameter reset) + param3=0, # Mission reset (not used) + param4=0, # unused + param5=0, # unused + param6=0, # unused + param7=0, # unused + timeout=10.0, # Give more time for parameter reset + ) + + if success: + logging_info(_("Parameter reset to defaults command confirmed by flight controller")) + # Clear local cache in params manager + self._params_manager.fc_parameters.clear() + else: + error_msg = _("Parameter reset command failed: %(error)s") % {"error": error_msg} + logging_error(error_msg) + + return success, error_msg + + def test_motor( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, test_sequence_nr: int, motor_letters: str, motor_output_nr: int, throttle_percent: int, timeout_seconds: int + ) -> tuple[bool, str]: + """ + Test a specific motor. + + Args: + test_sequence_nr: Motor test number, this is not the same as the output number! + motor_letters: Motor letters (for logging purposes only) + motor_output_nr: Motor output number (for logging purposes only) + throttle_percent: Throttle percentage (0-100) + timeout_seconds: Test duration in seconds + + Returns: + tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, + error_message is empty string on success or contains error description on failure + + """ + if self.master is None: + error_msg = _("No flight controller connection available for motor test") + logging_error(error_msg) + return False, error_msg + + # MAV_CMD_DO_MOTOR_TEST command + # https://mavlink.io/en/messages/common.html#MAV_CMD_DO_MOTOR_TEST + success, error_msg = self.send_command_and_wait_ack( + mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, + param1=test_sequence_nr + 1, # motor test number, this is not the same as the output number! + param2=mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT, # throttle type + param3=throttle_percent, # throttle value + param4=timeout_seconds, # timeout + param5=0, # motor count (0=test just the motor specified in param1) + param6=0, # test order (0=default/board order) + param7=0, # unused + ) + + if success: + logging_info( + _( + "Motor test command acknowledged: Motor %(seq)s on output %(output)d at %(throttle)d%% thrust" + " for %(duration)d seconds" + ), + { + "seq": motor_letters, + "output": motor_output_nr, + "throttle": throttle_percent, + "duration": timeout_seconds, + }, + ) + else: + error_msg = _("Motor test command failed: %(error)s") % {"error": error_msg} + logging_error(error_msg) + + return success, error_msg + + def test_all_motors(self, nr_of_motors: int, throttle_percent: int, timeout_seconds: int) -> tuple[bool, str]: + """ + Test all motors simultaneously. + + Args: + nr_of_motors: Number of motors to test + throttle_percent: Throttle percentage (0-100) + timeout_seconds: Test duration in seconds + + Returns: + tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, + error_message is empty string on success or contains error description on failure + + """ + if self.master is None: + error_msg = _("No flight controller connection available for motor test") + logging_error(error_msg) + return False, error_msg + + for i in range(nr_of_motors): + # MAV_CMD_DO_MOTOR_TEST command for all motors + self.master.mav.command_long_send( # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + self.master.target_system, # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + self.master.target_component, # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, + 0, # confirmation + param1=i + 1, # motor number (1-based) + param2=mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT, # throttle type + param3=throttle_percent, # throttle value + param4=timeout_seconds, # timeout + param5=0, # motor count (0=all motors when param1=0) + param6=0, # test order (0=default/board order) + param7=0, # unused + ) + time_sleep(self.MOTOR_TEST_COMMAND_DELAY) # to let the FC parse each command individually + + return True, "" + + def test_motors_in_sequence( + self, start_motor: int, motor_count: int, throttle_percent: int, timeout_seconds: int + ) -> tuple[bool, str]: + """ + Test motors in sequence (A, B, C, D, etc.). + + Args: + start_motor: The first motor to test (1-based index) + motor_count: Number of motors to test in sequence + throttle_percent: Throttle percentage (1-100) + timeout_seconds: Test duration per motor in seconds + + Returns: + tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, + error_message is empty string on success or contains error description on failure + + """ + if self.master is None: + error_msg = _("No flight controller connection available for motor test") + logging_error(error_msg) + return False, error_msg + + # MAV_CMD_DO_MOTOR_TEST command for sequence test + success, error_msg = self.send_command_and_wait_ack( + mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, + param1=start_motor, # starting motor number (1-based) + param2=mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT, # throttle type + param3=throttle_percent, # throttle value + param4=timeout_seconds, # timeout per motor + param5=motor_count, # number of motors to test in sequence + param6=mavutil.mavlink.MOTOR_TEST_ORDER_SEQUENCE, # test order (sequence) + param7=0, # unused + ) + + if success: + logging_info( + _("Sequential motor test command confirmed at %(throttle)d%% for %(duration)d seconds per motor"), + { + "throttle": throttle_percent, + "duration": timeout_seconds, + }, + ) + else: + error_msg = _("Sequential motor test command failed: %(error)s") % {"error": error_msg} + logging_error(error_msg) + + return success, error_msg + + def stop_all_motors(self) -> tuple[bool, str]: + """ + Emergency stop for all motors. + + Returns: + tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, + error_message is empty string on success or contains error description on failure + + """ + if self.master is None: + error_msg = _("No flight controller connection available for motor stop") + logging_error(error_msg) + return False, error_msg + + # Send motor test command with 0% throttle to stop all motors + success, error_msg = self.send_command_and_wait_ack( + mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, + param1=0, # motor number (0 = all motors) + param2=mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT, # throttle type + param3=0, # throttle value (0% = stop) + param4=0, # timeout (0 = immediate stop) + param5=0, # motor count (0 = all motors when param1=0) + param6=0, # test order (0 = default/board order) + param7=0, # unused + ) + + if success: + logging_info(_("Motor stop command confirmed")) + else: + error_msg = _("Motor stop command failed: %(error)s") % {"error": error_msg} + logging_error(error_msg) + + return success, error_msg + + def request_periodic_battery_status(self, interval_microseconds: int = 1000000) -> tuple[bool, str]: + """ + Request periodic BATTERY_STATUS messages from the flight controller. + + Args: + interval_microseconds: Message interval in microseconds (default: 1 second = 1,000,000 microseconds) + + Returns: + tuple[bool, str]: (success, error_message) - success is True if command was acknowledged successfully, + error_message is empty string on success or contains error description on failure + + """ + if self.master is None: + error_msg = _("No flight controller connection available for battery status request") + logging_debug(error_msg) + return False, error_msg + + last_error = "" + request_succeeded = False + for attempt in range(self.BATTERY_STATUS_REQUEST_ATTEMPTS): + success, error_msg = self.send_command_and_wait_ack( + mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL, + param1=mavutil.mavlink.MAVLINK_MSG_ID_BATTERY_STATUS, # message ID (BATTERY_STATUS) + param2=interval_microseconds, # interval in microseconds + param3=0, + param4=0, + param5=0, + param6=0, + param7=0, + timeout=self.COMMAND_ACK_TIMEOUT_BATTERY, + ) + if success: + request_succeeded = True + logging_debug( + _("BATTERY_STATUS stream request attempt %(attempt)d confirmed"), + {"attempt": attempt + 1}, + ) + else: + last_error = error_msg + logging_debug( + _("BATTERY_STATUS stream request attempt %(attempt)d failed: %(error)s"), + {"attempt": attempt + 1, "error": error_msg}, + ) + time_sleep(self.BATTERY_STATUS_REQUEST_DELAY) + + if not request_succeeded: + error_msg = _("Failed to request periodic battery status: %(error)s") % {"error": last_error} + logging_debug(error_msg) + return False, error_msg + + logging_debug( + _("Periodic BATTERY_STATUS messages confirmed every %(interval)d microseconds"), + {"interval": interval_microseconds}, + ) + time_sleep(self.BATTERY_STATUS_ACTIVATION_WAIT) + return True, "" + + def get_battery_status(self) -> tuple[Union[tuple[float, float], None], str]: + """ + Get current battery voltage and current. + + Returns: + tuple[Union[tuple[float, float], None], str]: ((voltage, current), error_message) - + voltage and current in volts and amps, + or None if not available with error message + + """ + if not self._params_manager.fc_parameters or self.master is None: + error_msg = _("No flight controller connection or parameters available") + return None, error_msg + + # Check if battery monitoring is enabled + if not self.is_battery_monitoring_enabled(): + error_msg = _("Battery monitoring is not enabled (BATT_MONITOR=0)") + return None, error_msg + + try: + # Try to get real telemetry data + battery_status = self.master.recv_match( # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + type="BATTERY_STATUS", blocking=False, timeout=self.BATTERY_STATUS_TIMEOUT + ) + if battery_status: + # Convert from millivolts to volts, and centiamps to amps using pure business logic + voltage, current = convert_battery_telemetry_units( + battery_status.voltages[0], + battery_status.current_battery, + ) + self._last_battery_status = (voltage, current) + self._last_battery_message_time = time_time() + return (voltage, current), "" + except Exception as e: # pylint: disable=broad-exception-caught + logging_debug(_("Failed to get battery status from telemetry: %(error)s"), {"error": str(e)}) + + if ( + self._last_battery_message_time + and (time_time() - self._last_battery_message_time) < self.BATTERY_STATUS_CACHE_TIME + ): + # If we received a battery message recently, don't log an error + return self._last_battery_status, "" + self._last_battery_status = None + error_msg = _("Battery status not available from telemetry") + return None, error_msg + + def get_voltage_thresholds(self) -> tuple[float, float]: + """ + Get battery voltage thresholds for motor testing safety. + + Returns: + tuple[float, float]: (min_voltage, max_voltage) for safe motor testing + + """ + return calculate_voltage_thresholds(self._params_manager.fc_parameters) + + def is_battery_monitoring_enabled(self) -> bool: + """ + Check if battery monitoring is enabled. + + Returns: + bool: True if BATT_MONITOR != 0, False otherwise + + """ + return is_battery_monitoring_enabled(self._params_manager.fc_parameters) + + def get_frame_info(self) -> tuple[int, int]: + """ + Get frame class and frame type from flight controller parameters. + + Returns: + tuple[int, int]: (frame_class, frame_type) + + """ + return get_frame_info(self._params_manager.fc_parameters) diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_connection.py b/ardupilot_methodic_configurator/backend_flightcontroller_connection.py new file mode 100644 index 000000000..9ad89556f --- /dev/null +++ b/ardupilot_methodic_configurator/backend_flightcontroller_connection.py @@ -0,0 +1,835 @@ +""" +Flight controller connection management. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import contextlib +from logging import debug as logging_debug +from logging import error as logging_error +from logging import info as logging_info +from logging import warning as logging_warning +from os import name as os_name +from os import path as os_path +from os import readlink as os_readlink +from time import sleep as time_sleep +from time import time as time_time +from typing import TYPE_CHECKING, Any, Callable, ClassVar, NoReturn, Optional, Union, no_type_check + +import serial.tools.list_ports +import serial.tools.list_ports_common +from pymavlink import mavutil +from pymavlink.dialects.v20.ardupilotmega import MAVLink_autopilot_version_message +from serial.serialutil import SerialException +from serial.tools.list_ports_common import ListPortInfo + +from ardupilot_methodic_configurator import _ +from ardupilot_methodic_configurator.backend_flightcontroller_factory_mavlink import ( + MavlinkConnectionFactory, + SystemMavlinkConnectionFactory, +) +from ardupilot_methodic_configurator.backend_flightcontroller_factory_serial import ( + SerialPortDiscovery, + SystemSerialPortDiscovery, +) +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo + +if TYPE_CHECKING: + from ardupilot_methodic_configurator.backend_flightcontroller_protocols import MavlinkConnection + + +class FakeSerialForTests: + """ + A mock serial class for unit testing purposes. + + This class simulates the behavior of a serial connection for testing purposes, + allowing for the testing of serial communication without needing a physical + serial device. It includes methods for reading, writing, and checking the + number of bytes in the input buffer, as well as closing the connection. + """ + + def __init__(self, device: str) -> None: + self.device = device + + def read(self, _len) -> str: # noqa: ANN001 + return "" + + def write(self, _buf) -> NoReturn: # noqa: ANN001 + msg = "write always fails" + raise Exception(msg) # pylint: disable=broad-exception-raised + + def inWaiting(self) -> int: # noqa: N802, pylint: disable=invalid-name + return 0 + + def close(self) -> None: + pass + + +DEFAULT_BAUDRATE: int = 115200 +# https://github.com/ArduPilot/ardupilot/blob/master/libraries/AP_SerialManager/AP_SerialManager.cpp#L741C1-L757C32 +SUPPORTED_BAUDRATES: list[str] = [ + "1200", + "2400", + "4800", + "9600", + "19200", + "38400", + "57600", + "100000", + "111100", + "115200", + "230400", + "256000", + "460800", + "500000", + "921600", + "1500000", + "2000000", +] + + +class FlightControllerConnection: # pylint: disable=too-many-instance-attributes + """ + Manages flight controller connection establishment and lifecycle. + + This class handles all aspects of connecting to a flight controller: + - Port discovery (serial and network) + - Connection establishment with retries + - Vehicle detection from heartbeats + - Autopilot version and banner retrieval + - Connection error handling and guidance + """ + + # Connection timeout constants + CONNECTION_RETRY_COUNT: ClassVar[int] = 3 + CONNECTION_TIMEOUT: ClassVar[int] = 5 + CONNECTION_RETRY_TIMEOUT: ClassVar[int] = 2 + HEARTBEAT_POLL_DELAY: ClassVar[float] = 0.1 + BANNER_RECEIVE_TIMEOUT: ClassVar[float] = 1.0 + + # Default network ports to try + DEFAULT_NETWORK_PORTS: ClassVar[list[str]] = [ + "tcp:127.0.0.1:5760", + "udp:0.0.0.0:14550", + ] + + def __init__( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + info: FlightControllerInfo, + baudrate: int = DEFAULT_BAUDRATE, + network_ports: Optional[list[str]] = None, + serial_port_discovery: Optional[SerialPortDiscovery] = None, + mavlink_connection_factory: Optional[MavlinkConnectionFactory] = None, + ) -> None: + """ + Initialize the connection manager. + + Args: + info: Flight controller information object to populate + baudrate: Default baud rate for serial connections + network_ports: Optional list of network ports to try (overrides defaults) + serial_port_discovery: Optional serial port discovery service + mavlink_connection_factory: Optional MAVLink connection factory service + + """ + self.info = info + self.master: Optional[MavlinkConnection] = None + self.comport: Union[mavutil.SerialPort, serial.tools.list_ports_common.ListPortInfo, None] = None + self._baudrate = baudrate + self._network_ports = list(network_ports) if network_ports is not None else self.DEFAULT_NETWORK_PORTS[:] + self._connection_tuples: list[tuple[str, str]] = [] + self._serial_port_discovery: SerialPortDiscovery = serial_port_discovery or SystemSerialPortDiscovery() + self._mavlink_connection_factory: MavlinkConnectionFactory = ( + mavlink_connection_factory or SystemMavlinkConnectionFactory() + ) + + def discover_connections(self) -> None: + """ + Discover all available connections (serial and network ports). + + Populates the list of available serial ports and network ports + that can be used to connect to a flight controller. + """ + comports = self._serial_port_discovery.get_available_ports() + netports = self.get_network_ports() + # list of tuples with the first element being the port name and the second element being the port description + self._connection_tuples = [(port.device, port.description) for port in comports] + [(port, port) for port in netports] + logging_info(_("Available connection ports are:")) + for port in self._connection_tuples: + logging_info("%s - %s", port[0], port[1]) + # now that it is logged, add the 'Add another' tuple + self._connection_tuples += [(_("Add another"), _("Add another"))] + + def disconnect(self) -> None: + """Close the connection to the flight controller.""" + if self.master is not None: + with contextlib.suppress(Exception): + self.master.close() # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + self.master = None + self.info.reset() + + def add_connection(self, connection_string: str) -> bool: + """ + Add a connection string to the list of available connections. + + Args: + connection_string: Connection string (e.g., "COM3", "tcp:localhost:5760") + + Returns: + bool: True if connection string is valid and added + + """ + if connection_string: + # Check if connection_string is not the first element of any tuple in self._connection_tuples + if all(connection_string != t[0] for t in self._connection_tuples): + self._connection_tuples.insert(-1, (connection_string, connection_string)) + logging_debug(_("Added connection %s"), connection_string) + return True + logging_debug(_("Did not add duplicated connection %s"), connection_string) + else: + logging_debug(_("Did not add empty connection")) + return False + + def _register_and_try_connect( + self, + comport: Union[mavutil.SerialPort, serial.tools.list_ports_common.ListPortInfo], + progress_callback: Union[None, Callable[[int, int], None]], + baudrate: int, + log_errors: bool, + ) -> str: + """ + Register a device in the connection list (if missing) and attempt connection. + + Args: + comport: Serial port object to register and connect to + progress_callback: Optional callback for progress updates + baudrate: Baud rate for serial connections + log_errors: Whether to log errors + + Returns: + str: empty string on success, or error message. + + """ + # set comport for subsequent calls + self.comport = comport + # Add the detected port to the list of available connections if it is not there + if self.comport and self.comport.device not in [t[0] for t in self._connection_tuples]: + self._connection_tuples.insert(-1, (self.comport.device, getattr(self.comport, "description", ""))) + # Try to connect + return self.create_connection_with_retry( + progress_callback=progress_callback, + baudrate=baudrate, + log_errors=log_errors, + timeout=self.CONNECTION_RETRY_TIMEOUT, + ) + + def connect( + self, + device: str, + progress_callback: Union[None, Callable[[int, int], None]] = None, + log_errors: bool = True, + baudrate: Optional[int] = None, + ) -> str: + """ + Establishes a connection to the FlightController using a specified device. + + This method attempts to connect to the FlightController using the provided device + connection string. If no device is specified, it attempts to auto-detect a serial + port that matches the preferred ports list. If no serial device is found it tries + the "standard" ArduPilot UDP and TCP connections. + + Args: + device (str): The connection string to the flight controller. If an empty string + is provided, the method attempts to auto-detect a serial port. + progress_callback (callable, optional): A callback function to report the progress + of the connection attempt. Default is None. + log_errors: log errors + baudrate (int, optional): The baudrate to use for the connection. If None, + uses the default baudrate from initialization. + + Returns: + str: An error message if the connection fails, otherwise an empty string indicating + a successful connection. + + """ + connection_baudrate = baudrate if baudrate is not None else self._baudrate + + # Always clear cached metadata before attempting a new connection so UI + # components never display stale data while we probe ports. + self.info.reset() + + if device: + if device == "none": + return "" + self.add_connection(device) + self.comport = mavutil.SerialPort(device=device, description=device) + return self.create_connection_with_retry( + progress_callback=progress_callback, baudrate=connection_baudrate, log_errors=log_errors + ) + + # Try to autodetect serial ports + autodetect_serial = self._auto_detect_serial() + if autodetect_serial: + # Resolve the soft link if it's a Linux system + if os_name == "posix": + try: + dev = autodetect_serial[0].device + logging_debug(_("Auto-detected device %s"), dev) + # Get the directory part of the soft link + softlink_dir = os_path.dirname(dev) + # Resolve the soft link and join it with the directory part + resolved_path = os_path.abspath(os_path.join(softlink_dir, os_readlink(dev))) + autodetect_serial[0].device = resolved_path + logging_debug(_("Resolved soft link %s to %s"), dev, resolved_path) + except OSError: + pass # Not a soft link, proceed with the original device path + err = self._register_and_try_connect( + comport=autodetect_serial[-1], + progress_callback=progress_callback, + baudrate=connection_baudrate, + log_errors=False, + ) + if err == "": + return "" + + # Try to autodetect network ports + netports = self.get_network_ports() + for port in netports: + # try to connect to each "standard" ArduPilot UDP and TCP ports + logging_debug(_("Trying network port %s"), port) + err = self._register_and_try_connect( + comport=mavutil.SerialPort(device=port, description=port), + progress_callback=progress_callback, + baudrate=self._baudrate, + log_errors=False, + ) + if err == "": + return "" + + return _("No auto-detected ports responded. Please connect a flight controller and try again.") + + def _create_mavlink_connection( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + device: str, + baudrate: int = 115200, + timeout: int = 5, + retries: int = 3, + progress_callback: Union[None, Callable[[int, int], None]] = None, + ) -> mavutil.mavlink_connection: # pyright: ignore[reportGeneralTypeIssues] + """ + Factory method for creating MAVLink connections. + + This method can be overridden in tests to inject mock connections. + + Args: + device: Device string (serial port, TCP, UDP address, etc.) + baudrate: Baud rate for serial connections + timeout: Connection timeout in seconds + retries: Number of connection retries + progress_callback: Optional callback for progress updates + + Returns: + mavutil.mavlink_connection: The MAVLink connection object + + """ + return self._mavlink_connection_factory.create( + device=device, + baudrate=baudrate, + timeout=timeout, + retries=retries, + progress_callback=progress_callback, + ) + + def _detect_vehicles_from_heartbeats(self, timeout: int) -> dict[tuple[int, int], Any]: + """ + Detect all vehicles by collecting HEARTBEAT messages within timeout period. + + Args: + timeout: Time in seconds to wait for HEARTBEAT messages + + Returns: + dict[tuple[int, int], Any]: Dictionary mapping (system_id, component_id) to HEARTBEAT message + + """ + start_time = time_time() + detected_vehicles: dict[tuple[int, int], Any] = {} + + while time_time() - start_time < timeout: + m = ( + self.master.recv_match( # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + type="HEARTBEAT", blocking=False + ) + if self.master + else None + ) + if m is None: + time_sleep(self.HEARTBEAT_POLL_DELAY) + continue + sysid = m.get_srcSystem() + compid = m.get_srcComponent() + detected_vehicles[(sysid, compid)] = m + logging_debug(_("Detected vehicle %u:%u (autopilot=%u, type=%u)"), sysid, compid, m.autopilot, m.type) + + return detected_vehicles + + def _select_supported_autopilot(self, detected_vehicles: dict[tuple[int, int], Any]) -> str: + """ + Select a supported autopilot from detected vehicles. + + Args: + detected_vehicles: Dictionary mapping (system_id, component_id) to HEARTBEAT message + + Returns: + str: Error message if no supported autopilot found, empty string on success + + """ + if not detected_vehicles: + return _("No MAVLink heartbeat received, connection failed.") + + for (sysid, compid), m in detected_vehicles.items(): + self.info.set_system_id_and_component_id(str(sysid), str(compid)) + logging_debug( + _("Connection established with systemID %d, componentID %d."), self.info.system_id, self.info.component_id + ) + self.info.set_autopilot(m.autopilot) + if self.info.is_supported: + msg = _("Autopilot type {self.info.autopilot}") + logging_info(msg.format(**locals())) + self.info.set_type(m.type) + msg = _("Vehicle type: {self.info.mav_type} running {self.info.vehicle_type} firmware") + logging_info(msg.format(**locals())) + return "" # Success + msg = _("Unsupported autopilot type {self.info.autopilot}") + logging_info(msg.format(**locals())) + + return _("No supported autopilots found") + + def _retrieve_autopilot_version_and_banner(self, timeout: int) -> str: + """ + Request and process autopilot version and banner information. + + Args: + timeout: Timeout in seconds for receiving messages + + Returns: + str: Error message if processing failed, empty string on success + + """ + # Request banner and collect messages + self._request_banner() + banner_msgs = self._receive_banner_text() + + # Request AUTOPILOT_VERSION message + self._request_message(mavutil.mavlink.MAVLINK_MSG_ID_AUTOPILOT_VERSION) + m = ( + self.master.recv_match( # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + type="AUTOPILOT_VERSION", blocking=True, timeout=timeout + ) + if self.master + else None + ) + + return self._process_autopilot_version(m, banner_msgs) + + def _request_banner(self) -> None: + """Request banner information from the flight controller.""" + # https://mavlink.io/en/messages/ardupilotmega.html#MAV_CMD_DO_SEND_BANNER + if self.master is not None: + # Note: Don't wait for ACK here as banner requests are fire-and-forget + # and we handle the response via STATUS_TEXT messages + self.master.mav.command_long_send( # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + self.master.target_system, # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + self.master.target_component, # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + mavutil.mavlink.MAV_CMD_DO_SEND_BANNER, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ) + + def _receive_banner_text(self) -> list[str]: + """ + Starts listening for STATUS_TEXT MAVLink messages. + + Returns: + list[str]: List of banner text messages received + + """ + start_time = time_time() + banner_msgs: list[str] = [] + while self.master: + msg = self.master.recv_match( # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + type="STATUSTEXT", blocking=False + ) + if msg: + if banner_msgs: + banner_msgs.append(msg.text) + else: + banner_msgs = [msg.text] + time_sleep(0.1) # Sleep briefly to reduce CPU usage + if time_time() - start_time > self.BANNER_RECEIVE_TIMEOUT: + break # Exit the loop if timeout elapsed + return banner_msgs + + def _request_message(self, message_id: int) -> None: + """ + Request a specific message from the flight controller. + + Args: + message_id: MAVLink message ID to request + + """ + if self.master is not None: + # Note: Don't wait for ACK here as this is used internally for autopilot version requests + # and the response comes as the requested message itself + # Convert system_id and component_id from string to int for MAVLink + system_id = int(self.info.system_id) if self.info.system_id else 0 + component_id = int(self.info.component_id) if self.info.component_id else 0 + + self.master.mav.command_long_send( # type: ignore[union-attr] # pyright: ignore[reportAttributeAccessIssue] + system_id, + component_id, + mavutil.mavlink.MAV_CMD_REQUEST_MESSAGE, + 0, # confirmation + message_id, + 0, + 0, + 0, + 0, + 0, + 0, + ) + + def _get_connection_error_guidance(self, error: Exception, device: str) -> str: + """ + Provides guidance based on the type of connection error. + + Args: + error (Exception): The exception that occurred during connection. + device (str): The device path or connection string. + + Returns: + str: Guidance message specific to the error type, or empty string if no specific guidance. + + """ + # Check for permission denied errors on Linux + if isinstance(error, PermissionError) and os_name == "posix" and "/dev/" in device: + return _( + "Permission denied accessing the serial port. This is common on Linux systems.\n" + "To fix this issue, add your user to the 'dialout' group with the following command:\n" + " sudo adduser $USER dialout\n" + "Then log out and log back in for the changes to take effect." + ) + + # Add more specific guidance for other error types as needed + + return "" + + def _extract_chibios_version_from_banner(self, banner_msgs: list[str]) -> tuple[str, Optional[int]]: + """ + Extract ChibiOS version and its index from banner messages. + + Args: + banner_msgs: List of banner messages received from flight controller + + Returns: + tuple[str, Optional[int]]: (os_custom_version, os_custom_version_index) + os_custom_version is the extracted ChibiOS version string + os_custom_version_index is the index where ChibiOS version was found, or None + + """ + os_custom_version = "" + os_custom_version_index = None + + for i, msg in enumerate(banner_msgs): + if "ChibiOS:" in msg: + os_custom_version = msg.split(" ")[1].strip() + hash_len1 = max(7, len(os_custom_version) - 1) + hash_len2 = max(7, len(self.info.os_custom_version) - 1) + hash_len = min(hash_len1, hash_len2) + if os_custom_version[:hash_len] != self.info.os_custom_version[:hash_len]: + logging_warning( + _("ChibiOS version mismatch: %s (BANNER) != % s (AUTOPILOT_VERSION)"), + os_custom_version, + self.info.os_custom_version, + ) + os_custom_version_index = i + continue + logging_debug("FC banner %s", msg) + + return os_custom_version, os_custom_version_index + + def _extract_firmware_type_from_banner(self, banner_msgs: list[str], os_custom_version_index: Optional[int]) -> str: + """ + Extract firmware type from banner messages. + + Args: + banner_msgs: List of banner messages received from flight controller + os_custom_version_index: Index where ChibiOS version was found, or None + + Returns: + str: The extracted firmware type (e.g., "ArduCopter", "ArduPlane") + + """ + firmware_type = "" + + # Try to extract from message after ChibiOS version + if os_custom_version_index is not None and os_custom_version_index + 1 < len(banner_msgs): + firmware_type_banner_substrings = banner_msgs[os_custom_version_index + 1].split(" ") + if len(firmware_type_banner_substrings) >= 3: + firmware_type = firmware_type_banner_substrings[0] + + # Fallback: try first banner message (for SITL or systems without ChibiOS) + elif banner_msgs and not firmware_type: + firmware_type_banner_substrings = banner_msgs[0].split(" ") + if len(firmware_type_banner_substrings) >= 1 and firmware_type_banner_substrings[0].strip(): + firmware_type = firmware_type_banner_substrings[0].strip() + + return firmware_type + + def _populate_flight_controller_info(self, m: MAVLink_autopilot_version_message) -> None: + """ + Populate flight controller info from AUTOPILOT_VERSION message. + + Args: + m: The AUTOPILOT_VERSION MAVLink message + + """ + self.info.set_capabilities(m.capabilities) + self.info.set_flight_sw_version(m.flight_sw_version) + self.info.set_usb_vendor_and_product_ids(m.vendor_id, m.product_id) # must be done before set_board_version() + self.info.set_board_version(m.board_version) + self.info.set_flight_custom_version(m.flight_custom_version) + self.info.set_os_custom_version(m.os_custom_version) + + def _process_autopilot_version(self, m: Optional[MAVLink_autopilot_version_message], banner_msgs: list[str]) -> str: + """ + Process AUTOPILOT_VERSION message and banner messages to extract flight controller info. + + Args: + m: The AUTOPILOT_VERSION MAVLink message, or None if not received + banner_msgs: List of banner messages received from flight controller + + Returns: + str: Error message if processing failed, empty string on success + + """ + if m is None: + return _( + "No AUTOPILOT_VERSION MAVLink message received, connection failed.\n" + "Only ArduPilot versions newer than 4.3.8 are supported.\n" + "Make sure parameter SERIAL0_PROTOCOL is set to 2" + ) + + # Populate basic flight controller info from AUTOPILOT_VERSION message + self._populate_flight_controller_info(m) + + # Extract ChibiOS version from banner messages + _os_custom_version, os_custom_version_index = self._extract_chibios_version_from_banner(banner_msgs) + + # Extract firmware type from banner messages + firmware_type = self._extract_firmware_type_from_banner(banner_msgs, os_custom_version_index) + + # Update firmware type if found and different from AUTOPILOT_VERSION + if firmware_type and firmware_type != self.info.firmware_type: + logging_debug( + _("FC firmware type mismatch: %s (BANNER) != %s (AUTOPILOT_VERSION)"), firmware_type, self.info.firmware_type + ) + self.info.firmware_type = firmware_type # force the one from the banner because it is more reliable + + return "" + + @staticmethod + @no_type_check + def get_serial_ports() -> list[ListPortInfo]: # pyright: ignore[reportGeneralTypeIssues] + """ + Get all available serial ports. + + Returns: + list[ListPortInfo]: List of available serial ports + + """ + return list(serial.tools.list_ports.comports()) + + def get_network_ports(self) -> list[str]: + """ + Get available network ports. + + Returns: + list[str]: List of network connection strings + + """ + return self._network_ports + + # pylint: disable=duplicate-code + def _auto_detect_serial(self) -> list[mavutil.SerialPort]: + """ + Auto-detect serial ports with connected flight controllers. + + Returns: + list[mavutil.SerialPort]: List of detected serial ports + + """ + preferred_ports = [ + "*FTDI*", + "*3D*", + "*USB_to_UART*", + "*Ardu*", + "*PX4*", + "*Hex_*", + "*ProfiCNC*", + "*Holybro_*", + "*mRo*", + "*FMU*", + "*Swift-Flyer*", + "*Serial*", + "*CubePilot*", + "*Qiotek*", + ] + serial_list: list[mavutil.SerialPort] = [ + mavutil.SerialPort(device=connection[0], description=connection[1]) + for connection in self._connection_tuples + if connection[1] and "mavlink" in connection[1].lower() + ] + if len(serial_list) == 1: + # selected automatically if unique + return serial_list + + serial_list = mavutil.auto_detect_serial(preferred_list=preferred_ports) + serial_list.sort(key=lambda x: x.device) + + # remove OTG2 ports for dual CDC + if ( + len(serial_list) == 2 + and serial_list[0].device.startswith("/dev/serial/by-id") + and serial_list[0].device[:-1] == serial_list[1].device[0:-1] + ): + serial_list.pop(1) + + return serial_list + + # pylint: enable=duplicate-code + + def create_connection_with_retry( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + progress_callback: Union[None, Callable[[int, int], None]], + retries: int = 3, + timeout: int = 5, + baudrate: int = DEFAULT_BAUDRATE, + log_errors: bool = True, + ) -> str: + """ + Attempts to create a connection to the flight controller with retries. + + This method attempts to establish a connection to the flight controller using the + provided device connection string. It will retry the connection attempt up to the + specified number of retries if the initial attempt fails. The method also supports + a progress callback to report the progress of the connection attempt. + + Args: + progress_callback (callable, optional): A callback function to report the progress + of the connection attempt. Default is None. + retries (int, optional): The number of retries before giving up. Default is 3. + timeout (int, optional): The timeout in seconds for each connection attempt. Default is 5. + baudrate (int, optional): The baud rate for the connection. Default is DEFAULT_BAUDRATE. + log_errors (bool): log errors. + + Returns: + str: An error message if the connection fails after all retries, otherwise an empty string + indicating a successful connection. + + """ + if self.comport is None or self.comport.device == "file": + # will read parameters from a params.param file instead of a from a flight controller + return "" + if self.comport.device.startswith("udp") or self.comport.device.startswith("tcp"): + logging_info(_("Will connect to %s"), self.comport.device) + else: + logging_info(_("Will connect to %s @ %u baud"), self.comport.device, baudrate) + try: + # Create the connection + self.master = self._create_mavlink_connection( + device=self.comport.device, + baudrate=baudrate, + timeout=timeout, + retries=retries, + progress_callback=progress_callback, + ) + logging_debug(_("Waiting for MAVLink heartbeats...")) + if not self.master: + msg = f"Failed to create mavlink connect to {self.comport.device}" + raise ConnectionError(msg) + + # Detect all vehicles from HEARTBEAT messages + detected_vehicles = self._detect_vehicles_from_heartbeats(timeout) + + # Select a supported autopilot + error = self._select_supported_autopilot(detected_vehicles) + if error: + return error + + # Retrieve autopilot version and banner information + return self._retrieve_autopilot_version_and_banner(timeout) + + except (ConnectionError, SerialException, PermissionError, ConnectionRefusedError) as e: + if log_errors: + logging_warning(_("Connection failed: %s"), e) + logging_error(_("Failed to connect after %d attempts."), retries) + error_message = str(e) + guidance = self._get_connection_error_guidance(e, self.comport.device if self.comport else "") + if guidance: + error_message = f"{error_message}\n\n{guidance}" + return error_message + + def get_connection_tuples(self) -> list[tuple[str, str]]: + """ + Get list of available connection strings as (device, description) tuples. + + Returns: + list[tuple[str, str]]: List of (device, description) tuples + + """ + return self._connection_tuples + + @property + def comport_device(self) -> str: + """Get the device string of the current comport.""" + if self.comport is None: + return "" + return str(getattr(self.comport, "device", "")) + + @property + def baudrate(self) -> int: + """Get the default baud rate for serial connections.""" + return self._baudrate + + def set_master_for_testing( + self, + master: Optional[mavutil.mavlink_connection], # pyright: ignore[reportGeneralTypeIssues] + ) -> None: + """ + Set the MAVLink connection for testing purposes. + + WARNING: This is a testing-only method. Do not use in production code. + Use connect() instead for proper connection establishment. + + This method properly initializes the connection state including setting + the master connection object. Unlike direct property assignment, this + ensures consistent state initialization. + + Args: + master: The MAVLink connection object or None + + """ + self.master = master + # If setting to None, also clear related state + if master is None: + self.comport = None diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_factory_mavftp.py b/ardupilot_methodic_configurator/backend_flightcontroller_factory_mavftp.py new file mode 100644 index 000000000..aea91c589 --- /dev/null +++ b/ardupilot_methodic_configurator/backend_flightcontroller_factory_mavftp.py @@ -0,0 +1,61 @@ +""" +MAVFTP utility functions. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from typing import Optional, Union + +from pymavlink import mavutil + +from ardupilot_methodic_configurator.backend_mavftp import MAVFTP + + +def create_mavftp(master: Union[mavutil.mavlink_connection, None]) -> MAVFTP: # pyright: ignore[reportGeneralTypeIssues] + """ + Factory function for creating MAVFTP instances. + + This function can be mocked in tests to inject mock MAVFTP. + + Args: + master: The MAVLink connection object + + Returns: + MAVFTP: The MAVFTP instance + + Raises: + RuntimeError: If no MAVLink connection is available + + """ + if master is None: + msg = "No MAVLink connection available for MAVFTP" + raise RuntimeError(msg) + return MAVFTP(master, target_system=master.target_system, target_component=master.target_component) + + +def create_mavftp_safe( + master: Union[mavutil.mavlink_connection, None], # pyright: ignore[reportGeneralTypeIssues] +) -> Optional[MAVFTP]: # pyright: ignore[reportGeneralTypeIssues] + """ + Factory function for creating MAVFTP instances with safe error handling. + + Returns None instead of raising an exception when MAVFTP is unavailable. + + Args: + master: The MAVLink connection object + + Returns: + MAVFTP: The MAVFTP instance, or None if not available + + """ + if master is None or MAVFTP is None: + return None + return MAVFTP( + master, + target_system=master.target_system, + target_component=master.target_component, + ) diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_factory_mavlink.py b/ardupilot_methodic_configurator/backend_flightcontroller_factory_mavlink.py new file mode 100644 index 000000000..7152a0908 --- /dev/null +++ b/ardupilot_methodic_configurator/backend_flightcontroller_factory_mavlink.py @@ -0,0 +1,123 @@ +""" +MAVLink connection factory service for flight controller connections. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from typing import Optional, Protocol + +from pymavlink import mavutil + + +class MavlinkConnectionFactory(Protocol): # pylint: disable=too-few-public-methods + """Protocol for creating MAVLink connections.""" + + def create( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + device: str, + baudrate: int, + timeout: float = 5.0, + retries: int = 3, + progress_callback: Optional[object] = None, + ) -> Optional[mavutil.mavlink_connection]: # pyright: ignore[reportGeneralTypeIssues] + """Create a MAVLink connection.""" + ... # pylint: disable=unnecessary-ellipsis + + +class SystemMavlinkConnectionFactory: # pylint: disable=too-few-public-methods + """Real implementation using PyMAVLink library.""" + + def create( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + device: str, + baudrate: int, + timeout: float = 5.0, + retries: int = 3, + progress_callback: Optional[object] = None, + ) -> Optional[mavutil.mavlink_connection]: # pyright: ignore[reportGeneralTypeIssues] + """Create connection using actual PyMAVLink library.""" + try: + return mavutil.mavlink_connection( + device=device, + baud=baudrate, + timeout=timeout, + retries=retries, + progress_callback=progress_callback, + ) + except (OSError, TimeoutError, ValueError) as exc: + # Preserve the root cause in a ConnectionError so callers can display + # actionable information to the user. + msg = f"{device}: {exc}" + raise ConnectionError(msg) from exc + + +class FakeMavlinkConnectionFactory: + """Mock implementation for testing without actual hardware.""" + + def __init__(self) -> None: + """Initialize mock factory.""" + self._connections: dict[str, FakeMavlinkConnection] = {} + + def create( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + device: str, + baudrate: int, + timeout: float = 5.0, # noqa: ARG002 # pylint: disable=unused-argument + retries: int = 3, + progress_callback: Optional[object] = None, + ) -> Optional["FakeMavlinkConnection"]: + """Create a fake MAVLink connection for testing.""" + conn = FakeMavlinkConnection(device, baudrate) + conn.retries = retries + conn.progress_callback = progress_callback + return conn + + def get_connection(self, device: str) -> Optional["FakeMavlinkConnection"]: + """Get a previously created fake connection.""" + return self._connections.get(device) + + +class FakeMavlinkConnection: + """Fake MAVLink connection for testing.""" + + retries: int + progress_callback: Optional[object] + + def __init__(self, device: str, baudrate: int) -> None: + """Initialize fake connection.""" + self.device = device + self.baudrate = baudrate + self.connected = True + self._message_queue: list[object] = [] + + def recv_match( + self, + blocking: bool = True, # noqa: ARG002 # pylint: disable=unused-argument + timeout: Optional[float] = None, # noqa: ARG002 # pylint: disable=unused-argument + ) -> Optional[object]: + """Receive a matched message from queue.""" + # Note: blocking and timeout parameters are accepted for API compatibility + # but not used in fake implementation + if self._message_queue: + return self._message_queue.pop(0) + return None + + def mav_send(self, msg: object) -> None: + """Send a MAVLink message (no-op for fake).""" + # Note: msg parameter is accepted for API compatibility but not used in fake + + def close(self) -> None: + """Close connection.""" + self.connected = False + + def add_message(self, msg: object) -> None: + """Add a message to the queue for testing.""" + self._message_queue.append(msg) + + def clear_messages(self) -> None: + """Clear all queued messages.""" + self._message_queue.clear() diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_factory_serial.py b/ardupilot_methodic_configurator/backend_flightcontroller_factory_serial.py new file mode 100644 index 000000000..648d4aba5 --- /dev/null +++ b/ardupilot_methodic_configurator/backend_flightcontroller_factory_serial.py @@ -0,0 +1,88 @@ +""" +Serial port discovery service for flight controller connections. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from typing import Protocol + +import serial.tools.list_ports +import serial.tools.list_ports_common + + +class SerialPortDiscovery(Protocol): + """Protocol for discovering and managing serial ports.""" + + def get_available_ports( + self, + ) -> list[serial.tools.list_ports_common.ListPortInfo]: + """Get list of available serial ports.""" + ... # pylint: disable=unnecessary-ellipsis + + def get_port_description(self, device: str) -> str: + """Get description for a serial port device.""" + ... # pylint: disable=unnecessary-ellipsis + + +class SystemSerialPortDiscovery: + """Real implementation using PySerial for hardware discovery.""" + + def get_available_ports( + self, + ) -> list[serial.tools.list_ports_common.ListPortInfo]: + """Get actual serial ports from system.""" + return list(serial.tools.list_ports.comports()) + + def get_port_description(self, device: str) -> str: + """Get port description from system.""" + for port in serial.tools.list_ports.comports(): + if port.device == device: + return str(port.description) + return device + + +class FakeSerialPortDiscovery: + """Mock implementation for testing without physical hardware.""" + + def __init__(self) -> None: + """Initialize with empty port list.""" + self._ports: list[serial.tools.list_ports_common.ListPortInfo] = [] + + def get_available_ports( + self, + ) -> list[serial.tools.list_ports_common.ListPortInfo]: + """Return configured fake ports.""" + return self._ports.copy() + + def get_port_description(self, device: str) -> str: + """Return fake port description.""" + for port in self._ports: + if port.device == device: + return str(port.description) + return f"Test Port {device}" + + def add_port( + self, + device: str, + description: str = "Test Serial Port", + manufacturer: str = "Test Manufacturer", + ) -> None: + """Add a fake port for testing.""" + port_info = _create_mock_port_info(device, description, manufacturer) + self._ports.append(port_info) + + def clear_ports(self) -> None: + """Clear all configured fake ports.""" + self._ports.clear() + + +def _create_mock_port_info(device: str, description: str, manufacturer: str) -> serial.tools.list_ports_common.ListPortInfo: + """Create a mock ListPortInfo object for testing.""" + port_info = serial.tools.list_ports_common.ListPortInfo(device) + port_info.description = description + port_info.manufacturer = manufacturer + return port_info diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_files.py b/ardupilot_methodic_configurator/backend_flightcontroller_files.py new file mode 100644 index 000000000..3f9516865 --- /dev/null +++ b/ardupilot_methodic_configurator/backend_flightcontroller_files.py @@ -0,0 +1,374 @@ +""" +Flight controller file operations using MAVFTP. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import os +from logging import debug as logging_debug +from logging import error as logging_error +from logging import info as logging_info +from logging import warning as logging_warning +from typing import TYPE_CHECKING, Callable, ClassVar, Optional, Union + +from pymavlink import mavutil + +from ardupilot_methodic_configurator import _ +from ardupilot_methodic_configurator.backend_flightcontroller_factory_mavftp import create_mavftp_safe +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo + +if TYPE_CHECKING: + from ardupilot_methodic_configurator.backend_flightcontroller_protocols import FlightControllerConnectionProtocol + +# Conditionally import MAVFTP if available +try: + from ardupilot_methodic_configurator.backend_mavftp import MAVFTP + + # from pymavlink import mavftp + # MAVFTP = mavftp.MAVFTP +except ImportError: + MAVFTP = None # type: ignore[assignment,misc] + + +class FlightControllerFiles: + """ + Handles file operations via MAVFTP protocol. + + This class manages all file transfer operations: + - Uploading files to flight controller + - Downloading files from flight controller + - Finding and downloading last flight log + - Directory listing and scanning + """ + + # MAVFTP timeout constants + MAVFTP_FILE_OPERATION_TIMEOUT: ClassVar[int] = 10 + MAVFTP_FILE_OPERATION_TIMEOUT_SHORT: ClassVar[int] = 5 + + def __init__( + self, + connection_manager: Optional["FlightControllerConnectionProtocol"] = None, + ) -> None: + """ + Initialize the file operations manager. + + Args: + connection_manager: Connection manager to get master and info from + + """ + if connection_manager is None: + msg = "connection_manager is required" + raise ValueError(msg) + self._connection_manager: FlightControllerConnectionProtocol = connection_manager + + @property + def master(self) -> Optional[mavutil.mavlink_connection]: # pyright: ignore[reportGeneralTypeIssues] + """Get master connection.""" + return self._connection_manager.master + + @property + def info(self) -> FlightControllerInfo: + """Get flight controller info.""" + return self._connection_manager.info + + def upload_file( + self, local_filename: str, remote_filename: str, progress_callback: Union[None, Callable[[int, int], None]] = None + ) -> bool: + """ + Upload a file to the flight controller. + + Args: + local_filename: Local file path to upload + remote_filename: Remote file path on flight controller + progress_callback: Optional callback function for progress updates (current, total) + + Returns: + bool: True if upload was successful, False otherwise + + """ + if self.master is None: + logging_error(_("No flight controller connection available for file upload")) + return False + + mavftp_instance = create_mavftp_safe(self.master) + if mavftp_instance is None: + logging_error(_("MAVFTP is not available for file upload")) + return False + + def put_progress_callback(completion: float) -> None: + if progress_callback is not None and completion is not None: + progress_callback(int(completion * 100), 100) + + try: + mavftp_instance.cmd_put([local_filename, remote_filename], progress_callback=put_progress_callback) + ret = mavftp_instance.process_ftp_reply("CreateFile", timeout=self.MAVFTP_FILE_OPERATION_TIMEOUT) + if ret.error_code != 0: + ret.display_message() + return False + logging_info( + _("Successfully uploaded %(local)s to %(remote)s"), {"local": local_filename, "remote": remote_filename} + ) + return True + except Exception as e: # pylint: disable=broad-exception-caught + logging_error(_("Failed to upload file: %(error)s"), {"error": str(e)}) + return False + + def download_last_flight_log( + self, local_filename: str, progress_callback: Union[None, Callable[[int, int], None]] = None + ) -> bool: + """ + Download the last flight log from the flight controller. + + Args: + local_filename: Local file path to save the downloaded log + progress_callback: Optional callback function for progress updates (current, total) + + Returns: + bool: True if download was successful, False otherwise + + """ + if self.master is None: + error_msg = _("No flight controller connected") + logging_error(error_msg) + return False + if not self.info.is_mavftp_supported: + error_msg = _("MAVFTP is not supported by the flight controller") + logging_error(error_msg) + return False + + mavftp_instance = create_mavftp_safe(self.master) + if mavftp_instance is None: + logging_error(_("MAVFTP is not available for file download")) + return False + + def get_progress_callback(completion: float) -> None: + if progress_callback is not None and completion is not None: + progress_callback(int(completion * 100), 100) + + try: + # Try to get the last log number using different methods + remote_filenumber = self._get_last_log_number(mavftp_instance) + if remote_filenumber is None: + return False + + return self._download_log_file(mavftp_instance, remote_filenumber, local_filename, get_progress_callback) + + except Exception as e: # pylint: disable=broad-exception-caught + logging_error(_("Error during flight log download: %(error)s"), {"error": str(e)}) + return False + + def _get_last_log_number(self, mavftp_instance: "MAVFTP") -> Optional[int]: # pyright: ignore[reportInvalidTypeForm] + """ + Get the last log number using multiple fallback methods. + + Args: + mavftp_instance: MAVFTP object for file operations + + Returns: + Optional[int]: Last log number, or None if not found + + """ + # Method 1: Try to get LASTLOG.TXT + log_number = self._get_log_number_from_lastlog_txt(mavftp_instance) + if log_number is not None: + return log_number + + # Method 2: Try to list the logs directory and find the highest numbered log + log_number = self._get_log_number_from_directory_listing(mavftp_instance) + if log_number is not None: + return log_number + + # Method 3: Try common log numbers (scan backwards from a reasonable max) + log_number = self._get_log_number_by_scanning(mavftp_instance) + if log_number is not None: + return log_number + + logging_error(_("Could not determine the last log number using any method")) + return None + + def _get_log_number_from_lastlog_txt( + self, + mavftp_instance: "MAVFTP", # pyright: ignore[reportInvalidTypeForm] + ) -> Optional[int]: + """ + Try to get the log number from LASTLOG.TXT file. + + Args: + mavftp_instance: MAVFTP object for file operations + + Returns: + Optional[int]: Log number from LASTLOG.TXT, or None if not available + + """ + logging_info(_("Trying to get log number from LASTLOG.TXT")) + try: + temp_lastlog_file = "temp_lastlog.txt" + mavftp_instance.cmd_get(["/APM/LOGS/LASTLOG.TXT", temp_lastlog_file]) + ret = mavftp_instance.process_ftp_reply("OpenFileRO", timeout=self.MAVFTP_FILE_OPERATION_TIMEOUT) + if ret.error_code != 0: + logging_warning(_("LASTLOG.TXT not available, trying alternative methods")) + return None + + return self._extract_log_number_from_file(temp_lastlog_file) + except Exception as e: # pylint: disable=broad-exception-caught + logging_warning(_("Failed to get log number from LASTLOG.TXT: %(error)s"), {"error": str(e)}) + return None + + def _get_log_number_from_directory_listing( + self, + mavftp_instance: "MAVFTP", # pyright: ignore[reportInvalidTypeForm] + ) -> Optional[int]: + """ + Try to get the highest log number by listing the logs directory using MAVFTP. + + Args: + mavftp_instance: MAVFTP object for file operations + + Returns: + int: Highest log number from directory listing, or None if not found + + """ + logging_info(_("Trying to get log number from directory listing")) + try: + result = mavftp_instance.cmd_list(["/APM/LOGS/"]) + if not hasattr(result, "directory_listing") or not isinstance(result.directory_listing, dict): + logging_error(_("No directory listing found in MAVFTPReturn")) + return None + highest = -1 + for name in result.directory_listing: + # Typical log file names: 00000036.BIN, 00000037.BIN, etc. + if name.endswith(".BIN") and name[:8].isdigit(): + try: + log_num = int(name[:8]) + highest = max(highest, log_num) + except ValueError: + continue + if highest != -1: + logging_info(_("Highest log number found: %(number)d"), {"number": highest}) + return highest + logging_error(_("No log files found in directory listing")) + return None + except Exception as e: # pylint: disable=broad-exception-caught + logging_warning(_("Failed to get log number from directory listing: %(error)s"), {"error": str(e)}) + return None + + def _get_log_number_by_scanning( + self, + mavftp_instance: "MAVFTP", # pyright: ignore[reportInvalidTypeForm] + ) -> Optional[int]: + """ + Try to find the last log using binary search for efficiency. + + Args: + mavftp_instance: MAVFTP object for file operations + + Returns: + Optional[int]: Highest log number found, or None if not found + + """ + logging_info(_("Trying to find log number using binary search")) + try: + # Binary search to find the highest log number + low = 1 + high = 9999 # Reasonable upper bound for log numbers + last_found = None + + while low <= high: + mid = (low + high) // 2 + remote_filename = f"/APM/LOGS/{mid:08}.BIN" + + # Test if this log file exists + temp_test_file = f"temp_test_{mid}.tmp" + mavftp_instance.cmd_get([remote_filename, temp_test_file]) + # Must be > idle_detection_time (3.7s) + ret = mavftp_instance.process_ftp_reply("OpenFileRO", timeout=self.MAVFTP_FILE_OPERATION_TIMEOUT_SHORT) + + # Clean up the temp file if it was created + if os.path.exists(temp_test_file): + os.remove(temp_test_file) + + if ret.error_code == 0: + # File exists, search in upper half + last_found = mid + low = mid + 1 + logging_debug(_("Log %(number)d exists, searching higher"), {"number": mid}) + else: + # File doesn't exist, search in lower half + high = mid - 1 + logging_debug(_("Log %(number)d doesn't exist, searching lower"), {"number": mid}) + + if last_found is not None: + logging_info(_("Found highest log number using binary search: %(number)d"), {"number": last_found}) + return last_found + + logging_warning(_("No log files found using binary search")) + return None + + except Exception as e: # pylint: disable=broad-exception-caught + logging_warning(_("Failed to scan for log numbers using binary search: %(error)s"), {"error": str(e)}) + return None + + def _download_log_file( + self, + mavftp_instance: "MAVFTP", # pyright: ignore[reportInvalidTypeForm] + remote_filenumber: int, + local_filename: str, + get_progress_callback: Callable, + ) -> bool: + """ + Download the actual log file from the flight controller. + + Args: + mavftp_instance: MAVFTP object for file operations + remote_filenumber: Remote log file number to download + local_filename: Local file path to save the downloaded log + get_progress_callback: Callback function for progress updates + + Returns: + bool: True if download was successful, False otherwise + + """ + remote_filename = f"/APM/LOGS/{remote_filenumber:08}.BIN" + logging_info(_("Downloading flight log %(remote)s to %(local)s"), {"remote": remote_filename, "local": local_filename}) + + try: + # Download the actual log file + mavftp_instance.cmd_get([remote_filename, local_filename], progress_callback=get_progress_callback) + ret = mavftp_instance.process_ftp_reply("OpenFileRO", timeout=0) # No timeout for large log files + if ret.error_code != 0: + logging_error(_("Failed to download flight log %(remote)s"), {"remote": remote_filename}) + ret.display_message() + return False + + logging_info(_("Successfully downloaded flight log to %(local)s"), {"local": local_filename}) + return True + except Exception as e: # pylint: disable=broad-exception-caught + logging_error(_("Failed to download log file: %(error)s"), {"error": str(e)}) + return False + + def _extract_log_number_from_file(self, temp_lastlog_file: str) -> Optional[int]: + """ + Extract log number from LASTLOG.TXT file and clean up the temporary file. + + Args: + temp_lastlog_file: Path to the file containing the log number + + Returns: + Optional[int]: Log number from the file, or None if not found or parsing failed + + """ + try: + with open(temp_lastlog_file, encoding="UTF-8") as file: + file_contents = file.readline() + return int(file_contents.strip()) + except (FileNotFoundError, ValueError) as e: + logging_error(_("Could not extract last log file number from LASTLOG.TXT: %(error)s"), {"error": str(e)}) + return None + finally: + # Clean up the temporary file + if os.path.exists(temp_lastlog_file): + os.remove(temp_lastlog_file) diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_params.py b/ardupilot_methodic_configurator/backend_flightcontroller_params.py new file mode 100644 index 000000000..a4a89c556 --- /dev/null +++ b/ardupilot_methodic_configurator/backend_flightcontroller_params.py @@ -0,0 +1,343 @@ +""" +Flight controller parameter management. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from logging import debug as logging_debug +from logging import error as logging_error +from logging import info as logging_info +from logging import warning as logging_warning +from math import nan +from pathlib import Path +from time import sleep as time_sleep +from time import time as time_time +from typing import TYPE_CHECKING, Any, Callable, Optional, Union + +from pymavlink import mavutil + +from ardupilot_methodic_configurator import _ +from ardupilot_methodic_configurator.backend_flightcontroller_factory_mavftp import create_mavftp +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo +from ardupilot_methodic_configurator.data_model_par_dict import ParDict, validate_param_name + +# Type hint for connection manager to avoid circular imports +if TYPE_CHECKING: + from ardupilot_methodic_configurator.backend_flightcontroller_protocols import FlightControllerConnectionProtocol + + +class FlightControllerParams: + """ + Manages flight controller parameter operations. + + This class handles all parameter-related operations: + - Downloading parameters (via MAVLink or MAVFTP) + - Setting individual parameters + - Fetching individual parameters + - Resetting all parameters to defaults + """ + + # Parameter operation timeout constants + PARAM_SET_PROPAGATION_DELAY: float = 0.5 + FILE_SYNC_DELAY: float = 0.3 + PARAM_FETCH_POLL_DELAY: float = 0.01 + PARAM_RESET_TIMEOUT: float = 10.0 + MAVFTP_GETPARAMS_TIMEOUT: float = 40.0 + + def __init__( + self, + connection_manager: Optional["FlightControllerConnectionProtocol"] = None, + fc_parameters: Optional[dict[str, float]] = None, # to simplify testing/mocking + ) -> None: + """ + Initialize the parameter manager. + + Args: + connection_manager: Connection manager to get master/info/comport from + fc_parameters: Shared parameter dictionary (if None, creates new one) + + """ + if connection_manager is None: + msg = "connection_manager is required" + raise ValueError(msg) + self._connection_manager: FlightControllerConnectionProtocol = connection_manager + # Use provided fc_parameters dict or create new one + self.fc_parameters: dict[str, float] = fc_parameters if fc_parameters is not None else {} + + @property + def master(self) -> Optional[mavutil.mavlink_connection]: # pyright: ignore[reportGeneralTypeIssues] + """Get master connection.""" + return self._connection_manager.master + + @property + def info(self) -> FlightControllerInfo: + """Get flight controller info.""" + return self._connection_manager.info + + @property + def comport_device(self) -> str: + """Get comport device string.""" + return self._connection_manager.comport_device + + def download_params( + self, + progress_callback: Union[None, Callable[[int, int], None]] = None, + parameter_values_filename: Optional[Path] = None, + parameter_defaults_filename: Optional[Path] = None, + ) -> tuple[dict[str, float], ParDict]: + """ + Requests all flight controller parameters from a MAVLink connection. + + Args: + progress_callback: A callback function to report download progress + parameter_values_filename: The filename to save the parameter values + parameter_defaults_filename: The filename to save the parameter defaults + + Returns: + tuple[dict[str, float], ParDict]: (parameter_values, default_parameters) + parameter_values is a dictionary of parameter name to value + default_parameters is a ParDict of default parameter values + + """ + if self.master is None and self.comport_device == "file": + filename = "params.param" + logging_warning(_("Testing active, will load all parameters from the %s file"), filename) + par_dict_with_comments = ParDict.from_file(filename) + param_dict = {k: v.value for k, v in par_dict_with_comments.items()} + self.fc_parameters = param_dict + return param_dict, ParDict() + + if self.master is None: + return {}, ParDict() + + # Check if MAVFTP is supported + if self.info.is_mavftp_supported: + logging_info(_("MAVFTP is supported by the %s flight controller"), self.comport_device) + + param_dict, default_param_dict = self._download_params_via_mavftp( + progress_callback, parameter_values_filename, parameter_defaults_filename + ) + if param_dict: + self.fc_parameters = param_dict + return param_dict, default_param_dict + + logging_info(_("MAVFTP is not supported by the %s flight controller, fallback to MAVLink"), self.comport_device) + param_dict = self._download_params_via_mavlink(progress_callback) + self.fc_parameters = param_dict + return param_dict, ParDict() + + def _download_params_via_mavlink( + self, progress_callback: Union[None, Callable[[int, int], None]] = None + ) -> dict[str, float]: + """ + Requests all flight controller parameters via MAVLink PARAM_REQUEST_LIST. + + Gets parameters via PARAM_REQUEST_LIST and PARAM_VALUE messages + + Args: + progress_callback: A callback function to report download progress + + Returns: + dict[str, float]: A dictionary of flight controller parameters + + """ + logging_debug(_("Will fetch all parameters from the %s flight controller"), self.comport_device) + + # Dictionary to store parameters + parameters: dict[str, float] = {} + + # Request all parameters + if self.master is None: + return parameters + + self.master.mav.param_request_list_send(self.master.target_system, self.master.target_component) + + # Loop to receive all parameters + while True: + try: + m = self.master.recv_match(type="PARAM_VALUE", blocking=True, timeout=10) + if m is None: + break + message = m.to_dict() + param_id = message["param_id"] + param_value = message["param_value"] + parameters[param_id] = param_value + logging_debug(_("Received parameter: %s = %s"), param_id, param_value) + # Call the progress callback with the current progress + if progress_callback: + progress_callback(len(parameters), m.param_count) + if m.param_count == len(parameters): + logging_debug( + _("Fetched %d parameter values from the %s flight controller"), m.param_count, self.comport_device + ) + break + except Exception as error: # pylint: disable=broad-except + logging_error(_("Error: %s"), error) + break + return parameters + + def _download_params_via_mavftp( + self, + progress_callback: Union[None, Callable[[int, int], None]] = None, + parameter_values_filename: Optional[Path] = None, + parameter_defaults_filename: Optional[Path] = None, + ) -> tuple[dict[str, float], ParDict]: + """ + Requests all flight controller parameters via MAVFTP protocol. + + Gets parameters via MAVFTP protocol, which is faster than MAVLink for parameter downloads. + + Args: + progress_callback: A callback function to report download progress + parameter_values_filename: The filename to save the parameter values + parameter_defaults_filename: The filename to save the parameter defaults + + Returns: + tuple[dict[str, float], ParDict]: (parameter_values, default_parameters) + + """ + if self.master is None: + return {}, ParDict() + mavftp = create_mavftp(self.master) + + def get_params_progress_callback(completion: float) -> None: + if progress_callback is not None and completion is not None: + progress_callback(int(completion * 100), 100) + + complete_param_filename = str(parameter_values_filename) if parameter_values_filename else "complete.param" + default_param_filename = str(parameter_defaults_filename) if parameter_defaults_filename else "00_default.param" + mavftp.cmd_getparams([complete_param_filename, default_param_filename], progress_callback=get_params_progress_callback) + # on slow links parameter download might take a long time + ret = mavftp.process_ftp_reply("getparams", timeout=self.MAVFTP_GETPARAMS_TIMEOUT) + pdict: dict[str, float] = {} + defdict: ParDict = ParDict() + + # add a file sync operation to ensure the file is completely written + time_sleep(self.FILE_SYNC_DELAY) + if ret.error_code == 0: + # load the parameters from the file + par_dict = ParDict.from_file(complete_param_filename) + pdict = {name: data.value for name, data in par_dict.items()} + defdict = ParDict.from_file(default_param_filename) + else: + ret.display_message() + + if progress_callback is not None: + progress_callback(100, 100) + + return pdict, defdict + + def set_param(self, param_name: str, param_value: float) -> tuple[bool, str]: + """ + Set a parameter on the flight controller. + + Note: This method sends the parameter but does NOT wait for confirmation. + This is an ArduPilot limitation - the parameter_set command does not return an ACK. + The local cache is updated optimistically. + + Args: + param_name: The name of the parameter to set + param_value: The value to set the parameter to + + Returns: + tuple[bool, str]: (True, "") if command sent successfully, + (False, error_message) if no connection available or invalid parameters + + """ + if self.master is None: + return False, _("No flight controller connection available") + + # Validate parameter name using ArduPilot standards + is_valid_name, name_error = validate_param_name(param_name) + if not is_valid_name: + logging_error(name_error) + return False, name_error + + # Validate parameter value + if not isinstance(param_value, (int, float)): + error_msg = _("Invalid parameter value type: %s (expected numeric)") % type(param_value).__name__ + logging_error(error_msg) + return False, error_msg + + self.master.param_set_send(param_name, param_value) + # Update local cache optimistically + self.fc_parameters[param_name] = param_value + return True, "" + + def get_param(self, param_name: str, default: float = nan) -> float: + """ + Get a parameter value from the local cache. + + Args: + param_name: The name of the parameter to get + default: Default value if parameter not found + + Returns: + float: The parameter value from cache, or default if not found + + """ + return self.fc_parameters.get(param_name, default) + + def fetch_param(self, param_name: str, timeout: int = 5) -> Optional[float]: + """ + Fetch a parameter from the flight controller using MAVLink PARAM_REQUEST_READ message. + + Args: + param_name: The name of the parameter to fetch + timeout: Timeout in seconds to wait for the response. Default is 5 + + Returns: + float: The value of the parameter + + """ + if self.master is None: + return None + + # Validate parameter name using ArduPilot standards + is_valid_name, name_error = validate_param_name(param_name) + if not is_valid_name: + logging_error(name_error) + raise IndexError(name_error) + + if timeout <= 0: + msg = _("Timeout for parameter %s is non-positive, skipping request") % param_name + logging_error(msg) + raise ValueError(msg) + + # Send PARAM_REQUEST_READ message + self.master.mav.param_request_read_send( + self.master.target_system, + self.master.target_component, + param_name.encode("utf-8"), + -1, # param_index: -1 means use param_id instead + ) + + # Wait for PARAM_VALUE response + start_time = time_time() + while time_time() - start_time < timeout: + param_msg: Any = self.master.recv_match(type="PARAM_VALUE", blocking=False) + if param_msg is not None: + # Check if this is the parameter we requested + received_param_name = param_msg.param_id.rstrip("\x00") + if received_param_name == param_name: + logging_debug(_("Received parameter: %s = %s"), param_name, param_msg.param_value) + value = float(param_msg.param_value) + # Update local cache + self.fc_parameters[param_name] = value + return value + time_sleep(self.PARAM_FETCH_POLL_DELAY) # Small sleep to prevent busy waiting + + raise TimeoutError(_("Timeout waiting for parameter %s") % param_name) + + def clear_parameters(self) -> None: + """ + Clear all cached parameters. + + This should be called when disconnecting from the flight controller + to ensure stale parameter data is not retained. + """ + self.fc_parameters.clear() diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_protocols.py b/ardupilot_methodic_configurator/backend_flightcontroller_protocols.py new file mode 100644 index 000000000..f27aced4a --- /dev/null +++ b/ardupilot_methodic_configurator/backend_flightcontroller_protocols.py @@ -0,0 +1,265 @@ +""" +Protocol interfaces for flight controller component managers. + +These protocols define the contracts between the main FlightController class +and its component managers, enabling dependency injection and better testability. + +Type Checking Pattern: + To avoid circular imports, implementations should import these protocols + using TYPE_CHECKING guard: + + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from ardupilot_methodic_configurator.backend_flightcontroller_protocols import ( + FlightControllerConnectionProtocol, + ) + + This allows type hints to reference the protocol without runtime import, + preventing circular dependency issues. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Optional, Protocol, Union + +import serial.tools.list_ports_common + +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo +from ardupilot_methodic_configurator.data_model_par_dict import ParDict + +# Type alias for MAVLink connection to avoid type checker issues +# pymavlink.mavutil.mavlink_connection is actually a function that returns various connection types +# We define MavlinkConnection as a protocol-like type to represent any MAVLink connection object +if TYPE_CHECKING: + # During type checking, import the actual mavutil module for better type hints + from pymavlink import mavutil + from pymavlink.dialects.v20.ardupilotmega import MAVLink_autopilot_version_message + + # Use a union of known connection types for better type safety + # Note: mavutil.mavlink_connection() returns different types based on the connection string + MavlinkConnection = Union[ + mavutil.mavserial, + mavutil.mavudp, + mavutil.mavtcp, + mavutil.mavtcpin, + mavutil.mavmcast, + object, # Fallback for other connection types + ] +else: + # At runtime, we don't need the actual types + from pymavlink import mavutil + + MavlinkConnection = object + + +class FlightControllerConnectionProtocol(Protocol): + """ + Protocol for flight controller connection management. + + The connection manager is the single source of truth for: + - master: MAVLink connection object + - comport: Current serial/network port + - info: Flight controller metadata (connection manager is sole mutator) + + Dependencies: + Required at construction: + - info: FlightControllerInfo (shared state, can be provided or created) + - baudrate: int (default baud rate for serial connections) + - network_ports: list[str] (list of network ports to try) + """ + + @property + def master(self) -> Optional[MavlinkConnection]: + """Get the current MAVLink connection object.""" + + @property + def info(self) -> FlightControllerInfo: # pyright: ignore[reportInvalidTypeForm] + """Get flight controller information (connection manager is sole mutator).""" + ... # pylint: disable=unnecessary-ellipsis + + @property + def comport(self) -> Union[mavutil.SerialPort, serial.tools.list_ports_common.ListPortInfo, None]: + """Get the current communication port.""" + + @property + def comport_device(self) -> str: ... + + @property + def baudrate(self) -> int: + """Get the default baud rate for serial connections.""" + ... # pylint: disable=unnecessary-ellipsis + + def discover_connections(self) -> None: ... + + def disconnect(self) -> None: ... + + def add_connection(self, connection_string: str) -> bool: ... + + def connect( + self, + device: str, + progress_callback: Union[None, Callable[[int, int], None]], + log_errors: bool, + baudrate: Optional[int], + ) -> str: ... + + def create_connection_with_retry( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + progress_callback: Union[None, Callable[[int, int], None]], + retries: int, + timeout: int, + baudrate: int, + log_errors: bool, + ) -> str: ... + + def get_serial_ports(self) -> list[serial.tools.list_ports_common.ListPortInfo]: + """Get all available serial ports.""" + ... # pylint: disable=unnecessary-ellipsis + + def get_network_ports(self) -> list[str]: + """Get all available network ports.""" + ... # pylint: disable=unnecessary-ellipsis + + def get_connection_tuples(self) -> list[tuple[str, str]]: ... + + def set_master_for_testing(self, master: Optional[MavlinkConnection]) -> None: + """ + Set the MAVLink connection for testing purposes. + + WARNING: This is a testing-only method that properly initializes connection state. + Do not use in production code - use connect() instead. + + Args: + master: The MAVLink connection object or None + + """ + + def _detect_vehicles_from_heartbeats(self, timeout: int) -> dict[tuple[int, int], Any]: ... + + def _extract_firmware_type_from_banner(self, banner_msgs: list[str], os_custom_version_index: Optional[int]) -> str: ... + + def _extract_chibios_version_from_banner(self, banner_msgs: list[str]) -> tuple[str, Optional[int]]: ... + + def _select_supported_autopilot(self, detected_vehicles: dict[tuple[int, int], Any]) -> str: ... + + def _populate_flight_controller_info(self, m: "MAVLink_autopilot_version_message") -> None: ... + + def _retrieve_autopilot_version_and_banner(self, timeout: int) -> str: ... + + +class FlightControllerParamsProtocol(Protocol): + """ + Protocol for flight controller parameter operations. + + Dependencies: + Required at construction: + - connection_manager: FlightControllerConnectionProtocol (to access master, info, comport_device) + Optional at construction: + - fc_parameters: dict[str, float] (shared parameter dictionary, created if not provided) + """ + + # Class constant exposed as property for backward compatibility + PARAM_FETCH_POLL_DELAY: float + + @property + def fc_parameters(self) -> dict[str, float]: + """Get the parameter dictionary.""" + ... # pylint: disable=unnecessary-ellipsis + + @fc_parameters.setter + def fc_parameters(self, value: dict[str, float]) -> None: + """Set the parameter dictionary.""" + + def download_params( + self, + progress_callback: Union[None, Callable[[int, int], None]], + parameter_values_filename: Optional[Path], + parameter_defaults_filename: Optional[Path], + ) -> tuple[dict[str, float], ParDict]: ... + + def set_param(self, param_name: str, param_value: float) -> tuple[bool, str]: ... + + def fetch_param(self, param_name: str, timeout: int) -> Optional[float]: ... + + def get_param(self, param_name: str, default: float = 0.0) -> float: ... + + def clear_parameters(self) -> None: ... + + +class FlightControllerCommandsProtocol(Protocol): + """ + Protocol for flight controller command execution. + + Note: Commands manager queries params_manager for parameter values + rather than caching references, ensuring fresh data. + + Dependencies: + Required at construction: + - params_manager: FlightControllerParamsProtocol (to query parameter values) + - connection_manager: FlightControllerConnectionProtocol (to access master) + """ + + # Class constants exposed for testing + COMMAND_ACK_TIMEOUT: float + BATTERY_STATUS_CACHE_TIME: float + BATTERY_STATUS_TIMEOUT: float + + def send_command_and_wait_ack( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + command: int, + param1: float, + param2: float, + param3: float, + param4: float, + param5: float, + param6: float, + param7: float, + timeout: float, + ) -> tuple[bool, str]: ... + + def reset_all_parameters_to_default(self) -> tuple[bool, str]: ... + + def test_motor( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, test_sequence_nr: int, motor_letters: str, motor_output_nr: int, throttle_percent: int, timeout_seconds: int + ) -> tuple[bool, str]: ... + + def test_all_motors(self, nr_of_motors: int, throttle_percent: int, timeout_seconds: int) -> tuple[bool, str]: ... + + def test_motors_in_sequence( + self, start_motor: int, motor_count: int, throttle_percent: int, timeout_seconds: int + ) -> tuple[bool, str]: ... + + def stop_all_motors(self) -> tuple[bool, str]: ... + + def request_periodic_battery_status(self, interval_microseconds: int) -> tuple[bool, str]: ... + + def get_battery_status(self) -> tuple[Union[tuple[float, float], None], str]: ... + + def get_voltage_thresholds(self) -> tuple[float, float]: ... + + def is_battery_monitoring_enabled(self) -> bool: ... + + def get_frame_info(self) -> tuple[int, int]: ... + + +class FlightControllerFilesProtocol(Protocol): + """ + Protocol for flight controller file operations. + + Dependencies: + Required at construction: + - connection_manager: FlightControllerConnectionProtocol (to access master and info) + """ + + def upload_file( + self, local_filename: str, remote_filename: str, progress_callback: Union[None, Callable[[int, int], None]] + ) -> bool: ... + + def download_last_flight_log( + self, local_filename: str, progress_callback: Union[None, Callable[[int, int], None]] + ) -> bool: ... diff --git a/ardupilot_methodic_configurator/configuration_manager.py b/ardupilot_methodic_configurator/configuration_manager.py index d88dd19e7..a483013f5 100644 --- a/ardupilot_methodic_configurator/configuration_manager.py +++ b/ardupilot_methodic_configurator/configuration_manager.py @@ -457,8 +457,8 @@ def download_flight_controller_parameters(self, progress_callback: Optional[Call Path(self._local_filesystem.vehicle_dir) / "00_default.param", ) - # Update the flight controller parameters - self._flight_controller.fc_parameters = fc_parameters + # Note: fc_parameters are already updated internally in the flight controller + # via params_manager.download_params() # Write default values to file if available if param_default_values: @@ -498,7 +498,10 @@ def upload_parameters_that_require_reset_workflow( ): param_metadata = self._local_filesystem.doc_dict.get(param_name, None) if param_metadata and param_metadata.get("RebootRequired", False): - self._flight_controller.set_param(param_name, float(param.value)) + success, error_msg = self._flight_controller.set_param(param_name, float(param.value)) + if not success: + logging_error(_("Failed to set parameter %s: %s"), param_name, error_msg) + continue if param_name in self._flight_controller.fc_parameters: logging_info( _("Parameter %s changed from %f to %f, reset required"), @@ -511,7 +514,10 @@ def upload_parameters_that_require_reset_workflow( reset_required = True # Check if any of the selected parameters have a _TYPE, _EN, or _ENABLE suffix elif param_name.endswith(("_TYPE", "_EN", "_ENABLE", "SID_AXIS")): - self._flight_controller.set_param(param_name, float(param.value)) + success, error_msg = self._flight_controller.set_param(param_name, float(param.value)) + if not success: + logging_error(_("Failed to set parameter %s: %s"), param_name, error_msg) + continue if param_name in self._flight_controller.fc_parameters: logging_info( _("Parameter %s changed from %f to %f, possible reset required"), @@ -522,7 +528,7 @@ def upload_parameters_that_require_reset_workflow( else: logging_info(_("Parameter %s changed to %f, possible reset required"), param_name, param.value) reset_unsure_params.append(param_name) - except ValueError as e: # noqa: PERF203 + except ValueError as e: error_msg = _("Failed to set parameter {param_name}: {e}").format(param_name=param_name, e=e) logging_error(error_msg) error_messages.append(error_msg) @@ -633,7 +639,12 @@ def _upload_parameters_to_fc(self, selected_params: dict, show_error: Callable[[ for param_name, param in selected_params.items(): try: - self._flight_controller.set_param(param_name, param.value) + success, error_msg = self._flight_controller.set_param(param_name, param.value) + if not success: + error_messages.append( + _("Failed to set parameter %(name)s: %(error)s") % {"name": param_name, "error": error_msg} + ) + continue if param_name not in self._flight_controller.fc_parameters or not is_within_tolerance( self._flight_controller.fc_parameters[param_name], param.value ): @@ -654,7 +665,7 @@ def _upload_parameters_to_fc(self, selected_params: dict, show_error: Callable[[ else: logging_info(_("Parameter %s unchanged from %f"), param_name, param.value) nr_unchanged += 1 - except ValueError as _e: # noqa: PERF203 + except ValueError as _e: error_msg = _("Failed to set parameter {param_name}: {_e}").format(**locals()) logging_error(error_msg) error_messages.append(error_msg) diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_info.py b/ardupilot_methodic_configurator/data_model_flightcontroller_info.py similarity index 88% rename from ardupilot_methodic_configurator/backend_flightcontroller_info.py rename to ardupilot_methodic_configurator/data_model_flightcontroller_info.py index 6e142b530..579060acb 100644 --- a/ardupilot_methodic_configurator/backend_flightcontroller_info.py +++ b/ardupilot_methodic_configurator/data_model_flightcontroller_info.py @@ -25,7 +25,7 @@ ) -class BackendFlightcontrollerInfo: # pylint: disable=too-many-instance-attributes +class FlightControllerInfo: # pylint: disable=too-many-instance-attributes """ Handle flight controller information. @@ -34,30 +34,58 @@ class BackendFlightcontrollerInfo: # pylint: disable=too-many-instance-attribut """ def __init__(self) -> None: - self.system_id: str = "" - self.component_id: str = "" - self.autopilot: str = "" - self.vehicle_type: str = "" - self.firmware_type: str = "" - self.mav_type: str = "" - self.flight_sw_version: str = "" - self.flight_sw_version_and_type: str = "" - self.board_version: str = "" - self.apj_board_id: str = "" - self.flight_custom_version: str = "" - self.os_custom_version: str = "" - self.vendor: str = "" - self.vendor_id: str = "" - self.vendor_and_vendor_id: str = "" - self.product: str = "" - self.product_id: str = "" - self.product_and_product_id: str = "" - self.mcu_series: str = "" + self.system_id = "" + self.component_id = "" + self.autopilot = "" + self.vehicle_type = "" + self.firmware_type = "" + self.mav_type = "" + self.flight_sw_version = "" + self.flight_sw_version_and_type = "" + self.board_version = "" + self.apj_board_id = "" + self.flight_custom_version = "" + self.os_custom_version = "" + self.vendor = "" + self.vendor_id = "" + self.vendor_and_vendor_id = "" + self.product = "" + self.product_id = "" + self.product_and_product_id = "" + self.mcu_series = "" + + self.is_supported = False + self.is_mavftp_supported = False + self.capabilities: dict[str, str] = {} + def reset(self) -> None: + """Reset all cached flight controller metadata.""" + self.system_id = "" + self.component_id = "" + self.autopilot = "" + self.vehicle_type = "" + self.firmware_type = "" + self.mav_type = "" + self.flight_sw_version = "" + self.flight_sw_version_and_type = "" + self.board_version = "" + self.apj_board_id = "" + self.flight_custom_version = "" + self.os_custom_version = "" + self.vendor = "" + self.vendor_id = "" + self.vendor_and_vendor_id = "" + self.product = "" + self.product_id = "" + self.product_and_product_id = "" + self.mcu_series = "" + self.is_supported = False self.is_mavftp_supported = False + self.capabilities.clear() + def get_info(self) -> dict[str, Union[str, dict[str, str]]]: return { _("USB Vendor"): self.vendor_and_vendor_id, diff --git a/ardupilot_methodic_configurator/data_model_motor_test.py b/ardupilot_methodic_configurator/data_model_motor_test.py index c5bcbf36a..f7df1facb 100644 --- a/ardupilot_methodic_configurator/data_model_motor_test.py +++ b/ardupilot_methodic_configurator/data_model_motor_test.py @@ -447,7 +447,11 @@ def set_parameter( # pylint: disable=too-many-arguments, too-many-positional-ar ) # Set parameter and verify it was set correctly - self.flight_controller.set_param(param_name, value) + success, error_msg = self.flight_controller.set_param(param_name, value) + if not success: + raise ParameterError( + _("Failed to set parameter %(param)s: %(error)s") % {"param": param_name, "error": error_msg} + ) # Read back the parameter to verify it was set correctly actual_value = self.flight_controller.fetch_param(param_name) if actual_value is not None and abs(actual_value - value) < 0.001: # Allow small floating-point tolerance diff --git a/ardupilot_methodic_configurator/data_model_par_dict.py b/ardupilot_methodic_configurator/data_model_par_dict.py index 11748a543..4e8f56a75 100644 --- a/ardupilot_methodic_configurator/data_model_par_dict.py +++ b/ardupilot_methodic_configurator/data_model_par_dict.py @@ -24,6 +24,36 @@ PARAM_NAME_MAX_LEN = 16 +def validate_param_name(param_name: str) -> tuple[bool, str]: + """ + Validate parameter name according to ArduPilot standards. + + Args: + param_name: The parameter name to validate + + Returns: + tuple[bool, str]: (is_valid, error_message) + is_valid: True if valid, False otherwise + error_message: Description of validation error, empty if valid + + """ + # Check if parameter name is provided and is a string + if not param_name or not isinstance(param_name, str): + return False, _("Parameter name cannot be empty") + + # Check if parameter name exceeds maximum length + if len(param_name) > PARAM_NAME_MAX_LEN: + msg = _("Parameter name too long (max %d characters): %s") % (PARAM_NAME_MAX_LEN, param_name) + return False, msg + + # Check if parameter name matches the required format + if not re.match(PARAM_NAME_REGEX, param_name): + msg = _("Invalid parameter name format (must start with capital letter, contain only A-Z, 0-9, _): %s") % param_name + return False, msg + + return True, "" + + def is_within_tolerance(x: float, y: float, atol: float = 1e-08, rtol: float = 1e-04) -> bool: """ Determines if the absolute difference between two numbers is within a specified tolerance. @@ -213,14 +243,8 @@ def _format_params(self, file_format: str = "missionplanner") -> list[str]: """ if file_format == "missionplanner": - sorted_dict = ParDict(dict(sorted(self.items(), key=lambda x: ParDict.missionplanner_sort(x[0])))) - elif file_format == "mavproxy": - sorted_dict = ParDict(dict(sorted(self.items()))) - else: - msg = _("ERROR: Unsupported file format {file_format}").format(file_format=file_format) - raise SystemExit(msg) - - if file_format == "missionplanner": + sorted_items = dict(sorted(self.items(), key=lambda x: ParDict.missionplanner_sort(x[0]))) + sorted_dict = ParDict(sorted_items) formatted_params = [ ( f"{key},{format(parameter.value, '.6f').rstrip('0').rstrip('.')} # {parameter.comment}" @@ -230,6 +254,7 @@ def _format_params(self, file_format: str = "missionplanner") -> list[str]: for key, parameter in sorted_dict.items() ] elif file_format == "mavproxy": + sorted_dict = ParDict(dict(sorted(self.items()))) formatted_params = [ ( f"{key:<16} {parameter.value:<8.6f} # {parameter.comment}" @@ -239,7 +264,8 @@ def _format_params(self, file_format: str = "missionplanner") -> list[str]: for key, parameter in sorted_dict.items() ] else: - formatted_params = [] + msg = _("ERROR: Unsupported file format {file_format}").format(file_format=file_format) + raise SystemExit(msg) return formatted_params def export_to_param( diff --git a/ardupilot_methodic_configurator/frontend_tkinter_flightcontroller_info.py b/ardupilot_methodic_configurator/frontend_tkinter_flightcontroller_info.py index 7c0ab42af..3fa5eab3d 100644 --- a/ardupilot_methodic_configurator/frontend_tkinter_flightcontroller_info.py +++ b/ardupilot_methodic_configurator/frontend_tkinter_flightcontroller_info.py @@ -48,9 +48,13 @@ def download_parameters(self, progress_callback: Optional[Callable[[int, int], N Returns: Dictionary of parameter default values + Note: + The flight controller's fc_parameters are updated internally by download_params(). + We only need to store the default values for this window's use. + """ - fc_parameters, param_default_values = self.flight_controller.download_params(progress_callback) - self.flight_controller.fc_parameters = fc_parameters + _fc_parameters, param_default_values = self.flight_controller.download_params(progress_callback) + # Note: fc_parameters are already updated in the backend, no need to reassign self.param_default_values = param_default_values return param_default_values diff --git a/pyproject.toml b/pyproject.toml index 9e1f272bc..fb1d25df8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,7 +144,7 @@ version = {attr = "ardupilot_methodic_configurator.__version__"} [tool.codespell] check-filenames = true -ignore-words-list = "datas,intoto,juli,laf,ned,parm,sade,sitl,thst,sie" +ignore-words-list = "datas,intoto,juli,laf,ned,parm,sade,sitl,thst,sie,mot" skip = "*.pdef.xml,*.pdf,*.po" [tool.ruff] diff --git a/pytest.ini b/pytest.ini index a0a57821c..137fbe772 100644 --- a/pytest.ini +++ b/pytest.ini @@ -8,3 +8,4 @@ addopts = -v --strict-config --continue-on-collection-errors markers = integration: mark a test as an integration test slow: mark a test as slow running + sitl: mark a test as requiring SITL (real ArduPilot simulation) diff --git a/scripts/run_sitl_tests.sh b/scripts/run_sitl_tests.sh new file mode 100755 index 000000000..23eb71ced --- /dev/null +++ b/scripts/run_sitl_tests.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# +# SITL Testing Script for ArduPilot Methodic Configurator +# This script helps set up and run SITL tests for local development +# +# This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator +# +# SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas +# +# SPDX-License-Identifier: GPL-3.0-or-later + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +ARDUPILOT_DIR="${ARDUPILOT_DIR:-$HOME/ardupilot-sitl}" +SITL_BINARY="${ARDUPILOT_DIR}/ardupilot/build/sitl/bin/arducopter" + +echo -e "${GREEN}ArduPilot SITL Testing Setup${NC}" +echo "=================================" + +# Check if ArduPilot SITL is available +check_sitl() { + # First check for downloaded SITL + if [ -f "$PROJECT_ROOT/sitl/arducopter" ]; then + echo -e "${GREEN}✓ Downloaded ArduCopter SITL found at: $PROJECT_ROOT/sitl/arducopter${NC}" + export SITL_BINARY="$PROJECT_ROOT/sitl/arducopter" + return 0 + # Then check for locally built SITL + elif [ -f "$SITL_BINARY" ]; then + echo -e "${GREEN}✓ Locally built ArduCopter SITL found at: $SITL_BINARY${NC}" + return 0 + else + echo -e "${RED}✗ ArduCopter SITL not found${NC}" + echo -e "${YELLOW}Run '$0 download' to download SITL from ArduPilot website, or build locally and set ARDUPILOT_DIR${NC}" + return 1 + fi +} + +# Setup SITL for testing +setup_sitl() { + echo "Setting up SITL environment..." + + # Create SITL cache directory if it doesn't exist + mkdir -p "$PROJECT_ROOT/sitl-cache" + + # Handle downloaded SITL + if [ -f "$PROJECT_ROOT/sitl/arducopter" ]; then + export SITL_BINARY="$PROJECT_ROOT/sitl/arducopter" + cp "$PROJECT_ROOT/sitl/arducopter" "$PROJECT_ROOT/sitl-cache/" + # Download default parameters if not already present + if [ ! -f "$PROJECT_ROOT/sitl/copter.parm" ]; then + curl -L -o "$PROJECT_ROOT/sitl/copter.parm" https://raw.githubusercontent.com/ArduPilot/ardupilot/master/Tools/autotest/default_params/copter.parm + fi + cp "$PROJECT_ROOT/sitl/copter.parm" "$PROJECT_ROOT/sitl-cache/" + echo -e "${GREEN}✓ Downloaded SITL binary and config copied to cache${NC}" + # Handle locally built SITL + elif [ -f "$SITL_BINARY" ]; then + export SITL_BINARY="$SITL_BINARY" + cp "$SITL_BINARY" "$PROJECT_ROOT/sitl-cache/" + cp "${ARDUPILOT_DIR}/ardupilot/Tools/autotest/default_params/copter.parm" "$PROJECT_ROOT/sitl-cache/" + echo -e "${GREEN}✓ Locally built SITL binary and config copied to cache${NC}" + fi +} + +# Run SITL tests +run_sitl_tests() { + echo "Running SITL integration tests..." + + cd "$PROJECT_ROOT" + + # Set environment variable for SITL binary + export SITL_BINARY="$SITL_BINARY" + + # Run only SITL-marked tests + python -m pytest tests/test_backend_flightcontroller_sitl.py \ + -v \ + --tb=short \ + --capture=no \ + -x +} + +# Clean up SITL processes +cleanup_sitl() { + echo "Cleaning up SITL processes..." + + # Kill any running SITL processes + pkill -f arducopter || true + + # Remove SITL cache + rm -rf "$PROJECT_ROOT/sitl-cache" + + echo -e "${GREEN}✓ Cleanup completed${NC}" +} + +# Download ArduCopter SITL from official firmware server +download_sitl() { + echo "Downloading ArduCopter SITL from official firmware server..." + + # Create SITL directory + mkdir -p "$PROJECT_ROOT/sitl" + + # Download SITL binary and metadata + if curl -L -o "$PROJECT_ROOT/sitl/arducopter" https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/arducopter; then + curl -L -o "$PROJECT_ROOT/sitl/firmware-version.txt" https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/firmware-version.txt + curl -L -o "$PROJECT_ROOT/sitl/git-version.txt" https://firmware.ardupilot.org/Copter/latest/SITL_x86_64_linux_gnu/git-version.txt + + # Make executable + chmod +x "$PROJECT_ROOT/sitl/arducopter" + + # Set environment variable + export SITL_BINARY="$PROJECT_ROOT/sitl/arducopter" + + echo -e "${GREEN}✓ ArduCopter SITL downloaded successfully${NC}" + if [ -f "$PROJECT_ROOT/sitl/git-version.txt" ] && [ -s "$PROJECT_ROOT/sitl/git-version.txt" ]; then + echo "SITL version: $(cat "$PROJECT_ROOT/sitl/git-version.txt")" + fi + if [ -f "$PROJECT_ROOT/sitl/firmware-version.txt" ] && [ -s "$PROJECT_ROOT/sitl/firmware-version.txt" ]; then + echo "Firmware version: $(cat "$PROJECT_ROOT/sitl/firmware-version.txt")" + fi + return 0 + else + echo -e "${RED}✗ Failed to download ArduCopter SITL${NC}" + return 1 + fi +} + +# Main script logic +case "${1:-help}" in + "check") + check_sitl + ;; + "download") + download_sitl + ;; + "setup") + if check_sitl; then + setup_sitl + fi + ;; + "test") + if check_sitl; then + setup_sitl + run_sitl_tests + fi + ;; + "download-test") + if download_sitl; then + setup_sitl + run_sitl_tests + fi + ;; + "cleanup") + cleanup_sitl + ;; + "help"|*) + echo "Usage: $0 [command]" + echo "" + echo "Commands:" + echo " check - Check if SITL is available (downloaded or locally built)" + echo " download - Download ArduCopter SITL from official ArduPilot firmware server" + echo " setup - Set up SITL for testing (copy to cache)" + echo " test - Run SITL integration tests" + echo " download-test- Download SITL and run tests in one command" + echo " cleanup - Clean up SITL processes and cache" + echo " help - Show this help message" + echo "" + echo "Environment variables:" + echo " ARDUPILOT_DIR - Path to ArduPilot directory for locally built SITL (default: \$HOME/ardupilot-sitl)" + echo "" + echo "Examples:" + echo " $0 download # Download SITL from ArduPilot website" + echo " $0 download-test # Download and test in one command" + echo " $0 check # Check if SITL is available" + echo " ARDUPILOT_DIR=/path/to/ardupilot $0 test # Use locally built SITL" + ;; +esac diff --git a/sitl/copter.parm b/sitl/copter.parm new file mode 100644 index 000000000..585a75061 --- /dev/null +++ b/sitl/copter.parm @@ -0,0 +1,102 @@ +FRAME_TYPE 0 +FS_THR_ENABLE 1 +ARSPD_PIN 1 +ARSPD_BUS 2 +ATC_RAT_YAW_P 0.3 +ATC_RAT_YAW_I 0.02 +BATT_MONITOR 4 +BATT_VOLT_PIN 2 +BATT_CURR_PIN 3 +BATT_VOLT_MULT 18.182 +BATT_AMP_PERVLT 36.364 +BATT_CAPACITY 5000 +BATT_ARM_VOLT 14.0 +BATT_LOW_VOLT 13.6 +BATT_CRT_VOLT 13.2 +BATT_FS_LOW_ACT 2 +BATT_FS_CRT_ACT 1 +BATT_LOW_MAH 1000 +BATT_CRT_MAH 500 +COMPASS_OFS_X 5 +COMPASS_OFS_Y 13 +COMPASS_OFS_Z -18 +COMPASS_OFS2_X 5 +COMPASS_OFS2_Y 13 +COMPASS_OFS2_Z -18 +COMPASS_OFS3_X 5 +COMPASS_OFS3_Y 13 +COMPASS_OFS3_Z -18 +FENCE_RADIUS 150 +FRAME_CLASS 1 +RC1_MAX 2000.000000 +RC1_MIN 1000.000000 +RC1_TRIM 1500.000000 +RC2_MAX 2000.000000 +RC2_MIN 1000.000000 +RC2_TRIM 1500.000000 +RC3_MAX 2000.000000 +RC3_MIN 1000.000000 +RC3_TRIM 1500.000000 +RC4_MAX 2000.000000 +RC4_MIN 1000.000000 +RC4_TRIM 1500.000000 +RC5_MAX 2000.000000 +RC5_MIN 1000.000000 +RC5_TRIM 1500.000000 +RC6_MAX 2000.000000 +RC6_MIN 1000.000000 +RC6_TRIM 1500.000000 +RC7_MAX 2000.000000 +RC7_MIN 1000.000000 +RC7_OPTION 7 +RC7_TRIM 1500.000000 +RC8_MAX 2000.000000 +RC8_MIN 1000.000000 +RC8_TRIM 1500.000000 +FLTMODE1 7 +FLTMODE2 9 +FLTMODE3 6 +FLTMODE4 3 +FLTMODE5 5 +FLTMODE6 0 +SIM_BARO_RND 0 +# we need small INS_ACC offsets so INS is recognised as being calibrated +INS_ACCOFFS_X 0.001 +INS_ACCOFFS_Y 0.001 +INS_ACCOFFS_Z 0.001 +INS_ACCSCAL_X 1.001 +INS_ACCSCAL_Y 1.001 +INS_ACCSCAL_Z 1.001 +INS_ACC2OFFS_X 0.001 +INS_ACC2OFFS_Y 0.001 +INS_ACC2OFFS_Z 0.001 +INS_ACC2SCAL_X 1.001 +INS_ACC2SCAL_Y 1.001 +INS_ACC2SCAL_Z 1.001 +INS_ACC3OFFS_X 0.000 +INS_ACC3OFFS_Y 0.000 +INS_ACC3OFFS_Z 0.000 +INS_ACC3SCAL_X 1.000 +INS_ACC3SCAL_Y 1.000 +INS_ACC3SCAL_Z 1.000 +MOT_THST_EXPO 0.65 +MOT_THST_HOVER 0.39 +MOT_BAT_VOLT_MIN 9.6 +MOT_BAT_VOLT_MAX 12.8 +# flightmodes +# switch 1 Circle +# switch 2 LAND +# switch 3 RTL +# switch 4 Auto +# switch 5 Loiter +# switch 6 Stab +# STABILIZE 0 ! +# ACRO 1 +# ALT_HOLD 2 +# AUTO 3 ! +# GUIDED 4 +# LOITER 5 ! +# RTL 6 ! +# CIRCLE 7 ! +# POSITION 8 +# LAND 9 ! diff --git a/tests/conftest.py b/tests/conftest.py index 5a310a6d4..f11524ed5 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,9 +12,16 @@ import contextlib import json +import logging import os +import platform +import select +import signal +import subprocess +import time import tkinter as tk from collections.abc import Callable, Generator +from pathlib import Path from typing import Any, NamedTuple, Optional from unittest.mock import patch @@ -239,3 +246,254 @@ def test_config_manager(tmp_path) -> ConfigurationManager: # Create ConfigurationManager return ConfigurationManager("04_board_orientation.param", fc, filesystem) + + +# ==================== SITL TESTING FIXTURES ==================== + + +class SITLManager: + """Manages ArduCopter SITL process lifecycle.""" + + def __init__(self, sitl_binary: Optional[str] = None) -> None: + self.sitl_binary = sitl_binary or os.environ.get("SITL_BINARY") + self.process: Optional[subprocess.Popen] = None + self.connection_string = "tcp:127.0.0.1:5760" + self._ready = False + + def is_available(self) -> bool: + """Check if SITL binary is available.""" + return self.sitl_binary is not None and Path(self.sitl_binary).exists() + + def is_running(self) -> bool: + """Check if SITL process is currently running.""" + return self.process is not None and self.process.poll() is None + + def ensure_running(self) -> bool: + """Ensure the SITL process is running, starting it if necessary.""" + if self.is_running() and self._ready: + return True + return self.start() + + def start(self) -> bool: # pylint: disable=too-many-return-statements, too-many-branches, too-many-statements, too-many-locals # noqa: PLR0911, PLR0915 + """Start SITL process.""" + if self.is_running(): + logging.info("SITL already running, reusing existing instance") + return True + + if not self.is_available(): + return False + + # Kill any existing SITL processes + self.stop() + + self._ready = False + + if self.sitl_binary is None: + return False + + # Validate SITL binary path for security + sitl_path = Path(self.sitl_binary) + if not sitl_path.exists(): + logging.error("SITL binary does not exist: %s", self.sitl_binary) + return False + if not sitl_path.is_file(): + logging.error("SITL binary is not a file: %s", self.sitl_binary) + return False + # Ensure the binary name looks reasonable (contains 'arducopter' or 'sitl') + if not any(keyword in sitl_path.name.lower() for keyword in ["arducopter", "sitl", "copter"]): + logging.warning("SITL binary name looks suspicious: %s", sitl_path.name) + + # Build SITL command + sitl_args = [ + "--model", + "quad", + "--home", + "40.071374,-105.229930,1440,0", # Random location + "--defaults", + "copter.parm", # Relative to SITL binary directory + "--sysid", + "1", + "--speedup", + "1", # Real-time for better connection stability on Windows/WSL + ] + + # On Windows, run SITL through WSL + if platform.system() == "Windows": + # Convert Windows path to WSL path + wsl_sitl_path = str(sitl_path).replace("\\", "/").replace("C:", "/mnt/c") + wsl_cwd = str(sitl_path.parent).replace("\\", "/").replace("C:", "/mnt/c") + + # Run SITL in WSL - simpler command that keeps process alive + cmd = ["wsl", "cd", wsl_cwd, "&&", wsl_sitl_path, *sitl_args] + else: + cmd = [self.sitl_binary, *sitl_args] + + # Set environment to force unbuffered output + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + + sitl_ready = False + + try: # pylint: disable=too-many-nested-blocks + # Change to SITL binary directory so it finds copter.parm + cwd = str(sitl_path.parent) + + # pylint: disable=consider-using-with + self.process = subprocess.Popen( # noqa: S603 + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, # Merge stderr into stdout + start_new_session=True, # Create new process group + bufsize=0, # Unbuffered for real-time output + universal_newlines=True, + env=env, + cwd=cwd, + ) + # pylint: enable=consider-using-with + + # Wait for SITL to initialize and print startup messages + timeout = 20 # Increased timeout for slower systems + start_time = time.time() + startup_output = [] + + while time.time() - start_time < timeout: + if self.process.poll() is not None: + # Process died + stdout, _ = self.process.communicate() + error_msg = f"SITL process died with exit code {self.process.returncode}." + if stdout: + error_msg += f" Output: {stdout[:500]}" # First 500 chars + logging.error(error_msg) + pytest.fail(error_msg) + return False + + # Check for ready indicator in output + if self.process.stdout: + # Check if there's data to read (non-blocking) + if platform.system() != "Windows": + ready, _, _ = select.select([self.process.stdout], [], [], 0.1) + if ready: + line = self.process.stdout.readline() + if line: + startup_output.append(line.strip()) + logging.info("SITL: %s", line.strip()) # Changed to info for visibility + # Look for signs SITL is ready - be more specific + if "bind port 5760" in line.lower() or "waiting for connection" in line.lower(): + logging.info("SITL is ready and waiting for connections") + sitl_ready = True + # Don't return yet, consume a bit more output + time.sleep(1) # Let SITL stabilize + return True + else: + # On Windows, just wait + time.sleep(1) + else: + time.sleep(0.5) + + # If we got here, check if we saw the ready message + if sitl_ready: + logging.info("SITL is ready") + return True + + # If process is still running, assume it's ready + if self.process.poll() is None: + logging.warning("SITL startup timeout reached but process still running, assuming SITL is ready") + return True + + logging.error("SITL failed to start within timeout") + return False + + except (OSError, subprocess.SubprocessError, FileNotFoundError, PermissionError) as e: + logging.error("Failed to start SITL: %s", e) + pytest.fail(f"Failed to start SITL: {e}") + return False + finally: + if sitl_ready: + self._ready = True + elif self.is_running(): + # Even if we hit timeout but process is running, assume ready to allow reuse + self._ready = True + + def stop(self) -> None: + """Stop SITL process.""" + if self.process: + try: + self._ready = False + if platform.system() == "Windows": + # On Windows, kill the WSL bash process and any child processes + # First try graceful termination + subprocess.run(["wsl", "pkill", "-f", "arducopter"], check=False, capture_output=True) # noqa: S607 + self.process.terminate() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + # Force kill + subprocess.run( + ["wsl", "pkill", "-9", "-f", "arducopter"], # noqa: S607 + check=False, + capture_output=True, + ) + self.process.kill() + self.process.wait(timeout=5) + else: + # Kill the entire process group on Linux + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + + # Wait for process to terminate + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + # Force kill if it doesn't terminate gracefully + os.killpg(os.getpgid(self.process.pid), signal.SIGKILL) + self.process.wait(timeout=5) + + except (ProcessLookupError, OSError): + # Process already dead + pass + finally: + self.process = None + else: + self._ready = False + + +@pytest.fixture(scope="session") +def sitl_manager() -> Generator[SITLManager, None, None]: + """Provide SITL manager for the test session.""" + manager = SITLManager() + + if not manager.is_available(): + pytest.skip("ArduCopter SITL binary not available") + + if not manager.start(): + pytest.skip("Failed to start ArduCopter SITL") + + yield manager + + # Cleanup + manager.stop() + + +@pytest.fixture +def sitl_flight_controller(sitl_manager: SITLManager) -> Generator[FlightController, None, None]: # pylint: disable=redefined-outer-name + """FlightController connected to SITL instance.""" + if not sitl_manager.ensure_running(): + pytest.fail("Could not start SITL") + + # Allow brief stabilization if SITL was just started + time.sleep(2) + + fc = FlightController(reboot_time=2, baudrate=115200) + + # Attempt to connect, retrying once if SITL is still warming up + connection_error = fc.connect(device=sitl_manager.connection_string) + if connection_error: + time.sleep(3) + connection_error = fc.connect(device=sitl_manager.connection_string) + + if connection_error: + pytest.fail(f"Could not connect to SITL: {connection_error}") + + yield fc + + # Cleanup connection but keep SITL running for subsequent tests + fc.disconnect() diff --git a/tests/test_backend_flightcontroller.py b/tests/test_backend_flightcontroller.py index e898d3821..8225c0536 100755 --- a/tests/test_backend_flightcontroller.py +++ b/tests/test_backend_flightcontroller.py @@ -1,7 +1,10 @@ #!/usr/bin/env python3 """ -Tests for the backend_flightcontroller.py file. +BDD-style tests for the backend_flightcontroller.py file. + +This file focuses on meaningful behavior-driven tests that validate user workflows +and business value rather than implementation details. This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator @@ -10,445 +13,300 @@ SPDX-License-Identifier: GPL-3.0-or-later """ -from typing import Union -from unittest.mock import MagicMock, mock_open, patch +import tempfile +from argparse import ArgumentParser +from typing import Any, Union, cast # pylint: disable=unused-import +from unittest.mock import MagicMock, patch import pytest from pymavlink import mavutil from ardupilot_methodic_configurator.backend_flightcontroller import FlightController -from ardupilot_methodic_configurator.data_model_par_dict import Par, ParDict - -# pylint: disable=protected-access - - -def test_add_connection() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - assert fc.add_connection("test_connection") is True - assert fc.add_connection("test_connection") is False - assert fc.add_connection("") is False - - -def test_discover_connections() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.discover_connections() - assert len(fc.get_connection_tuples()) > 0 - - -def test_connect() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - result = fc.connect(device="test") - assert result == "" - - -def test_disconnect() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - fc.disconnect() - assert fc.master is None - - -@patch("builtins.open", new_callable=mock_open, read_data="param1=1\nparam2=2") -@patch( - "ardupilot_methodic_configurator.data_model_par_dict.ParDict.load_param_file_into_dict", - side_effect=lambda x: ParDict({"param1": Par(1, x), "param2": Par(2, x)}), +from ardupilot_methodic_configurator.backend_flightcontroller_commands import FlightControllerCommands +from ardupilot_methodic_configurator.backend_flightcontroller_connection import ( + FlightControllerConnection, ) -def test_download_params(mock_load_param_file_into_dict, mock_file) -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - with patch("ardupilot_methodic_configurator.backend_flightcontroller.open", mock_file): - params, _ = fc.download_params() - assert isinstance(params, dict) - assert params == {"param1": 1, "param2": 2} - mock_load_param_file_into_dict.assert_called_once_with("params.param") - - -@patch("builtins.open", new_callable=mock_open, read_data="param1,1\nparam2,2") -@patch( - "ardupilot_methodic_configurator.data_model_par_dict.ParDict.load_param_file_into_dict", - side_effect=lambda x: ParDict({"param1": Par(1, x), "param2": Par(2, x)}), +from ardupilot_methodic_configurator.backend_flightcontroller_factory_mavlink import ( + FakeMavlinkConnectionFactory, ) -def test_set_param(mock_load_param_file_into_dict, mock_file) -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - fc.set_param("TEST_PARAM", 1.0) - with patch("ardupilot_methodic_configurator.backend_flightcontroller.open", mock_file): - params, _ = fc.download_params() - assert params.get("TEST_PARAM") is None # Assuming the mock environment does not actually set the parameter - mock_load_param_file_into_dict.assert_called_once_with("params.param") - - -def test_reset_and_reconnect() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - result = fc.reset_and_reconnect() - assert result == "" - - -def test_upload_file() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - result = fc.upload_file("local.txt", "remote.txt") - # Assuming the mock environment always returns False for upload_file - assert result is False - - -def test_get_connection_tuples() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.add_connection("test_connection") - connections = fc.get_connection_tuples() - assert ("test_connection", "test_connection") in connections - - -@patch("builtins.open", new_callable=mock_open, read_data="param1,1\nparam2,2") -@patch( - "ardupilot_methodic_configurator.data_model_par_dict.ParDict.load_param_file_into_dict", - side_effect=lambda x: ParDict({"param1": Par(1, x), "param2": Par(2, x)}), +from ardupilot_methodic_configurator.backend_flightcontroller_factory_serial import ( + FakeSerialPortDiscovery, ) -def test_set_param_and_verify(mock_load_param_file_into_dict, mock_file) -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - fc.set_param("TEST_PARAM", 1.0) - with patch("ardupilot_methodic_configurator.backend_flightcontroller.open", mock_file): - params, _ = fc.download_params() - # Assuming the mock environment does not actually set the parameter - assert params.get("TEST_PARAM") is None - mock_load_param_file_into_dict.assert_called_once_with("params.param") - - -def test_download_params_via_mavftp() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - params, default_params = fc._download_params_via_mavftp() - assert isinstance(params, dict) - assert isinstance(default_params, dict) - - -def test_auto_detect_serial() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - serial_ports = fc._FlightController__auto_detect_serial() # pylint: disable=protected-access - assert isinstance(serial_ports, list) - - -def test_list_serial_ports() -> None: - serial_ports = FlightController._FlightController__list_serial_ports() # pylint: disable=protected-access - assert isinstance(serial_ports, list) - - -def test_list_network_ports() -> None: - network_ports = FlightController._FlightController__list_network_ports() # pylint: disable=protected-access - assert isinstance(network_ports, list) - assert "tcp:127.0.0.1:5760" in network_ports - - -def test_request_banner() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - fc._FlightController__request_banner() # pylint: disable=protected-access - # Since we cannot verify in the mock environment, we will just ensure no exceptions are raised - +from ardupilot_methodic_configurator.data_model_flightcontroller_info import ( + FlightControllerInfo, +) +from ardupilot_methodic_configurator.data_model_par_dict import ParDict -def test_receive_banner_text() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - banner_text = fc._FlightController__receive_banner_text() # pylint: disable=protected-access - assert isinstance(banner_text, list) +# pylint: disable=protected-access -def test_request_message() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - fc._FlightController__request_message(1) # pylint: disable=protected-access - # Since we cannot verify in the mock environment, we will just ensure no exceptions are raised +def _build_flight_controller_with_mocks( + reboot_time: int = 2, +) -> tuple[FlightController, MagicMock, MagicMock, MagicMock, MagicMock, MagicMock]: + """Helper returning a facade wired with MagicMock managers for delegation tests.""" + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + mock_conn_mgr = MagicMock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + mock_conn_mgr.comport = "ttyACM0" + mock_conn_mgr.comport_device = "/dev/ttyACM0" + mock_conn_mgr.baudrate = 115200 + mock_conn_mgr.discover_connections.return_value = None + mock_conn_mgr.disconnect = MagicMock() + mock_conn_mgr.add_connection.return_value = True + mock_conn_mgr.create_connection_with_retry.return_value = "RECONNECTED" + mock_conn_mgr.get_network_ports.return_value = ["tcp:127.0.0.1:5760"] + mock_conn_mgr.get_connection_tuples.return_value = [("tcp:127.0.0.1:5760", "SITL")] + mock_conn_mgr.set_master_for_testing = MagicMock() + mock_conn_mgr._detect_vehicles_from_heartbeats.return_value = {(1, 1): {}} + mock_conn_mgr._extract_firmware_type_from_banner.return_value = "ArduCopter" + mock_conn_mgr._extract_chibios_version_from_banner.return_value = ("ChibiOS", None) + mock_conn_mgr._select_supported_autopilot.return_value = "copter" + mock_conn_mgr._populate_flight_controller_info = MagicMock() + mock_conn_mgr._retrieve_autopilot_version_and_banner.return_value = "1.0" + + mock_params_mgr = MagicMock() + mock_params_mgr.PARAM_FETCH_POLL_DELAY = 0.01 + mock_params_mgr.fc_parameters = {} + mock_params_mgr.download_params.return_value = ({}, ParDict()) + mock_params_mgr.set_param.return_value = (True, "") + mock_params_mgr.fetch_param.return_value = 1.0 + mock_params_mgr.clear_parameters = MagicMock() + + mock_commands_mgr = MagicMock() + mock_commands_mgr.BATTERY_STATUS_CACHE_TIME = 1.0 + mock_commands_mgr.BATTERY_STATUS_TIMEOUT = 1.0 + mock_commands_mgr.COMMAND_ACK_TIMEOUT = 1.0 + mock_commands_mgr.reset_all_parameters_to_default.return_value = (True, "") + mock_commands_mgr.test_motor.return_value = (True, "") + mock_commands_mgr.test_all_motors.return_value = (True, "") + mock_commands_mgr.test_motors_in_sequence.return_value = (True, "") + mock_commands_mgr.stop_all_motors.return_value = (True, "") + mock_commands_mgr.request_periodic_battery_status.return_value = (True, "") + mock_commands_mgr.get_battery_status.return_value = ((12.0, 5.0), "") + mock_commands_mgr.get_voltage_thresholds.return_value = (10.5, 21.0) + mock_commands_mgr.is_battery_monitoring_enabled.return_value = True + mock_commands_mgr.get_frame_info.return_value = (1, 2) + + mock_files_mgr = MagicMock() + mock_files_mgr.upload_file.return_value = True + mock_files_mgr.download_last_flight_log.return_value = True + + fc = FlightController( + reboot_time=reboot_time, + connection_manager=mock_conn_mgr, + params_manager=mock_params_mgr, + commands_manager=mock_commands_mgr, + files_manager=mock_files_mgr, + ) + return fc, mock_conn_mgr, mock_params_mgr, mock_commands_mgr, mock_files_mgr, mock_master + + +class TestFlightControllerConnectionLifecycle: + """Test complete flight controller connection lifecycle from user perspective.""" + @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") + def test_user_can_initialize_flight_controller_for_configuration(self, mock_discover) -> None: + """ + User can initialize a flight controller instance ready for configuration tasks. -def test_create_connection_with_retry() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - result = fc._FlightController__create_connection_with_retry(progress_callback=None, retries=1, timeout=1) # pylint: disable=protected-access - assert result == "" + GIVEN: A user needs to configure a flight controller + WHEN: They create a FlightController instance with appropriate settings + THEN: The instance should be properly initialized + AND: Ready to connect to devices for configuration + """ + # Given: User needs flight controller for configuration + mock_discover.return_value = None + # When: Initialize flight controller with standard configuration settings + fc = FlightController(reboot_time=5, baudrate=115200) -def test_process_autopilot_version() -> None: - fc = FlightController(reboot_time=7, baudrate=115200) - fc.connect(device="test") - banner_msgs = ["ChibiOS: 123", "ArduPilot"] - result = fc._FlightController__process_autopilot_version(None, banner_msgs) # pylint: disable=protected-access - assert isinstance(result, str) + # Then: Flight controller properly initialized + assert fc is not None + assert fc.reboot_time == 5 # Standard reboot time + assert fc.baudrate == 115200 # Standard baudrate + assert fc.master is None # Not connected yet + assert not fc.fc_parameters # No parameters loaded yet + # And: Connection discovery was attempted + mock_discover.assert_called_once() -class TestMotorTestFunctionality: - """Test motor test commands and functionality in FlightController.""" + @pytest.mark.integration + @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") + @patch("ardupilot_methodic_configurator.backend_flightcontroller.mavutil.mavlink_connection") + def test_user_can_establish_connection_to_hardware_flight_controller(self, mock_mavlink, mock_discover) -> None: + """ + User can establish MAVLink connection to physical flight controller hardware. - @pytest.fixture - def mock_fc_connection(self) -> MagicMock: - """Fixture providing a mocked flight controller connection.""" + GIVEN: A physical flight controller connected via USB/serial + WHEN: User connects using appropriate connection string + THEN: MAVLink communication should be established + AND: Flight controller should be ready for parameter operations + """ + # Given: Physical flight controller available + mock_discover.return_value = None mock_connection = MagicMock() mock_connection.target_system = 1 mock_connection.target_component = 1 - mock_connection.wait_heartbeat.return_value = None - - # Mock COMMAND_ACK response for successful commands - mock_ack = MagicMock() - mock_ack.command = mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST - mock_ack.result = mavutil.mavlink.MAV_RESULT_ACCEPTED - mock_ack.progress = 100 - mock_ack.result_param2 = 0 - - # Configure recv_match to return COMMAND_ACK on first call, None on subsequent calls - def recv_match_side_effect(*_args: tuple, **kwargs: dict) -> Union[MagicMock, None]: - # Check if looking for COMMAND_ACK message type - type_arg = kwargs.get("type") - if isinstance(type_arg, str) and type_arg == "COMMAND_ACK": - return mock_ack - return None - - mock_connection.recv_match.side_effect = recv_match_side_effect - return mock_connection + mock_mavlink.return_value = mock_connection - @pytest.fixture - def flight_controller(self, mock_fc_connection) -> FlightController: - """Fixture providing a configured FlightController for motor testing.""" - with patch( - "ardupilot_methodic_configurator.backend_flightcontroller.mavutil.mavlink_connection", - return_value=mock_fc_connection, - ): - fc = FlightController() - fc.master = mock_fc_connection - return fc + fc = FlightController(reboot_time=2, baudrate=115200) + + # Mock the connect method to return success + with patch.object(fc, "connect", return_value="") as mock_connect: + # When: Connect to serial device + result = fc.connect(device="/dev/ttyACM0") + + # Then: Connection established successfully + assert result == "" # Empty string indicates success + mock_connect.assert_called_once_with(device="/dev/ttyACM0") - def test_user_can_test_individual_motor(self, flight_controller) -> None: + @pytest.mark.integration + @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") + @patch("ardupilot_methodic_configurator.backend_flightcontroller.mavutil.mavlink_connection") + def test_user_can_connect_to_sitl_for_development_testing(self, mock_mavlink, mock_discover) -> None: """ - User can test an individual motor safely. + User can connect to SITL instance for development and testing. - GIVEN: A connected flight controller with proper motor test setup - WHEN: User requests to test motor 1 at 15% throttle for 3 seconds - THEN: The motor test command should be sent with correct parameters - AND: The function should return True indicating success + GIVEN: SITL (Software In The Loop) simulation is running + WHEN: User connects using TCP connection to SITL + THEN: Connection should be established to simulation environment + AND: Ready for testing configuration workflows """ - # Arrange: Set up motor test parameters - test_sequence_nr = 0 # First motor (0-based for test sequence) - motor_letters = "A" - motor_output_nr = 1 # First output (1-based) - throttle_percent = 15 - timeout_seconds = 3 + # Given: SITL running on standard port + mock_discover.return_value = None + mock_connection = MagicMock() + mock_connection.target_system = 1 + mock_connection.target_component = 1 + mock_mavlink.return_value = mock_connection - # Act: Execute motor test - success, error_msg = flight_controller.test_motor( - test_sequence_nr, motor_letters, motor_output_nr, throttle_percent, timeout_seconds - ) + fc = FlightController(reboot_time=2, baudrate=115200) - # Assert: Motor test command sent correctly - assert success is True, f"Motor test should succeed, but got error: {error_msg}" - assert error_msg == "", f"No error message expected on success, but got: {error_msg}" - flight_controller.master.mav.command_long_send.assert_called_once() - call_args = flight_controller.master.mav.command_long_send.call_args[0] # Positional args + # Mock the connect method to return success + with patch.object(fc, "connect", return_value="") as mock_connect: + # When: Connect to SITL TCP endpoint + result = fc.connect(device="tcp:127.0.0.1:5760") - assert call_args[0] == 1 # target_system - assert call_args[1] == 1 # target_component - assert call_args[2] == mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST # command - assert call_args[3] == 0 # confirmation - assert call_args[4] == test_sequence_nr + 1 # param1: motor test number (1-based) - assert call_args[5] == mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT # param2: throttle type - assert call_args[6] == throttle_percent # param3: throttle value - assert call_args[7] == timeout_seconds # param4: timeout - assert call_args[8] == 0 # param5: motor count (0=single motor test) - assert call_args[9] == 0 # param6: test order (0=default/board order) + # Then: SITL connection established + assert result == "" # Success + mock_connect.assert_called_once_with(device="tcp:127.0.0.1:5760") - def test_user_can_stop_all_motors_immediately(self, flight_controller) -> None: + @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") + def test_user_can_disconnect_cleanly_after_configuration(self, mock_discover) -> None: """ - User can stop all motors immediately for safety. + User can disconnect from flight controller after completing configuration. - GIVEN: Motors are currently running during test - WHEN: User presses emergency stop button - THEN: All motors should stop immediately - AND: The function should return True indicating success + GIVEN: User has finished configuring flight controller + WHEN: They disconnect from the device + THEN: Connection should be cleanly closed + AND: Resources should be properly released """ - # Arrange: Emergency stop scenario - - # Act: Execute emergency stop - success, error_msg = flight_controller.stop_all_motors() + # Given: Connected flight controller after configuration + mock_discover.return_value = None + fc = FlightController(reboot_time=2, baudrate=115200) + mock_master = MagicMock() + fc.set_master_for_testing(mock_master) - # Assert: Emergency stop command sent - assert success is True, f"Motor stop should succeed, but got error: {error_msg}" - assert error_msg == "", f"No error message expected on success, but got: {error_msg}" - flight_controller.master.mav.command_long_send.assert_called_once() - call_args = flight_controller.master.mav.command_long_send.call_args[0] # Positional args + # When: Disconnect after configuration complete + fc.disconnect() - assert call_args[0] == 1 # target_system - assert call_args[1] == 1 # target_component - assert call_args[2] == mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST # command - assert call_args[3] == 0 # confirmation - assert call_args[4] == 0 # param1: motor number (0 = all) - assert call_args[5] == mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT # param2: throttle type - assert call_args[6] == 0 # param3: throttle value (0 = stop) - assert call_args[7] == 0 # param4: timeout (0 = immediate) - assert call_args[8] == 0 # param5: motor count (0 = all) - assert call_args[9] == 0 # param6: test order (0 = default/board order) - - def test_user_can_test_motors_in_sequence(self, flight_controller) -> None: - """ - User can test all motors in sequence automatically. - - GIVEN: A quadcopter frame with 4 motors configured - WHEN: User requests sequential motor test at 12% throttle for 2 seconds each - THEN: Each motor should be tested in sequence (A, B, C, D) - AND: The function should return True indicating success - """ - # Arrange: Configure for quad frame (4 motors) - flight_controller.fc_parameters = { - "FRAME_CLASS": 1, # Quad - "FRAME_TYPE": 1, # X configuration - } - throttle_percent = 12 - timeout_seconds = 2 - - # Act: Execute sequential motor test - start_motor = 1 # Start with first motor - motor_count = 4 # Test 4 motors - success, error_msg = flight_controller.test_motors_in_sequence( - start_motor, motor_count, throttle_percent, timeout_seconds - ) + # Then: Connection properly closed + assert fc.master is None + mock_master.close.assert_called_once() - # Assert: Sequential test command sent for all motors - assert success is True, f"Sequential motor test should succeed, but got error: {error_msg}" - assert error_msg == "", f"No error message expected on success, but got: {error_msg}" - flight_controller.master.mav.command_long_send.assert_called_once() - call_args = flight_controller.master.mav.command_long_send.call_args[0] # Positional args - assert call_args[0] == 1 # target_system - assert call_args[1] == 1 # target_component - assert call_args[2] == mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST # command - assert call_args[3] == 0 # confirmation - assert call_args[4] == start_motor # param1: starting motor number (1-based) - assert call_args[5] == mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT # param2: throttle type - assert call_args[6] == throttle_percent # param3: throttle value - assert call_args[7] == timeout_seconds # param4: timeout per motor - assert call_args[8] == motor_count # param5: number of motors to test in sequence - assert call_args[9] == mavutil.mavlink.MOTOR_TEST_ORDER_SEQUENCE # param6: test order (sequence) +class TestFlightControllerParameterManagement: + """Test parameter download, modification, and verification workflows.""" - def test_motor_test_handles_communication_failure(self, flight_controller) -> None: + @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") + @pytest.mark.integration + @patch( + "ardupilot_methodic_configurator.backend_flightcontroller_params.FlightControllerParams._download_params_via_mavlink" + ) + def test_user_can_download_all_parameters_for_configuration_review(self, mock_download, mock_discover) -> None: """ - Motor test handles communication failures gracefully. + User can download complete parameter set for configuration review and modification. - GIVEN: Flight controller connection is unstable - WHEN: Motor test command fails to send due to communication error - THEN: The function should handle the exception gracefully - AND: Return False to indicate failure + GIVEN: Connected flight controller with configuration parameters + WHEN: User downloads all parameters for review + THEN: Complete parameter set should be retrieved + AND: Parameters should be available for configuration decisions """ - # Arrange: Simulate communication failure - flight_controller.master.mav.command_long_send.side_effect = Exception("Connection lost") + # Given: Connected flight controller with parameters + mock_discover.return_value = None + test_params = { + "FRAME_TYPE": 1.0, # Quad X + "BATT_MONITOR": 4.0, # Battery monitoring enabled + "MOT_SPIN_ARM": 0.1, # Motor spin on arm + "MOT_SPIN_MIN": 0.15, # Minimum motor spin + } + mock_download.return_value = test_params - # Act: Attempt motor test during communication failure - success, error_msg = flight_controller.test_motor(0, "A", 1, 10, 2) + fc = FlightController(reboot_time=2, baudrate=115200) + fc.set_master_for_testing(MagicMock()) + # Mock info to disable MAVFTP so it uses mavlink + fc.info.is_mavftp_supported = False - # Assert: Function handles error gracefully - assert success is False - assert "Connection lost" in error_msg + # When: Download parameters for configuration review + params, defaults = fc.download_params() - def test_battery_status_monitoring_during_motor_test(self, flight_controller) -> None: - """ - Battery status is properly monitored during motor tests. + # Then: All parameters retrieved successfully + assert params == test_params + assert isinstance(defaults, dict) # Default parameters also available + assert "FRAME_TYPE" in params + assert "BATT_MONITOR" in params - GIVEN: Battery monitoring is enabled with voltage and current sensors - WHEN: Battery status is requested during motor testing - THEN: Current voltage and current readings should be returned - AND: Values should be within expected ranges for safe operation + # And: Download method called correctly + mock_download.assert_called_once() + + @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") + def test_user_can_modify_individual_parameters_for_customization(self, mock_discover) -> None: """ - # Arrange: Set up battery monitoring parameters - flight_controller.fc_parameters = { - "BATT_MONITOR": 4, # Voltage and current monitoring - "BATT_ARM_VOLT": 11.0, # Minimum arming voltage - "MOT_BAT_VOLT_MAX": 12.6, # Maximum motor voltage - } + User can modify individual parameters to customize flight controller behavior. - # Mock battery status message - mock_battery_status = MagicMock() - mock_battery_status.voltages = [12100, -1, -1, -1, -1, -1, -1, -1, -1, -1] # 12.1V in mV - mock_battery_status.current_battery = 250 # 2.5A in cA - - # Configure recv_match to return BATTERY_STATUS for this specific test - def recv_match_battery_side_effect(*_args: tuple, **kwargs: dict) -> Union[MagicMock, None]: - type_arg = kwargs.get("type") - if isinstance(type_arg, str) and type_arg == "BATTERY_STATUS": - return mock_battery_status - if isinstance(type_arg, str) and type_arg == "COMMAND_ACK": - # Return the existing COMMAND_ACK mock from the fixture - mock_ack = MagicMock() - mock_ack.command = mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST - mock_ack.result = mavutil.mavlink.MAV_RESULT_ACCEPTED - return mock_ack - return None - - flight_controller.master.recv_match.side_effect = recv_match_battery_side_effect - - # Act: Get battery status - battery_info, error_msg = flight_controller.get_battery_status() - - # Assert: Battery values are correctly parsed - assert battery_info is not None, f"Battery info should be available, got error: {error_msg}" - voltage, current = battery_info - assert error_msg == "", f"No error expected on successful battery status, got: {error_msg}" - assert voltage == 12.1 # Converted from mV to V - assert current == 2.5 # Converted from cA to A - assert 11.0 <= voltage <= 12.6 # Within safe operating range - - def test_battery_status_monitoring_during_motor_test_no_current(self, flight_controller) -> None: - """ - Battery status can be monitored during motor test operations. - - GIVEN: A flight controller connection is available - WHEN: Battery status is requested during motor test - THEN: Battery voltage information should be available - AND: The information should be properly formatted for safety checks - """ - # Arrange: Configure battery monitoring - flight_controller.fc_parameters = { - "BATT_MONITOR": 4, - "BATT_ARM_VOLT": 11.0, - "MOT_BAT_VOLT_MAX": 12.6, - } + GIVEN: Connected flight controller with downloaded parameters + WHEN: User modifies specific parameter for customization + THEN: Parameter change should be sent to flight controller + AND: Change should be applied immediately + """ + # Given: Connected flight controller ready for configuration + mock_discover.return_value = None + fc = FlightController(reboot_time=2, baudrate=115200) + mock_master = MagicMock() + fc.set_master_for_testing(mock_master) - # Mock battery status response - mock_battery_status = MagicMock() - mock_battery_status.voltages = [12100, -1, -1, -1, -1, -1, -1, -1, -1, -1] # 12.1V - mock_battery_status.current_battery = 0 - flight_controller.master.recv_match.return_value = mock_battery_status - - # Act: Request battery status - flight_controller.master.mav.request_data_stream_send( - flight_controller.master.target_system, - flight_controller.master.target_component, - mavutil.mavlink.MAV_DATA_STREAM_EXTENDED_STATUS, - 1, - 1, - ) + # When: Modify battery monitoring parameter + fc.set_param("BATT_MONITOR", 4.0) # Enable battery monitoring - # Assert: Battery status should be available for monitoring - assert flight_controller.master.mav.request_data_stream_send.called - assert mock_battery_status.voltages[0] == 12100 # Voltage in millivolts - assert mock_battery_status.current_battery >= 0 # Valid battery index + # Then: Parameter change sent to flight controller + mock_master.param_set_send.assert_called_once_with("BATT_MONITOR", 4.0) + # And: Parameter cached locally + assert fc.fc_parameters["BATT_MONITOR"] == 4.0 -class TestMotorTestCommandSending: - """Test motor test command sending functionality.""" +class TestFlightControllerMotorTestingWorkflow: + """Test complete motor testing workflow for safety and functionality verification.""" + # pylint: disable=duplicate-code @pytest.fixture - def flight_controller(self) -> FlightController: - """Fixture providing a FlightController for command sending testing.""" + def mock_connected_fc(self) -> FlightController: + """Fixture providing a properly mocked connected flight controller.""" with patch("ardupilot_methodic_configurator.backend_flightcontroller.mavutil.mavlink_connection"): fc = FlightController() mock_master = MagicMock() mock_master.target_system = 1 mock_master.target_component = 1 - # Mock COMMAND_ACK response for successful commands + # Mock successful COMMAND_ACK response mock_ack = MagicMock() mock_ack.command = mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST mock_ack.result = mavutil.mavlink.MAV_RESULT_ACCEPTED mock_ack.progress = 100 mock_ack.result_param2 = 0 - # Configure recv_match to return COMMAND_ACK def recv_match_side_effect(*_args: tuple, **kwargs: dict) -> Union[MagicMock, None]: type_arg = kwargs.get("type") if isinstance(type_arg, str) and type_arg == "COMMAND_ACK": @@ -456,508 +314,530 @@ def recv_match_side_effect(*_args: tuple, **kwargs: dict) -> Union[MagicMock, No return None mock_master.recv_match.side_effect = recv_match_side_effect - fc.master = mock_master + fc.set_master_for_testing(mock_master) return fc - def test_motor_commands_are_sent_to_flight_controller(self, flight_controller) -> None: - """ - Motor test commands are properly sent to the flight controller. - - GIVEN: A valid flight controller connection exists - WHEN: Motor test commands are issued with various parameters - THEN: The commands should be sent to the flight controller via MAVLink - AND: The function should return True to indicate successful command sending - """ - # Arrange: Set up test parameters - test_cases = [ - (1, 5, 1.0), # Minimum test - (4, 50, 5.0), # Mid-range test - (8, 100, 10.0), # Maximum test - ] - - # Act & Assert: Test command sending for each case - for motor_num, throttle, timeout in test_cases: - # Convert motor_num to test parameters - test_sequence_nr = motor_num - 1 # Convert to 0-based index - motor_letters = chr(ord("A") + test_sequence_nr) # A, B, C, etc. - motor_output_nr = motor_num # Keep 1-based for output number - success, error_msg = flight_controller.test_motor( - test_sequence_nr, motor_letters, motor_output_nr, throttle, timeout - ) + # pylint: enable=duplicate-code - # Assert: Command should be sent successfully - assert success is True, f"Motor test command should be sent successfully for motor {motor_num}, error: {error_msg}" - assert error_msg == "", f"No error expected on successful motor test, got: {error_msg}" - flight_controller.master.mav.command_long_send.assert_called() - - def test_command_sending_handles_no_connection_gracefully(self, flight_controller) -> None: + def test_user_can_safely_test_individual_motor_before_flight(self, mock_connected_fc: FlightController) -> None: """ - Motor test commands handle connection failures gracefully. + User can safely test individual motors before flight to verify functionality. - GIVEN: No flight controller connection is available - WHEN: Motor test commands are attempted - THEN: The function should return False safely - AND: No exceptions should be raised + GIVEN: Flight controller connected and armed state safe for testing + WHEN: User tests motor 1 at low throttle for short duration + THEN: Motor should spin at specified throttle + AND: Test should complete without errors + AND: Safety protocols should prevent accidental flight """ - # Arrange: Remove connection - flight_controller.master = None - - # Act: Attempt motor test without connection - success, error_msg = flight_controller.test_motor(0, "A", 1, 10, 2.0) - - # Assert: Should fail gracefully - assert success is False, "Motor test should fail gracefully when no connection is available" - assert "No flight controller connection" in error_msg, f"Expected connection error message, got: {error_msg}" + # Given: Safe testing conditions + fc = mock_connected_fc + # When: Test individual motor safely + success, error_msg = fc.test_motor( + test_sequence_nr=0, # First motor in sequence + motor_letters="A", # Motor A + motor_output_nr=1, # Output 1 + throttle_percent=10, # Low throttle for safety + timeout_seconds=2, # Short test duration + ) -# ==================== COMPREHENSIVE BDD TEST CLASSES ==================== + # Then: Motor test completed successfully + assert success is True, f"Motor test failed: {error_msg}" + assert error_msg == "", "No error message expected on success" + # And: Correct MAVLink command sent + assert fc.master is not None + master = cast("Any", fc.master) + master.mav.command_long_send.assert_called_once() + call_args = master.mav.command_long_send.call_args[0] -class TestFlightControllerConnectionManagement: - """Test flight controller connection lifecycle in BDD style.""" + assert call_args[0] == 1 # target_system + assert call_args[1] == 1 # target_component + assert call_args[2] == mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST + assert call_args[4] == 1 # motor test number (1-based) + assert call_args[5] == mavutil.mavlink.MOTOR_TEST_THROTTLE_PERCENT + assert call_args[6] == 10 # 10% throttle + assert call_args[7] == 2 # 2 second timeout - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - def test_user_can_create_flight_controller_without_auto_discovery(self, mock_discover) -> None: + def test_user_can_emergency_stop_all_motors_during_testing(self, mock_connected_fc: FlightController) -> None: """ - User can create flight controller instance without automatic connection discovery. + User can immediately stop all motors during testing for emergency safety. - GIVEN: A system where automatic discovery is disabled - WHEN: The user creates a FlightController instance - THEN: The instance should be created successfully - AND: No automatic connection discovery should occur + GIVEN: Motors are running during testing + WHEN: User activates emergency stop + THEN: All motors should stop immediately + AND: Safety should be prioritized over test completion """ - # Given: Mock discover_connections to prevent automatic discovery - mock_discover.return_value = None + # Given: Motors running during test + fc = mock_connected_fc - # When: Create FlightController instance - fc = FlightController(reboot_time=5, baudrate=57600) + # When: Emergency stop activated + success, error_msg = fc.stop_all_motors() - # Then: Instance created successfully - assert fc is not None - assert fc._FlightController__reboot_time == 5 - assert fc._FlightController__baudrate == 57600 - assert fc.master is None - assert not fc.fc_parameters + # Then: All motors stopped immediately + assert success is True, f"Emergency stop failed: {error_msg}" + assert error_msg == "" - # And: No automatic discovery occurred - mock_discover.assert_called_once() + # And: Stop command sent to all motors + assert fc.master is not None + master = cast("Any", fc.master) + master.mav.command_long_send.assert_called_once() + call_args = master.mav.command_long_send.call_args[0] - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - @patch("ardupilot_methodic_configurator.backend_flightcontroller.mavutil.mavlink_connection") - def test_user_can_connect_to_flight_controller_successfully(self, mock_mavlink, mock_discover) -> None: + assert call_args[2] == mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST + assert call_args[4] == 0 # motor number 0 = all motors + assert call_args[6] == 0 # throttle 0 = stop + assert call_args[7] == 0 # timeout 0 = immediate + + def test_user_can_test_all_motors_simultaneously_for_efficiency(self, mock_connected_fc: FlightController) -> None: """ - User can establish successful connection to flight controller. + User can test all motors simultaneously to efficiently verify quadcopter functionality. - GIVEN: A flight controller that responds to connection attempts - WHEN: The user connects to a specific device - THEN: A successful connection should be established - AND: Connection status should be available + GIVEN: Quadcopter frame with 4 motors configured + WHEN: User tests all motors at once + THEN: All motors should spin together + AND: Test should complete efficiently """ - # Given: Mock successful connection - mock_discover.return_value = None - mock_connection = MagicMock() - mock_connection.target_system = 1 - mock_connection.target_component = 1 - mock_mavlink.return_value = mock_connection + # Given: Quadcopter configuration + fc = mock_connected_fc - fc = FlightController(reboot_time=5, baudrate=115200) + # When: Test all motors simultaneously + success, error_msg = fc.test_all_motors( + nr_of_motors=4, # Quadcopter + throttle_percent=15, # Moderate throttle + timeout_seconds=3, # Reasonable test duration + ) - # When: Connect to device - fc.connect(device="tcp:127.0.0.1:5760") + # Then: All motors tested successfully + assert success is True, f"All motors test failed: {error_msg}" + assert error_msg == "" - # Then: Connection established successfully + # And: Commands sent for all motors (4 commands for 4 motors) assert fc.master is not None - assert fc.master == mock_connection - mock_mavlink.assert_called_once() + master = cast("Any", fc.master) + assert master.mav.command_long_send.call_count == 4 - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - def test_user_can_disconnect_from_flight_controller_cleanly(self, mock_discover) -> None: + def test_motor_testing_handles_connection_failures_gracefully(self) -> None: """ - User can cleanly disconnect from flight controller. + Motor testing handles connection failures gracefully without crashes. - GIVEN: A connected flight controller - WHEN: The user disconnects - THEN: All connection resources should be released - AND: Connection state should be cleared + GIVEN: Flight controller connection lost during operation + WHEN: User attempts motor testing + THEN: Clear error message should be provided + AND: No exceptions should be raised """ - # Given: Connected flight controller - mock_discover.return_value = None - fc = FlightController(reboot_time=5, baudrate=115200) - mock_master = MagicMock() - fc.master = mock_master + # Given: No connection available + with patch("ardupilot_methodic_configurator.backend_flightcontroller.mavutil.mavlink_connection"): + fc = FlightController() + fc.set_master_for_testing(None) # Simulate lost connection - # When: Disconnect - fc.disconnect() + # When: Attempt motor test without connection + success, error_msg = fc.test_motor(0, "A", 1, 10, 2) - # Then: Connection resources released - assert fc.master is None - mock_master.close.assert_called_once() + # Then: Graceful failure with clear message + assert success is False + assert "No flight controller connection" in error_msg + + +class TestFlightControllerBatteryMonitoringWorkflow: + """Test battery monitoring setup and status checking for safe operation.""" @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - def test_user_can_manage_multiple_connection_strings(self, mock_discover) -> None: + @patch( + "ardupilot_methodic_configurator.backend_flightcontroller_commands.FlightControllerCommands.send_command_and_wait_ack" + ) + def test_user_can_enable_battery_monitoring_for_flight_safety(self, mock_send_command, mock_discover) -> None: """ - User can manage multiple connection strings for different devices. + User can enable battery monitoring to ensure safe flight operations. - GIVEN: A flight controller with no initial connections - WHEN: The user adds multiple connection strings - THEN: All valid connections should be stored - AND: Duplicate connections should be rejected - AND: Invalid connections should be rejected + GIVEN: Flight controller capable of battery monitoring + WHEN: User enables battery status monitoring + THEN: Periodic battery data should be available + AND: Low battery warnings can prevent unsafe flight """ - # Given: Flight controller with no connections + # Given: Flight controller with battery monitoring capability mock_discover.return_value = None - fc = FlightController(reboot_time=5, baudrate=115200) - - # When: Add multiple connection strings - result1 = fc.add_connection("tcp:127.0.0.1:5760") - result2 = fc.add_connection("udp:127.0.0.1:14550") - result3 = fc.add_connection("tcp:127.0.0.1:5760") # Duplicate - result4 = fc.add_connection("") # Invalid - - # Then: Valid connections accepted, invalid rejected - assert result1 is True, "First connection should be accepted" - assert result2 is True, "Second connection should be accepted" - assert result3 is False, "Duplicate connection should be rejected" - assert result4 is False, "Empty connection should be rejected" + mock_send_command.return_value = (True, "") + fc = FlightController(reboot_time=2, baudrate=115200) + fc.set_master_for_testing(MagicMock()) - # And: Connection tuples available - connection_tuples = fc.get_connection_tuples() - assert len(connection_tuples) >= 2, "Should have at least 2 connections" + # When: Enable battery monitoring + success, error_msg = fc.request_periodic_battery_status(interval_microseconds=1000000) # 1 second + # Then: Battery monitoring enabled successfully + assert success is True, f"Battery monitoring setup failed: {error_msg}" + assert error_msg == "" -class TestFlightControllerParameterOperations: - """Test parameter download and management operations in BDD style.""" + # And: Data stream requests sent per retry configuration + assert mock_send_command.call_count == FlightControllerCommands.BATTERY_STATUS_REQUEST_ATTEMPTS @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController._download_params_via_mavlink") - def test_user_can_download_parameters_with_progress_feedback(self, mock_download, mock_discover) -> None: + def test_user_can_verify_battery_monitoring_configuration(self, mock_discover) -> None: """ - User can download parameters with real-time progress feedback. + User can verify battery monitoring is properly configured before flight. - GIVEN: A connected flight controller with parameters - WHEN: The user downloads parameters with a progress callback - THEN: Parameters should be downloaded successfully - AND: Progress callback should be called with updates + GIVEN: Flight controller with parameter configuration + WHEN: User checks battery monitoring status + THEN: Configuration state should be clearly indicated + AND: User can confirm safety systems are active """ - # Given: Connected flight controller + # Given: Flight controller with battery monitoring configured mock_discover.return_value = None - mock_params = {"ALT_HOLD_RTL": 100.0, "BATT_MONITOR": 4.0} - # Note: _download_params_via_mavlink returns only dict, not tuple - mock_download.return_value = mock_params + fc = FlightController(reboot_time=2, baudrate=115200) + fc.set_master_for_testing(MagicMock()) # Need a master connection - fc = FlightController(reboot_time=5, baudrate=115200) - fc.master = MagicMock() + # When: Check monitoring with battery monitoring enabled + fc.set_param("BATT_MONITOR", 4.0) # Analog voltage monitoring + is_enabled = fc.is_battery_monitoring_enabled() - # Track progress updates - progress_updates = [] + # Then: Monitoring correctly identified as enabled + assert is_enabled is True - def progress_callback(current, total) -> None: - progress_updates.append((current, total)) + # When: Check with monitoring disabled + fc.set_param("BATT_MONITOR", 0.0) # Disabled + is_enabled = fc.is_battery_monitoring_enabled() - # When: Download parameters with progress callback - result_params, result_defaults = fc.download_params(progress_callback) + # Then: Monitoring correctly identified as disabled + assert is_enabled is False - # Then: Parameters downloaded successfully - assert result_params == mock_params - assert isinstance(result_defaults, ParDict) # Will be empty ParDict() from MAVLink fallback - mock_download.assert_called_once() + +class TestFlightControllerErrorHandlingAndRecovery: + """Test error handling and recovery scenarios for robust operation.""" @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - def test_user_can_set_individual_parameter_values(self, mock_discover) -> None: + def test_user_gets_clear_error_when_connection_lost(self, mock_discover) -> None: """ - User can set individual parameter values on flight controller. + User gets clear error messages when flight controller connection is lost. - GIVEN: A connected flight controller - WHEN: The user sets a parameter value - THEN: The parameter should be sent to the flight controller - AND: No errors should occur + GIVEN: Flight controller was connected but connection lost + WHEN: User attempts operations requiring connection + THEN: Clear error message should indicate connection issue + AND: No cryptic exceptions should be raised """ - # Given: Connected flight controller + # Given: Connection lost during operation mock_discover.return_value = None - fc = FlightController(reboot_time=5, baudrate=115200) - mock_master = MagicMock() - fc.master = mock_master + fc = FlightController(reboot_time=2, baudrate=115200) + fc.set_master_for_testing(None) # Simulate lost connection - # When: Set parameter - fc.set_param("ALT_HOLD_RTL", 150.0) + # When: Attempt parameter operation without connection + success, message = fc.reset_all_parameters_to_default() - # Then: Parameter sent to flight controller - mock_master.param_set_send.assert_called_once_with("ALT_HOLD_RTL", 150.0) + # Then: Clear error message provided + assert success is False + assert "No flight controller connection" in message - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController._send_command_and_wait_ack") - def test_user_can_reset_all_parameters_to_defaults(self, mock_send_command, mock_discover) -> None: + def test_user_gets_timeout_error_for_nonexistent_parameters(self) -> None: """ - User can reset all parameters to factory defaults. + User gets appropriate timeout behavior when requesting nonexistent parameters. - GIVEN: A connected flight controller with modified parameters - WHEN: The user resets all parameters to defaults - THEN: A reset command should be sent to the flight controller - AND: Success status should be returned + GIVEN: Connected flight controller + WHEN: User requests parameter that doesn't exist + THEN: fetch_param should raise TimeoutError after timeout + AND: User understands the parameter is not available """ # Given: Connected flight controller - mock_discover.return_value = None - mock_send_command.return_value = (True, "") - fc = FlightController(reboot_time=5, baudrate=115200) - fc.master = MagicMock() - - # When: Reset all parameters - success, message = fc.reset_all_parameters_to_default() + fc = FlightController(reboot_time=2, baudrate=115200) + mock_master = MagicMock() + # Mock recv_match to always return None (no PARAM_VALUE response) + mock_master.recv_match.return_value = None + # Mock the mav object for param_request_read_send + mock_master.mav = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections", + return_value=None, + ): + fc.set_master_for_testing(mock_master) - # Then: Reset command sent successfully - assert success is True - assert message == "" - mock_send_command.assert_called_once() + # When/Then: Fetch nonexistent parameter and expect timeout + with patch("ardupilot_methodic_configurator.backend_flightcontroller_params.time_time") as mock_time: + mock_time.side_effect = [0.0, 2.0] + with pytest.raises(TimeoutError, match="FAKE_PARAM"): + fc.fetch_param("FAKE_PARAM", timeout=1) @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - def test_user_receives_timeout_error_when_fetching_nonexistent_parameter(self, mock_discover) -> None: + @patch("ardupilot_methodic_configurator.backend_flightcontroller.mavutil.mavlink_connection") + def test_user_can_recover_from_connection_failures(self, mock_mavlink, mock_discover) -> None: """ - User receives appropriate timeout error when fetching nonexistent parameter. + User can recover from connection failures and re-establish communication. - GIVEN: A connected flight controller - WHEN: The user fetches a parameter that doesn't exist - THEN: A TimeoutError should be raised after waiting period + GIVEN: Connection failure occurred + WHEN: User attempts to reconnect + THEN: New connection should be established + AND: Previous connection state should be cleared """ - # Given: Connected flight controller that doesn't respond to param requests + # Given: Previous connection failed mock_discover.return_value = None - fc = FlightController(reboot_time=5, baudrate=115200) - mock_master = MagicMock() - mock_master.recv_match.return_value = None # No response - fc.master = mock_master + mock_connection = MagicMock() + mock_connection.target_system = 1 + mock_connection.target_component = 1 + mock_mavlink.return_value = mock_connection - # When: Fetch nonexistent parameter - # Then: Timeout error should be raised - with pytest.raises(TimeoutError, match="Timeout waiting for parameter NONEXISTENT_PARAM"): - fc.fetch_param("NONEXISTENT_PARAM", timeout=1) # Short timeout for testing + fc = FlightController(reboot_time=2, baudrate=115200) + # When: Recover with reconnection + result = fc.reset_and_reconnect() -class TestFlightControllerMotorTesting: - """Test motor testing functionality in BDD style.""" + # Then: Reconnection attempted (result may vary based on implementation) + assert isinstance(result, str) # Result is a string message - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController._send_command_and_wait_ack") - def test_user_can_test_all_motors_simultaneously(self, mock_send_command, mock_discover) -> None: + +class TestServiceInjectionIntegration: + """Test service injection integration with FlightControllerConnection.""" + + def test_user_can_inject_both_services(self) -> None: """ - User can test all motors simultaneously at specified throttle. + User can inject both serial discovery and MAVLink factory together. - GIVEN: A connected flight controller with multiple motors - WHEN: The user tests all motors at once - THEN: Motor test commands should be sent for all motors - AND: Success status should be returned + GIVEN: Developer wants full test isolation + WHEN: Injecting both services + THEN: Both should be used consistently """ - # Given: Connected flight controller - mock_discover.return_value = None - mock_send_command.return_value = (True, "") - fc = FlightController(reboot_time=5, baudrate=115200) - fc.master = MagicMock() + # Given: Both fake services + fake_serial = FakeSerialPortDiscovery() + fake_serial.add_port("/dev/ttyUSB0", "Fake Controller") + + fake_mavlink = FakeMavlinkConnectionFactory() - # When: Test all 4 motors at 20% throttle - success, message = fc.test_all_motors(nr_of_motors=4, throttle_percent=20, timeout_seconds=5) + # When: Inject both services + info = FlightControllerInfo() + connection = FlightControllerConnection( + info=info, + serial_port_discovery=fake_serial, + mavlink_connection_factory=fake_mavlink, + ) - # Then: Motor test successful - assert success is True - assert message == "" + # Then: Both services are active + assert connection._serial_port_discovery is fake_serial # pylint: disable=protected-access + assert connection._mavlink_connection_factory is fake_mavlink # pylint: disable=protected-access - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController._send_command_and_wait_ack") - def test_user_can_stop_all_motors_immediately(self, mock_send_command, mock_discover) -> None: + def test_discovery_and_creation_workflow(self) -> None: """ - User can immediately stop all motors for safety. + Complete workflow: discover ports then create connections. - GIVEN: A flight controller with motors running - WHEN: The user stops all motors - THEN: Motor stop commands should be sent - AND: All motors should be stopped safely + GIVEN: Both services injected + WHEN: Discovering ports and creating connections + THEN: Workflow should complete without errors """ - # Given: Connected flight controller - mock_discover.return_value = None - mock_send_command.return_value = (True, "") - fc = FlightController(reboot_time=5, baudrate=115200) - fc.master = MagicMock() + # Given: Fake services + fake_serial = FakeSerialPortDiscovery() + fake_serial.add_port("/dev/ttyUSB0", "Test FC") + + fake_mavlink = FakeMavlinkConnectionFactory() - # When: Stop all motors - success, _message = fc.stop_all_motors() + info = FlightControllerInfo() + connection = FlightControllerConnection( + info=info, + serial_port_discovery=fake_serial, + mavlink_connection_factory=fake_mavlink, + ) - # Then: Motors stopped successfully - assert success is True - mock_send_command.assert_called() + # When: Discover connections + connection.discover_connections() - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController._send_command_and_wait_ack") - def test_user_can_test_motors_in_sequence_safely(self, mock_send_command, mock_discover) -> None: + # Then: Ports are discoverable + connection_tuples = connection.get_connection_tuples() + assert any(t[0] == "/dev/ttyUSB0" for t in connection_tuples) + + +class TestFlightControllerResetAndDelegation: + """Test reset workflow and delegation helpers exposed by the facade.""" + + def test_user_is_warned_about_modemmanager_interference(self) -> None: """ - User can test motors in sequence for safer testing. + Users are warned about ModemManager before connections are attempted. - GIVEN: A connected flight controller with multiple motors - WHEN: The user tests motors in sequence - THEN: A single sequential motor test command should be sent - AND: The command should specify the motor sequence parameters + GIVEN: ModemManager is detected on the operating system + WHEN: A flight controller instance is created + THEN: A warning should be logged with remediation guidance """ - # Given: Connected flight controller - mock_discover.return_value = None - mock_send_command.return_value = (True, "") - fc = FlightController(reboot_time=5, baudrate=115200) - fc.master = MagicMock() + with ( + patch("ardupilot_methodic_configurator.backend_flightcontroller.os_path.exists", return_value=True), + patch("ardupilot_methodic_configurator.backend_flightcontroller.logging_warning") as mock_warning, + ): + _build_flight_controller_with_mocks() + + mock_warning.assert_called_once() - # When: Test motors in sequence - success, _message = fc.test_motors_in_sequence(start_motor=1, motor_count=3, throttle_percent=15, timeout_seconds=3) + def test_user_can_reset_and_reconnect_after_configuration(self) -> None: + """ + Reset workflow restarts the controller and reconnects with progress updates. - # Then: Sequential motor test command sent successfully - assert success is True - mock_send_command.assert_called_once() + GIVEN: Connected controller after configuration changes + WHEN: User requests a reset to apply firmware changes + THEN: The autopilot should reboot and reconnect using retries if needed + AND: Progress callbacks should reflect each wait step + """ + fc, mock_conn_mgr, *_others, mock_master = _build_flight_controller_with_mocks(reboot_time=2) + mock_conn_mgr.create_connection_with_retry.return_value = "RECONNECTED" + progress_updates: list[tuple[int, int]] = [] + connection_progress = MagicMock() + with patch("ardupilot_methodic_configurator.backend_flightcontroller.time_sleep", return_value=None): + result = fc.reset_and_reconnect( + reset_progress_callback=lambda current, total: progress_updates.append((current, total)), + connection_progress_callback=connection_progress, + extra_sleep_time=1, + ) -class TestFlightControllerBatteryMonitoring: - """Test battery monitoring functionality in BDD style.""" + mock_master.reboot_autopilot.assert_called_once() + mock_conn_mgr.disconnect.assert_called_once() + mock_conn_mgr.create_connection_with_retry.assert_called_once_with( + progress_callback=connection_progress, + retries=3, + timeout=5, + baudrate=mock_conn_mgr.baudrate, + log_errors=True, + ) + assert progress_updates[0] == (0, 3) + assert progress_updates[-1] == (3, 3) + assert result == "RECONNECTED" - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController._send_command_and_wait_ack") - def test_user_can_enable_periodic_battery_status_monitoring(self, mock_send_command, mock_discover) -> None: + def test_reset_and_reconnect_returns_immediately_when_disconnected(self) -> None: """ - User can enable periodic battery status monitoring. + Reset workflow aborts gracefully when no connection exists. - GIVEN: A connected flight controller with battery monitoring capability - WHEN: The user enables periodic battery status - THEN: Battery monitoring should be configured - AND: Periodic status messages should be requested + GIVEN: Controller facade without an active connection + WHEN: User triggers reset + THEN: No reboot attempts should be performed + AND: An empty status string should be returned """ - # Given: Connected flight controller - mock_discover.return_value = None - mock_send_command.return_value = (True, "") - fc = FlightController(reboot_time=5, baudrate=115200) - fc.master = MagicMock() + fc, mock_conn_mgr, *_ = _build_flight_controller_with_mocks() + mock_conn_mgr.master = None - # When: Enable battery monitoring with 1-second interval - success, _message = fc.request_periodic_battery_status(interval_microseconds=1000000) + result = fc.reset_and_reconnect() - # Then: Battery monitoring enabled - assert success is True - mock_send_command.assert_called() + assert result == "" + mock_conn_mgr.disconnect.assert_not_called() - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - def test_user_can_retrieve_current_battery_status(self, mock_discover) -> None: + def test_user_can_read_current_comport_and_device(self) -> None: """ - User can retrieve current battery voltage and current readings. + Users can inspect the delegated comport information for diagnostics. - GIVEN: A connected flight controller with battery monitoring enabled - WHEN: The user requests current battery status - THEN: Voltage and current readings should be returned - AND: Values should be within expected ranges + GIVEN: Controller facade backed by a mocked connection manager + WHEN: The user inspects comport properties + THEN: Values should be retrieved directly from the connection manager """ - # Given: Connected flight controller with battery data - mock_discover.return_value = None - fc = FlightController(reboot_time=5, baudrate=115200) - mock_master = MagicMock() + fc, mock_conn_mgr, *_ = _build_flight_controller_with_mocks() + mock_conn_mgr.comport = "ttyUSB0" + mock_conn_mgr.comport_device = "/dev/ttyUSB0" - # Mock battery status message - mock_battery_msg = MagicMock() - mock_battery_msg.voltages = [4200, 4180, 4190] # mV per cell - mock_battery_msg.current_battery = 1500 # cA (15A) - mock_master.recv_match.return_value = mock_battery_msg - fc.master = mock_master - # Need battery monitoring enabled for get_battery_status to work - fc.fc_parameters = {"BATT_MONITOR": 4} # Battery monitoring enabled - - # When: Get battery status - battery_data, message = fc.get_battery_status() - - # Then: Battery data retrieved successfully - if battery_data is not None: - voltage, current = battery_data - assert voltage > 0, "Voltage should be positive" - assert current > 0, "Current should be positive" - assert message == "" + assert fc.comport == "ttyUSB0" + assert fc.comport_device == "/dev/ttyUSB0" - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - def test_user_can_check_battery_monitoring_configuration(self, mock_discover) -> None: + def test_user_can_query_available_network_ports(self) -> None: """ - User can check if battery monitoring is properly configured. + Users can request enumerated network ports through the facade. - GIVEN: A flight controller with parameter configuration - WHEN: The user checks battery monitoring status - THEN: Configuration status should be returned accurately + GIVEN: Connection manager advertises network targets + WHEN: User calls get_network_ports() + THEN: The same list should be returned unchanged """ - # Given: Flight controller with battery monitoring enabled - mock_discover.return_value = None - fc = FlightController(reboot_time=5, baudrate=115200) - fc.fc_parameters = {"BATT_MONITOR": 4.0} # Battery monitoring enabled + fc, mock_conn_mgr, *_ = _build_flight_controller_with_mocks() + mock_conn_mgr.get_network_ports.return_value = ["tcp:1"] - # When: Check battery monitoring status - is_enabled = fc.is_battery_monitoring_enabled() + assert fc.get_network_ports() == ["tcp:1"] + mock_conn_mgr.get_network_ports.assert_called_once() - # Then: Monitoring status correctly identified - assert is_enabled is True + def test_user_can_list_known_connections(self) -> None: + """ + Users can view named connection tuples gathered by discovery. - # When: Check with monitoring disabled - fc.fc_parameters = {"BATT_MONITOR": 0.0} # Disabled - is_enabled = fc.is_battery_monitoring_enabled() + GIVEN: Connection discovery populated friendly names + WHEN: User requests the tuple list + THEN: The manager-provided tuples should be returned as-is + """ + fc, mock_conn_mgr, *_ = _build_flight_controller_with_mocks() + mock_conn_mgr.get_connection_tuples.return_value = [("tcp:2", "SITL")] - # Then: Disabled status correctly identified - assert is_enabled is False + assert fc.get_connection_tuples() == [("tcp:2", "SITL")] + mock_conn_mgr.get_connection_tuples.assert_called_once() + def test_user_can_retry_connection_attempts_via_facade(self) -> None: + """ + Users can reuse the retry helper exposed by the facade. -class TestFlightControllerErrorHandling: - """Test error handling and edge cases in BDD style.""" + GIVEN: Controller facade with mocked connection manager + WHEN: create_connection_with_retry is invoked with custom options + THEN: The connection manager should receive the provided arguments + """ + fc, mock_conn_mgr, *_ = _build_flight_controller_with_mocks() + progress = MagicMock() - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - def test_user_receives_appropriate_error_when_operations_attempted_without_connection(self, mock_discover) -> None: + fc.create_connection_with_retry(progress_callback=progress, retries=5, timeout=10, baudrate=57600, log_errors=False) + + mock_conn_mgr.create_connection_with_retry.assert_called_with( + progress_callback=progress, + retries=5, + timeout=10, + baudrate=57600, + log_errors=False, + ) + + def test_user_can_request_serial_port_listing(self) -> None: """ - User receives clear error messages when attempting operations without connection. + Serial port enumeration delegates to the connection utility. - GIVEN: A flight controller that is not connected - WHEN: The user attempts various operations - THEN: Clear error messages should be provided - AND: Operations should fail gracefully + GIVEN: FlightControllerConnection exposes discovered ports + WHEN: User calls the static helper + THEN: The static method should forward to the connection class """ - # Given: Unconnected flight controller - mock_discover.return_value = None - fc = FlightController(reboot_time=5, baudrate=115200) - assert fc.master is None + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller.FlightControllerConnection.get_serial_ports", + return_value=["/dev/ttyS0"], + ) as mock_ports: + ports = FlightController.get_serial_ports() - # When/Then: Various operations should fail gracefully + assert ports == ["/dev/ttyS0"] + mock_ports.assert_called_once() - # Motor testing without connection - success, error = fc.test_motor(0, "A", 1, 10, 2) - assert success is False - assert "No flight controller connection" in error + def test_user_can_delegate_file_transfers_through_facade(self) -> None: + """ + File uploads and downloads reuse the injected files manager. + + GIVEN: Controller facade wired with mocked files manager + WHEN: User uploads or downloads a file + THEN: The corresponding files manager methods should be triggered + """ + fc, _conn_mgr, _params_mgr, _commands_mgr, mock_files_mgr, _master = _build_flight_controller_with_mocks() - # Battery status without connection - battery_data, error = fc.get_battery_status() - assert battery_data is None - assert "No flight controller connection" in error + with tempfile.NamedTemporaryFile() as tmp_file: + destination = tmp_file.name + assert fc.upload_file("local.txt", "@SYS/local.txt") is True + assert fc.download_last_flight_log(destination) is True - # Stop motors without connection - success, error = fc.stop_all_motors() - assert success is False - assert "No flight controller connection" in error + mock_files_mgr.upload_file.assert_called_once_with("local.txt", "@SYS/local.txt", None) + mock_files_mgr.download_last_flight_log.assert_called_once_with(destination, None) - @patch("ardupilot_methodic_configurator.backend_flightcontroller.FlightController.discover_connections") - def test_user_gets_voltage_thresholds_with_graceful_fallback(self, mock_discover) -> None: + def test_cli_argument_helper_exposes_expected_flags(self) -> None: """ - User gets voltage thresholds with graceful fallback to defaults. + CLI helper wires baudrate, device, and reboot-time arguments for users. - GIVEN: A flight controller that may or may not have threshold parameters - WHEN: The user requests voltage thresholds - THEN: Either configured values or safe defaults should be returned + GIVEN: An ArgumentParser dedicated to CLI tooling + WHEN: add_argparse_arguments is invoked + THEN: The resulting parser should accept key flight controller options """ - # Given: Flight controller without threshold parameters - mock_discover.return_value = None - fc = FlightController(reboot_time=5, baudrate=115200) - fc.fc_parameters = {} + parser = ArgumentParser(prog="fc") + parser = FlightController.add_argparse_arguments(parser) + args = parser.parse_args(["--device", "tcp:1", "--baudrate", "57600", "--reboot-time", "9"]) - # When: Get voltage thresholds (default values) - low_threshold, critical_threshold = fc.get_voltage_thresholds() + assert args.device == "tcp:1" + assert args.baudrate == 57600 + assert args.reboot_time == 9 - # Then: Default thresholds returned (both 0.0 when no parameters set) - assert isinstance(low_threshold, float) - assert isinstance(critical_threshold, float) - assert low_threshold == 0.0 - assert critical_threshold == 0.0 + def test_sitl_helper_methods_delegate_to_connection_manager(self) -> None: + """ + SITL helper methods expose the connection manager without duplicating logic. - # When: Flight controller has configured thresholds - fc.fc_parameters = {"BATT_ARM_VOLT": 14.0, "MOT_BAT_VOLT_MAX": 16.8} - low_threshold, critical_threshold = fc.get_voltage_thresholds() + GIVEN: Controller facade with mocked connection manager internals + WHEN: Developer utilities are invoked from tests + THEN: Each call should be forwarded to the connection manager implementation + """ + fc, mock_conn_mgr, *_ = _build_flight_controller_with_mocks() + fc._select_supported_autopilot({(1, 1): {}}) + dummy_msg = MagicMock() + fc._populate_flight_controller_info(dummy_msg) - # Then: Configured thresholds returned - assert low_threshold == 14.0 - assert critical_threshold == 16.8 + mock_conn_mgr._select_supported_autopilot.assert_called_once() + mock_conn_mgr._populate_flight_controller_info.assert_called_once_with(dummy_msg) diff --git a/tests/test_backend_flightcontroller_business_logic.py b/tests/test_backend_flightcontroller_business_logic.py new file mode 100755 index 000000000..4aeecaca2 --- /dev/null +++ b/tests/test_backend_flightcontroller_business_logic.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 + +""" +Unit tests for pure business logic functions. + +These tests verify the business logic without needing hardware or SITL. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import pytest + +from ardupilot_methodic_configurator.backend_flightcontroller_business_logic import ( + calculate_motor_sequence_number, + calculate_voltage_thresholds, + convert_battery_telemetry_units, + get_frame_info, + is_battery_monitoring_enabled, + validate_battery_voltage, + validate_motor_test_duration, + validate_throttle_percentage, +) + + +class TestVoltageThresholds: + """Test battery voltage threshold calculations.""" + + def test_with_valid_parameters(self) -> None: + """ + Test voltage threshold extraction with valid parameters. + + Given a parameter dict with voltage thresholds + When calculating thresholds + Then correct min and max voltages are returned. + """ + params = {"BATT_ARM_VOLT": 10.5, "MOT_BAT_VOLT_MAX": 25.2} + min_v, max_v = calculate_voltage_thresholds(params) + assert min_v == 10.5 + assert max_v == 25.2 + + def test_with_missing_parameters(self) -> None: + """ + Test voltage threshold extraction with missing parameters. + + Given an empty parameter dict + When calculating thresholds + Then default values (0.0) are returned. + """ + min_v, max_v = calculate_voltage_thresholds({}) + assert min_v == 0.0 + assert max_v == 0.0 + + def test_with_partial_parameters(self) -> None: + """ + Test voltage threshold extraction with only one parameter. + + Given a parameter dict with only min voltage + When calculating thresholds + Then min voltage is returned and max is default. + """ + params = {"BATT_ARM_VOLT": 11.1} + min_v, max_v = calculate_voltage_thresholds(params) + assert min_v == 11.1 + assert max_v == 0.0 + + +class TestBatteryMonitoring: + """Test battery monitoring enabled check.""" + + def test_monitoring_enabled(self) -> None: + """ + Test battery monitoring detection when enabled. + + Given BATT_MONITOR is non-zero + When checking if monitoring is enabled + Then True is returned. + """ + assert is_battery_monitoring_enabled({"BATT_MONITOR": 4.0}) is True + assert is_battery_monitoring_enabled({"BATT_MONITOR": 1.0}) is True + + def test_monitoring_disabled(self) -> None: + """ + Test battery monitoring detection when disabled. + + Given BATT_MONITOR is zero + When checking if monitoring is enabled + Then False is returned. + """ + assert is_battery_monitoring_enabled({"BATT_MONITOR": 0.0}) is False + + def test_monitoring_missing_parameter(self) -> None: + """ + Test battery monitoring with missing parameter. + + Given BATT_MONITOR is not in parameters + When checking if monitoring is enabled + Then False is returned (default). + """ + assert is_battery_monitoring_enabled({}) is False + + +class TestFrameInfo: + """Test frame information extraction.""" + + def test_with_valid_parameters(self) -> None: + """ + Test frame info extraction with valid parameters. + + Given parameters with frame class and type + When extracting frame info + Then correct class and type are returned as integers. + """ + params = {"FRAME_CLASS": 1.0, "FRAME_TYPE": 3.0} + frame_class, frame_type = get_frame_info(params) + assert frame_class == 1 + assert frame_type == 3 + + def test_with_default_values(self) -> None: + """ + Test frame info extraction with missing parameters. + + Given empty parameter dict + When extracting frame info + Then default values (1, 1) are returned. + """ + frame_class, frame_type = get_frame_info({}) + assert frame_class == 1 # Default QUAD + assert frame_type == 1 # Default X + + +class TestBatteryVoltageValidation: + """Test battery voltage validation logic.""" + + def test_voltage_in_range(self) -> None: + """ + Test voltage validation when within range. + + Given voltage between min and max + When validating + Then validation passes with no error. + """ + is_valid, error = validate_battery_voltage(12.6, 10.5, 25.2) + assert is_valid is True + assert error is None + + def test_voltage_below_minimum(self) -> None: + """ + Test voltage validation when below minimum. + + Given voltage below min threshold + When validating + Then validation fails with descriptive error. + """ + is_valid, error = validate_battery_voltage(9.0, 10.5, 25.2) + assert is_valid is False + assert error is not None + assert "below minimum" in error + assert "9.00" in error + assert "10.50" in error + + def test_voltage_above_maximum(self) -> None: + """ + Test voltage validation when above maximum. + + Given voltage above max threshold + When validating + Then validation fails with descriptive error. + """ + is_valid, error = validate_battery_voltage(26.0, 10.5, 25.2) + assert is_valid is False + assert error is not None + assert "above maximum" in error + assert "26.00" in error + assert "25.20" in error + + def test_voltage_at_boundaries(self) -> None: + """ + Test voltage validation at exact boundaries. + + Given voltage exactly at min or max + When validating + Then validation passes (boundaries are inclusive). + """ + assert validate_battery_voltage(10.5, 10.5, 25.2)[0] is True + assert validate_battery_voltage(25.2, 10.5, 25.2)[0] is True + + +class TestBatteryTelemetryConversion: + """Test battery telemetry unit conversions.""" + + def test_valid_telemetry(self) -> None: + """ + Test conversion of valid telemetry values. + + Given millivolts and centiamps + When converting to standard units + Then volts and amps are returned. + """ + voltage, current = convert_battery_telemetry_units(12600, 1050) + assert voltage == pytest.approx(12.6) + assert current == pytest.approx(10.5) + + def test_invalid_telemetry(self) -> None: + """ + Test conversion of invalid telemetry markers (-1). + + Given -1 values (MAVLink "not available" marker) + When converting + Then 0.0 is returned for both. + """ + voltage, current = convert_battery_telemetry_units(-1, -1) + assert voltage == 0.0 + assert current == 0.0 + + def test_mixed_validity(self) -> None: + """ + Test conversion with one valid and one invalid value. + + Given valid voltage but invalid current + When converting + Then voltage is converted, current is 0. + """ + voltage, current = convert_battery_telemetry_units(11100, -1) + assert voltage == pytest.approx(11.1) + assert current == 0.0 + + +class TestThrottleValidation: + """Test throttle percentage validation.""" + + def test_valid_throttle(self) -> None: + """ + Test validation of valid throttle percentages. + + Given throttle in range 0-100 + When validating + Then validation passes. + """ + assert validate_throttle_percentage(0)[0] is True + assert validate_throttle_percentage(50)[0] is True + assert validate_throttle_percentage(100)[0] is True + + def test_throttle_below_minimum(self) -> None: + """ + Test validation of negative throttle. + + Given negative throttle value + When validating + Then validation fails with error message. + """ + is_valid, error = validate_throttle_percentage(-10) + assert is_valid is False + assert error is not None + assert "below minimum" in error + + def test_throttle_above_maximum(self) -> None: + """ + Test validation of excessive throttle. + + Given throttle above 100 + When validating + Then validation fails with error message. + """ + is_valid, error = validate_throttle_percentage(150) + assert is_valid is False + assert error is not None + assert "above maximum" in error + + +class TestMotorTestDurationValidation: + """Test motor test duration validation.""" + + def test_valid_duration(self) -> None: + """ + Test validation of valid test durations. + + Given duration in safe range (1-30 seconds) + When validating + Then validation passes. + """ + assert validate_motor_test_duration(1)[0] is True + assert validate_motor_test_duration(5)[0] is True + assert validate_motor_test_duration(30)[0] is True + + def test_duration_too_short(self) -> None: + """ + Test validation of too-short duration. + + Given duration of 0 seconds + When validating + Then validation fails. + """ + is_valid, error = validate_motor_test_duration(0) + assert is_valid is False + assert error is not None + assert "too short" in error + + def test_duration_too_long(self) -> None: + """ + Test validation of excessive duration. + + Given duration above 30 seconds + When validating + Then validation fails. + """ + is_valid, error = validate_motor_test_duration(35) + assert is_valid is False + assert error is not None + assert "too long" in error + + +class TestMotorSequenceNumber: + """Test motor sequence number calculation.""" + + def test_zero_based_index(self) -> None: + """ + Test conversion from 0-based index. + + Given 0-based motor index + When calculating sequence number + Then 1-based sequence is returned. + """ + assert calculate_motor_sequence_number(0, zero_based=True) == 1 + assert calculate_motor_sequence_number(3, zero_based=True) == 4 + + def test_one_based_index(self) -> None: + """ + Test passthrough of 1-based index. + + Given already 1-based motor index + When calculating sequence number + Then same number is returned. + """ + assert calculate_motor_sequence_number(1, zero_based=False) == 1 + assert calculate_motor_sequence_number(4, zero_based=False) == 4 diff --git a/tests/test_backend_flightcontroller_commands.py b/tests/test_backend_flightcontroller_commands.py new file mode 100755 index 000000000..0cf713afd --- /dev/null +++ b/tests/test_backend_flightcontroller_commands.py @@ -0,0 +1,956 @@ +#!/usr/bin/env python3 + +""" +BDD-style tests for backend_flightcontroller_commands.py. + +This file focuses on command execution behavior including motor tests, +battery status requests, and parameter reset commands. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import time +from unittest.mock import MagicMock, Mock + +import pytest +from pymavlink import mavutil + +from ardupilot_methodic_configurator.backend_flightcontroller_commands import FlightControllerCommands + + +class TestFlightControllerCommandsInitialization: + """Test command manager initialization and setup.""" + + def test_user_can_create_commands_manager(self) -> None: + """ + User can create command manager with required dependencies. + + GIVEN: Params manager and connection manager available + WHEN: User creates commands manager + THEN: Manager should be initialized successfully + AND: Dependencies should be stored + """ + # Given: Mock dependencies + mock_params_mgr = Mock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + + # When: Create commands manager + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # Then: Manager initialized + assert commands_mgr is not None + assert commands_mgr.master is None + + def test_commands_manager_requires_dependencies(self) -> None: + """ + Command manager requires both params and connection managers. + + GIVEN: Missing required dependencies + WHEN: User attempts to create commands manager + THEN: ValueError should be raised + AND: Clear error message should be provided + """ + # When/Then: Missing params manager + with pytest.raises(ValueError, match="params_manager is required"): + FlightControllerCommands(params_manager=None, connection_manager=Mock()) + + # When/Then: Missing connection manager + with pytest.raises(ValueError, match="connection_manager is required"): + FlightControllerCommands(params_manager=Mock(), connection_manager=None) + + +class TestFlightControllerCommandsMotorTest: + """Test motor testing command functionality.""" + + def test_user_can_test_individual_motor(self) -> None: + """ + User can test individual motor at specified throttle. + + GIVEN: Connected flight controller ready for motor test + WHEN: User tests motor 1 at 10% throttle + THEN: MAVLink command should be sent correctly + AND: Command acknowledgment should be received + """ + # Given: Connected FC with ACK response + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + mock_ack = MagicMock() + mock_ack.command = mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST + mock_ack.result = mavutil.mavlink.MAV_RESULT_ACCEPTED + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Test motor + success, error = commands_mgr.test_motor( + test_sequence_nr=1, motor_letters="A", motor_output_nr=1, throttle_percent=10, timeout_seconds=2 + ) + + # Then: Command sent successfully + assert success is True + assert error == "" + mock_master.mav.command_long_send.assert_called_once() + + def test_motor_test_fails_without_connection(self) -> None: + """ + Motor test fails gracefully without connection. + + GIVEN: No flight controller connection + WHEN: User attempts motor test + THEN: Operation should fail with clear error + AND: No exceptions should be raised + """ + # Given: No connection + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Attempt motor test + success, error = commands_mgr.test_motor(1, "A", 1, 10, 2) + + # Then: Clear failure + assert success is False + assert "connection" in error.lower() + + +class TestFlightControllerCommandsBatteryStatus: + """Test battery status request functionality.""" + + def test_user_can_request_battery_status(self) -> None: + """ + User can request periodic battery status updates. + + GIVEN: Connected flight controller with battery monitoring + WHEN: User requests battery status at 1Hz + THEN: Data stream request should be sent + AND: Command should be acknowledged + """ + # Given: Connected FC + mock_master = MagicMock() + mock_ack = MagicMock() + mock_ack.command = mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL + mock_ack.result = mavutil.mavlink.MAV_RESULT_ACCEPTED + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Request battery status + success, error = commands_mgr.request_periodic_battery_status( + interval_microseconds=1000000 # 1Hz + ) + + # Then: Request sent + assert success is True + assert error == "" + + def test_user_can_get_battery_status(self) -> None: + """ + User can get current battery status from flight controller. + + GIVEN: Flight controller with battery monitoring enabled + WHEN: User requests current battery status + THEN: Voltage and current should be returned + AND: Values should be in expected ranges + """ + # Given: FC with battery data + mock_master = MagicMock() + mock_battery_msg = MagicMock() + mock_battery_msg.voltages = [4200, 4180, 4190] # mV + mock_battery_msg.current_battery = 2500 # centi-amps + mock_master.recv_match.return_value = mock_battery_msg + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = {"BATT_MONITOR": 4.0} + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Get battery status + battery_data, message = commands_mgr.get_battery_status() + + # Then: Status retrieved + assert battery_data is not None + assert message == "" + if battery_data: + voltage, current = battery_data + assert voltage > 0 + assert current >= 0 + + +class TestFlightControllerCommandsParameterReset: # pylint: disable=too-few-public-methods + """Test parameter reset command functionality.""" + + def test_user_can_reset_all_parameters_to_defaults(self) -> None: + """ + User can reset all parameters to factory defaults. + + GIVEN: Flight controller with modified parameters + WHEN: User resets all parameters to defaults + THEN: Reset command should be sent + AND: Command should be acknowledged + """ + # Given: Connected FC + mock_master = MagicMock() + mock_ack = MagicMock() + mock_ack.command = mavutil.mavlink.MAV_CMD_PREFLIGHT_STORAGE + mock_ack.result = mavutil.mavlink.MAV_RESULT_ACCEPTED + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Reset parameters + success, error = commands_mgr.reset_all_parameters_to_default() + + # Then: Command sent + assert success is True + assert error == "" + + +class TestFlightControllerCommandsSendCommandAndWaitAck: + """Test low-level command sending with ACK waiting.""" + + def test_command_waits_for_acknowledgment(self) -> None: + """ + Command sending waits for proper acknowledgment. + + GIVEN: Flight controller command infrastructure + WHEN: User sends command with ACK expected + THEN: Function should wait for ACK message + AND: Return success when ACK received + """ + # Given: FC that sends ACK + mock_master = MagicMock() + mock_ack = MagicMock() + mock_ack.command = 123 + mock_ack.result = mavutil.mavlink.MAV_RESULT_ACCEPTED + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Send command + success, error = commands_mgr.send_command_and_wait_ack( + command=123, param1=0, param2=0, param3=0, param4=0, param5=0, param6=0, param7=0, timeout=1.0 + ) + + # Then: ACK received + assert success is True + assert error == "" + + def test_command_timeout_returns_error(self) -> None: + """ + Command timeout returns appropriate error. + + GIVEN: Flight controller not responding + WHEN: User sends command with timeout + THEN: Timeout error should be returned + AND: Error message should indicate timeout + """ + # Given: FC that doesn't respond + mock_master = MagicMock() + mock_master.recv_match.return_value = None + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Send command with short timeout + success, error = commands_mgr.send_command_and_wait_ack( + command=999, param1=0, param2=0, param3=0, param4=0, param5=0, param6=0, param7=0, timeout=0.1 + ) + + # Then: Timeout error + assert success is False + assert "timeout" in error.lower() + + +class TestFlightControllerCommandsPropertyDelegation: # pylint: disable=too-few-public-methods + """Test property delegation to connection manager.""" + + def test_master_property_delegates_to_connection_manager(self) -> None: + """ + Master property correctly delegates to connection manager. + + GIVEN: Commands manager with connection manager + WHEN: Accessing master property + THEN: Connection manager's master should be returned + """ + # Given: Connection with master + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Access master + retrieved_master = commands_mgr.master + + # Then: Correct master returned + assert retrieved_master is mock_master + + +class TestFlightControllerCommandsEdgeCases: + """Additional edge-case tests for battery and command handling.""" + + def test_request_periodic_battery_status_denied(self) -> None: + """Battery status request should propagate NACK/denied responses.""" + mock_master = MagicMock() + mock_ack = MagicMock() + mock_ack.command = mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL + mock_ack.result = mavutil.mavlink.MAV_RESULT_DENIED + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + success, error = commands_mgr.request_periodic_battery_status(interval_microseconds=1000000) + + assert success is False + assert "denied" in error.lower() or "failed" in error.lower() + + def test_get_battery_status_returns_recent_cache(self) -> None: + """Return cached battery data when no new telemetry is available but a recent value exists.""" + mock_master = MagicMock() + # Simulate no new telemetry + mock_master.recv_match.return_value = None + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = {"BATT_MONITOR": 4.0} + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # Inject a recent cached battery reading + commands_mgr._last_battery_status = (11.1, 2.2) # pylint: disable=protected-access + commands_mgr._last_battery_message_time = time.time() # now-ish # pylint: disable=protected-access + + data, _ = commands_mgr.get_battery_status() + + assert data == (11.1, 2.2) + + def test_get_battery_status_handles_invalid_readings(self) -> None: + """BATTERY_STATUS messages with invalid sentinel values (-1) should be converted to zeros.""" + mock_master = MagicMock() + mock_battery_msg = MagicMock() + mock_battery_msg.voltages = [-1] + mock_battery_msg.current_battery = -1 + mock_master.recv_match.return_value = mock_battery_msg + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = {"BATT_MONITOR": 4.0} + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + battery_data, _ = commands_mgr.get_battery_status() + + assert battery_data is not None + if battery_data: + voltage, current = battery_data + assert voltage == 0.0 + assert current == 0.0 + + def test_motor_test_denied_ack(self) -> None: + """Motor test should fail when FC returns a DENIED acknowledgment.""" + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + mock_ack = MagicMock() + mock_ack.command = mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST + mock_ack.result = mavutil.mavlink.MAV_RESULT_DENIED + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + success, error = commands_mgr.test_motor(1, "A", 1, 10, 2) + + assert success is False + assert "denied" in error.lower() or "failed" in error.lower() + + def test_send_command_unknown_result(self) -> None: + """send_command_and_wait_ack should return an error for unknown result codes.""" + mock_master = MagicMock() + mock_ack = MagicMock() + mock_ack.command = 999 + mock_ack.result = 9999 # unknown result + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + success, error = commands_mgr.send_command_and_wait_ack(command=999, timeout=0.5) + + assert success is False + assert "unknown result" in error.lower() + + def test_send_command_in_progress_then_accepted(self) -> None: + """send_command_and_wait_ack should handle IN_PROGRESS followed by ACCEPTED.""" + mock_master = MagicMock() + + in_progress = MagicMock() + in_progress.command = 555 + in_progress.result = mavutil.mavlink.MAV_RESULT_IN_PROGRESS + in_progress.progress = 10 + + accepted = MagicMock() + accepted.command = 555 + accepted.result = mavutil.mavlink.MAV_RESULT_ACCEPTED + + # First return in-progress, then accepted + mock_master.recv_match.side_effect = [in_progress, accepted] + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + success, error = commands_mgr.send_command_and_wait_ack(command=555, timeout=1.0) + + assert success is True + assert error == "" + + +class TestFlightControllerCommandsAllMotors: + """Test test_all_motors functionality.""" + + def test_user_can_test_all_motors(self) -> None: + """ + User can test all motors simultaneously. + + GIVEN: Connected flight controller ready for motor test + WHEN: User tests all 4 motors at 25% throttle + THEN: Motor commands should be sent for each motor + AND: Method should return success + """ + # Given: Connected FC + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Test all motors + success, error = commands_mgr.test_all_motors(nr_of_motors=4, throttle_percent=25, timeout_seconds=5) + + # Then: Commands should be sent + assert success is True + assert error == "" + assert mock_master.mav.command_long_send.call_count == 4 + + def test_test_all_motors_fails_without_connection(self) -> None: + """ + Testing all motors fails without connection. + + GIVEN: Flight controller with no active connection + WHEN: User attempts to test all motors + THEN: Should return failure + AND: Error message should indicate no connection + """ + # Given: No connection + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When/Then + success, error = commands_mgr.test_all_motors(nr_of_motors=4, throttle_percent=25, timeout_seconds=5) + + assert success is False + assert "no flight controller connection" in error.lower() + + +class TestFlightControllerCommandsSequencedMotors: + """Test test_motors_in_sequence functionality.""" + + def test_user_can_test_motors_in_sequence(self) -> None: + """ + User can test motors in sequence. + + GIVEN: Connected flight controller ready for motor test + WHEN: User tests 4 motors starting from motor 1 at 30% throttle + THEN: Sequential motor test command should be sent with wait for ack + AND: Method should return success + """ + # Given: Connected FC with ACK response + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + mock_ack = MagicMock() + mock_ack.command = mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST + mock_ack.result = mavutil.mavlink.MAV_RESULT_ACCEPTED + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Test motors in sequence + success, error = commands_mgr.test_motors_in_sequence( + start_motor=1, motor_count=4, throttle_percent=30, timeout_seconds=3 + ) + + # Then: Command acknowledged + assert success is True + assert error == "" + + def test_test_motors_in_sequence_fails_without_connection(self) -> None: + """ + Testing motors in sequence fails without connection. + + GIVEN: Flight controller with no active connection + WHEN: User attempts to test motors in sequence + THEN: Should return failure + AND: Error message should indicate no connection + """ + # Given: No connection + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When/Then + success, error = commands_mgr.test_motors_in_sequence( + start_motor=1, motor_count=4, throttle_percent=30, timeout_seconds=3 + ) + + assert success is False + assert "no flight controller connection" in error.lower() + + +class TestFlightControllerCommandsStopMotors: # pylint: disable=too-few-public-methods + """Test stop_all_motors functionality.""" + + def test_user_can_stop_all_motors(self) -> None: + """ + User can stop all motors with emergency stop command. + + GIVEN: Connected flight controller with running motors + WHEN: User executes stop all motors command + THEN: Motor stop command should be sent + AND: Command should be acknowledged + """ + # Given: Connected FC with ACK response + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + mock_ack = MagicMock() + mock_ack.command = mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST + mock_ack.result = mavutil.mavlink.MAV_RESULT_ACCEPTED + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Stop all motors + success, error = commands_mgr.stop_all_motors() + + # Then: Command acknowledged + assert success is True + assert error == "" + + +class TestFlightControllerCommandsWrapperMethods: + """Test wrapper methods that delegate to business logic.""" + + def test_user_can_get_voltage_thresholds(self) -> None: + """ + User can retrieve voltage thresholds for safe motor testing. + + GIVEN: Parameters manager with battery voltage parameters + WHEN: User requests voltage thresholds + THEN: Should return min and max voltage values + AND: Values should be reasonable thresholds + """ + # Given: Mock params manager with typical battery parameters + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = { + "BATT_ARM_VOLT": 9.6, + "MOT_BAT_VOLT_MAX": 12.6, + } + + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Get voltage thresholds + min_volt, max_volt = commands_mgr.get_voltage_thresholds() + + # Then: Should return reasonable thresholds + assert isinstance(min_volt, (int, float)) + assert isinstance(max_volt, (int, float)) + assert min_volt < max_volt + assert min_volt == 9.6 + assert max_volt == 12.6 + + def test_user_can_check_battery_monitoring_enabled(self) -> None: + """ + User can check if battery monitoring is enabled. + + GIVEN: Parameters manager with BATT_MONITOR parameter + WHEN: User checks battery monitoring status + THEN: Should return True if BATT_MONITOR != 0 + """ + # Given: Battery monitoring enabled + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = {"BATT_MONITOR": 4} + + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Check if battery monitoring enabled + enabled = commands_mgr.is_battery_monitoring_enabled() + + # Then: Should be True + assert enabled is True + + def test_battery_monitoring_disabled_returns_false(self) -> None: + """ + Battery monitoring check returns False when disabled. + + GIVEN: Parameters manager with BATT_MONITOR=0 + WHEN: User checks battery monitoring status + THEN: Should return False + """ + # Given: Battery monitoring disabled + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = {"BATT_MONITOR": 0} + + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Check if battery monitoring enabled + enabled = commands_mgr.is_battery_monitoring_enabled() + + # Then: Should be False + assert enabled is False + + def test_user_can_get_frame_info(self) -> None: + """ + User can retrieve frame class and type information. + + GIVEN: Parameters manager with FRAME_CLASS and FRAME_TYPE + WHEN: User requests frame information + THEN: Should return frame class and type as integers + """ + # Given: Mock params manager with frame parameters + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = { + "FRAME_CLASS": 1, # Copter quadrotor + "FRAME_TYPE": 1, # X + } + + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Get frame info + frame_class, frame_type = commands_mgr.get_frame_info() + + # Then: Should return frame class and type + assert frame_class == 1 + assert frame_type == 1 + + +class TestFlightControllerCommandsResultCodes: + """Test command result code handling.""" + + def test_send_command_temporarily_rejected(self) -> None: + """ + send_command_and_wait_ack handles TEMPORARILY_REJECTED result. + + GIVEN: Flight controller that rejects command temporarily + WHEN: User sends command + THEN: Should return failure + AND: Error message should indicate temporary rejection + """ + # Given + mock_master = MagicMock() + mock_ack = MagicMock() + mock_ack.command = 999 + mock_ack.result = mavutil.mavlink.MAV_RESULT_TEMPORARILY_REJECTED + + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When + success, error = commands_mgr.send_command_and_wait_ack(command=999, timeout=0.5) + + # Then + assert success is False + assert "temporarily rejected" in error.lower() + + def test_send_command_unsupported(self) -> None: + """ + send_command_and_wait_ack handles UNSUPPORTED result. + + GIVEN: Flight controller that doesn't support command + WHEN: User sends unsupported command + THEN: Should return failure + AND: Error message should indicate command unsupported + """ + # Given + mock_master = MagicMock() + mock_ack = MagicMock() + mock_ack.command = 999 + mock_ack.result = mavutil.mavlink.MAV_RESULT_UNSUPPORTED + + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When + success, error = commands_mgr.send_command_and_wait_ack(command=999, timeout=0.5) + + # Then + assert success is False + assert "unsupported" in error.lower() + + def test_send_command_failed(self) -> None: + """ + send_command_and_wait_ack handles FAILED result. + + GIVEN: Flight controller that reports command failure + WHEN: User sends command + THEN: Should return failure + AND: Error message should indicate command failed + """ + # Given + mock_master = MagicMock() + mock_ack = MagicMock() + mock_ack.command = 999 + mock_ack.result = mavutil.mavlink.MAV_RESULT_FAILED + + mock_master.recv_match.return_value = mock_ack + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When + success, error = commands_mgr.send_command_and_wait_ack(command=999, timeout=0.5) + + # Then + assert success is False + assert "failed" in error.lower() + + +class TestFlightControllerCommandsBatteryEdgeCases: + """Test battery status edge cases and error handling.""" + + def test_get_battery_status_returns_none_when_battery_monitoring_disabled(self) -> None: + """ + get_battery_status returns None when battery monitoring is disabled. + + GIVEN: Flight controller with BATT_MONITOR=0 + WHEN: User requests battery status + THEN: Should return None + AND: Error message should indicate monitoring disabled + """ + # Given: Battery monitoring disabled + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = {"BATT_MONITOR": 0} + + mock_master = MagicMock() + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr.fc_parameters = {"BATT_MONITOR": 0} + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Get battery status + battery_status, error = commands_mgr.get_battery_status() + + # Then: Should return None + assert battery_status is None + assert "battery monitoring is not enabled" in error.lower() + + def test_get_battery_status_returns_none_when_no_connection(self) -> None: + """ + get_battery_status returns None when no connection available. + + GIVEN: Flight controller with no connection + WHEN: User requests battery status + THEN: Should return None + AND: Error message should indicate no connection + """ + # Given: No connection + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = None + + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Get battery status + battery_status, error = commands_mgr.get_battery_status() + + # Then: Should return None + assert battery_status is None + assert "no flight controller connection" in error.lower() + + def test_get_battery_status_handles_exception_in_telemetry(self) -> None: + """ + get_battery_status handles exceptions during telemetry fetch. + + GIVEN: Flight controller with exception in recv_match + WHEN: User requests battery status + THEN: Should handle exception gracefully + AND: Should return None with error message + """ + # Given: Exception in telemetry + mock_master = MagicMock() + mock_master.recv_match.side_effect = Exception("Connection lost") + + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = {"BATT_MONITOR": 4} + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: Get battery status + battery_status, error = commands_mgr.get_battery_status() + + # Then: Should return None + assert battery_status is None + assert error is not None + + def test_get_battery_status_uses_cache_when_recent(self) -> None: + """ + get_battery_status uses cached data when recent. + + GIVEN: Flight controller with recent battery status cached + WHEN: User requests battery status but telemetry fails + THEN: Should return cached data + AND: Error message should be empty + """ + # Given: Setup with working telemetry first + mock_master = MagicMock() + mock_battery_msg = MagicMock() + mock_battery_msg.voltages = [12000] # 12V in millivolts + mock_battery_msg.current_battery = 1050 # 10.5A in centiamps + mock_master.recv_match.return_value = mock_battery_msg + + mock_params_mgr = Mock() + mock_params_mgr.fc_parameters = {"BATT_MONITOR": 4} + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When: First call gets data successfully + battery_status1, error1 = commands_mgr.get_battery_status() + + # Then: First call succeeds + assert battery_status1 is not None + assert error1 == "" + + # When: Second call (telemetry fails but cache is still fresh) + mock_master.recv_match.side_effect = Exception("Connection lost") + battery_status2, error2 = commands_mgr.get_battery_status() + + # Then: Should use cached data + assert battery_status2 == battery_status1 + assert error2 == "" + + def test_send_command_exception_in_send(self) -> None: + """ + send_command_and_wait_ack handles exception during command send. + + GIVEN: Flight controller connection that raises exception on send + WHEN: User sends command + THEN: Should handle exception + AND: Should return failure with error message + """ + # Given: Exception on command send + mock_master = MagicMock() + mock_master.mav.command_long_send.side_effect = Exception("Serial port error") + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_params_mgr = Mock() + + commands_mgr = FlightControllerCommands(params_manager=mock_params_mgr, connection_manager=mock_conn_mgr) + + # When + success, error = commands_mgr.send_command_and_wait_ack(command=999, timeout=0.5) + + # Then + assert success is False + assert "failed to send command" in error.lower() diff --git a/tests/test_backend_flightcontroller_connection.py b/tests/test_backend_flightcontroller_connection.py new file mode 100755 index 000000000..c3b4a0983 --- /dev/null +++ b/tests/test_backend_flightcontroller_connection.py @@ -0,0 +1,986 @@ +#!/usr/bin/env python3 + +""" +BDD-style tests for backend_flightcontroller_connection.py. + +This file focuses on connection management behavior including port discovery, +connection establishment, heartbeat detection, and error handling. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from unittest.mock import Mock, patch + +import pytest +import serial.tools.list_ports_common + +from ardupilot_methodic_configurator.backend_flightcontroller_connection import ( + DEFAULT_BAUDRATE, + SUPPORTED_BAUDRATES, + FlightControllerConnection, +) +from ardupilot_methodic_configurator.backend_flightcontroller_factory_mavlink import ( + FakeMavlinkConnectionFactory, + SystemMavlinkConnectionFactory, +) +from ardupilot_methodic_configurator.backend_flightcontroller_factory_serial import ( + FakeSerialPortDiscovery, + SystemSerialPortDiscovery, +) +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo + +# pylint: disable=protected-access + + +class TestFlightControllerConnectionServiceInjection: + """Test dependency injection of services into connection manager.""" + + def test_default_services_are_system_implementations(self) -> None: + """ + Default services use system implementations for real hardware. + + GIVEN: FlightControllerConnection created without services + WHEN: Connection initializes + THEN: System serial discovery should be default + AND: System MAVLink factory should be default + """ + # Given/When: Create connection without services + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # Then: System services are defaults + assert isinstance(connection._serial_port_discovery, SystemSerialPortDiscovery) + assert isinstance(connection._mavlink_connection_factory, SystemMavlinkConnectionFactory) + + def test_user_can_inject_fake_serial_discovery(self) -> None: + """ + User can inject fake serial discovery for testing. + + GIVEN: Developer testing without real hardware + WHEN: Injecting FakeSerialPortDiscovery + THEN: Connection should use the fake service + AND: Port discovery should return only fake ports + """ + # Given: Fake serial discovery with test ports + fake_serial = FakeSerialPortDiscovery() + fake_serial.add_port("/dev/ttyUSB0", "Test Controller") + + # When: Inject into connection + connection = FlightControllerConnection( + info=FlightControllerInfo(), + serial_port_discovery=fake_serial, + ) + + # Then: Fake service is used + assert connection._serial_port_discovery is fake_serial + connection.discover_connections() + tuples = connection.get_connection_tuples() + assert any(t[0] == "/dev/ttyUSB0" for t in tuples) + + def test_user_can_inject_fake_mavlink_factory(self) -> None: + """ + User can inject fake MAVLink factory for testing. + + GIVEN: Developer testing without real flight controller + WHEN: Injecting FakeMavlinkConnectionFactory + THEN: Connection should use the fake factory + AND: Factory should create test connections + """ + # Given: Fake MAVLink factory + fake_mavlink = FakeMavlinkConnectionFactory() + + # When: Inject into connection + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=fake_mavlink, + ) + + # Then: Fake factory is used + assert connection._mavlink_connection_factory is fake_mavlink + + def test_user_can_inject_both_fake_services(self) -> None: + """ + User can inject both services for complete test isolation. + + GIVEN: Developer needs full test environment control + WHEN: Injecting both fake services + THEN: Both should be active + AND: Complete workflow should work with fakes + """ + # Given: Both fake services + fake_serial = FakeSerialPortDiscovery() + fake_serial.add_port("/dev/ttyUSB0", "Fake FC") + fake_mavlink = FakeMavlinkConnectionFactory() + + # When: Inject both + connection = FlightControllerConnection( + info=FlightControllerInfo(), + serial_port_discovery=fake_serial, + mavlink_connection_factory=fake_mavlink, + ) + + # Then: Both services are active + assert connection._serial_port_discovery is fake_serial + assert connection._mavlink_connection_factory is fake_mavlink + + +class TestFlightControllerConnectionPortDiscovery: + """Test port discovery functionality for serial and network connections.""" + + def test_user_can_discover_available_serial_ports(self) -> None: + """ + User can discover all available serial ports on the system. + + GIVEN: System with multiple USB serial devices + WHEN: User requests port discovery + THEN: All available serial ports should be listed + AND: Each port should have device path and description + """ + # Given: System with serial ports + mock_port1 = Mock(spec=serial.tools.list_ports_common.ListPortInfo) + mock_port1.device = "/dev/ttyUSB0" + mock_port1.description = "CP2102 USB to UART" + + mock_port2 = Mock(spec=serial.tools.list_ports_common.ListPortInfo) + mock_port2.device = "/dev/ttyACM0" + mock_port2.description = "Pixhawk Flight Controller" + + with patch("serial.tools.list_ports.comports", return_value=[mock_port1, mock_port2]): + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # When: Discover serial ports + ports = connection.get_serial_ports() + + # Then: All ports discovered with details + assert len(ports) == 2 + assert any(p.device == "/dev/ttyUSB0" for p in ports) + assert any(p.device == "/dev/ttyACM0" for p in ports) + assert any("CP2102" in p.description for p in ports) + assert any("Pixhawk" in p.description for p in ports) + + def test_user_discovers_fake_ports_with_injected_service(self) -> None: + """ + User can discover fake ports using injected serial discovery service. + + GIVEN: FakeSerialPortDiscovery with test ports + WHEN: User calls discover_connections + THEN: Only fake ports should appear + AND: Real system ports should not appear + """ + # Given: Fake discovery with specific ports + fake_serial = FakeSerialPortDiscovery() + fake_serial.add_port("/dev/ttyFAKE0", "Fake Port 1") + fake_serial.add_port("/dev/ttyFAKE1", "Fake Port 2") + + connection = FlightControllerConnection( + info=FlightControllerInfo(), + serial_port_discovery=fake_serial, + ) + + # When: Discover connections + connection.discover_connections() + + # Then: Only fake ports present + tuples = connection.get_connection_tuples() + assert any(t[0] == "/dev/ttyFAKE0" for t in tuples) + assert any(t[0] == "/dev/ttyFAKE1" for t in tuples) + + def test_user_can_discover_network_connection_options(self) -> None: + """ + User can see available network connection options for SITL/remote connections. + + GIVEN: Application supporting UDP/TCP connections + WHEN: User requests network ports + THEN: Standard ArduPilot network ports should be listed + AND: Both UDP and TCP options should be available + """ + # Given: Connection manager with network support + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # When: Get network ports + network_ports = connection.get_network_ports() + + # Then: Standard ArduPilot ports available + assert len(network_ports) > 0 + # Check for common SITL ports + assert any("udp:0.0.0.0:14550" in port for port in network_ports) + assert any("tcp:127.0.0.1:5760" in port for port in network_ports) + + def test_user_can_specify_custom_network_ports(self) -> None: + """ + User can specify custom network ports when creating connection. + + GIVEN: User needs different network ports for their setup + WHEN: Creating connection with custom network_ports + THEN: Specified ports should override defaults + AND: Only custom ports should be available + """ + # Given: Custom network ports + custom_ports = ["tcp:192.168.1.1:5760", "udp:10.0.0.1:14550"] + + # When: Create connection with custom ports + connection = FlightControllerConnection( + info=FlightControllerInfo(), + network_ports=custom_ports, + ) + + # Then: Custom ports are used + ports = connection.get_network_ports() + assert ports == custom_ports + + def test_connection_list_includes_add_another_option(self) -> None: + """ + Connection list includes option to add custom connection strings. + + GIVEN: Standard ports discovered + WHEN: User views connection options + THEN: "Add another" option should be present + AND: User can specify custom connection strings + """ + # Given: Connection manager + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # When: Discover connections + connection.discover_connections() + tuples = connection.get_connection_tuples() + + # Then: Add another option present + assert len(tuples) > 0 + assert tuples[-1] == ("Add another", "Add another") + + +class TestFlightControllerConnectionLifecycle: + """Test connection lifecycle management.""" + + def test_user_can_disconnect_cleanly(self) -> None: + """ + User can disconnect cleanly from flight controller. + + GIVEN: Active connection to flight controller + WHEN: User disconnects + THEN: Connection should be closed + AND: Resources should be released + """ + # Given: Connected flight controller + info = FlightControllerInfo() + info.system_id = "42" + info.capabilities["FTP"] = "supported" + connection = FlightControllerConnection(info=info) + mock_master = Mock() + connection.set_master_for_testing(mock_master) + + # When: Disconnect + connection.disconnect() + + # Then: Connection closed + assert connection.master is None + mock_master.close.assert_called_once() + assert info.system_id == "" + assert not info.capabilities + + def test_disconnect_handles_no_connection_gracefully(self) -> None: + """ + Disconnect handles case where no connection exists. + + GIVEN: No active connection + WHEN: User calls disconnect + THEN: Operation should complete without errors + AND: No exceptions should be raised + """ + # Given: No connection + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # When: Disconnect with no connection + # Then: No exception raised + connection.disconnect() + assert connection.master is None + + def test_disconnect_handles_exception_during_close(self) -> None: + """ + Disconnect handles exceptions during close gracefully. + + GIVEN: Connection with mock that raises exception + WHEN: Disconnect is called + THEN: Exception should be suppressed + AND: Master should still be set to None + """ + # Given: Connected with mock that raises + connection = FlightControllerConnection(info=FlightControllerInfo()) + mock_master = Mock() + mock_master.close.side_effect = RuntimeError("Close failed") + connection.set_master_for_testing(mock_master) + + # When: Disconnect (should not raise) + connection.disconnect() + + # Then: Master is None despite exception + assert connection.master is None + + def test_connect_none_resets_cached_info(self) -> None: + """ + Selecting the "none" device clears stale controller metadata. + + GIVEN: Previously populated FlightControllerInfo + WHEN: User selects the special "none" device to skip connecting + THEN: Info should be reset so UI shows blank values + """ + # Given: Cached info with stale values + info = FlightControllerInfo() + info.system_id = "11" + info.capabilities["FTP"] = "supported" + connection = FlightControllerConnection(info=info) + + # When: Connect with device="none" + result = connection.connect(device="none") + + # Then: Info reset and no error reported + assert result == "" + assert info.system_id == "" + assert not info.capabilities + + +class TestFlightControllerConnectionConfiguration: + """Test connection configuration and settings.""" + + def test_supported_baudrates_include_standard_values(self) -> None: + """ + Supported baudrates include all standard ArduPilot values. + + GIVEN: ArduPilot firmware with specific baud rate support + WHEN: User checks supported baud rates + THEN: All standard rates should be available + AND: Common rates like 115200 and 57600 should be included + """ + # Then: Standard baud rates supported + assert "115200" in SUPPORTED_BAUDRATES + assert "57600" in SUPPORTED_BAUDRATES + assert "921600" in SUPPORTED_BAUDRATES + assert len(SUPPORTED_BAUDRATES) >= 10 + + def test_default_baudrate_is_standard_value(self) -> None: + """ + Default baud rate is set to standard USB serial value. + + GIVEN: USB serial connections typically use 115200 + WHEN: User creates connection with defaults + THEN: Baud rate should be 115200 + """ + # Then: Default is standard USB serial rate + assert DEFAULT_BAUDRATE == 115200 + + def test_user_can_specify_custom_baudrate(self) -> None: + """ + User can specify custom baudrate for connection. + + GIVEN: Non-standard baudrate requirement + WHEN: Creating connection with custom baudrate + THEN: Specified baudrate should be used + """ + # When: Create connection with custom baudrate + connection = FlightControllerConnection( + info=FlightControllerInfo(), + baudrate=57600, + ) + + # Then: Custom baudrate is set + assert connection.baudrate == 57600 + + def test_default_baudrate_is_used_when_not_specified(self) -> None: + """ + Default baudrate is used when not explicitly specified. + + GIVEN: User creates connection without specifying baudrate + WHEN: Connection initializes + THEN: Default baudrate should be used + """ + # When: Create without specifying + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # Then: Default is used + assert connection.baudrate == DEFAULT_BAUDRATE + + +class TestFlightControllerConnectionCustomStrings: + """Test custom connection string handling.""" + + def test_user_can_add_custom_connection_string(self) -> None: + """ + User can add custom connection strings for specialized setups. + + GIVEN: Non-standard connection requirement + WHEN: User adds custom connection string + THEN: Connection should be added to available options + AND: Duplicate connections should not be added + """ + # Given: Connection manager + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # When: Add custom connection + result1 = connection.add_connection("udp:192.168.1.100:14550") + result2 = connection.add_connection("udp:192.168.1.100:14550") # Duplicate + + # Then: Custom connection added once + assert result1 is True + assert result2 is False # Duplicate not added + + tuples = connection.get_connection_tuples() + custom_added = any(t[0] == "udp:192.168.1.100:14550" for t in tuples) + assert custom_added is True + + def test_empty_connection_string_rejected(self) -> None: + """ + Empty connection strings are rejected. + + GIVEN: User attempts to add empty connection + WHEN: Add connection is called with empty string + THEN: Connection should not be added + AND: False should be returned + """ + # Given: Connection manager + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # When: Add empty string + result = connection.add_connection("") + + # Then: Not added + assert result is False + + def test_custom_connection_string_deduplication(self) -> None: + """ + Duplicate connection strings are not added. + + GIVEN: Connection already added + WHEN: Same connection is added again + THEN: Duplicate should be rejected + AND: Only one instance should exist + """ + # Given: Connection manager + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # When: Add same connection multiple times + assert connection.add_connection("/dev/ttyUSB0") is True + assert connection.add_connection("/dev/ttyUSB0") is False + + # Then: Only one instance + tuples = connection.get_connection_tuples() + count = sum(1 for t in tuples if t[0] == "/dev/ttyUSB0") + assert count == 1 + + +class TestFlightControllerConnectionInfo: + """Test flight controller information gathering.""" + + def test_connection_info_is_single_source_of_truth(self) -> None: + """ + Connection manager is single source of truth for flight controller info. + + GIVEN: Connection manager with info object + WHEN: Info is accessed + THEN: Same info object should be returned consistently + AND: External modifications should be reflected + """ + # Given: Connection manager + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # When: Get info multiple times + info1 = connection.info + info2 = connection.info + + # Then: Same object returned + assert info1 is info2 + assert isinstance(info1, FlightControllerInfo) + + def test_comport_device_returns_device_string(self) -> None: + """ + Comport device property returns the device string when available. + + GIVEN: Connection with comport set + WHEN: User accesses comport_device + THEN: Device string should be returned + """ + # Given: Connection with comport + connection = FlightControllerConnection(info=FlightControllerInfo()) + mock_comport = Mock() + mock_comport.device = "/dev/ttyACM0" + connection.comport = mock_comport + + # When: Access device string + device = connection.comport_device + + # Then: Correct device returned + assert device == "/dev/ttyACM0" + + def test_comport_device_returns_empty_when_no_connection(self) -> None: + """ + Comport device property returns empty string when no connection. + + GIVEN: Connection without comport + WHEN: User accesses comport_device + THEN: Empty string should be returned + """ + # Given: Connection without comport + connection = FlightControllerConnection(info=FlightControllerInfo()) + + # When: Access device string + device = connection.comport_device + + # Then: Empty string returned + assert device == "" + + def test_connection_persists_comport_across_calls(self) -> None: + """ + Comport is persisted across multiple calls. + + GIVEN: Connection with comport set + WHEN: Accessing comport multiple times + THEN: Same comport should be returned + """ + # Given: Connection with comport + connection = FlightControllerConnection(info=FlightControllerInfo()) + mock_comport = Mock() + connection.comport = mock_comport + + # When: Access multiple times + comport1 = connection.comport + comport2 = connection.comport + + # Then: Same object returned + assert comport1 is comport2 + assert comport1 is mock_comport + + +class TestFlightControllerConnectionFactoryIntegration: + """Test MAVLink factory integration with connection creation.""" + + def test_fake_factory_creates_connections_with_attributes(self) -> None: + """ + Fake MAVLink factory properly sets retries and progress_callback. + + GIVEN: Fake factory injected into connection + WHEN: Creating a connection + THEN: Connection should have retries attribute set + AND: Connection should have progress_callback attribute + """ + # Given: Connection with fake factory + fake_factory = FakeMavlinkConnectionFactory() + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=fake_factory, + ) + + # When: Create connection via factory + def test_callback(current: int, total: int) -> None: # pylint: disable=unused-argument + pass + + test_conn = connection._mavlink_connection_factory.create( + device="/dev/ttyUSB0", + baudrate=115200, + retries=5, + progress_callback=test_callback, + ) + + # Then: Attributes are set + assert test_conn is not None + assert test_conn.retries == 5 + assert test_conn.progress_callback is test_callback + + def test_connection_factory_methods_are_called(self) -> None: + """ + Connection factory methods are properly called with parameters. + + GIVEN: Fake factory for testing + WHEN: Calling factory create method + THEN: Method should be invoked with correct parameters + """ + # Given: Connection with fake factory + fake_factory = FakeMavlinkConnectionFactory() + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=fake_factory, + ) + + # When: Create connection + conn = connection._mavlink_connection_factory.create( + device="/dev/ttyUSB0", + baudrate=57600, + timeout=10.0, + retries=2, + ) + + # Then: Connection created with parameters + assert conn is not None + assert conn.device == "/dev/ttyUSB0" + assert conn.baudrate == 57600 + assert conn.retries == 2 + + +class TestConnectionErrorHandling: + """Test error handling for connection failures and edge cases.""" + + def test_failed_connection_includes_root_cause(self) -> None: + """ + Connection errors surface the underlying exception message for users. + + GIVEN: MAVLink factory raises a ConnectionError with detailed cause + WHEN: User attempts to connect to a specific device + THEN: Returned error message should contain the original cause text + """ + + class ExplodingFactory(SystemMavlinkConnectionFactory): # pylint: disable=too-few-public-methods + """MAVLink factory that always raises ConnectionError.""" + + def create( # type: ignore[override] # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + device: str, + baudrate: int, + timeout: float = 5.0, + retries: int = 3, + progress_callback: object = None, + ) -> object: + _ = (baudrate, timeout, retries, progress_callback) + msg = f"{device}: Permission denied" + raise ConnectionError(msg) + + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=ExplodingFactory(), + ) + + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller_connection.mavutil.SerialPort" + ) as mock_serial_port: + mock_serial = Mock() + mock_serial.device = "/dev/ttyACM0" + mock_serial.description = "Mock" + mock_serial_port.return_value = mock_serial + error = connection.connect(device="/dev/ttyACM0", log_errors=False) + + assert "Permission denied" in error + assert "/dev/ttyACM0" in error + + def test_connection_with_invalid_device_string(self) -> None: + """ + Connection handles empty device strings gracefully. + + GIVEN: Empty device string + WHEN: Creating connection with empty device + THEN: Fake factory should create connection with empty device + AND: Real factory would validate and reject empty device + """ + # Given: Connection with fake services + connection = FlightControllerConnection( + info=FlightControllerInfo(), + serial_port_discovery=FakeSerialPortDiscovery(), + mavlink_connection_factory=FakeMavlinkConnectionFactory(), + ) + + # When: Create with empty device (fake factory accepts anything) + conn = connection._mavlink_connection_factory.create( + device="", + baudrate=115200, + retries=1, + ) + + # Then: Fake factory creates connection even with empty device + # (Real factory would validate and reject this) + assert conn is not None + assert conn.device == "" + + def test_connection_with_unsupported_baudrate(self) -> None: + """ + Connection validates baudrate is in supported list. + + GIVEN: Unsupported baudrate value (e.g., 999999) + WHEN: Attempting connection with unsupported baudrate + THEN: Should reject unsupported baudrates + AND: Error message should list supported rates + """ + # Given: Unsupported baudrate + unsupported_baudrate = 999999 + + # When: Try to create with unsupported rate + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=FakeMavlinkConnectionFactory(), + ) + + # Then: Should handle gracefully (either via mock or validation) + test_conn = connection._mavlink_connection_factory.create( + device="/dev/ttyUSB0", + baudrate=unsupported_baudrate, + retries=1, + ) + # Fake factory accepts anything, but real factory would validate + assert test_conn is not None + + def test_connection_discovery_with_no_serial_ports_available(self) -> None: + """ + Connection discovery returns network ports when no serial ports available. + + GIVEN: No serial ports connected to system + WHEN: Discovering connections + THEN: Should return default network ports + AND: get_connection_tuples should include network ports + """ + # Given: Fake serial discovery with no ports + fake_serial = FakeSerialPortDiscovery() + connection = FlightControllerConnection( + info=FlightControllerInfo(), + serial_port_discovery=fake_serial, + ) + + # When: Discover connections + connection.discover_connections() + + # Then: Should return network ports even without serial ports + tuples = connection.get_connection_tuples() + assert isinstance(tuples, list) + # Default network ports are always included + assert len(tuples) > 0 + # Should include TCP network port + assert any("tcp" in str(t[0]).lower() for t in tuples) + + def test_connection_retries_parameter_validation(self) -> None: + """ + Connection validates retries parameter is positive. + + GIVEN: Various retry values (0, negative, positive) + WHEN: Creating connection with different retry counts + THEN: Should accept positive integers + AND: Should handle edge cases (0, negative) appropriately + """ + # Given: Connection factory + fake_factory = FakeMavlinkConnectionFactory() + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=fake_factory, + ) + + # When: Create with valid positive retries + conn_positive = connection._mavlink_connection_factory.create( + device="/dev/ttyUSB0", + baudrate=115200, + retries=5, + ) + assert conn_positive is not None + assert conn_positive.retries == 5 + + # When: Create with zero retries (edge case) + conn_zero = connection._mavlink_connection_factory.create( + device="/dev/ttyUSB0", + baudrate=115200, + retries=0, + ) + assert conn_zero is not None + assert conn_zero.retries == 0 + + def test_connection_timeout_parameter_validation(self) -> None: + """ + Connection validates timeout parameter is positive. + + GIVEN: Various timeout values + WHEN: Creating connection with different timeouts + THEN: Should accept positive timeout values + AND: Should handle None as default + """ + # Given: Connection factory + fake_factory = FakeMavlinkConnectionFactory() + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=fake_factory, + ) + + # When: Create with valid timeout + conn = connection._mavlink_connection_factory.create( + device="/dev/ttyUSB0", + baudrate=115200, + timeout=5.0, + ) + assert conn is not None + + def test_connection_with_progress_callback_none(self) -> None: + """ + Connection handles missing progress callback gracefully. + + GIVEN: Connection without progress callback + WHEN: Creating connection + THEN: Should work without callback + AND: Connection should be created successfully + """ + # Given: No callback provided + fake_factory = FakeMavlinkConnectionFactory() + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=fake_factory, + ) + + # When: Create connection without callback + conn = connection._mavlink_connection_factory.create( + device="/dev/ttyUSB0", + baudrate=115200, + progress_callback=None, + ) + + # Then: Connection created successfully + assert conn is not None + assert conn.progress_callback is None + + +class TestConnectionStateManagement: + """Test connection state management and lifecycle.""" + + def test_connection_info_is_populated_after_connection(self) -> None: + """ + Connection info object is populated with flight controller details. + + GIVEN: FlightControllerConnection with info object + WHEN: Connection initializes + THEN: Info object should be accessible and updateable + AND: Should maintain state across operations + """ + # Given: Connection with info object + info = FlightControllerInfo() + connection = FlightControllerConnection( + info=info, + mavlink_connection_factory=FakeMavlinkConnectionFactory(), + ) + + # When: Access info + # Then: Info should be available + assert connection.info is not None + assert connection.info is info + + def test_connection_master_attribute_starts_none(self) -> None: + """ + Master connection attribute starts as None until connected. + + GIVEN: New FlightControllerConnection + WHEN: Created but not yet connected + THEN: Master should be None initially + AND: Should be set only after successful connection + """ + # Given: New connection + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=FakeMavlinkConnectionFactory(), + ) + + # Then: Master should be None initially + assert connection.master is None + + def test_connection_multiple_discover_cycles(self) -> None: + """ + Connection can be discovered multiple times without issues. + + GIVEN: FlightControllerConnection with services + WHEN: Calling discover_connections multiple times + THEN: Should not accumulate duplicate ports + AND: Should handle repeated discovery gracefully + """ + # Given: Connection with fake discovery + fake_serial = FakeSerialPortDiscovery() + fake_serial.add_port("/dev/ttyUSB0", "Test Controller") + connection = FlightControllerConnection( + info=FlightControllerInfo(), + serial_port_discovery=fake_serial, + ) + + # When: Discover multiple times + connection.discover_connections() + tuples_1 = connection.get_connection_tuples() + connection.discover_connections() + tuples_2 = connection.get_connection_tuples() + + # Then: Should get consistent results + assert len(tuples_1) > 0 + assert len(tuples_2) > 0 + + def test_connection_get_connection_tuples_format(self) -> None: + """ + Connection tuples have correct format (device, description). + + GIVEN: Connection with serial ports configured + WHEN: Getting connection tuples + THEN: Each tuple should have exactly 2 elements + AND: First element should be device string, second description + """ + # Given: Connection with ports + connection = FlightControllerConnection( + info=FlightControllerInfo(), + serial_port_discovery=FakeSerialPortDiscovery(), + ) + + # When: Discover and get tuples + connection.discover_connections() + tuples = connection.get_connection_tuples() + + # Then: All tuples should be properly formatted + for device, description in tuples: + assert isinstance(device, str) + assert isinstance(description, str) + assert len(device) > 0 # Device string should not be empty + # Description can be empty for network ports + + def test_connection_baudrate_configuration(self) -> None: + """ + Connection stores and retrieves baudrate configuration. + + GIVEN: FlightControllerConnection created with custom baudrate + WHEN: Accessing connection properties + THEN: Baudrate should match configured value + AND: Should support standard baudrates + """ + # Given: Connection with custom baudrate + custom_baudrate = 57600 + connection = FlightControllerConnection( + info=FlightControllerInfo(), + baudrate=custom_baudrate, + mavlink_connection_factory=FakeMavlinkConnectionFactory(), + ) + + # Then: Baudrate should be stored + assert connection._baudrate == custom_baudrate + + def test_connection_network_ports_override(self) -> None: + """ + Connection supports custom network port configuration. + + GIVEN: Custom network ports list provided + WHEN: Creating connection with custom ports + THEN: Custom ports should override defaults + AND: Ports should be accessible for discovery + """ + # Given: Custom network ports + custom_ports = ["tcp:192.168.1.100:5760", "udp:192.168.1.100:14550"] + connection = FlightControllerConnection( + info=FlightControllerInfo(), + network_ports=custom_ports, + ) + + # Then: Custom ports should be set + assert connection._network_ports == custom_ports + + def test_connection_comport_attribute_lifecycle(self) -> None: + """ + Connection comport attribute lifecycle management. + + GIVEN: FlightControllerConnection + WHEN: Checking comport status + THEN: Should start as None + AND: Should be updateable for connection tracking + """ + # Given: New connection + connection = FlightControllerConnection( + info=FlightControllerInfo(), + mavlink_connection_factory=FakeMavlinkConnectionFactory(), + ) + + # Then: Comport should be None initially + assert connection.comport is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_backend_flightcontroller_factory_mavftp.py b/tests/test_backend_flightcontroller_factory_mavftp.py new file mode 100755 index 000000000..598ce41d7 --- /dev/null +++ b/tests/test_backend_flightcontroller_factory_mavftp.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 + +""" +BDD-style tests for backend_flightcontroller_factory_mavftp.py. + +This file focuses on MAVFTP factory function behavior. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from unittest.mock import MagicMock + +import pytest + +from ardupilot_methodic_configurator.backend_flightcontroller_factory_mavftp import ( + create_mavftp, + create_mavftp_safe, +) + + +class TestCreateMavftpFactory: + """Test create_mavftp factory function.""" + + def test_user_can_create_mavftp_with_valid_connection(self) -> None: + """ + User can create MAVFTP instance with valid connection. + + GIVEN: A valid MAVLink connection with target system and component + WHEN: User calls create_mavftp with the connection + THEN: MAVFTP instance should be created successfully + AND: MAVFTP should be initialized with correct target parameters + """ + # Given: Valid MAVLink connection + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + # When: Create MAVFTP + mavftp = create_mavftp(mock_master) + + # Then: MAVFTP should be created + assert mavftp is not None + + def test_create_mavftp_raises_runtime_error_when_no_connection(self) -> None: + """ + create_mavftp raises RuntimeError when connection is None. + + GIVEN: No connection available (None) + WHEN: User calls create_mavftp with None + THEN: RuntimeError should be raised + AND: Error message should indicate no MAVLink connection + """ + # When/Then: Should raise RuntimeError + with pytest.raises(RuntimeError, match="No MAVLink connection available for MAVFTP"): + create_mavftp(None) + + def test_create_mavftp_passes_target_system_to_mavftp(self) -> None: + """ + create_mavftp passes target_system to MAVFTP initialization. + + GIVEN: A MAVLink connection with specific target_system value + WHEN: User creates MAVFTP + THEN: MAVFTP should be initialized with correct target_system + """ + # Given: Connection with specific target system + mock_master = MagicMock() + mock_master.target_system = 42 + mock_master.target_component = 1 + + # When: Create MAVFTP + mavftp = create_mavftp(mock_master) + + # Then: MAVFTP created successfully (target_system passed internally) + assert mavftp is not None + + def test_create_mavftp_passes_target_component_to_mavftp(self) -> None: + """ + create_mavftp passes target_component to MAVFTP initialization. + + GIVEN: A MAVLink connection with specific target_component value + WHEN: User creates MAVFTP + THEN: MAVFTP should be initialized with correct target_component + """ + # Given: Connection with specific target component + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 191 # MAV_COMP_ID_AUTOPILOT + + # When: Create MAVFTP + mavftp = create_mavftp(mock_master) + + # Then: MAVFTP created successfully (target_component passed internally) + assert mavftp is not None + + +class TestCreateMavftpSafeFactory: + """Test create_mavftp_safe factory function with safe error handling.""" + + def test_user_can_create_mavftp_safe_with_valid_connection(self) -> None: + """ + User can create MAVFTP instance safely with valid connection. + + GIVEN: A valid MAVLink connection with target system and component + WHEN: User calls create_mavftp_safe with the connection + THEN: MAVFTP instance should be created successfully + AND: Return value should not be None + """ + # Given: Valid MAVLink connection + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + # When: Create MAVFTP safely + mavftp = create_mavftp_safe(mock_master) + + # Then: MAVFTP should be created + assert mavftp is not None + + def test_create_mavftp_safe_returns_none_when_no_connection(self) -> None: + """ + create_mavftp_safe returns None instead of raising when connection is None. + + GIVEN: No connection available (None) + WHEN: User calls create_mavftp_safe with None + THEN: Should return None instead of raising exception + AND: No error should occur + """ + # When: Call create_mavftp_safe with None + mavftp = create_mavftp_safe(None) + + # Then: Should return None gracefully + assert mavftp is None + + def test_create_mavftp_safe_returns_none_when_mavftp_unavailable(self) -> None: + """ + create_mavftp_safe returns None when MAVFTP module is unavailable. + + GIVEN: A MAVLink connection but MAVFTP class is None + WHEN: User calls create_mavftp_safe + THEN: Should return None instead of raising exception + AND: No error should occur even if MAVFTP is unavailable + """ + # Note: This test validates the safety check for MAVFTP availability + # In normal operation, MAVFTP will be imported, but the function has + # defensive checks for when it might not be available + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + # When: Create MAVFTP safely (MAVFTP is normally available) + mavftp = create_mavftp_safe(mock_master) + + # Then: Should create successfully (MAVFTP is available in normal case) + assert mavftp is not None + + def test_create_mavftp_safe_passes_target_system_to_mavftp(self) -> None: + """ + create_mavftp_safe passes target_system to MAVFTP initialization. + + GIVEN: A MAVLink connection with specific target_system value + WHEN: User creates MAVFTP safely + THEN: MAVFTP should be initialized with correct target_system + """ + # Given: Connection with specific target system + mock_master = MagicMock() + mock_master.target_system = 99 + mock_master.target_component = 1 + + # When: Create MAVFTP safely + mavftp = create_mavftp_safe(mock_master) + + # Then: MAVFTP created successfully + assert mavftp is not None + + def test_create_mavftp_safe_passes_target_component_to_mavftp(self) -> None: + """ + create_mavftp_safe passes target_component to MAVFTP initialization. + + GIVEN: A MAVLink connection with specific target_component value + WHEN: User creates MAVFTP safely + THEN: MAVFTP should be initialized with correct target_component + """ + # Given: Connection with specific target component + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 50 + + # When: Create MAVFTP safely + mavftp = create_mavftp_safe(mock_master) + + # Then: MAVFTP created successfully + assert mavftp is not None + + +class TestCreateMavftpErrorHandling: + """Test error handling in MAVFTP factory functions.""" + + def test_create_mavftp_error_message_is_descriptive(self) -> None: + """ + create_mavftp error message clearly describes the issue. + + GIVEN: No connection available + WHEN: User calls create_mavftp with None + THEN: RuntimeError should indicate MAVFTP needs connection + AND: Message should be clear and actionable + """ + # When/Then: Should raise with clear message + with pytest.raises(RuntimeError) as exc_info: + create_mavftp(None) + + error_msg = str(exc_info.value) + assert "MAVFTP" in error_msg + assert "connection" in error_msg.lower() + + def test_create_mavftp_safe_handles_none_gracefully(self) -> None: + """ + create_mavftp_safe handles None connection gracefully without logging errors. + + GIVEN: No connection available + WHEN: User calls create_mavftp_safe with None + THEN: Should return None without raising + AND: Call should be idempotent (safe to call multiple times) + """ + # When: Call multiple times + result1 = create_mavftp_safe(None) + result2 = create_mavftp_safe(None) + result3 = create_mavftp_safe(None) + + # Then: All should return None consistently + assert result1 is None + assert result2 is None + assert result3 is None + + +class TestCreateMavftpEdgeCases: + """Test edge cases in MAVFTP factory functions.""" + + def test_create_mavftp_with_zero_target_system(self) -> None: + """ + create_mavftp works with target_system=0. + + GIVEN: A MAVLink connection with target_system=0 + WHEN: User creates MAVFTP + THEN: MAVFTP should be created (0 is valid, though unusual) + """ + # Given: Connection with target_system = 0 + mock_master = MagicMock() + mock_master.target_system = 0 + mock_master.target_component = 1 + + # When: Create MAVFTP + mavftp = create_mavftp(mock_master) + + # Then: MAVFTP should be created + assert mavftp is not None + + def test_create_mavftp_with_max_target_ids(self) -> None: + """ + create_mavftp works with maximum target system/component IDs. + + GIVEN: A MAVLink connection with maximum valid IDs + WHEN: User creates MAVFTP + THEN: MAVFTP should be created successfully + """ + # Given: Connection with maximum IDs + mock_master = MagicMock() + mock_master.target_system = 255 # Max system ID + mock_master.target_component = 255 # Max component ID + + # When: Create MAVFTP + mavftp = create_mavftp(mock_master) + + # Then: MAVFTP should be created + assert mavftp is not None + + def test_create_mavftp_safe_returns_none_not_false(self) -> None: + """ + create_mavftp_safe returns None specifically, not False or empty. + + GIVEN: No connection available + WHEN: User calls create_mavftp_safe + THEN: Should return None specifically (not False, not empty string) + AND: Type should be correct for optional checks + """ + # When: Call with None + result = create_mavftp_safe(None) + + # Then: Should be None specifically + assert result is None + assert result is not False + assert result != "" + + def test_create_mavftp_with_connection_attributes(self) -> None: + """ + create_mavftp correctly accesses connection attributes. + + GIVEN: A MAVLink connection with specific attributes + WHEN: User creates MAVFTP + THEN: Factory should read target_system and target_component attributes + AND: No AttributeError should occur + """ + # Given: Connection with required attributes (MagicMock includes mav attribute) + mock_master = MagicMock() + mock_master.target_system = 10 + mock_master.target_component = 20 + + # When: Create MAVFTP + try: + mavftp = create_mavftp(mock_master) + # Then: Should succeed + assert mavftp is not None + except AttributeError as e: + pytest.fail(f"Factory should access target_system and target_component attributes: {e}") diff --git a/tests/test_backend_flightcontroller_factory_mavlink.py b/tests/test_backend_flightcontroller_factory_mavlink.py new file mode 100755 index 000000000..1c3f27615 --- /dev/null +++ b/tests/test_backend_flightcontroller_factory_mavlink.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 + +""" +BDD-style tests for dependency injection services in backend_flightcontroller_factory_mavlink.py. + +These tests verify the injectable services for testability, demonstrating how to use fake +implementations in production code for better test isolation and no external dependencies. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from unittest import mock + +import pytest + +from ardupilot_methodic_configurator.backend_flightcontroller_connection import ( + FlightControllerConnection, +) +from ardupilot_methodic_configurator.backend_flightcontroller_factory_mavlink import ( + FakeMavlinkConnectionFactory, + SystemMavlinkConnectionFactory, +) +from ardupilot_methodic_configurator.data_model_flightcontroller_info import ( + FlightControllerInfo, +) + +# pylint: disable=protected-access + + +class TestMavlinkConnectionFactoryService: + """Test MAVLink connection factory service abstraction.""" + + def test_system_mavlink_factory_defaults_when_none_provided(self) -> None: + """ + System MAVLink factory is used when no factory service provided. + + GIVEN: FlightControllerConnection without explicit factory + WHEN: Connection initializes + THEN: SystemMavlinkConnectionFactory should be the default + """ + # Given: Create connection without factory + info = FlightControllerInfo() + connection = FlightControllerConnection(info=info) + + # Then: Default system factory is used + assert isinstance(connection._mavlink_connection_factory, SystemMavlinkConnectionFactory) + + def test_fake_mavlink_factory_can_be_injected(self) -> None: + """ + Custom MAVLink factory service can be injected for testing. + + GIVEN: Developer wants to test without real hardware + WHEN: Injecting FakeMavlinkConnectionFactory + THEN: Connection should use the injected fake factory + """ + # Given: Create fake factory + fake_mavlink = FakeMavlinkConnectionFactory() + + # When: Inject into connection + info = FlightControllerInfo() + connection = FlightControllerConnection( + info=info, + mavlink_connection_factory=fake_mavlink, + ) + + # Then: Injected factory is used + assert connection._mavlink_connection_factory is fake_mavlink + + def test_fake_mavlink_factory_creates_connections_with_attributes(self) -> None: + """ + Fake factory creates connections with retries and progress_callback attributes. + + GIVEN: Fake MAVLink factory + WHEN: Creating connection with retries and callback + THEN: Connection should have these attributes set + """ + # Given: Fake factory + fake_factory = FakeMavlinkConnectionFactory() + + # When: Create connection with attributes + def test_callback(current: int, total: int) -> None: # pylint: disable=unused-argument + pass + + conn = fake_factory.create( + device="/dev/ttyUSB0", + baudrate=115200, + timeout=5.0, + retries=3, + progress_callback=test_callback, + ) + + # Then: Connection has attributes set + assert conn is not None + assert conn.retries == 3 + assert conn.progress_callback is test_callback + + def test_fake_mavlink_connection_attributes(self) -> None: + """ + Fake MAVLink connection has all expected attributes and methods. + + GIVEN: Fake connection created by factory + WHEN: Accessing attributes + THEN: All attributes should be present and initialized + """ + # Given: Fake factory and connection + fake_factory = FakeMavlinkConnectionFactory() + conn = fake_factory.create(device="/dev/ttyUSB0", baudrate=115200) + + # Then: All attributes present + assert conn is not None + assert conn.device == "/dev/ttyUSB0" + assert conn.baudrate == 115200 + assert conn.connected is True + assert hasattr(conn, "retries") + assert hasattr(conn, "progress_callback") + + def test_fake_mavlink_connection_message_queueing(self) -> None: + """ + Fake MAVLink connection supports message queueing for testing. + + GIVEN: Fake connection + WHEN: Queuing and receiving messages + THEN: Messages should be FIFO ordered + """ + # Given: Fake connection with messages + fake_factory = FakeMavlinkConnectionFactory() + conn = fake_factory.create(device="/dev/ttyUSB0", baudrate=115200) + + # When: Queue messages + assert conn is not None + conn.add_message("message_1") + conn.add_message("message_2") + conn.add_message("message_3") + + # Then: Messages received in FIFO order + assert conn.recv_match(blocking=False) == "message_1" + assert conn.recv_match(blocking=False) == "message_2" + assert conn.recv_match(blocking=False) == "message_3" + assert conn.recv_match(blocking=False) is None # Empty queue + + def test_fake_mavlink_connection_message_clearing(self) -> None: + """ + Fake MAVLink connection supports clearing message queue. + + GIVEN: Fake connection with queued messages + WHEN: Clearing messages + THEN: Queue should be empty + """ + # Given: Fake connection with messages + fake_factory = FakeMavlinkConnectionFactory() + conn = fake_factory.create(device="/dev/ttyUSB0", baudrate=115200) + assert conn is not None + conn.add_message("msg1") + conn.add_message("msg2") + + # When: Clear messages + conn.clear_messages() + + # Then: Queue is empty + assert conn.recv_match(blocking=False) is None + + def test_fake_mavlink_connection_close_method(self) -> None: + """ + Fake MAVLink connection can be closed. + + GIVEN: Fake connection + WHEN: Closing connection + THEN: Connected flag should be False + """ + # Given: Fake connection + fake_factory = FakeMavlinkConnectionFactory() + conn = fake_factory.create(device="/dev/ttyUSB0", baudrate=115200) + assert conn is not None + assert conn.connected is True + + # When: Close connection + conn.close() + + # Then: Connected is False + assert conn.connected is False + + def test_fake_mavlink_connection_send_method(self) -> None: + """ + Fake MAVLink connection mav_send method accepts messages. + + GIVEN: Fake connection + WHEN: Sending message via mav_send + THEN: Method should accept and handle message + """ + # Given: Fake connection + fake_factory = FakeMavlinkConnectionFactory() + conn = fake_factory.create(device="/dev/ttyUSB0", baudrate=115200) + + # When/Then: Send should not raise (no-op for fake) + assert conn is not None + conn.mav_send("test_message") # Should not raise + + def test_fake_mavlink_factory_get_connection_not_stored(self) -> None: + """ + FakeMavlinkConnectionFactory.get_connection returns None for unstored device. + + GIVEN: Fake factory + WHEN: Getting connection for device that was never created + THEN: Should return None + """ + # Given: Fake factory (connections not explicitly stored in _connections dict) + fake_factory = FakeMavlinkConnectionFactory() + + # When: Get connection that doesn't exist + result = fake_factory.get_connection("/dev/ttyUSB0") + + # Then: Returns None + assert result is None + + def test_system_mavlink_factory_preserves_oserror(self) -> None: + """ + SystemMavlinkConnectionFactory surfaces OSError causes. + + GIVEN: mavutil.mavlink_connection raises OSError (device not found) + WHEN: Creating connection + THEN: ConnectionError is raised with the OSError details + """ + # Given: Factory with mocked mavutil + factory = SystemMavlinkConnectionFactory() + + with mock.patch( + "ardupilot_methodic_configurator.backend_flightcontroller_factory_mavlink.mavutil.mavlink_connection" + ) as mock_mavlink: + mock_mavlink.side_effect = OSError("Device not found") + + # When/Then: Create connection and get ConnectionError containing root cause + with pytest.raises(ConnectionError, match="Device not found"): + factory.create( + device="/dev/invalid", + baudrate=115200, + ) + mock_mavlink.assert_called_once() + + def test_system_mavlink_factory_preserves_timeouterror(self) -> None: + """ + SystemMavlinkConnectionFactory surfaces TimeoutError causes. + + GIVEN: mavutil.mavlink_connection raises TimeoutError + WHEN: Creating connection + THEN: ConnectionError is raised with the TimeoutError details + """ + # Given: Factory with mocked mavutil + factory = SystemMavlinkConnectionFactory() + + with mock.patch( + "ardupilot_methodic_configurator.backend_flightcontroller_factory_mavlink.mavutil.mavlink_connection" + ) as mock_mavlink: + mock_mavlink.side_effect = TimeoutError("Connection timeout") + + # When/Then: Create connection and get ConnectionError containing root cause + with pytest.raises(ConnectionError, match="Connection timeout"): + factory.create( + device="/dev/ttyUSB0", + baudrate=115200, + timeout=0.1, + ) + mock_mavlink.assert_called_once() + + def test_system_mavlink_factory_preserves_valueerror(self) -> None: + """ + SystemMavlinkConnectionFactory surfaces ValueError causes. + + GIVEN: mavutil.mavlink_connection raises ValueError (invalid parameters) + WHEN: Creating connection + THEN: ConnectionError is raised with the ValueError details + """ + # Given: Factory with mocked mavutil + factory = SystemMavlinkConnectionFactory() + + with mock.patch( + "ardupilot_methodic_configurator.backend_flightcontroller_factory_mavlink.mavutil.mavlink_connection" + ) as mock_mavlink: + mock_mavlink.side_effect = ValueError("Invalid baudrate") + + # When/Then: Create connection and get ConnectionError containing root cause + with pytest.raises(ConnectionError, match="Invalid baudrate"): + factory.create( + device="/dev/ttyUSB0", + baudrate=99999, # Invalid baudrate + ) + mock_mavlink.assert_called_once() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_backend_flightcontroller_factory_serial.py b/tests/test_backend_flightcontroller_factory_serial.py new file mode 100755 index 000000000..d3bb44d68 --- /dev/null +++ b/tests/test_backend_flightcontroller_factory_serial.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 + +""" +BDD-style tests for dependency injection services SerialPortDiscovery. + +These tests verify the injectable services for testability, demonstrating how to use fake +implementations in production code for better test isolation and no external dependencies. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from ardupilot_methodic_configurator.backend_flightcontroller_connection import ( + FlightControllerConnection, +) +from ardupilot_methodic_configurator.backend_flightcontroller_factory_serial import ( + FakeSerialPortDiscovery, + SystemSerialPortDiscovery, +) +from ardupilot_methodic_configurator.data_model_flightcontroller_info import ( + FlightControllerInfo, +) + +# pylint: disable=protected-access + + +class TestSerialPortDiscoveryService: + """Test serial port discovery service abstraction.""" + + def test_system_serial_discovery_defaults_when_none_provided(self) -> None: + """ + System serial discovery is used when no discovery service provided. + + GIVEN: FlightControllerConnection without explicit serial discovery + WHEN: Connection initializes + THEN: SystemSerialPortDiscovery should be the default + """ + # Given: Create connection without service + info = FlightControllerInfo() + connection = FlightControllerConnection(info=info) + + # Then: Default system service is used + assert isinstance(connection._serial_port_discovery, SystemSerialPortDiscovery) + + def test_fake_serial_discovery_can_be_injected(self) -> None: + """ + Custom serial discovery service can be injected for testing. + + GIVEN: Developer wants to test without real hardware + WHEN: Injecting FakeSerialPortDiscovery + THEN: Connection should use the injected fake service + """ + # Given: Create fake discovery service + fake_serial = FakeSerialPortDiscovery() + + # When: Inject into connection + info = FlightControllerInfo() + connection = FlightControllerConnection( + info=info, + serial_port_discovery=fake_serial, + ) + + # Then: Injected service is used + assert connection._serial_port_discovery is fake_serial + + def test_user_discovers_fake_serial_ports(self) -> None: + """ + User can discover ports from fake serial discovery. + + GIVEN: Fake serial discovery with test ports + WHEN: User calls discover_connections + THEN: Fake ports should appear in available connections + """ + # Given: Fake discovery with ports + fake_serial = FakeSerialPortDiscovery() + fake_serial.add_port("/dev/ttyUSB0", "Test Flight Controller") + fake_serial.add_port("/dev/ttyUSB1", "Another Controller") + + info = FlightControllerInfo() + connection = FlightControllerConnection( + info=info, + serial_port_discovery=fake_serial, + ) + + # When: Discover connections + connection.discover_connections() + + # Then: Fake ports should be discoverable + connection_devices = [conn[0] for conn in connection._connection_tuples] + assert "/dev/ttyUSB0" in connection_devices + assert "/dev/ttyUSB1" in connection_devices + assert any("Test Flight Controller" in conn[1] for conn in connection._connection_tuples) + + def test_fake_serial_discovery_supports_port_management(self) -> None: + """ + Fake serial discovery supports adding and clearing ports between tests. + + GIVEN: Fake serial discovery service + WHEN: Adding and clearing ports + THEN: Port list should be properly managed + """ + # Given: Empty fake discovery + fake_serial = FakeSerialPortDiscovery() + assert len(fake_serial.get_available_ports()) == 0 + + # When: Add ports + fake_serial.add_port("/dev/ttyUSB0", "Port 1") + fake_serial.add_port("/dev/ttyUSB1", "Port 2") + + # Then: Ports are added + assert len(fake_serial.get_available_ports()) == 2 + + # When: Clear ports + fake_serial.clear_ports() + + # Then: Ports are cleared + assert len(fake_serial.get_available_ports()) == 0 + + def test_fake_serial_discovery_returns_port_description_when_found(self) -> None: + """ + Fake serial discovery returns correct description when port is found. + + GIVEN: Fake discovery with a known port + WHEN: Getting description for that port + THEN: Should return the configured description + """ + # Given: Fake discovery with a port + fake_serial = FakeSerialPortDiscovery() + fake_serial.add_port("/dev/ttyUSB0", "My Test Controller", "MyManufacturer") + + # When: Get description for known port + description = fake_serial.get_port_description("/dev/ttyUSB0") + + # Then: Should return configured description + assert description == "My Test Controller" + + def test_fake_serial_discovery_returns_default_description_when_not_found(self) -> None: + """ + Fake serial discovery returns default description when port not found. + + GIVEN: Fake discovery without specific port + WHEN: Getting description for unknown port + THEN: Should return default description with device name + """ + # Given: Empty fake discovery + fake_serial = FakeSerialPortDiscovery() + + # When: Get description for unknown port + description = fake_serial.get_port_description("/dev/unknown") + + # Then: Should return default description + assert "unknown" in description + assert "Test Port" in description + + def test_system_serial_discovery_returns_available_ports(self) -> None: + """ + System serial discovery returns list of available ports. + + GIVEN: System serial discovery service + WHEN: Requesting available ports + THEN: Should return a list (may be empty in test environment) + """ + # Given: System discovery + system_serial = SystemSerialPortDiscovery() + + # When: Get available ports + ports = system_serial.get_available_ports() + + # Then: Should return a list + assert isinstance(ports, list) + + def test_system_serial_discovery_returns_port_description(self) -> None: + """ + System serial discovery returns port description or device name as fallback. + + GIVEN: System serial discovery service + WHEN: Getting description for a port (real or fake) + THEN: Should return string description or device as fallback + """ + # Given: System discovery + system_serial = SystemSerialPortDiscovery() + + # When: Get description for a port that probably doesn't exist + description = system_serial.get_port_description("/dev/nonexistent") + + # Then: Should return the device name as fallback + assert isinstance(description, str) + assert description == "/dev/nonexistent" # Falls back to device name + + def test_system_serial_discovery_finds_port_description_when_available(self) -> None: + """ + System serial discovery finds and returns port description when port exists. + + GIVEN: System serial discovery with mocked available ports + WHEN: Getting description for an available port + THEN: Should return the port's description + """ + # Given: Mock a serial port + mock_port = MagicMock() + mock_port.device = "/dev/ttyACM0" + mock_port.description = "Arduino Uno" + + # When: System discovery with mocked comports + with patch("serial.tools.list_ports.comports", return_value=[mock_port]): + system_serial = SystemSerialPortDiscovery() + description = system_serial.get_port_description("/dev/ttyACM0") + + # Then: Should return the mocked description + assert description == "Arduino Uno" + + def test_system_serial_discovery_searches_multiple_ports(self) -> None: + """ + System serial discovery searches through multiple ports to find matching device. + + GIVEN: System serial discovery with multiple mocked ports + WHEN: Getting description for a specific port (not first in list) + THEN: Should find and return the correct port's description + """ + # Given: Mock multiple serial ports + mock_port1 = MagicMock() + mock_port1.device = "/dev/ttyUSB0" + mock_port1.description = "USB Serial" + + mock_port2 = MagicMock() + mock_port2.device = "/dev/ttyACM0" + mock_port2.description = "Arduino Uno" + + mock_port3 = MagicMock() + mock_port3.device = "/dev/ttyACM1" + mock_port3.description = "Arduino Mega" + + # When: System discovery with multiple mocked comports + with patch( + "serial.tools.list_ports.comports", + return_value=[mock_port1, mock_port2, mock_port3], + ): + system_serial = SystemSerialPortDiscovery() + # Find the middle port + description = system_serial.get_port_description("/dev/ttyACM0") + + # Then: Should find the correct port even when not first + assert description == "Arduino Uno" + + def test_system_serial_discovery_lists_multiple_ports(self) -> None: + """ + System serial discovery returns all available ports from comports. + + GIVEN: System serial discovery with mocked multiple ports + WHEN: Getting available ports + THEN: Should return list of all mocked ports + """ + # Given: Mock multiple serial ports + mock_port1 = MagicMock() + mock_port1.device = "/dev/ttyUSB0" + + mock_port2 = MagicMock() + mock_port2.device = "/dev/ttyACM0" + + # When: System discovery with multiple mocked comports + with patch( + "serial.tools.list_ports.comports", + return_value=[mock_port1, mock_port2], + ): + system_serial = SystemSerialPortDiscovery() + ports = system_serial.get_available_ports() + + # Then: Should return all mocked ports + assert len(ports) == 2 + assert ports[0].device == "/dev/ttyUSB0" + assert ports[1].device == "/dev/ttyACM0" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_backend_flightcontroller_files.py b/tests/test_backend_flightcontroller_files.py new file mode 100755 index 000000000..0c6adc4df --- /dev/null +++ b/tests/test_backend_flightcontroller_files.py @@ -0,0 +1,894 @@ +#!/usr/bin/env python3 + +""" +BDD-style tests for backend_flightcontroller_files.py. + +This file focuses on MAVFTP file operations behavior including file uploads, +log downloads, and error handling for unavailable MAVFTP functionality. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from pathlib import Path +from typing import Callable, Optional +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from ardupilot_methodic_configurator.backend_flightcontroller_files import FlightControllerFiles +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo + + +def _create_files_manager() -> FlightControllerFiles: + """Helper to build a files manager with default mocks.""" + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + info = FlightControllerInfo() + info.is_mavftp_supported = True + mock_conn_mgr.info = info + return FlightControllerFiles(connection_manager=mock_conn_mgr) + + +# pylint: disable=protected-access, too-few-public-methods + + +class TestFlightControllerFilesInitialization: + """Test file operations manager initialization.""" + + def test_user_can_create_files_manager(self) -> None: + """ + User can create files manager with required dependencies. + + GIVEN: Connection manager available + WHEN: User creates files manager + THEN: Manager should be initialized successfully + AND: Dependencies should be stored + """ + # Given: Mock connection manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.info = FlightControllerInfo() + + # When: Create files manager + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + # Then: Manager initialized + assert files_mgr is not None + assert files_mgr.master is None + assert files_mgr.info is not None + + def test_files_manager_requires_connection_manager(self) -> None: + """ + Files manager requires connection manager dependency. + + GIVEN: Missing connection manager + WHEN: User attempts to create files manager + THEN: ValueError should be raised + AND: Clear error message should be provided + """ + # When/Then: Missing connection manager + with pytest.raises(ValueError, match="connection_manager is required"): + FlightControllerFiles(connection_manager=None) + + +class TestFlightControllerFilesUpload: + """Test file upload functionality via MAVFTP.""" + + def test_file_upload_fails_without_connection(self) -> None: + """ + File upload fails gracefully without connection. + + GIVEN: No flight controller connection + WHEN: User attempts to upload file + THEN: Operation should fail with False + AND: Error should be logged appropriately + """ + # Given: No connection + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.info = FlightControllerInfo() + + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + # When: Attempt upload + success = files_mgr.upload_file(local_filename="/tmp/test.param", remote_filename="@SYS/test.param") # noqa: S108 + + # Then: Operation fails + assert success is False + + def test_file_upload_fails_without_mavftp(self) -> None: + """ + File upload fails when MAVFTP is not available. + + GIVEN: Connected flight controller without MAVFTP support + WHEN: User attempts to upload file + THEN: Operation should fail with False + AND: Error should indicate MAVFTP unavailable + """ + # Given: Connection but no MAVFTP + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + # When: Attempt upload with MAVFTP unavailable + with patch("ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", return_value=None): + success = files_mgr.upload_file(local_filename="/tmp/test.param", remote_filename="@SYS/test.param") # noqa: S108 + + # Then: Operation fails + assert success is False + + def test_user_can_upload_file_with_progress_callback(self) -> None: + """ + User can upload file and receive progress updates. + + GIVEN: Connected flight controller with MAVFTP support + WHEN: User uploads file with progress callback + THEN: File should be uploaded successfully + AND: Progress callback should be invoked + """ + # Given: MAVFTP available + mock_ret = MagicMock() + mock_ret.error_code = 0 + + mock_mavftp = MagicMock() + mock_mavftp.cmd_put = MagicMock() + mock_mavftp.process_ftp_reply.return_value = mock_ret + + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_info = FlightControllerInfo() + mock_info.is_mavftp_supported = True + mock_conn_mgr.info = mock_info + + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + progress_calls = [] + + def progress_callback(current: int, total: int) -> None: + progress_calls.append((current, total)) + + # When: Upload file with mocked file existence + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", return_value=mock_mavftp + ): + success = files_mgr.upload_file( + local_filename="/tmp/test.param", # noqa: S108 + remote_filename="@SYS/test.param", + progress_callback=progress_callback, + ) + + # Then: Upload successful + assert success is True + mock_mavftp.cmd_put.assert_called_once() + callback = mock_mavftp.cmd_put.call_args.kwargs["progress_callback"] + callback(0.42) + assert progress_calls == [(42, 100)] + + def test_file_upload_reports_mavftp_error_code(self) -> None: + """Upload reports MAVFTP errors when CreateFile fails.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_ret = MagicMock() + mock_ret.error_code = 5 + mock_mavftp.process_ftp_reply.return_value = mock_ret + + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", + return_value=mock_mavftp, + ): + success = files_mgr.upload_file( + local_filename="/tmp/test.param", # noqa: S108 + remote_filename="@SYS/test.param", + ) + + assert success is False + mock_ret.display_message.assert_called_once() + + def test_file_upload_handles_exceptions(self) -> None: + """Upload gracefully handles unexpected exceptions.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_mavftp.cmd_put.side_effect = RuntimeError("boom") + + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", + return_value=mock_mavftp, + ): + success = files_mgr.upload_file( + local_filename="/tmp/test.param", # noqa: S108 + remote_filename="@SYS/test.param", + ) + + assert success is False + + +class TestFlightControllerFilesDownload: + """Test log file download functionality via MAVFTP.""" + + def test_log_download_fails_without_connection(self) -> None: + """ + Log download fails gracefully without connection. + + GIVEN: No flight controller connection + WHEN: User attempts to download last log + THEN: Operation should return None + AND: Error should be logged appropriately + """ + # Given: No connection + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.info = FlightControllerInfo() + + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + # When: Attempt download + result = files_mgr.download_last_flight_log(local_filename="/tmp/test.BIN") # noqa: S108 + + # Then: Operation fails + assert result is False + + def test_log_download_fails_without_mavftp(self) -> None: + """ + Log download fails when MAVFTP is not available. + + GIVEN: Connected flight controller without MAVFTP support + WHEN: User attempts to download last log + THEN: Operation should return None + AND: Error should indicate MAVFTP unavailable + """ + # Given: Connection but no MAVFTP + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + # When: Attempt download with MAVFTP unavailable + with patch("ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", return_value=None): + result = files_mgr.download_last_flight_log(local_filename="/tmp/test.BIN") # noqa: S108 + + # Then: Operation fails + assert result is False + + def test_user_can_download_last_log_with_progress_callback(self) -> None: + """ + User can download last log file and receive progress updates. + + GIVEN: Connected flight controller with MAVFTP and logs available + WHEN: User downloads last log with progress callback + THEN: Log should be downloaded successfully + AND: Progress callback should be invoked + AND: Downloaded file path should be returned + """ + # Given: MAVFTP available with logs + mock_mavftp = MagicMock() + + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_info = FlightControllerInfo() + mock_info.is_mavftp_supported = True + mock_conn_mgr.info = mock_info + + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + progress_calls = [] + + def progress_callback(current: int, total: int) -> None: + progress_calls.append((current, total)) + + # When: Download log with mocked log number discovery + with ( + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", return_value=mock_mavftp + ), + patch.object(files_mgr, "_get_last_log_number", return_value=42), + patch.object(files_mgr, "_download_log_file", return_value=True), + ): + result = files_mgr.download_last_flight_log( + local_filename="/tmp/00000042.BIN", # noqa: S108 + progress_callback=progress_callback, + ) + + # Then: Download successful + assert result is True + + def test_log_download_fails_when_last_log_unknown(self) -> None: + """ + Users receive clear failure when no log number is discoverable. + + GIVEN: MAVFTP connection is available but no discovery strategy succeeds + WHEN: User requests the last flight log download + THEN: Operation should stop gracefully with False + AND: Actual download helper should never be invoked + """ + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + + with ( + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", + return_value=mock_mavftp, + ), + patch.object(files_mgr, "_get_last_log_number", return_value=None), + patch.object(files_mgr, "_download_log_file") as mock_download, + ): + result = files_mgr.download_last_flight_log(local_filename="/tmp/last.BIN") # noqa: S108 + + assert result is False + mock_download.assert_not_called() + + def test_log_download_fails_when_mavftp_not_supported(self) -> None: + """Download fails immediately when MAVFTP is not supported.""" + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + info = FlightControllerInfo() + info.is_mavftp_supported = False + mock_conn_mgr.info = info + + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + with patch("ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe") as mock_factory: + result = files_mgr.download_last_flight_log(local_filename="/tmp/unsupported.BIN") # noqa: S108 + + assert result is False + mock_factory.assert_not_called() + + def test_log_download_fails_when_mavftp_instance_missing(self) -> None: + """Download fails when MAVFTP creation returns None.""" + files_mgr = _create_files_manager() + + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", + return_value=None, + ): + result = files_mgr.download_last_flight_log(local_filename="/tmp/missing_instance.BIN") # noqa: S108 + + assert result is False + + def test_log_download_invokes_progress_callback(self) -> None: + """Progress callback receives updates from helper function.""" + files_mgr = _create_files_manager() + progress_calls: list[tuple[int, int]] = [] + + def user_progress(current: int, total: int) -> None: + progress_calls.append((current, total)) + + def fake_download(_mavftp: MagicMock, _number: int, _local: str, callback: Callable[[float], None]) -> bool: + callback(0.25) + return True + + with ( + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", + return_value=MagicMock(), + ), + patch.object(files_mgr, "_get_last_log_number", return_value=3), + patch.object(files_mgr, "_download_log_file", side_effect=fake_download), + ): + result = files_mgr.download_last_flight_log( + local_filename="/tmp/00000003.BIN", # noqa: S108 + progress_callback=user_progress, + ) + + assert result is True + assert progress_calls == [(25, 100)] + + def test_log_download_handles_exceptions(self) -> None: + """Download gracefully handles unexpected exceptions.""" + files_mgr = _create_files_manager() + + with ( + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.create_mavftp_safe", + return_value=MagicMock(), + ), + patch.object(files_mgr, "_get_last_log_number", side_effect=RuntimeError("fail")), + ): + result = files_mgr.download_last_flight_log(local_filename="/tmp/boom.BIN") # noqa: S108 + + assert result is False + + +class TestFlightControllerFilesLogDiscovery: + """Test LASTLOG, directory listing, and scanning behaviors.""" + + def test_lastlog_txt_result_short_circuits_fallbacks(self) -> None: + """ + System prefers LASTLOG.TXT before any fallback strategy. + + GIVEN: LASTLOG.TXT contains a valid log number + WHEN: The system searches for the most recent log + THEN: The reported number should come from LASTLOG.TXT + AND: Alternative strategies must not execute + """ + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + + with ( + patch.object(files_mgr, "_get_log_number_from_lastlog_txt", return_value=73), + patch.object(files_mgr, "_get_log_number_from_directory_listing") as mock_dir, + patch.object(files_mgr, "_get_log_number_by_scanning") as mock_scan, + ): + result = files_mgr._get_last_log_number(mock_mavftp) + + assert result == 73 + mock_dir.assert_not_called() + mock_scan.assert_not_called() + + def test_binary_search_used_when_prior_methods_fail(self) -> None: + """ + Binary search is used after LASTLOG and directory listing fail. + + GIVEN: LASTLOG.TXT and directory listings provide no clues + WHEN: The system hunts for the last log number + THEN: Binary search should provide the answer + AND: The returned value should match the binary search discovery + """ + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + + with ( + patch.object(files_mgr, "_get_log_number_from_lastlog_txt", return_value=None), + patch.object(files_mgr, "_get_log_number_from_directory_listing", return_value=None), + patch.object(files_mgr, "_get_log_number_by_scanning", return_value=88) as mock_scan, + ): + result = files_mgr._get_last_log_number(mock_mavftp) + + assert result == 88 + mock_scan.assert_called_once_with(mock_mavftp) + + def test_directory_listing_returns_highest_numeric_log(self) -> None: + """ + Directory listing uses the highest numeric BIN file. + + GIVEN: Mixed directory contents that include BIN files and noise + WHEN: The system inspects the MAVFTP directory listing + THEN: The highest BIN number should be returned + AND: Non-BIN entries are skipped + """ + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + + class ListingResult: + """Directory listing result.""" + + def __init__(self) -> None: + self.directory_listing: dict[str, dict[str, object]] = { + "00000005.BIN": {}, + "README.TXT": {}, + "00000012.BIN": {}, + "junk": {}, + } + + mock_mavftp.cmd_list.return_value = ListingResult() + + result = files_mgr._get_log_number_from_directory_listing(mock_mavftp) + + assert result == 12 + + def test_directory_listing_returns_none_when_listing_missing(self) -> None: + """ + Directory listing gracefully fails when MAVFTP omits entries. + + GIVEN: MAVFTP returns an object without directory details + WHEN: The system inspects the listing response + THEN: No log number can be produced + AND: None should be returned + """ + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_mavftp.cmd_list.return_value = MagicMock() + + result = files_mgr._get_log_number_from_directory_listing(mock_mavftp) + + assert result is None + + def test_binary_search_returns_highest_log_number(self) -> None: + """ + Binary search converges on the highest available log number. + + GIVEN: MAVFTP responds with success for files up to a known number + WHEN: The system performs its binary search strategy + THEN: The discovered number matches the highest available log + AND: Search terminates without errors + """ + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + highest = 37 + state: dict[str, Optional[int]] = {"last": None} + + def record_request(args: list[str], progress_callback: Optional[Callable[[int, int], None]] = None) -> None: + del progress_callback + remote_filename = args[0] + state["last"] = int(remote_filename.split("/")[-1].split(".")[0]) + + def build_reply(*_args: object, **_kwargs: object) -> MagicMock: + ret = MagicMock() + last = state["last"] + if last is None: + ret.error_code = 1 + elif last <= highest: + ret.error_code = 0 + else: + ret.error_code = 5 + return ret + + mock_mavftp.cmd_get.side_effect = record_request + mock_mavftp.process_ftp_reply.side_effect = build_reply + + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.os.path.exists", + return_value=False, + ): + result = files_mgr._get_log_number_by_scanning(mock_mavftp) + + assert result == 37 + + def test_binary_search_removes_temp_files_when_present(self) -> None: + """Binary search cleans temp files when they exist.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + highest = 2 + state: dict[str, Optional[int]] = {"last": None} + + def record_request(args: list[str], *_: object, **__: object) -> None: + remote_filename = args[0] + state["last"] = int(remote_filename.split("/")[-1].split(".")[0]) + + def build_reply(*_args: object, **_kwargs: object) -> MagicMock: + ret = MagicMock() + last = state["last"] + ret.error_code = 0 if last is not None and last <= highest else 5 + return ret + + mock_mavftp.cmd_get.side_effect = record_request + mock_mavftp.process_ftp_reply.side_effect = build_reply + + with ( + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.os.path.exists", + return_value=True, + ), + patch("ardupilot_methodic_configurator.backend_flightcontroller_files.os.remove") as mock_remove, + ): + result = files_mgr._get_log_number_by_scanning(mock_mavftp) + + assert result == 2 + mock_remove.assert_called() + + def test_binary_search_reports_none_when_no_files_found(self) -> None: + """Binary search reports None when no files respond.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_ret = MagicMock() + mock_ret.error_code = 5 + mock_mavftp.process_ftp_reply.return_value = mock_ret + + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller_files.os.path.exists", + return_value=False, + ): + result = files_mgr._get_log_number_by_scanning(mock_mavftp) + + assert result is None + + def test_binary_search_handles_exceptions(self) -> None: + """Binary search helper handles unexpected exceptions.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_mavftp.cmd_get.side_effect = RuntimeError("boom") + + result = files_mgr._get_log_number_by_scanning(mock_mavftp) + + assert result is None + + def test_directory_listing_skips_entries_that_raise_value_error(self) -> None: + """Directory listing continues when parsing raises ValueError.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + + class FakeName(str): + """String subclass with misleading isdigit result.""" + + __slots__ = () + + def isdigit(self) -> bool: + return True + + class ListingResult: + """List the FTP directory contents.""" + + def __init__(self) -> None: + self.directory_listing: dict[str, dict[str, object]] = { + FakeName("12BADVAL.BIN"): {}, + "00000099.BIN": {}, + } + + mock_mavftp.cmd_list.return_value = ListingResult() + + result = files_mgr._get_log_number_from_directory_listing(mock_mavftp) + + assert result == 99 + + def test_directory_listing_reports_when_no_logs_found(self) -> None: + """Directory listing reports failure when no BIN files exist.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + + class ListingResult: + """List the FTP directory contents.""" + + def __init__(self) -> None: + self.directory_listing: dict[str, dict[str, object]] = {"README.TXT": {}, "notes.log": {}} + + mock_mavftp.cmd_list.return_value = ListingResult() + + result = files_mgr._get_log_number_from_directory_listing(mock_mavftp) + + assert result is None + + def test_directory_listing_handles_exceptions(self) -> None: + """Directory listing helper handles unexpected exceptions.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_mavftp.cmd_list.side_effect = RuntimeError("boom") + + result = files_mgr._get_log_number_from_directory_listing(mock_mavftp) + + assert result is None + + def test_directory_listing_used_when_lastlog_missing(self) -> None: + """Directory listing result is used when LASTLOG.TXT is absent.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + + with ( + patch.object(files_mgr, "_get_log_number_from_lastlog_txt", return_value=None), + patch.object(files_mgr, "_get_log_number_from_directory_listing", return_value=91), + ): + result = files_mgr._get_last_log_number(mock_mavftp) + + assert result == 91 + + def test_log_number_lookup_reports_failure_when_all_methods_fail(self) -> None: + """Failure is reported when no strategy yields a log number.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + + with ( + patch.object(files_mgr, "_get_log_number_from_lastlog_txt", return_value=None), + patch.object(files_mgr, "_get_log_number_from_directory_listing", return_value=None), + patch.object(files_mgr, "_get_log_number_by_scanning", return_value=None), + ): + result = files_mgr._get_last_log_number(mock_mavftp) + + assert result is None + + +class TestFlightControllerFilesDownloadHelpers: + """Test helper utilities for downloading logs.""" + + def test_download_log_file_reports_mavftp_errors(self) -> None: + """ + Download helper reports MAVFTP failures to the caller. + + GIVEN: MAVFTP rejects the requested log download + WHEN: The helper executes the download workflow + THEN: The operation should fail with False + AND: The MAVFTP error message should be surfaced + """ + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_ret = MagicMock() + mock_ret.error_code = 1 + mock_mavftp.process_ftp_reply.return_value = mock_ret + + result = files_mgr._download_log_file( + mavftp_instance=mock_mavftp, + remote_filenumber=9, + local_filename="/tmp/00000009.BIN", # noqa: S108 + get_progress_callback=lambda *_args: None, + ) + + assert result is False + mock_ret.display_message.assert_called_once() + + def test_download_log_file_succeeds_with_progress_updates(self) -> None: + """ + Download helper streams progress before reporting success. + + GIVEN: MAVFTP accepts the download request + WHEN: The helper transfers the desired BIN file + THEN: The call should return True + AND: The caller should receive progress callbacks + """ + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_ret = MagicMock() + mock_ret.error_code = 0 + mock_mavftp.process_ftp_reply.return_value = mock_ret + + def _progress_callback(current: int, total: int) -> None: + del current, total + + result = files_mgr._download_log_file( + mavftp_instance=mock_mavftp, + remote_filenumber=10, + local_filename="/tmp/00000010.BIN", # noqa: S108 + get_progress_callback=_progress_callback, + ) + + assert result is True + mock_mavftp.cmd_get.assert_called_once() + assert mock_mavftp.cmd_get.call_args.kwargs["progress_callback"] is _progress_callback + + def test_extract_log_number_reads_value_and_cleans_file(self, tmp_path: Path) -> None: + """ + LASTLOG extractor reads value and cleans up temporary file. + + GIVEN: LASTLOG.TXT contains a trailing newline with the last log number + WHEN: The extractor parses the file + THEN: The number should be returned as int + AND: The temporary file should be removed afterward + """ + files_mgr = _create_files_manager() + temp_file = tmp_path / "lastlog.txt" + temp_file.write_text("57\n", encoding="UTF-8") + + result = files_mgr._extract_log_number_from_file(str(temp_file)) + + assert result == 57 + assert not temp_file.exists() + + +class TestFlightControllerFilesLastlogTxt: + """Test behaviors around LASTLOG.TXT lookups.""" + + def test_lastlog_txt_returns_value_when_available(self) -> None: + """LASTLOG helper returns parsed number when MAVFTP succeeds.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_ret = MagicMock() + mock_ret.error_code = 0 + mock_mavftp.process_ftp_reply.return_value = mock_ret + + with patch.object(files_mgr, "_extract_log_number_from_file", return_value=55) as mock_extract: + result = files_mgr._get_log_number_from_lastlog_txt(mock_mavftp) + + assert result == 55 + mock_mavftp.cmd_get.assert_called_once() + mock_extract.assert_called_once() + + def test_lastlog_txt_returns_none_when_file_missing(self) -> None: + """LASTLOG helper returns None when MAVFTP reports error.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_ret = MagicMock() + mock_ret.error_code = 2 + mock_mavftp.process_ftp_reply.return_value = mock_ret + + result = files_mgr._get_log_number_from_lastlog_txt(mock_mavftp) + + assert result is None + + def test_lastlog_txt_handles_exceptions(self) -> None: + """LASTLOG helper handles unexpected exceptions.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_mavftp.cmd_get.side_effect = RuntimeError("boom") + + result = files_mgr._get_log_number_from_lastlog_txt(mock_mavftp) + + assert result is None + + def test_extract_log_number_handles_invalid_content(self, tmp_path: Path) -> None: + """ + LASTLOG extractor handles invalid files gracefully. + + GIVEN: LASTLOG.TXT contains unreadable content + WHEN: The extractor attempts to parse it + THEN: None should be returned + AND: The temporary file should still be removed + """ + files_mgr = _create_files_manager() + temp_file = tmp_path / "bad_lastlog.txt" + temp_file.write_text("not-a-number", encoding="UTF-8") + + result = files_mgr._extract_log_number_from_file(str(temp_file)) + + assert result is None + assert not temp_file.exists() + + def test_download_log_file_handles_exceptions(self) -> None: + """Download helper handles unexpected exceptions from MAVFTP.""" + files_mgr = _create_files_manager() + mock_mavftp = MagicMock() + mock_mavftp.cmd_get.side_effect = RuntimeError("boom") + + result = files_mgr._download_log_file( + mavftp_instance=mock_mavftp, + remote_filenumber=11, + local_filename="/tmp/00000011.BIN", # noqa: S108 + get_progress_callback=lambda *_: None, + ) + + assert result is False + + +class TestFlightControllerFilesConstants: # pylint: disable=too-few-public-methods + """Test MAVFTP timeout constants are properly defined.""" + + def test_mavftp_timeout_constants_are_defined(self) -> None: + """ + MAVFTP timeout constants should be defined for file operations. + + GIVEN: FlightControllerFiles class + WHEN: Checking timeout constants + THEN: Constants should be defined with reasonable values + AND: Short timeout should be less than regular timeout + """ + # When/Then: Check constants + assert hasattr(FlightControllerFiles, "MAVFTP_FILE_OPERATION_TIMEOUT") + assert hasattr(FlightControllerFiles, "MAVFTP_FILE_OPERATION_TIMEOUT_SHORT") + + assert FlightControllerFiles.MAVFTP_FILE_OPERATION_TIMEOUT == 10 + assert FlightControllerFiles.MAVFTP_FILE_OPERATION_TIMEOUT_SHORT == 5 + assert FlightControllerFiles.MAVFTP_FILE_OPERATION_TIMEOUT_SHORT < FlightControllerFiles.MAVFTP_FILE_OPERATION_TIMEOUT + + +class TestFlightControllerFilesPropertyDelegation: + """Test property delegation to connection manager.""" + + def test_master_property_delegates_to_connection_manager(self) -> None: + """ + Master property correctly delegates to connection manager. + + GIVEN: Files manager with connection manager + WHEN: Accessing master property + THEN: Connection manager's master should be returned + """ + # Given: Connection with master + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + # When: Access master + retrieved_master = files_mgr.master + + # Then: Correct master returned + assert retrieved_master is mock_master + + def test_info_property_delegates_to_connection_manager(self) -> None: + """ + Info property correctly delegates to connection manager. + + GIVEN: Files manager with connection manager + WHEN: Accessing info property + THEN: Connection manager's info should be returned + """ + # Given: Connection with info + mock_info = FlightControllerInfo() + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.info = mock_info + + files_mgr = FlightControllerFiles(connection_manager=mock_conn_mgr) + + # When: Access info + retrieved_info = files_mgr.info + + # Then: Correct info returned + assert retrieved_info is mock_info diff --git a/tests/test_backend_flightcontroller_params.py b/tests/test_backend_flightcontroller_params.py new file mode 100755 index 000000000..de5c19e1a --- /dev/null +++ b/tests/test_backend_flightcontroller_params.py @@ -0,0 +1,1061 @@ +#!/usr/bin/env python3 + +""" +BDD-style tests for backend_flightcontroller_params.py. + +This file focuses on parameter management behavior including downloading, +setting, fetching, and clearing parameters. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from ardupilot_methodic_configurator.backend_flightcontroller_params import FlightControllerParams +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo +from ardupilot_methodic_configurator.data_model_par_dict import Par, ParDict + +# pylint: disable=too-many-lines + + +class TestFlightControllerParamsInitialization: + """Test parameter manager initialization and setup.""" + + def test_user_can_create_params_manager_with_connection(self) -> None: + """ + User can create parameter manager with connection manager. + + GIVEN: A connection manager is available + WHEN: User creates parameter manager + THEN: Manager should be initialized with empty parameters + AND: Connection manager reference should be stored + """ + # Given: Mock connection manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.info = FlightControllerInfo() + mock_conn_mgr.comport_device = "" + + # When: Create params manager + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # Then: Manager initialized correctly + assert params_mgr is not None + assert params_mgr.fc_parameters == {} + assert params_mgr.master is None + + def test_params_manager_requires_connection_manager(self) -> None: + """ + Parameter manager requires connection manager at initialization. + + GIVEN: No connection manager provided + WHEN: User attempts to create params manager + THEN: ValueError should be raised + AND: Clear error message should be provided + """ + # When/Then: Attempting creation without connection manager raises error + with pytest.raises(ValueError, match="connection_manager is required"): + FlightControllerParams(connection_manager=None) + + def test_params_manager_can_use_provided_parameter_dict(self) -> None: + """ + Parameter manager can use externally provided parameter dictionary. + + GIVEN: Pre-existing parameter dictionary + WHEN: User creates params manager with that dictionary + THEN: Manager should use the provided dictionary + AND: Dictionary should be shared (not copied) + """ + # Given: Pre-existing parameters + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + existing_params = {"PARAM1": 1.0, "PARAM2": 2.0} + + # When: Create with existing dict + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr, fc_parameters=existing_params) + + # Then: Uses provided dictionary (not a copy) + assert params_mgr.fc_parameters is existing_params + assert params_mgr.fc_parameters["PARAM1"] == 1.0 + + +class TestFlightControllerParamsSetParameter: + """Test parameter setting functionality.""" + + def test_user_can_set_parameter_value(self) -> None: + """ + User can set individual parameter values. + + GIVEN: Connected flight controller + WHEN: User sets a parameter value + THEN: Parameter should be sent to flight controller + AND: Parameter should be cached locally + """ + # Given: Connected FC + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Set parameter + success, error = params_mgr.set_param("BATT_MONITOR", 4.0) + + # Then: Parameter sent and cached + assert success is True + assert error == "" + mock_master.param_set_send.assert_called_once_with("BATT_MONITOR", 4.0) + assert params_mgr.fc_parameters["BATT_MONITOR"] == 4.0 + + def test_set_parameter_fails_without_connection(self) -> None: + """ + Setting parameter fails gracefully without connection. + + GIVEN: No flight controller connection + WHEN: User attempts to set parameter + THEN: Operation should fail with clear error + AND: No exceptions should be raised + """ + # Given: No connection + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Attempt to set parameter + success, error = params_mgr.set_param("PARAM1", 1.0) + + # Then: Clear failure message + assert success is False + assert "connection" in error.lower() + + def test_set_parameter_with_string_value_fails(self) -> None: + """ + Setting parameter with string value fails with clear error. + + GIVEN: Connected flight controller + WHEN: User attempts to set parameter with string value (invalid) + THEN: Operation should fail + AND: Error message should indicate invalid type + AND: Parameter should not be cached + """ + # Given: Connected FC + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Try to set with string value + success, error = params_mgr.set_param("PARAM1", "invalid_string") # type: ignore[arg-type] + + # Then: Should fail with type error + assert success is False + assert "Invalid" in error or "type" in error.lower() + assert "PARAM1" not in params_mgr.fc_parameters + + def test_set_parameter_with_none_value_fails(self) -> None: + """ + Setting parameter with None value fails appropriately. + + GIVEN: Connected flight controller + WHEN: User attempts to set parameter with None + THEN: Operation should fail + AND: Error message should indicate invalid type + """ + # Given: Connected FC + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Try to set with None value + success, error = params_mgr.set_param("PARAM1", None) # type: ignore[arg-type] + + # Then: Should fail with type error + assert success is False + assert "Invalid" in error or "type" in error.lower() + + def test_set_parameter_with_list_value_fails(self) -> None: + """ + Setting parameter with list value fails appropriately. + + GIVEN: Connected flight controller + WHEN: User attempts to set parameter with list (invalid) + THEN: Operation should fail + AND: Error message should indicate invalid type + """ + # Given: Connected FC + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Try to set with list value + success, error = params_mgr.set_param("PARAM1", [1, 2, 3]) # type: ignore[arg-type] + + # Then: Should fail with type error + assert success is False + assert "Invalid" in error or "type" in error.lower() + + def test_set_parameter_with_dict_value_fails(self) -> None: + """ + Setting parameter with dict value fails appropriately. + + GIVEN: Connected flight controller + WHEN: User attempts to set parameter with dict (invalid) + THEN: Operation should fail + AND: Error message should indicate invalid type + """ + # Given: Connected FC + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Try to set with dict value + success, error = params_mgr.set_param("PARAM1", {"key": "value"}) # type: ignore[arg-type] + + # Then: Should fail with type error + assert success is False + assert "Invalid" in error or "type" in error.lower() + + +class TestFlightControllerParamsFetchParameter: + """Test parameter fetching functionality.""" + + def test_user_can_fetch_parameter_from_flight_controller(self) -> None: + """ + User can fetch current parameter value from flight controller. + + GIVEN: Connected flight controller with parameters + WHEN: User fetches a specific parameter + THEN: Current value should be retrieved from FC + AND: Value should be cached locally + """ + # Given: Connected FC with parameter response + mock_master = MagicMock() + mock_param_msg = MagicMock() + mock_param_msg.param_value = 4.0 + mock_param_msg.param_id = "BATT_MONITOR" + mock_master.recv_match.return_value = mock_param_msg + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Fetch parameter + value = params_mgr.fetch_param("BATT_MONITOR", timeout=1) + + # Then: Value retrieved and cached + assert value == 4.0 + assert params_mgr.fc_parameters["BATT_MONITOR"] == 4.0 + + def test_fetch_parameter_times_out_for_nonexistent_param(self) -> None: + """ + Fetching nonexistent parameter raises TimeoutError. + + GIVEN: Connected flight controller + WHEN: User fetches parameter that doesn't exist + THEN: TimeoutError should be raised after timeout expires + AND: User receives clear feedback about missing parameter + """ + # Given: Connected FC with no response + mock_master = MagicMock() + mock_master.recv_match.return_value = None + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When/Then: Fetch nonexistent parameter and expect timeout + with patch("ardupilot_methodic_configurator.backend_flightcontroller_params.time_time") as mock_time: + mock_time.side_effect = [0.0, 2.0] + with pytest.raises(TimeoutError, match="NONEXISTENT"): + params_mgr.fetch_param("NONEXISTENT", timeout=1) + + +class TestFlightControllerParamsGetParameter: + """Test parameter retrieval from cache.""" + + def test_user_can_get_cached_parameter_value(self) -> None: + """ + User can get parameter value from local cache. + + GIVEN: Parameter cached locally + WHEN: User gets parameter value + THEN: Cached value should be returned + AND: No FC communication should occur + """ + # Given: Cached parameters + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + params_mgr.fc_parameters["CACHED_PARAM"] = 42.0 + + # When: Get cached parameter + value = params_mgr.get_param("CACHED_PARAM") + + # Then: Cached value returned + assert value == 42.0 + + def test_get_parameter_returns_default_for_missing_param(self) -> None: + """ + Getting missing parameter returns default value. + + GIVEN: Parameter not in cache + WHEN: User gets parameter with default value + THEN: Default value should be returned + AND: Cache should remain unchanged + """ + # Given: Empty cache + mock_conn_mgr = Mock() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Get missing parameter with default + value = params_mgr.get_param("MISSING_PARAM", default=99.0) + + # Then: Default returned + assert value == 99.0 + assert "MISSING_PARAM" not in params_mgr.fc_parameters + + +class TestFlightControllerParamsClearParameters: # pylint: disable=too-few-public-methods + """Test parameter cache clearing.""" + + def test_user_can_clear_parameter_cache(self) -> None: + """ + User can clear all cached parameters. + + GIVEN: Parameters cached from previous operations + WHEN: User clears parameter cache + THEN: All cached parameters should be removed + AND: Cache should be empty + """ + # Given: Cached parameters + mock_conn_mgr = Mock() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + params_mgr.fc_parameters = {"PARAM1": 1.0, "PARAM2": 2.0, "PARAM3": 3.0} + + # When: Clear cache + params_mgr.clear_parameters() + + # Then: Cache empty + assert params_mgr.fc_parameters == {} + assert len(params_mgr.fc_parameters) == 0 + + +class TestFlightControllerParamsConstants: + """Test parameter manager constants and configuration.""" + + def test_param_fetch_poll_delay_is_reasonable(self) -> None: + """ + Parameter fetch poll delay is set to reasonable value. + + GIVEN: Parameter manager class + WHEN: Checking poll delay constant + THEN: Value should be small but not zero + AND: Value should prevent busy-waiting + """ + # Then: Reasonable poll delay + assert FlightControllerParams.PARAM_FETCH_POLL_DELAY > 0 + assert FlightControllerParams.PARAM_FETCH_POLL_DELAY < 1.0 # Less than 1 second + + def test_param_set_propagation_delay_allows_fc_processing(self) -> None: + """ + Parameter set propagation delay allows FC time to process. + + GIVEN: Parameter manager class + WHEN: Checking propagation delay constant + THEN: Value should allow FC to process parameter change + AND: Value should not cause excessive delays + """ + # Then: Reasonable propagation delay + assert FlightControllerParams.PARAM_SET_PROPAGATION_DELAY >= 0.1 + assert FlightControllerParams.PARAM_SET_PROPAGATION_DELAY < 2.0 + + +class TestFlightControllerParamsPropertyDelegation: + """Test property delegation to connection manager.""" + + def test_master_property_delegates_to_connection_manager(self) -> None: + """ + Master property correctly delegates to connection manager. + + GIVEN: Parameter manager with connection manager + WHEN: Accessing master property + THEN: Connection manager's master should be returned + AND: No caching should occur + """ + # Given: Connection manager with master + mock_master = MagicMock() + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Access master + retrieved_master = params_mgr.master + + # Then: Correct master returned + assert retrieved_master is mock_master + + def test_info_property_delegates_to_connection_manager(self) -> None: + """ + Info property correctly delegates to connection manager. + + GIVEN: Parameter manager with connection manager + WHEN: Accessing info property + THEN: Connection manager's info should be returned + AND: Info should be single source of truth + """ + # Given: Connection manager with info + mock_info = FlightControllerInfo() + mock_conn_mgr = Mock() + mock_conn_mgr.info = mock_info + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Access info + retrieved_info = params_mgr.info + + # Then: Correct info returned + assert retrieved_info is mock_info + + def test_comport_device_property_delegates_correctly(self) -> None: + """ + Comport device property correctly delegates to connection manager. + + GIVEN: Parameter manager with connection manager + WHEN: Accessing comport_device property + THEN: Connection manager's comport_device should be returned + """ + # Given: Connection manager with comport device + mock_conn_mgr = Mock() + mock_conn_mgr.comport_device = "/dev/ttyACM0" + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Access comport device + device = params_mgr.comport_device + + # Then: Correct device returned + assert device == "/dev/ttyACM0" + + +class TestFlightControllerParamsDownload: + """Test parameter download functionality.""" + + @patch( + "ardupilot_methodic_configurator.backend_flightcontroller_params.FlightControllerParams._download_params_via_mavlink" + ) + def test_download_params_uses_mavlink_when_mavftp_not_supported(self, mock_download: MagicMock) -> None: + """ + Parameter download uses MAVLink when MAVFTP not supported. + + GIVEN: Flight controller without MAVFTP support + WHEN: User downloads parameters + THEN: MAVLink protocol should be used + AND: Parameters should be retrieved successfully + """ + # Given: FC without MAVFTP + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_info = FlightControllerInfo() + mock_info.is_mavftp_supported = False + mock_conn_mgr.info = mock_info + + test_params = {"PARAM1": 1.0, "PARAM2": 2.0} + mock_download.return_value = test_params + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Download parameters + params, _defaults = params_mgr.download_params() + + # Then: MAVLink used + mock_download.assert_called_once() + assert params == test_params + + def test_download_params_requires_connection(self) -> None: + """ + Parameter download requires active connection. + + GIVEN: No flight controller connection + WHEN: User attempts to download parameters + THEN: Empty parameter dict should be returned + AND: Error should be logged + """ + # Given: No connection + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Attempt download + params, defaults = params_mgr.download_params() + + # Then: Empty results + assert params == {} + assert not defaults + + def test_user_can_load_parameters_from_local_file_mode(self) -> None: + """ + File-mode downloads reuse local params when no connection exists. + + GIVEN: Flight controller running in offline file mode without master connection + WHEN: User triggers a parameter download + THEN: Parameters should be loaded from params.param + AND: Local cache should contain the loaded values + """ + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.comport_device = "file" + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + fake_params = ParDict({"BATT_MONITOR": Par(4.0)}) + + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller_params.ParDict.from_file", + return_value=fake_params, + ) as mock_from_file: + param_values, default_params = params_mgr.download_params() + + mock_from_file.assert_called_once_with("params.param") + assert param_values == {"BATT_MONITOR": 4.0} + assert isinstance(default_params, ParDict) + assert params_mgr.fc_parameters == param_values + + def test_user_can_download_parameters_via_mavftp_when_supported(self, tmp_path: Path) -> None: + """ + MAVFTP-backed downloads stream parameter and default files when available. + + GIVEN: Connected controller with MAVFTP support + WHEN: User requests a parameter download with progress feedback + THEN: MAVFTP should fetch both parameter and default files + AND: Local cache plus return values should include converted floats + """ + mock_conn_mgr = Mock() + mock_master = MagicMock() + mock_conn_mgr.master = mock_master + mock_info = FlightControllerInfo() + mock_info.is_mavftp_supported = True + mock_conn_mgr.info = mock_info + mock_conn_mgr.comport_device = "tcp:127.0.0.1:5760" + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + ret = MagicMock() + ret.error_code = 0 + mock_mavftp = MagicMock() + mock_mavftp.process_ftp_reply.return_value = ret + + value_file = tmp_path / "values.param" + default_file = tmp_path / "defaults.param" + + with ( + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_params.create_mavftp", + return_value=mock_mavftp, + ), + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_params.ParDict.from_file", + side_effect=[ParDict({"PSC_ACCZ_P": Par(0.5)}), ParDict({"ATC_ANG_RLL_P": Par(4.5)})], + ) as mock_from_file, + patch("ardupilot_methodic_configurator.backend_flightcontroller_params.time_sleep"), + ): + progress_updates: list[tuple[int, int]] = [] + + def progress(current: int, total: int) -> None: + progress_updates.append((current, total)) + + params, defaults = params_mgr.download_params( + progress_callback=progress, + parameter_values_filename=value_file, + parameter_defaults_filename=default_file, + ) + + mock_mavftp.cmd_getparams.assert_called_once() + mock_from_file.assert_any_call(str(value_file)) + mock_from_file.assert_any_call(str(default_file)) + assert params == {"PSC_ACCZ_P": 0.5} + assert isinstance(defaults, ParDict) + assert params_mgr.fc_parameters == params + assert progress_updates # Callback should have been invoked + + def test_mavftp_download_reports_failures_cleanly(self) -> None: + """ + MAVFTP failures surface clear errors instead of stale data. + + GIVEN: Connected controller experiencing MAVFTP errors + WHEN: Parameter download is attempted + THEN: Users should be notified of the error + AND: Empty dictionaries should be returned + """ + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + ret = MagicMock() + ret.error_code = 5 + mock_mavftp = MagicMock() + mock_mavftp.process_ftp_reply.return_value = ret + + with ( + patch( + "ardupilot_methodic_configurator.backend_flightcontroller_params.create_mavftp", + return_value=mock_mavftp, + ), + patch("ardupilot_methodic_configurator.backend_flightcontroller_params.time_sleep"), + ): + params, defaults = params_mgr._download_params_via_mavftp() # pylint: disable=protected-access + + ret.display_message.assert_called_once() + assert params == {} + assert isinstance(defaults, ParDict) + + def test_user_can_download_parameters_over_mavlink_with_progress(self) -> None: + """ + MAVLink downloads iterate until all parameters are received. + + GIVEN: Connected controller without MAVFTP support + WHEN: User forces a MAVLink-based parameter download with progress callbacks + THEN: PARAM_VALUE messages should be accumulated until the advertised count is met + AND: The progress callback should reflect the growing tally + """ + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + mock_master.mav = MagicMock() + + first_msg = MagicMock() + first_msg.param_count = 2 + first_msg.to_dict.return_value = {"param_id": "PSC_ACCZ_P", "param_value": 0.5} + + second_msg = MagicMock() + second_msg.param_count = 2 + second_msg.to_dict.return_value = {"param_id": "ATC_RATE_RLL_FF", "param_value": 0.12} + + mock_master.recv_match.side_effect = [first_msg, second_msg, None] + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + progress_updates: list[tuple[int, int]] = [] + params = params_mgr._download_params_via_mavlink( # pylint: disable=protected-access + lambda current, total: progress_updates.append((current, total)) + ) + + mock_master.mav.param_request_list_send.assert_called_once_with(1, 1) + assert params == {"PSC_ACCZ_P": 0.5, "ATC_RATE_RLL_FF": 0.12} + assert progress_updates == [(1, 2), (2, 2)] + + def test_download_params_falls_back_to_mavlink_when_mavftp_returns_no_data(self) -> None: + """ + MAVFTP fallback gracefully switches to MAVLink when files contain no data. + + GIVEN: Controller that advertises MAVFTP support but returns empty files + WHEN: User requests a parameter sync + THEN: The code should fall back to standard MAVLink downloads + AND: Returned parameters should come from the secondary path + """ + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + mock_master.mav = MagicMock() + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + mock_conn_mgr.info.is_mavftp_supported = True + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + fallback_params = {"AHRS_EKF_TYPE": 10.0} + + with ( + patch.object(params_mgr, "_download_params_via_mavftp", return_value=({}, ParDict())) as mock_mavftp, + patch.object(params_mgr, "_download_params_via_mavlink", return_value=fallback_params) as mock_mavlink, + ): + params, defaults = params_mgr.download_params() + + mock_mavftp.assert_called_once() + mock_mavlink.assert_called_once() + assert params == fallback_params + assert isinstance(defaults, ParDict) + + +class TestFlightControllerParamsFileOperations: # pylint: disable=too-few-public-methods + """Test parameter file save/load operations.""" + + def test_download_params_can_save_to_file(self, tmp_path: Path) -> None: + """ + Downloaded parameters can be saved to file. + + GIVEN: Parameters downloaded from FC + WHEN: User specifies output filename + THEN: Parameters should be saved to file + AND: File should contain parameter values + """ + # Given: Connected FC with parameters + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_info = FlightControllerInfo() + mock_info.is_mavftp_supported = False + mock_conn_mgr.info = mock_info + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # Mock the download to return test data + with patch.object(params_mgr, "_download_params_via_mavlink", return_value={"TEST": 1.0}): + output_file = tmp_path / "params.txt" + + # When: Download with file output + params_mgr.download_params(parameter_values_filename=output_file) + + # Then: File created + # Note: Actual file creation depends on implementation details + # This test validates the interface accepts the parameter + assert True # Interface test only + + +class TestParameterEdgeCases: + """Test edge cases and error handling for parameter operations.""" + + def test_fetch_param_with_empty_name_raises_error(self) -> None: + """ + Fetching parameter with empty name raises validation error. + + GIVEN: Parameter manager with connection + WHEN: Fetching parameter with empty string name + THEN: Should raise IndexError for invalid name + AND: Should not corrupt internal state + """ + # Given: Connected parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When/Then: Fetch with empty name raises validation error + with pytest.raises(IndexError, match="Parameter name cannot be empty"): + params_mgr.fetch_param("", timeout=1) + + def test_fetch_param_updates_cache_after_successful_response(self) -> None: + """ + Fetching a parameter updates the cache when MAVLink responds. + + GIVEN: Connected controller that responds to PARAM_REQUEST_READ + WHEN: User fetches a specific parameter + THEN: The returned value should be cached locally + AND: Trailing null characters should be stripped transparently + """ + mock_master = MagicMock() + mock_master.target_system = 1 + mock_master.target_component = 1 + + mock_msg = MagicMock() + mock_msg.param_id = "RC1_MIN\x00" + mock_msg.param_value = 987.0 + + mock_master.recv_match.side_effect = [mock_msg] + + mock_conn_mgr = Mock() + mock_conn_mgr.master = mock_master + mock_conn_mgr.info = FlightControllerInfo() + + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + with patch( + "ardupilot_methodic_configurator.backend_flightcontroller_params.time_time", + side_effect=[0.0, 0.1], + ): + value = params_mgr.fetch_param("RC1_MIN", timeout=1) + + mock_master.mav.param_request_read_send.assert_called_once() + assert value == 987.0 + assert params_mgr.fc_parameters["RC1_MIN"] == 987.0 + + def test_fetch_param_with_invalid_name_raises_error(self) -> None: + """ + Fetching parameter with invalid length raises validation error. + + GIVEN: Parameter manager with connection + WHEN: Fetching parameter name longer than MAVLink allows + THEN: IndexError should be raised immediately + AND: No MAVLink request should be sent + """ + # Given: Connected parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When/Then: Fetch invalid parameter name raises error + with pytest.raises(IndexError, match="Parameter name too long"): + params_mgr.fetch_param("NONEXISTENT_PARAM_XYZ", timeout=1) + + def test_fetch_param_returns_none_when_disconnected(self) -> None: + """ + Fetching while disconnected returns None without raising errors. + + GIVEN: Parameter manager without a live MAVLink master + WHEN: User attempts to fetch any parameter + THEN: The method should return None immediately + AND: No MAVLink requests should be issued + """ + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + value = params_mgr.fetch_param("FRAME_TYPE", timeout=1) + + assert value is None + + def test_fetch_param_with_zero_timeout_raises_value_error(self) -> None: + """ + Fetch parameter with zero timeout raises ValueError. + + GIVEN: Parameter manager with connection + WHEN: Attempting fetch with zero timeout + THEN: Should raise ValueError to signal invalid timeout + AND: Should not perform any MAVLink requests + """ + # Given: Connected parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When/Then: Fetch with zero timeout raises ValueError + with pytest.raises(ValueError, match="Timeout for parameter"): + params_mgr.fetch_param("FRAME_TYPE", timeout=0) + + def test_set_param_with_empty_name_fails_gracefully(self) -> None: + """ + Setting parameter with empty name fails gracefully. + + GIVEN: Parameter manager with connection + WHEN: Setting parameter with empty name + THEN: Should return failure status + AND: Error message should indicate invalid name + """ + # Given: Connected parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Try to set with empty name + success, error_msg = params_mgr.set_param("", 123.0) + + # Then: Should fail with descriptive error message + assert not success + assert isinstance(error_msg, str) + assert len(error_msg) > 0 # Should have error message + assert "Invalid" in error_msg or "parameter name" in error_msg.lower() + + def test_set_param_with_zero_value(self) -> None: + """ + Setting parameter to zero value is allowed. + + GIVEN: Parameter manager with valid parameter name + WHEN: Setting parameter value to zero + THEN: Should accept zero as valid value + AND: Should not treat zero as error condition + """ + # Given: Connected parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # Pre-populate with a parameter + params_mgr.fc_parameters = {"FRAME_TYPE": 2.0} + + # When: Set parameter to zero + success, error_msg = params_mgr.set_param("FRAME_TYPE", 0.0) + + # Then: Should accept zero + assert isinstance(success, bool) + assert isinstance(error_msg, str) + + def test_set_param_with_negative_value(self) -> None: + """ + Setting parameter to negative value is allowed where valid. + + GIVEN: Parameter manager with parameter that accepts negative values + WHEN: Setting parameter to negative value + THEN: Should accept negative values + AND: Should not reject based on sign alone + """ + # Given: Connected parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # Pre-populate with a parameter + params_mgr.fc_parameters = {"TRIM_PITCH_CD": 100.0} + + # When: Set parameter to negative value (valid for trims) + success, error_msg = params_mgr.set_param("TRIM_PITCH_CD", -50.0) + + # Then: Should handle negative values + assert isinstance(success, bool) + assert isinstance(error_msg, str) + + def test_set_param_with_very_large_value(self) -> None: + """ + Setting parameter to very large value is handled. + + GIVEN: Parameter manager with connection + WHEN: Setting parameter to very large numeric value + THEN: Should handle large numbers appropriately + AND: Should not overflow or crash + """ + # Given: Connected parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # Pre-populate with a parameter + params_mgr.fc_parameters = {"FRAME_TYPE": 2.0} + + # When: Set to very large value + success, error_msg = params_mgr.set_param("FRAME_TYPE", 9999999.0) + + # Then: Should handle without crashing + assert isinstance(success, bool) + assert isinstance(error_msg, str) + + def test_set_param_with_floating_point_precision(self) -> None: + """ + Setting parameter with floating point values maintains precision. + + GIVEN: Parameter manager with connection + WHEN: Setting parameter to precise floating point value + THEN: Should store precision appropriately + AND: Value should be retrievable + """ + # Given: Connected parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # Pre-populate with a parameter + params_mgr.fc_parameters = {"ANGLE_MAX": 4500.0} + + # When: Set to precise floating point value + success, error_msg = params_mgr.set_param("ANGLE_MAX", 3.14159) + + # Then: Should handle floating point values + assert isinstance(success, bool) + assert isinstance(error_msg, str) + + def test_fc_parameters_empty_initially(self) -> None: + """ + Parameter collection starts empty. + + GIVEN: New parameter manager + WHEN: Checking initial parameters + THEN: Should have empty dictionary + AND: Should be mutable for adding parameters + """ + # Given: New parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # Then: Initially empty + assert params_mgr.fc_parameters == {} + assert isinstance(params_mgr.fc_parameters, dict) + + def test_fc_parameters_can_be_set_directly(self) -> None: + """ + FC parameters dictionary can be updated directly. + + GIVEN: Parameter manager with empty parameters + WHEN: Setting parameters directly + THEN: Should update internal dictionary + AND: Should persist across accesses + """ + # Given: New parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = None + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # When: Set parameters directly + test_params = {"FRAME_TYPE": 2.0, "BATT_MONITOR": 3.0} + params_mgr.fc_parameters = test_params + + # Then: Should be stored and retrievable + assert params_mgr.fc_parameters == test_params + assert params_mgr.fc_parameters["FRAME_TYPE"] == 2.0 + + def test_multiple_set_param_operations_sequence(self) -> None: + """ + Multiple parameter set operations can be performed in sequence. + + GIVEN: Parameter manager with connection + WHEN: Setting multiple parameters sequentially + THEN: Each operation should complete successfully + AND: State should remain consistent + """ + # Given: Connected parameter manager + mock_conn_mgr = Mock() + mock_conn_mgr.master = MagicMock() + mock_conn_mgr.info = FlightControllerInfo() + params_mgr = FlightControllerParams(connection_manager=mock_conn_mgr) + + # Pre-populate with parameters + params_mgr.fc_parameters = { + "FRAME_TYPE": 2.0, + "BATT_MONITOR": 3.0, + "BATT_CAPACITY": 5000.0, + } + + # When: Set multiple parameters sequentially + result1 = params_mgr.set_param("FRAME_TYPE", 1.0) + result2 = params_mgr.set_param("BATT_MONITOR", 4.0) + result3 = params_mgr.set_param("BATT_CAPACITY", 4000.0) + + # Then: All operations should complete + assert isinstance(result1[0], bool) + assert isinstance(result2[0], bool) + assert isinstance(result3[0], bool) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_backend_flightcontroller_sitl.py b/tests/test_backend_flightcontroller_sitl.py new file mode 100755 index 000000000..665624b16 --- /dev/null +++ b/tests/test_backend_flightcontroller_sitl.py @@ -0,0 +1,1905 @@ +#!/usr/bin/env python3 + +""" +SITL integration tests for the backend_flightcontroller.py file. + +These tests exercise REAL MAVLink communication with ArduPilot SITL (Software In The Loop). +Unlike unit tests with mocks, these validate actual protocol behavior, timing, and edge cases. + +WHY SITL TESTS MATTER: +- Catch protocol bugs that mocks hide (message sequencing, timing, retries) +- Validate compatibility with real ArduPilot firmware +- Test async behavior that's impossible to properly mock +- Ensure changes work with actual hardware (SITL uses same protocol as real flight controllers) +- Verify real timeout handling and error conditions +- Validate actual MAVLink message acknowledgments (COMMAND_ACK, PARAM_VALUE, etc.) + +RUNNING THESE TESTS: +1. These tests require SITL to be running (managed by sitl_manager fixture in conftest.py) +2. Run all SITL tests: pytest -m sitl tests/ +3. Skip SITL tests: pytest -m "not sitl" tests/ +4. SITL is automatically started/stopped by pytest fixtures +5. Connection string is configured in conftest.py (default: tcp:127.0.0.1:5760) + +FIXTURES: +- sitl_manager: Manages SITL lifecycle (start/stop ArduCopter SITL process) +- sitl_flight_controller: Connected FlightController instance ready for testing with real SITL + +IMPORTANT: These tests validate REAL protocol behavior that mocks cannot simulate: +- Actual network timing and latency +- Real parameter persistence in SITL memory +- Authentic command acknowledgment sequences +- True async communication patterns +- Genuine timeout and retry logic + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import time +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +import pytest +from pymavlink import mavutil + +from ardupilot_methodic_configurator.backend_flightcontroller import FlightController +from ardupilot_methodic_configurator.backend_flightcontroller_connection import FlightControllerConnection +from ardupilot_methodic_configurator.backend_flightcontroller_factory_mavftp import ( + create_mavftp, + create_mavftp_safe, +) +from ardupilot_methodic_configurator.backend_flightcontroller_factory_mavlink import SystemMavlinkConnectionFactory +from ardupilot_methodic_configurator.backend_flightcontroller_files import FlightControllerFiles +from ardupilot_methodic_configurator.backend_mavftp import ERR_FileExists +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo + +if TYPE_CHECKING: + from conftest import SITLManager + + +# pylint: disable=too-many-lines + + +# Test helper to wait for parameter value with timeout +def wait_for_param_value(fc: FlightController, param_name: str, expected_value: float, timeout: float = 2.0) -> bool: + """ + Wait for parameter to reach expected value. + + Poll the parameter until it matches the expected value or timeout is reached. + """ + start = time.time() + while time.time() - start < timeout: + actual = fc.fetch_param(param_name) + if actual == expected_value: + return True + time.sleep(fc.PARAM_FETCH_POLL_DELAY) + return False + + +def _ensure_remote_logs_directory(mavftp) -> None: + """Ensure /APM/LOGS exists before running file-transfer tests.""" + for directory in ("/APM", "/APM/LOGS"): + result = mavftp.cmd_mkdir([directory]) + if result is None: + continue + error_code = getattr(result, "error_code", 0) + if error_code not in (0, ERR_FileExists): + pytest.skip(f"Unable to create required directory {directory}: MAVFTP error {error_code}") + + +def _backup_remote_file(mavftp, remote_path: str, local_backup: Path) -> bool: + """Attempt to download a remote file for backup; return True if it exists.""" + try: + mavftp.cmd_get([remote_path, str(local_backup)]) + reply = mavftp.process_ftp_reply( + "OpenFileRO", + timeout=FlightControllerFiles.MAVFTP_FILE_OPERATION_TIMEOUT, + ) + if reply.error_code == 0: + return True + except Exception: # pylint: disable=broad-exception-caught + local_backup.unlink(missing_ok=True) + return False + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_connect_to_real_sitl_via_tcp(sitl_flight_controller: FlightController) -> None: + """ + User can establish TCP connection to real ArduPilot SITL simulation. + + GIVEN: Real ArduCopter SITL instance is running on localhost:5760 + WHEN: User connects FlightController via TCP MAVLink connection + THEN: Connection should be established successfully + AND: Firmware type should be detected as ArduCopter from real heartbeat + AND: Actual MAVLink communication channel should be active + + NOTE: This test validates REAL connection behavior that mocks cannot simulate: + - Actual TCP socket communication + - Real MAVLink heartbeat detection + - Authentic firmware version identification + - True network timing and latency + """ + assert sitl_flight_controller.master is not None + assert sitl_flight_controller.info.firmware_type == "ArduCopter" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_download_all_parameters_from_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can download complete parameter set from real SITL via MAVLink protocol. + + GIVEN: Connected flight controller with real SITL containing ArduCopter parameters + WHEN: User downloads all parameters using MAVLink PARAM_REQUEST_LIST + THEN: Standard ArduCopter parameters should be retrieved successfully + AND: Parameters should include frame configuration (FRAME_TYPE, FRAME_CLASS) + AND: Actual MAVLink parameter protocol should complete without errors + + NOTE: This validates REAL parameter download that mocks cannot replicate: + - Actual PARAM_REQUEST_LIST / PARAM_VALUE message sequence + - Real parameter count and ordering from SITL + - Authentic retry logic for missing parameters + - True async parameter streaming behavior + """ + params, _ = sitl_flight_controller.download_params() + + assert isinstance(params, dict) + assert len(params) > 0 + assert "FRAME_TYPE" in params # Common ArduCopter parameter + assert "FRAME_CLASS" in params + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_safely_test_motors_with_real_sitl_acknowledgment(sitl_flight_controller: FlightController) -> None: + """ + User receives real MAVLink COMMAND_ACK from SITL during motor testing. + + GIVEN: Real ArduCopter SITL that processes MAV_CMD_DO_MOTOR_TEST commands + WHEN: User sends motor test command with 10% throttle for 2 seconds + THEN: Real SITL should send COMMAND_ACK with MAV_RESULT_ACCEPTED + AND: Test should complete without errors + AND: Safety protocols should be validated by real SITL firmware + + NOTE: SITL is essential here because it validates: + - Real SITL safety parameter checks (throttle limits, arming state) + - COMMAND_ACK timing is realistic (not instant like mocks) + - Actual command sequencing that hardware uses + - Authentic MAV_CMD_DO_MOTOR_TEST protocol behavior + """ + success, error_msg = sitl_flight_controller.test_motor( + test_sequence_nr=0, motor_letters="A", motor_output_nr=1, throttle_percent=10, timeout_seconds=2 + ) + + assert success, f"Motor test failed: {error_msg}" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_monitor_battery_status_from_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can monitor battery voltage and current from real SITL telemetry. + + GIVEN: Connected flight controller with battery monitoring enabled in real SITL + WHEN: User requests periodic battery status via MAVLink + THEN: Real battery voltage and current should be returned from SITL + AND: MAVLink BATTERY_STATUS messages should be received + AND: Actual telemetry streaming should work correctly + + NOTE: This validates REAL telemetry that mocks cannot simulate: + - Actual MAVLink message streaming (SET_MESSAGE_INTERVAL) + - Real BATTERY_STATUS message format and timing + - Authentic parameter-dependent behavior (BATT_MONITOR) + - True async telemetry reception + """ + # Download parameters first as get_battery_status requires them + params, _ = sitl_flight_controller.download_params() + assert isinstance(params, dict) + assert len(params) > 0 + # Store parameters in the flight controller instance + sitl_flight_controller.fc_parameters = params + + success, error_msg = sitl_flight_controller.request_periodic_battery_status() + assert success, f"Battery monitoring setup failed: {error_msg}" + + # Use the constant instead of magic number + time.sleep(sitl_flight_controller.BATTERY_STATUS_CACHE_TIME / 3) # Wait for battery data + + battery_status, status_error = sitl_flight_controller.get_battery_status() + assert battery_status is not None, f"Battery status retrieval failed: {status_error}" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_modify_and_verify_parameters_on_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can reliably set and verify parameters with real ArduPilot SITL. + + GIVEN: Real SITL instance with actual parameter storage in memory + WHEN: User modifies FRAME_TYPE parameter via MAVLink PARAM_SET + THEN: Parameter should be set and acknowledged by real SITL + AND: Fetching parameter should return the new value from SITL memory + AND: Original value should be restorable without issues + + NOTE: This validates REAL MAVLink parameter protocol including: + - PARAM_SET message handling by actual SITL firmware + - PARAM_VALUE acknowledgment with real timing + - Parameter persistence in SITL memory during session + - Async request/response timing and retry logic + - Actual parameter validation by SITL (value ranges, types) + """ + # Save original value + original_value = sitl_flight_controller.fetch_param("FRAME_TYPE") + assert original_value is not None + + # Set a new value (ensure it's different) + new_value = 1 if original_value != 1 else 2 + sitl_flight_controller.set_param("FRAME_TYPE", float(new_value)) + + # Use helper function with proper polling instead of arbitrary sleep + assert wait_for_param_value(sitl_flight_controller, "FRAME_TYPE", new_value), ( + f"Parameter not set correctly: expected {new_value}" + ) + + # Restore original value + sitl_flight_controller.set_param("FRAME_TYPE", float(original_value)) + assert wait_for_param_value(sitl_flight_controller, "FRAME_TYPE", original_value), "Failed to restore original value" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_retrieve_frame_configuration_from_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can retrieve accurate frame information from real SITL parameters. + + GIVEN: Connected flight controller with real SITL parameter storage + WHEN: User requests frame class and type information + THEN: Frame class and type should be returned correctly as integers + AND: Values should match actual SITL configuration + AND: Frame class should be valid (> 0) + AND: Frame type should be valid (>= 0) + + NOTE: This validates real parameter retrieval and business logic: + - Actual parameter fetch from SITL (FRAME_CLASS, FRAME_TYPE) + - Type conversion from float to int with real data + - Default value handling matches SITL behavior + """ + frame_class, frame_type = sitl_flight_controller.get_frame_info() + + assert isinstance(frame_class, int) + assert isinstance(frame_type, int) + assert frame_class > 0 # Should have valid frame class + assert frame_type >= 0 # Frame type can be 0 + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_configure_custom_network_ports_for_sitl(sitl_manager: "SITLManager") -> None: + """ + User can initialize FlightController with custom network ports for flexible SITL connections. + + GIVEN: Custom network port configuration for SITL (tcp://127.0.0.1:5760) + WHEN: User creates FlightController with custom network_ports parameter + THEN: Custom ports should be stored and used for connection attempts + AND: Connection discovery should include custom port + AND: User can connect to non-standard SITL ports + + NOTE: This validates real configuration flexibility needed for: + - Multiple SITL instances on different ports + - Custom network configurations + - Integration with different SITL setups + """ + # Use the new configurable network_ports feature + custom_port = sitl_manager.connection_string.replace("tcp:", "tcp://") + custom_fc = FlightController(reboot_time=2, baudrate=115200, network_ports=[custom_port]) + + # Verify the custom port was set - check if port number is in the connection string + assert "5760" in custom_fc._network_ports[0] # pylint: disable=protected-access + + # Clean up - always disconnect to ensure proper resource cleanup + custom_fc.disconnect() + + +@pytest.mark.integration +@pytest.mark.sitl +def test_timeout_constants_are_properly_configured(sitl_flight_controller: FlightController) -> None: + """ + Timeout constants are properly defined and accessible for real MAVLink timing. + + GIVEN: Connected flight controller instance + WHEN: Checking timeout and polling constants + THEN: Constants should be properly defined and accessible + AND: Values should be reasonable for real network communication + AND: Constants should match actual usage in MAVLink protocol handlers + + NOTE: These constants control real timing behavior that affects: + - Command acknowledgment timeouts (COMMAND_ACK_TIMEOUT) + - Parameter fetch polling intervals (PARAM_FETCH_POLL_DELAY) + - Battery status request timeouts (BATTERY_STATUS_TIMEOUT) + """ + # Verify new constants are accessible + assert hasattr(sitl_flight_controller, "COMMAND_ACK_TIMEOUT") + assert hasattr(sitl_flight_controller, "PARAM_FETCH_POLL_DELAY") + assert hasattr(sitl_flight_controller, "BATTERY_STATUS_TIMEOUT") + + # Verify constants have reasonable values + assert sitl_flight_controller.COMMAND_ACK_TIMEOUT > 0 + assert sitl_flight_controller.PARAM_FETCH_POLL_DELAY > 0 + assert sitl_flight_controller.BATTERY_STATUS_TIMEOUT > 0 + + +@pytest.mark.integration +@pytest.mark.sitl +def test_firmware_type_extraction_from_real_banner_messages(sitl_flight_controller: FlightController) -> None: + """ + Firmware type can be correctly extracted from various real banner message formats. + + GIVEN: Different banner message formats from real flight controllers + WHEN: Extracting firmware type using _extract_firmware_type_from_banner + THEN: Correct firmware type should be identified (ArduCopter, ArduPlane, etc.) + AND: Method should handle both hardware (with ChibiOS) and SITL (without ChibiOS) banners + AND: Empty banners should return empty string gracefully + + NOTE: This validates real-world banner parsing that users encounter: + - ChibiOS-style banners from real hardware flight controllers + - SITL-style banners without ChibiOS information + - Different ArduPilot vehicle types (Copter, Plane, Rover, etc.) + - Robustness with malformed or missing banner data + """ + # Test with ChibiOS-style banner (typical for hardware) + banner_with_chibios = [ + "Some header message", + "ChibiOS: 12345678", + "ArduCopter V4.5.0 (abc123)", + "Other info", + ] + firmware_type = sitl_flight_controller._extract_firmware_type_from_banner(banner_with_chibios, 1) # pylint: disable=protected-access + assert firmware_type == "ArduCopter" + + # Test with SITL-style banner (no ChibiOS) + banner_sitl = [ + "ArduCopter V4.5.0-dev (12345678)", + "Frame: QUAD", + ] + firmware_type = sitl_flight_controller._extract_firmware_type_from_banner(banner_sitl, None) # pylint: disable=protected-access + assert firmware_type == "ArduCopter" + + # Test with ArduPlane + banner_plane = ["ArduPlane V4.5.0 (abc123)"] + firmware_type = sitl_flight_controller._extract_firmware_type_from_banner(banner_plane, None) # pylint: disable=protected-access + assert firmware_type == "ArduPlane" + + # Test with empty banner + firmware_type = sitl_flight_controller._extract_firmware_type_from_banner([], None) # pylint: disable=protected-access + assert firmware_type == "" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_chibios_version_extraction_from_banner_messages(sitl_flight_controller: FlightController) -> None: + """ + ChibiOS version can be correctly extracted from real flight controller banners. + + GIVEN: Banner messages from real flight controllers with or without ChibiOS + WHEN: Extracting ChibiOS version using _extract_chibios_version_from_banner + THEN: Version and index should be correctly identified when present + AND: Empty version and None index should be returned for SITL (no ChibiOS) + AND: Method should handle various banner formats robustly + + NOTE: This validates real banner parsing needed for: + - Hardware flight controller identification (has ChibiOS) + - SITL vs hardware detection (SITL lacks ChibiOS) + - Firmware version tracking and compatibility checks + """ + # Test with ChibiOS present + banner_with_chibios = [ + "Some header", + "ChibiOS: 12345678", + "ArduCopter V4.5.0", + ] + version, index = sitl_flight_controller._extract_chibios_version_from_banner(banner_with_chibios) # pylint: disable=protected-access + assert version == "12345678" + assert index == 1 + + # Test without ChibiOS (SITL case) + banner_no_chibios = [ + "ArduCopter V4.5.0-dev", + "Frame: QUAD", + ] + version, index = sitl_flight_controller._extract_chibios_version_from_banner(banner_no_chibios) # pylint: disable=protected-access + assert version == "" + assert index is None + + +@pytest.mark.integration +@pytest.mark.sitl +def test_vehicle_detection_from_real_heartbeat_messages(sitl_flight_controller: FlightController) -> None: + """ + Vehicles can be detected from real MAVLink HEARTBEAT messages from SITL. + + GIVEN: Real SITL instance broadcasting HEARTBEAT messages + WHEN: Detecting vehicles using _detect_vehicles_from_heartbeats + THEN: At least one vehicle should be detected with valid system/component IDs + AND: System ID should be positive integer + AND: Component ID should be non-negative integer + AND: Detection should work within reasonable timeout + + NOTE: This validates REAL heartbeat detection that is critical for: + - Initial connection establishment to flight controllers + - Multiple vehicle detection in MAVLink networks + - System/component ID identification for message routing + - Actual async message reception and filtering + """ + # The sitl_flight_controller fixture already has a connection established + # We can test the detection method works by verifying the master connection exists + assert sitl_flight_controller.master is not None + + # Detect vehicles (should find at least the SITL instance) + detected = sitl_flight_controller._detect_vehicles_from_heartbeats(timeout=2) # pylint: disable=protected-access + + # Should have detected at least one vehicle + assert len(detected) > 0 + + # Verify the detected vehicle has valid IDs + for sysid, compid in detected: + assert isinstance(sysid, int) + assert isinstance(compid, int) + assert sysid > 0 # System ID should be positive + assert compid >= 0 # Component ID can be 0 + + +@pytest.mark.integration +@pytest.mark.sitl +def test_protected_methods_remain_accessible_after_refactoring(sitl_flight_controller: FlightController) -> None: + """ + Protected methods remain accessible for testing after Phase 1 refactoring. + + GIVEN: FlightController instance after refactoring to delegation pattern + WHEN: Accessing protected methods (single underscore prefix) + THEN: Methods should exist and be callable (not name-mangled) + AND: Refactoring should maintain testability + AND: Protected methods should not be hidden by double underscore + + NOTE: This validates a key refactoring goal: + - Methods are protected (single _) not private (double __) + - Testing infrastructure can access internal methods when needed + - Refactoring preserved the ability to test implementation details + - Documentation and maintainability are improved + """ + # Verify protected methods exist and are accessible + assert hasattr(sitl_flight_controller, "_extract_firmware_type_from_banner") + assert hasattr(sitl_flight_controller, "_extract_chibios_version_from_banner") + assert hasattr(sitl_flight_controller, "_detect_vehicles_from_heartbeats") + assert hasattr(sitl_flight_controller, "_select_supported_autopilot") + assert hasattr(sitl_flight_controller, "_populate_flight_controller_info") + assert hasattr(sitl_flight_controller, "_retrieve_autopilot_version_and_banner") + + # Verify they are callable + assert callable(sitl_flight_controller._extract_firmware_type_from_banner) # pylint: disable=protected-access + assert callable(sitl_flight_controller._extract_chibios_version_from_banner) # pylint: disable=protected-access + assert callable(sitl_flight_controller._detect_vehicles_from_heartbeats) # pylint: disable=protected-access + + +@pytest.mark.integration +@pytest.mark.sitl +def test_real_mavlink_connection_factory_used_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + Real MAVLink connection factory is used with actual SITL instance. + + GIVEN: FlightController connected to real SITL via TCP + WHEN: Verifying connection manager uses real MAVLink factory + THEN: SystemMavlinkConnectionFactory should be active (not fake) + AND: Connection should be using real PyMAVLink library + AND: Master connection should support full MAVLink protocol + """ + # Connection manager should use real factory in SITL tests + assert isinstance( + sitl_flight_controller._connection_manager._mavlink_connection_factory, # type: ignore[attr-defined] # pylint: disable=protected-access + SystemMavlinkConnectionFactory, + ) + # Master should be a real PyMAVLink connection + assert sitl_flight_controller.master is not None + # Should have recv_match method from real MAVLink + assert hasattr(sitl_flight_controller.master, "recv_match") + # Should have mav attribute with command_long_send for sending commands + assert hasattr(sitl_flight_controller.master, "mav") + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_request_multiple_parameters_from_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can request multiple specific parameters from real SITL in sequence. + + GIVEN: Connected flight controller with parameter download capability + WHEN: Requesting specific parameters (FRAME_CLASS, FRAME_TYPE, BATTERY_MONITOR) + THEN: All requested parameters should be available + AND: Parameter values should be valid numbers + AND: Sequential parameter requests should work correctly with real SITL + + NOTE: Tests real parameter fetch behavior: + - Specific parameter requests (not full list) + - Parameter value type conversion (float to int) + - Real SITL parameter storage consistency + """ + params_to_fetch = ["FRAME_CLASS", "FRAME_TYPE", "BATT_MONITOR"] + retrieved = {} + + for param_name in params_to_fetch: + value = sitl_flight_controller.fetch_param(param_name) + assert value is not None, f"Parameter {param_name} should be retrievable from real SITL" + retrieved[param_name] = value + + # Verify all parameters were retrieved + assert len(retrieved) == len(params_to_fetch) + # All values should be numeric + for param_name, value in retrieved.items(): + assert isinstance(value, (int, float)), f"{param_name} should have numeric value" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_retrieve_flight_controller_info_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can retrieve complete flight controller information from real SITL. + + GIVEN: Real SITL connection with populated flight controller info + WHEN: Accessing flight controller info object + THEN: Firmware type should be populated (ArduCopter) + AND: Flight software version should be available + AND: System and component IDs should be valid + AND: AutoPilot type should be identified + + NOTE: Validates real info population from SITL: + - Firmware type extraction from heartbeat + - Flight version from autopilot_version message + - Hardware identification + - System ID assignment + """ + info = sitl_flight_controller.info + assert info is not None + assert info.firmware_type == "ArduCopter" + assert info.flight_sw_version is not None or info.flight_sw_version == "" + assert info.system_id is not None + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_set_multiple_parameters_on_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can set multiple parameters sequentially on real SITL. + + GIVEN: Real SITL with modifiable parameters + WHEN: Setting multiple parameters in sequence + THEN: Each parameter should be set successfully + AND: Sequential sets should not interfere with each other + AND: Parameter changes should be persisted in real SITL memory + + NOTE: Validates real parameter set behavior: + - PARAM_SET protocol with real SITL + - Sequential parameter modifications + - Real timing and acknowledgments + - Parameter persistence across requests + """ + # Set and verify multiple battery-related parameters + test_params = { + "BATT_MONITOR": 3.0, # Type depends on SITL config, using common value + "BATT_CAPACITY": 5000.0, + } + original_params: dict[str, Optional[float]] = {name: sitl_flight_controller.fetch_param(name) for name in test_params} + + try: + for param_name, value in test_params.items(): + sitl_flight_controller.set_param(param_name, value) + # Verify it was set + retrieved = sitl_flight_controller.fetch_param(param_name) + assert retrieved == value, f"Parameter {param_name} should be set to {value}, got {retrieved}" + finally: + # Restore original parameter values so other SITL tests see the expected defaults + for param_name, original_value in original_params.items(): + if original_value is None: + continue + sitl_flight_controller.set_param(param_name, float(original_value)) + assert wait_for_param_value(sitl_flight_controller, param_name, float(original_value)), ( + f"Failed to restore {param_name}" + ) + + +@pytest.mark.integration +@pytest.mark.sitl +def test_heartbeat_detection_includes_system_and_component_ids(sitl_flight_controller: FlightController) -> None: + """ + Heartbeat detection from real SITL includes valid system and component IDs. + + GIVEN: Real SITL broadcasting heartbeat messages + WHEN: Detecting vehicles from heartbeat messages + THEN: Detected vehicles should have valid system IDs (1+ for SITL) + AND: Component IDs should be valid (0-255 range) + AND: Detection should yield at least one vehicle (the SITL instance) + + NOTE: Validates real heartbeat protocol: + - System ID ranges (SITL typically uses 1) + - Component ID ranges (MAV_COMP_ID_AUTOPILOT typically 1) + - Real heartbeat message structure + - Multiple heartbeats with same IDs + """ + detected = sitl_flight_controller._detect_vehicles_from_heartbeats(timeout=2) # pylint: disable=protected-access + assert len(detected) > 0 + + for sysid, compid in detected: + # System ID should be valid SITL ID (typically 1) + assert 1 <= sysid <= 255 + # Component ID should be in valid range + assert 0 <= compid <= 255 + + +@pytest.mark.integration +@pytest.mark.sitl +def test_parameter_download_includes_expected_autopilot_parameters(sitl_flight_controller: FlightController) -> None: + """ + Parameter download from real SITL includes all expected autopilot parameters. + + GIVEN: Real SITL with full parameter set downloaded + WHEN: Downloading complete parameter list + THEN: Critical autopilot parameters should be included + AND: Parameter count should be substantial (>100 for ArduCopter) + AND: All expected parameter categories should be present + + NOTE: Validates real parameter download completeness: + - Frame configuration parameters (FRAME_CLASS, FRAME_TYPE) + - Motor/servo parameters + - Battery monitoring parameters + - Compass/sensor parameters + - Flight mode parameters + - Real parameter diversity and quantity + """ + params, _ = sitl_flight_controller.download_params() + + assert isinstance(params, dict) + assert len(params) > 50 # ArduCopter has many parameters + + # Verify categories of parameters exist + critical_param_prefixes = ["FRAME", "BATT", "COMPASS", "SERVO", "MOT"] + found_categories = dict.fromkeys(critical_param_prefixes, False) + + for param_name in params: + for prefix in critical_param_prefixes: + if param_name.startswith(prefix): + found_categories[prefix] = True + + # At least some critical parameters should be present + assert any(found_categories.values()), "Should find at least some critical parameter categories" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_real_connection_disconnect_cleanup(sitl_flight_controller: FlightController) -> None: + """ + Real MAVLink connection cleanup works correctly on disconnect. + + GIVEN: Real SITL connection that is active + WHEN: Calling disconnect + THEN: Master connection should be closed + AND: Master should be set to None + AND: Subsequent operations should handle no connection gracefully + + NOTE: Validates real connection cleanup: + - Socket closure from PyMAVLink + - Resource release + - Clean state after disconnect + - No resource leaks from real connections + """ + # Verify connection is active + assert sitl_flight_controller.master is not None + + # Disconnect + sitl_flight_controller.disconnect() + + # Verify cleanup + assert sitl_flight_controller.master is None + + +@pytest.mark.integration +@pytest.mark.sitl +def test_system_selection_identifies_real_autopilot_type(sitl_flight_controller: FlightController) -> None: + """ + System selection correctly identifies real autopilot type from SITL. + + GIVEN: Real SITL with specific autopilot type (ArduCopter) + WHEN: Running system selection (autopilot detection) + THEN: Autopilot type should be correctly identified + AND: Supported autopilot should be selected + AND: Type identification should work with real heartbeat data + + NOTE: Validates real autopilot detection: + - Type identification from MAV_TYPE in heartbeat + - ArduCopter = MAV_TYPE_QUADROTOR typically + - Real firmware detection + - Correct autopilot selection from detected type + """ + # The sitl_flight_controller is already connected and initialized + # Verify autopilot was correctly selected + assert sitl_flight_controller.info.firmware_type == "ArduCopter" + assert sitl_flight_controller.info.autopilot.startswith("ArduPilot"), ( + f"Unexpected autopilot type: {sitl_flight_controller.info.autopilot}" + ) + + +@pytest.mark.integration +@pytest.mark.sitl +def test_banner_extraction_from_real_sitl_messages(sitl_flight_controller: FlightController) -> None: + """ + Banner extraction works correctly with real SITL message data. + + GIVEN: Real SITL connection with banner messages available + WHEN: Extracting firmware type and ChibiOS version from actual messages + THEN: Banner should be correctly parsed + AND: ArduCopter should be identified from banner + AND: SITL banner format should be handled correctly (no ChibiOS typically) + + NOTE: Validates real banner parsing: + - SITL-specific banner format (no ChibiOS) + - Real message content structure + - Firmware version line identification + - Robustness with actual SITL output + """ + # Retrieve autopilot version to get banner + sitl_flight_controller._retrieve_autopilot_version_and_banner(timeout=5) # pylint: disable=protected-access + + # Verify banner data is available and correct + assert sitl_flight_controller.info.firmware_type == "ArduCopter" + # For SITL, flight version should be extracted + assert sitl_flight_controller.info.flight_sw_version is not None or sitl_flight_controller.info.flight_sw_version == "" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_real_mavlink_timeout_behavior_with_sitl(sitl_flight_controller: FlightController) -> None: + """ + Real MAVLink connection handles timeout correctly with SITL. + + GIVEN: Real SITL connection with configurable timeout + WHEN: Setting timeout and attempting to receive messages + THEN: Timeout should be respected by real PyMAVLink + AND: Operations should not hang indefinitely + AND: Connection should remain valid after timeout + + NOTE: Validates real timeout handling: + - PyMAVLink timeout in recv_match + - Real network timeout behavior + - Connection persistence after timeout + - No deadlocks with real sockets + """ + # Verify master connection exists + assert sitl_flight_controller.master is not None + + # Try receiving with timeout (should timeout if no heartbeat waiting) + # pyright: ignore[reportOptionalMemberAccess] + msg = sitl_flight_controller.master.recv_match(timeout=0.1) # type: ignore[union-attr] + + # Connection should still be valid after timeout + assert sitl_flight_controller.master is not None + # msg can be None (timeout) or a message (heartbeat caught) + assert msg is None or hasattr(msg, "get_type") # Verify message type if received + + +@pytest.mark.integration +@pytest.mark.sitl +def test_command_ack_reception_from_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + COMMAND_ACK reception works correctly with real SITL acknowledgments. + + GIVEN: Real SITL that sends COMMAND_ACK for motor test commands + WHEN: Sending motor test command and waiting for acknowledgment + THEN: Real COMMAND_ACK should be received from SITL + AND: Acknowledgment should have valid result code + AND: Sequence number should match request + + NOTE: Validates real COMMAND_ACK protocol: + - SITL command processing + - Real MAVLink acknowledgment messages + - Sequence number tracking + - Result code interpretation + - Async command/response timing + """ + # Motor test already sends command and waits for ACK + success, error_msg = sitl_flight_controller.test_motor( + test_sequence_nr=0, motor_letters="A", motor_output_nr=1, throttle_percent=10, timeout_seconds=2 + ) + + # Real SITL should acknowledge motor test command + assert success, f"Motor test should succeed with real SITL: {error_msg}" + # Error message should be empty on success + assert error_msg == "" or error_msg is None, f"Expected empty error message on success, got: {error_msg}" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_parameter_fetch_polling_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + Parameter fetch polling works correctly with real SITL delays. + + GIVEN: Real SITL with actual network latency + WHEN: Fetching parameter that may not be immediately available + THEN: Polling should retry with real delays + AND: Parameter should eventually be retrieved + AND: Polling should respect configured delays + + NOTE: Validates real polling behavior: + - PARAM_REQUEST_READ protocol with real SITL + - Network latency handling + - Retry logic with actual timing + - Parameter availability delays + """ + # Fetch a parameter that should exist + param_name = "FRAME_TYPE" + value = sitl_flight_controller.fetch_param(param_name) + + # Should eventually retrieve it despite any SITL delays + assert value is not None, f"Parameter {param_name} should be retrieved with polling" + assert isinstance(value, (int, float)), f"Parameter value should be numeric, got {type(value)}" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_real_message_filtering_in_mavlink_stream(sitl_flight_controller: FlightController) -> None: + """ + Real MAVLink message filtering works with SITL message stream. + + GIVEN: Real SITL sending continuous MAVLink message stream + WHEN: Using recv_match with message filtering + THEN: Should correctly filter real messages + AND: Only matching message types should be returned + AND: Message stream should remain consistent + + NOTE: Validates real message filtering: + - PyMAVLink message type filtering + - Real SITL message stream characteristics + - Multiple message type handling + - Stream consistency after filtering + """ + # Master should exist + assert sitl_flight_controller.master is not None + + # Try to receive specific message types with short timeout + # Heartbeat is almost always in stream + heartbeat = sitl_flight_controller.master.recv_match(type="HEARTBEAT", timeout=0.5) # type: ignore[union-attr] + + # May or may not get message in timeout, but should not error + if heartbeat is not None: + # Verify it's actually a heartbeat message + assert hasattr(heartbeat, "get_type"), "Message should have get_type method" + assert heartbeat.get_type() == "HEARTBEAT", f"Received message should be HEARTBEAT, got {heartbeat.get_type()}" + # If timeout occurs, heartbeat will be None, which is acceptable + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_test_all_motors_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can test all motors simultaneously with real SITL. + + GIVEN: Connected flight controller with real SITL + WHEN: User tests all motors simultaneously at 25% throttle + THEN: Motor test command should be sent successfully + AND: Command acknowledgments should be received + AND: SITL should accept all motor test commands + + NOTE: This validates real protocol behavior: + - Actual MAVLink command_long_send for multiple motors + - Real COMMAND_ACK message processing + - Genuine command acknowledgment timing + - True motor test command handling in SITL + """ + # Given: Connected to real SITL + assert sitl_flight_controller.master is not None + + # When: Test all 4 motors at 25% throttle for 1 second + success, error = sitl_flight_controller.test_all_motors(nr_of_motors=4, throttle_percent=25, timeout_seconds=1) + + # Then: Command should be sent successfully (no connection error) + assert success is True, f"test_all_motors should succeed with real SITL, got error: {error}" + assert error == "" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_test_motors_in_sequence_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can test motors in sequence with real SITL. + + GIVEN: Connected flight controller with real SITL + WHEN: User tests motors in sequence starting from motor 1 + THEN: Sequential motor test command should be sent + AND: Command acknowledgment should be received + AND: SITL should accept sequential motor test command + + NOTE: This validates real SITL behavior: + - Actual MAVLink sequential motor test command + - Real COMMAND_ACK processing + - Genuine sequence test command handling + - Correct parameter encoding for sequence tests + """ + # Given: Connected to real SITL + assert sitl_flight_controller.master is not None + + # When: Test 4 motors in sequence starting from motor 1 at 30% throttle for 1 second each + success, error = sitl_flight_controller.test_motors_in_sequence( + start_motor=1, motor_count=4, throttle_percent=30, timeout_seconds=1 + ) + + # Then: Command acknowledgment should be received + assert success is True, f"test_motors_in_sequence should succeed with real SITL, got error: {error}" + assert error == "" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_stop_all_motors_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can emergency stop all motors with real SITL. + + GIVEN: Connected flight controller with real SITL + WHEN: User executes stop all motors command + THEN: Motor stop command should be sent successfully + AND: Command acknowledgment should be received + AND: SITL should accept motor stop command + + NOTE: This validates real emergency stop behavior: + - Actual MAVLink motor stop command (0% throttle) + - Real COMMAND_ACK for stop command + - Genuine emergency stop handling in SITL + - Correct param encoding for all-motors stop + """ + # Given: Connected to real SITL + assert sitl_flight_controller.master is not None + + # When: Stop all motors + success, error = sitl_flight_controller.stop_all_motors() + + # Then: Command acknowledgment should be received + assert success is True, f"stop_all_motors should succeed with real SITL, got error: {error}" + assert error == "" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_battery_status_retrieval_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + Battery status can be retrieved from real SITL. + + GIVEN: Connected flight controller with real SITL + WHEN: User requests battery status + THEN: Battery voltage and current should be retrieved + AND: Values should be within realistic ranges for simulated battery + AND: No connection errors should occur + + NOTE: This validates real battery telemetry: + - Actual BATTERY_STATUS message reception from SITL + - Real voltage/current conversion from MAVLink units + - Genuine battery monitoring parameter validation + - Correct telemetry unit handling (millivolts, centiamps) + """ + # Given: Connected to real SITL + assert sitl_flight_controller.master is not None + + # Ensure parameters are available (required by battery helpers) + if not sitl_flight_controller.fc_parameters: + params, _ = sitl_flight_controller.download_params() + sitl_flight_controller.fc_parameters = params + + desired_monitor = 4.0 # SITL simulated battery monitor type + original_monitor = sitl_flight_controller.fetch_param("BATT_MONITOR") + if original_monitor is None: + pytest.fail("BATT_MONITOR parameter not available from SITL") + + monitor_changed = float(original_monitor) != desired_monitor + if monitor_changed: + sitl_flight_controller.set_param("BATT_MONITOR", desired_monitor) + assert wait_for_param_value(sitl_flight_controller, "BATT_MONITOR", desired_monitor), ( + "Failed to configure battery monitor for SITL" + ) + + try: + # Request periodic battery status first + success, error = sitl_flight_controller.request_periodic_battery_status() + assert success is True, f"Failed to request battery status: {error}" + + # Wait for SITL to start sending battery messages and poll until data arrives + wait_window = max(5.0, sitl_flight_controller.BATTERY_STATUS_TIMEOUT * 10) + deadline = time.time() + wait_window + battery_status: Optional[tuple[float, float]] = None + error = "" + while time.time() < deadline and battery_status is None: + battery_status, error = sitl_flight_controller.get_battery_status() + if battery_status is None: + time.sleep(0.25) + + # Then: Battery status should be retrieved + assert battery_status is not None, f"Battery status should be available from SITL, got error: {error}" + assert len(battery_status) == 2, "Battery status should contain voltage and current" + + voltage, current = battery_status + assert isinstance(voltage, (int, float)), f"Voltage should be numeric, got {type(voltage)}" + assert isinstance(current, (int, float)), f"Current should be numeric, got {type(current)}" + + # SITL simulates a typical battery, so values should be reasonable + assert voltage > 0, f"Voltage should be positive, got {voltage}V" + assert voltage < 50, f"Voltage should be reasonable for a battery, got {voltage}V" + assert current >= 0, f"Current should be non-negative, got {current}A" + finally: + if monitor_changed: + sitl_flight_controller.set_param("BATT_MONITOR", float(original_monitor)) + assert wait_for_param_value(sitl_flight_controller, "BATT_MONITOR", float(original_monitor)), ( + "Failed to restore original BATT_MONITOR value" + ) + + +@pytest.mark.integration +@pytest.mark.sitl +def test_wrapper_methods_work_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + Wrapper methods correctly delegate to business logic with real SITL. + + GIVEN: Connected flight controller with real SITL parameters + WHEN: User calls wrapper methods for voltage thresholds and frame info + THEN: Should return actual values from SITL parameters + AND: Voltage thresholds should be reasonable + AND: Frame info should match SITL configuration (Copter) + + NOTE: This validates real parameter delegation: + - Actual parameter retrieval from SITL + - Correct business logic delegation + - Real voltage threshold calculations + - Genuine frame class/type from SITL parameters + """ + # Given: Connected to real SITL + assert sitl_flight_controller.master is not None + + # When: Check if battery monitoring is enabled + enabled = sitl_flight_controller.is_battery_monitoring_enabled() + # SITL typically has battery monitoring enabled, but might vary + assert isinstance(enabled, bool), "Battery monitoring check should return boolean" + + # When: Get voltage thresholds + min_volt, max_volt = sitl_flight_controller.get_voltage_thresholds() + # Should have valid thresholds (may be defaults if not set) + assert isinstance(min_volt, (int, float)), f"Min voltage should be numeric, got {type(min_volt)}" + assert isinstance(max_volt, (int, float)), f"Max voltage should be numeric, got {type(max_volt)}" + + # When: Get frame info + frame_class, frame_type = sitl_flight_controller.get_frame_info() + # SITL runs ArduCopter, so frame class should indicate copter + assert isinstance(frame_class, (int, float)), f"Frame class should be numeric, got {type(frame_class)}" + assert isinstance(frame_type, (int, float)), f"Frame type should be numeric, got {type(frame_type)}" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_create_mavftp_with_real_sitl_connection(sitl_flight_controller: FlightController) -> None: + """ + User can create MAVFTP instance with real SITL connection. + + GIVEN: Connected flight controller with real SITL and active master connection + WHEN: Creating MAVFTP instance from the connection + THEN: MAVFTP should be created successfully + AND: MAVFTP should be initialized with correct target system/component + AND: Connection should support MAVFTP protocol + + NOTE: This validates real MAVFTP initialization: + - Real MAVLink connection handoff to MAVFTP + - Correct target system/component parameters + - Real SITL MAVFTP support detection + - MAVFTP protocol initialization with real SITL + """ + # Given: Connected flight controller with real master + assert sitl_flight_controller.master is not None + + # When: Create MAVFTP with real connection + try: + mavftp = create_mavftp(sitl_flight_controller.master) + + # Then: MAVFTP should be created successfully + assert mavftp is not None + # MAVFTP initialization should complete without errors + except RuntimeError as e: + pytest.fail(f"Failed to create MAVFTP with real SITL connection: {e}") + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_create_mavftp_safely_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can safely create MAVFTP with error handling using real SITL. + + GIVEN: Connected flight controller with real SITL + WHEN: Creating MAVFTP using safe factory function + THEN: MAVFTP should be created successfully + AND: No exceptions should be raised + AND: Returned MAVFTP should be usable + + NOTE: Validates safe MAVFTP creation: + - Safe factory with error handling + - Real SITL connection handling + - Error tolerance for MAVFTP initialization + - Production-ready MAVFTP creation pattern + """ + # Given: Connected flight controller with real master + assert sitl_flight_controller.master is not None + + # When: Create MAVFTP safely + mavftp = create_mavftp_safe(sitl_flight_controller.master) + + # Then: MAVFTP should be created or None if unavailable + # Either outcome is acceptable (some SITL configs may not support MAVFTP) + assert mavftp is None or mavftp is not None + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_upload_files_via_files_manager(sitl_flight_controller: FlightController, tmp_path: Path) -> None: + """ + User can push configuration files to the controller via MAVFTP. + + GIVEN: Connected SITL controller with MAVFTP enabled + WHEN: The files manager uploads a local configuration file with progress tracking + THEN: The upload should succeed without errors + AND: The remote file contents should match the local file after transfer + """ + files_mgr = FlightControllerFiles(connection_manager=sitl_flight_controller) + mavftp = create_mavftp_safe(sitl_flight_controller.master) + if mavftp is None: + pytest.skip("MAVFTP not available in this SITL build") + _ensure_remote_logs_directory(mavftp) + + local_file = tmp_path / "copilot_upload.txt" + local_file.write_text("Uploaded from integration test", encoding="UTF-8") + remote_filename = f"/APM/LOGS/copilot_{int(time.time())}.TXT" + + progress_updates: list[tuple[int, int]] = [] + + def progress_callback(current: int, total: int) -> None: + progress_updates.append((current, total)) + + cleanup_needed = False + try: + result = files_mgr.upload_file( + local_filename=str(local_file), + remote_filename=remote_filename, + progress_callback=progress_callback, + ) + + assert result is True + cleanup_needed = True + if progress_updates: + assert progress_updates[-1][1] == 100 + + verification_file = tmp_path / "copilot_upload_verify.txt" + mavftp.cmd_get([remote_filename, str(verification_file)]) + ret = mavftp.process_ftp_reply("OpenFileRO", timeout=FlightControllerFiles.MAVFTP_FILE_OPERATION_TIMEOUT) + assert ret.error_code == 0 + assert verification_file.read_text(encoding="UTF-8") == local_file.read_text(encoding="UTF-8") + finally: + if cleanup_needed: + mavftp.cmd_rm([remote_filename]) + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_download_synthetic_log_via_files_manager(sitl_flight_controller: FlightController, tmp_path: Path) -> None: # pylint: disable=too-many-locals + """ + User can recover a prepared log file through the files manager. + + GIVEN: A synthetic log file is staged on the SITL controller via MAVFTP + WHEN: The files manager requests the last available log with progress feedback + THEN: The download should succeed and write the expected bytes locally + AND: Progress callbacks should be invoked during transfer + """ + files_mgr = FlightControllerFiles(connection_manager=sitl_flight_controller) + mavftp = create_mavftp_safe(sitl_flight_controller.master) + if mavftp is None: + pytest.skip("MAVFTP not available in this SITL build") + _ensure_remote_logs_directory(mavftp) + + remote_log_number = 9876 + remote_filename = f"/APM/LOGS/{remote_log_number:08}.BIN" + local_source = tmp_path / "synthetic_remote_log.bin" + local_source.write_bytes(b"synthetic SITL log for download test") + lastlog_remote = "/APM/LOGS/LASTLOG.TXT" + lastlog_backup = tmp_path / "lastlog_backup.txt" + lastlog_backed_up = _backup_remote_file(mavftp, lastlog_remote, lastlog_backup) + + # Remove any leftovers from previous runs (ignore errors) + mavftp.cmd_rm([remote_filename]) + + mavftp.cmd_put([str(local_source), remote_filename]) + put_reply = mavftp.process_ftp_reply("CreateFile", timeout=FlightControllerFiles.MAVFTP_FILE_OPERATION_TIMEOUT) + assert put_reply.error_code == 0 + + staged_lastlog = tmp_path / "lastlog_staged.txt" + staged_lastlog.write_text(f"{remote_log_number}\n", encoding="UTF-8") + mavftp.cmd_put([str(staged_lastlog), lastlog_remote]) + lastlog_reply = mavftp.process_ftp_reply("CreateFile", timeout=FlightControllerFiles.MAVFTP_FILE_OPERATION_TIMEOUT) + assert lastlog_reply.error_code == 0 + + downloaded_file = tmp_path / "downloaded_synthetic_log.bin" + progress_updates: list[tuple[int, int]] = [] + + def download_progress(current: int, total: int) -> None: + progress_updates.append((current, total)) + + try: + result = files_mgr.download_last_flight_log( + local_filename=str(downloaded_file), + progress_callback=download_progress, + ) + + assert result is True + assert downloaded_file.read_bytes() == local_source.read_bytes() + if progress_updates: + assert progress_updates[-1][1] == 100 + finally: + mavftp.cmd_rm([remote_filename]) + if lastlog_backed_up: + mavftp.cmd_put([str(lastlog_backup), lastlog_remote]) + restore_reply = mavftp.process_ftp_reply( + "CreateFile", + timeout=FlightControllerFiles.MAVFTP_FILE_OPERATION_TIMEOUT, + ) + assert restore_reply.error_code == 0 + else: + mavftp.cmd_rm([lastlog_remote]) + + +@pytest.mark.integration +@pytest.mark.sitl +def test_mavftp_factory_handles_none_connection_safely() -> None: + """ + MAVFTP factory handles None connection gracefully in real scenario. + + GIVEN: No connection available + WHEN: Calling create_mavftp with None connection + THEN: RuntimeError should be raised with clear message + AND: Error message should indicate connection requirement + + NOTE: Validates error handling: + - Proper error for missing connection + - Clear error messaging + - No silent failures + - Production error handling + """ + # When/Then: Should raise RuntimeError + with pytest.raises(RuntimeError, match="No MAVLink connection available for MAVFTP"): + create_mavftp(None) + + +@pytest.mark.integration +@pytest.mark.sitl +def test_mavftp_safe_factory_returns_none_for_none_connection() -> None: + """ + MAVFTP safe factory returns None gracefully for None connection. + + GIVEN: No connection available + WHEN: Calling create_mavftp_safe with None connection + THEN: Should return None without raising exception + AND: No error should occur + AND: Can be called safely in optional scenarios + + NOTE: Validates safe error handling: + - Optional MAVFTP creation pattern + - Graceful None handling + - No exceptions in safe factory + - Production-ready optional pattern + """ + # When: Call safe factory with None + mavftp = create_mavftp_safe(None) + + # Then: Should return None gracefully + assert mavftp is None + + +@pytest.mark.integration +@pytest.mark.sitl +def test_mavftp_targets_real_system_and_component_ids(sitl_flight_controller: FlightController) -> None: + """ + MAVFTP initialization uses correct real system and component IDs from SITL. + + GIVEN: Real SITL with specific target system and component IDs + WHEN: Creating MAVFTP with SITL connection + THEN: MAVFTP should use correct target_system from connection + AND: MAVFTP should use correct target_component from connection + AND: Command routing should work correctly with SITL + + NOTE: Validates real ID handling: + - Correct system ID propagation from SITL + - Correct component ID propagation from SITL + - MAVFTP protocol respects target IDs + - Real message routing to correct autopilot + """ + # Given: Connected flight controller + assert sitl_flight_controller.master is not None + assert sitl_flight_controller.master.target_system is not None # type: ignore[union-attr] + assert sitl_flight_controller.master.target_component is not None # type: ignore[union-attr] + + # When: Create MAVFTP + try: + mavftp = create_mavftp(sitl_flight_controller.master) + # Then: MAVFTP created with correct target parameters + # MAVFTP uses target_system and target_component from the connection + assert mavftp is not None + except RuntimeError as e: + pytest.fail(f"MAVFTP creation failed with real target IDs: {e}") + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_download_parameters_via_mavlink_with_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can download parameters via MAVLink protocol from real SITL. + + GIVEN: Real SITL with MAVLink connection + WHEN: Downloading parameters using MAVLink protocol + THEN: Parameters should be downloaded successfully + AND: Parameter count should be non-zero + AND: Should contain expected autopilot parameters + + NOTE: This validates: + - MAVLink parameter download path + - Real parameter streaming from SITL + - Parameter caching behavior + - Progress callback invocation + """ + # Given: Real SITL connection + assert sitl_flight_controller.master is not None + + # Clear any cached parameters first + sitl_flight_controller.fc_parameters.clear() + assert sitl_flight_controller.fc_parameters == {} + + # When: Download parameters with progress tracking + download_progress: list[tuple[int, int]] = [] + + def progress_callback(current: int, total: int) -> None: + download_progress.append((current, total)) + + params, _ = sitl_flight_controller.download_params(progress_callback=progress_callback) + + # Then: Parameters downloaded successfully + assert len(params) > 0, "Should download at least some parameters" + assert len(download_progress) > 0, "Progress callback should have been invoked" + assert sitl_flight_controller.fc_parameters == params, "Parameters should be cached" + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_set_and_verify_parameter_on_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + User can set a parameter on real SITL and verify it was set. + + GIVEN: Real SITL with valid parameters downloaded + WHEN: Setting a parameter value and fetching it back + THEN: Parameter should be set successfully + AND: Fetched value should match what was set + AND: Should verify round-trip consistency + + NOTE: This validates: + - Parameter set via MAVLink + - Parameter fetch via MAVLink + - Value consistency across round-trip + - Real FC parameter handling + """ + # Given: Real SITL with parameters + assert sitl_flight_controller.master is not None + + # Download parameters first + params, _ = sitl_flight_controller.download_params() + assert len(params) > 0 + + # Pick a parameter to test (use a safe one) + param_name = "BATT_MONITOR" + + # When: Set parameter to a test value + success, error = sitl_flight_controller.set_param(param_name, 4.0) + + # Then: Set operation should complete + assert success is True, f"Failed to set parameter: {error}" + + # When: Fetch it back immediately + fetched_value = sitl_flight_controller.fetch_param(param_name, timeout=2) + + # Then: Should retrieve the value (or be in cache) + if fetched_value is not None: + # Value was fetched from FC + assert fetched_value == 4.0 or abs(fetched_value - 4.0) < 0.01 + + +@pytest.mark.integration +@pytest.mark.sitl +def test_parameter_cache_persists_across_operations_on_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + Parameter cache persists across multiple operations on real SITL connection. + + GIVEN: Real SITL with parameters downloaded and cached + WHEN: Performing multiple parameter operations + THEN: Cache should maintain parameters across operations + AND: Connection should remain stable + AND: Parameters can be cleared and redownloaded + + NOTE: This validates: + - Cache persistence across operations + - Connection stability + - Cache clearing and rebuild + - State management with real SITL + """ + # Given: Real SITL with cached parameters + assert sitl_flight_controller.master is not None + + # Download and cache parameters + params, _ = sitl_flight_controller.download_params() + assert len(params) > 0 + assert len(sitl_flight_controller.fc_parameters) > 0 + + initial_cache_size = len(sitl_flight_controller.fc_parameters) + + # When: Clear cache + sitl_flight_controller.fc_parameters.clear() + + # Then: Cache should be empty + assert sitl_flight_controller.fc_parameters == {} + assert sitl_flight_controller.master is not None # Connection still active + + # When: Redownload parameters + sitl_flight_controller.download_params() + + # Then: Cache should be repopulated + assert len(sitl_flight_controller.fc_parameters) > 0 + assert len(sitl_flight_controller.fc_parameters) == initial_cache_size + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_connect_via_tcp_with_explicit_device_string(sitl_manager: "SITLManager") -> None: + """ + User can connect to real SITL using explicit TCP device string. + + GIVEN: Real SITL running on tcp:127.0.0.1:5760 + WHEN: User connects with explicit device string + THEN: Connection should be established successfully + AND: Master connection should be available + AND: Flight controller info should be populated + + NOTE: This validates explicit connection workflow: + - Direct device connection (not auto-detection) + - TCP connection establishment + - Full connection initialization + - Info population from real SITL + """ + # Given: Real SITL connection string + if not sitl_manager.ensure_running(): + pytest.fail("SITL instance is not running") + connection_string = sitl_manager.connection_string + + # When: Connect with explicit device string + info = FlightControllerInfo() + connection = FlightControllerConnection(info=info) + error = connection.connect(device=connection_string, log_errors=False) + + # Then: Connection should succeed + assert error == "", f"Connection should succeed, got error: {error}" + assert connection.master is not None + assert info.firmware_type == "ArduCopter" + + # Cleanup + connection.disconnect() + + +@pytest.mark.integration +@pytest.mark.sitl +def test_user_can_connect_with_empty_device_to_auto_detect_network(sitl_manager: "SITLManager") -> None: + """ + User can connect with empty device string to trigger network auto-detection. + + GIVEN: Real SITL on standard network port + WHEN: User connects with empty device string + THEN: Auto-detection should try serial ports first + AND: Should fallback to network ports (tcp/udp) + AND: Connection should succeed with network port + + NOTE: This validates auto-detection workflow: + - Serial port detection attempted first + - Network port fallback on no serial + - Real network connection establishment + - Full initialization with auto-detected port + """ + # Given: Empty device string to trigger auto-detection + if not sitl_manager.ensure_running(): + pytest.fail("SITL instance is not running") + info = FlightControllerInfo() + connection = FlightControllerConnection( + info=info, + network_ports=[sitl_manager.connection_string], # Ensure our SITL port is in the list + ) + + # When: Connect with empty string (auto-detect) + error = connection.connect(device="", log_errors=False) + + # Then: Should connect via network fallback + assert error == "", f"Auto-detection should succeed, got error: {error}" + assert connection.master is not None + assert info.firmware_type == "ArduCopter" + + # Cleanup + connection.disconnect() + + +@pytest.mark.integration +@pytest.mark.sitl +def test_connection_with_none_device_returns_immediately() -> None: + """ + Connection with 'none' device string returns without attempting connection. + + GIVEN: User wants to skip connection (file-based parameter editing) + WHEN: Connecting with device='none' + THEN: Should return empty error immediately + AND: Master should remain None + AND: No connection attempt should be made + + NOTE: This validates special 'none' device behavior: + - Skip connection for file-based operations + - No network traffic generated + - Immediate return without error + - Useful for parameter file editing without FC + """ + # Given: Connection manager + info = FlightControllerInfo() + connection = FlightControllerConnection(info=info) + + # When: Connect with 'none' + error = connection.connect(device="none") + + # Then: Should return without error, no connection made + assert error == "" + assert connection.master is None + + +@pytest.mark.integration +@pytest.mark.sitl +def test_discover_connections_includes_network_ports_with_real_sitl(sitl_manager: "SITLManager") -> None: + """ + Connection discovery includes network ports for SITL access. + + GIVEN: SITL connection manager with network ports configured + WHEN: User discovers available connections + THEN: Network ports should be included in list + AND: TCP and UDP options should be available + AND: Connection tuples should include descriptions + + NOTE: This validates connection discovery: + - Network port enumeration + - Serial port discovery (if available) + - Connection tuple formatting + - "Add another" option included + """ + # Given: Connection with network ports + if not sitl_manager.ensure_running(): + pytest.fail("SITL instance is not running") + info = FlightControllerInfo() + connection = FlightControllerConnection( + info=info, + network_ports=[sitl_manager.connection_string], + ) + + # When: Discover connections + connection.discover_connections() + tuples = connection.get_connection_tuples() + + # Then: Should include network ports + assert len(tuples) > 0 + # Check for our SITL connection string (may have tcp: or tcp://) + assert any("5760" in t[0] for t in tuples), f"Should include SITL port, got: {tuples}" + # Last tuple should be "Add another" + assert tuples[-1] == ("Add another", "Add another") + + +@pytest.mark.integration +@pytest.mark.sitl +def test_connection_retry_logic_with_real_sitl(sitl_manager: "SITLManager") -> None: + """ + Connection retry logic works correctly with real SITL. + + GIVEN: Real SITL that may have brief connection delays + WHEN: Creating connection with retry enabled + THEN: Should successfully connect within retry attempts + AND: Retry count should be configurable + AND: Connection should be established despite transient issues + + NOTE: This validates retry behavior: + - Configurable retry count + - Real network retry logic + - Transient error handling + - Final success after retries + """ + # Given: Connection manager with retry configuration + if not sitl_manager.ensure_running(): + pytest.fail("SITL instance is not running") + info = FlightControllerInfo() + connection = FlightControllerConnection(info=info) + connection.comport = mavutil.SerialPort(device=sitl_manager.connection_string, description="SITL Test") + + # When: Create connection with 3 retries + error = connection.create_connection_with_retry( + progress_callback=None, + retries=3, + timeout=5, + log_errors=False, + ) + + # Then: Should connect successfully + assert error == "", f"Connection with retries should succeed, got: {error}" + assert connection.master is not None + assert info.firmware_type == "ArduCopter" + + # Cleanup + connection.disconnect() + + +@pytest.mark.integration +@pytest.mark.sitl +def test_autopilot_selection_from_real_heartbeats(sitl_flight_controller: FlightController) -> None: + """ + Autopilot selection works correctly with real SITL heartbeats. + + GIVEN: Real SITL sending heartbeat messages + WHEN: Selecting supported autopilot from detected vehicles + THEN: Should identify ArduPilot as supported + AND: Should set system and component IDs + AND: Should populate autopilot type + + NOTE: This validates autopilot selection: + - Real heartbeat processing + - Autopilot type identification + - System/component ID assignment + - Supported autopilot verification + """ + # Given: Connected flight controller + assert sitl_flight_controller.master is not None + + # When: Detect vehicles and select autopilot + detected = sitl_flight_controller._detect_vehicles_from_heartbeats(timeout=2) # pylint: disable=protected-access + assert len(detected) > 0 + + # Recreate selection logic to test + # (Already done during connection, but we can verify the result) + # Then: Autopilot should be selected + assert sitl_flight_controller.info.autopilot.startswith("ArduPilot") + assert sitl_flight_controller.info.is_supported is True + assert sitl_flight_controller.info.system_id is not None + assert sitl_flight_controller.info.component_id is not None + + +@pytest.mark.integration +@pytest.mark.sitl +def test_autopilot_version_retrieval_from_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + Autopilot version can be retrieved from real SITL via MAVLink. + + GIVEN: Real SITL with autopilot version info + WHEN: Requesting AUTOPILOT_VERSION message + THEN: Message should be received and processed + AND: Flight software version should be populated + AND: Board version should be available + AND: Capabilities should be set + + NOTE: This validates version retrieval: + - MAV_CMD_REQUEST_MESSAGE protocol + - AUTOPILOT_VERSION message reception + - Version info population + - Real SITL version data + """ + # Given: Connected flight controller + assert sitl_flight_controller.master is not None + + # Flight controller info should have version populated from connection + info = sitl_flight_controller.info + assert info.flight_sw_version is not None or info.flight_sw_version == "" + # SITL should have some capabilities + assert info.capabilities is not None + + +@pytest.mark.integration +@pytest.mark.sitl +def test_banner_request_and_reception_from_real_sitl(sitl_flight_controller: FlightController) -> None: + """ + Banner can be requested and received from real SITL. + + GIVEN: Real SITL that supports banner command + WHEN: Requesting banner via MAV_CMD_DO_SEND_BANNER + THEN: STATUS_TEXT messages should be received + AND: Banner should contain firmware information + AND: Banner parsing should extract firmware type + + NOTE: This validates banner protocol: + - MAV_CMD_DO_SEND_BANNER command + - STATUS_TEXT message reception + - Banner message timeout handling + - Firmware type extraction + """ + # Given: Connected flight controller + assert sitl_flight_controller.master is not None + + # When: Request banner (simulate what happens during connection) + sitl_flight_controller._connection_manager._request_banner() # type: ignore[attr-defined] # pylint: disable=protected-access + + # Then: Receive banner text + banner_msgs = sitl_flight_controller._connection_manager._receive_banner_text() # type: ignore[attr-defined] # pylint: disable=protected-access + + # SITL may or may not send banner (depends on configuration) + # But the call should not error + assert isinstance(banner_msgs, list) + # If banner received, should be text messages + for msg in banner_msgs: + assert isinstance(msg, str) + + +@pytest.mark.integration +@pytest.mark.sitl +def test_flight_controller_info_population_from_real_autopilot_version( + sitl_flight_controller: FlightController, +) -> None: + """ + Flight controller info is correctly populated from real AUTOPILOT_VERSION. + + GIVEN: Real SITL with AUTOPILOT_VERSION message available + WHEN: Processing autopilot version message + THEN: All info fields should be populated correctly + AND: Capabilities should reflect real SITL capabilities + AND: Version numbers should be valid + + NOTE: This validates info population: + - AUTOPILOT_VERSION field extraction + - Version number formatting + - Capability flags processing + - Board identification + """ + # Given: Connected flight controller with info populated + info = sitl_flight_controller.info + + # Then: Info should be populated + assert info.firmware_type == "ArduCopter" + assert info.autopilot.startswith("ArduPilot") + # Version may be empty for SITL dev builds, but should be string + assert isinstance(info.flight_sw_version, str) + # Capabilities should be populated (may be 0 for basic SITL) + assert info.capabilities is not None + + +@pytest.mark.integration +@pytest.mark.sitl +def test_disconnect_and_reconnect_with_real_sitl(sitl_manager: "SITLManager") -> None: + """ + User can disconnect and reconnect to real SITL multiple times. + + GIVEN: Connected flight controller to real SITL + WHEN: Disconnecting and reconnecting + THEN: Disconnect should clean up connection + AND: Reconnect should work correctly + AND: Info should be repopulated + + NOTE: This validates connection lifecycle: + - Clean disconnect from real connection + - Resource cleanup + - Successful reconnection + - State reset and repopulation + """ + # Given: Connected flight controller + if not sitl_manager.ensure_running(): + pytest.fail("SITL instance is not running") + info = FlightControllerInfo() + connection = FlightControllerConnection(info=info) + error = connection.connect(device=sitl_manager.connection_string, log_errors=False) + assert error == "" + assert connection.master is not None + + # When: Disconnect + connection.disconnect() + + # Then: Connection should be closed + assert connection.master is None + + # When: Reconnect + error = connection.connect(device=sitl_manager.connection_string, log_errors=False) + + # Then: Should reconnect successfully + assert error == "" + assert connection.master is not None + assert info.firmware_type == "ArduCopter" + + # Cleanup + connection.disconnect() + + +@pytest.mark.integration +@pytest.mark.sitl +def test_connection_timeout_with_invalid_port() -> None: + """ + Connection timeout works correctly when connecting to invalid port. + + GIVEN: Invalid TCP port that won't respond + WHEN: Attempting to connect with timeout + THEN: Should timeout and return error + AND: Should not hang indefinitely + AND: Error message should be informative + + NOTE: This validates timeout behavior: + - Connection timeout enforcement + - Error message generation + - No hanging on invalid ports + - Proper exception handling + """ + # Given: Invalid port that won't respond + invalid_port = "tcp:127.0.0.1:9999" # Unlikely to have service on this port + + # When: Connect with timeout + info = FlightControllerInfo() + connection = FlightControllerConnection(info=info) + start_time = time.time() + error = connection.connect(device=invalid_port, log_errors=False) + elapsed = time.time() - start_time + + # Then: Should timeout with error + assert error != "", "Connection to invalid port should fail" + # Should timeout relatively quickly (within retry timeout * retry count) + assert elapsed < 30, f"Connection should timeout, not hang. Took {elapsed}s" + + # Cleanup + connection.disconnect() + + +@pytest.mark.integration +@pytest.mark.sitl +def test_add_connection_to_connection_list(sitl_manager: "SITLManager") -> None: + """ + User can add custom connection string to available connections. + + GIVEN: Connection manager with default connections + WHEN: Adding custom connection string + THEN: Connection should be added to list + AND: Should be available in connection tuples + AND: Duplicate additions should be prevented + + NOTE: This validates connection management: + - Custom connection string addition + - Connection list management + - Duplicate prevention + - Connection tuple updates + """ + # Given: Connection manager + info = FlightControllerInfo() + connection = FlightControllerConnection(info=info) + connection.discover_connections() + + # When: Add custom connection + custom_conn = sitl_manager.connection_string + existing_connections = {t[0] for t in connection.get_connection_tuples()} + result = connection.add_connection(custom_conn) + + # Then: Connection should either be newly added or already present + assert result is True or custom_conn in existing_connections + assert any(custom_conn == conn[0] for conn in connection.get_connection_tuples()) + tuples = connection.get_connection_tuples() + assert any(custom_conn in t[0] for t in tuples) + + # When: Try to add again + result2 = connection.add_connection(custom_conn) + + # Then: Should reject duplicate + assert result2 is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_configuration_manager.py b/tests/test_configuration_manager.py index 97398d8e2..ae089a0ee 100755 --- a/tests/test_configuration_manager.py +++ b/tests/test_configuration_manager.py @@ -27,6 +27,8 @@ def mock_flight_controller() -> MagicMock: """Fixture providing a mock flight controller with realistic test data.""" mock_fc = MagicMock() mock_fc.fc_parameters = {"PARAM1": 1.0, "PARAM2": 3.0} + # Configure set_param to return a tuple (success, error_message) + mock_fc.set_param.return_value = (True, "") return mock_fc @@ -818,7 +820,17 @@ def test_user_can_download_flight_controller_parameters_successfully(self, confi # Arrange: Set up mock flight controller download expected_fc_params = {"PARAM1": 1.0, "PARAM2": 2.0} expected_defaults = {"PARAM1": 0.0, "PARAM2": 0.0} - configuration_manager._flight_controller.download_params.return_value = (expected_fc_params, expected_defaults) + + # Mock download_params to return the expected parameters AND update fc_parameters + def mock_download_params_side_effect( + _progress_callback, + _complete_param_path, + _default_param_path, + ) -> tuple[dict, dict]: + configuration_manager._flight_controller.fc_parameters = expected_fc_params.copy() + return (expected_fc_params, expected_defaults) + + configuration_manager._flight_controller.download_params.side_effect = mock_download_params_side_effect # Act: Download parameters fc_params, defaults = configuration_manager.download_flight_controller_parameters() @@ -1478,10 +1490,11 @@ def test_user_handles_parameter_upload_errors(self, configuration_manager) -> No } # Mock set_param to raise ValueError for PARAM2 - def mock_set_param(param_name: str, _value: float) -> None: + def mock_set_param(param_name: str, _value: float) -> tuple[bool, str]: if param_name == "PARAM2": error_msg = "Invalid parameter value" raise ValueError(error_msg) + return True, "" configuration_manager._flight_controller.set_param.side_effect = mock_set_param configuration_manager._flight_controller.fc_parameters = {"PARAM1": 1.0} diff --git a/tests/test_backend_flightcontroller_info.py b/tests/test_data_model_flightcontroller_info.py similarity index 86% rename from tests/test_backend_flightcontroller_info.py rename to tests/test_data_model_flightcontroller_info.py index 5aa3cfb7c..4cc3ca336 100755 --- a/tests/test_backend_flightcontroller_info.py +++ b/tests/test_data_model_flightcontroller_info.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Tests for the backend_flightcontroller_info.py file. +Tests for the data_model_flightcontroller_info.py file. This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator @@ -17,7 +17,7 @@ import pytest from pymavlink import mavutil -from ardupilot_methodic_configurator.backend_flightcontroller_info import BackendFlightcontrollerInfo +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo # pylint: disable=too-many-lines,protected-access @@ -32,17 +32,17 @@ def mock_mavlink_enums(self, enums_dict=None) -> None: yield -class TestBackendFlightcontrollerInfo: # pylint: disable=too-many-public-methods - """Test class for BackendFlightcontrollerInfo.""" +class TestFlightcontrollerInfo: # pylint: disable=too-many-public-methods + """Test class for FlightControllerInfo.""" @pytest.fixture - def fc_info(self) -> BackendFlightcontrollerInfo: - """Fixture providing a BackendFlightcontrollerInfo instance.""" - return BackendFlightcontrollerInfo() + def fc_info(self) -> FlightControllerInfo: + """Fixture providing a FlightControllerInfo instance.""" + return FlightControllerInfo() @pytest.fixture - def fc_info_with_basic_data(self, fc_info) -> BackendFlightcontrollerInfo: - """Fixture providing a BackendFlightcontrollerInfo with basic data populated.""" + def fc_info_with_basic_data(self, fc_info) -> FlightControllerInfo: + """Fixture providing a FlightControllerInfo with basic data populated.""" fc_info.system_id = "1" fc_info.component_id = "2" fc_info.autopilot = "ArduPilot" @@ -51,8 +51,8 @@ def fc_info_with_basic_data(self, fc_info) -> BackendFlightcontrollerInfo: return fc_info @pytest.fixture - def fc_info_with_complete_data(self, fc_info_with_basic_data) -> BackendFlightcontrollerInfo: - """Fixture providing a BackendFlightcontrollerInfo with complete data populated.""" + def fc_info_with_complete_data(self, fc_info_with_basic_data) -> FlightControllerInfo: + """Fixture providing a FlightControllerInfo with complete data populated.""" fc_info = fc_info_with_basic_data fc_info.firmware_type = "PX4-FMUv5" fc_info.flight_sw_version = "4.3.2" @@ -102,7 +102,7 @@ def common_mock_data(self) -> dict: } def test_init(self, fc_info) -> None: - """Test the initial state of BackendFlightcontrollerInfo.""" + """Test the initial state of FlightControllerInfo.""" assert fc_info.system_id == "" assert fc_info.component_id == "" assert fc_info.is_supported is False @@ -118,7 +118,7 @@ def test_set_system_id_and_component_id(self, fc_info) -> None: def test_set_autopilot(self, fc_info) -> None: """Test setting autopilot type.""" - with patch.object(fc_info, "_BackendFlightcontrollerInfo__decode_mav_autopilot", return_value="ArduPilot"): + with patch.object(fc_info, "_FlightControllerInfo__decode_mav_autopilot", return_value="ArduPilot"): fc_info.set_autopilot(mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA) assert fc_info.autopilot == "ArduPilot" assert fc_info.is_supported is True @@ -129,8 +129,8 @@ def test_set_autopilot(self, fc_info) -> None: def test_set_type(self, fc_info) -> None: """Test setting vehicle type.""" with ( - patch.object(fc_info, "_BackendFlightcontrollerInfo__classify_vehicle_type", return_value="ArduCopter"), - patch.object(fc_info, "_BackendFlightcontrollerInfo__decode_mav_type", return_value="Quadrotor"), + patch.object(fc_info, "_FlightControllerInfo__classify_vehicle_type", return_value="ArduCopter"), + patch.object(fc_info, "_FlightControllerInfo__decode_mav_type", return_value="Quadrotor"), ): fc_info.set_type(mavutil.mavlink.MAV_TYPE_QUADROTOR) assert fc_info.vehicle_type == "ArduCopter" @@ -138,7 +138,7 @@ def test_set_type(self, fc_info) -> None: def test_set_flight_sw_version(self, fc_info) -> None: """Test setting flight software version.""" - with patch.object(fc_info, "_BackendFlightcontrollerInfo__decode_flight_sw_version", return_value=(4, 3, 2, "beta")): + with patch.object(fc_info, "_FlightControllerInfo__decode_flight_sw_version", return_value=(4, 3, 2, "beta")): fc_info.set_flight_sw_version(0) assert fc_info.flight_sw_version == "4.3.2" assert fc_info.flight_sw_version_and_type == "4.3.2 beta" @@ -158,7 +158,7 @@ def test_decode_flight_sw_version_parameterized( # pylint: disable=too-many-arg self, version_code, expected_major, expected_minor, expected_patch, expected_type ) -> None: """Test decoding flight software version with parameterized values.""" - major, minor, patch, fw_type = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_flight_sw_version( # pylint: disable=redefined-outer-name + major, minor, patch, fw_type = FlightControllerInfo._FlightControllerInfo__decode_flight_sw_version( # pylint: disable=redefined-outer-name version_code ) assert major == expected_major @@ -174,13 +174,13 @@ def test_set_board_version(self, fc_info, monkeypatch) -> None: mock_mcu_dict = {1: ["STM32"]} monkeypatch.setattr( - "ardupilot_methodic_configurator.backend_flightcontroller_info.APJ_BOARD_ID_NAME_DICT", mock_name_dict + "ardupilot_methodic_configurator.data_model_flightcontroller_info.APJ_BOARD_ID_NAME_DICT", mock_name_dict ) monkeypatch.setattr( - "ardupilot_methodic_configurator.backend_flightcontroller_info.APJ_BOARD_ID_VENDOR_DICT", mock_vendor_dict + "ardupilot_methodic_configurator.data_model_flightcontroller_info.APJ_BOARD_ID_VENDOR_DICT", mock_vendor_dict ) monkeypatch.setattr( - "ardupilot_methodic_configurator.backend_flightcontroller_info.APJ_BOARD_ID_MCU_SERIES_DICT", mock_mcu_dict + "ardupilot_methodic_configurator.data_model_flightcontroller_info.APJ_BOARD_ID_MCU_SERIES_DICT", mock_mcu_dict ) # Test with known board ID @@ -232,9 +232,11 @@ def test_set_usb_vendor_and_product_ids(self, fc_info, monkeypatch) -> None: mock_vendor_dict = {0x1234: ["Test Vendor"]} mock_product_dict = {(0x1234, 0x5678): ["Test Product"]} - monkeypatch.setattr("ardupilot_methodic_configurator.backend_flightcontroller_info.VID_VENDOR_DICT", mock_vendor_dict) monkeypatch.setattr( - "ardupilot_methodic_configurator.backend_flightcontroller_info.VID_PID_PRODUCT_DICT", mock_product_dict + "ardupilot_methodic_configurator.data_model_flightcontroller_info.VID_VENDOR_DICT", mock_vendor_dict + ) + monkeypatch.setattr( + "ardupilot_methodic_configurator.data_model_flightcontroller_info.VID_PID_PRODUCT_DICT", mock_product_dict ) # Test with known IDs @@ -263,7 +265,7 @@ def test_set_usb_vendor_and_product_ids(self, fc_info, monkeypatch) -> None: def test_set_capabilities(self, fc_info) -> None: """Test setting capabilities.""" with patch.object( - fc_info, "_BackendFlightcontrollerInfo__decode_flight_capabilities", return_value={"FTP": "File Transfer Protocol"} + fc_info, "_FlightControllerInfo__decode_flight_capabilities", return_value={"FTP": "File Transfer Protocol"} ): # Test with FTP capability enabled ftp_cap = mavutil.mavlink.MAV_PROTOCOL_CAPABILITY_FTP @@ -286,7 +288,7 @@ def test_decode_flight_capabilities(self) -> None: mock_enums = {"MAV_PROTOCOL_CAPABILITY": {1: mock_capability}} with patch.object(mavutil.mavlink, "enums", mock_enums): - result = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_flight_capabilities(1) + result = FlightControllerInfo._FlightControllerInfo__decode_flight_capabilities(1) assert "FTP" in result assert result["FTP"] == "File Transfer Protocol" @@ -300,35 +302,30 @@ def test_decode_mav_type(self) -> None: patch.object(mavutil.mavlink, "enums", {"MAV_TYPE": {2: mock_entry}}), patch.object(mavutil.mavlink, "EnumEntry", return_value=mock_entry), ): - result = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_mav_type(2) + result = FlightControllerInfo._FlightControllerInfo__decode_mav_type(2) assert result == "Quadcopter" # Test with unknown type - result = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_mav_type(99) + result = FlightControllerInfo._FlightControllerInfo__decode_mav_type(99) assert result == "Quadcopter" # Should be "Unknown type" but our mock returns "Quadcopter" def test_classify_vehicle_type(self) -> None: """Test classifying vehicle type from MAV type.""" # Test known types assert ( - BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__classify_vehicle_type(mavutil.mavlink.MAV_TYPE_QUADROTOR) + FlightControllerInfo._FlightControllerInfo__classify_vehicle_type(mavutil.mavlink.MAV_TYPE_QUADROTOR) == "ArduCopter" ) assert ( - BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__classify_vehicle_type( - mavutil.mavlink.MAV_TYPE_FIXED_WING - ) + FlightControllerInfo._FlightControllerInfo__classify_vehicle_type(mavutil.mavlink.MAV_TYPE_FIXED_WING) == "ArduPlane" ) assert ( - BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__classify_vehicle_type( - mavutil.mavlink.MAV_TYPE_GROUND_ROVER - ) - == "Rover" + FlightControllerInfo._FlightControllerInfo__classify_vehicle_type(mavutil.mavlink.MAV_TYPE_GROUND_ROVER) == "Rover" ) # Test unknown type - assert BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__classify_vehicle_type(999) == "" + assert FlightControllerInfo._FlightControllerInfo__classify_vehicle_type(999) == "" def test_get_info(self, fc_info) -> None: """Test getting all flight controller information.""" @@ -360,7 +357,7 @@ def test_decode_flight_capabilities_with_multiple_bits(self) -> None: with patch.object(mavutil.mavlink, "enums", mock_enums): # Test with both capabilities enabled (bits 0 and 1 set, value = 3) - result = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_flight_capabilities(3) + result = FlightControllerInfo._FlightControllerInfo__decode_flight_capabilities(3) assert len(result) == 2 assert "FTP" in result assert "SET_ATTITUDE_TARGET" in result @@ -384,7 +381,7 @@ def test_decode_flight_capabilities_with_missing_description(self) -> None: mock_enums = {"MAV_PROTOCOL_CAPABILITY": {1: mock_cap}} with patch.object(mavutil.mavlink, "enums", mock_enums): - result = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_flight_capabilities(1) + result = FlightControllerInfo._FlightControllerInfo__decode_flight_capabilities(1) assert "BIT0" in result assert result["BIT0"] == mock_cap @@ -400,7 +397,7 @@ def test_decode_flight_capabilities_with_high_bit(self) -> None: mock_enums = {"MAV_PROTOCOL_CAPABILITY": {high_bit_value: mock_cap}} with patch.object(mavutil.mavlink, "enums", mock_enums): - result = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_flight_capabilities(high_bit_value) + result = FlightControllerInfo._FlightControllerInfo__decode_flight_capabilities(high_bit_value) assert "HIGH_BIT" in result assert result["HIGH_BIT"] == "High bit capability" @@ -409,8 +406,8 @@ def test_set_autopilot_with_actual_enum_values(self, fc_info) -> None: # Use actual decode implementation rather than mocking with patch.object( fc_info, - "_BackendFlightcontrollerInfo__decode_mav_autopilot", - side_effect=BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_mav_autopilot, + "_FlightControllerInfo__decode_mav_autopilot", + side_effect=FlightControllerInfo._FlightControllerInfo__decode_mav_autopilot, ): # Test with ArduPilot fc_info.set_autopilot(mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA) @@ -448,7 +445,7 @@ def test_classify_vehicle_type_comprehensive(self) -> None: (mavutil.mavlink.MAV_TYPE_VTOL_DUOROTOR, "ArduPlane"), (mavutil.mavlink.MAV_TYPE_VTOL_QUADROTOR, "ArduPlane"), ]: - vehicle_type = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__classify_vehicle_type(mav_type) + vehicle_type = FlightControllerInfo._FlightControllerInfo__classify_vehicle_type(mav_type) assert vehicle_type == expected_vehicle, ( f"Failed for MAV_TYPE {mav_type}, expected {expected_vehicle}, got {vehicle_type}" ) @@ -539,18 +536,18 @@ def test_set_board_version_zero(self, fc_info) -> None: ) def test_decode_mav_autopilot_with_valid_values(self, autopilot_id, expected_text) -> None: """Test decoding various valid MAV_AUTOPILOT values.""" - result = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_mav_autopilot(autopilot_id) + result = FlightControllerInfo._FlightControllerInfo__decode_mav_autopilot(autopilot_id) assert expected_text in result.lower(), f"Failed for autopilot ID {autopilot_id}" def test_decode_flight_capabilities_empty(self) -> None: """Test decoding with no capabilities set (0).""" - result = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_flight_capabilities(0) + result = FlightControllerInfo._FlightControllerInfo__decode_flight_capabilities(0) assert result == {} def test_set_capabilities_invalid_bit(self, fc_info) -> None: """Test setting capabilities with invalid bit patterns.""" # Set an invalid bit pattern that doesn't match any known capability - with patch.object(fc_info, "_BackendFlightcontrollerInfo__decode_flight_capabilities", return_value={}): + with patch.object(fc_info, "_FlightControllerInfo__decode_flight_capabilities", return_value={}): fc_info.set_capabilities(1 << 50) # Way beyond 32 bits assert fc_info.capabilities == {} assert fc_info.is_mavftp_supported is False @@ -561,7 +558,7 @@ def test_decode_mav_type_error_handling(self) -> None: with patch.object(mavutil.mavlink, "EnumEntry", mock_enum_entry): # When the dictionary lookup fails, it should use the EnumEntry fallback - result = BackendFlightcontrollerInfo._BackendFlightcontrollerInfo__decode_mav_type(999) + result = FlightControllerInfo._FlightControllerInfo__decode_mav_type(999) assert "Unknown" in result # Verify EnumEntry was called with the expected fallback values mock_enum_entry.assert_called_once_with("None", "Unknown type") @@ -576,7 +573,7 @@ def test_vendor_derived_from_board_id_when_unknown(self, fc_info, monkeypatch) - """Test vendor derivation from board ID when current vendor is Unknown.""" mock_vendor_dict = {42: ["CubePilot"]} monkeypatch.setattr( - "ardupilot_methodic_configurator.backend_flightcontroller_info.APJ_BOARD_ID_VENDOR_DICT", mock_vendor_dict + "ardupilot_methodic_configurator.data_model_flightcontroller_info.APJ_BOARD_ID_VENDOR_DICT", mock_vendor_dict ) # Set vendor to Unknown first @@ -603,7 +600,7 @@ def test_vendor_derived_from_board_id_when_unknown(self, fc_info, monkeypatch) - ) def test_set_capabilities_ftp_detection(self, fc_info, input_capabilities, expected_mavftp) -> None: """Test FTP capability detection.""" - with patch.object(fc_info, "_BackendFlightcontrollerInfo__decode_flight_capabilities", return_value={}): + with patch.object(fc_info, "_FlightControllerInfo__decode_flight_capabilities", return_value={}): fc_info.set_capabilities(input_capabilities) assert fc_info.is_mavftp_supported is expected_mavftp @@ -616,7 +613,7 @@ def test_log_flight_controller_info_logs_all_attributes(self) -> None: Then: All flight controller attributes are logged at INFO level """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() backend_info.flight_sw_version_and_type = "4.5.6 official" backend_info.flight_custom_version = "abc12345" backend_info.os_custom_version = "def67890" @@ -627,7 +624,7 @@ def test_log_flight_controller_info_logs_all_attributes(self) -> None: backend_info.product = "Test FC" # When - with patch("ardupilot_methodic_configurator.backend_flightcontroller_info.logging_info") as mock_logging_info: + with patch("ardupilot_methodic_configurator.data_model_flightcontroller_info.logging_info") as mock_logging_info: backend_info.log_flight_controller_info() # Then - verify all expected log calls were made @@ -647,20 +644,20 @@ def test_log_flight_controller_info_logs_all_attributes(self) -> None: # ==================== BACKEND FORMATTING TESTS ==================== -class TestBackendFlightcontrollerInfoFormatting: +class TestFlightcontrollerInfoFormatting: """ - Test the display formatting logic in BackendFlightcontrollerInfo. + Test the display formatting logic in FlightControllerInfo. Tests focus on ensuring different data types are correctly formatted for display in the user interface. """ @pytest.fixture - def flight_controller_info(self) -> BackendFlightcontrollerInfo: + def flight_controller_info(self) -> FlightControllerInfo: """Create a flight controller info instance for testing.""" - return BackendFlightcontrollerInfo() + return FlightControllerInfo() - def test_user_sees_string_values_formatted_correctly(self, flight_controller_info: BackendFlightcontrollerInfo) -> None: + def test_user_sees_string_values_formatted_correctly(self, flight_controller_info: FlightControllerInfo) -> None: """ Test that string values are displayed as-is. @@ -677,9 +674,7 @@ def test_user_sees_string_values_formatted_correctly(self, flight_controller_inf # Then assert result == "Test String Value" - def test_user_sees_dictionary_values_as_comma_separated_keys( - self, flight_controller_info: BackendFlightcontrollerInfo - ) -> None: + def test_user_sees_dictionary_values_as_comma_separated_keys(self, flight_controller_info: FlightControllerInfo) -> None: """ Test that dictionary values are displayed as comma-separated keys. @@ -698,7 +693,7 @@ def test_user_sees_dictionary_values_as_comma_separated_keys( @pytest.mark.parametrize("empty_value", [None, "", {}]) def test_user_sees_na_for_empty_values( - self, flight_controller_info: BackendFlightcontrollerInfo, empty_value: Union[None, str, dict] + self, flight_controller_info: FlightControllerInfo, empty_value: Union[None, str, dict] ) -> None: """ Test that empty values are displayed as "N/A". @@ -713,9 +708,7 @@ def test_user_sees_na_for_empty_values( # Then assert "N/A" in result # Should return translated "N/A" - def test_user_sees_single_key_dictionary_formatted_correctly( - self, flight_controller_info: BackendFlightcontrollerInfo - ) -> None: + def test_user_sees_single_key_dictionary_formatted_correctly(self, flight_controller_info: FlightControllerInfo) -> None: """ Test that single-key dictionaries are handled correctly. @@ -736,9 +729,9 @@ def test_user_sees_single_key_dictionary_formatted_correctly( # ==================== BACKEND SETTER METHODS TESTS ==================== -class TestBackendFlightcontrollerInfoSetters: +class TestFlightcontrollerInfoSetters: """ - Test the setter methods in BackendFlightcontrollerInfo. + Test the setter methods in FlightControllerInfo. Tests focus on ensuring the various set_* methods correctly process and store flight controller information. @@ -753,7 +746,7 @@ def test_set_system_id_and_component_id_stores_values(self) -> None: Then: Values are stored correctly """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # When backend_info.set_system_id_and_component_id("1", "1") @@ -771,7 +764,7 @@ def test_set_autopilot_with_ardupilot_sets_supported_flag(self) -> None: Then: The supported flag is set to True """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # When backend_info.set_autopilot(mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA) @@ -789,7 +782,7 @@ def test_set_autopilot_with_non_ardupilot_clears_supported_flag(self) -> None: Then: The supported flag is set to False """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # When backend_info.set_autopilot(mavutil.mavlink.MAV_AUTOPILOT_PX4) @@ -806,7 +799,7 @@ def test_set_type_classifies_vehicle_type_correctly(self) -> None: Then: The vehicle type is correctly classified """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # When backend_info.set_type(mavutil.mavlink.MAV_TYPE_QUADROTOR) @@ -824,7 +817,7 @@ def test_set_flight_sw_version_formats_version_correctly(self) -> None: Then: The version is correctly formatted as major.minor.patch """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # Version encoding: major=4, minor=4, patch=4, fw_type=12 (undefined) # This creates a version like 4.4.4 version_int = (4 << 24) | (4 << 16) | (4 << 8) | 12 @@ -845,7 +838,7 @@ def test_set_board_version_extracts_board_info(self) -> None: Then: Board version and APJ board ID are correctly extracted """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # Board version with APJ board ID = 1000, board version = 0x1234 board_version_int = (1000 << 16) | 0x1234 @@ -865,7 +858,7 @@ def test_set_flight_custom_version_stores_git_hash(self) -> None: Then: The Git hash is correctly formatted and stored """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() git_hash = [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] # ASCII for "12345678" # When @@ -883,7 +876,7 @@ def test_set_os_custom_version_stores_os_git_hash(self) -> None: Then: The OS Git hash is correctly formatted and stored """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() os_git_hash = [0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68] # ASCII for "abcdefgh" # When @@ -901,7 +894,7 @@ def test_set_usb_vendor_and_product_ids_with_known_vendor(self) -> None: Then: Vendor and product information are correctly set """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() vendor_id = 0x26AC # ArduPilot vendor ID product_id = 0x0001 @@ -923,7 +916,7 @@ def test_set_capabilities_processes_bitmask_correctly(self) -> None: Then: Capabilities are correctly decoded and stored """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # Set some known capability bits capabilities = mavutil.mavlink.MAV_PROTOCOL_CAPABILITY_PARAM_FLOAT @@ -938,7 +931,7 @@ def test_set_capabilities_processes_bitmask_correctly(self) -> None: # ==================== BACKEND STATIC DECODER TESTS ==================== -class TestBackendFlightcontrollerInfoDecoders: +class TestFlightcontrollerInfoDecoders: """ Test the behavior of backend methods that use static decoders. @@ -955,7 +948,7 @@ def test_flight_sw_version_decoding_through_public_api(self) -> None: Then: The version is correctly decoded and formatted """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # Version encoding that should result in a recognizable version version_int = (4 << 24) | (5 << 16) | (6 << 8) | 255 # 4.5.6 official @@ -975,7 +968,7 @@ def test_mav_type_decoding_through_public_api(self) -> None: Then: The type is correctly decoded and classified """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # When backend_info.set_type(mavutil.mavlink.MAV_TYPE_QUADROTOR) @@ -993,7 +986,7 @@ def test_autopilot_decoding_through_public_api(self) -> None: Then: The autopilot is correctly decoded """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() # When backend_info.set_autopilot(mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA) @@ -1011,7 +1004,7 @@ def test_capabilities_decoding_through_public_api(self) -> None: Then: The capabilities are correctly decoded into a dictionary """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() capabilities = mavutil.mavlink.MAV_PROTOCOL_CAPABILITY_PARAM_FLOAT # When @@ -1032,7 +1025,7 @@ def test_vehicle_type_classification_covers_common_types(self) -> None: Then: They are correctly classified to expected vehicle types """ # Given - backend_info = BackendFlightcontrollerInfo() + backend_info = FlightControllerInfo() test_cases = [ (mavutil.mavlink.MAV_TYPE_QUADROTOR, "ArduCopter"), (mavutil.mavlink.MAV_TYPE_FIXED_WING, "ArduPlane"), diff --git a/tests/test_data_model_motor_test.py b/tests/test_data_model_motor_test.py index 4275fd822..8fa625a07 100755 --- a/tests/test_data_model_motor_test.py +++ b/tests/test_data_model_motor_test.py @@ -61,9 +61,9 @@ def mock_flight_controller() -> MagicMock: fc.stop_all_motors.return_value = (True, "") # Configure set_param to update fc_parameters and return success - def set_param_side_effect(param_name: str, value: float) -> bool: + def set_param_side_effect(param_name: str, value: float) -> tuple[bool, str]: fc.fc_parameters[param_name] = value - return True + return (True, "") fc.set_param.side_effect = set_param_side_effect diff --git a/tests/test_data_model_par_dict.py b/tests/test_data_model_par_dict.py index 2f546380a..c6b4e2b25 100755 --- a/tests/test_data_model_par_dict.py +++ b/tests/test_data_model_par_dict.py @@ -14,11 +14,12 @@ import os import tempfile from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -from ardupilot_methodic_configurator.data_model_par_dict import Par, ParDict, is_within_tolerance +import ardupilot_methodic_configurator.data_model_par_dict as par_dict_module +from ardupilot_methodic_configurator.data_model_par_dict import Par, ParDict, is_within_tolerance, validate_param_name # pylint: disable=redefined-outer-name, too-many-lines @@ -156,6 +157,33 @@ def test_is_within_tolerance_edge_cases(self) -> None: assert is_within_tolerance(100, 110, rtol=0.1) # 10% difference but within rtol=0.1 +class TestParameterNameValidation: # pylint: disable=too-few-public-methods + """Test user-facing validation of parameter names.""" + + def test_user_receives_clear_feedback_when_validating_parameter_names(self) -> None: + """ + User gets actionable guidance when checking parameter names. + + GIVEN: A user verifies both valid and invalid parameter name candidates + WHEN: They call validate_param_name + THEN: Valid names pass silently and invalid names return descriptive errors + """ + # Act & Assert: Valid name succeeds without message + assert validate_param_name("ACRO_YAW_P") == (True, "") + + # Assert: Invalid names report the specific issue + invalid_cases = { + "": "cannot be empty", + "A" * 20: "too long", + "lowercase": "Invalid parameter name format", + "1INVALID": "Invalid parameter name format", + } + for candidate, expected_snippet in invalid_cases.items(): + is_valid, error_message = validate_param_name(candidate) + assert not is_valid + assert expected_snippet in error_message + + class TestParClassBehavior: """Test Par class behavior and functionality.""" @@ -387,6 +415,102 @@ def test_user_receives_error_for_duplicate_parameters(self) -> None: os.unlink(f.name) + def test_user_receives_guidance_when_parameter_file_is_not_utf8(self) -> None: + """ + User gets clear UTF-8 guidance when loading non-compliant files. + + GIVEN: A user tries to load a parameter file saved with legacy encoding + WHEN: They read it through load_param_file_into_dict + THEN: A SystemExit with UTF-8 instructions should be raised + """ + with tempfile.NamedTemporaryFile(mode="wb", suffix=".param", delete=False) as f: + f.write(b"\xff\xfe\xfa") # Invalid UTF-8 byte sequence + f.flush() + bad_file = f.name + + try: + with pytest.raises(SystemExit, match="UTF-8"): + ParDict.load_param_file_into_dict(bad_file) + finally: + os.unlink(bad_file) + + def test_user_receives_traceback_hint_when_parameter_assignment_fails(self) -> None: + """ + User gets actionable context when the filesystem rejects parameter writes. + + GIVEN: A user loads a valid line but the underlying write fails (e.g., disk full) + WHEN: _validate_parameter handles the assignment + THEN: A SystemExit should include the offending line information + """ + parameter_dict = ParDict() + original_line = "ACRO_YAW_P,4.5" + with ( + patch.object(ParDict, "__setitem__", side_effect=OSError("disk full"), autospec=True), + pytest.raises(SystemExit, match="Caused by line 1"), + ): + ParDict._validate_parameter( # pylint: disable=protected-access + "test.param", + parameter_dict, + 1, + original_line, + None, + "ACRO_YAW_P", + "4.5", + ) + + def test_user_can_ignore_blank_and_comment_only_lines_when_loading(self) -> None: + """ + User can safely ignore blank and comment-only lines in parameter files. + + GIVEN: A parameter file that mixes comments, blank lines, and valid entries + WHEN: They load it with load_param_file_into_dict + THEN: Only the valid parameters should appear in the resulting dictionary + """ + content = """# Initial comment + +ACRO_YAW_P,4.5 + +# Another comment line +PILOT_SPEED_UP,250.0 +""" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".param", delete=False) as f: + f.write(content) + f.flush() + file_path = f.name + + try: + param_dict = ParDict.load_param_file_into_dict(file_path) + finally: + os.unlink(file_path) + + assert len(param_dict) == 2 + assert param_dict["ACRO_YAW_P"].value == 4.5 + assert param_dict["PILOT_SPEED_UP"].value == 250.0 + + def test_user_continues_when_traceback_information_is_missing(self, monkeypatch) -> None: + """ + User still completes validation even if traceback information is unavailable. + + GIVEN: A filesystem write fails but sys_exc_info cannot provide traceback details + WHEN: _validate_parameter handles the failure + THEN: The method should suppress the error without raising SystemExit + """ + parameter_dict = ParDict() + monkeypatch.setattr(par_dict_module, "sys_exc_info", lambda: (None, None, None)) + with patch.object(ParDict, "__setitem__", side_effect=OSError("disk full"), autospec=True): + ParDict._validate_parameter( # pylint: disable=protected-access + "test.param", + parameter_dict, + 1, + "ACRO_YAW_P,4.5", + None, + "ACRO_YAW_P", + "4.5", + ) + + assert "ACRO_YAW_P" not in parameter_dict + class TestParameterFileExporting: """Test parameter file exporting workflows.""" @@ -598,6 +722,17 @@ def test_user_can_find_missing_or_different_parameters(self, parameter_dict, alt assert "ACRO_YAW_P" in differences # Different value (4.5 vs 6.0) assert "COMPASS_ENABLE" not in differences # Same value in both + def test_user_receives_error_when_requesting_differences_with_invalid_type(self, parameter_dict) -> None: + """ + User receives clear feedback when requesting differences with invalid data types. + + GIVEN: A user attempts to compare against a plain dictionary + WHEN: They call get_missing_or_different + THEN: A TypeError should explain that only ParDict instances are supported + """ + with pytest.raises(TypeError, match="Can only compare with another ParDict instance"): + parameter_dict.get_missing_or_different({"PARAM": Par(1.0)}) + class TestParameterCreationFromDifferentSources: """Test creating ParDict from various data sources.""" @@ -866,6 +1001,42 @@ def test_user_can_print_parameter_list_with_pagination(self, mock_popen) -> None # Assert: Print called with pagination info mock_print.assert_any_call("\nTest Parameters has 50 parameters:") + def test_user_skips_printing_when_parameter_list_is_empty(self) -> None: + """ + User skips any output when no parameters exist to print. + + GIVEN: A user requests to print an empty parameter list + WHEN: They call print_out + THEN: No print statements should be executed + """ + with patch("builtins.print") as mock_print: + ParDict.print_out([], "Empty Params") + + mock_print.assert_not_called() + + def test_user_gets_cli_pagination_when_running_as_main(self, monkeypatch) -> None: + """ + User sees CLI pagination prompts when running the module as a script. + + GIVEN: A user invokes print_out while the module behaves as __main__ + WHEN: The parameter list exceeds one terminal page + THEN: The user should be prompted to continue and terminal size should refresh + """ + terminal_result = MagicMock() + terminal_result.read.return_value = "5 80" # Terminal with 5 rows for this test + mock_popen = MagicMock(return_value=terminal_result) + monkeypatch.setattr(par_dict_module, "os_popen", mock_popen) + monkeypatch.setattr(par_dict_module, "__name__", "__main__") + mock_input = MagicMock(return_value="") + monkeypatch.setattr("builtins.input", mock_input) + + with patch("builtins.print") as mock_print: + ParDict.print_out([f"PARAM_{i},1.0" for i in range(6)], "CLI Parameters") + + assert mock_input.call_count >= 1 + assert mock_popen.call_count >= 2 # Initial size read and refresh after pagination pause + mock_print.assert_any_call("\nCLI Parameters has 6 parameters:") + class TestParameterCategorization: """Test parameter categorization workflows by documentation.""" diff --git a/tests/test_frontend_tkinter_flightcontroller_info.py b/tests/test_frontend_tkinter_flightcontroller_info.py index 27072c04a..52f3ea678 100755 --- a/tests/test_frontend_tkinter_flightcontroller_info.py +++ b/tests/test_frontend_tkinter_flightcontroller_info.py @@ -16,7 +16,7 @@ import pytest from ardupilot_methodic_configurator.backend_flightcontroller import FlightController -from ardupilot_methodic_configurator.backend_flightcontroller_info import BackendFlightcontrollerInfo +from ardupilot_methodic_configurator.data_model_flightcontroller_info import FlightControllerInfo from ardupilot_methodic_configurator.data_model_par_dict import Par, ParDict from ardupilot_methodic_configurator.frontend_tkinter_flightcontroller_info import ( FlightControllerInfoPresenter, @@ -32,7 +32,7 @@ def configured_flight_controller() -> Mock: """Create a realistic mock flight controller with proper behavior for user testing.""" mock_fc = Mock(spec=FlightController) - mock_fc.info = Mock(spec=BackendFlightcontrollerInfo) + mock_fc.info = Mock(spec=FlightControllerInfo) # Realistic flight controller information that users would see mock_fc.info.get_info.return_value = {