diff --git a/.github/actions/android-emulator-test/action.yml b/.github/actions/android-emulator-test/action.yml index e9549c166b90..eb2628b88f02 100644 --- a/.github/actions/android-emulator-test/action.yml +++ b/.github/actions/android-emulator-test/action.yml @@ -28,6 +28,12 @@ inputs: description: Build directory (for diagnostics collection) required: false default: ${{ runner.temp }}/build + androidtest-apk-path: + description: >- + Path to the instrumentation (androidTest) APK. When set, the emulator step + installs it alongside the app APK and runs `am instrument` after the boot test. + required: false + default: '' runs: using: composite @@ -59,7 +65,8 @@ runs: script: | timeout 20s adb start-server >/dev/null 2>&1 || true timeout 180s adb wait-for-device || (echo "::error::adb wait-for-device timed out before boot test." && exit 1) - python3 .github/scripts/android_boot_test.py --apk "${{ inputs.apk-path }}" --package ${{ inputs.package }} --timeout 120 --stability-window 20 --adb-ready-timeout 180 --install-retries 4 --install-retry-delay 5 --launch-retries 2 --log-output /tmp/qgc_emulator_boot.log + python3 .github/scripts/android_boot_test.py --apk "${{ inputs.apk-path }}" --package "${{ inputs.package }}" --timeout 120 --stability-window 20 --adb-ready-timeout 180 --install-retries 4 --install-retry-delay 5 --launch-retries 2 --log-output /tmp/qgc_emulator_boot.log + python3 .github/scripts/android_instrumentation_test.py --package "${{ inputs.package }}" --androidtest-apk "${{ inputs.androidtest-apk-path }}" --log-output /tmp/qgc_instrumentation.log - name: Collect Emulator Diagnostics if: failure() @@ -71,6 +78,7 @@ runs: --out-dir "${RUNNER_TEMP}/emulator-diagnostics" \ --build-dir "${BUILD_DIR}" \ --boot-log /tmp/qgc_emulator_boot.log + cp /tmp/qgc_instrumentation.log "${RUNNER_TEMP}/emulator-diagnostics/" 2>/dev/null || true ls -la "${RUNNER_TEMP}/emulator-diagnostics" || true - name: Upload Emulator Diagnostics diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index ffbed00a7f08..fe63ffa84b94 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -30,6 +30,10 @@ inputs: description: 'Additional packages to install (space-separated, Linux only)' required: false default: '' + package-category: + description: 'Restrict the Linux host apt set to a single category (e.g. core); empty installs all' + required: false + default: '' runs: using: composite @@ -73,6 +77,8 @@ runs: if: runner.os == 'Linux' id: linux-deps shell: bash + env: + INPUT_PACKAGE_CATEGORY: ${{ inputs.package-category }} run: python3 "${GITHUB_WORKSPACE}/.github/scripts/install_dependencies_helper.py" print-packages - name: Enable universe repository (Linux) @@ -80,19 +86,41 @@ runs: shell: bash run: python3 "${GITHUB_WORKSPACE}/.github/scripts/install_dependencies_helper.py" enable-universe + # `sudo timeout` (not nick-fields/retry) bounds each attempt: that action runs as `runner` + # and dies with EPERM trying to kill root-owned apt on timeout, so it can't retry. Running + # the timeout under sudo lets root kill apt; the loop resumes from apt's archive cache and + # `dpkg --configure -a` recovers a half-applied attempt. Bounds a throttled mirror without + # the unbounded grind composite steps would otherwise allow (no native timeout-minutes). - name: Install apt packages (Linux) if: runner.os == 'Linux' shell: bash env: APT_PACKAGES: ${{ steps.linux-deps.outputs.packages }} run: | - sudo apt-get -o Acquire::Retries=3 update -qq - # shellcheck disable=SC2086 - sudo apt-get -o Acquire::Retries=3 install -y --no-install-recommends $APT_PACKAGES + set -uo pipefail + apt_opts=(-o DPkg::Lock::Timeout=300 -o Acquire::Retries=3) + sudo timeout 300 apt-get "${apt_opts[@]}" update -qq \ + || echo "::warning::apt update timed out/failed; proceeding with cached lists" + for attempt in 1 2 3; do + echo "::group::apt install attempt ${attempt}" + sudo dpkg --configure -a || true + # shellcheck disable=SC2086 + if sudo timeout 720 apt-get "${apt_opts[@]}" install -y --no-install-recommends $APT_PACKAGES; then + echo "::endgroup::" + exit 0 + fi + echo "::endgroup::" + echo "::warning::apt install attempt ${attempt} failed or timed out; retrying" + sleep 15 + done + echo "::error::apt install failed after 3 attempts" + exit 1 - name: Fix apt alternatives (Linux) if: runner.os == 'Linux' shell: bash + env: + INPUT_PACKAGE_CATEGORY: ${{ inputs.package-category }} run: python3 "${GITHUB_WORKSPACE}/.github/scripts/install_dependencies_helper.py" fix-apt-alternatives - name: Install optional apt packages (Linux) diff --git a/.github/scripts/android_instrumentation_test.py b/.github/scripts/android_instrumentation_test.py new file mode 100644 index 000000000000..61d9e3636aa1 --- /dev/null +++ b/.github/scripts/android_instrumentation_test.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Install and run Android instrumentation (androidTest) tests for QGroundControl. + +Invoked as a single line from the emulator-runner ``script:`` block, which +executes each script line as its own ``sh -c`` and therefore cannot host a +multi-line shell ``if``/``fi`` block. A no-op (exit 0) when ``--androidtest-apk`` +is empty so the caller needs no shell conditional. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from ci_bootstrap import ensure_tools_dir + +ensure_tools_dir(__file__) + +from common.proc import run_bytes + +# STATUS_CODE -1/-2 are per-test error/failure; session `INSTRUMENTATION_CODE: -1` is RESULT_OK — don't match it. +_FAILURE_PATTERN = re.compile( + r"^INSTRUMENTATION_STATUS_CODE: -[12]\b|FAILURES!!!|Process crashed", + re.MULTILINE, +) +_PASS_PATTERN = re.compile(r"^OK \([1-9][0-9]* test", re.MULTILINE) +# Also matches clean native System.exit (no Java exception), which `am instrument` reports as a crash. +_FATAL_LINE = re.compile( + r"FATAL EXCEPTION|SIGSEGV|SIGABRT|UnsatisfiedLinkError|abort message|" + r"VM exiting with result|System\.exit called|Crash of app" +) + + +def _decode(value: bytes) -> str: + return value.decode("utf-8", errors="replace") + + +def _capture_crash_logcat(package: str) -> str: + """Drain logcat now — the emulator often goes offline before the diagnostics step runs.""" + sections: list[str] = [] + + crash = _decode(run_bytes(["adb", "logcat", "-b", "crash", "-d", "-v", "time"]).stdout).strip() + if crash: + sections.append("===== crash buffer =====\n" + crash) + + app_tag = package.rsplit(".", 1)[ + -1 + ] # logcat truncates the process name to e.g. ".qgroundcontrol" + lines = _decode(run_bytes(["adb", "logcat", "-d", "-v", "time"]).stdout).splitlines() + relevant = [ln for ln in lines if app_tag in ln or _FATAL_LINE.search(ln)] + if relevant: + sections.append( + f"===== main buffer ({app_tag} + fatal) =====\n" + "\n".join(relevant[-200:]) + ) + + return "\n\n".join(sections) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run Android instrumentation tests for QGroundControl." + ) + parser.add_argument( + "--androidtest-apk", + default="", + help="Path to the androidTest APK. Empty string is a no-op (exit 0).", + ) + parser.add_argument( + "--package", + default="org.mavlink.qgroundcontrol", + help="Android application package id under test", + ) + parser.add_argument( + "--runner", + default="org.mavlink.qgroundcontrol.QGCTestRunner", + help="Instrumentation test runner class", + ) + parser.add_argument( + "--timeout", + type=int, + default=600, + help="Timeout in seconds for the am instrument run", + ) + parser.add_argument( + "--log-output", + type=Path, + default=Path("/tmp/qgc_instrumentation.log"), + help="Path to store full am instrument output", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if not args.androidtest_apk: + print("::notice::No instrumentation APK provided; skipping androidTest run.") + return 0 + + print(f"Installing instrumentation APK: {args.androidtest_apk}") + install = run_bytes(["adb", "install", "-r", args.androidtest_apk]) + if install.returncode != 0: + print( + f"::error::adb install of instrumentation APK failed: {_decode(install.stderr).strip()}" + ) + return 1 + + run_bytes(["adb", "shell", "am", "force-stop", args.package]) + run_bytes(["adb", "logcat", "-c"]) + + target = f"{args.package}.test/{args.runner}" + print(f"Running instrumentation: {target}") + result = run_bytes( + ["adb", "shell", "am", "instrument", "-w", "-r", target], + timeout=args.timeout, + ) + output = _decode(result.stdout) + _decode(result.stderr) + print(output) + + failed = bool(_FAILURE_PATTERN.search(output)) + passed = bool(_PASS_PATTERN.search(output)) + + if failed or not passed: + crash = _capture_crash_logcat(args.package) + if crash: + output += "\n\n===== logcat (crash excerpt) =====\n" + crash + print("::group::logcat crash excerpt") + print(crash) + print("::endgroup::") + + args.log_output.write_text(output, encoding="utf-8") + + if failed: + print("::error::Android instrumentation tests failed.") + return 1 + if not passed: + print( + "::error::Android instrumentation tests did not report a passing run with at least one test." + ) + return 1 + + print("Android instrumentation tests passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/android_sdk_helper.py b/.github/scripts/android_sdk_helper.py index f7fd59255c49..4aca0926bd90 100644 --- a/.github/scripts/android_sdk_helper.py +++ b/.github/scripts/android_sdk_helper.py @@ -68,8 +68,8 @@ def main() -> None: sdkmanager = "sdkmanager" gradlew = os.path.join(args.workspace, "android", "gradlew") - run_with_retries([sdkmanager, "--update"]) - run_with_retries([gradlew, "--version"]) + run_with_retries([sdkmanager, "--update"], timeout=600) + run_with_retries([gradlew, "--version"], timeout=300) if __name__ == "__main__": diff --git a/.github/scripts/install_dependencies_helper.py b/.github/scripts/install_dependencies_helper.py index a3357cdd72d4..20b8096aec88 100644 --- a/.github/scripts/install_dependencies_helper.py +++ b/.github/scripts/install_dependencies_helper.py @@ -10,6 +10,7 @@ from __future__ import annotations +import os import re import subprocess import sys @@ -101,6 +102,10 @@ def fix_apt_alternatives() -> None: _sudo(["ldconfig"]) if not _ldconfig_has_blas(): + # core-only installs (e.g. Android host) never pull BLAS; absence is expected, not fatal. + if os.environ.get("INPUT_PACKAGE_CATEGORY", "").strip(): + print("::warning::libblas.so.3 absent; skipping (no BLAS in selected package category)") + return print("::error::libblas.so.3 is still missing after repair attempt") sys.exit(1) @@ -132,16 +137,17 @@ def detect_python_version() -> None: def print_packages() -> None: """Resolve the debian apt package list and emit it as a GITHUB_OUTPUT value.""" - result = run_captured( - [ - sys.executable, - "tools/setup/install_dependencies", - "--platform", - "debian", - "--print-packages", - ], - check=True, - ) + cmd = [ + sys.executable, + "tools/setup/install_dependencies", + "--platform", + "debian", + "--print-packages", + ] + category = os.environ.get("INPUT_PACKAGE_CATEGORY", "").strip() + if category: + cmd += ["--category", category] + result = run_captured(cmd, check=True) write_github_output({"packages": result.stdout.strip()}) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index d89a3123c237..f63a26ca176d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -114,9 +114,6 @@ jobs: with: fetch-depth: ${{ github.event_name == 'pull_request' && 1 || 0 }} - # RunsOn images ship no Android SDK and don't preset ANDROID_SDK_ROOT (unlike - # GitHub-hosted images), so setup-android@v4 has no install target. Point it - # at a writable path. macOS stays GitHub-hosted with its preinstalled SDK. - name: Set Android SDK location if: matrix.host != 'mac' shell: bash @@ -126,6 +123,9 @@ jobs: - name: Build Setup (Android) id: setup + # Backstop above the ~15 min cold cost so a stalled mirror/cache fails + # fast instead of burning the 120 min job timeout (~3 min warm). + timeout-minutes: 45 uses: ./.github/actions/build-setup with: mode: android @@ -138,6 +138,9 @@ jobs: - name: Install host build dependencies if: runner.os == 'Linux' uses: ./.github/actions/install-dependencies + with: + # Android cross-compiles; host needs only build tools, not desktop Qt/X11/GStreamer dev libs. + package-category: core - name: Create Debug Keystore if: ${{ env.QT_ANDROID_KEYSTORE_STORE_PASS == '' || !matrix.primary }} @@ -212,6 +215,12 @@ jobs: if-no-files-found: ignore retention-days: 7 + - name: Cache Gradle (Android tests) + if: ${{ matrix.host == 'linux' || matrix.emulator }} + uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: ${{ github.ref != 'refs/heads/master' }} + - name: Android Lint if: ${{ matrix.host == 'linux' }} shell: bash @@ -224,6 +233,26 @@ jobs: fi ./gradlew lintRelease --no-daemon + - name: Android Unit Tests + if: ${{ matrix.host == 'linux' }} + shell: bash + working-directory: ${{ runner.temp }}/build/android-build + run: | + if [[ ! -x "./gradlew" ]]; then + echo "::error::Gradle wrapper not found in ${PWD}" + ls -la + exit 1 + fi + ./gradlew testDebugUnitTest --no-daemon + + - name: Publish Unit Test Report + if: ${{ always() && matrix.host == 'linux' }} + uses: mikepenz/action-junit-report@v5 + with: + report_paths: ${{ runner.temp }}/build/android-build/build/test-results/testDebugUnitTest/TEST-*.xml + check_name: Android Unit Test Report + fail_on_failure: true + - name: Prepare Artifact shell: bash run: | @@ -236,11 +265,38 @@ jobs: fi cp "${APK_DIR}"/*.apk "${TEMP_DIR}/build/${{ env.PACKAGE }}.apk" + - name: Assemble Instrumentation APK + if: ${{ matrix.emulator }} + id: androidtest + shell: bash + working-directory: ${{ runner.temp }}/build/android-build + run: | + if [[ ! -x "./gradlew" ]]; then + echo "::error::Gradle wrapper not found in ${PWD}" + ls -la + exit 1 + fi + ./gradlew assembleDebugAndroidTest --no-daemon + APK="${PWD}/build/outputs/apk/androidTest/debug/android-build-debug-androidTest.apk" + if [[ ! -f "${APK}" ]]; then + echo "::error::androidTest APK not found at ${APK}" + find "${PWD}/build/outputs/apk" -name '*.apk' || true + exit 1 + fi + # Gradle signs the test APK with its own debug key; re-sign with the app's keystore + # so the signatures match — Android refuses instrumentation otherwise. + APKSIGNER="$(find "${ANDROID_SDK_ROOT}/build-tools" -maxdepth 2 -name apksigner | sort -V | tail -1)" + "${APKSIGNER}" sign \ + --ks "${RUNNER_TEMP}/debug.keystore" --ks-pass pass:android \ + --ks-key-alias androiddebugkey --key-pass pass:android "${APK}" + echo "apk-path=${APK}" >> "$GITHUB_OUTPUT" + - name: Emulator Boot Test if: ${{ matrix.emulator }} uses: ./.github/actions/android-emulator-test with: apk-path: ${{ runner.temp }}/build/${{ env.PACKAGE }}.apk + androidtest-apk-path: ${{ steps.androidtest.outputs.apk-path }} package: org.mavlink.qgroundcontrol android-platform: ${{ steps.setup.outputs.android_platform }} qt-version: ${{ steps.setup.outputs.qt_version }} diff --git a/.lychee.toml b/.lychee.toml index c595debd9deb..08d663558374 100644 --- a/.lychee.toml +++ b/.lychee.toml @@ -7,6 +7,12 @@ verbose = "info" # Disable progress bar (cleaner CI output) no_progress = true +# Tolerate transient 5xx from external doc hosts (e.g. mavlink.io) without failing CI. +# Defaults are max_retries=2 / retry_wait_time=2 / timeout=20. +max_retries = 5 +retry_wait_time = 3 +timeout = 30 + # Accept these HTTP status codes as valid accept = ["200", "204", "301", "302", "403", "429"] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 644377027180..48fc57c362de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,6 +75,12 @@ repos: files: ^tools/.*\.py$ pass_filenames: false + - id: checkstyle + name: checkstyle (first-party Android Java) + entry: tools/checkstyle/run_checkstyle.sh + language: script + files: ^android/src/(org/mavlink/qgroundcontrol/|(test|androidTest)/java/org/mavlink/qgroundcontrol/).*\.java$ + # uv lockfile sync (keeps tools/uv.lock current with tools/pyproject.toml) - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.11.15 diff --git a/AGENTS.md b/AGENTS.md index 7358fcfac746..4ea7b455aadc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,73 @@ Your output will be reviewed by another AI agent before being accepted. Write co - Testing details (CTest labels, coverage, sanitizers): [test/TESTING.md](test/TESTING.md). - CI Python script tests: [.github/ci-overview.md](.github/ci-overview.md#tests). +## Android: Install + Logcat + +Workflow for installing a debug-built APK to a connected device and capturing logs. + +Prereqs (on PATH): `adb`, `zipalign`, `apksigner` (Android `build-tools//`). Debug keystore at `~/.android/debug.keystore` (alias `androiddebugkey`, password `android`). + +```bash +# Adjust APK path to your Qt-Android build kit/configuration +APK=build/Qt_6_10_3_for_Android_arm64_v8a-Debug/android-build-QGroundControl/QGroundControl.apk + +# Re-sign with the debug keystore (Qt's androiddeployqt output is unsigned/misaligned for adb install) +zipalign -p -f 4 "$APK" "${APK%.apk}-aligned.apk" +apksigner sign --ks ~/.android/debug.keystore --ks-pass pass:android --ks-key-alias androiddebugkey \ + --key-pass pass:android --out "${APK%.apk}-signed.apk" "${APK%.apk}-aligned.apk" + +# Install (replace existing); -r keeps data, add -d to allow downgrade +adb install -r "${APK%.apk}-signed.apk" + +# Launch (force-stop first for a clean session) +adb shell am force-stop org.mavlink.qgroundcontrol +adb shell am start -n org.mavlink.qgroundcontrol/.QGCActivity + +# Logcat: clear, then stream QGC + serial-related tags only +adb logcat -c +adb logcat -v time \ + QGroundControl:V QGCActivity:V QGCUsbSerialManager:V QGCUsbPermissionHandler:V \ + QGCFtdiSerialDriver:V QGCSerialPort:V QGCUsbSerialProber:V \ + UsbSerialEnumerator:V UsbAttachDetachReceiver:V '*:S' + +# Enable Java serial VERBOSE for code paths gated on Log.isLoggable(..., VERBOSE). +# Tag must be ≤23 chars; setprop survives until reboot. +adb shell setprop log.tag.QGCSerial VERBOSE + +# Qt-side verbose categories — set via QT_LOGGING_RULES before launch, or in-app via the log viewer +adb shell am start -n org.mavlink.qgroundcontrol/.QGCActivity \ + --es "QT_LOGGING_RULES" "Android.Serial:verbose.debug=true;VehicleSetup.FirmwareUpgrade.debug=true" +``` + +Tip: run logcat in a background shell (`run_in_background=true`) and grep the captured file rather than streaming into the agent context. + +### Running unit tests on a device + +A `QGC_BUILD_TESTING=ON` APK is unittest-capable. Pass QGC's `--unittest` flags via the +`applicationArguments` intent extra (Qt's `QtLoader` appends them to argv): + +```bash +adb shell am start -n org.mavlink.qgroundcontrol/org.mavlink.qgroundcontrol.QGCActivity \ + --es applicationArguments "--unittest:NmeaSerialDeviceTest --onscreen" +``` + +`--onscreen` is required — Qt-for-Android ships no offscreen QPA plugin, so the default test path +aborts with "no Qt platform plugin could be initialized". On-device is a smoke check only (clean +exit = no crash/ASSERT in logcat); QtTest stdout isn't routed to logcat and scoped storage blocks +reading `--unittest-output` XML. Run on the **host via `ctest`** for the authoritative verdict. + +For a **dedicated CI test APK** (args fixed for the build), bake them in at configure time via Qt's +`QT_ANDROID_APPLICATION_ARGUMENTS` instead of the runtime intent — androiddeployqt writes them to the +manifest as `android.app.arguments` and `QtLoader` appends them to argv on launch (same codepath): + +```bash +cmake ... -DQGC_BUILD_TESTING=ON -DQT_ANDROID_APPLICATION_ARGUMENTS="--unittest:NmeaSerialDeviceTest --onscreen" +adb shell am start -n org.mavlink.qgroundcontrol/org.mavlink.qgroundcontrol.QGCActivity # no intent extra needed +``` + +Trade-off: static (reconfigure + redeploy to change the filter) and tech-preview (Qt 6.0+). Prefer the +intent extra above for ad-hoc/iterative runs; it also overrides the baked-in args at launch. + ## Golden Rules See [.github/CONTRIBUTING.md#architecture-patterns](.github/CONTRIBUTING.md#architecture-patterns) for the canonical list (Fact System, Multi-Vehicle null-check, FirmwarePlugin, QML integration) and [CODING_STYLE.md#common-pitfalls](CODING_STYLE.md#common-pitfalls) for the full pitfall list with code examples. diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index cc1a861ae551..7e9c19557ff2 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -6,6 +6,10 @@ + + + + @@ -57,17 +61,11 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/libs/d2xx.jar b/android/libs/d2xx.jar index 8673f2f2930e..001544f13738 100644 Binary files a/android/libs/d2xx.jar and b/android/libs/d2xx.jar differ diff --git a/android/libs/d2xx_jar_license.txt b/android/libs/d2xx_jar_license.txt new file mode 100644 index 000000000000..ec097759bdc2 --- /dev/null +++ b/android/libs/d2xx_jar_license.txt @@ -0,0 +1,36 @@ +/*============================================================================ +* +* (C) Copyright 2018, Future Technology Devices International Ltd. +* ============================================================================ +* +* This source code ("the Software") is provided by Future Technology Devices +* International Limited ("FTDI") subject to the licence terms set out +* http://www.ftdichip.com/FTSourceCodeLicenceTerms.htm ("the Licence Terms"). +* You must read the Licence Terms before downloading or using the Software. +* By installing or using the Software you agree to the Licence Terms. If you +* do not agree to the Licence Terms then do not download or use the Software. +* +* Without prejudice to the Licence Terms, here is a summary of some of the key +* terms of the Licence Terms (and in the event of any conflict between this +* summary and the Licence Terms then the text of the Licence Terms will +* prevail). +* +* The Software is provided "as is". +* There are no warranties (or similar) in relation to the quality of the +* Software. You use it at your own risk. +* The Software should not be used in, or for, any medical device, system or +* appliance. There are exclusions of FTDI liability for certain types of loss +* such as: special loss or damage; incidental loss or damage; indirect or +* consequential loss or damage; loss of income; loss of business; loss of +* profits; loss of revenue; loss of contracts; business interruption; loss of +* the use of money or anticipated savings; loss of information; loss of +* opportunity; loss of goodwill or reputation; and/or loss of, damage to or +* corruption of data. +* There is a monetary cap on FTDI's liability. +* The Software may have subsequently been amended by another user and then +* distributed by that other user ("Adapted Software"). If so that user may +* have additional licence terms that apply to those amendments. However, FTDI +* has no liability in relation to those amendments. +* ============================================================================*/ + + diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro index 17d99d40d709..ed58c5e84544 100644 --- a/android/proguard-rules.pro +++ b/android/proguard-rules.pro @@ -8,23 +8,23 @@ -keepclasseswithmembers class org.mavlink.qgroundcontrol.QGCActivity { native ; } --keepclasseswithmembers class org.mavlink.qgroundcontrol.QGCUsbSerialManager { +-keepclasseswithmembers class org.mavlink.qgroundcontrol.serial.QGCUsbSerialManager { + native ; +} +-keepclasseswithmembers class org.mavlink.qgroundcontrol.QGCNativeLogSink { native ; } # Static methods are resolved from C++ by method name/signature. -keepclassmembers class org.mavlink.qgroundcontrol.QGCActivity { public static *; } --keepclassmembers class org.mavlink.qgroundcontrol.QGCUsbSerialManager { +-keepclassmembers class org.mavlink.qgroundcontrol.serial.QGCUsbSerialManager { public static *; } --keep class org.mavlink.qgroundcontrol.QGCUsbId { *; } --keep class org.mavlink.qgroundcontrol.QGCUsbSerialProber { *; } -keep class org.mavlink.qgroundcontrol.QGCLogger { *; } --keep class org.mavlink.qgroundcontrol.QGCFtdiSerialDriver { *; } --keep class org.mavlink.qgroundcontrol.QGCFtdiSerialDriver$QGCFtdiSerialPort { *; } --keep class org.mavlink.qgroundcontrol.QGCFtdiDriver { *; } +-keep class org.mavlink.qgroundcontrol.QGCNativeLogSink { *; } -keep class org.mavlink.qgroundcontrol.QGCSDLManager { *; } +-keep class org.mavlink.qgroundcontrol.serial.** { *; } # SDL - native method stubs required for JNI registration -keep class org.libsdl.app.** { *; } @@ -38,6 +38,23 @@ # AndroidX FileProvider -keep class androidx.core.content.FileProvider { *; } +# androidx.tracing — transitive via androidx.core, which references androidx.tracing.Trace at +# startup. R8 strips it from the minified release APK while leaving the reference live, crashing +# the app with NoClassDefFoundError: Landroidx/tracing/Trace;. Only affects the Release variant. +-keep class androidx.tracing.** { *; } +-dontwarn androidx.tracing.** + +# Kotlin runtime — required for on-device androidx.test instrumentation. +# The androidx.test 1.6.x runner is Kotlin-compiled and resolves kotlin.jvm.internal.* (Intrinsics +# null-checks, Metadata, Unit, ...) from the *installed* app's classloader at runtime. The debug +# androidTest APK omits kotlin-stdlib (AGP dedups it against the app variant, which pulls it in +# transitively via androidx.core), so on device these classes survive only if R8 keeps them in the +# release app APK. Without this the runner dies with +# NoClassDefFoundError: kotlin.jvm.internal.Intrinsics +# in AndroidJUnitRunner.newApplication, before any test executes (PR #14536 emulator CI). +-keep class kotlin.** { *; } +-dontwarn kotlin.** + # Preserve native method names -keepclasseswithmembernames class * { native ; diff --git a/android/res/xml/device_filter.xml b/android/res/xml/device_filter.xml index 406531c90019..bfe0b3d5de1e 100644 --- a/android/res/xml/device_filter.xml +++ b/android/res/xml/device_filter.xml @@ -1,41 +1,132 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/androidTest/java/org/mavlink/qgroundcontrol/QGCTestRunner.java b/android/src/androidTest/java/org/mavlink/qgroundcontrol/QGCTestRunner.java new file mode 100644 index 000000000000..d47d96cf3053 --- /dev/null +++ b/android/src/androidTest/java/org/mavlink/qgroundcontrol/QGCTestRunner.java @@ -0,0 +1,16 @@ +package org.mavlink.qgroundcontrol; + +import android.app.Application; +import android.content.Context; + +import androidx.test.runner.AndroidJUnitRunner; + +// Instrument against a plain Application instead of the target app's QtApplication, so QGC's +// app-level init stays out of these class-loading smoke tests. +public final class QGCTestRunner extends AndroidJUnitRunner { + @Override + public Application newApplication(ClassLoader cl, String className, Context context) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + return super.newApplication(cl, Application.class.getName(), context); + } +} diff --git a/android/src/androidTest/java/org/mavlink/qgroundcontrol/serial/ComponentResolutionInstrumentedTest.java b/android/src/androidTest/java/org/mavlink/qgroundcontrol/serial/ComponentResolutionInstrumentedTest.java new file mode 100644 index 000000000000..32fd090627b6 --- /dev/null +++ b/android/src/androidTest/java/org/mavlink/qgroundcontrol/serial/ComponentResolutionInstrumentedTest.java @@ -0,0 +1,43 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class ComponentResolutionInstrumentedTest { + + @Test + public void foregroundServiceClassResolves() throws ClassNotFoundException { + final ClassLoader loader = getClass().getClassLoader(); + final Class service = + Class.forName("org.mavlink.qgroundcontrol.QGCForegroundService", false, loader); + assertTrue(android.app.Service.class.isAssignableFrom(service)); + } + + @Test + public void foregroundServiceDeclaredInManifest() throws PackageManager.NameNotFoundException { + final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + final ComponentName component = + new ComponentName(context, "org.mavlink.qgroundcontrol.QGCForegroundService"); + final ServiceInfo info = + context.getPackageManager().getServiceInfo(component, 0); + assertNotNull(info); + } + + @Test + public void mainActivityClassResolves() throws ClassNotFoundException { + final ClassLoader loader = getClass().getClassLoader(); + assertNotNull(Class.forName("org.mavlink.qgroundcontrol.QGCActivity", false, loader)); + } +} diff --git a/android/src/androidTest/java/org/mavlink/qgroundcontrol/serial/SerialClassLoadingInstrumentedTest.java b/android/src/androidTest/java/org/mavlink/qgroundcontrol/serial/SerialClassLoadingInstrumentedTest.java new file mode 100644 index 000000000000..26a0acd7768f --- /dev/null +++ b/android/src/androidTest/java/org/mavlink/qgroundcontrol/serial/SerialClassLoadingInstrumentedTest.java @@ -0,0 +1,37 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.content.Context; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class SerialClassLoadingInstrumentedTest { + + @Test + public void targetPackageIdentifierMatches() { + final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("org.mavlink.qgroundcontrol", context.getPackageName()); + } + + @Test + public void serialClassesLoadOnArtRuntime() throws ClassNotFoundException { + final ClassLoader loader = getClass().getClassLoader(); + assertNotNull(loader); + + for (final String name : new String[] { + "org.mavlink.qgroundcontrol.serial.UsbSerialEnumerator", + "org.mavlink.qgroundcontrol.serial.QGCUsbSerialManager", + "org.mavlink.qgroundcontrol.serial.QGCSerialPort", + "org.mavlink.qgroundcontrol.serial.UsbAttachDetachReceiver", + "org.mavlink.qgroundcontrol.serial.UsbPermissionManager" }) { + assertNotNull(name, Class.forName(name, false, loader)); + } + } +} diff --git a/android/src/androidTest/java/org/mavlink/qgroundcontrol/serial/UsbManagerEnumerationInstrumentedTest.java b/android/src/androidTest/java/org/mavlink/qgroundcontrol/serial/UsbManagerEnumerationInstrumentedTest.java new file mode 100644 index 000000000000..bf04aaec51bb --- /dev/null +++ b/android/src/androidTest/java/org/mavlink/qgroundcontrol/serial/UsbManagerEnumerationInstrumentedTest.java @@ -0,0 +1,23 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertNotNull; + +import android.content.Context; +import android.hardware.usb.UsbManager; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class UsbManagerEnumerationInstrumentedTest { + + @Test + public void usbServiceResolves() { + final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + final UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); + assertNotNull(usbManager); + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/QGCActivity.java b/android/src/org/mavlink/qgroundcontrol/QGCActivity.java index 2ff0fa63fbe7..35e397b39134 100644 --- a/android/src/org/mavlink/qgroundcontrol/QGCActivity.java +++ b/android/src/org/mavlink/qgroundcontrol/QGCActivity.java @@ -6,19 +6,23 @@ import android.app.Activity; import android.content.Intent; import android.content.Context; +import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.net.wifi.WifiManager; +import android.os.Build; import android.os.Bundle; import android.provider.OpenableColumns; -import android.util.Log; import android.view.KeyEvent; import android.view.MotionEvent; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; import org.qtproject.qt.android.bindings.QtActivity; -import org.freedesktop.gstreamer.GStreamer; + +import org.mavlink.qgroundcontrol.serial.QGCUsbSerialManager; public class QGCActivity extends QtActivity { private static final String TAG = QGCActivity.class.getSimpleName(); @@ -26,6 +30,7 @@ public class QGCActivity extends QtActivity { private static volatile QGCActivity m_instance = null; private static final int IMPORT_FILE_REQUEST_CODE = 42; + private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 2; private static String s_importDestPath = ""; private WifiManager.MulticastLock m_wifiMulticastLock; @@ -39,9 +44,26 @@ public void onCreate(Bundle savedInstanceState) { nativeInit(); setupMulticastLock(); - QGCUsbSerialManager.initialize(this); + QGCUsbSerialManager.createInstance(this); QGCSDLManager.initialize(this); m_storagePermissionController = new QGCStoragePermissionController(this); + requestNotificationPermissionIfNeeded(); + } + + /** + * Requests POST_NOTIFICATIONS on Android 13+ so the USB serial foreground-service + * notification is visible. Denial is non-fatal — the service still runs. + */ + private void requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return; + } + final String permission = android.Manifest.permission.POST_NOTIFICATIONS; + if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { + return; + } + runOnUiThread(() -> ActivityCompat.requestPermissions( + this, new String[]{permission}, NOTIFICATION_PERMISSION_REQUEST_CODE)); } @Override @@ -61,14 +83,14 @@ protected void onDestroy() { try { QGCSDLManager.cleanup(); releaseMulticastLock(); - QGCUsbSerialManager.cleanup(this); + // Tear down the process-global manager only when the current instance is genuinely finishing; a launch/config recreation destroys a stale instance the live one still needs. + if (m_instance == this && isFinishing()) { + QGCUsbSerialManager.destroyInstance(); + m_instance = null; + } } catch (final Exception e) { QGCLogger.e(TAG, "Exception onDestroy()", e); } - - if (m_instance == this) { - m_instance = null; - } super.onDestroy(); } @@ -111,7 +133,7 @@ private void setupMulticastLock() { return; } m_wifiMulticastLock.acquire(); - QGCLogger.d(TAG, "Multicast lock: " + m_wifiMulticastLock.toString()); + QGCLogger.d(TAG, () -> "Multicast lock: " + m_wifiMulticastLock); } /** @@ -137,7 +159,7 @@ private String copyFileToDestination(final Uri uri, final String destDir) { if (cursor != null && cursor.moveToFirst()) { final int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); if (nameIndex >= 0) { - displayName = cursor.getString(nameIndex); + displayName = cursor.getString(nameIndex); displayName = sanitizeFilename(displayName); } } @@ -168,6 +190,10 @@ private String copyFileToDestination(final Uri uri, final String destDir) { File destFile; try { destFile = resolveDestFile(destDirectory, displayName); + if (!destFile.getCanonicalPath().startsWith(destDirectory.getCanonicalPath() + File.separator)) { + QGCLogger.e(TAG, "Rejected path traversal for: " + displayName); + return null; + } } catch (Exception e) { QGCLogger.e(TAG, "failed to get filename for: " + displayName, e); return null; @@ -191,7 +217,7 @@ private String copyFileToDestination(final Uri uri, final String destDir) { * sanitize file name. */ static String sanitizeFilename(String displayName) { - String[] badCharacters = new String[] { "..", "/" }; + String[] badCharacters = new String[] { "..", "/", "\\" }; String[] segments = displayName.split("/"); String fileName = segments[segments.length - 1]; for (String suspString : badCharacters) { @@ -286,18 +312,26 @@ public static void openFileImportDialog(final String destPath) { @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - - if (m_storagePermissionController == null) { - m_storagePermissionController = new QGCStoragePermissionController(this); + if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) { + final boolean granted = QGCStoragePermissionController.areAllPermissionsGranted(grantResults); + QGCLogger.i(TAG, "POST_NOTIFICATIONS " + (granted ? "granted" : "denied")); + return; } - final Boolean granted = m_storagePermissionController.onRequestPermissionsResult(requestCode, grantResults); - if (granted == null) { + if (requestCode == QGCStoragePermissionController.STORAGE_PERMISSION_REQUEST_CODE) { + if (m_storagePermissionController == null) { + m_storagePermissionController = new QGCStoragePermissionController(this); + } + final Boolean granted = m_storagePermissionController.onRequestPermissionsResult(requestCode, grantResults); + if (granted != null) { + nativeStoragePermissionsResult(granted); + } return; } - nativeStoragePermissionsResult(granted); + // Codes we did not issue belong to Qt; forwarding our own codes makes Qt log a + // spurious "no valid pending permission request" warning. + super.onRequestPermissionsResult(requestCode, permissions, grantResults); } @Override @@ -314,8 +348,6 @@ public boolean dispatchKeyEvent(KeyEvent event) { // Native C++ functions public native boolean nativeInit(); - public native void qgcLogDebug(final String message); - public native void qgcLogWarning(final String message); public native void nativeStoragePermissionsResult(boolean granted); public native void onImportResult(final String filePath); } diff --git a/android/src/org/mavlink/qgroundcontrol/QGCForegroundService.java b/android/src/org/mavlink/qgroundcontrol/QGCForegroundService.java new file mode 100644 index 000000000000..f3bde0240b11 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/QGCForegroundService.java @@ -0,0 +1,155 @@ +package org.mavlink.qgroundcontrol; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; + +/** + * Foreground service that keeps a QGC background workload alive while the app is backgrounded. + * + *

Caller provides a {@link Config} naming the notification channel + text and the + * {@link ServiceInfo} FGS type. The manifest entry's {@code android:foregroundServiceType} + * must declare every FGS type passed in via {@link Config#foregroundServiceType()}.

+ */ +public final class QGCForegroundService extends Service { + + private static final String ACTION_START = "org.mavlink.qgroundcontrol.action.START_FOREGROUND_SERVICE"; + + private static final String EXTRA_FGS_TYPE = "qgc.fgs.type"; + private static final String EXTRA_CHANNEL_ID = "qgc.fgs.channelId"; + private static final String EXTRA_CHANNEL_NAME = "qgc.fgs.channelName"; + private static final String EXTRA_CHANNEL_DESC = "qgc.fgs.channelDesc"; + private static final String EXTRA_NOTIFICATION_TXT = "qgc.fgs.notificationText"; + private static final int NOTIFICATION_ID = 1001; + + /** + * Notification + FGS-type description handed in by the caller. {@code channelId} must be + * stable per workload so Android doesn't keep recreating channels. + */ + public record Config( + int foregroundServiceType, + String channelId, + String channelName, + String channelDescription, + String notificationText) { + } + + public static void start(final Context context, final Config config) { + if (context == null || config == null) { + return; + } + final Context appContext = context.getApplicationContext(); + final Intent intent = new Intent(appContext, QGCForegroundService.class) + .setAction(ACTION_START) + .putExtra(EXTRA_FGS_TYPE, config.foregroundServiceType()) + .putExtra(EXTRA_CHANNEL_ID, config.channelId()) + .putExtra(EXTRA_CHANNEL_NAME, config.channelName()) + .putExtra(EXTRA_CHANNEL_DESC, config.channelDescription()) + .putExtra(EXTRA_NOTIFICATION_TXT, config.notificationText()); + appContext.startForegroundService(intent); + } + + public static void stop(final Context context) { + if (context == null) { + return; + } + final Context appContext = context.getApplicationContext(); + final Intent intent = new Intent(appContext, QGCForegroundService.class); + appContext.stopService(intent); + } + + @Override + public IBinder onBind(final Intent intent) { + return null; + } + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + if (intent == null) { + stopSelf(startId); + return START_NOT_STICKY; + } + final int fgsType = intent.getIntExtra(EXTRA_FGS_TYPE, + ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE); + final String channelId = intent.getStringExtra(EXTRA_CHANNEL_ID); + final String channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME); + final String channelDesc = intent.getStringExtra(EXTRA_CHANNEL_DESC); + final String text = intent.getStringExtra(EXTRA_NOTIFICATION_TXT); + if (channelId == null || channelName == null) { + stopSelf(startId); + return START_NOT_STICKY; + } + createNotificationChannel(channelId, channelName, channelDesc); + final Notification notification = buildNotification(channelId, text); + int resolvedFgsType = fgsType; + if (Build.VERSION.SDK_INT >= 34 + && resolvedFgsType == ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE) { + android.util.Log.w("QGCForegroundService", + "fgsType is NONE on API 34+; defaulting to CONNECTED_DEVICE"); + resolvedFgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE; + } + // startForeground(id, notification, type) requires API 29; minSdk=28 needs the 2-arg + // overload with the FGS type pulled from the manifest entry. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, resolvedFgsType); + } else { + startForeground(NOTIFICATION_ID, notification); + } + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + stopForeground(STOP_FOREGROUND_REMOVE); + super.onDestroy(); + } + + private void createNotificationChannel(final String channelId, final String channelName, + final String channelDescription) { + final NotificationManager manager = + (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager == null || manager.getNotificationChannel(channelId) != null) { + return; + } + final NotificationChannel channel = new NotificationChannel( + channelId, channelName, NotificationManager.IMPORTANCE_LOW); + if (channelDescription != null) { + channel.setDescription(channelDescription); + } + manager.createNotificationChannel(channel); + } + + private Notification buildNotification(final String channelId, final String text) { + final Intent launchIntent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + final PendingIntent contentIntent = (launchIntent == null) ? null : PendingIntent.getActivity( + this, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + final CharSequence appLabel = getApplicationInfo().loadLabel(getPackageManager()); + final int icon = (getApplicationInfo().icon != 0) + ? getApplicationInfo().icon + : android.R.drawable.sym_def_app_icon; + + final Notification.Builder builder = new Notification.Builder(this, channelId) + .setSmallIcon(icon) + .setContentTitle(appLabel) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_SERVICE); + if (text != null) { + builder.setContentText(text); + } + if (contentIntent != null) { + builder.setContentIntent(contentIntent); + } + return builder.build(); + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/QGCFtdiDriver.java b/android/src/org/mavlink/qgroundcontrol/QGCFtdiDriver.java deleted file mode 100644 index 5b67b38169a9..000000000000 --- a/android/src/org/mavlink/qgroundcontrol/QGCFtdiDriver.java +++ /dev/null @@ -1,305 +0,0 @@ -package org.mavlink.qgroundcontrol; - -import android.content.Context; -import android.hardware.usb.UsbDevice; -import com.ftdi.j2xx.D2xxManager; -import com.ftdi.j2xx.FT_Device; -import com.hoho.android.usbserial.driver.UsbSerialPort; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -final class QGCFtdiDriver { - private static final String TAG = QGCFtdiDriver.class.getSimpleName(); - - private static Context sAppContext; - private static D2xxManager sManager; - - private final FT_Device _device; - - private int _flowControl = UsbSerialPort.FlowControl.NONE.ordinal(); - private boolean _dtr; - private boolean _rts; - - private QGCFtdiDriver(final FT_Device device) { - _device = device; - } - - static void initialize(final Context context) { - sAppContext = context.getApplicationContext(); - try { - sManager = D2xxManager.getInstance(sAppContext); - } catch (D2xxManager.D2xxException e) { - sManager = null; - QGCLogger.w(TAG, "D2XX manager unavailable: " + e.getMessage()); - } catch (Throwable t) { - sManager = null; - QGCLogger.w(TAG, "D2XX manager unavailable: " + t.getMessage()); - } - } - - static void cleanup() { - sManager = null; - sAppContext = null; - } - - static boolean isAvailable() { - return (sManager != null) && (sAppContext != null); - } - - static boolean isFtdiDevice(final UsbDevice device) { - return device != null - && device.getVendorId() == QGCUsbId.VENDOR_FTDI - && QGCUsbId.isSupportedFtdiProductId(device.getProductId()); - } - - static QGCFtdiDriver open(final UsbDevice device) { - if (sManager == null || sAppContext == null || !isFtdiDevice(device)) { - return null; - } - - try { - final FT_Device d2xxDevice = sManager.openByUsbDevice(sAppContext, device); - if (d2xxDevice == null || !d2xxDevice.isOpen()) { - return null; - } - return new QGCFtdiDriver(d2xxDevice); - } catch (Throwable t) { - QGCLogger.w(TAG, "Failed to open D2XX FTDI device " + device.getDeviceName() + ": " + t.getMessage()); - return null; - } - } - - boolean isOpen() { - return _device != null && _device.isOpen(); - } - - void close() { - try { - if (_device != null) { - _device.close(); - } - } catch (Throwable t) { - QGCLogger.w(TAG, "Error closing D2XX device: " + t.getMessage()); - } - } - - int write(final byte[] data, final int length, final int timeoutMSec) { - if (!isOpen() || data == null || length <= 0) { - return -1; - } - - final int writeLength = Math.min(length, data.length); - final byte[] writeData = (writeLength == data.length) ? data : Arrays.copyOf(data, writeLength); - try { - return _device.write(writeData, writeLength, true, timeoutMSec); - } catch (Throwable t) { - QGCLogger.e(TAG, "Error writing D2XX data", t); - return -1; - } - } - - byte[] read(final int length, final int timeoutMs) { - if (!isOpen() || length <= 0) { - return new byte[]{}; - } - - final byte[] buffer = new byte[length]; - try { - final int bytesRead = _device.read(buffer, length, timeoutMs); - if (bytesRead <= 0) { - return new byte[]{}; - } - return (bytesRead < length) ? Arrays.copyOf(buffer, bytesRead) : buffer; - } catch (Throwable t) { - QGCLogger.e(TAG, "Error reading D2XX data", t); - return new byte[]{}; - } - } - - boolean setParameters(final int baudRate, final int dataBits, final int stopBits, final int parity) { - if (!isOpen()) { - return false; - } - try { - final boolean baudOk = _device.setBaudRate(baudRate); - final boolean charsOk = _device.setDataCharacteristics(toD2xxDataBits(dataBits), toD2xxStopBits(stopBits), toD2xxParity(parity)); - return baudOk && charsOk; - } catch (Throwable t) { - QGCLogger.e(TAG, "Error setting D2XX parameters", t); - return false; - } - } - - boolean getControlLine(final UsbSerialPort.ControlLine controlLine) { - if (!isOpen()) { - return false; - } - - try { - final short modemStatus = _device.getModemStatus(); - switch (controlLine) { - case CD: - return (modemStatus & D2xxManager.FT_DCD) != 0; - case CTS: - return (modemStatus & D2xxManager.FT_CTS) != 0; - case DSR: - return (modemStatus & D2xxManager.FT_DSR) != 0; - case DTR: - return _dtr; - case RI: - return (modemStatus & D2xxManager.FT_RI) != 0; - case RTS: - return _rts; - default: - return false; - } - } catch (Throwable t) { - QGCLogger.e(TAG, "Error reading D2XX control line " + controlLine, t); - return false; - } - } - - boolean setControlLine(final UsbSerialPort.ControlLine controlLine, final boolean on) { - if (!isOpen()) { - return false; - } - - try { - switch (controlLine) { - case DTR: - _dtr = on; - return on ? _device.setDtr() : _device.clrDtr(); - case RTS: - _rts = on; - return on ? _device.setRts() : _device.clrRts(); - default: - return false; - } - } catch (Throwable t) { - QGCLogger.e(TAG, "Error setting D2XX control line " + controlLine, t); - return false; - } - } - - int[] getControlLines() { - final List lines = new ArrayList<>(); - if (getControlLine(UsbSerialPort.ControlLine.RTS)) { - lines.add(UsbSerialPort.ControlLine.RTS.ordinal()); - } - if (getControlLine(UsbSerialPort.ControlLine.CTS)) { - lines.add(UsbSerialPort.ControlLine.CTS.ordinal()); - } - if (getControlLine(UsbSerialPort.ControlLine.DTR)) { - lines.add(UsbSerialPort.ControlLine.DTR.ordinal()); - } - if (getControlLine(UsbSerialPort.ControlLine.DSR)) { - lines.add(UsbSerialPort.ControlLine.DSR.ordinal()); - } - if (getControlLine(UsbSerialPort.ControlLine.CD)) { - lines.add(UsbSerialPort.ControlLine.CD.ordinal()); - } - if (getControlLine(UsbSerialPort.ControlLine.RI)) { - lines.add(UsbSerialPort.ControlLine.RI.ordinal()); - } - return lines.stream().mapToInt(Integer::intValue).toArray(); - } - - int getFlowControl() { - return _flowControl; - } - - boolean setFlowControl(final int flowControl) { - if (!isOpen()) { - return false; - } - try { - final short flowMode = toD2xxFlowControl(flowControl); - final boolean result = _device.setFlowControl(flowMode, (byte) 0x11, (byte) 0x13); - if (result) { - _flowControl = flowControl; - } - return result; - } catch (Throwable t) { - QGCLogger.e(TAG, "Error setting D2XX flow control", t); - return false; - } - } - - boolean setBreak(final boolean on) { - if (!isOpen()) { - return false; - } - try { - return on ? _device.setBreakOn() : _device.setBreakOff(); - } catch (Throwable t) { - QGCLogger.e(TAG, "Error setting D2XX break condition", t); - return false; - } - } - - boolean purgeBuffers(final boolean input, final boolean output) { - if (!isOpen()) { - return false; - } - - byte purgeFlags = 0; - if (input) { - purgeFlags |= D2xxManager.FT_PURGE_RX; - } - if (output) { - purgeFlags |= D2xxManager.FT_PURGE_TX; - } - if (purgeFlags == 0) { - return true; - } - - try { - return _device.purge(purgeFlags); - } catch (Throwable t) { - QGCLogger.e(TAG, "Error purging D2XX buffers", t); - return false; - } - } - - private static byte toD2xxDataBits(final int dataBits) { - return (dataBits == 7) ? D2xxManager.FT_DATA_BITS_7 : D2xxManager.FT_DATA_BITS_8; - } - - private static byte toD2xxStopBits(final int stopBits) { - if (stopBits == UsbSerialPort.STOPBITS_2) { - return D2xxManager.FT_STOP_BITS_2; - } - return D2xxManager.FT_STOP_BITS_1; - } - - private static byte toD2xxParity(final int parity) { - switch (parity) { - case UsbSerialPort.PARITY_ODD: - return D2xxManager.FT_PARITY_ODD; - case UsbSerialPort.PARITY_EVEN: - return D2xxManager.FT_PARITY_EVEN; - case UsbSerialPort.PARITY_MARK: - return D2xxManager.FT_PARITY_MARK; - case UsbSerialPort.PARITY_SPACE: - return D2xxManager.FT_PARITY_SPACE; - case UsbSerialPort.PARITY_NONE: - default: - return D2xxManager.FT_PARITY_NONE; - } - } - - private static short toD2xxFlowControl(final int flowControl) { - if (flowControl == UsbSerialPort.FlowControl.RTS_CTS.ordinal()) { - return D2xxManager.FT_FLOW_RTS_CTS; - } - if (flowControl == UsbSerialPort.FlowControl.DTR_DSR.ordinal()) { - return D2xxManager.FT_FLOW_DTR_DSR; - } - if (flowControl == UsbSerialPort.FlowControl.XON_XOFF.ordinal()) { - return D2xxManager.FT_FLOW_XON_XOFF; - } - return D2xxManager.FT_FLOW_NONE; - } -} diff --git a/android/src/org/mavlink/qgroundcontrol/QGCFtdiSerialDriver.java b/android/src/org/mavlink/qgroundcontrol/QGCFtdiSerialDriver.java deleted file mode 100644 index b04ab98eee3e..000000000000 --- a/android/src/org/mavlink/qgroundcontrol/QGCFtdiSerialDriver.java +++ /dev/null @@ -1,432 +0,0 @@ -package org.mavlink.qgroundcontrol; - -import android.hardware.usb.UsbConstants; -import android.hardware.usb.UsbDevice; -import android.hardware.usb.UsbDeviceConnection; -import android.hardware.usb.UsbEndpoint; -import android.hardware.usb.UsbInterface; -import com.hoho.android.usbserial.driver.UsbSerialDriver; -import com.hoho.android.usbserial.driver.UsbSerialPort; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.EnumSet; -import java.util.List; -import java.util.Map; - -public final class QGCFtdiSerialDriver implements UsbSerialDriver { - private final UsbDevice _device; - private final List _ports; - - public QGCFtdiSerialDriver(final UsbDevice device) { - _device = device; - - final int interfaceCount = device.getInterfaceCount(); - if (interfaceCount <= 0) { - _ports = Collections.emptyList(); - return; - } - final List ports = new ArrayList<>(interfaceCount); - for (int i = 0; i < interfaceCount; i++) { - ports.add(new QGCFtdiSerialPort(this, device, i)); - } - - _ports = Collections.unmodifiableList(ports); - } - - public static Map getSupportedDevices() { - return Collections.singletonMap(QGCUsbId.VENDOR_FTDI, new int[] { - QGCUsbId.DEVICE_FTDI_FT232R, - QGCUsbId.DEVICE_FTDI_FT2232H, - QGCUsbId.DEVICE_FTDI_FT4232H, - QGCUsbId.DEVICE_FTDI_FT232H, - QGCUsbId.DEVICE_FTDI_FT231X - }); - } - - @Override - public UsbDevice getDevice() { - return _device; - } - - @Override - public List getPorts() { - return _ports; - } - - private static final class QGCFtdiSerialPort implements UsbSerialPort { - private final QGCFtdiSerialDriver _driver; - private final UsbDevice _device; - private final int _portNumber; - - private UsbDeviceConnection _connection; - private UsbEndpoint _readEndpoint; - private UsbEndpoint _writeEndpoint; - private QGCFtdiDriver _ftdi; - private int _readQueueBufferCount; - private int _readQueueBufferSize; - - QGCFtdiSerialPort(final QGCFtdiSerialDriver driver, final UsbDevice device, final int portNumber) { - _driver = driver; - _device = device; - _portNumber = portNumber; - } - - @Override - public UsbSerialDriver getDriver() { - return _driver; - } - - @Override - public UsbDevice getDevice() { - return _device; - } - - @Override - public int getPortNumber() { - return _portNumber; - } - - @Override - public UsbEndpoint getWriteEndpoint() { - return _writeEndpoint; - } - - @Override - public UsbEndpoint getReadEndpoint() { - return _readEndpoint; - } - - @Override - public String getSerial() { - try { - return _device.getSerialNumber(); - } catch (SecurityException e) { - return null; - } - } - - @Override - public void setReadQueue(final int bufferCount, final int bufferSize) { - if (bufferCount < 0) { - throw new IllegalArgumentException("Invalid bufferCount"); - } - if (bufferSize < 0) { - throw new IllegalArgumentException("Invalid bufferSize"); - } - - int effectiveBufferSize = bufferSize; - if (isOpen() && (effectiveBufferSize == 0) && (_readEndpoint != null)) { - effectiveBufferSize = _readEndpoint.getMaxPacketSize(); - } - - if (isOpen()) { - if (bufferCount < _readQueueBufferCount) { - throw new IllegalStateException("Cannot reduce bufferCount when port is open"); - } - if ((_readQueueBufferCount != 0) && (_readQueueBufferSize != 0) && (effectiveBufferSize != _readQueueBufferSize)) { - throw new IllegalStateException("Cannot change bufferSize when port is open"); - } - } - - _readQueueBufferCount = bufferCount; - _readQueueBufferSize = effectiveBufferSize; - } - - @Override - public int getReadQueueBufferCount() { - return _readQueueBufferCount; - } - - @Override - public int getReadQueueBufferSize() { - return _readQueueBufferSize; - } - - @Override - public void open(final UsbDeviceConnection connection) throws IOException { - if (isOpen()) { - throw new IOException("Already open"); - } - if (connection == null) { - throw new IOException("Null USB device connection"); - } - - final QGCFtdiDriver ftdi = QGCFtdiDriver.open(_device); - if (ftdi == null || !ftdi.isOpen()) { - throw new IOException("Failed to open D2XX FTDI device " + _device.getDeviceName()); - } - - _connection = connection; - _ftdi = ftdi; - - if (!resolveBulkEndpoints()) { - close(); - throw new IOException("Failed to resolve FTDI bulk endpoints for " + _device.getDeviceName()); - } - } - - @Override - public void close() throws IOException { - if (_ftdi != null) { - _ftdi.close(); - _ftdi = null; - } - - if (_connection != null) { - _connection.close(); - _connection = null; - } - - _readEndpoint = null; - _writeEndpoint = null; - } - - @Override - public int read(final byte[] dest, final int timeout) throws IOException { - return read(dest, dest.length, timeout); - } - - @Override - public int read(final byte[] dest, final int length, final int timeout) throws IOException { - ensureOpen(); - if (dest == null) { - throw new IOException("Null read buffer"); - } - if (length <= 0) { - return 0; - } - - final int readLength = Math.min(length, dest.length); - final byte[] data = _ftdi.read(readLength, timeout); - if (data.length > 0) { - System.arraycopy(data, 0, dest, 0, data.length); - } - return data.length; - } - - @Override - public void write(final byte[] src, final int timeout) throws IOException { - write(src, src.length, timeout); - } - - @Override - public void write(final byte[] src, final int length, final int timeout) throws IOException { - ensureOpen(); - if (src == null) { - throw new IOException("Null write buffer"); - } - if (length <= 0) { - return; - } - - final int writeLength = Math.min(length, src.length); - final int written = _ftdi.write(src, writeLength, timeout); - if (written < writeLength) { - throw new IOException("D2XX write failed or short write: " + written + " / " + writeLength); - } - } - - @Override - public void setParameters(final int baudRate, final int dataBits, final int stopBits, final int parity) throws IOException { - ensureOpen(); - if (!_ftdi.setParameters(baudRate, dataBits, stopBits, parity)) { - throw new IOException("Failed to set FTDI serial parameters"); - } - } - - @Override - public boolean getCD() throws IOException { - ensureOpen(); - return _ftdi.getControlLine(ControlLine.CD); - } - - @Override - public boolean getCTS() throws IOException { - ensureOpen(); - return _ftdi.getControlLine(ControlLine.CTS); - } - - @Override - public boolean getDSR() throws IOException { - ensureOpen(); - return _ftdi.getControlLine(ControlLine.DSR); - } - - @Override - public boolean getDTR() throws IOException { - ensureOpen(); - return _ftdi.getControlLine(ControlLine.DTR); - } - - @Override - public void setDTR(final boolean value) throws IOException { - ensureOpen(); - if (!_ftdi.setControlLine(ControlLine.DTR, value)) { - throw new IOException("Failed to set DTR"); - } - } - - @Override - public boolean getRI() throws IOException { - ensureOpen(); - return _ftdi.getControlLine(ControlLine.RI); - } - - @Override - public boolean getRTS() throws IOException { - ensureOpen(); - return _ftdi.getControlLine(ControlLine.RTS); - } - - @Override - public void setRTS(final boolean value) throws IOException { - ensureOpen(); - if (!_ftdi.setControlLine(ControlLine.RTS, value)) { - throw new IOException("Failed to set RTS"); - } - } - - @Override - public EnumSet getControlLines() throws IOException { - ensureOpen(); - final EnumSet lines = EnumSet.noneOf(ControlLine.class); - if (getRTS()) { - lines.add(ControlLine.RTS); - } - if (getCTS()) { - lines.add(ControlLine.CTS); - } - if (getDTR()) { - lines.add(ControlLine.DTR); - } - if (getDSR()) { - lines.add(ControlLine.DSR); - } - if (getCD()) { - lines.add(ControlLine.CD); - } - if (getRI()) { - lines.add(ControlLine.RI); - } - return lines; - } - - @Override - public EnumSet getSupportedControlLines() { - return EnumSet.of(ControlLine.RTS, ControlLine.CTS, ControlLine.DTR, ControlLine.DSR, ControlLine.CD, ControlLine.RI); - } - - @Override - public void setFlowControl(final FlowControl flowControl) throws IOException { - ensureOpen(); - if (flowControl == FlowControl.XON_XOFF_INLINE) { - throw new IOException("XON/XOFF inline flow control is not supported by D2XX adapter"); - } - - if (!_ftdi.setFlowControl(flowControl.ordinal())) { - throw new IOException("Failed to set flow control: " + flowControl); - } - } - - @Override - public FlowControl getFlowControl() { - if (!isOpen()) { - return FlowControl.NONE; - } - final int ordinal = _ftdi.getFlowControl(); - final FlowControl[] values = FlowControl.values(); - if (ordinal < 0 || ordinal >= values.length) { - return FlowControl.NONE; - } - return values[ordinal]; - } - - @Override - public EnumSet getSupportedFlowControl() { - return EnumSet.of(FlowControl.NONE, FlowControl.RTS_CTS, FlowControl.DTR_DSR, FlowControl.XON_XOFF); - } - - @Override - public boolean getXON() throws IOException { - ensureOpen(); - return false; - } - - @Override - public void purgeHwBuffers(final boolean purgeReadBuffers, final boolean purgeWriteBuffers) throws IOException { - ensureOpen(); - if (!_ftdi.purgeBuffers(purgeReadBuffers, purgeWriteBuffers)) { - throw new IOException("Failed to purge FTDI buffers"); - } - } - - @Override - public void setBreak(final boolean value) throws IOException { - ensureOpen(); - if (!_ftdi.setBreak(value)) { - throw new IOException("Failed to set break condition"); - } - } - - @Override - public boolean isOpen() { - return _ftdi != null && _ftdi.isOpen(); - } - - private void ensureOpen() throws IOException { - if (!isOpen()) { - throw new IOException("Port not open"); - } - } - - private boolean resolveBulkEndpoints() { - UsbEndpoint read = null; - UsbEndpoint write = null; - - if (_portNumber < _device.getInterfaceCount()) { - final UsbInterface usbInterface = _device.getInterface(_portNumber); - for (int e = 0; e < usbInterface.getEndpointCount(); e++) { - final UsbEndpoint endpoint = usbInterface.getEndpoint(e); - if (endpoint.getType() != UsbConstants.USB_ENDPOINT_XFER_BULK) { - continue; - } - if (endpoint.getDirection() == UsbConstants.USB_DIR_IN && read == null) { - read = endpoint; - } else if (endpoint.getDirection() == UsbConstants.USB_DIR_OUT && write == null) { - write = endpoint; - } - } - } - - // Fallback to global endpoint scan for devices that don't expose - // interface-per-port semantics. - if (read == null || write == null) { - for (int i = 0; i < _device.getInterfaceCount(); i++) { - final UsbInterface usbInterface = _device.getInterface(i); - for (int e = 0; e < usbInterface.getEndpointCount(); e++) { - final UsbEndpoint endpoint = usbInterface.getEndpoint(e); - if (endpoint.getType() != UsbConstants.USB_ENDPOINT_XFER_BULK) { - continue; - } - if (endpoint.getDirection() == UsbConstants.USB_DIR_IN && read == null) { - read = endpoint; - } else if (endpoint.getDirection() == UsbConstants.USB_DIR_OUT && write == null) { - write = endpoint; - } - } - - if (read != null && write != null) { - break; - } - } - } - - _readEndpoint = read; - _writeEndpoint = write; - - return _readEndpoint != null && _writeEndpoint != null; - } - } -} diff --git a/android/src/org/mavlink/qgroundcontrol/QGCLogger.java b/android/src/org/mavlink/qgroundcontrol/QGCLogger.java index b117a6fac6cc..bc1b25b09acb 100644 --- a/android/src/org/mavlink/qgroundcontrol/QGCLogger.java +++ b/android/src/org/mavlink/qgroundcontrol/QGCLogger.java @@ -2,14 +2,37 @@ import android.util.Log; +import java.util.function.Supplier; + /** * A centralized logging utility that manages log messages across the application. * It controls log levels and formats based on build configurations. + * + *

Each log call is mirrored to {@link QGCNativeLogSink} so that Java log + * output appears in the Qt logging system (and therefore in QGC's log viewer). + * If the native library is not yet loaded, the mirror call is silently skipped + * (see {@link QGCNativeLogSink#log}). + * + *

The hot-path {@code d}/{@code v} (debug/verbose) methods carry + * {@link Supplier Supplier<String>} overloads that defer message + * construction until after the level gate: + *

+ *     QGCLogger.d(TAG, () -> "Large payload (" + data.length + " bytes)");
+ * 
+ * The lambda is only invoked when the level is enabled, eliminating the + * per-call allocation on calls the short-circuit gate would have dropped. + * {@code i}/{@code w}/{@code e} always log, so they take plain strings only. */ public class QGCLogger { // Determine if the build is a debug build private static final boolean DEBUG = BuildConfig.DEBUG; + /** True if debug-level logging is enabled. Callers on hot paths can + * guard expensive diagnostics themselves: {@code if (QGCLogger.isDebugEnabled()) ...}. */ + public static boolean isDebugEnabled() { + return DEBUG; + } + /** * Logs a debug message. * @@ -19,6 +42,48 @@ public class QGCLogger { public static void d(String tag, String message) { if (DEBUG) { Log.d(tag, message); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_DEBUG, tag, message); + } + } + + /** Debug log that defers message construction via a {@link Supplier}. */ + public static void d(String tag, Supplier message) { + if (DEBUG) { + final String msg = message.get(); + Log.d(tag, msg); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_DEBUG, tag, msg); + } + } + + /** Verbose log gated by per-tag {@code adb shell setprop log.tag. VERBOSE} (≤23 chars). */ + public static void v(String tag, String message) { + if (Log.isLoggable(tag, Log.VERBOSE)) { + Log.v(tag, message); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_DEBUG, tag, message); + } + } + + /** Verbose log enabled by either the class tag or a package-wide parent tag. */ + public static void v(String tag, String parentTag, String message) { + if (Log.isLoggable(tag, Log.VERBOSE) || Log.isLoggable(parentTag, Log.VERBOSE)) { + Log.v(tag, message); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_DEBUG, tag, message); + } + } + + public static void v(String tag, Supplier message) { + if (Log.isLoggable(tag, Log.VERBOSE)) { + final String msg = message.get(); + Log.v(tag, msg); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_DEBUG, tag, msg); + } + } + + public static void v(String tag, String parentTag, Supplier message) { + if (Log.isLoggable(tag, Log.VERBOSE) || Log.isLoggable(parentTag, Log.VERBOSE)) { + final String msg = message.get(); + Log.v(tag, msg); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_DEBUG, tag, msg); } } @@ -30,6 +95,7 @@ public static void d(String tag, String message) { */ public static void i(String tag, String message) { Log.i(tag, message); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_INFO, tag, message); } /** @@ -40,6 +106,7 @@ public static void i(String tag, String message) { */ public static void w(String tag, String message) { Log.w(tag, message); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_WARNING, tag, message); } /** @@ -51,6 +118,8 @@ public static void w(String tag, String message) { */ public static void w(String tag, String message, Throwable throwable) { Log.w(tag, message, throwable); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_WARNING, tag, + message + ": " + throwable.getMessage()); } /** @@ -62,6 +131,8 @@ public static void w(String tag, String message, Throwable throwable) { */ public static void e(String tag, String message, Throwable throwable) { Log.e(tag, message, throwable); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_ERROR, tag, + message + ": " + throwable.getMessage()); } /** @@ -72,5 +143,6 @@ public static void e(String tag, String message, Throwable throwable) { */ public static void e(String tag, String message) { Log.e(tag, message); + QGCNativeLogSink.log(QGCNativeLogSink.LEVEL_ERROR, tag, message); } } diff --git a/android/src/org/mavlink/qgroundcontrol/QGCNativeLogSink.java b/android/src/org/mavlink/qgroundcontrol/QGCNativeLogSink.java new file mode 100644 index 000000000000..0fd633a3556f --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/QGCNativeLogSink.java @@ -0,0 +1,54 @@ +package org.mavlink.qgroundcontrol; + +/** + * Bridges Java log output into the QGC native (Qt) logging system. + * + *

Each log call is forwarded via JNI to {@code nativeLog}, which maps the + * level ordinal to the appropriate {@code qCDebug/qCInfo/qCWarning/qCCritical} + * category on the C++ side. + * + *

JNI availability is tested lazily on the first call. If the native + * library is not yet loaded (e.g. during early unit-test bootstrap), the + * {@link UnsatisfiedLinkError} is caught once and subsequent calls are + * silently skipped. + */ +public final class QGCNativeLogSink { + + /** Log-level ordinals — must match the C++ side mapping in AndroidLogSink.cc. */ + public static final int LEVEL_DEBUG = 0; + public static final int LEVEL_INFO = 1; + public static final int LEVEL_WARNING = 2; + public static final int LEVEL_ERROR = 3; + + private static final java.util.concurrent.atomic.AtomicBoolean sJniAvailable = new java.util.concurrent.atomic.AtomicBoolean(true); + + private QGCNativeLogSink() { /* utility class */ } + + /** + * Forwards a log entry to native. + * + * @param level one of {@link #LEVEL_DEBUG}, {@link #LEVEL_INFO}, + * {@link #LEVEL_WARNING}, {@link #LEVEL_ERROR}. + * @param tag log tag (typically the calling class name). + * @param message log message. + */ + public static void log(final int level, final String tag, final String message) { + if (!sJniAvailable.get()) { + return; + } + try { + nativeLog(level, tag, message); + } catch (final UnsatisfiedLinkError e) { + sJniAvailable.compareAndSet(true, false); + } + } + + /** + * Native entry point registered by {@code AndroidLogSink.cc}. + * + * @param level log level ordinal (see {@link #LEVEL_DEBUG} etc.). + * @param tag log tag. + * @param message log message. + */ + public static native void nativeLog(int level, String tag, String message); +} diff --git a/android/src/org/mavlink/qgroundcontrol/QGCUsbId.java b/android/src/org/mavlink/qgroundcontrol/QGCUsbId.java deleted file mode 100644 index 433f4b94cb5a..000000000000 --- a/android/src/org/mavlink/qgroundcontrol/QGCUsbId.java +++ /dev/null @@ -1,85 +0,0 @@ -package org.mavlink.qgroundcontrol; - -public final class QGCUsbId { - - public static final int VENDOR_FTDI = 0x0403; - public static final int DEVICE_FTDI_FT232R = 0x6001; - public static final int DEVICE_FTDI_FT2232H = 0x6010; - public static final int DEVICE_FTDI_FT4232H = 0x6011; - public static final int DEVICE_FTDI_FT232H = 0x6014; - public static final int DEVICE_FTDI_FT231X = 0x6015; - - public static final int VENDOR_PX4 = 0x26AC; - public static final int DEVICE_PX4FMU_V1 = 0x0010; - public static final int DEVICE_PX4FMU_V2 = 0x0011; - public static final int DEVICE_PX4FMU_V3 = 0x0011; // V3 shares V2 USB PID - public static final int DEVICE_PX4FMU_V4 = 0x0012; - public static final int DEVICE_PX4FMU_V4PRO = 0x0013; - public static final int DEVICE_PX4FMU_V5 = 0x0032; - public static final int DEVICE_PX4FMU_V5X = 0x0033; - public static final int DEVICE_PX4FMU_V6C = 0x0038; - public static final int DEVICE_PX4FMU_V6U = 0x0036; - public static final int DEVICE_PX4FMU_V6X = 0x0035; - public static final int DEVICE_PX4FMU_V6XRT = 0x001D; - public static final int DEVICE_PX4MINDPX_V2 = 0x0030; - - public static final int VENDOR_UBLOX = 0x1546; - public static final int DEVICE_UBLOX_5 = 0x01a5; - public static final int DEVICE_UBLOX_6 = 0x01a6; - public static final int DEVICE_UBLOX_7 = 0x01a7; - public static final int DEVICE_UBLOX_8 = 0x01a8; - - public static final int VENDOR_OPENPILOT = 0x20A0; - public static final int DEVICE_REVOLUTION = 0x415E; - public static final int DEVICE_OPLINK = 0x415C; - public static final int DEVICE_SPARKY2 = 0x41D0; - public static final int DEVICE_CC3D = 0x415D; - - public static final int VENDOR_ARDUPILOT = 0x1209; - public static final int DEVICE_ARDUPILOT_CHIBIOS = 0x5740; - public static final int DEVICE_ARDUPILOT_CHIBIOS2 = 0x5741; - - public static final int VENDOR_DRAGONLINK = 0x1FC9; - public static final int DEVICE_DRAGONLINK = 0x0083; - - public static final int VENDOR_CUBEPILOT = 0x2DAE; - public static final int DEVICE_CUBE_BLACK = 0x1011; - public static final int DEVICE_CUBE_BLACK_BOOTLOADER = 0x1001; - public static final int DEVICE_CUBE_BLACK_PLUS = 0x1011; - public static final int DEVICE_CUBE_ORANGE = 0x1016; - public static final int DEVICE_CUBE_ORANGE2 = 0x1017; - public static final int DEVICE_CUBE_ORANGEPLUS = 0x1058; - public static final int DEVICE_CUBE_YELLOW_BOOTLOADER = 0x1002; - public static final int DEVICE_CUBE_YELLOW = 0x1012; - public static final int DEVICE_CUBE_PURPLE_BOOTLOADER = 0x1005; - public static final int DEVICE_CUBE_PURPLE = 0x1015; - - public static final int VENDOR_CUAV = 0x3163; - public static final int DEVICE_CUAV_NORA = 0x004C; - public static final int DEVICE_CUAV_X7PRO = 0x004C; // X7PRO shares NORA USB PID - - public static final int VENDOR_HOLYBRO = 0x3162; - public static final int DEVICE_PIXHAWK4 = 0x0047; - public static final int DEVICE_PH4_MINI = 0x0049; - public static final int DEVICE_DURANDAL = 0x004B; - - public static final int VENDOR_LASER_NAVIGATION = 0x27AC; - public static final int DEVICE_VRBRAIN_V51 = 0x1151; - public static final int DEVICE_VRBRAIN_V52 = 0x1152; - public static final int DEVICE_VRBRAIN_V54 = 0x1154; - public static final int DEVICE_VRCORE_V10 = 0x1910; - public static final int DEVICE_VRUBRAIN_V51 = 0x1351; - - private QGCUsbId() - { - throw new IllegalAccessError("Non-instantiable class"); - } - - public static boolean isSupportedFtdiProductId(final int productId) { - return productId == DEVICE_FTDI_FT232R - || productId == DEVICE_FTDI_FT2232H - || productId == DEVICE_FTDI_FT4232H - || productId == DEVICE_FTDI_FT232H - || productId == DEVICE_FTDI_FT231X; - } -} diff --git a/android/src/org/mavlink/qgroundcontrol/QGCUsbSerialManager.java b/android/src/org/mavlink/qgroundcontrol/QGCUsbSerialManager.java deleted file mode 100644 index 6d60f978b30d..000000000000 --- a/android/src/org/mavlink/qgroundcontrol/QGCUsbSerialManager.java +++ /dev/null @@ -1,1520 +0,0 @@ -package org.mavlink.qgroundcontrol; - -import android.app.PendingIntent; -import android.content.*; -import android.hardware.usb.*; -import android.os.Build; -import android.os.Process; -import com.hoho.android.usbserial.driver.*; -import com.hoho.android.usbserial.util.*; - -import java.io.IOException; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; - -public class QGCUsbSerialManager { - private static final String TAG = QGCUsbSerialManager.class.getSimpleName(); - private static final String ACTION_USB_PERMISSION = "org.mavlink.qgroundcontrol.action.USB_PERMISSION"; - // Sentinel values: BAD_DEVICE_ID (0) for invalid device IDs, - // getDeviceHandle() returns -1 for missing file descriptors. - private static final int BAD_DEVICE_ID = 0; - private static final int READ_BUF_SIZE = 2048; - private static final int MAX_NATIVE_CALLBACK_DATA_BYTES = 16 * 1024; - private static final String PORT_SUFFIX = "#p"; - private static final Object lifecycleLock = new Object(); - - private static UsbManager usbManager; - private static Context appContext; - private static PendingIntent usbPermissionIntent; - private static UsbSerialProber usbSerialProber; - private static boolean receiverRegistered; - - private static final List drivers = new CopyOnWriteArrayList<>(); - private static final ConcurrentHashMap deviceResourcesMap = new ConcurrentHashMap<>(); - private static final ConcurrentHashMap portAddressToResourceId = new ConcurrentHashMap<>(); - private static final ConcurrentHashMap resourceIdToPortAddress = new ConcurrentHashMap<>(); - private static final AtomicInteger nextResourceId = new AtomicInteger(1); - - interface NativeCallbacks { - void onDeviceHasDisconnected(long classPtr); - void onDeviceException(long classPtr, String message); - void onDeviceNewData(long classPtr, byte[] data); - } - - private static final class JniNativeCallbacks implements NativeCallbacks { - @Override - public void onDeviceHasDisconnected(final long classPtr) { - nativeDeviceHasDisconnected(classPtr); - } - - @Override - public void onDeviceException(final long classPtr, final String message) { - nativeDeviceException(classPtr, message); - } - - @Override - public void onDeviceNewData(final long classPtr, final byte[] data) { - nativeDeviceNewData(classPtr, data); - } - } - - private static volatile NativeCallbacks nativeCallbacks = new JniNativeCallbacks(); - - // Native methods - private static native void nativeDeviceHasDisconnected(final long classPtr); - private static native void nativeDeviceException(final long classPtr, final String message); - private static native void nativeDeviceNewData(final long classPtr, final byte[] data); - - static void setNativeCallbacksForTesting(final NativeCallbacks callbacks) { - nativeCallbacks = (callbacks != null) ? callbacks : new JniNativeCallbacks(); - } - - private static void emitDeviceHasDisconnected(final long classPtr) { - if (classPtr != 0) { - nativeCallbacks.onDeviceHasDisconnected(classPtr); - } - } - - private static void emitDeviceException(final long classPtr, final String message) { - if (classPtr != 0) { - nativeCallbacks.onDeviceException(classPtr, message); - } - } - - private static void emitDeviceNewData(final long classPtr, final byte[] data) { - if (classPtr != 0 && data != null && data.length > 0) { - nativeCallbacks.onDeviceNewData(classPtr, data); - } - } - - @SuppressWarnings("deprecation") - private static UsbDevice getUsbDevice(Intent intent) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice.class); - } - return intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); - } - - private static DevicePortSpec parseDevicePortSpec(final String deviceName) { - if (deviceName == null || deviceName.isEmpty()) { - return new DevicePortSpec("", 0); - } - - final int split = deviceName.lastIndexOf(PORT_SUFFIX); - if (split <= 0) { - return new DevicePortSpec(deviceName, 0); - } - - final String baseName = deviceName.substring(0, split); - final String suffix = deviceName.substring(split + PORT_SUFFIX.length()); - try { - final int parsedIndex = Integer.parseInt(suffix); - return new DevicePortSpec(baseName, Math.max(0, parsedIndex)); - } catch (final NumberFormatException e) { - QGCLogger.w(TAG, "Invalid device port suffix in " + deviceName + ", defaulting to port 0"); - return new DevicePortSpec(deviceName, 0); - } - } - - static String getBaseDeviceNameForTesting(final String deviceName) { - return parseDevicePortSpec(deviceName).baseDeviceName; - } - - static int getPortIndexForTesting(final String deviceName) { - return parseDevicePortSpec(deviceName).portIndex; - } - - private static String buildPortDeviceName(final UsbDevice device, final int portIndex, final int portCount) { - final String baseName = device.getDeviceName(); - if (portCount <= 1 && portIndex == 0) { - return baseName; - } - return baseName + PORT_SUFFIX + portIndex; - } - - private static UsbSerialPort getPortFromDriver(final UsbSerialDriver driver, final int portIndex) { - if (driver == null) { - return null; - } - - final List ports = driver.getPorts(); - if (ports == null || ports.isEmpty()) { - return null; - } - - for (final UsbSerialPort port : ports) { - if (port.getPortNumber() == portIndex) { - return port; - } - } - - if (portIndex >= 0 && portIndex < ports.size()) { - return ports.get(portIndex); - } - - return null; - } - - private static int getOrCreateResourceId(final int physicalDeviceId, final int portIndex) { - final PortAddress address = new PortAddress(physicalDeviceId, portIndex); - final Integer existing = portAddressToResourceId.get(address); - if (existing != null) { - return existing; - } - - final int candidateId = nextResourceId.getAndUpdate(v -> (v >= Integer.MAX_VALUE) ? 1 : v + 1); - final Integer raced = portAddressToResourceId.putIfAbsent(address, candidateId); - final int resourceId = (raced != null) ? raced : candidateId; - if (raced == null) { - resourceIdToPortAddress.put(resourceId, address); - } - return resourceId; - } - - static int getOrCreateResourceIdForTesting(final int physicalDeviceId, final int portIndex) { - return getOrCreateResourceId(physicalDeviceId, portIndex); - } - - private static void removeResourceMapping(final int resourceId) { - final PortAddress address = resourceIdToPortAddress.remove(resourceId); - if (address != null) { - portAddressToResourceId.remove(address); - } - } - - static void removeResourceMappingForTesting(final int resourceId) { - removeResourceMapping(resourceId); - } - - static void resetResourceMappingsForTesting() { - portAddressToResourceId.clear(); - resourceIdToPortAddress.clear(); - nextResourceId.set(1); - } - - private static List findResourceIdsForPhysicalDevice(final int physicalDeviceId) { - final List ids = new ArrayList<>(); - for (Map.Entry entry : resourceIdToPortAddress.entrySet()) { - if (entry.getValue().physicalDeviceId == physicalDeviceId) { - ids.add(entry.getKey()); - } - } - return ids; - } - - private static void removeAllResourcesForPhysicalDevice(final int physicalDeviceId) { - for (final Integer resourceId : findResourceIdsForPhysicalDevice(physicalDeviceId)) { - close(resourceId); - } - } - - /** - * Encapsulates all resources associated with a USB device. - */ - private static class UsbDeviceResources { - UsbSerialDriver driver; - SerialInputOutputManager ioManager; - int fileDescriptor; - volatile long classPtr; - int portIndex; - int physicalDeviceId; - String baseDeviceName; - - UsbDeviceResources(final UsbSerialDriver driver, final int portIndex) { - this.driver = driver; - this.portIndex = portIndex; - if (driver != null && driver.getDevice() != null) { - this.physicalDeviceId = driver.getDevice().getDeviceId(); - this.baseDeviceName = driver.getDevice().getDeviceName(); - } - } - } - - private static final class PortAddress { - final int physicalDeviceId; - final int portIndex; - - PortAddress(final int physicalDeviceId, final int portIndex) { - this.physicalDeviceId = physicalDeviceId; - this.portIndex = portIndex; - } - - @Override - public boolean equals(final Object other) { - if (this == other) { - return true; - } - if (!(other instanceof PortAddress)) { - return false; - } - final PortAddress rhs = (PortAddress) other; - return (physicalDeviceId == rhs.physicalDeviceId) && (portIndex == rhs.portIndex); - } - - @Override - public int hashCode() { - return Objects.hash(physicalDeviceId, portIndex); - } - } - - private static final class DevicePortSpec { - final String baseDeviceName; - final int portIndex; - - DevicePortSpec(final String baseDeviceName, final int portIndex) { - this.baseDeviceName = baseDeviceName; - this.portIndex = Math.max(0, portIndex); - } - } - - /** - * Initializes the UsbSerialManager. Should be called once, typically from QGCActivity.onCreate(). - * - * @param context The application context. - */ - public static void initialize(Context context) { - synchronized (lifecycleLock) { - if (usbManager != null) { - return; - } - - appContext = context.getApplicationContext(); - usbManager = (UsbManager) appContext.getSystemService(Context.USB_SERVICE); - if (usbManager == null) { - QGCLogger.e(TAG, "Failed to get UsbManager"); - return; - } - - QGCFtdiDriver.initialize(appContext); - setupUsbPermissionIntent(appContext); - usbSerialProber = QGCUsbSerialProber.getQGCUsbSerialProber(); - registerUsbReceiver(appContext); - updateCurrentDrivers(); - } - } - - /** - * Cleans up resources by unregistering the BroadcastReceiver. - * Should be called when the manager is no longer needed, typically from QGCActivity.onDestroy(). - */ - public static void cleanup(Context context) { - synchronized (lifecycleLock) { - for (Integer deviceId : new ArrayList<>(deviceResourcesMap.keySet())) { - close(deviceId); - } - - final Context unregisterContext = (appContext != null) ? appContext : context; - try { - if (receiverRegistered) { - unregisterContext.unregisterReceiver(usbReceiver); - receiverRegistered = false; - QGCLogger.i(TAG, "BroadcastReceiver unregistered successfully."); - } - } catch (final IllegalArgumentException e) { - QGCLogger.w(TAG, "Receiver not registered: " + e.getMessage()); - } - - usbPermissionIntent = null; - usbSerialProber = null; - QGCFtdiDriver.cleanup(); - usbManager = null; - appContext = null; - drivers.clear(); - deviceResourcesMap.clear(); - portAddressToResourceId.clear(); - resourceIdToPortAddress.clear(); - nextResourceId.set(1); - } - } - - /** - * Sets up the PendingIntent for USB permission requests. - * - * @param context The application context. - */ - private static void setupUsbPermissionIntent(Context context) { - Intent permissionIntent = new Intent(ACTION_USB_PERMISSION).setPackage(context.getPackageName()); - int flags = PendingIntent.FLAG_UPDATE_CURRENT; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - flags |= PendingIntent.FLAG_IMMUTABLE; - } - usbPermissionIntent = PendingIntent.getBroadcast(context, 0, permissionIntent, flags); - } - - /** - * Registers the BroadcastReceiver to listen for USB-related events. - * - * @param context The application context. - */ - private static void registerUsbReceiver(Context context) { - IntentFilter filter = new IntentFilter(); - filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); - filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); - filter.addAction(ACTION_USB_PERMISSION); - - try { - if (android.os.Build.VERSION.SDK_INT >= - android.os.Build.VERSION_CODES.TIRAMISU) { - int flags = Context.RECEIVER_NOT_EXPORTED; - context.registerReceiver(usbReceiver, filter, flags); - } else { - context.registerReceiver(usbReceiver, filter); - } - - receiverRegistered = true; - QGCLogger.i(TAG, "BroadcastReceiver registered successfully."); - } catch (Exception e) { - receiverRegistered = false; - QGCLogger.e(TAG, "Failed to register BroadcastReceiver", e); - } - } - - /** - * BroadcastReceiver to handle USB events. - */ - private static final BroadcastReceiver usbReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - final String action = intent.getAction(); - QGCLogger.i(TAG, "BroadcastReceiver USB action " + action); - - switch (action) { - case ACTION_USB_PERMISSION: - handleUsbPermission(intent); - break; - case UsbManager.ACTION_USB_DEVICE_DETACHED: - handleUsbDeviceDetached(intent); - break; - case UsbManager.ACTION_USB_DEVICE_ATTACHED: - handleUsbDeviceAttached(intent); - break; - default: - break; - } - - updateCurrentDrivers(); - } - }; - - /** - * Handles USB permission results. - * - * @param intent The intent containing permission data. - */ - private static void handleUsbPermission(final Intent intent) { - UsbDevice device = getUsbDevice(intent); - if (device != null) { - final int physicalDeviceId = device.getDeviceId(); - if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { - QGCLogger.i(TAG, "Permission granted to " + device.getDeviceName()); - addOrUpdateDevice(device); - } else { - QGCLogger.i(TAG, "Permission denied for " + device.getDeviceName()); - for (final Integer resourceId : findResourceIdsForPhysicalDevice(physicalDeviceId)) { - final UsbDeviceResources resources = deviceResourcesMap.get(resourceId); - if (resources != null) { - emitDeviceException(resources.classPtr, "USB Permission Denied"); - } - } - } - } - } - - /** - * Handles USB device detachment events. - * - * @param intent The intent containing device data. - */ - private static void handleUsbDeviceDetached(final Intent intent) { - UsbDevice device = getUsbDevice(intent); - if (device != null) { - final int physicalDeviceId = device.getDeviceId(); - final List resourceIds = findResourceIdsForPhysicalDevice(physicalDeviceId); - for (final Integer resourceId : resourceIds) { - final UsbDeviceResources resources = deviceResourcesMap.get(resourceId); - if (resources == null) { - continue; - } - final long classPtr = resources.classPtr; - close(resourceId); - emitDeviceHasDisconnected(classPtr); - } - QGCLogger.i(TAG, "Device detached: " + device.getDeviceName()); - } - } - - /** - * Handles USB device attachment events. - * - * @param intent The intent containing device data. - */ - private static void handleUsbDeviceAttached(final Intent intent) { - UsbDevice device = getUsbDevice(intent); - if (device != null) { - addOrUpdateDevice(device); - } - } - - /** - * Adds a new device or updates an existing one. - * - * @param device The UsbDevice to add or update. - */ - private static void addOrUpdateDevice(UsbDevice device) { - UsbSerialDriver driver = findDriverByDeviceId(device.getDeviceId()); - if (driver != null) { - if (usbManager.hasPermission(device)) { - QGCLogger.i(TAG, "Already have permission to use device " + device.getDeviceName()); - addDriver(driver); - } else { - QGCLogger.i(TAG, "Requesting permission to use device " + device.getDeviceName()); - usbManager.requestPermission(device, usbPermissionIntent); - } - } - } - - /** - * Checks if a device name is valid (i.e., exists in the current driver list). - * - * @param name The device name to check. - * @return True if valid, false otherwise. - */ - public static boolean isDeviceNameValid(final String name) { - final DevicePortSpec spec = parseDevicePortSpec(name); - final UsbSerialDriver driver = findDriverByDeviceName(spec.baseDeviceName); - return getPortFromDriver(driver, spec.portIndex) != null; - } - - /** - * Checks if a device name is currently open. - * - * @param name The device name to check. - * @return True if open, false otherwise. - */ - public static boolean isDeviceNameOpen(final String name) { - final DevicePortSpec spec = parseDevicePortSpec(name); - final UsbSerialDriver driver = findDriverByDeviceName(spec.baseDeviceName); - if (driver == null) { - return false; - } - - final UsbSerialPort port = getPortFromDriver(driver, spec.portIndex); - return (port != null && port.isOpen()); - } - - /** - * Retrieves the device ID for a given device name. - * - * @param deviceName The device name. - * @return The device ID, or BAD_DEVICE_ID if not found. - */ - public static int getDeviceId(final String deviceName) { - final DevicePortSpec spec = parseDevicePortSpec(deviceName); - UsbSerialDriver driver = findDriverByDeviceName(spec.baseDeviceName); - if (driver == null) { - QGCLogger.w(TAG, "Attempt to get ID of unknown device " + deviceName); - return BAD_DEVICE_ID; - } - - if (getPortFromDriver(driver, spec.portIndex) == null) { - QGCLogger.w(TAG, "Attempt to get ID of unknown port index " + spec.portIndex + " for " + spec.baseDeviceName); - return BAD_DEVICE_ID; - } - - return getOrCreateResourceId(driver.getDevice().getDeviceId(), spec.portIndex); - } - - /** - * Retrieves the native device handle (file descriptor). - * - * @param deviceId The device ID. - * @return The device handle, or -1 if not found. - */ - public static int getDeviceHandle(final int deviceId) { - UsbDeviceResources resources = deviceResourcesMap.get(deviceId); - return (resources != null) ? resources.fileDescriptor : -1; - } - - /** - * Updates the current list of USB serial drivers by scanning connected devices. - */ - private static void updateCurrentDrivers() { - if (usbManager == null || usbSerialProber == null) { - QGCLogger.w(TAG, "USB serial manager not ready, skipping driver refresh"); - return; - } - - final List currentDrivers = usbSerialProber.findAllDrivers(usbManager); - removeStaleDrivers(currentDrivers); - if (!currentDrivers.isEmpty()) { - addNewDrivers(currentDrivers); - } - } - - /** - * Removes drivers that are no longer connected. - * Collects stale IDs first to avoid nested mutation of concurrent maps during iteration. - * - * @param currentDrivers The list of currently connected drivers. - */ - private static void removeStaleDrivers(final List currentDrivers) { - final List stalePhysicalIds = new ArrayList<>(); - drivers.removeIf(existingDriver -> { - final int existingPhysicalDeviceId = existingDriver.getDevice().getDeviceId(); - boolean found = currentDrivers.stream() - .anyMatch(currentDriver -> currentDriver.getDevice().getDeviceId() == existingDriver.getDevice().getDeviceId()); - - if (!found) { - stalePhysicalIds.add(existingPhysicalDeviceId); - QGCLogger.i(TAG, "Removed stale driver for device ID " + existingPhysicalDeviceId); - return true; - } - return false; - }); - - for (final int physicalDeviceId : stalePhysicalIds) { - removeAllResourcesForPhysicalDevice(physicalDeviceId); - } - } - - /** - * Adds new drivers that are not already in the driver list. - * - * @param currentDrivers The list of currently connected drivers. - */ - private static void addNewDrivers(final List currentDrivers) { - for (UsbSerialDriver newDriver : currentDrivers) { - boolean found = drivers.stream() - .anyMatch(existingDriver -> existingDriver.getDevice().getDeviceId() == newDriver.getDevice().getDeviceId()); - - if (!found) { - addDriver(newDriver); - } - } - } - - /** - * Adds a new USB serial driver to the driver list and requests permission if needed. - * - * @param newDriver The UsbSerialDriver to add. - */ - private static void addDriver(final UsbSerialDriver newDriver) { - UsbDevice device = newDriver.getDevice(); - String deviceName = device.getDeviceName(); - - final boolean alreadyTracked = drivers.stream() - .anyMatch(existingDriver -> existingDriver.getDevice().getDeviceId() == device.getDeviceId()); - if (alreadyTracked) { - QGCLogger.d(TAG, "Driver already tracked for device ID " + device.getDeviceId()); - return; - } - - drivers.add(newDriver); - QGCLogger.i(TAG, "Adding new driver for device ID " + device.getDeviceId() + ": " + deviceName); - - if (usbManager.hasPermission(device)) { - QGCLogger.i(TAG, "Already have permission to use device " + deviceName); - } else { - QGCLogger.i(TAG, "Requesting permission to use device " + deviceName); - usbManager.requestPermission(device, usbPermissionIntent); - } - } - - /** - * Finds a USB serial driver by its device ID. - * - * @param deviceId The device ID. - * @return The corresponding UsbSerialDriver or null if not found. - */ - private static UsbSerialDriver findDriverByDeviceId(final int deviceId) { - for (UsbSerialDriver driver : drivers) { - if (driver.getDevice().getDeviceId() == deviceId) { - return driver; - } - } - return null; - } - - /** - * Finds a USB serial driver by its device name. - * - * @param deviceName The device name. - * @return The corresponding UsbSerialDriver or null if not found. - */ - private static UsbSerialDriver findDriverByDeviceName(final String deviceName) { - for (UsbSerialDriver driver : drivers) { - if (driver.getDevice().getDeviceName().equals(deviceName)) { - return driver; - } - } - return null; - } - - /** - * Finds a USB serial port by its device ID. - * - * @param deviceId The device ID. - * @return The corresponding UsbSerialPort or null if not found. - */ - private static UsbSerialPort findPortByDeviceId(final int deviceId) { - if (deviceId == BAD_DEVICE_ID) { - QGCLogger.w(TAG, "Finding port failed for invalid Device ID " + deviceId); - return null; - } - - final UsbDeviceResources resources = deviceResourcesMap.get(deviceId); - if (resources == null || resources.driver == null) { - QGCLogger.w(TAG, "No resources found for device ID " + deviceId); - return null; - } - - UsbSerialPort port = getPortFromDriver(resources.driver, resources.portIndex); - if (port == null) { - QGCLogger.w(TAG, "No port available on device ID " + deviceId + " at port index " + resources.portIndex); - return null; - } - - return port; - } - - private static UsbSerialPort getPortOrWarn(final int deviceId, final String operation) { - final UsbSerialPort port = findPortByDeviceId(deviceId); - if (port == null) { - QGCLogger.w(TAG, "Attempted to " + operation + " on a null port for device ID " + deviceId); - return null; - } - - return port; - } - - private static UsbSerialPort getOpenPortOrWarn(final int deviceId, final String operation) { - final UsbSerialPort port = getPortOrWarn(deviceId, operation); - if (port == null) { - return null; - } - - if (!port.isOpen()) { - QGCLogger.w(TAG, "Attempted to " + operation + " on a closed port for device ID " + deviceId); - return null; - } - - return port; - } - - /** - * Retrieves information about all available USB serial devices. - * - * @return An array of device information strings or null if no devices are available. - */ - public static String[] availableDevicesInfo() { - if (usbManager == null || drivers.isEmpty()) { - return null; - } - - final List deviceInfoList = new ArrayList<>(); - - for (final UsbSerialDriver driver : drivers) { - try { - final List ports = driver.getPorts(); - if (ports == null || ports.isEmpty()) { - continue; - } - - final int portCount = ports.size(); - for (final UsbSerialPort port : ports) { - final int portIndex = port.getPortNumber(); - final String exposedDeviceName = buildPortDeviceName(driver.getDevice(), portIndex, portCount); - final String deviceInfo = formatDeviceInfo(driver.getDevice(), exposedDeviceName); - deviceInfoList.add(deviceInfo); - } - } catch (SecurityException e) { - // On some integrated controllers like the Siyi UNIRC7 the usb device is used for video output. - // This in turn causes a security exception when trying to access device info without permission. - // We just eat the exception in these cases to prevent log spamming. - } - } - - return deviceInfoList.isEmpty() ? null : deviceInfoList.toArray(new String[0]); - } - - /** - * Formats device information into a standardized string. - * - * @param device The UsbDevice to format. - * @return A formatted string containing device information. - */ - private static String formatDeviceInfo(final UsbDevice device, final String exposedDeviceName) { - StringBuilder deviceInfo = new StringBuilder(); - deviceInfo.append(exposedDeviceName).append("\t") - .append(device.getProductName()).append("\t") - .append(device.getManufacturerName()).append("\t") - .append(device.getSerialNumber()).append("\t") - .append(device.getProductId()).append("\t") - .append(device.getVendorId()); - - QGCLogger.d(TAG, "Formatted Device Info: " + deviceInfo.toString()); - - return deviceInfo.toString(); - } - - /** - * Opens a USB serial device. - * - * @param deviceName The name of the device to open. - * @param classPtr A native pointer associated with the device. - * @return The device ID if successful, or BAD_DEVICE_ID if failed. - */ - public static int open(final String deviceName, final long classPtr) { - final DevicePortSpec spec = parseDevicePortSpec(deviceName); - UsbSerialDriver driver = findDriverByDeviceName(spec.baseDeviceName); - if (driver == null) { - QGCLogger.w(TAG, "Attempt to open unknown device " + deviceName); - return BAD_DEVICE_ID; - } - - UsbDevice device = driver.getDevice(); - final UsbSerialPort port = getPortFromDriver(driver, spec.portIndex); - if (port == null) { - QGCLogger.w(TAG, "No port " + spec.portIndex + " available on device " + deviceName); - return BAD_DEVICE_ID; - } - - final int physicalDeviceId = device.getDeviceId(); - final int resourceId = getOrCreateResourceId(physicalDeviceId, spec.portIndex); - if (!deviceResourcesMap.containsKey(resourceId)) { - deviceResourcesMap.put(resourceId, new UsbDeviceResources(driver, spec.portIndex)); - } - - final UsbDeviceResources resources = deviceResourcesMap.get(resourceId); - if (resources != null) { - resources.driver = driver; - resources.portIndex = spec.portIndex; - resources.physicalDeviceId = physicalDeviceId; - resources.baseDeviceName = device.getDeviceName(); - resources.classPtr = classPtr; - } - - if (!openDriver(port, device, resourceId, classPtr)) { - QGCLogger.e(TAG, "Failed to open driver for device " + deviceName); - emitDeviceException(classPtr, "Failed to open driver for device: " + deviceName); - final UsbDeviceResources failedResources = deviceResourcesMap.get(resourceId); - if (failedResources != null && failedResources.ioManager == null) { - deviceResourcesMap.remove(resourceId); - removeResourceMapping(resourceId); - } - return BAD_DEVICE_ID; - } - - if (!createIoManager(resourceId, port, classPtr)) { - try { - port.close(); - } catch (IOException e) { - QGCLogger.e(TAG, "Error closing port after IO manager failure", e); - } - deviceResourcesMap.remove(resourceId); - removeResourceMapping(resourceId); - return BAD_DEVICE_ID; - } - - QGCLogger.d(TAG, "Port open successful: " + port.toString()); - return resourceId; - } - - /** - * Opens the driver for a specific USB serial port. - * - * @param port The UsbSerialPort to open. - * @param device The UsbDevice associated with the port. - * @param deviceId The device ID. - * @param classPtr A native pointer associated with the device. - * @return True if successful, false otherwise. - */ - private static boolean openDriver(final UsbSerialPort port, final UsbDevice device, final int deviceId, final long classPtr) { - if (port == null) { - QGCLogger.w(TAG, "Null UsbSerialPort for device " + device.getDeviceName()); - emitDeviceException(classPtr, "No serial port available for device: " + device.getDeviceName()); - return false; - } - - if (port.isOpen()) { - QGCLogger.d(TAG, "Port already open for device ID " + deviceId); - return true; - } - - UsbDeviceConnection connection = usbManager.openDevice(device); - if (connection == null) { - QGCLogger.w(TAG, "No Usb Device Connection"); - emitDeviceException(classPtr, "No USB device connection for device: " + device.getDeviceName()); - return false; - } - - try { - port.open(connection); - } catch (final IOException ex) { - QGCLogger.e(TAG, "Error opening driver for device " + device.getDeviceName(), ex); - emitDeviceException(classPtr, "Error opening driver: " + ex.getMessage()); - connection.close(); - return false; - } - - UsbDeviceResources resources = deviceResourcesMap.get(deviceId); - if (resources != null) { - resources.fileDescriptor = connection.getFileDescriptor(); - } - - QGCLogger.d(TAG, "Port Driver open successful"); - return true; - } - - /** - * Creates and initializes the SerialInputOutputManager for a device. - * - * @param deviceId The device ID. - * @param port The UsbSerialPort to manage. - * @param classPtr A native pointer associated with the device. - * @return True if successful, false otherwise. - */ - private static boolean createIoManager(final int deviceId, final UsbSerialPort port, final long classPtr) { - UsbDeviceResources resources = deviceResourcesMap.get(deviceId); - if (resources == null) { - QGCLogger.w(TAG, "No resources found for device ID " + deviceId); - return false; - } - - if (resources.ioManager != null) { - QGCLogger.i(TAG, "IO Manager already exists for device ID " + deviceId); - return true; - } - - if (port == null) { - QGCLogger.w(TAG, "Cannot create USB serial IO manager with null port for device ID " + deviceId); - return false; - } - - QGCSerialListener listener = new QGCSerialListener(classPtr); - SerialInputOutputManager ioManager = new SerialInputOutputManager(port, listener); - - int readBufferSize = READ_BUF_SIZE; - final UsbEndpoint readEndpoint = port.getReadEndpoint(); - if (readEndpoint != null) { - readBufferSize = Math.max(readEndpoint.getMaxPacketSize(), READ_BUF_SIZE); - } - ioManager.setReadBufferSize(readBufferSize); - - QGCLogger.d(TAG, "Read Buffer Size: " + ioManager.getReadBufferSize()); - QGCLogger.d(TAG, "Write Buffer Size: " + ioManager.getWriteBufferSize()); - - try { - ioManager.setReadTimeout(0); - ioManager.setReadQueue(2); - ioManager.setWriteTimeout(0); - ioManager.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO); - } catch (final IllegalStateException e) { - QGCLogger.e(TAG, "IO Manager configuration error:", e); - return false; - } - - resources.ioManager = ioManager; - QGCLogger.d(TAG, "Serial I/O Manager created for device ID " + deviceId); - return true; - } - - /** - * Starts the SerialInputOutputManager for a specific device. - * - * @param deviceId The device ID. - * @return True if successful or already running, false otherwise. - */ - public static boolean startIoManager(final int deviceId) { - UsbDeviceResources resources = deviceResourcesMap.get(deviceId); - if (resources == null) { - QGCLogger.w(TAG, "IO Manager not found for device ID " + deviceId); - return false; - } - - if (resources.ioManager == null) { - QGCLogger.w(TAG, "IO Manager not found for device ID " + deviceId); - return false; - } - - SerialInputOutputManager.State ioState = resources.ioManager.getState(); - if (ioState == SerialInputOutputManager.State.RUNNING) { - return true; - } - - try { - resources.ioManager.start(); - QGCLogger.d(TAG, "Serial I/O Manager started for device ID " + deviceId); - return true; - } catch (final IllegalStateException e) { - QGCLogger.e(TAG, "IO Manager Start exception:", e); - return false; - } - } - - /** - * Stops the SerialInputOutputManager for a specific device. - * - * @param deviceId The device ID. - * @return True if successful or already stopped, false otherwise. - */ - public static boolean stopIoManager(final int deviceId) { - UsbDeviceResources resources = deviceResourcesMap.get(deviceId); - if (resources == null) { - return false; - } - - if (resources.ioManager == null) { - return false; - } - - SerialInputOutputManager.State ioState = resources.ioManager.getState(); - if (ioState == SerialInputOutputManager.State.STOPPED || ioState == SerialInputOutputManager.State.STOPPING) { - return true; - } - - resources.ioManager.stop(); - QGCLogger.d(TAG, "Serial I/O Manager stopped for device ID " + deviceId); - return true; - } - - /** - * Checks if the SerialInputOutputManager is running for a specific device. - * - * @param deviceId The device ID. - * @return True if running, false otherwise. - */ - public static boolean ioManagerRunning(final int deviceId) { - UsbDeviceResources resources = deviceResourcesMap.get(deviceId); - if (resources == null) { - return false; - } - - if (resources.ioManager == null) { - return false; - } - - SerialInputOutputManager.State ioState = resources.ioManager.getState(); - return (ioState == SerialInputOutputManager.State.RUNNING); - } - - /** - * Closes the USB serial device. - * - * @param deviceId The device ID. - * @return True if successful, false otherwise. - */ - public static boolean close(int deviceId) { - UsbDeviceResources resources = deviceResourcesMap.get(deviceId); - if (resources == null) { - QGCLogger.d(TAG, "Close requested for already cleaned device ID " + deviceId); - removeResourceMapping(deviceId); - return true; - } - - UsbSerialPort port = findPortByDeviceId(deviceId); - if (port == null) { - QGCLogger.w(TAG, "Attempted to close a null port for device ID " + deviceId); - deviceResourcesMap.remove(deviceId); - removeResourceMapping(deviceId); - return true; - } - - if (!port.isOpen()) { - QGCLogger.d(TAG, "Close requested for already closed device ID " + deviceId); - deviceResourcesMap.remove(deviceId); - removeResourceMapping(deviceId); - return true; - } - - stopIoManager(deviceId); - - try { - port.close(); - QGCLogger.d(TAG, "Device " + deviceId + " closed successfully."); - return true; - } catch (final IOException ex) { - QGCLogger.e(TAG, "Error closing driver:", ex); - return false; - } finally { - deviceResourcesMap.remove(deviceId); - removeResourceMapping(deviceId); - } - } - - /** - * Writes data to the USB serial device. - * - * @param deviceId The device ID. - * @param data The byte array of data to write. - * @param length The number of bytes to write. - * @param timeoutMSec The timeout in milliseconds. - * @return The number of bytes written, or -1 if failed. - */ - public static int write(final int deviceId, final byte[] data, final int length, final int timeoutMSec) { - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "write"); - if (port == null) { - return -1; - } - - try { - port.write(data, length, timeoutMSec); - return length; - } catch (final SerialTimeoutException e) { - QGCLogger.e(TAG, "Write timeout occurred", e); - return -1; - } catch (final IOException e) { - QGCLogger.e(TAG, "Error writing data", e); - return -1; - } - } - - /** - * Writes data asynchronously to the USB serial device. - * - * @param deviceId The device ID. - * @param data The byte array of data to write. - * @param timeoutMSec The timeout in milliseconds. - * @return The number of bytes written, or -1 if failed. - */ - public static int writeAsync(final int deviceId, final byte[] data, final int timeoutMSec) { - UsbDeviceResources resources = deviceResourcesMap.get(deviceId); - if (resources == null || resources.ioManager == null) { - QGCLogger.w(TAG, "IO Manager not found for device ID " + deviceId); - return -1; - } - - if (resources.ioManager.getReadTimeout() == 0) { - QGCLogger.w(TAG, "Read Timeout is 0 for writeAsync"); - } - - resources.ioManager.setWriteTimeout(timeoutMSec); - resources.ioManager.writeAsync(data); - - return data.length; - } - - /** - * Reads data from the USB serial device. - * - * @param deviceId The device ID. - * @param length The number of bytes to read. - * @param timeoutMs The timeout in milliseconds. - * @return A byte array containing the read data. - */ - public static byte[] read(final int deviceId, final int length, final int timeoutMs) { - if (timeoutMs < 200) { - QGCLogger.w(TAG, "Read with timeout less than recommended minimum of 200ms"); - } - - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "read"); - if (port == null) { - return new byte[]{}; - } - - byte[] buffer = new byte[length]; - int bytesRead = 0; - - try { - bytesRead = port.read(buffer, timeoutMs); - } catch (final IOException e) { - QGCLogger.e(TAG, "Error reading data", e); - } - - if (bytesRead < length) { - return Arrays.copyOf(buffer, bytesRead); - } - - return buffer; - } - - /** - * Sets the parameters on an open USB serial port. - * - * @param deviceId The device ID. - * @param baudRate The baud rate (e.g., 9600, 115200). - * @param dataBits The number of data bits (5, 6, 7, 8). - * @param stopBits The number of stop bits (1, 2). - * @param parity The parity setting (0: None, 1: Odd, 2: Even). - * @return True if successful, false otherwise. - */ - public static boolean setParameters(final int deviceId, final int baudRate, final int dataBits, final int stopBits, final int parity) { - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "set parameters"); - if (port == null) { - return false; - } - - try { - port.setParameters(baudRate, dataBits, stopBits, parity); - } catch (final UnsupportedOperationException e) { - QGCLogger.w(TAG, "Error setting parameters" + ": " + e); - return false; - } catch (final IOException e) { - QGCLogger.e(TAG, "Error setting parameters", e); - return false; - } - - return true; - } - - private static boolean getControlLine(int deviceId, UsbSerialPort.ControlLine controlLine) { - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "get " + controlLine); - if (port == null) { - return false; - } - - if (!isControlLineSupported(port, controlLine)) { - QGCLogger.w(TAG, "Getting " + controlLine + " Not Supported"); - return false; - } - - try { - switch (controlLine) { - case CD: - return port.getCD(); - case CTS: - return port.getCTS(); - case DSR: - return port.getDSR(); - case DTR: - return port.getDTR(); - case RI: - return port.getRI(); - case RTS: - return port.getRTS(); - default: - return false; - } - } catch (final UnsupportedOperationException e) { - QGCLogger.w(TAG, "Error getting " + controlLine + ": " + e); - return false; - } catch (final IOException e) { - QGCLogger.e(TAG, "Error getting " + controlLine, e); - return false; - } - } - - private static boolean setControlLine(int deviceId, UsbSerialPort.ControlLine controlLine, boolean on) { - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "set " + controlLine); - if (port == null) { - return false; - } - - if (!isControlLineSupported(port, controlLine)) { - QGCLogger.e(TAG, "Setting " + controlLine + " Not Supported"); - return false; - } - - try { - switch (controlLine) { - case DTR: - port.setDTR(on); - break; - case RTS: - port.setRTS(on); - break; - default: - QGCLogger.w(TAG, "Setting " + controlLine + " is not supported via this method."); - return false; - } - } catch (final UnsupportedOperationException e) { - QGCLogger.w(TAG, "Error setting " + controlLine + ": " + e); - return false; - } catch (final IOException e) { - QGCLogger.e(TAG, "Error setting " + controlLine, e); - return false; - } - - return true; - } - - /** - * Checks if a specific control line is supported by the device. - * - * @param port The UsbSerialPort. - * @param controlLine The control line to check. - * @return True if supported, false otherwise. - */ - private static boolean isControlLineSupported(final UsbSerialPort port, final UsbSerialPort.ControlLine controlLine) { - EnumSet supportedControlLines; - - try { - supportedControlLines = port.getSupportedControlLines(); - } catch (final IOException e) { - QGCLogger.e(TAG, "Error getting supported control lines", e); - return false; - } - - return supportedControlLines.contains(controlLine); - } - - /** - * Retrieves the carrier detect (CD) flag from the device. - * - * @param deviceId The device ID. - * @return True if CD is active, false otherwise. - */ - public static boolean getCarrierDetect(final int deviceId) { - return getControlLine(deviceId, UsbSerialPort.ControlLine.CD); - } - - /** - * Retrieves the clear to send (CTS) flag from the device. - * - * @param deviceId The device ID. - * @return True if CTS is active, false otherwise. - */ - public static boolean getClearToSend(final int deviceId) { - return getControlLine(deviceId, UsbSerialPort.ControlLine.CTS); - } - - /** - * Retrieves the data set ready (DSR) flag from the device. - * - * @param deviceId The device ID. - * @return True if DSR is active, false otherwise. - */ - public static boolean getDataSetReady(final int deviceId) { - return getControlLine(deviceId, UsbSerialPort.ControlLine.DSR); - } - - /** - * Retrieves the data terminal ready (DTR) flag from the device. - * - * @param deviceId The device ID. - * @return True if DTR is active, false otherwise. - */ - public static boolean getDataTerminalReady(final int deviceId) { - return getControlLine(deviceId, UsbSerialPort.ControlLine.DTR); - } - - /** - * Sets the data terminal ready (DTR) flag on the device. - * - * @param deviceId The device ID. - * @param on True to set DTR, false to clear. - * @return True if successful, false otherwise. - */ - public static boolean setDataTerminalReady(final int deviceId, final boolean on) { - return setControlLine(deviceId, UsbSerialPort.ControlLine.DTR, on); - } - - /** - * Retrieves the request to send (RTS) flag from the device. - * - * @param deviceId The device ID. - * @return True if RTS is active, false otherwise. - */ - public static boolean getRequestToSend(final int deviceId) { - return getControlLine(deviceId, UsbSerialPort.ControlLine.RTS); - } - - /** - * Sets the request to send (RTS) flag on the device. - * - * @param deviceId The device ID. - * @param on True to set RTS, false to clear. - * @return True if successful, false otherwise. - */ - public static boolean setRequestToSend(final int deviceId, final boolean on) { - return setControlLine(deviceId, UsbSerialPort.ControlLine.RTS, on); - } - - /** - * Retrieves the ring indicator (RI) flag from the device. - * - * @param deviceId The device ID. - * @return True if RI is active, false otherwise. - */ - public static boolean getRingIndicator(final int deviceId) { - return getControlLine(deviceId, UsbSerialPort.ControlLine.RI); - } - - /** - * Retrieves the supported control lines from the device. - * - * @param deviceId The device ID. - * @return An array of control line ordinals. - */ - public static int[] getControlLines(final int deviceId) { - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "get control lines"); - if (port == null) { - return new int[]{}; - } - - EnumSet currentControlLines; - - try { - currentControlLines = port.getControlLines(); - } catch (final UnsupportedOperationException e) { - QGCLogger.w(TAG, "Error getting control lines: " + e); - return new int[]{}; - } catch (final IOException e) { - QGCLogger.e(TAG, "Error getting control lines", e); - return new int[]{}; - } - - int[] lines = currentControlLines.stream().mapToInt(UsbSerialPort.ControlLine::ordinal).toArray(); - return lines; - } - - /** - * Retrieves the current flow control setting from the device. - * - * @param deviceId The device ID. - * @return The flow control ordinal, or 0 if not supported. - */ - public static int getFlowControl(final int deviceId) { - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "get flow control"); - if (port == null) { - return 0; - } - - EnumSet supportedFlowControl = port.getSupportedFlowControl(); - if (supportedFlowControl.isEmpty()) { - QGCLogger.e(TAG, "Flow Control Not Supported"); - return 0; - } - - UsbSerialPort.FlowControl flowControl = port.getFlowControl(); - return flowControl.ordinal(); - } - - /** - * Sets the flow control setting on the device. - * - * @param deviceId The device ID. - * @param flowControl The flow control ordinal. - * @return True if successful, false otherwise. - */ - public static boolean setFlowControl(final int deviceId, final int flowControl) { - if (getFlowControl(deviceId) == flowControl) { - return true; - } - - if (flowControl < 0 || flowControl >= UsbSerialPort.FlowControl.values().length) { - QGCLogger.w(TAG, "Invalid flow control ordinal " + flowControl); - return false; - } - - UsbSerialPort.FlowControl flowControlEnum = UsbSerialPort.FlowControl.values()[flowControl]; - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "set flow control"); - if (port == null) { - return false; - } - - EnumSet supportedFlowControl = port.getSupportedFlowControl(); - if (!supportedFlowControl.contains(flowControlEnum)) { - QGCLogger.e(TAG, "Setting Flow Control Not Supported"); - return false; - } - - try { - port.setFlowControl(flowControlEnum); - } catch (final UnsupportedOperationException e) { - QGCLogger.w(TAG, "Error setting Flow Control: " + e); - return false; - } catch (final IOException e) { - QGCLogger.e(TAG, "Error setting Flow Control", e); - return false; - } - - return true; - } - - /** - * Sets the break condition on the device. - * - * @param deviceId The device ID. - * @param on True to set break, false to clear break. - * @return True if successful, false otherwise. - */ - public static boolean setBreak(final int deviceId, final boolean on) { - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "set break"); - if (port == null) { - return false; - } - - try { - port.setBreak(on); - } catch (final UnsupportedOperationException e) { - QGCLogger.w(TAG, "Error setting break condition: " + e); - return false; - } catch (final IOException e) { - QGCLogger.e(TAG, "Error setting break condition", e); - return false; - } - - return true; - } - - /** - * Purges the hardware buffers on the device. - * - * @param deviceId The device ID. - * @param input True to purge the input buffer. - * @param output True to purge the output buffer. - * @return True if successful, false otherwise. - */ - public static boolean purgeBuffers(final int deviceId, final boolean input, final boolean output) { - final UsbSerialPort port = getOpenPortOrWarn(deviceId, "purge buffers"); - if (port == null) { - return false; - } - - try { - port.purgeHwBuffers(input, output); - } catch (final UnsupportedOperationException e) { - QGCLogger.w(TAG, "Error purging buffers: " + e); - return false; - } catch (final IOException e) { - QGCLogger.e(TAG, "Error purging buffers", e); - return false; - } - - return true; - } - - /** - * Inner class to handle serial data callbacks. - */ - private static class QGCSerialListener implements SerialInputOutputManager.Listener { - private final long classPtr; - - public QGCSerialListener(long classPtr) { - this.classPtr = classPtr; - } - - @Override - public void onRunError(Exception e) { - QGCLogger.e(TAG, "Runner stopped.", e); - emitDeviceException(classPtr, "Runner stopped: " + e.getMessage()); - } - - @Override - public void onNewData(final byte[] data) { - if (!isValidData(data)) { - QGCLogger.w(TAG, "Invalid data received: " + Arrays.toString(data)); - return; - } - - if (data.length <= MAX_NATIVE_CALLBACK_DATA_BYTES) { - emitDeviceNewData(classPtr, data); - return; - } - - QGCLogger.w(TAG, "Large USB payload (" + data.length + " bytes), chunking before JNI callback"); - int offset = 0; - while (offset < data.length) { - final int end = Math.min(offset + MAX_NATIVE_CALLBACK_DATA_BYTES, data.length); - emitDeviceNewData(classPtr, Arrays.copyOfRange(data, offset, end)); - offset = end; - } - } - - private boolean isValidData(byte[] data) { - return ((data != null) && (data.length > 0)); - } - } -} diff --git a/android/src/org/mavlink/qgroundcontrol/QGCUsbSerialProber.java b/android/src/org/mavlink/qgroundcontrol/QGCUsbSerialProber.java deleted file mode 100644 index 852cdf8def0c..000000000000 --- a/android/src/org/mavlink/qgroundcontrol/QGCUsbSerialProber.java +++ /dev/null @@ -1,85 +0,0 @@ -package org.mavlink.qgroundcontrol; - -import com.hoho.android.usbserial.driver.CdcAcmSerialDriver; -import com.hoho.android.usbserial.driver.ProbeTable; -import com.hoho.android.usbserial.driver.UsbSerialProber; - -public class QGCUsbSerialProber { - - public static UsbSerialProber getQGCUsbSerialProber() { - return new UsbSerialProber(getQGCProbeTable()); - } - - public static ProbeTable getQGCProbeTable() { - final ProbeTable probeTable = UsbSerialProber.getDefaultProbeTable(); - - // FTDI (D2XX-backed adapter). When unavailable, keep default FTDI probing. - if (QGCFtdiDriver.isAvailable()) { - probeTable.addProduct(QGCUsbId.VENDOR_FTDI, QGCUsbId.DEVICE_FTDI_FT232R, QGCFtdiSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_FTDI, QGCUsbId.DEVICE_FTDI_FT2232H, QGCFtdiSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_FTDI, QGCUsbId.DEVICE_FTDI_FT4232H, QGCFtdiSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_FTDI, QGCUsbId.DEVICE_FTDI_FT232H, QGCFtdiSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_FTDI, QGCUsbId.DEVICE_FTDI_FT231X, QGCFtdiSerialDriver.class); - } - - // PX4 - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V1, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V2, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V4, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V4PRO, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V5, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V5X, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V6C, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V6U, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V6X, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4FMU_V6XRT, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_PX4, QGCUsbId.DEVICE_PX4MINDPX_V2, CdcAcmSerialDriver.class); - - // u-blox GPS - probeTable.addProduct(QGCUsbId.VENDOR_UBLOX, QGCUsbId.DEVICE_UBLOX_5, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_UBLOX, QGCUsbId.DEVICE_UBLOX_6, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_UBLOX, QGCUsbId.DEVICE_UBLOX_7, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_UBLOX, QGCUsbId.DEVICE_UBLOX_8, CdcAcmSerialDriver.class); - - // OpenPilot - probeTable.addProduct(QGCUsbId.VENDOR_OPENPILOT, QGCUsbId.DEVICE_REVOLUTION, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_OPENPILOT, QGCUsbId.DEVICE_OPLINK, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_OPENPILOT, QGCUsbId.DEVICE_SPARKY2, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_OPENPILOT, QGCUsbId.DEVICE_CC3D, CdcAcmSerialDriver.class); - - // ArduPilot - probeTable.addProduct(QGCUsbId.VENDOR_ARDUPILOT, QGCUsbId.DEVICE_ARDUPILOT_CHIBIOS, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_ARDUPILOT, QGCUsbId.DEVICE_ARDUPILOT_CHIBIOS2, CdcAcmSerialDriver.class); - - // DragonLink - probeTable.addProduct(QGCUsbId.VENDOR_DRAGONLINK, QGCUsbId.DEVICE_DRAGONLINK, CdcAcmSerialDriver.class); - - // CubePilot - probeTable.addProduct(QGCUsbId.VENDOR_CUBEPILOT, QGCUsbId.DEVICE_CUBE_BLACK, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_CUBEPILOT, QGCUsbId.DEVICE_CUBE_BLACK_BOOTLOADER, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_CUBEPILOT, QGCUsbId.DEVICE_CUBE_ORANGE, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_CUBEPILOT, QGCUsbId.DEVICE_CUBE_ORANGE2, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_CUBEPILOT, QGCUsbId.DEVICE_CUBE_ORANGEPLUS, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_CUBEPILOT, QGCUsbId.DEVICE_CUBE_YELLOW_BOOTLOADER, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_CUBEPILOT, QGCUsbId.DEVICE_CUBE_YELLOW, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_CUBEPILOT, QGCUsbId.DEVICE_CUBE_PURPLE_BOOTLOADER, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_CUBEPILOT, QGCUsbId.DEVICE_CUBE_PURPLE, CdcAcmSerialDriver.class); - - // CUAV - probeTable.addProduct(QGCUsbId.VENDOR_CUAV, QGCUsbId.DEVICE_CUAV_NORA, CdcAcmSerialDriver.class); - - // Holybro - probeTable.addProduct(QGCUsbId.VENDOR_HOLYBRO, QGCUsbId.DEVICE_PIXHAWK4, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_HOLYBRO, QGCUsbId.DEVICE_PH4_MINI, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_HOLYBRO, QGCUsbId.DEVICE_DURANDAL, CdcAcmSerialDriver.class); - - // Laser Navigation (VRBrain) - probeTable.addProduct(QGCUsbId.VENDOR_LASER_NAVIGATION, QGCUsbId.DEVICE_VRBRAIN_V51, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_LASER_NAVIGATION, QGCUsbId.DEVICE_VRBRAIN_V52, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_LASER_NAVIGATION, QGCUsbId.DEVICE_VRBRAIN_V54, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_LASER_NAVIGATION, QGCUsbId.DEVICE_VRCORE_V10, CdcAcmSerialDriver.class); - probeTable.addProduct(QGCUsbId.VENDOR_LASER_NAVIGATION, QGCUsbId.DEVICE_VRUBRAIN_V51, CdcAcmSerialDriver.class); - - return probeTable; - } -} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/D2xxLibrary.java b/android/src/org/mavlink/qgroundcontrol/serial/D2xxLibrary.java new file mode 100644 index 000000000000..2e56770ce22b --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/D2xxLibrary.java @@ -0,0 +1,176 @@ +package org.mavlink.qgroundcontrol.serial; + +import org.mavlink.qgroundcontrol.QGCLogger; + +import android.content.Context; +import android.hardware.usb.UsbDevice; + +import com.ftdi.j2xx.D2xxManager; + +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * D2XX runtime plumbing: library lifecycle, capability checks (isAvailable, isFtdiDevice, canOpenViaD2XX), + * and the helpers that translate {@link UsbSerialPort} enums into FTDI D2XX byte/short constants. All state is process-global. + */ +final class D2xxLibrary { + + private static final String TAG = D2xxLibrary.class.getSimpleName(); + + static final int VENDOR_FTDI = 0x0403; + + static final byte D2XX_LATENCY_TIMER_MS = 2; + static final int D2XX_READ_TIMEOUT_MS = 50; + + // D2XX defaults to 16×16 KB = 256 KB/port; SIOManager only submits READ_BUF_SIZE (2 KB) at a time, so trim to 4×4 KB = 16 KB (valid: bufferNumber 2-16, maxBufferSize 64-16384). + static final int D2XX_BUFFER_NUMBER = 4; + static final int D2XX_MAX_BUFFER_SIZE = 4096; + + /** D2XX manager + the Context it was initialised against, published together via {@link #sD2xx} so an open() snapshot sees a consistent pair even if cleanup() races. */ + static final class Handle { + final Context appContext; + final D2xxManager manager; + Handle(final Context appContext, final D2xxManager manager) { + this.appContext = appContext; + this.manager = manager; + } + } + + private static final AtomicReference sD2xx = new AtomicReference<>(); + + private D2xxLibrary() {} + + static void initialize(final Context context) { + final Context appContext = context.getApplicationContext(); + D2xxManager manager = null; + try { + manager = D2xxManager.getInstance(appContext); + // Disable D2XX's internal permission + broadcast handling — QGC owns both (QGCUsbSerialManager / UsbAttachDetachReceiver). + // Running D2XX's parallel handlers creates two competing flows for the same USB events (likely root of #14146; see TN_147). + manager.setRequestPermission(false); + manager.setUsbRegisterBroadcast(false); + } catch (D2xxManager.D2xxException e) { + QGCLogger.w(TAG, "D2XX manager unavailable (D2xxException): " + e.getMessage()); + } catch (Throwable t) { + QGCLogger.e(TAG, "D2XX manager unavailable (" + t.getClass().getName() + ")", t); + } + sD2xx.set((manager != null) ? new Handle(appContext, manager) : null); + String libVersion = "unknown"; + if (manager != null) { + try { + libVersion = "0x" + Integer.toHexString(D2xxManager.getLibraryVersion()); + } catch (Throwable t) { + libVersion = "unavailable"; + } + } + QGCLogger.i(TAG, "D2XX initialize: manager=" + (manager != null ? "ready" : "null") + + " libVersion=" + libVersion); + } + + static void cleanup() { + sD2xx.set(null); + } + + static boolean isAvailable() { + return sD2xx.get() != null; + } + + /** Atomic snapshot of the (manager, context) pair, or null if uninitialised. */ + static Handle handle() { + return sD2xx.get(); + } + + // Cheap VID pre-gate; canOpenViaD2XX()'s D2xxManager.isFtDevice() is the PID authority (no parallel list to drift). + static boolean isFtdiDevice(final UsbDevice device) { + return device != null && device.getVendorId() == VENDOR_FTDI; + } + + static int d2xxLocation(final int physicalDeviceId, final int interfaceId) { + if (interfaceId < 0 || interfaceId > 3) { + throw new IllegalArgumentException("interfaceId must be 0-3, got " + interfaceId); + } + return (physicalDeviceId << 4) | ((interfaceId + 1) & 0x0f); + } + + /** True only if the D2XX library currently enumerates {@code device}; generic FT232R USB-TTL adapters present in VCP mode and fall through to the stock {@code FtdiSerialDriver}. */ + static boolean canOpenViaD2XX(final UsbDevice device) { + if (!isFtdiDevice(device)) return false; + final Handle h = sD2xx.get(); + if (h == null) { + QGCLogger.d(TAG, "canOpenViaD2XX: D2XX handle null"); + return false; + } + // D2xxManager.isFtDevice is the canonical pre-open check; the prior createDeviceInfoList + serial-match heuristic could return true for devices D2XX would then reject (root of #14146). + try { + return h.manager.isFtDevice(device); + } catch (Throwable t) { + QGCLogger.e(TAG, "canOpenViaD2XX check failed (" + t.getClass().getName() + ")", t); + return false; + } + } + + static D2xxManager.DriverParameters makeDriverParameters() { + final D2xxManager.DriverParameters params = new D2xxManager.DriverParameters(); + params.setReadTimeout(D2XX_READ_TIMEOUT_MS); + params.setBufferNumber(D2XX_BUFFER_NUMBER); + params.setMaxBufferSize(D2XX_MAX_BUFFER_SIZE); + return params; + } + + static int normalizeReadResult(final int bytesRead, final int requestedLength) throws java.io.IOException { + if (bytesRead < 0) { + throw new java.io.IOException("D2XX read returned " + bytesRead); + } + return Math.min(bytesRead, requestedLength); + } + + static byte toD2xxDataBits(final int dataBits) throws java.io.IOException { + // D2XX only exposes 7/8 data bits (D2xxManager defines no FT_DATA_BITS_5/6); reject rather than silently send 8. + switch (dataBits) { + case 7: + return D2xxManager.FT_DATA_BITS_7; + case 8: + return D2xxManager.FT_DATA_BITS_8; + default: + throw new java.io.IOException("D2XX supports only 7 or 8 data bits, requested " + dataBits); + } + } + + static byte toD2xxStopBits(final int stopBits) { + if (stopBits == UsbSerialPort.STOPBITS_2) { + return D2xxManager.FT_STOP_BITS_2; + } + return D2xxManager.FT_STOP_BITS_1; + } + + static byte toD2xxParity(final int parity) { + switch (parity) { + case UsbSerialPort.PARITY_ODD: + return D2xxManager.FT_PARITY_ODD; + case UsbSerialPort.PARITY_EVEN: + return D2xxManager.FT_PARITY_EVEN; + case UsbSerialPort.PARITY_MARK: + return D2xxManager.FT_PARITY_MARK; + case UsbSerialPort.PARITY_SPACE: + return D2xxManager.FT_PARITY_SPACE; + case UsbSerialPort.PARITY_NONE: + default: + return D2xxManager.FT_PARITY_NONE; + } + } + + static short toD2xxFlowControl(final UsbSerialPort.FlowControl flowControl) { + if (flowControl == UsbSerialPort.FlowControl.RTS_CTS) { + return D2xxManager.FT_FLOW_RTS_CTS; + } + if (flowControl == UsbSerialPort.FlowControl.DTR_DSR) { + return D2xxManager.FT_FLOW_DTR_DSR; + } + if (flowControl == UsbSerialPort.FlowControl.XON_XOFF) { + return D2xxManager.FT_FLOW_XON_XOFF; + } + return D2xxManager.FT_FLOW_NONE; + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/DriverStrategy.java b/android/src/org/mavlink/qgroundcontrol/serial/DriverStrategy.java new file mode 100644 index 000000000000..c3e8eefce4ec --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/DriverStrategy.java @@ -0,0 +1,174 @@ +package org.mavlink.qgroundcontrol.serial; + +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; + +import org.mavlink.qgroundcontrol.QGCLogger; + +import com.hoho.android.usbserial.driver.CdcAcmSerialDriver; +import com.hoho.android.usbserial.driver.Ch34xSerialDriver; +import com.hoho.android.usbserial.driver.Cp21xxSerialDriver; +import com.hoho.android.usbserial.driver.FtdiSerialDriver; +import com.hoho.android.usbserial.driver.ProlificSerialDriver; +import com.hoho.android.usbserial.driver.UsbSerialDriver; + +/** + * Per-driver serial behaviour — read timeout/queue, write chunking, baud support, post-baud-change purge — selected + * once from the concrete {@link UsbSerialDriver} type into an immutable {@link Caps} bundle. A single {@code switch} over + * the device {@link Kind} computes every capability up front, so call sites read fields instead of dispatching on the + * driver's runtime type. The Prolific legacy-baud cap is orthogonal to driver type (read from the device descriptor), + * so it stays a parameter rather than a kind. + */ +final class DriverStrategy { + + private DriverStrategy() {} + + /** Device kind selected once from the concrete {@link UsbSerialDriver} type. */ + enum Kind { CDC_ACM, CH34X, CP21XX, MIK3Y_FTDI, D2XX, GENERIC } + + // SIOManager queue depth: per usb-serial-for-android 3.10.0, queuing multiple buffers prevents data loss when the + // JVM stalls (GC/JIT) between kernel USB copies at >115200 baud; 3 covers typical Android GC pauses (<30ms). + static final int READ_QUEUE_DEPTH = 3; + /** CDC-ACM bulkTransfer read timeout. */ + static final int CDC_ACM_READ_TIMEOUT_MS = 200; + /** CP21xx high-baud writes must stay below the chip payload limit. */ + static final int CP21XX_HIGH_BAUD_WRITE_CHUNK_BYTES = 512; + /** Baud at/above which a single submitted CP21xx write buffer risks high-throughput loss. */ + static final int HIGH_BAUD_WRITE_CHUNK_THRESHOLD = 460800; + /** Legacy Prolific type-H clone descriptors are capped to this rate. */ + static final int PROLIFIC_LEGACY_MAX_BAUD_RATE = 115200; + + private static final int CH34X_UNSUPPORTED_BAUD = 921600; + + private static final String TAG = DriverStrategy.class.getSimpleName(); + + // FTDI vendor control-transfer constants (AN232B-04): drop the chip latency timer from the 16ms default on + // VCP-mode FTDI. The D2XX path uses FT_Device.setLatencyTimer in QGCFtdiSerialPort instead. + private static final int FTDI_REQTYPE_OUT = 0x40; // vendor | host-to-device | device + private static final int FTDI_SIO_SET_LATENCY = 0x09; + private static final int FTDI_LATENCY_MS = 1; // 1ms minimum; default is 16ms + private static final int FTDI_CTRL_TIMEOUT_MS = 100; + + /** One-shot FTDI latency-timer control transfer for mik3y's VCP-mode FtdiSerialDriver. Best-effort; caller holds the port's lifecycleLock. */ + static void applyHostFtdiLatencyTimer(final UsbDeviceConnection connection, final int portIndex) { + try { + connection.controlTransfer(FTDI_REQTYPE_OUT, FTDI_SIO_SET_LATENCY, + FTDI_LATENCY_MS, portIndex + 1, null, 0, FTDI_CTRL_TIMEOUT_MS); + } catch (final Throwable t) { + QGCLogger.w(TAG, "FTDI setLatencyTimer failed: " + t.getMessage()); + } + } + + static final int[] STANDARD_BAUD_RATES = { + 50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, + 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 500000, + 576000, 921600, 1000000, 1152000, 1500000, 2000000, 2500000, 3000000, + 3500000, 4000000 + }; + + static Kind of(final UsbSerialDriver driver) { + if (driver instanceof QGCFtdiSerialPort.QGCFtdiSerialDriver) return Kind.D2XX; + if (driver instanceof FtdiSerialDriver) return Kind.MIK3Y_FTDI; + if (driver instanceof CdcAcmSerialDriver) return Kind.CDC_ACM; + if (driver instanceof Ch34xSerialDriver) return Kind.CH34X; + if (driver instanceof Cp21xxSerialDriver) return Kind.CP21XX; + return Kind.GENERIC; + } + + static boolean isProlificLegacyBaudLimitedDeviceClass(final int deviceClass, final int deviceSubclass) { + return deviceClass == 0x02 && deviceSubclass == 0x00; + } + + /** Read timeout for {@code SerialInputOutputManager.setReadTimeout}; {@code d2xxOverride} is honoured only by {@link Kind#D2XX}. */ + static int readTimeoutMs(final Kind kind, final int d2xxOverride) { + return switch (kind) { + case CDC_ACM -> CDC_ACM_READ_TIMEOUT_MS; // bulkTransfer reads: a bounded timeout keeps a safe-mode device from blocking requestWait() forever. + case D2XX -> d2xxOverride; // D2XX owns its own read timeout. + default -> 0; + }; + } + + /** CDC-ACM has no read queue (bulkTransfer reads); every other kind queues {@link #READ_QUEUE_DEPTH} buffers. */ + static int readQueueDepth(final Kind kind) { + return kind == Kind.CDC_ACM ? 0 : READ_QUEUE_DEPTH; + } + + /** CP21xx high-baud writes must stay under the chip payload limit; every other kind uses the shared max chunk. */ + static int writeChunkSize(final Kind kind, final int baudRate) { + if (kind == Kind.CP21XX && baudRate >= HIGH_BAUD_WRITE_CHUNK_THRESHOLD) { + return CP21XX_HIGH_BAUD_WRITE_CHUNK_BYTES; + } + return SerialWireConstants.MAX_CHUNK_BYTES; + } + + /** CP21xx's HW FIFO needs a purge after a baud change. */ + static boolean needsPurgeAfterBaudChange(final Kind kind) { + return kind == Kind.CP21XX; + } + + static boolean supportsBaud(final Kind kind, final int baudRate, final boolean prolificLegacyBaudLimited) { + if (kind == Kind.D2XX) { + return true; // D2XX accepts any baud. + } + if (kind == Kind.CH34X && baudRate == CH34X_UNSUPPORTED_BAUD) { + return false; // mik3y CH34x cannot run 921600. + } + return !prolificLegacyBaudLimited || baudRate <= PROLIFIC_LEGACY_MAX_BAUD_RATE; + } + + static int[] supportedBaudRates(final Kind kind, final boolean prolificLegacyBaudLimited) { + int count = 0; + for (final int baudRate : STANDARD_BAUD_RATES) { + if (supportsBaud(kind, baudRate, prolificLegacyBaudLimited)) { + count++; + } + } + final int[] rates = new int[count]; + int index = 0; + for (final int baudRate : STANDARD_BAUD_RATES) { + if (supportsBaud(kind, baudRate, prolificLegacyBaudLimited)) { + rates[index++] = baudRate; + } + } + return rates; + } + + /** Builds the capability bundle for {@code driver}: its kind plus the orthogonal Prolific legacy-baud cap. */ + static Caps capsFor(final UsbSerialDriver driver) { + return new Caps(of(driver), computeProlificLegacyBaudLimited(driver)); + } + + private static boolean computeProlificLegacyBaudLimited(final UsbSerialDriver driver) { + if (!(driver instanceof ProlificSerialDriver)) { + return false; + } + final UsbDevice device = driver.getDevice(); + return device != null && isProlificLegacyBaudLimitedDeviceClass( + device.getDeviceClass(), device.getDeviceSubclass()); + } + + /** + * Immutable per-driver capability bundle, computed once at port construction and safe to read from any thread: + * the selected device {@link Kind} plus the orthogonal Prolific legacy-baud cap (read from the device descriptor, + * not the driver type). + */ + record Caps(Kind kind, boolean prolificLegacyBaudLimited) { + + /** D2XX owns its own USB connection (no Java-side handle) and read timeout. */ + boolean usesD2xx() { return kind == Kind.D2XX; } + + /** mik3y VCP-mode FTDI needs the host-side latency-timer control transfer. */ + boolean needsHostFtdiLatencyTimer() { return kind == Kind.MIK3Y_FTDI; } + + /** Read timeout for {@code SerialInputOutputManager.setReadTimeout}; pass the port's override (only D2XX uses it). */ + int readTimeoutForIoManager(final int d2xxOverride) { return readTimeoutMs(kind, d2xxOverride); } + + int readQueueDepth() { return DriverStrategy.readQueueDepth(kind); } + + int writeChunkSizeForBaud(final int baudRate) { return writeChunkSize(kind, baudRate); } + + boolean supportsBaudRate(final int baudRate) { return supportsBaud(kind, baudRate, prolificLegacyBaudLimited); } + + boolean needsPurgeAfterBaudChange() { return DriverStrategy.needsPurgeAfterBaudChange(kind); } + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/PortMonitor.java b/android/src/org/mavlink/qgroundcontrol/serial/PortMonitor.java new file mode 100644 index 000000000000..f5d499eda183 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/PortMonitor.java @@ -0,0 +1,12 @@ +package org.mavlink.qgroundcontrol.serial; + +/** Shared seam onto the owning port for the read and write loops: lifecycle monitor, native handle, listener mute, and exception fan-out. */ +interface PortMonitor { + /** The owning port's lifecycle monitor; every {@code *Locked} query below must be made while holding it. */ + Object lock(); + /** Caller holds {@link #lock()}: snapshot of the live native handle (0 once closed). */ + long nativeHandleLocked(); + /** Caller holds {@link #lock()}: mark the listener muted so no stray emit/ack/exception reaches a torn-down native side. */ + void muteListenerLocked(); + void fireException(int kind, String message); +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/QGCFtdiSerialPort.java b/android/src/org/mavlink/qgroundcontrol/serial/QGCFtdiSerialPort.java new file mode 100644 index 000000000000..6b3e79ceaaa6 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/QGCFtdiSerialPort.java @@ -0,0 +1,550 @@ +package org.mavlink.qgroundcontrol.serial; + +import org.mavlink.qgroundcontrol.QGCLogger; + +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; +import android.hardware.usb.UsbInterface; + +import com.ftdi.j2xx.D2xxManager; +import com.ftdi.j2xx.FT_Device; +import com.hoho.android.usbserial.driver.UsbSerialDriver; +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; + +/** + * D2XX-backed {@link UsbSerialPort}, owned by {@link QGCFtdiSerialDriver}; uses {@link D2xxLibrary} for the D2XX runtime and param translation. + * + *

Writes use {@code FT_Device.write(wait=true)}, paced at wire rate by USB 2.0 bulk flow control (the FT232R NAKs its OUT endpoint when full), + * so no Java-side rate limiter is needed; {@link #cancelPendingWrites} preempts a write parked on a wedged wire so close() can't stall.

+ */ +final class QGCFtdiSerialPort implements UsbSerialPort { + + private static final String TAG = QGCFtdiSerialPort.class.getSimpleName(); + + private final QGCFtdiSerialDriver _driver; + private final UsbDevice _device; + private final int _portNumber; + + private volatile FT_Device _ftDevice; + private FlowControl _flowControl = FlowControl.NONE; + private boolean _dtr; + private boolean _rts; + private int _readQueueBufferCount; + private int _readQueueBufferSize; + private short _lastLineStatus; + + /** Sticky once close() starts, so a chunked write loop bails at the next chunk boundary. */ + private volatile boolean _writesCancelled; + /** Thread parked inside the blocking D2XX write, or null; interrupting it makes FT_Device.write's Future.get throw, cancelling the UsbRequest. */ + private volatile Thread _blockedWriter; + /** Guards _writesCancelled and the _blockedWriter publish so cancelPendingWrites() and write() can't race the cancelled-check + thread-publish pair. */ + private final Object _writeGate = new Object(); + + /** Preempts an in-flight {@link #write} so close() can't sit behind a wedged-wire D2XX write holding lifecycleLock; called from close() before it takes the lock. Sticky — close is terminal. */ + public void cancelPendingWrites() { + synchronized (_writeGate) { + _writesCancelled = true; + if (_blockedWriter != null) { + _blockedWriter.interrupt(); + } + } + } + + QGCFtdiSerialPort(final QGCFtdiSerialDriver driver, final UsbDevice device, final int portNumber) { + _driver = driver; + _device = device; + _portNumber = portNumber; + } + + @Override + public UsbSerialDriver getDriver() { + return _driver; + } + + @Override + public UsbDevice getDevice() { + return _device; + } + + @Override + public int getPortNumber() { + return _portNumber; + } + + private int d2xxLocationForPort() { + int interfaceId = _portNumber; + final int interfaceCount = _device.getInterfaceCount(); + if (_portNumber >= 0 && _portNumber < interfaceCount) { + interfaceId = _device.getInterface(_portNumber).getId(); + } + return D2xxLibrary.d2xxLocation(_device.getDeviceId(), interfaceId); + } + + public int readTimeoutForIoManager() { + return D2xxLibrary.D2XX_READ_TIMEOUT_MS; + } + + // D2XX does I/O internally; bulk endpoints are exposed only so the I/O manager can size its read buffer via getMaxPacketSize(). Resolved lazily, never fails open(). + @Override + public UsbEndpoint getWriteEndpoint() { + return null; + } + + @Override + public UsbEndpoint getReadEndpoint() { + if (!isOpen()) { + return null; + } + final int interfaceCount = _device.getInterfaceCount(); + final int interfaceIndex = (_portNumber < interfaceCount) ? _portNumber + : (interfaceCount == 1 ? 0 : -1); + if (interfaceIndex < 0) { + return null; + } + final UsbInterface usbInterface = _device.getInterface(interfaceIndex); + for (int e = 0; e < usbInterface.getEndpointCount(); e++) { + final UsbEndpoint endpoint = usbInterface.getEndpoint(e); + if (endpoint.getType() == UsbConstants.USB_ENDPOINT_XFER_BULK + && endpoint.getDirection() == UsbConstants.USB_DIR_IN) { + return endpoint; + } + } + return null; + } + + @Override + public String getSerial() { + try { + return _device.getSerialNumber(); + } catch (SecurityException e) { + return null; + } + } + + @Override + public void setReadQueue(final int bufferCount, final int bufferSize) { + if (bufferCount < 0 || bufferSize < 0) { + throw new IllegalArgumentException("setReadQueue: negative arguments"); + } + // D2XX manages its own read buffering; values are recorded for diagnostic logging in open(). + _readQueueBufferCount = bufferCount; + _readQueueBufferSize = bufferSize; + } + + @Override + public int getReadQueueBufferCount() { + return _readQueueBufferCount; + } + + @Override + public int getReadQueueBufferSize() { + return _readQueueBufferSize; + } + + @FunctionalInterface + private interface D2xxOp { T run(FT_Device device) throws IOException; } + + /** Snapshots {@code _ftDevice} so a concurrent close() nulling the field can't NPE a caller mid-op; ops on the snapshot fail with IOException via d2xxOp once closed. */ + private T d2xxOp(final String desc, final D2xxOp op) throws IOException { + final FT_Device device = _ftDevice; + if (device == null || !device.isOpen()) { + throw new IOException("Port not open"); + } + try { return op.run(device); } + catch (final IOException e) { throw e; } + catch (final Throwable t) { + QGCLogger.e(TAG, "Error " + desc, t); + throw new IOException(desc + " failed: " + t.getMessage(), t); + } + } + + @Override + public void open(final UsbDeviceConnection connection) throws IOException { + if (isOpen()) { + throw new IOException("Already open"); + } + // D2XX path passes null — the caller closes its probe connection before D2XX's openDevice() to avoid the double-open race; QGCFtdiSerialPort never uses connection for I/O. + // Snapshot the (manager, context) pair atomically so a concurrent cleanup() can't null one between this check and the D2XX open below. + final D2xxLibrary.Handle handle = D2xxLibrary.handle(); + if (handle == null || !D2xxLibrary.isFtdiDevice(_device)) { + throw new IOException("D2XX manager unavailable or device is not an FTDI device"); + } + + QGCLogger.d(TAG, "D2XX opening: dev=" + _device.getDeviceName() + + " vid=0x" + Integer.toHexString(_device.getVendorId()) + + " pid=0x" + Integer.toHexString(_device.getProductId()) + + " serial=" + _device.getSerialNumber() + + " ifaces=" + _device.getInterfaceCount() + + " readQueue=" + _readQueueBufferCount + "x" + _readQueueBufferSize); + + // Refresh D2XX's device list immediately before openByLocation (TN_147 pattern); otherwise findDevice() may iterate a stale list from the prober's check and return null for an attached device. + try { + final int count = handle.manager.createDeviceInfoList(handle.appContext); + QGCLogger.d(TAG, "D2XX createDeviceInfoList pre-open: count=" + count); + } catch (Throwable t) { + QGCLogger.w(TAG, "createDeviceInfoList pre-open failed (" + t.getClass().getName() + "): " + t.getMessage()); + } + + final int d2xxLocation = d2xxLocationForPort(); + FT_Device d2xxDevice = null; + Throwable openThrowable = null; + try { + d2xxDevice = handle.manager.openByLocation( + handle.appContext, d2xxLocation, D2xxLibrary.makeDriverParameters()); + } catch (Throwable t) { + openThrowable = t; + } + + if (d2xxDevice == null || !d2xxDevice.isOpen()) { + // openByLocation binds the requested Android USB interface instead of always interface 0 on multi-port FT2232/FT4232; FTDI logs kernel/permission failures to tag "FTDI_Device::". + final String mode; + if (openThrowable != null) { + mode = "threw " + openThrowable.getClass().getName() + ": " + openThrowable.getMessage(); + } else if (d2xxDevice == null) { + mode = "openByLocation returned null for location 0x" + + Integer.toHexString(d2xxLocation); + } else { + mode = "openByLocation returned an FT_Device but isOpen()=false for location 0x" + + Integer.toHexString(d2xxLocation); + } + QGCLogger.e(TAG, "D2XX open FAILED for " + _device.getDeviceName() + ": " + mode, openThrowable); + throw new IOException("Failed to open D2XX FTDI device " + _device.getDeviceName() + ": " + mode, openThrowable); + } + + // connection unused on D2XX path; D2XX opens its own connection in openByLocation + _ftDevice = d2xxDevice; + QGCLogger.d(TAG, "D2XX open OK: " + _device.getDeviceName()); + + // Reset bit mode to UART before configuring, defending against an FT232H/FT2232H left in MPSSE/bitbang by a prior app (else baud/data would apply to a chip still in bitbang). + try { + _ftDevice.setBitMode((byte) 0, D2xxManager.FT_BITMODE_RESET); + } catch (Throwable t) { + QGCLogger.w(TAG, "setBitMode RESET failed (" + t.getClass().getName() + "): " + t.getMessage()); + } + + // FTDI's default 16ms latency timer caps short-read flush at ~62 Hz; D2XX documents 2ms as the programmable minimum. Best-effort. + try { + _ftDevice.setLatencyTimer(D2xxLibrary.D2XX_LATENCY_TIMER_MS); + } catch (Throwable t) { + QGCLogger.w(TAG, "setLatencyTimer failed (" + t.getClass().getName() + "): " + t.getMessage()); + } + } + + @Override + public void close() throws IOException { + if (_ftDevice != null) { + try { + _ftDevice.close(); + } catch (Throwable t) { + QGCLogger.w(TAG, "Error closing D2XX device: " + t.getMessage()); + } + _ftDevice = null; + } + + } + + @Override + public int read(final byte[] dest, final int timeout) throws IOException { + return read(dest, dest.length, timeout); + } + + @Override + public int read(final byte[] dest, final int length, final int timeout) throws IOException { + final FT_Device device = _ftDevice; + if (device == null || !device.isOpen()) { + throw new IOException("Port not open"); + } + if (dest == null) { + throw new IOException("Null read buffer"); + } + if (length <= 0) { + return 0; + } + + final int readLength = Math.min(length, dest.length); + try { + final int bytesRead = device.read(dest, readLength, timeout); + return D2xxLibrary.normalizeReadResult(bytesRead, readLength); + } catch (final IOException e) { + throw e; + } catch (Throwable t) { + // Hot-unplug surfaces from D2XX as RuntimeException/NPE out of JNI (no declared IOException). Returning 0 would spin SerialInputOutputManager forever without onRunError, + // so C++ never sees EXC_RESOURCE and the port stays "open"; throw so the IO manager exits and the listener-mute / token-stale path runs. + QGCLogger.e(TAG, "Error reading D2XX data", t); + throw new IOException("D2XX read error: " + t.getMessage(), t); + } + } + + @Override + public void write(final byte[] src, final int timeout) throws IOException { + write(src, src.length, timeout); + } + + @Override + public void write(final byte[] src, final int length, final int timeout) throws IOException { + if (src == null || length <= 0) { + return; + } + final int writeLen = Math.min(length, src.length); + // Publish _blockedWriter and re-check _writesCancelled inside _writeGate to close the lost-wakeup gap: a concurrent cancelPendingWrites() either sees our thread and interrupts, or this re-check catches its earlier flip. + synchronized (_writeGate) { + if (_writesCancelled) { + throw new IOException("D2XX write cancelled (port closing)"); + } + _blockedWriter = Thread.currentThread(); + } + try { + d2xxOp("writing D2XX data", (device) -> { + // wait=true blocks until the bulk-OUT URB completes; the FT232R NAKs its OUT endpoint when full, so completion is wire-rate paced. + final int n = device.write(src, writeLen, true, timeout); + if (n < writeLen) { + throw new IOException("D2XX short write: " + n + "/" + writeLen); + } + return n; + }); + } finally { + synchronized (_writeGate) { + _blockedWriter = null; + } + // FT_Device.write consumes any InterruptedException; clear a stray interrupt cancelPendingWrites may have set after the write so it can't leak into the C++ owner thread's next JNI call. + Thread.interrupted(); + } + } + + @Override + public void setParameters(final int baudRate, final int dataBits, final int stopBits, final int parity) throws IOException { + d2xxOp("setting D2XX parameters", (device) -> { + final boolean baudOk = device.setBaudRate(baudRate); + final boolean charsOk = device.setDataCharacteristics( + D2xxLibrary.toD2xxDataBits(dataBits), + D2xxLibrary.toD2xxStopBits(stopBits), + D2xxLibrary.toD2xxParity(parity)); + if (!baudOk || !charsOk) { + throw new IOException("Failed to set FTDI serial parameters"); + } + // Canonical FTDI pattern: purge both FIFOs after baud/data is set so stale bytes captured at the previous (wrong) baud don't reach the parser. + device.purge((byte) (D2xxManager.FT_PURGE_RX | D2xxManager.FT_PURGE_TX)); + return null; + }); + } + + @Override + public boolean getCD() throws IOException { + return readModemStatusBit(D2xxManager.FT_DCD); + } + + @Override + public boolean getCTS() throws IOException { + return readModemStatusBit(D2xxManager.FT_CTS); + } + + @Override + public boolean getDSR() throws IOException { + return readModemStatusBit(D2xxManager.FT_DSR); + } + + @Override + public boolean getDTR() throws IOException { + ensureOpen(); + return _dtr; + } + + @Override + public void setDTR(final boolean value) throws IOException { + d2xxOp("setting D2XX control line DTR", (device) -> { + final boolean ok = value ? device.setDtr() : device.clrDtr(); + if (!ok) { + throw new IOException("Failed to set DTR"); + } + _dtr = value; + return null; + }); + } + + @Override + public boolean getRI() throws IOException { + return readModemStatusBit(D2xxManager.FT_RI); + } + + @Override + public boolean getRTS() throws IOException { + ensureOpen(); + return _rts; + } + + @Override + public void setRTS(final boolean value) throws IOException { + d2xxOp("setting D2XX control line RTS", (device) -> { + final boolean ok = value ? device.setRts() : device.clrRts(); + if (!ok) { + throw new IOException("Failed to set RTS"); + } + _rts = value; + return null; + }); + } + + @Override + public EnumSet getControlLines() throws IOException { + // One control transfer reads all modem bits atomically, not four separate calls. + final int status = d2xxOp("reading D2XX modem status", (device) -> device.getModemStatus()); + // Best-effort UART line-status read (overrun/parity/framing/break), logged on change so persistent cabling/baud errors surface in logcat without spamming. + final FT_Device device = _ftDevice; + try { + final short lineStatus = device != null ? device.getLineStatus() : 0; + if (lineStatus != _lastLineStatus) { + if (lineStatus != 0) { + QGCLogger.w(TAG, "D2XX line status non-zero on " + _device.getDeviceName() + + ":" + describeLineStatus(lineStatus)); + } else { + QGCLogger.i(TAG, "D2XX line status cleared on " + _device.getDeviceName()); + } + _lastLineStatus = lineStatus; + } + } catch (Throwable t) { + // getLineStatus is best-effort diagnostic; don't fail getControlLines on its failure. + } + final EnumSet lines = EnumSet.noneOf(ControlLine.class); + if (_rts) lines.add(ControlLine.RTS); + if ((status & D2xxManager.FT_CTS) != 0) lines.add(ControlLine.CTS); + if (_dtr) lines.add(ControlLine.DTR); + if ((status & D2xxManager.FT_DSR) != 0) lines.add(ControlLine.DSR); + if ((status & D2xxManager.FT_DCD) != 0) lines.add(ControlLine.CD); + if ((status & D2xxManager.FT_RI) != 0) lines.add(ControlLine.RI); + return lines; + } + + private static String describeLineStatus(final short lineStatus) { + final StringBuilder sb = new StringBuilder(); + if ((lineStatus & D2xxManager.FT_OE) != 0) sb.append(" overrun"); + if ((lineStatus & D2xxManager.FT_PE) != 0) sb.append(" parity"); + if ((lineStatus & D2xxManager.FT_FE) != 0) sb.append(" framing"); + if ((lineStatus & D2xxManager.FT_BI) != 0) sb.append(" break"); + return sb.toString(); + } + + @Override + public EnumSet getSupportedControlLines() { + return EnumSet.of(ControlLine.RTS, ControlLine.CTS, ControlLine.DTR, ControlLine.DSR, ControlLine.CD, ControlLine.RI); + } + + @Override + public void setFlowControl(final FlowControl flowControl) throws IOException { + if (flowControl == FlowControl.XON_XOFF_INLINE) { + throw new IOException("XON/XOFF inline flow control is not supported by D2XX adapter"); + } + d2xxOp("setting D2XX flow control", (device) -> { + final byte XON = 0x11; // DC1 — standard ASCII XON + final byte XOFF = 0x13; // DC3 — standard ASCII XOFF + final short flowMode = D2xxLibrary.toD2xxFlowControl(flowControl); + final boolean ok = device.setFlowControl(flowMode, XON, XOFF); + if (!ok) { + throw new IOException("Failed to set flow control: " + flowControl); + } + _flowControl = flowControl; + return null; + }); + } + + @Override + public FlowControl getFlowControl() { + return isOpen() ? _flowControl : FlowControl.NONE; + } + + @Override + public EnumSet getSupportedFlowControl() { + return EnumSet.of(FlowControl.NONE, FlowControl.RTS_CTS, FlowControl.DTR_DSR, FlowControl.XON_XOFF); + } + + @Override + public boolean getXON() throws IOException { + ensureOpen(); + return false; + } + + @Override + public void purgeHwBuffers(final boolean purgeReadBuffers, final boolean purgeWriteBuffers) throws IOException { + final byte purgeFlags = (byte) ( + (purgeReadBuffers ? D2xxManager.FT_PURGE_RX : 0) | + (purgeWriteBuffers ? D2xxManager.FT_PURGE_TX : 0)); + if (purgeFlags == 0) { + return; + } + d2xxOp("purging D2XX buffers", (device) -> { + if (!device.purge(purgeFlags)) { + throw new IOException("Failed to purge FTDI buffers"); + } + return null; + }); + } + + @Override + public void setBreak(final boolean value) throws IOException { + d2xxOp("setting D2XX break condition", (device) -> { + final boolean ok = value ? device.setBreakOn() : device.setBreakOff(); + if (!ok) { + throw new IOException("Failed to set break condition"); + } + return null; + }); + } + + @Override + public boolean isOpen() { + return _ftDevice != null && _ftDevice.isOpen(); + } + + private void ensureOpen() throws IOException { + if (!isOpen()) { + throw new IOException("Port not open"); + } + } + + private boolean readModemStatusBit(final int mask) throws IOException { + return (d2xxOp("reading D2XX modem status", (device) -> device.getModemStatus()) & mask) != 0; + } + + /** + * D2XX-backed {@link UsbSerialDriver}; the port impl is the enclosing {@link QGCFtdiSerialPort} and the D2XX runtime + capability checks live in {@link D2xxLibrary}. + * This class is just the {@code UsbSerialDriver} hookup. + */ + public static final class QGCFtdiSerialDriver implements UsbSerialDriver { + + private final UsbDevice _device; + private final List _ports; + + public QGCFtdiSerialDriver(final UsbDevice device) { + _device = device; + + final int interfaceCount = device.getInterfaceCount(); + if (interfaceCount <= 0) { + _ports = Collections.emptyList(); + return; + } + final List ports = new ArrayList<>(interfaceCount); + for (int i = 0; i < interfaceCount; i++) { + ports.add(new QGCFtdiSerialPort(this, device, i)); + } + + _ports = Collections.unmodifiableList(ports); + } + + @Override + public UsbDevice getDevice() { + return _device; + } + + @Override + public List getPorts() { + return _ports; + } + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/QGCSerialPort.java b/android/src/org/mavlink/qgroundcontrol/serial/QGCSerialPort.java new file mode 100644 index 000000000000..61f750b609f3 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/QGCSerialPort.java @@ -0,0 +1,592 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.mavlink.qgroundcontrol.serial.SerialWireConstants.EXC_OPEN_FAILED; +import static org.mavlink.qgroundcontrol.serial.SerialWireConstants.EXC_RESOURCE; + +import org.mavlink.qgroundcontrol.QGCLogger; + +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbManager; + +import org.qtproject.qt.android.UsedFromNativeCode; + +import com.hoho.android.usbserial.driver.UsbSerialDriver; +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** One Java object per open Android USB serial port; hosts a write loop and a read loop over the shared lifecycle monitor. */ +public class QGCSerialPort { + + private static final String TAG = QGCSerialPort.class.getSimpleName(); + + // REGISTERED gates configure(); CONFIGURED means reported to the sink (so onPortClosed only fires for those); CLOSING/CLOSED gate close() re-entry. + enum LifecycleState { REGISTERED, CONFIGURED, CLOSING, CLOSED } + + enum CloseReason { + USER(false), + // Both DETACHED (broadcast) and DEVICE_ERROR (onRunError IOException) mean the device is gone; the C++ side must learn either way (#1A). + DETACHED(true), + STALE_DRIVER(true), + DEVICE_ERROR(true), + SHUTDOWN(false), + OPEN_ROLLBACK(false); + + final boolean emitDisconnected; + + CloseReason(final boolean emitDisconnected) { + this.emitDisconnected = emitDisconnected; + } + } + + interface PortLifecycleSink { + void onPortConfigured(QGCSerialPort port); + void onPortClosed(QGCSerialPort port); + void onPortDeviceError(QGCSerialPort port); + } + + /** Seam over the four JNI entry points so write/read/lifecycle logic is testable without the native layer (tests inject a fake via setNativeBridgeForTest). */ + interface NativeBridge { + void deviceHasDisconnected(long handle); + void deviceException(long handle, int kind, String message); + void deviceNewData(long handle, ByteBuffer data, int length); + void deviceBytesWritten(long handle, int n); + } + + private final QGCUsbSerialManager.PortAddress address; + private final UsbSerialPort port; + /** Same object as {@link #port} when it is the D2XX-backed FTDI port (the only one with QGC hooks), else null; resolved once. */ + private final QGCFtdiSerialPort extendedPort; + /** Per-driver quirks as one immutable object so write/read paths read capabilities without touching the driver's runtime type. */ + private final DriverStrategy.Caps caps; + static final int WRITE_QUEUE_CAPACITY = SerialWriteLoop.WRITE_QUEUE_CAPACITY; + // Declared before the loops: each captures host.lock() in its constructor, so lifecycleLock must already be initialized. + private final Object lifecycleLock = new Object(); + private final HostImpl host = new HostImpl(); + // Synchronous writer (not ioManager.writeAsync): p.write() lets us ack each sub-write via nativeDeviceBytesWritten for C++ backpressure. + private final SerialWriteLoop writeLoop = new SerialWriteLoop(host); + private final SerialReadLoop readLoop = new SerialReadLoop(host); + private final PortLifecycleSink portLifecycleSink; + private final Runnable unregisterCallback; + /** Zeroed by close() so an in-flight emit/exception after the C++ handle drain is a no-op (Java-side belt-and-suspenders to the C++ QReadWriteLock). */ + private volatile long nativeHandle; + private final UsbManager usbManager; + private final UsbSerialDriver driver; + /** Owned host USB connection. Null for D2XX (which owns its own internally); set otherwise. Guarded by lifecycleLock. */ + private UsbDeviceConnection connection; + private int currentBaudRate; + /** Forward-only lifecycle state. All mutation flows through transitionLocked under lifecycleLock. */ + private LifecycleState lifecycleState = LifecycleState.REGISTERED; + private boolean disconnectEmitted; + private volatile boolean listenerMuted; + + QGCSerialPort(final QGCUsbSerialManager.PortAddress address, + final UsbManager usbManager, + final UsbSerialDriver driver, + final UsbSerialPort port, + final long nativeHandle, + final PortLifecycleSink portLifecycleSink, + final Runnable unregisterCallback) { + this.address = address; + this.usbManager = usbManager; + this.driver = driver; + this.port = port; + this.extendedPort = (port instanceof QGCFtdiSerialPort ext) ? ext : null; + this.caps = DriverStrategy.capsFor(driver); + this.nativeHandle = nativeHandle; + this.portLifecycleSink = portLifecycleSink; + this.unregisterCallback = unregisterCallback; + } + + // JNI bridge — bound by name from C++ native registration on this per-port class. + private native void nativeDeviceHasDisconnected(final long nativeHandle); + private native void nativeDeviceException(final long nativeHandle, final int kind, final String message); + /** JNI bridge — direct ByteBuffer for GetDirectBufferAddress; native code must consume it synchronously (recycled after return). */ + private native void nativeDeviceNewData(final long nativeHandle, + final java.nio.ByteBuffer data, final int length); + private native void nativeDeviceBytesWritten(final long nativeHandle, final int n); + + private volatile NativeBridge nativeBridge = new RealNativeBridge(); + + private final class RealNativeBridge implements NativeBridge { + @Override public void deviceHasDisconnected(final long handle) { nativeDeviceHasDisconnected(handle); } + @Override public void deviceException(final long handle, final int kind, final String message) { nativeDeviceException(handle, kind, message); } + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { nativeDeviceNewData(handle, data, length); } + @Override public void deviceBytesWritten(final long handle, final int n) { nativeDeviceBytesWritten(handle, n); } + } + + void setNativeBridgeForTest(final NativeBridge bridge) { + this.nativeBridge = bridge; + } + + void forceConfiguredForTest(final int baudRate) { + synchronized (lifecycleLock) { + currentBaudRate = baudRate; + transitionLocked(LifecycleState.CONFIGURED); + writeLoop.startLocked(String.valueOf(address)); + } + } + + boolean awaitWriteLoopDrainedForTest(final long timeoutMs) throws InterruptedException { + return writeLoop.awaitDrainedForTest(timeoutMs); + } + + SerialReadLoop readLoopForTest() { + return readLoop; + } + + void muteListenerForTest() { + synchronized (lifecycleLock) { + host.muteListenerLocked(); + } + } + + QGCUsbSerialManager.PortAddress address() { return address; } + long nativeHandle() { return nativeHandle; } + + String stateForLogging() { + synchronized (lifecycleLock) { + return lifecycleState.name(); + } + } + + public boolean configure(final QGCUsbSerialManager.SerialParameters params, + final int flowControl, final boolean assertDtr) { + synchronized (lifecycleLock) { + if (lifecycleState != LifecycleState.REGISTERED) { + QGCLogger.w(TAG, "configure requested in state " + lifecycleState + " for " + address); + return false; + } + if (!openLocked(port, address)) { + closeLocked(CloseReason.OPEN_ROLLBACK); + return false; + } + if (!readLoop.createIoManagerLocked()) { + fireException(EXC_OPEN_FAILED, "Failed to start I/O for device: " + address); + closeLocked(CloseReason.OPEN_ROLLBACK); + return false; + } + if (!setSerialParametersLocked(params) + || !setFlowControlLocked(flowControl) + || (assertDtr && !setControlLineLocked(UsbSerialPort.ControlLine.DTR, true))) { + QGCLogger.e(TAG, "Failed to apply post-open config for " + address + "; rolling back"); + closeLocked(CloseReason.OPEN_ROLLBACK); + return false; + } + notifyConfiguredLocked(); + return true; + } + } + + /** Opens {@code targetPort} (per-driver quirks; the D2XX T1/T2 claim ordering must stay one contiguous critical section). Caller holds lifecycleLock; on failure fireException has fired and any connection is released. */ + private boolean openLocked(final UsbSerialPort targetPort, final QGCUsbSerialManager.PortAddress addr) { + if (usbManager == null || driver == null || targetPort == null) { + fireException(EXC_OPEN_FAILED, "No serial port available for device: " + addr); + return false; + } + if (targetPort.isOpen()) { + return true; + } + + final UsbDevice device = driver.getDevice(); + final UsbDeviceConnection openedConnection = usbManager.openDevice(device); + if (openedConnection == null) { + // Detach-during-open gap (#1D): device gone between registration and open routes through EXC_RESOURCE so C++ surfaces ResourceUnavailable, not a generic failure. + final boolean stillPresent = usbManager.getDeviceList().containsKey(device.getDeviceName()); + final int kind = stillPresent ? EXC_OPEN_FAILED : EXC_RESOURCE; + fireException(kind, "No USB device connection for device: " + device.getDeviceName()); + return false; + } + + // D2XX race fix (#14146): FT_Device.openDevice re-calls UsbManager.openDevice() + claimInterface(force=true), + // revoking/leaking T1's claim (and returning null on some Android 12+ OEMs); close the T1 probe before D2XX opens T2. + if (caps.usesD2xx()) { + openedConnection.close(); + try { + targetPort.open(null); + } catch (final IOException ex) { + QGCLogger.e(TAG, "Error opening driver for device " + device.getDeviceName(), ex); + fireException(EXC_OPEN_FAILED, "Error opening driver: " + ex.getMessage()); + return false; + } + // D2XX owns the connection internally — no Java-side handle to track. + connection = null; + return true; + } + + try { + targetPort.open(openedConnection); + } catch (final IOException ex) { + QGCLogger.e(TAG, "Error opening driver for device " + device.getDeviceName(), ex); + fireException(EXC_OPEN_FAILED, "Error opening driver: " + ex.getMessage()); + openedConnection.close(); + return false; + } + + if (caps.needsHostFtdiLatencyTimer()) { + DriverStrategy.applyHostFtdiLatencyTimer(openedConnection, addr.portIndex()); + } + + connection = openedConnection; + return true; + } + + /** Legal lifecycle transitions (forward only): REGISTERED→CONFIGURED, REGISTERED/CONFIGURED→CLOSING, CLOSING→CLOSED. */ + static boolean isLifecycleTransitionAllowed(final LifecycleState from, final LifecycleState to) { + switch (from) { + case REGISTERED: return to == LifecycleState.CONFIGURED || to == LifecycleState.CLOSING; + case CONFIGURED: return to == LifecycleState.CLOSING; + case CLOSING: return to == LifecycleState.CLOSED; + default: return false; + } + } + + /** All lifecycleState mutation flows through here. Caller holds lifecycleLock. */ + private void transitionLocked(final LifecycleState to) { + final LifecycleState from = lifecycleState; + if (from == to) { + return; + } + if (!isLifecycleTransitionAllowed(from, to)) { + QGCLogger.e(TAG, "Illegal lifecycle transition " + from + " -> " + to + " for " + address); + return; + } + lifecycleState = to; + } + + /** Transitions REGISTERED → CONFIGURED and fires the sink. Idempotent. */ + private void notifyConfiguredLocked() { + if (lifecycleState == LifecycleState.REGISTERED) { + transitionLocked(LifecycleState.CONFIGURED); + if (portLifecycleSink != null) { + portLifecycleSink.onPortConfigured(this); + } + } + } + + /** {@code wasConfigured} captures pre-CLOSING state so onPortClosed fires only for ports reported configured upstream. */ + private void notifyClosedLocked(final boolean wasConfigured) { + if (wasConfigured && portLifecycleSink != null) { + portLifecycleSink.onPortClosed(this); + } + } + + @UsedFromNativeCode + public boolean close() { + return close(CloseReason.USER); + } + + public boolean close(final CloseReason reason) { + // Preempt any in-flight blocking write before taking lifecycleLock, else close() sits behind a wedged D2XX write (multi-second); cancelPendingWrites interrupts the parked writer, no-op on non-D2XX. + cancelPendingWritesUnlocked(); + writeLoop.stopUnlocked(String.valueOf(address)); + synchronized (lifecycleLock) { + return closeLocked(reason); + } + } + + private void cancelPendingWritesUnlocked() { + if (extendedPort != null) { + extendedPort.cancelPendingWrites(); + } + } + + private boolean closeLocked(final CloseReason reason) { + if (lifecycleState == LifecycleState.CLOSED) { + return true; + } + if (lifecycleState == LifecycleState.CLOSING) { + return true; + } + + final boolean wasConfigured = (lifecycleState == LifecycleState.CONFIGURED); + transitionLocked(LifecycleState.CLOSING); + + // stopIoManagerLocked() mutes the listener as its first action, so no separate mute is needed here. + readLoop.stopIoManagerLocked(address); + final boolean ok = closePortLocked(); + closeConnectionLocked(); + unregisterLocked(); + transitionLocked(LifecycleState.CLOSED); + notifyClosedLocked(wasConfigured); + maybeEmitDisconnectLocked(reason); + // After the disconnect emit, no further JNI traffic is valid for this port. + nativeHandle = 0L; + return ok; + } + + boolean closePortLocked() { + if (port == null || !port.isOpen()) { + return true; + } + try { + port.close(); + QGCLogger.d(TAG, "Device " + address + " closed successfully."); + return true; + } catch (final IOException ex) { + QGCLogger.e(TAG, "Error closing driver:", ex); + return false; + } + } + + /** Releases the owned host connection. Caller holds lifecycleLock. */ + void closeConnectionLocked() { + if (connection != null) { + try { connection.close(); } + catch (final Throwable t) { QGCLogger.w(TAG, "Error closing UsbDeviceConnection: " + t.getMessage()); } + connection = null; + } + } + + void unregisterLocked() { + if (unregisterCallback != null) { + unregisterCallback.run(); + } + } + + @UsedFromNativeCode + public boolean startIoManager() { + synchronized (lifecycleLock) { + // Only a CONFIGURED port has a live IO manager; start() on a STOPPING SerialInputOutputManager throws IllegalStateException (hoho.android.usbserial), so fail fast on lifecycle. + if (lifecycleState != LifecycleState.CONFIGURED) { + QGCLogger.w(TAG, "startIoManager rejected in state " + lifecycleState + " for " + address); + return false; + } + if (!readLoop.startLocked(address)) { + return false; + } + if (!writeLoop.startLocked(String.valueOf(address))) { + readLoop.stopIoManagerLocked(address); + return false; + } + return true; + } + } + + @UsedFromNativeCode + public boolean stopIoManager() { + synchronized (lifecycleLock) { + return readLoop.stopIoManagerLocked(address); + } + } + + @UsedFromNativeCode + public boolean setSerialParameters(final QGCUsbSerialManager.SerialParameters params) { + synchronized (lifecycleLock) { + return setSerialParametersLocked(params); + } + } + + private boolean setSerialParametersLocked(final QGCUsbSerialManager.SerialParameters params) { + if (!caps.supportsBaudRate(params.baudRate())) { + QGCLogger.e(TAG, "Baud rate " + params.baudRate() + " is not supported for device " + address); + return false; + } + return withOpenPortLocked("setting parameters", false, p -> { + p.setParameters(params.baudRate(), params.dataBits(), params.stopBits(), params.parity()); + if (caps.needsPurgeAfterBaudChange()) { + p.purgeHwBuffers(true, true); + } + currentBaudRate = params.baudRate(); + return true; + }); + } + + @UsedFromNativeCode + public boolean setDataTerminalReady(final boolean on) { + synchronized (lifecycleLock) { + return setControlLineLocked(UsbSerialPort.ControlLine.DTR, on); + } + } + + @UsedFromNativeCode + public boolean setRequestToSend(final boolean on) { + synchronized (lifecycleLock) { + return setControlLineLocked(UsbSerialPort.ControlLine.RTS, on); + } + } + + private boolean setControlLineLocked(final UsbSerialPort.ControlLine controlLine, final boolean on) { + return withOpenPortLocked("set " + controlLine, false, p -> { + if (!isControlLineSupported(p, controlLine)) { + QGCLogger.e(TAG, "Setting " + controlLine + " Not Supported"); + return false; + } + switch (controlLine) { + case DTR: p.setDTR(on); break; + case RTS: p.setRTS(on); break; + default: + QGCLogger.w(TAG, "Setting " + controlLine + " is not supported via this method."); + return false; + } + return true; + }); + } + + private static boolean isControlLineSupported(final UsbSerialPort p, + final UsbSerialPort.ControlLine controlLine) { + try { + return p.getSupportedControlLines().contains(controlLine); + } catch (final IOException e) { + QGCLogger.e(TAG, "Error getting supported control lines", e); + return false; + } + } + + @UsedFromNativeCode + public boolean setFlowControl(final int flowControl) { + synchronized (lifecycleLock) { + return setFlowControlLocked(flowControl); + } + } + + private boolean setFlowControlLocked(final int flowControl) { + // Decode wire int via explicit switch — see #1C and SerialWireConstants.FC_*. + final UsbSerialPort.FlowControl target; + switch (flowControl) { + case SerialWireConstants.FC_NONE: target = UsbSerialPort.FlowControl.NONE; break; + case SerialWireConstants.FC_RTS_CTS: target = UsbSerialPort.FlowControl.RTS_CTS; break; + case SerialWireConstants.FC_DTR_DSR: target = UsbSerialPort.FlowControl.DTR_DSR; break; + case SerialWireConstants.FC_XON_XOFF: target = UsbSerialPort.FlowControl.XON_XOFF; break; + case SerialWireConstants.FC_XON_XOFF_INLINE: target = UsbSerialPort.FlowControl.XON_XOFF_INLINE; break; + default: + QGCLogger.w(TAG, "Invalid flow control wire value " + flowControl); + return false; + } + return withOpenPortLocked("setting Flow Control", false, p -> { + final var supported = p.getSupportedFlowControl(); + if (supported.isEmpty() || !supported.contains(target)) { + QGCLogger.e(TAG, "Flow Control " + target + " not supported on this port"); + return false; + } + if (p.getFlowControl() == target) { + return true; + } + p.setFlowControl(target); + return true; + }); + } + + @UsedFromNativeCode + public boolean setBreak(final boolean on) { + synchronized (lifecycleLock) { + return withOpenPortLocked("setting break condition", false, p -> { + p.setBreak(on); + return true; + }); + } + } + + @UsedFromNativeCode + public boolean purgeBuffers(final boolean input, final boolean output) { + synchronized (lifecycleLock) { + return withOpenPortLocked("purging buffers", false, p -> { + try { + p.purgeHwBuffers(input, output); + } catch (UnsupportedOperationException ignored) { + // CDC-ACM drivers have no HW FIFO to purge; treat as no-op. + } + return true; + }); + } + } + + /** Non-blocking write from the C++ owner thread: hands the payload to the writer loop; returns bytes accepted or -1. */ + @UsedFromNativeCode + public int enqueueWrite(final ByteBuffer data, final int length) { + return writeLoop.enqueue(data, length, String.valueOf(address)); + } + + /** Ack written bytes to C++ outside lifecycleLock (mirrors emitNewData) to avoid AB-BA with the C++ writeMutex; skipped once muted so we never call into a torn-down native side. */ + private void ackBytesWritten(final long handle, final int n) { + if (n > 0 && handle != 0 && !listenerMuted) { + nativeBridge.deviceBytesWritten(handle, n); + } + } + + /** Single capability adapter handed to both loops: keeps QGCSerialPort's lifecycle/handle/port internals private while exposing each loop only the methods it needs. */ + private final class HostImpl implements SerialReadLoop.ReadHost, SerialWriteLoop.WriteHost { + // Shared lifecycle/handle/mute/exception surface (declared on both ReadHost and WriteHost). + @Override public Object lock() { return lifecycleLock; } + @Override public long nativeHandleLocked() { return nativeHandle; } + @Override public void muteListenerLocked() { listenerMuted = true; } + @Override public void fireException(final int kind, final String message) { QGCSerialPort.this.fireException(kind, message); } + + // WriteHost + @Override public boolean isWritableLocked() { return lifecycleState == LifecycleState.CONFIGURED; } + @Override public int writeChunkSizeLocked() { return caps.writeChunkSizeForBaud(currentBaudRate); } + @Override public UsbSerialPort openPortOrWarnLocked(final String operation) { return QGCSerialPort.this.openPortOrWarnLocked(operation); } + @Override public void ackBytesWritten(final long handle, final int n) { QGCSerialPort.this.ackBytesWritten(handle, n); } + @Override public void cancelPendingWritesUnlocked() { QGCSerialPort.this.cancelPendingWritesUnlocked(); } + + // ReadHost + @Override public UsbSerialPort port() { return port; } + @Override public DriverStrategy.Caps caps() { return caps; } + @Override public int readTimeoutForIoManager() { + // Extended (D2XX) port carries its own read-timeout constant; pass it through for that path. + final int d2xxOverride = (extendedPort != null) ? extendedPort.readTimeoutForIoManager() : 0; + return caps.readTimeoutForIoManager(d2xxOverride); + } + @Override public boolean isListenerMuted() { return listenerMuted; } + @Override public void clearListenerMuteLocked() { listenerMuted = false; } + @Override public void emitNewDataToNative(final long handle, final ByteBuffer buf, final int len) { nativeBridge.deviceNewData(handle, buf, len); } + @Override public void onReaderDeviceError() { + if (portLifecycleSink != null) { + portLifecycleSink.onPortDeviceError(QGCSerialPort.this); + } + } + } + + void fireException(final int kind, final String message) { + // Snapshot under lock so closeLocked can't zero nativeHandle between guard and JNI call (#1B). + final long handle; + synchronized (lifecycleLock) { + handle = nativeHandle; + if (handle == 0L) return; + } + nativeBridge.deviceException(handle, kind, message); + } + + private void maybeEmitDisconnectLocked(final CloseReason reason) { + if (!reason.emitDisconnected || disconnectEmitted || nativeHandle == 0) { + return; + } + disconnectEmitted = true; + nativeBridge.deviceHasDisconnected(nativeHandle); + } + + private UsbSerialPort openPortOrWarnLocked(final String operation) { + if (port == null) { + QGCLogger.d(TAG, "Attempted to " + operation + " on a null port for " + address); + return null; + } + if (!port.isOpen()) { + QGCLogger.d(TAG, "Attempted to " + operation + " on a closed port for " + address); + return null; + } + return port; + } + + @FunctionalInterface + private interface PortOp { + T run(UsbSerialPort p) throws IOException, UnsupportedOperationException; + } + + private T withOpenPortLocked(final String operation, final T fallback, final PortOp op) { + final UsbSerialPort p = openPortOrWarnLocked(operation); + if (p == null) { + return fallback; + } + try { + return op.run(p); + } catch (final UnsupportedOperationException e) { + QGCLogger.w(TAG, "Error " + operation + ": " + e); + return fallback; + } catch (final IOException e) { + QGCLogger.e(TAG, "Error " + operation, e); + return fallback; + } + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/QGCUsbSerialManager.java b/android/src/org/mavlink/qgroundcontrol/serial/QGCUsbSerialManager.java new file mode 100644 index 000000000000..6916de3babd2 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/QGCUsbSerialManager.java @@ -0,0 +1,476 @@ +package org.mavlink.qgroundcontrol.serial; + +import org.mavlink.qgroundcontrol.QGCForegroundService; +import org.mavlink.qgroundcontrol.QGCLogger; + +import android.content.Context; +import android.content.pm.ServiceInfo; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; + +import org.qtproject.qt.android.UsedFromNativeCode; + +import com.hoho.android.usbserial.driver.UsbSerialDriver; +import com.hoho.android.usbserial.driver.UsbSerialPort; +import com.hoho.android.usbserial.driver.UsbSerialProber; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** Android USB serial facade: owns enumeration, permission, and attach/detach routing; per-port ops live on {@link QGCSerialPort}. */ +public class QGCUsbSerialManager + implements QGCSerialPort.PortLifecycleSink { + + private static final String TAG = QGCUsbSerialManager.class.getSimpleName(); + + private static final Object singletonLock = new Object(); + private static volatile QGCUsbSerialManager sInstance; + + /** Stable Android device path plus runtime device id and usb-serial-for-android port index. */ + public static record PortAddress(String deviceName, int physicalDeviceId, int portIndex) {} + + /** Open-port registry keyed by {@link PortAddress}; detach/permission-denied use a linear name scan (open-port count is single-digit, so no fanout index). */ + static final class PortRegistry { + private final Map portsByAddress = new ConcurrentHashMap<>(); + + boolean register(final QGCSerialPort port) { + return portsByAddress.putIfAbsent(port.address(), port) == null; + } + + void unregister(final QGCSerialPort port) { + portsByAddress.remove(port.address(), port); + } + + QGCSerialPort get(final PortAddress address) { + return portsByAddress.get(address); + } + + List portsForDeviceName(final String deviceName) { + final List matches = new ArrayList<>(); + for (final QGCSerialPort port : portsByAddress.values()) { + if (port.address().deviceName().equals(deviceName)) { + matches.add(port); + } + } + return matches; + } + + List allPorts() { + return new ArrayList<>(portsByAddress.values()); + } + } + + /** Creates the singleton. No-op if already created. */ + public static void createInstance(final Context context) { + synchronized (singletonLock) { + if (sInstance != null) { + return; + } + sInstance = new QGCUsbSerialManager(context); + } + } + + /** Returns the singleton, or {@code null} if not yet created. */ + public static QGCUsbSerialManager getInstance() { + return sInstance; + } + + /** Capture-and-null under the static lock, then run _destroy unlocked: per-port close() can sit on its lifecycleLock through the D2XX drain, which would otherwise pin the static lock. */ + @UsedFromNativeCode + public static void destroyInstance() { + final QGCUsbSerialManager doomed; + synchronized (singletonLock) { + doomed = sInstance; + sInstance = null; + } + if (doomed != null) { + doomed._destroy(); + } + } + + private final UsbManager usbManager; + private final Context appContext; + private final UsbAttachDetachReceiver attachDetachReceiver; + private final UsbSerialEnumerator enumerator; + private final PortRegistry portRegistry = new PortRegistry(); + /** Serializes device teardown (detach vs reader-thread error) so their close/replace-driver sequences can't interleave for one device. */ + private final Object deviceTeardownLock = new Object(); + /** Guards {@link #configuredPortCount}; the lifecycle-sink callbacks fire from arbitrary threads. */ + private final Object serviceLock = new Object(); + private int configuredPortCount; + + private static final QGCForegroundService.Config USB_SERIAL_FGS_CONFIG = + new QGCForegroundService.Config( + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, + "qgc_usb_serial", + "USB serial", + "QGroundControl USB serial connection", + "USB serial connection active"); + + private final UsbPermissionManager permissionManager; + + /** Single worker for blocking USB enumeration: keeps the probe off the broadcast main looper (ANR path) while preserving the enumerator's probe-unlocked/mutate-locked ordering. */ + private final ExecutorService usbWorker = + Executors.newSingleThreadExecutor(r -> { + final Thread t = new Thread(r, "QGCUsbEnumerator"); + t.setDaemon(true); + return t; + }); + + private QGCUsbSerialManager(final Context context) { + if (!SerialWireConstants.verifyExternalFlowControlOrdinals()) { + throw new IllegalStateException( + "mik3y UsbSerialPort.FlowControl ordinals diverged from SerialWireConstants.FC_* wire values"); + } + appContext = context.getApplicationContext(); + usbManager = (UsbManager) appContext.getSystemService(Context.USB_SERVICE); + attachDetachReceiver = new UsbAttachDetachReceiver(this::onUsbDeviceAttached, this::onUsbDeviceDetached); + permissionManager = new UsbPermissionManager(this::onUsbPermissionGranted, this::onUsbPermissionDenied); + + if (usbManager == null) { + QGCLogger.e(TAG, "Failed to get UsbManager"); + enumerator = new UsbSerialEnumerator(null); + enumerator.setStaleDriverCallback(this::closeAllPortsForDeviceName); + return; + } + + D2xxLibrary.initialize(appContext); + enumerator = new UsbSerialEnumerator(buildProber()); + enumerator.setStaleDriverCallback(this::closeAllPortsForDeviceName); + + permissionManager.register(appContext); + attachDetachReceiver.register(appContext); + + enumerator.updateCurrentDrivers(usbManager); + for (final UsbDevice device : usbManager.getDeviceList().values()) { + requestPermissionIfTracked(device); + } + } + + private void _destroy() { + // Stop the FGS (idempotent) and reset configuredPortCount under serviceLock so a stale onPortConfigured during teardown can't re-trigger the start. + synchronized (serviceLock) { + configuredPortCount = 0; + QGCForegroundService.stop(appContext); + } + for (final QGCSerialPort port : portRegistry.allPorts()) { + port.close(QGCSerialPort.CloseReason.SHUTDOWN); + } + permissionManager.unregister(); + attachDetachReceiver.unregister(); + // Drain the worker before clearing the enumerator so an in-flight probe can't mutate the driver list after we wipe it. + shutdownUsbWorker(); + D2xxLibrary.cleanup(); + enumerator.clear(); + } + + /** + * Builds the {@link UsbSerialProber} for enumeration. Driver matching uses mik3y's default probe table + * (CDC-ACM class-descriptor probing + built-in CP210x / FTDI / CH34x / Prolific VID/PID lists); the only + * QGC-specific behavior is preferring the D2XX-backed {@link QGCFtdiSerialPort.QGCFtdiSerialDriver} when D2XX actually + * enumerates the attached FTDI device. + */ + private static UsbSerialProber buildProber() { + return new UsbSerialProber(UsbSerialProber.getDefaultProbeTable()) { + @Override + public UsbSerialDriver probeDevice(final UsbDevice device) { + if (device == null) return null; + final String tag = String.format("vid=0x%04X pid=0x%04X (%s)", + device.getVendorId(), device.getProductId(), device.getDeviceName()); + // D2XX is only selected after the FTDI library itself recognizes the device; otherwise VCP-mode FtdiSerialDriver remains the fallback. + if (D2xxLibrary.isAvailable() && D2xxLibrary.isFtdiDevice(device) + && D2xxLibrary.canOpenViaD2XX(device)) { + QGCLogger.i(TAG, "probe " + tag + " -> QGCFtdiSerialDriver (D2XX)"); + return new QGCFtdiSerialPort.QGCFtdiSerialDriver(device); + } + // PL2303GT (0x067B/0x23C3) is handled correctly by upstream ProlificSerialDriver (descriptor-based DEVICE_TYPE_HXN skips doBlackMagic() since commit c82cd28, May 2021). + final UsbSerialDriver driver = super.probeDevice(device); + QGCLogger.i(TAG, "probe " + tag + + " -> " + (driver == null ? "no driver" : driver.getClass().getSimpleName())); + return driver; + } + }; + } + + private void shutdownUsbWorker() { + usbWorker.shutdown(); + try { + if (!usbWorker.awaitTermination(USB_WORKER_DRAIN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + QGCLogger.w(TAG, "USB worker did not drain within " + USB_WORKER_DRAIN_TIMEOUT_MS + "ms; forcing shutdown"); + usbWorker.shutdownNow(); + } + } catch (final InterruptedException e) { + usbWorker.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + private static final long USB_WORKER_DRAIN_TIMEOUT_MS = 2000L; + + /** Routes a JNI call to the live instance; returns fallback when not initialised. */ + private static T withInstance(final T fallback, final Function op) { + final QGCUsbSerialManager mgr = getInstance(); + if (mgr == null) { + QGCLogger.e(TAG, "Manager not initialized"); + return fallback; + } + return op.apply(mgr); + } + + /** + * Single-JNI-hop enumeration: serialises all ports into one UTF-8 JSON byte[] so C++ decodes with QJsonDocument + * instead of N*7 getField crossings. Shape (must stay in lockstep with SerialPortInfoCodec::unpack): + * {"ports":[{deviceName,productName,manufacturerName,serialNumber,productId,vendorId,baudRates:[...]}]}. + * A null string field is omitted (distinct from an empty ""); a null/empty baud array yields []. + */ + @UsedFromNativeCode + public static byte[] availablePortsPacked() { + return packPortsInfo(withInstance(new UsbPortInfo[0], QGCUsbSerialManager::_availablePortsInfo)); + } + + static byte[] packPortsInfo(final UsbPortInfo[] ports) { + final JSONArray portsArray = new JSONArray(); + try { + for (final UsbPortInfo port : ports) { + final JSONObject obj = new JSONObject(); + putString(obj, SerialWireConstants.KEY_DEVICE_NAME, port.deviceName()); + putString(obj, SerialWireConstants.KEY_PRODUCT_NAME, port.productName()); + putString(obj, SerialWireConstants.KEY_MANUFACTURER_NAME, port.manufacturerName()); + putString(obj, SerialWireConstants.KEY_SERIAL_NUMBER, port.serialNumber()); + obj.put(SerialWireConstants.KEY_PRODUCT_ID, port.productId()); + obj.put(SerialWireConstants.KEY_VENDOR_ID, port.vendorId()); + final JSONArray baudArray = new JSONArray(); + final int[] bauds = port.supportedBaudRates(); + if (bauds != null) { + for (final int baud : bauds) { + baudArray.put(baud); + } + } + obj.put(SerialWireConstants.KEY_BAUD_RATES, baudArray); + portsArray.put(obj); + } + final String json = new JSONObject().put(SerialWireConstants.KEY_PORTS, portsArray).toString(); + return json.getBytes(StandardCharsets.UTF_8); + } catch (final JSONException e) { + // org.json only throws on NaN/Infinity doubles, which this object graph never contains; treat as fatal. + throw new IllegalStateException("Failed to pack UsbPortInfo", e); + } + } + + /** Omits the key for a null string so C++ can distinguish "absent" from an empty ""; org.json drops null puts. */ + private static void putString(final JSONObject obj, final String key, final String value) throws JSONException { + if (value != null) { + obj.put(key, value); + } + } + + /** One-shot open: register + configure + optionally start the reader in one JNI hop; null on failure (port rolled back). */ + @UsedFromNativeCode + public static QGCSerialPort openConfiguredPort(final String deviceName, final long nativeHandle, + final SerialParameters params, final int flowControl, final boolean assertDtr, + final boolean startReader) { + return withInstance(null, mgr -> { + final QGCSerialPort port = mgr._openPort(deviceName, nativeHandle); + if (port == null) { + return null; + } + if (!port.configure(params, flowControl, assertDtr)) { + return null; + } + if (startReader && !port.startIoManager()) { + port.close(QGCSerialPort.CloseReason.OPEN_ROLLBACK); + return null; + } + return port; + }); + } + + private QGCSerialPort _openPort(final String deviceName, final long nativeHandle) { + final UsbSerialEnumerator.DevicePortSpec spec = UsbSerialEnumerator.parseDevicePortSpec(deviceName); + final UsbSerialDriver driver = enumerator.findDriverByDeviceName(spec.baseDeviceName()); + if (driver == null) { + QGCLogger.d(TAG, "Attempt to open unknown device " + deviceName); + return null; + } + + final UsbSerialPort port = UsbSerialEnumerator.getPortFromDriver(driver, spec.portIndex()); + if (port == null) { + QGCLogger.w(TAG, "No port " + spec.portIndex() + " available on device " + deviceName); + return null; + } + + final PortAddress address = new PortAddress( + driver.getDevice().getDeviceName(), + driver.getDevice().getDeviceId(), + spec.portIndex()); + final QGCSerialPort[] holder = new QGCSerialPort[1]; + final QGCSerialPort serialPort = new QGCSerialPort( + address, + usbManager, + driver, + port, + nativeHandle, + this, + () -> portRegistry.unregister(holder[0])); + holder[0] = serialPort; + + if (!portRegistry.register(serialPort)) { + final QGCSerialPort existing = portRegistry.get(address); + final String state = (existing == null) ? "unknown" : existing.stateForLogging(); + QGCLogger.w(TAG, "Duplicate open rejected for " + address + " while existing port is " + state); + return null; + } + return serialPort; + } + + /** Immutable value-object carrying the four serial-port configuration fields. */ + @UsedFromNativeCode + public record SerialParameters(int baudRate, int dataBits, int stopBits, int parity) {} + + /** Posts blocking USB enumeration onto the serial worker; drops the task if the worker is already shut down. */ + private void postToUsbWorker(final Runnable task) { + try { + usbWorker.execute(task); + } catch (final RejectedExecutionException e) { + QGCLogger.w(TAG, "USB worker rejected task (shutting down)"); + } + } + + private void onUsbDeviceAttached(final UsbDevice device) { + postToUsbWorker(() -> onDeviceAvailable(device)); + } + + private void onUsbDeviceDetached(final UsbDevice device) { + postToUsbWorker(() -> { + synchronized (deviceTeardownLock) { + permissionManager.clearPermission(device.getDeviceName()); + for (final QGCSerialPort port : portRegistry.portsForDeviceName(device.getDeviceName())) { + port.close(QGCSerialPort.CloseReason.DETACHED); + } + QGCLogger.i(TAG, "Device detached: " + device.getDeviceName()); + } + updateCurrentDrivers(); // probe outside the teardown lock + }); + } + + private void onUsbPermissionGranted(final UsbDevice device) { + postToUsbWorker(() -> onDeviceAvailable(device)); + } + + private void onUsbPermissionDenied(final UsbDevice device) { + postToUsbWorker(() -> firePermissionDeniedForDeviceName(portRegistry, device.getDeviceName())); + } + + static void firePermissionDeniedForDeviceName(final PortRegistry registry, final String deviceName) { + for (final QGCSerialPort port : registry.portsForDeviceName(deviceName)) { + port.fireException(SerialWireConstants.EXC_PERMISSION, "USB Permission Denied"); + } + } + + @Override + public void onPortConfigured(final QGCSerialPort port) { + synchronized (serviceLock) { + if (configuredPortCount++ == 0) { + QGCForegroundService.start(appContext, USB_SERIAL_FGS_CONFIG); + } + } + } + + @Override + public void onPortClosed(final QGCSerialPort port) { + synchronized (serviceLock) { + if (configuredPortCount == 0) { + QGCLogger.w(TAG, "onPortClosed with configuredPortCount==0 (counter skew)"); + return; + } + if (--configuredPortCount == 0) { + QGCForegroundService.stop(appContext); + } + } + } + + @Override + public void onPortDeviceError(final QGCSerialPort port) { + if (port == null || usbManager == null) { + return; + } + final String deviceName = port.address().deviceName(); + // Source port already self-reports via fireException → close(); closing it again here would race a second nativeDeviceHasDisconnected against the in-flight exception lambda. + synchronized (deviceTeardownLock) { + for (final QGCSerialPort sibling : portRegistry.portsForDeviceName(deviceName)) { + if (sibling != port) { + sibling.close(QGCSerialPort.CloseReason.DEVICE_ERROR); + } + } + } + // Probe + driver-list swap is enumerator-locked; running it outside deviceTeardownLock keeps a slow enumeration from stalling concurrent detach handlers. + enumerator.replaceDriverForDeviceName(usbManager, deviceName); + } + + /** Central funnel for cold-start enumeration, USB-attach, and permission-granted callbacks. */ + private void onDeviceAvailable(final UsbDevice device) { + if (usbManager == null) { + QGCLogger.w(TAG, "USB serial manager not ready, ignoring device " + device.getDeviceName()); + return; + } + enumerator.updateCurrentDrivers(usbManager); + requestPermissionIfTracked(device); + // Nudge the C++ autoconnect to re-scan immediately rather than waiting for its next poll tick. + try { + nativeUsbDevicesChanged(); + } catch (final UnsatisfiedLinkError e) { + QGCLogger.w(TAG, "nativeUsbDevicesChanged unavailable", e); + } + } + + // Implemented in AndroidSerialPort.cc; registered against this class at JNI_OnLoad. + private native void nativeUsbDevicesChanged(); + + /** Caller must have already refreshed the tracked-driver set. */ + private void requestPermissionIfTracked(final UsbDevice device) { + if (enumerator.findDriverByDeviceName(device.getDeviceName()) == null) { + QGCLogger.d(TAG, "No driver found for device " + device.getDeviceName()); + return; + } + if (usbManager.hasPermission(device)) { + return; + } + if (permissionManager.isPermissionDenied(device.getDeviceName())) { + QGCLogger.d(TAG, "Skipping permission re-prompt for previously denied device " + + device.getDeviceName()); + return; + } + QGCLogger.i(TAG, "Requesting permission to use device " + device.getDeviceName()); + permissionManager.request(usbManager, device); + } + + private void closeAllPortsForDeviceName(final String deviceName) { + for (final QGCSerialPort port : portRegistry.portsForDeviceName(deviceName)) { + port.close(QGCSerialPort.CloseReason.STALE_DRIVER); + } + } + + private void updateCurrentDrivers() { + enumerator.updateCurrentDrivers(usbManager); + } + + private UsbPortInfo[] _availablePortsInfo() { + if (usbManager == null) { + return new UsbPortInfo[0]; + } + // Surfacing all tracked devices lets QGC see a second device hot-plugged while a link is active; duplicate-open is rejected in _openPort. + return enumerator.availablePortsInfo(); + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/SerialReadLoop.java b/android/src/org/mavlink/qgroundcontrol/serial/SerialReadLoop.java new file mode 100644 index 000000000000..8a147637f1f7 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/SerialReadLoop.java @@ -0,0 +1,209 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.mavlink.qgroundcontrol.serial.SerialWireConstants.EXC_RESOURCE; +import static org.mavlink.qgroundcontrol.serial.SerialWireConstants.EXC_UNKNOWN; +import static org.mavlink.qgroundcontrol.serial.SerialWireConstants.MAX_CHUNK_BYTES; + +import org.mavlink.qgroundcontrol.QGCLogger; + +import android.hardware.usb.UsbEndpoint; +import android.os.Process; + +import com.hoho.android.usbserial.driver.SerialTimeoutException; +import com.hoho.android.usbserial.driver.UsbSerialPort; +import com.hoho.android.usbserial.util.SerialInputOutputManager; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * Drains the RX path on hoho's {@link SerialInputOutputManager} reader thread, forwarding device bytes to C++. + * + *

Mirror of {@link SerialWriteLoop}: all state shared with the owning port (lifecycle, native handle, mute flag) is + * read under {@link ReadHost#lock()}, and the JNI emit runs outside that lock so it can never deadlock a C++ close path. + * The stop handshake parks under the same monitor until the reader reaches STOPPED, because {@code port.close()} during + * a concurrent read crashes FTDI D2XX.

+ */ +final class SerialReadLoop implements SerialInputOutputManager.Listener { + + private static final String TAG = SerialReadLoop.class.getSimpleName(); + + /** Default read buffer size handed to {@code SerialInputOutputManager}. */ + private static final int READ_BUF_SIZE = 2048; + /** Serial package-wide verbose tag; enables hot-path serial diagnostics with one setprop. */ + private static final String VERBOSE_LOGGING = "QGCSerial"; + + // Budget for stopIoManagerLocked() to see the reader reach STOPPED; exceeds the reader's blocking read window so a parked reader can wake and transition. + private static final long STOP_HANDSHAKE_TIMEOUT_NS = 500_000_000L; // 500 ms + private static final long STOP_HANDSHAKE_POLL_MS = 25L; + + /** Narrow seam onto the owning port: the loop reads lifecycle/handle/mute under {@link #lock()} and drives native emits/exceptions through it. */ + interface ReadHost extends PortMonitor { + /** The open port for this read loop, or null. */ + UsbSerialPort port(); + /** Per-driver quirks for read buffer/queue sizing. */ + DriverStrategy.Caps caps(); + /** D2XX port carries its own read-timeout constant; the host passes it through for the D2XX path. */ + int readTimeoutForIoManager(); + boolean isListenerMuted(); + /** Caller holds {@link #lock()}: clear the mute so a freshly (re)opened reader may emit again. */ + void clearListenerMuteLocked(); + /** RX forward into the facade's NativeBridge (shared by read+write+exception); called outside {@link #lock()}. */ + void emitNewDataToNative(long handle, ByteBuffer buf, int len); + /** Hot-unplug (onRunError IOException, non-timeout) → tell the lifecycle sink the device is gone (#1A). */ + void onReaderDeviceError(); + } + + private final ReadHost host; + private final Object lock; + private SerialInputOutputManager ioManager; + // Direct buffer ferrying read bytes to native; written only by the per-port read thread (onNewData), so the emit is lock-free. + private final ByteBuffer nativeDataBuffer = ByteBuffer.allocateDirect(MAX_CHUNK_BYTES); + + SerialReadLoop(final ReadHost host) { + this.host = host; + this.lock = host.lock(); + } + + /** Caller holds {@link ReadHost#lock()}. Builds and configures the reader; idempotent while one already exists. */ + boolean createIoManagerLocked() { + if (ioManager != null) { + return true; + } + final UsbSerialPort port = host.port(); + if (port == null) { + return false; + } + + host.clearListenerMuteLocked(); + ioManager = new SerialInputOutputManager(port, this); + + int readBufferSize = READ_BUF_SIZE; + final UsbEndpoint readEndpoint = port.getReadEndpoint(); + if (readEndpoint != null) { + readBufferSize = Math.max(readEndpoint.getMaxPacketSize(), READ_BUF_SIZE); + } + ioManager.setReadBufferSize(readBufferSize); + + try { + ioManager.setReadTimeout(host.readTimeoutForIoManager()); + ioManager.setReadQueue(host.caps().readQueueDepth()); + ioManager.setWriteTimeout(0); + ioManager.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO); + } catch (final IllegalStateException e) { + QGCLogger.e(TAG, "IO Manager configuration error:", e); + return false; + } + + return true; + } + + /** Caller holds {@link ReadHost#lock()}. Starts the reader thread; refuses if no IO manager exists. */ + boolean startLocked(final QGCUsbSerialManager.PortAddress address) { + if (ioManager == null) { + QGCLogger.w(TAG, "IO Manager not found for " + address); + return false; + } + if (ioManager.getState() == SerialInputOutputManager.State.RUNNING) { + return true; + } + try { + ioManager.start(); + return true; + } catch (final IllegalStateException e) { + QGCLogger.e(TAG, "IO Manager Start exception:", e); + return false; + } + } + + /** Caller holds {@link ReadHost#lock()}. Posts STOPPING, then releases the monitor via wait()/poll until the reader reaches STOPPED. */ + boolean stopIoManagerLocked(final QGCUsbSerialManager.PortAddress address) { + host.muteListenerLocked(); + if (ioManager == null) { + return false; + } + SerialInputOutputManager.State ioState = ioManager.getState(); + if (ioState != SerialInputOutputManager.State.STOPPED + && ioState != SerialInputOutputManager.State.STOPPING) { + ioManager.stop(); + } + // Block until the reader reaches STOPPED — closing the port mid-read crashes FTDI D2XX. + // wait() releases the host lock so a muted onNewData can exit and let the reader transition. + final long deadlineNs = System.nanoTime() + STOP_HANDSHAKE_TIMEOUT_NS; + while (ioManager.getState() != SerialInputOutputManager.State.STOPPED) { + final long remainingMs = (deadlineNs - System.nanoTime()) / 1_000_000L; + if (remainingMs <= 0) { + QGCLogger.w(TAG, "IO manager stop handshake timed out for " + address + + " (state=" + ioManager.getState() + ")"); + break; + } + try { + // Nothing notifies; the timeout wakes us. + lock.wait(Math.min(remainingMs, STOP_HANDSHAKE_POLL_MS)); + } catch (final InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + // Drop the reference once stopped so startLocked()'s null-check can't race a STOPPING SerialInputOutputManager being re-started. + ioManager = null; + return true; + } + + @Override + public void onRunError(final Exception e) { + if (host.isListenerMuted()) return; + // Runtime IOException is the hot-unplug path; a SerialTimeoutException is a still-valid stall (not unplug), excluded so a transient timeout doesn't replace the driver via onPortDeviceError. + final boolean isUnplug = (e instanceof IOException) && !(e instanceof SerialTimeoutException); + if (isUnplug) { + QGCLogger.w(TAG, "Runner stopped (device disconnected): " + e.getMessage()); + } else { + QGCLogger.e(TAG, "Runner stopped.", e); + } + final int kind = isUnplug ? EXC_RESOURCE : EXC_UNKNOWN; + host.fireException(kind, "Runner stopped: " + e.getMessage()); + if (isUnplug) { + host.onReaderDeviceError(); + } + } + + @Override + public void onNewData(final byte[] data) { + if (data == null || data.length == 0) { + QGCLogger.w(TAG, "Invalid data received"); + return; + } + + QGCLogger.v(TAG, VERBOSE_LOGGING, () -> "onNewData n=" + data.length + + " first=0x" + String.format("%02x", data[0] & 0xff)); + + // Read the mute flag and snapshot nativeHandle under the host lock so closeLocked can't zero it + // between guard and JNI call (#1B); holding the lock across emitNewData would deadlock C++ close paths. + final long handle; + synchronized (lock) { + if (host.isListenerMuted()) return; + handle = host.nativeHandleLocked(); + if (handle == 0L) return; + } + if (data.length <= MAX_CHUNK_BYTES) { + emitNewData(handle, data, 0, data.length); + return; + } + + QGCLogger.w(TAG, "Large USB payload (" + data.length + " bytes), chunking before JNI callback"); + int offset = 0; + while (offset < data.length) { + final int chunkLen = Math.min(MAX_CHUNK_BYTES, data.length - offset); + emitNewData(handle, data, offset, chunkLen); + offset += chunkLen; + } + } + + private void emitNewData(final long nativeToken, final byte[] data, final int offset, final int length) { + // No lock: nativeDataBuffer is touched only by the single per-port read thread (onNewData). + nativeDataBuffer.clear(); + nativeDataBuffer.put(data, offset, length); + nativeDataBuffer.flip(); + host.emitNewDataToNative(nativeToken, nativeDataBuffer, length); + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/SerialWireConstants.java b/android/src/org/mavlink/qgroundcontrol/serial/SerialWireConstants.java new file mode 100644 index 000000000000..6e1b4c4b50b8 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/SerialWireConstants.java @@ -0,0 +1,50 @@ +package org.mavlink.qgroundcontrol.serial; + +import com.hoho.android.usbserial.driver.UsbSerialPort; + +/** Java half of the Android serial JNI wire contract; keep in sync with SerialWireConstants.h. */ +final class SerialWireConstants { + + private SerialWireConstants() {} + + /** Max per-chunk size shared by the JNI read callback and Java write path. */ + static final int MAX_CHUNK_BYTES = 16384; + + /** Sentinel device-id returned on any open/setup failure. */ + static final int BAD_DEVICE_ID = 0; + + // Exception-kind ordinals delivered through nativeDeviceException. + static final int EXC_UNKNOWN = 0; // Unclassified failure + static final int EXC_RESOURCE = 1; // IOException at runtime — hot-unplug + static final int EXC_PERMISSION = 2; // USB permission denied + static final int EXC_OPEN_FAILED = 3; // Open-path failure (driver / port / connection) + + // JSON key names for the USB-port enumeration blob; twin of AndroidSerialWire::JsonKeys in + // SerialWireConstants.h. The shared golden fixture (test/Comms/Serial/data/PortInfoGolden.json) pins + // these literals across both languages. + static final String KEY_PORTS = "ports"; + static final String KEY_DEVICE_NAME = "deviceName"; + static final String KEY_PRODUCT_NAME = "productName"; + static final String KEY_MANUFACTURER_NAME = "manufacturerName"; + static final String KEY_SERIAL_NUMBER = "serialNumber"; + static final String KEY_PRODUCT_ID = "productId"; + static final String KEY_VENDOR_ID = "vendorId"; + static final String KEY_BAUD_RATES = "baudRates"; + + // Flow-control wire ordinals; must match mik3y UsbSerialPort.FlowControl ordinals (asserted below). + static final int FC_NONE = 0; + static final int FC_RTS_CTS = 1; + static final int FC_DTR_DSR = 2; + static final int FC_XON_XOFF = 3; + static final int FC_XON_XOFF_INLINE = 4; + + /** Fails fast if mik3y's external FlowControl enum is ever reordered out from under our wire ordinals. */ + static boolean verifyExternalFlowControlOrdinals() { + return true + && FC_NONE == UsbSerialPort.FlowControl.NONE.ordinal() + && FC_RTS_CTS == UsbSerialPort.FlowControl.RTS_CTS.ordinal() + && FC_DTR_DSR == UsbSerialPort.FlowControl.DTR_DSR.ordinal() + && FC_XON_XOFF == UsbSerialPort.FlowControl.XON_XOFF.ordinal() + && FC_XON_XOFF_INLINE == UsbSerialPort.FlowControl.XON_XOFF_INLINE.ordinal(); + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/SerialWriteLoop.java b/android/src/org/mavlink/qgroundcontrol/serial/SerialWriteLoop.java new file mode 100644 index 000000000000..f06f3f29e166 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/SerialWriteLoop.java @@ -0,0 +1,256 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.mavlink.qgroundcontrol.serial.SerialWireConstants.EXC_RESOURCE; + +import org.mavlink.qgroundcontrol.QGCLogger; + +import com.hoho.android.usbserial.driver.SerialTimeoutException; +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * Drains queued writes on a dedicated thread, acking each device-sized sub-write to C++ for backpressure accounting. + * + *

Synchronous {@code port.write()} (not {@code ioManager.writeAsync}) lets every accepted byte be acked via + * {@link WriteHost#ackBytesWritten}. All state shared with the owning port (lifecycle, baud, native handle) is read + * under {@link WriteHost#lock()} so critical sections stay identical to the in-port original.

+ */ +final class SerialWriteLoop { + + private static final String TAG = SerialWriteLoop.class.getSimpleName(); + + private static final byte[] WRITER_STOP = new byte[0]; + private static final int WRITER_WRITE_TIMEOUT_MS = 5000; + private static final int WRITER_WRITE_MAX_TIMEOUT_RETRIES = 2; + // Short so teardown never outlasts the C++ DISCONNECT_TIMEOUT (3s); a wedged writer is unblocked by the close right after this join, not by waiting it out. + private static final long WRITER_JOIN_TIMEOUT_MS = 1000L; + private static final long ENQUEUE_OFFER_TIMEOUT_NS = 2_000_000_000L; // 2s < C++ DISCONNECT_TIMEOUT (3s) + static final int WRITE_QUEUE_CAPACITY = 64; + + /** Narrow seam onto the owning port: the loop reads lifecycle/handle/caps under {@link #lock()} and drives native acks/exceptions through it. */ + interface WriteHost extends PortMonitor { + /** Caller holds {@link #lock()}: true while the port may still accept writes (CONFIGURED). */ + boolean isWritableLocked(); + /** Caller holds {@link #lock()}: device-sized sub-write length for the current baud. */ + int writeChunkSizeLocked(); + /** Caller holds {@link #lock()}: the open port, or null (warns) if unavailable. */ + UsbSerialPort openPortOrWarnLocked(String operation); + void ackBytesWritten(long handle, int n); + void cancelPendingWritesUnlocked(); + } + + private final WriteHost host; + private final Object lock; + private final LinkedBlockingQueue writeQueue = new LinkedBlockingQueue<>(WRITE_QUEUE_CAPACITY); + private volatile Thread writerThread; + /** Writers that overran their join timeout; retained so a reopen fails fast while any is still alive. */ + private final List leakedWriterThreads = new ArrayList<>(); + private volatile boolean writerRunning; + + SerialWriteLoop(final WriteHost host) { + this.host = host; + this.lock = host.lock(); + } + + boolean isRunning() { + return writerRunning; + } + + /** Test-only: joins the active and any leaked writer threads so assertions never race a winding-down writer. */ + boolean awaitDrainedForTest(final long timeoutMs) throws InterruptedException { + final List pending = new ArrayList<>(); + synchronized (lock) { + if (writerThread != null) { + pending.add(writerThread); + } + pending.addAll(leakedWriterThreads); + } + boolean drained = true; + for (final Thread t : pending) { + t.join(timeoutMs); + if (t.isAlive()) { + drained = false; + } + } + return drained; + } + + /** Caller holds {@link WriteHost#lock()}. Starts the writer thread; refuses if a prior writer is still stuck. */ + boolean startLocked(final String address) { + if (writerThread != null && writerThread.isAlive()) { + return true; + } + // A writer that self-exited (e.g. fireException) leaves the field non-null but dead; clear it so a fresh thread starts instead of silently dropping TX. + writerThread = null; + if (!leakedWriterThreads.isEmpty()) { + for (final Thread leaked : leakedWriterThreads) { + if (leaked.isAlive()) { + QGCLogger.e(TAG, "Cannot start writer for " + address + + "; a prior writer is still stuck (leaked). Refusing to claim TX is live."); + return false; + } + } + QGCLogger.i(TAG, "All previously leaked writers for " + address + " have exited; clearing records"); + leakedWriterThreads.clear(); + } + writeQueue.clear(); + writerRunning = true; + final Thread t = new Thread(() -> runWriteLoop(address), "QGCSerialWriter"); + t.setDaemon(true); + writerThread = t; + t.start(); + return true; + } + + /** Stops the writer thread and joins it; caller must NOT hold {@link WriteHost#lock()}. */ + void stopUnlocked(final String address) { + final Thread t = writerThread; + if (t == null) { + return; + } + synchronized (lock) { + writerRunning = false; + host.muteListenerLocked(); + writeQueue.clear(); + writeQueue.offer(WRITER_STOP); + } + host.cancelPendingWritesUnlocked(); + try { + t.join(WRITER_JOIN_TIMEOUT_MS); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + synchronized (lock) { + if (t.isAlive()) { + QGCLogger.e(TAG, "Writer thread for " + address + + " did not exit within " + WRITER_JOIN_TIMEOUT_MS + "ms; leaking reference"); + // Park the stuck thread off the active field so a reopen can try a fresh writer; startLocked() re-checks every parked writer and fails loudly if any is still hung. + leakedWriterThreads.add(t); + writerThread = null; + } else { + writerThread = null; + } + } + } + + /** Non-blocking enqueue from the C++ owner thread; copies into a fresh array for the writer thread and returns bytes accepted. */ + int enqueue(final ByteBuffer data, final int length, final String address) { + if (data == null || length <= 0) { + QGCLogger.w(TAG, "Invalid write request for " + address); + return -1; + } + // Unique per call: writeQueue.remove(chunk) on the close path relies on this array identity. + final byte[] chunk = new byte[length]; + data.position(0); + data.get(chunk, 0, length); + + // Bounded blocking enqueue: a full queue is backpressure, not fatal — block briefly so the writer drains rather than returning the link-fatal sentinel; only a non-writable lifecycle returns -1. + synchronized (lock) { + if (!host.isWritableLocked() || !writerRunning) { + QGCLogger.w(TAG, "enqueueWrite rejected in non-writable state for " + address); + return -1; + } + } + try { + // Single bounded offer outside the lock (the writer needs the lock to advance subwrites); stopUnlocked always clear()s on teardown, so a full queue drains within the timeout if the port is still live. + if (!writeQueue.offer(chunk, ENQUEUE_OFFER_TIMEOUT_NS, TimeUnit.NANOSECONDS)) { + QGCLogger.w(TAG, "Write queue saturated past timeout for " + address); + return -1; + } + // Re-check under lock: if stopUnlocked ran clear()+offer(WRITER_STOP) after our offer, remove our payload so it can't drain after WRITER_STOP and return the failed-enqueue sentinel. + synchronized (lock) { + if (!host.isWritableLocked() || !writerRunning) { + writeQueue.remove(chunk); + return -1; + } + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return -1; + } + return length; + } + + private void runWriteLoop(final String address) { + while (writerRunning) { + final byte[] chunk; + try { + chunk = writeQueue.take(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + if (chunk == WRITER_STOP || !writerRunning) { + break; + } + if (!writeChunkWithAcks(chunk, address)) { + host.fireException(EXC_RESOURCE, "Write failed for " + address); + break; + } + } + } + + /** Splits a queued chunk into device-sized sub-writes, acking each to C++ so accounting never desyncs on a partial write. + * Returns false only on a genuine sub-write failure; a concurrent close returns true (the close path zeroes the count). */ + private boolean writeChunkWithAcks(final byte[] chunk, final String address) { + final int length = chunk.length; + int offset = 0; + int timeoutRetries = 0; + while (offset < length) { + final int base = offset; + final long handle; + int written; + final int subLen; + final byte[] subBuf; + final UsbSerialPort p; + synchronized (lock) { + if (!host.isWritableLocked() || !writerRunning) { + return true; + } + handle = host.nativeHandleLocked(); + subLen = Math.min(host.writeChunkSizeLocked(), length - base); + p = host.openPortOrWarnLocked("write"); + if (p == null) { + return false; + } + // Copy the exact sub-range (mik3y writes from index 0): never mutate chunk in place — a partial write advances base, so an in-place slide would clobber bytes a later sub-write still needs. + subBuf = (base == 0 && subLen == length) ? chunk : Arrays.copyOfRange(chunk, base, base + subLen); + } + // Blocking I/O runs outside the lock: holding it across a wedged write starves teardown (all paths need the lock) and made cancelPendingWrites unreachable, crashing the worker on unplug. Concurrent close is safe — CDC returns on a closed connection, FTDI is interrupted by cancelPendingWrites. + try { + p.write(subBuf, subLen, WRITER_WRITE_TIMEOUT_MS); + written = subLen; + } catch (final SerialTimeoutException e) { + // Transient stall (e.g. XOFF), thrown distinct from a lost-connection IOException so we don't tear the link down. + // Ack exactly bytesTransferred (never subLen) and advance, so the retry re-sends only the remainder and C++ inFlightBytes never desyncs. + written = Math.max(0, e.bytesTransferred); + host.ackBytesWritten(handle, written); + offset += written; + if (++timeoutRetries > WRITER_WRITE_MAX_TIMEOUT_RETRIES) { + QGCLogger.w(TAG, "Write stalled past " + WRITER_WRITE_MAX_TIMEOUT_RETRIES + + " timeouts on " + address + "; treating as failure"); + return false; + } + continue; + } catch (final IOException e) { + // Lost-connection failure (incl. D2XX short-write): accepted bytes are unrecoverable, but the close after fireException zeroes C++ inFlightBytes so accounting reconciles. + QGCLogger.e(TAG, "Write failed on " + address, e); + return false; + } + if (written <= 0) { + return false; + } + timeoutRetries = 0; + host.ackBytesWritten(handle, written); + offset += written; + } + return true; + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/UsbAttachDetachReceiver.java b/android/src/org/mavlink/qgroundcontrol/serial/UsbAttachDetachReceiver.java new file mode 100644 index 000000000000..94b067e3aab5 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/UsbAttachDetachReceiver.java @@ -0,0 +1,75 @@ +package org.mavlink.qgroundcontrol.serial; + +import org.mavlink.qgroundcontrol.QGCLogger; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import androidx.core.content.ContextCompat; +import androidx.core.content.IntentCompat; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** Owns {@code ACTION_USB_DEVICE_ATTACHED}/{@code DETACHED}, kept separate from the permission receiver in {@link QGCUsbSerialManager} so the permission and device-presence flows have independent receivers. */ +final class UsbAttachDetachReceiver { + + private static final String TAG = UsbAttachDetachReceiver.class.getSimpleName(); + + private final Consumer onAttached; + private final Consumer onDetached; + private final AtomicBoolean registered = new AtomicBoolean(false); + private Context appContext; + + private final BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + final String action = intent.getAction(); + if (action == null) return; + final UsbDevice device = IntentCompat.getParcelableExtra( + intent, UsbManager.EXTRA_DEVICE, UsbDevice.class); + if (device == null) return; + if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) { + onAttached.accept(device); + } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { + onDetached.accept(device); + } + } + }; + + UsbAttachDetachReceiver(final Consumer onAttached, final Consumer onDetached) { + this.onAttached = onAttached; + this.onDetached = onDetached; + } + + void register(final Context context) { + if (!registered.compareAndSet(false, true)) return; + appContext = context.getApplicationContext(); + final IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + try { + // System-delivered protected broadcasts; NOT_EXPORTED stops other apps forging a USB attach/detach to DoS the link. ContextCompat maps this to no flag pre-API 33. + ContextCompat.registerReceiver(appContext, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED); + QGCLogger.i(TAG, "Attach/detach receiver registered."); + } catch (final Exception e) { + registered.set(false); + appContext = null; + QGCLogger.e(TAG, "Failed to register attach/detach receiver", e); + } + } + + void unregister() { + if (!registered.compareAndSet(true, false)) return; + try { + if (appContext != null) appContext.unregisterReceiver(receiver); + } catch (final IllegalArgumentException e) { + QGCLogger.w(TAG, "Receiver not registered: " + e.getMessage()); + } finally { + appContext = null; + } + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/UsbPermissionManager.java b/android/src/org/mavlink/qgroundcontrol/serial/UsbPermissionManager.java new file mode 100644 index 000000000000..bc0654de11da --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/UsbPermissionManager.java @@ -0,0 +1,207 @@ +package org.mavlink.qgroundcontrol.serial; + +import org.mavlink.qgroundcontrol.QGCLogger; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; + +import androidx.annotation.MainThread; +import androidx.core.content.ContextCompat; +import androidx.core.content.IntentCompat; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** Owns the USB permission PendingIntent, receiver, and per-device permission state. */ +final class UsbPermissionManager { + + private static final String TAG = UsbPermissionManager.class.getSimpleName(); + + private static final String ACTION_USB_PERMISSION = + "org.mavlink.qgroundcontrol.action.USB_PERMISSION"; + + enum PermissionStatus { PENDING, GRANTED, DENIED } + + private final Consumer onGranted; + private final Consumer onDenied; + + private final Map permissionStatus = new ConcurrentHashMap<>(); + /** Guards {@link #usbPermissionIntent} and the registered flag. */ + private final Object permissionLock = new Object(); + private volatile boolean permissionRegistered; + private PendingIntent usbPermissionIntent; + + private record PermissionRequest(UsbDevice device, UsbManager manager) {} + /** Pending requests keyed by device name so concurrent attaches don't clobber each other; the malformed-broadcast fallback resolves only when exactly one request is pending. */ + private final Map pendingRequests = new ConcurrentHashMap<>(); + + private Context appContext; + + private final BroadcastReceiver usbPermissionReceiver = new BroadcastReceiver() { + @Override + @MainThread + public void onReceive(final Context context, final Intent intent) { + final String action = intent.getAction(); + QGCLogger.i(TAG, "BroadcastReceiver USB action " + action); + if (action == null) { + return; + } + if (ACTION_USB_PERMISSION.equals(action)) { + handleUsbPermissionResult(intent); + } + } + }; + + UsbPermissionManager(final Consumer onGranted, final Consumer onDenied) { + this.onGranted = onGranted; + this.onDenied = onDenied; + } + + /** Builds the {@link PendingIntent} and registers the USB permission receiver; safe to call repeatedly (no-op if already registered). */ + void register(final Context context) { + synchronized (permissionLock) { + if (permissionRegistered) { + return; + } + + final Context ctx = context.getApplicationContext(); + appContext = ctx; + final Intent permissionIntent = new Intent(ACTION_USB_PERMISSION).setPackage(ctx.getPackageName()); + // API 31+: immutable is safe — framework populates EXTRA_DEVICE / EXTRA_PERMISSION_GRANTED via the delivery mechanism, not by mutating the intent. + final int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; + usbPermissionIntent = PendingIntent.getBroadcast(ctx, 0, permissionIntent, flags); + + final IntentFilter filter = new IntentFilter(ACTION_USB_PERMISSION); + + try { + ContextCompat.registerReceiver( + ctx, + usbPermissionReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED); + permissionRegistered = true; + QGCLogger.i(TAG, "USB permission BroadcastReceiver registered successfully."); + } catch (final Exception e) { + usbPermissionIntent = null; + appContext = null; + QGCLogger.e(TAG, "Failed to register USB permission BroadcastReceiver", e); + } + } + } + + /** Unregisters the USB permission receiver and releases the {@link PendingIntent}; safe to call when not registered. */ + void unregister() { + synchronized (permissionLock) { + if (!permissionRegistered) { + return; + } + try { + if (appContext != null) appContext.unregisterReceiver(usbPermissionReceiver); + QGCLogger.i(TAG, "USB permission BroadcastReceiver unregistered successfully."); + } catch (final IllegalArgumentException e) { + QGCLogger.w(TAG, "Permission receiver not registered: " + e.getMessage()); + } finally { + // Flip registered=false first so an in-flight handleUsbPermissionResult bails at its registered-gate before reading the maps we clear below. + permissionRegistered = false; + usbPermissionIntent = null; + pendingRequests.clear(); + permissionStatus.clear(); + appContext = null; + } + } + } + + /** Requests USB permission for {@code device}; callers should check {@link UsbManager#hasPermission} first. */ + void request(final UsbManager mgr, final UsbDevice device) { + final PendingIntent intent; + synchronized (permissionLock) { + intent = usbPermissionIntent; + } + if (intent == null) { + QGCLogger.e(TAG, "requestUsbPermission called before registerPermissionReceiver()"); + return; + } + pendingRequests.put(device.getDeviceName(), new PermissionRequest(device, mgr)); + permissionStatus.put(device.getDeviceName(), PermissionStatus.PENDING); + mgr.requestPermission(device, intent); + } + + /** Returns true if the user previously denied permission for {@code deviceName} this attach cycle. */ + boolean isPermissionDenied(final String deviceName) { + return permissionStatus.get(deviceName) == PermissionStatus.DENIED; + } + + /** + * Drops cached permission status for {@code deviceName} on detach so re-attach can re-prompt. Deliberately leaves + * {@link #pendingRequests} untouched: detach runs on the USB worker while the permission result lands on the main + * looper with no shared lock, so a transient detach (hub blip / OTG jiggle) racing the dialog must not drop the + * in-flight request — else the malformed-broadcast fallback loses the grant when the user taps Allow. A stale entry + * left behind by a detach is tolerated by {@link #resolveMalformedTarget}; it self-clears via + * {@link #handleUsbPermissionResult}, a same-key re-request, or {@link #unregister}. + */ + void clearPermission(final String deviceName) { + permissionStatus.remove(deviceName); + } + + /** + * Picks the target for a malformed (no-EXTRA_DEVICE) broadcast: the sole outstanding request, or — when several are + * pending because a stale entry survived a detach ({@link #clearPermission} keeps {@link #pendingRequests}) — the + * unique device the framework just flipped to {@code hasPermission()==true}. Returns null when the choice is + * genuinely ambiguous (zero pending, or more than one currently granted). + */ + private PermissionRequest resolveMalformedTarget() { + if (pendingRequests.size() == 1) { + return pendingRequests.values().iterator().next(); + } + PermissionRequest granted = null; + for (final PermissionRequest req : pendingRequests.values()) { + if (req.manager().hasPermission(req.device())) { + if (granted != null) { + return null; + } + granted = req; + } + } + return granted; + } + + private void handleUsbPermissionResult(final Intent intent) { + // Ignore broadcasts arriving after unregister() (teardown) — they'd resurrect a request. + if (!permissionRegistered) { + QGCLogger.w(TAG, "USB permission result after unregister; ignoring"); + return; + } + final boolean granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false); + UsbDevice device = IntentCompat.getParcelableExtra(intent, UsbManager.EXTRA_DEVICE, UsbDevice.class); + boolean actuallyGranted = granted; + if (device == null) { + // Android-15 OEM quirk: ACTION_USB_PERMISSION delivered with no EXTRA_DEVICE and granted=false regardless of choice. + final PermissionRequest pending = resolveMalformedTarget(); + if (pending == null) { + QGCLogger.w(TAG, "ACTION_USB_PERMISSION malformed with " + pendingRequests.size() + + " pending requests; cannot resolve target device"); + return; + } + device = pending.device(); + actuallyGranted = pending.manager().hasPermission(device); + QGCLogger.w(TAG, "ACTION_USB_PERMISSION malformed; resolved " + device.getDeviceName() + + " via hasPermission()=" + actuallyGranted); + } + pendingRequests.remove(device.getDeviceName()); + if (actuallyGranted) { + permissionStatus.put(device.getDeviceName(), PermissionStatus.GRANTED); + QGCLogger.i(TAG, "Permission granted to " + device.getDeviceName()); + onGranted.accept(device); + } else { + permissionStatus.put(device.getDeviceName(), PermissionStatus.DENIED); + QGCLogger.i(TAG, "Permission denied for " + device.getDeviceName()); + onDenied.accept(device); + } + } +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/UsbPortInfo.java b/android/src/org/mavlink/qgroundcontrol/serial/UsbPortInfo.java new file mode 100644 index 000000000000..6b64741f1e3d --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/UsbPortInfo.java @@ -0,0 +1,15 @@ +package org.mavlink.qgroundcontrol.serial; + +import org.qtproject.qt.android.UsedFromNativeCode; + +/** Structured device-info record for one USB serial port; {@link QGCUsbSerialManager#packPortsInfo} serialises an array of these into the JSON wire format that C++ {@code SerialPortInfoCodec} decodes (no per-field JNI getField). */ +@UsedFromNativeCode +public record UsbPortInfo( + String deviceName, + String productName, + String manufacturerName, + String serialNumber, + int productId, + int vendorId, + int[] supportedBaudRates) { +} diff --git a/android/src/org/mavlink/qgroundcontrol/serial/UsbSerialEnumerator.java b/android/src/org/mavlink/qgroundcontrol/serial/UsbSerialEnumerator.java new file mode 100644 index 000000000000..dd0690c6ba54 --- /dev/null +++ b/android/src/org/mavlink/qgroundcontrol/serial/UsbSerialEnumerator.java @@ -0,0 +1,266 @@ +package org.mavlink.qgroundcontrol.serial; + +import org.mavlink.qgroundcontrol.QGCLogger; + +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; + +import androidx.annotation.AnyThread; +import androidx.annotation.GuardedBy; + +import com.hoho.android.usbserial.driver.ProlificSerialDriver; +import com.hoho.android.usbserial.driver.UsbSerialDriver; +import com.hoho.android.usbserial.driver.UsbSerialPort; +import com.hoho.android.usbserial.driver.UsbSerialProber; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Single source of truth for which serial drivers are currently attached; owns the tracked-driver list and all device-name / port-index parsing. + * + *

All driver-list mutations go through {@link #lock} so compound operations (probe → removeStale → addNew) are atomic. {@link StaleDriverCallback} + * is invoked outside the lock (it releases manager-side registry entries) to prevent re-entrancy deadlocks.

+ */ +final class UsbSerialEnumerator { + + private static final String TAG = UsbSerialEnumerator.class.getSimpleName(); + static final String PORT_SUFFIX = "#p"; + + interface StaleDriverCallback { + void onPhysicalDeviceRemoved(String deviceName); + } + + private final UsbSerialProber prober; + private volatile StaleDriverCallback staleCallback; + @GuardedBy("lock") + private final List drivers = new ArrayList<>(); + private final Object lock = new Object(); + + UsbSerialEnumerator(final UsbSerialProber prober) { + this.prober = prober; + } + + /** Bound post-construction to break the enumerator↔lifecycle ctor cycle; must be called before any {@code removeStale}. */ + void setStaleDriverCallback(final StaleDriverCallback callback) { + this.staleCallback = callback; + } + + void clear() { + synchronized (lock) { + drivers.clear(); + } + } + + UsbSerialDriver findDriverByDeviceName(final String deviceName) { + synchronized (lock) { + for (final UsbSerialDriver driver : drivers) { + if (driver.getDevice().getDeviceName().equals(deviceName)) { + return driver; + } + } + return null; + } + } + + /** Returns the freshly-probed driver list without mutating the tracked set. */ + private List probeAll(final UsbManager usbManager) { + if (prober == null || usbManager == null) { + return new ArrayList<>(); + } + return prober.findAllDrivers(usbManager); + } + + /** Caller must hold {@link #lock}. Returns true if the driver was added. */ + private boolean addDriverLocked(final UsbSerialDriver newDriver) { + final UsbDevice device = newDriver.getDevice(); + final String deviceName = device.getDeviceName(); + for (final UsbSerialDriver d : drivers) { + if (d.getDevice().getDeviceName().equals(deviceName)) { + QGCLogger.d(TAG, "Driver already tracked for device " + deviceName); + return false; + } + } + drivers.add(newDriver); + QGCLogger.i(TAG, "Adding new driver for device " + deviceName + + String.format(" vid=0x%04x pid=0x%04x", device.getVendorId(), device.getProductId())); + return true; + } + + /** Caller must hold {@link #lock}. Returns the stale device names to notify. */ + private List removeStaleDriversLocked(final List currentDrivers) { + final List staleDeviceNames = new ArrayList<>(); + drivers.removeIf(existingDriver -> { + final String existingDeviceName = existingDriver.getDevice().getDeviceName(); + final boolean found = currentDrivers.stream() + .anyMatch(d -> d.getDevice().getDeviceName().equals(existingDeviceName)); + if (!found) { + staleDeviceNames.add(existingDeviceName); + QGCLogger.i(TAG, "Removed stale driver for device " + existingDeviceName); + return true; + } + return false; + }); + return staleDeviceNames; + } + + private void invokeStaleCallbacks(final List staleDeviceNames) { + final StaleDriverCallback cb = staleCallback; + if (cb == null) return; + for (final String deviceName : staleDeviceNames) { + cb.onPhysicalDeviceRemoved(deviceName); + } + } + + /** Caller must hold {@link #lock}. */ + private void addNewDriversLocked(final List currentDrivers) { + for (final UsbSerialDriver newDriver : currentDrivers) { + addDriverLocked(newDriver); + } + } + + void updateCurrentDrivers(final UsbManager usbManager) { + if (prober == null || usbManager == null) { + QGCLogger.w(TAG, "USB serial enumerator not ready, skipping driver refresh"); + return; + } + final List currentDrivers = probeAll(usbManager); + // Single lock acquisition makes remove-stale + add-new atomic; callbacks outside lock to prevent re-entrancy. + final List staleDeviceNames; + synchronized (lock) { + staleDeviceNames = removeStaleDriversLocked(currentDrivers); + if (!currentDrivers.isEmpty()) { + addNewDriversLocked(currentDrivers); + } + } + invokeStaleCallbacks(staleDeviceNames); + } + + void replaceDriverForDeviceName(final UsbManager usbManager, final String deviceName) { + if (prober == null || usbManager == null || deviceName == null || deviceName.isEmpty()) { + return; + } + // Drop the stale entry FIRST so a concurrent findDriverByDeviceName() during the unlocked probe returns null (the truthful "being replaced" state) rather than a torn-down driver. + // Holding lock across probeAll() would stall every other caller for the probe's duration — the reason probeAll runs unlocked. + synchronized (lock) { + drivers.removeIf(driver -> driver.getDevice().getDeviceName().equals(deviceName)); + } + final List currentDrivers = probeAll(usbManager); + synchronized (lock) { + for (final UsbSerialDriver driver : currentDrivers) { + if (driver.getDevice().getDeviceName().equals(deviceName)) { + addDriverLocked(driver); + return; + } + } + } + } + + @AnyThread + UsbPortInfo[] availablePortsInfo() { + // Snapshot under lock; UsbDevice introspection (productName, serialNumber) can throw SecurityException and must not run with the lock held. + final List snapshot; + synchronized (lock) { + if (drivers.isEmpty()) { + return new UsbPortInfo[0]; + } + snapshot = new ArrayList<>(drivers); + } + + final List portInfoList = new ArrayList<>(); + for (final UsbSerialDriver driver : snapshot) { + final List ports = driver.getPorts(); + if (ports == null || ports.isEmpty()) { + continue; + } + final UsbDevice device = driver.getDevice(); + + // Device introspection can throw SecurityException for permission-less devices (e.g. Siyi UNIRC7 video); read it ONCE up front so a throw skips only this driver, not the rest of a multi-port device. + final String productName; + final String manufacturerName; + final String serialNumber; + final int productId; + final int vendorId; + final boolean prolificLegacyBaudLimited; + try { + productName = Objects.toString(device.getProductName(), ""); + manufacturerName = Objects.toString(device.getManufacturerName(), ""); + serialNumber = Objects.toString(device.getSerialNumber(), ""); + productId = device.getProductId(); + vendorId = device.getVendorId(); + prolificLegacyBaudLimited = driver instanceof ProlificSerialDriver + && DriverStrategy.isProlificLegacyBaudLimitedDeviceClass( + device.getDeviceClass(), device.getDeviceSubclass()); + } catch (final SecurityException e) { + continue; + } + + final int portCount = ports.size(); + final DriverStrategy.Kind kind = DriverStrategy.of(driver); + for (final UsbSerialPort port : ports) { + final int portIndex = port.getPortNumber(); + portInfoList.add(new UsbPortInfo( + buildPortDeviceName(device, portIndex, portCount), + productName, + manufacturerName, + serialNumber, + productId, + vendorId, + DriverStrategy.supportedBaudRates(kind, prolificLegacyBaudLimited))); + } + } + return portInfoList.toArray(new UsbPortInfo[0]); + } + + /** Immutable (baseDeviceName, portIndex) pair. Construct via {@link #of} so portIndex is clamped ≥ 0. */ + record DevicePortSpec(String baseDeviceName, int portIndex) { + static DevicePortSpec of(final String baseDeviceName, final int portIndex) { + return new DevicePortSpec(baseDeviceName, Math.max(0, portIndex)); + } + } + + static DevicePortSpec parseDevicePortSpec(final String deviceName) { + if (deviceName == null || deviceName.isEmpty()) { + return DevicePortSpec.of("", 0); + } + final int split = deviceName.lastIndexOf(PORT_SUFFIX); + if (split <= 0) { + return DevicePortSpec.of(deviceName, 0); + } + final String baseName = deviceName.substring(0, split); + final String suffix = deviceName.substring(split + PORT_SUFFIX.length()); + try { + return DevicePortSpec.of(baseName, Integer.parseInt(suffix)); + } catch (final NumberFormatException e) { + QGCLogger.w(TAG, "Invalid device port suffix in " + deviceName + ", defaulting to port 0"); + return DevicePortSpec.of(deviceName, 0); + } + } + + static String buildPortDeviceName(final UsbDevice device, final int portIndex, final int portCount) { + final String baseName = device.getDeviceName(); + if (portCount <= 1 && portIndex == 0) { + return baseName; + } + return baseName + PORT_SUFFIX + portIndex; + } + + static UsbSerialPort getPortFromDriver(final UsbSerialDriver driver, final int portIndex) { + if (driver == null) { + return null; + } + final List ports = driver.getPorts(); + if (ports == null || ports.isEmpty()) { + return null; + } + for (final UsbSerialPort port : ports) { + if (port.getPortNumber() == portIndex) { + return port; + } + } + QGCLogger.w(TAG, "No port with index " + portIndex + " on driver " + driver.getClass().getSimpleName()); + return null; + } + +} diff --git a/android/src/test/java/android/hardware/usb/FakeUsbDevice.java b/android/src/test/java/android/hardware/usb/FakeUsbDevice.java new file mode 100644 index 000000000000..9d831e7e0188 --- /dev/null +++ b/android/src/test/java/android/hardware/usb/FakeUsbDevice.java @@ -0,0 +1,27 @@ +package android.hardware.usb; + +public final class FakeUsbDevice extends UsbDevice { + + private final int interfaceCount; + private final UsbInterface[] interfaces; + + public FakeUsbDevice(final int interfaceCount) { + this.interfaceCount = interfaceCount; + this.interfaces = new UsbInterface[Math.max(interfaceCount, 0)]; + } + + @Override + public int getInterfaceCount() { + return interfaceCount; + } + + @Override + public UsbInterface getInterface(final int index) { + return interfaces[index]; + } + + @Override + public int getDeviceId() { + return 0; + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/QGCActivityTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/QGCActivityTest.java index 6f52c11f2189..ee8857534e2b 100644 --- a/android/src/test/java/org/mavlink/qgroundcontrol/QGCActivityTest.java +++ b/android/src/test/java/org/mavlink/qgroundcontrol/QGCActivityTest.java @@ -11,8 +11,6 @@ import java.lang.reflect.Modifier; import java.nio.file.Files; -import org.junit.After; -import org.junit.Before; import org.junit.Test; /** diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/QGCUsbSerialManagerTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/QGCUsbSerialManagerTest.java deleted file mode 100644 index e99de6b827fe..000000000000 --- a/android/src/test/java/org/mavlink/qgroundcontrol/QGCUsbSerialManagerTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.mavlink.qgroundcontrol; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -public class QGCUsbSerialManagerTest { - - @Before - public void setUp() { - QGCUsbSerialManager.resetResourceMappingsForTesting(); - } - - @After - public void tearDown() { - QGCUsbSerialManager.resetResourceMappingsForTesting(); - } - - @Test - public void parseDevicePortSpec_withoutSuffix_defaultsToPortZero() { - final String deviceName = "/dev/bus/usb/001/002"; - assertEquals(deviceName, QGCUsbSerialManager.getBaseDeviceNameForTesting(deviceName)); - assertEquals(0, QGCUsbSerialManager.getPortIndexForTesting(deviceName)); - } - - @Test - public void parseDevicePortSpec_withSuffix_extractsPortIndex() { - assertEquals("/dev/bus/usb/001/002", QGCUsbSerialManager.getBaseDeviceNameForTesting("/dev/bus/usb/001/002#p3")); - assertEquals(3, QGCUsbSerialManager.getPortIndexForTesting("/dev/bus/usb/001/002#p3")); - } - - @Test - public void parseDevicePortSpec_invalidSuffix_fallsBackToOriginalNameAndPortZero() { - final String malformed = "/dev/bus/usb/001/002#pabc"; - assertEquals(malformed, QGCUsbSerialManager.getBaseDeviceNameForTesting(malformed)); - assertEquals(0, QGCUsbSerialManager.getPortIndexForTesting(malformed)); - } - - @Test - public void resourceIdMapping_reusesSameAddressAndSeparatesPorts() { - final int first = QGCUsbSerialManager.getOrCreateResourceIdForTesting(42, 0); - final int second = QGCUsbSerialManager.getOrCreateResourceIdForTesting(42, 0); - final int otherPort = QGCUsbSerialManager.getOrCreateResourceIdForTesting(42, 1); - - assertEquals(first, second); - assertNotEquals(first, otherPort); - } - - @Test - public void resourceIdMapping_removedAddressGetsNewId() { - final int first = QGCUsbSerialManager.getOrCreateResourceIdForTesting(7, 2); - QGCUsbSerialManager.removeResourceMappingForTesting(first); - final int next = QGCUsbSerialManager.getOrCreateResourceIdForTesting(7, 2); - - assertNotEquals(first, next); - } -} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/D2xxLibraryTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/D2xxLibraryTest.java new file mode 100644 index 000000000000..f606bc33339a --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/D2xxLibraryTest.java @@ -0,0 +1,35 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.io.IOException; + +import org.junit.Test; + +public class D2xxLibraryTest { + + @Test + public void normalizeReadResult_clampsToRequestedLength() throws IOException { + assertEquals(5, D2xxLibrary.normalizeReadResult(5, 10)); + assertEquals(10, D2xxLibrary.normalizeReadResult(20, 10)); + assertEquals(0, D2xxLibrary.normalizeReadResult(0, 10)); + } + + @Test + public void normalizeReadResult_negativeThrows() { + assertThrows(IOException.class, () -> D2xxLibrary.normalizeReadResult(-1, 10)); + } + + @Test + public void d2xxLocation_packsPhysicalAndInterface() { + assertEquals((1 << 4) | 1, D2xxLibrary.d2xxLocation(1, 0)); + assertEquals((2 << 4) | 4, D2xxLibrary.d2xxLocation(2, 3)); + } + + @Test + public void d2xxLocation_rejectsOutOfRangeInterface() { + assertThrows(IllegalArgumentException.class, () -> D2xxLibrary.d2xxLocation(0, 4)); + assertThrows(IllegalArgumentException.class, () -> D2xxLibrary.d2xxLocation(0, -1)); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/DriverStrategyTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/DriverStrategyTest.java new file mode 100644 index 000000000000..4ca173639ee3 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/DriverStrategyTest.java @@ -0,0 +1,155 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.hardware.usb.FakeUsbDevice; + +import com.hoho.android.usbserial.driver.Cp21xxSerialDriver; +import com.hoho.android.usbserial.driver.FtdiSerialDriver; + +import org.junit.Test; + +import org.mavlink.qgroundcontrol.serial.DriverStrategy.Kind; + +public class DriverStrategyTest { + + @Test + public void prolificLegacyDeviceClass_matchesOnlyClass02Subclass00() { + assertTrue(DriverStrategy.isProlificLegacyBaudLimitedDeviceClass(0x02, 0x00)); + assertFalse(DriverStrategy.isProlificLegacyBaudLimitedDeviceClass(0x02, 0x01)); + assertFalse(DriverStrategy.isProlificLegacyBaudLimitedDeviceClass(0x00, 0x00)); + } + + @Test + public void ch34xRejects921600_othersSupported() { + assertFalse(DriverStrategy.supportsBaud(Kind.CH34X, 921600, false)); + assertTrue(DriverStrategy.supportsBaud(Kind.CH34X, 460800, false)); + assertTrue(DriverStrategy.supportsBaud(Kind.GENERIC, 921600, false)); + } + + @Test + public void prolificLegacyCapsAt115200() { + assertTrue(DriverStrategy.supportsBaud(Kind.GENERIC, 115200, true)); + assertFalse(DriverStrategy.supportsBaud(Kind.GENERIC, 230400, true)); + assertTrue(DriverStrategy.supportsBaud(Kind.GENERIC, 4000000, false)); + } + + @Test + public void d2xxAcceptsAnyBaud() { + assertTrue(DriverStrategy.supportsBaud(Kind.D2XX, 921600, false)); + assertTrue(DriverStrategy.supportsBaud(Kind.D2XX, 4000000, true)); + } + + @Test + public void supportedBaudRates_filteredPerDriverQuirk() { + assertEquals(30, DriverStrategy.supportedBaudRates(Kind.GENERIC, false).length); + assertEquals(29, DriverStrategy.supportedBaudRates(Kind.CH34X, false).length); + assertEquals(17, DriverStrategy.supportedBaudRates(Kind.GENERIC, true).length); + } + + @Test + public void supportedBaudRates_ch34xListOmits921600() { + for (final int rate : DriverStrategy.supportedBaudRates(Kind.CH34X, false)) { + assertFalse(rate == 921600); + } + } + + @Test + public void prolificLegacyList_allWithinCap() { + for (final int rate : DriverStrategy.supportedBaudRates(Kind.GENERIC, true)) { + assertTrue(rate <= DriverStrategy.PROLIFIC_LEGACY_MAX_BAUD_RATE); + } + } + + @Test + public void cp21xxHighBaudWriteChunkClamped() { + assertEquals(DriverStrategy.CP21XX_HIGH_BAUD_WRITE_CHUNK_BYTES, + DriverStrategy.writeChunkSize(Kind.CP21XX, 460800)); + assertEquals(SerialWireConstants.MAX_CHUNK_BYTES, + DriverStrategy.writeChunkSize(Kind.CP21XX, 230400)); + assertEquals(SerialWireConstants.MAX_CHUNK_BYTES, + DriverStrategy.writeChunkSize(Kind.GENERIC, 4000000)); + } + + @Test + public void readQueueDepth_cdcAcmZero_othersAtLeastTwo() { + assertEquals(0, DriverStrategy.readQueueDepth(Kind.CDC_ACM)); + assertTrue(DriverStrategy.readQueueDepth(Kind.GENERIC) >= 2); + assertTrue(DriverStrategy.READ_QUEUE_DEPTH >= 2); + } + + @Test + public void readTimeout_cdcAcmBounded_d2xxUsesOverride_othersZero() { + assertEquals(DriverStrategy.CDC_ACM_READ_TIMEOUT_MS, DriverStrategy.readTimeoutMs(Kind.CDC_ACM, 0)); + assertEquals(0, DriverStrategy.readTimeoutMs(Kind.GENERIC, 50)); + assertEquals(987654, DriverStrategy.readTimeoutMs(Kind.D2XX, 987654)); + } + + @Test + public void purgeAfterBaudChange_onlyCp21xx() { + assertTrue(DriverStrategy.needsPurgeAfterBaudChange(Kind.CP21XX)); + assertFalse(DriverStrategy.needsPurgeAfterBaudChange(Kind.GENERIC)); + assertFalse(DriverStrategy.needsPurgeAfterBaudChange(Kind.D2XX)); + } + + @Test + public void standardBaudRatesAscendingAndUnique() { + final int[] rates = DriverStrategy.STANDARD_BAUD_RATES; + for (int i = 1; i < rates.length; i++) { + assertTrue("rates must be strictly ascending at index " + i, rates[i] > rates[i - 1]); + } + } + + @Test + public void supportedBaudRates_remainAscendingAfterFiltering() { + final int[] rates = DriverStrategy.supportedBaudRates(Kind.CH34X, true); + for (int i = 1; i < rates.length; i++) { + assertTrue(rates[i] > rates[i - 1]); + } + final int[] expectedPrefix = new int[] {50, 75, 110}; + assertArrayEquals(expectedPrefix, new int[] {rates[0], rates[1], rates[2]}); + } + + @Test + public void strategySelection_mapsDriverTypes() { + final FakeUsbDevice device = new FakeUsbDevice(1); + assertEquals(Kind.GENERIC, DriverStrategy.of(null)); + assertEquals(Kind.D2XX, DriverStrategy.of(new QGCFtdiSerialPort.QGCFtdiSerialDriver(device))); + assertEquals(Kind.MIK3Y_FTDI, DriverStrategy.of(new FtdiSerialDriver(device))); + assertEquals(Kind.CP21XX, DriverStrategy.of(new Cp21xxSerialDriver(device))); + } + + private static DriverStrategy.Caps generic() { + return DriverStrategy.capsFor(null); + } + + @Test + public void nullDriver_resolvesToGenericStrategy() { + final DriverStrategy.Caps caps = generic(); + assertEquals(Kind.GENERIC, caps.kind()); + assertFalse(caps.usesD2xx()); + assertFalse(caps.needsHostFtdiLatencyTimer()); + } + + @Test + public void genericDriver_readQueueDepthMatchesStrategy() { + assertEquals(DriverStrategy.READ_QUEUE_DEPTH, generic().readQueueDepth()); + assertEquals(DriverStrategy.readQueueDepth(Kind.GENERIC), generic().readQueueDepth()); + } + + @Test + public void genericDriver_ignoresD2xxReadTimeoutOverride() { + assertEquals(DriverStrategy.readTimeoutMs(Kind.GENERIC, 0), generic().readTimeoutForIoManager(987654)); + } + + @Test + public void genericDriver_delegatesWriteChunkAndBaudSupportAndPurge() { + final DriverStrategy.Caps caps = generic(); + assertEquals(DriverStrategy.writeChunkSize(Kind.GENERIC, 921600), caps.writeChunkSizeForBaud(921600)); + assertEquals(DriverStrategy.supportsBaud(Kind.GENERIC, 921600, false), caps.supportsBaudRate(921600)); + assertEquals(DriverStrategy.needsPurgeAfterBaudChange(Kind.GENERIC), caps.needsPurgeAfterBaudChange()); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/FakeUsbSerialPort.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/FakeUsbSerialPort.java new file mode 100644 index 000000000000..500ca4477cd3 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/FakeUsbSerialPort.java @@ -0,0 +1,104 @@ +package org.mavlink.qgroundcontrol.serial; + +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; + +import com.hoho.android.usbserial.driver.SerialTimeoutException; +import com.hoho.android.usbserial.driver.UsbSerialDriver; +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +final class FakeUsbSerialPort implements UsbSerialPort { + + interface WriteHandler { + void onWrite(byte[] data, int length, int callIndex) throws IOException; + } + + static WriteHandler noop() { + return (data, length, call) -> { }; + } + + static WriteHandler alwaysTimeout(final int bytesTransferred) { + return (data, length, call) -> { throw new SerialTimeoutException("stalled", bytesTransferred); }; + } + + static WriteHandler alwaysIoError() { + return (data, length, call) -> { throw new IOException("device lost"); }; + } + + private final WriteHandler handler; + private final AtomicInteger callIndex = new AtomicInteger(); + private final List writeLengths = Collections.synchronizedList(new ArrayList<>()); + private final ByteArrayOutputStream written = new ByteArrayOutputStream(); + private volatile boolean open = true; + + FakeUsbSerialPort(final WriteHandler handler) { + this.handler = handler; + } + + List writeLengths() { + return writeLengths; + } + + synchronized byte[] writtenBytes() { + return written.toByteArray(); + } + + int writeCallCount() { + return callIndex.get(); + } + + void setOpen(final boolean value) { + open = value; + } + + @Override public void write(final byte[] src, final int length, final int timeout) throws IOException { + writeLengths.add(length); + handler.onWrite(src, length, callIndex.getAndIncrement()); + synchronized (this) { + written.write(src, 0, length); + } + } + + @Override public boolean isOpen() { return open; } + @Override public void close() { open = false; } + + @Override public UsbSerialDriver getDriver() { return null; } + @Override public UsbDevice getDevice() { return null; } + @Override public int getPortNumber() { return 0; } + @Override public UsbEndpoint getWriteEndpoint() { return null; } + @Override public UsbEndpoint getReadEndpoint() { return null; } + @Override public String getSerial() { return null; } + @Override public void setReadQueue(final int count, final int size) { } + @Override public int getReadQueueBufferCount() { return 0; } + @Override public int getReadQueueBufferSize() { return 0; } + @Override public void open(final UsbDeviceConnection connection) { } + @Override public int read(final byte[] dest, final int timeout) { return 0; } + @Override public int read(final byte[] dest, final int offset, final int timeout) { return 0; } + @Override public void write(final byte[] src, final int timeout) throws IOException { write(src, src.length, timeout); } + @Override public void setParameters(final int baudRate, final int dataBits, final int stopBits, final int parity) { } + @Override public boolean getCD() { return false; } + @Override public boolean getCTS() { return false; } + @Override public boolean getDSR() { return false; } + @Override public boolean getDTR() { return false; } + @Override public void setDTR(final boolean value) { } + @Override public boolean getRI() { return false; } + @Override public boolean getRTS() { return false; } + @Override public void setRTS(final boolean value) { } + @Override public EnumSet getControlLines() { return EnumSet.noneOf(ControlLine.class); } + @Override public EnumSet getSupportedControlLines() { return EnumSet.noneOf(ControlLine.class); } + @Override public void setFlowControl(final FlowControl flowControl) { } + @Override public FlowControl getFlowControl() { return FlowControl.NONE; } + @Override public EnumSet getSupportedFlowControl() { return EnumSet.of(FlowControl.NONE); } + @Override public boolean getXON() { return false; } + @Override public void purgeHwBuffers(final boolean purgeWriteBuffers, final boolean purgeReadBuffers) { } + @Override public void setBreak(final boolean value) { } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCFtdiSerialDriverTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCFtdiSerialDriverTest.java new file mode 100644 index 000000000000..0db9e046c6ac --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCFtdiSerialDriverTest.java @@ -0,0 +1,56 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import android.hardware.usb.FakeUsbDevice; +import android.hardware.usb.UsbDevice; + +import org.mavlink.qgroundcontrol.serial.QGCFtdiSerialPort.QGCFtdiSerialDriver; + +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import org.junit.Test; + +import java.util.List; + +public class QGCFtdiSerialDriverTest { + + @Test + public void buildsOnePortPerInterface() { + final QGCFtdiSerialDriver driver = new QGCFtdiSerialDriver(new FakeUsbDevice(3)); + + final List ports = driver.getPorts(); + assertEquals(3, ports.size()); + for (int i = 0; i < ports.size(); i++) { + assertEquals(i, ports.get(i).getPortNumber()); + assertSame(driver, ports.get(i).getDriver()); + } + } + + @Test + public void zeroInterfaces_yieldsEmptyPorts() { + final QGCFtdiSerialDriver driver = new QGCFtdiSerialDriver(new FakeUsbDevice(0)); + assertTrue(driver.getPorts().isEmpty()); + } + + @Test + public void negativeInterfaceCount_yieldsEmptyPorts() { + final QGCFtdiSerialDriver driver = new QGCFtdiSerialDriver(new FakeUsbDevice(-1)); + assertTrue(driver.getPorts().isEmpty()); + } + + @Test + public void getPorts_isUnmodifiable() { + final QGCFtdiSerialDriver driver = new QGCFtdiSerialDriver(new FakeUsbDevice(2)); + assertThrows(UnsupportedOperationException.class, () -> driver.getPorts().clear()); + } + + @Test + public void getDevice_isPassthrough() { + final UsbDevice device = new FakeUsbDevice(1); + assertSame(device, new QGCFtdiSerialDriver(device).getDevice()); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCFtdiSerialPortTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCFtdiSerialPortTest.java new file mode 100644 index 000000000000..f033e96133a3 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCFtdiSerialPortTest.java @@ -0,0 +1,127 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import android.hardware.usb.FakeUsbDevice; +import android.hardware.usb.UsbDevice; + +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import org.junit.Test; + +import java.io.IOException; +import java.util.EnumSet; + +public class QGCFtdiSerialPortTest { + + private static QGCFtdiSerialPort closedPort() { + final UsbDevice device = new FakeUsbDevice(1); + final QGCFtdiSerialPort.QGCFtdiSerialDriver driver = new QGCFtdiSerialPort.QGCFtdiSerialDriver(device); + return new QGCFtdiSerialPort(driver, device, 0); + } + + @Test + public void notOpen_whenConstructed() { + assertFalse(closedPort().isOpen()); + } + + @Test + public void getCD_whenClosed_throwsPortNotOpen() { + final IOException e = assertThrows(IOException.class, () -> closedPort().getCD()); + assertEquals("Port not open", e.getMessage()); + } + + @Test + public void getCTS_whenClosed_throwsPortNotOpen() { + assertEquals("Port not open", assertThrows(IOException.class, () -> closedPort().getCTS()).getMessage()); + } + + @Test + public void getDSR_whenClosed_throwsPortNotOpen() { + assertEquals("Port not open", assertThrows(IOException.class, () -> closedPort().getDSR()).getMessage()); + } + + @Test + public void getDTR_whenClosed_throwsPortNotOpen() { + assertEquals("Port not open", assertThrows(IOException.class, () -> closedPort().getDTR()).getMessage()); + } + + @Test + public void getRI_whenClosed_throwsPortNotOpen() { + assertEquals("Port not open", assertThrows(IOException.class, () -> closedPort().getRI()).getMessage()); + } + + @Test + public void getRTS_whenClosed_throwsPortNotOpen() { + assertEquals("Port not open", assertThrows(IOException.class, () -> closedPort().getRTS()).getMessage()); + } + + @Test + public void getControlLines_whenClosed_throwsPortNotOpen() { + assertEquals("Port not open", assertThrows(IOException.class, () -> closedPort().getControlLines()).getMessage()); + } + + @Test + public void getXON_whenClosed_throwsPortNotOpen() { + assertEquals("Port not open", assertThrows(IOException.class, () -> closedPort().getXON()).getMessage()); + } + + @Test + public void getFlowControl_whenClosed_isNone() { + assertEquals(UsbSerialPort.FlowControl.NONE, closedPort().getFlowControl()); + } + + @Test + public void getWriteEndpoint_isNull() { + assertNull(closedPort().getWriteEndpoint()); + } + + @Test + public void getReadEndpoint_whenClosed_isNull() { + assertNull(closedPort().getReadEndpoint()); + } + + @Test + public void getSupportedControlLines_listsAllFtdiLines() { + assertEquals( + EnumSet.of(UsbSerialPort.ControlLine.RTS, UsbSerialPort.ControlLine.CTS, + UsbSerialPort.ControlLine.DTR, UsbSerialPort.ControlLine.DSR, + UsbSerialPort.ControlLine.CD, UsbSerialPort.ControlLine.RI), + closedPort().getSupportedControlLines()); + } + + @Test + public void getSupportedFlowControl_listsNoneRtsCtsDtrDsrXonXoff() { + assertEquals( + EnumSet.of(UsbSerialPort.FlowControl.NONE, UsbSerialPort.FlowControl.RTS_CTS, + UsbSerialPort.FlowControl.DTR_DSR, UsbSerialPort.FlowControl.XON_XOFF), + closedPort().getSupportedFlowControl()); + } + + @Test + public void setReadQueue_negativeCount_throwsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> closedPort().setReadQueue(-1, 16)); + } + + @Test + public void setReadQueue_negativeSize_throwsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> closedPort().setReadQueue(2, -1)); + } + + @Test + public void setReadQueue_validArgs_recordsCountAndSize() { + final QGCFtdiSerialPort port = closedPort(); + port.setReadQueue(4, 4096); + assertEquals(4, port.getReadQueueBufferCount()); + assertEquals(4096, port.getReadQueueBufferSize()); + } + + @Test + public void getPortNumber_isPassthrough() { + assertTrue(closedPort().getPortNumber() >= 0); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortBackpressureTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortBackpressureTest.java new file mode 100644 index 000000000000..2082bb7daee5 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortBackpressureTest.java @@ -0,0 +1,207 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; + +import com.hoho.android.usbserial.driver.UsbSerialDriver; +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import org.junit.After; +import org.junit.Test; + +import org.mavlink.qgroundcontrol.serial.QGCSerialPort.NativeBridge; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.EnumSet; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +public class QGCSerialPortBackpressureTest { + + private static final int CHUNK = 8; + + private QGCSerialPort port; + + @After + public void tearDown() { + if (port != null) { + port.close(QGCSerialPort.CloseReason.USER); + } + } + + private static final class CountingBridge implements NativeBridge { + final AtomicLong totalAcked = new AtomicLong(); + final AtomicInteger ackCalls = new AtomicInteger(); + volatile CountDownLatch acks; + + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { } + @Override public void deviceBytesWritten(final long handle, final int n) { + totalAcked.addAndGet(n); + ackCalls.incrementAndGet(); + final CountDownLatch l = acks; + if (l != null) { + l.countDown(); + } + } + @Override public void deviceException(final long handle, final int kind, final String message) { } + @Override public void deviceHasDisconnected(final long handle) { } + } + + private QGCSerialPort start(final UsbSerialPort fake, final NativeBridge bridge) { + port = new QGCSerialPort(null, null, null, fake, 0x1L, null, null); + port.setNativeBridgeForTest(bridge); + port.forceConfiguredForTest(57600); + return port; + } + + private static ByteBuffer payload(final int length) { + return ByteBuffer.allocate(length); + } + + @Test + public void invalidRequest_returnsMinusOne() { + start(new GatedPort(new CountDownLatch(0)), new CountingBridge()); + assertEquals(-1, port.enqueueWrite(null, CHUNK)); + assertEquals(-1, port.enqueueWrite(payload(CHUNK), 0)); + assertEquals(-1, port.enqueueWrite(payload(CHUNK), -1)); + } + + @Test + public void fullQueueIsBackpressure_acceptsAfterWriterDrains() throws Exception { + final int total = QGCSerialPort.WRITE_QUEUE_CAPACITY + 2; + final CountingBridge bridge = new CountingBridge(); + bridge.acks = new CountDownLatch(total); + final CountDownLatch release = new CountDownLatch(1); + final GatedPort fake = new GatedPort(release); + + start(fake, bridge); + + assertEquals(CHUNK, port.enqueueWrite(payload(CHUNK), CHUNK)); + assertTrue(fake.firstWriteEntered.await(5, TimeUnit.SECONDS)); + + for (int i = 0; i < QGCSerialPort.WRITE_QUEUE_CAPACITY; i++) { + assertEquals(CHUNK, port.enqueueWrite(payload(CHUNK), CHUNK)); + } + + final AtomicInteger overflowResult = new AtomicInteger(Integer.MIN_VALUE); + final Thread overflow = new Thread(() -> + overflowResult.set(port.enqueueWrite(payload(CHUNK), CHUNK))); + overflow.start(); + + release.countDown(); + overflow.join(5000); + + assertEquals(CHUNK, overflowResult.get()); + assertTrue(bridge.acks.await(5, TimeUnit.SECONDS)); + assertEquals((long) total * CHUNK, bridge.totalAcked.get()); + assertEquals(total, bridge.ackCalls.get()); + } + + @Test + public void fullQueuePastTimeout_returnsMinusOne() throws Exception { + final CountDownLatch neverReleased = new CountDownLatch(1); + final GatedPort fake = new GatedPort(neverReleased); + start(fake, new CountingBridge()); + + assertEquals(CHUNK, port.enqueueWrite(payload(CHUNK), CHUNK)); + assertTrue(fake.firstWriteEntered.await(5, TimeUnit.SECONDS)); + for (int i = 0; i < QGCSerialPort.WRITE_QUEUE_CAPACITY; i++) { + assertEquals(CHUNK, port.enqueueWrite(payload(CHUNK), CHUNK)); + } + + assertEquals(-1, port.enqueueWrite(payload(CHUNK), CHUNK)); + + neverReleased.countDown(); + } + + @Test + public void closeDuringBlockedEnqueue_returnsMinusOneAndDropsPayload() throws Exception { + final CountDownLatch release = new CountDownLatch(1); + final GatedPort fake = new GatedPort(release); + final CountingBridge bridge = new CountingBridge(); + start(fake, bridge); + + assertEquals(CHUNK, port.enqueueWrite(payload(CHUNK), CHUNK)); + assertTrue(fake.firstWriteEntered.await(5, TimeUnit.SECONDS)); + for (int i = 0; i < QGCSerialPort.WRITE_QUEUE_CAPACITY; i++) { + assertEquals(CHUNK, port.enqueueWrite(payload(CHUNK), CHUNK)); + } + + final AtomicInteger result = new AtomicInteger(Integer.MIN_VALUE); + final CountDownLatch done = new CountDownLatch(1); + final Thread blocked = new Thread(() -> { + result.set(port.enqueueWrite(payload(CHUNK), CHUNK)); + done.countDown(); + }); + blocked.start(); + + port.close(QGCSerialPort.CloseReason.USER); + + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertEquals(-1, result.get()); + assertEquals(0, bridge.ackCalls.get()); + + release.countDown(); + } + + private static final class GatedPort implements UsbSerialPort { + final CountDownLatch firstWriteEntered = new CountDownLatch(1); + private final CountDownLatch release; + private volatile boolean open = true; + + GatedPort(final CountDownLatch release) { + this.release = release; + } + + @Override public void write(final byte[] src, final int length, final int timeout) throws IOException { + firstWriteEntered.countDown(); + try { + release.await(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + } + + @Override public boolean isOpen() { return open; } + @Override public void close() { open = false; } + + @Override public UsbSerialDriver getDriver() { return null; } + @Override public UsbDevice getDevice() { return null; } + @Override public int getPortNumber() { return 0; } + @Override public UsbEndpoint getWriteEndpoint() { return null; } + @Override public UsbEndpoint getReadEndpoint() { return null; } + @Override public String getSerial() { return null; } + @Override public void setReadQueue(final int count, final int size) { } + @Override public int getReadQueueBufferCount() { return 0; } + @Override public int getReadQueueBufferSize() { return 0; } + @Override public void open(final UsbDeviceConnection connection) { } + @Override public int read(final byte[] dest, final int timeout) { return 0; } + @Override public int read(final byte[] dest, final int offset, final int timeout) { return 0; } + @Override public void write(final byte[] src, final int timeout) { } + @Override public void setParameters(final int baudRate, final int dataBits, final int stopBits, final int parity) { } + @Override public boolean getCD() { return false; } + @Override public boolean getCTS() { return false; } + @Override public boolean getDSR() { return false; } + @Override public boolean getDTR() { return false; } + @Override public void setDTR(final boolean value) { } + @Override public boolean getRI() { return false; } + @Override public boolean getRTS() { return false; } + @Override public void setRTS(final boolean value) { } + @Override public EnumSet getControlLines() { return EnumSet.noneOf(ControlLine.class); } + @Override public EnumSet getSupportedControlLines() { return EnumSet.noneOf(ControlLine.class); } + @Override public void setFlowControl(final FlowControl flowControl) { } + @Override public FlowControl getFlowControl() { return FlowControl.NONE; } + @Override public EnumSet getSupportedFlowControl() { return EnumSet.of(FlowControl.NONE); } + @Override public boolean getXON() { return false; } + @Override public void purgeHwBuffers(final boolean purgeWriteBuffers, final boolean purgeReadBuffers) { } + @Override public void setBreak(final boolean value) { } + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortLoopbackTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortLoopbackTest.java new file mode 100644 index 000000000000..1cfefc6c1b23 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortLoopbackTest.java @@ -0,0 +1,193 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.hardware.usb.FakeUsbDevice; + +import com.hoho.android.usbserial.driver.Cp21xxSerialDriver; + +import org.junit.After; +import org.junit.Test; + +import org.mavlink.qgroundcontrol.serial.QGCSerialPort.NativeBridge; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +public class QGCSerialPortLoopbackTest { + + private static final int LOW_BAUD = 57600; + private static final int HIGH_BAUD = 500000; + + private QGCSerialPort port; + + @After + public void tearDown() { + if (port != null) { + port.close(QGCSerialPort.CloseReason.USER); + } + } + + private static final class RecordingBridge implements NativeBridge { + final AtomicLong totalAcked = new AtomicLong(); + final List rxChunks = Collections.synchronizedList(new ArrayList<>()); + final AtomicInteger exceptionCalls = new AtomicInteger(); + final AtomicInteger disconnectCalls = new AtomicInteger(); + volatile CountDownLatch ackLatch; + + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { + final byte[] copy = new byte[length]; + final int pos = data.position(); + data.get(copy, 0, length); + data.position(pos); + rxChunks.add(copy); + } + + @Override public void deviceBytesWritten(final long handle, final int n) { + totalAcked.addAndGet(n); + final CountDownLatch l = ackLatch; + if (l != null) { + for (int i = 0; i < n; i++) { + l.countDown(); + } + } + } + + @Override public void deviceException(final long handle, final int kind, final String message) { + exceptionCalls.incrementAndGet(); + } + + @Override public void deviceHasDisconnected(final long handle) { + disconnectCalls.incrementAndGet(); + } + + byte[] rxJoined() { + synchronized (rxChunks) { + int total = 0; + for (final byte[] c : rxChunks) { + total += c.length; + } + final byte[] out = new byte[total]; + int off = 0; + for (final byte[] c : rxChunks) { + System.arraycopy(c, 0, out, off, c.length); + off += c.length; + } + return out; + } + } + } + + private static ByteBuffer ramp(final int length, final int start) { + final ByteBuffer buf = ByteBuffer.allocate(length); + for (int i = 0; i < length; i++) { + buf.put((byte) ((start + i) & 0xff)); + } + buf.flip(); + return buf; + } + + private QGCSerialPort start(final FakeUsbSerialPort fake, final RecordingBridge bridge, final int baud) { + final Cp21xxSerialDriver driver = new Cp21xxSerialDriver(new FakeUsbDevice(1)); + port = new QGCSerialPort(null, null, driver, fake, 0x1L, null, null); + port.setNativeBridgeForTest(bridge); + port.forceConfiguredForTest(baud); + return port; + } + + @Test + public void roundTrip_acksEnqueuedAndSurfacedBytesPreservingOrder() throws Exception { + final int total = SerialWireConstants.MAX_CHUNK_BYTES * 2 + 777; + final RecordingBridge bridge = new RecordingBridge(); + bridge.ackLatch = new CountDownLatch(total); + final FakeUsbSerialPort fake = new FakeUsbSerialPort(FakeUsbSerialPort.noop()); + + start(fake, bridge, LOW_BAUD); + assertEquals(total, port.enqueueWrite(ramp(total, 0), total)); + + assertTrue(bridge.ackLatch.await(5, TimeUnit.SECONDS)); + assertEquals((long) total, bridge.totalAcked.get()); + + final byte[] onWire = fake.writtenBytes(); + assertEquals(total, onWire.length); + + port.readLoopForTest().onNewData(onWire); + + final byte[] rx = bridge.rxJoined(); + assertEquals(total, rx.length); + assertArrayEquals(onWire, rx); + final byte[] expected = new byte[total]; + for (int i = 0; i < total; i++) { + expected[i] = (byte) (i & 0xff); + } + assertArrayEquals(expected, rx); + } + + @Test + public void baudChangeMidStream_subWriteChunkingFollowsBaud_roundTripExact() throws Exception { + final int first = SerialWireConstants.MAX_CHUNK_BYTES + 100; + final RecordingBridge bridge = new RecordingBridge(); + bridge.ackLatch = new CountDownLatch(first); + final FakeUsbSerialPort fake = new FakeUsbSerialPort(FakeUsbSerialPort.noop()); + + start(fake, bridge, LOW_BAUD); + assertEquals(first, port.enqueueWrite(ramp(first, 0), first)); + assertTrue(bridge.ackLatch.await(5, TimeUnit.SECONDS)); + + final int lowChunks = fake.writeLengths().size(); + assertTrue(fake.writeLengths().contains(SerialWireConstants.MAX_CHUNK_BYTES)); + + assertTrue(port.setSerialParameters(new QGCUsbSerialManager.SerialParameters(HIGH_BAUD, 8, 1, 0))); + + final int second = DriverStrategy.CP21XX_HIGH_BAUD_WRITE_CHUNK_BYTES * 3; + bridge.ackLatch = new CountDownLatch(second); + assertEquals(second, port.enqueueWrite(ramp(second, 7), second)); + assertTrue(bridge.ackLatch.await(5, TimeUnit.SECONDS)); + + final List highBaudWrites = fake.writeLengths().subList(lowChunks, fake.writeLengths().size()); + for (final int len : highBaudWrites) { + assertTrue(len <= DriverStrategy.CP21XX_HIGH_BAUD_WRITE_CHUNK_BYTES); + } + assertEquals((long) (first + second), bridge.totalAcked.get()); + assertEquals(first + second, fake.writtenBytes().length); + } + + @Test + public void disconnectMidStream_emitsSingleDisconnect_andAccountingReconciles() throws Exception { + final int total = SerialWireConstants.MAX_CHUNK_BYTES * 4; + final RecordingBridge bridge = new RecordingBridge(); + final CountDownLatch firstWrite = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + final FakeUsbSerialPort fake = new FakeUsbSerialPort((data, length, call) -> { + if (call == 0) { + firstWrite.countDown(); + try { + release.await(2, TimeUnit.SECONDS); + } catch (final InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + + start(fake, bridge, HIGH_BAUD); + assertEquals(total, port.enqueueWrite(ramp(total, 0), total)); + assertTrue(firstWrite.await(5, TimeUnit.SECONDS)); + + port.close(QGCSerialPort.CloseReason.DETACHED); + port.close(QGCSerialPort.CloseReason.DETACHED); + release.countDown(); + + assertTrue(port.awaitWriteLoopDrainedForTest(5000)); + assertEquals(1, bridge.disconnectCalls.get()); + assertEquals(0, bridge.exceptionCalls.get()); + assertTrue(bridge.totalAcked.get() <= total); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortReadPathTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortReadPathTest.java new file mode 100644 index 000000000000..812f6aadff70 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortReadPathTest.java @@ -0,0 +1,142 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.hoho.android.usbserial.driver.SerialTimeoutException; + +import org.junit.Test; + +import org.mavlink.qgroundcontrol.serial.QGCSerialPort.NativeBridge; +import org.mavlink.qgroundcontrol.serial.QGCSerialPort.PortLifecycleSink; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +public class QGCSerialPortReadPathTest { + + private static final class RecordingBridge implements NativeBridge { + final List chunkSizes = new ArrayList<>(); + int exceptionCalls; + int lastExceptionKind = Integer.MIN_VALUE; + int disconnectCalls; + + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { + chunkSizes.add(length); + } + @Override public void deviceBytesWritten(final long handle, final int n) { } + @Override public void deviceException(final long handle, final int kind, final String message) { + exceptionCalls++; + lastExceptionKind = kind; + } + @Override public void deviceHasDisconnected(final long handle) { disconnectCalls++; } + + int totalBytes() { + int sum = 0; + for (final int n : chunkSizes) { + sum += n; + } + return sum; + } + } + + private static final class RecordingSink implements PortLifecycleSink { + int deviceErrorCalls; + @Override public void onPortConfigured(final QGCSerialPort port) { } + @Override public void onPortClosed(final QGCSerialPort port) { } + @Override public void onPortDeviceError(final QGCSerialPort port) { deviceErrorCalls++; } + } + + private static QGCSerialPort newPort(final NativeBridge bridge, final PortLifecycleSink sink) { + final QGCSerialPort port = new QGCSerialPort(null, null, null, null, 0x1L, sink, null); + port.setNativeBridgeForTest(bridge); + return port; + } + + @Test + public void smallPayload_emitsSingleChunk() { + final RecordingBridge bridge = new RecordingBridge(); + newPort(bridge, null).readLoopForTest().onNewData(new byte[256]); + + assertEquals(List.of(256), bridge.chunkSizes); + assertEquals(256, bridge.totalBytes()); + } + + @Test + public void payloadAtChunkLimit_emitsSingleChunk() { + final RecordingBridge bridge = new RecordingBridge(); + newPort(bridge, null).readLoopForTest().onNewData(new byte[SerialWireConstants.MAX_CHUNK_BYTES]); + + assertEquals(List.of(SerialWireConstants.MAX_CHUNK_BYTES), bridge.chunkSizes); + } + + @Test + public void largePayload_chunkedAtMaxChunkBytesPreservingTotal() { + final RecordingBridge bridge = new RecordingBridge(); + final int total = (2 * SerialWireConstants.MAX_CHUNK_BYTES) + 7232; + newPort(bridge, null).readLoopForTest().onNewData(new byte[total]); + + assertEquals(List.of(SerialWireConstants.MAX_CHUNK_BYTES, SerialWireConstants.MAX_CHUNK_BYTES, 7232), + bridge.chunkSizes); + assertEquals(total, bridge.totalBytes()); + } + + @Test + public void nullOrEmptyPayload_emitsNothing() { + final RecordingBridge bridge = new RecordingBridge(); + final QGCSerialPort port = newPort(bridge, null); + + port.readLoopForTest().onNewData(null); + port.readLoopForTest().onNewData(new byte[0]); + + assertTrue(bridge.chunkSizes.isEmpty()); + } + + @Test + public void afterClose_onNewDataEmitsNothing() { + final RecordingBridge bridge = new RecordingBridge(); + final QGCSerialPort port = newPort(bridge, null); + port.close(QGCSerialPort.CloseReason.USER); + + port.readLoopForTest().onNewData(new byte[128]); + + assertTrue(bridge.chunkSizes.isEmpty()); + } + + @Test + public void runError_ioException_firesResourceAndReplacesDriver() { + final RecordingBridge bridge = new RecordingBridge(); + final RecordingSink sink = new RecordingSink(); + newPort(bridge, sink).readLoopForTest().onRunError(new IOException("device lost")); + + assertEquals(1, bridge.exceptionCalls); + assertEquals(SerialWireConstants.EXC_RESOURCE, bridge.lastExceptionKind); + assertEquals(1, sink.deviceErrorCalls); + } + + @Test + public void runError_serialTimeout_firesUnknownAndKeepsDriver() { + final RecordingBridge bridge = new RecordingBridge(); + final RecordingSink sink = new RecordingSink(); + newPort(bridge, sink).readLoopForTest().onRunError(new SerialTimeoutException("stalled", 0)); + + assertEquals(1, bridge.exceptionCalls); + assertEquals(SerialWireConstants.EXC_UNKNOWN, bridge.lastExceptionKind); + assertEquals(0, sink.deviceErrorCalls); + } + + @Test + public void afterClose_runErrorIsSilent() { + final RecordingBridge bridge = new RecordingBridge(); + final RecordingSink sink = new RecordingSink(); + final QGCSerialPort port = newPort(bridge, sink); + port.close(QGCSerialPort.CloseReason.USER); + + port.readLoopForTest().onRunError(new IOException("device lost")); + + assertEquals(0, bridge.exceptionCalls); + assertEquals(0, sink.deviceErrorCalls); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortStateTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortStateTest.java new file mode 100644 index 000000000000..211587c6d413 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortStateTest.java @@ -0,0 +1,283 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import org.mavlink.qgroundcontrol.serial.QGCSerialPort.LifecycleState; +import org.mavlink.qgroundcontrol.serial.QGCSerialPort.NativeBridge; +import org.mavlink.qgroundcontrol.serial.QGCUsbSerialManager.SerialParameters; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class QGCSerialPortStateTest { + + private static final int LOW_BAUD = 57600; + private static final int HIGH_BAUD = 921600; + + private static ByteBuffer payload(final int length) { + return ByteBuffer.allocate(length); + } + + private static String key(final LifecycleState from, final LifecycleState to) { + return from + "->" + to; + } + + @Test + public void onlyForwardTransitionsAllowed() { + final Set allowed = Set.of( + key(LifecycleState.REGISTERED, LifecycleState.CONFIGURED), + key(LifecycleState.REGISTERED, LifecycleState.CLOSING), + key(LifecycleState.CONFIGURED, LifecycleState.CLOSING), + key(LifecycleState.CLOSING, LifecycleState.CLOSED)); + + for (final LifecycleState from : LifecycleState.values()) { + for (final LifecycleState to : LifecycleState.values()) { + final boolean expected = allowed.contains(key(from, to)); + assertTrue(key(from, to) + " expected=" + expected, + expected == QGCSerialPort.isLifecycleTransitionAllowed(from, to)); + } + } + } + + @Test + public void closedIsTerminal() { + for (final LifecycleState to : LifecycleState.values()) { + assertFalse(QGCSerialPort.isLifecycleTransitionAllowed(LifecycleState.CLOSED, to)); + } + } + + @Test + public void noSelfTransitionAllowed() { + for (final LifecycleState s : LifecycleState.values()) { + assertFalse(QGCSerialPort.isLifecycleTransitionAllowed(s, s)); + } + } + + private static final class NoopBridge implements NativeBridge { + volatile CountDownLatch ackLatch; + @Override public void deviceHasDisconnected(final long handle) { } + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { } + @Override public void deviceBytesWritten(final long handle, final int n) { + final CountDownLatch l = ackLatch; + if (l != null) { + l.countDown(); + } + } + @Override public void deviceException(final long handle, final int kind, final String message) { } + } + + @Test + public void enqueueWrite_inRegisteredState_returnsMinusOne() { + final FakeUsbSerialPort fake = new FakeUsbSerialPort(FakeUsbSerialPort.noop()); + final QGCSerialPort port = new QGCSerialPort(null, null, null, fake, 0x1L, null, null); + port.setNativeBridgeForTest(new NoopBridge()); + + assertEquals(-1, port.enqueueWrite(payload(64), 64)); + + port.close(QGCSerialPort.CloseReason.USER); + } + + @Test + public void baudChange_genericPort_keepsMaxChunkSubWriteLength() throws Exception { + final FakeUsbSerialPort fake = new FakeUsbSerialPort(FakeUsbSerialPort.noop()); + final NoopBridge bridge = new NoopBridge(); + final QGCSerialPort port = new QGCSerialPort(null, null, null, fake, 0x1L, null, null); + port.setNativeBridgeForTest(bridge); + port.forceConfiguredForTest(LOW_BAUD); + + bridge.ackLatch = new CountDownLatch(1); + final int len = SerialWireConstants.MAX_CHUNK_BYTES + 4096; + assertEquals(len, port.enqueueWrite(payload(len), len)); + assertTrue(bridge.ackLatch.await(5, TimeUnit.SECONDS)); + + assertTrue(port.setSerialParameters(new SerialParameters(HIGH_BAUD, 8, 1, 0))); + + bridge.ackLatch = new CountDownLatch(1); + assertEquals(len, port.enqueueWrite(payload(len), len)); + assertTrue(bridge.ackLatch.await(5, TimeUnit.SECONDS)); + + port.close(QGCSerialPort.CloseReason.USER); + + for (final int sub : new ArrayList<>(fake.writeLengths())) { + assertTrue(sub <= SerialWireConstants.MAX_CHUNK_BYTES); + } + } + + private static final class MutedRecordingBridge implements NativeBridge { + final List chunkSizes = Collections.synchronizedList(new ArrayList<>()); + int exceptionCalls; + int disconnectCalls; + + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { + chunkSizes.add(length); + } + @Override public void deviceBytesWritten(final long handle, final int n) { } + @Override public void deviceException(final long handle, final int kind, final String message) { exceptionCalls++; } + @Override public void deviceHasDisconnected(final long handle) { disconnectCalls++; } + } + + private static QGCSerialPort mutedOpenPort(final MutedRecordingBridge bridge) { + final QGCSerialPort port = new QGCSerialPort(null, null, null, null, 0x1L, null, null); + port.setNativeBridgeForTest(bridge); + port.forceConfiguredForTest(57600); + port.muteListenerForTest(); + return port; + } + + @Test + public void onNewData_whenMutedButOpen_emitsNothing() { + final MutedRecordingBridge bridge = new MutedRecordingBridge(); + final QGCSerialPort port = mutedOpenPort(bridge); + + port.readLoopForTest().onNewData(new byte[128]); + + assertTrue(bridge.chunkSizes.isEmpty()); + port.close(QGCSerialPort.CloseReason.USER); + } + + @Test + public void runError_whenMutedButOpen_firesNoException() { + final MutedRecordingBridge bridge = new MutedRecordingBridge(); + final QGCSerialPort port = mutedOpenPort(bridge); + + port.readLoopForTest().onRunError(new java.io.IOException("device lost")); + + assertEquals(0, bridge.exceptionCalls); + port.close(QGCSerialPort.CloseReason.USER); + } + + private static final class DisconnectCountingBridge implements NativeBridge { + int disconnectCalls; + + @Override public void deviceHasDisconnected(final long handle) { disconnectCalls++; } + @Override public void deviceException(final long handle, final int kind, final String message) { } + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { } + @Override public void deviceBytesWritten(final long handle, final int n) { } + } + + private static QGCSerialPort newPort(final long handle, final NativeBridge bridge) { + final QGCSerialPort port = new QGCSerialPort(null, null, null, null, handle, null, null); + port.setNativeBridgeForTest(bridge); + return port; + } + + @Test + public void doubleClose_isIdempotent() { + final DisconnectCountingBridge bridge = new DisconnectCountingBridge(); + final QGCSerialPort port = newPort(0x1L, bridge); + assertTrue(port.close()); + assertTrue(port.close()); + assertEquals(0, bridge.disconnectCalls); + } + + @Test + public void closeFromRegistered_userReason_emitsNoDisconnect() { + final DisconnectCountingBridge bridge = new DisconnectCountingBridge(); + final QGCSerialPort port = newPort(0x1L, bridge); + + port.close(QGCSerialPort.CloseReason.USER); + + assertEquals(0, bridge.disconnectCalls); + } + + @Test + public void closeFromConfigured_detached_emitsDisconnectExactlyOnce() { + final DisconnectCountingBridge bridge = new DisconnectCountingBridge(); + final QGCSerialPort port = newPort(0x1L, bridge); + port.forceConfiguredForTest(57600); + + port.close(QGCSerialPort.CloseReason.DETACHED); + port.close(QGCSerialPort.CloseReason.DETACHED); + + assertEquals(1, bridge.disconnectCalls); + } + + @Test + public void closeFromConfigured_staleDriver_emitsDisconnectExactlyOnce() { + final DisconnectCountingBridge bridge = new DisconnectCountingBridge(); + final QGCSerialPort port = newPort(0x1L, bridge); + port.forceConfiguredForTest(57600); + + port.close(QGCSerialPort.CloseReason.STALE_DRIVER); + port.close(QGCSerialPort.CloseReason.STALE_DRIVER); + + assertEquals(1, bridge.disconnectCalls); + } + + @Test + public void closeFromConfigured_deviceError_emitsDisconnectExactlyOnce() { + final DisconnectCountingBridge bridge = new DisconnectCountingBridge(); + final QGCSerialPort port = newPort(0x1L, bridge); + port.forceConfiguredForTest(57600); + + port.close(QGCSerialPort.CloseReason.DEVICE_ERROR); + port.close(QGCSerialPort.CloseReason.DEVICE_ERROR); + + assertEquals(1, bridge.disconnectCalls); + } + + @Test + public void closeFromRegistered_detached_emitsDisconnectExactlyOnce() { + final DisconnectCountingBridge bridge = new DisconnectCountingBridge(); + final QGCSerialPort port = newPort(0x1L, bridge); + + port.close(QGCSerialPort.CloseReason.DETACHED); + + assertEquals(1, bridge.disconnectCalls); + } + + private static final class ExceptionRecordingBridge implements NativeBridge { + long exceptionHandle = -1; + int exceptionKind = -1; + String exceptionMessage; + boolean exceptionCalled; + + @Override public void deviceHasDisconnected(final long handle) { } + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { } + @Override public void deviceBytesWritten(final long handle, final int n) { } + + @Override public void deviceException(final long handle, final int kind, final String message) { + exceptionCalled = true; + exceptionHandle = handle; + exceptionKind = kind; + exceptionMessage = message; + } + } + + private static QGCSerialPort newPortWithHandle(final long handle) { + return new QGCSerialPort(null, null, null, null, handle, null, null); + } + + @Test + public void fireException_routesThroughBridgeWithLiveHandle() { + final QGCSerialPort port = newPortWithHandle(0x1234L); + final ExceptionRecordingBridge bridge = new ExceptionRecordingBridge(); + port.setNativeBridgeForTest(bridge); + + port.fireException(SerialWireConstants.EXC_RESOURCE, "boom"); + + assertEquals(0x1234L, bridge.exceptionHandle); + assertEquals(SerialWireConstants.EXC_RESOURCE, bridge.exceptionKind); + assertEquals("boom", bridge.exceptionMessage); + } + + @Test + public void fireException_suppressedWhenHandleZero() { + final QGCSerialPort port = newPortWithHandle(0L); + final ExceptionRecordingBridge bridge = new ExceptionRecordingBridge(); + port.setNativeBridgeForTest(bridge); + + port.fireException(SerialWireConstants.EXC_UNKNOWN, "ignored"); + + assertFalse(bridge.exceptionCalled); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortWriterTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortWriterTest.java new file mode 100644 index 000000000000..74993211f051 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCSerialPortWriterTest.java @@ -0,0 +1,140 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.hoho.android.usbserial.driver.SerialTimeoutException; + +import org.junit.After; +import org.junit.Test; + +import org.mavlink.qgroundcontrol.serial.QGCSerialPort.NativeBridge; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +public class QGCSerialPortWriterTest { + + private QGCSerialPort port; + + @After + public void tearDown() { + if (port != null) { + port.close(QGCSerialPort.CloseReason.USER); + } + } + + private static final class RecordingBridge implements NativeBridge { + final AtomicLong totalAcked = new AtomicLong(); + final AtomicInteger ackCalls = new AtomicInteger(); + final AtomicInteger exceptionCalls = new AtomicInteger(); + final AtomicInteger lastExceptionKind = new AtomicInteger(Integer.MIN_VALUE); + final List ackSizes = Collections.synchronizedList(new ArrayList<>()); + volatile CountDownLatch latch; + + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { } + + @Override public void deviceBytesWritten(final long handle, final int n) { + totalAcked.addAndGet(n); + ackCalls.incrementAndGet(); + ackSizes.add(n); + trip(); + } + + @Override public void deviceException(final long handle, final int kind, final String message) { + lastExceptionKind.set(kind); + exceptionCalls.incrementAndGet(); + trip(); + } + + @Override public void deviceHasDisconnected(final long handle) { + trip(); + } + + private void trip() { + final CountDownLatch l = latch; + if (l != null) { + l.countDown(); + } + } + } + + private QGCSerialPort startConfiguredPort(final FakeUsbSerialPort fake, final NativeBridge bridge) { + port = new QGCSerialPort(null, null, null, fake, 0x1L, null, null); + port.setNativeBridgeForTest(bridge); + port.forceConfiguredForTest(57600); + return port; + } + + private static ByteBuffer payload(final int length) { + return ByteBuffer.allocate(length); + } + + @Test + public void fullWrite_acksExactlyRequestedBytes() throws Exception { + final RecordingBridge bridge = new RecordingBridge(); + bridge.latch = new CountDownLatch(1); + final FakeUsbSerialPort fake = new FakeUsbSerialPort((data, length, call) -> { }); + + startConfiguredPort(fake, bridge); + assertEquals(256, port.enqueueWrite(payload(256), 256)); + + assertTrue(bridge.latch.await(5, TimeUnit.SECONDS)); + assertEquals(256L, bridge.totalAcked.get()); + assertEquals(1, bridge.ackCalls.get()); + } + + @Test + public void partialStallThenAccept_acksOnlyTransferredBytesPerSubwrite() throws Exception { + final RecordingBridge bridge = new RecordingBridge(); + bridge.latch = new CountDownLatch(2); + final FakeUsbSerialPort fake = new FakeUsbSerialPort((data, length, call) -> { + if (call == 0) { + throw new SerialTimeoutException("stalled", 100); + } + }); + + startConfiguredPort(fake, bridge); + assertEquals(256, port.enqueueWrite(payload(256), 256)); + + assertTrue(bridge.latch.await(5, TimeUnit.SECONDS)); + assertEquals(256L, bridge.totalAcked.get()); + assertEquals(2, bridge.ackCalls.get()); + assertEquals(List.of(100, 156), bridge.ackSizes); + } + + @Test + public void writeIoException_firesResourceExceptionAndAcksNothing() throws Exception { + final RecordingBridge bridge = new RecordingBridge(); + bridge.latch = new CountDownLatch(1); + final FakeUsbSerialPort fake = new FakeUsbSerialPort((data, length, call) -> { + throw new IOException("device lost"); + }); + + startConfiguredPort(fake, bridge); + assertEquals(64, port.enqueueWrite(payload(64), 64)); + + assertTrue(bridge.latch.await(5, TimeUnit.SECONDS)); + assertEquals(1, bridge.exceptionCalls.get()); + assertEquals(SerialWireConstants.EXC_RESOURCE, bridge.lastExceptionKind.get()); + assertEquals(0, bridge.ackCalls.get()); + } + + @Test + public void enqueueAfterClose_returnsMinusOne() { + final RecordingBridge bridge = new RecordingBridge(); + final FakeUsbSerialPort fake = new FakeUsbSerialPort((data, length, call) -> { }); + + startConfiguredPort(fake, bridge); + port.close(QGCSerialPort.CloseReason.USER); + + assertEquals(-1, port.enqueueWrite(payload(32), 32)); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCUsbSerialManagerPortRegistryTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCUsbSerialManagerPortRegistryTest.java new file mode 100644 index 000000000000..072a42fa274e --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/QGCUsbSerialManagerPortRegistryTest.java @@ -0,0 +1,166 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import org.mavlink.qgroundcontrol.serial.QGCSerialPort.NativeBridge; +import org.mavlink.qgroundcontrol.serial.QGCSerialPort.PortLifecycleSink; +import org.mavlink.qgroundcontrol.serial.QGCUsbSerialManager.PortAddress; +import org.mavlink.qgroundcontrol.serial.QGCUsbSerialManager.PortRegistry; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +public class QGCUsbSerialManagerPortRegistryTest { + + private static final class RecordingBridge implements NativeBridge { + int exceptionCalls; + int lastExceptionKind = Integer.MIN_VALUE; + + @Override public void deviceHasDisconnected(final long handle) { } + @Override public void deviceNewData(final long handle, final ByteBuffer data, final int length) { } + @Override public void deviceBytesWritten(final long handle, final int n) { } + @Override public void deviceException(final long handle, final int kind, final String message) { + exceptionCalls++; + lastExceptionKind = kind; + } + } + + private static QGCSerialPort port(final String deviceName, final int physicalId, final int portIndex, + final long handle, final RecordingBridge bridge) { + final PortAddress address = new PortAddress(deviceName, physicalId, portIndex); + final QGCSerialPort p = new QGCSerialPort(address, null, null, null, handle, null, null); + if (bridge != null) { + p.setNativeBridgeForTest(bridge); + } + return p; + } + + @Test + public void register_putIfAbsentRejectsDuplicateAddress() { + final PortRegistry registry = new PortRegistry(); + final QGCSerialPort first = port("dev/a", 1, 0, 0x1L, null); + final QGCSerialPort dup = port("dev/a", 1, 0, 0x2L, null); + + assertTrue(registry.register(first)); + assertFalse(registry.register(dup)); + assertSame(first, registry.get(new PortAddress("dev/a", 1, 0))); + } + + @Test + public void unregister_removesOnlyOnValueMatch() { + final PortRegistry registry = new PortRegistry(); + final QGCSerialPort registered = port("dev/a", 1, 0, 0x1L, null); + final QGCSerialPort stranger = port("dev/a", 1, 0, 0x2L, null); + registry.register(registered); + + registry.unregister(stranger); + assertSame(registered, registry.get(new PortAddress("dev/a", 1, 0))); + + registry.unregister(registered); + assertNull(registry.get(new PortAddress("dev/a", 1, 0))); + } + + @Test + public void portsForDeviceName_filtersByDeviceName() { + final PortRegistry registry = new PortRegistry(); + final QGCSerialPort a0 = port("dev/a", 1, 0, 0x1L, null); + final QGCSerialPort a1 = port("dev/a", 1, 1, 0x2L, null); + final QGCSerialPort b0 = port("dev/b", 2, 0, 0x3L, null); + registry.register(a0); + registry.register(a1); + registry.register(b0); + + final List matches = registry.portsForDeviceName("dev/a"); + + assertEquals(2, matches.size()); + assertTrue(matches.contains(a0)); + assertTrue(matches.contains(a1)); + assertFalse(matches.contains(b0)); + } + + @Test + public void portsForDeviceName_returnsEmptyWhenNoMatch() { + final PortRegistry registry = new PortRegistry(); + registry.register(port("dev/a", 1, 0, 0x1L, null)); + + assertTrue(registry.portsForDeviceName("dev/missing").isEmpty()); + } + + @Test + public void allPorts_returnsEveryRegisteredPort() { + final PortRegistry registry = new PortRegistry(); + final QGCSerialPort a = port("dev/a", 1, 0, 0x1L, null); + final QGCSerialPort b = port("dev/b", 2, 0, 0x2L, null); + registry.register(a); + registry.register(b); + + final List all = new ArrayList<>(registry.allPorts()); + + assertEquals(2, all.size()); + assertTrue(all.contains(a)); + assertTrue(all.contains(b)); + } + + @Test + public void close_clearsRegistryBeforeOnPortClosedFires() { + final PortRegistry registry = new PortRegistry(); + final PortAddress address = new PortAddress("dev/a", 1, 0); + final boolean[] emptyWhenClosed = { false }; + final boolean[] onPortClosedFired = { false }; + + final QGCSerialPort[] holder = new QGCSerialPort[1]; + final PortLifecycleSink sink = new PortLifecycleSink() { + @Override + public void onPortConfigured(final QGCSerialPort port) {} + + @Override + public void onPortClosed(final QGCSerialPort port) { + onPortClosedFired[0] = true; + // C++ unregisterPort runs LAST (after this callback); the Java registry must already be cleared here. + emptyWhenClosed[0] = registry.get(address) == null; + } + + @Override + public void onPortDeviceError(final QGCSerialPort port) {} + }; + final QGCSerialPort p = new QGCSerialPort(address, null, null, null, 0x42L, sink, + () -> registry.unregister(holder[0])); + holder[0] = p; + + assertTrue(registry.register(p)); + assertSame(p, registry.get(address)); + + p.forceConfiguredForTest(57600); + assertTrue(p.close()); + + assertTrue(onPortClosedFired[0]); + assertTrue(emptyWhenClosed[0]); + assertNull(registry.get(address)); + } + + @Test + public void firePermissionDenied_firesExcPermissionOnEveryPortForDeviceName() { + final PortRegistry registry = new PortRegistry(); + final RecordingBridge a0Bridge = new RecordingBridge(); + final RecordingBridge a1Bridge = new RecordingBridge(); + final RecordingBridge b0Bridge = new RecordingBridge(); + registry.register(port("dev/a", 1, 0, 0x10L, a0Bridge)); + registry.register(port("dev/a", 1, 1, 0x11L, a1Bridge)); + registry.register(port("dev/b", 2, 0, 0x20L, b0Bridge)); + + QGCUsbSerialManager.firePermissionDeniedForDeviceName(registry, "dev/a"); + + assertEquals(1, a0Bridge.exceptionCalls); + assertEquals(SerialWireConstants.EXC_PERMISSION, a0Bridge.lastExceptionKind); + assertEquals(1, a1Bridge.exceptionCalls); + assertEquals(SerialWireConstants.EXC_PERMISSION, a1Bridge.lastExceptionKind); + assertEquals(0, b0Bridge.exceptionCalls); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/SerialWireConstantsTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/SerialWireConstantsTest.java new file mode 100644 index 000000000000..c1def0834117 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/SerialWireConstantsTest.java @@ -0,0 +1,39 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +// Java twin of C++ SerialWireContractTest: both sides pin the same literals, so a one-sided edit breaks its own test. +public class SerialWireConstantsTest { + + @Test + public void chunkAndSentinel_matchCppTwin() { + assertEquals(16384, SerialWireConstants.MAX_CHUNK_BYTES); + assertEquals(0, SerialWireConstants.BAD_DEVICE_ID); + } + + @Test + public void exceptionKinds_matchCppTwin() { + assertEquals(0, SerialWireConstants.EXC_UNKNOWN); + assertEquals(1, SerialWireConstants.EXC_RESOURCE); + assertEquals(2, SerialWireConstants.EXC_PERMISSION); + assertEquals(3, SerialWireConstants.EXC_OPEN_FAILED); + } + + @Test + public void flowControlOrdinals_matchCppTwin() { + assertEquals(0, SerialWireConstants.FC_NONE); + assertEquals(1, SerialWireConstants.FC_RTS_CTS); + assertEquals(2, SerialWireConstants.FC_DTR_DSR); + assertEquals(3, SerialWireConstants.FC_XON_XOFF); + assertEquals(4, SerialWireConstants.FC_XON_XOFF_INLINE); + } + + @Test + public void flowControlOrdinals_matchExternalMik3yEnum() { + assertTrue("mik3y UsbSerialPort.FlowControl reordered out from under FC_* wire ordinals", + SerialWireConstants.verifyExternalFlowControlOrdinals()); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/SerialWriteLoopTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/SerialWriteLoopTest.java new file mode 100644 index 000000000000..fe6cc8392c0b --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/SerialWriteLoopTest.java @@ -0,0 +1,243 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import org.junit.After; +import org.junit.Test; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +public class SerialWriteLoopTest { + + private static final String ADDR = "dev/0/0"; + + private SerialWriteLoop loop; + + @After + public void tearDown() { + if (loop != null) { + loop.stopUnlocked(ADDR); + } + } + + private static final class FakeWriteHost implements SerialWriteLoop.WriteHost { + final Object lock = new Object(); + volatile boolean writable = true; + volatile UsbSerialPort port; + volatile int chunkSize = SerialWireConstants.MAX_CHUNK_BYTES; + final long handle = 0x99L; + + final AtomicLong totalAcked = new AtomicLong(); + final List ackSizes = Collections.synchronizedList(new ArrayList<>()); + final AtomicInteger exceptionCalls = new AtomicInteger(); + final AtomicInteger lastExceptionKind = new AtomicInteger(Integer.MIN_VALUE); + final CountDownLatch exceptionLatch = new CountDownLatch(1); + + @Override public Object lock() { return lock; } + @Override public boolean isWritableLocked() { return writable; } + @Override public long nativeHandleLocked() { return handle; } + @Override public int writeChunkSizeLocked() { return chunkSize; } + @Override public UsbSerialPort openPortOrWarnLocked(final String operation) { return port; } + @Override public void muteListenerLocked() { } + @Override public void cancelPendingWritesUnlocked() { } + + @Override public void ackBytesWritten(final long h, final int n) { + totalAcked.addAndGet(n); + ackSizes.add(n); + } + + @Override public void fireException(final int kind, final String message) { + lastExceptionKind.set(kind); + exceptionCalls.incrementAndGet(); + exceptionLatch.countDown(); + } + } + + private static ByteBuffer payload(final int length) { + return ByteBuffer.allocate(length); + } + + private boolean startLocked(final SerialWriteLoop l, final FakeWriteHost host) { + synchronized (host.lock) { + return l.startLocked(ADDR); + } + } + + @Test + public void startLocked_refusesWhenLeakedWriterStillAlive() throws Exception { + final FakeWriteHost host = new FakeWriteHost(); + final CountDownLatch blockWrite = new CountDownLatch(1); + final CountDownLatch writeEntered = new CountDownLatch(1); + host.port = new FakeUsbSerialPort((data, length, call) -> { + writeEntered.countDown(); + try { + blockWrite.await(); + } catch (final InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }); + + loop = new SerialWriteLoop(host); + assertTrue(startLocked(loop, host)); + assertEquals(256, loop.enqueue(payload(256), 256, ADDR)); + assertTrue(writeEntered.await(2, TimeUnit.SECONDS)); + + loop.stopUnlocked(ADDR); + + synchronized (host.lock) { + assertFalse(loop.startLocked(ADDR)); + } + assertFalse(loop.isRunning()); + + blockWrite.countDown(); + } + + @Test + public void startLocked_clearsSelfExitedWriterAndStartsFresh() throws Exception { + final FakeWriteHost host = new FakeWriteHost(); + host.port = new FakeUsbSerialPort(FakeUsbSerialPort.noop()); + + loop = new SerialWriteLoop(host); + assertTrue(startLocked(loop, host)); + + host.writable = false; + loop.stopUnlocked(ADDR); + assertFalse(loop.isRunning()); + + host.writable = true; + synchronized (host.lock) { + assertTrue(loop.startLocked(ADDR)); + } + assertTrue(loop.isRunning()); + } + + @Test + public void startLocked_returnsTrueEarlyWhenWriterAlreadyAlive() throws Exception { + final FakeWriteHost host = new FakeWriteHost(); + host.port = new FakeUsbSerialPort(FakeUsbSerialPort.noop()); + + loop = new SerialWriteLoop(host); + assertTrue(startLocked(loop, host)); + assertTrue(loop.isRunning()); + + synchronized (host.lock) { + assertTrue(loop.startLocked(ADDR)); + } + assertTrue(loop.isRunning()); + } + + @Test + public void stopUnlocked_parksThreadAndBlocksRestartWhenJoinTimesOut() throws Exception { + final FakeWriteHost host = new FakeWriteHost(); + final CountDownLatch blockWrite = new CountDownLatch(1); + final CountDownLatch writeEntered = new CountDownLatch(1); + host.port = new FakeUsbSerialPort((data, length, call) -> { + writeEntered.countDown(); + try { + blockWrite.await(); + } catch (final InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }); + + loop = new SerialWriteLoop(host); + assertTrue(startLocked(loop, host)); + assertEquals(256, loop.enqueue(payload(256), 256, ADDR)); + assertTrue(writeEntered.await(2, TimeUnit.SECONDS)); + + loop.stopUnlocked(ADDR); + + synchronized (host.lock) { + assertFalse(loop.startLocked(ADDR)); + } + + blockWrite.countDown(); + } + + @Test + public void timeoutRetryExhaustion_firesResourceAndAcksTransferredBytes() throws Exception { + final FakeWriteHost host = new FakeWriteHost(); + host.port = new FakeUsbSerialPort(FakeUsbSerialPort.alwaysTimeout(10)); + + loop = new SerialWriteLoop(host); + assertTrue(startLocked(loop, host)); + assertEquals(256, loop.enqueue(payload(256), 256, ADDR)); + + assertTrue(host.exceptionLatch.await(5, TimeUnit.SECONDS)); + assertEquals(1, host.exceptionCalls.get()); + assertEquals(SerialWireConstants.EXC_RESOURCE, host.lastExceptionKind.get()); + assertEquals(List.of(10, 10, 10), host.ackSizes); + assertEquals(30L, host.totalAcked.get()); + } + + @Test + public void concurrentCloseDuringSubWriteLoop_returnsCleanlyWithoutException() throws Exception { + final FakeWriteHost host = new FakeWriteHost(); + host.chunkSize = 64; + final AtomicBoolean flipped = new AtomicBoolean(); + final CountDownLatch firstWrite = new CountDownLatch(1); + host.port = new FakeUsbSerialPort((data, length, call) -> { + if (flipped.compareAndSet(false, true)) { + host.writable = false; + firstWrite.countDown(); + } + }); + + loop = new SerialWriteLoop(host); + assertTrue(startLocked(loop, host)); + assertEquals(256, loop.enqueue(payload(256), 256, ADDR)); + + assertTrue(firstWrite.await(5, TimeUnit.SECONDS)); + loop.stopUnlocked(ADDR); + assertFalse(loop.isRunning()); + assertEquals(0, host.exceptionCalls.get()); + } + + @Test + public void enqueue_rejectsInvalidRequestWithSentinel() throws Exception { + final FakeWriteHost host = new FakeWriteHost(); + host.port = new FakeUsbSerialPort(FakeUsbSerialPort.noop()); + + loop = new SerialWriteLoop(host); + assertTrue(startLocked(loop, host)); + + assertEquals(-1, loop.enqueue(null, 256, ADDR)); + assertEquals(-1, loop.enqueue(payload(256), 0, ADDR)); + assertEquals(-1, loop.enqueue(payload(256), -5, ADDR)); + } + + @Test + public void enqueue_rejectsBeforeStartWithSentinel() { + final FakeWriteHost host = new FakeWriteHost(); + host.port = new FakeUsbSerialPort(FakeUsbSerialPort.noop()); + + loop = new SerialWriteLoop(host); + + assertFalse(loop.isRunning()); + assertEquals(-1, loop.enqueue(payload(256), 256, ADDR)); + } + + @Test + public void enqueue_rejectsWhenNotWritableWithSentinel() throws Exception { + final FakeWriteHost host = new FakeWriteHost(); + host.port = new FakeUsbSerialPort(FakeUsbSerialPort.noop()); + + loop = new SerialWriteLoop(host); + assertTrue(startLocked(loop, host)); + + host.writable = false; + assertEquals(-1, loop.enqueue(payload(256), 256, ADDR)); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbAttachDetachReceiverTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbAttachDetachReceiverTest.java new file mode 100644 index 000000000000..6f89e9165b59 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbAttachDetachReceiverTest.java @@ -0,0 +1,92 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import android.app.Application; +import android.content.Context; +import android.content.Intent; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +@Config(application = Application.class) +public class UsbAttachDetachReceiverTest { + + private Context context; + private final List attached = new ArrayList<>(); + private final List detached = new ArrayList<>(); + private UsbAttachDetachReceiver receiver; + + private static UsbDevice device(final String name) { + final UsbDevice device = ReflectionHelpers.callConstructor(UsbDevice.class); + ReflectionHelpers.setField(device, "mName", name); + return device; + } + + private void deliver(final Intent intent) { + context.sendBroadcast(intent); + Shadows.shadowOf(RuntimeEnvironment.getApplication().getMainLooper()).idle(); + } + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + receiver = new UsbAttachDetachReceiver(attached::add, detached::add); + receiver.register(context); + } + + @Test + public void attachAction_invokesOnAttachedOnly() { + final UsbDevice dev = device("/dev/bus/usb/001/010"); + deliver(new Intent(UsbManager.ACTION_USB_DEVICE_ATTACHED) + .putExtra(UsbManager.EXTRA_DEVICE, dev)); + + assertEquals(1, attached.size()); + assertSame(dev, attached.get(0)); + assertTrue(detached.isEmpty()); + } + + @Test + public void detachAction_invokesOnDetachedOnly() { + final UsbDevice dev = device("/dev/bus/usb/001/011"); + deliver(new Intent(UsbManager.ACTION_USB_DEVICE_DETACHED) + .putExtra(UsbManager.EXTRA_DEVICE, dev)); + + assertEquals(1, detached.size()); + assertSame(dev, detached.get(0)); + assertTrue(attached.isEmpty()); + } + + @Test + public void attachAction_nullDevice_isIgnored() { + deliver(new Intent(UsbManager.ACTION_USB_DEVICE_ATTACHED)); + + assertTrue(attached.isEmpty()); + assertTrue(detached.isEmpty()); + } + + @Test + public void unrelatedAction_withDevice_isIgnored() { + final UsbDevice dev = device("/dev/bus/usb/001/012"); + deliver(new Intent("org.mavlink.qgroundcontrol.action.UNRELATED") + .setPackage(context.getPackageName()) + .putExtra(UsbManager.EXTRA_DEVICE, dev)); + + assertTrue(attached.isEmpty()); + assertTrue(detached.isEmpty()); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbPermissionManagerMalformedBroadcastTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbPermissionManagerMalformedBroadcastTest.java new file mode 100644 index 000000000000..60c8b1a52504 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbPermissionManagerMalformedBroadcastTest.java @@ -0,0 +1,148 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.app.Application; +import android.content.Context; +import android.content.Intent; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowUsbManager; +import org.robolectric.util.ReflectionHelpers; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +@Config(application = Application.class) +public class UsbPermissionManagerMalformedBroadcastTest { + + private static final String ACTION_USB_PERMISSION = + "org.mavlink.qgroundcontrol.action.USB_PERMISSION"; + + private Context context; + private UsbManager usbManager; + private ShadowUsbManager shadowUsbManager; + private final List granted = new ArrayList<>(); + private final List denied = new ArrayList<>(); + private UsbPermissionManager manager; + + private static UsbDevice device(final String name) { + final UsbDevice device = ReflectionHelpers.callConstructor(UsbDevice.class); + ReflectionHelpers.setField(device, "mName", name); + return device; + } + + private Intent malformedPermissionResult() { + return new Intent(ACTION_USB_PERMISSION).setPackage(context.getPackageName()); + } + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); + shadowUsbManager = Shadow.extract(usbManager); + manager = new UsbPermissionManager(granted::add, denied::add); + manager.register(context); + } + + @Test + public void malformedBroadcast_zeroPending_resolvesNothing() { + context.sendBroadcast(malformedPermissionResult()); + Shadows.shadowOf(RuntimeEnvironment.getApplication().getMainLooper()).idle(); + + assertTrue(granted.isEmpty()); + assertTrue(denied.isEmpty()); + } + + @Test + public void malformedBroadcast_singlePendingGranted_resolvesThatDevice() { + final UsbDevice dev = device("/dev/bus/usb/001/002"); + shadowUsbManager.addOrUpdateUsbDevice(dev, true); + manager.request(usbManager, dev); + + context.sendBroadcast(malformedPermissionResult()); + Shadows.shadowOf(RuntimeEnvironment.getApplication().getMainLooper()).idle(); + + assertEquals(1, granted.size()); + assertEquals("/dev/bus/usb/001/002", granted.get(0).getDeviceName()); + assertTrue(denied.isEmpty()); + assertFalse(manager.isPermissionDenied("/dev/bus/usb/001/002")); + } + + @Test + public void malformedBroadcast_singlePendingNoSystemGrant_resolvesAsDenied() { + final UsbDevice dev = device("/dev/bus/usb/001/003"); + shadowUsbManager.addOrUpdateUsbDevice(dev, false); + manager.request(usbManager, dev); + + context.sendBroadcast(malformedPermissionResult()); + Shadows.shadowOf(RuntimeEnvironment.getApplication().getMainLooper()).idle(); + + assertEquals(1, denied.size()); + assertEquals("/dev/bus/usb/001/003", denied.get(0).getDeviceName()); + assertTrue(granted.isEmpty()); + assertTrue(manager.isPermissionDenied("/dev/bus/usb/001/003")); + } + + @Test + public void detachClearsPermissionMidDialog_malformedGrantStillResolves() { + final UsbDevice dev = device("/dev/bus/usb/001/006"); + shadowUsbManager.addOrUpdateUsbDevice(dev, true); + manager.request(usbManager, dev); + + manager.clearPermission(dev.getDeviceName()); + + context.sendBroadcast(malformedPermissionResult()); + Shadows.shadowOf(RuntimeEnvironment.getApplication().getMainLooper()).idle(); + + assertEquals(1, granted.size()); + assertEquals("/dev/bus/usb/001/006", granted.get(0).getDeviceName()); + assertTrue(denied.isEmpty()); + } + + @Test + public void malformedBroadcast_staleDetachedEntry_resolvesLiveGrant() { + final UsbDevice stale = device("/dev/bus/usb/001/007"); + final UsbDevice live = device("/dev/bus/usb/001/008"); + shadowUsbManager.addOrUpdateUsbDevice(stale, false); + shadowUsbManager.addOrUpdateUsbDevice(live, true); + manager.request(usbManager, stale); + manager.clearPermission(stale.getDeviceName()); + manager.request(usbManager, live); + + context.sendBroadcast(malformedPermissionResult()); + Shadows.shadowOf(RuntimeEnvironment.getApplication().getMainLooper()).idle(); + + assertEquals(1, granted.size()); + assertEquals("/dev/bus/usb/001/008", granted.get(0).getDeviceName()); + assertTrue(denied.isEmpty()); + } + + @Test + public void malformedBroadcast_multiplePending_resolvesNothing() { + final UsbDevice first = device("/dev/bus/usb/001/004"); + final UsbDevice second = device("/dev/bus/usb/001/005"); + shadowUsbManager.addOrUpdateUsbDevice(first, true); + shadowUsbManager.addOrUpdateUsbDevice(second, true); + manager.request(usbManager, first); + manager.request(usbManager, second); + + context.sendBroadcast(malformedPermissionResult()); + Shadows.shadowOf(RuntimeEnvironment.getApplication().getMainLooper()).idle(); + + assertTrue(granted.isEmpty()); + assertTrue(denied.isEmpty()); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbPermissionManagerStateTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbPermissionManagerStateTest.java new file mode 100644 index 000000000000..442c259d3014 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbPermissionManagerStateTest.java @@ -0,0 +1,119 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.app.Application; +import android.content.Context; +import android.content.Intent; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowUsbManager; +import org.robolectric.util.ReflectionHelpers; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +@Config(application = Application.class) +public class UsbPermissionManagerStateTest { + + private static final String ACTION_USB_PERMISSION = + "org.mavlink.qgroundcontrol.action.USB_PERMISSION"; + + private Context context; + private UsbManager usbManager; + private ShadowUsbManager shadowUsbManager; + private final List granted = new ArrayList<>(); + private final List denied = new ArrayList<>(); + private UsbPermissionManager manager; + + private static UsbDevice device(final String name) { + final UsbDevice device = ReflectionHelpers.callConstructor(UsbDevice.class); + ReflectionHelpers.setField(device, "mName", name); + return device; + } + + private void deliverMalformedResult() { + context.sendBroadcast(new Intent(ACTION_USB_PERMISSION).setPackage(context.getPackageName())); + Shadows.shadowOf(RuntimeEnvironment.getApplication().getMainLooper()).idle(); + } + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); + shadowUsbManager = Shadow.extract(usbManager); + manager = new UsbPermissionManager(granted::add, denied::add); + } + + @Test + public void deniedThenCleared_isPermissionDeniedFlipsBack() { + manager.register(context); + final UsbDevice dev = device("/dev/bus/usb/002/001"); + shadowUsbManager.addOrUpdateUsbDevice(dev, false); + manager.request(usbManager, dev); + deliverMalformedResult(); + + assertTrue(manager.isPermissionDenied("/dev/bus/usb/002/001")); + + manager.clearPermission("/dev/bus/usb/002/001"); + assertFalse(manager.isPermissionDenied("/dev/bus/usb/002/001")); + } + + @Test + public void grantedDevice_isNotReportedDenied() { + manager.register(context); + final UsbDevice dev = device("/dev/bus/usb/002/002"); + shadowUsbManager.addOrUpdateUsbDevice(dev, true); + manager.request(usbManager, dev); + deliverMalformedResult(); + + assertFalse(manager.isPermissionDenied("/dev/bus/usb/002/002")); + } + + @Test + public void unknownDevice_isNotReportedDenied() { + assertFalse(manager.isPermissionDenied("/dev/bus/usb/002/099")); + } + + @Test + public void requestBeforeRegister_noOpsAndLeavesNoPendingState() { + final UsbDevice dev = device("/dev/bus/usb/002/003"); + shadowUsbManager.addOrUpdateUsbDevice(dev, false); + + manager.request(usbManager, dev); + + assertFalse(manager.isPermissionDenied("/dev/bus/usb/002/003")); + + manager.register(context); + deliverMalformedResult(); + assertTrue(granted.isEmpty()); + assertTrue(denied.isEmpty()); + } + + @Test + public void unregisterClearsState_lateBroadcastResolvesNothing() { + manager.register(context); + final UsbDevice dev = device("/dev/bus/usb/002/004"); + shadowUsbManager.addOrUpdateUsbDevice(dev, false); + manager.request(usbManager, dev); + + manager.unregister(); + + assertFalse(manager.isPermissionDenied("/dev/bus/usb/002/004")); + + deliverMalformedResult(); + assertTrue(granted.isEmpty()); + assertTrue(denied.isEmpty()); + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbPortInfoPackingTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbPortInfoPackingTest.java new file mode 100644 index 000000000000..8e6520ebdaf6 --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbPortInfoPackingTest.java @@ -0,0 +1,131 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.app.Application; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import java.util.TreeSet; + +@RunWith(RobolectricTestRunner.class) +@Config(application = Application.class) +public class UsbPortInfoPackingTest { + + private static JSONObject parse(final byte[] packed) throws JSONException { + return new JSONObject(new String(packed, StandardCharsets.UTF_8)); + } + + @Test + public void emptyArray_packsEmptyPortsList() throws JSONException { + final JSONObject root = parse(QGCUsbSerialManager.packPortsInfo(new UsbPortInfo[0])); + assertEquals(0, root.getJSONArray("ports").length()); + } + + @Test + public void singlePort_roundTripsAllFieldsInRecordOrder() throws JSONException { + final UsbPortInfo info = new UsbPortInfo("/dev/ttyACM0", "Pixhawk", "ArduPilot", "SN123", + 0x0011, 0x26ac, new int[] { 57600, 115200, 921600 }); + final JSONObject port = parse(QGCUsbSerialManager.packPortsInfo(new UsbPortInfo[] { info })) + .getJSONArray("ports").getJSONObject(0); + + assertEquals("/dev/ttyACM0", port.getString("deviceName")); + assertEquals("Pixhawk", port.getString("productName")); + assertEquals("ArduPilot", port.getString("manufacturerName")); + assertEquals("SN123", port.getString("serialNumber")); + assertEquals(0x0011, port.getInt("productId")); + assertEquals(0x26ac, port.getInt("vendorId")); + final JSONArray bauds = port.getJSONArray("baudRates"); + assertEquals(3, bauds.length()); + assertEquals(57600, bauds.getInt(0)); + assertEquals(115200, bauds.getInt(1)); + assertEquals(921600, bauds.getInt(2)); + } + + @Test + public void nullString_omitsKeyDistinctFromEmpty() throws JSONException { + final UsbPortInfo info = new UsbPortInfo("dev", null, "", null, 1, 2, new int[0]); + final JSONObject port = parse(QGCUsbSerialManager.packPortsInfo(new UsbPortInfo[] { info })) + .getJSONArray("ports").getJSONObject(0); + + assertEquals("dev", port.getString("deviceName")); + assertFalse("null string omitted", port.has("productName")); + assertTrue("empty string present", port.has("manufacturerName")); + assertEquals("", port.getString("manufacturerName")); + assertFalse(port.has("serialNumber")); + assertEquals(0, port.getJSONArray("baudRates").length()); + } + + @Test + public void nullBaudArray_packsEmptyBaudList() throws JSONException { + final UsbPortInfo info = new UsbPortInfo("dev", "p", "m", "s", 1, 2, null); + final JSONObject port = parse(QGCUsbSerialManager.packPortsInfo(new UsbPortInfo[] { info })) + .getJSONArray("ports").getJSONObject(0); + assertEquals(0, port.getJSONArray("baudRates").length()); + } + + @Test + public void utf8MultiByte_survivesRoundTrip() throws JSONException { + final UsbPortInfo info = new UsbPortInfo("café", "", "", "", 0, 0, new int[0]); + final JSONObject port = parse(QGCUsbSerialManager.packPortsInfo(new UsbPortInfo[] { info })) + .getJSONArray("ports").getJSONObject(0); + assertEquals("café", port.getString("deviceName")); + } + + @Test + public void multiplePorts_packCountAndOrderPreserved() throws JSONException { + final UsbPortInfo a = new UsbPortInfo("a", "", "", "", 1, 1, new int[] { 9600 }); + final UsbPortInfo b = new UsbPortInfo("b", "", "", "", 2, 2, new int[] { 19200, 38400 }); + final JSONArray ports = parse(QGCUsbSerialManager.packPortsInfo(new UsbPortInfo[] { a, b })) + .getJSONArray("ports"); + + assertEquals(2, ports.length()); + assertEquals("a", ports.getJSONObject(0).getString("deviceName")); + assertEquals(9600, ports.getJSONObject(0).getJSONArray("baudRates").getInt(0)); + assertEquals("b", ports.getJSONObject(1).getString("deviceName")); + assertEquals(38400, ports.getJSONObject(1).getJSONArray("baudRates").getInt(1)); + } + + // Packs against the same on-disk fixture the C++ SerialPortInfoCodecTest decodes, so a JSON key rename + // on either side drifts from the shared golden literal and fails one suite. + @Test + public void goldenFixture_packerMatchesSharedContract() throws Exception { + final JSONObject goldenPort = loadGolden().getJSONArray("ports").getJSONObject(0); + final UsbPortInfo info = new UsbPortInfo("/dev/ttyACM0", "Pixhawk", "ArduPilot", "SN123", + 0x0011, 0x26ac, new int[] { 57600, 115200, 921600 }); + final JSONObject packed = parse(QGCUsbSerialManager.packPortsInfo(new UsbPortInfo[] { info })) + .getJSONArray("ports").getJSONObject(0); + + assertEquals(keys(goldenPort), keys(packed)); + for (final String key : keys(goldenPort)) { + assertEquals("value for key " + key, goldenPort.get(key).toString(), packed.get(key).toString()); + } + } + + private static JSONObject loadGolden() throws Exception { + try (InputStream in = UsbPortInfoPackingTest.class.getResourceAsStream("/PortInfoGolden.json")) { + assertNotNull("golden fixture missing — check test resources.srcDirs in android/build.gradle", in); + return new JSONObject(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + private static Set keys(final JSONObject obj) { + final Set result = new TreeSet<>(); + for (final java.util.Iterator it = obj.keys(); it.hasNext(); ) { + result.add(it.next()); + } + return result; + } +} diff --git a/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbSerialEnumeratorParsingTest.java b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbSerialEnumeratorParsingTest.java new file mode 100644 index 000000000000..9228d1f560ca --- /dev/null +++ b/android/src/test/java/org/mavlink/qgroundcontrol/serial/UsbSerialEnumeratorParsingTest.java @@ -0,0 +1,71 @@ +package org.mavlink.qgroundcontrol.serial; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class UsbSerialEnumeratorParsingTest { + + @Test + public void nullOrEmpty_yieldsEmptyBasePortZero() { + UsbSerialEnumerator.DevicePortSpec s = UsbSerialEnumerator.parseDevicePortSpec(null); + assertEquals("", s.baseDeviceName()); + assertEquals(0, s.portIndex()); + + s = UsbSerialEnumerator.parseDevicePortSpec(""); + assertEquals("", s.baseDeviceName()); + assertEquals(0, s.portIndex()); + } + + @Test + public void noSuffix_returnsWholeNamePortZero() { + final UsbSerialEnumerator.DevicePortSpec s = + UsbSerialEnumerator.parseDevicePortSpec("/dev/bus/usb/001/002"); + assertEquals("/dev/bus/usb/001/002", s.baseDeviceName()); + assertEquals(0, s.portIndex()); + } + + @Test + public void validSuffix_splitsBaseAndIndex() { + final UsbSerialEnumerator.DevicePortSpec s = + UsbSerialEnumerator.parseDevicePortSpec("/dev/bus/usb/001/002" + UsbSerialEnumerator.PORT_SUFFIX + "2"); + assertEquals("/dev/bus/usb/001/002", s.baseDeviceName()); + assertEquals(2, s.portIndex()); + } + + @Test + public void malformedSuffix_fallsBackToWholeNamePortZero() { + final UsbSerialEnumerator.DevicePortSpec s = + UsbSerialEnumerator.parseDevicePortSpec("/dev/x" + UsbSerialEnumerator.PORT_SUFFIX + "ZZ"); + assertEquals("/dev/x" + UsbSerialEnumerator.PORT_SUFFIX + "ZZ", s.baseDeviceName()); + assertEquals(0, s.portIndex()); + } + + @Test + public void suffixAtStart_treatedAsNoSplit() { + final UsbSerialEnumerator.DevicePortSpec s = + UsbSerialEnumerator.parseDevicePortSpec(UsbSerialEnumerator.PORT_SUFFIX + "2"); + assertEquals(UsbSerialEnumerator.PORT_SUFFIX + "2", s.baseDeviceName()); + assertEquals(0, s.portIndex()); + } + + @Test + public void negativeSuffix_clampedToZero() { + final UsbSerialEnumerator.DevicePortSpec s = + UsbSerialEnumerator.parseDevicePortSpec("name" + UsbSerialEnumerator.PORT_SUFFIX + "-1"); + assertEquals("name", s.baseDeviceName()); + assertEquals(0, s.portIndex()); + } + + @Test + public void devicePortSpec_clampsNegativeIndex() { + assertEquals(0, UsbSerialEnumerator.DevicePortSpec.of("x", -5).portIndex()); + assertEquals(3, UsbSerialEnumerator.DevicePortSpec.of("x", 3).portIndex()); + } + + @Test + public void getPortFromNullDriver_returnsNull() { + assertNull(UsbSerialEnumerator.getPortFromDriver(null, 0)); + } +} diff --git a/cmake/platform/Android.cmake b/cmake/platform/Android.cmake index cca4e8c0c8f6..08170dd1eba4 100644 --- a/cmake/platform/Android.cmake +++ b/cmake/platform/Android.cmake @@ -70,6 +70,7 @@ set(QGC_ANDROID_PROPERTIES_CONTENT "QGC_ANDROID_VERSION_CODE=${ANDROID_VERSION_CODE}\n" "QGC_ANDROID_VERSION_NAME=${CMAKE_PROJECT_VERSION}\n" "QGC_CPM_JAVA_SRC_DIR=${CMAKE_BINARY_DIR}/extra_java_sources\n" + "QGC_SERIAL_TESTDATA_DIR=${CMAKE_SOURCE_DIR}/test/Comms/Serial/data\n" ) string(JOIN "" QGC_ANDROID_PROPERTIES_CONTENT ${QGC_ANDROID_PROPERTIES_CONTENT}) file(GENERATE diff --git a/src/Android/AndroidInit.cc b/src/Android/AndroidInit.cc index 44b56382b48e..580743ad8b86 100644 --- a/src/Android/AndroidInit.cc +++ b/src/Android/AndroidInit.cc @@ -1,6 +1,8 @@ #include "AndroidInterface.h" +#include "AndroidLogSink.h" #ifndef QGC_NO_SERIAL_LINK -#include "AndroidSerial.h" +#include "AndroidSerialPort.h" +#include "AndroidSerialPortRegistry.h" #endif #include #include @@ -43,51 +45,27 @@ extern "C" #endif +static jboolean jniInit(JNIEnv *env, jobject thiz); +Q_DECLARE_JNI_NATIVE_METHOD(jniInit, nativeInit) + static jboolean jniInit(JNIEnv *env, jobject thiz) { qCDebug(AndroidInitLog) << Q_FUNC_INFO; - const jclass context_cls = env->GetObjectClass(thiz); - if (!context_cls) { - return JNI_FALSE; - } - - const jmethodID get_app_context_id = env->GetMethodID(context_cls, "getApplicationContext", "()Landroid/content/Context;"); - env->DeleteLocalRef(context_cls); - if (QJniEnvironment::checkAndClearExceptions(env)) { - return JNI_FALSE; - } - - const jobject app_context = env->CallObjectMethod(thiz, get_app_context_id); - if (QJniEnvironment::checkAndClearExceptions(env) || !app_context) { - return JNI_FALSE; - } - - const jclass app_context_cls = env->GetObjectClass(app_context); - if (!app_context_cls) { - env->DeleteLocalRef(app_context); - return JNI_FALSE; - } - - const jmethodID get_class_loader_id = env->GetMethodID(app_context_cls, "getClassLoader", "()Ljava/lang/ClassLoader;"); - env->DeleteLocalRef(app_context_cls); - if (QJniEnvironment::checkAndClearExceptions(env)) { - env->DeleteLocalRef(app_context); + const QJniObject activity(thiz); + const QJniObject appContext = activity.callObjectMethod("getApplicationContext", "()Landroid/content/Context;"); + if (QJniEnvironment::checkAndClearExceptions(env) || !appContext.isValid()) { return JNI_FALSE; } - const jobject class_loader = env->CallObjectMethod(app_context, get_class_loader_id); - if (QJniEnvironment::checkAndClearExceptions(env)) { - env->DeleteLocalRef(app_context); + const QJniObject classLoader = appContext.callObjectMethod("getClassLoader", "()Ljava/lang/ClassLoader;"); + if (QJniEnvironment::checkAndClearExceptions(env) || !classLoader.isValid()) { return JNI_FALSE; } - const jobject app_context_global = env->NewGlobalRef(app_context); - const jobject class_loader_global = env->NewGlobalRef(class_loader); - - env->DeleteLocalRef(app_context); - env->DeleteLocalRef(class_loader); - + // GStreamer's C-ABI accessors return a raw jobject for the process lifetime, so promote to global refs the QJniObject temporaries don't own. + const jobject app_context_global = env->NewGlobalRef(appContext.object()); + const jobject class_loader_global = env->NewGlobalRef(classLoader.object()); if (!app_context_global || !class_loader_global || QJniEnvironment::checkAndClearExceptions(env)) { if (app_context_global) { env->DeleteGlobalRef(app_context_global); @@ -112,32 +90,14 @@ static jint jniSetNativeMethods() { qCDebug(AndroidInitLog) << Q_FUNC_INFO; - const JNINativeMethod javaMethods[] { - {"nativeInit", "()Z", reinterpret_cast(jniInit)}, - }; - - QJniEnvironment jniEnv; - (void) jniEnv.checkAndClearExceptions(); - - jclass objectClass = jniEnv->FindClass(AndroidInterface::kJniQGCActivityClassName); - if (!objectClass) { - qCWarning(AndroidInitLog) << "Couldn't find class:" << AndroidInterface::kJniQGCActivityClassName; - (void) jniEnv.checkAndClearExceptions(); - return JNI_ERR; - } - - const jint val = jniEnv->RegisterNatives(objectClass, javaMethods, std::size(javaMethods)); - jniEnv->DeleteLocalRef(objectClass); - if (val < 0) { - qCWarning(AndroidInitLog) << "Error registering methods:" << val; - (void) jniEnv.checkAndClearExceptions(); + QJniEnvironment env; + if (!env.registerNativeMethods({ Q_JNI_NATIVE_METHOD(jniInit) })) { + qCWarning(AndroidInitLog) << "Failed to register native methods for" + << AndroidInterface::kJniQGCActivityClassName; return JNI_ERR; } qCDebug(AndroidInitLog) << "Main Native Functions Registered"; - - (void) jniEnv.checkAndClearExceptions(); - return JNI_OK; } @@ -157,9 +117,10 @@ jint JNI_OnLoad(JavaVM* vm, void*) } AndroidInterface::setNativeMethods(); + AndroidLogSink::setNativeMethods(); #ifndef QGC_NO_SERIAL_LINK - AndroidSerial::setNativeMethods(); + AndroidSerialPort::initializeNative(); #endif QNativeInterface::QAndroidApplication::hideSplashScreen(333); @@ -181,9 +142,10 @@ void JNI_OnUnload(JavaVM* vm, void*) } } - _java_vm.store(nullptr, std::memory_order_release); - #ifndef QGC_NO_SERIAL_LINK - AndroidSerial::cleanupJniCache(); + // Drop token->port entries so a same-process native reload doesn't inherit stale pointers. + PortRegistry::clear(); #endif + + _java_vm.store(nullptr, std::memory_order_release); } diff --git a/src/Android/AndroidInterface.cc b/src/Android/AndroidInterface.cc index c1c7bb02d683..2e12f47c8c81 100644 --- a/src/Android/AndroidInterface.cc +++ b/src/Android/AndroidInterface.cc @@ -9,8 +9,11 @@ #include #include #include +#include +#include #include #include +#include #include "AppSettings.h" #include "QGCApplication.h" @@ -20,20 +23,15 @@ QGC_LOGGING_CATEGORY(AndroidInterfaceLog, "Android.AndroidInterface") -namespace AndroidInterface { +// QtJniTypes::QGCActivity declared in AndroidInterface.h (shared with AndroidInit.cc). +namespace AndroidInterface { static std::function s_importCallback; +static QMutex s_importCallbackMutex; +} // namespace AndroidInterface -static void jniLogDebug(JNIEnv*, jobject, jstring message) -{ - qCDebug(AndroidInterfaceLog) << QJniObject(message).toString(); -} - -static void jniLogWarning(JNIEnv*, jobject, jstring message) -{ - qCWarning(AndroidInterfaceLog) << QJniObject(message).toString(); -} - +// File-static (not in namespace) so Q_DECLARE_JNI_NATIVE_METHOD can name the function via +// QT_PREPEND_NAMESPACE. Mirrors Qt BLE jni_android.cpp. static void jniStoragePermissionsResult(JNIEnv*, jobject, jboolean granted) { if (!granted) { @@ -41,13 +39,17 @@ static void jniStoragePermissionsResult(JNIEnv*, jobject, jboolean granted) return; } - if (!qgcApp()) { + QPointer app = qgcApp(); + if (!app) { return; } (void)QMetaObject::invokeMethod( - qgcApp(), - []() { + app.data(), + [app]() { + if (!app) { + return; + } SettingsManager* const settingsManager = SettingsManager::instance(); if (!settingsManager) { return; @@ -72,7 +74,7 @@ static void jniStoragePermissionsResult(JNIEnv*, jobject, jboolean granted) return; } - const QString sdCardRootPath = getSDCardPath(); + const QString sdCardRootPath = AndroidInterface::getSDCardPath(); if (sdCardRootPath.isEmpty() || !QDir(sdCardRootPath).exists() || !QFileInfo(sdCardRootPath).isWritable()) { return; } @@ -85,32 +87,45 @@ static void jniStoragePermissionsResult(JNIEnv*, jobject, jboolean granted) }, Qt::QueuedConnection); } +Q_DECLARE_JNI_NATIVE_METHOD(jniStoragePermissionsResult, nativeStoragePermissionsResult) static void jniOnImportResult(JNIEnv* env, jobject, jstring filePathA) { + if (!filePathA) { + return; + } const char* const filePathCStr = env->GetStringUTFChars(filePathA, nullptr); + if (QJniEnvironment::checkAndClearExceptions(env, QJniEnvironment::OutputMode::Verbose) || !filePathCStr) { + qCWarning(AndroidInterfaceLog) << "GetStringUTFChars failed in jniOnImportResult"; + if (filePathCStr) { + env->ReleaseStringUTFChars(filePathA, filePathCStr); + } + return; + } const QString filePath = QString::fromUtf8(filePathCStr); env->ReleaseStringUTFChars(filePathA, filePathCStr); - (void)QJniEnvironment::checkAndClearExceptions(env); - auto callback = std::move(s_importCallback); + + std::function callback; + { + QMutexLocker lk(&AndroidInterface::s_importCallbackMutex); + callback = std::move(AndroidInterface::s_importCallback); + } if (!callback) { return; } callback(filePath); } +Q_DECLARE_JNI_NATIVE_METHOD(jniOnImportResult, onImportResult) + +namespace AndroidInterface { void setNativeMethods() { - qCDebug(AndroidInterfaceLog) << "Registering Native Functions"; - - const JNINativeMethod javaMethods[]{ - {"qgcLogDebug", "(Ljava/lang/String;)V", reinterpret_cast(jniLogDebug)}, - {"qgcLogWarning", "(Ljava/lang/String;)V", reinterpret_cast(jniLogWarning)}, - {"nativeStoragePermissionsResult", "(Z)V", reinterpret_cast(jniStoragePermissionsResult)}, - {"onImportResult", "(Ljava/lang/String;)V", reinterpret_cast(jniOnImportResult)}}; - QJniEnvironment env; - if (!env.registerNativeMethods(kJniQGCActivityClassName, javaMethods, std::size(javaMethods))) { + if (!env.registerNativeMethods({ + Q_JNI_NATIVE_METHOD(jniStoragePermissionsResult), + Q_JNI_NATIVE_METHOD(jniOnImportResult), + })) { qCWarning(AndroidInterfaceLog) << "Failed to register native methods for" << kJniQGCActivityClassName; } else { qCDebug(AndroidInterfaceLog) << "Native Functions Registered"; @@ -160,7 +175,10 @@ QString getSDCardPath() void openFileImportDialog(const QString& destPath, std::function callback) { - s_importCallback = std::move(callback); + { + QMutexLocker lk(&s_importCallbackMutex); + s_importCallback = std::move(callback); + } const QJniObject jDestPath = QJniObject::fromString(destPath); QJniObject::callStaticMethod( @@ -172,8 +190,12 @@ void openFileImportDialog(const QString& destPath, std::function cb; + { + QMutexLocker lk(&s_importCallbackMutex); + cb = std::move(s_importCallback); + } + if (cb) { cb(QString()); } } diff --git a/src/Android/AndroidInterface.h b/src/Android/AndroidInterface.h index 141d78f7feed..067a61d37800 100644 --- a/src/Android/AndroidInterface.h +++ b/src/Android/AndroidInterface.h @@ -1,11 +1,11 @@ #pragma once -#include -#include #include - +#include #include +Q_DECLARE_JNI_CLASS(QGCActivity, "org/mavlink/qgroundcontrol/QGCActivity") + namespace AndroidInterface { void setNativeMethods(); bool checkStoragePermissions(); @@ -14,87 +14,4 @@ void setKeepScreenOn(bool on); void openFileImportDialog(const QString& destPath, std::function callback); constexpr const char* kJniQGCActivityClassName = "org/mavlink/qgroundcontrol/QGCActivity"; - -template -class JniLocalRef -{ -public: - JniLocalRef(JNIEnv* env, T ref = nullptr) : _env(env), _ref(ref) - { - } - - ~JniLocalRef() - { - reset(); - } - - JniLocalRef(const JniLocalRef&) = delete; - JniLocalRef& operator=(const JniLocalRef&) = delete; - - JniLocalRef(JniLocalRef&& other) noexcept : _env(other._env), _ref(other._ref) - { - other._ref = nullptr; - } - - JniLocalRef& operator=(JniLocalRef&& other) noexcept - { - if (this == &other) { - return *this; - } - - reset(); - _env = other._env; - _ref = other._ref; - other._ref = nullptr; - return *this; - } - - T get() const - { - return _ref; - } - - operator T() const - { - return _ref; - } - - void reset(T ref = nullptr) - { - if (_env && _ref) { - _env->DeleteLocalRef(_ref); - } - _ref = ref; - } - -private: - JNIEnv* _env = nullptr; - T _ref = nullptr; -}; - -template -inline bool callStaticIntMethod(QJniEnvironment& env, jclass cls, jmethodID method, const char* caller, - const QLoggingCategory& logCategory, jint& result, Args... args) -{ - result = env->CallStaticIntMethod(cls, method, args...); - if (env.checkAndClearExceptions()) { - qCWarning(logCategory) << "Exception occurred while calling" << caller; - return false; - } - - return true; -} - -template -inline bool callStaticBooleanMethod(QJniEnvironment& env, jclass cls, jmethodID method, const char* caller, - const QLoggingCategory& logCategory, jboolean& result, Args... args) -{ - result = env->CallStaticBooleanMethod(cls, method, args...); - if (env.checkAndClearExceptions()) { - qCWarning(logCategory) << "Exception occurred while calling" << caller; - return false; - } - - return true; -} } // namespace AndroidInterface diff --git a/src/Android/AndroidLogSink.cc b/src/Android/AndroidLogSink.cc new file mode 100644 index 000000000000..2624dd4553df --- /dev/null +++ b/src/Android/AndroidLogSink.cc @@ -0,0 +1,89 @@ +#include +#include +#include +#include +#include + +#include + +#include "QGCLoggingCategory.h" + +// Log category that receives all Java-side log output forwarded through +// QGCNativeLogSink / QGCLogger. Enable with: +// QT_LOGGING_RULES="qgc.android.java=true" +QGC_LOGGING_CATEGORY(AndroidJavaLog, "Android.LogSink") + +Q_DECLARE_JNI_CLASS(QGCNativeLogSink, "org/mavlink/qgroundcontrol/QGCNativeLogSink") + +// Level ordinals must match QGCNativeLogSink.java constants. +namespace { +constexpr jint kLevelDebug = 0; +constexpr jint kLevelInfo = 1; +constexpr jint kLevelWarning = 2; +constexpr jint kLevelError = 3; +} // namespace + +// File-static (not anon-namespaced) so Q_DECLARE_JNI_NATIVE_METHOD can name it via +// QT_PREPEND_NAMESPACE. Matches the Qt BLE jni_android.cpp pattern. +static void nativeLog(JNIEnv* env, jobject /*obj*/, jint level, + jstring jTag, jstring jMessage) +{ + thread_local bool inSink = false; + if (inSink) return; + inSink = true; + struct G { ~G() { inSink = false; } } g; + + if (!jTag || !jMessage) { + return; + } + + // The qC* macro only suppresses operator<<, not argument evaluation, so gate the JNI toString() + // round-trip on the category being enabled at this level before building the string. + const auto build = [&] { + QString out = QJniObject(jTag).toString() + QStringLiteral(": ") + QJniObject(jMessage).toString(); + (void)QJniEnvironment::checkAndClearExceptions(env); + return out; + }; + + const QLoggingCategory &cat = AndroidJavaLog(); + switch (level) { + case kLevelDebug: + if (cat.isDebugEnabled()) { + qCDebug(AndroidJavaLog).noquote() << build(); + } + break; + case kLevelInfo: + if (cat.isInfoEnabled()) { + qCInfo(AndroidJavaLog).noquote() << build(); + } + break; + case kLevelWarning: + if (cat.isWarningEnabled()) { + qCWarning(AndroidJavaLog).noquote() << build(); + } + break; + case kLevelError: + default: + if (cat.isCriticalEnabled()) { + qCCritical(AndroidJavaLog).noquote() << build(); + } + break; + } +} +Q_DECLARE_JNI_NATIVE_METHOD(nativeLog) + +namespace AndroidLogSink { + +void setNativeMethods() +{ + QJniEnvironment env; + if (!env.registerNativeMethods({ + Q_JNI_NATIVE_METHOD(nativeLog), + })) { + qCWarning(AndroidJavaLog) << "Failed to register native methods for QGCNativeLogSink"; + } else { + qCDebug(AndroidJavaLog) << "QGCNativeLogSink native methods registered"; + } +} + +} // namespace AndroidLogSink diff --git a/src/Android/AndroidLogSink.h b/src/Android/AndroidLogSink.h new file mode 100644 index 000000000000..45caa9867b2b --- /dev/null +++ b/src/Android/AndroidLogSink.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +Q_DECLARE_LOGGING_CATEGORY(AndroidJavaLog) + +namespace AndroidLogSink { + +/** + * Registers the JNI native method for QGCNativeLogSink.nativeLog(). + * Must be called from JNI_OnLoad (or equivalent) after the class loader + * is available. + */ +void setNativeMethods(); + +} // namespace AndroidLogSink diff --git a/src/Android/AndroidSerial.cc b/src/Android/AndroidSerial.cc deleted file mode 100644 index 70c0df9f3a02..000000000000 --- a/src/Android/AndroidSerial.cc +++ /dev/null @@ -1,975 +0,0 @@ -#include "AndroidSerial.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "AndroidInterface.h" -#include "QGCLoggingCategory.h" - -QGC_LOGGING_CATEGORY(AndroidSerialLog, "Android.AndroidSerial"); - -namespace AndroidSerial { - -// ---------------------------------------------------------------------------- -// Token-based pointer tracking (UAF protection) -// -// Java receives an opaque random jlong token instead of a raw C++ pointer. -// A bidirectional hash map under QReadWriteLock maps tokens ↔ pointers. -// JNI callbacks (readers) take a shared read lock; register/unregister -// (writers) take an exclusive write lock. Pattern follows Qt Bluetooth's -// LowEnergyNotificationHub. -// ---------------------------------------------------------------------------- - -static QReadWriteLock s_ptrLock; -static QHash s_tokenToPtr; -static QHash s_ptrToToken; - -void registerPointer(QSerialPortPrivate* ptr) -{ - if (!ptr) { - qCWarning(AndroidSerialLog) << "registerPointer called with null pointer"; - return; - } - - QWriteLocker locker(&s_ptrLock); - - const auto existingIt = s_ptrToToken.constFind(ptr); - if (existingIt != s_ptrToToken.cend()) { - s_tokenToPtr.remove(*existingIt); - s_ptrToToken.erase(existingIt); - } - - jlong token; - do { - token = static_cast(QRandomGenerator::global()->generate64()); - } while (token == 0 || s_tokenToPtr.contains(token)); - - s_tokenToPtr.insert(token, ptr); - s_ptrToToken.insert(ptr, token); -} - -void unregisterPointer(QSerialPortPrivate* ptr) -{ - if (!ptr) { - return; - } - - QWriteLocker locker(&s_ptrLock); - const jlong token = s_ptrToToken.take(ptr); - if (token == 0) { - return; - } - s_tokenToPtr.remove(token); -} - -static QSerialPortPrivate* lookupByToken(jlong token) -{ - QReadLocker locker(&s_ptrLock); - return s_tokenToPtr.value(token, nullptr); -} - -static jlong lookupToken(QSerialPortPrivate* ptr) -{ - QReadLocker locker(&s_ptrLock); - return s_ptrToToken.value(ptr, 0); -} - -static QSerialPort* lookupPortByTokenLocked(jlong token) -{ - QSerialPortPrivate* const serialPortPrivate = s_tokenToPtr.value(token, nullptr); - if (!serialPortPrivate) { - return nullptr; - } - - return qobject_cast(serialPortPrivate->q_ptr); -} - -template -static bool dispatchToPortObject(QSerialPort* serialPort, Functor&& func, const char* context) -{ - if (!serialPort) { - qCWarning(AndroidSerialLog) << context << ": null serial port"; - return false; - } - - QThread* const targetThread = serialPort->thread(); - const bool sameThread = (targetThread == QThread::currentThread()); - const bool hasEventLoop = targetThread && targetThread->eventDispatcher(); - - if (sameThread) { - std::forward(func)(); - return true; - } - - if (hasEventLoop) { - // BlockingQueuedConnection ensures the operation completes on the target thread - // before returning to the JNI caller (e.g. device disconnect is fully processed - // before Java-side cleanup continues). - const bool ok = QMetaObject::invokeMethod(serialPort, std::forward(func), Qt::BlockingQueuedConnection); - if (!ok) { - qCWarning(AndroidSerialLog) << context << ": failed to invoke method on target thread"; - } - return ok; - } - - qCWarning(AndroidSerialLog) << context << ": target thread has no event loop, running inline fallback"; - std::forward(func)(); - return true; -} - -// ---------------------------------------------------------------------------- -// JNI method ID cache -// ---------------------------------------------------------------------------- - -struct JniMethodCache -{ - jmethodID availableDevicesInfo = nullptr; - jmethodID getDeviceId = nullptr; - jmethodID getDeviceHandle = nullptr; - jmethodID open = nullptr; - jmethodID close = nullptr; - jmethodID isDeviceNameOpen = nullptr; - jmethodID read = nullptr; - jmethodID write = nullptr; - jmethodID writeAsync = nullptr; - jmethodID setParameters = nullptr; - jmethodID getCarrierDetect = nullptr; - jmethodID getClearToSend = nullptr; - jmethodID getDataSetReady = nullptr; - jmethodID getDataTerminalReady = nullptr; - jmethodID setDataTerminalReady = nullptr; - jmethodID getRingIndicator = nullptr; - jmethodID getRequestToSend = nullptr; - jmethodID setRequestToSend = nullptr; - jmethodID getControlLines = nullptr; - jmethodID getFlowControl = nullptr; - jmethodID setFlowControl = nullptr; - jmethodID purgeBuffers = nullptr; - jmethodID setBreak = nullptr; - jmethodID startIoManager = nullptr; - jmethodID stopIoManager = nullptr; - jmethodID ioManagerRunning = nullptr; -}; - -static JniMethodCache s_methods; -static bool s_methodsCached = false; -static QMutex s_cacheLock; -static jclass s_serialManagerClass = nullptr; - -static bool cacheMethodIds(JNIEnv* env, jclass javaClass) -{ - struct MethodDef - { - jmethodID* target; - const char* name; - const char* sig; - }; - - const MethodDef defs[] = { - {&s_methods.availableDevicesInfo, "availableDevicesInfo", "()[Ljava/lang/String;"}, - {&s_methods.getDeviceId, "getDeviceId", "(Ljava/lang/String;)I"}, - {&s_methods.getDeviceHandle, "getDeviceHandle", "(I)I"}, - {&s_methods.open, "open", "(Ljava/lang/String;J)I"}, - {&s_methods.close, "close", "(I)Z"}, - {&s_methods.isDeviceNameOpen, "isDeviceNameOpen", "(Ljava/lang/String;)Z"}, - {&s_methods.read, "read", "(III)[B"}, - {&s_methods.write, "write", "(I[BII)I"}, - {&s_methods.writeAsync, "writeAsync", "(I[BI)I"}, - {&s_methods.setParameters, "setParameters", "(IIIII)Z"}, - {&s_methods.getCarrierDetect, "getCarrierDetect", "(I)Z"}, - {&s_methods.getClearToSend, "getClearToSend", "(I)Z"}, - {&s_methods.getDataSetReady, "getDataSetReady", "(I)Z"}, - {&s_methods.getDataTerminalReady, "getDataTerminalReady", "(I)Z"}, - {&s_methods.setDataTerminalReady, "setDataTerminalReady", "(IZ)Z"}, - {&s_methods.getRingIndicator, "getRingIndicator", "(I)Z"}, - {&s_methods.getRequestToSend, "getRequestToSend", "(I)Z"}, - {&s_methods.setRequestToSend, "setRequestToSend", "(IZ)Z"}, - {&s_methods.getControlLines, "getControlLines", "(I)[I"}, - {&s_methods.getFlowControl, "getFlowControl", "(I)I"}, - {&s_methods.setFlowControl, "setFlowControl", "(II)Z"}, - {&s_methods.purgeBuffers, "purgeBuffers", "(IZZ)Z"}, - {&s_methods.setBreak, "setBreak", "(IZ)Z"}, - {&s_methods.startIoManager, "startIoManager", "(I)Z"}, - {&s_methods.stopIoManager, "stopIoManager", "(I)Z"}, - {&s_methods.ioManagerRunning, "ioManagerRunning", "(I)Z"}, - }; - - for (const auto& def : defs) { - *def.target = env->GetStaticMethodID(javaClass, def.name, def.sig); - if (!*def.target) { - qCWarning(AndroidSerialLog) << "Failed to cache method:" << def.name << def.sig; - (void)QJniEnvironment::checkAndClearExceptions(env); - return false; - } - } - - s_methodsCached = true; - qCDebug(AndroidSerialLog) << "All JNI method IDs cached successfully"; - return true; -} - -// ---------------------------------------------------------------------------- -// Class resolution -// ---------------------------------------------------------------------------- - -static jclass getSerialManagerClass() -{ - QMutexLocker locker(&s_cacheLock); - - if (s_serialManagerClass && s_methodsCached) { - return s_serialManagerClass; - } - - QJniEnvironment env; - if (!env.isValid()) { - qCWarning(AndroidSerialLog) << "Invalid QJniEnvironment"; - return nullptr; - } - - if (!s_serialManagerClass) { - const jclass resolvedClass = env.findClass(kJniUsbSerialManagerClassName); - if (!resolvedClass) { - qCWarning(AndroidSerialLog) << "Class Not Found:" << kJniUsbSerialManagerClassName; - return nullptr; - } - - s_serialManagerClass = static_cast(env->NewGlobalRef(resolvedClass)); - if (env->GetObjectRefType(resolvedClass) == JNILocalRefType) { - env->DeleteLocalRef(resolvedClass); - } - - if (!s_serialManagerClass) { - qCWarning(AndroidSerialLog) << "Failed to create global ref for class:" << kJniUsbSerialManagerClassName; - (void)env.checkAndClearExceptions(); - return nullptr; - } - } - - if (!s_methodsCached && !cacheMethodIds(env.jniEnv(), s_serialManagerClass)) { - qCWarning(AndroidSerialLog) << "Failed to cache JNI method IDs"; - env->DeleteGlobalRef(s_serialManagerClass); - s_serialManagerClass = nullptr; - s_methods = {}; - (void)env.checkAndClearExceptions(); - return nullptr; - } - - s_methodsCached = true; - (void)env.checkAndClearExceptions(); - return s_serialManagerClass; -} - -void cleanupJniCache() -{ - QMutexLocker locker(&s_cacheLock); - QJniEnvironment env; - if (s_serialManagerClass && env.isValid()) { - env->DeleteGlobalRef(s_serialManagerClass); - } - s_serialManagerClass = nullptr; - s_methods = {}; - s_methodsCached = false; -} - -// ---------------------------------------------------------------------------- -// Native method registration -// ---------------------------------------------------------------------------- - -// Forward declarations for JNI callbacks (defined below) -static void jniDeviceHasDisconnected(JNIEnv* env, jobject obj, jlong token); -static void jniDeviceNewData(JNIEnv* env, jobject obj, jlong token, jbyteArray data); -static void jniDeviceException(JNIEnv* env, jobject obj, jlong token, jstring message); - -void setNativeMethods() -{ - qCDebug(AndroidSerialLog) << "Registering Native Functions"; - - const JNINativeMethod javaMethods[]{ - {"nativeDeviceHasDisconnected", "(J)V", reinterpret_cast(jniDeviceHasDisconnected)}, - {"nativeDeviceNewData", "(J[B)V", reinterpret_cast(jniDeviceNewData)}, - {"nativeDeviceException", "(JLjava/lang/String;)V", reinterpret_cast(jniDeviceException)}, - }; - - QJniEnvironment env; - if (!env.registerNativeMethods(kJniUsbSerialManagerClassName, javaMethods, std::size(javaMethods))) { - qCWarning(AndroidSerialLog) << "Failed to register native methods for" << kJniUsbSerialManagerClassName; - return; - } - - if (!getSerialManagerClass()) { - qCWarning(AndroidSerialLog) << "Failed to cache JNI method IDs"; - return; - } - - qCDebug(AndroidSerialLog) << "Native Functions Registered Successfully"; -} - -// ---------------------------------------------------------------------------- -// JNI callbacks (called from Java threads) -// ---------------------------------------------------------------------------- - -static void jniDeviceHasDisconnected(JNIEnv*, jobject, jlong token) -{ - if (token == 0) { - qCWarning(AndroidSerialLog) << "nativeDeviceHasDisconnected called with token=0"; - return; - } - - QPointer serialPort; - { - QReadLocker locker(&s_ptrLock); - serialPort = lookupPortByTokenLocked(token); - if (!serialPort) { - qCWarning(AndroidSerialLog) << "nativeDeviceHasDisconnected: stale token, object already destroyed"; - return; - } - qCDebug(AndroidSerialLog) << "Device disconnected:" << serialPort->portName(); - } - - if (!dispatchToPortObject( - serialPort.data(), - [token]() { - QSerialPortPrivate* const p = lookupByToken(token); - if (!p) { - qCDebug(AndroidSerialLog) << "Token already invalidated in nativeDeviceHasDisconnected"; - return; - } - - QSerialPort* const port = qobject_cast(p->q_ptr); - if (port && port->isOpen()) { - port->close(); - qCDebug(AndroidSerialLog) << "Serial port closed in nativeDeviceHasDisconnected"; - } else { - qCDebug(AndroidSerialLog) << "Serial port was already closed in nativeDeviceHasDisconnected"; - } - }, - "nativeDeviceHasDisconnected")) { - qCWarning(AndroidSerialLog) << "nativeDeviceHasDisconnected: failed to dispatch cleanup"; - } -} - -static void jniDeviceNewData(JNIEnv* env, jobject, jlong token, jbyteArray data) -{ - constexpr jsize kMaxNativePayloadBytes = static_cast(MAX_READ_SIZE); - - if (token == 0) { - qCWarning(AndroidSerialLog) << "nativeDeviceNewData called with token=0"; - return; - } - - if (!data) { - qCWarning(AndroidSerialLog) << "nativeDeviceNewData called with null data"; - return; - } - - const jsize len = env->GetArrayLength(data); - if (len <= 0) { - qCWarning(AndroidSerialLog) << "nativeDeviceNewData received empty data array"; - return; - } - - const jsize cappedLen = (len > kMaxNativePayloadBytes) ? kMaxNativePayloadBytes : len; - if (cappedLen != len) { - qCWarning(AndroidSerialLog) << "nativeDeviceNewData payload exceeds limit, truncating from" << len << "to" - << cappedLen << "bytes"; - } - - QByteArray payload(cappedLen, Qt::Uninitialized); - env->GetByteArrayRegion(data, 0, cappedLen, reinterpret_cast(payload.data())); - if (QJniEnvironment::checkAndClearExceptions(env)) { - qCWarning(AndroidSerialLog) << "nativeDeviceNewData failed to copy JNI byte array"; - return; - } - - { - // Deliver inline while holding read lock so unregister/destroy cannot - // invalidate the pointer until this handoff is complete. - QReadLocker locker(&s_ptrLock); - QSerialPortPrivate* const serialPortPrivate = s_tokenToPtr.value(token, nullptr); - if (!serialPortPrivate) { - qCWarning(AndroidSerialLog) << "nativeDeviceNewData: stale token, object already destroyed"; - return; - } - - serialPortPrivate->newDataArrived(payload.constData(), payload.size()); - } -} - -static void jniDeviceException(JNIEnv*, jobject, jlong token, jstring message) -{ - if (token == 0) { - qCWarning(AndroidSerialLog) << "nativeDeviceException called with token=0"; - return; - } - - if (!message) { - qCWarning(AndroidSerialLog) << "nativeDeviceException called with null message"; - return; - } - - const QString exceptionMessage = QJniObject(message).toString(); - - QPointer serialPort; - { - QReadLocker locker(&s_ptrLock); - serialPort = lookupPortByTokenLocked(token); - if (!serialPort) { - qCWarning(AndroidSerialLog) << "nativeDeviceException: stale token, object already destroyed"; - return; - } - } - - qCWarning(AndroidSerialLog) << "Exception from Java:" << exceptionMessage; - - if (!dispatchToPortObject( - serialPort.data(), - [token, exceptionMessage]() { - QSerialPortPrivate* const p = lookupByToken(token); - if (!p) { - qCDebug(AndroidSerialLog) << "Token already invalidated in nativeDeviceException"; - return; - } - - p->exceptionArrived(exceptionMessage); - }, - "nativeDeviceException")) { - qCWarning(AndroidSerialLog) << "nativeDeviceException: failed to dispatch exception callback"; - } -} - -// ---------------------------------------------------------------------------- -// Helper: get env + class + check cached method in one shot -// ---------------------------------------------------------------------------- - -struct JniContext -{ - QJniEnvironment env; - jclass cls = nullptr; - bool valid = false; -}; - -static bool getContext(JniContext& ctx, const char* caller) -{ - if (!ctx.env.isValid()) { - qCWarning(AndroidSerialLog) << "Invalid QJniEnvironment in" << caller; - return false; - } - - ctx.cls = getSerialManagerClass(); - if (!ctx.cls) { - qCWarning(AndroidSerialLog) << "getSerialManagerClass returned null in" << caller; - return false; - } - - ctx.valid = true; - return true; -} - -// ---------------------------------------------------------------------------- -// Device enumeration -// ---------------------------------------------------------------------------- - -QList availableDevices() -{ - QList serialPortInfoList; - - JniContext ctx; - if (!getContext(ctx, "availableDevices")) - return serialPortInfoList; - - AndroidInterface::JniLocalRef objArray( - ctx.env.jniEnv(), - static_cast(ctx.env->CallStaticObjectMethod(ctx.cls, s_methods.availableDevicesInfo))); - if (!objArray.get()) { - qCDebug(AndroidSerialLog) << "availableDevicesInfo returned null"; - (void)ctx.env.checkAndClearExceptions(); - return serialPortInfoList; - } - - if (ctx.env.checkAndClearExceptions()) { - qCWarning(AndroidSerialLog) << "Exception occurred while calling availableDevicesInfo"; - return serialPortInfoList; - } - - const jsize count = ctx.env->GetArrayLength(objArray.get()); - for (jsize i = 0; i < count; ++i) { - AndroidInterface::JniLocalRef jstr( - ctx.env.jniEnv(), static_cast(ctx.env->GetObjectArrayElement(objArray.get(), i))); - if (!jstr.get()) { - qCWarning(AndroidSerialLog) << "Null string at index" << i; - continue; - } - - const QStringList strList = QJniObject(jstr.get()).toString().split(QLatin1Char('\t')); - - if (strList.size() < 6) { - qCWarning(AndroidSerialLog) << "Invalid device info at index" << i << ":" << strList; - continue; - } - - bool pidOK, vidOK; - QSerialPortInfoPrivate info; - info.portName = QSerialPortInfoPrivate::portNameFromSystemLocation(strList[0]); - info.device = strList[0]; - info.description = strList[1]; - info.manufacturer = strList[2]; - info.serialNumber = strList[3]; - info.productIdentifier = strList[4].toInt(&pidOK); - info.hasProductIdentifier = (pidOK && (info.productIdentifier != INVALID_DEVICE_ID)); - info.vendorIdentifier = strList[5].toInt(&vidOK); - info.hasVendorIdentifier = (vidOK && (info.vendorIdentifier != INVALID_DEVICE_ID)); - - serialPortInfoList.append(info); - } - - (void)ctx.env.checkAndClearExceptions(); - - return serialPortInfoList; -} - -// ---------------------------------------------------------------------------- -// Device ID / handle lookup -// ---------------------------------------------------------------------------- - -int getDeviceId(const QString& portName) -{ - const QJniObject name = QJniObject::fromString(portName); - if (!name.isValid()) { - qCWarning(AndroidSerialLog) << "Invalid QJniObject for portName in getDeviceId"; - return -1; - } - - JniContext ctx; - if (!getContext(ctx, "getDeviceId")) - return -1; - - jint result = -1; - if (!AndroidInterface::callStaticIntMethod(ctx.env, ctx.cls, s_methods.getDeviceId, "getDeviceId", - AndroidSerialLog(), result, name.object())) { - return -1; - } - - return static_cast(result); -} - -int getDeviceHandle(int deviceId) -{ - JniContext ctx; - if (!getContext(ctx, "getDeviceHandle")) - return -1; - - jint result = -1; - if (!AndroidInterface::callStaticIntMethod(ctx.env, ctx.cls, s_methods.getDeviceHandle, "getDeviceHandle", - AndroidSerialLog(), result, static_cast(deviceId))) { - return -1; - } - - return static_cast(result); -} - -// ---------------------------------------------------------------------------- -// Open / close / isOpen -// ---------------------------------------------------------------------------- - -int open(const QString& portName, QSerialPortPrivate* classPtr) -{ - if (!classPtr) { - qCWarning(AndroidSerialLog) << "open called with null serialPort"; - return INVALID_DEVICE_ID; - } - - const jlong token = lookupToken(classPtr); - if (token == 0) { - qCWarning(AndroidSerialLog) << "open called with unregistered pointer — call registerPointer first"; - return INVALID_DEVICE_ID; - } - - const QJniObject name = QJniObject::fromString(portName); - if (!name.isValid()) { - qCWarning(AndroidSerialLog) << "Invalid QJniObject for portName in open"; - return INVALID_DEVICE_ID; - } - - JniContext ctx; - if (!getContext(ctx, "open")) - return INVALID_DEVICE_ID; - - jint deviceId = INVALID_DEVICE_ID; - if (!AndroidInterface::callStaticIntMethod(ctx.env, ctx.cls, s_methods.open, "open", AndroidSerialLog(), deviceId, - name.object(), token)) { - return INVALID_DEVICE_ID; - } - - return static_cast(deviceId); -} - -bool close(int deviceId) -{ - JniContext ctx; - if (!getContext(ctx, "close")) - return false; - - jboolean result = JNI_FALSE; - if (!AndroidInterface::callStaticBooleanMethod(ctx.env, ctx.cls, s_methods.close, "close", AndroidSerialLog(), - result, static_cast(deviceId))) { - return false; - } - - return (result == JNI_TRUE); -} - -bool isOpen(const QString& portName) -{ - const QJniObject name = QJniObject::fromString(portName); - if (!name.isValid()) { - qCWarning(AndroidSerialLog) << "Invalid QJniObject for portName in isOpen"; - return false; - } - - JniContext ctx; - if (!getContext(ctx, "isOpen")) - return false; - - jboolean result = JNI_FALSE; - if (!AndroidInterface::callStaticBooleanMethod(ctx.env, ctx.cls, s_methods.isDeviceNameOpen, "isDeviceNameOpen", - AndroidSerialLog(), result, name.object())) { - return false; - } - - return (result == JNI_TRUE); -} - -// ---------------------------------------------------------------------------- -// Read / write -// ---------------------------------------------------------------------------- - -QByteArray read(int deviceId, int length, int timeout) -{ - JniContext ctx; - if (!getContext(ctx, "read")) - return QByteArray(); - - AndroidInterface::JniLocalRef jarray( - ctx.env.jniEnv(), static_cast( - ctx.env->CallStaticObjectMethod(ctx.cls, s_methods.read, static_cast(deviceId), - static_cast(length), static_cast(timeout)))); - - if (!jarray.get()) { - qCWarning(AndroidSerialLog) << "read method returned null"; - (void)ctx.env.checkAndClearExceptions(); - return QByteArray(); - } - - if (ctx.env.checkAndClearExceptions()) { - qCWarning(AndroidSerialLog) << "Exception occurred while calling read"; - return QByteArray(); - } - - const jsize len = ctx.env->GetArrayLength(jarray.get()); - jbyte* const bytes = ctx.env->GetByteArrayElements(jarray.get(), nullptr); - if (!bytes) { - qCWarning(AndroidSerialLog) << "Failed to get byte array elements in read"; - return QByteArray(); - } - - const QByteArray data(reinterpret_cast(bytes), len); - ctx.env->ReleaseByteArrayElements(jarray.get(), bytes, JNI_ABORT); - - return data; -} - -int write(int deviceId, const char* data, int length, int timeout, bool async) -{ - if (!data || length <= 0) { - qCWarning(AndroidSerialLog) << "Invalid data or length in write"; - return -1; - } - - JniContext ctx; - if (!getContext(ctx, "write")) - return -1; - - AndroidInterface::JniLocalRef jarray(ctx.env.jniEnv(), - ctx.env->NewByteArray(static_cast(length))); - if (!jarray.get()) { - qCWarning(AndroidSerialLog) << "Failed to create jbyteArray in write"; - return -1; - } - - ctx.env->SetByteArrayRegion(jarray.get(), 0, static_cast(length), reinterpret_cast(data)); - if (ctx.env.checkAndClearExceptions()) { - qCWarning(AndroidSerialLog) << "Exception occurred while setting byte array region in write"; - return -1; - } - - jint result; - if (async) { - result = ctx.env->CallStaticIntMethod(ctx.cls, s_methods.writeAsync, static_cast(deviceId), jarray.get(), - static_cast(timeout)); - } else { - result = ctx.env->CallStaticIntMethod(ctx.cls, s_methods.write, static_cast(deviceId), jarray.get(), - static_cast(length), static_cast(timeout)); - } - - if (ctx.env.checkAndClearExceptions()) { - qCWarning(AndroidSerialLog) << "Exception occurred while calling write/writeAsync"; - return -1; - } - - return static_cast(result); -} - -// ---------------------------------------------------------------------------- -// Port configuration -// ---------------------------------------------------------------------------- - -bool setParameters(int deviceId, int baudRate, int dataBits, int stopBits, int parity) -{ - JniContext ctx; - if (!getContext(ctx, "setParameters")) - return false; - - jboolean result = JNI_FALSE; - if (!AndroidInterface::callStaticBooleanMethod(ctx.env, ctx.cls, s_methods.setParameters, "setParameters", - AndroidSerialLog(), result, static_cast(deviceId), - static_cast(baudRate), static_cast(dataBits), - static_cast(stopBits), static_cast(parity))) { - return false; - } - - return (result == JNI_TRUE); -} - -// ---------------------------------------------------------------------------- -// Control line helpers (DRY macro for bool getters) -// ---------------------------------------------------------------------------- - -static bool callBoolMethod(jmethodID method, int deviceId, const char* name) -{ - JniContext ctx; - if (!getContext(ctx, name)) - return false; - - jboolean result = JNI_FALSE; - if (!AndroidInterface::callStaticBooleanMethod(ctx.env, ctx.cls, method, name, AndroidSerialLog(), result, - static_cast(deviceId))) { - return false; - } - - return (result == JNI_TRUE); -} - -static bool callBoolSetMethod(jmethodID method, int deviceId, bool set, const char* name) -{ - JniContext ctx; - if (!getContext(ctx, name)) - return false; - - const jboolean jSet = set ? JNI_TRUE : JNI_FALSE; - jboolean result = JNI_FALSE; - if (!AndroidInterface::callStaticBooleanMethod(ctx.env, ctx.cls, method, name, AndroidSerialLog(), result, - static_cast(deviceId), jSet)) { - return false; - } - - return (result == JNI_TRUE); -} - -// ---------------------------------------------------------------------------- -// Control lines -// ---------------------------------------------------------------------------- - -bool getCarrierDetect(int deviceId) -{ - return callBoolMethod(s_methods.getCarrierDetect, deviceId, "getCarrierDetect"); -} - -bool getClearToSend(int deviceId) -{ - return callBoolMethod(s_methods.getClearToSend, deviceId, "getClearToSend"); -} - -bool getDataSetReady(int deviceId) -{ - return callBoolMethod(s_methods.getDataSetReady, deviceId, "getDataSetReady"); -} - -bool getDataTerminalReady(int deviceId) -{ - return callBoolMethod(s_methods.getDataTerminalReady, deviceId, "getDataTerminalReady"); -} - -bool getRingIndicator(int deviceId) -{ - return callBoolMethod(s_methods.getRingIndicator, deviceId, "getRingIndicator"); -} - -bool getRequestToSend(int deviceId) -{ - return callBoolMethod(s_methods.getRequestToSend, deviceId, "getRequestToSend"); -} - -bool setDataTerminalReady(int deviceId, bool set) -{ - return callBoolSetMethod(s_methods.setDataTerminalReady, deviceId, set, "setDataTerminalReady"); -} - -bool setRequestToSend(int deviceId, bool set) -{ - return callBoolSetMethod(s_methods.setRequestToSend, deviceId, set, "setRequestToSend"); -} - -QSerialPort::PinoutSignals getControlLines(int deviceId) -{ - JniContext ctx; - if (!getContext(ctx, "getControlLines")) - return QSerialPort::PinoutSignals(); - - AndroidInterface::JniLocalRef jarray( - ctx.env.jniEnv(), static_cast(ctx.env->CallStaticObjectMethod(ctx.cls, s_methods.getControlLines, - static_cast(deviceId)))); - if (!jarray.get()) { - qCWarning(AndroidSerialLog) << "getControlLines returned null"; - (void)ctx.env.checkAndClearExceptions(); - return QSerialPort::PinoutSignals(); - } - - if (ctx.env.checkAndClearExceptions()) { - qCWarning(AndroidSerialLog) << "Exception occurred while calling getControlLines"; - return QSerialPort::PinoutSignals(); - } - - jint* const ints = ctx.env->GetIntArrayElements(jarray.get(), nullptr); - if (!ints) { - qCWarning(AndroidSerialLog) << "Failed to get int array elements in getControlLines"; - return QSerialPort::PinoutSignals(); - } - - const jsize len = ctx.env->GetArrayLength(jarray.get()); - QSerialPort::PinoutSignals data = QSerialPort::PinoutSignals(); - - for (jsize i = 0; i < len; ++i) { - switch (ints[i]) { - case RtsControlLine: - data |= QSerialPort::RequestToSendSignal; - break; - case CtsControlLine: - data |= QSerialPort::ClearToSendSignal; - break; - case DtrControlLine: - data |= QSerialPort::DataTerminalReadySignal; - break; - case DsrControlLine: - data |= QSerialPort::DataSetReadySignal; - break; - case CdControlLine: - data |= QSerialPort::DataCarrierDetectSignal; - break; - case RiControlLine: - data |= QSerialPort::RingIndicatorSignal; - break; - default: - qCWarning(AndroidSerialLog) << "Unknown ControlLine value:" << ints[i]; - break; - } - } - - ctx.env->ReleaseIntArrayElements(jarray.get(), ints, JNI_ABORT); - (void)ctx.env.checkAndClearExceptions(); - - return data; -} - -// ---------------------------------------------------------------------------- -// Flow control -// ---------------------------------------------------------------------------- - -int getFlowControl(int deviceId) -{ - JniContext ctx; - if (!getContext(ctx, "getFlowControl")) - return QSerialPort::NoFlowControl; - - jint flowControl = QSerialPort::NoFlowControl; - if (!AndroidInterface::callStaticIntMethod(ctx.env, ctx.cls, s_methods.getFlowControl, "getFlowControl", - AndroidSerialLog(), flowControl, static_cast(deviceId))) { - return QSerialPort::NoFlowControl; - } - - return static_cast(flowControl); -} - -bool setFlowControl(int deviceId, int flowControl) -{ - JniContext ctx; - if (!getContext(ctx, "setFlowControl")) - return false; - - jboolean result = JNI_FALSE; - if (!AndroidInterface::callStaticBooleanMethod(ctx.env, ctx.cls, s_methods.setFlowControl, "setFlowControl", - AndroidSerialLog(), result, static_cast(deviceId), - static_cast(flowControl))) { - return false; - } - - return result == JNI_TRUE; -} - -// ---------------------------------------------------------------------------- -// Buffer / break -// ---------------------------------------------------------------------------- - -bool purgeBuffers(int deviceId, bool input, bool output) -{ - JniContext ctx; - if (!getContext(ctx, "purgeBuffers")) - return false; - - const jboolean jInput = input ? JNI_TRUE : JNI_FALSE; - const jboolean jOutput = output ? JNI_TRUE : JNI_FALSE; - - jboolean result = JNI_FALSE; - if (!AndroidInterface::callStaticBooleanMethod(ctx.env, ctx.cls, s_methods.purgeBuffers, "purgeBuffers", - AndroidSerialLog(), result, static_cast(deviceId), jInput, - jOutput)) { - return false; - } - - return (result == JNI_TRUE); -} - -bool setBreak(int deviceId, bool set) -{ - return callBoolSetMethod(s_methods.setBreak, deviceId, set, "setBreak"); -} - -// ---------------------------------------------------------------------------- -// IO manager (read thread) -// ---------------------------------------------------------------------------- - -bool startReadThread(int deviceId) -{ - return callBoolMethod(s_methods.startIoManager, deviceId, "startIoManager"); -} - -bool stopReadThread(int deviceId) -{ - return callBoolMethod(s_methods.stopIoManager, deviceId, "stopIoManager"); -} - -bool readThreadRunning(int deviceId) -{ - return callBoolMethod(s_methods.ioManagerRunning, deviceId, "ioManagerRunning"); -} - -} // namespace AndroidSerial diff --git a/src/Android/AndroidSerial.h b/src/Android/AndroidSerial.h deleted file mode 100644 index 8ef2191e0ec0..000000000000 --- a/src/Android/AndroidSerial.h +++ /dev/null @@ -1,89 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -class QSerialPortPrivate; - -namespace AndroidSerial { -enum DataBits -{ - Data5 = 5, - Data6 = 6, - Data7 = 7, - Data8 = 8 -}; - -enum Parity -{ - NoParity = 0, - OddParity, - EvenParity, - MarkParity, - SpaceParity -}; - -enum StopBits -{ - OneStop = 1, - OneAndHalfStop = 3, - TwoStop = 2 -}; - -enum ControlLine -{ - RtsControlLine = 0, - CtsControlLine, - DtrControlLine, - DsrControlLine, - CdControlLine, - RiControlLine -}; - -enum FlowControl -{ - NoFlowControl = 0, - RtsCtsFlowControl, - DtrDsrFlowControl, - XonXoffFlowControl, - XonXoffInlineFlowControl -}; - -constexpr char CHAR_XON = 17; -constexpr char CHAR_XOFF = 19; - -constexpr const char* kJniUsbSerialManagerClassName = "org/mavlink/qgroundcontrol/QGCUsbSerialManager"; - -void setNativeMethods(); -QList availableDevices(); -int getDeviceId(const QString& portName); -int getDeviceHandle(int deviceId); -int open(const QString& portName, QSerialPortPrivate* classPtr); -bool close(int deviceId); -bool isOpen(const QString& portName); -QByteArray read(int deviceId, int length, int timeout); -int write(int deviceId, const char* data, int length, int timeout, bool async); -bool setParameters(int deviceId, int baudRate, int dataBits, int stopBits, int parity); -bool getCarrierDetect(int deviceId); -bool getClearToSend(int deviceId); -bool getDataSetReady(int deviceId); -bool getDataTerminalReady(int deviceId); -bool setDataTerminalReady(int deviceId, bool set); -bool getRingIndicator(int deviceId); -bool getRequestToSend(int deviceId); -bool setRequestToSend(int deviceId, bool set); -QSerialPort::PinoutSignals getControlLines(int deviceId); -int getFlowControl(int deviceId); -bool setFlowControl(int deviceId, int flowControl); -bool purgeBuffers(int deviceId, bool input, bool output); -bool setBreak(int deviceId, bool set); -bool startReadThread(int deviceId); -bool stopReadThread(int deviceId); -bool readThreadRunning(int deviceId); - -void registerPointer(QSerialPortPrivate* ptr); -void unregisterPointer(QSerialPortPrivate* ptr); -void cleanupJniCache(); -} // namespace AndroidSerial diff --git a/src/Android/CMakeLists.txt b/src/Android/CMakeLists.txt index 1b3feb558008..04b64465ac83 100644 --- a/src/Android/CMakeLists.txt +++ b/src/Android/CMakeLists.txt @@ -3,7 +3,10 @@ # Android platform-specific initialization and interfaces # ============================================================================ -# Only build on Android platforms +# Serial subsystem (own CMakeLists): compiles its JNI-free pieces on every platform for host tests, +# the JNI-backed port on Android only. Added before the NOT-ANDROID guard so the host trio builds. +add_subdirectory(Serial) + if(NOT ANDROID) return() endif() @@ -15,17 +18,12 @@ target_sources(${CMAKE_PROJECT_NAME} AndroidInit.cc AndroidInterface.cc AndroidInterface.h - AndroidSerial.cc - AndroidSerial.h + AndroidLogSink.cc + AndroidLogSink.h ) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -# ---------------------------------------------------------------------------- -# Android Serial Port Support -# ---------------------------------------------------------------------------- -add_subdirectory(qtandroidserialport) - # ---------------------------------------------------------------------------- # Qt Android Extensions (2GIS) — selective compile # https://github.com/2gis/qtandroidextensions diff --git a/src/Android/Serial/AndroidSerialPort.cc b/src/Android/Serial/AndroidSerialPort.cc new file mode 100644 index 000000000000..1a8fb23a153f --- /dev/null +++ b/src/Android/Serial/AndroidSerialPort.cc @@ -0,0 +1,701 @@ +#include "AndroidSerialPort.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AndroidSerialPortPrivate.h" +#include "AndroidSerialPortRegistry.h" +#include "QGCLoggingCategory.h" +#include "QGCSerialPortInfo.h" +#include "SerialPlatform.h" +#include "SerialPortInfoCodec.h" + +QGC_LOGGING_CATEGORY(AndroidSerialPortLog, "Android.Serial.Port") + +// JNI wire glue, parity/flow marshalling and the jni* callbacks live in +// AndroidSerialPortPrivate.h + AndroidSerialPortJni.cc. + +namespace { + +constexpr int kDefaultWriteTimeoutMs = 5000; + +} // namespace + +void AndroidSerialPortPrivate::setError(QGCSerialPortError code, const QString& message) +{ + Q_Q(AndroidSerialPort); + if (!onOwnerThread(q, "setError")) { + return; + } + error = code; + // errorString is QIODevicePrivate's storage; touch directly since Private isn't a QIODevice subclass. + if (!message.isEmpty()) + errorString = message; + emit q->errorOccurred(code); +} + +void AndroidSerialPortPrivate::postBytesWritten(qint64 n) +{ + postToOwner([n](AndroidSerialPort* self) { + AndroidSerialPortPrivate* const d = self->d_func(); + if (!d->emittedBytesWritten) { + QScopedValueRollback r(d->emittedBytesWritten, true); + emit self->bytesWritten(n); + } + }); +} + +void AndroidSerialPortPrivate::ackBytesWrittenFromJavaThread(qint64 n) +{ + writeEngine.ack(n); // decrement in-flight, wake drain waiters + postBytesWritten(n); // marshal bytesWritten(n) to the owner thread +} + +bool AndroidSerialPortPrivate::openJavaPort(qint64 newHandle, QIODeviceBase::OpenMode mode) +{ + const QJniObject name = QJniObject::fromString(systemLocation); + if (!name.isValid()) { + qCWarning(AndroidSerialPortLog) << "Invalid QJniObject for portName"; + return false; + } + + const auto paramsObj = makeSerialParamsJni(pending.baud, pending.dataBits, pending.stopBits, pending.parity); + if (!paramsObj.isValid()) { + qCWarning(AndroidSerialPortLog) << "Failed to construct SerialParameters JNI object"; + return false; + } + + const bool startReader = (mode & QIODevice::ReadOnly) != 0; + QJniObject newJavaPort = QJniObject::callStaticMethod( + "openConfiguredPort", name.object(), static_cast(newHandle), paramsObj, + static_cast(flowControlToWire(pending.flowControl)), + static_cast(true), // assertDtr + static_cast(startReader)); + if (QJniEnvironment::checkAndClearExceptions(QJniEnvironment::getJniEnv(), QJniEnvironment::OutputMode::Verbose)) { + qCWarning(AndroidSerialPortLog) << "Exception in openConfiguredPort for" << systemLocation; + return false; + } + if (!newJavaPort.isValid()) { + qCWarning(AndroidSerialPortLog) << "openConfiguredPort returned null for" << systemLocation; + // Classify the failure: the device is present but the port couldn't be claimed (busy/duplicate/transient), so SerialWorker reports a reason instead of an empty errorString. + setError(QGCSerialPortError::OpenFailed, QObject::tr("Could not open port (device busy or unavailable)")); + return false; + } + + javaPort = std::move(newJavaPort); + // Warm m_className so callMethod() uses Qt's jmethodID cache (empty after jobject-ctor = uncached GetMethodID per call). + (void) javaPort.className(); + javaAlive.store(true, std::memory_order_release); + return true; +} + +void AndroidSerialPortPrivate::teardownJavaPort() +{ + // Invariant: the C++ PortRegistry token must be unregistered LAST — after this Java close — or LookupGuard cannot fence in-flight JNI callbacks (use-after-free). + // The Java writer thread is owned and joined Java-side by close(); nothing to join here. + if (javaPortAlive()) { + // Best-effort: stopIoManager's bool return is moot in the teardown path. + (void) javaPort.callMethod("stopIoManager"); + } + + // exchange(false) wins the close race; a prior flip (double-close / Java auto-close on unplug) skips the redundant Java close(). + const bool wasAlive = javaAlive.exchange(false, std::memory_order_acq_rel); + writeEngine.wakeDrainWaiters(); // unblock any in-progress waitForDrain now that the port is gone + if (wasAlive && javaPort.isValid()) { + if (!javaPort.callMethod("close")) { + qCWarning(AndroidSerialPortLog) << "close returned false for serial port"; + } + } + javaPort = QJniObject(); + writeEngine.releaseScratch(); +} + +void AndroidSerialPortPrivate::dispatchCloseFromJavaThread() +{ + // Called from a JNI callback with PortRegistry::LookupGuard held. + // Mark dead so the write guard stops submitting; the queued close() then skips the redundant Java close() (Java auto-closed on unplug). + javaAlive.store(false, std::memory_order_release); + writeEngine.wakeDrainWaiters(); // unblock waitForBytesWritten / flush waiting on javaAlive + + postToOwner([](AndroidSerialPort* self) { + AndroidSerialPortPrivate* const d = self->d_func(); + if (self->isOpen()) { + qCDebug(AndroidSerialPortLog) << "Closing serial port" << d->systemLocation << "after disconnect"; + self->close(); + } else { + qCDebug(AndroidSerialPortLog) << "Serial port" << d->systemLocation << "already closed at disconnect"; + } + }); +} + +void AndroidSerialPortPrivate::dispatchExceptionFromJavaThread(QGCSerialPortError errorCode, QString message) +{ + // Called from a JNI callback with PortRegistry::LookupGuard held. + // Latch the write error on the JNI thread so a blocked waitForBytesWritten/flush wakes immediately. + writeEngine.fail(); + postToOwner([errorCode, message = std::move(message)](AndroidSerialPort* self) { + AndroidSerialPortPrivate* const d = self->d_func(); + if (d->expectClosure && errorCode == QGCSerialPortError::ResourceUnavailable) { + qCDebug(AndroidSerialPortLog) + << "Suppressed expected Resource exception on" << d->systemLocation << "during close:" << message; + return; + } + qCWarning(AndroidSerialPortLog) << "Exception arrived on" << d->systemLocation + << "error=" << static_cast(errorCode) << ":" << message; + // Close FIRST so reentrant DirectConnection slots on errorOccurred see fully-closed state. + if (errorCode == QGCSerialPortError::ResourceUnavailable && self->isOpen()) { + self->close(); + } + d->setError(errorCode, message); + }); +} + +AndroidSerialPort::AndroidSerialPort(QObject* parent) : QGCSerialPort(*new AndroidSerialPortPrivate, parent) +{ + initializeNative(); +} + +AndroidSerialPort::AndroidSerialPort(const QString& systemLocation, QObject* parent) + : QGCSerialPort(*new AndroidSerialPortPrivate, parent) +{ + Q_D(AndroidSerialPort); + d->systemLocation = systemLocation; + initializeNative(); +} + +void AndroidSerialPortPrivate::jniUsbDevicesChanged(JNIEnv*, jobject) +{ + // Fired from a Java/binder thread; LinkManager's queued connection governs delivery and receiver lifetime, so emitting the relay signal is all that's needed. + SerialPlatform::SerialDevicesNotifier::instance()->notifyChanged(); +} + +bool AndroidSerialPort::initializeNative() +{ + // Function-local static keeps native registration idempotent across JNI unload+reload. + static const bool registered = []() { + QJniEnvironment env; + const bool ok = env.registerNativeMethods({ + Q_JNI_NATIVE_SCOPED_METHOD(jniDeviceHasDisconnected, AndroidSerialPortPrivate), + Q_JNI_NATIVE_SCOPED_METHOD(jniDeviceNewData, AndroidSerialPortPrivate), + Q_JNI_NATIVE_SCOPED_METHOD(jniDeviceException, AndroidSerialPortPrivate), + Q_JNI_NATIVE_SCOPED_METHOD(jniDeviceBytesWritten, AndroidSerialPortPrivate), + }); + if (!ok) { + qCWarning(AndroidSerialPortLog) << "Failed to register native methods for QGCSerialPort"; + return false; + } + + const bool managerOk = env.registerNativeMethods({ + Q_JNI_NATIVE_SCOPED_METHOD(jniUsbDevicesChanged, AndroidSerialPortPrivate), + }); + if (!managerOk) { + qCWarning(AndroidSerialPortLog) << "Failed to register native methods for QGCUsbSerialManager"; + return false; + } + + // The C++/Java wire constants are kept in sync by hand across (SerialWireConstants.{h,java}), + // so the former runtime handshake is gone. The only remaining invariant — our FC_* ordinals vs mik3y's + // external FlowControl enum — is asserted by SerialWireConstantsTest at build time. + + qCDebug(AndroidSerialPortLog) << "Native Functions Registered Successfully"; + return true; + }(); + + return registered; +} + +AndroidSerialPort::~AndroidSerialPort() +{ + Q_D(AndroidSerialPort); + // Silent teardown: do NOT call the virtual close() from a destructor — it emits readChannelFinished() (DirectConnection slots run + // against a half-destroyed object) and posts a queued lambda to a dying object. Backstop for "destroyed while open". + if (isOpen()) { + d->expectClosure = true; + d->teardownJavaPort(); + QIODevice::close(); + } + // Invariant: the C++ PortRegistry token must be unregistered LAST — after the Java close above — or LookupGuard cannot fence in-flight JNI callbacks (use-after-free). + // Unconditional unregister: the token must leave the registry before the object dies (independent of open state), or a stale JNI callback dereferences freed memory. + if (d->handle != 0) { + PortRegistry::unregisterPort(d->handle); + d->handle = 0; + } +} + +QList AndroidSerialPort::availableDevices() +{ + QJniEnvironment env; + + // Single JNI hop: Java packs every port into one UTF-8 JSON byte[] (QGCUsbSerialManager.availablePortsPacked); + // SerialPortInfoCodec (JNI-free, host-tested) owns the wire format. + const QByteArray packed = + QJniObject::callStaticMethod("availablePortsPacked"); + if (env.checkAndClearExceptions()) { + qCWarning(AndroidSerialPortLog) << "Exception occurred while calling availablePortsPacked"; + return {}; + } + if (packed.isEmpty()) { + qCDebug(AndroidSerialPortLog) << "availablePortsPacked returned empty"; + return {}; + } + + const QList decoded = SerialPortInfoCodec::unpack(packed); + QList serialPortInfoList; + serialPortInfoList.reserve(decoded.size()); + for (const QGCSerialPortInfo::Data& data : decoded) { + serialPortInfoList.emplace_back(data); + } + return serialPortInfoList; +} + +bool AndroidSerialPort::_validateOpenMode(QIODeviceBase::OpenMode mode) +{ + Q_D(AndroidSerialPort); + static const OpenMode kUnsupported = Append | Truncate | Text | Unbuffered; + if ((mode & kUnsupported) || mode == NotOpen) { + d->setError(QGCSerialPortError::UnsupportedOperation, tr("Unsupported open mode")); + return false; + } + return true; +} + +bool AndroidSerialPort::open(QIODeviceBase::OpenMode mode) +{ + Q_D(AndroidSerialPort); + + // Teardown does a blocking JNI join, so a GUI-thread open would ANR (Qt's NMEA position source tries exactly that); refuse gracefully, callers run us off-thread. + if (QCoreApplication::instance() && (thread() == QCoreApplication::instance()->thread())) { + d->setError(QGCSerialPortError::OpenFailed, tr("Serial port cannot be opened on the GUI thread")); + return false; + } + + if (isOpen()) { + d->setError(QGCSerialPortError::OpenFailed, tr("Device is already open")); + return false; + } + if (!_validateOpenMode(mode)) + return false; + + // If JNI native methods never registered (Java/C++ wire-constant drift), callbacks never fire: the port "opens" but reads hang and disconnects go undetected. Fail loudly. + if (!initializeNative()) { + d->setError(QGCSerialPortError::OpenFailed, tr("Serial JNI bridge not initialized")); + return false; + } + + qCDebug(AndroidSerialPortLog) << "Opening" << d->systemLocation; + clearError(); + + const jlong handle = PortRegistry::allocateToken(); + PortRegistry::registerPort(handle, this); + d->handle = handle; + + auto tokenCleanup = qScopeGuard([d, handle] { + PortRegistry::unregisterPort(handle); + d->handle = 0; + d->javaAlive.store(false, std::memory_order_release); + }); + + if (!d->openJavaPort(handle, mode)) + return false; + // After this point we own javaPort; any subsequent failure must tear it down. + auto javaCleanup = qScopeGuard([d] { d->teardownJavaPort(); }); + + if (!d->writeEngine.allocateScratch(d->systemLocation)) { + d->setError(QGCSerialPortError::OpenFailed, tr("Failed to allocate write buffer")); + return false; + } + + // Reset write accounting; the Java writer thread is started Java-side inside openConfiguredPort. + d->writeEngine.reset(); + // Clear any RX backlog left over from a prior open so stale queued lambdas can't under-count the cap. + d->rxQueue.flush(); + + javaCleanup.dismiss(); + tokenCleanup.dismiss(); + + QIODevice::open(mode); + // Purge AFTER QIODevice::open so clear()'s isOpen() guard doesn't leave a stale NotOpen error. + if (!clear()) { + qCDebug(AndroidSerialPortLog) << "Best-effort purge on open failed for" << d->systemLocation; + } + // clear() may have set a non-NoError code (e.g. purgeBuffers failure); a successful open must end clean. + clearError(); + return true; +} + +void AndroidSerialPort::close() +{ + Q_D(AndroidSerialPort); + + if (!onOwnerThread(this, "close")) { + return; + } + + if (!isOpen()) + return; + + qCDebug(AndroidSerialPortLog) << "Closing" << d->systemLocation; + + d->expectClosure = true; + emit aboutToClose(); + + d->teardownJavaPort(); + + if (d->handle != 0) { + PortRegistry::unregisterPort(d->handle); + d->handle = 0; + } + d->emittedReadyRead = false; + d->emittedBytesWritten = false; + d->buffer.clear(); + d->rxQueue.flush(); + d->writeEngine.reset(); + + QIODevice::close(); + + // Defer expectClosure=false so racing JNI IOException lambdas (QueuedConnection on this owner thread) drain first — event-loop FIFO. + QPointer self = this; + QMetaObject::invokeMethod( + this, + [self]() { + if (!self) + return; + self->d_func()->expectClosure = false; + }, + Qt::QueuedConnection); + + emit readChannelFinished(); +} + +qint64 AndroidSerialPort::readData(char* data, qint64 maxSize) +{ + // Sequential device: RX lands in QIODevicePrivate::buffer (appendToBuffer) and is drained by the base read() + // after readyRead; this override is never the data source, so it returns 0 ("no data now", not EOF). + if (!onOwnerThread(this, "readData")) { + return -1; + } + Q_UNUSED(data); + Q_UNUSED(maxSize); + return 0; +} + +qint64 AndroidSerialPort::writeData(const char* data, qint64 maxSize) +{ + Q_D(AndroidSerialPort); + if (!onOwnerThread(this, "writeData")) { + return -1; + } + + if (!data || maxSize <= 0) { + qCWarning(AndroidSerialPortLog) << "Invalid data or size for" << d->systemLocation; + return -1; + } + + return d->writeEngine.submit(d->javaPort, QByteArrayView(data, maxSize), d->systemLocation); +} + +bool AndroidSerialPort::waitForReadyRead(int msecs) +{ + Q_D(AndroidSerialPort); + + if (!d->buffer.isEmpty()) + return true; + if (!isOpen()) { + d->setError(QGCSerialPortError::NotOpen, tr("Device is not open")); + return false; + } + + if (d->javaPortAlive() && msecs != 0) { + QEventLoop loop; + const QMetaObject::Connection ready = connect(this, &QIODevice::readyRead, &loop, &QEventLoop::quit); + const QMetaObject::Connection closed = connect(this, &QIODevice::aboutToClose, &loop, &QEventLoop::quit); + if (msecs > 0) { + QTimer::singleShot(msecs, Qt::PreciseTimer, &loop, &QEventLoop::quit); + } + loop.exec(QEventLoop::ExcludeUserInputEvents); + disconnect(ready); + disconnect(closed); + if (!isOpen()) { + return false; + } + } + + if (d->buffer.isEmpty()) { + qCWarning(AndroidSerialPortLog) << "Timeout while waiting for ready read on" << d->systemLocation; + d->setError(QGCSerialPortError::Timeout, tr("Timeout while waiting for ready read")); + return false; + } + + if (!d->emittedReadyRead) { + QScopedValueRollback rollback(d->emittedReadyRead, true); + emit readyRead(); + } + return true; +} + +bool AndroidSerialPort::waitForBytesWritten(int msecs) +{ + Q_D(AndroidSerialPort); + + if (d->writeEngine.waitForDrain(msecs)) { + return true; + } + // A write error or a gone port already failed the wait (error reported via dispatchException / teardown); + // a clean deadline expiry with the port still alive is the only Timeout case. + if (!d->writeEngine.hasError() && d->javaPortAlive()) { + d->setError(QGCSerialPortError::Timeout, tr("Timeout while waiting for bytes written")); + } + return false; +} + +qint64 AndroidSerialPort::bytesToWrite() const +{ + Q_D(const AndroidSerialPort); + // writeData enqueues straight to Java (never QIODevice's writeBuffer), so in-flight is the whole story; QIODevice::bytesToWrite() is always 0 here. + return d->writeEngine.inFlight(); +} + +QString AndroidSerialPort::systemLocation() const +{ + Q_D(const AndroidSerialPort); + return d->systemLocation; +} + +void AndroidSerialPort::setSystemLocation(const QString& location) +{ + Q_D(AndroidSerialPort); + d->systemLocation = location; +} + +QString AndroidSerialPort::portName() const +{ + Q_D(const AndroidSerialPort); + const int idx = d->systemLocation.lastIndexOf(QLatin1Char('/')); + return idx >= 0 ? d->systemLocation.mid(idx + 1) : d->systemLocation; +} + +SerialPortConfig AndroidSerialPort::portConfig() const +{ + Q_D(const AndroidSerialPort); + return d->pending; +} + +QGCSerialPortError AndroidSerialPort::error() const +{ + Q_D(const AndroidSerialPort); + return d->error; +} + +bool AndroidSerialPort::reconfigure(const SerialPortConfig& cfg) +{ + Q_D(AndroidSerialPort); + + if (!cfg.isValid()) { + d->setError(QGCSerialPortError::UnsupportedOperation, tr("Invalid serial configuration")); + return false; + } + if (cfg == d->pending) + return true; + + // Pre-open: stash. Java owns truth once open() runs; closed callers tee up next open. + if (!isOpen()) { + d->pending = cfg; + return true; + } + + if (!d->javaPortAlive()) { + d->setError(QGCSerialPortError::NotOpen, tr("Device is not open")); + return false; + } + + // Two JNI hops (params batched, flowControl separate); stash only what Java accepted so d->pending stays in sync on partial failure. + const bool paramsDiff = d->pending.baud != cfg.baud || d->pending.dataBits != cfg.dataBits || + d->pending.stopBits != cfg.stopBits || d->pending.parity != cfg.parity; + if (paramsDiff) { + const auto paramsObj = makeSerialParamsJni(cfg.baud, cfg.dataBits, cfg.stopBits, cfg.parity); + if (!paramsObj.isValid()) { + qCWarning(AndroidSerialPortLog) << "Failed to construct SerialParameters JNI object"; + d->setError(QGCSerialPortError::UnsupportedOperation, tr("Failed to set serial parameters")); + return false; + } + const jboolean setParamsOk = d->javaPort.callMethod("setSerialParameters", paramsObj); + if (QJniEnvironment::checkAndClearExceptions(QJniEnvironment::getJniEnv(), + QJniEnvironment::OutputMode::Verbose) || + !setParamsOk) { + d->setError(QGCSerialPortError::UnsupportedOperation, tr("Failed to set serial parameters")); + return false; + } + d->pending.baud = cfg.baud; + d->pending.dataBits = cfg.dataBits; + d->pending.stopBits = cfg.stopBits; + d->pending.parity = cfg.parity; + } + + if (d->pending.flowControl != cfg.flowControl) { + const jboolean setFlowOk = + d->javaPort.callMethod("setFlowControl", static_cast(flowControlToWire(cfg.flowControl))); + if (QJniEnvironment::checkAndClearExceptions(QJniEnvironment::getJniEnv(), + QJniEnvironment::OutputMode::Verbose) || + !setFlowOk) { + d->setError(QGCSerialPortError::UnsupportedOperation, tr("Failed to set flow control")); + return false; + } + d->pending.flowControl = cfg.flowControl; + } + + return true; +} + +bool AndroidSerialPort::setDataTerminalReady(bool set) +{ + Q_D(AndroidSerialPort); + if (!isOpen() || !d->javaPortAlive()) { + d->setError(QGCSerialPortError::NotOpen, tr("Device is not open")); + return false; + } + const jboolean setDtrOk = d->javaPort.callMethod("setDataTerminalReady", static_cast(set)); + if (QJniEnvironment::checkAndClearExceptions(QJniEnvironment::getJniEnv(), QJniEnvironment::OutputMode::Verbose) || + !setDtrOk) { + d->setError(QGCSerialPortError::UnsupportedOperation, tr("Failed to set DTR")); + return false; + } + return true; +} + +bool AndroidSerialPort::flush() +{ + Q_D(AndroidSerialPort); + if (!isOpen()) { + d->setError(QGCSerialPortError::NotOpen, tr("Device is not open")); + return false; + } + + if (d->writeEngine.waitForDrain(kDefaultWriteTimeoutMs)) { + return true; + } + if (!d->writeEngine.hasError() && d->javaPortAlive()) { + d->setError(QGCSerialPortError::Timeout, tr("Timeout while flushing")); + } + return false; +} + +bool AndroidSerialPort::clear(bool input, bool output) +{ + Q_D(AndroidSerialPort); + if (!onOwnerThread(this, "clear")) { + return false; + } + if (!isOpen() || !d->javaPortAlive()) { + d->setError(QGCSerialPortError::NotOpen, tr("Device is not open")); + return false; + } + if (input) { + d->buffer.clear(); + d->rxQueue.flush(); + } + if (output) { + d->writeEngine.clearInFlight(); + } + const jboolean purgeOk = + d->javaPort.callMethod("purgeBuffers", static_cast(input), static_cast(output)); + if (QJniEnvironment::checkAndClearExceptions(QJniEnvironment::getJniEnv(), QJniEnvironment::OutputMode::Verbose) || + !purgeOk) { + d->setError(QGCSerialPortError::Unknown, tr("Failed to purge buffers")); + return false; + } + return true; +} + +void AndroidSerialPort::clearError() +{ + Q_D(AndroidSerialPort); + d->error = QGCSerialPortError::NoError; + setErrorString(tr("No error")); +} + +void AndroidSerialPort::setWriteBufferSize(qint64 size) +{ + Q_D(AndroidSerialPort); + d->writeEngine.setWriteBufferMaxSize(size); +} + +qint64 AndroidSerialPort::writeBufferSize() const +{ + Q_D(const AndroidSerialPort); + return d->writeEngine.writeBufferMaxSize(); +} + +void AndroidSerialPortPrivate::enqueueFromJavaThread(QByteArrayView bytes) +{ + // JNI thread -> owner thread via one QueuedConnection hop; payload copied once into the capture. + if (bytes.isEmpty()) + return; + + if (!javaPortAlive()) + return; + + // Bound the posted-lambda backlog against the read-buffer cap: a stalled owner thread would otherwise let queued payloads grow unbounded (appendToBuffer's cap only fires once drained). Drop here instead. + const AndroidSerialRxQueue::Reservation reservation = rxQueue.reserve(bytes.size()); + if (!reservation.accepted) { + if (reservation.shouldWarn) { + qCWarning(AndroidSerialPortLog) << "RX queue backlog cap" << rxQueue.cap() << "exceeded on" + << systemLocation << "- dropping data (consumer stalled)"; + } + return; + } + + postToOwner([generation = reservation.generation, + payload = QByteArray(bytes.constData(), bytes.size())](AndroidSerialPort* self) mutable { + self->d_func()->appendToBuffer(std::move(payload), generation); + }); +} + +void AndroidSerialPortPrivate::appendToBuffer(QByteArray payload, quint32 generation) +{ + // Owner thread — sole writer of buffer alongside clear()/close(). + Q_Q(AndroidSerialPort); + if (!onOwnerThread(q, "appendToBuffer")) { + return; + } + // Drop payloads stamped before a clear(Input)/close() bumped the epoch (caller asked them flushed); still release the reservation below so cap accounting stays balanced. + const bool stale = rxQueue.isStale(generation); + // Release the backlog reservation taken in enqueueFromJavaThread regardless of whether the payload is kept. + auto releaseReservation = qScopeGuard([this, reserved = payload.size()] { rxQueue.releaseReservation(reserved); }); + if (stale || !q->isOpen() || !javaPortAlive() || payload.isEmpty()) + return; + + // Bound RX growth if the consumer stalls (mirrors HostSerialPort's lossless 512 KB cap; lossy here because Android's reader has no pause primitive). + const AndroidSerialRxQueue::BufferCapDecision capDecision = rxQueue.checkBufferCap(buffer.size(), payload.size()); + if (capDecision.overCap) { + if (capDecision.shouldWarn) { + qCWarning(AndroidSerialPortLog) << "RX buffer cap" << rxQueue.cap() << "exceeded on" << systemLocation + << "- dropping data (consumer stalled)"; + } + return; + } + + buffer.append(payload); + + if (!emittedReadyRead) { + emittedReadyRead = true; + // Not QScopedValueRollback: a readyRead slot may delete the port, so the flag restore must be + // gated on a QPointer liveness check — an unconditional rollback would write to freed memory. + QPointer self = q; + emit q->readyRead(); + if (self) + emittedReadyRead = false; + } +} diff --git a/src/Android/Serial/AndroidSerialPort.h b/src/Android/Serial/AndroidSerialPort.h new file mode 100644 index 000000000000..5a1467b38b37 --- /dev/null +++ b/src/Android/Serial/AndroidSerialPort.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "QGCSerialPort.h" + +QT_FORWARD_DECLARE_CLASS(QObject) + +class AndroidSerialPortPrivate; +class QGCSerialPortInfo; + +class AndroidSerialPort : public QGCSerialPort +{ + Q_OBJECT + Q_DECLARE_PRIVATE(AndroidSerialPort) + friend class AndroidSerialPortPrivate; // JNI callbacks live in Private; reach back via _dispatch* helpers + +public: + explicit AndroidSerialPort(QObject* parent = nullptr); + explicit AndroidSerialPort(const QString& systemLocation, QObject* parent = nullptr); + ~AndroidSerialPort() override; + + static bool initializeNative(); // false if JNI native registration / wire-constant check failed + static QList availableDevices(); + + bool isSequential() const override { return true; } + + // Streaming device: more data is always possible while open. Base default reports atEnd=true between reads. + bool atEnd() const override { return false; } + + bool open(QIODeviceBase::OpenMode mode) override; + void close() override; + qint64 bytesToWrite() const override; + bool waitForReadyRead(int msecs) override; + bool waitForBytesWritten(int msecs) override; + + void setPortName(const QString& name) override { setSystemLocation(name); } + + QString systemLocation() const; + void setSystemLocation(const QString& location); + QString portName() const override; // Display name (last path component). + + // Single source of truth for wire params; stashes while closed, pushes to Java when open. + bool reconfigure(const SerialPortConfig& cfg) override; + SerialPortConfig portConfig() const; + + bool setDataTerminalReady(bool set) override; + + bool flush() override; + bool clear(bool input = true, bool output = true); + + QGCSerialPortError error() const override; + void clearError() override; + + void setWriteBufferSize(qint64 size) override; + qint64 writeBufferSize() const override; + +protected: + qint64 readData(char* data, qint64 maxSize) override; + qint64 writeData(const char* data, qint64 maxSize) override; + +private: + bool _validateOpenMode(QIODeviceBase::OpenMode mode); +}; diff --git a/src/Android/Serial/AndroidSerialPortJni.cc b/src/Android/Serial/AndroidSerialPortJni.cc new file mode 100644 index 000000000000..856ed3eb0422 --- /dev/null +++ b/src/Android/Serial/AndroidSerialPortJni.cc @@ -0,0 +1,152 @@ +#include +#include +#include +#include +#include +#include + +#include "AndroidSerialPortPrivate.h" + +namespace { + +// JNI native preamble: lifetime-fenced registry lookup, stale-token warn; invokes fn(port), value-initialized Ret on any guard rejection. +template > +static Ret withPort(jlong token, F&& fn) +{ + if (token != 0) { + PortRegistry::LookupGuard guard(token); + if (AndroidSerialPort* const port = guard.port()) { + return std::forward(fn)(port); + } + qCWarning(AndroidSerialPortLog) << "stale token, port already destroyed"; + } else { + qCWarning(AndroidSerialPortLog) << "called with token=0"; + } + if constexpr (!std::is_void_v) { + return Ret{}; + } +} + +} // namespace + +bool onOwnerThread(const QObject* obj, const char* who) +{ + if (obj->thread() == QThread::currentThread()) { + return true; + } + qCWarning(AndroidSerialPortLog) << who << "called off owner thread"; + return false; +} + +int flowControlToWire(QGCFlowControl fc) +{ + switch (fc) { + case QGCFlowControl::None: + return AndroidSerialWire::NoFlowControl; + case QGCFlowControl::HardwareRtsCts: + return AndroidSerialWire::RtsCtsFlowControl; + case QGCFlowControl::SoftwareXonXoff: + return AndroidSerialWire::XonXoffFlowControl; + case QGCFlowControl::DtrDsr: + return AndroidSerialWire::DtrDsrFlowControl; + case QGCFlowControl::XonXoffInline: + return AndroidSerialWire::XonXoffInlineFlowControl; + } + Q_UNREACHABLE_RETURN(AndroidSerialWire::NoFlowControl); +} + +QGCSerialPortError exceptionKindToError(AndroidSerialWire::JavaExceptionKind kind) +{ + switch (kind) { + case AndroidSerialWire::JavaExceptionKind::Resource: + return QGCSerialPortError::ResourceUnavailable; + case AndroidSerialWire::JavaExceptionKind::Permission: + return QGCSerialPortError::PermissionDenied; + case AndroidSerialWire::JavaExceptionKind::OpenFailed: + return QGCSerialPortError::OpenFailed; + case AndroidSerialWire::JavaExceptionKind::Unknown: + return QGCSerialPortError::Unknown; + } + return QGCSerialPortError::Unknown; +} + +QtJniTypes::QGCUsbSerialManagerSerialParameters makeSerialParamsJni(qint32 baud, QGCDataBits dataBits, + QGCStopBits stopBits, QGCParity parity) +{ + return QJniObject::construct( + static_cast(baud), static_cast(dataBits), static_cast(stopBits), + static_cast(parity)); +} + +void AndroidSerialPortPrivate::jniDeviceHasDisconnected(JNIEnv*, jobject, jlong token) +{ + withPort(token, [](AndroidSerialPort* port) { port->d_func()->dispatchCloseFromJavaThread(); }); +} + +void AndroidSerialPortPrivate::jniDeviceNewData(JNIEnv* env, jobject, jlong token, QtJniTypes::ByteBuffer buffer, + jint length) +{ + constexpr jsize kMaxNativePayloadBytes = static_cast(AndroidSerialWire::MAX_CHUNK_BYTES); + + if (!buffer.isValid()) { + qCWarning(AndroidSerialPortLog) << "called with null buffer"; + return; + } + + if (length <= 0) { + qCWarning(AndroidSerialPortLog) << "received empty data (length=" << length << ")"; + return; + } + + void* const ptr = env->GetDirectBufferAddress(buffer.object()); + if (QJniEnvironment::checkAndClearExceptions(env, QJniEnvironment::OutputMode::Silent) || !ptr) { + qCWarning(AndroidSerialPortLog) << "GetDirectBufferAddress failed or buffer is not direct"; + return; + } + + const jlong capacity = env->GetDirectBufferCapacity(buffer.object()); + if (capacity <= 0) { + qCWarning(AndroidSerialPortLog) << "GetDirectBufferCapacity returned" << capacity; + return; + } + jsize cappedLen = (length > kMaxNativePayloadBytes) ? kMaxNativePayloadBytes : static_cast(length); + cappedLen = qMin(cappedLen, static_cast(capacity)); + if (cappedLen != static_cast(length)) { + qCWarning(AndroidSerialPortLog) << "payload exceeds limit, truncating from" << length << "to" << cappedLen + << "bytes"; + } + + // Copy required: the direct buffer returns to Java's pool when this JNI call returns. + const QByteArray payload(reinterpret_cast(ptr), static_cast(cappedLen)); + withPort(token, [&payload](AndroidSerialPort* port) { port->d_func()->enqueueFromJavaThread(payload); }); +} + +void AndroidSerialPortPrivate::jniDeviceException(JNIEnv* env, jobject, jlong token, jint kind, jstring message) +{ + if (!message) { + qCWarning(AndroidSerialPortLog) << "called with null message"; + return; + } + + QJniEnvironment::checkAndClearExceptions(env, QJniEnvironment::OutputMode::Verbose); + QString exceptionMessage = QJniObject(message).toString(); + if (QJniEnvironment::checkAndClearExceptions(env, QJniEnvironment::OutputMode::Verbose)) { + qCWarning(AndroidSerialPortLog) << "exception converting jstring"; + } + + const auto kindEnum = (kind >= 0 && kind <= static_cast(AndroidSerialWire::JavaExceptionKind::OpenFailed)) + ? static_cast(kind) + : AndroidSerialWire::JavaExceptionKind::Unknown; + + withPort(token, [&](AndroidSerialPort* port) { + qCWarning(AndroidSerialPortLog) << "Exception from Java:" << exceptionMessage << "kind=" << kind; + port->d_func()->dispatchExceptionFromJavaThread(exceptionKindToError(kindEnum), std::move(exceptionMessage)); + }); +} + +void AndroidSerialPortPrivate::jniDeviceBytesWritten(JNIEnv*, jobject, jlong token, jint n) +{ + if (n <= 0) + return; + withPort(token, [n](AndroidSerialPort* port) { port->d_func()->ackBytesWrittenFromJavaThread(n); }); +} diff --git a/src/Android/Serial/AndroidSerialPortPrivate.h b/src/Android/Serial/AndroidSerialPortPrivate.h new file mode 100644 index 000000000000..5092ecd501b5 --- /dev/null +++ b/src/Android/Serial/AndroidSerialPortPrivate.h @@ -0,0 +1,133 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AndroidSerialPort.h" // Q_DECLARE_PUBLIC requires the full type for its static_cast. +#include "AndroidSerialPortRegistry.h" +#include "AndroidSerialRxQueue.h" +#include "QGCLoggingCategory.h" +#include "QGCSerialPortTypes.h" +#include "SerialWireConstants.h" // hand-maintained; keep in sync with SerialWireConstants.java +#include "SerialWriteEngine.h" + +Q_DECLARE_JNI_CLASS(ByteBuffer, "java/nio/ByteBuffer") +Q_DECLARE_JNI_CLASS(QGCSerialPort, "org/mavlink/qgroundcontrol/serial/QGCSerialPort") +Q_DECLARE_JNI_CLASS(QGCUsbSerialManager, "org/mavlink/qgroundcontrol/serial/QGCUsbSerialManager") +Q_DECLARE_JNI_CLASS(QGCUsbSerialManagerSerialParameters, + "org/mavlink/qgroundcontrol/serial/QGCUsbSerialManager$SerialParameters") + +Q_DECLARE_LOGGING_CATEGORY(AndroidSerialPortLog) + +int flowControlToWire(QGCFlowControl fc); + +// Warns (naming `who`) and returns false when called off obj's owner thread. +bool onOwnerThread(const QObject* obj, const char* who); +QGCSerialPortError exceptionKindToError(AndroidSerialWire::JavaExceptionKind kind); +QtJniTypes::QGCUsbSerialManagerSerialParameters makeSerialParamsJni(qint32 baud, QGCDataBits dataBits, + QGCStopBits stopBits, QGCParity parity); + +// Private impl for AndroidSerialPort (Qt d-ptr; public class is a thin QIODevice façade). +// +// Cross-language port bridge: the Java PortRegistry owns the UsbSerialPort; the C++ +// AndroidSerialPortRegistry owns this QIODevice and hands Java an opaque jlong token at open. +// Java callbacks (data/disconnect/exception/bytesWritten) carry that token; the C++ side looks the +// owner up via PortRegistry::LookupGuard (withPort) so a stale token after destruction is a no-op. +// open() is single-owner: one QIODevice maps to exactly one Java port for its lifetime. +class AndroidSerialPortPrivate : public QIODevicePrivate +{ +public: + Q_DECLARE_PUBLIC(AndroidSerialPort) + + AndroidSerialPortPrivate() = default; + + // Registered against the QGCSerialPort Java class by AndroidSerialPort::initializeNative. + static void jniDeviceHasDisconnected(JNIEnv* env, jobject obj, jlong token); + static void jniDeviceNewData(JNIEnv* env, jobject obj, jlong token, QtJniTypes::ByteBuffer buffer, jint length); + static void jniDeviceException(JNIEnv* env, jobject obj, jlong token, jint kind, jstring message); + static void jniDeviceBytesWritten(JNIEnv* env, jobject obj, jlong token, jint n); + + // Registered against the QGCUsbSerialManager Java class; no token (manager-wide attach/permission event). + static void jniUsbDevicesChanged(JNIEnv* env, jobject obj); + + Q_DECLARE_JNI_NATIVE_METHOD_IN_CURRENT_SCOPE(jniDeviceHasDisconnected, nativeDeviceHasDisconnected) + Q_DECLARE_JNI_NATIVE_METHOD_IN_CURRENT_SCOPE(jniDeviceNewData, nativeDeviceNewData) + Q_DECLARE_JNI_NATIVE_METHOD_IN_CURRENT_SCOPE(jniDeviceException, nativeDeviceException) + Q_DECLARE_JNI_NATIVE_METHOD_IN_CURRENT_SCOPE(jniDeviceBytesWritten, nativeDeviceBytesWritten) + Q_DECLARE_JNI_NATIVE_METHOD_IN_CURRENT_SCOPE(jniUsbDevicesChanged, nativeUsbDevicesChanged) + + bool javaPortAlive() const noexcept { return javaAlive.load(std::memory_order_acquire); } + + // Marshal `fn(AndroidSerialPort*)` onto the owner thread via a queued call; no-op if the owner is gone + // by the time it runs (QPointer-fenced). The single home for the JNI-thread -> owner-thread hop that + // postBytesWritten / dispatchClose / dispatchException / appendToBuffer all need. + template + void postToOwner(F&& fn) + { + Q_Q(AndroidSerialPort); + QPointer self = q; + QMetaObject::invokeMethod( + q, + [self, fn = std::forward(fn)]() mutable { + if (self) { + fn(self.data()); + } + }, + Qt::QueuedConnection); + } + + void setError(QGCSerialPortError code, const QString& message = {}); + + // Java-writer-thread ack: forward to the write engine, then post bytesWritten(n) to the owner thread. + void ackBytesWrittenFromJavaThread(qint64 n); + void postBytesWritten(qint64 n); + + bool openJavaPort(qint64 handle, QIODeviceBase::OpenMode mode); + void teardownJavaPort(); // Safe after partial open(); single-shot via javaAlive exchange. + + // JNI-thread entry; copies payload and posts to owner thread. + void enqueueFromJavaThread(QByteArrayView bytes); + // Owner-thread continuation of enqueueFromJavaThread; drops payloads stamped with a stale RX generation. + void appendToBuffer(QByteArray payload, quint32 generation); + void dispatchCloseFromJavaThread(); + void dispatchExceptionFromJavaThread(QGCSerialPortError errorCode, QString message); + + // JNI port object and associated lifecycle state. + QJniObject javaPort; + jlong handle = 0; + std::atomic javaAlive{false}; // owner exchange(false) in close(), JNI store(false) on unplug. + + // Write-side accounting + JNI submission. Holds references to javaAlive/javaPort above, so it must be + // declared after them. + SerialWriteEngine writeEngine{javaAlive}; + + QString systemLocation; + // Single source of truth for wire params; stashed while closed, re-synced to Java on every (re)open. + SerialPortConfig pending; + + QGCSerialPortError error = QGCSerialPortError::NoError; + + // Re-entrancy guards (mirrors qserialport_unix.cpp) — block readyRead↔bytesWritten recursion. + bool emittedReadyRead = false; + bool emittedBytesWritten = false; + + // RX flow-control accounting (backlog reservation, generation epoch, drop-warn latches). Pure logic, + // host-unit-tested in AndroidSerialRxQueueTest; the byte storage stays in QIODevicePrivate::buffer. + AndroidSerialRxQueue rxQueue; + + // Suppresses the racing Java IOException as ResourceUnavailable during a forced close. + bool expectClosure = false; +}; diff --git a/src/Android/Serial/AndroidSerialPortRegistry.cc b/src/Android/Serial/AndroidSerialPortRegistry.cc new file mode 100644 index 000000000000..e214f2b0be85 --- /dev/null +++ b/src/Android/Serial/AndroidSerialPortRegistry.cc @@ -0,0 +1,52 @@ +#include "AndroidSerialPortRegistry.h" + +#include +#include +#include + +namespace PortRegistry { + +namespace { + +QReadWriteLock& registryLock() +{ + static QReadWriteLock lock; + return lock; +} + +QHash& registry() +{ + static QHash h; + return h; +} + +std::atomic g_nextToken{1}; + +} // namespace + +Token allocateToken() +{ + return g_nextToken.fetch_add(1, std::memory_order_relaxed); +} + +void registerPort(Token token, AndroidSerialPort* port) +{ + QWriteLocker locker(®istryLock()); + registry().insert(token, port); +} + +void unregisterPort(Token token) +{ + QWriteLocker locker(®istryLock()); + registry().remove(token); +} + +void clear() +{ + QWriteLocker locker(®istryLock()); + registry().clear(); +} + +LookupGuard::LookupGuard(Token token) : _locker(®istryLock()), _port(registry().value(token, nullptr)) {} + +} // namespace PortRegistry diff --git a/src/Android/Serial/AndroidSerialPortRegistry.h b/src/Android/Serial/AndroidSerialPortRegistry.h new file mode 100644 index 000000000000..98d0c26937c7 --- /dev/null +++ b/src/Android/Serial/AndroidSerialPortRegistry.h @@ -0,0 +1,42 @@ +#pragma once + +// Thread-safe token→port map for the JNI boundary; fences port lifetime against in-flight callbacks (see LookupGuard). + +#include +#include + +class AndroidSerialPort; + +namespace PortRegistry { + +// Token type matches the JNI jlong width (both 64-bit signed); kept jni-free so the registry builds and unit-tests on the host. +using Token = qint64; + +// Strictly-increasing, never reused — JNI tokens go stale on close() rather than being recycled. +Token allocateToken(); + +void registerPort(Token token, AndroidSerialPort* port); +void unregisterPort(Token token); + +// Drop all token->port entries; called from JNI_OnUnload so a same-process native reload starts clean. +void clear(); + +// RAII guard for JNI callback entry: its read lock fences port lifetime so the port can't be destroyed mid-callback (UAF). QPointer won't do — it nulls after destruction, not before. +class LookupGuard +{ +public: + explicit LookupGuard(Token token); + + LookupGuard(const LookupGuard&) = delete; + LookupGuard& operator=(const LookupGuard&) = delete; + + AndroidSerialPort* port() const { return _port; } + + explicit operator bool() const { return _port != nullptr; } + +private: + QReadLocker _locker; + AndroidSerialPort* _port; +}; + +} // namespace PortRegistry diff --git a/src/Android/Serial/AndroidSerialRxQueue.cc b/src/Android/Serial/AndroidSerialRxQueue.cc new file mode 100644 index 000000000000..0a6e2911a1be --- /dev/null +++ b/src/Android/Serial/AndroidSerialRxQueue.cc @@ -0,0 +1,59 @@ +#include "AndroidSerialRxQueue.h" + +AndroidSerialRxQueue::Reservation AndroidSerialRxQueue::reserve(qint64 size) +{ + // Stamp the epoch before committing the reservation: a flush() between here and the matching append() + // then tags this payload with the old generation, so isStale() drops it instead of re-appending + // pre-flush bytes. Sampled before the CAS so a racing flush can't be missed. + const quint32 generation = _generation.load(std::memory_order_acquire); + + // CAS the cap check and the reserve into one atomic step so concurrent producers can't both pass the + // check and over-reserve past the cap (the documented invariant is a single JNI reader thread; this + // keeps the accounting correct even if that ever changes). + qint64 pending = _pendingRxBytes.load(std::memory_order_acquire); + do { + if ((pending + size) > _cap) { + Reservation result; + result.accepted = false; + result.shouldWarn = !_queueCapWarned.exchange(true, std::memory_order_relaxed); + return result; + } + } while (!_pendingRxBytes.compare_exchange_weak(pending, pending + size, std::memory_order_acq_rel)); + + Reservation result; + result.accepted = true; + result.generation = generation; + return result; +} + +void AndroidSerialRxQueue::releaseReservation(qint64 size) +{ + const qint64 prev = _pendingRxBytes.fetch_sub(size, std::memory_order_acq_rel); + // flush() may have zeroed the counter between reserve and here; CAS-clamp to 0 so a concurrent + // new-session fetch_add isn't erased by a blind store. + qint64 cur = prev - size; + while ((cur < 0) && !_pendingRxBytes.compare_exchange_weak(cur, 0, std::memory_order_acq_rel)) { } + if ((prev - size) <= 0) { + _queueCapWarned.store(false, std::memory_order_relaxed); + } +} + +AndroidSerialRxQueue::BufferCapDecision AndroidSerialRxQueue::checkBufferCap(qint64 currentBuffered, qint64 size) +{ + BufferCapDecision decision; + if ((currentBuffered + size) > _cap) { + decision.overCap = true; + decision.shouldWarn = !_bufferCapWarned; + _bufferCapWarned = true; + return decision; + } + _bufferCapWarned = false; + return decision; +} + +void AndroidSerialRxQueue::flush() +{ + _generation.fetch_add(1, std::memory_order_acq_rel); + _pendingRxBytes.store(0, std::memory_order_release); + _queueCapWarned.store(false, std::memory_order_relaxed); +} diff --git a/src/Android/Serial/AndroidSerialRxQueue.h b/src/Android/Serial/AndroidSerialRxQueue.h new file mode 100644 index 000000000000..8c162650345e --- /dev/null +++ b/src/Android/Serial/AndroidSerialRxQueue.h @@ -0,0 +1,64 @@ +#pragma once + +// Pure RX flow-control accounting for AndroidSerialPort's lossy receive path. Holds no QObject/JNI/ +// QIODevice state, so it builds and unit-tests on the host. The Android port keeps the actual byte +// storage (QIODevicePrivate::buffer) and emits readyRead(); this class only owns the policy: +// * a backlog reservation bounding queued-but-not-yet-drained JNI payloads against the RX cap, +// * an RX generation/epoch so a clear(Input)/close() drops already-posted payloads as stale, +// * warn-once latches for the queue-backlog and buffer overflow drop messages. +// +// Threading: reserve()/isStale()/releaseReservation()/flush() touch only atomics and are safe from the +// JNI thread; checkBufferCap() mutates the owner-thread-only buffer-cap latch and must run on the owner. + +#include + +#include + +#include "QGCSerialPortTypes.h" + +class AndroidSerialRxQueue +{ +public: + explicit AndroidSerialRxQueue(qint64 capBytes = kSerialRxBufferCapBytes) : _cap(capBytes) {} + + struct Reservation + { + bool accepted = false; // false: over the backlog cap, caller drops the payload + bool shouldWarn = false; // first drop of an overflow episode — caller logs once + quint32 generation = 0; // epoch to stamp on the payload (only meaningful when accepted) + }; + + // JNI thread: reserve backlog for `size` bytes. Stamps the current generation so a flush() between + // here and the matching append() invalidates the payload. + Reservation reserve(qint64 size); + + // Owner thread: did `generation` predate a flush()? + bool isStale(quint32 generation) const { return generation != _generation.load(std::memory_order_acquire); } + + // Owner thread: release a reservation taken by reserve(), whether or not the payload was kept. + void releaseReservation(qint64 size); + + struct BufferCapDecision + { + bool overCap = false; + bool shouldWarn = false; // first over-cap drop of an episode + }; + + // Owner thread: may `size` bytes append to a buffer currently holding `currentBuffered`? A within-cap + // result clears the warn-once latch so the next overflow logs again. + BufferCapDecision checkBufferCap(qint64 currentBuffered, qint64 size); + + // clear(Input)/close()/open(): bump the epoch and reset the backlog so already-posted payloads drop. + void flush(); + + qint64 cap() const { return _cap; } + qint64 pendingBytes() const { return _pendingRxBytes.load(std::memory_order_acquire); } + quint32 generation() const { return _generation.load(std::memory_order_acquire); } + +private: + const qint64 _cap; + std::atomic _pendingRxBytes{0}; + std::atomic _queueCapWarned{false}; + std::atomic _generation{0}; + bool _bufferCapWarned = false; // owner-thread only +}; diff --git a/src/Android/Serial/CMakeLists.txt b/src/Android/Serial/CMakeLists.txt new file mode 100644 index 000000000000..26a64c517a01 --- /dev/null +++ b/src/Android/Serial/CMakeLists.txt @@ -0,0 +1,41 @@ +# ============================================================================ +# Android Serial subsystem +# JNI-backed USB-host serial port plus its host-testable pure-logic pieces. +# ============================================================================ + +# The JNI-free trio below includes src/Comms/Serial headers; when serial is disabled (e.g. iOS) that dir +# returns early without publishing its include path, so skip these too. +if(QGC_NO_SERIAL_LINK) + return() +endif() + +# JNI-free pieces compiled on every platform so the host test suite (test/Comms/Serial) can exercise them: +# * AndroidSerialPortRegistry — token->port map fencing JNI callbacks (UAF guard) +# * AndroidSerialRxQueue — RX flow-control accounting (backlog/epoch/warn latches) +# * SerialPortInfoCodec — packed USB-port enumeration decoder (wire layout shared with Java) +# The include dir is target-global, so the host tests and src/Comms/Serial reach these headers too. +target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + AndroidSerialPortRegistry.cc + AndroidSerialPortRegistry.h + AndroidSerialRxQueue.cc + AndroidSerialRxQueue.h + SerialPortInfoCodec.cc + SerialPortInfoCodec.h +) +target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +if(NOT ANDROID) + return() +endif() + +target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + AndroidSerialPort.cc + AndroidSerialPort.h + AndroidSerialPortJni.cc + AndroidSerialPortPrivate.h + SerialWireConstants.h + SerialWriteEngine.cc + SerialWriteEngine.h +) diff --git a/src/Android/Serial/SerialPortInfoCodec.cc b/src/Android/Serial/SerialPortInfoCodec.cc new file mode 100644 index 000000000000..c7296a04ed4b --- /dev/null +++ b/src/Android/Serial/SerialPortInfoCodec.cc @@ -0,0 +1,75 @@ +#include "SerialPortInfoCodec.h" + +#include +#include +#include +#include +#include + +#include "QGCSerialPortTypes.h" +#include "SerialWireConstants.h" + +namespace { + +namespace keys = AndroidSerialWire::JsonKeys; + +QString portNameFromDevicePath(const QString& source) +{ + return source.startsWith(kDevPrefix) ? source.mid(kDevPrefix.size()) : source; +} + +} // namespace + +namespace SerialPortInfoCodec { + +QList unpack(QByteArrayView packed) +{ + QList out; + if (packed.isEmpty()) { + return out; + } + + const QJsonDocument doc = QJsonDocument::fromJson(QByteArray(packed.data(), packed.size())); + if (!doc.isObject()) { // garbled buffer; callers log and proceed + return out; + } + + const QJsonArray ports = doc.object().value(keys::Ports).toArray(); + out.reserve(ports.size()); + for (const QJsonValue& portValue : ports) { + const QJsonObject port = portValue.toObject(); + + // A missing string key (absent in JSON) yields a null QString, distinct from an empty "". + const QString deviceName = port.value(keys::DeviceName).toString(); + if (deviceName.isEmpty()) { + continue; // unusable port + } + + QGCSerialPortInfo::Data data; + data.portName = portNameFromDevicePath(deviceName); + data.systemLocation = deviceName; + data.description = port.value(keys::ProductName).toString(); + data.manufacturer = port.value(keys::ManufacturerName).toString(); + data.serialNumber = port.value(keys::SerialNumber).toString(); + data.productIdentifier = static_cast(port.value(keys::ProductId).toInt()); + data.vendorIdentifier = static_cast(port.value(keys::VendorId).toInt()); + // VID/PID enable LinkManager::_filterCompositePorts dedup, which also needs a non-empty, non-"0" + // serialNumber to drop a board's secondary CDC port. Boards reporting no serial are not deduped. + data.hasVendorIdentifier = (data.vendorIdentifier != 0); + data.hasProductIdentifier = (data.productIdentifier != 0); + + const QJsonArray bauds = port.value(keys::BaudRates).toArray(); + QList baudList; + baudList.reserve(bauds.size()); + for (const QJsonValue& baud : bauds) { + baudList.append(baud.toInt()); + } + data.supportedBaudRates = std::move(baudList); + + out.emplace_back(std::move(data)); + } + + return out; +} + +} // namespace SerialPortInfoCodec diff --git a/src/Android/Serial/SerialPortInfoCodec.h b/src/Android/Serial/SerialPortInfoCodec.h new file mode 100644 index 000000000000..6d8137221647 --- /dev/null +++ b/src/Android/Serial/SerialPortInfoCodec.h @@ -0,0 +1,24 @@ +#pragma once + +// JNI-free decoder for the JSON USB-port enumeration buffer produced Java-side by +// QGCUsbSerialManager.packPortsInfo. Holds no JNI/QObject state, so it builds and unit-tests on the +// host (SerialPortInfoCodecTest). Keeping the wire format in one decoder — instead of inlined in +// AndroidSerialPort::availableDevices — means it is exercised by a round-trip test mirrored by the +// Java packer test (UsbPortInfoPackingTest), so a format change on either side fails a test. +// +// Shape (UTF-8 JSON): {"ports":[{deviceName, productName, manufacturerName, serialNumber, productId, +// vendorId, baudRates:[...]}]}. An absent string key is a null QString (distinct from an empty ""); +// a missing baudRates yields no supported rates. + +#include +#include + +#include "QGCSerialPortInfo.h" + +namespace SerialPortInfoCodec { + +// Decodes `packed` into per-port Data records. Returns the ports parsed before any malformity; a +// truncated/garbled buffer yields the prefix that parsed cleanly (callers log and proceed). +QList unpack(QByteArrayView packed); + +} // namespace SerialPortInfoCodec diff --git a/src/Android/Serial/SerialWireConstants.h b/src/Android/Serial/SerialWireConstants.h new file mode 100644 index 000000000000..e7e74928fd70 --- /dev/null +++ b/src/Android/Serial/SerialWireConstants.h @@ -0,0 +1,52 @@ +#pragma once + +// Hand-maintained: keep every value in sync with its twin in SerialWireConstants.java. Both sides pin the same +// literals via twin tests (SerialWireContractTest / SerialWireConstantsTest); FlowControl is also checked vs mik3y at runtime. + +#include +#include + +#include + +namespace AndroidSerialWire { + +constexpr qint64 MAX_CHUNK_BYTES = 16384; +constexpr int BAD_DEVICE_ID = 0; + +// jint is spec-guaranteed 32-bit signed; use qint32 so this header compiles host-side (test) without JNI types. +static_assert(MAX_CHUNK_BYTES <= std::numeric_limits::max(), + "MAX_CHUNK_BYTES must fit the JNI jint length field"); + +enum class JavaExceptionKind : int +{ + Unknown = 0, // Unclassified failure + Resource = 1, // IOException at runtime — hot-unplug + Permission = 2, // USB permission denied + OpenFailed = 3, // Open-path failure (driver / port / connection) + Last = OpenFailed, +}; + +// FlowControl wire values match mik3y UsbSerialPort.FlowControl.ordinal() / SerialConstants.FC_*. +enum FlowControl +{ + NoFlowControl = 0, + RtsCtsFlowControl = 1, + DtrDsrFlowControl = 2, + XonXoffFlowControl = 3, + XonXoffInlineFlowControl = 4, +}; + +// JSON key names for the USB-port enumeration blob; twin of KEY_* in SerialWireConstants.java. The shared +// golden fixture (test/Comms/Serial/data/PortInfoGolden.json) pins these literals across both languages. +namespace JsonKeys { +constexpr auto Ports = QLatin1StringView("ports"); +constexpr auto DeviceName = QLatin1StringView("deviceName"); +constexpr auto ProductName = QLatin1StringView("productName"); +constexpr auto ManufacturerName = QLatin1StringView("manufacturerName"); +constexpr auto SerialNumber = QLatin1StringView("serialNumber"); +constexpr auto ProductId = QLatin1StringView("productId"); +constexpr auto VendorId = QLatin1StringView("vendorId"); +constexpr auto BaudRates = QLatin1StringView("baudRates"); +} // namespace JsonKeys + +} // namespace AndroidSerialWire diff --git a/src/Android/Serial/SerialWriteEngine.cc b/src/Android/Serial/SerialWriteEngine.cc new file mode 100644 index 000000000000..2133a0acc3c1 --- /dev/null +++ b/src/Android/Serial/SerialWriteEngine.cc @@ -0,0 +1,179 @@ +#include "SerialWriteEngine.h" + +#include +#include +#include + +#include + +#include "QGCLoggingCategory.h" +#include "SerialWireConstants.h" + +Q_DECLARE_LOGGING_CATEGORY(AndroidSerialPortLog) +Q_DECLARE_JNI_CLASS(ByteBuffer, "java/nio/ByteBuffer") + +namespace { +constexpr int kDefaultWriteTimeoutMs = 5000; +} + +bool SerialWriteEngine::allocateScratch(const QString& location) +{ + // Release any prior buffer first: the global ref wraps the old backing alloc, so resetting the unique_ptr + // before clearing the ref would leave a DirectByteBuffer over freed memory (UAF). + _scratchBuffer = QJniObject(); + _scratch.reset(); + _scratch = std::make_unique(AndroidSerialWire::MAX_CHUNK_BYTES); + + JNIEnv* const env = QJniEnvironment::getJniEnv(); + if (!env) { + qCWarning(AndroidSerialPortLog) << "No JNI env while allocating write scratch for" << location; + return false; + } + const jobject raw = + env->NewDirectByteBuffer(_scratch.get(), static_cast(AndroidSerialWire::MAX_CHUNK_BYTES)); + if (QJniEnvironment::checkAndClearExceptions(env, QJniEnvironment::OutputMode::Silent) || !raw) { + qCWarning(AndroidSerialPortLog) << "Failed to allocate write scratch DirectByteBuffer for" << location; + return false; + } + _scratchBuffer = QJniObject::fromLocalRef(raw); + return true; +} + +void SerialWriteEngine::releaseScratch() +{ + // Release the global ref before the heap alloc — DirectByteBuffer wraps the raw pointer. + _scratchBuffer = QJniObject(); + _scratch.reset(); +} + +void SerialWriteEngine::reset() +{ + QMutexLocker lk(&_mutex); + _error.store(false, std::memory_order_relaxed); + _inFlight.store(0, std::memory_order_relaxed); +} + +void SerialWriteEngine::clearInFlight() +{ + QMutexLocker lk(&_mutex); + _inFlight.store(0, std::memory_order_relaxed); +} + +qint64 SerialWriteEngine::enqueueToJava(QJniObject& javaPort, QByteArrayView bytes, const QString& location) +{ + if (!_scratch || !_scratchBuffer.isValid()) { + qCWarning(AndroidSerialPortLog) << "Write scratch buffer missing on" << location; + return -1; + } + JNIEnv* const env = QJniEnvironment::getJniEnv(); + if (!env) { + qCWarning(AndroidSerialPortLog) << "No JNI env while enqueuing write on" << location; + return -1; + } + const QtJniTypes::ByteBuffer jBuffer{_scratchBuffer.object()}; + qint64 offset = 0; + const qint64 total = bytes.size(); + while (offset < total) { + if (!_javaAlive.load(std::memory_order_acquire)) { + return offset > 0 ? offset : -1; + } + const qint64 remaining = total - offset; + const int capped = static_cast(qMin(remaining, AndroidSerialWire::MAX_CHUNK_BYTES)); + std::memcpy(_scratch.get(), bytes.data() + offset, static_cast(capped)); + + const jint accepted = javaPort.callMethod("enqueueWrite", jBuffer, static_cast(capped)); + if (QJniEnvironment::checkAndClearExceptions(env, QJniEnvironment::OutputMode::Verbose)) { + qCWarning(AndroidSerialPortLog) << "enqueueWrite threw on" << location; + return offset > 0 ? offset : -1; + } + if (accepted <= 0) { + qCWarning(AndroidSerialPortLog) << "enqueueWrite rejected on" << location; + return offset > 0 ? offset : -1; + } + offset += accepted; + } + return offset; +} + +qint64 SerialWriteEngine::submit(QJniObject& javaPort, QByteArrayView bytes, const QString& location) +{ + if (_error.load(std::memory_order_acquire)) { + return -1; + } + if (!_javaAlive.load(std::memory_order_acquire)) { + return -1; + } + + const qint64 maxSize = bytes.size(); + const qint64 budget = _writeBufferMaxSize ? _writeBufferMaxSize - _inFlight.load(std::memory_order_acquire) : maxSize; + // Buffer full -> 0 (backpressure); QSerialPort convention, not a port-level error. + if (budget <= 0) { + return 0; + } + + const qint64 toWrite = qMin(maxSize, budget); + // Reserve before enqueuing: the Java writer can ack (decrement, clamped to 0) before a post-enqueue + // fetch_add lands, stranding _inFlight high so waitForDrain never drains. + { + QMutexLocker lk(&_mutex); + _inFlight.fetch_add(toWrite, std::memory_order_relaxed); + } + + const qint64 enqueued = enqueueToJava(javaPort, QByteArrayView(bytes.data(), toWrite), location); + if (enqueued != toWrite) { + QMutexLocker lk(&_mutex); + const qint64 refund = toWrite - qMax(enqueued, 0); + const qint64 next = _inFlight.load(std::memory_order_relaxed) - refund; + _inFlight.store(next < 0 ? 0 : next, std::memory_order_relaxed); + } + return (enqueued > 0) ? enqueued : -1; +} + +void SerialWriteEngine::ack(qint64 n) +{ + { + QMutexLocker lk(&_mutex); + const qint64 next = _inFlight.load(std::memory_order_relaxed) - n; + _inFlight.store(next < 0 ? 0 : next, std::memory_order_relaxed); + } + _cv.wakeAll(); +} + +void SerialWriteEngine::fail() +{ + { + QMutexLocker lk(&_mutex); + _error.store(true, std::memory_order_relaxed); + _inFlight.store(0, std::memory_order_relaxed); + } + _cv.wakeAll(); +} + +void SerialWriteEngine::wakeDrainWaiters() +{ + // Lock so we never wake between a waiter's predicate check and its wait() (no missed wakeup). + QMutexLocker lk(&_mutex); + _cv.wakeAll(); +} + +bool SerialWriteEngine::waitForDrain(int msecs) +{ + QMutexLocker locker(&_mutex); + const auto settled = [this] { + return _inFlight.load(std::memory_order_acquire) == 0 || _error.load(std::memory_order_acquire) || + !_javaAlive.load(std::memory_order_acquire); + }; + + // Clamp an infinite wait to a bounded one: a silently-wedged Java writer (USB stall with no + // exception/disconnect callback) would otherwise block the link I/O thread forever. + const int waitMs = (msecs < 0) ? kDefaultWriteTimeoutMs : msecs; + QDeadlineTimer deadline(waitMs); + while (!settled()) { + if (!_cv.wait(&_mutex, deadline)) { + break; + } + } + // Drained iff in-flight hit zero with no write error. A still-nonzero backlog means error, gone port, + // or timeout — the caller separates those via hasError()/inFlight()/the port-alive flag. + return _inFlight.load(std::memory_order_acquire) == 0 && !_error.load(std::memory_order_acquire); +} diff --git a/src/Android/Serial/SerialWriteEngine.h b/src/Android/Serial/SerialWriteEngine.h new file mode 100644 index 000000000000..1f58d73a019b --- /dev/null +++ b/src/Android/Serial/SerialWriteEngine.h @@ -0,0 +1,78 @@ +#pragma once + +// Write-side accounting + JNI submission for AndroidSerialPort, factored out of AndroidSerialPortPrivate. +// Owns the in-flight byte count, the write-error latch, the write-scratch DirectByteBuffer and the drain +// condition variable — i.e. everything between "owner thread enqueues" and "Java writer thread acks". +// It does NOT own the Java port or its liveness flag: those belong to the port's open/close lifecycle, so +// the engine holds references to them (stable for the owning AndroidSerialPortPrivate's lifetime). +// +// Threading: submit()/allocateScratch()/releaseScratch()/reset() run on the owner thread; ack()/fail() run +// on the JNI writer thread; waitForDrain() runs on whatever thread calls flush()/waitForBytesWritten(). +// _mutex serialises the drain predicate against waiters. + +#include +#include +#include +#include +#include +#include + +#include +#include + +class SerialWriteEngine +{ +public: + explicit SerialWriteEngine(const std::atomic& javaAlive) + : _javaAlive(javaAlive) + { + } + + // Owner thread: (re)allocate the pinned write-scratch DirectByteBuffer. Logs and returns false on + // failure (the caller maps that to QGCSerialPortError::OpenFailed). + bool allocateScratch(const QString& location); + void releaseScratch(); + + // Owner thread: clear in-flight + error accounting on open()/close(). + void reset(); + // Owner thread: clear only the in-flight count on clear(output) (a purge, not a failure). + void clearInFlight(); + + void setWriteBufferMaxSize(qint64 size) { _writeBufferMaxSize = size; } + qint64 writeBufferMaxSize() const { return _writeBufferMaxSize; } + qint64 inFlight() const { return _inFlight.load(std::memory_order_acquire); } + bool hasError() const { return _error.load(std::memory_order_acquire); } + + // Owner thread: enqueue up to maxSize bytes to the Java writer, honouring the write-buffer budget. + // Returns bytes accepted, 0 for backpressure (buffer full), or -1 on error. javaPort is passed per call + // (owner thread owns its lifetime) rather than aliased, so the engine holds no reference into the port. + qint64 submit(QJniObject& javaPort, QByteArrayView bytes, const QString& location); + + // JNI writer thread: ack n written bytes (decrement in-flight, wake drain waiters). + void ack(qint64 n); + // JNI thread: latch a write-side failure and wake waiters (e.g. on a Java IOException). + void fail(); + + // Owner/JNI thread: wake drain waiters after the port's javaAlive flag was flipped false elsewhere + // (disconnect/teardown), so a blocked waitForDrain re-checks its predicate and returns (not drained). + void wakeDrainWaiters(); + + // flush()/waitForBytesWritten() thread: block until drained / error / port-gone, bounded by msecs. + // Returns true when in-flight reached zero; false on a write error, a gone port, or the deadline. + // Callers disambiguate the false case via hasError()/inFlight(). + bool waitForDrain(int msecs); + +private: + qint64 enqueueToJava(QJniObject& javaPort, QByteArrayView bytes, const QString& location); + + const std::atomic& _javaAlive; + + QMutex _mutex; + QWaitCondition _cv; + std::atomic _inFlight{0}; // bytes enqueued to Java, not yet acked + std::atomic _error{false}; // write-side failure latch + + std::unique_ptr _scratch; + QJniObject _scratchBuffer; + qint64 _writeBufferMaxSize = 0; +}; diff --git a/src/Android/qtandroidserialport/CMakeLists.txt b/src/Android/qtandroidserialport/CMakeLists.txt deleted file mode 100644 index c2e91cf55cec..000000000000 --- a/src/Android/qtandroidserialport/CMakeLists.txt +++ /dev/null @@ -1,41 +0,0 @@ -# ============================================================================ -# Qt Android Serial Port — vendored from Qt SerialPort -# ============================================================================ - -if(NOT ANDROID) - return() -endif() - -qt_add_library(QGCAndroidSerialPort STATIC - qserialport.cpp - qserialport.h - qserialport_android.cpp - qserialport_p.h - qserialportglobal.h - qserialportinfo.cpp - qserialportinfo.h - qserialportinfo_p.h - qserialportinfo_android.cpp - qtserialportexports.h - qtserialportversion.h -) - -target_link_libraries(QGCAndroidSerialPort - PUBLIC - Qt6::Core - PRIVATE - Qt6::CorePrivate # , qproperty_p.h - QGCLogging # QGCLoggingCategory.h -) - -target_include_directories(QGCAndroidSerialPort - PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR} - PRIVATE - ${CMAKE_SOURCE_DIR}/src/Android # AndroidSerial.h -) - -# TODO: Enable I/O device debugging -# target_compile_definitions(QGCAndroidSerialPort PRIVATE QIODEVICE_DEBUG) - -target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE QGCAndroidSerialPort) diff --git a/src/Android/qtandroidserialport/qserialport.cpp b/src/Android/qtandroidserialport/qserialport.cpp deleted file mode 100644 index c5e14665ab17..000000000000 --- a/src/Android/qtandroidserialport/qserialport.cpp +++ /dev/null @@ -1,1209 +0,0 @@ -// Copyright (C) 2011-2012 Denis Shienkov -// Copyright (C) 2011 Sergey Belyashov -// Copyright (C) 2012 Laszlo Papp -// Copyright (C) 2012 Andre Hartmann -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -#include "qserialport.h" - -#include - -#include "qserialport_p.h" -#include "qserialportinfo.h" -#include "qserialportinfo_p.h" - -QT_BEGIN_NAMESPACE - -QSerialPortErrorInfo::QSerialPortErrorInfo(QSerialPort::SerialPortError newErrorCode, const QString& newErrorString) - : errorCode(newErrorCode), errorString(newErrorString) -{ - if (errorString.isNull()) { - switch (errorCode) { - case QSerialPort::NoError: - errorString = QSerialPort::tr("No error"); - break; - case QSerialPort::OpenError: - errorString = QSerialPort::tr("Device is already open"); - break; - case QSerialPort::NotOpenError: - errorString = QSerialPort::tr("Device is not open"); - break; - case QSerialPort::TimeoutError: - errorString = QSerialPort::tr("Operation timed out"); - break; - case QSerialPort::ReadError: - errorString = QSerialPort::tr("Error reading from device"); - break; - case QSerialPort::WriteError: - errorString = QSerialPort::tr("Error writing to device"); - break; - case QSerialPort::ResourceError: - errorString = QSerialPort::tr("Device disappeared from the system"); - break; - default: - // an empty string will be interpreted as "Unknown error" - // from the QIODevice::errorString() - break; - } - } -} - -QSerialPortPrivate::QSerialPortPrivate() -{ - writeBufferChunkSize = QSERIALPORT_BUFFERSIZE; - readBufferChunkSize = QSERIALPORT_BUFFERSIZE; -} - -void QSerialPortPrivate::setError(const QSerialPortErrorInfo& errorInfo) -{ - Q_Q(QSerialPort); - - q->setErrorString(errorInfo.errorString); - error.setValue(errorInfo.errorCode); - error.notify(); - emit q->errorOccurred(error); -} - -/*! - \class QSerialPort - - \brief Provides functions to access serial ports. - - \reentrant - \ingroup serialport-main - \inmodule QtSerialPort - \since 5.1 - - You can get information about the available serial ports using the - QSerialPortInfo helper class, which allows an enumeration of all the serial - ports in the system. This is useful to obtain the correct name of the - serial port you want to use. You can pass an object - of the helper class as an argument to the setPort() or setPortName() - methods to assign the desired serial device. - - After setting the port, you can open it in read-only (r/o), write-only - (w/o), or read-write (r/w) mode using the open() method. - - \note The serial port is always opened with exclusive access - (that is, no other process or thread can access an already opened serial port). - - Use the close() method to close the port and cancel the I/O operations. - - Having successfully opened, QSerialPort tries to determine the current - configuration of the port and initializes itself. You can reconfigure the - port to the desired setting using the setBaudRate(), setDataBits(), - setParity(), setStopBits(), and setFlowControl() methods. - - There are a couple of properties to work with the pinout signals namely: - QSerialPort::dataTerminalReady, QSerialPort::requestToSend. It is also - possible to use the pinoutSignals() method to query the current pinout - signals set. - - Once you know that the ports are ready to read or write, you can - use the read() or write() methods. Alternatively the - readLine() and readAll() convenience methods can also be invoked. - If not all the data is read at once, the remaining data will - be available for later as new incoming data is appended to the - QSerialPort's internal read buffer. You can limit the size of the read - buffer using setReadBufferSize(). - - QSerialPort provides a set of functions that suspend the - calling thread until certain signals are emitted. These functions - can be used to implement blocking serial ports: - - \list - - \li waitForReadyRead() blocks calls until new data is available for - reading. - - \li waitForBytesWritten() blocks calls until one payload of data has - been written to the serial port. - - \endlist - - See the following example: - - \code - int numRead = 0, numReadTotal = 0; - char buffer[50]; - - for (;;) { - numRead = serial.read(buffer, 50); - - // Do whatever with the array - - numReadTotal += numRead; - if (numRead == 0 && !serial.waitForReadyRead()) - break; - } - \endcode - - If \l{QIODevice::}{waitForReadyRead()} returns \c false, the - connection has been closed or an error has occurred. - - If an error occurs at any point in time, QSerialPort will emit the - errorOccurred() signal. You can also call error() to find the type of - error that occurred last. - - Programming with a blocking serial port is radically different from - programming with a non-blocking serial port. A blocking serial port - does not require an event loop and typically leads to simpler code. - However, in a GUI application, blocking serial port should only be - used in non-GUI threads, to avoid freezing the user interface. - - For more details about these approaches, refer to the - \l {Qt Serial Port Examples}{example} applications. - - The QSerialPort class can also be used with QTextStream and QDataStream's - stream operators (operator<<() and operator>>()). There is one issue to be - aware of, though: make sure that enough data is available before attempting - to read by using the operator>>() overloaded operator. - - \sa QSerialPortInfo -*/ - -/*! - \enum QSerialPort::Direction - - This enum describes the possible directions of the data transmission. - - \note This enumeration is used for setting the baud rate of the device - separately for each direction on some operating systems (for example, - POSIX-like). - - \value Input Input direction. - \value Output Output direction. - \value AllDirections Simultaneously in two directions. -*/ - -/*! - \enum QSerialPort::BaudRate - - This enum describes the baud rate which the communication device operates - with. - - \note Only the most common standard baud rates are listed in this enum. - - \value Baud1200 1200 baud. - \value Baud2400 2400 baud. - \value Baud4800 4800 baud. - \value Baud9600 9600 baud. - \value Baud19200 19200 baud. - \value Baud38400 38400 baud. - \value Baud57600 57600 baud. - \value Baud115200 115200 baud. - - \sa QSerialPort::baudRate -*/ - -/*! - \enum QSerialPort::DataBits - - This enum describes the number of data bits used. - - \value Data5 The number of data bits in each character is 5. It - is used for Baudot code. It generally only makes - sense with older equipment such as teleprinters. - \value Data6 The number of data bits in each character is 6. It - is rarely used. - \value Data7 The number of data bits in each character is 7. It - is used for true ASCII. It generally only makes - sense with older equipment such as teleprinters. - \value Data8 The number of data bits in each character is 8. It - is used for most kinds of data, as this size matches - the size of a byte. It is almost universally used in - newer applications. - - \sa QSerialPort::dataBits -*/ - -/*! - \enum QSerialPort::Parity - - This enum describes the parity scheme used. - - \value NoParity No parity bit it sent. This is the most common - parity setting. Error detection is handled by the - communication protocol. - \value EvenParity The number of 1 bits in each character, including - the parity bit, is always even. - \value OddParity The number of 1 bits in each character, including - the parity bit, is always odd. It ensures that at - least one state transition occurs in each character. - \value SpaceParity Space parity. The parity bit is sent in the space - signal condition. It does not provide error - detection information. - \value MarkParity Mark parity. The parity bit is always set to the - mark signal condition (logical 1). It does not - provide error detection information. - - \sa QSerialPort::parity -*/ - -/*! - \enum QSerialPort::StopBits - - This enum describes the number of stop bits used. - - \value OneStop 1 stop bit. - \value OneAndHalfStop 1.5 stop bits. This is only for the Windows platform. - \value TwoStop 2 stop bits. - - \sa QSerialPort::stopBits -*/ - -/*! - \enum QSerialPort::FlowControl - - This enum describes the flow control used. - - \value NoFlowControl No flow control. - \value HardwareControl Hardware flow control (RTS/CTS). - \value SoftwareControl Software flow control (XON/XOFF). - - \sa QSerialPort::flowControl -*/ - -/*! - \enum QSerialPort::PinoutSignal - - This enum describes the possible RS-232 pinout signals. - - \value NoSignal No line active - \value DataTerminalReadySignal DTR (Data Terminal Ready). - \value DataCarrierDetectSignal DCD (Data Carrier Detect). - \value DataSetReadySignal DSR (Data Set Ready). - \value RingIndicatorSignal RNG (Ring Indicator). - \value RequestToSendSignal RTS (Request To Send). - \value ClearToSendSignal CTS (Clear To Send). - \value SecondaryTransmittedDataSignal STD (Secondary Transmitted Data). - \value SecondaryReceivedDataSignal SRD (Secondary Received Data). - - \sa pinoutSignals(), QSerialPort::dataTerminalReady, - QSerialPort::requestToSend -*/ - -/*! - \enum QSerialPort::SerialPortError - - This enum describes the errors that may be contained by the - QSerialPort::error property. - - \value NoError No error occurred. - - \value DeviceNotFoundError An error occurred while attempting to - open an non-existing device. - - \value PermissionError An error occurred while attempting to - open an already opened device by another - process or a user not having enough permission - and credentials to open. - - \value OpenError An error occurred while attempting to open an - already opened device in this object. - - \value NotOpenError This error occurs when an operation is executed - that can only be successfully performed if the - device is open. This value was introduced in - QtSerialPort 5.2. - - \value WriteError An I/O error occurred while writing the data. - - \value ReadError An I/O error occurred while reading the data. - - \value ResourceError An I/O error occurred when a resource becomes - unavailable, e.g. when the device is - unexpectedly removed from the system. - - \value UnsupportedOperationError The requested device operation is not - supported or prohibited by the running operating - system. - - \value TimeoutError A timeout error occurred. This value was - introduced in QtSerialPort 5.2. - - \value UnknownError An unidentified error occurred. - \sa QSerialPort::error -*/ - -/*! - Constructs a new serial port object with the given \a parent. -*/ -QSerialPort::QSerialPort(QObject* parent) : QIODevice(*new QSerialPortPrivate, parent) -{ -} - -/*! - Constructs a new serial port object with the given \a parent - to represent the serial port with the specified \a name. - - The name should have a specific format; see the setPort() method. -*/ -QSerialPort::QSerialPort(const QString& name, QObject* parent) : QIODevice(*new QSerialPortPrivate, parent) -{ - setPortName(name); -} - -/*! - Constructs a new serial port object with the given \a parent - to represent the serial port with the specified helper class - \a serialPortInfo. -*/ -QSerialPort::QSerialPort(const QSerialPortInfo& serialPortInfo, QObject* parent) - : QIODevice(*new QSerialPortPrivate, parent) -{ - setPort(serialPortInfo); -} - -/*! - Closes the serial port, if necessary, and then destroys object. -*/ -QSerialPort::~QSerialPort() -{ - /**/ - if (isOpen()) - close(); -} - -/*! - Sets the \a name of the serial port. - - The name of the serial port can be passed as either a short name or - the long system location if necessary. - - \sa portName(), QSerialPortInfo -*/ -void QSerialPort::setPortName(const QString& name) -{ - Q_D(QSerialPort); - d->systemLocation = QSerialPortInfoPrivate::portNameToSystemLocation(name); -} - -/*! - Sets the port stored in the serial port info instance \a serialPortInfo. - - \sa portName(), QSerialPortInfo -*/ -void QSerialPort::setPort(const QSerialPortInfo& serialPortInfo) -{ - Q_D(QSerialPort); - d->systemLocation = serialPortInfo.systemLocation(); -} - -/*! - Returns the name set by setPort() or passed to the QSerialPort constructor. - This name is short, i.e. it is extracted and converted from the internal - variable system location of the device. The conversion algorithm is - platform specific: - \table - \header - \li Platform - \li Brief Description - \row - \li Windows - \li Removes the prefix "\\\\.\\" or "//./" from the system location - and returns the remainder of the string. - \row - \li Unix, BSD - \li Removes the prefix "/dev/" from the system location - and returns the remainder of the string. - \endtable - - \sa setPort(), QSerialPortInfo::portName() -*/ -QString QSerialPort::portName() const -{ - Q_D(const QSerialPort); - return QSerialPortInfoPrivate::portNameFromSystemLocation(d->systemLocation); -} - -/*! - \reimp - - Opens the serial port using OpenMode \a mode, and then returns \c true if - successful; otherwise returns \c false and sets an error code which can be - obtained by calling the error() method. - - \note The method returns \c false if opening the port is successful, but could - not set any of the port settings successfully. In that case, the port is - closed automatically not to leave the port around with incorrect settings. - - \warning The \a mode has to be QIODeviceBase::ReadOnly, QIODeviceBase::WriteOnly, - or QIODeviceBase::ReadWrite. Other modes are unsupported. - - \sa QIODeviceBase::OpenMode, setPort() -*/ -bool QSerialPort::open(OpenMode mode) -{ - Q_D(QSerialPort); - - if (isOpen()) { - d->setError(QSerialPortErrorInfo(QSerialPort::OpenError)); - return false; - } - - // Define while not supported modes. - static const OpenMode unsupportedModes = Append | Truncate | Text | Unbuffered; - if ((mode & unsupportedModes) || mode == NotOpen) { - d->setError(QSerialPortErrorInfo(QSerialPort::UnsupportedOperationError, tr("Unsupported open mode"))); - return false; - } - - clearError(); - if (!d->open(mode)) - return false; - - QIODevice::open(mode); - return true; -} - -/*! - \reimp - - \note The serial port has to be open before trying to close it; otherwise - sets the NotOpenError error code. - - \sa QIODevice::close() -*/ -void QSerialPort::close() -{ - Q_D(QSerialPort); - if (!isOpen()) { - d->setError(QSerialPortErrorInfo(QSerialPort::NotOpenError)); - return; - } - - d->close(); - d->isBreakEnabled.setValue(false); - QIODevice::close(); -} - -/*! - \property QSerialPort::baudRate - \brief the data baud rate for the desired direction - - If the setting is successful or set before opening the port, returns \c true; - otherwise returns \c false and sets an error code which can be obtained by - accessing the value of the QSerialPort::error property. To set the baud - rate, use the enumeration QSerialPort::BaudRate or any positive qint32 - value. - - \note If the setting is set before opening the port, the actual serial port - setting is done automatically in the \l{QSerialPort::open()} method right - after that the opening of the port succeeds. - - \warning Setting the AllDirections flag is supported on all platforms. - Windows supports only this mode. - - \warning Returns equal baud rate in any direction on Windows. - - The default value is Baud9600, i.e. 9600 bits per second. -*/ -bool QSerialPort::setBaudRate(qint32 baudRate, Directions directions) -{ - Q_D(QSerialPort); - - if (!isOpen() || d->setBaudRate(baudRate, directions)) { - if (directions & QSerialPort::Input) { - if (d->inputBaudRate != baudRate) - d->inputBaudRate = baudRate; - else - directions &= ~QSerialPort::Input; - } - - if (directions & QSerialPort::Output) { - if (d->outputBaudRate != baudRate) - d->outputBaudRate = baudRate; - else - directions &= ~QSerialPort::Output; - } - - if (directions) - emit baudRateChanged(baudRate, directions); - - return true; - } - - return false; -} - -qint32 QSerialPort::baudRate(Directions directions) const -{ - Q_D(const QSerialPort); - if (directions == QSerialPort::AllDirections) - return d->inputBaudRate == d->outputBaudRate ? d->inputBaudRate : -1; - return directions & QSerialPort::Input ? d->inputBaudRate : d->outputBaudRate; -} - -/*! - \fn void QSerialPort::baudRateChanged(qint32 baudRate, Directions directions) - - This signal is emitted after the baud rate has been changed. The new baud - rate is passed as \a baudRate and directions as \a directions. - - \sa QSerialPort::baudRate -*/ - -/*! - \property QSerialPort::dataBits - \brief the data bits in a frame - - If the setting is successful or set before opening the port, returns - \c true; otherwise returns \c false and sets an error code which can be obtained - by accessing the value of the QSerialPort::error property. - - \note If the setting is set before opening the port, the actual serial port - setting is done automatically in the \l{QSerialPort::open()} method right - after that the opening of the port succeeds. - - The default value is Data8, i.e. 8 data bits. -*/ -bool QSerialPort::setDataBits(DataBits dataBits) -{ - Q_D(QSerialPort); - d->dataBits.removeBindingUnlessInWrapper(); - const auto currentDataBits = d->dataBits.valueBypassingBindings(); - if (!isOpen() || d->setDataBits(dataBits)) { - d->dataBits.setValueBypassingBindings(dataBits); - if (currentDataBits != dataBits) { - d->dataBits.notify(); - emit dataBitsChanged(dataBits); - } - return true; - } - return false; -} - -QSerialPort::DataBits QSerialPort::dataBits() const -{ - Q_D(const QSerialPort); - return d->dataBits; -} - -QBindable QSerialPort::bindableDataBits() -{ - return &d_func()->dataBits; -} - -/*! - \fn void QSerialPort::dataBitsChanged(DataBits dataBits) - - This signal is emitted after the data bits in a frame has been changed. The - new data bits in a frame is passed as \a dataBits. - - \sa QSerialPort::dataBits -*/ - -/*! - \property QSerialPort::parity - \brief the parity checking mode - - If the setting is successful or set before opening the port, returns \c true; - otherwise returns \c false and sets an error code which can be obtained by - accessing the value of the QSerialPort::error property. - - \note If the setting is set before opening the port, the actual serial port - setting is done automatically in the \l{QSerialPort::open()} method right - after that the opening of the port succeeds. - - The default value is NoParity, i.e. no parity. -*/ -bool QSerialPort::setParity(Parity parity) -{ - Q_D(QSerialPort); - d->parity.removeBindingUnlessInWrapper(); - const auto currentParity = d->parity.valueBypassingBindings(); - if (!isOpen() || d->setParity(parity)) { - d->parity.setValueBypassingBindings(parity); - if (currentParity != parity) { - d->parity.notify(); - emit parityChanged(parity); - } - return true; - } - return false; -} - -QSerialPort::Parity QSerialPort::parity() const -{ - Q_D(const QSerialPort); - return d->parity; -} - -QBindable QSerialPort::bindableParity() -{ - return &d_func()->parity; -} - -/*! - \fn void QSerialPort::parityChanged(Parity parity) - - This signal is emitted after the parity checking mode has been changed. The - new parity checking mode is passed as \a parity. - - \sa QSerialPort::parity -*/ - -/*! - \property QSerialPort::stopBits - \brief the number of stop bits in a frame - - If the setting is successful or set before opening the port, returns \c true; - otherwise returns \c false and sets an error code which can be obtained by - accessing the value of the QSerialPort::error property. - - \note If the setting is set before opening the port, the actual serial port - setting is done automatically in the \l{QSerialPort::open()} method right - after that the opening of the port succeeds. - - The default value is OneStop, i.e. 1 stop bit. -*/ -bool QSerialPort::setStopBits(StopBits stopBits) -{ - Q_D(QSerialPort); - d->stopBits.removeBindingUnlessInWrapper(); - const auto currentStopBits = d->stopBits.valueBypassingBindings(); - if (!isOpen() || d->setStopBits(stopBits)) { - d->stopBits.setValueBypassingBindings(stopBits); - if (currentStopBits != stopBits) { - d->stopBits.notify(); - emit stopBitsChanged(stopBits); - } - return true; - } - return false; -} - -QSerialPort::StopBits QSerialPort::stopBits() const -{ - Q_D(const QSerialPort); - return d->stopBits; -} - -QBindable QSerialPort::bindableStopBits() -{ - return &d_func()->stopBits; -} - -/*! - \fn void QSerialPort::stopBitsChanged(StopBits stopBits) - - This signal is emitted after the number of stop bits in a frame has been - changed. The new number of stop bits in a frame is passed as \a stopBits. - - \sa QSerialPort::stopBits -*/ - -/*! - \property QSerialPort::flowControl - \brief the desired flow control mode - - If the setting is successful or set before opening the port, returns \c true; - otherwise returns \c false and sets an error code which can be obtained by - accessing the value of the QSerialPort::error property. - - \note If the setting is set before opening the port, the actual serial port - setting is done automatically in the \l{QSerialPort::open()} method right - after that the opening of the port succeeds. - - The default value is NoFlowControl, i.e. no flow control. -*/ -bool QSerialPort::setFlowControl(FlowControl flowControl) -{ - Q_D(QSerialPort); - d->flowControl.removeBindingUnlessInWrapper(); - const auto currentFlowControl = d->flowControl.valueBypassingBindings(); - if (!isOpen() || d->setFlowControl(flowControl)) { - d->flowControl.setValueBypassingBindings(flowControl); - if (currentFlowControl != flowControl) { - d->flowControl.notify(); - emit flowControlChanged(flowControl); - } - return true; - } - return false; -} - -QSerialPort::FlowControl QSerialPort::flowControl() const -{ - Q_D(const QSerialPort); - return d->flowControl; -} - -QBindable QSerialPort::bindableFlowControl() -{ - return &d_func()->flowControl; -} - -/*! - \fn void QSerialPort::flowControlChanged(FlowControl flow) - - This signal is emitted after the flow control mode has been changed. The - new flow control mode is passed as \a flow. - - \sa QSerialPort::flowControl -*/ - -/*! - \property QSerialPort::dataTerminalReady - \brief the state (high or low) of the line signal DTR - - Returns \c true on success, \c false otherwise. - If the flag is \c true then the DTR signal is set to high; otherwise low. - - \note The serial port has to be open before trying to set or get this - property; otherwise \c false is returned and the error code is set to - NotOpenError. - - \sa pinoutSignals() -*/ -bool QSerialPort::setDataTerminalReady(bool set) -{ - Q_D(QSerialPort); - - if (!isOpen()) { - d->setError(QSerialPortErrorInfo(QSerialPort::NotOpenError)); - qWarning("%s: device not open", Q_FUNC_INFO); - return false; - } - - const bool dataTerminalReady = isDataTerminalReady(); - const bool retval = d->setDataTerminalReady(set); - if (retval && (dataTerminalReady != set)) - emit dataTerminalReadyChanged(set); - - return retval; -} - -bool QSerialPort::isDataTerminalReady() -{ - Q_D(QSerialPort); - return d->pinoutSignals() & QSerialPort::DataTerminalReadySignal; -} - -/*! - \fn void QSerialPort::dataTerminalReadyChanged(bool set) - - This signal is emitted after the state (high or low) of the line signal DTR - has been changed. The new the state (high or low) of the line signal DTR is - passed as \a set. - - \sa QSerialPort::dataTerminalReady -*/ - -/*! - \property QSerialPort::requestToSend - \brief the state (high or low) of the line signal RTS - - Returns \c true on success, \c false otherwise. - If the flag is \c true then the RTS signal is set to high; otherwise low. - - \note The serial port has to be open before trying to set or get this - property; otherwise \c false is returned and the error code is set to - NotOpenError. - - \note An attempt to control the RTS signal in the HardwareControl mode - will fail with error code set to UnsupportedOperationError, because - the signal is automatically controlled by the driver. - - \sa pinoutSignals() -*/ -bool QSerialPort::setRequestToSend(bool set) -{ - Q_D(QSerialPort); - - if (!isOpen()) { - d->setError(QSerialPortErrorInfo(QSerialPort::NotOpenError)); - qWarning("%s: device not open", Q_FUNC_INFO); - return false; - } - - if (d->flowControl == QSerialPort::HardwareControl) { - d->setError(QSerialPortErrorInfo(QSerialPort::UnsupportedOperationError)); - return false; - } - - const bool requestToSend = isRequestToSend(); - const bool retval = d->setRequestToSend(set); - if (retval && (requestToSend != set)) - emit requestToSendChanged(set); - - return retval; -} - -bool QSerialPort::isRequestToSend() -{ - Q_D(QSerialPort); - return d->pinoutSignals() & QSerialPort::RequestToSendSignal; -} - -/*! - \fn void QSerialPort::requestToSendChanged(bool set) - - This signal is emitted after the state (high or low) of the line signal RTS - has been changed. The new the state (high or low) of the line signal RTS is - passed as \a set. - - \sa QSerialPort::requestToSend -*/ - -/*! - Returns the state of the line signals in a bitmap format. - - From this result, it is possible to allocate the state of the - desired signal by applying a mask "AND", where the mask is - the desired enumeration value from QSerialPort::PinoutSignals. - - \note This method performs a system call, thus ensuring that the line signal - states are returned properly. This is necessary when the underlying - operating systems cannot provide proper notifications about the changes. - - \note The serial port has to be open before trying to get the pinout - signals; otherwise returns NoSignal and sets the NotOpenError error code. - - \sa QSerialPort::dataTerminalReady, QSerialPort::requestToSend -*/ -QSerialPort::PinoutSignals QSerialPort::pinoutSignals() -{ - Q_D(QSerialPort); - - if (!isOpen()) { - d->setError(QSerialPortErrorInfo(QSerialPort::NotOpenError)); - qWarning("%s: device not open", Q_FUNC_INFO); - return QSerialPort::NoSignal; - } - - return d->pinoutSignals(); -} - -/*! - This function writes as much as possible from the internal write - buffer to the underlying serial port without blocking. If any data - was written, this function returns \c true; otherwise returns \c false. - - Call this function for sending the buffered data immediately to the serial - port. The number of bytes successfully written depends on the operating - system. In most cases, this function does not need to be called, because the - QSerialPort class will start sending data automatically once control is - returned to the event loop. In the absence of an event loop, call - waitForBytesWritten() instead. - - \note The serial port has to be open before trying to flush any buffered - data; otherwise returns \c false and sets the NotOpenError error code. - - \sa write(), waitForBytesWritten() -*/ -bool QSerialPort::flush() -{ - Q_D(QSerialPort); - - if (!isOpen()) { - d->setError(QSerialPortErrorInfo(QSerialPort::NotOpenError)); - qWarning("%s: device not open", Q_FUNC_INFO); - return false; - } - - return d->flush(); -} - -/*! - Discards all characters from the output or input buffer, depending on - given directions \a directions. This includes clearing the internal class buffers and - the UART (driver) buffers. Also terminate pending read or write operations. - If successful, returns \c true; otherwise returns \c false. - - \note The serial port has to be open before trying to clear any buffered - data; otherwise returns \c false and sets the NotOpenError error code. -*/ -bool QSerialPort::clear(Directions directions) -{ - Q_D(QSerialPort); - - if (!isOpen()) { - d->setError(QSerialPortErrorInfo(QSerialPort::NotOpenError)); - qWarning("%s: device not open", Q_FUNC_INFO); - return false; - } - - if (directions & Input) { - QMutexLocker locker(&d->_readMutex); - d->buffer.clear(); - d->_pendingData.clear(); - d->_bufferBytesEstimate.store(0, std::memory_order_relaxed); - } - if (directions & Output) - d->writeBuffer.clear(); - return d->clear(directions); -} - -/*! - \property QSerialPort::error - \brief the error status of the serial port - - The I/O device status returns an error code. For example, if open() - returns \c false, or a read/write operation returns \c -1, this property can - be used to figure out the reason why the operation failed. - - The error code is set to the default QSerialPort::NoError after a call to - clearError() -*/ -QSerialPort::SerialPortError QSerialPort::error() const -{ - Q_D(const QSerialPort); - return d->error; -} - -void QSerialPort::clearError() -{ - Q_D(QSerialPort); - d->setError(QSerialPortErrorInfo(QSerialPort::NoError)); -} - -QBindable QSerialPort::bindableError() const -{ - return &d_func()->error; -} - -/*! - \fn void QSerialPort::errorOccurred(SerialPortError error) - \since 5.8 - - This signal is emitted when an error occurs in the serial port. - The specified \a error describes the type of error that occurred. - - \sa QSerialPort::error -*/ - -/*! - Returns the size of the internal read buffer. This limits the - amount of data that the client can receive before calling the read() - or readAll() methods. - - A read buffer size of \c 0 (the default) means that the buffer has - no size limit, ensuring that no data is lost. - - \sa setReadBufferSize(), read() -*/ -qint64 QSerialPort::readBufferSize() const -{ - Q_D(const QSerialPort); - return d->readBufferMaxSize; -} - -/*! - Sets the size of QSerialPort's internal read buffer to be \a - size bytes. - - If the buffer size is limited to a certain size, QSerialPort - will not buffer more than this size of data. The special case of a buffer - size of \c 0 means that the read buffer is unlimited and all - incoming data is buffered. This is the default. - - This option is useful if the data is only read at certain points - in time (for instance in a real-time streaming application) or if the serial - port should be protected against receiving too much data, which may - eventually cause the application to run out of memory. - - \sa readBufferSize(), read() -*/ -void QSerialPort::setReadBufferSize(qint64 size) -{ - Q_D(QSerialPort); - d->readBufferMaxSize = size; - if (isReadable()) - d->startAsyncRead(); -} - -/*! - \reimp - - Always returns \c true. The serial port is a sequential device. -*/ -bool QSerialPort::isSequential() const -{ - return true; -} - -/*! - \reimp - - Returns the number of incoming bytes that are waiting to be read. - - \sa bytesToWrite(), read() -*/ -qint64 QSerialPort::bytesAvailable() const -{ - return QIODevice::bytesAvailable(); -} - -/*! - \reimp - - Returns the number of bytes that are waiting to be written. The - bytes are written when control goes back to the event loop or - when flush() is called. - - \sa bytesAvailable(), flush() -*/ -qint64 QSerialPort::bytesToWrite() const -{ - qint64 pendingBytes = QIODevice::bytesToWrite(); - return pendingBytes; -} - -/*! - \reimp - - Returns \c true if a line of data can be read from the serial port; - otherwise returns \c false. - - \sa readLine() -*/ -bool QSerialPort::canReadLine() const -{ - return QIODevice::canReadLine(); -} - -/*! - \reimp - - This function blocks until new data is available for reading and the - \l{QIODevice::}{readyRead()} signal has been emitted. The function - will timeout after \a msecs milliseconds; the default timeout is - 30000 milliseconds. If \a msecs is -1, this function will not time out. - - The function returns \c true if the readyRead() signal is emitted and - there is new data available for reading; otherwise it returns \c false - (if an error occurred or the operation timed out). - - \sa waitForBytesWritten() -*/ -bool QSerialPort::waitForReadyRead(int msecs) -{ - Q_D(QSerialPort); - return d->waitForReadyRead(msecs); -} - -/*! - \fn Handle QSerialPort::handle() const - \since 5.2 - - If the platform is supported and the serial port is open, returns the native - serial port handle; otherwise returns \c -1. - - \warning This function is for expert use only; use it at your own risk. - Furthermore, this function carries no compatibility promise between minor - Qt releases. -*/ - -/*! - \reimp - - This function blocks until at least one byte has been written to the serial - port and the \l{QIODevice::}{bytesWritten()} signal has been emitted. The - function will timeout after \a msecs milliseconds; the default timeout is - 30000 milliseconds. If \a msecs is -1, this function will not time out. - - The function returns \c true if the bytesWritten() signal is emitted; otherwise - it returns \c false (if an error occurred or the operation timed out). -*/ -bool QSerialPort::waitForBytesWritten(int msecs) -{ - Q_D(QSerialPort); - return d->waitForBytesWritten(msecs); -} - -/*! - \property QSerialPort::breakEnabled - \since 5.5 - \brief the state of the transmission line in break - - Returns \c true on success, \c false otherwise. - If the flag is \c true then the transmission line is in break state; - otherwise is in non-break state. - - \note The serial port has to be open before trying to set or get this - property; otherwise returns \c false and sets the NotOpenError error code. - This is a bit unusual as opposed to the regular Qt property settings of - a class. However, this is a special use case since the property is set - through the interaction with the kernel and hardware. Hence, the two - scenarios cannot be completely compared to each other. -*/ -bool QSerialPort::setBreakEnabled(bool set) -{ - Q_D(QSerialPort); - d->isBreakEnabled.removeBindingUnlessInWrapper(); - const auto currentSet = d->isBreakEnabled.valueBypassingBindings(); - if (isOpen()) { - if (d->setBreakEnabled(set)) { - d->isBreakEnabled.setValueBypassingBindings(set); - if (currentSet != set) { - d->isBreakEnabled.notify(); - emit breakEnabledChanged(set); - } - return true; - } - } else { - d->setError(QSerialPortErrorInfo(QSerialPort::NotOpenError)); - qWarning("%s: device not open", Q_FUNC_INFO); - } - return false; -} - -bool QSerialPort::isBreakEnabled() const -{ - Q_D(const QSerialPort); - return d->isBreakEnabled; -} - -QBindable QSerialPort::bindableIsBreakEnabled() -{ - return &d_func()->isBreakEnabled; -} - -/*! - \reimp - - \omit - This function does not really read anything, as we use QIODevicePrivate's - buffer. The buffer will be read inside of QIODevice before this - method will be called. - \endomit -*/ -qint64 QSerialPort::readData(char* data, qint64 maxSize) -{ - Q_UNUSED(data); - Q_UNUSED(maxSize); - - // QIODevice drains from d->buffer before calling here; refresh estimate so - // Android read-backpressure tracks current buffered bytes. - d_func()->_bufferBytesEstimate.store(d_func()->buffer.size(), std::memory_order_relaxed); - - // In any case we need to start the notifications if they were - // disabled by the read handler. If enabled, next call does nothing. - d_func()->startAsyncRead(); - - // return 0 indicating there may be more data in the future - return qint64(0); -} - -/*! - \reimp -*/ -qint64 QSerialPort::readLineData(char* data, qint64 maxSize) -{ - return QIODevice::readLineData(data, maxSize); -} - -/*! - \reimp -*/ -qint64 QSerialPort::writeData(const char* data, qint64 maxSize) -{ - Q_D(QSerialPort); - return d->writeData(data, maxSize); -} - -QT_END_NAMESPACE - -#include "moc_qserialport.cpp" diff --git a/src/Android/qtandroidserialport/qserialport.h b/src/Android/qtandroidserialport/qserialport.h deleted file mode 100644 index 4406d2b4cc52..000000000000 --- a/src/Android/qtandroidserialport/qserialport.h +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (C) 2012 Denis Shienkov -// Copyright (C) 2013 Laszlo Papp -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -#pragma once - -#include - -#include "qserialportglobal.h" - -QT_BEGIN_NAMESPACE - -class QSerialPortInfo; -class QSerialPortPrivate; - -class Q_SERIALPORT_EXPORT QSerialPort : public QIODevice -{ - Q_OBJECT - Q_DECLARE_PRIVATE(QSerialPort) - - Q_PROPERTY(qint32 baudRate READ baudRate WRITE setBaudRate NOTIFY baudRateChanged) - Q_PROPERTY(DataBits dataBits READ dataBits WRITE setDataBits NOTIFY dataBitsChanged BINDABLE bindableDataBits) - Q_PROPERTY(Parity parity READ parity WRITE setParity NOTIFY parityChanged BINDABLE bindableParity) - Q_PROPERTY(StopBits stopBits READ stopBits WRITE setStopBits NOTIFY stopBitsChanged BINDABLE bindableStopBits) - Q_PROPERTY(FlowControl flowControl READ flowControl WRITE setFlowControl NOTIFY flowControlChanged BINDABLE - bindableFlowControl) - Q_PROPERTY( - bool dataTerminalReady READ isDataTerminalReady WRITE setDataTerminalReady NOTIFY dataTerminalReadyChanged) - Q_PROPERTY(bool requestToSend READ isRequestToSend WRITE setRequestToSend NOTIFY requestToSendChanged) - Q_PROPERTY(SerialPortError error READ error RESET clearError NOTIFY errorOccurred BINDABLE bindableError) - Q_PROPERTY(bool breakEnabled READ isBreakEnabled WRITE setBreakEnabled NOTIFY breakEnabledChanged BINDABLE - bindableIsBreakEnabled) - - typedef int Handle; - -public: - enum Direction - { - Input = 1, - Output = 2, - AllDirections = Input | Output - }; - Q_FLAG(Direction) - Q_DECLARE_FLAGS(Directions, Direction) - - enum BaudRate - { - Baud1200 = 1200, - Baud2400 = 2400, - Baud4800 = 4800, - Baud9600 = 9600, - Baud19200 = 19200, - Baud38400 = 38400, - Baud57600 = 57600, - Baud115200 = 115200 - }; - Q_ENUM(BaudRate) - - enum DataBits - { - Data5 = 5, - Data6 = 6, - Data7 = 7, - Data8 = 8 - }; - Q_ENUM(DataBits) - - enum Parity - { - NoParity = 0, - EvenParity = 2, - OddParity = 3, - SpaceParity = 4, - MarkParity = 5 - }; - Q_ENUM(Parity) - - enum StopBits - { - OneStop = 1, - OneAndHalfStop = 3, - TwoStop = 2 - }; - Q_ENUM(StopBits) - - enum FlowControl - { - NoFlowControl, - HardwareControl, - SoftwareControl - }; - Q_ENUM(FlowControl) - - enum PinoutSignal - { - NoSignal = 0x00, - DataTerminalReadySignal = 0x04, - DataCarrierDetectSignal = 0x08, - DataSetReadySignal = 0x10, - RingIndicatorSignal = 0x20, - RequestToSendSignal = 0x40, - ClearToSendSignal = 0x80, - SecondaryTransmittedDataSignal = 0x100, - SecondaryReceivedDataSignal = 0x200 - }; - Q_FLAG(PinoutSignal) - Q_DECLARE_FLAGS(PinoutSignals, PinoutSignal) - - enum SerialPortError - { - NoError, - DeviceNotFoundError, - PermissionError, - OpenError, - WriteError, - ReadError, - ResourceError, - UnsupportedOperationError, - UnknownError, - TimeoutError, - NotOpenError - }; - Q_ENUM(SerialPortError) - - explicit QSerialPort(QObject* parent = nullptr); - explicit QSerialPort(const QString& name, QObject* parent = nullptr); - explicit QSerialPort(const QSerialPortInfo& info, QObject* parent = nullptr); - virtual ~QSerialPort(); - - void setPortName(const QString& name); - QString portName() const; - - void setPort(const QSerialPortInfo& info); - - bool open(OpenMode mode) override; - void close() override; - - bool setBaudRate(qint32 baudRate, Directions directions = AllDirections); - qint32 baudRate(Directions directions = AllDirections) const; - - bool setDataBits(DataBits dataBits); - DataBits dataBits() const; - QBindable bindableDataBits(); - - bool setParity(Parity parity); - Parity parity() const; - QBindable bindableParity(); - - bool setStopBits(StopBits stopBits); - StopBits stopBits() const; - QBindable bindableStopBits(); - - bool setFlowControl(FlowControl flowControl); - FlowControl flowControl() const; - QBindable bindableFlowControl(); - - bool setDataTerminalReady(bool set); - bool isDataTerminalReady(); - - bool setRequestToSend(bool set); - bool isRequestToSend(); - - PinoutSignals pinoutSignals(); - - bool flush(); - bool clear(Directions directions = AllDirections); - - SerialPortError error() const; - void clearError(); - QBindable bindableError() const; - - qint64 readBufferSize() const; - void setReadBufferSize(qint64 size); - - bool isSequential() const override; - - qint64 bytesAvailable() const override; - qint64 bytesToWrite() const override; - bool canReadLine() const override; - - bool waitForReadyRead(int msecs = 30000) override; - bool waitForBytesWritten(int msecs = 30000) override; - - bool setBreakEnabled(bool set = true); - bool isBreakEnabled() const; - QBindable bindableIsBreakEnabled(); - - Handle handle() const; - -Q_SIGNALS: - void baudRateChanged(qint32 baudRate, QSerialPort::Directions directions); - void dataBitsChanged(QSerialPort::DataBits dataBits); - void parityChanged(QSerialPort::Parity parity); - void stopBitsChanged(QSerialPort::StopBits stopBits); - void flowControlChanged(QSerialPort::FlowControl flowControl); - void dataTerminalReadyChanged(bool set); - void requestToSendChanged(bool set); - void errorOccurred(QSerialPort::SerialPortError error); - void breakEnabledChanged(bool set); - -protected: - qint64 readData(char* data, qint64 maxSize) override; - qint64 readLineData(char* data, qint64 maxSize) override; - qint64 writeData(const char* data, qint64 maxSize) override; - -private: - Q_DISABLE_COPY(QSerialPort) -}; - -Q_DECLARE_OPERATORS_FOR_FLAGS(QSerialPort::Directions) -Q_DECLARE_OPERATORS_FOR_FLAGS(QSerialPort::PinoutSignals) - -QT_END_NAMESPACE diff --git a/src/Android/qtandroidserialport/qserialport_android.cpp b/src/Android/qtandroidserialport/qserialport_android.cpp deleted file mode 100644 index 2357443f344a..000000000000 --- a/src/Android/qtandroidserialport/qserialport_android.cpp +++ /dev/null @@ -1,603 +0,0 @@ -#include -#include -#include - -#include - -#include "QGCLoggingCategory.h" -#include "qserialport_p.h" - -QGC_LOGGING_CATEGORY(AndroidSerialPortLog, "Android.AndroidSerialPort") - -QT_BEGIN_NAMESPACE - -bool QSerialPortPrivate::open(QIODevice::OpenMode mode) -{ - qCDebug(AndroidSerialPortLog) << "Opening" << systemLocation; - - AndroidSerial::registerPointer(this); - auto tokenGuard = qScopeGuard([this]() { AndroidSerial::unregisterPointer(this); }); - - _deviceId = AndroidSerial::open(systemLocation, this); - if (_deviceId == INVALID_DEVICE_ID) { - qCWarning(AndroidSerialPortLog) << "Error opening" << systemLocation; - setError(QSerialPortErrorInfo(QSerialPort::DeviceNotFoundError)); - return false; - } - - descriptor = AndroidSerial::getDeviceHandle(_deviceId); - if (descriptor == -1) { - qCWarning(AndroidSerialPortLog) << "Failed to get device handle for" << systemLocation; - setError(QSerialPortErrorInfo(QSerialPort::OpenError)); - close(); - return false; - } - - if (!_setParameters(inputBaudRate, dataBits, stopBits, parity)) { - qCWarning(AndroidSerialPortLog) << "Failed to set serial port parameters for" << systemLocation; - close(); - return false; - } - - if (!setFlowControl(flowControl)) { - qCWarning(AndroidSerialPortLog) << "Failed to set serial port flow control for" << systemLocation; - close(); - return false; - } - - if (mode & QIODevice::ReadOnly) { - if (!startAsyncRead()) { - qCWarning(AndroidSerialPortLog) << "Failed to start async read for" << systemLocation; - close(); - return false; - } - } else if (mode & QIODevice::WriteOnly) { - if (!_stopAsyncRead()) { - qCWarning(AndroidSerialPortLog) << "Failed to stop async read for" << systemLocation; - } - } - - (void)clear(QSerialPort::AllDirections); - tokenGuard.dismiss(); - - return true; -} - -void QSerialPortPrivate::close() -{ - qCDebug(AndroidSerialPortLog) << "Closing" << systemLocation; - - _stopAsyncRead(); - - if (_deviceId != INVALID_DEVICE_ID) { - if (!AndroidSerial::close(_deviceId)) { - qCWarning(AndroidSerialPortLog) << "Failed to close device with ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Closing device failed"))); - } - _deviceId = INVALID_DEVICE_ID; - } - - descriptor = -1; - - AndroidSerial::unregisterPointer(this); -} - -void QSerialPortPrivate::exceptionArrived(const QString& ex) -{ - qCWarning(AndroidSerialPortLog) << "Exception arrived on device ID" << _deviceId << ":" << ex; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, ex)); -} - -bool QSerialPortPrivate::startAsyncRead() -{ - if (!AndroidSerial::readThreadRunning(_deviceId)) { - const bool result = AndroidSerial::startReadThread(_deviceId); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to start async read thread for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to start async read"))); - return false; - } - } - - // If pending bytes were left behind due to read buffer backpressure, - // schedule another drain as soon as reads are active again. - _scheduleReadyRead(); - - return true; -} - -bool QSerialPortPrivate::_stopAsyncRead() -{ - bool result = true; - - if (AndroidSerial::readThreadRunning(_deviceId)) { - result = AndroidSerial::stopReadThread(_deviceId); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to stop async read thread for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to stop async read"))); - } - } - - return result; -} - -qint64 QSerialPortPrivate::_drainPendingDataLocked(qint64 maxBytes) -{ - const qsizetype pendingSize = _pendingSizeLocked(); - if (pendingSize <= 0) { - _pendingData.clear(); - _pendingDataOffset = 0; - return 0; - } - - qint64 toDrain = pendingSize; - if (maxBytes >= 0) { - toDrain = qMin(toDrain, maxBytes); - } - - if (toDrain <= 0) { - return 0; - } - - buffer.append(_pendingData.constData() + _pendingDataOffset, toDrain); - _pendingDataOffset += static_cast(toDrain); - - if (_pendingDataOffset >= _pendingData.size()) { - _pendingData.clear(); - _pendingDataOffset = 0; - } else { - // Compact occasionally to keep append operations efficient without - // paying the cost on every drain. - constexpr qsizetype kCompactThreshold = 4096; - if (_pendingDataOffset >= kCompactThreshold && (_pendingDataOffset * 2) >= _pendingData.size()) { - _compactPendingDataLocked(); - } - } - - _bufferBytesEstimate.store(buffer.size(), std::memory_order_relaxed); - return toDrain; -} - -qsizetype QSerialPortPrivate::_pendingSizeLocked() const -{ - return qMax(0, _pendingData.size() - _pendingDataOffset); -} - -void QSerialPortPrivate::_compactPendingDataLocked() -{ - if (_pendingDataOffset <= 0) { - return; - } - - if (_pendingDataOffset >= _pendingData.size()) { - _pendingData.clear(); - _pendingDataOffset = 0; - return; - } - - _pendingData.remove(0, _pendingDataOffset); - _pendingDataOffset = 0; -} - -void QSerialPortPrivate::newDataArrived(const char* bytes, int length) -{ - // qCDebug(AndroidSerialPortLog) << "newDataArrived" << length; - - qint64 droppedBytes = 0; - - QMutexLocker locker(&_readMutex); - int bytesToRead = length; - if (readBufferMaxSize) { - const qint64 totalBuffered = _pendingSizeLocked() + _bufferBytesEstimate.load(std::memory_order_relaxed); - const qint64 headroom = readBufferMaxSize - totalBuffered; - if (bytesToRead > headroom) { - bytesToRead = static_cast(qMax(qint64(0), headroom)); - droppedBytes = static_cast(length - bytesToRead); - } - } - - if (bytesToRead > 0) { - constexpr qsizetype kCompactBeforeAppendThreshold = 8192; - if (_pendingDataOffset >= kCompactBeforeAppendThreshold) { - _compactPendingDataLocked(); - } - _pendingData.append(bytes, bytesToRead); - _readWaitCondition.wakeAll(); - } - locker.unlock(); - - if (droppedBytes > 0) { - qCWarning(AndroidSerialPortLog) << "Read buffer full, dropping" << droppedBytes << "bytes"; - } - - if (bytesToRead <= 0) { - return; - } - - _scheduleReadyRead(); -} - -void QSerialPortPrivate::_scheduleReadyRead() -{ - Q_Q(QSerialPort); - - if (!_readyReadPending.exchange(true)) { - QPointer guard(q); - QMetaObject::invokeMethod( - q, - [this, guard]() { - if (!guard) { - return; - } - - QMutexLocker locker(&_readMutex); - if (_pendingSizeLocked() <= 0) { - _readyReadPending.store(false); - return; - } - - if (readBufferMaxSize > 0) { - const qint64 canAccept = readBufferMaxSize - buffer.size(); - if (canAccept > 0) { - (void)_drainPendingDataLocked(canAccept); - } - } else { - (void)_drainPendingDataLocked(); - } - - // Reset flag after drain so data arriving during the drain - // does not enqueue redundant lambdas. If pending data remains, - // reschedule so nothing is left undelivered. - const bool more = (_pendingSizeLocked() > 0); - _readyReadPending.store(false); - - _readWaitCondition.wakeAll(); - locker.unlock(); - - emit guard->readyRead(); - - if (more) { - _scheduleReadyRead(); - } - }, - Qt::QueuedConnection); - } -} - -bool QSerialPortPrivate::waitForReadyRead(int msecs) -{ - QMutexLocker locker(&_readMutex); - if (!buffer.isEmpty()) { - return true; - } - - if (_pendingSizeLocked() > 0) { - (void)_drainPendingDataLocked(); - return true; - } - - QDeadlineTimer deadline(msecs); - while (buffer.isEmpty() && (_pendingSizeLocked() <= 0)) { - if (!_readWaitCondition.wait(&_readMutex, deadline)) { - break; - } - - if (!buffer.isEmpty()) { - return true; - } - - if (_pendingSizeLocked() > 0) { - (void)_drainPendingDataLocked(); - return true; - } - } - locker.unlock(); - - qCWarning(AndroidSerialPortLog) << "Timeout while waiting for ready read on device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::TimeoutError, QSerialPort::tr("Timeout while waiting for ready read"))); - - return false; -} - -bool QSerialPortPrivate::waitForBytesWritten(int msecs) -{ - const bool result = _writeDataOneShot(msecs); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Timeout while waiting for bytes written on device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::TimeoutError, - QSerialPort::tr("Timeout while waiting for bytes written"))); - } - - return result; -} - -bool QSerialPortPrivate::_writeDataOneShot(int msecs) -{ - if (writeBuffer.isEmpty()) { - return true; - } - - qint64 pendingBytesWritten = 0; - - while (!writeBuffer.isEmpty()) { - const char* dataPtr = writeBuffer.readPointer(); - const qint64 dataSize = writeBuffer.nextDataBlockSize(); - - const qint64 written = _writeToPort(dataPtr, dataSize, msecs); - if (written < 0) { - qCWarning(AndroidSerialPortLog) << "Failed to write data one shot on device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::WriteError, QSerialPort::tr("Failed to write data one shot"))); - return false; - } - - writeBuffer.free(written); - pendingBytesWritten += written; - } - - const bool result = (pendingBytesWritten > 0); - if (result) { - Q_Q(QSerialPort); - emit q->bytesWritten(pendingBytesWritten); - } - - return result; -} - -qint64 QSerialPortPrivate::_writeToPort(const char* data, qint64 maxSize, int timeout, bool async) -{ - const qint64 result = AndroidSerial::write(_deviceId, data, maxSize, timeout, async); - if (result < 0) { - qCWarning(AndroidSerialPortLog) << "Failed to write to port ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::WriteError, QSerialPort::tr("Failed to write to port"))); - } - - return result; -} - -qint64 QSerialPortPrivate::writeData(const char* data, qint64 maxSize) -{ - if (!data || (maxSize <= 0)) { - qCWarning(AndroidSerialPortLog) << "Invalid data or size in writeData for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::WriteError, QSerialPort::tr("Invalid data or size"))); - return -1; - } - - return _writeToPort(data, maxSize); -} - -bool QSerialPortPrivate::flush() -{ - const bool result = _writeDataOneShot(); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Flush operation failed for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to flush"))); - } - - return result; -} - -bool QSerialPortPrivate::clear(QSerialPort::Directions directions) -{ - const bool input = directions & QSerialPort::Input; - const bool output = directions & QSerialPort::Output; - - const bool result = AndroidSerial::purgeBuffers(_deviceId, input, output); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to purge buffers for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to purge buffers"))); - } - - return result; -} - -QSerialPort::PinoutSignals QSerialPortPrivate::pinoutSignals() -{ - return AndroidSerial::getControlLines(_deviceId); -} - -bool QSerialPortPrivate::setDataTerminalReady(bool set) -{ - const bool result = AndroidSerial::setDataTerminalReady(_deviceId, set); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to set DTR for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to set DTR"))); - } - - return result; -} - -bool QSerialPortPrivate::setRequestToSend(bool set) -{ - const bool result = AndroidSerial::setRequestToSend(_deviceId, set); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to set RTS for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to set RTS"))); - } - - return result; -} - -bool QSerialPortPrivate::_setParameters(qint32 baudRate, QSerialPort::DataBits dataBits_, - QSerialPort::StopBits stopBits_, QSerialPort::Parity parity_) -{ - const bool result = - AndroidSerial::setParameters(_deviceId, baudRate, _dataBitsToAndroidDataBits(dataBits_), - _stopBitsToAndroidStopBits(stopBits_), _parityToAndroidParity(parity_)); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to set Parameters for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to set parameters"))); - } - - return result; -} - -bool QSerialPortPrivate::setBaudRate() -{ - return setBaudRate(inputBaudRate, QSerialPort::AllDirections); -} - -bool QSerialPortPrivate::setBaudRate(qint32 baudRate, QSerialPort::Directions directions) -{ - if (baudRate <= 0) { - qCWarning(AndroidSerialPortLog) << "Invalid baud rate value:" << baudRate; - setError( - QSerialPortErrorInfo(QSerialPort::UnsupportedOperationError, QSerialPort::tr("Invalid baud rate value"))); - return false; - } - - if (directions != QSerialPort::AllDirections) { - qCWarning(AndroidSerialPortLog) << "Custom baud rate direction is unsupported:" << directions; - setError(QSerialPortErrorInfo(QSerialPort::UnsupportedOperationError, - QSerialPort::tr("Custom baud rate direction is unsupported"))); - return false; - } - - const bool result = _setParameters(baudRate, dataBits, stopBits, parity); - if (result) { - inputBaudRate = outputBaudRate = baudRate; - } else { - qCWarning(AndroidSerialPortLog) << "Failed to set baud rate for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to set baud rate"))); - } - - return result; -} - -int QSerialPortPrivate::_dataBitsToAndroidDataBits(QSerialPort::DataBits dataBits_) -{ - switch (dataBits_) { - case QSerialPort::Data5: - return AndroidSerial::Data5; - case QSerialPort::Data6: - return AndroidSerial::Data6; - case QSerialPort::Data7: - return AndroidSerial::Data7; - case QSerialPort::Data8: - return AndroidSerial::Data8; - default: - qCWarning(AndroidSerialPortLog) << "Invalid Data Bits" << dataBits_; - return AndroidSerial::Data8; // Default to Data8 - } -} - -bool QSerialPortPrivate::setDataBits(QSerialPort::DataBits dataBits_) -{ - const bool result = _setParameters(inputBaudRate, dataBits_, stopBits, parity); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to set data bits for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to set data bits"))); - } - - return result; -} - -int QSerialPortPrivate::_parityToAndroidParity(QSerialPort::Parity parity_) -{ - switch (parity_) { - case QSerialPort::SpaceParity: - return AndroidSerial::SpaceParity; - case QSerialPort::MarkParity: - return AndroidSerial::MarkParity; - case QSerialPort::EvenParity: - return AndroidSerial::EvenParity; - case QSerialPort::OddParity: - return AndroidSerial::OddParity; - case QSerialPort::NoParity: - return AndroidSerial::NoParity; - default: - qCWarning(AndroidSerialPortLog) << "Invalid parity type:" << parity_; - return AndroidSerial::NoParity; // Default to NoParity - } -} - -bool QSerialPortPrivate::setParity(QSerialPort::Parity parity_) -{ - const bool result = _setParameters(inputBaudRate, dataBits, stopBits, parity_); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to set parity for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to set parity"))); - } - - return result; -} - -int QSerialPortPrivate::_stopBitsToAndroidStopBits(QSerialPort::StopBits stopBits_) -{ - switch (stopBits_) { - case QSerialPort::TwoStop: - return AndroidSerial::TwoStop; - case QSerialPort::OneAndHalfStop: - return AndroidSerial::OneAndHalfStop; - case QSerialPort::OneStop: - return AndroidSerial::OneStop; - default: - qCWarning(AndroidSerialPortLog) << "Invalid Stop Bits type:" << stopBits_; - return AndroidSerial::OneStop; // Default to OneStop - } -} - -bool QSerialPortPrivate::setStopBits(QSerialPort::StopBits stopBits_) -{ - const bool result = _setParameters(inputBaudRate, dataBits, stopBits_, parity); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to set StopBits for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to set StopBits"))); - } - - return result; -} - -int QSerialPortPrivate::_flowControlToAndroidFlowControl(QSerialPort::FlowControl flowControl_) -{ - switch (flowControl_) { - case QSerialPort::HardwareControl: - return AndroidSerial::RtsCtsFlowControl; - case QSerialPort::SoftwareControl: - return AndroidSerial::XonXoffFlowControl; - case QSerialPort::NoFlowControl: - return AndroidSerial::NoFlowControl; - default: - qCWarning(AndroidSerialPortLog) << "Invalid Flow Control type:" << flowControl_; - return AndroidSerial::NoFlowControl; // Default to NoFlowControl - } -} - -bool QSerialPortPrivate::setFlowControl(QSerialPort::FlowControl flowControl_) -{ - const bool result = AndroidSerial::setFlowControl(_deviceId, _flowControlToAndroidFlowControl(flowControl_)); - if (!result) { - qCWarning(AndroidSerialPortLog) << "Failed to set Flow Control for device ID" << _deviceId; - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to set Flow Control"))); - } - - return result; -} - -bool QSerialPortPrivate::setBreakEnabled(bool set) -{ - const bool result = AndroidSerial::setBreak(_deviceId, set); - if (!result) { - setError(QSerialPortErrorInfo(QSerialPort::UnknownError, QSerialPort::tr("Failed to set Break Enabled"))); - } - - return result; -} - -static constexpr qint32 kStandardBaudRates[] = { - 50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, - 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 500000, - 576000, 921600, 1000000, 1152000, 1500000, 2000000, 2500000, 3000000, 3500000, 4000000, -}; - -QList QSerialPortPrivate::standardBaudRates() -{ - return QList(std::begin(kStandardBaudRates), std::end(kStandardBaudRates)); -} - -QSerialPort::Handle QSerialPort::handle() const -{ - Q_D(const QSerialPort); - return d->descriptor; -} - -QT_END_NAMESPACE diff --git a/src/Android/qtandroidserialport/qserialport_p.h b/src/Android/qtandroidserialport/qserialport_p.h deleted file mode 100644 index d945a509ef8c..000000000000 --- a/src/Android/qtandroidserialport/qserialport_p.h +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (C) 2011-2012 Denis Shienkov -// Copyright (C) 2011 Sergey Belyashov -// Copyright (C) 2012 Laszlo Papp -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -#pragma once - -// -// W A R N I N G -// ------------- -// -// This file is not part of the Qt API. It exists purely as an -// implementation detail. This header file may change from version to -// version without notice, or even be removed. -// -// We mean it. -// - -#include -#include -#include -#include -#include - -#include - -#include "AndroidSerial.h" -#include "qserialport.h" - -constexpr int INVALID_DEVICE_ID = 0; -constexpr int MIN_READ_TIMEOUT = 500; -constexpr qint64 MAX_READ_SIZE = 16 * 1024; -constexpr qint64 DEFAULT_READ_BUFFER_SIZE = MAX_READ_SIZE; -constexpr qint64 DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024; -constexpr int DEFAULT_WRITE_TIMEOUT = 5000; -constexpr int DEFAULT_READ_TIMEOUT = 0; -constexpr int EMIT_THRESHOLD = 64; - -#ifndef QSERIALPORT_BUFFERSIZE -#define QSERIALPORT_BUFFERSIZE DEFAULT_WRITE_BUFFER_SIZE -#endif - -Q_DECLARE_LOGGING_CATEGORY(AndroidSerialPortLog) - -QT_BEGIN_NAMESPACE - -class QSerialPortErrorInfo -{ -public: - QSerialPortErrorInfo(QSerialPort::SerialPortError newErrorCode = QSerialPort::UnknownError, - const QString& newErrorString = QString()); - QSerialPort::SerialPortError errorCode = QSerialPort::UnknownError; - QString errorString; -}; - -class QSerialPortPrivate : public QIODevicePrivate -{ -public: - Q_DECLARE_PUBLIC(QSerialPort) - - QSerialPortPrivate(); - - bool open(QIODevice::OpenMode mode); - void close(); - - bool flush(); - bool clear(QSerialPort::Directions directions); - - QSerialPort::PinoutSignals pinoutSignals(); - - bool setDataTerminalReady(bool set); - bool setRequestToSend(bool set); - - bool setBaudRate(); - bool setBaudRate(qint32 baudRate, QSerialPort::Directions directions); - bool setDataBits(QSerialPort::DataBits dataBits); - bool setParity(QSerialPort::Parity parity); - bool setStopBits(QSerialPort::StopBits stopBits); - bool setFlowControl(QSerialPort::FlowControl flowControl); - - bool setBreakEnabled(bool set); - - void setError(const QSerialPortErrorInfo& errorInfo); - - void setBindableError(QSerialPort::SerialPortError error_) - { - setError(error_); - } - Q_OBJECT_COMPAT_PROPERTY_WITH_ARGS(QSerialPortPrivate, QSerialPort::SerialPortError, error, - &QSerialPortPrivate::setBindableError, QSerialPort::NoError) - - bool setBindableDataBits(QSerialPort::DataBits dataBits_) - { - return q_func()->setDataBits(dataBits_); - } - Q_OBJECT_COMPAT_PROPERTY_WITH_ARGS(QSerialPortPrivate, QSerialPort::DataBits, dataBits, - &QSerialPortPrivate::setBindableDataBits, QSerialPort::Data8) - - bool setBindableParity(QSerialPort::Parity parity_) - { - return q_func()->setParity(parity_); - } - Q_OBJECT_COMPAT_PROPERTY_WITH_ARGS(QSerialPortPrivate, QSerialPort::Parity, parity, - &QSerialPortPrivate::setBindableParity, QSerialPort::NoParity) - - bool setBindableStopBits(QSerialPort::StopBits stopBits_) - { - return q_func()->setStopBits(stopBits_); - } - Q_OBJECT_COMPAT_PROPERTY_WITH_ARGS(QSerialPortPrivate, QSerialPort::StopBits, stopBits, - &QSerialPortPrivate::setBindableStopBits, QSerialPort::OneStop) - - bool setBindableFlowControl(QSerialPort::FlowControl flowControl_) - { - return q_func()->setFlowControl(flowControl_); - } - Q_OBJECT_COMPAT_PROPERTY_WITH_ARGS(QSerialPortPrivate, QSerialPort::FlowControl, flowControl, - &QSerialPortPrivate::setBindableFlowControl, QSerialPort::NoFlowControl) - - bool setBindableBreakEnabled(bool isBreakEnabled_) - { - return q_func()->setBreakEnabled(isBreakEnabled_); - } - Q_OBJECT_COMPAT_PROPERTY_WITH_ARGS(QSerialPortPrivate, bool, isBreakEnabled, - &QSerialPortPrivate::setBindableBreakEnabled, false) - - bool waitForReadyRead(int msec); - bool waitForBytesWritten(int msec); - - bool startAsyncRead(); - - qint64 writeData(const char* data, qint64 maxSize); - - void newDataArrived(const char* bytes, int length); - void exceptionArrived(const QString& ex); - - static QList standardBaudRates(); - - QString systemLocation; - qint32 inputBaudRate = QSerialPort::Baud9600; - qint32 outputBaudRate = QSerialPort::Baud9600; - qint64 readBufferMaxSize = 0; - int descriptor = -1; - -private: - qint64 _writeToPort(const char* data, qint64 maxSize, int timeout = DEFAULT_WRITE_TIMEOUT, bool async = false); - bool _stopAsyncRead(); - void _scheduleReadyRead(); - qsizetype _pendingSizeLocked() const; - void _compactPendingDataLocked(); - qint64 _drainPendingDataLocked(qint64 maxBytes = -1); - bool _setParameters(qint32 baudRate, QSerialPort::DataBits dataBits, QSerialPort::StopBits stopBits, - QSerialPort::Parity parity); - bool _writeDataOneShot(int msecs = DEFAULT_WRITE_TIMEOUT); - - static int _stopBitsToAndroidStopBits(QSerialPort::StopBits stopBits); - static int _dataBitsToAndroidDataBits(QSerialPort::DataBits dataBits); - static int _parityToAndroidParity(QSerialPort::Parity parity); - static int _flowControlToAndroidFlowControl(QSerialPort::FlowControl flowControl); - - int _deviceId = INVALID_DEVICE_ID; - - std::atomic _readyReadPending{false}; - std::atomic _bufferBytesEstimate{0}; - QMutex _readMutex; - QWaitCondition _readWaitCondition; - QByteArray _pendingData; - qsizetype _pendingDataOffset = 0; -}; - -QT_END_NAMESPACE diff --git a/src/Android/qtandroidserialport/qserialportglobal.h b/src/Android/qtandroidserialport/qserialportglobal.h deleted file mode 100644 index 3fc7f1410e2c..000000000000 --- a/src/Android/qtandroidserialport/qserialportglobal.h +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (C) 2012 Denis Shienkov -// Copyright (C) 2012 Laszlo Papp -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -#pragma once - -#include -#include -#include "qtserialportexports.h" diff --git a/src/Android/qtandroidserialport/qserialportinfo.cpp b/src/Android/qtandroidserialport/qserialportinfo.cpp deleted file mode 100644 index be58f6948a99..000000000000 --- a/src/Android/qtandroidserialport/qserialportinfo.cpp +++ /dev/null @@ -1,255 +0,0 @@ -// Copyright (C) 2011-2012 Denis Shienkov -// Copyright (C) 2011 Sergey Belyashov -// Copyright (C) 2012 Laszlo Papp -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -#include "qserialportinfo.h" -#include "qserialportinfo_p.h" -#include "qserialport.h" -#include "qserialport_p.h" - -QT_BEGIN_NAMESPACE - -// We changed from QScopedPointer to std::unique_ptr, make sure it's -// binary compatible. The QScopedPointer had a non-default deleter, but -// the deleter just provides a static function to use for deletion so we don't -// include it in this template definition (the deleter-class was deleted). -static_assert(sizeof(std::unique_ptr) - == sizeof(QScopedPointer)); - -/*! - \class QSerialPortInfo - - \brief Provides information about existing serial ports. - - \ingroup serialport-main - \inmodule QtSerialPort - \since 5.1 - - Use the static \l availablePorts() function to generate a list of - QSerialPortInfo objects. Each QSerialPortInfo object in the list represents - a single serial port and can be queried for the \l {portName}{port name}, - \l {systemLocation}{system location}, \l description, \l manufacturer, and - some other hardware parameters. The QSerialPortInfo class can also be - used as an input parameter for the \l {QSerialPort::}{setPort()} method of - the QSerialPort class. - - \section1 Example Usage - - The example code enumerates all available serial ports and prints their - parameters to console: - - \snippet doc_src_serialport.cpp enumerate_ports - - \sa QSerialPort -*/ - -/*! - Constructs an empty QSerialPortInfo object. - - \sa isNull() -*/ -QSerialPortInfo::QSerialPortInfo() -{ -} - -/*! - Constructs a copy of \a other. -*/ -QSerialPortInfo::QSerialPortInfo(const QSerialPortInfo &other) - : d_ptr(other.d_ptr ? new QSerialPortInfoPrivate(*other.d_ptr) : nullptr) -{ -} - -/*! - Constructs a QSerialPortInfo object from serial \a port. -*/ -QSerialPortInfo::QSerialPortInfo(const QSerialPort &port) - : QSerialPortInfo(port.portName()) -{ -} - -/*! - Constructs a QSerialPortInfo object from serial port \a name. - - This constructor finds the relevant serial port among the available ones - according to the port name \a name, and constructs the serial port info - instance for that port. -*/ -QSerialPortInfo::QSerialPortInfo(const QString &name) -{ - const auto infos = QSerialPortInfo::availablePorts(); - for (const QSerialPortInfo &info : infos) { - if (name == info.portName()) { - *this = info; - break; - } - } -} - -QSerialPortInfo::QSerialPortInfo(const QSerialPortInfoPrivate &dd) - : d_ptr(new QSerialPortInfoPrivate(dd)) -{ -} - -/*! - Destroys the QSerialPortInfo object. References to the values in the - object become invalid. -*/ -QSerialPortInfo::~QSerialPortInfo() -{ -} - -/*! - Swaps QSerialPortInfo \a other with this QSerialPortInfo. This operation is - very fast and never fails. -*/ -void QSerialPortInfo::swap(QSerialPortInfo &other) -{ - d_ptr.swap(other.d_ptr); -} - -/*! - Sets the QSerialPortInfo object to be equal to \a other. -*/ -QSerialPortInfo& QSerialPortInfo::operator=(const QSerialPortInfo &other) -{ - QSerialPortInfo(other).swap(*this); - return *this; -} - -/*! - Returns the name of the serial port. - - \sa systemLocation() -*/ -QString QSerialPortInfo::portName() const -{ - Q_D(const QSerialPortInfo); - return !d ? QString() : d->portName; -} - -/*! - Returns the system location of the serial port. - - \sa portName() -*/ -QString QSerialPortInfo::systemLocation() const -{ - Q_D(const QSerialPortInfo); - return !d ? QString() : d->device; -} - -/*! - Returns the description string of the serial port, - if available; otherwise returns an empty string. - - \sa manufacturer(), serialNumber() -*/ -QString QSerialPortInfo::description() const -{ - Q_D(const QSerialPortInfo); - return !d ? QString() : d->description; -} - -/*! - Returns the manufacturer string of the serial port, - if available; otherwise returns an empty string. - - \sa description(), serialNumber() -*/ -QString QSerialPortInfo::manufacturer() const -{ - Q_D(const QSerialPortInfo); - return !d ? QString() : d->manufacturer; -} - -/*! - \since 5.3 - - Returns the serial number string of the serial port, - if available; otherwise returns an empty string. - - \note The serial number may include letters. - - \sa description(), manufacturer() -*/ -QString QSerialPortInfo::serialNumber() const -{ - Q_D(const QSerialPortInfo); - return !d ? QString() : d->serialNumber; -} - -/*! - Returns the 16-bit vendor number for the serial port, if available; - otherwise returns zero. - - \sa hasVendorIdentifier(), productIdentifier(), hasProductIdentifier() -*/ -quint16 QSerialPortInfo::vendorIdentifier() const -{ - Q_D(const QSerialPortInfo); - return !d ? 0 : d->vendorIdentifier; -} - -/*! - Returns the 16-bit product number for the serial port, if available; - otherwise returns zero. - - \sa hasProductIdentifier(), vendorIdentifier(), hasVendorIdentifier() -*/ -quint16 QSerialPortInfo::productIdentifier() const -{ - Q_D(const QSerialPortInfo); - return !d ? 0 : d->productIdentifier; -} - -/*! - Returns \c true if there is a valid \c 16-bit vendor number present; otherwise - returns \c false. - - \sa vendorIdentifier(), productIdentifier(), hasProductIdentifier() -*/ -bool QSerialPortInfo::hasVendorIdentifier() const -{ - Q_D(const QSerialPortInfo); - return !d ? false : d->hasVendorIdentifier; -} - -/*! - Returns \c true if there is a valid \c 16-bit product number present; otherwise - returns \c false. - - \sa productIdentifier(), vendorIdentifier(), hasVendorIdentifier() -*/ -bool QSerialPortInfo::hasProductIdentifier() const -{ - Q_D(const QSerialPortInfo); - return !d ? false : d->hasProductIdentifier; -} - -/*! - \fn bool QSerialPortInfo::isNull() const - - Returns whether this QSerialPortInfo object holds a - serial port definition. -*/ - -/*! - \fn QList QSerialPortInfo::standardBaudRates() - - Returns a list of available standard baud rates supported - by the target platform. -*/ -QList QSerialPortInfo::standardBaudRates() -{ - return QSerialPortPrivate::standardBaudRates(); -} - -/*! - \fn QList QSerialPortInfo::availablePorts() - - Returns a list of available serial ports on the system. -*/ - -QT_END_NAMESPACE diff --git a/src/Android/qtandroidserialport/qserialportinfo.h b/src/Android/qtandroidserialport/qserialportinfo.h deleted file mode 100644 index efa3abe2018b..000000000000 --- a/src/Android/qtandroidserialport/qserialportinfo.h +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (C) 2012 Denis Shienkov -// Copyright (C) 2012 Laszlo Papp -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -#pragma once - -#include -#include - -#include "qserialportglobal.h" - -QT_BEGIN_NAMESPACE - -class QSerialPort; -class QSerialPortInfoPrivate; - -class Q_SERIALPORT_EXPORT QSerialPortInfo -{ - Q_DECLARE_PRIVATE(QSerialPortInfo) -public: - QSerialPortInfo(); - explicit QSerialPortInfo(const QSerialPort& port); - explicit QSerialPortInfo(const QString& name); - QSerialPortInfo(const QSerialPortInfo& other); - QSerialPortInfo(const QSerialPortInfoPrivate& dd); - ~QSerialPortInfo(); - - QSerialPortInfo& operator=(const QSerialPortInfo& other); - void swap(QSerialPortInfo& other); - - QString portName() const; - QString systemLocation() const; - QString description() const; - QString manufacturer() const; - QString serialNumber() const; - - quint16 vendorIdentifier() const; - quint16 productIdentifier() const; - - bool hasVendorIdentifier() const; - bool hasProductIdentifier() const; - - bool isNull() const; - - static QList standardBaudRates(); - static QList availablePorts(); - -private: - friend QList availablePortsByFiltersOfDevices(bool& ok); - std::unique_ptr d_ptr; -}; - -inline bool QSerialPortInfo::isNull() const -{ - return !d_ptr; -} - -QT_END_NAMESPACE diff --git a/src/Android/qtandroidserialport/qserialportinfo_android.cpp b/src/Android/qtandroidserialport/qserialportinfo_android.cpp deleted file mode 100644 index 60788d06e453..000000000000 --- a/src/Android/qtandroidserialport/qserialportinfo_android.cpp +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (C) 2011-2012 Denis Shienkov -// Copyright (C) 2011 Sergey Belyashov -// Copyright (C) 2012 Laszlo Papp -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -#include -#include - -#include - -#include "qserialport_p.h" -#include "qserialportinfo.h" -#include "qserialportinfo_p.h" - -QGC_LOGGING_CATEGORY(QSerialPortInfo_AndroidLog, "Android.AndroidSerialPortInfo") - -QT_BEGIN_NAMESPACE - -QList availablePortsByFiltersOfDevices(bool& ok) -{ - const QList serialPortInfoList = AndroidSerial::availableDevices(); - ok = !serialPortInfoList.isEmpty(); - return serialPortInfoList; -} - -QList availablePortsBySysfs(bool& ok) -{ - ok = false; - return QList(); -} - -QList availablePortsByUdev(bool& ok) -{ - ok = false; - return QList(); -} - -QList QSerialPortInfo::availablePorts() -{ - bool ok = false; - const QList serialPortInfoList = availablePortsByFiltersOfDevices(ok); - return (ok ? serialPortInfoList : QList()); -} - -QString QSerialPortInfoPrivate::portNameToSystemLocation(const QString& source) -{ - return (source.startsWith(QLatin1Char('/')) || source.startsWith(QLatin1String("./")) || - source.startsWith(QLatin1String("../"))) - ? source - : (QLatin1String("/dev/") + source); -} - -QString QSerialPortInfoPrivate::portNameFromSystemLocation(const QString& source) -{ - return source.startsWith(QLatin1String("/dev/")) ? source.mid(5) : source; -} - -QT_END_NAMESPACE diff --git a/src/Android/qtandroidserialport/qserialportinfo_p.h b/src/Android/qtandroidserialport/qserialportinfo_p.h deleted file mode 100644 index e22c9efc4a7d..000000000000 --- a/src/Android/qtandroidserialport/qserialportinfo_p.h +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (C) 2011-2012 Denis Shienkov -// Copyright (C) 2017 Sergey Belyashov -// Copyright (C) 2013 Laszlo Papp -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -#pragma once - -// -// W A R N I N G -// ------------- -// -// This file is not part of the Qt API. It exists purely as an -// implementation detail. This header file may change from version to -// version without notice, or even be removed. -// -// We mean it. -// - -#include -#include - -QT_BEGIN_NAMESPACE - -class Q_AUTOTEST_EXPORT QSerialPortInfoPrivate -{ -public: - static QString portNameToSystemLocation(const QString &source); - static QString portNameFromSystemLocation(const QString &source); - - QString portName; - QString device; - QString description; - QString manufacturer; - QString serialNumber; - - quint16 vendorIdentifier = 0; - quint16 productIdentifier = 0; - - bool hasVendorIdentifier = false; - bool hasProductIdentifier = false; -}; - -QT_END_NAMESPACE diff --git a/src/Android/qtandroidserialport/qtserialportexports.h b/src/Android/qtandroidserialport/qtserialportexports.h deleted file mode 100644 index 198df1cd6533..000000000000 --- a/src/Android/qtandroidserialport/qtserialportexports.h +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (C) 2022 The Qt Company Ltd. -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -#pragma once - -#include -#include // Q_SERIALPORT_EXPORT -#include // QT_IF_DEPRECATED_SINCE - -#if defined(QT_SHARED) || !defined(QT_STATIC) -# if defined(QT_BUILD_SERIALPORT_LIB) -# define Q_SERIALPORT_EXPORT Q_DECL_EXPORT -# else -# define Q_SERIALPORT_EXPORT Q_DECL_IMPORT -# endif -#else -# define Q_SERIALPORT_EXPORT -#endif - -#if !defined(QT_BUILD_SERIALPORT_LIB) && !defined(QT_STATIC) -/* outside library -> inline decl + defi */ -/* static builds treat everything as part of the library, so they never inline */ -# define QT_SERIALPORT_INLINE_SINCE(major, minor) inline -# define QT_SERIALPORT_INLINE_IMPL_SINCE(major, minor) 1 -#elif defined(QT_SERIALPORT_BUILD_REMOVED_API) -/* inside library, inside removed_api.cpp: - * keep deprecated API -> non-inline decl; - * remove deprecated API -> inline decl; - * definition is always available */ -# define QT_SERIALPORT_INLINE_SINCE(major, minor) \ - QT_IF_DEPRECATED_SINCE(major, minor, inline, /* not inline */) -# define QT_SERIALPORT_INLINE_IMPL_SINCE(major, minor) 1 -#else -/* inside library, outside removed_api.cpp: - * keep deprecated API -> non-inline decl, no defi; - * remove deprecated API -> inline decl, defi */ -# define QT_SERIALPORT_INLINE_SINCE(major, minor) \ - QT_IF_DEPRECATED_SINCE(major, minor, inline, /* not inline */) -# define QT_SERIALPORT_INLINE_IMPL_SINCE(major, minor) \ - QT_IF_DEPRECATED_SINCE(major, minor, 1, 0) -#endif - -#ifdef QT_SERIALPORT_BUILD_REMOVED_API -# define QT_SERIALPORT_REMOVED_SINCE(major, minor) QT_DEPRECATED_SINCE(major, minor) -#else -# define QT_SERIALPORT_REMOVED_SINCE(major, minor) 0 -#endif diff --git a/src/Android/qtandroidserialport/qtserialportversion.h b/src/Android/qtandroidserialport/qtserialportversion.h deleted file mode 100644 index 3fe2601c46ae..000000000000 --- a/src/Android/qtandroidserialport/qtserialportversion.h +++ /dev/null @@ -1,6 +0,0 @@ -/* This file was generated by syncqt. */ -#pragma once - -#define QTSERIALPORT_VERSION_STR "6.6.3" - -#define QTSERIALPORT_VERSION 0x060603 diff --git a/src/AppSettings/NmeaGpsSettings.qml b/src/AppSettings/NmeaGpsSettings.qml index 54720e141ac7..118f80fa0ad2 100644 --- a/src/AppSettings/NmeaGpsSettings.qml +++ b/src/AppSettings/NmeaGpsSettings.qml @@ -21,7 +21,7 @@ SettingsGroupLayout { } } - Component.onCompleted: { + function rebuildModel() { var model = [] model.push(qsTr("Disabled")) @@ -39,6 +39,14 @@ SettingsGroupLayout { const index = nmeaPortCombo.comboBox.find(QGroundControl.settingsManager.autoConnectSettings.autoConnectNmeaPort.valueString); nmeaPortCombo.currentIndex = index; } + + Component.onCompleted: rebuildModel() + + // Serial ports come and go with USB hot-plug; rebuild so the current device is selectable. + Connections { + target: QGroundControl.linkManager + function onCommPortsChanged() { nmeaPortCombo.rebuildModel() } + } } LabelledComboBox { diff --git a/src/AppSettings/SerialSettings.qml b/src/AppSettings/SerialSettings.qml index d185beea52ad..1c23250a8b48 100644 --- a/src/AppSettings/SerialSettings.qml +++ b/src/AppSettings/SerialSettings.qml @@ -36,6 +36,7 @@ ColumnLayout { } else { subEditConfig.portName = QGroundControl.linkManager.serialPorts[index] } + baudCombo.refreshModel() } } @@ -78,8 +79,8 @@ ColumnLayout { } } - Component.onCompleted: { - var rates = QGroundControl.linkManager.serialBaudRates.slice() + function refreshModel() { + var rates = QGroundControl.linkManager.serialBaudRatesForPort(subEditConfig.portName).slice() rates.push(_customLabel) model = rates @@ -92,6 +93,8 @@ ColumnLayout { baudCombo.currentIndex = index } } + + Component.onCompleted: refreshModel() } QGCLabel { @@ -143,39 +146,10 @@ ColumnLayout { QGCLabel { text: qsTr("Parity") } QGCComboBox { Layout.preferredWidth: _secondColumnWidth - model: [qsTr("None"), qsTr("Even"), qsTr("Odd")] - - onActivated: (index) => { - // Hard coded values from qserialport.h - switch (index) { - case 0: - subEditConfig.parity = 0 - break - case 1: - subEditConfig.parity = 2 - break - case 2: - subEditConfig.parity = 3 - break - } - } - - Component.onCompleted: { - switch (subEditConfig.parity) { - case 0: - currentIndex = 0 - break - case 2: - currentIndex = 1 - break - case 3: - currentIndex = 2 - break - default: - console.warn("Unknown parity", subEditConfig.parity) - break - } - } + // Combo index is the QGCParity enum value (None, Odd, Even, Mark, Space). + model: [qsTr("None"), qsTr("Odd"), qsTr("Even"), qsTr("Mark"), qsTr("Space")] + currentIndex: Math.max(Math.min(subEditConfig.parity, 4), 0) + onActivated: (index) => { subEditConfig.parity = index } } QGCLabel { text: qsTr("Data Bits") } diff --git a/src/AutoPilotPlugins/APM/APMAutoPilotPlugin.cc b/src/AutoPilotPlugins/APM/APMAutoPilotPlugin.cc index 2b221a2f3caa..17474368ae57 100644 --- a/src/AutoPilotPlugins/APM/APMAutoPilotPlugin.cc +++ b/src/AutoPilotPlugins/APM/APMAutoPilotPlugin.cc @@ -252,7 +252,8 @@ void APMAutoPilotPlugin::_checkForBadCubeBlack(bool parametersReady) return; } - if (!QGCSerialPortInfo(*serialLink->port()).isBlackCube()) { + const SerialConfiguration *serialConfig = qobject_cast(serialLink->linkConfiguration().get()); + if (!serialConfig || !QGCSerialPortInfo(serialConfig->portName()).isBlackCube()) { return; } diff --git a/src/Comms/CMakeLists.txt b/src/Comms/CMakeLists.txt index 0748ac4ca126..9d734df65119 100644 --- a/src/Comms/CMakeLists.txt +++ b/src/Comms/CMakeLists.txt @@ -28,48 +28,15 @@ target_sources(${CMAKE_PROJECT_NAME} UDPLink.h ) -# USB board information JSON data -qt_add_resources(${CMAKE_PROJECT_NAME} json_comm - PREFIX "/json" - FILES USBBoardInfo.json -) - target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) # Uncomment to enable Qt socket debugging # target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE QABSTRACTSOCKET_DEBUG) -# ============================================================================ -# Optional Communication Features -# ============================================================================ - -# ---------------------------------------------------------------------------- -# Serial Port Communication -# ---------------------------------------------------------------------------- - -if(QGC_NO_SERIAL_LINK) - target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE QGC_NO_SERIAL_LINK) -else() - target_sources(${CMAKE_PROJECT_NAME} - PRIVATE - QGCSerialPortInfo.cc - QGCSerialPortInfo.h - SerialLink.cc - SerialLink.h - ) - - if(NOT ANDROID) - target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE Qt6::SerialPort) - endif() -endif() - -# ---------------------------------------------------------------------------- -# Bluetooth Communication -# ---------------------------------------------------------------------------- -add_subdirectory(Bluetooth) - # ============================================================================ # Communication Link Subdirectories # ============================================================================ +add_subdirectory(Serial) +add_subdirectory(Bluetooth) add_subdirectory(MockLink) diff --git a/src/Comms/LinkManager.cc b/src/Comms/LinkManager.cc index 076fc04cbb42..b25623462621 100644 --- a/src/Comms/LinkManager.cc +++ b/src/Comms/LinkManager.cc @@ -21,6 +21,8 @@ #include "SerialLink.h" #include "GPSManager.h" #include "GPSRtk.h" +#include "QGCSerialPort.h" +#include "SerialPlatform.h" #endif #ifdef QT_DEBUG @@ -52,6 +54,7 @@ LinkManager::LinkManager(QObject *parent) LinkManager::~LinkManager() { + // The SerialDevicesNotifier connection auto-disconnects when this QObject is destroyed; no manual teardown. qCDebug(LinkManagerLog) << this; } @@ -66,7 +69,14 @@ void LinkManager::init() if (!QGC::runningUnitTests()) { (void) connect(_portListTimer, &QTimer::timeout, this, &LinkManager::_updateAutoConnectLinks); - _portListTimer->start(_autoconnectUpdateTimerMSecs); // timeout must be long enough to get past bootloader on second pass + _portListTimer->start(_autoconnectUpdateTimerMSecs); +#if defined(Q_OS_ANDROID) && !defined(QGC_NO_SERIAL_LINK) + // USB attach fires an immediate pass instead of waiting for the next poll. The notifier emits from a + // binder thread; the queued connection hops to this (GUI) thread and auto-disconnects on destruction. + (void) connect(SerialPlatform::SerialDevicesNotifier::instance(), + &SerialPlatform::SerialDevicesNotifier::devicesChanged, + this, &LinkManager::_updateAutoConnectLinks, Qt::QueuedConnection); +#endif } } @@ -127,9 +137,15 @@ bool LinkManager::createConnectedLink(SharedLinkConfigurationPtr &config) switch(config->type()) { #ifndef QGC_NO_SERIAL_LINK - case LinkConfiguration::TypeSerial: + case LinkConfiguration::TypeSerial: { + const SerialConfiguration* const serialConfig = qobject_cast(config.get()); + if (serialConfig && (serialConfig->portName() == _autoConnectRTKPort)) { + qCWarning(LinkManagerLog) << "Serial port is already in use by RTK GPS" << serialConfig->portName(); + return false; + } link = std::make_shared(config); break; + } #endif case LinkConfiguration::TypeUdp: link = std::make_shared(config); @@ -805,17 +821,10 @@ void LinkManager::_filterCompositePorts(QList &portList) void LinkManager::_addSerialAutoConnectLink() { - QList portList; -#ifdef Q_OS_ANDROID - // Android builds only support a single serial connection. Repeatedly calling availablePorts after that one serial - // port is connected leaks file handles due to a bug somewhere in android serial code. In order to work around that - // bug after we connect the first serial port we stop probing for additional ports. - if (!_isSerialPortConnected()) { - portList = QGCSerialPortInfo::availablePorts(); - } -#else - portList = QGCSerialPortInfo::availablePorts(); -#endif + QList portList = QGCSerialPortInfo::availablePorts(); + + // Keep the QML-visible port list fresh each poll so dropdowns (e.g. NMEA device) track hot-plug. + _setSerialPortsFrom(portList); _filterCompositePorts(portList); @@ -837,23 +846,21 @@ void LinkManager::_addSerialAutoConnectLink() // check to see if nmea gps is configured for current Serial port, if so, set it up to connect if (portInfo.systemLocation().trimmed() == _autoConnectSettings->autoConnectNmeaPort()->cookedValueString()) { - if (portInfo.systemLocation().trimmed() != _nmeaDeviceName) { - _nmeaDeviceName = portInfo.systemLocation().trimmed(); - qCDebug(LinkManagerLog) << "Configuring nmea port" << _nmeaDeviceName; - QSerialPort* newPort = new QSerialPort(portInfo, this); - _nmeaBaud = _autoConnectSettings->autoConnectNmeaBaud()->cookedValue().toUInt(); - newPort->setBaudRate(static_cast(_nmeaBaud)); - qCDebug(LinkManagerLog) << "Configuring nmea baudrate" << _nmeaBaud; - // This will stop polling old device if previously set + const QString location = portInfo.systemLocation().trimmed(); + const uint32_t baud = _autoConnectSettings->autoConnectNmeaBaud()->cookedValue().toUInt(); + // Recreate on first sight or baud change; makeNmeaSerialSource picks a GUI-safe device per platform + // (Android runs the USB serial I/O on a worker thread; the backend refuses GUI-thread open). + if ((location != _nmeaDeviceName) || (baud != _nmeaBaud)) { + _nmeaDeviceName = location; + _nmeaBaud = baud; + qCDebug(LinkManagerLog) << "Configuring nmea port" << _nmeaDeviceName << "baud" << _nmeaBaud; + QIODevice* newPort = SerialPlatform::makeNmeaSerialSource(portInfo.systemLocation(), static_cast(_nmeaBaud), this); + // Switches the position source off the old device before we delete it. QGCPositionManager::instance()->setNmeaSourceDevice(newPort); if (_nmeaPort) { delete _nmeaPort; } _nmeaPort = newPort; - } else if (_autoConnectSettings->autoConnectNmeaBaud()->cookedValue().toUInt() != _nmeaBaud) { - _nmeaBaud = _autoConnectSettings->autoConnectNmeaBaud()->cookedValue().toUInt(); - _nmeaPort->setBaudRate(static_cast(_nmeaBaud)); - qCDebug(LinkManagerLog) << "Configuring nmea baudrate" << _nmeaBaud; } } else if (portInfo.getBoardInfo(boardType, boardName)) { // Should we be auto-connecting to this board type? @@ -866,7 +873,7 @@ void LinkManager::_addSerialAutoConnectLink() qCDebug(LinkManagerLog) << "Waiting for bootloader to finish" << portInfo.systemLocation(); continue; } - if (_portAlreadyConnected(portInfo.systemLocation()) || (_autoConnectRTKPort == portInfo.systemLocation())) { + if (serialPortConnected(portInfo.systemLocation()) || (_autoConnectRTKPort == portInfo.systemLocation())) { qCDebug(LinkManagerVerboseLog) << "Skipping existing autoconnect" << portInfo.systemLocation(); } else if (!_autoconnectPortWaitList.contains(portInfo.systemLocation())) { // We don't connect to the port the first time we see it. The ability to correctly detect whether we @@ -951,7 +958,7 @@ bool LinkManager::_allowAutoConnectToBoard(QGCSerialPortInfo::BoardType_t boardT return false; } -bool LinkManager::_portAlreadyConnected(const QString &portName) +bool LinkManager::serialPortConnected(const QString &portName) { QMutexLocker locker(&_linksMutex); @@ -969,14 +976,26 @@ bool LinkManager::_portAlreadyConnected(const QString &portName) void LinkManager::_updateSerialPorts() { - _commPortList.clear(); - _commPortDisplayList.clear(); - const QList portList = QGCSerialPortInfo::availablePorts(); - for (const QGCSerialPortInfo &info: portList) { + _setSerialPortsFrom(QGCSerialPortInfo::availablePorts()); +} + +void LinkManager::_setSerialPortsFrom(const QList &infos) +{ + QStringList portList; + QStringList displayList; + for (const QGCSerialPortInfo &info: infos) { const QString port = info.systemLocation().trimmed(); - _commPortList += port; - _commPortDisplayList += SerialConfiguration::cleanPortDisplayName(port); + portList += port; + displayList += info.portName(); + } + + if (portList == _commPortList) { + return; } + _commPortList = portList; + _commPortDisplayList = displayList; + emit commPortsChanged(); + emit commPortStringsChanged(); } QStringList LinkManager::serialPortStrings() @@ -999,7 +1018,12 @@ QStringList LinkManager::serialPorts() QStringList LinkManager::serialBaudRates() { - return SerialConfiguration::supportedBaudRates(); + return QGCSerialPortInfo::supportedBaudRateStrings(); +} + +QStringList LinkManager::serialBaudRatesForPort(const QString &portName) const +{ + return QGCSerialPortInfo::supportedBaudRateStrings(portName); } bool LinkManager::_isSerialPortConnected() diff --git a/src/Comms/LinkManager.h b/src/Comms/LinkManager.h index 739b2a521a78..a39c76a67fe8 100644 --- a/src/Comms/LinkManager.h +++ b/src/Comms/LinkManager.h @@ -18,6 +18,8 @@ class AutoConnectSettings; class LogReplayLink; class MAVLinkProtocol; class QmlObjectListModel; +class QGCSerialPort; +class QIODevice; class QTimer; class SerialLink; class UDPConfiguration; @@ -152,11 +154,17 @@ private slots: static constexpr const char *_mavlinkForwardingLinkName = "MAVLink Forwarding Link"; static constexpr const char *_mavlinkForwardingSupportLinkName = "MAVLink Support Forwarding Link"; +#if defined(Q_OS_ANDROID) + // Detection is instant (USB attach kicks a rescan); the delay only clears the board's + // enumerate->firstMAVLink window (~440ms on a Cube) so we open past the boot tail, not at ~2s. + static constexpr int _autoconnectUpdateTimerMSecs = 500; + static constexpr int _autoconnectConnectDelayMSecs = 1000; +#elif defined(Q_OS_WIN) static constexpr int _autoconnectUpdateTimerMSecs = 1000; -#ifdef Q_OS_WIN // Have to manually let the bootloader go by on Windows to get a working connect static constexpr int _autoconnectConnectDelayMSecs = 6000; #else + static constexpr int _autoconnectUpdateTimerMSecs = 1000; static constexpr int _autoconnectConnectDelayMSecs = 1000; #endif @@ -168,8 +176,10 @@ private slots: public: static QStringList serialBaudRates(); + Q_INVOKABLE QStringList serialBaudRatesForPort(const QString &portName) const; QStringList serialPortStrings(); QStringList serialPorts(); + bool serialPortConnected(const QString &portName); signals: void commPortStringsChanged(); @@ -178,9 +188,9 @@ private slots: private: bool _isSerialPortConnected(); void _updateSerialPorts(); + void _setSerialPortsFrom(const QList &infos); // refresh QML-visible list; emits on change bool _allowAutoConnectToBoard(QGCSerialPortInfo::BoardType_t boardType) const; void _addSerialAutoConnectLink(); - bool _portAlreadyConnected(const QString &portName); void _filterCompositePorts(QList &portList); QMap _autoconnectPortWaitList; ///< key: QGCSerialPortInfo::systemLocation, value: wait count @@ -190,7 +200,7 @@ private slots: QString _autoConnectRTKPort; QString _nmeaDeviceName; uint32_t _nmeaBaud = 0; - QSerialPort *_nmeaPort = nullptr; + QIODevice *_nmeaPort = nullptr; #endif // QGC_NO_SERIAL_LINK // NMEA UDP is network-only; available regardless of QGC_NO_SERIAL_LINK. diff --git a/src/Comms/QGCSerialPortInfo.cc b/src/Comms/QGCSerialPortInfo.cc deleted file mode 100644 index 01f5f866880e..000000000000 --- a/src/Comms/QGCSerialPortInfo.cc +++ /dev/null @@ -1,328 +0,0 @@ -#include "QGCSerialPortInfo.h" - -#include "JsonParsing.h" -#include "QGCLoggingCategory.h" - -#include -#include -#include -#include - -QGC_LOGGING_CATEGORY(QGCSerialPortInfoLog, "Comms.QGCSerialPortInfo") - -bool QGCSerialPortInfo::_jsonLoaded = false; -bool QGCSerialPortInfo::_jsonDataValid = false; -QList QGCSerialPortInfo::_boardInfoList; -QList QGCSerialPortInfo::_boardDescriptionFallbackList; -QList QGCSerialPortInfo::_boardManufacturerFallbackList; - -QGCSerialPortInfo::QGCSerialPortInfo() - : QSerialPortInfo() -{ - qCDebug(QGCSerialPortInfoLog) << this; -} - -QGCSerialPortInfo::QGCSerialPortInfo(const QSerialPort &port) - : QSerialPortInfo(port) -{ - qCDebug(QGCSerialPortInfoLog) << this; -} - -QGCSerialPortInfo::~QGCSerialPortInfo() -{ - qCDebug(QGCSerialPortInfoLog) << this; -} - -bool QGCSerialPortInfo::_loadJsonData() -{ - if (_jsonLoaded) { - return _jsonDataValid; - } - - _jsonLoaded = true; - - QString errorString; - int version; - const QJsonObject json = JsonParsing::openInternalQGCJsonFile(QStringLiteral(":/json/USBBoardInfo.json"), QString(_jsonFileTypeValue), 1, 1, version, errorString); - if (!errorString.isEmpty()) { - qCWarning(QGCSerialPortInfoLog) << "Internal Error:" << errorString; - return false; - } - - static const QList rootKeyInfoList = { - { _jsonBoardInfoKey, QJsonValue::Array, true }, - { _jsonBoardDescriptionFallbackKey, QJsonValue::Array, true }, - { _jsonBoardManufacturerFallbackKey, QJsonValue::Array, true }, - }; - if (!JsonParsing::validateKeys(json, rootKeyInfoList, errorString)) { - qCWarning(QGCSerialPortInfoLog) << errorString; - return false; - } - - static const QList boardKeyInfoList = { - { _jsonVendorIDKey, QJsonValue::Double, true }, - { _jsonProductIDKey, QJsonValue::Double, true }, - { _jsonBoardClassKey, QJsonValue::String, true }, - { _jsonNameKey, QJsonValue::String, true }, - }; - const QJsonArray rgBoardInfo = json[_jsonBoardInfoKey].toArray(); - for (const QJsonValue &jsonValue : rgBoardInfo) { - if (!jsonValue.isObject()) { - qCWarning(QGCSerialPortInfoLog) << "Entry in boardInfo array is not object"; - return false; - } - - const QJsonObject boardObject = jsonValue.toObject(); - if (!JsonParsing::validateKeys(boardObject, boardKeyInfoList, errorString)) { - qCWarning(QGCSerialPortInfoLog) << errorString; - return false; - } - - const BoardInfo_t boardInfo = { - boardObject[_jsonVendorIDKey].toInt(), - boardObject[_jsonProductIDKey].toInt(), - _boardClassStringToType(boardObject[_jsonBoardClassKey].toString()), - boardObject[_jsonNameKey].toString() - }; - if (boardInfo.boardType == BoardTypeUnknown) { - qCWarning(QGCSerialPortInfoLog) << "Bad board class" << boardObject[_jsonBoardClassKey].toString(); - return false; - } - - _boardInfoList.append(boardInfo); - } - - static const QList fallbackKeyInfoList = { - { _jsonRegExpKey, QJsonValue::String, true }, - { _jsonBoardClassKey, QJsonValue::String, true }, - { _jsonAndroidOnlyKey, QJsonValue::Bool, false }, - }; - const QJsonArray rgBoardDescriptionFallback = json[_jsonBoardDescriptionFallbackKey].toArray(); - for (const QJsonValue &jsonValue : rgBoardDescriptionFallback) { - if (!jsonValue.isObject()) { - qCWarning(QGCSerialPortInfoLog) << "Entry in boardFallback array is not object"; - return false; - } - - const QJsonObject fallbackObject = jsonValue.toObject(); - if (!JsonParsing::validateKeys(fallbackObject, fallbackKeyInfoList, errorString)) { - qCWarning(QGCSerialPortInfoLog) << errorString; - return false; - } - - const QRegularExpression regExp(fallbackObject[_jsonRegExpKey].toString(), QRegularExpression::CaseInsensitiveOption); - if (!regExp.isValid()) { - qCWarning(QGCSerialPortInfoLog) << "Invalid regular expression in board description fallback:" - << regExp.errorString() - << "pattern:" << fallbackObject[_jsonRegExpKey].toString(); - return false; - } - const BoardRegExpFallback_t boardFallback = { - regExp, - _boardClassStringToType(fallbackObject[_jsonBoardClassKey].toString()), - fallbackObject[_jsonAndroidOnlyKey].toBool(false) - }; - if (boardFallback.boardType == BoardTypeUnknown) { - qCWarning(QGCSerialPortInfoLog) << "Bad board class" << fallbackObject[_jsonBoardClassKey].toString(); - return false; - } - - _boardDescriptionFallbackList.append(boardFallback); - } - - const QJsonArray rgBoardManufacturerFallback = json[_jsonBoardManufacturerFallbackKey].toArray(); - for (const QJsonValue &jsonValue : rgBoardManufacturerFallback) { - if (!jsonValue.isObject()) { - qCWarning(QGCSerialPortInfoLog) << "Entry in boardFallback array is not object"; - return false; - } - - const QJsonObject fallbackObject = jsonValue.toObject(); - if (!JsonParsing::validateKeys(fallbackObject, fallbackKeyInfoList, errorString)) { - qCWarning(QGCSerialPortInfoLog) << errorString; - return false; - } - - const QRegularExpression regExp(fallbackObject[_jsonRegExpKey].toString(), QRegularExpression::CaseInsensitiveOption); - if (!regExp.isValid()) { - qCWarning(QGCSerialPortInfoLog) << "Invalid regular expression in board manufacturer fallback:" - << regExp.errorString() - << "pattern:" << fallbackObject[_jsonRegExpKey].toString(); - return false; - } - const BoardRegExpFallback_t boardFallback = { - regExp, - _boardClassStringToType(fallbackObject[_jsonBoardClassKey].toString()), - fallbackObject[_jsonAndroidOnlyKey].toBool(false) - }; - if (boardFallback.boardType == BoardTypeUnknown) { - qCWarning(QGCSerialPortInfoLog) << "Bad board class" << fallbackObject[_jsonBoardClassKey].toString(); - return false; - } - - _boardManufacturerFallbackList.append(boardFallback); - } - - _jsonDataValid = true; - - return true; -} - -QGCSerialPortInfo::BoardType_t QGCSerialPortInfo::_boardClassStringToType(const QString &boardClass) -{ - static const BoardClassString2BoardType_t rgBoardClass2BoardType[BoardTypeUnknown] = { - { _boardTypeToString(BoardTypePixhawk), BoardTypePixhawk }, - { _boardTypeToString(BoardTypeRTKGPS), BoardTypeRTKGPS }, - { _boardTypeToString(BoardTypeSiKRadio), BoardTypeSiKRadio }, - { _boardTypeToString(BoardTypeOpenPilot), BoardTypeOpenPilot }, - }; - - for (const BoardClassString2BoardType_t &board : rgBoardClass2BoardType) { - if (boardClass == board.classString) { - return board.boardType; - } - } - - return BoardTypeUnknown; -} - -bool QGCSerialPortInfo::getBoardInfo(QGCSerialPortInfo::BoardType_t &boardType, QString &name) const -{ - boardType = BoardTypeUnknown; - - if (!_loadJsonData()) { - return false; - } - - if (isNull()) { - return false; - } - - for (const BoardInfo_t &boardInfo : _boardInfoList) { - if ((vendorIdentifier() == boardInfo.vendorId) && ((productIdentifier() == boardInfo.productId) || (boardInfo.productId == 0))) { - boardType = boardInfo.boardType; - name = boardInfo.name; - return true; - } - } - - Q_ASSERT(boardType == BoardTypeUnknown); - - for (const BoardRegExpFallback_t &boardFallback : _boardDescriptionFallbackList) { - if (description().contains(boardFallback.regExp)) { -#ifndef Q_OS_ANDROID - if (boardFallback.androidOnly) { - continue; - } -#endif - boardType = boardFallback.boardType; - name = _boardTypeToString(boardType); - return true; - } - } - - for (const BoardRegExpFallback_t &boardFallback : _boardManufacturerFallbackList) { - if (manufacturer().contains(boardFallback.regExp)) { -#ifndef Q_OS_ANDROID - if (boardFallback.androidOnly) { - continue; - } -#endif - boardType = boardFallback.boardType; - name = _boardTypeToString(boardType); - return true; - } - } - - return false; -} - -QString QGCSerialPortInfo::_boardTypeToString(BoardType_t boardType) -{ - switch (boardType) { - case BoardTypePixhawk: - return QStringLiteral("Pixhawk"); - case BoardTypeSiKRadio: - return QStringLiteral("SiK Radio"); - case BoardTypeOpenPilot: - return QStringLiteral("OpenPilot"); - case BoardTypeRTKGPS: - return QStringLiteral("RTK GPS"); - case BoardTypeUnknown: - default: - return QStringLiteral("Unknown"); - } -} - -QList QGCSerialPortInfo::availablePorts() -{ - QList list; - - const QList availablePorts = QSerialPortInfo::availablePorts(); - for (const QSerialPortInfo &portInfo : availablePorts) { - if (isSystemPort(portInfo)) { - continue; - } - - const QGCSerialPortInfo *const qgcPortInfo = reinterpret_cast(&portInfo); - list << *qgcPortInfo; - } - - return list; -} - -bool QGCSerialPortInfo::isBootloader() const -{ - BoardType_t boardType; - QString name; - if (!getBoardInfo(boardType, name)) { - return false; - } - - return ((boardType == BoardTypePixhawk) && description().contains(QStringLiteral("BL"))); -} - -bool QGCSerialPortInfo::isBlackCube() const -{ - return description().contains(QStringLiteral("CubeBlack")); -} - -bool QGCSerialPortInfo::isSystemPort(const QSerialPortInfo &port) -{ -#ifdef Q_OS_MACOS - static const QList systemPortLocations = { - QStringLiteral("tty.MALS"), - QStringLiteral("tty.SOC"), - QStringLiteral("tty.Bluetooth-Incoming-Port"), - QStringLiteral("tty.usbserial"), - QStringLiteral("tty.usbmodem") - }; - for (const QString &systemPortLocation : systemPortLocations) { - if (port.systemLocation().contains(systemPortLocation)) { - return true; - } - } -#else - Q_UNUSED(port); -#endif - - // TODO: Add Linux (LTE modems, etc) and Windows as needed - - return false; -} - -bool QGCSerialPortInfo::canFlash() const -{ - BoardType_t boardType; - QString name; - if (!getBoardInfo(boardType, name)) { - return false; - } - - static const QList flashable = { - BoardTypePixhawk, - BoardTypeSiKRadio - }; - - return flashable.contains(boardType); -} diff --git a/src/Comms/QGCSerialPortInfo.h b/src/Comms/QGCSerialPortInfo.h deleted file mode 100644 index 2a006aa0ab25..000000000000 --- a/src/Comms/QGCSerialPortInfo.h +++ /dev/null @@ -1,90 +0,0 @@ -#pragma once - -#include -#include -#ifdef Q_OS_ANDROID - #include "qserialportinfo.h" -#else - #include -#endif - -class QGCSerialPortInfoTest; - -/// \brief QGC's version of Qt QSerialPortInfo. It provides additional information about board types -/// that QGC cares about. - -class QGCSerialPortInfo : public QSerialPortInfo -{ - friend class QGCSerialPortInfoTest; -public: - QGCSerialPortInfo(); - explicit QGCSerialPortInfo(const QSerialPort &port); - ~QGCSerialPortInfo(); - - enum BoardType_t { - BoardTypePixhawk = 0, - BoardTypeSiKRadio, - BoardTypeOpenPilot, - BoardTypeRTKGPS, - BoardTypeUnknown - }; - - bool getBoardInfo(BoardType_t &boardType, QString &name) const; - - /// @return true: we can flash this board type - bool canFlash() const; - - /// @return true: Board is currently in bootloader - bool isBootloader() const; - - /// @return true: Board is BlackCube - bool isBlackCube() const; - - /// Known operating system peripherals that are NEVER a peripheral that we should connect to. - /// @return true: Port is a system port and not an autopilot - static bool isSystemPort(const QSerialPortInfo &port); - - /// Override of QSerialPortInfo::availablePorts - static QList availablePorts(); - -private: - struct BoardClassString2BoardType_t { - const QString classString; - const BoardType_t boardType = BoardTypeUnknown; - }; - - static bool _loadJsonData(); - static BoardType_t _boardClassStringToType(const QString &boardClass); - static QString _boardTypeToString(BoardType_t boardType); - - static bool _jsonLoaded; - static bool _jsonDataValid; - - struct BoardInfo_t { - int vendorId; - int productId; - BoardType_t boardType; - QString name; - }; - static QList _boardInfoList; - - struct BoardRegExpFallback_t { - QRegularExpression regExp; - BoardType_t boardType; - bool androidOnly; - }; - static QList _boardDescriptionFallbackList; - static QList _boardManufacturerFallbackList; - - static constexpr const char *_jsonFileTypeValue = "USBBoardInfo"; - static constexpr const char *_jsonBoardInfoKey = "boardInfo"; - static constexpr const char *_jsonBoardDescriptionFallbackKey = "boardDescriptionFallback"; - static constexpr const char *_jsonBoardManufacturerFallbackKey = "boardManufacturerFallback"; - static constexpr const char *_jsonVendorIDKey = "vendorID"; - static constexpr const char *_jsonProductIDKey = "productID"; - static constexpr const char *_jsonBoardClassKey = "boardClass"; - static constexpr const char *_jsonNameKey = "name"; - static constexpr const char *_jsonRegExpKey = "regExp"; - static constexpr const char *_jsonAndroidOnlyKey = "androidOnly"; -}; -Q_DECLARE_METATYPE(QGCSerialPortInfo) diff --git a/src/Comms/Serial/CMakeLists.txt b/src/Comms/Serial/CMakeLists.txt new file mode 100644 index 000000000000..d6eca95fe7ae --- /dev/null +++ b/src/Comms/Serial/CMakeLists.txt @@ -0,0 +1,42 @@ +# ============================================================================ +# Serial Communication +# QSerialPort-backed link on host (HostSerialPort); on Android, makeSerialPort() routes USB-host +# locations to JNI-backed AndroidSerialPort and "/dev/tty*" paths to HostSerialPort/QSerialPort +# (used by devices like the Radiomaster AX12 that expose firmware UARTs via kernel TTYs). +# ============================================================================ + +if(QGC_NO_SERIAL_LINK) + target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE QGC_NO_SERIAL_LINK) + return() +endif() + +target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + HostSerialPort.cc + HostSerialPort.h + NmeaSerialDevice.cc + NmeaSerialDevice.h + QGCSerialPort.h + QGCSerialPortInfo.cc + QGCSerialPortInfo.h + QGCSerialPortTypes.h + SerialPlatform.cc + SerialPlatform.h + SerialLink.cc + SerialLink.h +) + +if(ANDROID) + target_sources(${CMAKE_PROJECT_NAME} PRIVATE QGCSerialPortInfo_android.cc) +else() + target_sources(${CMAKE_PROJECT_NAME} PRIVATE QGCSerialPortInfo_host.cc) +endif() + +qt_add_resources(${CMAKE_PROJECT_NAME} json_serial + PREFIX "/json" + FILES USBBoardInfo.json +) + +target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE Qt6::SerialPort) diff --git a/src/Comms/Serial/HostSerialPort.cc b/src/Comms/Serial/HostSerialPort.cc new file mode 100644 index 000000000000..ea9bdf3c9138 --- /dev/null +++ b/src/Comms/Serial/HostSerialPort.cc @@ -0,0 +1,207 @@ +#include "HostSerialPort.h" + +#ifndef Q_OS_ANDROID +#include + +#include "QGCSerialPortInfo.h" +#endif + +namespace { + +#ifndef Q_OS_ANDROID +// Slow poll — this is only a backstop for drivers that swallow the unplug hangup, so latency is fine. +constexpr int kPresencePollMs = 2000; +// Require consecutive misses — a single enumeration can transiently drop a live port during re-enumeration. +constexpr int kPresenceMissesForMissing = 2; +#endif + +QGCSerialPortError mapHostError(QSerialPort::SerialPortError e) +{ + switch (e) { + case QSerialPort::NoError: + return QGCSerialPortError::NoError; + case QSerialPort::DeviceNotFoundError: + case QSerialPort::OpenError: + return QGCSerialPortError::OpenFailed; + case QSerialPort::PermissionError: + return QGCSerialPortError::PermissionDenied; + case QSerialPort::ResourceError: + return QGCSerialPortError::ResourceUnavailable; + case QSerialPort::ReadError: + return QGCSerialPortError::Read; + case QSerialPort::WriteError: + return QGCSerialPortError::Write; + case QSerialPort::UnsupportedOperationError: + return QGCSerialPortError::UnsupportedOperation; + case QSerialPort::NotOpenError: + return QGCSerialPortError::NotOpen; + case QSerialPort::TimeoutError: + return QGCSerialPortError::Timeout; + default: + return QGCSerialPortError::Unknown; + } +} + +// QSerialPort::Parity has a gap at 1 (0,2,3,4,5); QGCParity is contiguous. +constexpr QSerialPort::Parity kQGCParityToHost[] = { + QSerialPort::NoParity, QSerialPort::OddParity, QSerialPort::EvenParity, + QSerialPort::MarkParity, QSerialPort::SpaceParity, +}; + +// Guard the numeric QGC↔QSerialPort coupling so reordering either enum is a compile error. +// (DataBits/StopBits are now aliases of QSerialPort's own enums — no remap needed.) +static_assert(static_cast(QGCFlowControl::None) == QSerialPort::NoFlowControl); +static_assert(static_cast(QGCFlowControl::HardwareRtsCts) == QSerialPort::HardwareControl); +static_assert(static_cast(QGCFlowControl::SoftwareXonXoff) == QSerialPort::SoftwareControl); + +QSerialPort::FlowControl qgcFlowControlToHost(QGCFlowControl fc) +{ + switch (fc) { + case QGCFlowControl::None: + return QSerialPort::NoFlowControl; + case QGCFlowControl::HardwareRtsCts: + return QSerialPort::HardwareControl; + case QGCFlowControl::SoftwareXonXoff: + return QSerialPort::SoftwareControl; + // QSerialPort has no DTR/DSR or inline XON/XOFF mode; degrade to no flow control on host. + case QGCFlowControl::DtrDsr: + case QGCFlowControl::XonXoffInline: + return QSerialPort::NoFlowControl; + } + return QSerialPort::NoFlowControl; +} + +} // namespace + +HostSerialPort::HostSerialPort(const QString& portName, QObject* parent) : QGCSerialPort(parent), _port(this) +{ + _port.setPortName(portName); + // We reconfigure on every open, so skip QSerialPort's restore-on-close termios round-trip. + _port.setSettingsRestoredOnClose(false); + connect(&_port, &QSerialPort::errorOccurred, this, + [this](QSerialPort::SerialPortError e) { _setError(mapHostError(e), _port.errorString()); }); + connect(&_port, &QIODevice::readyRead, this, &QIODevice::readyRead); + connect(&_port, &QIODevice::bytesWritten, this, &QIODevice::bytesWritten); + // Mirror an explicit _port->close() (e.g. error path) into this QIODevice's state — QSerialPort never self-closes on ResourceError, it only emits errorOccurred. + connect(&_port, &QIODevice::aboutToClose, this, [this]() { + if (QIODevice::isOpen()) { + QIODevice::close(); + } + }); +} + +QIODevice* HostSerialPort::openHostNmeaSource(const QString& name, qint32 baud, QObject* parent) +{ + auto* port = new HostSerialPort(name, parent); + SerialPortConfig cfg{}; + cfg.baud = baud; + if (!port->openConfigured(QIODevice::ReadOnly, cfg)) { + delete port; + return nullptr; + } + return port; +} + +bool HostSerialPort::open(QIODevice::OpenMode mode) +{ + _error = QGCSerialPortError::NoError; + if (!_port.open(mode)) { + _setErrorIfPortSilent(QGCSerialPortError::OpenFailed, tr("Failed to open serial port")); + return false; + } + _port.setReadBufferSize(kSerialRxBufferCapBytes); + QIODevice::open(mode); +#ifndef Q_OS_ANDROID + _presenceMissCount = 0; + _presenceTimer.start(kPresencePollMs, this); +#endif + return true; +} + +void HostSerialPort::close() +{ +#ifndef Q_OS_ANDROID + _presenceTimer.stop(); +#endif + _port.close(); + if (QIODevice::isOpen()) { + QIODevice::close(); + } +} + +#ifndef Q_OS_ANDROID +void HostSerialPort::timerEvent(QTimerEvent* event) +{ + if (event->timerId() == _presenceTimer.timerId()) { + _checkPresence(); + return; + } + QGCSerialPort::timerEvent(event); +} + +void HostSerialPort::_checkPresence() +{ + const QString name = _port.portName(); + for (const QGCSerialPortInfo& info : QGCSerialPortInfo::availablePorts()) { + if (info.portName() == name) { + _presenceMissCount = 0; + return; + } + } + if (++_presenceMissCount >= kPresenceMissesForMissing) { + _presenceTimer.stop(); + _setError(QGCSerialPortError::ResourceUnavailable, tr("Serial port is no longer available")); + } +} +#endif + +bool HostSerialPort::reconfigure(const SerialPortConfig& cfg) +{ + if (!cfg.isValid()) { + _setError(QGCSerialPortError::UnsupportedOperation, tr("Invalid serial configuration")); + return false; + } + // QSerialPort has no batched setter — five separate termios/SetCommState calls. + const bool ok1 = _port.setBaudRate(cfg.baud); + const bool ok2 = _port.setDataBits(cfg.dataBits); + const bool ok3 = _port.setStopBits(cfg.stopBits); + const bool ok4 = _port.setParity(kQGCParityToHost[static_cast(cfg.parity)]); + const bool ok5 = _port.setFlowControl(qgcFlowControlToHost(cfg.flowControl)); + if (ok1 && ok2 && ok3 && ok4 && ok5) { + return true; + } + + _setErrorIfPortSilent(QGCSerialPortError::UnsupportedOperation, tr("Failed to apply serial configuration")); + return false; +} + +void HostSerialPort::clearError() +{ + _error = QGCSerialPortError::NoError; + _port.clearError(); + setErrorString(tr("No error")); +} + +QGCSerialPortError HostSerialPort::error() const +{ + if (_error != QGCSerialPortError::NoError) { + return _error; + } + return mapHostError(_port.error()); +} + +void HostSerialPort::_setError(QGCSerialPortError error, const QString& errorString) +{ + _error = error; + if (!errorString.isEmpty()) { + setErrorString(errorString); + } + emit errorOccurred(error); +} + +void HostSerialPort::_setErrorIfPortSilent(QGCSerialPortError error, const QString& errorString) +{ + if (_port.error() == QSerialPort::NoError) { + _setError(error, errorString); + } +} diff --git a/src/Comms/Serial/HostSerialPort.h b/src/Comms/Serial/HostSerialPort.h new file mode 100644 index 000000000000..3f1bb9feaaa0 --- /dev/null +++ b/src/Comms/Serial/HostSerialPort.h @@ -0,0 +1,73 @@ +#pragma once + +// QGCSerialPort backed by QSerialPort (host ports + Android "/dev/tty*" direct-UART path). + +#include +#include + +#include "QGCSerialPort.h" + +class HostSerialPort final : public QGCSerialPort +{ + Q_OBJECT + +public: + explicit HostSerialPort(const QString& portName, QObject* parent = nullptr); + + // Returns a configured read-only port for a kernel TTY NMEA source, or nullptr on failure. + static QIODevice* openHostNmeaSource(const QString& name, qint32 baud, QObject* parent); + + void setPortName(const QString& name) override { _port.setPortName(name); } + + QString portName() const override { return _port.portName(); } + + bool open(QIODevice::OpenMode mode) override; + void close() override; + + qint64 bytesAvailable() const override { return _port.bytesAvailable(); } + + qint64 bytesToWrite() const override { return _port.bytesToWrite(); } + + bool waitForReadyRead(int msecs) override { return _port.waitForReadyRead(msecs); } + + bool waitForBytesWritten(int msecs) override { return _port.waitForBytesWritten(msecs); } + + bool reconfigure(const SerialPortConfig& cfg) override; + + bool setDataTerminalReady(bool on) override { return _port.setDataTerminalReady(on); } + + bool flush() override { return _port.flush(); } + + void setWriteBufferSize(qint64 size) override { _port.setWriteBufferSize(size); } + + qint64 writeBufferSize() const override { return _port.writeBufferSize(); } + + QGCSerialPortError error() const override; + + void clearError() override; + +protected: + qint64 readData(char* data, qint64 maxSize) override { return _port.read(data, maxSize); } + + qint64 writeData(const char* data, qint64 size) override { return _port.write(data, size); } + +#ifndef Q_OS_ANDROID + void timerEvent(QTimerEvent* event) override; +#endif + +private: +#ifndef Q_OS_ANDROID + // Backstop poll: QSerialPort doesn't reliably emit ResourceError on unplug for some adapters/macOS ttys, so while + // open we watch the enumerated port list and synthesize errorOccurred(ResourceUnavailable) when ours disappears. + // Android needs none of this — USB-host disconnects arrive event-driven via the JNI layer. + void _checkPresence(); + QBasicTimer _presenceTimer; + int _presenceMissCount = 0; +#endif + void _setError(QGCSerialPortError error, const QString& errorString); + // QSerialPort emits errorOccurred (→ _setError) synchronously on failure; only synthesize one if it stayed silent. + void _setErrorIfPortSilent(QGCSerialPortError error, const QString& errorString); + + QSerialPort _port; + QGCSerialPortError _error = QGCSerialPortError::NoError; +}; diff --git a/src/Comms/Serial/NmeaSerialDevice.cc b/src/Comms/Serial/NmeaSerialDevice.cc new file mode 100644 index 000000000000..d3b32eb58d18 --- /dev/null +++ b/src/Comms/Serial/NmeaSerialDevice.cc @@ -0,0 +1,176 @@ +#include "NmeaSerialDevice.h" + +#include +#include +#include +#include + +#include "QGCSerialPort.h" +#include "QGCSerialPortTypes.h" +#include "SerialPlatform.h" +#include "WorkerThread.h" + +namespace { +// Bounds the GUI-thread block in close() if the worker is wedged in a JNI/USB call; past it we detach, never terminate(). +constexpr int kReaderStopTimeoutMs = 2000; +} // namespace + +// Push-model device: the worker appends bytes straight into QIODevicePrivate's read buffer, so QIODevice's own read()/bytesAvailable()/canReadLine() work on primed data (QIOPipe pattern, matching AndroidSerialPort). +class NmeaSerialDevicePrivate : public QIODevicePrivate +{ + Q_DECLARE_PUBLIC(NmeaSerialDevice) + +public: + NmeaSerialDevicePrivate(QString portName, qint32 baud) : _portName(std::move(portName)), _baud(baud) {} + + const QString _portName; + const qint32 _baud; + NmeaSerialReader* _reader = nullptr; // owned by _workerThread + std::unique_ptr _workerThread; +}; + +NmeaSerialReader::NmeaSerialReader(QString portName, qint32 baud, QObject* parent) + : QObject(parent), _portName(std::move(portName)), _baud(baud) +{} + +NmeaSerialReader::~NmeaSerialReader() = default; + +void NmeaSerialReader::process() +{ + // Created here so the port's thread affinity is this worker, not the GUI thread (mirrors GPSProvider). + _serial.reset(SerialPlatform::makeSerialPort(_portName, nullptr)); + SerialPortConfig cfg{}; + cfg.baud = _baud; + if (!_serial || !_serial->openConfigured(QIODevice::ReadOnly, cfg)) { + _serial.reset(); + emit finished(); + return; + } + + (void) connect(_serial.get(), &QIODevice::readyRead, this, &NmeaSerialReader::_onReadyRead); + // Port self-close (USB unplug, ResourceError) after a successful open: tell the device the source is gone. + (void) connect(_serial.get(), &QIODevice::aboutToClose, this, &NmeaSerialReader::_onPortAboutToClose); + + // Drain anything that arrived between open() and the readyRead connect above. + _onReadyRead(); +} + +void NmeaSerialReader::_onReadyRead() +{ + if (!_serial) { + return; + } + const QByteArray chunk = _serial->readAll(); + if (!chunk.isEmpty()) { + emit dataReceived(chunk); + } +} + +void NmeaSerialReader::_onPortAboutToClose() +{ + if (_finished) { + return; + } + _finished = true; + emit sourceLost(); // the device closes itself; owner-driven cleanup follows via stop() + emit finished(); +} + +void NmeaSerialReader::stop() +{ + if (_finished) { + return; // already torn down via a port self-close, or a second explicit stop() + } + _finished = true; + if (_serial) { + // Drop our slots first so closing the port here doesn't re-enter as a sourceLost(). + disconnect(_serial.get(), nullptr, this, nullptr); + _serial->close(); + _serial.reset(); + } + emit finished(); +} + +NmeaSerialDevice::NmeaSerialDevice(QString portName, qint32 baud, QObject* parent) + : QIODevice(*new NmeaSerialDevicePrivate(std::move(portName), baud), parent) +{} + +NmeaSerialDevice::~NmeaSerialDevice() +{ + NmeaSerialDevice::close(); +} + +bool NmeaSerialDevice::open(OpenMode mode) +{ + Q_D(NmeaSerialDevice); + if (isOpen()) { + return false; + } + // Read-only NMEA source: require a read request and reject any write capability. + if (!(mode & QIODeviceBase::ReadOnly) || (mode & QIODeviceBase::WriteOnly)) { + return false; + } + + d->_reader = new NmeaSerialReader(d->_portName, d->_baud, nullptr); + d->_workerThread = std::make_unique(d->_reader, QStringLiteral("NmeaSerialReader")); + + (void) connect(d->_workerThread->thread(), &QThread::started, d->_reader, &NmeaSerialReader::process); + // Cross-thread (worker emits, this lives on the GUI thread) -> auto-queued. + (void) connect(d->_reader, &NmeaSerialReader::dataReceived, this, &NmeaSerialDevice::_appendFromReader); + // A failed open leaves the device open-but-idle (no auto-close); only a post-open source loss closes it. + // close() drops the ->this connections first so owner-initiated teardown doesn't re-enter. + (void) connect(d->_reader, &NmeaSerialReader::sourceLost, this, &NmeaSerialDevice::close, Qt::QueuedConnection); + (void) connect(d->_reader, &NmeaSerialReader::finished, d->_workerThread->thread(), &QThread::quit); + d->_workerThread->start(); + + return QIODevice::open(mode); +} + +void NmeaSerialDevice::close() +{ + Q_D(NmeaSerialDevice); + const bool wasOpen = isOpen(); + if (d->_workerThread) { + if (d->_reader) { + disconnect(d->_reader, nullptr, this, nullptr); + // Close the port + emit finished on the worker's own thread; the readyRead-driven loop unwinds there. + (void) QMetaObject::invokeMethod(d->_reader, "stop", Qt::QueuedConnection); + // Wait for the worker-driven quit (finished()->quit(), see open()) so the port is closed on the worker + // thread before stopAndWait()'s owner-thread quit() can race it shut. + (void) d->_workerThread->thread()->wait(QDeadlineTimer(kReaderStopTimeoutMs)); + } + // Already quiescent on the normal path; bounded detach only if the USB stack wedged the worker. + (void) d->_workerThread->stopAndWait(0); + d->_reader = nullptr; + d->_workerThread.reset(); + } + d->buffer.clear(); + if (wasOpen) { + QIODevice::close(); // emits aboutToClose(); the consumer learns the source is gone + } +} + +void NmeaSerialDevice::_appendFromReader(const QByteArray& chunk) +{ + Q_D(NmeaSerialDevice); + if (!isOpen()) { + return; // a chunk queued before close() drained the event loop + } + d->buffer.append(chunk); + emit readyRead(); +} + +qint64 NmeaSerialDevice::readData(char* data, qint64 maxlen) +{ + // Async-fed: bytes are appended directly into d->buffer, which QIODevice drains before calling here. + Q_UNUSED(data); + Q_UNUSED(maxlen); + return 0; // 0 = "no data now, not EOF" for a sequential device +} + +qint64 NmeaSerialDevice::writeData(const char* data, qint64 len) +{ + Q_UNUSED(data); + Q_UNUSED(len); + return -1; +} diff --git a/src/Comms/Serial/NmeaSerialDevice.h b/src/Comms/Serial/NmeaSerialDevice.h new file mode 100644 index 000000000000..fbf349eded35 --- /dev/null +++ b/src/Comms/Serial/NmeaSerialDevice.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +class QGCSerialPort; + +// Reads a serial NMEA GPS on a worker thread, exposed as a sequential read-only QIODevice that QNmeaPositionInfoSource drives on the GUI thread. +// Needed on Android, whose USB-serial backend refuses GUI-thread open() while QtPositioning opens its source there (mirrors UdpIODevice for NMEA-over-UDP). +// Worker moved onto a QThread (not a QThread subclass, per the project rule); reads run off the thread's event loop via readyRead, and stop() tears it down from the owner thread — no polling. +class NmeaSerialReader : public QObject +{ + Q_OBJECT + +public: + NmeaSerialReader(QString portName, qint32 baud, QObject *parent = nullptr); + ~NmeaSerialReader() override; + +public slots: + void process(); // opens the port then returns; the thread's event loop drives reads via readyRead + void stop(); // closes the port and emits finished(); invoke via a queued connection from the owner thread + +signals: + void dataReceived(const QByteArray &chunk); + void finished(); // worker exited (open failure or stop) — quit the thread; does not close the device + void sourceLost(); // port closed under us after a successful open (USB unplug) — close the device + +private slots: + void _onReadyRead(); + void _onPortAboutToClose(); + +private: + const QString _portName; + const qint32 _baud; + std::unique_ptr _serial; + bool _finished = false; +}; + +class NmeaSerialDevicePrivate; + +class NmeaSerialDevice : public QIODevice +{ + Q_OBJECT + Q_DECLARE_PRIVATE(NmeaSerialDevice) + +public: + NmeaSerialDevice(QString portName, qint32 baud, QObject *parent = nullptr); + ~NmeaSerialDevice() override; + + bool isSequential() const override { return true; } + bool open(OpenMode mode) override; + void close() override; + +protected: + qint64 readData(char *data, qint64 maxlen) override; + qint64 writeData(const char *data, qint64 len) override; + +private slots: + void _appendFromReader(const QByteArray &chunk); +}; diff --git a/src/Comms/Serial/QGCSerialPort.h b/src/Comms/Serial/QGCSerialPort.h new file mode 100644 index 000000000000..c8da2b7cbd9d --- /dev/null +++ b/src/Comms/Serial/QGCSerialPort.h @@ -0,0 +1,53 @@ +#pragma once + +// Unified serial interface; makeSerialPort() picks impl per platform (no caller #ifdef needed). + +#include +#include +#include + +#include "QGCSerialPortTypes.h" + +class QIODevicePrivate; + +class QGCSerialPort : public QIODevice +{ + Q_OBJECT + +public: + explicit QGCSerialPort(QObject* parent = nullptr) : QIODevice(parent) {} + + // Host: a QSerialPort portName. Android: a systemLocation() (USB) or "/dev/tty*" path. + virtual void setPortName(const QString& name) = 0; + virtual QString portName() const = 0; + + // Atomic wire-parameter apply (one JNI hop on Android USB; wraps QSerialPort termios calls on host). + virtual bool reconfigure(const SerialPortConfig& cfg) = 0; + + // Rolls back to closed on either failure; false always leaves device closed. + bool openConfigured(QIODevice::OpenMode mode, const SerialPortConfig& cfg) + { + if (!open(mode)) { + return false; + } + if (!reconfigure(cfg)) { + close(); + return false; + } + return true; + } + + virtual bool setDataTerminalReady(bool on) = 0; + virtual bool flush() = 0; + virtual void setWriteBufferSize(qint64 size) = 0; + virtual qint64 writeBufferSize() const = 0; + virtual QGCSerialPortError error() const = 0; + virtual void clearError() = 0; + +signals: + void errorOccurred(QGCSerialPortError error); + +protected: + // d-pointer ctor for subclasses with a custom QIODevicePrivate (AndroidSerialPort); keeps the type incomplete here. + QGCSerialPort(QIODevicePrivate& dd, QObject* parent) : QIODevice(dd, parent) {} +}; diff --git a/src/Comms/Serial/QGCSerialPortInfo.cc b/src/Comms/Serial/QGCSerialPortInfo.cc new file mode 100644 index 000000000000..c1e33571c7f8 --- /dev/null +++ b/src/Comms/Serial/QGCSerialPortInfo.cc @@ -0,0 +1,383 @@ +#include "QGCSerialPortInfo.h" + +#include "JsonParsing.h" +#include "QGCLoggingCategory.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include + +QGC_LOGGING_CATEGORY(QGCSerialPortInfoLog, "Comms.QGCSerialPortInfo") + +// Curated baud list; QSerialPortInfo::standardBaudRates() caps at 256000 on Windows and drops high rates on Android. +QList QGCSerialPortInfo::standardBaudRates() +{ + static const QList kRates = { + 50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, + 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 500000, + 576000, 921600, 1000000, 1152000, 1500000, 2000000, 2500000, 3000000, 3500000, 4000000, + }; + return kRates; +} + +QStringList QGCSerialPortInfo::supportedBaudRateStrings(const QString &portName) +{ + static const QSet kDefaultSupportedBaudRates = { +#ifdef Q_OS_UNIX + 50, 75, +#endif + 110, +#ifdef Q_OS_UNIX + 150, 200, 134, +#endif + 300, 600, 1200, +#ifdef Q_OS_UNIX + 1800, +#endif + 2400, 4800, 9600, +#ifdef Q_OS_WIN + 14400, +#endif + 19200, 38400, +#ifdef Q_OS_WIN + 56000, +#endif + 57600, 115200, +#ifdef Q_OS_WIN + 128000, +#endif + 230400, +#ifdef Q_OS_WIN + 256000, +#endif + 460800, 500000, +#ifdef Q_OS_LINUX + 576000, +#endif + 921600, + }; + + QList activeSupportedBaudRates = portSpecificBaudRates(portName); + const bool usePortSpecificRates = !activeSupportedBaudRates.isEmpty(); + if (!usePortSpecificRates) { + activeSupportedBaudRates = standardBaudRates(); + } + + QList mergedBaudRateList; + if (usePortSpecificRates) { + mergedBaudRateList = activeSupportedBaudRates; + } else { + mergedBaudRateList = QList(kDefaultSupportedBaudRates.constBegin(), kDefaultSupportedBaudRates.constEnd()); + mergedBaudRateList.append(activeSupportedBaudRates); + } + + std::ranges::sort(mergedBaudRateList); + const auto duplicates = std::ranges::unique(mergedBaudRateList); + mergedBaudRateList.erase(duplicates.begin(), duplicates.end()); + + QStringList supportBaudRateStrings; + supportBaudRateStrings.reserve(mergedBaudRateList.size()); + for (const qint32 rate : std::as_const(mergedBaudRateList)) { + supportBaudRateStrings.append(QString::number(rate)); + } + + return supportBaudRateStrings; +} + +QGCSerialPortInfo::QGCSerialPortInfo() = default; +QGCSerialPortInfo::QGCSerialPortInfo(const QGCSerialPortInfo &) = default; +QGCSerialPortInfo::QGCSerialPortInfo(QGCSerialPortInfo &&) noexcept = default; +QGCSerialPortInfo &QGCSerialPortInfo::operator=(const QGCSerialPortInfo &) = default; +QGCSerialPortInfo &QGCSerialPortInfo::operator=(QGCSerialPortInfo &&) noexcept = default; +QGCSerialPortInfo::~QGCSerialPortInfo() = default; + +QGCSerialPortInfo::QGCSerialPortInfo(Data data) + : _data(std::move(data)) +{ +} + +QGCSerialPortInfo::QGCSerialPortInfo(const QString &name) +{ + const QList ports = availablePorts(); + for (const QGCSerialPortInfo &info : ports) { + if (info.portName() == name || info.systemLocation() == name) { + _data = info._data; + return; + } + } +} + +QGCSerialPortInfo::QGCSerialPortInfo(const QSerialPortInfo &info) +{ + _data.portName = info.portName(); + _data.systemLocation = info.systemLocation(); + _data.description = info.description(); + _data.manufacturer = info.manufacturer(); + _data.serialNumber = info.serialNumber(); + _data.vendorIdentifier = info.vendorIdentifier(); + _data.productIdentifier = info.productIdentifier(); + _data.hasVendorIdentifier = info.hasVendorIdentifier(); + _data.hasProductIdentifier= info.hasProductIdentifier(); +} + +const QGCSerialPortInfo::BoardDatabase &QGCSerialPortInfo::_boardDatabase() +{ + static const BoardDatabase db = _loadBoardDatabase(); + return db; +} + +QGCSerialPortInfo::BoardDatabase QGCSerialPortInfo::_loadBoardDatabase() +{ + BoardDatabase db; + + QString errorString; + int version; + const QJsonObject json = JsonParsing::openInternalQGCJsonFile(QStringLiteral(":/json/USBBoardInfo.json"), QString(_jsonFileTypeValue), 1, 1, version, errorString); + if (!errorString.isEmpty()) { + qCWarning(QGCSerialPortInfoLog) << "Internal Error:" << errorString; + return db; + } + + static const QList rootKeyInfoList = { + { _jsonBoardInfoKey, QJsonValue::Array, true }, + { _jsonBoardDescriptionFallbackKey, QJsonValue::Array, true }, + { _jsonBoardManufacturerFallbackKey, QJsonValue::Array, true }, + }; + if (!JsonParsing::validateKeys(json, rootKeyInfoList, errorString)) { + qCWarning(QGCSerialPortInfoLog) << errorString; + return db; + } + + static const QList boardKeyInfoList = { + { _jsonVendorIDKey, QJsonValue::Double, true }, + { _jsonProductIDKey, QJsonValue::Double, true }, + { _jsonBoardClassKey, QJsonValue::String, true }, + { _jsonNameKey, QJsonValue::String, true }, + }; + const QJsonArray rgBoardInfo = json[_jsonBoardInfoKey].toArray(); + for (const QJsonValue &jsonValue : rgBoardInfo) { + if (!jsonValue.isObject()) { + qCWarning(QGCSerialPortInfoLog) << "Entry in boardInfo array is not object"; + return db; + } + + const QJsonObject boardObject = jsonValue.toObject(); + if (!JsonParsing::validateKeys(boardObject, boardKeyInfoList, errorString)) { + qCWarning(QGCSerialPortInfoLog) << errorString; + return db; + } + + const BoardInfo_t boardInfo = { + boardObject[_jsonVendorIDKey].toInt(), + boardObject[_jsonProductIDKey].toInt(), + _boardClassStringToType(boardObject[_jsonBoardClassKey].toString()), + boardObject[_jsonNameKey].toString() + }; + if (boardInfo.boardType == BoardTypeUnknown) { + qCWarning(QGCSerialPortInfoLog) << "Bad board class" << boardObject[_jsonBoardClassKey].toString(); + return db; + } + + db.boardInfo.append(boardInfo); + } + + static const QList fallbackKeyInfoList = { + { _jsonRegExpKey, QJsonValue::String, true }, + { _jsonBoardClassKey, QJsonValue::String, true }, + }; + + const auto parseFallback = [&](const char *jsonKey, const char *label, + QList &out) -> bool { + const QJsonArray arr = json[jsonKey].toArray(); + for (const QJsonValue &jsonValue : arr) { + if (!jsonValue.isObject()) { + qCWarning(QGCSerialPortInfoLog) << "Entry in boardFallback array is not object"; + return false; + } + const QJsonObject obj = jsonValue.toObject(); + if (!JsonParsing::validateKeys(obj, fallbackKeyInfoList, errorString)) { + qCWarning(QGCSerialPortInfoLog) << errorString; + return false; + } + const QString pattern = obj[_jsonRegExpKey].toString(); + const QRegularExpression regExp(pattern, QRegularExpression::CaseInsensitiveOption); + if (!regExp.isValid()) { + qCWarning(QGCSerialPortInfoLog) << "Invalid regular expression in board" << label << "fallback:" + << regExp.errorString() << "pattern:" << pattern; + return false; + } + const BoardRegExpFallback_t fb = { + regExp, + _boardClassStringToType(obj[_jsonBoardClassKey].toString()) + }; + if (fb.boardType == BoardTypeUnknown) { + qCWarning(QGCSerialPortInfoLog) << "Bad board class" << obj[_jsonBoardClassKey].toString(); + return false; + } + out.append(fb); + } + return true; + }; + + if (!parseFallback(_jsonBoardDescriptionFallbackKey, "description", db.descriptionFallback) || + !parseFallback(_jsonBoardManufacturerFallbackKey, "manufacturer", db.manufacturerFallback)) { + return db; + } + + for (const BoardRegExpFallback_t &fb : _additionalDescriptionFallbacks()) { + db.descriptionFallback.append(fb); + } + + db.valid = true; + + return db; +} + +QGCSerialPortInfo::BoardType_t QGCSerialPortInfo::_boardClassStringToType(const QString &boardClass) +{ + static const BoardClassString2BoardType_t rgBoardClass2BoardType[BoardTypeUnknown] = { + { _boardTypeToString(BoardTypePixhawk), BoardTypePixhawk }, + { _boardTypeToString(BoardTypeRTKGPS), BoardTypeRTKGPS }, + { _boardTypeToString(BoardTypeSiKRadio), BoardTypeSiKRadio }, + { _boardTypeToString(BoardTypeOpenPilot), BoardTypeOpenPilot }, + }; + + for (const BoardClassString2BoardType_t &board : rgBoardClass2BoardType) { + if (boardClass == board.classString) { + return board.boardType; + } + } + + return BoardTypeUnknown; +} + +bool QGCSerialPortInfo::getBoardInfo(QGCSerialPortInfo::BoardType_t &boardType, QString &name) const +{ + boardType = BoardTypeUnknown; + + const BoardDatabase &db = _boardDatabase(); + if (!db.valid) { + return false; + } + + if (isNull()) { + return false; + } + + for (const BoardInfo_t &boardInfo : db.boardInfo) { + if ((vendorIdentifier() == boardInfo.vendorId) && ((productIdentifier() == boardInfo.productId) || (boardInfo.productId == 0))) { + boardType = boardInfo.boardType; + name = boardInfo.name; + return true; + } + } + + const auto matchFallback = [&](const QList &list, const QString &field) { + for (const BoardRegExpFallback_t &fb : list) { + if (field.contains(fb.regExp)) { + boardType = fb.boardType; + name = _boardTypeToString(boardType); + return true; + } + } + return false; + }; + + return matchFallback(db.descriptionFallback, description()) + || matchFallback(db.manufacturerFallback, manufacturer()); +} + +QString QGCSerialPortInfo::_boardTypeToString(BoardType_t boardType) +{ + switch (boardType) { + case BoardTypePixhawk: + return QStringLiteral("Pixhawk"); + case BoardTypeSiKRadio: + return QStringLiteral("SiK Radio"); + case BoardTypeOpenPilot: + return QStringLiteral("OpenPilot"); + case BoardTypeRTKGPS: + return QStringLiteral("RTK GPS"); + case BoardTypeUnknown: + default: + return QStringLiteral("Unknown"); + } +} + +QList QGCSerialPortInfo::availablePorts() +{ + QList list = _nativeDevices(); + QSet seenLocations; + for (const QGCSerialPortInfo &portInfo : list) { + seenLocations.insert(portInfo.systemLocation()); + } + + // Android: acceptQSerialPortInfo() keeps only kernel TTYs (/dev/tty*); USB-host rows come from nativeDevices(). + for (const QSerialPortInfo &portInfo : QSerialPortInfo::availablePorts()) { + if (!_acceptQSerialPortInfo(portInfo)) { + continue; + } + QGCSerialPortInfo qgcPortInfo(portInfo); + if (isSystemPort(qgcPortInfo) || seenLocations.contains(qgcPortInfo.systemLocation())) { + continue; + } + list << qgcPortInfo; + } + + return list; +} + +QString QGCSerialPortInfo::displayNameForLocation(const QString &systemLocation) +{ + for (const QGCSerialPortInfo &portInfo : availablePorts()) { + if (portInfo.systemLocation() == systemLocation) { + return portInfo.portName(); + } + } + + return QString(); +} + +bool QGCSerialPortInfo::isBootloader() const +{ + BoardType_t boardType; + QString name; + if (!getBoardInfo(boardType, name)) { + return false; + } + + return ((boardType == BoardTypePixhawk) && description().contains(QStringLiteral("BL"))); +} + +bool QGCSerialPortInfo::isBlackCube() const +{ + return description().contains(QStringLiteral("CubeBlack")); +} + +bool QGCSerialPortInfo::isSystemPort(const QGCSerialPortInfo &port) +{ + return _platformIsSystemPort(port); +} + +bool QGCSerialPortInfo::canFlash() const +{ + BoardType_t boardType; + QString name; + if (!getBoardInfo(boardType, name)) { + return false; + } + + static const QList flashable = { + BoardTypePixhawk, + BoardTypeSiKRadio + }; + + return flashable.contains(boardType); +} diff --git a/src/Comms/Serial/QGCSerialPortInfo.h b/src/Comms/Serial/QGCSerialPortInfo.h new file mode 100644 index 000000000000..fcdc89f1b653 --- /dev/null +++ b/src/Comms/Serial/QGCSerialPortInfo.h @@ -0,0 +1,149 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +QT_BEGIN_NAMESPACE +class QSerialPortInfo; +QT_END_NAMESPACE + +class QGCSerialPortInfoTest; + +/// Value-type port descriptor with board-classifier. Standalone (not QSerialPortInfo) because QSerialPortInfo's +/// populating ctor is friend-locked to Qt's internal enumerators — Android JNI rows can't use it. +class QGCSerialPortInfo +{ + friend class QGCSerialPortInfoTest; +public: + struct Data { + QString portName; + QString systemLocation; + QString description; + QString manufacturer; + QString serialNumber; + quint16 vendorIdentifier = 0; + quint16 productIdentifier = 0; + bool hasVendorIdentifier = false; + bool hasProductIdentifier = false; + QList supportedBaudRates; + }; + + QGCSerialPortInfo(); + QGCSerialPortInfo(const QGCSerialPortInfo &other); + QGCSerialPortInfo(QGCSerialPortInfo &&other) noexcept; + /// Expensive: triggers a full availablePorts() enumeration to match by name. + explicit QGCSerialPortInfo(const QString &name); + explicit QGCSerialPortInfo(Data data); + explicit QGCSerialPortInfo(const QSerialPortInfo &info); + ~QGCSerialPortInfo(); + + QGCSerialPortInfo &operator=(const QGCSerialPortInfo &other); + QGCSerialPortInfo &operator=(QGCSerialPortInfo &&other) noexcept; + + enum BoardType_t { + BoardTypePixhawk = 0, + BoardTypeSiKRadio, + BoardTypeOpenPilot, + BoardTypeRTKGPS, + BoardTypeUnknown + }; + + struct BoardRegExpFallback_t { + QRegularExpression regExp; + BoardType_t boardType; + }; + + QString portName() const { return _data.portName; } + QString systemLocation() const { return _data.systemLocation; } + QString description() const { return _data.description; } + QString manufacturer() const { return _data.manufacturer; } + QString serialNumber() const { return _data.serialNumber; } + quint16 vendorIdentifier() const { return _data.vendorIdentifier; } + quint16 productIdentifier()const { return _data.productIdentifier; } + bool hasVendorIdentifier() const { return _data.hasVendorIdentifier; } + bool hasProductIdentifier() const { return _data.hasProductIdentifier; } + QList supportedBaudRates() const { return _data.supportedBaudRates; } + bool isNull() const { return _data.portName.isEmpty() && _data.systemLocation.isEmpty(); } + + bool getBoardInfo(BoardType_t &boardType, QString &name) const; + + bool canFlash() const; + + bool isBootloader() const; + + bool isBlackCube() const; + + /// Known OS peripherals that are never an autopilot — never auto-connect to these. + static bool isSystemPort(const QGCSerialPortInfo &port); + + static QList availablePorts(); + + /// Port-specific baud list when the platform enumerator knows it (Android USB-host); empty so the + /// caller falls back to the standard set on host. Per-platform TU (QGCSerialPortInfo_host/android.cc). + static QList portSpecificBaudRates(const QString &portName); + + /// Human-readable name for a systemLocation() from the current enumeration; empty if the device isn't present. + static QString displayNameForLocation(const QString &systemLocation); + + /// Curated baud-rate list for UI dropdowns; QSerialPortInfo::standardBaudRates() caps at 256000 on Windows. + static QList standardBaudRates(); + + /// UI baud-rate strings: platform-specific rates when the enumerator knows them (Android USB-host), + /// else the curated default set merged with standardBaudRates(). Sorted ascending, unique. + static QStringList supportedBaudRateStrings(const QString &portName = QString()); + +private: + struct BoardClassString2BoardType_t { + const QString classString; + const BoardType_t boardType = BoardTypeUnknown; + }; + + struct BoardInfo_t { + int vendorId; + int productId; + BoardType_t boardType; + QString name; + }; + + struct BoardDatabase { + QList boardInfo; + QList descriptionFallback; + QList manufacturerFallback; + bool valid = false; + }; + + // Platform enumeration glue, defined in the per-platform TU (QGCSerialPortInfo_host/android.cc) so this + // class stays #ifdef-free. + /// Devices enumerated natively (Android: JNI USB results); host returns empty (uses QSerialPortInfo). + static QList _nativeDevices(); + /// Whether a QSerialPortInfo row joins the visible list; Android keeps only "/dev/tty*". + static bool _acceptQSerialPortInfo(const QSerialPortInfo &info); + /// Platform-specific system-port test backing isSystemPort(). + static bool _platformIsSystemPort(const QGCSerialPortInfo &port); + /// Extra description-regex fallbacks on top of USBBoardInfo.json (Android: SiK USB-UART adapters). + static QList _additionalDescriptionFallbacks(); + + // Parsed once on first access (magic-static is the thread-safe-once guard — no manual mutex). + static const BoardDatabase &_boardDatabase(); + static BoardDatabase _loadBoardDatabase(); + static BoardType_t _boardClassStringToType(const QString &boardClass); + static QString _boardTypeToString(BoardType_t boardType); + + static constexpr const char *_jsonFileTypeValue = "USBBoardInfo"; + static constexpr const char *_jsonBoardInfoKey = "boardInfo"; + static constexpr const char *_jsonBoardDescriptionFallbackKey = "boardDescriptionFallback"; + static constexpr const char *_jsonBoardManufacturerFallbackKey = "boardManufacturerFallback"; + static constexpr const char *_jsonVendorIDKey = "vendorID"; + static constexpr const char *_jsonProductIDKey = "productID"; + static constexpr const char *_jsonBoardClassKey = "boardClass"; + static constexpr const char *_jsonNameKey = "name"; + static constexpr const char *_jsonRegExpKey = "regExp"; + + Data _data; +}; +Q_DECLARE_METATYPE(QGCSerialPortInfo) diff --git a/src/Comms/Serial/QGCSerialPortInfo_android.cc b/src/Comms/Serial/QGCSerialPortInfo_android.cc new file mode 100644 index 000000000000..e301827fbbe3 --- /dev/null +++ b/src/Comms/Serial/QGCSerialPortInfo_android.cc @@ -0,0 +1,101 @@ +#include + +#include + +#include "AndroidSerialPort.h" +#include "HostSerialPort.h" +#include "NmeaSerialDevice.h" +#include "QGCSerialPort.h" +#include "QGCSerialPortInfo.h" +#include "QGCSerialPortTypes.h" +#include "SerialPlatform.h" + +namespace SerialPlatform { + +// makeSerialPort routing: "/dev/tty*" kernel TTYs use QSerialPort (AX12, Rock5 UARTs); USB-host "/dev/bus/usb/..." → AndroidSerialPort. Narrow "/dev/tty" prefix keeps USB paths off this branch. +QGCSerialPort* makeSerialPort(const QString& name, QObject* parent) +{ + if (const auto& factory = portFactoryOverride()) { + return factory(name, parent); + } + if (name.startsWith(kDirectUartPathPrefix)) { + return new HostSerialPort(name, parent); + } + return new AndroidSerialPort(name, parent); +} + +QIODevice *makeNmeaSerialSource(const QString &systemLocation, qint32 baud, QObject *parent) +{ + // "/dev/tty*" kernel TTYs work as a plain GUI-thread QIODevice; USB-host paths must run off-thread. + if (systemLocation.startsWith(kDirectUartPathPrefix)) { + return HostSerialPort::openHostNmeaSource(systemLocation, baud, parent); + } + return new NmeaSerialDevice(systemLocation, baud, parent); +} + +} // namespace SerialPlatform + +QList QGCSerialPortInfo::portSpecificBaudRates(const QString &portName) +{ + // QGCSerialPortInfo carries per-port supportedBaudRates populated at enumeration; avoids a second JNI roundtrip through a dedicated AndroidSerialPort entry point. + const QString trimmedName = portName.trimmed(); + if (trimmedName.isEmpty()) { + return {}; + } + for (const QGCSerialPortInfo &info : QGCSerialPortInfo::availablePorts()) { + if ((info.systemLocation() == trimmedName) || (info.portName() == trimmedName)) { + return info.supportedBaudRates(); + } + } + return {}; +} + +namespace { +QMutex s_nativeDevicesMutex; +bool s_nativeDevicesValid = false; +QList s_nativeDevicesCache; +} // namespace + +// JNI USB enumeration is expensive; cache it and invalidate on devicesChanged() (attach/detach/permission-grant). +QList QGCSerialPortInfo::_nativeDevices() +{ + QMutexLocker locker(&s_nativeDevicesMutex); + + static bool s_invalidationConnected = false; + if (!s_invalidationConnected) { + s_invalidationConnected = true; + SerialPlatform::SerialDevicesNotifier *const notifier = SerialPlatform::SerialDevicesNotifier::instance(); + (void) QObject::connect(notifier, &SerialPlatform::SerialDevicesNotifier::devicesChanged, notifier, []() { + QMutexLocker invalidationLocker(&s_nativeDevicesMutex); + s_nativeDevicesValid = false; + }, Qt::QueuedConnection); + } + + if (!s_nativeDevicesValid) { + s_nativeDevicesCache = AndroidSerialPort::availableDevices(); + s_nativeDevicesValid = true; + } + + return s_nativeDevicesCache; +} + +bool QGCSerialPortInfo::_acceptQSerialPortInfo(const QSerialPortInfo& info) +{ + // Keep only kernel TTYs; USB-host paths are already delivered by _nativeDevices(). + return info.systemLocation().startsWith(kDirectUartPathPrefix); +} + +bool QGCSerialPortInfo::_platformIsSystemPort(const QGCSerialPortInfo &) +{ + // Android USB-host and direct-UART paths are real devices, never OS peripherals. + return false; +} + +QList QGCSerialPortInfo::_additionalDescriptionFallbacks() +{ + const QRegularExpression usbUartRegExp(QStringLiteral("USB UART$"), QRegularExpression::CaseInsensitiveOption); + if (!usbUartRegExp.isValid()) { + return {}; + } + return {{usbUartRegExp, QGCSerialPortInfo::BoardTypeSiKRadio}}; +} diff --git a/src/Comms/Serial/QGCSerialPortInfo_host.cc b/src/Comms/Serial/QGCSerialPortInfo_host.cc new file mode 100644 index 000000000000..a9ff33eb11f6 --- /dev/null +++ b/src/Comms/Serial/QGCSerialPortInfo_host.cc @@ -0,0 +1,79 @@ +#include "QGCSerialPortInfo.h" +#include "SerialPlatform.h" +#include "QGCSerialPort.h" +#include "HostSerialPort.h" + +#include +#include + +namespace SerialPlatform { + +QGCSerialPort *makeSerialPort(const QString &name, QObject *parent) +{ + if (const auto &factory = portFactoryOverride()) { + return factory(name, parent); + } + return new HostSerialPort(name, parent); +} + +QIODevice *makeNmeaSerialSource(const QString &systemLocation, qint32 baud, QObject *parent) +{ + return HostSerialPort::openHostNmeaSource(systemLocation, baud, parent); +} + +} // namespace SerialPlatform + +QList QGCSerialPortInfo::portSpecificBaudRates(const QString &) +{ + return {}; +} + +QList QGCSerialPortInfo::_nativeDevices() +{ + return {}; +} + +bool QGCSerialPortInfo::_acceptQSerialPortInfo(const QSerialPortInfo &) +{ + return true; +} + +QList QGCSerialPortInfo::_additionalDescriptionFallbacks() +{ + return {}; +} + +bool QGCSerialPortInfo::_platformIsSystemPort(const QGCSerialPortInfo &port) +{ +#ifdef Q_OS_MACOS + static const QList systemPortLocations = { + QStringLiteral("tty.MALS"), + QStringLiteral("tty.SOC"), + QStringLiteral("tty.Bluetooth-Incoming-Port"), + QStringLiteral("tty.usbserial"), + QStringLiteral("tty.usbmodem") + }; + for (const QString &systemPortLocation : systemPortLocations) { + if (port.systemLocation().contains(systemPortLocation)) { + return true; + } + } +#elif defined(Q_OS_LINUX) + const QString &portName = port.portName(); + static const QRegularExpression ttySRegExp(QStringLiteral("^ttyS\\d+$")); + static const QRegularExpression rfcommRegExp(QStringLiteral("^rfcomm\\d*$")); + static const QRegularExpression ttyAcmRegExp(QStringLiteral("^ttyACM\\d+$")); + + if (ttySRegExp.match(portName).hasMatch() || rfcommRegExp.match(portName).hasMatch()) { + return true; + } + + if (ttyAcmRegExp.match(portName).hasMatch()) { + return !(port.hasVendorIdentifier() && port.hasProductIdentifier()); + } +#else + Q_UNUSED(port); +#endif + + return false; +} diff --git a/src/Comms/Serial/QGCSerialPortTypes.h b/src/Comms/Serial/QGCSerialPortTypes.h new file mode 100644 index 000000000000..3ee6580269e5 --- /dev/null +++ b/src/Comms/Serial/QGCSerialPortTypes.h @@ -0,0 +1,90 @@ +#pragma once + +// Platform-neutral enums shared by QGCSerialPort and its HostSerialPort / AndroidSerialPort impls. + +#include +#include +#include +#include +#include + +// makeSerialPort() routing prefixes: "/dev/tty*" → kernel TTY via QSerialPort; else (Android) → AndroidSerialPort. +inline constexpr QLatin1StringView kDirectUartPathPrefix{"/dev/tty"}; +inline constexpr QLatin1StringView kDevPrefix{"/dev/"}; + +// RX cap: HostSerialPort pauses at this fill (lossless); AndroidSerialPort drops over-cap (lossy — no pause). +inline constexpr qint64 kSerialRxBufferCapBytes = 512 * 1024; + +// TX cap: write() returns 0 at fill; SerialLink blocks on waitForBytesWritten until a chunk drains. +inline constexpr qint64 kSerialWriteBufferCapBytes = 2 * 1024 * 1024; + +namespace QGCSerial { +Q_NAMESPACE + +enum class QGCSerialPortError +{ + NoError, + NotOpen, + OpenFailed, + PermissionDenied, + ResourceUnavailable, + Read, + Write, + Timeout, + UnsupportedOperation, + Unknown, +}; +Q_ENUM_NS(QGCSerialPortError) + +// DataBits/StopBits are value-identical to QSerialPort's and cross the JNI wire by value; alias rather than +// duplicate. Parity is NOT aliased: QGCParity is contiguous (Odd=1,Even=2,Mark=3,Space=4) and its ordinals are +// the Android JNI wire values, whereas QSerialPort::Parity is gapped (Even=2,Odd=3,Space=4,Mark=5). +enum class QGCParity : uint8_t +{ + None = 0, + Odd = 1, + Even = 2, + Mark = 3, + Space = 4 +}; +Q_ENUM_NS(QGCParity) + +enum class QGCFlowControl : uint8_t +{ + None = 0, + HardwareRtsCts = 1, + SoftwareXonXoff = 2, + DtrDsr = 3, + XonXoffInline = 4, +}; +Q_ENUM_NS(QGCFlowControl) + +} // namespace QGCSerial + +using QGCSerial::QGCFlowControl; +using QGCSerial::QGCParity; +using QGCSerial::QGCSerialPortError; + +// 1:1 with QSerialPort; same underlying integers cross the Android JNI wire. +using QGCDataBits = QSerialPort::DataBits; +using QGCStopBits = QSerialPort::StopBits; + +// Bundled wire params, carried by value so each impl applies the set atomically (one JNI hop on Android USB). +struct SerialPortConfig +{ + qint32 baud = 57600; + QGCDataBits dataBits = QGCDataBits::Data8; + QGCStopBits stopBits = QGCStopBits::OneStop; + QGCParity parity = QGCParity::None; + QGCFlowControl flowControl = QGCFlowControl::None; + + bool isValid() const noexcept + { + return (baud > 0) && (dataBits >= QGCDataBits::Data5) && (dataBits <= QGCDataBits::Data8) && + (stopBits == QGCStopBits::OneStop || stopBits == QGCStopBits::OneAndHalfStop || + stopBits == QGCStopBits::TwoStop) && + (parity <= QGCParity::Space) && (flowControl <= QGCFlowControl::XonXoffInline); + } + + friend bool operator==(const SerialPortConfig&, const SerialPortConfig&) = default; +}; diff --git a/src/Comms/Serial/SerialLink.cc b/src/Comms/Serial/SerialLink.cc new file mode 100644 index 000000000000..38198b7bc401 --- /dev/null +++ b/src/Comms/Serial/SerialLink.cc @@ -0,0 +1,461 @@ +#include "SerialLink.h" + +#include +#include + +#include "QGCLoggingCategory.h" +#include "QGCSerialPort.h" +#include "QGCSerialPortInfo.h" +#include "SerialPlatform.h" + +QGC_LOGGING_CATEGORY(SerialLinkLog, "Comms.SerialLink") + +namespace { +constexpr int DISCONNECT_TIMEOUT_MS = 3000; +} // namespace + +SerialConfiguration::SerialConfiguration(const QString& name, QObject* parent) : LinkConfiguration(name, parent) +{ + qCDebug(SerialLinkLog) << this; + + (void) connect(SerialPlatform::SerialDevicesNotifier::instance(), + &SerialPlatform::SerialDevicesNotifier::devicesChanged, + this, [this]() { + _portDisplayNameValid = false; + emit portDisplayNameChanged(); + }, Qt::QueuedConnection); +} + +SerialConfiguration::SerialConfiguration(const SerialConfiguration* source, QObject* parent) + : LinkConfiguration(source, parent) +{ + qCDebug(SerialLinkLog) << this; + + (void) connect(SerialPlatform::SerialDevicesNotifier::instance(), + &SerialPlatform::SerialDevicesNotifier::devicesChanged, + this, [this]() { + _portDisplayNameValid = false; + emit portDisplayNameChanged(); + }, Qt::QueuedConnection); + + SerialConfiguration::copyFrom(source); +} + +SerialConfiguration::~SerialConfiguration() +{ + qCDebug(SerialLinkLog) << this; +} + +void SerialConfiguration::setPortName(const QString& name) +{ + const QString portName = name.trimmed(); + if (portName.isEmpty()) { + return; + } + + if (portName != _portName) { + _portName = portName; + _portDisplayNameValid = false; + emit portNameChanged(); + emit portDisplayNameChanged(); // portDisplayName() is derived from _portName + } +} + +void SerialConfiguration::copyFrom(const LinkConfiguration* source) +{ + LinkConfiguration::copyFrom(source); + + const SerialConfiguration* serialSource = qobject_cast(source); + if (!serialSource) { + qCWarning(SerialLinkLog) << "copyFrom called with non-SerialConfiguration source"; + return; + } + + setBaud(serialSource->baud()); + setDataBits(serialSource->dataBits()); + setFlowControl(serialSource->flowControl()); + setStopBits(serialSource->stopBits()); + setParity(serialSource->parity()); + setPortName(serialSource->portName()); + setUsbDirect(serialSource->usbDirect()); + setdtrForceLow(serialSource->dtrForceLow()); +} + +void SerialConfiguration::loadSettings(QSettings& settings, const QString& root) +{ + settings.beginGroup(root); + + setBaud(settings.value("baud", _baud).toInt()); + setDataBits(settings.value("dataBits", dataBits()).toInt()); + setFlowControl(settings.value("flowControl", flowControl()).toInt()); + setStopBits(settings.value("stopBits", stopBits()).toInt()); + if (settings.contains("parityV2")) { + setParity(settings.value("parityV2").toInt()); + } else { + // Migrate legacy QSerialPort-numbered "parity" (No=0,Even=2,Odd=3,Space=4,Mark=5) to QGCParity. + static constexpr int kLegacyParityToQGC[] = {0, 0, 2, 1, 4, 3}; + setParity(kLegacyParityToQGC[qBound(0, settings.value("parity", 0).toInt(), 5)]); + } + setPortName(settings.value("portName", _portName).toString()); + setdtrForceLow(settings.value("dtrForceLow", _dtrForceLow).toBool()); + setUsbDirect(settings.value("usbDirect", _usbDirect).toBool()); + + settings.endGroup(); +} + +void SerialConfiguration::saveSettings(QSettings& settings, const QString& root) const +{ + settings.beginGroup(root); + + settings.setValue("baud", _baud); + settings.setValue("dataBits", dataBits()); + settings.setValue("flowControl", flowControl()); + settings.setValue("stopBits", stopBits()); + settings.setValue("parityV2", parity()); + settings.setValue("portName", _portName); + settings.setValue("dtrForceLow", _dtrForceLow); + settings.setValue("usbDirect", _usbDirect); + + settings.endGroup(); +} + +SerialPortConfig SerialConfiguration::portConfig() const +{ + SerialPortConfig cfg; + cfg.baud = _baud; + cfg.dataBits = _dataBits; + cfg.stopBits = _stopBits; + cfg.parity = _parity; + cfg.flowControl = _flowControl; + return cfg; +} + +QString SerialConfiguration::portDisplayName() const +{ + if (!_portDisplayNameValid) { + _portDisplayName = QGCSerialPortInfo::displayNameForLocation(_portName); + _portDisplayNameValid = true; + } + return _portDisplayName; +} + +SerialWorker::SerialWorker(const SharedLinkConfigurationPtr& config, QObject* parent) + : QObject(parent), _configHolder(config), _serialConfig(qobject_cast(config.get())) +{ + qCDebug(SerialLinkLog) << this; +} + +SerialWorker::~SerialWorker() +{ + disconnectFromPort(); + + qCDebug(SerialLinkLog) << this; +} + +bool SerialWorker::isConnected() const +{ + // _connected is atomic; _port is set once in setupPort() and never changes after. + return _connected.load(std::memory_order_acquire); +} + +void SerialWorker::setupPort() +{ + if (!_port) { + _port = SerialPlatform::makeSerialPort(_serialConfig->portName(), this); + + (void) connect(_port, &QIODevice::aboutToClose, this, &SerialWorker::_onPortDisconnected); + (void) connect(_port, &QIODevice::readyRead, this, &SerialWorker::_onPortReadyRead); + (void) connect(_port, &QIODevice::bytesWritten, this, &SerialWorker::_onPortBytesWritten); + (void) connect(_port, &QGCSerialPort::errorOccurred, this, &SerialWorker::_onPortErrorOccurred); + } +} + +void SerialWorker::connectToPort() +{ + if (!_port) { + emit errorOccurred(tr("Serial port not created")); + _onPortDisconnected(); + return; + } + + if (isConnected()) { + qCWarning(SerialLinkLog) << "Already connected to" << _port->portName(); + return; + } + + _port->setPortName(_serialConfig->portName()); + + const QGCSerialPortInfo portInfo(_serialConfig->portName()); + if (portInfo.isBootloader()) { + qCWarning(SerialLinkLog) << "Not connecting to bootloader" << _port->portName(); + emit errorOccurred(tr("Not connecting to a bootloader")); + _onPortDisconnected(); + return; + } + + _errorEmitted = false; + + qCDebug(SerialLinkLog) << "Attempting to open port" << _port->portName(); + // 2 MB soft cap: write() returns 0 when full; _flushPendingWrites holds the remainder and resumes on bytesWritten. + _port->setWriteBufferSize(kSerialWriteBufferCapBytes); + if (_port->writeBufferSize() != kSerialWriteBufferCapBytes) { + qCWarning(SerialLinkLog) << "Write buffer cap not honored for" << _port->portName() << "- requested" + << kSerialWriteBufferCapBytes << "got" << _port->writeBufferSize(); + } + if (!_port->open(QIODevice::ReadWrite)) { + qCWarning(SerialLinkLog) << "Opening port" << _port->portName() << "failed:" << _port->errorString(); + + // If auto-connect is enabled, we don't want to emit an error for PermissionError from devices already in use + if (!_errorEmitted && + (!_serialConfig->isAutoConnect() || _port->error() != QGCSerialPortError::PermissionDenied)) { + emit errorOccurred(tr("Could not open port: %1").arg(_port->errorString())); + _errorEmitted = true; + } + + _onPortDisconnected(); + + return; + } + + _onPortConnected(); +} + +void SerialWorker::disconnectFromPort() +{ + if (!_port) { + return; + } + + if (!isConnected()) { + qCDebug(SerialLinkLog) << "Already disconnected from port:" << _port->portName(); + return; + } + + qCDebug(SerialLinkLog) << "Attempting to close port:" << _port->portName(); + + _port->close(); +} + +void SerialWorker::writeData(const QByteArray& data) +{ + if (data.isEmpty()) { + _emitErrorOnce(tr("Data to Send is Empty")); + return; + } + + if (!isConnected()) { + _emitErrorOnce(tr("Port is not Connected")); + return; + } + + if (!_port->isWritable()) { + _emitErrorOnce(tr("Port is not Writable")); + return; + } + + // Backlog is bounded by the same cap as the port write buffer; exceeding it means the device has wedged. + if ((_pendingWrite.size() + data.size()) > kSerialWriteBufferCapBytes) { + _emitErrorOnce(tr("Could Not Send Data - Write Buffer Overflow")); + return; + } + + _pendingWrite.append(data); + _flushPendingWrites(); +} + +void SerialWorker::_flushPendingWrites() +{ + while (!_pendingWrite.isEmpty()) { + const qint64 bytesWritten = _port->write(_pendingWrite.constData(), _pendingWrite.size()); + if (bytesWritten == -1) { + _emitErrorOnce(tr("Could Not Send Data - Write Failed: %1").arg(_port->errorString())); + _pendingWrite.clear(); + return; + } + if (bytesWritten == 0) { + // Port write buffer full; QSerialPort emits bytesWritten as it drains → _onPortBytesWritten resumes. + return; + } + const QByteArray sent = _pendingWrite.left(bytesWritten); + _pendingWrite.remove(0, bytesWritten); + emit dataSent(sent); + } +} + +void SerialWorker::_onPortBytesWritten() +{ + _flushPendingWrites(); +} + +void SerialWorker::_emitErrorOnce(const QString& errorString) +{ + // One emission per connect/disconnect cycle prevents burst UI-modal floods. + if (_errorEmitted) { + return; + } + _errorEmitted = true; + emit errorOccurred(errorString); +} + +void SerialWorker::_onPortConnected() +{ + qCDebug(SerialLinkLog) << "Port connected:" << _port->portName(); + + // Wire params before DTR: dtrForceLow callers must not see a partially-configured device. + if (!_port->reconfigure(_serialConfig->portConfig())) { + _emitErrorOnce(tr("Failed to configure port: %1").arg(_port->errorString())); + _port->close(); // _connected never published here, so _onPortDisconnected's exchange emits nothing (balanced) + return; + } + // DTR failure is non-fatal; QSerialPort treats unsupported DTR benignly on host too. + if (!_port->setDataTerminalReady(!_serialConfig->dtrForceLow())) { + qCWarning(SerialLinkLog) << "setDataTerminalReady failed on" << _port->portName() << ":" + << _port->errorString(); + } + + _errorEmitted = false; + // Publish connected state only after full success so isConnected() never leads the connected() signal. + _connected.store(true, std::memory_order_release); + emit connected(); +} + +void SerialWorker::_onPortDisconnected() +{ + qCDebug(SerialLinkLog) << "Port disconnected:" << (_port ? _port->portName() : QString()); + + _errorEmitted = false; + _pendingWrite.clear(); // drop backlog: a closed port can never drain it + // Only emit if we previously emitted connected(); prevents unbalanced disconnected() on a failed open. + if (_connected.exchange(false, std::memory_order_acq_rel)) { + emit disconnected(); + } +} + +void SerialWorker::_onPortReadyRead() +{ + const QByteArray data = _port->readAll(); + if (!data.isEmpty()) { + emit dataReceived(data); + } +} + +void SerialWorker::_onPortErrorOccurred(QGCSerialPortError portError) +{ + switch (portError) { + case QGCSerialPortError::NoError: + return; + case QGCSerialPortError::ResourceUnavailable: + qCDebug(SerialLinkLog) << "Resource error (likely USB disconnect):" << _port->errorString(); + _port->close(); + return; + case QGCSerialPortError::PermissionDenied: + if (_serialConfig->isAutoConnect()) { + return; + } + break; + default: + break; + } + + const QString errorString = _port->errorString(); + qCWarning(SerialLinkLog) << "Port error:" << portError << errorString; + + _emitErrorOnce(errorString); +} + +SerialLink::SerialLink(SharedLinkConfigurationPtr& config, QObject* parent) + : LinkInterface(config, parent), + _serialConfig(qobject_cast(config.get())), + _worker(new SerialWorker(config)), + _workerThread(std::make_unique(_worker, QStringLiteral("Serial_%1").arg(_serialConfig->name()))) +{ + qCDebug(SerialLinkLog) << this; + + (void) connect(_workerThread->thread(), &QThread::started, _worker, &SerialWorker::setupPort); + + (void) connect(_worker, &SerialWorker::connected, this, &SerialLink::_onConnected, Qt::QueuedConnection); + (void) connect(_worker, &SerialWorker::disconnected, this, &SerialLink::_onDisconnected, Qt::QueuedConnection); + (void) connect(_worker, &SerialWorker::dataReceived, this, &SerialLink::_onDataReceived, Qt::QueuedConnection); + (void) connect(_worker, &SerialWorker::dataSent, this, &SerialLink::_onDataSent, Qt::QueuedConnection); + (void) connect(_worker, &SerialWorker::errorOccurred, this, &SerialLink::_onErrorOccurred, Qt::QueuedConnection); + + _workerThread->start(); +} + +SerialLink::~SerialLink() +{ + // Close on the worker thread directly if we are it; otherwise queue it (stopAndWait drives the missed-queue case). + if (isConnected()) { + if (QThread::currentThread() == _workerThread->thread()) { + _worker->disconnectFromPort(); + } else { + (void) QMetaObject::invokeMethod(_worker, "disconnectFromPort", Qt::QueuedConnection); + } + } + // Flush disconnected() for the USB-unplug race: worker set _connected=false but the queued slot hasn't drained. + if (_emittedConnected.load(std::memory_order_acquire)) { + _onDisconnected(); + } + + // quit + bounded join; detaches (never terminate()) if the worker is wedged in a JNI/USB call. Deletes the worker. + (void) _workerThread->stopAndWait(DISCONNECT_TIMEOUT_MS); + _worker = nullptr; + + qCDebug(SerialLinkLog) << this; +} + +bool SerialLink::isConnected() const +{ + return _worker && _worker->isConnected(); +} + +bool SerialLink::_connect() +{ + return QMetaObject::invokeMethod(_worker, "connectToPort", Qt::QueuedConnection); +} + +void SerialLink::disconnect() +{ + if (_worker && isConnected()) { + (void) QMetaObject::invokeMethod(_worker, "disconnectFromPort", Qt::QueuedConnection); + } +} + +void SerialLink::_onConnected() +{ + _emittedConnected.store(true, std::memory_order_release); + emit connected(); +} + +void SerialLink::_onDisconnected() +{ + if (_emittedConnected.exchange(false)) { + emit disconnected(); + } +} + +void SerialLink::_onErrorOccurred(const QString& errorString) +{ + qCWarning(SerialLinkLog) << "Communication error:" << errorString; + emit communicationError( + tr("Serial Link Error"), + tr("Link %1: (Port: %2) %3").arg(_serialConfig->name(), _serialConfig->portName(), errorString)); +} + +void SerialLink::_onDataReceived(const QByteArray& data) +{ + emit bytesReceived(this, data); +} + +void SerialLink::_onDataSent(const QByteArray& data) +{ + emit bytesSent(this, data); +} + +void SerialLink::_writeBytes(const QByteArray& data) +{ + if (!_worker) { + return; + } + (void) QMetaObject::invokeMethod(_worker, "writeData", Qt::QueuedConnection, Q_ARG(QByteArray, data)); +} diff --git a/src/Comms/Serial/SerialLink.h b/src/Comms/Serial/SerialLink.h new file mode 100644 index 000000000000..933b99297f6e --- /dev/null +++ b/src/Comms/Serial/SerialLink.h @@ -0,0 +1,175 @@ +#pragma once + +#include "LinkConfiguration.h" +#include "LinkInterface.h" +#include "QGCSerialPort.h" +#include "WorkerThread.h" + +#include +#include + +#include +#include + +class SerialConfiguration : public LinkConfiguration +{ + Q_OBJECT + Q_PROPERTY(qint32 baud READ baud WRITE setBaud NOTIFY baudChanged) + Q_PROPERTY(int dataBits READ dataBits WRITE setDataBits NOTIFY dataBitsChanged) + Q_PROPERTY(int flowControl READ flowControl WRITE setFlowControl NOTIFY flowControlChanged) + Q_PROPERTY(int stopBits READ stopBits WRITE setStopBits NOTIFY stopBitsChanged) + Q_PROPERTY(int parity READ parity WRITE setParity NOTIFY parityChanged) + Q_PROPERTY(QString portName READ portName WRITE setPortName NOTIFY portNameChanged) + Q_PROPERTY(QString portDisplayName READ portDisplayName NOTIFY portDisplayNameChanged) + Q_PROPERTY(bool usbDirect READ usbDirect WRITE setUsbDirect NOTIFY usbDirectChanged) + Q_PROPERTY(bool dtrForceLow READ dtrForceLow WRITE setdtrForceLow NOTIFY dtrForceLowChanged) + +public: + explicit SerialConfiguration(const QString &name, QObject *parent = nullptr); + explicit SerialConfiguration(const SerialConfiguration *copy, QObject *parent = nullptr); + virtual ~SerialConfiguration(); + + LinkType type() const override { return LinkConfiguration::TypeSerial; } + void copyFrom(const LinkConfiguration *source) override; + void loadSettings(QSettings &settings, const QString &root) override; + void saveSettings(QSettings &settings, const QString &root) const override; + QString settingsURL() const override { return QStringLiteral("SerialSettings.qml"); } + QString settingsTitle() const override { return tr("Serial Link Settings"); } + + qint32 baud() const { return _baud; } + void setBaud(qint32 baud) { if (baud != _baud) { _baud = baud; emit baudChanged(); } } + + // QML-facing as int; stored as the QGC* enums so portConfig() needs no casts and setters reject out-of-range values. + int dataBits() const { return static_cast(_dataBits); } + void setDataBits(int dataBits) { const auto v = static_cast(dataBits); if (v != _dataBits) { _dataBits = v; emit dataBitsChanged(); } } + + int flowControl() const { return static_cast(_flowControl); } + void setFlowControl(int flowControl) { const auto v = static_cast(flowControl); if (v != _flowControl) { _flowControl = v; emit flowControlChanged(); } } + + int stopBits() const { return static_cast(_stopBits); } + void setStopBits(int stopBits) { const auto v = static_cast(stopBits); if (v != _stopBits) { _stopBits = v; emit stopBitsChanged(); } } + + int parity() const { return static_cast(_parity); } + void setParity(int parity) { const auto v = static_cast(parity); if (v != _parity) { _parity = v; emit parityChanged(); } } + + QString portName() const { return _portName; } + void setPortName(const QString &name); + + // Derived on demand from the current enumeration; not persisted, so a renamed/reprobed device never shows a stale name. + QString portDisplayName() const; + + bool usbDirect() const { return _usbDirect; } + void setUsbDirect(bool usbDirect) { if (usbDirect != _usbDirect) { _usbDirect = usbDirect; emit usbDirectChanged(); } } + + bool dtrForceLow() const { return _dtrForceLow; } + void setdtrForceLow(bool dtrForceLow) { if (dtrForceLow != _dtrForceLow) { _dtrForceLow = dtrForceLow; emit dtrForceLowChanged(); } } + + // Builds SerialPortConfig from QML-facing fields. + SerialPortConfig portConfig() const; + +signals: + void baudChanged(); + void dataBitsChanged(); + void flowControlChanged(); + void stopBitsChanged(); + void parityChanged(); + void portNameChanged(); + void portDisplayNameChanged(); + void usbDirectChanged(); + void dtrForceLowChanged(); + +private: + qint32 _baud = 57600; + QGCDataBits _dataBits = QGCDataBits::Data8; + QGCFlowControl _flowControl = QGCFlowControl::None; + QGCStopBits _stopBits = QGCStopBits::OneStop; + QGCParity _parity = QGCParity::None; + QString _portName; + bool _usbDirect = false; + bool _dtrForceLow = false; + + mutable QString _portDisplayName; + mutable bool _portDisplayNameValid = false; +}; + +class SerialWorker : public QObject +{ + Q_OBJECT + +public: + explicit SerialWorker(const SharedLinkConfigurationPtr &config, QObject *parent = nullptr); + ~SerialWorker(); + + bool isConnected() const; + +signals: + void connected(); + void disconnected(); + void dataReceived(const QByteArray &data); + void dataSent(const QByteArray &data); + void errorOccurred(const QString &errorString); + +public slots: + void setupPort(); + void connectToPort(); + void disconnectFromPort(); + void writeData(const QByteArray &data); + +private slots: + void _onPortConnected(); + void _onPortDisconnected(); + void _onPortReadyRead(); + void _onPortBytesWritten(); + void _onPortErrorOccurred(QGCSerialPortError portError); + + // At most once per connect/disconnect cycle — prevents write-cap floods from queueing UI popups. + void _emitErrorOnce(const QString &errorString); + +private: + // Drains _pendingWrite into the port without blocking; resumes from _onPortBytesWritten as the port drains. + void _flushPendingWrites(); + + // Holds a ref to the shared config so a detached (wedged) worker that outlives its SerialLink + // never dangles _serialConfig, which aliases into it. + SharedLinkConfigurationPtr _configHolder; + const SerialConfiguration *_serialConfig = nullptr; + QGCSerialPort *_port = nullptr; + bool _errorEmitted = false; + // Bytes accepted from writeData() but not yet handed to the port (port write buffer was full). + QByteArray _pendingWrite; + // Mirrors _port->isOpen() for cross-thread reads; races on QIODevicePrivate::openMode otherwise. + std::atomic _connected{false}; +}; + +class SerialLink : public LinkInterface +{ + Q_OBJECT + +public: + explicit SerialLink(SharedLinkConfigurationPtr &config, QObject *parent = nullptr); + virtual ~SerialLink(); + + bool isConnected() const override; + bool isSecureConnection() const override { return _serialConfig->usbDirect(); } + +public slots: + void disconnect() override; + +private slots: + void _onConnected(); + void _onDisconnected(); + void _onDataReceived(const QByteArray &data); + void _onDataSent(const QByteArray &data); + void _onErrorOccurred(const QString &errorString); + +private: + bool _connect() override; + void _writeBytes(const QByteArray &data) override; + + const SerialConfiguration *_serialConfig = nullptr; + SerialWorker *_worker = nullptr; + std::unique_ptr _workerThread; + // True between an emitted connected() and its disconnected(); ~SerialLink uses it to flush a pending + // disconnected() on the USB-unplug-then-quit race, and _onDisconnected() to emit exactly once. + std::atomic _emittedConnected{false}; +}; diff --git a/src/Comms/Serial/SerialPlatform.cc b/src/Comms/Serial/SerialPlatform.cc new file mode 100644 index 000000000000..317561e1c5f9 --- /dev/null +++ b/src/Comms/Serial/SerialPlatform.cc @@ -0,0 +1,22 @@ +#include "SerialPlatform.h" + +#include + +namespace SerialPlatform { + +// Test-only port factory, shared by both platform TUs (host/android) so the seam isn't duplicated per platform. +namespace { +SerialPortFactory s_portFactoryOverride; +} // namespace + +void setPortFactoryForTest(SerialPortFactory factory) +{ + s_portFactoryOverride = std::move(factory); +} + +const SerialPortFactory &portFactoryOverride() +{ + return s_portFactoryOverride; +} + +} // namespace SerialPlatform diff --git a/src/Comms/Serial/SerialPlatform.h b/src/Comms/Serial/SerialPlatform.h new file mode 100644 index 000000000000..c8359e80d550 --- /dev/null +++ b/src/Comms/Serial/SerialPlatform.h @@ -0,0 +1,49 @@ +#pragma once + +// Link-time platform seam for serial-port construction; one TU per platform (QGCSerialPortInfo_host/android.cc) so +// callers stay #ifdef-free. Port-enumeration glue lives on QGCSerialPortInfo itself. + +#include +#include + +QT_FORWARD_DECLARE_CLASS(QIODevice) +QT_FORWARD_DECLARE_CLASS(QObject) +class QGCSerialPort; + +namespace SerialPlatform { + +// Construct the concrete serial port for a location (host: HostSerialPort; Android: USB-host → AndroidSerialPort, "/dev/tty*" → HostSerialPort). Per-platform TU so host never includes AndroidSerialPort.h. +QGCSerialPort* makeSerialPort(const QString& name, QObject* parent); + +// Test-only seam: when set, makeSerialPort() returns this factory's port instead of the platform impl, +// letting SerialLink/SerialWorker run against an in-memory MockSerialPort with no hardware. Pass {} to restore. +using SerialPortFactory = std::function; +void setPortFactoryForTest(SerialPortFactory factory); +const SerialPortFactory& portFactoryOverride(); + +// QIODevice feeding QNmeaPositionInfoSource for a serial NMEA GPS; host returns the port directly (GUI thread), Android a worker-threaded adapter (its USB backend can't open on the GUI thread). +QIODevice* makeNmeaSerialSource(const QString& systemLocation, qint32 baud, QObject* parent); + +// Singleton relay that fires devicesChanged() when the attached serial-device set changes (Android USB attach/permission-grant; host never emits). +// Connect with Qt::QueuedConnection — the receiver's lifetime governs delivery and Qt auto-disconnects on destruction, so no manual teardown. +class SerialDevicesNotifier : public QObject +{ + Q_OBJECT + +public: + static SerialDevicesNotifier* instance() + { + static SerialDevicesNotifier s_instance; + return &s_instance; + } + + void notifyChanged() { emit devicesChanged(); } + +signals: + void devicesChanged(); + +private: + SerialDevicesNotifier() = default; +}; + +} // namespace SerialPlatform diff --git a/src/Comms/USBBoardInfo.json b/src/Comms/Serial/USBBoardInfo.json similarity index 98% rename from src/Comms/USBBoardInfo.json rename to src/Comms/Serial/USBBoardInfo.json index fbb517cd50e0..18dae963b53f 100644 --- a/src/Comms/USBBoardInfo.json +++ b/src/Comms/Serial/USBBoardInfo.json @@ -134,8 +134,7 @@ { "regExp": "^PX4 TROPIC Community","boardClass": "Pixhawk" }, { "regExp": "^PX4 MR-TROPIC", "boardClass": "Pixhawk" }, { "regExp": "^PX4 BL MR-TROPIC", "boardClass": "Pixhawk" }, - { "regExp": "^FT231X USB UART$", "boardClass": "SiK Radio" }, - { "regExp": "USB UART$", "boardClass": "SiK Radio", "androidOnly": true, "comment": "Very broad fallback, too dangerous for non-android" } + { "regExp": "^FT231X USB UART$", "boardClass": "SiK Radio" } ], "boardManufacturerFallback": [ diff --git a/src/Comms/SerialLink.cc b/src/Comms/SerialLink.cc deleted file mode 100644 index 4df76c24dcff..000000000000 --- a/src/Comms/SerialLink.cc +++ /dev/null @@ -1,488 +0,0 @@ -#include "SerialLink.h" -#include "QGCLoggingCategory.h" -#include "QGCSerialPortInfo.h" -#include -#include -#include - -QGC_LOGGING_CATEGORY(SerialLinkLog, "Comms.SerialLink") - -namespace { - constexpr int CONNECT_TIMEOUT_MS = 1000; - constexpr int DISCONNECT_TIMEOUT_MS = 3000; -} - -/*===========================================================================*/ - -SerialConfiguration::SerialConfiguration(const QString &name, QObject *parent) - : LinkConfiguration(name, parent) -{ - qCDebug(SerialLinkLog) << this; -} - -SerialConfiguration::SerialConfiguration(const SerialConfiguration *source, QObject *parent) - : LinkConfiguration(source, parent) -{ - qCDebug(SerialLinkLog) << this; - - SerialConfiguration::copyFrom(source); -} - -SerialConfiguration::~SerialConfiguration() -{ - qCDebug(SerialLinkLog) << this; -} - -void SerialConfiguration::setPortName(const QString &name) -{ - const QString portName = name.trimmed(); - if (portName.isEmpty()) { - return; - } - - if (portName != _portName) { - _portName = portName; - emit portNameChanged(); - } - - const QString portDisplayName = cleanPortDisplayName(portName); - setPortDisplayName(portDisplayName); -} - -void SerialConfiguration::copyFrom(const LinkConfiguration *source) -{ - LinkConfiguration::copyFrom(source); - - const SerialConfiguration* serialSource = qobject_cast(source); - - setBaud(serialSource->baud()); - setDataBits(serialSource->dataBits()); - setFlowControl(serialSource->flowControl()); - setStopBits(serialSource->stopBits()); - setParity(serialSource->parity()); - setPortName(serialSource->portName()); - setPortDisplayName(serialSource->portDisplayName()); - setUsbDirect(serialSource->usbDirect()); - setdtrForceLow(serialSource->dtrForceLow()); -} - -void SerialConfiguration::loadSettings(QSettings &settings, const QString &root) -{ - settings.beginGroup(root); - - setBaud(settings.value("baud", _baud).toInt()); - setDataBits(static_cast(settings.value("dataBits", _dataBits).toInt())); - setFlowControl(static_cast(settings.value("flowControl", _flowControl).toInt())); - setStopBits(static_cast(settings.value("stopBits", _stopBits).toInt())); - setParity(static_cast(settings.value("parity", _parity).toInt())); - setPortName(settings.value("portName", _portName).toString()); - setPortDisplayName(settings.value("portDisplayName", _portDisplayName).toString()); - setdtrForceLow(settings.value("dtrForceLow", _dtrForceLow).toBool()); - - settings.endGroup(); -} - -void SerialConfiguration::saveSettings(QSettings &settings, const QString &root) const -{ - settings.beginGroup(root); - - settings.setValue("baud", _baud); - settings.setValue("dataBits", _dataBits); - settings.setValue("flowControl", _flowControl); - settings.setValue("stopBits", _stopBits); - settings.setValue("parity", _parity); - settings.setValue("portName", _portName); - settings.setValue("portDisplayName", _portDisplayName); - settings.setValue("dtrForceLow", _dtrForceLow); - - settings.endGroup(); -} - -QStringList SerialConfiguration::supportedBaudRates() -{ - static const QSet kDefaultSupportedBaudRates = { -#ifdef Q_OS_UNIX - 50, - 75, -#endif - 110, -#ifdef Q_OS_UNIX - 150, - 200, - 134, -#endif - 300, - 600, - 1200, -#ifdef Q_OS_UNIX - 1800, -#endif - 2400, - 4800, - 9600, -#ifdef Q_OS_WIN - 14400, -#endif - 19200, - 38400, -#ifdef Q_OS_WIN - 56000, -#endif - 57600, - 115200, -#ifdef Q_OS_WIN - 128000, -#endif - 230400, -#ifdef Q_OS_WIN - 256000, -#endif - 460800, - 500000, -#ifdef Q_OS_LINUX - 576000, -#endif - 921600, - }; - - const QList activeSupportedBaudRates = QSerialPortInfo::standardBaudRates(); - - QSet mergedBaudRateSet(kDefaultSupportedBaudRates.constBegin(), kDefaultSupportedBaudRates.constEnd()); - (void) mergedBaudRateSet.unite(QSet(activeSupportedBaudRates.constBegin(), activeSupportedBaudRates.constEnd())); - - QList mergedBaudRateList = mergedBaudRateSet.values(); - std::sort(mergedBaudRateList.begin(), mergedBaudRateList.end()); - - QStringList supportBaudRateStrings{}; - supportBaudRateStrings.reserve(mergedBaudRateList.size()); - for (const qint32 rate : std::as_const(mergedBaudRateList)) { - supportBaudRateStrings.append(QString::number(rate)); - } - - return supportBaudRateStrings; -} - -QString SerialConfiguration::cleanPortDisplayName(const QString &name) -{ - const QList availablePorts = QSerialPortInfo::availablePorts(); - for (const QSerialPortInfo &portInfo : availablePorts) { - if (portInfo.systemLocation() == name) { - return portInfo.portName(); - } - } - - return QString(); -} - -/*===========================================================================*/ - -SerialWorker::SerialWorker(const SerialConfiguration *config, QObject *parent) - : QObject(parent) - , _serialConfig(config) -{ - qCDebug(SerialLinkLog) << this; - - (void) qRegisterMetaType("QSerialPort::SerialPortError"); -} - -SerialWorker::~SerialWorker() -{ - disconnectFromPort(); - - qCDebug(SerialLinkLog) << this; -} - -bool SerialWorker::isConnected() const -{ - return (_port && _port->isOpen()); -} - -void SerialWorker::setupPort() -{ - if (!_port) { - _port = new QSerialPort(this); - } - - if (!_timer) { - _timer = new QTimer(this); - } - - (void) connect(_port, &QSerialPort::aboutToClose, this, &SerialWorker::_onPortDisconnected); - (void) connect(_port, &QSerialPort::readyRead, this, &SerialWorker::_onPortReadyRead); - (void) connect(_port, &QSerialPort::errorOccurred, this, &SerialWorker::_onPortErrorOccurred); - - /* if (SerialLinkLog().isDebugEnabled()) { - (void) connect(_port, &QSerialPort::bytesWritten, this, &SerialWorker::_onPortBytesWritten); - } */ - - (void) connect(_timer, &QTimer::timeout, this, &SerialWorker::_checkPortAvailability); -} - -void SerialWorker::connectToPort() -{ - if (isConnected()) { - qCWarning(SerialLinkLog) << "Already connected to" << _port->portName(); - return; - } - - _port->setPortName(_serialConfig->portName()); - - const QGCSerialPortInfo portInfo(*_port); - if (portInfo.isBootloader()) { - qCWarning(SerialLinkLog) << "Not connecting to bootloader" << _port->portName(); - emit errorOccurred(tr("Not connecting to a bootloader")); - _onPortDisconnected(); - return; - } - - _errorEmitted = false; - - qCDebug(SerialLinkLog) << "Attempting to open port" << _port->portName(); - if (!_port->open(QIODevice::ReadWrite)) { - qCWarning(SerialLinkLog) << "Opening port" << _port->portName() << "failed:" << _port->errorString(); - - // If auto-connect is enabled, we don't want to emit an error for PermissionError from devices already in use - if (!_errorEmitted && (!_serialConfig->isAutoConnect() || _port->error() != QSerialPort::PermissionError)) { - emit errorOccurred(tr("Could not open port: %1").arg(_port->errorString())); - _errorEmitted = true; - } - - _onPortDisconnected(); - - return; - } - - _onPortConnected(); -} - -void SerialWorker::disconnectFromPort() -{ - if (!isConnected()) { - qCDebug(SerialLinkLog) << "Already disconnected from port:" << _port->portName(); - return; - } - - qCDebug(SerialLinkLog) << "Attempting to close port:" << _port->portName(); - - _port->close(); -} - -void SerialWorker::writeData(const QByteArray &data) -{ - if (data.isEmpty()) { - emit errorOccurred(tr("Data to Send is Empty")); - return; - } - - if (!isConnected()) { - emit errorOccurred(tr("Port is not Connected")); - return; - } - - if (!_port->isWritable()) { - emit errorOccurred(tr("Port is not Writable")); - return; - } - - qint64 totalBytesWritten = 0; - while (totalBytesWritten < data.size()) { - const qint64 bytesWritten = _port->write(data.constData() + totalBytesWritten, data.size() - totalBytesWritten); - if (bytesWritten == -1) { - emit errorOccurred(tr("Could Not Send Data - Write Failed: %1").arg(_port->errorString())); - return; - } else if (bytesWritten == 0) { - emit errorOccurred(tr("Could Not Send Data - Write Returned 0 Bytes")); - return; - } - totalBytesWritten += bytesWritten; - } - - const QByteArray sent = data.first(totalBytesWritten); - emit dataSent(sent); -} - -void SerialWorker::_onPortConnected() -{ - qCDebug(SerialLinkLog) << "Port connected:" << _port->portName(); - - _port->setDataTerminalReady(_serialConfig->dtrForceLow() ? false : true); - _port->setBaudRate(_serialConfig->baud()); - _port->setDataBits(static_cast(_serialConfig->dataBits())); - _port->setFlowControl(static_cast(_serialConfig->flowControl())); - _port->setStopBits(static_cast(_serialConfig->stopBits())); - _port->setParity(static_cast(_serialConfig->parity())); - - if (_timer) { - _timer->start(CONNECT_TIMEOUT_MS); - } - - _errorEmitted = false; - emit connected(); -} - -void SerialWorker::_onPortDisconnected() -{ - qCDebug(SerialLinkLog) << "Port disconnected:" << _port->portName(); - - if (_timer) { - _timer->stop(); - } - - _errorEmitted = false; - emit disconnected(); -} - -void SerialWorker::_onPortReadyRead() -{ - const QByteArray data = _port->readAll(); - if (!data.isEmpty()) { - // qCDebug(SerialLinkLog) << data.size(); - emit dataReceived(data); - } -} - -void SerialWorker::_onPortBytesWritten(qint64 bytes) const -{ - qCDebug(SerialLinkLog) << _port->portName() << "Wrote" << bytes << "bytes"; -} - -void SerialWorker::_onPortErrorOccurred(QSerialPort::SerialPortError portError) -{ - switch (portError) { - case QSerialPort::NoError: - qCDebug(SerialLinkLog) << "About to open port" << _port->portName(); - return; - case QSerialPort::ResourceError: - // We get this when a usb cable is unplugged - close port to allow reconnection - qCDebug(SerialLinkLog) << "Resource error (likely USB disconnect):" << _port->errorString(); - _port->close(); - return; - case QSerialPort::PermissionError: - if (_serialConfig->isAutoConnect()) { - return; - } - break; - default: - break; - } - - const QString errorString = _port->errorString(); - qCWarning(SerialLinkLog) << "Port error:" << portError << errorString; - - if (!_errorEmitted) { - emit errorOccurred(errorString); - _errorEmitted = true; - } -} - -void SerialWorker::_checkPortAvailability() -{ - if (!isConnected()) { - return; - } - - bool portExists = false; - const auto availablePorts = QSerialPortInfo::availablePorts(); - for (const QSerialPortInfo &info : availablePorts) { - if (info.portName() == _serialConfig->portDisplayName()) { - portExists = true; - break; - } - } - - if (!portExists) { - _port->close(); - } -} - -/*===========================================================================*/ - -SerialLink::SerialLink(SharedLinkConfigurationPtr &config, QObject *parent) - : LinkInterface(config, parent) - , _serialConfig(qobject_cast(config.get())) - , _worker(new SerialWorker(_serialConfig)) - , _workerThread(new QThread(this)) -{ - qCDebug(SerialLinkLog) << this; - - _workerThread->setObjectName(QStringLiteral("Serial_%1").arg(_serialConfig->name())); - - (void) _worker->moveToThread(_workerThread); - - (void) connect(_workerThread, &QThread::started, _worker, &SerialWorker::setupPort); - (void) connect(_workerThread, &QThread::finished, _worker, &QObject::deleteLater); - - (void) connect(_worker, &SerialWorker::connected, this, &SerialLink::_onConnected, Qt::QueuedConnection); - (void) connect(_worker, &SerialWorker::disconnected, this, &SerialLink::_onDisconnected, Qt::QueuedConnection); - (void) connect(_worker, &SerialWorker::dataReceived, this, &SerialLink::_onDataReceived, Qt::QueuedConnection); - (void) connect(_worker, &SerialWorker::dataSent, this, &SerialLink::_onDataSent, Qt::QueuedConnection); - (void) connect(_worker, &SerialWorker::errorOccurred, this, &SerialLink::_onErrorOccurred, Qt::QueuedConnection); - - _workerThread->start(); -} - -SerialLink::~SerialLink() -{ - if (isConnected()) { - (void) QMetaObject::invokeMethod(_worker, "disconnectFromPort", Qt::BlockingQueuedConnection); - _onDisconnected(); - } - - _workerThread->quit(); - if (!_workerThread->wait(DISCONNECT_TIMEOUT_MS)) { - qCWarning(SerialLinkLog) << "Failed to wait for Serial Thread to close"; - } - - qCDebug(SerialLinkLog) << this; -} - -bool SerialLink::isConnected() const -{ - return _worker && _worker->isConnected(); -} - -bool SerialLink::_connect() -{ - return QMetaObject::invokeMethod(_worker, "connectToPort", Qt::QueuedConnection); -} - -void SerialLink::disconnect() -{ - if (isConnected()) { - (void) QMetaObject::invokeMethod(_worker, "disconnectFromPort", Qt::QueuedConnection); - } -} - -void SerialLink::_onConnected() -{ - _disconnectedEmitted = false; - emit connected(); -} - -void SerialLink::_onDisconnected() -{ - if (!_disconnectedEmitted.exchange(true)) { - emit disconnected(); - } -} - -void SerialLink::_onErrorOccurred(const QString &errorString) -{ - qCWarning(SerialLinkLog) << "Communication error:" << errorString; - emit communicationError(tr("Serial Link Error"), tr("Link %1: (Port: %2) %3").arg(_serialConfig->name(), _serialConfig->portName(), errorString)); -} - -void SerialLink::_onDataReceived(const QByteArray &data) -{ - emit bytesReceived(this, data); -} - -void SerialLink::_onDataSent(const QByteArray &data) -{ - emit bytesSent(this, data); -} - -void SerialLink::_writeBytes(const QByteArray &data) -{ - (void) QMetaObject::invokeMethod(_worker, "writeData", Qt::QueuedConnection, Q_ARG(QByteArray, data)); -} diff --git a/src/Comms/SerialLink.h b/src/Comms/SerialLink.h deleted file mode 100644 index d6734d608288..000000000000 --- a/src/Comms/SerialLink.h +++ /dev/null @@ -1,172 +0,0 @@ -#pragma once - -#include "LinkConfiguration.h" -#include "LinkInterface.h" - -#include -#ifdef Q_OS_ANDROID -#include "qserialport.h" -#else -#include -#endif - -#include - -class QThread; -class QTimer; - -/*===========================================================================*/ - -class SerialConfiguration : public LinkConfiguration -{ - Q_OBJECT - Q_PROPERTY(qint32 baud READ baud WRITE setBaud NOTIFY baudChanged) - Q_PROPERTY(QSerialPort::DataBits dataBits READ dataBits WRITE setDataBits NOTIFY dataBitsChanged) - Q_PROPERTY(QSerialPort::FlowControl flowControl READ flowControl WRITE setFlowControl NOTIFY flowControlChanged) - Q_PROPERTY(QSerialPort::StopBits stopBits READ stopBits WRITE setStopBits NOTIFY stopBitsChanged) - Q_PROPERTY(QSerialPort::Parity parity READ parity WRITE setParity NOTIFY parityChanged) - Q_PROPERTY(QString portName READ portName WRITE setPortName NOTIFY portNameChanged) - Q_PROPERTY(QString portDisplayName READ portDisplayName NOTIFY portDisplayNameChanged) - Q_PROPERTY(bool usbDirect READ usbDirect WRITE setUsbDirect NOTIFY usbDirectChanged) - Q_PROPERTY(bool dtrForceLow READ dtrForceLow WRITE setdtrForceLow NOTIFY dtrForceLowChanged) - -public: - explicit SerialConfiguration(const QString &name, QObject *parent = nullptr); - explicit SerialConfiguration(const SerialConfiguration *copy, QObject *parent = nullptr); - virtual ~SerialConfiguration(); - - LinkType type() const override { return LinkConfiguration::TypeSerial; } - void copyFrom(const LinkConfiguration *source) override; - void loadSettings(QSettings &settings, const QString &root) override; - void saveSettings(QSettings &settings, const QString &root) const override; - QString settingsURL() const override { return QStringLiteral("SerialSettings.qml"); } - QString settingsTitle() const override { return tr("Serial Link Settings"); } - - qint32 baud() const { return _baud; } - void setBaud(qint32 baud) { if (baud != _baud) { _baud = baud; emit baudChanged(); } } - - QSerialPort::DataBits dataBits() const { return _dataBits; } - void setDataBits(QSerialPort::DataBits databits) { if (databits != _dataBits) { _dataBits = databits; emit dataBitsChanged(); } } - - QSerialPort::FlowControl flowControl() const { return _flowControl; } - void setFlowControl(QSerialPort::FlowControl flowControl) { if (flowControl != _flowControl) { _flowControl = flowControl; emit flowControlChanged(); } } - - QSerialPort::StopBits stopBits() const { return _stopBits; } - void setStopBits(QSerialPort::StopBits stopBits) { if (stopBits != _stopBits) { _stopBits = stopBits; emit stopBitsChanged(); } } - - QSerialPort::Parity parity() const { return _parity; } - void setParity(QSerialPort::Parity parity) { if (parity != _parity) { _parity = parity; emit parityChanged(); } } - - QString portName() const { return _portName; } - void setPortName(const QString &name); - - QString portDisplayName() const { return _portDisplayName; } - void setPortDisplayName(const QString &portDisplayName) { if (portDisplayName != _portDisplayName) { _portDisplayName = portDisplayName; emit portDisplayNameChanged(); } } - - bool usbDirect() const { return _usbDirect; } - void setUsbDirect(bool usbDirect) { if (usbDirect != _usbDirect) { _usbDirect = usbDirect; emit usbDirectChanged(); } } - - bool dtrForceLow() const { return _dtrForceLow; } - void setdtrForceLow(bool dtrForceLow) { if (dtrForceLow != _dtrForceLow) { _dtrForceLow = dtrForceLow; emit dtrForceLowChanged(); } } - - static QStringList supportedBaudRates(); - static QString cleanPortDisplayName(const QString &name); - -signals: - void baudChanged(); - void dataBitsChanged(); - void flowControlChanged(); - void stopBitsChanged(); - void parityChanged(); - void portNameChanged(); - void portDisplayNameChanged(); - void usbDirectChanged(); - void dtrForceLowChanged(); - -private: - qint32 _baud = QSerialPort::Baud57600; - QSerialPort::DataBits _dataBits = QSerialPort::Data8; - QSerialPort::FlowControl _flowControl = QSerialPort::NoFlowControl; - QSerialPort::StopBits _stopBits = QSerialPort::OneStop; - QSerialPort::Parity _parity = QSerialPort::NoParity; - QString _portName; - QString _portDisplayName; - bool _usbDirect = false; - bool _dtrForceLow = false; -}; - -/*===========================================================================*/ - -class SerialWorker : public QObject -{ - Q_OBJECT - -public: - explicit SerialWorker(const SerialConfiguration *config, QObject *parent = nullptr); - ~SerialWorker(); - - bool isConnected() const; - const QSerialPort *port() const { return _port; } - -signals: - void connected(); - void disconnected(); - void dataReceived(const QByteArray &data); - void dataSent(const QByteArray &data); - void errorOccurred(const QString &errorString); - -public slots: - void setupPort(); - void connectToPort(); - void disconnectFromPort(); - void writeData(const QByteArray &data); - -private slots: - void _onPortConnected(); - void _onPortDisconnected(); - void _onPortReadyRead(); - void _onPortBytesWritten(qint64 bytes) const; - void _onPortErrorOccurred(QSerialPort::SerialPortError portError); - void _checkPortAvailability(); - -private: - const SerialConfiguration *_serialConfig = nullptr; - QSerialPort *_port = nullptr; - QTimer *_timer = nullptr; - bool _errorEmitted = false; -}; - -/*===========================================================================*/ - -class SerialLink : public LinkInterface -{ - Q_OBJECT - -public: - explicit SerialLink(SharedLinkConfigurationPtr &config, QObject *parent = nullptr); - virtual ~SerialLink(); - - bool isConnected() const override; - bool isSecureConnection() const override { return _serialConfig->usbDirect(); } - - const QSerialPort *port() const { return _worker->port(); } - -public slots: - void disconnect() override; - -private slots: - void _onConnected(); - void _onDisconnected(); - void _onDataReceived(const QByteArray &data); - void _onDataSent(const QByteArray &data); - void _onErrorOccurred(const QString &errorString); - -private: - bool _connect() override; - void _writeBytes(const QByteArray &data) override; - - const SerialConfiguration *_serialConfig = nullptr; - SerialWorker *_worker = nullptr; - QThread *_workerThread = nullptr; - std::atomic _disconnectedEmitted{false}; -}; diff --git a/src/GPS/SerialGPSTransport.cc b/src/GPS/SerialGPSTransport.cc index 837832f9a863..2eaa97560f90 100644 --- a/src/GPS/SerialGPSTransport.cc +++ b/src/GPS/SerialGPSTransport.cc @@ -1,12 +1,8 @@ #include "SerialGPSTransport.h" #include "QGCLoggingCategory.h" - -#ifdef Q_OS_ANDROID -#include "qserialport.h" -#else -#include -#endif +#include "QGCSerialPort.h" +#include "SerialPlatform.h" #include @@ -24,31 +20,39 @@ SerialGPSTransport::~SerialGPSTransport() = default; bool SerialGPSTransport::open() { - _serial = std::make_unique(); - _serial->setPortName(_device); + _serial.reset(SerialPlatform::makeSerialPort(_device, nullptr)); + if (!_serial) { + qCWarning(SerialGPSTransportLog) << "GPS: Failed to create Serial Device" << _device; + return false; + } if (!_serial->open(QIODevice::ReadWrite)) { // Device can take 10-20s to become accessible after startup. uint32_t retries = 60; - while ((retries-- > 0) && !_requestStop && (_serial->error() == QSerialPort::PermissionError)) { + while ((retries-- > 0) && !_requestStop && (_serial->error() == QGCSerialPortError::PermissionDenied)) { qCDebug(SerialGPSTransportLog) << "Cannot open device... retrying"; QThread::msleep(500); + _serial->clearError(); if (_serial->open(QIODevice::ReadWrite)) { - _serial->clearError(); break; } } - if (_serial->error() != QSerialPort::NoError) { + if (_serial->error() != QGCSerialPortError::NoError) { qCWarning(SerialGPSTransportLog) << "GPS: Failed to open Serial Device" << _device << _serial->errorString(); return false; } } - (void) _serial->setBaudRate(QSerialPort::Baud9600); - (void) _serial->setDataBits(QSerialPort::Data8); - (void) _serial->setParity(QSerialPort::NoParity); - (void) _serial->setStopBits(QSerialPort::OneStop); - (void) _serial->setFlowControl(QSerialPort::NoFlowControl); + _config.baud = 9600; + _config.dataBits = QGCDataBits::Data8; + _config.parity = QGCParity::None; + _config.stopBits = QGCStopBits::OneStop; + _config.flowControl = QGCFlowControl::None; + if (!_serial->reconfigure(_config)) { + qCWarning(SerialGPSTransportLog) << "GPS: Failed to configure Serial Device" << _device << _serial->errorString(); + _serial->close(); + return false; + } return true; } @@ -56,8 +60,8 @@ bool SerialGPSTransport::open() bool SerialGPSTransport::fatalError() const { return _serial - && (_serial->error() != QSerialPort::NoError) - && (_serial->error() != QSerialPort::TimeoutError); + && (_serial->error() != QGCSerialPortError::NoError) + && (_serial->error() != QGCSerialPortError::Timeout); } int SerialGPSTransport::read(uint8_t *buffer, int length, int timeoutMs) @@ -85,7 +89,7 @@ int SerialGPSTransport::write(const uint8_t *buffer, int length) return -1; } written += static_cast(n); - if (!_serial->waitForBytesWritten(kWriteTimeoutMs)) { + if (written < length && !_serial->waitForBytesWritten(kWriteTimeoutMs)) { return -1; } } @@ -94,5 +98,6 @@ int SerialGPSTransport::write(const uint8_t *buffer, int length) bool SerialGPSTransport::setBaudrate(unsigned baudrate) { - return _serial->setBaudRate(baudrate); + _config.baud = static_cast(baudrate); + return _serial->reconfigure(_config); } diff --git a/src/GPS/SerialGPSTransport.h b/src/GPS/SerialGPSTransport.h index 9bed86ef6df7..846dc8deabc8 100644 --- a/src/GPS/SerialGPSTransport.h +++ b/src/GPS/SerialGPSTransport.h @@ -1,6 +1,7 @@ #pragma once #include "GPSTransport.h" +#include "QGCSerialPortTypes.h" #include @@ -8,10 +9,11 @@ #include #include -class QSerialPort; +class QGCSerialPort; -/// GPSTransport backed by a QSerialPort. Owns the port and must be constructed on -/// the thread that pumps the driver — QSerialPort has thread affinity. +/// GPSTransport backed by a QGCSerialPort (HostSerialPort on desktop, AndroidSerialPort on +/// Android USB-host). Owns the port and must be constructed on the thread that pumps the +/// driver — the port has thread affinity. class SerialGPSTransport : public GPSTransport { public: @@ -34,5 +36,6 @@ class SerialGPSTransport : public GPSTransport QString _device; const std::atomic_bool &_requestStop; - std::unique_ptr _serial; + SerialPortConfig _config{}; + std::unique_ptr _serial; }; diff --git a/src/Utilities/Concurrency/CMakeLists.txt b/src/Utilities/Concurrency/CMakeLists.txt index 9c49e16c845f..ea5c0c4d68d8 100644 --- a/src/Utilities/Concurrency/CMakeLists.txt +++ b/src/Utilities/Concurrency/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(QGCConcurrency INTERFACE) target_sources(QGCConcurrency INTERFACE AutoSuspendGuard.h + WorkerThread.h ) target_include_directories(QGCConcurrency diff --git a/src/Utilities/Concurrency/WorkerThread.h b/src/Utilities/Concurrency/WorkerThread.h new file mode 100644 index 000000000000..026a91dffe73 --- /dev/null +++ b/src/Utilities/Concurrency/WorkerThread.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include + +// Owns a QThread plus a worker QObject moved onto it, with a bounded teardown. +// +// stopAndWait() quits the worker's event loop and joins up to a timeout. On a clean join it deletes the +// worker then the thread; on timeout (worker wedged in a JNI/USB call) it detaches both to self-delete on +// QThread::finished, and NEVER calls terminate() (which qFatals and can strand JNI global refs). A worker +// whose blocking call never returns also never emits finished, so it is leaked deliberately — the accepted +// cost of not terminating. Callers must close the OS handle before teardown so a blocked read can unwind. +// +// The worker is owned here: pass a heap-allocated, unparented QObject; do not delete it elsewhere. +class WorkerThread final +{ +public: + WorkerThread(QObject *worker, const QString &threadName) + : _thread(new QThread), _worker(worker) + { + _thread->setObjectName(threadName); + _worker->setParent(nullptr); + (void) _worker->moveToThread(_thread); + } + + ~WorkerThread() + { + // Best-effort guard if the owner forgot to stopAndWait(): detach immediately, never block in a dtor. + (void) stopAndWait(0); + } + + WorkerThread(const WorkerThread &) = delete; + WorkerThread &operator=(const WorkerThread &) = delete; + + QThread *thread() const { return _thread; } + bool isRunning() const { return _thread && _thread->isRunning(); } + void start() { if (_thread) _thread->start(); } + + // Quit + join up to timeoutMs. Returns true if joined (worker+thread deleted), false if detached. + // Idempotent: a no-op once already stopped. + bool stopAndWait(int timeoutMs) + { + if (!_thread) { + return true; + } + + _thread->quit(); + if (_thread->wait(timeoutMs)) { + // Joined: the worker is quiescent, so deleting it from the owner thread is safe. + delete _worker; + delete _thread; + _worker = nullptr; + _thread = nullptr; + return true; + } + + // Wedged: hand both to deleteLater on the eventual finish and release ownership. + QObject::connect(_thread, &QThread::finished, _worker, &QObject::deleteLater); + QObject::connect(_thread, &QThread::finished, _thread, &QObject::deleteLater); + _worker = nullptr; + _thread = nullptr; + return false; + } + +private: + QThread *_thread = nullptr; + QObject *_worker = nullptr; +}; diff --git a/src/Utilities/Logging/QGCLoggingCategoryManager.cc b/src/Utilities/Logging/QGCLoggingCategoryManager.cc index 9efcd6cc6f5d..244d2d2640e4 100644 --- a/src/Utilities/Logging/QGCLoggingCategoryManager.cc +++ b/src/Utilities/Logging/QGCLoggingCategoryManager.cc @@ -233,7 +233,8 @@ void QGCLoggingCategoryManager::_categoryFilter(QLoggingCategory* category) // Leave "default" (uncategorized qDebug()) and "qml" (QML console.log/warn/error) // to Qt's built-in filter, which enables debug messages in Debug builds by default. // Without this, the QGC filter would suppress them at the default level (Warning). - if (categoryName == QLatin1String("default") || categoryName == QLatin1String("qml")) { + const QLoggingCategory* const defaultCat = QLoggingCategory::defaultCategory(); + if ((defaultCat && (categoryName == QLatin1String(defaultCat->categoryName()))) || categoryName == QLatin1String("qml")) { return; } diff --git a/src/Utilities/SDL/CMakeLists.txt b/src/Utilities/SDL/CMakeLists.txt index 4a8b75d3a778..aa05794207f5 100644 --- a/src/Utilities/SDL/CMakeLists.txt +++ b/src/Utilities/SDL/CMakeLists.txt @@ -88,6 +88,7 @@ endif() if(ANDROID) target_compile_definitions(SDL3-shared PRIVATE SDL_MAIN_NOIMPL SDL_MAIN_HANDLED) target_link_libraries(SDL3-shared PRIVATE log android) + target_link_options(SDL3-shared PRIVATE "LINKER:-z,max-page-size=16384") set(QGC_SDL3_TARGET SDL3::SDL3-shared) else() target_compile_definitions(SDL3-static PRIVATE SDL_MAIN_NOIMPL SDL_MAIN_HANDLED) diff --git a/src/Vehicle/Vehicle.cc b/src/Vehicle/Vehicle.cc index c19094d300b4..5f818c879984 100644 --- a/src/Vehicle/Vehicle.cc +++ b/src/Vehicle/Vehicle.cc @@ -1064,8 +1064,9 @@ void Vehicle::_handleExtendedSysState(mavlink_message_t& message) bool Vehicle::_apmArmingNotRequired() { QString armingRequireParam("ARMING_REQUIRE"); + // getParameter() returns &_defaultFact (non-null, rawValue==0) when missing — must gate on parameterExists(). return _parameterManager->parameterExists(ParameterManager::defaultComponentId, armingRequireParam) && - _parameterManager->getParameter(ParameterManager::defaultComponentId, armingRequireParam)->rawValue().toInt() == 0; + _parameterManager->getParameter(ParameterManager::defaultComponentId, armingRequireParam)->rawValue().toInt() == 0; } void Vehicle::_handleSysStatus(mavlink_message_t& message) @@ -1407,7 +1408,7 @@ bool Vehicle::sendMessageOnLinkThreadSafe(LinkInterface* link, mavlink_message_t int Vehicle::motorCount() { uint8_t frameType = 0; - if (_vehicleType == MAV_TYPE_SUBMARINE) { + if ((_vehicleType == MAV_TYPE_SUBMARINE) && parameterManager()->parameterExists(_compID, "FRAME_CONFIG")) { frameType = parameterManager()->getParameter(_compID, "FRAME_CONFIG")->rawValue().toInt(); } return QGCMAVLink::motorCount(_vehicleType, frameType); diff --git a/src/Vehicle/VehicleSetup/Bootloader.cc b/src/Vehicle/VehicleSetup/Bootloader.cc index 742ab25035b7..e2e59a174ddd 100644 --- a/src/Vehicle/VehicleSetup/Bootloader.cc +++ b/src/Vehicle/VehicleSetup/Bootloader.cc @@ -1,6 +1,7 @@ #include "Bootloader.h" #include "QGCLoggingCategory.h" #include "FirmwareImage.h" +#include "SerialPlatform.h" #include "QGCMath.h" #include @@ -10,6 +11,24 @@ QGC_LOGGING_CATEGORY(FirmwareUpgradeLog, "VehicleSetup.FirmwareUpgrade") QGC_LOGGING_CATEGORY(FirmwareUpgradeVerboseLog, "VehicleSetup.FirmwareUpgrade:verbose") +namespace { +QString _hexPreview(const uint8_t* data, qint64 len) +{ + constexpr qint64 kCap = 32; + const qint64 n = qMin(len, kCap); + QString out; + out.reserve(int(n * 3)); + for (qint64 i = 0; i < n; ++i) { + if (i) out += QLatin1Char(' '); + out += QString::asprintf("%02x", data[i]); + } + if (len > kCap) { + out += QStringLiteral(" …(+%1)").arg(len - kCap); + } + return out; +} +} // namespace + /// This class manages interactions with the bootloader Bootloader::Bootloader(bool sikRadio, QObject *parent) : QObject (parent) @@ -22,15 +41,28 @@ bool Bootloader::open(const QString portName) { qCDebug(FirmwareUpgradeLog) << "open:" << portName; - _port.setPortName (portName); - _port.setBaudRate (QSerialPort::Baud115200); - _port.setDataBits (QSerialPort::Data8); - _port.setParity (QSerialPort::NoParity); - _port.setStopBits (QSerialPort::OneStop); - _port.setFlowControl(QSerialPort::NoFlowControl); + delete _port; + _port = SerialPlatform::makeSerialPort(portName, this); + if (!_port) { + _errorString = tr("Failed to create port %1").arg(portName); + return false; + } + SerialPortConfig cfg; + cfg.baud = 115200; + cfg.dataBits = QGCDataBits::Data8; + cfg.stopBits = QGCStopBits::OneStop; + cfg.parity = QGCParity::None; + cfg.flowControl = QGCFlowControl::None; + _portConfig = cfg; - if (!_port.open(QIODevice::ReadWrite)) { - _errorString = tr("Open failed on port %1: %2").arg(portName, _port.errorString()); + if (!_port->open(QIODevice::ReadWrite)) { + _errorString = tr("Open failed on port %1: %2").arg(portName, _port->errorString()); + return false; + } + + if (!_port->reconfigure(cfg)) { + _errorString = tr("Failed to configure port %1: %2").arg(portName, _port->errorString()); + _port->close(); return false; } @@ -50,8 +82,8 @@ QString Bootloader::_getNextLine(int timeoutMsecs) timeout.start(); while (timeout.elapsed() < timeoutMsecs) { char oneChar; - _port.waitForReadyRead(100); - if (_port.read(&oneChar, 1) > 0) { + _port->waitForReadyRead(100); + if (_port->read(&oneChar, 1) > 0) { if (oneChar == '\r') { foundCR = true; continue; @@ -77,15 +109,20 @@ bool Bootloader::getBoardInfo(uint32_t& bootloaderVersion, uint32_t& boardID, ui } } else { qCDebug(FirmwareUpgradeLog) << "Radio in normal mode"; - _port.readAll(); - _port.setBaudRate(QSerialPort::Baud57600); + _port->readAll(); + SerialPortConfig baudOnly = _portConfig; + baudOnly.baud = 57600; + if (!_port->reconfigure(baudOnly)) { + _errorString = tr("Failed to set radio baud %1: %2").arg(baudOnly.baud).arg(_port->errorString()); + goto Error; + } // Put radio into command mode _write("+++"); - if (!_port.waitForReadyRead(2000)) { + if (!_port->waitForReadyRead(2000)) { _errorString = tr("Unable to put radio into command mode +++"); goto Error; } - QByteArray bytes = _port.readAll(); + QByteArray bytes = _port->readAll(); if (!bytes.contains("OK")) { _errorString = tr("Radio did not respond to command mode"); goto Error; @@ -154,11 +191,16 @@ bool Bootloader::initFlashSequence(void) { if (_sikRadio && !_inBootloaderMode) { _write("AT&UPDATE\r\n"); - if (!_port.waitForReadyRead(1500)) { + if (!_port->waitForReadyRead(1500)) { _errorString = tr("Unable to reboot radio (ready read)"); return false; } - _port.setBaudRate(QSerialPort::Baud115200); + SerialPortConfig baudOnly = _portConfig; + baudOnly.baud = 115200; + if (!_port->reconfigure(baudOnly)) { + _errorString = tr("Failed to set radio baud %1: %2").arg(baudOnly.baud).arg(_port->errorString()); + return false; + } if (!_sync()) { return false; @@ -174,7 +216,8 @@ bool Bootloader::erase(void) // If flash size is bigger then 2MB we need to increase timeout if(_boardFlashSize > 2000 * 1024) { // Increase timeout for each 1MB by 4 seconds - timeout += (_boardFlashSize / 1e6) * 4000; + timeout += (_boardFlashSize / 1000000u) * 4000u; + if (timeout > 120000u) { timeout = 120000u; } } // Erase is slow, need larger timeout @@ -200,13 +243,13 @@ bool Bootloader::reboot(void) bool success; if (_sikRadio && !_inBootloaderMode) { qCDebug(FirmwareUpgradeLog) << "reboot ATZ"; - _port.readAll(); + _port->readAll(); success = _write("ATZ\r\n"); } else { qCDebug(FirmwareUpgradeLog) << "reboot"; success = _write(PROTO_BOOT) && _write(PROTO_EOC); } - _port.flush(); + _port->flush(); if (success) { QThread::msleep(1000); } @@ -220,18 +263,30 @@ bool Bootloader::_write(const char* data) bool Bootloader::_write(const uint8_t* data, qint64 maxSize) { - qint64 bytesWritten = _port.write((const char*)data, maxSize); - if (bytesWritten == -1) { - _errorString = tr("Write failed: %1").arg(_port.errorString()); - qWarning() << _errorString; - return false; - } - if (bytesWritten != maxSize) { - _errorString = tr("Incorrect number of bytes returned for write: actual(%1) expected(%2)").arg(bytesWritten).arg(maxSize); - qWarning() << _errorString; - return false; + // QGCSerialPort::write may short-write or return 0 under back-pressure (AndroidSerialPort's queued writer), + // so honor the QIODevice contract and drain the whole buffer before reporting success. + static constexpr int kWriteDrainTimeoutMsecs = 1000; + qint64 totalWritten = 0; + while (totalWritten < maxSize) { + const qint64 n = _port->write((const char*)data + totalWritten, maxSize - totalWritten); + if (n == -1) { + _errorString = tr("Write failed: %1").arg(_port->errorString()); + qWarning() << _errorString; + return false; + } + if (n == 0) { + if (!_port->waitForBytesWritten(kWriteDrainTimeoutMsecs)) { + _errorString = tr("Write timed out: %1 of %2 bytes").arg(totalWritten).arg(maxSize); + qWarning() << _errorString; + return false; + } + continue; + } + totalWritten += n; } + qCDebug(FirmwareUpgradeVerboseLog).noquote() + << QStringLiteral("TX n=%1 hex=%2").arg(maxSize).arg(_hexPreview(data, maxSize)); return true; } @@ -243,25 +298,39 @@ bool Bootloader::_write(const uint8_t byte) bool Bootloader::_read(uint8_t* data, qint64 cBytesExpected, int readTimeout) { + static constexpr int kReadPollIntervalMsecs = 10; QElapsedTimer timeout; timeout.start(); - while (_port.bytesAvailable() < cBytesExpected) { + qint64 bytesRead = 0; + while (bytesRead < cBytesExpected) { if (timeout.elapsed() > readTimeout) { - _errorString = tr("Timeout waiting for bytes to be available"); + _errorString = tr("Timeout waiting for bytes (expected=%1 available=%2 elapsed=%3ms)") + .arg(cBytesExpected).arg(bytesRead).arg(timeout.elapsed()); + qCDebug(FirmwareUpgradeVerboseLog).noquote() + << QStringLiteral("RX timeout expected=%1 received=%2 elapsed=%3ms") + .arg(cBytesExpected).arg(bytesRead).arg(timeout.elapsed()); return false; } - _port.waitForReadyRead(100); - } - - qint64 bytesRead; - bytesRead = _port.read((char *)data, cBytesExpected); - - if (bytesRead != cBytesExpected) { - _errorString = tr("Read failed: error: %1").arg(_port.errorString()); - return false; + if (_port->bytesAvailable() == 0) { + _port->waitForReadyRead(kReadPollIntervalMsecs); + continue; + } + const qint64 n = _port->read((char *)data + bytesRead, cBytesExpected - bytesRead); + if (n < 0) { + _errorString = tr("Read failed: error: %1").arg(_port->errorString()); + return false; + } + if (n == 0) { + _port->waitForReadyRead(kReadPollIntervalMsecs); // bytesAvailable lied; yield instead of busy-spin + continue; + } + bytesRead += n; } + qCDebug(FirmwareUpgradeVerboseLog).noquote() + << QStringLiteral("RX n=%1 elapsed=%2ms hex=%3") + .arg(bytesRead).arg(timeout.elapsed()).arg(_hexPreview(data, bytesRead)); return true; } @@ -280,7 +349,7 @@ bool Bootloader::_getCommandResponse(int responseTimeout) if (response[0] != PROTO_INSYNC) { _errorString = tr("Invalid sync response: 0x%1 0x%2").arg(response[0], 2, 16, QLatin1Char('0')).arg(response[1], 2, 16, QLatin1Char('0')); return false; - } else if (response[0] == PROTO_INSYNC && response[1] == PROTO_BAD_SILICON_REV) { + } else if (response[1] == PROTO_BAD_SILICON_REV) { _errorString = tr("This board is using a microcontroller with faulty silicon and an incorrect configuration and should be put out of service."); return false; } else if (response[1] != PROTO_OK) { @@ -331,7 +400,7 @@ bool Bootloader::_sendCommand(const uint8_t cmd, int responseTimeout) if (!_write(buf, 2)) { goto Error; } - _port.flush(); + _port->flush(); if (!_getCommandResponse(responseTimeout)) { goto Error; @@ -357,7 +426,7 @@ bool Bootloader::_binProgram(const FirmwareImage* image) uint32_t bytesSent = 0; _imageCRC = 0; - Q_ASSERT(PROG_MULTI_MAX <= 0x8F); + static_assert(PROG_MULTI_MAX <= 0x8F, "PROG_MULTI_MAX exceeds protocol maximum 0x8F"); while (bytesSent < imageSize) { int bytesToSend = imageSize - bytesSent; @@ -365,7 +434,10 @@ bool Bootloader::_binProgram(const FirmwareImage* image) bytesToSend = (int)sizeof(imageBuf); } - Q_ASSERT((bytesToSend % 4) == 0); + if ((bytesToSend % 4) != 0) { + _errorString = tr("Flash failed: bytesToSend %1 not a multiple of 4").arg(bytesToSend); + return false; + } int bytesRead = firmwareFile.read((char *)imageBuf, bytesToSend); if (bytesRead == -1 || bytesRead != bytesToSend) { @@ -373,7 +445,10 @@ bool Bootloader::_binProgram(const FirmwareImage* image) return false; } - Q_ASSERT(bytesToSend <= 0x8F); + if (bytesToSend > 0x8F) { + _errorString = tr("Flash failed: bytesToSend %1 exceeds protocol maximum 0x8F").arg(bytesToSend); + return false; + } bool failed = true; if (_write(PROTO_PROG_MULTI) && @@ -432,7 +507,7 @@ bool Bootloader::_ihxProgram(const FirmwareImage* image) _write(flashAddress & 0xFF) && _write((flashAddress >> 8) & 0xFF) && _write(PROTO_EOC)) { - _port.flush(); + _port->flush(); if (_getCommandResponse()) { failed = false; } @@ -462,7 +537,7 @@ bool Bootloader::_ihxProgram(const FirmwareImage* image) _write(bytesToWrite) && _write(&((uint8_t *)bytes.data())[bytesIndex], bytesToWrite) && _write(PROTO_EOC)) { - _port.flush(); + _port->flush(); if (_getCommandResponse()) { failed = false; } @@ -510,7 +585,10 @@ bool Bootloader::_verifyBytes(const FirmwareImage* image) bool Bootloader::_binVerifyBytes(const FirmwareImage* image) { - Q_ASSERT(image->imageIsBinFormat()); + if (!image->imageIsBinFormat()) { + _errorString = tr("Verify failed: expected binary format image"); + return false; + } QFile firmwareFile(image->binFilename()); if (!firmwareFile.open(QIODevice::ReadOnly)) { @@ -527,7 +605,7 @@ bool Bootloader::_binVerifyBytes(const FirmwareImage* image) uint8_t readBuf[READ_MULTI_MAX]; uint32_t bytesVerified = 0; - Q_ASSERT(PROG_MULTI_MAX <= 0x8F); + static_assert(PROG_MULTI_MAX <= 0x8F, "PROG_MULTI_MAX exceeds protocol maximum 0x8F"); while (bytesVerified < imageSize) { int bytesToRead = imageSize - bytesVerified; @@ -535,7 +613,10 @@ bool Bootloader::_binVerifyBytes(const FirmwareImage* image) bytesToRead = (int)sizeof(readBuf); } - Q_ASSERT((bytesToRead % 4) == 0); + if ((bytesToRead % 4) != 0) { + _errorString = tr("Verify failed: bytesToRead %1 not a multiple of 4").arg(bytesToRead); + return false; + } int bytesRead = firmwareFile.read((char *)fileBuf, bytesToRead); if (bytesRead == -1 || bytesRead != bytesToRead) { @@ -543,13 +624,16 @@ bool Bootloader::_binVerifyBytes(const FirmwareImage* image) return false; } - Q_ASSERT(bytesToRead <= 0x8F); + if (bytesToRead > 0x8F) { + _errorString = tr("Verify failed: bytesToRead %1 exceeds protocol maximum 0x8F").arg(bytesToRead); + return false; + } bool failed = true; if (_write(PROTO_READ_MULTI) && _write((uint8_t)bytesToRead) && _write(PROTO_EOC)) { - _port.flush(); + _port->flush(); if (_read(readBuf, bytesToRead)) { if (_getCommandResponse()) { failed = false; @@ -580,7 +664,10 @@ bool Bootloader::_binVerifyBytes(const FirmwareImage* image) bool Bootloader::_ihxVerifyBytes(const FirmwareImage* image) { - Q_ASSERT(!image->imageIsBinFormat()); + if (image->imageIsBinFormat()) { + _errorString = tr("Verify failed: expected IHX format image"); + return false; + } uint32_t imageSize = image->imageSize(); uint32_t bytesVerified = 0; @@ -604,7 +691,7 @@ bool Bootloader::_ihxVerifyBytes(const FirmwareImage* image) _write(readAddress & 0xFF) && _write((readAddress >> 8) & 0xFF) && _write(PROTO_EOC)) { - _port.flush(); + _port->flush(); if (_getCommandResponse()) { failed = false; } @@ -634,7 +721,7 @@ bool Bootloader::_ihxVerifyBytes(const FirmwareImage* image) if (_write(PROTO_READ_MULTI) && _write(bytesToRead) && _write(PROTO_EOC)) { - _port.flush(); + _port->flush(); if (_read(readBuf, bytesToRead)) { if (_getCommandResponse()) { failed = false; @@ -678,7 +765,7 @@ bool Bootloader::_verifyCRC(void) bool failed = true; if (_write(buf, 2)) { - _port.flush(); + _port->flush(); if (_read((uint8_t*)&flashCRC, sizeof(flashCRC), _verifyTimeout)) { if (_getCommandResponse()) { failed = false; @@ -712,14 +799,17 @@ bool Bootloader::_syncWorker(void) bool Bootloader::_sync(void) { // Sometimes getting sync is flaky, try 3 times - _port.readAll(); bool success = false; for (int i=0; i<3; i++) { + _port->readAll(); + qCDebug(FirmwareUpgradeVerboseLog) << "sync attempt" << (i + 1) << "of 3"; success = _syncWorker(); if (success) { + qCDebug(FirmwareUpgradeVerboseLog) << "sync attempt" << (i + 1) << "succeeded"; return true; } + qCDebug(FirmwareUpgradeVerboseLog) << "sync attempt" << (i + 1) << "failed:" << _errorString; } return success; } @@ -731,7 +821,7 @@ bool Bootloader::_get3DRRadioBoardId(uint32_t& boardID) if (!_write(buf, sizeof(buf))) { goto Error; } - _port.flush(); + _port->flush(); if (!_read((uint8_t*)buf, 2)) { goto Error; diff --git a/src/Vehicle/VehicleSetup/Bootloader.h b/src/Vehicle/VehicleSetup/Bootloader.h index ae6d9fe2a2de..3d45385ae76e 100644 --- a/src/Vehicle/VehicleSetup/Bootloader.h +++ b/src/Vehicle/VehicleSetup/Bootloader.h @@ -2,11 +2,7 @@ #include -#ifdef Q_OS_ANDROID -#include "qserialport.h" -#else -#include -#endif +#include "QGCSerialPort.h" class FirmwareImage; @@ -22,7 +18,7 @@ class Bootloader : public QObject QString errorString(void) { return _errorString; } bool open (const QString portName); - void close (void) { _port.close(); } + void close (void) { if (_port) _port->close(); } bool getBoardInfo (uint32_t& bootloaderVersion, uint32_t& boardID, uint32_t& flashSize); bool initFlashSequence (void); bool erase (void); @@ -76,13 +72,13 @@ class Bootloader : public QObject PROTO_GET_SYNC = 0x21, ///< NOP for re-establishing sync PROTO_GET_DEVICE = 0x22, ///< get device ID bytes PROTO_CHIP_ERASE = 0x23, ///< erase program area and reset program address - PROTO_LOAD_ADDRESS = 0x24, ///< set next programming address + PROTO_LOAD_ADDRESS = 0x24, ///< set next programming address (Rev1+); aliased by PROTO_CHIP_VERIFY in Rev2 PROTO_PROG_MULTI = 0x27, ///< write bytes at program address and increment PROTO_GET_CRC = 0x29, ///< compute & return a CRC PROTO_BOOT = 0x30, ///< boot the application // Command bytes - Rev 2 boootloader only - PROTO_CHIP_VERIFY = 0x24, ///< begin verify mode + PROTO_CHIP_VERIFY = 0x24, ///< begin verify mode (Rev2); deliberately same byte as PROTO_LOAD_ADDRESS PROTO_READ_MULTI = 0x28, ///< read bytes at programm address and increment INFO_BL_REV = 1, ///< bootloader protocol revision @@ -95,8 +91,10 @@ class Bootloader : public QObject PROG_MULTI_MAX = 64, ///< write size for PROTO_PROG_MULTI, must be multiple of 4 READ_MULTI_MAX = 0x28 ///< read size for PROTO_READ_MULTI, must be multiple of 4. Sik Radio max size is 0x28 }; + static_assert(PROTO_CHIP_VERIFY == PROTO_LOAD_ADDRESS, "Rev2 PROTO_CHIP_VERIFY deliberately aliases PROTO_LOAD_ADDRESS (0x24)"); - QSerialPort _port; + QGCSerialPort *_port = nullptr; + SerialPortConfig _portConfig; bool _sikRadio = false; bool _inBootloaderMode = false; ///< true: board is in bootloader mode, false: special case for SiK Radio, board is in command mode uint32_t _boardID = 0; ///< board id for currently connected board diff --git a/src/Vehicle/VehicleSetup/FirmwareUpgrade.qml b/src/Vehicle/VehicleSetup/FirmwareUpgrade.qml index 7ac01d267631..1f41b7ca65e0 100644 --- a/src/Vehicle/VehicleSetup/FirmwareUpgrade.qml +++ b/src/Vehicle/VehicleSetup/FirmwareUpgrade.qml @@ -35,6 +35,12 @@ SetupPage { readonly property string unplugReplugText: highlightPrefix + qsTr("Now unplug your device and plug it back in to enter bootloader mode.") + highlightSuffix readonly property string flashFailText: qsTr("If upgrade failed, make sure to connect ") + highlightPrefix + qsTr("directly") + highlightSuffix + qsTr(" to a powered USB port on your computer, not through a USB hub. ") + qsTr("Also make sure you are only powered via USB ") + highlightPrefix + qsTr("not battery") + highlightSuffix + "." + readonly property string androidNoteText: highlightPrefix + qsTr("Android USB note: ") + highlightSuffix + + qsTr("the bootloader watchdog (~5 s) is shorter than the Android permission dialog. ") + + qsTr("Before flashing, force-stop QGroundControl and replug the device — Android will offer ") + + highlightPrefix + qsTr("\"Use by default for this USB device\"") + highlightSuffix + + qsTr(" with an ") + highlightPrefix + qsTr("Always") + highlightSuffix + + qsTr(" checkbox. Tick it for both the bootloader and the application device, then upgrade.") readonly property int _defaultFimwareTypePX4: 12 readonly property int _defaultFimwareTypeAPM: 3 @@ -148,6 +154,9 @@ SetupPage { onActiveVehicleChanged: { if (!globals.activeVehicle && !_flashStarted) { statusTextArea.append(plugInText) + if (Qt.platform.os === "android") { + statusTextArea.append(androidNoteText) + } } } diff --git a/src/Vehicle/VehicleSetup/FirmwareUpgradeController.cc b/src/Vehicle/VehicleSetup/FirmwareUpgradeController.cc index aded1d2c8aeb..489d566e6964 100644 --- a/src/Vehicle/VehicleSetup/FirmwareUpgradeController.cc +++ b/src/Vehicle/VehicleSetup/FirmwareUpgradeController.cc @@ -254,7 +254,7 @@ QStringList FirmwareUpgradeController::availableBoardsName(void) return names; } -void FirmwareUpgradeController::_foundBoard(bool firstAttempt, const QSerialPortInfo& info, int boardType, QString boardName) +void FirmwareUpgradeController::_foundBoard(bool firstAttempt, const QGCSerialPortInfo& info, int boardType, QString boardName) { _boardInfo = info; _boardType = static_cast(boardType); diff --git a/src/Vehicle/VehicleSetup/FirmwareUpgradeController.h b/src/Vehicle/VehicleSetup/FirmwareUpgradeController.h index c785503ff1ea..2008a61c09a3 100644 --- a/src/Vehicle/VehicleSetup/FirmwareUpgradeController.h +++ b/src/Vehicle/VehicleSetup/FirmwareUpgradeController.h @@ -167,7 +167,7 @@ class FirmwareUpgradeController : public QObject private slots: void _firmwareDownloadProgress (qint64 curr, qint64 total); void _firmwareDownloadComplete (bool success, const QString &localFile, const QString &errorMsg); - void _foundBoard (bool firstAttempt, const QSerialPortInfo& portInfo, int boardType, QString boardName); + void _foundBoard (bool firstAttempt, const QGCSerialPortInfo& portInfo, int boardType, QString boardName); void _noBoardFound (void); void _boardGone (void); void _foundBoardInfo (int bootloaderVersion, int boardID, int flashSize); @@ -238,7 +238,7 @@ private slots: bool _searchingForBoard; ///< true: searching for board, false: search for bootloader - QSerialPortInfo _boardInfo; + QGCSerialPortInfo _boardInfo; QGCSerialPortInfo::BoardType_t _boardType; QString _boardTypeName; diff --git a/src/Vehicle/VehicleSetup/VehicleConfigView.qml b/src/Vehicle/VehicleSetup/VehicleConfigView.qml index ced73e139db1..efc211610e82 100644 --- a/src/Vehicle/VehicleSetup/VehicleConfigView.qml +++ b/src/Vehicle/VehicleSetup/VehicleConfigView.qml @@ -221,7 +221,9 @@ Rectangle { Connections { target: QGroundControl.multiVehicleManager function onParameterReadyVehicleAvailableChanged(parametersReady) { - if (parametersReady || _selectedSpecial === "summary" || _selectedSpecial !== "firmware") { + // Refresh the summary on any connection/param-availability change, but never while the + // user is on the firmware page — a post-flash reconnect must not clobber the upgrade panel. + if (_selectedSpecial !== "firmware") { _showSummaryPanel() } } @@ -538,7 +540,7 @@ Rectangle { ConfigButton { id: firmwareButton icon.source: "/qmlimages/FirmwareUpgradeIcon.png" - visible: !ScreenTools.isMobile && _corePlugin.options.showFirmwareUpgrade && + visible: Qt.platform.os !== "ios" && _corePlugin.options.showFirmwareUpgrade && vehicleConfigView._searchQuery.trim() === "" text: qsTr("Firmware") Layout.fillWidth: true diff --git a/test/Comms/CMakeLists.txt b/test/Comms/CMakeLists.txt index 5ae931a593f9..4ceea7647cb2 100644 --- a/test/Comms/CMakeLists.txt +++ b/test/Comms/CMakeLists.txt @@ -1,6 +1,6 @@ # ============================================================================ # Communications Unit Tests -# Tests for serial port detection and communication links +# Tests for communication links; protocol- and transport-specific tests live in subdirectories # ============================================================================ target_sources(${CMAKE_PROJECT_NAME} @@ -9,14 +9,12 @@ target_sources(${CMAKE_PROJECT_NAME} LinkConfigurationTest.h LinkManagerTest.cc LinkManagerTest.h - QGCSerialPortInfoTest.cc - QGCSerialPortInfoTest.h ) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_subdirectory(Bluetooth) +add_subdirectory(Serial) add_qgc_test(LinkConfigurationTest LABELS Unit Comms RESOURCE_LOCK Settings TempFiles) add_qgc_test(LinkManagerTest LABELS Integration Comms SERIAL) -add_qgc_test(QGCSerialPortInfoTest LABELS Unit Comms) diff --git a/test/Comms/QGCSerialPortInfoTest.cc b/test/Comms/QGCSerialPortInfoTest.cc deleted file mode 100644 index ded8fda515cc..000000000000 --- a/test/Comms/QGCSerialPortInfoTest.cc +++ /dev/null @@ -1,124 +0,0 @@ -#include "QGCSerialPortInfoTest.h" - -#include "QGCSerialPortInfo.h" - -void QGCSerialPortInfoTest::_testLoadJsonData() -{ - QVERIFY(!QGCSerialPortInfo::_jsonLoaded); - QVERIFY(QGCSerialPortInfo::_loadJsonData()); - QVERIFY(QGCSerialPortInfo::_jsonLoaded); - QVERIFY(QGCSerialPortInfo::_jsonDataValid); - QVERIFY(!QGCSerialPortInfo::_boardInfoList.isEmpty()); - QVERIFY(!QGCSerialPortInfo::_boardDescriptionFallbackList.isEmpty()); - QVERIFY(!QGCSerialPortInfo::_boardManufacturerFallbackList.isEmpty()); -} - -void QGCSerialPortInfoTest::_testLoadJsonDataIdempotent() -{ - QVERIFY(QGCSerialPortInfo::_loadJsonData()); - const int boardCount = QGCSerialPortInfo::_boardInfoList.count(); - const int descriptionFallbackCount = QGCSerialPortInfo::_boardDescriptionFallbackList.count(); - const int manufacturerFallbackCount = QGCSerialPortInfo::_boardManufacturerFallbackList.count(); - - QVERIFY(QGCSerialPortInfo::_loadJsonData()); - QCOMPARE(QGCSerialPortInfo::_boardInfoList.count(), boardCount); - QCOMPARE(QGCSerialPortInfo::_boardDescriptionFallbackList.count(), descriptionFallbackCount); - QCOMPARE(QGCSerialPortInfo::_boardManufacturerFallbackList.count(), manufacturerFallbackCount); -} - -void QGCSerialPortInfoTest::_testBoardClassStringToType() -{ - QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QStringLiteral("Pixhawk")), - QGCSerialPortInfo::BoardTypePixhawk); - QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QStringLiteral("SiK Radio")), - QGCSerialPortInfo::BoardTypeSiKRadio); - QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QStringLiteral("OpenPilot")), - QGCSerialPortInfo::BoardTypeOpenPilot); - QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QStringLiteral("RTK GPS")), - QGCSerialPortInfo::BoardTypeRTKGPS); - QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QStringLiteral("UnknownClass")), - QGCSerialPortInfo::BoardTypeUnknown); -} - -void QGCSerialPortInfoTest::_testBoardTypeToString() -{ - QCOMPARE(QGCSerialPortInfo::_boardTypeToString(QGCSerialPortInfo::BoardTypePixhawk), - QStringLiteral("Pixhawk")); - QCOMPARE(QGCSerialPortInfo::_boardTypeToString(QGCSerialPortInfo::BoardTypeSiKRadio), - QStringLiteral("SiK Radio")); - QCOMPARE(QGCSerialPortInfo::_boardTypeToString(QGCSerialPortInfo::BoardTypeOpenPilot), - QStringLiteral("OpenPilot")); - QCOMPARE(QGCSerialPortInfo::_boardTypeToString(QGCSerialPortInfo::BoardTypeRTKGPS), - QStringLiteral("RTK GPS")); - QCOMPARE(QGCSerialPortInfo::_boardTypeToString(QGCSerialPortInfo::BoardTypeUnknown), - QStringLiteral("Unknown")); -} - -void QGCSerialPortInfoTest::_testBoardClassStringToTypeCaseInsensitivity() -{ - // String-to-type lookup is expected to treat whitespace/case gracefully on - // the Unknown fallback path: anything that doesn't exactly match a known - // class string must resolve to BoardTypeUnknown rather than asserting. - QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QString()), - QGCSerialPortInfo::BoardTypeUnknown); - QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QStringLiteral(" ")), - QGCSerialPortInfo::BoardTypeUnknown); - QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QStringLiteral("pixhawk")), - QGCSerialPortInfo::BoardTypeUnknown); // case-sensitive today -} - -void QGCSerialPortInfoTest::_testBoardTypeStringRoundTrip() -{ - // For every known board type, `_boardTypeToString` -> `_boardClassStringToType` - // must round-trip exactly. This pins the invariant that the two lookup - // tables stay in sync when either is extended. - const QGCSerialPortInfo::BoardType_t types[] = { - QGCSerialPortInfo::BoardTypePixhawk, - QGCSerialPortInfo::BoardTypeSiKRadio, - QGCSerialPortInfo::BoardTypeOpenPilot, - QGCSerialPortInfo::BoardTypeRTKGPS, - }; - for (QGCSerialPortInfo::BoardType_t t : types) { - const QString s = QGCSerialPortInfo::_boardTypeToString(t); - QVERIFY2(!s.isEmpty(), "_boardTypeToString must return non-empty for known types"); - QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(s), t); - } -} - -void QGCSerialPortInfoTest::_testBoardInfoListEntriesAreWellFormed() -{ - QVERIFY(QGCSerialPortInfo::_loadJsonData()); - - for (const auto& entry : QGCSerialPortInfo::_boardInfoList) { - // VID/PID are 16-bit USB identifiers; negative or >0xFFFF would mean - // the JSON parser produced junk. - QVERIFY2(entry.vendorId >= 0 && entry.vendorId <= 0xFFFF, - qPrintable(QStringLiteral("vendorId out of range for %1").arg(entry.name))); - QVERIFY2(entry.productId >= 0 && entry.productId <= 0xFFFF, - qPrintable(QStringLiteral("productId out of range for %1").arg(entry.name))); - QVERIFY2(!entry.name.isEmpty(), "board entry missing name"); - QVERIFY2(entry.boardType != QGCSerialPortInfo::BoardTypeUnknown, - qPrintable(QStringLiteral("board %1 has BoardTypeUnknown class") - .arg(entry.name))); - } -} - -void QGCSerialPortInfoTest::_testFallbackRegexesCompile() -{ - QVERIFY(QGCSerialPortInfo::_loadJsonData()); - - // Any invalid regex in the JSON would make runtime detection silently - // fail, so assert compile-time validity here instead. - for (const auto& entry : QGCSerialPortInfo::_boardDescriptionFallbackList) { - QVERIFY2(entry.regExp.isValid(), - qPrintable(QStringLiteral("invalid description regex: %1") - .arg(entry.regExp.pattern()))); - } - for (const auto& entry : QGCSerialPortInfo::_boardManufacturerFallbackList) { - QVERIFY2(entry.regExp.isValid(), - qPrintable(QStringLiteral("invalid manufacturer regex: %1") - .arg(entry.regExp.pattern()))); - } -} - -UT_REGISTER_TEST(QGCSerialPortInfoTest, TestLabel::Unit, TestLabel::Comms) diff --git a/test/Comms/Serial/AndroidSerialRxQueueTest.cc b/test/Comms/Serial/AndroidSerialRxQueueTest.cc new file mode 100644 index 000000000000..e83dba2ad875 --- /dev/null +++ b/test/Comms/Serial/AndroidSerialRxQueueTest.cc @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#include "AndroidSerialRxQueueTest.h" + +#include "AndroidSerialRxQueue.h" + +#include + +UT_REGISTER_TEST(AndroidSerialRxQueueTest, TestLabel::Unit, TestLabel::Comms) + +void AndroidSerialRxQueueTest::_reserveWithinCap_acceptsAndTracksPending() +{ + AndroidSerialRxQueue queue(100); + + const AndroidSerialRxQueue::Reservation reservation = queue.reserve(40); + QVERIFY(reservation.accepted); + QVERIFY(!reservation.shouldWarn); + QCOMPARE(reservation.generation, 0u); + QCOMPARE(queue.pendingBytes(), 40); +} + +void AndroidSerialRxQueueTest::_reserveOverCap_dropsAndWarnsOnce() +{ + AndroidSerialRxQueue queue(100); + + QVERIFY(queue.reserve(80).accepted); + + const AndroidSerialRxQueue::Reservation first = queue.reserve(80); + QVERIFY(!first.accepted); + QVERIFY(first.shouldWarn); + + const AndroidSerialRxQueue::Reservation second = queue.reserve(80); + QVERIFY(!second.accepted); + QVERIFY(!second.shouldWarn); + + QCOMPARE(queue.pendingBytes(), 80); +} + +void AndroidSerialRxQueueTest::_releaseReservation_balancesPendingAndResetsWarn() +{ + AndroidSerialRxQueue queue(100); + + QVERIFY(queue.reserve(80).accepted); + QVERIFY(queue.reserve(80).shouldWarn); // latches the queue-cap warning + + queue.releaseReservation(80); + QCOMPARE(queue.pendingBytes(), 0); + + // Draining to zero re-arms the warn-once latch, so the next overflow logs again. + QVERIFY(queue.reserve(80).accepted); + QVERIFY(queue.reserve(80).shouldWarn); +} + +void AndroidSerialRxQueueTest::_isStale_afterFlush_dropsOldGeneration() +{ + AndroidSerialRxQueue queue; + + const AndroidSerialRxQueue::Reservation before = queue.reserve(10); + QVERIFY(before.accepted); + QVERIFY(!queue.isStale(before.generation)); + + queue.flush(); + QVERIFY(queue.isStale(before.generation)); + + const AndroidSerialRxQueue::Reservation after = queue.reserve(10); + QVERIFY(!queue.isStale(after.generation)); +} + +void AndroidSerialRxQueueTest::_checkBufferCap_warnsOnceThenResetsOnAccept() +{ + AndroidSerialRxQueue queue(100); + + const AndroidSerialRxQueue::BufferCapDecision over1 = queue.checkBufferCap(90, 20); + QVERIFY(over1.overCap); + QVERIFY(over1.shouldWarn); + + const AndroidSerialRxQueue::BufferCapDecision over2 = queue.checkBufferCap(90, 20); + QVERIFY(over2.overCap); + QVERIFY(!over2.shouldWarn); + + const AndroidSerialRxQueue::BufferCapDecision within = queue.checkBufferCap(10, 20); + QVERIFY(!within.overCap); + + // Accepting (within cap) re-arms the buffer-cap warning. + const AndroidSerialRxQueue::BufferCapDecision over3 = queue.checkBufferCap(90, 20); + QVERIFY(over3.overCap); + QVERIFY(over3.shouldWarn); +} + +void AndroidSerialRxQueueTest::_flush_bumpsGenerationAndZeroesPending() +{ + AndroidSerialRxQueue queue; + + QVERIFY(queue.reserve(50).accepted); + const quint32 generation = queue.generation(); + + queue.flush(); + + QCOMPARE(queue.pendingBytes(), 0); + QCOMPARE(queue.generation(), generation + 1); +} diff --git a/test/Comms/Serial/AndroidSerialRxQueueTest.h b/test/Comms/Serial/AndroidSerialRxQueueTest.h new file mode 100644 index 000000000000..3056ea541a9d --- /dev/null +++ b/test/Comms/Serial/AndroidSerialRxQueueTest.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#pragma once + +#include "UnitTest.h" + +// Host test for AndroidSerialRxQueue — the pure RX flow-control accounting (backlog/epoch/warn latches). +class AndroidSerialRxQueueTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _reserveWithinCap_acceptsAndTracksPending(); + void _reserveOverCap_dropsAndWarnsOnce(); + void _releaseReservation_balancesPendingAndResetsWarn(); + void _isStale_afterFlush_dropsOldGeneration(); + void _checkBufferCap_warnsOnceThenResetsOnAccept(); + void _flush_bumpsGenerationAndZeroesPending(); +}; diff --git a/test/Comms/Serial/CMakeLists.txt b/test/Comms/Serial/CMakeLists.txt new file mode 100644 index 000000000000..a0b0d89f4efc --- /dev/null +++ b/test/Comms/Serial/CMakeLists.txt @@ -0,0 +1,50 @@ +# ============================================================================ +# Serial Communication Tests +# Tests for serial port detection, configuration, and the serial link stack +# ============================================================================ + +target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + AndroidSerialRxQueueTest.cc + AndroidSerialRxQueueTest.h + HostSerialPortTest.cc + HostSerialPortTest.h + MockSerialPort.cc + MockSerialPort.h + NmeaSerialDeviceTest.cc + NmeaSerialDeviceTest.h + PortRegistryTest.cc + PortRegistryTest.h + QGCSerialPortInfoTest.cc + QGCSerialPortInfoTest.h + SerialConfigurationTest.cc + SerialConfigurationTest.h + SerialLinkTest.cc + SerialLinkTest.h + SerialPortInfoCodecTest.cc + SerialPortInfoCodecTest.h + SerialWireContractTest.cc + SerialWireContractTest.h + SerialWorkerTest.cc + SerialWorkerTest.h +) + +target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +# SerialPortInfoCodecTest loads data/PortInfoGolden.json — the cross-language wire-contract fixture also +# consumed by the Java UsbPortInfoPackingTest (android/build.gradle test resources.srcDirs). +# TARGET_DIRECTORY scopes the SOURCE property to the root-defined main target (CMP0118). +set_property(SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/SerialPortInfoCodecTest.cc + TARGET_DIRECTORY ${CMAKE_PROJECT_NAME} + APPEND PROPERTY COMPILE_DEFINITIONS "SERIAL_TESTDATA_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}/data\"") + +add_qgc_test(AndroidSerialRxQueueTest LABELS Unit Comms) +add_qgc_test(HostSerialPortTest LABELS Unit Comms) +add_qgc_test(NmeaSerialDeviceTest LABELS Unit Comms) +add_qgc_test(PortRegistryTest LABELS Unit Comms) +add_qgc_test(QGCSerialPortInfoTest LABELS Unit Comms) +add_qgc_test(SerialPortInfoCodecTest LABELS Unit Comms) +add_qgc_test(SerialWireContractTest LABELS Unit Comms) +add_qgc_test(SerialConfigurationTest LABELS Unit Comms RESOURCE_LOCK Settings TempFiles) +add_qgc_test(SerialWorkerTest LABELS Unit Comms) +add_qgc_test(SerialLinkTest LABELS Integration Comms SERIAL) diff --git a/test/Comms/Serial/HostSerialPortTest.cc b/test/Comms/Serial/HostSerialPortTest.cc new file mode 100644 index 000000000000..171ee06f024d --- /dev/null +++ b/test/Comms/Serial/HostSerialPortTest.cc @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#include "HostSerialPortTest.h" + +#include "HostSerialPort.h" +#include "QGCSerialPortTypes.h" +#include "UnitTest.h" + +#include +#include + +#ifdef Q_OS_LINUX +#include +#include +#include +#endif + +UT_REGISTER_TEST(HostSerialPortTest, TestLabel::Unit, TestLabel::Comms) + +void HostSerialPortTest::_construct_noPortBaseline() +{ + // Construction yields a usable QSerialPort-backed port. We can't fully exercise without a real + // port; assert the no-port baseline. + HostSerialPort port(QString{}); + QVERIFY(!port.isOpen()); + QCOMPARE(port.error(), QGCSerialPortError::NoError); +} + +void HostSerialPortTest::_invalidConfigSetsError() +{ + HostSerialPort port(QString{}); + int errorCount = 0; + QGCSerialPortError lastError = QGCSerialPortError::NoError; + connect(&port, &QGCSerialPort::errorOccurred, this, [&](QGCSerialPortError error) { + ++errorCount; + lastError = error; + }); + + SerialPortConfig cfg; + cfg.baud = 0; + + QVERIFY(!port.reconfigure(cfg)); + QCOMPARE(errorCount, 1); + QCOMPARE(lastError, QGCSerialPortError::UnsupportedOperation); + QCOMPARE(port.error(), QGCSerialPortError::UnsupportedOperation); + QCOMPARE(port.errorString(), QStringLiteral("Invalid serial configuration")); +} + +void HostSerialPortTest::_ptyLoopback_readsAndWrites() +{ +#ifdef Q_OS_LINUX + const int master = ::posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0) { + QSKIP("posix_openpt unavailable in this environment"); + } + if ((::grantpt(master) != 0) || (::unlockpt(master) != 0)) { + ::close(master); + QSKIP("pty grant/unlock failed"); + } + (void) ::fcntl(master, F_SETFL, O_NONBLOCK); + const char *slavePath = ::ptsname(master); + if (!slavePath) { + ::close(master); + QSKIP("ptsname failed"); + } + + HostSerialPort port(QString::fromLocal8Bit(slavePath)); + SerialPortConfig cfg; + cfg.baud = 57600; + if (!port.openConfigured(QIODevice::ReadWrite, cfg)) { + ::close(master); + QSKIP("HostSerialPort could not open pty slave"); + } + + const QByteArray inbound = QByteArrayLiteral("ping"); + QCOMPARE(::write(master, inbound.constData(), inbound.size()), static_cast(inbound.size())); + + QByteArray received; + QElapsedTimer timer; + timer.start(); + while ((received.size() < inbound.size()) && (timer.elapsed() < 1000)) { + if (port.waitForReadyRead(100)) { + received.append(port.readAll()); + } + } + QCOMPARE(received, inbound); + + const QByteArray outbound = QByteArrayLiteral("pong"); + QCOMPARE(port.write(outbound), static_cast(outbound.size())); + QVERIFY(port.flush()); + + QByteArray echoed; + timer.restart(); + while ((echoed.size() < outbound.size()) && (timer.elapsed() < 1000)) { + char buf[16]; + const ssize_t n = ::read(master, buf, sizeof(buf)); + if (n > 0) { + echoed.append(buf, static_cast(n)); + } else { + QTest::qWait(20); + } + } + QCOMPARE(echoed, outbound); + + port.close(); + ::close(master); +#else + QSKIP("PTY loopback is Linux-only"); +#endif +} diff --git a/test/Comms/Serial/HostSerialPortTest.h b/test/Comms/Serial/HostSerialPortTest.h new file mode 100644 index 000000000000..df13d1a58aee --- /dev/null +++ b/test/Comms/Serial/HostSerialPortTest.h @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#pragma once + +#include "UnitTest.h" + +// Validates HostSerialPort (wraps QSerialPort) — the host build's QGCSerialPort impl, and the +// Android "/dev/tty*" direct-UART path. AndroidSerialPort is exercised on Android-only builds. +class HostSerialPortTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _construct_noPortBaseline(); + void _invalidConfigSetsError(); + void _ptyLoopback_readsAndWrites(); +}; diff --git a/test/Comms/Serial/MockSerialPort.cc b/test/Comms/Serial/MockSerialPort.cc new file mode 100644 index 000000000000..29dd078a826d --- /dev/null +++ b/test/Comms/Serial/MockSerialPort.cc @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#include "MockSerialPort.h" + +#include + +MockSerialPort::MockSerialPort(const QString &portName, QObject *parent) + : QGCSerialPort(parent), _portName(portName) +{ +} + +bool MockSerialPort::open(QIODevice::OpenMode mode) +{ + if (!_openResult) { + _setError(QGCSerialPortError::OpenFailed, QStringLiteral("mock open refused")); + return false; + } + return QIODevice::open(mode); +} + +void MockSerialPort::close() +{ + _rxBuffer.clear(); + _txBuffered = 0; + QIODevice::close(); +} + +bool MockSerialPort::reconfigure(const SerialPortConfig &cfg) +{ + _lastConfig = cfg; + if (!cfg.isValid()) { + _setError(QGCSerialPortError::UnsupportedOperation, QStringLiteral("invalid serial config")); + return false; + } + if (!_reconfigureResult) { + _setError(QGCSerialPortError::UnsupportedOperation, QStringLiteral("mock reconfigure refused")); + return false; + } + return true; +} + +void MockSerialPort::feedReceived(const QByteArray &data) +{ + if (data.isEmpty()) { + return; + } + _rxBuffer.append(data); + if (isOpen() && (openMode() & QIODevice::ReadOnly)) { + emit readyRead(); + } +} + +void MockSerialPort::injectError(QGCSerialPortError error, const QString &errorString) +{ + _setError(error, errorString); +} + +qint64 MockSerialPort::readData(char *data, qint64 maxSize) +{ + const qint64 n = qMin(maxSize, _rxBuffer.size()); + if (n > 0) { + std::memcpy(data, _rxBuffer.constData(), static_cast(n)); + _rxBuffer.remove(0, n); + } + return n; +} + +qint64 MockSerialPort::writeData(const char *data, qint64 size) +{ + if (!isOpen() || !(openMode() & QIODevice::WriteOnly)) { + _setError(QGCSerialPortError::NotOpen, QStringLiteral("write on closed mock port")); + return -1; + } + if (_writeShouldFail) { + _setError(QGCSerialPortError::Write, QStringLiteral("mock write failure")); + return -1; + } + if (_writeStalled || ((_txBuffered + size) > _writeBufferSize)) { + return 0; // TX buffer full — mirrors HostSerialPort backpressure (write() returns 0). + } + + _written.append(data, static_cast(size)); + _txBuffered += size; + // QSerialPort emits bytesWritten asynchronously after the OS drains; emitting it synchronously here would + // reenter SerialWorker::_flushPendingWrites (connected to bytesWritten) and recurse. Defer to the event loop, + // releasing the in-flight occupancy as the "OS" drains it. + QMetaObject::invokeMethod(this, [this, size]() { _txBuffered -= size; emit bytesWritten(size); }, + Qt::QueuedConnection); + + if (_loopback) { + feedReceived(QByteArray(data, static_cast(size))); + } + return size; +} + +void MockSerialPort::resumeWrites() +{ + _writeStalled = false; + emit bytesWritten(0); // wakes SerialWorker::_onPortBytesWritten to drain its pending backlog +} + +void MockSerialPort::_setError(QGCSerialPortError error, const QString &errorString) +{ + _error = error; + setErrorString(errorString); + emit errorOccurred(error); +} diff --git a/test/Comms/Serial/MockSerialPort.h b/test/Comms/Serial/MockSerialPort.h new file mode 100644 index 000000000000..563e41f68a35 --- /dev/null +++ b/test/Comms/Serial/MockSerialPort.h @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#pragma once + +// In-memory QGCSerialPort for unit tests: no OS device, no hardware. Inject via +// SerialPlatform::setPortFactoryForTest() to drive SerialWorker/SerialLink, or use directly. +// feedReceived(bytes) -> appears on the wire, emits readyRead() +// writtenData() -> everything the port was asked to send +// setLoopback(true) -> writes echo straight back into the RX buffer +// injectError()/setOpenResult()/setReconfigureResult() -> scripted failure paths + +#include "QGCSerialPort.h" + +#include + +class MockSerialPort final : public QGCSerialPort +{ + Q_OBJECT + +public: + explicit MockSerialPort(const QString &portName = {}, QObject *parent = nullptr); + + void feedReceived(const QByteArray &data); + QByteArray writtenData() const { return _written; } + void clearWritten() { _written.clear(); } + + void setLoopback(bool on) { _loopback = on; } + + void setOpenResult(bool ok) { _openResult = ok; } + void setReconfigureResult(bool ok) { _reconfigureResult = ok; } + void injectError(QGCSerialPortError error, const QString &errorString = {}); + + // Simulate a full TX buffer: writeData() returns 0 (no bytes accepted) until resumeWrites() drains it. + void setWriteStalled(bool on) { _writeStalled = on; } + void resumeWrites(); + // Next writeData() returns -1 with a Write error, exercising the worker's write-failure path. + void setWriteShouldFail(bool on) { _writeShouldFail = on; } + + SerialPortConfig lastConfig() const { return _lastConfig; } + bool dataTerminalReady() const { return _dtr; } + + // QGCSerialPort + void setPortName(const QString &name) override { _portName = name; } + QString portName() const override { return _portName; } + bool reconfigure(const SerialPortConfig &cfg) override; + bool setDataTerminalReady(bool on) override { _dtr = on; return true; } + bool flush() override { return true; } + void setWriteBufferSize(qint64 size) override { _writeBufferSize = size; } + qint64 writeBufferSize() const override { return _writeBufferSize; } + QGCSerialPortError error() const override { return _error; } + void clearError() override { _error = QGCSerialPortError::NoError; } + + // QIODevice + bool open(QIODevice::OpenMode mode) override; + void close() override; + bool isSequential() const override { return true; } + qint64 bytesAvailable() const override { return _rxBuffer.size() + QIODevice::bytesAvailable(); } + +protected: + qint64 readData(char *data, qint64 maxSize) override; + qint64 writeData(const char *data, qint64 size) override; + +private: + void _setError(QGCSerialPortError error, const QString &errorString); + + QString _portName; + QByteArray _rxBuffer; + QByteArray _written; // cumulative TX history for assertions, never drained + qint64 _txBuffered = 0; // in-flight TX occupancy for backpressure; drains as bytesWritten fires + SerialPortConfig _lastConfig; + QGCSerialPortError _error = QGCSerialPortError::NoError; + qint64 _writeBufferSize = kSerialWriteBufferCapBytes; + bool _loopback = false; + bool _dtr = false; + bool _openResult = true; + bool _reconfigureResult = true; + bool _writeStalled = false; + bool _writeShouldFail = false; +}; diff --git a/test/Comms/Serial/NmeaSerialDeviceTest.cc b/test/Comms/Serial/NmeaSerialDeviceTest.cc new file mode 100644 index 000000000000..f11bbe4fe5d3 --- /dev/null +++ b/test/Comms/Serial/NmeaSerialDeviceTest.cc @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#include "NmeaSerialDeviceTest.h" + +#include "NmeaSerialDevice.h" +#include "UnitTest.h" + +#include + +UT_REGISTER_TEST(NmeaSerialDeviceTest, TestLabel::Unit, TestLabel::Comms) + +namespace { +// "/dev/tty*" prefix routes to the QSerialPort-backed path on both host and Android (not the JNI +// USB-host path), so the worker fails to open this nonexistent device identically on either platform. +constexpr auto kBogusPort = "/dev/ttyQGCNmeaTestNoSuchPort"; +constexpr qint32 kBaud = 9600; +} + +void NmeaSerialDeviceTest::_construct_baseline() +{ + NmeaSerialDevice device(QString::fromLatin1(kBogusPort), kBaud); + QVERIFY(device.isSequential()); + QVERIFY(!device.isOpen()); +} + +void NmeaSerialDeviceTest::_openClose_lifecycle() +{ + NmeaSerialDevice device(QString::fromLatin1(kBogusPort), kBaud); + + QVERIFY(device.open(QIODevice::ReadOnly)); + QVERIFY(device.isOpen()); + QVERIFY(!device.open(QIODevice::ReadOnly)); // already open + + // Worker can't open a bogus port, so no bytes are ever pushed into the primed read buffer. + QTest::qWait(200); + QCOMPARE(device.bytesAvailable(), 0); + QVERIFY(!device.canReadLine()); + QVERIFY(device.readAll().isEmpty()); + + device.close(); + QVERIFY(!device.isOpen()); + + QVERIFY(device.open(QIODevice::ReadOnly)); // reusable after close + device.close(); + QVERIFY(!device.isOpen()); +} + +void NmeaSerialDeviceTest::_readOnly_notWritable() +{ + NmeaSerialDevice device(QString::fromLatin1(kBogusPort), kBaud); + QVERIFY(device.open(QIODevice::ReadOnly)); + QVERIFY(!device.isWritable()); // write() would return -1; asserting the contract avoids the QIODevice warning + device.close(); +} diff --git a/test/Comms/Serial/NmeaSerialDeviceTest.h b/test/Comms/Serial/NmeaSerialDeviceTest.h new file mode 100644 index 000000000000..a9e69db101ce --- /dev/null +++ b/test/Comms/Serial/NmeaSerialDeviceTest.h @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#pragma once + +#include "UnitTest.h" + +// Validates NmeaSerialDevice — the worker-threaded, read-only QIODevice that feeds QNmeaPositionInfoSource. +// Exercises the open/close lifecycle and the empty-buffer / read-only contract without real hardware +// (the worker fails to open a bogus port and exits cleanly). +class NmeaSerialDeviceTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _construct_baseline(); + void _openClose_lifecycle(); + void _readOnly_notWritable(); +}; diff --git a/test/Comms/Serial/PortRegistryTest.cc b/test/Comms/Serial/PortRegistryTest.cc new file mode 100644 index 000000000000..a5a3b1cd727b --- /dev/null +++ b/test/Comms/Serial/PortRegistryTest.cc @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#include "PortRegistryTest.h" + +#include "AndroidSerialPortRegistry.h" + +#include + +UT_REGISTER_TEST(PortRegistryTest, TestLabel::Unit, TestLabel::Comms) + +namespace { +// The registry only stores/returns the pointer (never dereferences), so opaque non-null values stand in. +AndroidSerialPort* fakePort(quintptr id) +{ + return reinterpret_cast(id); +} +} // namespace + +void PortRegistryTest::_allocateToken_isStrictlyIncreasingAndNonZero() +{ + const PortRegistry::Token first = PortRegistry::allocateToken(); + const PortRegistry::Token second = PortRegistry::allocateToken(); + const PortRegistry::Token third = PortRegistry::allocateToken(); + + QVERIFY(first != 0); + QVERIFY(second > first); + QVERIFY(third > second); +} + +void PortRegistryTest::_lookup_unregisteredToken_isNull() +{ + PortRegistry::LookupGuard guard(PortRegistry::allocateToken()); + QVERIFY(!guard); + QCOMPARE(guard.port(), nullptr); +} + +void PortRegistryTest::_registerThenLookup_resolvesPort() +{ + const PortRegistry::Token token = PortRegistry::allocateToken(); + AndroidSerialPort* const port = fakePort(0x1000); + PortRegistry::registerPort(token, port); + + { + PortRegistry::LookupGuard guard(token); + QVERIFY(guard); + QCOMPARE(guard.port(), port); + } + + PortRegistry::unregisterPort(token); +} + +void PortRegistryTest::_unregister_dropsLookup() +{ + const PortRegistry::Token token = PortRegistry::allocateToken(); + PortRegistry::registerPort(token, fakePort(0x2000)); + PortRegistry::unregisterPort(token); + + PortRegistry::LookupGuard guard(token); + QVERIFY(!guard); +} + +void PortRegistryTest::_clear_dropsAllLookups() +{ + const PortRegistry::Token a = PortRegistry::allocateToken(); + const PortRegistry::Token b = PortRegistry::allocateToken(); + PortRegistry::registerPort(a, fakePort(0x3000)); + PortRegistry::registerPort(b, fakePort(0x4000)); + + PortRegistry::clear(); + + { + PortRegistry::LookupGuard guardA(a); + QVERIFY(!guardA); + } + { + PortRegistry::LookupGuard guardB(b); + QVERIFY(!guardB); + } +} + +void PortRegistryTest::_register_overwritesStaleTokenMapping() +{ + const PortRegistry::Token token = PortRegistry::allocateToken(); + PortRegistry::registerPort(token, fakePort(0x5000)); + AndroidSerialPort* const replacement = fakePort(0x6000); + PortRegistry::registerPort(token, replacement); + + { + PortRegistry::LookupGuard guard(token); + QCOMPARE(guard.port(), replacement); + } + + PortRegistry::unregisterPort(token); +} diff --git a/test/Comms/Serial/PortRegistryTest.h b/test/Comms/Serial/PortRegistryTest.h new file mode 100644 index 000000000000..8fc4ccf93e05 --- /dev/null +++ b/test/Comms/Serial/PortRegistryTest.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#pragma once + +#include "UnitTest.h" + +// Host test for PortRegistry — the jni-free token->port map that fences port lifetime in JNI callbacks. +class PortRegistryTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _allocateToken_isStrictlyIncreasingAndNonZero(); + void _lookup_unregisteredToken_isNull(); + void _registerThenLookup_resolvesPort(); + void _unregister_dropsLookup(); + void _clear_dropsAllLookups(); + void _register_overwritesStaleTokenMapping(); +}; diff --git a/test/Comms/Serial/QGCSerialPortInfoTest.cc b/test/Comms/Serial/QGCSerialPortInfoTest.cc new file mode 100644 index 000000000000..b6216a686162 --- /dev/null +++ b/test/Comms/Serial/QGCSerialPortInfoTest.cc @@ -0,0 +1,161 @@ +#include "QGCSerialPortInfoTest.h" + +#include "QGCSerialPortInfo.h" + +#include + +#include +#include +#include +#include +#include +#include + +void QGCSerialPortInfoTest::_testLoadJsonData() +{ + const QGCSerialPortInfo::BoardDatabase &db = QGCSerialPortInfo::_boardDatabase(); + QVERIFY(db.valid); + QVERIFY(!db.boardInfo.isEmpty()); + QVERIFY(!db.descriptionFallback.isEmpty()); + QVERIFY(!db.manufacturerFallback.isEmpty()); +} + +void QGCSerialPortInfoTest::_testLoadJsonDataIdempotent() +{ + const QGCSerialPortInfo::BoardDatabase &first = QGCSerialPortInfo::_boardDatabase(); + const QGCSerialPortInfo::BoardDatabase &second = QGCSerialPortInfo::_boardDatabase(); + QCOMPARE(&first, &second); // magic-static parses exactly once + QCOMPARE(second.boardInfo.count(), first.boardInfo.count()); + QCOMPARE(second.descriptionFallback.count(), first.descriptionFallback.count()); + QCOMPARE(second.manufacturerFallback.count(), first.manufacturerFallback.count()); +} + +void QGCSerialPortInfoTest::_testBoardTypeStringMapping_data() +{ + QTest::addColumn("type"); + QTest::addColumn("string"); + QTest::addColumn("roundTrips"); + + QTest::newRow("Pixhawk") << QGCSerialPortInfo::BoardTypePixhawk << QStringLiteral("Pixhawk") << true; + QTest::newRow("SiK Radio") << QGCSerialPortInfo::BoardTypeSiKRadio << QStringLiteral("SiK Radio") << true; + QTest::newRow("OpenPilot") << QGCSerialPortInfo::BoardTypeOpenPilot << QStringLiteral("OpenPilot") << true; + QTest::newRow("RTK GPS") << QGCSerialPortInfo::BoardTypeRTKGPS << QStringLiteral("RTK GPS") << true; + // BoardTypeUnknown stringifies to "Unknown", but no class string parses back to it. + QTest::newRow("Unknown") << QGCSerialPortInfo::BoardTypeUnknown << QStringLiteral("Unknown") << false; +} + +void QGCSerialPortInfoTest::_testBoardTypeStringMapping() +{ + QFETCH(const QGCSerialPortInfo::BoardType_t, type); + QFETCH(const QString, string); + QFETCH(const bool, roundTrips); + + QCOMPARE(QGCSerialPortInfo::_boardTypeToString(type), string); + if (roundTrips) { + QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(string), type); + } +} + +void QGCSerialPortInfoTest::_testBoardClassStringToTypeCaseInsensitivity() +{ + // String-to-type lookup is expected to treat whitespace/case gracefully on + // the Unknown fallback path: anything that doesn't exactly match a known + // class string must resolve to BoardTypeUnknown rather than asserting. + QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QString()), + QGCSerialPortInfo::BoardTypeUnknown); + QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QStringLiteral(" ")), + QGCSerialPortInfo::BoardTypeUnknown); + QCOMPARE(QGCSerialPortInfo::_boardClassStringToType(QStringLiteral("pixhawk")), + QGCSerialPortInfo::BoardTypeUnknown); // case-sensitive today +} + +void QGCSerialPortInfoTest::_testBoardInfoListEntriesAreWellFormed() +{ + const QGCSerialPortInfo::BoardDatabase &db = QGCSerialPortInfo::_boardDatabase(); + QVERIFY(db.valid); + + for (const auto& entry : db.boardInfo) { + // VID/PID are 16-bit USB identifiers; negative or >0xFFFF would mean + // the JSON parser produced junk. + QVERIFY2(entry.vendorId >= 0 && entry.vendorId <= 0xFFFF, + qPrintable(QStringLiteral("vendorId out of range for %1").arg(entry.name))); + QVERIFY2(entry.productId >= 0 && entry.productId <= 0xFFFF, + qPrintable(QStringLiteral("productId out of range for %1").arg(entry.name))); + QVERIFY2(!entry.name.isEmpty(), "board entry missing name"); + QVERIFY2(entry.boardType != QGCSerialPortInfo::BoardTypeUnknown, + qPrintable(QStringLiteral("board %1 has BoardTypeUnknown class") + .arg(entry.name))); + } +} + +void QGCSerialPortInfoTest::_testFallbackRegexesCompile() +{ + const QGCSerialPortInfo::BoardDatabase &db = QGCSerialPortInfo::_boardDatabase(); + QVERIFY(db.valid); + + // Any invalid regex in the JSON would make runtime detection silently + // fail, so assert compile-time validity here instead. + for (const auto& entry : db.descriptionFallback) { + QVERIFY2(entry.regExp.isValid(), + qPrintable(QStringLiteral("invalid description regex: %1") + .arg(entry.regExp.pattern()))); + } + for (const auto& entry : db.manufacturerFallback) { + QVERIFY2(entry.regExp.isValid(), + qPrintable(QStringLiteral("invalid manufacturer regex: %1") + .arg(entry.regExp.pattern()))); + } +} + + +void QGCSerialPortInfoTest::_testFallbackSchemaIsPlatformNeutral() +{ + QFile file(QStringLiteral(":/json/USBBoardInfo.json")); + QVERIFY(file.open(QIODevice::ReadOnly)); + + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); + QCOMPARE(error.error, QJsonParseError::NoError); + + const auto assertNoPlatformKeys = [](const QJsonArray &fallbacks) { + for (const QJsonValue &value : fallbacks) { + QVERIFY(value.isObject()); + const QJsonObject fallback = value.toObject(); + QVERIFY2(!fallback.contains(QStringLiteral("androidOnly")), + qPrintable(QStringLiteral("platform-specific key in fallback regex: %1") + .arg(fallback[QStringLiteral("regExp")].toString()))); + } + }; + + const QJsonObject root = document.object(); + assertNoPlatformKeys(root[QStringLiteral("boardDescriptionFallback")].toArray()); + assertNoPlatformKeys(root[QStringLiteral("boardManufacturerFallback")].toArray()); +} + +void QGCSerialPortInfoTest::_testLinuxSystemPortFiltering() +{ +#ifdef Q_OS_LINUX + const auto makePort = [](const QString &systemLocation) { + QGCSerialPortInfo::Data data; + data.portName = systemLocation.section(QLatin1Char('/'), -1); + data.systemLocation = systemLocation; + return QGCSerialPortInfo(std::move(data)); + }; + + QVERIFY(QGCSerialPortInfo::isSystemPort(makePort(QStringLiteral("/dev/ttyS0")))); + QVERIFY(QGCSerialPortInfo::isSystemPort(makePort(QStringLiteral("/dev/rfcomm0")))); + QVERIFY(QGCSerialPortInfo::isSystemPort(makePort(QStringLiteral("/dev/ttyACM0")))); + QVERIFY(!QGCSerialPortInfo::isSystemPort(makePort(QStringLiteral("/dev/ttyUSB0")))); + + QGCSerialPortInfo::Data usbBoard; + usbBoard.portName = QStringLiteral("ttyACM0"); + usbBoard.systemLocation = QStringLiteral("/dev/ttyACM0"); + usbBoard.vendorIdentifier = 9900; + usbBoard.productIdentifier = 17; + usbBoard.hasVendorIdentifier = true; + usbBoard.hasProductIdentifier = true; + QVERIFY(!QGCSerialPortInfo::isSystemPort(QGCSerialPortInfo(std::move(usbBoard)))); +#endif +} + +UT_REGISTER_TEST(QGCSerialPortInfoTest, TestLabel::Unit, TestLabel::Comms) diff --git a/test/Comms/QGCSerialPortInfoTest.h b/test/Comms/Serial/QGCSerialPortInfoTest.h similarity index 65% rename from test/Comms/QGCSerialPortInfoTest.h rename to test/Comms/Serial/QGCSerialPortInfoTest.h index 6f2c4b67edff..7ef5e7be893d 100644 --- a/test/Comms/QGCSerialPortInfoTest.h +++ b/test/Comms/Serial/QGCSerialPortInfoTest.h @@ -9,10 +9,11 @@ class QGCSerialPortInfoTest : public UnitTest private slots: void _testLoadJsonData(); void _testLoadJsonDataIdempotent(); - void _testBoardClassStringToType(); - void _testBoardTypeToString(); + void _testBoardTypeStringMapping_data(); + void _testBoardTypeStringMapping(); void _testBoardClassStringToTypeCaseInsensitivity(); - void _testBoardTypeStringRoundTrip(); void _testBoardInfoListEntriesAreWellFormed(); void _testFallbackRegexesCompile(); + void _testFallbackSchemaIsPlatformNeutral(); + void _testLinuxSystemPortFiltering(); }; diff --git a/test/Comms/Serial/SerialConfigurationTest.cc b/test/Comms/Serial/SerialConfigurationTest.cc new file mode 100644 index 000000000000..0a3c6aa9ac37 --- /dev/null +++ b/test/Comms/Serial/SerialConfigurationTest.cc @@ -0,0 +1,153 @@ +#include "SerialConfigurationTest.h" + +#include "SerialLink.h" +#include "QGCSerialPortInfo.h" +#include "QGCSerialPortTypes.h" + +#include "Fixtures/RAIIFixtures.h" + +#include +#include + +UT_REGISTER_TEST(SerialConfigurationTest, TestLabel::Unit, TestLabel::Comms) + +void SerialConfigurationTest::_testPortConfigEnumMapping() +{ + SerialConfiguration config(QStringLiteral("EnumMap")); + config.setBaud(115200); + config.setDataBits(static_cast(QGCDataBits::Data7)); + config.setStopBits(static_cast(QGCStopBits::TwoStop)); + config.setParity(static_cast(QGCParity::Even)); + config.setFlowControl(static_cast(QGCFlowControl::HardwareRtsCts)); + + const SerialPortConfig cfg = config.portConfig(); + QCOMPARE(cfg.baud, 115200); + QVERIFY(cfg.dataBits == QGCDataBits::Data7); + QVERIFY(cfg.stopBits == QGCStopBits::TwoStop); + QVERIFY(cfg.parity == QGCParity::Even); + QVERIFY(cfg.flowControl == QGCFlowControl::HardwareRtsCts); + QVERIFY(cfg.isValid()); +} + +void SerialConfigurationTest::_testParityMigrationFromLegacy() +{ + TestFixtures::TempDirFixture tmpDir; + QVERIFY(tmpDir.isValid()); + const QString iniPath = tmpDir.path() + QStringLiteral("/settings.ini"); + const QString root = QStringLiteral("SerialConfigTest_LegacyParity"); + + const int legacyToQgc[][2] = {{0, 0}, {1, 0}, {2, 2}, {3, 1}, {4, 4}, {5, 3}}; + for (const auto &pair : legacyToQgc) { + QSettings settings(iniPath, QSettings::IniFormat); + settings.remove(root); + settings.beginGroup(root); + settings.setValue(QStringLiteral("parity"), pair[0]); + settings.endGroup(); + settings.sync(); + + SerialConfiguration config(QStringLiteral("LegacyLoad")); + config.loadSettings(settings, root); + QCOMPARE(config.parity(), pair[1]); + } +} + +void SerialConfigurationTest::_testParityMigrationClampsOutOfRange() +{ + TestFixtures::TempDirFixture tmpDir; + QVERIFY(tmpDir.isValid()); + const QString iniPath = tmpDir.path() + QStringLiteral("/settings.ini"); + const QString root = QStringLiteral("SerialConfigTest_ParityClamp"); + + const int clamp[][2] = {{99, static_cast(QGCParity::Mark)}, {-7, static_cast(QGCParity::None)}}; + for (const auto &pair : clamp) { + QSettings settings(iniPath, QSettings::IniFormat); + settings.remove(root); + settings.beginGroup(root); + settings.setValue(QStringLiteral("parity"), pair[0]); + settings.endGroup(); + settings.sync(); + + SerialConfiguration config(QStringLiteral("ClampLoad")); + config.loadSettings(settings, root); + QCOMPARE(config.parity(), pair[1]); + } +} + +void SerialConfigurationTest::_testParityV2TakesPrecedenceOverLegacy() +{ + TestFixtures::TempDirFixture tmpDir; + QVERIFY(tmpDir.isValid()); + const QString iniPath = tmpDir.path() + QStringLiteral("/settings.ini"); + const QString root = QStringLiteral("SerialConfigTest_ParityV2"); + + QSettings settings(iniPath, QSettings::IniFormat); + settings.beginGroup(root); + settings.setValue(QStringLiteral("parity"), 2); + settings.setValue(QStringLiteral("parityV2"), static_cast(QGCParity::Odd)); + settings.endGroup(); + settings.sync(); + + SerialConfiguration config(QStringLiteral("V2Load")); + config.loadSettings(settings, root); + QCOMPARE(config.parity(), static_cast(QGCParity::Odd)); +} + +void SerialConfigurationTest::_testSettingsRoundtripWritesParityV2() +{ + TestFixtures::TempDirFixture tmpDir; + QVERIFY(tmpDir.isValid()); + const QString iniPath = tmpDir.path() + QStringLiteral("/settings.ini"); + const QString root = QStringLiteral("SerialConfigTest_Roundtrip"); + QSettings settings(iniPath, QSettings::IniFormat); + + { + SerialConfiguration config(QStringLiteral("Save")); + config.setBaud(921600); + config.setDataBits(static_cast(QGCDataBits::Data7)); + config.setStopBits(static_cast(QGCStopBits::TwoStop)); + config.setParity(static_cast(QGCParity::Mark)); + config.setFlowControl(static_cast(QGCFlowControl::HardwareRtsCts)); + config.setPortName(QStringLiteral("/dev/ttyACM0")); + config.setUsbDirect(true); + config.setdtrForceLow(true); + config.saveSettings(settings, root); + } + + QVERIFY(settings.contains(root + QStringLiteral("/parityV2"))); + QVERIFY(!settings.contains(root + QStringLiteral("/parity"))); + + { + SerialConfiguration config(QStringLiteral("Load")); + config.loadSettings(settings, root); + QCOMPARE(config.baud(), 921600); + QCOMPARE(config.dataBits(), static_cast(QGCDataBits::Data7)); + QCOMPARE(config.stopBits(), static_cast(QGCStopBits::TwoStop)); + QCOMPARE(config.parity(), static_cast(QGCParity::Mark)); + QCOMPARE(config.flowControl(), static_cast(QGCFlowControl::HardwareRtsCts)); + QCOMPARE(config.portName(), QStringLiteral("/dev/ttyACM0")); + QVERIFY(config.usbDirect()); + QVERIFY(config.dtrForceLow()); + } +} + +void SerialConfigurationTest::_testSupportedBaudRatesSortedUniqueNonEmpty() +{ + const QStringList rates = QGCSerialPortInfo::supportedBaudRateStrings(); + QVERIFY(!rates.isEmpty()); + QVERIFY(rates.contains(QStringLiteral("57600"))); + QVERIFY(rates.contains(QStringLiteral("115200"))); + + int previous = -1; + for (const QString &rate : rates) { + bool ok = false; + const int value = rate.toInt(&ok); + QVERIFY(ok); + QVERIFY2(value > previous, qPrintable(QStringLiteral("rates must be strictly ascending: %1").arg(rate))); + previous = value; + } +} + +void SerialConfigurationTest::_testCleanPortDisplayNameUnknownReturnsEmpty() +{ + QVERIFY(QGCSerialPortInfo::displayNameForLocation(QStringLiteral("/dev/does-not-exist-xyz")).isEmpty()); +} diff --git a/test/Comms/Serial/SerialConfigurationTest.h b/test/Comms/Serial/SerialConfigurationTest.h new file mode 100644 index 000000000000..f9505566d09b --- /dev/null +++ b/test/Comms/Serial/SerialConfigurationTest.h @@ -0,0 +1,17 @@ +#pragma once + +#include "UnitTest.h" + +class SerialConfigurationTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _testPortConfigEnumMapping(); + void _testParityMigrationFromLegacy(); + void _testParityMigrationClampsOutOfRange(); + void _testParityV2TakesPrecedenceOverLegacy(); + void _testSettingsRoundtripWritesParityV2(); + void _testSupportedBaudRatesSortedUniqueNonEmpty(); + void _testCleanPortDisplayNameUnknownReturnsEmpty(); +}; diff --git a/test/Comms/Serial/SerialLinkTest.cc b/test/Comms/Serial/SerialLinkTest.cc new file mode 100644 index 000000000000..8dcf61ee1be5 --- /dev/null +++ b/test/Comms/Serial/SerialLinkTest.cc @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#include "SerialLinkTest.h" + +#include "LinkManager.h" +#include "MockSerialPort.h" +#include "SerialLink.h" +#include "SerialPlatform.h" + +#include +#include +#include + +UT_REGISTER_TEST(SerialLinkTest, TestLabel::Integration, TestLabel::Comms) + +namespace { + +SharedLinkInterfacePtr createSerialLink(LinkManager *linkManager, const QString &portName) +{ + SharedLinkConfigurationPtr config(new SerialConfiguration(QStringLiteral("SerialLinkTest"))); + auto *serialConfig = qobject_cast(config.get()); + serialConfig->setPortName(portName); + + if (!linkManager->createConnectedLink(config)) { + return nullptr; + } + return linkManager->sharedLinkInterfacePointerForLink(serialConfig->link()); +} + +} // namespace + +void SerialLinkTest::init() +{ + CommsTest::init(); + // The open-failure case logs Comms.SerialLink warnings; behavior is asserted via communicationError. + ignoreLogMessage("Comms.SerialLink", QtWarningMsg, QRegularExpression(QStringLiteral(".*"))); +} + +void SerialLinkTest::cleanup() +{ + SerialPlatform::setPortFactoryForTest({}); + CommsTest::cleanup(); +} + +void SerialLinkTest::_loopbackRoundTrip_deliversBytesReceived() +{ + SerialPlatform::setPortFactoryForTest([](const QString &name, QObject *parent) -> QGCSerialPort * { + auto *port = new MockSerialPort(name, parent); + port->setLoopback(true); + return port; + }); + + SharedLinkInterfacePtr link = createSerialLink(linkManager(), QStringLiteral("MOCK0")); + QVERIFY(link); + QTRY_VERIFY_WITH_TIMEOUT(link->isConnected(), 3000); + + QSignalSpy rxSpy(link.get(), &LinkInterface::bytesReceived); + link->writeBytesThreadSafe("hello", 5); + + QTRY_COMPARE_WITH_TIMEOUT(rxSpy.count(), 1, 3000); + QCOMPARE(rxSpy.first().at(1).toByteArray(), QByteArrayLiteral("hello")); + + link->disconnect(); + QTRY_VERIFY_WITH_TIMEOUT(!link->isConnected(), 3000); +} + +void SerialLinkTest::_openFailure_emitsCommunicationError() +{ + SerialPlatform::setPortFactoryForTest([](const QString &name, QObject *parent) -> QGCSerialPort * { + auto *port = new MockSerialPort(name, parent); + port->setOpenResult(false); + return port; + }); + + expectAppMessage(QRegularExpression(QStringLiteral("mock open refused"))); + + SharedLinkInterfacePtr link = createSerialLink(linkManager(), QStringLiteral("MOCK0")); + QVERIFY(link); + + QSignalSpy errorSpy(link.get(), &LinkInterface::communicationError); + QTRY_COMPARE_WITH_TIMEOUT(errorSpy.count(), 1, 3000); + verifyExpectedLogMessage(); + QVERIFY(!link->isConnected()); +} diff --git a/test/Comms/Serial/SerialLinkTest.h b/test/Comms/Serial/SerialLinkTest.h new file mode 100644 index 000000000000..b86fcbb1cab9 --- /dev/null +++ b/test/Comms/Serial/SerialLinkTest.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#pragma once + +#include "CommsTest.h" + +// End-to-end SerialLink coverage through LinkManager::createConnectedLink, with a loopback +// MockSerialPort injected via SerialPlatform::setPortFactoryForTest(). Exercises the worker-thread +// marshalling that SerialWorkerTest can't: queued connected/disconnected/bytesReceived and the +// communicationError path, all driven by the real link stack. +class SerialLinkTest : public CommsTest +{ + Q_OBJECT + +private slots: + void init() override; + void cleanup() override; + + void _loopbackRoundTrip_deliversBytesReceived(); + void _openFailure_emitsCommunicationError(); +}; diff --git a/test/Comms/Serial/SerialPortInfoCodecTest.cc b/test/Comms/Serial/SerialPortInfoCodecTest.cc new file mode 100644 index 000000000000..6e33e2a94068 --- /dev/null +++ b/test/Comms/Serial/SerialPortInfoCodecTest.cc @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#include "SerialPortInfoCodecTest.h" + +#include +#include +#include +#include +#include + +#include "SerialPortInfoCodec.h" + +namespace { + +QByteArray buildPorts(const QJsonArray& ports) +{ + QJsonObject root; + root["ports"] = ports; + return QJsonDocument(root).toJson(QJsonDocument::Compact); +} + +} // namespace + +void SerialPortInfoCodecTest::_emptyBuffer_returnsEmpty() +{ + QVERIFY(SerialPortInfoCodec::unpack(QByteArray()).isEmpty()); +} + +void SerialPortInfoCodecTest::_garbledBuffer_returnsEmpty() +{ + QVERIFY(SerialPortInfoCodec::unpack(QByteArrayLiteral("not json")).isEmpty()); +} + +void SerialPortInfoCodecTest::_singlePort_decodesAllFields() +{ + QJsonObject port; + port["deviceName"] = "/dev/ttyACM0"; + port["productName"] = "Pixhawk"; + port["manufacturerName"] = "ArduPilot"; + port["serialNumber"] = "SN123"; + port["productId"] = 0x0011; + port["vendorId"] = 0x26ac; + port["baudRates"] = QJsonArray{57600, 115200, 921600}; + + const QList ports = SerialPortInfoCodec::unpack(buildPorts({port})); + QCOMPARE(ports.size(), 1); + const QGCSerialPortInfo::Data& d = ports.first(); + QCOMPARE(d.systemLocation, QStringLiteral("/dev/ttyACM0")); + QCOMPARE(d.portName, QStringLiteral("ttyACM0")); // /dev/ stripped + QCOMPARE(d.description, QStringLiteral("Pixhawk")); + QCOMPARE(d.manufacturer, QStringLiteral("ArduPilot")); + QCOMPARE(d.serialNumber, QStringLiteral("SN123")); + QCOMPARE(d.productIdentifier, static_cast(0x0011)); + QCOMPARE(d.vendorIdentifier, static_cast(0x26ac)); + QVERIFY(d.hasProductIdentifier); + QVERIFY(d.hasVendorIdentifier); + QCOMPARE(d.supportedBaudRates, (QList{57600, 115200, 921600})); +} + +void SerialPortInfoCodecTest::_absentStringKey_isNullDistinctFromEmpty() +{ + QJsonObject port; + port["deviceName"] = "/dev/ttyUSB0"; + port["manufacturerName"] = ""; // present-but-empty + // productName, serialNumber absent -> null QString + port["productId"] = 1; + port["vendorId"] = 2; + port["baudRates"] = QJsonArray{}; + + const QList ports = SerialPortInfoCodec::unpack(buildPorts({port})); + QCOMPARE(ports.size(), 1); + const QGCSerialPortInfo::Data& d = ports.first(); + QVERIFY(d.description.isNull()); // absent key + QVERIFY(d.manufacturer.isEmpty()); // present empty + QVERIFY(!d.manufacturer.isNull()); + QVERIFY(d.serialNumber.isNull()); + QVERIFY(d.supportedBaudRates.isEmpty()); +} + +void SerialPortInfoCodecTest::_emptyDeviceName_skipsPortButKeepsParsing() +{ + QJsonObject ghost; + ghost["productName"] = "ghost"; // no deviceName -> dropped + ghost["baudRates"] = QJsonArray{9600, 19200}; + + QJsonObject valid; + valid["deviceName"] = "/dev/ttyUSB0"; + valid["productName"] = "FTDI"; + valid["manufacturerName"] = "FT"; + valid["serialNumber"] = "S2"; + valid["baudRates"] = QJsonArray{57600}; + + const QList ports = SerialPortInfoCodec::unpack(buildPorts({ghost, valid})); + QCOMPARE(ports.size(), 1); + QCOMPARE(ports.first().systemLocation, QStringLiteral("/dev/ttyUSB0")); + QCOMPARE(ports.first().supportedBaudRates, (QList{57600})); +} + +// Decodes the same on-disk fixture the Java UsbPortInfoPackingTest packs against, so a JSON key rename on +// either side drifts from the shared golden literal and fails one suite. +void SerialPortInfoCodecTest::_goldenFixture_matchesSharedContract() +{ + QFile golden(QStringLiteral(SERIAL_TESTDATA_DIR "/PortInfoGolden.json")); + QVERIFY2(golden.open(QIODevice::ReadOnly), "golden fixture missing — check SERIAL_TESTDATA_DIR"); + + const QList ports = SerialPortInfoCodec::unpack(golden.readAll()); + QCOMPARE(ports.size(), 1); + const QGCSerialPortInfo::Data& d = ports.first(); + QCOMPARE(d.systemLocation, QStringLiteral("/dev/ttyACM0")); + QCOMPARE(d.description, QStringLiteral("Pixhawk")); + QCOMPARE(d.manufacturer, QStringLiteral("ArduPilot")); + QCOMPARE(d.serialNumber, QStringLiteral("SN123")); + QCOMPARE(d.productIdentifier, static_cast(0x0011)); + QCOMPARE(d.vendorIdentifier, static_cast(0x26ac)); + QCOMPARE(d.supportedBaudRates, (QList{57600, 115200, 921600})); +} + +UT_REGISTER_TEST(SerialPortInfoCodecTest, TestLabel::Unit, TestLabel::Comms) diff --git a/test/Comms/Serial/SerialPortInfoCodecTest.h b/test/Comms/Serial/SerialPortInfoCodecTest.h new file mode 100644 index 000000000000..5f825444638d --- /dev/null +++ b/test/Comms/Serial/SerialPortInfoCodecTest.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#pragma once + +#include "UnitTest.h" + +// Host test for SerialPortInfoCodec::unpack — the JNI-free decoder for the JSON USB-port enumeration +// buffer. Mirrors the Java UsbPortInfoPackingTest round-trip so a wire-format change on either side +// fails a test. +class SerialPortInfoCodecTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _emptyBuffer_returnsEmpty(); + void _garbledBuffer_returnsEmpty(); + void _singlePort_decodesAllFields(); + void _absentStringKey_isNullDistinctFromEmpty(); + void _emptyDeviceName_skipsPortButKeepsParsing(); + void _goldenFixture_matchesSharedContract(); +}; diff --git a/test/Comms/Serial/SerialWireContractTest.cc b/test/Comms/Serial/SerialWireContractTest.cc new file mode 100644 index 000000000000..7b5691df02b4 --- /dev/null +++ b/test/Comms/Serial/SerialWireContractTest.cc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#include "SerialWireContractTest.h" + +#include "SerialWireConstants.h" + +#include + +UT_REGISTER_TEST(SerialWireContractTest, TestLabel::Unit, TestLabel::Comms) + +void SerialWireContractTest::_chunkAndSentinel_matchJavaTwin() +{ + QCOMPARE(AndroidSerialWire::MAX_CHUNK_BYTES, qint64(16384)); // Java MAX_CHUNK_BYTES + QCOMPARE(AndroidSerialWire::BAD_DEVICE_ID, 0); // Java BAD_DEVICE_ID +} + +void SerialWireContractTest::_exceptionKinds_matchJavaTwin() +{ + using K = AndroidSerialWire::JavaExceptionKind; + QCOMPARE(static_cast(K::Unknown), 0); // Java EXC_UNKNOWN + QCOMPARE(static_cast(K::Resource), 1); // Java EXC_RESOURCE + QCOMPARE(static_cast(K::Permission), 2); // Java EXC_PERMISSION + QCOMPARE(static_cast(K::OpenFailed), 3); // Java EXC_OPEN_FAILED +} + +void SerialWireContractTest::_flowControlOrdinals_matchJavaTwin() +{ + QCOMPARE(static_cast(AndroidSerialWire::NoFlowControl), 0); // Java FC_NONE + QCOMPARE(static_cast(AndroidSerialWire::RtsCtsFlowControl), 1); // Java FC_RTS_CTS + QCOMPARE(static_cast(AndroidSerialWire::DtrDsrFlowControl), 2); // Java FC_DTR_DSR + QCOMPARE(static_cast(AndroidSerialWire::XonXoffFlowControl), 3); // Java FC_XON_XOFF + QCOMPARE(static_cast(AndroidSerialWire::XonXoffInlineFlowControl), 4); // Java FC_XON_XOFF_INLINE +} diff --git a/test/Comms/Serial/SerialWireContractTest.h b/test/Comms/Serial/SerialWireContractTest.h new file mode 100644 index 000000000000..efef3ea29ed5 --- /dev/null +++ b/test/Comms/Serial/SerialWireContractTest.h @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#pragma once + +#include "UnitTest.h" + +// Pins SerialWireConstants.h to its SerialWireConstants.java twin so a one-sided edit can't mis-map across JNI. +class SerialWireContractTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _chunkAndSentinel_matchJavaTwin(); + void _exceptionKinds_matchJavaTwin(); + void _flowControlOrdinals_matchJavaTwin(); +}; diff --git a/test/Comms/Serial/SerialWorkerTest.cc b/test/Comms/Serial/SerialWorkerTest.cc new file mode 100644 index 000000000000..08c0295814f0 --- /dev/null +++ b/test/Comms/Serial/SerialWorkerTest.cc @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#include "SerialWorkerTest.h" + +#include "MockSerialPort.h" +#include "QGCSerialPortTypes.h" +#include "SerialLink.h" +#include "SerialPlatform.h" + +#include +#include +#include + +UT_REGISTER_TEST(SerialWorkerTest, TestLabel::Unit, TestLabel::Comms) + +namespace { + +SharedLinkConfigurationPtr makeConfig(const QString &portName = QStringLiteral("MOCK0")) +{ + SharedLinkConfigurationPtr config(new SerialConfiguration(QStringLiteral("SerialWorkerTest"))); + qobject_cast(config.get())->setPortName(portName); + return config; +} + +} // namespace + +void SerialWorkerTest::init() +{ + UnitTest::init(); + // Error-path cases intentionally drive the worker into failure states it logs warnings for. The + // behavior under test is asserted via signals, so suppress the expected Comms.SerialLink warnings. + ignoreLogMessage("Comms.SerialLink", QtWarningMsg, QRegularExpression(QStringLiteral(".*"))); +} + +void SerialWorkerTest::cleanup() +{ + SerialPlatform::setPortFactoryForTest({}); + UnitTest::cleanup(); +} + +void SerialWorkerTest::_connectThenDisconnect_emitsLifecycle() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + QSignalSpy connectedSpy(&worker, &SerialWorker::connected); + QSignalSpy disconnectedSpy(&worker, &SerialWorker::disconnected); + + worker.setupPort(); + QVERIFY(mock); + + worker.connectToPort(); + QVERIFY(worker.isConnected()); + QCOMPARE(connectedSpy.count(), 1); + QCOMPARE(mock->lastConfig().baud, 57600); + + worker.disconnectFromPort(); + QVERIFY(!worker.isConnected()); + QCOMPARE(disconnectedSpy.count(), 1); +} + +void SerialWorkerTest::_receivedData_emittedToWorker() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + worker.setupPort(); + worker.connectToPort(); + QVERIFY(worker.isConnected()); + + QSignalSpy rxSpy(&worker, &SerialWorker::dataReceived); + mock->feedReceived(QByteArrayLiteral("telemetry")); + + QCOMPARE(rxSpy.count(), 1); + QCOMPARE(rxSpy.first().first().toByteArray(), QByteArrayLiteral("telemetry")); +} + +void SerialWorkerTest::_writeData_capturedAndEmitsDataSent() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + worker.setupPort(); + worker.connectToPort(); + + QSignalSpy sentSpy(&worker, &SerialWorker::dataSent); + worker.writeData(QByteArrayLiteral("command")); + + QCOMPARE(mock->writtenData(), QByteArrayLiteral("command")); + QCOMPARE(sentSpy.count(), 1); + QCOMPARE(sentSpy.first().first().toByteArray(), QByteArrayLiteral("command")); +} + +void SerialWorkerTest::_writeBackpressure_holdsThenDrainsOnResume() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + worker.setupPort(); + worker.connectToPort(); + + QSignalSpy sentSpy(&worker, &SerialWorker::dataSent); + + mock->setWriteStalled(true); + worker.writeData(QByteArrayLiteral("queued")); + QCOMPARE(sentSpy.count(), 0); + QVERIFY(mock->writtenData().isEmpty()); + + mock->resumeWrites(); + QCOMPARE(sentSpy.count(), 1); + QCOMPARE(mock->writtenData(), QByteArrayLiteral("queued")); +} + +void SerialWorkerTest::_writeFailure_emitsErrorAndClearsBacklog() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + worker.setupPort(); + worker.connectToPort(); + + QSignalSpy errorSpy(&worker, &SerialWorker::errorOccurred); + mock->setWriteShouldFail(true); + worker.writeData(QByteArrayLiteral("doomed")); + + QCOMPARE(errorSpy.count(), 1); +} + +void SerialWorkerTest::_writeWhenNotConnected_emitsErrorOnce() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + worker.setupPort(); + + QSignalSpy errorSpy(&worker, &SerialWorker::errorOccurred); + worker.writeData(QByteArrayLiteral("a")); + worker.writeData(QByteArrayLiteral("b")); + + QCOMPARE(errorSpy.count(), 1); +} + +void SerialWorkerTest::_openFailure_emitsErrorAndStaysDisconnected() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + QSignalSpy connectedSpy(&worker, &SerialWorker::connected); + QSignalSpy errorSpy(&worker, &SerialWorker::errorOccurred); + + worker.setupPort(); + mock->setOpenResult(false); + worker.connectToPort(); + + QVERIFY(!worker.isConnected()); + QCOMPARE(connectedSpy.count(), 0); + QCOMPARE(errorSpy.count(), 1); +} + +void SerialWorkerTest::_reconfigureFailure_emitsErrorAndStaysDisconnected() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + QSignalSpy connectedSpy(&worker, &SerialWorker::connected); + QSignalSpy errorSpy(&worker, &SerialWorker::errorOccurred); + + worker.setupPort(); + mock->setReconfigureResult(false); + worker.connectToPort(); + + QVERIFY(!worker.isConnected()); + QCOMPARE(connectedSpy.count(), 0); + QCOMPARE(errorSpy.count(), 1); +} + +void SerialWorkerTest::_resourceError_disconnectsWithoutErrorSignal() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + worker.setupPort(); + worker.connectToPort(); + QVERIFY(worker.isConnected()); + + QSignalSpy disconnectedSpy(&worker, &SerialWorker::disconnected); + QSignalSpy errorSpy(&worker, &SerialWorker::errorOccurred); + + mock->injectError(QGCSerialPortError::ResourceUnavailable, QStringLiteral("unplugged")); + + QVERIFY(!worker.isConnected()); + QCOMPARE(disconnectedSpy.count(), 1); + QCOMPARE(errorSpy.count(), 0); +} + +void SerialWorkerTest::_genericPortError_emitsErrorSignal() +{ + MockSerialPort *mock = nullptr; + SerialPlatform::setPortFactoryForTest([&mock](const QString &name, QObject *parent) -> QGCSerialPort * { + mock = new MockSerialPort(name, parent); + return mock; + }); + + SerialWorker worker(makeConfig()); + worker.setupPort(); + worker.connectToPort(); + + QSignalSpy errorSpy(&worker, &SerialWorker::errorOccurred); + mock->injectError(QGCSerialPortError::Read, QStringLiteral("read fault")); + + QCOMPARE(errorSpy.count(), 1); +} + +void SerialWorkerTest::_connectWithoutSetup_emitsPortNotCreated() +{ + SerialWorker worker(makeConfig()); + QSignalSpy errorSpy(&worker, &SerialWorker::errorOccurred); + + worker.connectToPort(); + + QVERIFY(!worker.isConnected()); + QCOMPARE(errorSpy.count(), 1); +} + +void SerialWorkerTest::_factoryOverride_makeSerialPortReturnsMock() +{ + SerialPlatform::setPortFactoryForTest([](const QString &name, QObject *parent) -> QGCSerialPort * { + return new MockSerialPort(name, parent); + }); + + QGCSerialPort *port = SerialPlatform::makeSerialPort(QStringLiteral("mockPort"), this); + QVERIFY(qobject_cast(port) != nullptr); + QCOMPARE(port->portName(), QStringLiteral("mockPort")); + + SerialPlatform::setPortFactoryForTest({}); + delete port; + + QGCSerialPort *realPort = SerialPlatform::makeSerialPort(QString{}, this); + QVERIFY(qobject_cast(realPort) == nullptr); + delete realPort; +} diff --git a/test/Comms/Serial/SerialWorkerTest.h b/test/Comms/Serial/SerialWorkerTest.h new file mode 100644 index 000000000000..e36a78c22ca3 --- /dev/null +++ b/test/Comms/Serial/SerialWorkerTest.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 OR GPL-3.0-only + +#pragma once + +#include "UnitTest.h" + +// Drives SerialWorker synchronously on the test thread with a MockSerialPort injected via +// SerialPlatform::setPortFactoryForTest() — covers the connect/disconnect lifecycle, RX/TX paths, +// write backpressure, single-shot error emission, and port error handling without hardware. +class SerialWorkerTest : public UnitTest +{ + Q_OBJECT + +private slots: + void init() override; + void cleanup() override; + + void _connectThenDisconnect_emitsLifecycle(); + void _receivedData_emittedToWorker(); + void _writeData_capturedAndEmitsDataSent(); + void _writeBackpressure_holdsThenDrainsOnResume(); + void _writeFailure_emitsErrorAndClearsBacklog(); + void _writeWhenNotConnected_emitsErrorOnce(); + void _openFailure_emitsErrorAndStaysDisconnected(); + void _reconfigureFailure_emitsErrorAndStaysDisconnected(); + void _resourceError_disconnectsWithoutErrorSignal(); + void _genericPortError_emitsErrorSignal(); + void _connectWithoutSetup_emitsPortNotCreated(); + void _factoryOverride_makeSerialPortReturnsMock(); +}; diff --git a/test/Comms/Serial/data/PortInfoGolden.json b/test/Comms/Serial/data/PortInfoGolden.json new file mode 100644 index 000000000000..a47af0a163c9 --- /dev/null +++ b/test/Comms/Serial/data/PortInfoGolden.json @@ -0,0 +1 @@ +{"ports":[{"deviceName":"/dev/ttyACM0","productName":"Pixhawk","manufacturerName":"ArduPilot","serialNumber":"SN123","productId":17,"vendorId":9900,"baudRates":[57600,115200,921600]}]} diff --git a/tools/checkstyle/run_checkstyle.sh b/tools/checkstyle/run_checkstyle.sh new file mode 100755 index 000000000000..0c4741c84472 --- /dev/null +++ b/tools/checkstyle/run_checkstyle.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Run Checkstyle over first-party QGroundControl Android Java with android/checkstyle.xml. +# +# Self-contained for pre-commit + CI: resolves a pinned, checksum-verified Checkstyle jar from +# (1) $CHECKSTYLE_JAR, (2) the local cache, or (3) a one-time download to the cache. The CI +# pre-commit job sets up no Java toolchain of its own, so this script must not assume one beyond a +# `java` on PATH (ubuntu-latest ships a Temurin JDK). +# +# Usage: run_checkstyle.sh [ ...] # pre-commit passes the staged files +set -euo pipefail + +readonly VERSION="10.21.0" +readonly SHA256="911fee0b8a8495f0d8a168815e91bb8abb3f497c96a46d8b78e433a516bc88e7" +readonly JAR_NAME="checkstyle-${VERSION}-all.jar" +readonly URL="https://github.com/checkstyle/checkstyle/releases/download/checkstyle-${VERSION}/${JAR_NAME}" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +readonly SCRIPT_DIR REPO_ROOT +readonly CONFIG="${REPO_ROOT}/android/checkstyle.xml" +readonly CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/qgc-tools" + +[ "$#" -eq 0 ] && exit 0 # pre-commit invoked us with no matching files + +verify() { echo "${SHA256} $1" | sha256sum --check --status; } + +resolve_jar() { + if [ -n "${CHECKSTYLE_JAR:-}" ] && [ -f "${CHECKSTYLE_JAR}" ] && verify "${CHECKSTYLE_JAR}"; then + printf '%s' "${CHECKSTYLE_JAR}" + return + fi + local cached="${CACHE_DIR}/${JAR_NAME}" + if [ -f "${cached}" ] && verify "${cached}"; then + printf '%s' "${cached}" + return + fi + mkdir -p "${CACHE_DIR}" + local tmp="${cached}.tmp.$$" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "${URL}" -o "${tmp}" + else + wget -qO "${tmp}" "${URL}" + fi + if ! verify "${tmp}"; then + rm -f "${tmp}" + echo "checkstyle: downloaded jar failed SHA-256 check (expected ${SHA256})" >&2 + exit 1 + fi + mv -f "${tmp}" "${cached}" + printf '%s' "${cached}" +} + +if ! command -v java >/dev/null 2>&1; then + echo "checkstyle: 'java' not found on PATH (need a JRE/JDK to run Checkstyle)" >&2 + exit 1 +fi + +JAR="$(resolve_jar)" +exec java -jar "${JAR}" -c "${CONFIG}" "$@" diff --git a/tools/common/net.py b/tools/common/net.py index 15fbb46fcde2..44c93e5ae94a 100644 --- a/tools/common/net.py +++ b/tools/common/net.py @@ -22,18 +22,28 @@ def run_with_retries( - cmd: Sequence[str], *, attempts: int = 3, backoff: float = 15.0, check: bool = True + cmd: Sequence[str], + *, + attempts: int = 3, + backoff: float = 15.0, + check: bool = True, + timeout: float | None = None, ) -> None: - """Run *cmd*, retrying transient failures with exponential backoff.""" + """Run *cmd*, retrying transient failures with exponential backoff. + + *timeout* (seconds) bounds each attempt; a hang past it is killed and + retried, so a stalled download fails fast instead of blocking on the job timeout. + """ for attempt in range(1, attempts + 1): try: - subprocess.run(list(cmd), check=check) + subprocess.run(list(cmd), check=check, timeout=timeout) return - except subprocess.CalledProcessError: + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: if attempt == attempts: raise + reason = "timed out" if isinstance(exc, subprocess.TimeoutExpired) else "failed" print( - f"::warning::{cmd[0]} failed (attempt {attempt}/{attempts}); retrying in {backoff:g}s", + f"::warning::{cmd[0]} {reason} (attempt {attempt}/{attempts}); retrying in {backoff:g}s", file=sys.stderr, ) time.sleep(backoff) diff --git a/tools/setup/install_qt.py b/tools/setup/install_qt.py index 37181aedce85..f150ba75c1d9 100644 --- a/tools/setup/install_qt.py +++ b/tools/setup/install_qt.py @@ -18,7 +18,6 @@ import hashlib import re import shutil -import subprocess import sys from pathlib import Path @@ -32,6 +31,12 @@ from common import pip_install from common.gh_actions import write_github_output +from common.net import run_with_retries + +# aqt's per-request connection timeout doesn't catch a mirror that stalls +# mid-transfer; _AQT_RUN_TIMEOUT is the wall-clock cap per attempt that does. +_AQT_CONNECT_TIMEOUT = "60" +_AQT_RUN_TIMEOUT = 900.0 # aqtinstall creates directories that differ from the arch parameter. # This mapping resolves the actual on-disk directory name. @@ -126,7 +131,11 @@ def install_qt( print("::error::aqtinstall not found after pip install") sys.exit(1) - args = [aqt, "install-qt", host, target, version, arch, "--outputdir", str(outdir)] + args = [ + aqt, "install-qt", host, target, version, arch, + "--outputdir", str(outdir), + "--timeout", _AQT_CONNECT_TIMEOUT, + ] if modules: args.extend(["--modules", *modules.split()]) @@ -134,7 +143,7 @@ def install_qt( args.extend(["--archives", *archives.split()]) print(f"Running: {' '.join(args)}") - subprocess.run(args, check=True) + run_with_retries(args, attempts=3, backoff=15.0, timeout=_AQT_RUN_TIMEOUT) arch_dir = resolve_arch_dir(arch) return resolve_qt_root(outdir, version, arch_dir)