diff --git a/.github/workflows/simulator_ui_automation.yml b/.github/workflows/simulator_ui_automation.yml new file mode 100644 index 00000000000..1e096e260fa --- /dev/null +++ b/.github/workflows/simulator_ui_automation.yml @@ -0,0 +1,127 @@ +--- +name: Simulator UI automation + +on: + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/simulator_ui_automation.yml' + - 'radio/src/targets/simu/automation_*' + - 'radio/src/targets/simu/CMakeLists.txt' + - 'radio/src/targets/simu/arg_parser.*' + - 'radio/src/targets/simu/sdl_simu.cpp' + - 'radio/src/tests/simu_automation_*' + - 'radio/src/tests/simu_arg_parser.cpp' + - 'radio/src/tests/CMakeLists.txt' + - 'radio/src/main.cpp' + - 'radio/src/gui/colorlcd/LvglWrapper.cpp' + - 'tools/ui-harness/**' + push: + branches: + - main + paths: + - '.github/workflows/simulator_ui_automation.yml' + - 'radio/src/targets/simu/automation_*' + - 'radio/src/targets/simu/CMakeLists.txt' + - 'radio/src/targets/simu/arg_parser.*' + - 'radio/src/targets/simu/sdl_simu.cpp' + - 'radio/src/tests/simu_automation_*' + - 'radio/src/tests/simu_arg_parser.cpp' + - 'radio/src/tests/CMakeLists.txt' + - 'radio/src/main.cpp' + - 'radio/src/gui/colorlcd/LvglWrapper.cpp' + - 'tools/ui-harness/**' + +permissions: + contents: read + +jobs: + host-tests: + name: Host tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + steps: + - name: Check out the repo + uses: actions/checkout@v7 + + - name: Compile Python sources + run: python -m compileall -q tools/ui-harness + + - name: Run host and hardening tests + run: python -m unittest discover -s tools/ui-harness/tests -p "test_*.py" -v + + native-radio-tests: + name: Native radio automation tests (ASan/UBSan) + runs-on: ubuntu-latest + timeout-minutes: 45 + container: + image: ghcr.io/edgetx/edgetx-dev@sha256:75a076b11bcf82128f25261d4e3149b0b9aac9bd24f49fb74076b3d0e723686c + steps: + - name: Check out the repo and submodules + uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Configure native TX16S tests + run: >- + cmake --preset simu + -B build/ci-automation-radio + -DPCB=X10 + -DPCBREV=TX16S + -DCMAKE_BUILD_TYPE=Debug + -DTESTS_ASAN=ON + '-DCMAKE_C_FLAGS_DEBUG=-fsanitize=undefined -fno-sanitize-recover=undefined' + '-DCMAKE_CXX_FLAGS_DEBUG=-fsanitize=undefined -fno-sanitize-recover=undefined' + + - name: Build native radio tests + run: cmake --build build/ci-automation-radio --target gtests-radio --parallel 2 + + - name: Run native radio tests + env: + ASAN_OPTIONS: detect_leaks=0:halt_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + run: >- + ./build/ci-automation-radio/gtests-radio + --gtest_filter='SimuArgumentParser.*:SimuAutomation*.*' + + wasi-isolation: + name: WASI automation isolation + runs-on: ubuntu-latest + timeout-minutes: 60 + container: + image: ghcr.io/edgetx/edgetx-dev@sha256:75a076b11bcf82128f25261d4e3149b0b9aac9bd24f49fb74076b3d0e723686c + steps: + - name: Check out the repo and submodules + uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Configure TX16S WASI superbuild + run: >- + cmake -S . + -B build/ci-automation-wasi + -DPCB=X10 + -DPCBREV=TX16S + -DCMAKE_BUILD_TYPE=Release + + - name: Build TX16S WASI module + run: >- + cmake --build build/ci-automation-wasi + --target wasi-module + --parallel 2 + + - name: Assert automation surface is absent + shell: bash + run: | + shopt -s nullglob + modules=(build/ci-automation-wasi/wasm/*.wasm) + test "${#modules[@]}" -gt 0 + if grep -aE 'automation-stdio|automation-output|SIMU_AUTOMATION|AutomationStdio' "${modules[@]}"; then + echo "automation marker found in WASI output" >&2 + exit 1 + fi diff --git a/docs/development/simulator-ui-automation.md b/docs/development/simulator-ui-automation.md new file mode 100644 index 00000000000..c3bb2e61020 --- /dev/null +++ b/docs/development/simulator-ui-automation.md @@ -0,0 +1,301 @@ +# Simulator UI Automation + +EdgeTX provides an opt-in automation interface for the native simulator. It +turns a manual simulator reproduction into a repeatable flow that can drive the +real EdgeTX runtime, synchronize with firmware LCD updates, and retain +inspectable evidence after the process exits. + +Automation is disabled during normal simulator use. The implementation is also +excluded from physical firmware, WASI, and Emscripten builds. + +## Intended use + +The interface is designed for: + +- repeatable reproduction of simulator UI behavior; +- scripted input and simulator-state transitions; +- framebuffer capture independent of host-window size and scaling; +- lifecycle, restart, and cleanup validation; and +- future UI regression scenarios and visual comparison workflows. + +It is not a second UI renderer. Actions run through the existing simulator and +firmware ownership paths, and captures come from the firmware framebuffer. + +Protocol version 1 supports one local controller and one native simulator +process. The first complete declarative-flow profile is TX16S (`PCB=X10`, +`PCBREV=TX16S`, 480x272 RGB565). Other native targets can advertise their real +capabilities, but each target needs an explicit host profile, fixture, and smoke +flow before the declarative runner accepts it. + +## Architecture + +```text +JSON flow / Python session + | + | bounded requests and JSON-line responses + v +native simulator stdio transport + | + v +SDL automation executor -------- immediate simulator inputs + | keys, rotary, touch, switches + | and atomic analog overrides + | + +---------------------> bounded firmware mailbox + | telemetry and Lua reload + | + +---------------------> LCD sequence and snapshot + | + v + capture worker -> PPM + | + v + host PNG, hashes and manifest +``` + +The Python host owns process lifecycle, request deadlines, timed composite +actions, fixture copies, declarative flows, and artifact verification. The SDL +thread owns immediate simulator input transitions and lifecycle coordination. +Firmware-owned operations cross a bounded mailbox and complete only after the +firmware context reports their result. + +LCD notifications publish a display sequence and, when requested, a complete +framebuffer snapshot. The LCD callback performs no filesystem work. A dedicated +worker publishes native PPM artifacts, while PNG encoding and independent image +verification remain in the host harness. + +## Activating automation + +Launch a native simulator with both automation options: + +```text +simu --automation-stdio \ + --automation-output \ + --storage \ + --settings +``` + +`--automation-stdio` and `--automation-output` must be supplied together. The +output directory must already exist and pass the simulator's validation before +the protocol starts. + +Stdout is reserved for protocol responses and transport events. Simulator, +SDL, and diagnostic output is routed to stderr. The host uses binary pipes on +both Windows and POSIX and does not depend on shell interpolation, POSIX +`select`, or fixed startup sleeps. + +Closing stdin, losing stdout, or stopping a session releases owned key, touch, +and analog state, cancels pending work, and initiates bounded process shutdown. + +## Protocol version 1 + +Requests are UTF-8 newline-delimited records: + +```text +v1 [arguments...] +``` + +Request IDs are unsigned 64-bit decimal values and must increase strictly for +the lifetime of the process. Each admitted request receives exactly one +terminal JSON response with the same ID. Uncorrelated transport events that can +still be emitted use a null ID. + +The protocol accepts LF and CRLF records. Wire records, responses, queues, +arguments, paths, and asynchronous operations have explicit bounds. `capture` +is the only command whose single argument may contain internal spaces; other +commands use fixed argument boundaries. + +### Commands + +| Command | Arguments | Completion | +|---|---|---| +| `ping` | none | immediate response | +| `status` | none | consistent session snapshot returned | +| `describe` | none | target commands and capabilities returned | +| `key-down`, `key-up` | canonical key name | input state updated | +| `rotate` | nonzero steps in `-128..128` | rotary input delivered | +| `touch-down`, `touch-move` | in-bounds `x y` | touch state updated | +| `touch-up` | none | active touch released | +| `set-switch` | canonical name and `-1`, `0`, or `1` | target validates and updates the position | +| `set-analog` | canonical name and value in `0..4096` | atomic override published | +| `clear-analog` | canonical name or `all` | requested override cleared | +| `set-telemetry` | `id subId instance value unit precision [name]` | firmware operation completed | +| `reload-lua` | none | requested Lua generation reaches a terminal state | +| `wait-frame` | minimum `display_seq` | requested sequence observed | +| `capture` | safe relative `.ppm` path | fresh artifact published | +| `restart` | none | new epoch and its first LCD frame observed | +| `release-all` | none | keys, touch, and analog overrides released | +| `stop` | none | terminal response flushed and shutdown initiated | + +Generic success, error, and transport-event records have these shapes: + +```json +{"version":1,"type":"response","id":1,"ok":true,"epoch":1} +{"version":1,"type":"response","id":2,"ok":false,"epoch":1,"error":{"code":"invalid_argument","message":"..."}} +{"version":1,"type":"event","id":null,"epoch":1,"event":{"code":"invalid_record","message":"..."}} +``` + +`code` is the stable machine-consumable error identifier. `message` is bounded +diagnostic context and should not be parsed as an interface. + +`describe` is the authority for the current target. It reports the target and +LCD identity, available commands and capabilities, canonical key and control +names, and advertised numeric bounds. Clients should validate against that +response instead of assuming a target feature. + +`status` reports the current session phase, restart epoch, display sequence, +pending asynchronous work, queue counters, active input state, analog +overrides, and Lua state. + +Only one asynchronous operation can be active at a time. This includes frame +waits, capture, firmware work, Lua reload, and warm restart. When `stop` is +processed, already admitted requests that have not executed receive +`session_stopping`; no command side effect is allowed after the stop barrier. + +## Input and state ownership + +Key, rotary, touch, and switch commands reuse the native simulator input paths. +Analog values use atomic overrides read by the existing simulator ADC path. +Timed presses, taps, and drags are composed by the host from explicit down, +move, and up transitions, so native command execution never sleeps to model a +duration. + +Telemetry creation and updates run in firmware context and use the complete +sensor identity tuple. Lua reload also runs in firmware context and completes +only after its generation change is observed. + +`release-all` releases keys and touch and clears analog overrides. It does not +reset switches. Warm restart releases keys and touch, clears analog overrides, +resets switches, increments the epoch, restarts firmware tasks in the same +process, and completes after the first frame in the new epoch. Model, fixture, +and telemetry state should not be assumed to reset. A cold restart is a +host-side composition that reaps the old process and starts a new process with +fresh fixture copies. + +## Frame synchronization and capture + +`display_seq` advances only when firmware reports an LCD refresh. +`wait-frame N` completes when the sequence reaches `N`; it does not manufacture +a frame on a static screen. + +Capture is available only when the target advertises an RGB565 framebuffer. A +capture records the current sequence, requests one safe LVGL invalidation, and +accepts only a strictly newer complete framebuffer. The snapshot is copied to +worker-owned storage before the LCD callback returns. + +The capture worker converts RGB565 to canonical P6 PPM and publishes the file +without replacing an existing destination. The host can validate that PPM, +convert it to PNG using only the Python standard library, decode it +independently, and write a `.capture.json` sidecar containing dimensions, +display sequence, and hashes. + +Artifact paths must: + +- be valid UTF-8 relative paths; +- remain below the configured output root; +- contain no root, `.` or `..` components; +- use a lowercase `.ppm` extension for native capture; +- refer to an already existing parent directory; and +- not target an existing artifact. + +This containment model is for cooperative local development and CI. The output +root is trusted, session-owned state; it is not an operating-system sandbox +against another process running as the same user and mutating the directory +while a run is active. + +## Host harness + +The standard-library-only Python harness lives under `tools/ui-harness`. From +the repository root, a TX16S smoke can configure, build, run, and verify the +complete flow: + +```text +python tools/ui-harness/edgetx-ui smoke \ + --build-dir build/ui-harness/tx16s +``` + +Run another schema-v1 flow against an existing simulator: + +```text +python tools/ui-harness/edgetx-ui run-flow \ + path/to/flow.json path/to/simu +``` + +Run lifecycle, transport, Lua, restart, capture, fixture, and cleanup stress: + +```text +python tools/ui-harness/edgetx-ui harden path/to/simu +``` + +Flow schemas, static ranges, declared requirements, paths, and step coherence +are validated before the simulator starts. After launch, `describe` verifies +the actual target identity and capability availability before any flow action +executes. + +Declarative flow and hardening processes receive a unique writable copy of an +immutable fixture. Direct `probe` and `SimulatorSession` callers own the paths +they provide. A run records a manifest, bounded stderr, captures and metadata, +and all observed protocol records streamed to `protocol.jsonl`. The manifest +represents that evidence by relative path, record count, and SHA-256 instead of +embedding an unbounded transcript. + +Host-initiated cleanup attempts input release and graceful stop when the +protocol remains usable, then always reaps the child and joins reader/writer +threads. Timeout or transport failure poisons the session and uses terminate +and kill-and-wait fallbacks when graceful shutdown cannot complete. + +The operational command reference, artifact layout, Python session examples, +and troubleshooting guidance are in the +[`tools/ui-harness` README](https://github.com/EdgeTX/edgetx/blob/main/tools/ui-harness/README.md). + +## Extending the harness + +A new target profile should define: + +- PCB and revision identity; +- LCD geometry and pixel depth; +- canonical input names and ranges; +- required and optional capabilities; +- an immutable minimal fixture; and +- one representative smoke flow. + +A new native command should define its parser and bounds, capability +advertisement, execution context, completion and cancellation behavior, error +mapping, host-side validation, and native and host tests. Extend protocol v1 +compatibly rather than introducing another simulator control surface. + +Potential follow-up work includes additional target profiles, image comparison +and masks, a golden-image review policy, CTest or JUnit reporting, richer +telemetry scenarios, adapters over the existing Python session, and eventual +alignment with hardware-in-the-loop workflows. + +## Verification + +Run the host suite from the repository root: + +```text +python -m unittest discover \ + -s tools/ui-harness/tests -p "test_*.py" -v +``` + +The dedicated CI workflow also runs the host tests on Windows and Ubuntu, +focused native automation tests with AddressSanitizer and +UndefinedBehaviorSanitizer, and a WASI build-isolation check. Fixed pass counts +and pull-request status are intentionally omitted from this page because they +change as coverage evolves. + +## Design history + +The design consolidates the directions explored in EdgeTX +[PR #7337](https://github.com/EdgeTX/edgetx/pull/7337) and +[PR #7646](https://github.com/EdgeTX/edgetx/pull/7646). PR #7337 by Mateusz +Urban (`onliner10`) established the reusable host harness, CLI, flow, fixture, +and host-side framebuffer-tooling direction. PR #7646 explored Windows-native +control, explicit input transitions, simulator-state injection, Lua reload, +restart, and capture synchronized with a real LCD refresh. + +The implementation is a substantial redesign under one bounded protocol and +one cross-platform lifecycle. It does not carry forward the append-only command +file, a simulator-only Lua control API, native PNG encoding, or parallel +automation protocols. Thanks to Mateusz for the original harness direction and +fixture foundation. diff --git a/mkdocs.yml b/mkdocs.yml index 4c70fa87e50..d3b09c56403 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,6 +94,7 @@ nav: - Code Generation: development/code-generation.md - Control Inputs: development/control-inputs.md - External Module Protocols: development/external-module-protocols.md + - Simulator UI Automation: development/simulator-ui-automation.md - YAML Parser/Generator: development/yaml-parser-generator.md - Mixer Synchronisation: development/mixer-synchronisation.md diff --git a/radio/src/gui/colorlcd/LvglWrapper.cpp b/radio/src/gui/colorlcd/LvglWrapper.cpp index d918b5250d3..e90c3fe9755 100644 --- a/radio/src/gui/colorlcd/LvglWrapper.cpp +++ b/radio/src/gui/colorlcd/LvglWrapper.cpp @@ -29,6 +29,10 @@ #include "os/time.h" #include "view_main.h" +#if defined(SIMU_AUTOMATION) +#include "targets/simu/automation_capture.h" +#endif + LvglWrapper* LvglWrapper::_instance = nullptr; static lv_indev_drv_t touchDriver; @@ -370,6 +374,11 @@ void LvglWrapper::run() if (!updating) { // Normal UI loop - call lgvl timer handler updating = true; +#if defined(SIMU_AUTOMATION) + if (edgetx::automation::consumeAutomationLcdInvalidation()) { + lv_obj_invalidate(lv_scr_act()); + } +#endif lv_timer_handler(); updating = false; } else { diff --git a/radio/src/main.cpp b/radio/src/main.cpp index e426bbefa12..4d7a92241bd 100644 --- a/radio/src/main.cpp +++ b/radio/src/main.cpp @@ -28,6 +28,10 @@ #include "edgetx.h" #include "lua/lua_states.h" +#if defined(SIMU_AUTOMATION) +#include "targets/simu/automation_runtime.h" +#endif + #if defined(COLORLCD) #include "view_main.h" #include "startup_shutdown.h" @@ -580,6 +584,10 @@ void perMain() checkKeysLock(); +#if defined(SIMU_AUTOMATION) + edgetx::automation::simuAutomationBeforeUi(); +#endif + #if defined(COLORLCD) MainWindow::instance()->run(); #endif @@ -592,6 +600,9 @@ void perMain() lcdClear(); menuMainView(0); lcdRefresh(); +#endif +#if defined(SIMU_AUTOMATION) + edgetx::automation::simuAutomationAfterUi(); #endif return; } @@ -613,6 +624,9 @@ void perMain() lcdClear(); menuMainView(0); lcdRefresh(); +#endif +#if defined(SIMU_AUTOMATION) + edgetx::automation::simuAutomationAfterUi(); #endif return; } @@ -644,6 +658,10 @@ void perMain() DEBUG_TIMER_STOP(debugTimerGuiMain); #endif +#if defined(SIMU_AUTOMATION) + edgetx::automation::simuAutomationAfterUi(); +#endif + #if defined(PCBX9E) && !defined(SIMU) toplcdRefreshStart(); setTopFirstTimer(getValue(MIXSRC_FIRST_TIMER + g_model.toplcdTimer)); diff --git a/radio/src/targets/simu/CMakeLists.txt b/radio/src/targets/simu/CMakeLists.txt index 74578830d60..6db2564a990 100644 --- a/radio/src/targets/simu/CMakeLists.txt +++ b/radio/src/targets/simu/CMakeLists.txt @@ -40,6 +40,12 @@ set(SIMU_DRIVERS abnormal_reboot.cpp ) +set(SIMU_AUTOMATION_NATIVE OFF) +if(NOT WASI AND NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(SIMU_AUTOMATION_NATIVE ON) + list(APPEND SIMU_DRIVERS automation_runtime.cpp) +endif() + include_directories(${CMAKE_CURRENT_BINARY_DIR}) # Pack all simu driver sources into an object lib @@ -57,6 +63,11 @@ get_property(SIMU_SRC_OPTIONS target_compile_options(simu_drivers PRIVATE ${SIMU_SRC_OPTIONS}) set_property(TARGET simu_drivers PROPERTY POSITION_INDEPENDENT_CODE ON) +if(SIMU_AUTOMATION_NATIVE) + target_compile_definitions(simu_drivers PRIVATE SIMU_AUTOMATION) + target_compile_definitions(radiolib_native PRIVATE SIMU_AUTOMATION) +endif() + if(SIMU_AUX) target_compile_definitions(simu_drivers PRIVATE -DSIMU_COM_PORT=${SIMU_COM_PORT}) endif() @@ -134,6 +145,18 @@ else() sdl_simu.cpp ) + if(SIMU_AUTOMATION_NATIVE) + target_compile_definitions(simu PRIVATE SIMU_AUTOMATION) + endif() + + if(NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + target_sources(simu PRIVATE + automation_protocol.cpp + automation_capture.cpp + automation_stdio.cpp + ) + endif() + target_compile_options(simu PUBLIC -DSIMU) add_png_target(image_assets "assets/images/*.png") diff --git a/radio/src/targets/simu/arg_parser.cpp b/radio/src/targets/simu/arg_parser.cpp index 747a18afd89..ec5e10e1823 100644 --- a/radio/src/targets/simu/arg_parser.cpp +++ b/radio/src/targets/simu/arg_parser.cpp @@ -4,11 +4,26 @@ #include #include #include +#include +#if defined(SIMU_AUTOMATION) +#include +#endif ArgumentParser::ArgumentParser(const std::string &prog_name) : program_name(prog_name) {} bool ArgumentParser::parse(int argc, char *argv[]) { +#if defined(SIMU_AUTOMATION) + // Reserve stdout for protocol records as soon as automation is requested, + // including parse failures that occur before the flag's position. + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--automation-stdio") { + automation_stdio = true; + break; + } + } +#endif + for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; @@ -24,32 +39,52 @@ bool ArgumentParser::parse(int argc, char *argv[]) { } else if (arg == "--settings") { if (!getNextArg(argc, argv, i, settings_path, "settings")) return false; +#if defined(SIMU_AUTOMATION) + } else if (arg == "--automation-stdio") { + // Already recorded by the pre-scan above. + } else if (arg == "--automation-output") { + if (!getNextArg(argc, argv, i, automation_output_path, + "automation-output")) + return false; +#endif } else if (arg == "-h" || arg == "--help") { help_requested = true; return true; } else { - printf("Unknown option: %s\n", arg.c_str()); + printMessage("Unknown option: %s\n", arg.c_str()); printUsage(); return false; } } +#if defined(SIMU_AUTOMATION) + return validateAutomationOptions(); +#else return true; +#endif } void ArgumentParser::printUsage() const { - printf("usage: %s [--width width] [--height height] [--storage path] " - "[--settings path] [-h | --help]\n", - program_name.c_str()); + printMessage("usage: %s [--width width] [--height height] [--storage path] " + "[--settings path] " +#if defined(SIMU_AUTOMATION) + "[--automation-stdio --automation-output path] " +#endif + "[-h | --help]\n", + program_name.c_str()); } void ArgumentParser::printHelp() const { printUsage(); - printf("\nOptions:\n"); - printf(" --width width Set the width (integer)\n"); - printf(" --height height Set the height (integer)\n"); - printf(" --storage path Set the storage path\n"); - printf(" --settings path Set the settings path\n"); - printf(" -h, --help Show this help message\n"); + printMessage("\nOptions:\n"); + printMessage(" --width width Set the width (integer)\n"); + printMessage(" --height height Set the height (integer)\n"); + printMessage(" --storage path Set the storage path\n"); + printMessage(" --settings path Set the settings path\n"); +#if defined(SIMU_AUTOMATION) + printMessage(" --automation-stdio Enable the stdio automation protocol\n"); + printMessage(" --automation-output path Set the automation artifact root\n"); +#endif + printMessage(" -h, --help Show this help message\n"); } bool ArgumentParser::isHelpRequested() const { return help_requested; } @@ -66,6 +101,14 @@ const std::string &ArgumentParser::getSettingsPath() const { return settings_path; } +#if defined(SIMU_AUTOMATION) +const std::string &ArgumentParser::getAutomationOutputPath() const { + return automation_output_path; +} + +bool ArgumentParser::isAutomationStdio() const { return automation_stdio; } +#endif + bool ArgumentParser::hasWidth() const { return width != -1; } bool ArgumentParser::hasHeight() const { return height != -1; } @@ -74,11 +117,50 @@ bool ArgumentParser::hasStoragePath() const { return !storage_path.empty(); } bool ArgumentParser::hasSettingsPath() const { return !settings_path.empty(); } +#if defined(SIMU_AUTOMATION) +bool ArgumentParser::hasAutomationOutputPath() const { + return !automation_output_path.empty(); +} + +bool ArgumentParser::validateAutomationOptions() { + if (automation_stdio != hasAutomationOutputPath()) { + printMessage("Options --automation-stdio and --automation-output must be " + "used together\n"); + return false; + } + + if (!automation_stdio) + return true; + + std::error_code error; + std::filesystem::path output = + std::filesystem::canonical(automation_output_path, error); + if (error || !std::filesystem::is_directory(output, error) || error) { + printMessage("Option --automation-output must name an existing directory\n"); + return false; + } + + automation_output_path = output.string(); + return true; +} +#endif + +void ArgumentParser::printMessage(const char *format, ...) const { + va_list arguments; + va_start(arguments, format); +#if defined(SIMU_AUTOMATION) + vfprintf(automation_stdio ? stderr : stdout, format, arguments); +#else + vfprintf(stdout, format, arguments); +#endif + va_end(arguments); +} + bool ArgumentParser::getNextArg(int argc, char *argv[], int &i, std::string &value, const std::string &option_name) { if (i + 1 >= argc) { - printf("Option --%s requires an argument\n", option_name.c_str()); + printMessage("Option --%s requires an argument\n", option_name.c_str()); printUsage(); return false; } @@ -89,7 +171,7 @@ bool ArgumentParser::getNextArg(int argc, char *argv[], int &i, bool ArgumentParser::parseIntOption(int argc, char *argv[], int &i, int &value, const std::string &option_name) { if (i + 1 >= argc) { - printf("Option --%s requires an argument\n", option_name.c_str()); + printMessage("Option --%s requires an argument\n", option_name.c_str()); printUsage(); return false; } @@ -99,7 +181,7 @@ bool ArgumentParser::parseIntOption(int argc, char *argv[], int &i, int &value, // Check if string is empty or starts with non-digit (except for optional '+') if (!str || *str == '\0' || (!isdigit(*str) && *str != '+')) { - printf("Option --%s requires a valid integer\n", option_name.c_str()); + printMessage("Option --%s requires a valid integer\n", option_name.c_str()); return false; } @@ -107,8 +189,8 @@ bool ArgumentParser::parseIntOption(int argc, char *argv[], int &i, int &value, // Check for conversion errors if (*endptr != '\0' || result <= 0 || result > INT_MAX) { - printf("Option --%s requires a valid positive integer\n", - option_name.c_str()); + printMessage("Option --%s requires a valid positive integer\n", + option_name.c_str()); return false; } diff --git a/radio/src/targets/simu/arg_parser.h b/radio/src/targets/simu/arg_parser.h index 359cc6625e2..df1833b4dbb 100644 --- a/radio/src/targets/simu/arg_parser.h +++ b/radio/src/targets/simu/arg_parser.h @@ -8,6 +8,10 @@ class ArgumentParser { int height = -1; std::string storage_path; std::string settings_path; +#if defined(SIMU_AUTOMATION) + std::string automation_output_path; + bool automation_stdio = false; +#endif bool help_requested = false; std::string program_name; @@ -24,14 +28,25 @@ class ArgumentParser { int getHeight() const; const std::string &getStoragePath() const; const std::string &getSettingsPath() const; +#if defined(SIMU_AUTOMATION) + const std::string &getAutomationOutputPath() const; + bool isAutomationStdio() const; +#endif // Check if option was provided bool hasWidth() const; bool hasHeight() const; bool hasStoragePath() const; bool hasSettingsPath() const; +#if defined(SIMU_AUTOMATION) + bool hasAutomationOutputPath() const; +#endif private: +#if defined(SIMU_AUTOMATION) + bool validateAutomationOptions(); +#endif + void printMessage(const char *format, ...) const; bool getNextArg(int argc, char *argv[], int &i, std::string &value, const std::string &option_name); bool parseIntOption(int argc, char *argv[], int &i, int &value, diff --git a/radio/src/targets/simu/automation_capture.cpp b/radio/src/targets/simu/automation_capture.cpp new file mode 100644 index 00000000000..290a74e86b0 --- /dev/null +++ b/radio/src/targets/simu/automation_capture.cpp @@ -0,0 +1,708 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#include "automation_capture.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#include +#endif + +namespace edgetx +{ +namespace automation +{ +namespace +{ + +constexpr int WRITE_CANCELLABLE = 0; +constexpr int WRITE_CANCELLED = 1; +constexpr int WRITE_COMMITTING = 2; + +std::atomic lcdInvalidationRequested{false}; + +void setError(std::string* error, const std::string& message) +{ + if (error != nullptr) *error = message; +} + +void removeIfPresent(const std::filesystem::path& path) +{ + std::error_code ignored; + (void)std::filesystem::remove(path, ignored); +} + +#if defined(_WIN32) + +std::string windowsError(const char* operation, DWORD error) +{ + return std::string(operation) + " (Win32 error " + std::to_string(error) + + ")"; +} + +bool writeAll(HANDLE file, const void* data, std::size_t size, + std::string* error) +{ + const auto* bytes = static_cast(data); + std::size_t offset = 0; + while (offset < size) { + const std::size_t remaining = size - offset; + const DWORD requested = static_cast( + std::min(remaining, (std::numeric_limits::max)())); + DWORD written = 0; + if (!WriteFile(file, bytes + offset, requested, &written, nullptr)) { + setError(error, windowsError("cannot write capture temporary file", + GetLastError())); + return false; + } + if (written == 0) { + setError(error, "cannot write capture temporary file: zero-byte write"); + return false; + } + offset += written; + } + return true; +} + +#else + +bool writeAll(int file, const void* data, std::size_t size, std::string* error) +{ + const auto* bytes = static_cast(data); + std::size_t offset = 0; + while (offset < size) { + const ssize_t written = write(file, bytes + offset, size - offset); + if (written > 0) { + offset += static_cast(written); + continue; + } + if (written < 0 && errno == EINTR) continue; + setError(error, std::string("cannot write capture temporary file: ") + + std::strerror(errno)); + return false; + } + return true; +} + +#endif + +bool isPathWithin(const std::filesystem::path& child, + const std::filesystem::path& root) +{ + auto childPart = child.begin(); + auto rootPart = root.begin(); + for (; rootPart != root.end(); ++rootPart, ++childPart) { + if (childPart == child.end()) return false; +#if defined(_WIN32) + std::wstring childValue = childPart->native(); + std::wstring rootValue = rootPart->native(); + std::transform(childValue.begin(), childValue.end(), childValue.begin(), + [](wchar_t value) { return std::towlower(value); }); + std::transform(rootValue.begin(), rootValue.end(), rootValue.begin(), + [](wchar_t value) { return std::towlower(value); }); + if (childValue != rootValue) return false; +#else + if (*childPart != *rootPart) return false; +#endif + } + return true; +} + +#if defined(_WIN32) + +bool isUnsafeWin32Filename(const std::filesystem::path& filename) +{ + std::wstring value = filename.native(); + if (value.empty() || value.back() == L' ' || value.back() == L'.') + return true; + for (wchar_t character : value) { + if (character > 0 && character < 32) return true; + if (std::wstring(L"<>:\"/\\|?*").find(character) != std::wstring::npos) + return true; + } + + const std::size_t period = value.find(L'.'); + std::wstring base = value.substr(0, period); + std::transform(base.begin(), base.end(), base.begin(), + [](wchar_t character) { return std::towupper(character); }); + if (base == L"CON" || base == L"PRN" || base == L"AUX" || base == L"NUL") { + return true; + } + if (base.size() == 4 && + (base.compare(0, 3, L"COM") == 0 || base.compare(0, 3, L"LPT") == 0)) { + const wchar_t suffix = base[3]; + return (suffix >= L'1' && suffix <= L'9') || suffix == L'\u00b9' || + suffix == L'\u00b2' || suffix == L'\u00b3'; + } + return false; +} + +#endif + +CaptureOperationResult cancelledResult() +{ + return CaptureOperationResult::failure( + ErrorCode::CaptureCancelled, + "capture cancelled because the session is stopping"); +} + +} // namespace + +CaptureOperationResult CaptureOperationResult::success(std::uint64_t bytes) +{ + CaptureOperationResult result; + result.ok = true; + result.errorCode = ErrorCode::None; + result.bytes = bytes; + return result; +} + +CaptureOperationResult CaptureOperationResult::failure( + ErrorCode code, const std::string& message) +{ + CaptureOperationResult result; + result.errorCode = code; + result.message = message; + return result; +} + +CaptureOperationResult writeRgb565Ppm(const CaptureWriteRequest& request, + std::atomic& commitState) +{ + if (request.pixels == nullptr || request.width == 0 || request.height == 0 || + request.pixelCount != + static_cast(request.width) * request.height) { + return CaptureOperationResult::failure( + ErrorCode::CaptureFailed, "capture framebuffer dimensions are invalid"); + } + + const std::string header = "P6\n" + std::to_string(request.width) + " " + + std::to_string(request.height) + "\n255\n"; + const std::uint64_t expectedBytes = + static_cast(header.size()) + + static_cast(request.pixelCount) * 3; + std::string ioError; + bool opened = false; + +#if defined(_WIN32) + HANDLE file = + CreateFileW(request.artifactPath.temporaryPath.c_str(), GENERIC_WRITE, 0, + nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + return CaptureOperationResult::failure( + ErrorCode::CaptureFailed, + windowsError("cannot open capture temporary file", GetLastError())); + } + opened = true; +#else + int openFlags = O_WRONLY | O_CREAT | O_EXCL; +#if defined(O_CLOEXEC) + openFlags |= O_CLOEXEC; +#endif + int file = open(request.artifactPath.temporaryPath.c_str(), openFlags, 0600); + if (file == -1) { + return CaptureOperationResult::failure( + ErrorCode::CaptureFailed, + std::string("cannot open capture temporary file: ") + + std::strerror(errno)); + } + opened = true; +#endif + + bool writeOk = writeAll(file, header.data(), header.size(), &ioError); + std::vector row(static_cast(request.width) * 3); + for (std::uint16_t y = 0; writeOk && y < request.height; ++y) { + if (commitState.load(std::memory_order_acquire) == WRITE_CANCELLED) { + writeOk = false; + ioError = "capture cancelled"; + break; + } + const std::uint16_t* source = + request.pixels + static_cast(y) * request.width; + for (std::uint16_t x = 0; x < request.width; ++x) { + const std::uint16_t pixel = source[x]; + const std::uint8_t red = static_cast((pixel >> 11) & 0x1f); + const std::uint8_t green = static_cast((pixel >> 5) & 0x3f); + const std::uint8_t blue = static_cast(pixel & 0x1f); + row[static_cast(x) * 3] = + static_cast((red << 3) | (red >> 2)); + row[static_cast(x) * 3 + 1] = + static_cast((green << 2) | (green >> 4)); + row[static_cast(x) * 3 + 2] = + static_cast((blue << 3) | (blue >> 2)); + } + writeOk = writeAll(file, row.data(), row.size(), &ioError); + } + + bool flushOk = writeOk; + if (flushOk) { +#if defined(_WIN32) + if (!FlushFileBuffers(file)) { + ioError = + windowsError("cannot flush capture temporary file", GetLastError()); + flushOk = false; + } +#else + if (fsync(file) != 0) { + ioError = std::string("cannot flush capture temporary file: ") + + std::strerror(errno); + flushOk = false; + } +#endif + } + + bool closeOk = true; + if (opened) { +#if defined(_WIN32) + if (!CloseHandle(file)) { + if (ioError.empty()) + ioError = + windowsError("cannot close capture temporary file", GetLastError()); + closeOk = false; + } +#else + if (close(file) != 0) { + if (ioError.empty()) + ioError = std::string("cannot close capture temporary file: ") + + std::strerror(errno); + closeOk = false; + } +#endif + } + + if (!writeOk || !flushOk || !closeOk) { + removeIfPresent(request.artifactPath.temporaryPath); + if (commitState.load(std::memory_order_acquire) == WRITE_CANCELLED) + return cancelledResult(); + return CaptureOperationResult::failure(ErrorCode::CaptureFailed, ioError); + } + + std::error_code sizeError; + const std::uintmax_t actualBytes = + std::filesystem::file_size(request.artifactPath.temporaryPath, sizeError); + if (sizeError || actualBytes != expectedBytes) { + removeIfPresent(request.artifactPath.temporaryPath); + return CaptureOperationResult::failure( + ErrorCode::CaptureFailed, + sizeError ? "cannot verify capture temporary file size" + : "capture temporary file has an unexpected size"); + } + + int expectedState = WRITE_CANCELLABLE; + if (!commitState.compare_exchange_strong(expectedState, WRITE_COMMITTING, + std::memory_order_acq_rel)) { + removeIfPresent(request.artifactPath.temporaryPath); + return cancelledResult(); + } + +#if defined(_WIN32) + if (!MoveFileExW(request.artifactPath.temporaryPath.c_str(), + request.artifactPath.finalPath.c_str(), + MOVEFILE_WRITE_THROUGH)) { + const DWORD publishError = GetLastError(); + removeIfPresent(request.artifactPath.temporaryPath); + if (publishError == ERROR_FILE_EXISTS || + publishError == ERROR_ALREADY_EXISTS) { + return CaptureOperationResult::failure(ErrorCode::ArtifactExists, + "capture target already exists"); + } + return CaptureOperationResult::failure( + ErrorCode::CaptureFailed, + windowsError("cannot publish capture artifact", publishError)); + } +#else + if (link(request.artifactPath.temporaryPath.c_str(), + request.artifactPath.finalPath.c_str()) != 0) { + const int publishError = errno; + removeIfPresent(request.artifactPath.temporaryPath); + if (publishError == EEXIST) { + return CaptureOperationResult::failure(ErrorCode::ArtifactExists, + "capture target already exists"); + } + return CaptureOperationResult::failure( + ErrorCode::CaptureFailed, + std::string("cannot publish capture artifact: ") + + std::strerror(publishError)); + } + if (unlink(request.artifactPath.temporaryPath.c_str()) != 0) { + const int cleanupError = errno; + removeIfPresent(request.artifactPath.finalPath); + removeIfPresent(request.artifactPath.temporaryPath); + return CaptureOperationResult::failure( + ErrorCode::CaptureFailed, + std::string("cannot remove capture temporary link: ") + + std::strerror(cleanupError)); + } +#endif + + return CaptureOperationResult::success(expectedBytes); +} + +void AutomationCapture::PendingCapture::clear() +{ + id = 0; + epoch = 0; + armedAfter = 0; + capturedSequence = 0; + artifactPath = CaptureArtifactPath(); +} + +AutomationCapture::~AutomationCapture() { shutdown(); } + +bool AutomationCapture::configure(const std::string& outputRoot, + std::uint16_t captureWidth, + std::uint16_t captureHeight, + std::uint8_t captureDepth, std::string* error, + CaptureWriteFunction captureWriteFunction) +{ + if (error != nullptr) error->clear(); + std::lock_guard lock(mutex); + if (stage != Stage::Disabled) { + setError(error, "automation capture is already configured"); + return false; + } + + std::error_code pathError; + std::filesystem::path root = std::filesystem::canonical( + std::filesystem::u8path(outputRoot), pathError); + if (pathError || !std::filesystem::is_directory(root, pathError) || + pathError) { + setError(error, "automation output root is not an existing directory"); + return false; + } + + canonicalRoot = std::move(root); + width = captureWidth; + height = captureHeight; + depth = captureDepth; + writeFunction = + captureWriteFunction == nullptr ? writeRgb565Ppm : captureWriteFunction; + if (depth != 16 || width == 0 || height == 0) { + return true; + } + + try { + snapshot.resize(static_cast(width) * height); + stage = Stage::Idle; + worker = std::thread(&AutomationCapture::workerMain, this); + } catch (const std::exception& exception) { + snapshot.clear(); + stage = Stage::Disabled; + setError(error, + std::string("cannot start capture worker: ") + exception.what()); + return false; + } + return true; +} + +bool AutomationCapture::configured() const +{ + std::lock_guard lock(mutex); + return stage != Stage::Disabled && stage != Stage::Stopping; +} + +CaptureOperationResult AutomationCapture::validatePath( + const std::string& relativePath, RequestId id, SessionEpoch epoch, + CaptureArtifactPath* artifactPath) const +{ + std::filesystem::path root; + std::uint8_t captureDepth = 0; + { + std::lock_guard lock(mutex); + root = canonicalRoot; + captureDepth = depth; + } + if (captureDepth != 16) { + return CaptureOperationResult::failure( + ErrorCode::UnsupportedLcdDepth, + "capture currently requires an RGB565 LCD target"); + } + if (artifactPath == nullptr) { + return CaptureOperationResult::failure(ErrorCode::InternalError, + "capture path output is missing"); + } + if (relativePath.empty() || relativePath.size() > MAX_CAPTURE_PATH_BYTES) { + return CaptureOperationResult::failure( + relativePath.size() > MAX_CAPTURE_PATH_BYTES ? ErrorCode::PathTooLong + : ErrorCode::UnsafePath, + relativePath.size() > MAX_CAPTURE_PATH_BYTES + ? "capture path exceeds 1024 UTF-8 bytes" + : "capture path is empty"); + } + if (relativePath.find('\0') != std::string::npos) { + return CaptureOperationResult::failure(ErrorCode::UnsafePath, + "capture path contains NUL"); + } + if (!isValidUtf8(relativePath)) { + return CaptureOperationResult::failure(ErrorCode::InvalidUtf8, + "capture path is not valid UTF-8"); + } + + std::filesystem::path relative; + try { + relative = std::filesystem::u8path(relativePath); + } catch (const std::system_error&) { + return CaptureOperationResult::failure( + ErrorCode::InvalidUtf8, "capture path cannot be converted from UTF-8"); + } catch (const std::range_error&) { + return CaptureOperationResult::failure( + ErrorCode::InvalidUtf8, "capture path cannot be converted from UTF-8"); + } + if (relative.empty() || relative.is_absolute() || relative.has_root_name() || + relative.has_root_directory() || relative.filename().empty()) { + return CaptureOperationResult::failure( + ErrorCode::UnsafePath, "capture path must be a relative file path"); + } + for (const std::filesystem::path& component : relative) { + if (component == "." || component == "..") { + return CaptureOperationResult::failure( + ErrorCode::UnsafePath, + "capture path cannot contain dot or parent components"); + } + } + if (relative.extension() != ".ppm") { + return CaptureOperationResult::failure( + ErrorCode::UnsafePath, "capture path must end in lowercase .ppm"); + } +#if defined(_WIN32) + if (isUnsafeWin32Filename(relative.filename())) { + return CaptureOperationResult::failure( + ErrorCode::UnsafePath, "capture filename is reserved by Win32"); + } +#endif + + const std::filesystem::path finalPath = root / relative; + const std::filesystem::path parent = finalPath.parent_path(); + std::error_code pathError; + const std::filesystem::path canonicalParent = + std::filesystem::canonical(parent, pathError); + if (pathError || !std::filesystem::is_directory(canonicalParent, pathError) || + pathError || !isPathWithin(canonicalParent, root)) { + return CaptureOperationResult::failure( + ErrorCode::UnsafePath, + "capture parent must be an existing directory under the output root"); + } + + const std::filesystem::file_status finalStatus = + std::filesystem::symlink_status(finalPath, pathError); + if (!pathError && std::filesystem::exists(finalStatus)) { + return CaptureOperationResult::failure(ErrorCode::ArtifactExists, + "capture target already exists"); + } + if (pathError && pathError != std::errc::no_such_file_or_directory) { + return CaptureOperationResult::failure( + ErrorCode::UnsafePath, "cannot inspect capture target path"); + } + + std::filesystem::path temporaryName = "."; + temporaryName += finalPath.filename().native(); + temporaryName += + ".tmp-v1-" + std::to_string(epoch) + "-" + std::to_string(id); + + artifactPath->relative = relativePath; + artifactPath->finalPath = canonicalParent / finalPath.filename(); + artifactPath->temporaryPath = canonicalParent / temporaryName; + return CaptureOperationResult::success(0); +} + +CaptureOperationResult AutomationCapture::arm( + RequestId id, SessionEpoch epoch, DisplaySequence armedAfter, + CaptureArtifactPath&& artifactPath) +{ + std::lock_guard lock(mutex); + if (stage == Stage::Disabled) { + return CaptureOperationResult::failure( + ErrorCode::UnsupportedLcdDepth, + "capture currently requires an RGB565 LCD target"); + } + if (stage != Stage::Idle) { + return CaptureOperationResult::failure( + ErrorCode::OperationBusy, "another capture operation is active"); + } + + pending.id = id; + pending.epoch = epoch; + pending.armedAfter = armedAfter; + pending.capturedSequence = 0; + pending.artifactPath = std::move(artifactPath); + completion = CaptureCompletion(); + commitState.store(WRITE_CANCELLABLE, std::memory_order_release); + stage = Stage::Armed; + return CaptureOperationResult::success(0); +} + +void AutomationCapture::onDisplayFrame(DisplaySequence sequence, + SessionEpoch epoch, + const std::uint16_t* pixels, + std::size_t pixelCount) +{ + std::lock_guard lock(mutex); + if (stage != Stage::Armed || pending.epoch != epoch || + sequence <= pending.armedAfter) { + return; + } + + if (pixels == nullptr || pixelCount != snapshot.size()) { + completeLocked(CaptureOperationResult::failure( + ErrorCode::CaptureFailed, + "LCD notification exposed an unexpected framebuffer size")); + condition.notify_all(); + return; + } + + std::copy_n(pixels, pixelCount, snapshot.data()); + pending.capturedSequence = sequence; + stage = Stage::SnapshotReady; + condition.notify_one(); +} + +bool AutomationCapture::takeCompletion(CaptureCompletion* result) +{ + std::lock_guard lock(mutex); + return takeCompletionLocked(result); +} + +bool AutomationCapture::cancelAndWait(CaptureCompletion* result) +{ + std::unique_lock lock(mutex); + if (stage == Stage::Disabled || stage == Stage::Idle || + stage == Stage::Stopping) { + return false; + } + if (stage == Stage::Completed) return takeCompletionLocked(result); + if (stage == Stage::Armed) { + completeLocked(cancelledResult()); + return takeCompletionLocked(result); + } + + int expectedState = WRITE_CANCELLABLE; + (void)commitState.compare_exchange_strong(expectedState, WRITE_CANCELLED, + std::memory_order_acq_rel); + condition.notify_all(); + condition.wait(lock, [this]() { return stage == Stage::Completed; }); + return takeCompletionLocked(result); +} + +void AutomationCapture::shutdown() +{ + CaptureCompletion ignored; + (void)cancelAndWait(&ignored); + + { + std::lock_guard lock(mutex); + if (!worker.joinable()) { + stage = Stage::Disabled; + snapshot.clear(); + return; + } + stage = Stage::Stopping; + condition.notify_all(); + } + worker.join(); + + std::lock_guard lock(mutex); + stage = Stage::Disabled; + pending.clear(); + completion = CaptureCompletion(); + snapshot.clear(); +} + +void AutomationCapture::workerMain() +{ + while (true) { + CaptureWriteRequest request; + { + std::unique_lock lock(mutex); + condition.wait(lock, [this]() { + return stage == Stage::SnapshotReady || stage == Stage::Stopping; + }); + if (stage == Stage::Stopping) return; + + request.artifactPath = pending.artifactPath; + request.pixels = snapshot.data(); + request.pixelCount = snapshot.size(); + request.width = width; + request.height = height; + stage = Stage::Writing; + } + + CaptureOperationResult result; + try { + if (commitState.load(std::memory_order_acquire) == WRITE_CANCELLED) { + result = cancelledResult(); + } else { + result = writeFunction(request, commitState); + } + } catch (const std::exception& exception) { + removeIfPresent(request.artifactPath.temporaryPath); + result = CaptureOperationResult::failure( + ErrorCode::CaptureFailed, + std::string("capture writer raised an exception: ") + + exception.what()); + } catch (...) { + removeIfPresent(request.artifactPath.temporaryPath); + result = CaptureOperationResult::failure( + ErrorCode::CaptureFailed, + "capture writer raised an unknown exception"); + } + + { + std::lock_guard lock(mutex); + completeLocked(result); + condition.notify_all(); + } + } +} + +void AutomationCapture::completeLocked(const CaptureOperationResult& result) +{ + completion.id = pending.id; + completion.epoch = pending.epoch; + completion.ok = result.ok; + completion.errorCode = result.errorCode; + completion.message = result.message; + completion.artifact.displaySequence = pending.capturedSequence; + completion.artifact.path = pending.artifactPath.relative; + completion.artifact.width = width; + completion.artifact.height = height; + completion.artifact.depth = depth; + completion.artifact.bytes = result.bytes; + stage = Stage::Completed; +} + +bool AutomationCapture::takeCompletionLocked(CaptureCompletion* result) +{ + if (stage != Stage::Completed) return false; + if (result != nullptr) *result = completion; + completion = CaptureCompletion(); + pending.clear(); + stage = Stage::Idle; + return true; +} + +void requestAutomationLcdInvalidation() +{ + lcdInvalidationRequested.store(true, std::memory_order_release); +} + +bool consumeAutomationLcdInvalidation() +{ + return lcdInvalidationRequested.exchange(false, std::memory_order_acq_rel); +} + +} // namespace automation +} // namespace edgetx diff --git a/radio/src/targets/simu/automation_capture.h b/radio/src/targets/simu/automation_capture.h new file mode 100644 index 00000000000..5cc4239b483 --- /dev/null +++ b/radio/src/targets/simu/automation_capture.h @@ -0,0 +1,141 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "automation_protocol.h" + +namespace edgetx +{ +namespace automation +{ + +struct CaptureArtifactPath { + std::string relative; + std::filesystem::path finalPath; + std::filesystem::path temporaryPath; +}; + +struct CaptureCompletion { + RequestId id = 0; + SessionEpoch epoch = 0; + bool ok = false; + ErrorCode errorCode = ErrorCode::CaptureFailed; + std::string message; + CaptureResult artifact; + + bool active() const { return id != 0; } +}; + +struct CaptureOperationResult { + bool ok = false; + ErrorCode errorCode = ErrorCode::CaptureFailed; + std::string message; + std::uint64_t bytes = 0; + + static CaptureOperationResult success(std::uint64_t bytes); + static CaptureOperationResult failure(ErrorCode code, + const std::string& message); +}; + +struct CaptureWriteRequest { + CaptureArtifactPath artifactPath; + const std::uint16_t* pixels = nullptr; + std::size_t pixelCount = 0; + std::uint16_t width = 0; + std::uint16_t height = 0; +}; + +using CaptureWriteFunction = CaptureOperationResult (*)( + const CaptureWriteRequest& request, std::atomic& commitState); + +// commitState is 0 while cancellation may win, 1 after cancellation wins, and +// 2 after the worker owns the final publication step. +CaptureOperationResult writeRgb565Ppm(const CaptureWriteRequest& request, + std::atomic& commitState); + +class AutomationCapture +{ + public: + AutomationCapture() = default; + ~AutomationCapture(); + + AutomationCapture(const AutomationCapture&) = delete; + AutomationCapture& operator=(const AutomationCapture&) = delete; + + bool configure(const std::string& outputRoot, std::uint16_t width, + std::uint16_t height, std::uint8_t depth, std::string* error, + CaptureWriteFunction writeFunction = writeRgb565Ppm); + bool configured() const; + + CaptureOperationResult validatePath(const std::string& relativePath, + RequestId id, SessionEpoch epoch, + CaptureArtifactPath* artifactPath) const; + CaptureOperationResult arm(RequestId id, SessionEpoch epoch, + DisplaySequence armedAfter, + CaptureArtifactPath&& artifactPath); + void onDisplayFrame(DisplaySequence sequence, SessionEpoch epoch, + const std::uint16_t* pixels, std::size_t pixelCount); + + bool takeCompletion(CaptureCompletion* result); + bool cancelAndWait(CaptureCompletion* result); + void shutdown(); + + private: + enum class Stage { + Disabled, + Idle, + Armed, + SnapshotReady, + Writing, + Completed, + Stopping, + }; + + struct PendingCapture { + RequestId id = 0; + SessionEpoch epoch = 0; + DisplaySequence armedAfter = 0; + DisplaySequence capturedSequence = 0; + CaptureArtifactPath artifactPath; + + void clear(); + }; + + void workerMain(); + void completeLocked(const CaptureOperationResult& result); + bool takeCompletionLocked(CaptureCompletion* result); + + mutable std::mutex mutex; + std::condition_variable condition; + Stage stage = Stage::Disabled; + std::filesystem::path canonicalRoot; + std::uint16_t width = 0; + std::uint16_t height = 0; + std::uint8_t depth = 0; + std::vector snapshot; + PendingCapture pending; + CaptureCompletion completion; + CaptureWriteFunction writeFunction = writeRgb565Ppm; + std::atomic commitState{0}; + std::thread worker; +}; + +void requestAutomationLcdInvalidation(); +bool consumeAutomationLcdInvalidation(); + +} // namespace automation +} // namespace edgetx diff --git a/radio/src/targets/simu/automation_protocol.cpp b/radio/src/targets/simu/automation_protocol.cpp new file mode 100644 index 00000000000..dcc8c8c6e2a --- /dev/null +++ b/radio/src/targets/simu/automation_protocol.cpp @@ -0,0 +1,1226 @@ +#include "automation_protocol.h" + +#include +#include + +namespace edgetx +{ +namespace automation +{ + +namespace +{ + +struct CommandSpec { + const char* name; + Command command; + std::size_t minArguments; + std::size_t maxArguments; + bool remainderArgument; +}; + +constexpr CommandSpec COMMANDS[] = { + {"ping", Command::Ping, 0, 0, false}, + {"status", Command::Status, 0, 0, false}, + {"describe", Command::Describe, 0, 0, false}, + {"key-down", Command::KeyDown, 1, 1, false}, + {"key-up", Command::KeyUp, 1, 1, false}, + {"rotate", Command::Rotate, 1, 1, false}, + {"touch-down", Command::TouchDown, 2, 2, false}, + {"touch-move", Command::TouchMove, 2, 2, false}, + {"touch-up", Command::TouchUp, 0, 0, false}, + {"set-switch", Command::SetSwitch, 2, 2, false}, + {"set-analog", Command::SetAnalog, 2, 2, false}, + {"clear-analog", Command::ClearAnalog, 1, 1, false}, + {"set-telemetry", Command::SetTelemetry, 6, 7, false}, + {"reload-lua", Command::ReloadLua, 0, 0, false}, + {"wait-frame", Command::WaitFrame, 1, 1, false}, + {"capture", Command::Capture, 1, 1, true}, + {"restart", Command::Restart, 0, 0, false}, + {"release-all", Command::ReleaseAll, 0, 0, false}, + {"stop", Command::Stop, 0, 0, false}, +}; + +ParseResult makeError(ErrorCode code, const std::string& message, + RequestId id = 0, bool hasId = false) +{ + ParseResult result; + result.status = ParseStatus::Error; + result.error.code = code; + result.error.message = message; + result.error.requestId = id; + result.error.hasRequestId = hasId; + return result; +} + +bool parseUnsigned(const std::string& token, std::uint64_t maximum, + std::uint64_t* value) +{ + if (token.empty()) return false; + + std::uint64_t parsed = 0; + for (char byte : token) { + if (byte < '0' || byte > '9') return false; + const std::uint64_t digit = static_cast(byte - '0'); + if (digit > maximum || parsed > (maximum - digit) / 10) return false; + parsed = parsed * 10 + digit; + } + + *value = parsed; + return true; +} + +bool parseSigned(const std::string& token, std::int64_t minimum, + std::int64_t maximum, std::int64_t* value) +{ + if (token.empty()) return false; + + bool negative = false; + std::size_t offset = 0; + if (token[0] == '-') { + negative = true; + offset = 1; + } else if (token[0] == '+') { + return false; + } + if (offset == token.size()) return false; + + const std::uint64_t positiveLimit = static_cast(maximum); + const std::uint64_t negativeLimit = + static_cast(-(minimum + 1)) + 1; + std::uint64_t magnitude = 0; + if (!parseUnsigned(token.substr(offset), + negative ? negativeLimit : positiveLimit, &magnitude)) { + return false; + } + + if (negative) { + if (magnitude == negativeLimit) { + *value = minimum; + } else { + *value = -static_cast(magnitude); + } + } else { + *value = static_cast(magnitude); + } + return *value >= minimum && *value <= maximum; +} + +bool isAsciiToken(const std::string& token) +{ + if (token.empty()) return false; + for (unsigned char byte : token) { + if (byte <= 0x20 || byte >= 0x7f) return false; + } + return true; +} + +bool isTelemetryLabel(const std::string& token) +{ + if (token.empty() || token.size() > MAX_TELEMETRY_LABEL_BYTES) return false; + for (const unsigned char byte : token) { + const bool alpha = + (byte >= 'A' && byte <= 'Z') || (byte >= 'a' && byte <= 'z'); + const bool digit = byte >= '0' && byte <= '9'; + if (!alpha && !digit && byte != '_' && byte != '-') return false; + } + return true; +} + +std::vector splitArguments(const std::string& arguments, + bool* validSeparators) +{ + std::vector result; + *validSeparators = true; + if (arguments.empty()) return result; + + std::size_t start = 0; + while (start <= arguments.size()) { + const std::size_t separator = arguments.find(' ', start); + const std::size_t end = + separator == std::string::npos ? arguments.size() : separator; + if (end == start) { + *validSeparators = false; + return result; + } + result.push_back(arguments.substr(start, end - start)); + if (separator == std::string::npos) break; + start = separator + 1; + } + return result; +} + +const CommandSpec* findCommand(const std::string& name) +{ + for (const auto& spec : COMMANDS) { + if (name == spec.name) return &spec; + } + return nullptr; +} + +ErrorCode validateArguments(Command command, + const std::vector& arguments) +{ + std::uint64_t unsignedValue = 0; + std::int64_t signedValue = 0; + + switch (command) { + case Command::KeyDown: + case Command::KeyUp: + case Command::ClearAnalog: + return isAsciiToken(arguments[0]) ? ErrorCode::None + : ErrorCode::InvalidArgument; + + case Command::Rotate: + if (!parseSigned(arguments[0], -128, 128, &signedValue)) + return ErrorCode::OutOfRange; + return signedValue == 0 ? ErrorCode::InvalidArgument : ErrorCode::None; + + case Command::TouchDown: + case Command::TouchMove: + for (const auto& argument : arguments) { + if (!parseUnsigned(argument, std::numeric_limits::max(), + &unsignedValue)) + return ErrorCode::OutOfRange; + } + return ErrorCode::None; + + case Command::SetSwitch: + if (!isAsciiToken(arguments[0])) return ErrorCode::InvalidArgument; + return parseSigned(arguments[1], std::numeric_limits::min(), + std::numeric_limits::max(), &signedValue) + ? ErrorCode::None + : ErrorCode::OutOfRange; + + case Command::SetAnalog: + if (!isAsciiToken(arguments[0])) return ErrorCode::InvalidArgument; + return parseUnsigned(arguments[1], 4096, &unsignedValue) + ? ErrorCode::None + : ErrorCode::OutOfRange; + + case Command::SetTelemetry: { + const std::uint64_t limits[] = {65535, 7, 255}; + for (std::size_t index = 0; index < 3; ++index) { + if (!parseUnsigned(arguments[index], limits[index], &unsignedValue)) + return ErrorCode::OutOfRange; + if (index == 0 && unsignedValue == 0) return ErrorCode::OutOfRange; + } + if (!parseSigned(arguments[3], std::numeric_limits::min(), + std::numeric_limits::max(), &signedValue)) + return ErrorCode::OutOfRange; + if (!parseUnsigned(arguments[4], 255, &unsignedValue) || + !parseUnsigned(arguments[5], 2, &unsignedValue)) + return ErrorCode::OutOfRange; + if (arguments.size() == 7 && !isTelemetryLabel(arguments[6])) + return ErrorCode::InvalidArgument; + return ErrorCode::None; + } + + case Command::WaitFrame: + return parseUnsigned(arguments[0], + std::numeric_limits::max(), + &unsignedValue) + ? ErrorCode::None + : ErrorCode::OutOfRange; + + case Command::Capture: + if (arguments[0].size() > MAX_CAPTURE_PATH_BYTES) + return ErrorCode::PathTooLong; + return arguments[0].empty() ? ErrorCode::MissingArgument + : ErrorCode::None; + + default: + return ErrorCode::None; + } +} + +class BoundedJson +{ + public: + explicit BoundedJson(std::size_t limit) : limit(limit) + { + value.reserve(std::min(limit, 512)); + } + + bool append(const std::string& text) + { + if (text.size() > limit - value.size()) return false; + value += text; + return true; + } + + bool append(const char* text) { return append(std::string(text)); } + + bool appendNumber(std::uint64_t number) + { + return append(std::to_string(number)); + } + + bool appendSigned(std::int64_t number) + { + return append(std::to_string(number)); + } + + bool appendBoolean(bool boolean) + { + return append(boolean ? "true" : "false"); + } + + bool appendString(const std::string& text) + { + if (!isValidUtf8(text) || !append("\"")) return false; + static const char HEX[] = "0123456789abcdef"; + + for (unsigned char byte : text) { + switch (byte) { + case '"': + if (!append("\\\"")) return false; + break; + case '\\': + if (!append("\\\\")) return false; + break; + case '\b': + if (!append("\\b")) return false; + break; + case '\f': + if (!append("\\f")) return false; + break; + case '\n': + if (!append("\\n")) return false; + break; + case '\r': + if (!append("\\r")) return false; + break; + case '\t': + if (!append("\\t")) return false; + break; + default: + if (byte < 0x20) { + char escaped[] = { + '\\', 'u', '0', '0', HEX[byte >> 4], HEX[byte & 0x0f], '\0'}; + if (!append(escaped)) return false; + } else if (!append(std::string(1, static_cast(byte)))) { + return false; + } + } + } + return append("\""); + } + + std::string take() { return value; } + + private: + std::size_t limit; + std::string value; +}; + +bool appendLcdDescription(BoundedJson& json, const TargetDescription& target) +{ + return json.append("{\"width\":") && json.appendNumber(target.lcdWidth) && + json.append(",\"height\":") && json.appendNumber(target.lcdHeight) && + json.append(",\"depth\":") && json.appendNumber(target.lcdDepth) && + json.append("}"); +} + +bool appendCapabilities(BoundedJson& json, + const TargetCapabilities& capabilities) +{ + return json.append("{\"rotary\":") && + json.appendBoolean(capabilities.rotary) && + json.append(",\"touch\":") && json.appendBoolean(capabilities.touch) && + json.append(",\"switches\":") && + json.appendBoolean(capabilities.switches) && + json.append(",\"analog\":") && + json.appendBoolean(capabilities.analog) && + json.append(",\"telemetry\":") && + json.appendBoolean(capabilities.telemetry) && + json.append(",\"lua\":") && json.appendBoolean(capabilities.lua) && + json.append(",\"capture\":") && + json.appendBoolean(capabilities.capture) && + json.append(",\"warm_restart\":") && + json.appendBoolean(capabilities.warmRestart) && json.append("}"); +} + +bool appendCommands(BoundedJson& json, const std::vector& commands) +{ + if (!json.append("[")) return false; + for (std::size_t index = 0; index < commands.size(); ++index) { + if (index != 0 && !json.append(",")) return false; + if (!json.appendString(commandName(commands[index]))) return false; + } + return json.append("]"); +} + +bool appendStrings(BoundedJson& json, const std::vector& values) +{ + if (!json.append("[")) return false; + for (std::size_t index = 0; index < values.size(); ++index) { + if (index != 0 && !json.append(",")) return false; + if (!json.appendString(values[index])) return false; + } + return json.append("]"); +} + +bool appendNamedRanges(BoundedJson& json, const std::vector& ranges) +{ + if (!json.append("[")) return false; + for (std::size_t index = 0; index < ranges.size(); ++index) { + if (index != 0 && !json.append(",")) return false; + const NamedRange& range = ranges[index]; + if (!json.append("{\"name\":") || !json.appendString(range.name) || + !json.append(",\"min\":") || !json.appendSigned(range.minimum) || + !json.append(",\"max\":") || !json.appendSigned(range.maximum) || + !json.append("}")) { + return false; + } + } + return json.append("]"); +} + +bool appendStatusResult(BoundedJson& json, const Response& response) +{ + const StatusSnapshot& status = response.status; + const TargetDescription& target = response.target; + if (!json.append(",\"result\":{\"protocol_version\":1,\"running\":") || + !json.appendBoolean(status.running) || !json.append(",\"phase\":") || + !json.appendString(sessionPhaseName(status.phase)) || + !json.append(",\"target\":") || !json.appendString(target.flavour) || + !json.append(",\"lcd\":") || !appendLcdDescription(json, target) || + !json.append(",\"display_seq\":") || + !json.appendNumber(status.displaySequence) || + !json.append(",\"async_operation\":") || + !json.appendString(asyncOperationName(status.asyncOperation)) || + !json.append(",\"request_queue_depth\":") || + !json.appendNumber(status.requestQueueDepth) || + !json.append(",\"firmware_mailbox_depth\":") || + !json.appendNumber(status.firmwareMailboxDepth) || + !json.append(",\"line_overflow_count\":") || + !json.appendNumber(status.lineOverflowCount) || + !json.append(",\"queue_overflow_count\":") || + !json.appendNumber(status.queueOverflowCount) || + !json.append(",\"stale_completion_count\":") || + !json.appendNumber(status.staleCompletionCount) || + !json.append(",\"active_key_count\":") || + !json.appendNumber(status.activeKeyCount) || + !json.append(",\"touch_active\":") || + !json.appendBoolean(status.touchActive) || + !json.append(",\"analog_override_count\":") || + !json.appendNumber(status.analogOverrideCount) || + !json.append(",\"lua_state\":") || !json.appendString(status.luaState) || + !json.append(",\"capabilities\":") || + !appendCapabilities(json, target.capabilities) || + !json.append(",\"output_root\":") || + !json.appendString(target.outputRootReady ? "ready" : "invalid") || + !json.append("}")) { + return false; + } + return true; +} + +bool appendDescriptionResult(BoundedJson& json, const Response& response) +{ + const TargetDescription& target = response.target; + if (!json.append(",\"result\":{\"protocol_version\":1,\"target\":") || + !json.appendString(target.flavour) || !json.append(",\"lcd\":") || + !appendLcdDescription(json, target) || !json.append(",\"commands\":") || + !appendCommands(json, target.commands) || + !json.append(",\"capabilities\":") || + !appendCapabilities(json, target.capabilities) || + !json.append(",\"keys\":") || !appendStrings(json, target.keys) || + !json.append(",\"switches\":") || + !appendNamedRanges(json, target.switches) || + !json.append(",\"analogs\":") || + !appendNamedRanges(json, target.analogs) || !json.append("}")) { + return false; + } + return true; +} + +bool appendFrameResult(BoundedJson& json, const Response& response) +{ + return json.append(",\"result\":{\"display_seq\":") && + json.appendNumber(response.frameSequence) && json.append("}"); +} + +bool appendCaptureResult(BoundedJson& json, const Response& response) +{ + const CaptureResult& capture = response.capture; + return json.append(",\"result\":{\"display_seq\":") && + json.appendNumber(capture.displaySequence) && + json.append(",\"path\":") && json.appendString(capture.path) && + json.append(",\"width\":") && json.appendNumber(capture.width) && + json.append(",\"height\":") && json.appendNumber(capture.height) && + json.append(",\"depth\":") && json.appendNumber(capture.depth) && + json.append(",\"bytes\":") && json.appendNumber(capture.bytes) && + json.append("}"); +} + +bool appendLuaReloadResult(BoundedJson& json, const Response& response) +{ + return json.append(",\"result\":{\"generation\":") && + json.appendNumber(response.luaReload.generation) && + json.append(",\"state\":") && + json.appendString(response.luaReload.state) && json.append("}"); +} + +bool buildResponse(const Response& response, ErrorCode code, + const std::string& message, std::size_t maxBytes, + std::string* output) +{ + BoundedJson json(maxBytes); + if (!json.append("{\"version\":1,\"type\":\"response\",\"id\":") || + !json.appendNumber(response.id) || !json.append(",\"ok\":") || + !json.append(code == ErrorCode::None ? "true" : "false") || + !json.append(",\"epoch\":") || !json.appendNumber(response.epoch)) { + return false; + } + + if (code != ErrorCode::None) { + if (!json.append(",\"error\":{\"code\":\"") || + !json.append(errorCodeName(code)) || !json.append("\",\"message\":") || + !json.appendString(message) || !json.append("}")) { + return false; + } + } else if (response.resultKind == Response::ResultKind::Status) { + if (!appendStatusResult(json, response)) return false; + } else if (response.resultKind == Response::ResultKind::Description) { + if (!appendDescriptionResult(json, response)) return false; + } else if (response.resultKind == Response::ResultKind::Frame) { + if (!appendFrameResult(json, response)) return false; + } else if (response.resultKind == Response::ResultKind::Capture) { + if (!appendCaptureResult(json, response)) return false; + } else if (response.resultKind == Response::ResultKind::LuaReload) { + if (!appendLuaReloadResult(json, response)) return false; + } + + if (!json.append("}\n")) return false; + *output = json.take(); + return true; +} + +bool buildEvent(SessionEpoch epoch, ErrorCode code, const std::string& message, + std::size_t maxBytes, std::string* output) +{ + BoundedJson json(maxBytes); + if (!json.append( + "{\"version\":1,\"type\":\"event\",\"id\":null,\"epoch\":") || + !json.appendNumber(epoch) || !json.append(",\"event\":{\"code\":\"") || + !json.append(errorCodeName(code)) || !json.append("\",\"message\":") || + !json.appendString(message) || !json.append("}}\n")) { + return false; + } + + *output = json.take(); + return true; +} + +} // namespace + +LineBuffer::LineBuffer(std::size_t maxRecordBytes) : + maxRecordBytes(maxRecordBytes) +{ + buffer.reserve(maxRecordBytes); +} + +bool LineBuffer::append(char byte) +{ + if (buffer.size() == maxRecordBytes) { + buffer.clear(); + discarding = true; + pendingCarriageReturn = false; + return false; + } + buffer.push_back(byte); + return true; +} + +void LineBuffer::resetRecord() +{ + buffer.clear(); + pendingCarriageReturn = false; + discarding = false; +} + +std::vector LineBuffer::feed(const char* bytes, std::size_t size) +{ + std::vector events; + for (std::size_t index = 0; index < size; ++index) { + const char byte = bytes[index]; + + if (discarding) { + if (byte == '\n') { + events.push_back({LineEventType::LineTooLong, std::string()}); + resetRecord(); + } + continue; + } + + if (byte == '\n') { + const std::size_t delimiterBytes = pendingCarriageReturn ? 2 : 1; + if (delimiterBytes > maxRecordBytes || + buffer.size() > maxRecordBytes - delimiterBytes) { + events.push_back({LineEventType::LineTooLong, std::string()}); + resetRecord(); + continue; + } + pendingCarriageReturn = false; + events.push_back({LineEventType::Record, buffer}); + resetRecord(); + continue; + } + + if (byte == '\r') { + if (pendingCarriageReturn && !append('\r')) continue; + pendingCarriageReturn = true; + continue; + } + + if (pendingCarriageReturn) { + if (!append('\r')) continue; + pendingCarriageReturn = false; + } + append(byte); + } + return events; +} + +std::vector LineBuffer::finish() +{ + std::vector events; + if (discarding) { + events.push_back({LineEventType::LineTooLong, std::string()}); + } else { + if (pendingCarriageReturn) append('\r'); + if (discarding) { + events.push_back({LineEventType::LineTooLong, std::string()}); + } else if (!buffer.empty()) { + events.push_back({LineEventType::PartialRecordAtEof, buffer}); + } + } + resetRecord(); + return events; +} + +std::size_t LineBuffer::bufferedBytes() const { return buffer.size(); } + +bool LineBuffer::isDiscarding() const { return discarding; } + +ParseResult ProtocolParser::parse(const std::string& input) +{ + if (input.empty()) return ParseResult(); + if (input.size() >= MAX_RECORD_BYTES) + return makeError(ErrorCode::LineTooLong, "record exceeds 16 KiB"); + + std::string record = input; + if (!record.empty() && record.back() == '\r') record.pop_back(); + if (record.empty()) return ParseResult(); + if (record[0] == ' ' || record[0] == '\t') + return makeError(ErrorCode::InvalidRecord, + "leading whitespace is not allowed"); + + const std::size_t versionEnd = record.find(' '); + if (versionEnd == std::string::npos) + return makeError( + record == "v1" ? ErrorCode::InvalidId : ErrorCode::UnsupportedVersion, + record == "v1" ? "request id is required" + : "unsupported protocol version"); + + const std::string version = record.substr(0, versionEnd); + const std::size_t idStart = versionEnd + 1; + const std::size_t idEnd = record.find(' ', idStart); + const std::string idToken = + record.substr(idStart, idEnd == std::string::npos ? std::string::npos + : idEnd - idStart); + std::uint64_t id = 0; + if (!parseUnsigned(idToken, std::numeric_limits::max(), &id) || + id == 0) { + return makeError(ErrorCode::InvalidId, "request id must be 1..UINT64_MAX"); + } + + if (version != "v1") { + return makeError(ErrorCode::UnsupportedVersion, + "unsupported protocol version", id, true); + } + + if (id <= lastId) { + return makeError(ErrorCode::IdNotMonotonic, + "request id must increase monotonically", id, true); + } + lastId = id; + + if (!isValidUtf8(record)) + return makeError(ErrorCode::InvalidUtf8, "record is not valid UTF-8", id, + true); + if (record.find('\0') != std::string::npos) + return makeError(ErrorCode::InvalidRecord, "NUL is not allowed", id, true); + if (idEnd == std::string::npos || idEnd + 1 == record.size()) + return makeError(ErrorCode::MissingArgument, "command is required", id, + true); + + const std::size_t commandStart = idEnd + 1; + const std::size_t commandEnd = record.find(' ', commandStart); + const std::string commandToken = + record.substr(commandStart, commandEnd == std::string::npos + ? std::string::npos + : commandEnd - commandStart); + if (!isAsciiToken(commandToken)) + return makeError(ErrorCode::InvalidRecord, "invalid command token", id, + true); + + const CommandSpec* spec = findCommand(commandToken); + if (spec == nullptr) + return makeError(ErrorCode::UnknownCommand, "unknown command", id, true); + + std::vector arguments; + if (commandEnd != std::string::npos) { + const std::string remainder = record.substr(commandEnd + 1); + if (remainder.empty()) { + return makeError(spec->minArguments == 0 ? ErrorCode::ExtraArgument + : ErrorCode::MissingArgument, + "empty command argument", id, true); + } + if (spec->remainderArgument) { + arguments.push_back(remainder); + } else { + bool separatorsValid = false; + arguments = splitArguments(remainder, &separatorsValid); + if (!separatorsValid) + return makeError(ErrorCode::InvalidRecord, + "arguments must use one ASCII space", id, true); + } + } + + if (arguments.size() < spec->minArguments) + return makeError(ErrorCode::MissingArgument, "missing command argument", id, + true); + if (arguments.size() > spec->maxArguments) + return makeError(ErrorCode::ExtraArgument, "too many command arguments", id, + true); + + const ErrorCode argumentError = validateArguments(spec->command, arguments); + if (argumentError != ErrorCode::None) + return makeError(argumentError, "invalid command argument", id, true); + + ParseResult result; + result.status = ParseStatus::Request; + result.request.id = id; + result.request.command = spec->command; + result.request.arguments = arguments; + return result; +} + +RequestId ProtocolParser::lastRequestId() const { return lastId; } + +Response Response::success(RequestId id, SessionEpoch epoch) +{ + Response response; + response.id = id; + response.epoch = epoch; + return response; +} + +Response Response::successWithStatus(RequestId id, SessionEpoch epoch, + const StatusSnapshot& status, + const TargetDescription& target) +{ + Response response = success(id, epoch); + response.resultKind = ResultKind::Status; + response.status = status; + response.target = target; + return response; +} + +Response Response::successWithDescription(RequestId id, SessionEpoch epoch, + const TargetDescription& target) +{ + Response response = success(id, epoch); + response.resultKind = ResultKind::Description; + response.target = target; + return response; +} + +Response Response::successWithFrame(RequestId id, SessionEpoch epoch, + DisplaySequence displaySequence) +{ + Response response = success(id, epoch); + response.resultKind = ResultKind::Frame; + response.frameSequence = displaySequence; + return response; +} + +Response Response::successWithCapture(RequestId id, SessionEpoch epoch, + const CaptureResult& capture) +{ + Response response = success(id, epoch); + response.resultKind = ResultKind::Capture; + response.capture = capture; + return response; +} + +Response Response::successWithLuaReload(RequestId id, SessionEpoch epoch, + const LuaReloadResult& luaReload) +{ + Response response = success(id, epoch); + response.resultKind = ResultKind::LuaReload; + response.luaReload = luaReload; + return response; +} + +Response Response::failure(RequestId id, SessionEpoch epoch, ErrorCode code, + const std::string& message) +{ + Response response; + response.id = id; + response.ok = false; + response.epoch = epoch; + response.errorCode = code; + response.message = message; + return response; +} + +SerializeResult serializeResponse(const Response& response, std::string* output, + std::size_t maxBytes) +{ + if (output == nullptr) return SerializeResult::LimitTooSmall; + output->clear(); + + const ErrorCode code = response.ok ? ErrorCode::None + : (response.errorCode == ErrorCode::None + ? ErrorCode::InternalError + : response.errorCode); + if (buildResponse(response, code, response.message, maxBytes, output)) + return SerializeResult::Serialized; + + output->clear(); + if (buildResponse(response, ErrorCode::ResponseTooLarge, + "response exceeded the protocol limit", maxBytes, output)) { + return SerializeResult::UsedSizeFallback; + } + + output->clear(); + return SerializeResult::LimitTooSmall; +} + +SerializeResult serializeEvent(SessionEpoch epoch, ErrorCode code, + const std::string& message, std::string* output, + std::size_t maxBytes) +{ + if (output == nullptr) return SerializeResult::LimitTooSmall; + output->clear(); + + const ErrorCode eventCode = + code == ErrorCode::None ? ErrorCode::InternalError : code; + if (buildEvent(epoch, eventCode, message, maxBytes, output)) + return SerializeResult::Serialized; + + output->clear(); + if (buildEvent(epoch, ErrorCode::ResponseTooLarge, + "event exceeded the protocol limit", maxBytes, output)) { + return SerializeResult::UsedSizeFallback; + } + + output->clear(); + return SerializeResult::LimitTooSmall; +} + +const char* commandName(Command command) +{ + for (const auto& spec : COMMANDS) { + if (spec.command == command) return spec.name; + } + return "unknown"; +} + +const char* errorCodeName(ErrorCode code) +{ + switch (code) { + case ErrorCode::None: + return "none"; + case ErrorCode::InvalidUtf8: + return "invalid_utf8"; + case ErrorCode::LineTooLong: + return "line_too_long"; + case ErrorCode::InvalidRecord: + return "invalid_record"; + case ErrorCode::UnsupportedVersion: + return "unsupported_version"; + case ErrorCode::InvalidId: + return "invalid_id"; + case ErrorCode::IdNotMonotonic: + return "id_not_monotonic"; + case ErrorCode::UnknownCommand: + return "unknown_command"; + case ErrorCode::MissingArgument: + return "missing_argument"; + case ErrorCode::ExtraArgument: + return "extra_argument"; + case ErrorCode::InvalidArgument: + return "invalid_argument"; + case ErrorCode::OutOfRange: + return "out_of_range"; + case ErrorCode::UnsupportedCommand: + return "unsupported_command"; + case ErrorCode::UnsupportedTarget: + return "unsupported_target"; + case ErrorCode::UnsupportedLcdDepth: + return "unsupported_lcd_depth"; + case ErrorCode::KeyAlreadyDown: + return "key_already_down"; + case ErrorCode::KeyNotDown: + return "key_not_down"; + case ErrorCode::TouchAlreadyDown: + return "touch_already_down"; + case ErrorCode::TouchNotDown: + return "touch_not_down"; + case ErrorCode::QueueFull: + return "queue_full"; + case ErrorCode::FirmwareQueueFull: + return "firmware_queue_full"; + case ErrorCode::OperationBusy: + return "operation_busy"; + case ErrorCode::ResponseTooLarge: + return "response_too_large"; + case ErrorCode::SessionStopping: + return "session_stopping"; + case ErrorCode::RestartFailed: + return "restart_failed"; + case ErrorCode::LuaUnavailable: + return "lua_unavailable"; + case ErrorCode::LuaPanic: + return "lua_panic"; + case ErrorCode::UnsafePath: + return "unsafe_path"; + case ErrorCode::PathTooLong: + return "path_too_long"; + case ErrorCode::ArtifactExists: + return "artifact_exists"; + case ErrorCode::CaptureFailed: + return "capture_failed"; + case ErrorCode::CaptureCancelled: + return "capture_cancelled"; + case ErrorCode::StdinClosed: + return "stdin_closed"; + case ErrorCode::StdoutClosed: + return "stdout_closed"; + case ErrorCode::IoError: + return "io_error"; + case ErrorCode::InvariantViolation: + return "invariant_violation"; + case ErrorCode::InternalError: + return "internal_error"; + } + return "internal_error"; +} + +const char* sessionPhaseName(SessionPhase phase) +{ + switch (phase) { + case SessionPhase::Starting: + return "starting"; + case SessionPhase::Ready: + return "ready"; + case SessionPhase::Restarting: + return "restarting"; + case SessionPhase::Stopped: + return "stopped"; + } + return "stopped"; +} + +const char* asyncOperationName(AsyncOperation operation) +{ + switch (operation) { + case AsyncOperation::None: + return "none"; + case AsyncOperation::WaitFrame: + return "wait_frame"; + case AsyncOperation::Capture: + return "capture"; + case AsyncOperation::Firmware: + return "firmware"; + case AsyncOperation::ReloadLua: + return "reload_lua"; + case AsyncOperation::Restart: + return "restart"; + } + return "none"; +} + +bool isValidUtf8(const std::string& value) +{ + std::size_t index = 0; + while (index < value.size()) { + const unsigned char first = static_cast(value[index]); + if (first <= 0x7f) { + ++index; + continue; + } + + std::size_t continuationCount = 0; + std::uint32_t codePoint = 0; + if (first >= 0xc2 && first <= 0xdf) { + continuationCount = 1; + codePoint = first & 0x1f; + } else if (first >= 0xe0 && first <= 0xef) { + continuationCount = 2; + codePoint = first & 0x0f; + } else if (first >= 0xf0 && first <= 0xf4) { + continuationCount = 3; + codePoint = first & 0x07; + } else { + return false; + } + + if (index + continuationCount >= value.size()) return false; + for (std::size_t continuation = 1; continuation <= continuationCount; + ++continuation) { + const unsigned char byte = + static_cast(value[index + continuation]); + if ((byte & 0xc0) != 0x80) return false; + codePoint = (codePoint << 6) | (byte & 0x3f); + } + + if ((continuationCount == 2 && codePoint < 0x800) || + (continuationCount == 3 && codePoint < 0x10000) || + (codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) { + return false; + } + index += continuationCount + 1; + } + return true; +} + +TerminalResponseOwner::TerminalResponseOwner(RequestId requestId, + SessionEpoch epoch) : + id(requestId), ownerEpoch(epoch) +{ +} + +TerminalClaimResult TerminalResponseOwner::claim(SessionEpoch currentEpoch) +{ + if (terminal) return TerminalClaimResult::Duplicate; + if (currentEpoch != ownerEpoch) return TerminalClaimResult::StaleEpoch; + terminal = true; + return TerminalClaimResult::Claimed; +} + +TerminalClaimResult TerminalResponseOwner::cancel(SessionEpoch currentEpoch) +{ + return claim(currentEpoch); +} + +bool TerminalResponseOwner::isTerminal() const { return terminal; } + +RequestId TerminalResponseOwner::requestId() const { return id; } + +SessionState::SessionState() = default; + +RequestId SessionState::onDisplayFrame() +{ + if (currentPhase == SessionPhase::Stopped) return 0; + if (currentDisplaySequence != (std::numeric_limits::max)()) { + ++currentDisplaySequence; + } + bool becameReady = false; + if (currentPhase == SessionPhase::Starting) { + if (currentEpoch == 0) currentEpoch = 1; + currentPhase = SessionPhase::Ready; + becameReady = true; + } + + if (becameReady && activeOperation == AsyncOperation::Restart && + activeRequestEpoch == currentEpoch) { + const RequestId completedId = activeRequestId; + lastTerminalAsyncRequestId = completedId; + clearAsync(); + return completedId; + } + return 0; +} + +TransitionResult SessionState::keyDown(const std::string& key) +{ + if (currentPhase != SessionPhase::Ready || key.empty()) + return TransitionResult::InvalidState; + if (activeOperation != AsyncOperation::None) return TransitionResult::Busy; + if (activeKeys.count(key) != 0) return TransitionResult::Duplicate; + activeKeys.insert(key); + return TransitionResult::Applied; +} + +TransitionResult SessionState::keyUp(const std::string& key) +{ + if (currentPhase != SessionPhase::Ready) + return TransitionResult::InvalidState; + if (activeOperation != AsyncOperation::None) return TransitionResult::Busy; + const auto keyIterator = activeKeys.find(key); + if (keyIterator == activeKeys.end()) return TransitionResult::InvalidState; + activeKeys.erase(keyIterator); + return TransitionResult::Applied; +} + +TransitionResult SessionState::touchDown(std::uint16_t x, std::uint16_t y) +{ + if (currentPhase != SessionPhase::Ready) + return TransitionResult::InvalidState; + if (activeOperation != AsyncOperation::None) return TransitionResult::Busy; + if (touchActive) return TransitionResult::Duplicate; + touchActive = true; + touchX = x; + touchY = y; + return TransitionResult::Applied; +} + +TransitionResult SessionState::touchMove(std::uint16_t x, std::uint16_t y) +{ + if (currentPhase != SessionPhase::Ready) + return TransitionResult::InvalidState; + if (activeOperation != AsyncOperation::None) return TransitionResult::Busy; + if (!touchActive) return TransitionResult::InvalidState; + touchX = x; + touchY = y; + return TransitionResult::Applied; +} + +TransitionResult SessionState::touchUp() +{ + if (currentPhase != SessionPhase::Ready) + return TransitionResult::InvalidState; + if (activeOperation != AsyncOperation::None) return TransitionResult::Busy; + if (!touchActive) return TransitionResult::InvalidState; + touchActive = false; + return TransitionResult::Applied; +} + +void SessionState::releaseAll() +{ + activeKeys.clear(); + touchActive = false; +} + +TransitionResult SessionState::enqueue(RequestId id) +{ + if (currentPhase == SessionPhase::Stopped || id == 0) + return TransitionResult::InvalidState; + if (pendingRequests.size() == MAX_PENDING_REQUESTS) + return TransitionResult::QueueFull; + pendingRequests.push_back(id); + return TransitionResult::Applied; +} + +bool SessionState::dequeue(RequestId* id) +{ + if (id == nullptr || pendingRequests.empty()) return false; + *id = pendingRequests.front(); + pendingRequests.pop_front(); + return true; +} + +TransitionResult SessionState::beginAsync(AsyncOperation operation, + RequestId id) +{ + if (currentPhase != SessionPhase::Ready || + operation == AsyncOperation::None || id == 0) { + return TransitionResult::InvalidState; + } + if (activeOperation != AsyncOperation::None) return TransitionResult::Busy; + + activeOperation = operation; + activeRequestId = id; + activeRequestEpoch = currentEpoch; + if (operation == AsyncOperation::Restart) + currentPhase = SessionPhase::Restarting; + return TransitionResult::Applied; +} + +TransitionResult SessionState::completeAsync(RequestId id, + SessionEpoch completionEpoch) +{ + if (completionEpoch != currentEpoch) return TransitionResult::StaleEpoch; + if (activeOperation == AsyncOperation::None) { + return id == lastTerminalAsyncRequestId ? TransitionResult::Duplicate + : TransitionResult::NotPending; + } + if (activeOperation == AsyncOperation::Restart) + return TransitionResult::InvalidState; + if (id != activeRequestId) return TransitionResult::NotPending; + if (completionEpoch != activeRequestEpoch) + return TransitionResult::StaleEpoch; + + lastTerminalAsyncRequestId = id; + clearAsync(); + return TransitionResult::Applied; +} + +TransitionResult SessionState::cancelAsync() +{ + if (activeOperation == AsyncOperation::None) + return TransitionResult::NotPending; + if (currentPhase == SessionPhase::Restarting) + currentPhase = SessionPhase::Ready; + lastTerminalAsyncRequestId = activeRequestId; + clearAsync(); + return TransitionResult::Applied; +} + +TransitionResult SessionState::restartTasksStarted(RequestId id, + SessionEpoch requestEpoch) +{ + if (currentPhase != SessionPhase::Restarting || + activeOperation != AsyncOperation::Restart || id != activeRequestId) + return TransitionResult::NotPending; + if (requestEpoch != currentEpoch || requestEpoch != activeRequestEpoch) + return TransitionResult::StaleEpoch; + + releaseAll(); + pendingRequests.clear(); + currentEpoch = currentEpoch == 0 ? 1 : currentEpoch + 1; + activeRequestEpoch = currentEpoch; + currentPhase = SessionPhase::Starting; + return TransitionResult::Applied; +} + +void SessionState::stop() +{ + if (activeOperation != AsyncOperation::None) + lastTerminalAsyncRequestId = activeRequestId; + clearAsync(); + releaseAll(); + pendingRequests.clear(); + currentPhase = SessionPhase::Stopped; +} + +SessionPhase SessionState::phase() const { return currentPhase; } + +SessionEpoch SessionState::epoch() const { return currentEpoch; } + +DisplaySequence SessionState::displaySequence() const +{ + return currentDisplaySequence; +} + +AsyncOperation SessionState::asyncOperation() const { return activeOperation; } + +std::size_t SessionState::activeKeyCount() const { return activeKeys.size(); } + +std::vector SessionState::activeKeyNames() const +{ + return std::vector(activeKeys.begin(), activeKeys.end()); +} + +bool SessionState::isTouchActive() const { return touchActive; } + +std::size_t SessionState::queuedRequestCount() const +{ + return pendingRequests.size(); +} + +void SessionState::clearAsync() +{ + activeOperation = AsyncOperation::None; + activeRequestId = 0; + activeRequestEpoch = 0; +} + +} // namespace automation +} // namespace edgetx diff --git a/radio/src/targets/simu/automation_protocol.h b/radio/src/targets/simu/automation_protocol.h new file mode 100644 index 00000000000..fc4ae4dedbf --- /dev/null +++ b/radio/src/targets/simu/automation_protocol.h @@ -0,0 +1,372 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace edgetx +{ +namespace automation +{ + +// Total wire size, including the LF or CRLF record delimiter. +constexpr std::size_t MAX_RECORD_BYTES = 16 * 1024; +constexpr std::size_t MAX_RESPONSE_BYTES = 16 * 1024; +constexpr std::size_t MAX_CAPTURE_PATH_BYTES = 1024; +constexpr std::size_t MAX_PENDING_REQUESTS = 64; +constexpr std::size_t MAX_TELEMETRY_LABEL_BYTES = 4; + +using RequestId = std::uint64_t; +using SessionEpoch = std::uint64_t; +using DisplaySequence = std::uint64_t; + +enum class Command { + Ping, + Status, + Describe, + KeyDown, + KeyUp, + Rotate, + TouchDown, + TouchMove, + TouchUp, + SetSwitch, + SetAnalog, + ClearAnalog, + SetTelemetry, + ReloadLua, + WaitFrame, + Capture, + Restart, + ReleaseAll, + Stop, +}; + +enum class ErrorCode { + None, + InvalidUtf8, + LineTooLong, + InvalidRecord, + UnsupportedVersion, + InvalidId, + IdNotMonotonic, + UnknownCommand, + MissingArgument, + ExtraArgument, + InvalidArgument, + OutOfRange, + UnsupportedCommand, + UnsupportedTarget, + UnsupportedLcdDepth, + KeyAlreadyDown, + KeyNotDown, + TouchAlreadyDown, + TouchNotDown, + QueueFull, + FirmwareQueueFull, + OperationBusy, + ResponseTooLarge, + SessionStopping, + RestartFailed, + LuaUnavailable, + LuaPanic, + UnsafePath, + PathTooLong, + ArtifactExists, + CaptureFailed, + CaptureCancelled, + StdinClosed, + StdoutClosed, + IoError, + InvariantViolation, + InternalError, +}; + +enum class AsyncOperation { + None, + WaitFrame, + Capture, + Firmware, + ReloadLua, + Restart, +}; + +enum class SessionPhase { + Starting, + Ready, + Restarting, + Stopped, +}; + +struct TargetCapabilities { + bool rotary = false; + bool touch = false; + bool switches = false; + bool analog = false; + bool telemetry = false; + bool lua = false; + bool capture = false; + bool warmRestart = false; +}; + +struct NamedRange { + std::string name; + std::int32_t minimum = 0; + std::int32_t maximum = 0; +}; + +struct TargetDescription { + std::string flavour; + std::uint16_t lcdWidth = 0; + std::uint16_t lcdHeight = 0; + std::uint8_t lcdDepth = 0; + std::vector commands; + TargetCapabilities capabilities; + std::vector keys; + std::vector switches; + std::vector analogs; + bool outputRootReady = false; +}; + +struct StatusSnapshot { + bool running = false; + SessionPhase phase = SessionPhase::Starting; + DisplaySequence displaySequence = 0; + AsyncOperation asyncOperation = AsyncOperation::None; + std::size_t requestQueueDepth = 0; + std::size_t firmwareMailboxDepth = 0; + std::uint64_t lineOverflowCount = 0; + std::uint64_t queueOverflowCount = 0; + std::uint64_t staleCompletionCount = 0; + std::size_t activeKeyCount = 0; + bool touchActive = false; + std::size_t analogOverrideCount = 0; + std::string luaState = "unavailable"; +}; + +struct CaptureResult { + DisplaySequence displaySequence = 0; + std::string path; + std::uint16_t width = 0; + std::uint16_t height = 0; + std::uint8_t depth = 0; + std::uint64_t bytes = 0; +}; + +struct LuaReloadResult { + std::uint64_t generation = 0; + std::string state; +}; + +struct Request { + RequestId id = 0; + Command command = Command::Ping; + std::vector arguments; +}; + +struct ProtocolError { + ErrorCode code = ErrorCode::None; + std::string message; + RequestId requestId = 0; + bool hasRequestId = false; +}; + +enum class ParseStatus { + Ignored, + Request, + Error, +}; + +struct ParseResult { + ParseStatus status = ParseStatus::Ignored; + Request request; + ProtocolError error; +}; + +enum class LineEventType { + Record, + LineTooLong, + PartialRecordAtEof, +}; + +struct LineEvent { + LineEventType type = LineEventType::Record; + std::string record; +}; + +class LineBuffer +{ + public: + explicit LineBuffer(std::size_t maxRecordBytes = MAX_RECORD_BYTES); + + std::vector feed(const char* bytes, std::size_t size); + std::vector finish(); + + std::size_t bufferedBytes() const; + bool isDiscarding() const; + + private: + bool append(char byte); + void resetRecord(); + + const std::size_t maxRecordBytes; + std::string buffer; + bool pendingCarriageReturn = false; + bool discarding = false; +}; + +class ProtocolParser +{ + public: + ParseResult parse(const std::string& record); + RequestId lastRequestId() const; + + private: + RequestId lastId = 0; +}; + +struct Response { + enum class ResultKind { + None, + Status, + Description, + Frame, + Capture, + LuaReload, + }; + + RequestId id = 0; + bool ok = true; + SessionEpoch epoch = 0; + ErrorCode errorCode = ErrorCode::None; + std::string message; + ResultKind resultKind = ResultKind::None; + StatusSnapshot status; + TargetDescription target; + DisplaySequence frameSequence = 0; + CaptureResult capture; + LuaReloadResult luaReload; + + static Response success(RequestId id, SessionEpoch epoch); + static Response successWithStatus(RequestId id, SessionEpoch epoch, + const StatusSnapshot& status, + const TargetDescription& target); + static Response successWithDescription(RequestId id, SessionEpoch epoch, + const TargetDescription& target); + static Response successWithFrame(RequestId id, SessionEpoch epoch, + DisplaySequence displaySequence); + static Response successWithCapture(RequestId id, SessionEpoch epoch, + const CaptureResult& capture); + static Response successWithLuaReload(RequestId id, SessionEpoch epoch, + const LuaReloadResult& luaReload); + static Response failure(RequestId id, SessionEpoch epoch, ErrorCode code, + const std::string& message); +}; + +enum class SerializeResult { + Serialized, + UsedSizeFallback, + LimitTooSmall, +}; + +SerializeResult serializeResponse(const Response& response, std::string* output, + std::size_t maxBytes = MAX_RESPONSE_BYTES); +SerializeResult serializeEvent(SessionEpoch epoch, ErrorCode code, + const std::string& message, std::string* output, + std::size_t maxBytes = MAX_RESPONSE_BYTES); + +const char* commandName(Command command); +const char* errorCodeName(ErrorCode code); +const char* sessionPhaseName(SessionPhase phase); +const char* asyncOperationName(AsyncOperation operation); +bool isValidUtf8(const std::string& value); + +enum class TerminalClaimResult { + Claimed, + Duplicate, + StaleEpoch, +}; + +class TerminalResponseOwner +{ + public: + TerminalResponseOwner(RequestId requestId, SessionEpoch epoch); + TerminalResponseOwner(const TerminalResponseOwner&) = delete; + TerminalResponseOwner& operator=(const TerminalResponseOwner&) = delete; + TerminalResponseOwner(TerminalResponseOwner&&) = delete; + TerminalResponseOwner& operator=(TerminalResponseOwner&&) = delete; + + TerminalClaimResult claim(SessionEpoch currentEpoch); + TerminalClaimResult cancel(SessionEpoch currentEpoch); + bool isTerminal() const; + RequestId requestId() const; + + private: + RequestId id; + SessionEpoch ownerEpoch; + bool terminal = false; +}; + +enum class TransitionResult { + Applied, + Duplicate, + Busy, + InvalidState, + NotPending, + StaleEpoch, + QueueFull, +}; + +class SessionState +{ + public: + SessionState(); + + RequestId onDisplayFrame(); + TransitionResult keyDown(const std::string& key); + TransitionResult keyUp(const std::string& key); + TransitionResult touchDown(std::uint16_t x, std::uint16_t y); + TransitionResult touchMove(std::uint16_t x, std::uint16_t y); + TransitionResult touchUp(); + void releaseAll(); + + TransitionResult enqueue(RequestId id); + bool dequeue(RequestId* id); + + TransitionResult beginAsync(AsyncOperation operation, RequestId id); + TransitionResult completeAsync(RequestId id, SessionEpoch completionEpoch); + TransitionResult cancelAsync(); + TransitionResult restartTasksStarted(RequestId id, SessionEpoch requestEpoch); + void stop(); + + SessionPhase phase() const; + SessionEpoch epoch() const; + DisplaySequence displaySequence() const; + AsyncOperation asyncOperation() const; + std::size_t activeKeyCount() const; + std::vector activeKeyNames() const; + bool isTouchActive() const; + std::size_t queuedRequestCount() const; + + private: + void clearAsync(); + + SessionPhase currentPhase = SessionPhase::Starting; + SessionEpoch currentEpoch = 0; + DisplaySequence currentDisplaySequence = 0; + std::set activeKeys; + bool touchActive = false; + std::uint16_t touchX = 0; + std::uint16_t touchY = 0; + std::deque pendingRequests; + AsyncOperation activeOperation = AsyncOperation::None; + RequestId activeRequestId = 0; + SessionEpoch activeRequestEpoch = 0; + RequestId lastTerminalAsyncRequestId = 0; +}; + +} // namespace automation +} // namespace edgetx diff --git a/radio/src/targets/simu/automation_runtime.cpp b/radio/src/targets/simu/automation_runtime.cpp new file mode 100644 index 00000000000..a9ac52e3373 --- /dev/null +++ b/radio/src/targets/simu/automation_runtime.cpp @@ -0,0 +1,446 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#include "automation_runtime.h" + +#include +#include + +#include "edgetx.h" +#include "simulib.h" + +#if defined(LUA) +#include "lua/lua_api.h" +#endif + +namespace edgetx +{ +namespace automation +{ +namespace +{ + +static_assert(std::atomic::is_always_lock_free, + "simulator analog overrides require lock-free 32-bit atomics"); + +AutomationFirmwareMailbox firmwareMailbox; +AutomationAnalogOverrides analogOverrides; +std::atomic runtimeActive{false}; +std::atomic publishedLuaState{ +#if defined(LUA) + AutomationLuaState::NotObserved +#else + AutomationLuaState::Unavailable +#endif +}; + +// Accessed only by the firmware periodic context, or by the SDL owner after +// simuStop() has joined every firmware task. +bool deferredCompletionActive = false; +FirmwareCompletion deferredCompletion; +bool luaReloadActive = false; +FirmwareRequest luaReloadRequest; + +std::size_t nextIndex(std::size_t index) +{ + return (index + 1) % (FIRMWARE_MAILBOX_CAPACITY + 1); +} + +std::size_t queueDepth(std::size_t read, std::size_t write) +{ + return write >= read ? write - read + : FIRMWARE_MAILBOX_CAPACITY + 1 - read + write; +} + +AutomationLuaState observeLuaState() +{ +#if defined(LUA) + switch (luaState) { + case 0: + return AutomationLuaState::Idle; + case INTERPRETER_RELOAD_PERMANENT_SCRIPTS: + case INTERPRETER_LOADING: + case INTERPRETER_START_RUNNING: + return AutomationLuaState::Reloading; + case INTERPRETER_RUNNING: + return AutomationLuaState::Running; + case INTERPRETER_PANIC: + return AutomationLuaState::Panic; + default: + return AutomationLuaState::NotObserved; + } +#else + return AutomationLuaState::Unavailable; +#endif +} + +FirmwareCompletion processTelemetry(const FirmwareRequest& request) +{ + FirmwareCompletion completion; + completion.operation = request.operation; + completion.id = request.id; + completion.epoch = request.epoch; + completion.generation = request.generation; + + int existingIndex = -1; + for (int index = 0; index < MAX_TELEMETRY_SENSORS; ++index) { + const TelemetrySensor& sensor = g_model.telemetrySensors[index]; + if (sensor.type == TELEM_TYPE_CUSTOM && sensor.id == request.telemetryId && + sensor.subId == request.telemetrySubId && + sensor.instance == request.telemetryInstance) { + existingIndex = index; + break; + } + } + + // The interactive discovery toggle must not make an explicit automation + // command nondeterministic. Preserve the user's setting while allowing the + // established telemetry path to allocate a sensor when needed. + const bool discoverSensors = allowNewSensors; + const bool ignoreSensorIds = g_model.ignoreSensorIds; + allowNewSensors = true; + g_model.ignoreSensorIds = false; + int index = setTelemetryValue( + PROTOCOL_TELEMETRY_LUA, request.telemetryId, request.telemetrySubId, + request.telemetryInstance, request.telemetryValue, request.telemetryUnit, + request.telemetryPrecision); + g_model.ignoreSensorIds = ignoreSensorIds; + allowNewSensors = discoverSensors; + if (index < 0 && existingIndex >= 0) index = existingIndex; + + completion.telemetryIndex = index; + if (index < 0) { + completion.code = FirmwareCompletionCode::TelemetryUnavailable; + return completion; + } + + if (existingIndex < 0) { + TelemetrySensor& sensor = g_model.telemetrySensors[index]; + sensor.id = request.telemetryId; + sensor.subId = request.telemetrySubId; + sensor.instance = request.telemetryInstance; + sensor.init(request.telemetryName, request.telemetryUnit, + request.telemetryPrecision); + telemetryItems[index].setValue(sensor, request.telemetryValue, + request.telemetryUnit, + request.telemetryPrecision); + storageDirty(EE_MODEL); + } + return completion; +} + +void deferCompletion(const FirmwareCompletion& completion) +{ + if (deferredCompletionActive) return; + deferredCompletion = completion; + deferredCompletionActive = true; +} + +void processFirmwareRequest(const FirmwareRequest& request) +{ + if (request.operation == FirmwareOperation::Telemetry) { + deferCompletion(processTelemetry(request)); + return; + } + + if (request.operation == FirmwareOperation::ReloadLua) { + FirmwareCompletion completion; + completion.operation = request.operation; + completion.id = request.id; + completion.epoch = request.epoch; + completion.generation = request.generation; +#if defined(LUA) + if (luaReloadActive) { + completion.code = FirmwareCompletionCode::InternalError; + deferCompletion(completion); + return; + } + luaReloadRequest = request; + luaReloadActive = true; + publishedLuaState.store(AutomationLuaState::Reloading, + std::memory_order_release); + simuLuaReloadPermanentScripts(); +#else + completion.code = FirmwareCompletionCode::LuaUnavailable; + completion.luaState = AutomationLuaState::Unavailable; + deferCompletion(completion); +#endif + return; + } + + FirmwareCompletion completion; + completion.operation = request.operation; + completion.id = request.id; + completion.epoch = request.epoch; + completion.generation = request.generation; + completion.code = FirmwareCompletionCode::InternalError; + deferCompletion(completion); +} + +} // namespace + +AutomationFirmwareMailbox::AutomationFirmwareMailbox() { reset(); } + +bool AutomationFirmwareMailbox::enqueueRequest(const FirmwareRequest& request) +{ + const std::size_t write = requestWrite.load(std::memory_order_relaxed); + const std::size_t next = nextIndex(write); + if (next == requestRead.load(std::memory_order_acquire)) return false; + requests[write] = request; + requestWrite.store(next, std::memory_order_release); + return true; +} + +bool AutomationFirmwareMailbox::dequeueRequest(FirmwareRequest* request) +{ + if (request == nullptr) return false; + const std::size_t read = requestRead.load(std::memory_order_relaxed); + if (read == requestWrite.load(std::memory_order_acquire)) return false; + *request = requests[read]; + requestRead.store(nextIndex(read), std::memory_order_release); + return true; +} + +bool AutomationFirmwareMailbox::enqueueCompletion( + const FirmwareCompletion& completion) +{ + const std::size_t write = completionWrite.load(std::memory_order_relaxed); + const std::size_t next = nextIndex(write); + if (next == completionRead.load(std::memory_order_acquire)) return false; + completions[write] = completion; + completionWrite.store(next, std::memory_order_release); + return true; +} + +bool AutomationFirmwareMailbox::dequeueCompletion( + FirmwareCompletion* completion) +{ + if (completion == nullptr) return false; + const std::size_t read = completionRead.load(std::memory_order_relaxed); + if (read == completionWrite.load(std::memory_order_acquire)) return false; + *completion = completions[read]; + completionRead.store(nextIndex(read), std::memory_order_release); + return true; +} + +std::size_t AutomationFirmwareMailbox::requestDepth() const +{ + return queueDepth(requestRead.load(std::memory_order_acquire), + requestWrite.load(std::memory_order_acquire)); +} + +std::size_t AutomationFirmwareMailbox::completionDepth() const +{ + return queueDepth(completionRead.load(std::memory_order_acquire), + completionWrite.load(std::memory_order_acquire)); +} + +bool AutomationFirmwareMailbox::idle() const +{ + return requestDepth() == 0 && completionDepth() == 0; +} + +void AutomationFirmwareMailbox::reset() +{ + requestRead.store(0, std::memory_order_relaxed); + requestWrite.store(0, std::memory_order_relaxed); + completionRead.store(0, std::memory_order_relaxed); + completionWrite.store(0, std::memory_order_relaxed); +} + +AutomationAnalogOverrides::AutomationAnalogOverrides() { clearAll(); } + +bool AutomationAnalogOverrides::set(std::size_t index, std::uint16_t value) +{ + if (index >= values.size() || value > 4096) return false; + values[index].store(ENABLED | value, std::memory_order_release); + return true; +} + +bool AutomationAnalogOverrides::get(std::size_t index, + std::uint16_t* value) const +{ + if (index >= values.size() || value == nullptr) return false; + const std::uint32_t packed = values[index].load(std::memory_order_acquire); + if ((packed & ENABLED) == 0) return false; + *value = static_cast(packed & VALUE_MASK); + return true; +} + +bool AutomationAnalogOverrides::clear(std::size_t index) +{ + if (index >= values.size()) return false; + values[index].store(0, std::memory_order_release); + return true; +} + +void AutomationAnalogOverrides::clearAll() +{ + for (auto& value : values) value.store(0, std::memory_order_release); +} + +std::size_t AutomationAnalogOverrides::count() const +{ + return static_cast(std::count_if( + values.begin(), values.end(), + [](const std::atomic& value) { + return (value.load(std::memory_order_acquire) & ENABLED) != 0; + })); +} + +void simuAutomationRuntimeStart() +{ + firmwareMailbox.reset(); + analogOverrides.clearAll(); + deferredCompletionActive = false; + luaReloadActive = false; + publishedLuaState.store( +#if defined(LUA) + AutomationLuaState::NotObserved, +#else + AutomationLuaState::Unavailable, +#endif + std::memory_order_release); + runtimeActive.store(true, std::memory_order_release); +} + +void simuAutomationRuntimeStop() +{ + runtimeActive.store(false, std::memory_order_release); + analogOverrides.clearAll(); +} + +void simuAutomationRuntimeResetAfterTaskJoin() +{ + firmwareMailbox.reset(); + deferredCompletionActive = false; + luaReloadActive = false; + publishedLuaState.store( +#if defined(LUA) + AutomationLuaState::NotObserved, +#else + AutomationLuaState::Unavailable, +#endif + std::memory_order_release); +} + +bool simuAutomationRuntimeActive() +{ + return runtimeActive.load(std::memory_order_acquire); +} + +bool simuAutomationPostFirmwareRequest(const FirmwareRequest& request) +{ + return simuAutomationRuntimeActive() && + firmwareMailbox.enqueueRequest(request); +} + +bool simuAutomationTakeFirmwareCompletion(FirmwareCompletion* completion) +{ + return firmwareMailbox.dequeueCompletion(completion); +} + +std::size_t simuAutomationFirmwareRequestDepth() +{ + return firmwareMailbox.requestDepth(); +} + +std::size_t simuAutomationFirmwareCompletionDepth() +{ + return firmwareMailbox.completionDepth(); +} + +bool simuAutomationFirmwareIdle() { return firmwareMailbox.idle(); } + +bool simuAutomationSetAnalogOverride(std::size_t index, std::uint16_t value) +{ + return simuAutomationRuntimeActive() && analogOverrides.set(index, value); +} + +bool simuAutomationGetAnalogOverride(std::size_t index, std::uint16_t* value) +{ + return simuAutomationRuntimeActive() && analogOverrides.get(index, value); +} + +bool simuAutomationClearAnalogOverride(std::size_t index) +{ + return analogOverrides.clear(index); +} + +void simuAutomationClearAnalogOverrides() { analogOverrides.clearAll(); } + +std::size_t simuAutomationAnalogOverrideCount() +{ + return analogOverrides.count(); +} + +AutomationLuaState simuAutomationLuaState() +{ + return publishedLuaState.load(std::memory_order_acquire); +} + +const char* automationLuaStateName(AutomationLuaState state) +{ + switch (state) { + case AutomationLuaState::Unavailable: + return "unavailable"; + case AutomationLuaState::NotObserved: + return "not_observed"; + case AutomationLuaState::Idle: + return "idle"; + case AutomationLuaState::Reloading: + return "reloading"; + case AutomationLuaState::Running: + return "running"; + case AutomationLuaState::Panic: + return "panic"; + } + return "not_observed"; +} + +void simuAutomationBeforeUi() +{ + if (!simuAutomationRuntimeActive()) return; + publishedLuaState.store(observeLuaState(), std::memory_order_release); + + for (std::size_t count = 0; count < 2 && !deferredCompletionActive; ++count) { + FirmwareRequest request; + if (!firmwareMailbox.dequeueRequest(&request)) break; + processFirmwareRequest(request); + } +} + +void simuAutomationAfterUi() +{ + if (!simuAutomationRuntimeActive()) return; + + const AutomationLuaState observed = observeLuaState(); + publishedLuaState.store(observed, std::memory_order_release); + + if (deferredCompletionActive && + firmwareMailbox.enqueueCompletion(deferredCompletion)) { + deferredCompletionActive = false; + } + +#if defined(LUA) + if (!deferredCompletionActive && luaReloadActive && + (observed == AutomationLuaState::Running || + observed == AutomationLuaState::Panic)) { + FirmwareCompletion completion; + completion.operation = FirmwareOperation::ReloadLua; + completion.id = luaReloadRequest.id; + completion.epoch = luaReloadRequest.epoch; + completion.generation = luaReloadRequest.generation; + completion.luaState = observed; + if (observed == AutomationLuaState::Panic) + completion.code = FirmwareCompletionCode::LuaPanic; + if (firmwareMailbox.enqueueCompletion(completion)) luaReloadActive = false; + } +#endif +} + +} // namespace automation +} // namespace edgetx diff --git a/radio/src/targets/simu/automation_runtime.h b/radio/src/targets/simu/automation_runtime.h new file mode 100644 index 00000000000..5d76cfb8e17 --- /dev/null +++ b/radio/src/targets/simu/automation_runtime.h @@ -0,0 +1,140 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#pragma once + +#include +#include +#include +#include + +#include "dataconstants.h" + +namespace edgetx +{ +namespace automation +{ + +constexpr std::size_t FIRMWARE_MAILBOX_CAPACITY = 16; + +enum class FirmwareOperation : std::uint8_t { + None, + Telemetry, + ReloadLua, +}; + +enum class FirmwareCompletionCode : std::uint8_t { + None, + TelemetryUnavailable, + LuaUnavailable, + LuaPanic, + InternalError, +}; + +enum class AutomationLuaState : std::uint8_t { + Unavailable, + NotObserved, + Idle, + Reloading, + Running, + Panic, +}; + +struct FirmwareRequest { + FirmwareOperation operation = FirmwareOperation::None; + std::uint64_t id = 0; + std::uint64_t epoch = 0; + std::uint64_t generation = 0; + std::uint16_t telemetryId = 0; + std::uint8_t telemetrySubId = 0; + std::uint8_t telemetryInstance = 0; + std::int32_t telemetryValue = 0; + std::uint8_t telemetryUnit = 0; + std::uint8_t telemetryPrecision = 0; + char telemetryName[TELEM_LABEL_LEN + 1] = {}; +}; + +struct FirmwareCompletion { + FirmwareOperation operation = FirmwareOperation::None; + FirmwareCompletionCode code = FirmwareCompletionCode::None; + std::uint64_t id = 0; + std::uint64_t epoch = 0; + std::uint64_t generation = 0; + std::int32_t telemetryIndex = -1; + AutomationLuaState luaState = AutomationLuaState::NotObserved; + + bool ok() const { return code == FirmwareCompletionCode::None; } +}; + +class AutomationFirmwareMailbox +{ + public: + AutomationFirmwareMailbox(); + + bool enqueueRequest(const FirmwareRequest& request); + bool dequeueRequest(FirmwareRequest* request); + bool enqueueCompletion(const FirmwareCompletion& completion); + bool dequeueCompletion(FirmwareCompletion* completion); + std::size_t requestDepth() const; + std::size_t completionDepth() const; + bool idle() const; + void reset(); + + private: + static constexpr std::size_t STORAGE_SIZE = FIRMWARE_MAILBOX_CAPACITY + 1; + + std::array requests{}; + std::array completions{}; + std::atomic requestRead{0}; + std::atomic requestWrite{0}; + std::atomic completionRead{0}; + std::atomic completionWrite{0}; +}; + +class AutomationAnalogOverrides +{ + public: + AutomationAnalogOverrides(); + + bool set(std::size_t index, std::uint16_t value); + bool get(std::size_t index, std::uint16_t* value) const; + bool clear(std::size_t index); + void clearAll(); + std::size_t count() const; + + private: + static constexpr std::uint32_t ENABLED = UINT32_C(0x80000000); + static constexpr std::uint32_t VALUE_MASK = UINT32_C(0x00001fff); + + std::array, MAX_ANALOG_INPUTS> values{}; +}; + +void simuAutomationRuntimeStart(); +void simuAutomationRuntimeStop(); +void simuAutomationRuntimeResetAfterTaskJoin(); +bool simuAutomationRuntimeActive(); + +bool simuAutomationPostFirmwareRequest(const FirmwareRequest& request); +bool simuAutomationTakeFirmwareCompletion(FirmwareCompletion* completion); +std::size_t simuAutomationFirmwareRequestDepth(); +std::size_t simuAutomationFirmwareCompletionDepth(); +bool simuAutomationFirmwareIdle(); + +bool simuAutomationSetAnalogOverride(std::size_t index, std::uint16_t value); +bool simuAutomationGetAnalogOverride(std::size_t index, std::uint16_t* value); +bool simuAutomationClearAnalogOverride(std::size_t index); +void simuAutomationClearAnalogOverrides(); +std::size_t simuAutomationAnalogOverrideCount(); + +AutomationLuaState simuAutomationLuaState(); +const char* automationLuaStateName(AutomationLuaState state); + +// These hooks run only in the firmware periodic context. +void simuAutomationBeforeUi(); +void simuAutomationAfterUi(); + +} // namespace automation +} // namespace edgetx diff --git a/radio/src/targets/simu/automation_stdio.cpp b/radio/src/targets/simu/automation_stdio.cpp new file mode 100644 index 00000000000..2d2067dac8c --- /dev/null +++ b/radio/src/targets/simu/automation_stdio.cpp @@ -0,0 +1,1693 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#include "automation_stdio.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "automation_runtime.h" + +#if defined(_WIN32) +#include +#include +#else +#include +#include + +#include +#endif + +namespace edgetx +{ +namespace automation +{ +namespace +{ + +void setError(std::string* error, const std::string& message) +{ + if (error != nullptr) *error = message; +} + +void incrementSaturating(std::uint64_t* counter) +{ + if (*counter != (std::numeric_limits::max)()) ++*counter; +} + +std::uint64_t parseValidatedUnsigned(const std::string& value) +{ + std::uint64_t parsed = 0; + for (const char byte : value) + parsed = parsed * 10 + static_cast(byte - '0'); + return parsed; +} + +std::int32_t parseValidatedSigned(const std::string& value) +{ + const bool negative = value[0] == '-'; + const std::size_t offset = negative ? 1 : 0; + const std::uint64_t magnitude = parseValidatedUnsigned(value.substr(offset)); + const std::int64_t parsed = + negative ? -static_cast(magnitude) + : static_cast(magnitude); + return static_cast(parsed); +} + +Response captureResponse(const CaptureCompletion& completion) +{ + if (completion.ok) { + return Response::successWithCapture(completion.id, completion.epoch, + completion.artifact); + } + return Response::failure(completion.id, completion.epoch, + completion.errorCode, completion.message); +} + +std::string defaultTelemetryName(std::uint16_t id) +{ + static constexpr char HEX[] = "0123456789ABCDEF"; + std::string name(MAX_TELEMETRY_LABEL_BYTES, '0'); + for (std::size_t index = 0; index < name.size(); ++index) { + const std::size_t shift = (name.size() - index - 1) * 4; + name[index] = HEX[(id >> shift) & 0x0f]; + } + return name; +} + +#if defined(_WIN32) + +bool isClosedPipeError(DWORD error) +{ + return error == ERROR_BROKEN_PIPE || error == ERROR_PIPE_NOT_CONNECTED || + error == ERROR_NO_DATA; +} + +bool getHandleType(HANDLE handle, DWORD* type, std::string* error, + const char* streamName) +{ + if (handle == nullptr || handle == INVALID_HANDLE_VALUE) { + setError(error, + std::string("automation ") + streamName + " is not available"); + return false; + } + + SetLastError(ERROR_SUCCESS); + *type = GetFileType(handle); + const DWORD typeError = GetLastError(); + if (*type == FILE_TYPE_UNKNOWN && typeError != ERROR_SUCCESS) { + setError(error, std::string("cannot inspect automation ") + streamName + + " (Win32 error " + std::to_string(typeError) + ")"); + return false; + } + + if (*type != FILE_TYPE_PIPE && *type != FILE_TYPE_DISK) { + setError(error, std::string("automation ") + streamName + + " must be redirected to a pipe or file"); + return false; + } + return true; +} + +#endif + +} // namespace + +AutomationStdio::AutomationStdio(const TargetDescription& target) : + targetDescription(target) +{ +} + +AutomationStdio::~AutomationStdio() +{ + capture.shutdown(); + stopOutputWriter(); +#if defined(_WIN32) + if (outputHandle != 0) { + (void)CloseHandle(reinterpret_cast(outputHandle)); + } +#else + if (outputFd != -1) (void)close(outputFd); + if (restoreInputFlags) { + (void)fcntl(STDIN_FILENO, F_SETFL, originalInputFlags); + } +#endif +} + +void AutomationStdio::setTargetDescription(const TargetDescription& target) +{ + std::lock_guard lock(stateMutex); + targetDescription = target; +} + +void AutomationStdio::setInputHandlers(const AutomationInputHandlers& handlers) +{ + std::lock_guard lock(stateMutex); + inputHandlers = handlers; +} + +bool AutomationStdio::configureCapture(const std::string& outputRoot, + std::uint16_t width, + std::uint16_t height, std::uint8_t depth, + std::string* error) +{ + return capture.configure(outputRoot, width, height, depth, error); +} + +bool AutomationStdio::captureConfigured() const { return capture.configured(); } + +void AutomationStdio::markRuntimeStarted() +{ + simuAutomationRuntimeStart(); + std::lock_guard lock(stateMutex); + runtimeRunning = true; +} + +bool AutomationStdio::prepareRuntimeRestart(std::string* error) +{ + simuAutomationRuntimeResetAfterTaskJoin(); + AutomationInputHandlers handlers; + { + std::lock_guard lock(stateMutex); + runtimeRunning = false; + if (!pendingRestart.active()) { + setError(error, "no warm restart is pending"); + return false; + } + const TransitionResult transition = sessionState.restartTasksStarted( + pendingRestart.id, pendingRestart.epoch); + if (transition != TransitionResult::Applied) { + setError(error, "cannot arm the restarted runtime epoch"); + return false; + } + handlers = inputHandlers; + } + if (handlers.clearAllAnalogs != nullptr) handlers.clearAllAnalogs(); + if (handlers.resetSwitches != nullptr) handlers.resetSwitches(); + return true; +} + +void AutomationStdio::markRuntimeRestarted() +{ + std::lock_guard lock(stateMutex); + runtimeRunning = true; +} + +void AutomationStdio::markRuntimeStopped() +{ + capture.shutdown(); + simuAutomationRuntimeStop(); + simuAutomationRuntimeResetAfterTaskJoin(); + std::lock_guard lock(stateMutex); + runtimeRunning = false; + sessionState.stop(); +} + +void AutomationStdio::onDisplayFrame(const std::uint16_t* pixels, + std::size_t pixelCount) +{ + DisplaySequence sequence = 0; + SessionEpoch epoch = 0; + { + std::lock_guard lock(stateMutex); + const RequestId restarted = sessionState.onDisplayFrame(); + sequence = sessionState.displaySequence(); + epoch = sessionState.epoch(); + if (restarted != 0 && pendingRestart.active() && + restarted == pendingRestart.id) { + completedRestart.id = restarted; + completedRestart.epoch = epoch; + completedRestart.sequence = sequence; + pendingRestart.clear(); + } + if (pendingFrameWait.active() && sequence >= pendingFrameWait.minimum) { + const TransitionResult completion = sessionState.completeAsync( + pendingFrameWait.id, pendingFrameWait.epoch); + if (completion == TransitionResult::Applied) { + completedFrameWait.id = pendingFrameWait.id; + completedFrameWait.epoch = pendingFrameWait.epoch; + completedFrameWait.sequence = sequence; + } + pendingFrameWait.clear(); + } + } + capture.onDisplayFrame(sequence, epoch, pixels, pixelCount); +} + +bool AutomationStdio::start(std::string* error) +{ + if (error != nullptr) error->clear(); + if (started) { + setError(error, "automation stdio is already active"); + return false; + } + +#if defined(_WIN32) + HANDLE input = GetStdHandle(STD_INPUT_HANDLE); + HANDLE output = GetStdHandle(STD_OUTPUT_HANDLE); + HANDLE diagnostics = GetStdHandle(STD_ERROR_HANDLE); + DWORD inputType = FILE_TYPE_UNKNOWN; + DWORD outputType = FILE_TYPE_UNKNOWN; + if (!getHandleType(input, &inputType, error, "stdin") || + !getHandleType(output, &outputType, error, "stdout")) { + return false; + } + if (diagnostics == nullptr || diagnostics == INVALID_HANDLE_VALUE) { + setError(error, "automation stderr is not available"); + return false; + } + + HANDLE protocolOutput = nullptr; + // Keep a private binary handle for protocol records, then send every other + // process-wide stdout writer (including firmware TRACE) to diagnostics. + if (!DuplicateHandle(GetCurrentProcess(), output, GetCurrentProcess(), + &protocolOutput, 0, FALSE, DUPLICATE_SAME_ACCESS)) { + setError(error, "cannot preserve automation stdout (Win32 error " + + std::to_string(GetLastError()) + ")"); + return false; + } + + const int stdoutFd = _fileno(stdout); + const int stderrFd = _fileno(stderr); + const int savedStdoutFd = stdoutFd == -1 ? -1 : _dup(stdoutFd); + if (savedStdoutFd == -1 || stderrFd == -1) { + if (savedStdoutFd != -1) (void)_close(savedStdoutFd); + (void)CloseHandle(protocolOutput); + setError(error, "cannot preserve the C stdout stream"); + return false; + } + + (void)std::fflush(stdout); + if (!SetStdHandle(STD_OUTPUT_HANDLE, diagnostics)) { + const DWORD redirectError = GetLastError(); + (void)_close(savedStdoutFd); + (void)CloseHandle(protocolOutput); + setError(error, "cannot redirect automation diagnostics (Win32 error " + + std::to_string(redirectError) + ")"); + return false; + } + if (_dup2(stderrFd, stdoutFd) != 0) { + (void)SetStdHandle(STD_OUTPUT_HANDLE, output); + (void)_close(savedStdoutFd); + (void)CloseHandle(protocolOutput); + setError(error, "cannot redirect the C stdout stream"); + return false; + } + + inputHandle = reinterpret_cast(input); + outputHandle = reinterpret_cast(protocolOutput); + inputIsPipe = inputType == FILE_TYPE_PIPE; + (void)_close(savedStdoutFd); +#else + if (isatty(STDIN_FILENO) != 0 || isatty(STDOUT_FILENO) != 0) { + setError(error, "automation stdin and stdout must be redirected"); + return false; + } + + originalInputFlags = fcntl(STDIN_FILENO, F_GETFL); + if (originalInputFlags == -1 || fcntl(STDOUT_FILENO, F_GETFL) == -1 || + fcntl(STDERR_FILENO, F_GETFL) == -1) { + setError(error, std::string("cannot inspect automation stdio: ") + + std::strerror(errno)); + return false; + } + + if ((originalInputFlags & O_NONBLOCK) == 0) { + if (fcntl(STDIN_FILENO, F_SETFL, originalInputFlags | O_NONBLOCK) == -1) { + setError(error, + std::string("cannot make automation stdin non-blocking: ") + + std::strerror(errno)); + return false; + } + restoreInputFlags = true; + } + + if (std::signal(SIGPIPE, SIG_IGN) == SIG_ERR) { + if (restoreInputFlags) { + (void)fcntl(STDIN_FILENO, F_SETFL, originalInputFlags); + restoreInputFlags = false; + } + setError(error, "cannot ignore SIGPIPE for automation stdout"); + return false; + } + + (void)std::fflush(stdout); + // The duplicate remains the protocol channel after fd 1 becomes stderr. + outputFd = dup(STDOUT_FILENO); + if (outputFd == -1) { + if (restoreInputFlags) { + (void)fcntl(STDIN_FILENO, F_SETFL, originalInputFlags); + restoreInputFlags = false; + } + setError(error, std::string("cannot preserve automation stdout: ") + + std::strerror(errno)); + return false; + } + if (dup2(STDERR_FILENO, STDOUT_FILENO) == -1) { + const int redirectError = errno; + (void)close(outputFd); + outputFd = -1; + if (restoreInputFlags) { + (void)fcntl(STDIN_FILENO, F_SETFL, originalInputFlags); + restoreInputFlags = false; + } + setError(error, std::string("cannot redirect automation diagnostics: ") + + std::strerror(redirectError)); + return false; + } + + const int outputFlags = fcntl(outputFd, F_GETFL); + if (outputFlags == -1 || + fcntl(outputFd, F_SETFL, outputFlags | O_NONBLOCK) == -1) { + const int flagError = errno; + (void)close(outputFd); + outputFd = -1; + if (restoreInputFlags) { + (void)fcntl(STDIN_FILENO, F_SETFL, originalInputFlags); + restoreInputFlags = false; + } + setError(error, + std::string("cannot make automation stdout non-blocking: ") + + std::strerror(flagError)); + return false; + } +#endif + + if (!startOutputWriter(error)) return false; + started = true; + return true; +} + +StdioPumpResult AutomationStdio::pump(std::string* error) +{ + if (error != nullptr) error->clear(); + if (!started) { + setError(error, "automation stdio is not active"); + return StdioPumpResult::Error; + } + + const StdioPumpResult writerResult = checkOutputWriter(error); + if (writerResult != StdioPumpResult::Continue) return writerResult; + if (stopAfterFlush && pendingEvents.empty()) + return outputFlushed() ? StdioPumpResult::StopRequested + : StdioPumpResult::Continue; + if (outputBackpressured()) return StdioPumpResult::Continue; + + if (!stopAfterFlush) { + const StdioPumpResult completedResult = drainCompletedResponses(error); + if (completedResult != StdioPumpResult::Continue) return completedResult; + } + + if (outputBackpressured()) return StdioPumpResult::Continue; + + if (!stopAfterFlush && pendingEvents.empty() && !inputClosed) { + char input[STDIO_READ_BUDGET]; + std::size_t bytesRead = 0; + const ReadResult readResult = + readInput(input, sizeof(input), &bytesRead, error); + if (readResult == ReadResult::Data) { + queueEvents(lineBuffer.feed(input, bytesRead)); + } else if (readResult == ReadResult::Closed) { + inputClosed = true; + queueEvents(lineBuffer.finish()); + } else if (readResult == ReadResult::Error) { + return StdioPumpResult::Error; + } + } + + std::size_t processed = 0; + while (!pendingEvents.empty() && processed < STDIO_RECORD_BUDGET && + !outputBackpressured()) { + LineEvent event = std::move(pendingEvents.front()); + pendingEvents.pop_front(); + ++processed; + + const StdioPumpResult result = stopAfterFlush + ? rejectStoppingEvent(event, error) + : processEvent(event, error); + if (result != StdioPumpResult::Continue) return result; + } + + if (queueOverflowed) { + queueOverflowed = false; + const StdioPumpResult result = + emitEvent(ErrorCode::QueueFull, "input queue capacity exceeded", error); + if (result != StdioPumpResult::Continue) return result; + } + + if (stopAfterFlush) { + return pendingEvents.empty() && outputFlushed() + ? StdioPumpResult::StopRequested + : StdioPumpResult::Continue; + } + if (inputClosed && pendingEvents.empty()) { + return outputFlushed() ? StdioPumpResult::PeerClosed + : StdioPumpResult::Continue; + } + return StdioPumpResult::Continue; +} + +AutomationStdio::ReadResult AutomationStdio::readInput(char* bytes, + std::size_t capacity, + std::size_t* bytesRead, + std::string* error) +{ + *bytesRead = 0; +#if defined(_WIN32) + HANDLE input = reinterpret_cast(inputHandle); + DWORD requested = static_cast(capacity); + if (inputIsPipe) { + DWORD available = 0; + if (!PeekNamedPipe(input, nullptr, 0, nullptr, &available, nullptr)) { + const DWORD pipeError = GetLastError(); + if (isClosedPipeError(pipeError)) return ReadResult::Closed; + setError(error, "cannot inspect automation stdin (Win32 error " + + std::to_string(pipeError) + ")"); + return ReadResult::Error; + } + if (available == 0) return ReadResult::WouldBlock; + requested = requested < available ? requested : available; + } + + DWORD received = 0; + if (!ReadFile(input, bytes, requested, &received, nullptr)) { + const DWORD readError = GetLastError(); + if (isClosedPipeError(readError)) return ReadResult::Closed; + setError(error, "cannot read automation stdin (Win32 error " + + std::to_string(readError) + ")"); + return ReadResult::Error; + } + if (received == 0) return ReadResult::Closed; + *bytesRead = received; + return ReadResult::Data; +#else + const ssize_t received = read(STDIN_FILENO, bytes, capacity); + if (received > 0) { + *bytesRead = static_cast(received); + return ReadResult::Data; + } + if (received == 0) return ReadResult::Closed; + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) + return ReadResult::WouldBlock; + + setError(error, std::string("cannot read automation stdin: ") + + std::strerror(errno)); + return ReadResult::Error; +#endif +} + +AutomationStdio::WriteResult AutomationStdio::writeOutput( + const std::string& record, std::string* error) +{ + std::size_t offset = 0; + while (offset < record.size()) { + { + std::lock_guard lock(outputMutex); + if (outputStop) return WriteResult::Closed; + } +#if defined(_WIN32) + HANDLE output = reinterpret_cast(outputHandle); + const DWORD remaining = static_cast(record.size() - offset); + DWORD written = 0; + if (!WriteFile(output, record.data() + offset, remaining, &written, + nullptr)) { + const DWORD writeError = GetLastError(); + if (isClosedPipeError(writeError)) return WriteResult::Closed; + setError(error, "cannot write automation stdout (Win32 error " + + std::to_string(writeError) + ")"); + return WriteResult::Error; + } + if (written == 0) return WriteResult::Closed; + offset += written; +#else + const ssize_t written = + write(outputFd, record.data() + offset, record.size() - offset); + if (written > 0) { + offset += static_cast(written); + continue; + } + if (written < 0 && errno == EINTR) continue; + if (written < 0 && errno == EPIPE) return WriteResult::Closed; + if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + std::unique_lock lock(outputMutex); + if (outputStop) return WriteResult::Closed; + outputReady.wait_for(lock, std::chrono::milliseconds(1), + [this]() { return outputStop; }); + if (outputStop) return WriteResult::Closed; + continue; + } + + setError(error, std::string("cannot write automation stdout: ") + + std::strerror(errno)); + return WriteResult::Error; +#endif + } + return WriteResult::Complete; +} + +bool AutomationStdio::startOutputWriter(std::string* error) +{ + try { + outputThread = std::thread(&AutomationStdio::outputWriterLoop, this); + } catch (const std::exception& exception) { + setError(error, std::string("cannot start automation stdout writer: ") + + exception.what()); + return false; + } + return true; +} + +void AutomationStdio::stopOutputWriter() +{ + { + std::lock_guard lock(outputMutex); + outputStop = true; + outputQueue.clear(); + } + outputReady.notify_all(); +#if defined(_WIN32) + if (outputThread.joinable()) { + (void)CancelSynchronousIo( + reinterpret_cast(outputThread.native_handle())); + } +#endif + if (outputThread.joinable()) outputThread.join(); +} + +void AutomationStdio::outputWriterLoop() +{ + while (true) { + std::string record; + { + std::unique_lock lock(outputMutex); + outputReady.wait(lock, + [this]() { return outputStop || !outputQueue.empty(); }); + if (outputStop) return; + record = outputQueue.front(); + } + + std::string error; + const WriteResult result = writeOutput(record, &error); + { + std::lock_guard lock(outputMutex); + if (outputStop) return; + if (result != WriteResult::Complete) { + outputResult = result; + outputError = std::move(error); + outputQueue.clear(); + return; + } + outputQueue.pop_front(); + } + } +} + +StdioPumpResult AutomationStdio::checkOutputWriter(std::string* error) const +{ + std::lock_guard lock(outputMutex); + if (outputResult == WriteResult::Closed) return StdioPumpResult::PeerClosed; + if (outputResult == WriteResult::Error) { + setError(error, outputError.empty() ? "automation stdout writer failed" + : outputError); + return StdioPumpResult::Error; + } + return StdioPumpResult::Continue; +} + +bool AutomationStdio::outputBackpressured() const +{ + std::lock_guard lock(outputMutex); + return outputQueue.size() >= STDIO_OUTPUT_HIGH_WATERMARK; +} + +bool AutomationStdio::outputFlushed() const +{ + std::lock_guard lock(outputMutex); + return outputQueue.empty(); +} + +void AutomationStdio::queueEvents(std::vector&& events) +{ + for (LineEvent& event : events) { + if (event.type == LineEventType::Record && event.record.empty()) continue; + if (pendingEvents.size() == MAX_PENDING_REQUESTS) { + queueOverflowed = true; + std::lock_guard lock(stateMutex); + incrementSaturating(&queueOverflowCount); + continue; + } + pendingEvents.push_back(std::move(event)); + } +} + +StdioPumpResult AutomationStdio::processEvent(const LineEvent& event, + std::string* error) +{ + if (event.type == LineEventType::LineTooLong) { + { + std::lock_guard lock(stateMutex); + incrementSaturating(&lineOverflowCount); + } + return emitEvent(ErrorCode::LineTooLong, "record exceeds 16 KiB", error); + } + if (event.type == LineEventType::PartialRecordAtEof) + return StdioPumpResult::Continue; + + const ParseResult parsed = parser.parse(event.record); + if (parsed.status == ParseStatus::Ignored) return StdioPumpResult::Continue; + if (parsed.status == ParseStatus::Error) { + if (parsed.error.hasRequestId) { + return emitResponse( + Response::failure(parsed.error.requestId, currentEpoch(), + parsed.error.code, parsed.error.message), + error); + } + return emitEvent(parsed.error.code, parsed.error.message, error); + } + + if (!supportsCommand(parsed.request.command)) { + return emitResponse( + Response::failure( + parsed.request.id, currentEpoch(), ErrorCode::UnsupportedCommand, + std::string("command is not supported by this target: ") + + commandName(parsed.request.command)), + error); + } + + if (parsed.request.command == Command::Ping) { + return emitResponse(Response::success(parsed.request.id, currentEpoch()), + error); + } + if (parsed.request.command == Command::Status) { + return emitResponse(makeStatusResponse(parsed.request.id), error); + } + if (parsed.request.command == Command::Describe) { + return emitResponse(makeDescriptionResponse(parsed.request.id), error); + } + if (parsed.request.command == Command::KeyDown) { + return processKey(parsed.request, true, error); + } + if (parsed.request.command == Command::KeyUp) { + return processKey(parsed.request, false, error); + } + if (parsed.request.command == Command::Rotate) { + return processRotate(parsed.request, error); + } + if (parsed.request.command == Command::TouchDown || + parsed.request.command == Command::TouchMove || + parsed.request.command == Command::TouchUp) { + return processTouch(parsed.request, error); + } + if (parsed.request.command == Command::SetSwitch) { + return processSetSwitch(parsed.request, error); + } + if (parsed.request.command == Command::SetAnalog) { + return processSetAnalog(parsed.request, error); + } + if (parsed.request.command == Command::ClearAnalog) { + return processClearAnalog(parsed.request, error); + } + if (parsed.request.command == Command::SetTelemetry) { + return processSetTelemetry(parsed.request, error); + } + if (parsed.request.command == Command::ReloadLua) { + return processReloadLua(parsed.request, error); + } + if (parsed.request.command == Command::WaitFrame) { + return processWaitFrame(parsed.request, error); + } + if (parsed.request.command == Command::Capture) { + return processCapture(parsed.request, error); + } + if (parsed.request.command == Command::Restart) { + return processRestart(parsed.request, error); + } + if (parsed.request.command == Command::ReleaseAll) { + return processReleaseAll(parsed.request, error); + } + if (parsed.request.command == Command::Stop) { + return processStop(parsed.request, error); + } + + return emitResponse( + Response::failure(parsed.request.id, currentEpoch(), + ErrorCode::UnsupportedCommand, + std::string("command is not implemented yet: ") + + commandName(parsed.request.command)), + error); +} + +StdioPumpResult AutomationStdio::rejectStoppingEvent(const LineEvent& event, + std::string* error) +{ + if (event.type == LineEventType::LineTooLong) { + { + std::lock_guard lock(stateMutex); + incrementSaturating(&lineOverflowCount); + } + return emitEvent(ErrorCode::LineTooLong, "record exceeds 16 KiB", error); + } + if (event.type == LineEventType::PartialRecordAtEof) + return StdioPumpResult::Continue; + + const ParseResult parsed = parser.parse(event.record); + if (parsed.status == ParseStatus::Ignored) return StdioPumpResult::Continue; + if (parsed.status == ParseStatus::Error) { + if (parsed.error.hasRequestId) { + return emitResponse( + Response::failure(parsed.error.requestId, currentEpoch(), + parsed.error.code, parsed.error.message), + error); + } + return emitEvent(parsed.error.code, parsed.error.message, error); + } + + return emitResponse( + Response::failure(parsed.request.id, currentEpoch(), + ErrorCode::SessionStopping, + "request rejected because the session is stopping"), + error); +} + +StdioPumpResult AutomationStdio::processKey(const Request& request, + bool pressed, std::string* error) +{ + Response response; + void (*handler)(const std::string&, bool) = nullptr; + { + std::lock_guard lock(stateMutex); + const SessionEpoch epoch = sessionState.epoch(); + const std::string& key = request.arguments[0]; + if (std::find(targetDescription.keys.begin(), targetDescription.keys.end(), + key) == targetDescription.keys.end()) { + response = + Response::failure(request.id, epoch, ErrorCode::UnsupportedTarget, + "key is not supported by this target: " + key); + } else if (inputHandlers.setKey == nullptr) { + response = + Response::failure(request.id, epoch, ErrorCode::UnsupportedCommand, + "key input is not available"); + } else { + const TransitionResult transition = + pressed ? sessionState.keyDown(key) : sessionState.keyUp(key); + if (transition == TransitionResult::Applied) { + response = Response::success(request.id, epoch); + handler = inputHandlers.setKey; + } else if (transition == TransitionResult::Busy) { + response = + Response::failure(request.id, epoch, ErrorCode::OperationBusy, + "an asynchronous operation is active"); + } else if (transition == TransitionResult::Duplicate) { + response = + Response::failure(request.id, epoch, ErrorCode::KeyAlreadyDown, + "key is already down: " + key); + } else if (sessionState.phase() != SessionPhase::Ready) { + response = + Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for input"); + } else { + response = Response::failure(request.id, epoch, ErrorCode::KeyNotDown, + "key is not down: " + key); + } + } + } + + if (handler != nullptr) handler(request.arguments[0], pressed); + return emitResponse(response, error); +} + +StdioPumpResult AutomationStdio::processRotate(const Request& request, + std::string* error) +{ + Response response; + void (*handler)(std::int32_t) = nullptr; + const std::int32_t steps = parseValidatedSigned(request.arguments[0]); + { + std::lock_guard lock(stateMutex); + const SessionEpoch epoch = sessionState.epoch(); + if (inputHandlers.rotate == nullptr || + !targetDescription.capabilities.rotary) { + response = + Response::failure(request.id, epoch, ErrorCode::UnsupportedCommand, + "rotary input is not available"); + } else if (sessionState.phase() != SessionPhase::Ready) { + response = Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for input"); + } else if (sessionState.asyncOperation() != AsyncOperation::None) { + response = Response::failure(request.id, epoch, ErrorCode::OperationBusy, + "an asynchronous operation is active"); + } else { + response = Response::success(request.id, epoch); + handler = inputHandlers.rotate; + } + } + + if (handler != nullptr) handler(steps); + return emitResponse(response, error); +} + +StdioPumpResult AutomationStdio::processTouch(const Request& request, + std::string* error) +{ + const bool hasCoordinates = request.command != Command::TouchUp; + const std::uint16_t x = + hasCoordinates ? static_cast( + parseValidatedUnsigned(request.arguments[0])) + : 0; + const std::uint16_t y = + hasCoordinates ? static_cast( + parseValidatedUnsigned(request.arguments[1])) + : 0; + Response response; + void (*positionHandler)(std::uint16_t, std::uint16_t) = nullptr; + void (*releaseHandler)() = nullptr; + { + std::lock_guard lock(stateMutex); + const SessionEpoch epoch = sessionState.epoch(); + const bool callbacksReady = inputHandlers.touchDown != nullptr && + inputHandlers.touchMove != nullptr && + inputHandlers.touchUp != nullptr; + if (!targetDescription.capabilities.touch || !callbacksReady) { + response = + Response::failure(request.id, epoch, ErrorCode::UnsupportedCommand, + "touch input is not available"); + } else if (hasCoordinates && (x >= targetDescription.lcdWidth || + y >= targetDescription.lcdHeight)) { + response = Response::failure(request.id, epoch, ErrorCode::OutOfRange, + "touch coordinate is outside the LCD"); + } else { + TransitionResult transition = TransitionResult::InvalidState; + if (request.command == Command::TouchDown) { + transition = sessionState.touchDown(x, y); + } else if (request.command == Command::TouchMove) { + transition = sessionState.touchMove(x, y); + } else { + transition = sessionState.touchUp(); + } + + if (transition == TransitionResult::Applied) { + response = Response::success(request.id, epoch); + if (request.command == Command::TouchDown) + positionHandler = inputHandlers.touchDown; + else if (request.command == Command::TouchMove) + positionHandler = inputHandlers.touchMove; + else + releaseHandler = inputHandlers.touchUp; + } else if (transition == TransitionResult::Busy) { + response = + Response::failure(request.id, epoch, ErrorCode::OperationBusy, + "an asynchronous operation is active"); + } else if (transition == TransitionResult::Duplicate) { + response = + Response::failure(request.id, epoch, ErrorCode::TouchAlreadyDown, + "touch is already down"); + } else if (sessionState.phase() != SessionPhase::Ready) { + response = + Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for input"); + } else { + response = Response::failure(request.id, epoch, ErrorCode::TouchNotDown, + "touch is not down"); + } + } + } + + if (positionHandler != nullptr) positionHandler(x, y); + if (releaseHandler != nullptr) releaseHandler(); + return emitResponse(response, error); +} + +StdioPumpResult AutomationStdio::processSetSwitch(const Request& request, + std::string* error) +{ + const std::int32_t position = parseValidatedSigned(request.arguments[1]); + Response response; + bool admitted = false; + bool (*handler)(const std::string&, std::int8_t) = nullptr; + SessionEpoch epoch = 0; + { + std::lock_guard lock(stateMutex); + epoch = sessionState.epoch(); + const auto target = std::find_if(targetDescription.switches.begin(), + targetDescription.switches.end(), + [&request](const NamedRange& item) { + return item.name == request.arguments[0]; + }); + if (target == targetDescription.switches.end()) { + response = Response::failure( + request.id, epoch, ErrorCode::UnsupportedTarget, + "switch is not supported by this target: " + request.arguments[0]); + } else if (position < -1 || position > 1) { + response = Response::failure(request.id, epoch, ErrorCode::OutOfRange, + "switch position must be -1, 0, or 1"); + } else if (inputHandlers.setSwitch == nullptr) { + response = + Response::failure(request.id, epoch, ErrorCode::UnsupportedCommand, + "switch input is not available"); + } else if (sessionState.phase() != SessionPhase::Ready || + sessionState.asyncOperation() != AsyncOperation::None) { + response = Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for switch input"); + } else { + handler = inputHandlers.setSwitch; + admitted = true; + } + } + + if (admitted) { + if (handler(request.arguments[0], static_cast(position))) { + response = Response::success(request.id, epoch); + } else { + response = Response::failure( + request.id, epoch, ErrorCode::OutOfRange, + "switch position is not supported by this switch type"); + } + } + return emitResponse(response, error); +} + +StdioPumpResult AutomationStdio::processSetAnalog(const Request& request, + std::string* error) +{ + const std::uint16_t value = + static_cast(parseValidatedUnsigned(request.arguments[1])); + Response response; + bool admitted = false; + bool (*handler)(const std::string&, std::uint16_t) = nullptr; + SessionEpoch epoch = 0; + { + std::lock_guard lock(stateMutex); + epoch = sessionState.epoch(); + const auto target = std::find_if(targetDescription.analogs.begin(), + targetDescription.analogs.end(), + [&request](const NamedRange& item) { + return item.name == request.arguments[0]; + }); + if (target == targetDescription.analogs.end()) { + response = Response::failure( + request.id, epoch, ErrorCode::UnsupportedTarget, + "analog is not supported by this target: " + request.arguments[0]); + } else if (inputHandlers.setAnalog == nullptr) { + response = + Response::failure(request.id, epoch, ErrorCode::UnsupportedCommand, + "analog override is not available"); + } else if (sessionState.phase() != SessionPhase::Ready || + sessionState.asyncOperation() != AsyncOperation::None) { + response = Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for analog input"); + } else { + handler = inputHandlers.setAnalog; + admitted = true; + } + } + + if (admitted) { + response = + handler(request.arguments[0], value) + ? Response::success(request.id, epoch) + : Response::failure(request.id, epoch, ErrorCode::UnsupportedTarget, + "cannot resolve the analog input"); + } + return emitResponse(response, error); +} + +StdioPumpResult AutomationStdio::processClearAnalog(const Request& request, + std::string* error) +{ + Response response; + bool admitted = false; + bool clearAll = request.arguments[0] == "all"; + bool (*handler)(const std::string&) = nullptr; + void (*allHandler)() = nullptr; + SessionEpoch epoch = 0; + { + std::lock_guard lock(stateMutex); + epoch = sessionState.epoch(); + const auto target = std::find_if(targetDescription.analogs.begin(), + targetDescription.analogs.end(), + [&request](const NamedRange& item) { + return item.name == request.arguments[0]; + }); + if (!clearAll && target == targetDescription.analogs.end()) { + response = Response::failure( + request.id, epoch, ErrorCode::UnsupportedTarget, + "analog is not supported by this target: " + request.arguments[0]); + } else if ((clearAll && inputHandlers.clearAllAnalogs == nullptr) || + (!clearAll && inputHandlers.clearAnalog == nullptr)) { + response = + Response::failure(request.id, epoch, ErrorCode::UnsupportedCommand, + "analog override is not available"); + } else if (sessionState.phase() != SessionPhase::Ready || + sessionState.asyncOperation() != AsyncOperation::None) { + response = Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for analog input"); + } else { + handler = inputHandlers.clearAnalog; + allHandler = inputHandlers.clearAllAnalogs; + admitted = true; + } + } + + if (admitted) { + if (clearAll) { + allHandler(); + response = Response::success(request.id, epoch); + } else { + response = handler(request.arguments[0]) + ? Response::success(request.id, epoch) + : Response::failure(request.id, epoch, + ErrorCode::UnsupportedTarget, + "cannot resolve the analog input"); + } + } + return emitResponse(response, error); +} + +StdioPumpResult AutomationStdio::processSetTelemetry(const Request& request, + std::string* error) +{ + static_assert(MAX_TELEMETRY_LABEL_BYTES == TELEM_LABEL_LEN, + "protocol and model telemetry labels must match"); + + FirmwareRequest firmwareRequest; + firmwareRequest.operation = FirmwareOperation::Telemetry; + firmwareRequest.id = request.id; + firmwareRequest.telemetryId = + static_cast(parseValidatedUnsigned(request.arguments[0])); + firmwareRequest.telemetrySubId = + static_cast(parseValidatedUnsigned(request.arguments[1])); + firmwareRequest.telemetryInstance = + static_cast(parseValidatedUnsigned(request.arguments[2])); + firmwareRequest.telemetryValue = parseValidatedSigned(request.arguments[3]); + firmwareRequest.telemetryUnit = + static_cast(parseValidatedUnsigned(request.arguments[4])); + firmwareRequest.telemetryPrecision = + static_cast(parseValidatedUnsigned(request.arguments[5])); + const std::string telemetryName = + request.arguments.size() == 7 + ? request.arguments[6] + : defaultTelemetryName(firmwareRequest.telemetryId); + std::memcpy(firmwareRequest.telemetryName, telemetryName.data(), + telemetryName.size()); + + Response response; + SessionEpoch epoch = 0; + bool reserved = false; + { + std::lock_guard lock(stateMutex); + epoch = sessionState.epoch(); + firmwareRequest.epoch = epoch; + if (firmwareRequest.telemetryUnit > UNIT_MAX) { + response = Response::failure(request.id, epoch, ErrorCode::OutOfRange, + "telemetry unit is not supported"); + } else if (!targetDescription.capabilities.telemetry) { + response = + Response::failure(request.id, epoch, ErrorCode::UnsupportedCommand, + "telemetry injection is not available"); + } else if (sessionState.phase() != SessionPhase::Ready) { + response = Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for firmware work"); + } else if (sessionState.beginAsync(AsyncOperation::Firmware, request.id) != + TransitionResult::Applied) { + response = Response::failure(request.id, epoch, ErrorCode::OperationBusy, + "an asynchronous operation is active"); + } else { + pendingFirmware.id = request.id; + pendingFirmware.epoch = epoch; + pendingFirmware.operation = AsyncOperation::Firmware; + reserved = true; + } + } + + if (!reserved) return emitResponse(response, error); + if (simuAutomationPostFirmwareRequest(firmwareRequest)) + return StdioPumpResult::Continue; + + { + std::lock_guard lock(stateMutex); + (void)sessionState.cancelAsync(); + pendingFirmware.clear(); + } + return emitResponse( + Response::failure(request.id, epoch, ErrorCode::FirmwareQueueFull, + "firmware request mailbox is full"), + error); +} + +StdioPumpResult AutomationStdio::processReloadLua(const Request& request, + std::string* error) +{ + FirmwareRequest firmwareRequest; + firmwareRequest.operation = FirmwareOperation::ReloadLua; + firmwareRequest.id = request.id; + + Response response; + SessionEpoch epoch = 0; + bool reserved = false; + { + std::lock_guard lock(stateMutex); + epoch = sessionState.epoch(); + firmwareRequest.epoch = epoch; + if (!targetDescription.capabilities.lua) { + response = Response::failure(request.id, epoch, ErrorCode::LuaUnavailable, + "Lua is not available on this target"); + } else if (nextLuaGeneration == 0) { + response = + Response::failure(request.id, epoch, ErrorCode::InvariantViolation, + "Lua reload generation space is exhausted"); + } else if (sessionState.phase() != SessionPhase::Ready) { + response = Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for Lua reload"); + } else if (sessionState.beginAsync(AsyncOperation::ReloadLua, request.id) != + TransitionResult::Applied) { + response = Response::failure(request.id, epoch, ErrorCode::OperationBusy, + "an asynchronous operation is active"); + } else { + firmwareRequest.generation = nextLuaGeneration++; + pendingFirmware.id = request.id; + pendingFirmware.epoch = epoch; + pendingFirmware.operation = AsyncOperation::ReloadLua; + pendingFirmware.generation = firmwareRequest.generation; + reserved = true; + } + } + + if (!reserved) return emitResponse(response, error); + if (simuAutomationPostFirmwareRequest(firmwareRequest)) + return StdioPumpResult::Continue; + + { + std::lock_guard lock(stateMutex); + (void)sessionState.cancelAsync(); + pendingFirmware.clear(); + } + return emitResponse( + Response::failure(request.id, epoch, ErrorCode::FirmwareQueueFull, + "firmware request mailbox is full"), + error); +} + +StdioPumpResult AutomationStdio::processWaitFrame(const Request& request, + std::string* error) +{ + const DisplaySequence minimum = parseValidatedUnsigned(request.arguments[0]); + Response response; + bool immediate = false; + { + std::lock_guard lock(stateMutex); + const SessionEpoch epoch = sessionState.epoch(); + if (sessionState.phase() != SessionPhase::Ready) { + response = Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for a frame barrier"); + immediate = true; + } else if (sessionState.asyncOperation() != AsyncOperation::None || + pendingFrameWait.active() || completedFrameWait.active()) { + response = Response::failure(request.id, epoch, ErrorCode::OperationBusy, + "an asynchronous operation is active"); + immediate = true; + } else if (sessionState.displaySequence() >= minimum) { + response = Response::successWithFrame(request.id, epoch, + sessionState.displaySequence()); + immediate = true; + } else if (sessionState.beginAsync(AsyncOperation::WaitFrame, request.id) != + TransitionResult::Applied) { + response = + Response::failure(request.id, epoch, ErrorCode::InvariantViolation, + "cannot arm the frame barrier"); + immediate = true; + } else { + pendingFrameWait.id = request.id; + pendingFrameWait.epoch = epoch; + pendingFrameWait.minimum = minimum; + } + } + + return immediate ? emitResponse(response, error) : StdioPumpResult::Continue; +} + +StdioPumpResult AutomationStdio::processCapture(const Request& request, + std::string* error) +{ + CaptureArtifactPath artifactPath; + const SessionEpoch validationEpoch = currentEpoch(); + const CaptureOperationResult validation = capture.validatePath( + request.arguments[0], request.id, validationEpoch, &artifactPath); + if (!validation.ok) { + return emitResponse( + Response::failure(request.id, validationEpoch, validation.errorCode, + validation.message), + error); + } + + Response response; + SessionEpoch epoch = 0; + DisplaySequence armedAfter = 0; + bool reserved = false; + { + std::lock_guard lock(stateMutex); + epoch = sessionState.epoch(); + armedAfter = sessionState.displaySequence(); + if (sessionState.phase() != SessionPhase::Ready) { + response = Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for capture"); + } else if (sessionState.asyncOperation() != AsyncOperation::None || + pendingFrameWait.active() || completedFrameWait.active()) { + response = Response::failure(request.id, epoch, ErrorCode::OperationBusy, + "an asynchronous operation is active"); + } else if (sessionState.beginAsync(AsyncOperation::Capture, request.id) != + TransitionResult::Applied) { + response = + Response::failure(request.id, epoch, ErrorCode::InvariantViolation, + "cannot reserve the capture operation"); + } else { + reserved = true; + } + } + + if (!reserved) return emitResponse(response, error); + + CaptureOperationResult armResult = + capture.arm(request.id, epoch, armedAfter, std::move(artifactPath)); + if (!armResult.ok) { + { + std::lock_guard lock(stateMutex); + (void)sessionState.cancelAsync(); + } + return emitResponse( + Response::failure(request.id, epoch, armResult.errorCode, + armResult.message), + error); + } + + requestAutomationLcdInvalidation(); + return StdioPumpResult::Continue; +} + +StdioPumpResult AutomationStdio::processRestart(const Request& request, + std::string* error) +{ + const bool firmwareIdle = simuAutomationFirmwareIdle(); + Response response; + SessionEpoch epoch = 0; + bool reserved = false; + { + std::lock_guard lock(stateMutex); + epoch = sessionState.epoch(); + if (!targetDescription.capabilities.warmRestart) { + response = + Response::failure(request.id, epoch, ErrorCode::UnsupportedCommand, + "warm restart is not available"); + } else if (sessionState.phase() != SessionPhase::Ready) { + response = Response::failure(request.id, epoch, + sessionState.phase() == SessionPhase::Stopped + ? ErrorCode::SessionStopping + : ErrorCode::OperationBusy, + "session is not ready for warm restart"); + } else if (sessionState.asyncOperation() != AsyncOperation::None || + pendingFrameWait.active() || completedFrameWait.active() || + pendingFirmware.active() || completedRestart.active()) { + response = Response::failure(request.id, epoch, ErrorCode::OperationBusy, + "an asynchronous operation is active"); + } else if (!firmwareIdle) { + response = Response::failure(request.id, epoch, ErrorCode::OperationBusy, + "firmware mailbox is not idle"); + } else if (sessionState.beginAsync(AsyncOperation::Restart, request.id) != + TransitionResult::Applied) { + response = + Response::failure(request.id, epoch, ErrorCode::InvariantViolation, + "cannot reserve the warm restart"); + } else { + pendingRestart.id = request.id; + pendingRestart.epoch = epoch; + reserved = true; + } + } + + if (!reserved) return emitResponse(response, error); + releaseInputs(); + AutomationInputHandlers handlers; + { + std::lock_guard lock(stateMutex); + handlers = inputHandlers; + } + if (handlers.resetSwitches != nullptr) handlers.resetSwitches(); + return StdioPumpResult::RestartRequested; +} + +StdioPumpResult AutomationStdio::processReleaseAll(const Request& request, + std::string* error) +{ + releaseInputs(); + return emitResponse(Response::success(request.id, currentEpoch()), error); +} + +StdioPumpResult AutomationStdio::processStop(const Request& request, + std::string* error) +{ + CaptureCompletion captureCompletion; + const bool hasCaptureCompletion = capture.cancelAndWait(&captureCompletion); + CompletedFrameWait frameCompletion; + Response frameCancellation; + bool frameCancelled = false; + Response firmwareCancellation; + bool firmwareCancelled = false; + SessionEpoch epoch = 0; + { + std::lock_guard lock(stateMutex); + frameCompletion = completedFrameWait; + completedFrameWait.clear(); + epoch = sessionState.epoch(); + if (hasCaptureCompletion) { + const TransitionResult transition = sessionState.completeAsync( + captureCompletion.id, captureCompletion.epoch); + if (transition != TransitionResult::Applied) { + captureCompletion.ok = false; + captureCompletion.errorCode = ErrorCode::InvariantViolation; + captureCompletion.message = + "capture completion did not own the asynchronous operation"; + } + } + if (pendingFrameWait.active()) { + const RequestId pendingId = pendingFrameWait.id; + const SessionEpoch pendingEpoch = pendingFrameWait.epoch; + (void)sessionState.cancelAsync(); + pendingFrameWait.clear(); + frameCancellation = Response::failure( + pendingId, pendingEpoch, ErrorCode::SessionStopping, + "frame barrier cancelled because the session is stopping"); + frameCancelled = true; + } + if (pendingFirmware.active()) { + firmwareCancellation = Response::failure( + pendingFirmware.id, pendingFirmware.epoch, ErrorCode::SessionStopping, + "firmware operation cancelled because the session is stopping"); + (void)sessionState.cancelAsync(); + pendingFirmware.clear(); + firmwareCancelled = true; + } + } + + releaseInputs(); + if (frameCompletion.active()) { + const StdioPumpResult result = emitResponse( + Response::successWithFrame(frameCompletion.id, frameCompletion.epoch, + frameCompletion.sequence), + error); + if (result != StdioPumpResult::Continue) return result; + } + if (frameCancelled) { + const StdioPumpResult result = emitResponse(frameCancellation, error); + if (result != StdioPumpResult::Continue) return result; + } + if (firmwareCancelled) { + const StdioPumpResult result = emitResponse(firmwareCancellation, error); + if (result != StdioPumpResult::Continue) return result; + } + if (hasCaptureCompletion) { + const StdioPumpResult result = + emitResponse(captureResponse(captureCompletion), error); + if (result != StdioPumpResult::Continue) return result; + } + + const StdioPumpResult writeResult = + emitResponse(Response::success(request.id, epoch), error); + if (writeResult != StdioPumpResult::Continue) return writeResult; + stopAfterFlush = true; + return StdioPumpResult::Continue; +} + +StdioPumpResult AutomationStdio::drainCompletedResponses(std::string* error) +{ + FirmwareCompletion firmwareCompletion; + while (simuAutomationTakeFirmwareCompletion(&firmwareCompletion)) { + bool matched = false; + { + std::lock_guard lock(stateMutex); + const bool generationMatches = + pendingFirmware.operation != AsyncOperation::ReloadLua || + pendingFirmware.generation == firmwareCompletion.generation; + const bool operationMatches = + (pendingFirmware.operation == AsyncOperation::Firmware && + firmwareCompletion.operation == FirmwareOperation::Telemetry) || + (pendingFirmware.operation == AsyncOperation::ReloadLua && + firmwareCompletion.operation == FirmwareOperation::ReloadLua); + if (!pendingFirmware.active() || + pendingFirmware.id != firmwareCompletion.id || + pendingFirmware.epoch != firmwareCompletion.epoch || + firmwareCompletion.epoch != sessionState.epoch() || + !generationMatches || !operationMatches) { + incrementSaturating(&staleCompletionCount); + } else { + const TransitionResult transition = sessionState.completeAsync( + firmwareCompletion.id, firmwareCompletion.epoch); + if (transition == TransitionResult::Applied) { + pendingFirmware.clear(); + matched = true; + } else { + incrementSaturating(&staleCompletionCount); + } + } + } + if (!matched) continue; + + Response response; + if (firmwareCompletion.code == FirmwareCompletionCode::None && + firmwareCompletion.operation == FirmwareOperation::Telemetry) { + response = + Response::success(firmwareCompletion.id, firmwareCompletion.epoch); + } else if (firmwareCompletion.code == FirmwareCompletionCode::None && + firmwareCompletion.operation == FirmwareOperation::ReloadLua && + firmwareCompletion.luaState == AutomationLuaState::Running) { + LuaReloadResult luaReload; + luaReload.generation = firmwareCompletion.generation; + luaReload.state = automationLuaStateName(firmwareCompletion.luaState); + response = Response::successWithLuaReload( + firmwareCompletion.id, firmwareCompletion.epoch, luaReload); + } else if (firmwareCompletion.code == + FirmwareCompletionCode::TelemetryUnavailable) { + response = Response::failure( + firmwareCompletion.id, firmwareCompletion.epoch, + ErrorCode::UnsupportedTarget, + "no telemetry sensor slot is available in the run fixture"); + } else if (firmwareCompletion.code == + FirmwareCompletionCode::LuaUnavailable) { + response = Response::failure( + firmwareCompletion.id, firmwareCompletion.epoch, + ErrorCode::LuaUnavailable, "Lua is not available on this target"); + } else if (firmwareCompletion.code == FirmwareCompletionCode::LuaPanic) { + response = Response::failure( + firmwareCompletion.id, firmwareCompletion.epoch, ErrorCode::LuaPanic, + "Lua reload generation " + + std::to_string(firmwareCompletion.generation) + + " reached interpreter panic"); + } else { + response = Response::failure( + firmwareCompletion.id, firmwareCompletion.epoch, + ErrorCode::InternalError, "firmware operation did not complete"); + } + + const StdioPumpResult result = emitResponse(response, error); + if (result != StdioPumpResult::Continue) return result; + } + + CaptureCompletion captureCompletion; + if (capture.takeCompletion(&captureCompletion)) { + { + std::lock_guard lock(stateMutex); + const TransitionResult transition = sessionState.completeAsync( + captureCompletion.id, captureCompletion.epoch); + if (transition != TransitionResult::Applied) { + captureCompletion.ok = false; + captureCompletion.errorCode = ErrorCode::InvariantViolation; + captureCompletion.message = + "capture completion did not own the asynchronous operation"; + } + } + const StdioPumpResult result = + emitResponse(captureResponse(captureCompletion), error); + if (result != StdioPumpResult::Continue) return result; + } + + CompletedFrameWait completion; + CompletedRestart restartCompletion; + { + std::lock_guard lock(stateMutex); + if (completedFrameWait.active()) { + completion = completedFrameWait; + completedFrameWait.clear(); + } + if (completedRestart.active()) { + restartCompletion = completedRestart; + completedRestart.clear(); + } + } + if (completion.active()) { + const StdioPumpResult result = + emitResponse(Response::successWithFrame(completion.id, completion.epoch, + completion.sequence), + error); + if (result != StdioPumpResult::Continue) return result; + } + if (restartCompletion.active()) { + return emitResponse(Response::successWithFrame(restartCompletion.id, + restartCompletion.epoch, + restartCompletion.sequence), + error); + } + return StdioPumpResult::Continue; +} + +void AutomationStdio::releaseInputs() +{ + std::vector keys; + bool touchActive = false; + AutomationInputHandlers handlers; + { + std::lock_guard lock(stateMutex); + keys = sessionState.activeKeyNames(); + touchActive = sessionState.isTouchActive(); + sessionState.releaseAll(); + handlers = inputHandlers; + } + + if (handlers.setKey != nullptr) { + for (const std::string& key : keys) handlers.setKey(key, false); + } + if (touchActive && handlers.touchUp != nullptr) handlers.touchUp(); + if (handlers.clearAllAnalogs != nullptr) handlers.clearAllAnalogs(); +} + +bool AutomationStdio::supportsCommand(Command command) const +{ + std::lock_guard lock(stateMutex); + return std::find(targetDescription.commands.begin(), + targetDescription.commands.end(), + command) != targetDescription.commands.end(); +} + +StdioPumpResult AutomationStdio::emitResponse(const Response& response, + std::string* error) +{ + std::string record; + if (serializeResponse(response, &record) == SerializeResult::LimitTooSmall) { + setError(error, "cannot serialize automation response"); + return StdioPumpResult::Error; + } + return writeSerialized(record, error); +} + +StdioPumpResult AutomationStdio::emitEvent(ErrorCode code, + const std::string& message, + std::string* error) +{ + std::string record; + if (serializeEvent(currentEpoch(), code, message, &record) == + SerializeResult::LimitTooSmall) { + setError(error, "cannot serialize automation event"); + return StdioPumpResult::Error; + } + return writeSerialized(record, error); +} + +SessionEpoch AutomationStdio::currentEpoch() const +{ + std::lock_guard lock(stateMutex); + return sessionState.epoch(); +} + +Response AutomationStdio::makeStatusResponse(RequestId id) const +{ + const std::size_t firmwareDepth = simuAutomationFirmwareRequestDepth() + + simuAutomationFirmwareCompletionDepth(); + const std::size_t analogCount = simuAutomationAnalogOverrideCount(); + const std::string luaState = automationLuaStateName(simuAutomationLuaState()); + std::lock_guard lock(stateMutex); + StatusSnapshot status; + status.running = runtimeRunning; + status.phase = sessionState.phase(); + status.displaySequence = sessionState.displaySequence(); + status.asyncOperation = sessionState.asyncOperation(); + status.requestQueueDepth = + pendingEvents.size() + sessionState.queuedRequestCount(); + status.lineOverflowCount = lineOverflowCount; + status.queueOverflowCount = queueOverflowCount; + status.staleCompletionCount = staleCompletionCount; + status.activeKeyCount = sessionState.activeKeyCount(); + status.touchActive = sessionState.isTouchActive(); + status.firmwareMailboxDepth = firmwareDepth; + status.analogOverrideCount = analogCount; + status.luaState = luaState; + return Response::successWithStatus(id, sessionState.epoch(), status, + targetDescription); +} + +Response AutomationStdio::makeDescriptionResponse(RequestId id) const +{ + std::lock_guard lock(stateMutex); + return Response::successWithDescription(id, sessionState.epoch(), + targetDescription); +} + +StdioPumpResult AutomationStdio::writeSerialized(const std::string& record, + std::string* error) +{ + { + std::lock_guard lock(outputMutex); + if (outputResult == WriteResult::Closed) return StdioPumpResult::PeerClosed; + if (outputResult == WriteResult::Error) { + setError(error, outputError.empty() ? "automation stdout writer failed" + : outputError); + return StdioPumpResult::Error; + } + if (outputStop) return StdioPumpResult::PeerClosed; + if (outputQueue.size() >= STDIO_OUTPUT_QUEUE_CAPACITY) { + setError(error, "automation stdout queue capacity exceeded"); + return StdioPumpResult::Error; + } + outputQueue.push_back(record); + } + outputReady.notify_one(); + return StdioPumpResult::Continue; +} + +} // namespace automation +} // namespace edgetx diff --git a/radio/src/targets/simu/automation_stdio.h b/radio/src/targets/simu/automation_stdio.h new file mode 100644 index 00000000000..7a3b19d8af4 --- /dev/null +++ b/radio/src/targets/simu/automation_stdio.h @@ -0,0 +1,244 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "automation_capture.h" +#include "automation_protocol.h" + +namespace edgetx +{ +namespace automation +{ + +constexpr std::size_t STDIO_READ_BUDGET = 4096; +constexpr std::size_t STDIO_RECORD_BUDGET = 8; +constexpr std::size_t STDIO_OUTPUT_HIGH_WATERMARK = 64; +constexpr std::size_t STDIO_OUTPUT_QUEUE_CAPACITY = 128; + +enum class StdioPumpResult { + Continue, + RestartRequested, + StopRequested, + PeerClosed, + Error, +}; + +struct AutomationInputHandlers { + void (*setKey)(const std::string& key, bool pressed) = nullptr; + void (*rotate)(std::int32_t steps) = nullptr; + void (*touchDown)(std::uint16_t x, std::uint16_t y) = nullptr; + void (*touchMove)(std::uint16_t x, std::uint16_t y) = nullptr; + void (*touchUp)() = nullptr; + bool (*setSwitch)(const std::string& name, std::int8_t position) = nullptr; + bool (*setAnalog)(const std::string& name, std::uint16_t value) = nullptr; + bool (*clearAnalog)(const std::string& name) = nullptr; + void (*clearAllAnalogs)() = nullptr; + void (*resetSwitches)() = nullptr; +}; + +class AutomationStdio +{ + public: + explicit AutomationStdio( + const TargetDescription& target = TargetDescription()); + ~AutomationStdio(); + + AutomationStdio(const AutomationStdio&) = delete; + AutomationStdio& operator=(const AutomationStdio&) = delete; + + bool start(std::string* error); + StdioPumpResult pump(std::string* error); + void setTargetDescription(const TargetDescription& target); + void setInputHandlers(const AutomationInputHandlers& handlers); + bool configureCapture(const std::string& outputRoot, std::uint16_t width, + std::uint16_t height, std::uint8_t depth, + std::string* error); + bool captureConfigured() const; + void markRuntimeStarted(); + bool prepareRuntimeRestart(std::string* error); + void markRuntimeRestarted(); + void markRuntimeStopped(); + void onDisplayFrame(const std::uint16_t* pixels, std::size_t pixelCount); + + private: + enum class ReadResult { + Data, + WouldBlock, + Closed, + Error, + }; + + enum class WriteResult { + Complete, + Closed, + Error, + }; + + ReadResult readInput(char* bytes, std::size_t capacity, + std::size_t* bytesRead, std::string* error); + WriteResult writeOutput(const std::string& record, std::string* error); + bool startOutputWriter(std::string* error); + void stopOutputWriter(); + void outputWriterLoop(); + StdioPumpResult checkOutputWriter(std::string* error) const; + bool outputBackpressured() const; + bool outputFlushed() const; + void queueEvents(std::vector&& events); + StdioPumpResult processEvent(const LineEvent& event, std::string* error); + StdioPumpResult rejectStoppingEvent(const LineEvent& event, + std::string* error); + StdioPumpResult processKey(const Request& request, bool pressed, + std::string* error); + StdioPumpResult processRotate(const Request& request, std::string* error); + StdioPumpResult processTouch(const Request& request, std::string* error); + StdioPumpResult processSetSwitch(const Request& request, std::string* error); + StdioPumpResult processSetAnalog(const Request& request, std::string* error); + StdioPumpResult processClearAnalog(const Request& request, + std::string* error); + StdioPumpResult processSetTelemetry(const Request& request, + std::string* error); + StdioPumpResult processReloadLua(const Request& request, std::string* error); + StdioPumpResult processWaitFrame(const Request& request, std::string* error); + StdioPumpResult processCapture(const Request& request, std::string* error); + StdioPumpResult processRestart(const Request& request, std::string* error); + StdioPumpResult processReleaseAll(const Request& request, std::string* error); + StdioPumpResult processStop(const Request& request, std::string* error); + StdioPumpResult drainCompletedResponses(std::string* error); + StdioPumpResult emitResponse(const Response& response, std::string* error); + StdioPumpResult emitEvent(ErrorCode code, const std::string& message, + std::string* error); + StdioPumpResult writeSerialized(const std::string& record, + std::string* error); + SessionEpoch currentEpoch() const; + Response makeStatusResponse(RequestId id) const; + Response makeDescriptionResponse(RequestId id) const; + bool supportsCommand(Command command) const; + void releaseInputs(); + + struct PendingFrameWait { + RequestId id = 0; + SessionEpoch epoch = 0; + DisplaySequence minimum = 0; + + bool active() const { return id != 0; } + void clear() + { + id = 0; + epoch = 0; + minimum = 0; + } + }; + + struct CompletedFrameWait { + RequestId id = 0; + SessionEpoch epoch = 0; + DisplaySequence sequence = 0; + + bool active() const { return id != 0; } + void clear() + { + id = 0; + epoch = 0; + sequence = 0; + } + }; + + struct PendingFirmware { + RequestId id = 0; + SessionEpoch epoch = 0; + AsyncOperation operation = AsyncOperation::None; + std::uint64_t generation = 0; + + bool active() const { return id != 0; } + void clear() + { + id = 0; + epoch = 0; + operation = AsyncOperation::None; + generation = 0; + } + }; + + struct PendingRestart { + RequestId id = 0; + SessionEpoch epoch = 0; + + bool active() const { return id != 0; } + void clear() + { + id = 0; + epoch = 0; + } + }; + + struct CompletedRestart { + RequestId id = 0; + SessionEpoch epoch = 0; + DisplaySequence sequence = 0; + + bool active() const { return id != 0; } + void clear() + { + id = 0; + epoch = 0; + sequence = 0; + } + }; + + LineBuffer lineBuffer; + ProtocolParser parser; + std::deque pendingEvents; + mutable std::mutex stateMutex; + SessionState sessionState; + AutomationCapture capture; + TargetDescription targetDescription; + AutomationInputHandlers inputHandlers; + PendingFrameWait pendingFrameWait; + CompletedFrameWait completedFrameWait; + PendingFirmware pendingFirmware; + PendingRestart pendingRestart; + CompletedRestart completedRestart; + bool runtimeRunning = false; + std::uint64_t lineOverflowCount = 0; + std::uint64_t queueOverflowCount = 0; + std::uint64_t staleCompletionCount = 0; + std::uint64_t nextLuaGeneration = 1; + bool started = false; + bool inputClosed = false; + bool queueOverflowed = false; + + mutable std::mutex outputMutex; + std::condition_variable outputReady; + std::deque outputQueue; + std::thread outputThread; + WriteResult outputResult = WriteResult::Complete; + std::string outputError; + bool outputStop = false; + bool stopAfterFlush = false; + +#if defined(_WIN32) + std::intptr_t inputHandle = 0; + std::intptr_t outputHandle = 0; + bool inputIsPipe = false; +#else + int originalInputFlags = -1; + int outputFd = -1; + bool restoreInputFlags = false; +#endif +}; + +} // namespace automation +} // namespace edgetx diff --git a/radio/src/targets/simu/sdl_simu.cpp b/radio/src/targets/simu/sdl_simu.cpp index a6ab43e3301..7be6bff5398 100644 --- a/radio/src/targets/simu/sdl_simu.cpp +++ b/radio/src/targets/simu/sdl_simu.cpp @@ -28,12 +28,17 @@ #include #include +#include +#include +#include #include #include #include +#include #include #include +#include "analogs.h" #include "hal/adc_driver.h" #include "hal/rotary_encoder.h" #include "hal/switch_driver.h" @@ -66,6 +71,7 @@ #include "display.h" #include "simuaudio.h" +#include "simulcd.h" #include "simulib.h" #include "hal/key_driver.h" @@ -77,12 +83,235 @@ #include "edgetx.h" #include "arg_parser.h" +#if defined(SIMU_AUTOMATION) +#include "automation_runtime.h" +#include "automation_stdio.h" +#endif #define TIMER_INTERVAL 10 // 10ms static SDL_Window* window; static SDL_Renderer* renderer; static SDL_Texture* screen_frame_buffer; +static std::array simu_switch_slider_positions{}; +#if defined(SIMU_AUTOMATION) +static constexpr std::int8_t AUTOMATION_SWITCH_DISABLED = 2; +static std::array automation_switch_positions = [] { + std::array positions{}; + positions.fill(AUTOMATION_SWITCH_DISABLED); + return positions; +}(); + +static edgetx::automation::AutomationStdio* automation_stdio_instance = nullptr; + +struct AutomationKey { + const char* name; + EnumKeys key; +}; + +static constexpr AutomationKey AUTOMATION_KEYS[] = { + {"MENU", KEY_MENU}, {"EXIT", KEY_EXIT}, {"ENTER", KEY_ENTER}, + {"PAGEUP", KEY_PAGEUP}, {"PAGEDN", KEY_PAGEDN}, {"UP", KEY_UP}, + {"DOWN", KEY_DOWN}, {"LEFT", KEY_LEFT}, {"RIGHT", KEY_RIGHT}, + {"PLUS", KEY_PLUS}, {"MINUS", KEY_MINUS}, {"MODEL", KEY_MODEL}, + {"TELE", KEY_TELE}, {"SYS", KEY_SYS}, {"SHIFT", KEY_SHIFT}, + {"BIND", KEY_BIND}, +}; + +static void automationSetKey(const std::string& name, bool pressed) +{ + for (const AutomationKey& key : AUTOMATION_KEYS) { + if (name == key.name) { + simuSetKey(static_cast(key.key), pressed); + return; + } + } +} + +static int automationAnalogIndex(const std::string& name) +{ + const std::uint8_t types[] = {ADC_INPUT_MAIN, ADC_INPUT_FLEX}; + for (const std::uint8_t type : types) { + const std::uint8_t count = adcGetMaxInputs(type); + const std::uint8_t offset = adcGetInputOffset(type); + for (std::uint8_t index = 0; index < count; ++index) { + const char* canonical = analogGetCanonicalName(type, index); + if (canonical != nullptr && name == canonical) return offset + index; + } + } + return -1; +} + +static bool automationSetSwitch(const std::string& name, std::int8_t position) +{ + for (std::uint8_t index = 0; index < switchGetMaxSwitches(); ++index) { + const char* canonical = switchGetDefaultName(index); + if (canonical == nullptr || name != canonical || + switchIsCustomSwitch(index)) + continue; + + const SwitchConfig config = switchGetDefaultConfig(index); + if (config == SWITCH_NONE || (position == 0 && config != SWITCH_3POS)) { + return false; + } + automation_switch_positions[index] = position; + simu_switch_slider_positions[index] = position < 0 ? 0 + : position == 0 ? 1 + : 2; + simuSetSwitch(index, position); + return true; + } + return false; +} + +static void automationResetSwitches() +{ + automation_switch_positions.fill(AUTOMATION_SWITCH_DISABLED); + simu_switch_slider_positions.fill(0); + for (std::uint8_t index = 0; index < switchGetMaxSwitches(); ++index) { + if (!switchIsCustomSwitch(index) && + switchGetDefaultConfig(index) != SWITCH_NONE) { + simuSetSwitch(index, -1); + } + } +} + +static bool automationSetAnalog(const std::string& name, std::uint16_t value) +{ + const int index = automationAnalogIndex(name); + return index >= 0 && edgetx::automation::simuAutomationSetAnalogOverride( + static_cast(index), value); +} + +static bool automationClearAnalog(const std::string& name) +{ + const int index = automationAnalogIndex(name); + return index >= 0 && edgetx::automation::simuAutomationClearAnalogOverride( + static_cast(index)); +} + +static void automationClearAnalogs() +{ + edgetx::automation::simuAutomationClearAnalogOverrides(); +} + +#if defined(ROTARY_ENCODER_NAVIGATION) +static void automationRotate(std::int32_t steps) +{ + simuRotaryEncoderEvent(steps); +} +#endif + +#if defined(HARDWARE_TOUCH) +static void automationTouchPosition(std::uint16_t x, std::uint16_t y) +{ + static_assert(LCD_W <= (std::numeric_limits::max)() && + LCD_H <= (std::numeric_limits::max)(), + "automation touch coordinates must fit simuTouchDown"); + simuTouchDown(static_cast(x), static_cast(y)); +} + +static void automationTouchUp() { simuTouchUp(); } +#endif + +static edgetx::automation::TargetDescription automationTargetDescription( + bool captureAvailable) +{ + edgetx::automation::TargetDescription target; + target.flavour = FLAVOUR; + target.lcdWidth = static_cast(LCD_W); + target.lcdHeight = static_cast(LCD_H); + target.lcdDepth = static_cast(LCD_DEPTH); + target.commands = {edgetx::automation::Command::Ping, + edgetx::automation::Command::Status, + edgetx::automation::Command::Describe}; + for (const AutomationKey& key : AUTOMATION_KEYS) { + if (keyIsSupported(key.key)) target.keys.emplace_back(key.name); + } + for (std::uint8_t index = 0; index < switchGetMaxSwitches(); ++index) { + if (switchIsCustomSwitch(index)) continue; + const SwitchConfig config = switchGetDefaultConfig(index); + const char* name = switchGetDefaultName(index); + if (name == nullptr || (config != SWITCH_TOGGLE && config != SWITCH_2POS && + config != SWITCH_3POS)) { + continue; + } + target.switches.push_back({name, -1, 1}); + } + if (!target.switches.empty()) { + target.capabilities.switches = true; + target.commands.push_back(edgetx::automation::Command::SetSwitch); + } + const std::uint8_t analogTypes[] = {ADC_INPUT_MAIN, ADC_INPUT_FLEX}; + for (const std::uint8_t type : analogTypes) { + for (std::uint8_t index = 0; index < adcGetMaxInputs(type); ++index) { + const char* name = analogGetCanonicalName(type, index); + if (name != nullptr && name[0] != '\0') + target.analogs.push_back({name, 0, 4096}); + } + } + if (!target.analogs.empty()) { + target.capabilities.analog = true; + target.commands.push_back(edgetx::automation::Command::SetAnalog); + target.commands.push_back(edgetx::automation::Command::ClearAnalog); + } + target.capabilities.telemetry = true; + target.commands.push_back(edgetx::automation::Command::SetTelemetry); +#if defined(LUA) + target.capabilities.lua = true; + target.commands.push_back(edgetx::automation::Command::ReloadLua); +#endif + if (!target.keys.empty()) { + target.commands.push_back(edgetx::automation::Command::KeyDown); + target.commands.push_back(edgetx::automation::Command::KeyUp); + } +#if defined(ROTARY_ENCODER_NAVIGATION) + target.capabilities.rotary = true; + target.commands.push_back(edgetx::automation::Command::Rotate); +#endif +#if defined(HARDWARE_TOUCH) + target.capabilities.touch = true; + target.commands.push_back(edgetx::automation::Command::TouchDown); + target.commands.push_back(edgetx::automation::Command::TouchMove); + target.commands.push_back(edgetx::automation::Command::TouchUp); +#endif + target.commands.push_back(edgetx::automation::Command::WaitFrame); + if (captureAvailable) { + target.capabilities.capture = true; + target.commands.push_back(edgetx::automation::Command::Capture); + } + target.capabilities.warmRestart = true; + target.commands.push_back(edgetx::automation::Command::Restart); + if (!target.keys.empty() || target.capabilities.touch || + target.capabilities.analog) + target.commands.push_back(edgetx::automation::Command::ReleaseAll); + target.commands.push_back(edgetx::automation::Command::Stop); + // Capability flags describe commands that are usable through this protocol + // build, not merely hardware that the radio target happens to contain. + target.outputRootReady = true; + return target; +} + +static edgetx::automation::AutomationInputHandlers automationInputHandlers() +{ + edgetx::automation::AutomationInputHandlers handlers; + handlers.setKey = automationSetKey; + handlers.setSwitch = automationSetSwitch; + handlers.setAnalog = automationSetAnalog; + handlers.clearAnalog = automationClearAnalog; + handlers.clearAllAnalogs = automationClearAnalogs; + handlers.resetSwitches = automationResetSwitches; +#if defined(ROTARY_ENCODER_NAVIGATION) + handlers.rotate = automationRotate; +#endif +#if defined(HARDWARE_TOUCH) + handlers.touchDown = automationTouchPosition; + handlers.touchMove = automationTouchPosition; + handlers.touchUp = automationTouchUp; +#endif + return handlers; +} +#endif static GimbalState stick_left = {{0.5f, 0.5f}, false}; static GimbalState stick_right = {{0.5f, 0.5f}, false}; @@ -312,8 +541,6 @@ static void draw_switches() ImGui::PushID("switches"); { - static int switches[MAX_SWITCHES] = {0}; - ImGui::BeginGroup(); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing)); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); @@ -326,12 +553,21 @@ static void draw_switches() if (!switchIsCustomSwitch(i)) { if (++sw_idx >= MAX_SWITCHES / 2) sw_idx = 0; if (!SWITCH_EXISTS(i)) { - switches[i] = 0; + simu_switch_slider_positions[i] = 0; ImGui::Dummy(sw_size); } else { +#if defined(SIMU_AUTOMATION) + if (automation_switch_positions[i] != AUTOMATION_SWITCH_DISABLED) { + const std::int8_t position = automation_switch_positions[i]; + simu_switch_slider_positions[i] = position < 0 ? 0 + : position == 0 ? 1 + : 2; + } +#endif ImGui::PushID(i); ImGui::VSliderInt("##sw", sw_size, - &switches[i], IS_CONFIG_3POS(i) ? 2 : 1, + &simu_switch_slider_positions[i], + IS_CONFIG_3POS(i) ? 2 : 1, 0, "", ImGuiSliderFlags_NoInput); if (ImGui::IsItemActive() || ImGui::IsItemHovered()) { ImGui::SetTooltip("%s", switchGetDefaultName(i)); @@ -339,10 +575,17 @@ static void draw_switches() ImGui::PopID(); } +#if defined(SIMU_AUTOMATION) + if (automation_switch_positions[i] != AUTOMATION_SWITCH_DISABLED) { + simuSetSwitch(i, automation_switch_positions[i]); + } else +#endif if (IS_CONFIG_3POS(i)) { - simuSetSwitch(i, switches[i] == 0 ? -1 : switches[i] == 1 ? 0 : 1); + simuSetSwitch(i, simu_switch_slider_positions[i] == 0 + ? -1 + : simu_switch_slider_positions[i] == 1 ? 0 : 1); } else { - simuSetSwitch(i, switches[i] == 0 ? -1 : 1); + simuSetSwitch(i, simu_switch_slider_positions[i] == 0 ? -1 : 1); } } } @@ -697,6 +940,30 @@ int main(int argc, char* argv[]) return 0; } +#if defined(SIMU_AUTOMATION) + edgetx::automation::AutomationStdio automation_stdio; + if (args.isAutomationStdio()) { + SDL_LogSetOutputFunction( + [](void*, int, SDL_LogPriority, const char* message) { + fprintf(stderr, "%s\n", message); + }, + nullptr); + + std::string automation_error; + if (!automation_stdio.start(&automation_error)) { + fprintf(stderr, "Error: %s\n", automation_error.c_str()); + return 1; + } + if (!automation_stdio.configureCapture( + args.getAutomationOutputPath(), static_cast(LCD_W), + static_cast(LCD_H), + static_cast(LCD_DEPTH), &automation_error)) { + fprintf(stderr, "Error: %s\n", automation_error.c_str()); + return 1; + } + } +#endif + int window_height = 600; if (args.hasHeight()) { window_height = args.getHeight(); @@ -709,12 +976,12 @@ int main(int argc, char* argv[]) // Setup SDL if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER) != 0) { - printf("Error: %s\n", SDL_GetError()); + fprintf(stderr, "Error: %s\n", SDL_GetError()); return -1; } simuAudioInit(); - + // From 2.0.18: Enable native IME. #ifdef SDL_HINT_IME_SHOW_UI SDL_SetHint(SDL_HINT_IME_SHOW_UI, "1"); @@ -788,7 +1055,26 @@ int main(int argc, char* argv[]) simuInit(); simuFatfsSetPaths(args.getStoragePath().c_str(), args.getSettingsPath().c_str()); - simuStart(); +#if defined(SIMU_AUTOMATION) + if (args.isAutomationStdio()) { + automation_stdio.setTargetDescription( + automationTargetDescription(automation_stdio.captureConfigured())); + automation_stdio.setInputHandlers(automationInputHandlers()); + automationResetSwitches(); + automation_stdio_instance = &automation_stdio; + } +#endif + // Automation must reach the periodic firmware loop without interactive + // splash, calibration, or startup checks. Normal simulator runs keep the + // existing startup behavior. + bool runStartupChecks = true; +#if defined(SIMU_AUTOMATION) + runStartupChecks = !args.isAutomationStdio(); +#endif + simuStart(runStartupChecks); +#if defined(SIMU_AUTOMATION) + if (args.isAutomationStdio()) automation_stdio.markRuntimeStarted(); +#endif // Main loop SDL_SetEventFilter([](void*, SDL_Event* event){ @@ -800,11 +1086,47 @@ int main(int argc, char* argv[]) return 1; }, NULL); + int exit_code = 0; #if defined(__EMSCRIPTEN__) emscripten_set_main_loop([]() { handleEvents(); }, 0, true); #else do { Uint64 start_ts = SDL_GetPerformanceCounter(); + +#if defined(SIMU_AUTOMATION) + if (args.isAutomationStdio()) { + std::string automation_error; + const edgetx::automation::StdioPumpResult automation_result = + automation_stdio.pump(&automation_error); + if (automation_result == edgetx::automation::StdioPumpResult::Error) { + fprintf(stderr, "Automation error: %s\n", automation_error.c_str()); + exit_code = 1; + break; + } + if (automation_result == + edgetx::automation::StdioPumpResult::RestartRequested) { + simuStop(); + if (!automation_stdio.prepareRuntimeRestart(&automation_error)) { + fprintf(stderr, "Automation restart error: %s\n", + automation_error.c_str()); + exit_code = 1; + break; + } + simuStart(false); + if (!simuIsRunning()) { + fprintf(stderr, "Automation restart error: simulator did not start\n"); + exit_code = 1; + break; + } + automation_stdio.markRuntimeRestarted(); + continue; + } + if (automation_result != edgetx::automation::StdioPumpResult::Continue) { + break; + } + } +#endif + if (!handleEvents()) break; Uint64 end_ts = SDL_GetPerformanceCounter(); @@ -819,6 +1141,12 @@ int main(int argc, char* argv[]) // App cleanup simuStop(); +#if defined(SIMU_AUTOMATION) + if (args.isAutomationStdio()) { + automation_stdio_instance = nullptr; + automation_stdio.markRuntimeStopped(); + } +#endif // Cleanup ImGui_ImplSDLRenderer2_Shutdown(); @@ -833,12 +1161,20 @@ int main(int argc, char* argv[]) #endif SDL_CloseAudio(); SDL_Quit(); - - return 0; + + return exit_code; } uint16_t simuGetAnalog(uint8_t idx) { +#if defined(SIMU_AUTOMATION) + std::uint16_t automationValue = 0; + if (edgetx::automation::simuAutomationGetAnalogOverride(idx, + &automationValue)) { + return automationValue; + } +#endif + auto max_sticks = adcGetMaxInputs(ADC_INPUT_MAIN); if (idx < max_sticks) { switch(idx) { @@ -873,4 +1209,12 @@ uint16_t simuGetAnalog(uint8_t idx) } void simuTrace(const char* text) {} -void simuLcdNotify() {} +void simuLcdNotify() +{ +#if defined(SIMU_AUTOMATION) + if (automation_stdio_instance != nullptr) + automation_stdio_instance->onDisplayFrame( + reinterpret_cast(simuLcdBuf), + LCD_DEPTH == 16 ? static_cast(DISPLAY_BUFFER_SIZE) : 0); +#endif +} diff --git a/radio/src/tests/CMakeLists.txt b/radio/src/tests/CMakeLists.txt index f9c3945a95a..e803aa11520 100644 --- a/radio/src/tests/CMakeLists.txt +++ b/radio/src/tests/CMakeLists.txt @@ -35,12 +35,17 @@ file(GLOB TEST_SRC_FILES ${RADIO_SRC_DIR}/tests/*.cpp set(TEST_SRC_FILES ${TEST_SRC_FILES} ${SIMU_SRC} + ${RADIO_SRC_DIR}/targets/simu/arg_parser.cpp + ${RADIO_SRC_DIR}/targets/simu/automation_capture.cpp + ${RADIO_SRC_DIR}/targets/simu/automation_protocol.cpp + ${RADIO_SRC_DIR}/targets/simu/automation_stdio.cpp ) add_executable(gtests-radio EXCLUDE_FROM_ALL ${TEST_SRC_FILES} ) target_compile_options(gtests-radio PRIVATE ${SIMU_SRC_OPTIONS}) +target_compile_definitions(gtests-radio PRIVATE SIMU_AUTOMATION) target_link_libraries(gtests-radio gtests-radio-lib) message(STATUS "Added optional gtests target") diff --git a/radio/src/tests/simu_arg_parser.cpp b/radio/src/tests/simu_arg_parser.cpp new file mode 100644 index 00000000000..a9a90111a36 --- /dev/null +++ b/radio/src/tests/simu_arg_parser.cpp @@ -0,0 +1,149 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#include +#include +#include + +#include "arg_parser.h" +#include "gtests.h" + +namespace +{ + +class Arguments +{ + public: + Arguments(std::initializer_list arguments) + { + for (const char* argument : arguments) values.emplace_back(argument); + for (std::string& value : values) pointers.push_back(value.data()); + } + + int count() const { return static_cast(pointers.size()); } + char** data() { return pointers.data(); } + + private: + std::vector values; + std::vector pointers; +}; + +class OutputDirectory +{ + public: + OutputDirectory() : + path(std::filesystem::current_path() / "simu automation arg parser test") + { + std::error_code error; + std::filesystem::remove_all(path, error); + error.clear(); + EXPECT_TRUE(std::filesystem::create_directory(path, error)); + EXPECT_FALSE(error); + } + + ~OutputDirectory() + { + std::error_code error; + std::filesystem::remove_all(path, error); + } + + std::filesystem::path path; +}; + +} // namespace + +TEST(SimuArgumentParser, AutomationIsDisabledByDefault) +{ + Arguments arguments{"simu", "--width", "800"}; + ArgumentParser parser("simu"); + + EXPECT_TRUE(parser.parse(arguments.count(), arguments.data())); + EXPECT_FALSE(parser.isAutomationStdio()); + EXPECT_FALSE(parser.hasAutomationOutputPath()); + EXPECT_EQ(parser.getWidth(), 800); +} + +TEST(SimuArgumentParser, AutomationOptionsMustBeUsedTogether) +{ + Arguments missingOutput{"simu", "--automation-stdio"}; + ArgumentParser parserMissingOutput("simu"); + testing::internal::CaptureStderr(); + EXPECT_FALSE( + parserMissingOutput.parse(missingOutput.count(), missingOutput.data())); + EXPECT_FALSE(testing::internal::GetCapturedStderr().empty()); + + Arguments outputWithoutMode{"simu", "--automation-output", "."}; + ArgumentParser parserOutputWithoutMode("simu"); + testing::internal::CaptureStdout(); + EXPECT_FALSE(parserOutputWithoutMode.parse(outputWithoutMode.count(), + outputWithoutMode.data())); + EXPECT_FALSE(testing::internal::GetCapturedStdout().empty()); +} + +TEST(SimuArgumentParser, CanonicalizesExistingAutomationOutputDirectory) +{ + OutputDirectory output; + const std::string outputPath = output.path.string(); + Arguments arguments{"simu", "--automation-output", outputPath.c_str(), + "--automation-stdio"}; + ArgumentParser parser("simu"); + + ASSERT_TRUE(parser.parse(arguments.count(), arguments.data())); + EXPECT_TRUE(parser.isAutomationStdio()); + EXPECT_TRUE(parser.hasAutomationOutputPath()); + EXPECT_EQ(std::filesystem::path(parser.getAutomationOutputPath()), + std::filesystem::canonical(output.path)); +} + +TEST(SimuArgumentParser, RejectsMissingAutomationOutputDirectory) +{ + const auto missing = + std::filesystem::current_path() / "simu-automation-output-does-not-exist"; + std::error_code error; + std::filesystem::remove_all(missing, error); + const std::string missingPath = missing.string(); + Arguments arguments{"simu", "--automation-stdio", "--automation-output", + missingPath.c_str()}; + ArgumentParser parser("simu"); + + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse(arguments.count(), arguments.data())); + EXPECT_FALSE(testing::internal::GetCapturedStderr().empty()); +} + +TEST(SimuArgumentParser, AutomationParseErrorsLeaveStdoutEmpty) +{ + Arguments arguments{"simu", "--unknown", "--automation-stdio"}; + ArgumentParser parser("simu"); + + testing::internal::CaptureStdout(); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse(arguments.count(), arguments.data())); + const std::string standardError = testing::internal::GetCapturedStderr(); + const std::string standardOutput = testing::internal::GetCapturedStdout(); + + EXPECT_TRUE(standardOutput.empty()); + EXPECT_NE(standardError.find("Unknown option: --unknown"), std::string::npos); +} + +TEST(SimuArgumentParser, HelpSkipsAutomationDependencyValidation) +{ + Arguments arguments{"simu", "--automation-stdio", "--help"}; + ArgumentParser parser("simu"); + + EXPECT_TRUE(parser.parse(arguments.count(), arguments.data())); + EXPECT_TRUE(parser.isHelpRequested()); + + testing::internal::CaptureStdout(); + testing::internal::CaptureStderr(); + parser.printHelp(); + const std::string standardError = testing::internal::GetCapturedStderr(); + const std::string standardOutput = testing::internal::GetCapturedStdout(); + + EXPECT_TRUE(standardOutput.empty()); + EXPECT_NE(standardError.find("--automation-stdio"), std::string::npos); + EXPECT_NE(standardError.find("--automation-output"), std::string::npos); +} diff --git a/radio/src/tests/simu_automation_capture.cpp b/radio/src/tests/simu_automation_capture.cpp new file mode 100644 index 00000000000..75c9be0a01f --- /dev/null +++ b/radio/src/tests/simu_automation_capture.cpp @@ -0,0 +1,488 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "automation_capture.h" + +using namespace edgetx::automation; + +namespace +{ + +class ScopedTempDirectory +{ + public: + ScopedTempDirectory() + { + static std::atomic nextId{0}; + const auto stamp = + std::chrono::steady_clock::now().time_since_epoch().count(); + std::error_code error; + for (unsigned int attempt = 0; attempt < 100; ++attempt) { + directory = std::filesystem::temp_directory_path() / + ("edgetx-capture-test-" + std::to_string(stamp) + "-" + + std::to_string(nextId.fetch_add(1))); + if (std::filesystem::create_directory(directory, error)) return; + error.clear(); + } + throw std::runtime_error("cannot create capture test directory"); + } + + ~ScopedTempDirectory() + { + std::error_code ignored; + std::filesystem::remove_all(directory, ignored); + } + + const std::filesystem::path& path() const { return directory; } + + private: + std::filesystem::path directory; +}; + +std::vector readBytes(const std::filesystem::path& path) +{ + std::ifstream stream(path, std::ios::binary); + return std::vector(std::istreambuf_iterator(stream), + std::istreambuf_iterator()); +} + +bool waitForCompletion(AutomationCapture& capture, + CaptureCompletion* completion) +{ + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (std::chrono::steady_clock::now() < deadline) { + if (capture.takeCompletion(completion)) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return capture.takeCompletion(completion); +} + +CaptureOperationResult injectedWriteFailure(const CaptureWriteRequest&, + std::atomic&) +{ + return CaptureOperationResult::failure(ErrorCode::CaptureFailed, + "injected capture write failure"); +} + +CaptureOperationResult injectedWriterException(const CaptureWriteRequest&, + std::atomic&) +{ + throw std::runtime_error("injected writer exception"); +} + +std::atomic blockingWriterEntered{false}; + +CaptureOperationResult waitForCancellation(const CaptureWriteRequest&, + std::atomic& commitState) +{ + blockingWriterEntered.store(true, std::memory_order_release); + while (commitState.load(std::memory_order_acquire) == 0) { + std::this_thread::yield(); + } + return CaptureOperationResult::failure( + ErrorCode::CaptureCancelled, "injected writer observed cancellation"); +} + +void configureCapture(AutomationCapture& capture, + const std::filesystem::path& root, + CaptureWriteFunction writer = writeRgb565Ppm) +{ + std::string error; + ASSERT_TRUE(capture.configure(root.u8string(), 2, 2, 16, &error, writer)) + << error; + ASSERT_TRUE(capture.configured()); +} + +CaptureArtifactPath validatedPath(AutomationCapture& capture, + const std::string& relative, RequestId id = 1) +{ + CaptureArtifactPath artifact; + const CaptureOperationResult result = + capture.validatePath(relative, id, 1, &artifact); + EXPECT_TRUE(result.ok) << result.message; + return artifact; +} + +} // namespace + +TEST(SimuAutomationCapturePath, AcceptsContainedUtf8AndInternalSpaces) +{ + ScopedTempDirectory output; + ASSERT_TRUE( + std::filesystem::create_directory(output.path() / "nested folder")); + AutomationCapture capture; + configureCapture(capture, output.path()); + + const std::string relative = + "nested folder/\xc3\xa9" + "cran image.ppm"; + CaptureArtifactPath artifact; + const CaptureOperationResult result = + capture.validatePath(relative, 7, 3, &artifact); + + ASSERT_TRUE(result.ok) << result.message; + EXPECT_EQ(artifact.relative, relative); + EXPECT_EQ(artifact.finalPath.filename(), + std::filesystem::u8path("\xc3\xa9" + "cran image.ppm")); + EXPECT_EQ(artifact.finalPath.parent_path(), + std::filesystem::canonical(output.path() / "nested folder")); +} + +TEST(SimuAutomationCapturePath, RejectsInvalidUtf8WithoutThrowing) +{ + ScopedTempDirectory output; + AutomationCapture capture; + configureCapture(capture, output.path()); + + const std::array invalidSequences = { + std::string("\x80", 1), + std::string("\xc3", 1), + std::string("\xc0\xaf", 2), + std::string("\xed\xa0\x80", 3), + std::string("\xf4\x90\x80\x80", 4), + }; + for (const std::string& sequence : invalidSequences) { + const std::string invalidPath = "invalid-" + sequence + ".ppm"; + CaptureArtifactPath artifact; + artifact.relative = "sentinel"; + artifact.finalPath = "sentinel-final"; + artifact.temporaryPath = "sentinel-temporary"; + CaptureOperationResult result; + + EXPECT_NO_THROW(result = + capture.validatePath(invalidPath, 1, 1, &artifact)); + EXPECT_FALSE(result.ok); + EXPECT_EQ(result.errorCode, ErrorCode::InvalidUtf8); + EXPECT_EQ(artifact.relative, "sentinel"); + EXPECT_EQ(artifact.finalPath, std::filesystem::path("sentinel-final")); + EXPECT_EQ(artifact.temporaryPath, + std::filesystem::path("sentinel-temporary")); + } +} + +TEST(SimuAutomationCapturePath, RejectsUnsafeMissingAndExistingTargets) +{ + ScopedTempDirectory output; + AutomationCapture capture; + configureCapture(capture, output.path()); + const auto absolute = (output.path() / "absolute.ppm").u8string(); + + const std::array unsafe = { + "", + "../escape.ppm", + "./local.ppm", + "wrong.PNG", + "missing/child.ppm", + absolute, + "C:/rooted.ppm", + }; + for (const std::string& path : unsafe) { + CaptureArtifactPath artifact; + const CaptureOperationResult result = + capture.validatePath(path, 1, 1, &artifact); + EXPECT_FALSE(result.ok) << path; + } + + CaptureArtifactPath tooLongArtifact; + const CaptureOperationResult tooLong = capture.validatePath( + std::string(MAX_CAPTURE_PATH_BYTES + 1, 'a'), 1, 1, &tooLongArtifact); + EXPECT_FALSE(tooLong.ok); + EXPECT_EQ(tooLong.errorCode, ErrorCode::PathTooLong); + +#if defined(_WIN32) + CaptureArtifactPath reservedArtifact; + const CaptureOperationResult reserved = + capture.validatePath("CON.ppm", 1, 1, &reservedArtifact); + EXPECT_FALSE(reserved.ok); + EXPECT_EQ(reserved.errorCode, ErrorCode::UnsafePath); +#endif + + const std::filesystem::path existing = output.path() / "existing.ppm"; + std::ofstream(existing, std::ios::binary) << "keep"; + CaptureArtifactPath artifact; + const CaptureOperationResult result = + capture.validatePath("existing.ppm", 1, 1, &artifact); + EXPECT_FALSE(result.ok); + EXPECT_EQ(result.errorCode, ErrorCode::ArtifactExists); + EXPECT_EQ(readBytes(existing), + (std::vector{'k', 'e', 'e', 'p'})); +} + +TEST(SimuAutomationCapturePath, RejectsDanglingFinalAndEscapingParentSymlinks) +{ + ScopedTempDirectory output; + ScopedTempDirectory outside; + AutomationCapture capture; + configureCapture(capture, output.path()); + + std::error_code symlinkError; + std::filesystem::create_symlink(output.path() / "missing-target", + output.path() / "dangling.ppm", symlinkError); + if (!symlinkError) { + CaptureArtifactPath artifact; + const CaptureOperationResult result = + capture.validatePath("dangling.ppm", 1, 1, &artifact); + EXPECT_FALSE(result.ok); + EXPECT_EQ(result.errorCode, ErrorCode::ArtifactExists); + } + + symlinkError.clear(); + std::filesystem::create_directory_symlink( + outside.path(), output.path() / "outside", symlinkError); + if (!symlinkError) { + CaptureArtifactPath artifact; + const CaptureOperationResult result = + capture.validatePath("outside/escape.ppm", 2, 1, &artifact); + EXPECT_FALSE(result.ok); + EXPECT_EQ(result.errorCode, ErrorCode::UnsafePath); + } +} + +TEST(SimuAutomationCaptureInvalidation, CoalescesAndConsumesOneShotRequests) +{ + (void)consumeAutomationLcdInvalidation(); + requestAutomationLcdInvalidation(); + requestAutomationLcdInvalidation(); + + EXPECT_TRUE(consumeAutomationLcdInvalidation()); + EXPECT_FALSE(consumeAutomationLcdInvalidation()); +} + +TEST(SimuAutomationCaptureWriter, EmitsCanonicalPpmAndKnownRgb565Values) +{ + ScopedTempDirectory output; + const std::array pixels = { + 0x0000, 0xffff, 0xf800, 0x07e0, 0x001f, + }; + CaptureWriteRequest request; + request.artifactPath.relative = "colors.ppm"; + request.artifactPath.finalPath = output.path() / "colors.ppm"; + request.artifactPath.temporaryPath = output.path() / ".colors.ppm.tmp"; + request.pixels = pixels.data(); + request.pixelCount = pixels.size(); + request.width = 5; + request.height = 1; + std::atomic commitState{0}; + + const CaptureOperationResult result = writeRgb565Ppm(request, commitState); + + ASSERT_TRUE(result.ok) << result.message; + const std::string header = "P6\n5 1\n255\n"; + const std::vector bytes = + readBytes(request.artifactPath.finalPath); + ASSERT_EQ(bytes.size(), header.size() + 15); + EXPECT_TRUE(std::equal(header.begin(), header.end(), bytes.begin())); + const std::vector expectedRaster = { + 0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 255, 0, 0, 0, 255, + }; + EXPECT_TRUE(std::equal(expectedRaster.begin(), expectedRaster.end(), + bytes.begin() + header.size())); + EXPECT_EQ(result.bytes, bytes.size()); + EXPECT_FALSE(std::filesystem::exists(request.artifactPath.temporaryPath)); +} + +TEST(SimuAutomationCaptureWriter, PreservesExistingFinalAndCleansFailedPublish) +{ + ScopedTempDirectory output; + const std::array pixels = {0xffff}; + const std::filesystem::path finalPath = output.path() / "existing.ppm"; + std::ofstream(finalPath, std::ios::binary) << "keep"; + CaptureWriteRequest request; + request.artifactPath.finalPath = finalPath; + request.artifactPath.temporaryPath = output.path() / ".existing.ppm.tmp"; + request.pixels = pixels.data(); + request.pixelCount = pixels.size(); + request.width = 1; + request.height = 1; + std::atomic commitState{0}; + + const CaptureOperationResult result = writeRgb565Ppm(request, commitState); + + EXPECT_FALSE(result.ok); + EXPECT_EQ(result.errorCode, ErrorCode::ArtifactExists); + EXPECT_EQ(readBytes(finalPath), + (std::vector{'k', 'e', 'e', 'p'})); + EXPECT_FALSE(std::filesystem::exists(request.artifactPath.temporaryPath)); +} + +TEST(SimuAutomationCaptureWriter, ReportsOpenFailureWithoutPartialArtifact) +{ + ScopedTempDirectory output; + const std::array pixels = {0}; + CaptureWriteRequest request; + request.artifactPath.finalPath = output.path() / "never-created.ppm"; + request.artifactPath.temporaryPath = + output.path() / "missing" / ".capture.tmp"; + request.pixels = pixels.data(); + request.pixelCount = pixels.size(); + request.width = 1; + request.height = 1; + std::atomic commitState{0}; + + const CaptureOperationResult result = writeRgb565Ppm(request, commitState); + + EXPECT_FALSE(result.ok); + EXPECT_EQ(result.errorCode, ErrorCode::CaptureFailed); + EXPECT_NE(result.message.find("open"), std::string::npos); + EXPECT_FALSE(std::filesystem::exists(request.artifactPath.finalPath)); + EXPECT_FALSE(std::filesystem::exists(request.artifactPath.temporaryPath)); +} + +TEST(SimuAutomationCaptureCoordinator, RequiresNewerFrameAndReturnsMetadata) +{ + ScopedTempDirectory output; + AutomationCapture capture; + configureCapture(capture, output.path()); + CaptureArtifactPath artifact = validatedPath(capture, "fresh.ppm"); + ASSERT_TRUE(capture.arm(1, 1, 10, std::move(artifact)).ok); + const std::array pixels = {0, 0xffff, 0xf800, 0x001f}; + + capture.onDisplayFrame(10, 1, pixels.data(), pixels.size()); + CaptureCompletion completion; + EXPECT_FALSE(capture.takeCompletion(&completion)); + capture.onDisplayFrame(11, 1, pixels.data(), pixels.size()); + + ASSERT_TRUE(waitForCompletion(capture, &completion)); + ASSERT_TRUE(completion.ok) << completion.message; + EXPECT_EQ(completion.id, 1u); + EXPECT_EQ(completion.epoch, 1u); + EXPECT_EQ(completion.artifact.displaySequence, 11u); + EXPECT_EQ(completion.artifact.path, "fresh.ppm"); + EXPECT_EQ(completion.artifact.width, 2u); + EXPECT_EQ(completion.artifact.height, 2u); + EXPECT_EQ(completion.artifact.depth, 16u); + EXPECT_TRUE(std::filesystem::exists(output.path() / "fresh.ppm")); +} + +TEST(SimuAutomationCaptureCoordinator, EnforcesOneSlotAndCancelsArmedCapture) +{ + ScopedTempDirectory output; + AutomationCapture capture; + configureCapture(capture, output.path()); + CaptureArtifactPath first = validatedPath(capture, "first.ppm", 1); + CaptureArtifactPath second = validatedPath(capture, "second.ppm", 2); + ASSERT_TRUE(capture.arm(1, 1, 1, std::move(first)).ok); + + const CaptureOperationResult busy = capture.arm(2, 1, 1, std::move(second)); + EXPECT_FALSE(busy.ok); + EXPECT_EQ(busy.errorCode, ErrorCode::OperationBusy); + + CaptureCompletion completion; + ASSERT_TRUE(capture.cancelAndWait(&completion)); + EXPECT_FALSE(completion.ok); + EXPECT_EQ(completion.errorCode, ErrorCode::CaptureCancelled); + EXPECT_FALSE(std::filesystem::exists(output.path() / "first.ppm")); +} + +TEST(SimuAutomationCaptureCoordinator, CancelsWriterBeforeCommit) +{ + ScopedTempDirectory output; + AutomationCapture capture; + blockingWriterEntered.store(false, std::memory_order_release); + configureCapture(capture, output.path(), waitForCancellation); + CaptureArtifactPath artifact = validatedPath(capture, "cancelled.ppm"); + ASSERT_TRUE(capture.arm(1, 1, 1, std::move(artifact)).ok); + const std::array pixels = {0, 0, 0, 0}; + capture.onDisplayFrame(2, 1, pixels.data(), pixels.size()); + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!blockingWriterEntered.load(std::memory_order_acquire) && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + ASSERT_TRUE(blockingWriterEntered.load(std::memory_order_acquire)); + + CaptureCompletion completion; + ASSERT_TRUE(capture.cancelAndWait(&completion)); + EXPECT_EQ(completion.errorCode, ErrorCode::CaptureCancelled); + EXPECT_FALSE(std::filesystem::exists(output.path() / "cancelled.ppm")); +} + +TEST(SimuAutomationCaptureCoordinator, PropagatesFailureAndContainsExceptions) +{ + const std::array pixels = {0, 0, 0, 0}; + for (CaptureWriteFunction writer : + {injectedWriteFailure, injectedWriterException}) { + ScopedTempDirectory output; + AutomationCapture capture; + configureCapture(capture, output.path(), writer); + CaptureArtifactPath artifact = validatedPath(capture, "failure.ppm"); + ASSERT_TRUE(capture.arm(1, 1, 1, std::move(artifact)).ok); + capture.onDisplayFrame(2, 1, pixels.data(), pixels.size()); + + CaptureCompletion completion; + ASSERT_TRUE(waitForCompletion(capture, &completion)); + EXPECT_FALSE(completion.ok); + EXPECT_EQ(completion.errorCode, ErrorCode::CaptureFailed); + EXPECT_NE(completion.message.find("injected"), std::string::npos); + EXPECT_FALSE(std::filesystem::exists(output.path() / "failure.ppm")); + } +} + +TEST(SimuAutomationCaptureWriter, IsStableAcrossTwentyRunsAndDetectsChange) +{ + ScopedTempDirectory output; + const std::array staticPixels = { + 0x0000, + 0xffff, + 0xf800, + 0x001f, + }; + std::vector baseline; + + for (int index = 0; index < 20; ++index) { + const std::string name = "static-" + std::to_string(index) + ".ppm"; + CaptureWriteRequest request; + request.artifactPath.finalPath = output.path() / name; + request.artifactPath.temporaryPath = output.path() / ("." + name + ".tmp"); + request.pixels = staticPixels.data(); + request.pixelCount = staticPixels.size(); + request.width = 2; + request.height = 2; + std::atomic commitState{0}; + ASSERT_TRUE(writeRgb565Ppm(request, commitState).ok); + const std::vector bytes = + readBytes(request.artifactPath.finalPath); + if (index == 0) + baseline = bytes; + else + EXPECT_EQ(bytes, baseline) << index; + } + + const std::array changedPixels = { + 0x0000, + 0xffff, + 0xf800, + 0x07e0, + }; + CaptureWriteRequest changed; + changed.artifactPath.finalPath = output.path() / "changed.ppm"; + changed.artifactPath.temporaryPath = output.path() / ".changed.ppm.tmp"; + changed.pixels = changedPixels.data(); + changed.pixelCount = changedPixels.size(); + changed.width = 2; + changed.height = 2; + std::atomic commitState{0}; + ASSERT_TRUE(writeRgb565Ppm(changed, commitState).ok); + EXPECT_NE(readBytes(changed.artifactPath.finalPath), baseline); +} diff --git a/radio/src/tests/simu_automation_protocol.cpp b/radio/src/tests/simu_automation_protocol.cpp new file mode 100644 index 00000000000..4cf80eb804e --- /dev/null +++ b/radio/src/tests/simu_automation_protocol.cpp @@ -0,0 +1,797 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#include + +#include +#include +#include +#include + +#include "automation_protocol.h" +#include "automation_stdio.h" + +#if !defined(_WIN32) +#include +#include +#include +#endif + +using namespace edgetx::automation; + +#if !defined(_WIN32) +namespace +{ + +bool automationKeyDelivered = false; + +void observeAutomationKey(const std::string&, bool) +{ + automationKeyDelivered = true; +} + +struct StdioChildResult { + int status = -1; + std::string output; +}; + +StdioChildResult runStdioChild(const std::string& input, bool expectStop, + const std::string& outputRoot = std::string()) +{ + int inputPipe[2] = {-1, -1}; + int outputPipe[2] = {-1, -1}; + if (pipe(inputPipe) != 0 || pipe(outputPipe) != 0) return {}; + + const pid_t child = fork(); + if (child == 0) { + (void)close(inputPipe[1]); + (void)close(outputPipe[0]); + if (dup2(inputPipe[0], STDIN_FILENO) == -1 || + dup2(outputPipe[1], STDOUT_FILENO) == -1) { + _exit(90); + } + (void)close(inputPipe[0]); + (void)close(outputPipe[1]); + + int childStatus = 91; + { + TargetDescription target; + target.commands = {Command::Ping, Command::KeyDown, Command::SetSwitch, + Command::Capture, Command::Restart, Command::Stop}; + target.capabilities.capture = true; + target.capabilities.warmRestart = true; + target.keys = {"ENTER"}; + target.switches = {{"SA", -1, 1}}; + AutomationStdio automation(target); + AutomationInputHandlers handlers; + handlers.setKey = observeAutomationKey; + automation.setInputHandlers(handlers); + + std::string error; + if (!automation.start(&error)) _exit(92); + if (!outputRoot.empty() && + !automation.configureCapture(outputRoot, 1, 1, 16, &error)) { + _exit(89); + } + automation.onDisplayFrame(nullptr, 0); + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (std::chrono::steady_clock::now() < deadline) { + const StdioPumpResult result = automation.pump(&error); + if (result == StdioPumpResult::Error) _exit(93); + if (result == StdioPumpResult::RestartRequested) _exit(95); + if ((expectStop && result == StdioPumpResult::StopRequested) || + (!expectStop && result == StdioPumpResult::PeerClosed)) { + childStatus = automationKeyDelivered ? 94 : 0; + break; + } + std::this_thread::yield(); + } + } + _exit(childStatus); + } + + (void)close(inputPipe[0]); + (void)close(outputPipe[1]); + if (child < 0) { + (void)close(inputPipe[1]); + (void)close(outputPipe[0]); + return {}; + } + + std::size_t offset = 0; + while (offset < input.size()) { + const ssize_t written = + write(inputPipe[1], input.data() + offset, input.size() - offset); + if (written <= 0) break; + offset += static_cast(written); + } + (void)close(inputPipe[1]); + + StdioChildResult result; + char bytes[4096]; + while (true) { + const ssize_t received = read(outputPipe[0], bytes, sizeof(bytes)); + if (received <= 0) break; + result.output.append(bytes, static_cast(received)); + } + (void)close(outputPipe[0]); + (void)waitpid(child, &result.status, 0); + return result; +} + +std::size_t countSubstring(const std::string& value, + const std::string& pattern) +{ + std::size_t count = 0; + std::size_t offset = 0; + while ((offset = value.find(pattern, offset)) != std::string::npos) { + ++count; + offset += pattern.size(); + } + return count; +} + +} // namespace +#endif + +TEST(SimuAutomationLineBuffer, AcceptsLfCrlfBlankAndMultipleRecords) +{ + LineBuffer buffer; + const std::string bytes = "v1 1 ping\nv1 2 status\r\n\n"; + const auto events = buffer.feed(bytes.data(), bytes.size()); + + ASSERT_EQ(events.size(), 3u); + EXPECT_EQ(events[0].type, LineEventType::Record); + EXPECT_EQ(events[0].record, "v1 1 ping"); + EXPECT_EQ(events[1].record, "v1 2 status"); + EXPECT_TRUE(events[2].record.empty()); + EXPECT_TRUE(buffer.finish().empty()); +} + +TEST(SimuAutomationLineBuffer, PreservesUtf8AtEveryFeedSplit) +{ + const std::string record = std::string("v1 1 capture checkpoints/") + + std::string("\xc3\xa9\xe5\xb1\x8f.ppm\n", 10); + + for (std::size_t split = 0; split <= record.size(); ++split) { + LineBuffer buffer; + auto first = buffer.feed(record.data(), split); + auto second = buffer.feed(record.data() + split, record.size() - split); + + ASSERT_EQ(first.size() + second.size(), 1u) << "split=" << split; + const LineEvent& event = first.empty() ? second[0] : first[0]; + EXPECT_EQ(event.type, LineEventType::Record) << "split=" << split; + EXPECT_EQ(event.record, record.substr(0, record.size() - 1)) + << "split=" << split; + } +} + +TEST(SimuAutomationLineBuffer, EnforcesLimitAndRecoversAfterNewline) +{ + LineBuffer buffer(8); + auto exactLf = buffer.feed("1234567\n", 8); + ASSERT_EQ(exactLf.size(), 1u); + EXPECT_EQ(exactLf[0].type, LineEventType::Record); + EXPECT_EQ(exactLf[0].record, "1234567"); + + auto exactCrlf = buffer.feed("123456\r\n", 8); + ASSERT_EQ(exactCrlf.size(), 1u); + EXPECT_EQ(exactCrlf[0].type, LineEventType::Record); + EXPECT_EQ(exactCrlf[0].record, "123456"); + + const std::string overflowAndRecovery = "12345678\nok\n"; + auto events = + buffer.feed(overflowAndRecovery.data(), overflowAndRecovery.size()); + ASSERT_EQ(events.size(), 2u); + EXPECT_EQ(events[0].type, LineEventType::LineTooLong); + EXPECT_EQ(events[1].type, LineEventType::Record); + EXPECT_EQ(events[1].record, "ok"); + EXPECT_LE(buffer.bufferedBytes(), 8u); +} + +TEST(SimuAutomationLineBuffer, EnforcesProductionRecordBoundary) +{ + struct BoundaryCase { + std::size_t payloadBytes; + const char* delimiter; + LineEventType expected; + }; + const BoundaryCase cases[] = { + {MAX_RECORD_BYTES - 1, "\n", LineEventType::Record}, + {MAX_RECORD_BYTES, "\n", LineEventType::LineTooLong}, + {MAX_RECORD_BYTES - 2, "\r\n", LineEventType::Record}, + {MAX_RECORD_BYTES - 1, "\r\n", LineEventType::LineTooLong}, + }; + + for (const BoundaryCase& boundary : cases) { + LineBuffer buffer; + const std::string wire = + std::string(boundary.payloadBytes, 'x') + boundary.delimiter; + ASSERT_EQ(wire.size() <= MAX_RECORD_BYTES, + boundary.expected == LineEventType::Record); + const auto events = buffer.feed(wire.data(), wire.size()); + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].type, boundary.expected) + << "payload=" << boundary.payloadBytes + << " delimiter=" << (boundary.delimiter[0] == '\r' ? "CRLF" : "LF"); + EXPECT_LE(buffer.bufferedBytes(), MAX_RECORD_BYTES); + } +} + +TEST(SimuAutomationLineBuffer, ReportsOnlyPartialOrOverflowAtEof) +{ + LineBuffer empty; + EXPECT_TRUE(empty.finish().empty()); + + LineBuffer partial; + partial.feed("v1 1 ping", 9); + auto partialEvent = partial.finish(); + ASSERT_EQ(partialEvent.size(), 1u); + EXPECT_EQ(partialEvent[0].type, LineEventType::PartialRecordAtEof); + EXPECT_EQ(partialEvent[0].record, "v1 1 ping"); + + LineBuffer overflow(4); + overflow.feed("12345", 5); + auto overflowEvent = overflow.finish(); + ASSERT_EQ(overflowEvent.size(), 1u); + EXPECT_EQ(overflowEvent[0].type, LineEventType::LineTooLong); +} + +TEST(SimuAutomationParser, AcceptsVersionOneCommandSet) +{ + const char* records[] = { + "v1 1 ping", + "v1 2 status", + "v1 3 describe", + "v1 4 key-down ENTER", + "v1 5 key-up ENTER", + "v1 6 rotate -2", + "v1 7 touch-down 120 80", + "v1 8 touch-move 130 82", + "v1 9 touch-up", + "v1 10 set-switch SA 1", + "v1 11 set-analog AIL 2048", + "v1 12 clear-analog AIL", + "v1 13 set-telemetry 61696 0 1 115 1 1 RSSI", + "v1 14 reload-lua", + "v1 15 wait-frame 185", + "v1 16 capture checkpoints/home screen.ppm", + "v1 17 restart", + "v1 18 release-all", + "v1 19 stop", + }; + + ProtocolParser parser; + for (const char* record : records) { + const ParseResult result = parser.parse(record); + EXPECT_EQ(result.status, ParseStatus::Request) << record; + } + EXPECT_EQ(parser.lastRequestId(), 19u); +} + +TEST(SimuAutomationParser, IgnoresBlankAndPreservesCaptureRemainder) +{ + ProtocolParser parser; + EXPECT_EQ(parser.parse("").status, ParseStatus::Ignored); + + const ParseResult result = + parser.parse("v1 1 capture checkpoints/home screen.ppm"); + ASSERT_EQ(result.status, ParseStatus::Request); + ASSERT_EQ(result.request.arguments.size(), 1u); + EXPECT_EQ(result.request.arguments[0], "checkpoints/home screen.ppm"); +} + +TEST(SimuAutomationParser, ValidatesVersionAndDecimalRequestId) +{ + ProtocolParser parser; + ParseResult result = parser.parse("v2 1 ping"); + EXPECT_EQ(result.error.code, ErrorCode::UnsupportedVersion); + EXPECT_TRUE(result.error.hasRequestId); + EXPECT_EQ(parser.lastRequestId(), 0u); + + EXPECT_EQ(parser.parse("v1").error.code, ErrorCode::InvalidId); + EXPECT_EQ(parser.parse("v1 0 ping").error.code, ErrorCode::InvalidId); + EXPECT_EQ(parser.parse("v1 +1 ping").error.code, ErrorCode::InvalidId); + EXPECT_EQ(parser.parse("v1 18446744073709551616 ping").error.code, + ErrorCode::InvalidId); + + const ParseResult maximum = parser.parse("v1 18446744073709551615 ping"); + EXPECT_EQ(maximum.status, ParseStatus::Request); + EXPECT_EQ(maximum.request.id, std::numeric_limits::max()); +} + +TEST(SimuAutomationParser, ConsumesRecoveredIdAfterCommandFailure) +{ + ProtocolParser parser; + EXPECT_EQ(parser.parse("v1 10 not-a-command").error.code, + ErrorCode::UnknownCommand); + EXPECT_EQ(parser.lastRequestId(), 10u); + EXPECT_EQ(parser.parse("v1 10 ping").error.code, ErrorCode::IdNotMonotonic); + EXPECT_EQ(parser.parse("v1 11 ping").status, ParseStatus::Request); +} + +TEST(SimuAutomationParser, EnforcesArityAndNumericRanges) +{ + ProtocolParser parser; + EXPECT_EQ(parser.parse("v1 1 key-down").error.code, + ErrorCode::MissingArgument); + EXPECT_EQ(parser.parse("v1 2 ping extra").error.code, + ErrorCode::ExtraArgument); + EXPECT_EQ(parser.parse("v1 3 rotate 0").error.code, + ErrorCode::InvalidArgument); + EXPECT_EQ(parser.parse("v1 4 rotate -129").error.code, ErrorCode::OutOfRange); + EXPECT_EQ(parser.parse("v1 5 touch-down -1 2").error.code, + ErrorCode::OutOfRange); + EXPECT_EQ(parser.parse("v1 6 set-analog AIL 4097").error.code, + ErrorCode::OutOfRange); + EXPECT_EQ(parser.parse("v1 7 wait-frame 18446744073709551616").error.code, + ErrorCode::OutOfRange); + EXPECT_EQ(parser.parse("v1 8 ping ").error.code, ErrorCode::ExtraArgument); + + EXPECT_EQ(parser.parse("v1 9 set-telemetry 0 0 0 1 0 0").error.code, + ErrorCode::OutOfRange); + EXPECT_EQ(parser.parse("v1 10 set-telemetry 1 8 0 1 0 0").error.code, + ErrorCode::OutOfRange); + EXPECT_EQ(parser.parse("v1 11 set-telemetry 1 0 0 1 0 3").error.code, + ErrorCode::OutOfRange); + EXPECT_EQ(parser.parse("v1 12 set-telemetry 1 0 0 1 0 0 bad!").error.code, + ErrorCode::InvalidArgument); + EXPECT_EQ(parser.parse("v1 13 set-telemetry 1 0 0 1 0 0 ABCDE").error.code, + ErrorCode::InvalidArgument); + + const ParseResult signedMinimum = parser.parse( + "v1 14 set-telemetry 1 0 0 -2147483648 0 0 MIN"); + EXPECT_EQ(signedMinimum.status, ParseStatus::Request); + EXPECT_EQ(signedMinimum.request.arguments[3], "-2147483648"); + const ParseResult signedMaximum = parser.parse( + "v1 15 set-telemetry 1 0 0 2147483647 0 0 MAX"); + EXPECT_EQ(signedMaximum.status, ParseStatus::Request); + EXPECT_EQ(signedMaximum.request.arguments[3], "2147483647"); + EXPECT_EQ(parser.parse( + "v1 16 set-telemetry 1 0 0 -2147483649 0 0 LOW") + .error.code, + ErrorCode::OutOfRange); + EXPECT_EQ(parser.parse( + "v1 17 set-telemetry 1 0 0 2147483648 0 0 HIGH") + .error.code, + ErrorCode::OutOfRange); +} + +TEST(SimuAutomationParser, ReservesTheLfDelimiterFromTheWireLimit) +{ + ProtocolParser parser; + EXPECT_NE(parser.parse(std::string(MAX_RECORD_BYTES - 1, 'x')).error.code, + ErrorCode::LineTooLong); + EXPECT_EQ(parser.parse(std::string(MAX_RECORD_BYTES, 'x')).error.code, + ErrorCode::LineTooLong); +} + +TEST(SimuAutomationParser, RejectsMalformedSeparatorsNulAndInvalidUtf8) +{ + ProtocolParser parser; + EXPECT_EQ(parser.parse(" v1 1 ping").error.code, ErrorCode::InvalidRecord); + EXPECT_EQ(parser.parse("v1 1 ping").error.code, ErrorCode::InvalidRecord); + + std::string withNul("v1 2 capture bad", 16); + withNul.push_back('\0'); + withNul += ".ppm"; + EXPECT_EQ(parser.parse(withNul).error.code, ErrorCode::InvalidRecord); + + std::string invalidUtf8 = "v1 3 capture bad"; + invalidUtf8.append("\xc3\x28", 2); + invalidUtf8 += ".ppm"; + EXPECT_EQ(parser.parse(invalidUtf8).error.code, ErrorCode::InvalidUtf8); +} + +TEST(SimuAutomationParser, AcceptsUtf8CapturePathAndAsciiTelemetryLabel) +{ + ProtocolParser parser; + std::string record = "v1 1 capture screenshots/"; + record.append("\xc3\xa9\xe5\xb1\x8f", 5); + record += ".ppm"; + const ParseResult result = parser.parse(record); + EXPECT_EQ(result.status, ParseStatus::Request); + EXPECT_TRUE(isValidUtf8(result.request.arguments[0])); + + EXPECT_EQ(parser.parse("v1 2 set-telemetry 61696 0 1 115 1 1 R_1-").status, + ParseStatus::Request); + + std::string nonAscii = "v1 3 set-telemetry 61696 0 1 115 1 1 "; + nonAscii.append("\xc3\xa9", 2); + EXPECT_EQ(parser.parse(nonAscii).error.code, ErrorCode::InvalidArgument); +} + +TEST(SimuAutomationParser, EnforcesCapturePathBoundary) +{ + ProtocolParser parser; + const std::string exactPath(MAX_CAPTURE_PATH_BYTES - 4, 'a'); + EXPECT_EQ(parser.parse("v1 1 capture " + exactPath + ".ppm").status, + ParseStatus::Request); + + const std::string oversizedPath(MAX_CAPTURE_PATH_BYTES - 3, 'a'); + EXPECT_EQ(parser.parse("v1 2 capture " + oversizedPath + ".ppm").error.code, + ErrorCode::PathTooLong); +} + +TEST(SimuAutomationResponse, SerializesSuccessAndEscapesEveryControlByte) +{ + std::string successJson; + EXPECT_EQ(serializeResponse(Response::success(7, 2), &successJson), + SerializeResult::Serialized); + EXPECT_EQ(successJson, + "{\"version\":1,\"type\":\"response\",\"id\":7,\"ok\":true," + "\"epoch\":2}\n"); + + Response invalidFailure = Response::failure(7, 2, ErrorCode::None, "bad"); + EXPECT_EQ(serializeResponse(invalidFailure, &successJson), + SerializeResult::Serialized); + EXPECT_NE(successJson.find("\"code\":\"internal_error\""), std::string::npos); + + std::string message; + for (int byte = 0; byte < 0x20; ++byte) + message.push_back(static_cast(byte)); + message += "\"\\"; + message.append("\xc3\xa9", 2); + + std::string json; + EXPECT_EQ( + serializeResponse( + Response::failure(8, 2, ErrorCode::InvalidArgument, message), &json), + SerializeResult::Serialized); + ASSERT_FALSE(json.empty()); + EXPECT_TRUE(isValidUtf8(json)); + EXPECT_NE(json.find("\\u0000"), std::string::npos); + EXPECT_NE(json.find("\\\""), std::string::npos); + EXPECT_NE(json.find("\\\\"), std::string::npos); + EXPECT_NE(json.find("\xc3\xa9"), std::string::npos); + + for (std::size_t index = 0; index + 1 < json.size(); ++index) { + EXPECT_GE(static_cast(json[index]), 0x20u); + } + EXPECT_EQ(json.back(), '\n'); +} + +TEST(SimuAutomationResponse, FallsBackWithinResponseLimit) +{ + const std::string oversized(MAX_RESPONSE_BYTES, 'x'); + std::string json; + EXPECT_EQ( + serializeResponse( + Response::failure(9, 3, ErrorCode::InternalError, oversized), &json), + SerializeResult::UsedSizeFallback); + EXPECT_LE(json.size(), MAX_RESPONSE_BYTES); + EXPECT_NE(json.find("\"code\":\"response_too_large\""), std::string::npos); + + EXPECT_EQ(serializeResponse(Response::success(1, 1), &json, 4), + SerializeResult::LimitTooSmall); + EXPECT_TRUE(json.empty()); +} + +TEST(SimuAutomationResponse, EnforcesExactProductionResponseBoundary) +{ + std::string probe; + ASSERT_EQ( + serializeResponse(Response::failure(9, 3, ErrorCode::InternalError, ""), + &probe, MAX_RESPONSE_BYTES * 2), + SerializeResult::Serialized); + ASSERT_LT(probe.size(), MAX_RESPONSE_BYTES); + + const std::size_t exactMessageBytes = MAX_RESPONSE_BYTES - probe.size(); + std::string json; + EXPECT_EQ( + serializeResponse(Response::failure(9, 3, ErrorCode::InternalError, + std::string(exactMessageBytes, 'x')), + &json), + SerializeResult::Serialized); + EXPECT_EQ(json.size(), MAX_RESPONSE_BYTES); + + EXPECT_EQ(serializeResponse( + Response::failure(10, 3, ErrorCode::InternalError, + std::string(exactMessageBytes + 1, 'x')), + &json), + SerializeResult::UsedSizeFallback); + EXPECT_LE(json.size(), MAX_RESPONSE_BYTES); +} + +TEST(SimuAutomationResponse, SerializesBoundedStatusAndDescriptionResults) +{ + TargetDescription target; + target.flavour = "tx\"16s"; + target.lcdWidth = 480; + target.lcdHeight = 272; + target.lcdDepth = 16; + target.commands = {Command::Ping, Command::Status, Command::Describe, + Command::Stop}; + target.capabilities.capture = true; + target.keys = {"ENTER"}; + target.switches = {{"SA", -1, 1}}; + target.analogs = {{"AIL", 0, 4096}}; + target.outputRootReady = true; + + StatusSnapshot status; + status.running = true; + status.phase = SessionPhase::Ready; + status.displaySequence = 17; + status.requestQueueDepth = 2; + status.lineOverflowCount = 3; + status.staleCompletionCount = 4; + status.touchActive = true; + + std::string json; + EXPECT_EQ(serializeResponse(Response::successWithStatus(7, 1, status, target), + &json), + SerializeResult::Serialized); + EXPECT_NE(json.find("\"phase\":\"ready\""), std::string::npos); + EXPECT_NE(json.find("\"target\":\"tx\\\"16s\""), std::string::npos); + EXPECT_NE(json.find("\"display_seq\":17"), std::string::npos); + EXPECT_NE(json.find("\"request_queue_depth\":2"), std::string::npos); + EXPECT_NE(json.find("\"line_overflow_count\":3"), std::string::npos); + EXPECT_NE(json.find("\"stale_completion_count\":4"), std::string::npos); + EXPECT_NE(json.find("\"capture\":true"), std::string::npos); + EXPECT_NE(json.find("\"output_root\":\"ready\""), std::string::npos); + + EXPECT_EQ( + serializeResponse(Response::successWithDescription(8, 1, target), &json), + SerializeResult::Serialized); + EXPECT_NE( + json.find("\"commands\":[\"ping\",\"status\",\"describe\",\"stop\"]"), + std::string::npos); + EXPECT_NE(json.find("\"keys\":[\"ENTER\"]"), std::string::npos); + EXPECT_NE(json.find("{\"name\":\"SA\",\"min\":-1,\"max\":1}"), + std::string::npos); + EXPECT_NE(json.find("{\"name\":\"AIL\",\"min\":0,\"max\":4096}"), + std::string::npos); + EXPECT_LE(json.size(), MAX_RESPONSE_BYTES); + + EXPECT_EQ(serializeResponse(Response::successWithFrame(9, 1, 42), &json), + SerializeResult::Serialized); + EXPECT_EQ(json, + "{\"version\":1,\"type\":\"response\",\"id\":9,\"ok\":true," + "\"epoch\":1,\"result\":{\"display_seq\":42}}\n"); + + CaptureResult capture; + capture.displaySequence = 43; + capture.path = "checkpoints/home screen.ppm"; + capture.width = 480; + capture.height = 272; + capture.depth = 16; + capture.bytes = 391695; + EXPECT_EQ( + serializeResponse(Response::successWithCapture(10, 1, capture), &json), + SerializeResult::Serialized); + EXPECT_EQ(json, + "{\"version\":1,\"type\":\"response\",\"id\":10,\"ok\":true," + "\"epoch\":1,\"result\":{\"display_seq\":43,\"path\":" + "\"checkpoints/home screen.ppm\",\"width\":480,\"height\":272," + "\"depth\":16,\"bytes\":391695}}\n"); + + LuaReloadResult luaReload; + luaReload.generation = 7; + luaReload.state = "running"; + EXPECT_EQ(serializeResponse(Response::successWithLuaReload(11, 2, luaReload), + &json), + SerializeResult::Serialized); + EXPECT_EQ(json, + "{\"version\":1,\"type\":\"response\",\"id\":11,\"ok\":true," + "\"epoch\":2,\"result\":{\"generation\":7," + "\"state\":\"running\"}}\n"); +} + +TEST(SimuAutomationResponse, DescriptionOverflowUsesTerminalFallback) +{ + TargetDescription target; + target.flavour = "test"; + target.lcdWidth = 128; + target.lcdHeight = 64; + target.lcdDepth = 1; + target.commands = {Command::Ping, Command::Status, Command::Describe, + Command::Stop}; + target.keys = {std::string(MAX_RESPONSE_BYTES, 'x')}; + + std::string json; + EXPECT_EQ( + serializeResponse(Response::successWithDescription(1, 0, target), &json), + SerializeResult::UsedSizeFallback); + EXPECT_NE(json.find("\"code\":\"response_too_large\""), std::string::npos); + EXPECT_LE(json.size(), MAX_RESPONSE_BYTES); +} + +TEST(SimuAutomationResponse, SerializesUncorrelatedEvents) +{ + std::string json; + EXPECT_EQ( + serializeEvent(1, ErrorCode::InvalidRecord, "bad \"record\"", &json), + SerializeResult::Serialized); + EXPECT_EQ(json, + "{\"version\":1,\"type\":\"event\",\"id\":null,\"epoch\":1," + "\"event\":{\"code\":\"invalid_record\",\"message\":\"bad " + "\\\"record\\\"\"}}\n"); +} + +TEST(SimuAutomationTerminalResponse, HasOneOwnerAndRejectsDuplicates) +{ + TerminalResponseOwner owner(42, 3); + EXPECT_EQ(owner.requestId(), 42u); + EXPECT_EQ(owner.claim(2), TerminalClaimResult::StaleEpoch); + EXPECT_FALSE(owner.isTerminal()); + EXPECT_EQ(owner.claim(3), TerminalClaimResult::Claimed); + EXPECT_TRUE(owner.isTerminal()); + EXPECT_EQ(owner.cancel(3), TerminalClaimResult::Duplicate); +} + +TEST(SimuAutomationSessionState, EnforcesKeyAndTouchTransitions) +{ + SessionState state; + EXPECT_EQ(state.keyDown("ENTER"), TransitionResult::InvalidState); + state.onDisplayFrame(); + EXPECT_EQ(state.phase(), SessionPhase::Ready); + EXPECT_EQ(state.epoch(), 1u); + + EXPECT_EQ(state.keyDown("ENTER"), TransitionResult::Applied); + EXPECT_EQ(state.keyDown("EXIT"), TransitionResult::Applied); + EXPECT_EQ(state.activeKeyNames(), + (std::vector{"ENTER", "EXIT"})); + EXPECT_EQ(state.keyDown("ENTER"), TransitionResult::Duplicate); + EXPECT_EQ(state.keyUp("MENU"), TransitionResult::InvalidState); + EXPECT_EQ(state.keyUp("EXIT"), TransitionResult::Applied); + EXPECT_EQ(state.keyUp("ENTER"), TransitionResult::Applied); + + EXPECT_EQ(state.touchMove(1, 2), TransitionResult::InvalidState); + EXPECT_EQ(state.touchDown(1, 2), TransitionResult::Applied); + EXPECT_EQ(state.touchDown(2, 3), TransitionResult::Duplicate); + EXPECT_EQ(state.touchMove(2, 3), TransitionResult::Applied); + EXPECT_EQ(state.touchUp(), TransitionResult::Applied); + EXPECT_EQ(state.touchUp(), TransitionResult::InvalidState); +} + +TEST(SimuAutomationSessionState, AllowsOnlyOneAsyncOperationAndOneCompletion) +{ + SessionState state; + state.onDisplayFrame(); + EXPECT_EQ(state.beginAsync(AsyncOperation::WaitFrame, 1), + TransitionResult::Applied); + EXPECT_EQ(state.keyDown("ENTER"), TransitionResult::Busy); + EXPECT_EQ(state.beginAsync(AsyncOperation::Capture, 2), + TransitionResult::Busy); + EXPECT_EQ(state.completeAsync(2, 1), TransitionResult::NotPending); + EXPECT_EQ(state.completeAsync(1, 1), TransitionResult::Applied); + EXPECT_EQ(state.completeAsync(1, 1), TransitionResult::Duplicate); +} + +TEST(SimuAutomationSessionState, CancellationAndStopReleaseOwnedState) +{ + SessionState state; + state.onDisplayFrame(); + state.keyDown("ENTER"); + state.touchDown(10, 10); + state.enqueue(2); + state.beginAsync(AsyncOperation::Firmware, 1); + + EXPECT_EQ(state.cancelAsync(), TransitionResult::Applied); + EXPECT_EQ(state.completeAsync(1, 1), TransitionResult::Duplicate); + state.stop(); + + EXPECT_EQ(state.phase(), SessionPhase::Stopped); + EXPECT_EQ(state.activeKeyCount(), 0u); + EXPECT_FALSE(state.isTouchActive()); + EXPECT_EQ(state.queuedRequestCount(), 0u); + EXPECT_EQ(state.asyncOperation(), AsyncOperation::None); + EXPECT_EQ(state.keyDown("ENTER"), TransitionResult::InvalidState); +} + +TEST(SimuAutomationSessionState, RestartAdvancesEpochAndPurgesOldWork) +{ + SessionState state; + state.onDisplayFrame(); + state.onDisplayFrame(); + EXPECT_EQ(state.displaySequence(), 2u); + state.keyDown("ENTER"); + state.enqueue(2); + EXPECT_EQ(state.beginAsync(AsyncOperation::Restart, 1), + TransitionResult::Applied); + EXPECT_EQ(state.phase(), SessionPhase::Restarting); + EXPECT_EQ(state.onDisplayFrame(), 0u); + EXPECT_EQ(state.phase(), SessionPhase::Restarting); + EXPECT_EQ(state.asyncOperation(), AsyncOperation::Restart); + + EXPECT_EQ(state.restartTasksStarted(1, 1), TransitionResult::Applied); + EXPECT_EQ(state.epoch(), 2u); + EXPECT_EQ(state.phase(), SessionPhase::Starting); + EXPECT_EQ(state.displaySequence(), 3u); + EXPECT_EQ(state.activeKeyCount(), 0u); + EXPECT_EQ(state.queuedRequestCount(), 0u); + EXPECT_EQ(state.completeAsync(1, 1), TransitionResult::StaleEpoch); + + EXPECT_EQ(state.onDisplayFrame(), 1u); + EXPECT_EQ(state.phase(), SessionPhase::Ready); + EXPECT_EQ(state.epoch(), 2u); + EXPECT_EQ(state.displaySequence(), 4u); + EXPECT_EQ(state.asyncOperation(), AsyncOperation::None); +} + +TEST(SimuAutomationSessionState, BoundsPendingQueue) +{ + SessionState state; + state.onDisplayFrame(); + for (std::size_t index = 0; index < MAX_PENDING_REQUESTS; ++index) { + EXPECT_EQ(state.enqueue(index + 1), TransitionResult::Applied); + } + EXPECT_EQ(state.enqueue(MAX_PENDING_REQUESTS + 1), + TransitionResult::QueueFull); + + RequestId id = 0; + EXPECT_TRUE(state.dequeue(&id)); + EXPECT_EQ(id, 1u); + EXPECT_EQ(state.queuedRequestCount(), MAX_PENDING_REQUESTS - 1); +} + +#if !defined(_WIN32) +TEST(SimuAutomationStdio, StopRejectsAlreadyReadRequestsWithoutExecutingThem) +{ + char rootTemplate[] = "/tmp/edgetx-stop-barrier-XXXXXX"; + const char* root = mkdtemp(rootTemplate); + ASSERT_NE(root, nullptr); + + std::string input = + "v1 1 stop\n" + "v1 2 ping\n" + "v1 3 capture should-not-exist.ppm\n" + "v1 4 restart\n" + "v1 5 key-down ENTER\n"; + for (RequestId id = 6; id <= MAX_PENDING_REQUESTS; ++id) + input += "v1 " + std::to_string(id) + " ping\n"; + + const StdioChildResult child = runStdioChild(input, true, root); + const std::string artifact = + std::string(root) + "/should-not-exist.ppm"; + const bool artifactExists = access(artifact.c_str(), F_OK) == 0; + const int cleanupResult = rmdir(root); + + ASSERT_TRUE(WIFEXITED(child.status)); + EXPECT_EQ(WEXITSTATUS(child.status), 0); + EXPECT_FALSE(artifactExists); + EXPECT_EQ(cleanupResult, 0); + for (RequestId id = 1; id <= MAX_PENDING_REQUESTS; ++id) { + EXPECT_EQ(countSubstring(child.output, + "\"id\":" + std::to_string(id) + ","), + 1u) + << "request " << id << " did not receive exactly one terminal"; + } + EXPECT_NE(child.output.find("\"id\":1,\"ok\":true"), std::string::npos); + EXPECT_EQ(countSubstring(child.output, "\"ok\":false"), + MAX_PENDING_REQUESTS - 1); + EXPECT_EQ(countSubstring(child.output, "\"code\":\"session_stopping\""), + MAX_PENDING_REQUESTS - 1); +} + +TEST(SimuAutomationStdio, FlushesFinalLineOverflowEventBeforePeerClose) +{ + const StdioChildResult child = + runStdioChild(std::string(MAX_RECORD_BYTES + 1, 'x'), false); + + ASSERT_TRUE(WIFEXITED(child.status)); + EXPECT_EQ(WEXITSTATUS(child.status), 0); + EXPECT_EQ(countSubstring(child.output, "\"code\":\"line_too_long\""), 1u); +} + +TEST(SimuAutomationStdio, ParsesSignedInt32ExtremaWithoutOverflow) +{ + const StdioChildResult child = runStdioChild( + "v1 1 set-switch SA -2147483648\n" + "v1 2 set-switch SA 2147483647\n" + "v1 3 stop\n", + true); + + ASSERT_TRUE(WIFEXITED(child.status)); + EXPECT_EQ(WEXITSTATUS(child.status), 0); + EXPECT_NE(child.output.find("\"id\":1,\"ok\":false"), std::string::npos); + EXPECT_NE(child.output.find("\"id\":2,\"ok\":false"), std::string::npos); + EXPECT_EQ(countSubstring(child.output, "\"code\":\"out_of_range\""), 2u); + EXPECT_NE(child.output.find("\"id\":3,\"ok\":true"), std::string::npos); +} +#endif diff --git a/radio/src/tests/simu_automation_runtime.cpp b/radio/src/tests/simu_automation_runtime.cpp new file mode 100644 index 00000000000..231492ade7c --- /dev/null +++ b/radio/src/tests/simu_automation_runtime.cpp @@ -0,0 +1,181 @@ +/* + * Copyright (C) EdgeTX + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#include +#include + +#include "automation_runtime.h" +#include "gtests.h" + +using namespace edgetx::automation; + +TEST(SimuAutomationFirmwareMailbox, PreservesRequestFifoAndCapacity) +{ + AutomationFirmwareMailbox mailbox; + FirmwareRequest request; + request.operation = FirmwareOperation::Telemetry; + + for (std::size_t index = 0; index < FIRMWARE_MAILBOX_CAPACITY; ++index) { + request.id = index + 1; + EXPECT_TRUE(mailbox.enqueueRequest(request)); + } + EXPECT_FALSE(mailbox.enqueueRequest(request)); + EXPECT_EQ(mailbox.requestDepth(), FIRMWARE_MAILBOX_CAPACITY); + EXPECT_FALSE(mailbox.idle()); + + for (std::size_t index = 0; index < FIRMWARE_MAILBOX_CAPACITY; ++index) { + FirmwareRequest observed; + ASSERT_TRUE(mailbox.dequeueRequest(&observed)); + EXPECT_EQ(observed.id, index + 1); + EXPECT_EQ(observed.operation, FirmwareOperation::Telemetry); + } + EXPECT_FALSE(mailbox.dequeueRequest(&request)); + EXPECT_TRUE(mailbox.idle()); +} + +TEST(SimuAutomationFirmwareMailbox, PreservesCompletionFifoAndReset) +{ + AutomationFirmwareMailbox mailbox; + FirmwareCompletion completion; + completion.operation = FirmwareOperation::ReloadLua; + + for (std::size_t index = 0; index < FIRMWARE_MAILBOX_CAPACITY; ++index) { + completion.id = index + 10; + completion.generation = index + 1; + EXPECT_TRUE(mailbox.enqueueCompletion(completion)); + } + EXPECT_FALSE(mailbox.enqueueCompletion(completion)); + EXPECT_EQ(mailbox.completionDepth(), FIRMWARE_MAILBOX_CAPACITY); + + FirmwareCompletion observed; + ASSERT_TRUE(mailbox.dequeueCompletion(&observed)); + EXPECT_EQ(observed.id, 10u); + EXPECT_EQ(observed.generation, 1u); + EXPECT_EQ(mailbox.completionDepth(), FIRMWARE_MAILBOX_CAPACITY - 1); + + mailbox.reset(); + EXPECT_TRUE(mailbox.idle()); + EXPECT_FALSE(mailbox.dequeueCompletion(&observed)); +} + +TEST(SimuAutomationAnalogOverrides, ReplacesClearsAndFallsBack) +{ + AutomationAnalogOverrides overrides; + std::uint16_t value = 99; + + EXPECT_FALSE(overrides.get(0, &value)); + EXPECT_EQ(value, 99u); + EXPECT_TRUE(overrides.set(0, 0)); + EXPECT_TRUE(overrides.get(0, &value)); + EXPECT_EQ(value, 0u); + EXPECT_EQ(overrides.count(), 1u); + + EXPECT_TRUE(overrides.set(0, 4096)); + EXPECT_TRUE(overrides.get(0, &value)); + EXPECT_EQ(value, 4096u); + EXPECT_EQ(overrides.count(), 1u); + + EXPECT_TRUE(overrides.set(MAX_ANALOG_INPUTS - 1, 2048)); + EXPECT_EQ(overrides.count(), 2u); + EXPECT_FALSE(overrides.set(MAX_ANALOG_INPUTS, 1)); + EXPECT_FALSE(overrides.set(0, 4097)); + EXPECT_FALSE(overrides.get(MAX_ANALOG_INPUTS, &value)); + EXPECT_FALSE(overrides.get(0, nullptr)); + + EXPECT_TRUE(overrides.clear(0)); + EXPECT_FALSE(overrides.get(0, &value)); + EXPECT_EQ(overrides.count(), 1u); + EXPECT_FALSE(overrides.clear(MAX_ANALOG_INPUTS)); + overrides.clearAll(); + EXPECT_EQ(overrides.count(), 0u); +} + +TEST(SimuAutomationRuntime, TelemetryIgnoresInteractiveDiscoveryGate) +{ + MODEL_RESET(); + TELEMETRY_RESET(); + allowNewSensors = false; + simuAutomationRuntimeStart(); + + FirmwareRequest request; + request.operation = FirmwareOperation::Telemetry; + request.id = 42; + request.epoch = 3; + request.telemetryId = 0xf101; + request.telemetryValue = std::numeric_limits::min(); + request.telemetryUnit = UNIT_DBM; + std::memcpy(request.telemetryName, "RSSI", TELEM_LABEL_LEN); + + ASSERT_TRUE(simuAutomationPostFirmwareRequest(request)); + simuAutomationBeforeUi(); + simuAutomationAfterUi(); + + FirmwareCompletion completion; + ASSERT_TRUE(simuAutomationTakeFirmwareCompletion(&completion)); + ASSERT_TRUE(completion.ok()); + ASSERT_GE(completion.telemetryIndex, 0); + EXPECT_FALSE(allowNewSensors); + EXPECT_EQ(g_model.telemetrySensors[completion.telemetryIndex].id, 0xf101); + EXPECT_EQ(telemetryItems[completion.telemetryIndex].value, + std::numeric_limits::min()); + + request.id += 1; + request.telemetryValue = std::numeric_limits::max(); + ASSERT_TRUE(simuAutomationPostFirmwareRequest(request)); + simuAutomationBeforeUi(); + simuAutomationAfterUi(); + ASSERT_TRUE(simuAutomationTakeFirmwareCompletion(&completion)); + EXPECT_TRUE(completion.ok()); + EXPECT_FALSE(allowNewSensors); + EXPECT_EQ(telemetryItems[completion.telemetryIndex].value, + std::numeric_limits::max()); + + simuAutomationRuntimeStop(); +} + +TEST(SimuAutomationRuntime, TelemetryUsesExactTupleWhenSensorIdsAreIgnored) +{ + MODEL_RESET(); + TELEMETRY_RESET(); + allowNewSensors = false; + g_model.ignoreSensorIds = true; + + TelemetrySensor& original = g_model.telemetrySensors[0]; + original.id = 0xf101; + original.subId = 0; + original.instance = 0; + original.init("OLD", UNIT_DBM, 0); + telemetryItems[0].setValue(original, -10, UNIT_DBM, 0); + + simuAutomationRuntimeStart(); + + FirmwareRequest request; + request.operation = FirmwareOperation::Telemetry; + request.id = 43; + request.epoch = 4; + request.telemetryId = 0xf101; + request.telemetrySubId = 0; + request.telemetryInstance = 1; + request.telemetryValue = -73; + request.telemetryUnit = UNIT_DBM; + std::memcpy(request.telemetryName, "RSSI", TELEM_LABEL_LEN); + + ASSERT_TRUE(simuAutomationPostFirmwareRequest(request)); + simuAutomationBeforeUi(); + simuAutomationAfterUi(); + + FirmwareCompletion completion; + ASSERT_TRUE(simuAutomationTakeFirmwareCompletion(&completion)); + ASSERT_TRUE(completion.ok()); + ASSERT_GT(completion.telemetryIndex, 0); + EXPECT_EQ(telemetryItems[0].value, -10); + EXPECT_EQ(g_model.telemetrySensors[completion.telemetryIndex].instance, 1); + EXPECT_EQ(telemetryItems[completion.telemetryIndex].value, -73); + EXPECT_TRUE(g_model.ignoreSensorIds); + EXPECT_FALSE(allowNewSensors); + + simuAutomationRuntimeStop(); +} diff --git a/tools/ui-harness/README.md b/tools/ui-harness/README.md new file mode 100644 index 00000000000..7096acb64c5 --- /dev/null +++ b/tools/ui-harness/README.md @@ -0,0 +1,203 @@ +# EdgeTX simulator UI harness + +This directory contains the standard-library-only Python host side of the +native simulator automation protocol. Use it to turn a manual simulator +reproduction into a repeatable flow with inspectable artifacts. It supports +strict declarative flows, copied fixtures, target-filtered input and state +injection, real-LCD frame barriers, render-complete framebuffer capture, Lua +reload, restart, `release-all`, and clean process shutdown. `describe` +advertises only commands and capabilities that are usable in the current target +build. + +## One-command TX16S smoke + +From a compiler environment that can already build the native EdgeTX simulator, +the following command configures, incrementally builds, runs the checked-in +scenario, verifies its artifacts, and returns nonzero on any failure: + +```text +python tools/ui-harness/edgetx-ui smoke --build-dir build/ui-harness/tx16s +``` + +On Windows, run it from an x64 Visual Studio developer shell with `SDL2_DIR` +set to an SDL2 CMake package. On Linux, install the normal EdgeTX native build +dependencies or run it in the official `ghcr.io/edgetx/edgetx-dev` image. The +initial target is TX16S (`PCB=X10`, `PCBREV=TX16S`, 480x272 RGB565); other +targets are rejected before launch until they have an explicit schema profile +and fixture. + +To use an already-built simulator: + +```text +python tools/ui-harness/edgetx-ui smoke build/native/radio/src/targets/simu/simu +``` + +Use `run-flow` for another schema-v1 JSON scenario: + +```text +python tools/ui-harness/edgetx-ui run-flow path/to/flow.json path/to/simu +``` + +Use `tools/ui-harness/flows/tx16s-smoke.json` as the schema-v1 example. + +## TX16S hardening gate + +Lifecycle, transport, and visual stress are available as one reproducible +command. +Options precede the simulator path; arguments after `--` are passed through to +the simulator: + +```text +python tools/ui-harness/edgetx-ui harden \ + --runs build/ui-harness/hardening \ + --report build/ui-harness/hardening/windows.json \ + build/native/radio/src/targets/simu/simu +``` + +The defaults are 100 fresh process start/stop cycles, 10,000 +correlated pings, 20 Lua reloads, 20 warm restarts, and 20 static captures. +Use `--lifecycle-cycles`, `--ping-count`, `--lua-reloads`, +`--warm-restarts`, and `--captures` to tune a local or gate run. To keep reports +and artifact sets bounded, lifecycle cycles, Lua reloads, warm restarts, and +captures are capped at 1,000 each; pings are capped at 1,000,000. + +`--report` is published with exclusive-create semantics and fails if the target +already exists. Pass `--force` only when intentional replacement of that report +has been explicitly chosen; a publication failure still leaves diagnostic +evidence inside the unique run directory. + +Every process receives a unique writable copy of the TX16S fixture. The stable +JSON report records process reaping, reader/writer-thread shutdown, Lua generations, +restart epochs and display sequences, stale-completion counters, canonical PPM +and decoded-RGB SHA-256 values, fixture integrity, streaming protocol-evidence +path/count/SHA-256 metadata, and leftover temporary +artifacts. Any incomplete count, changing static capture, unchanged deliberate +visual mutation, stale completion, modified fixture, temporary artifact, or +unreaped child makes the command exit nonzero while preserving its evidence. + +Every flow is completely validated before process launch. Unknown fields, +duplicate JSON keys, unsupported actions or targets, out-of-range values, more +than 1000 steps, unsafe artifact names, and missing capabilities are failures. +Command and startup timeouts are positive and capped at 60 seconds. + +## Fixtures and output + +`tools/ui-harness/fixtures/tx16s` is an immutable template derived from the +fixture contributed by Mateusz Urban (`onliner10`) in EdgeTX PR #7337 and +migrated to the current settings schema. Each execution creates a unique tree: + +```text +build/ui-harness/runs/tx16s-smoke-/ + settings/ # writable fixture copy + sdcard/ # writable fixture copy + artifacts/checkpoints/ + home.ppm + home.png + home.capture.json + manifest.json + protocol.jsonl + stderr.log +``` + +The manifest records the EdgeTX commit, host platform, Python version, target, +LCD, fixture and flow hashes, all steps, protocol evidence metadata, +termination state, artifact SHA-256 values, and the exact failed step. All +observed request/response records are streamed to `protocol.jsonl` and +represented in the manifest by its relative path, record count, and SHA-256 +value. Simulator +stderr is kept separately and bounded by the session client. PPM, PNG, +metadata, protocol, and manifest output is UTF-8 or canonical binary data and +is never written into the fixture template. + +The unique run root is trusted, session-owned state. Do not modify or replace +its directories, symlinks, or Windows reparse points while a run is active. +Containment rejects unsafe protocol paths but is not an OS sandbox against a +concurrent process running as the same user. + +Failed runs are intentionally preserved for diagnosis. Successful and failed +run directories can be removed after their evidence is no longer needed; the +harness never reuses or overwrites them. It always attempts protocol `stop`, +then terminate and kill-and-wait if necessary, and releases owned inputs on +composite-action failures. + +## Troubleshooting + +- `unsupported target`, LCD, command, or capability means the simulator build + does not match the flow; rebuild the TX16S native simulator. +- `output root is not ready` means the artifacts directory was not accepted. +- A request timeout poisons that session by design. Inspect `manifest.json`, + `protocol.jsonl`, and `stderr.log`; increase a schema timeout only when the + operation legitimately needs it. +- Windows uses binary subprocess pipes and separate reader threads; no POSIX + `select` behavior is assumed. Ensure `SDL2.dll` is on `PATH` beside the + simulator or in the developer shell. +- Checked-in fixture YAML is forced to CRLF because the current TX16S settings + checksum was produced by the simulator's Windows writer; Git preserves that + representation on Linux and Windows. + +## Session API + +Run a lifecycle probe with Python 3: + +```text +python tools/ui-harness/edgetx-ui probe \ + --output build/ui-harness/manual-run \ + build/native/radio/src/targets/simu/simu \ + -- --storage --settings +``` + +The client launches with binary pipes, starts independent stdout and stderr +readers, sends `ping`, validates the bounded `describe` result, and polls +`status` until the simulator reports a real first LCD frame. Target identity, +LCD dimensions, command availability, and optional required capabilities are +checked before startup succeeds. Every response is correlated by request ID, +and every shutdown path waits for the child after graceful stop, terminate, or +kill. There is no pipe `select`, shell interpolation, or fixed startup sleep, so +the same lifecycle works on POSIX and Windows. + +Primitive calls are immediate; durations are host-side composites: + +```python +with SimulatorSession(simulator, output_root) as session: + session.press("ENTER", duration=0.05) + session.tap(20, 20, duration=0.05) + session.drag(((20, 20), (100, 80), (200, 120)), duration=0.2) + frame = session.wait_next_frame() +``` + +Key names, LCD bounds, command availability, and rotary/touch capabilities are +validated locally from `describe` before a primitive consumes a request ID. +Every composite attempts its matching release, with `release-all` as the +failure fallback. + +RGB565 targets advertise the `capture` capability. The native command waits +for a strictly newer firmware framebuffer and publishes a canonical PPM +without replacing an existing file. The client can validate that PPM directly +or convert it to a dependency-free, independently decoded PNG with SHA-256 +metadata: + +```python +checkpoint_dir = output_root / "checkpoints" +checkpoint_dir.mkdir(parents=True, exist_ok=True) + +with SimulatorSession(simulator, output_root) as session: + native = session.capture_ppm("checkpoints/home screen.ppm") + bundle = session.capture_png("checkpoints/model menu.png") + print(native.display_sequence, bundle.png.sha256) +``` + +Artifact paths are canonical forward-slash paths below the configured output +root. Their parent directories must already exist, and `.ppm`, `.png`, and +`.capture.json` outputs are never silently overwritten. + +`wait_next_frame()` waits for a future firmware LCD notification; it does not +manufacture one on a static screen. `capture_ppm()` does request one safe LVGL +invalidation so static checkpoints can be captured. To synchronize an input, +save `session.read_status().display_sequence` before the input and then call +`session.wait_frame(saved + 1)`. + +Run the focused tests from the repository root: + +```text +python -m unittest discover -s tools/ui-harness/tests -v +``` diff --git a/tools/ui-harness/edgetx-ui b/tools/ui-harness/edgetx-ui new file mode 100755 index 00000000000..35b7b0acc07 --- /dev/null +++ b/tools/ui-harness/edgetx-ui @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 + +from edgetx_ui.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ui-harness/edgetx_ui/__init__.py b/tools/ui-harness/edgetx_ui/__init__.py new file mode 100644 index 00000000000..38e47683270 --- /dev/null +++ b/tools/ui-harness/edgetx_ui/__init__.py @@ -0,0 +1,51 @@ +"""Dependency-free host client for EdgeTX simulator UI automation.""" + +from .flow import ( + FlowDefinition, + FlowError, + FlowExecutionError, + FlowRunResult, + FlowRunner, + FlowValidationError, + load_flow, +) +from .protocol import ( + CaptureArtifact, + Event, + FrameBarrier, + LuaReload, + ProtocolViolation, + Response, +) +from .session import ( + CaptureBundle, + CommandFailed, + ProcessExited, + ProtocolFailure, + RequestTimeout, + SessionError, + SimulatorSession, +) + +__all__ = [ + "CaptureArtifact", + "CaptureBundle", + "CommandFailed", + "Event", + "FlowDefinition", + "FlowError", + "FlowExecutionError", + "FlowRunResult", + "FlowRunner", + "FlowValidationError", + "FrameBarrier", + "LuaReload", + "ProcessExited", + "ProtocolFailure", + "ProtocolViolation", + "RequestTimeout", + "Response", + "SessionError", + "SimulatorSession", + "load_flow", +] diff --git a/tools/ui-harness/edgetx_ui/cli.py b/tools/ui-harness/edgetx_ui/cli.py new file mode 100644 index 00000000000..2a2f4d61326 --- /dev/null +++ b/tools/ui-harness/edgetx_ui/cli.py @@ -0,0 +1,301 @@ +"""Minimal command-line entry point for the simulator session foundation.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Optional, Sequence + +from .flow import FlowExecutionError, FlowRunner, FlowValidationError, load_flow +from .hardening import HardeningExecutionError, HardeningRunner +from .session import SessionError, SimulatorSession + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="edgetx-ui") + subcommands = parser.add_subparsers(dest="command", required=True) + + probe = subcommands.add_parser( + "probe", help="launch a simulator and verify first-frame readiness" + ) + probe.add_argument("--output", required=True, type=Path) + probe.add_argument("--timeout", type=float, default=5.0) + probe.add_argument("simulator") + probe.add_argument( + "simulator_args", + nargs=argparse.REMAINDER, + help="arguments passed to the simulator after an optional -- separator", + ) + + harden = subcommands.add_parser( + "harden", help="run reproducible TX16S lifecycle and visual hardening" + ) + harden.add_argument( + "--fixture", + type=Path, + default=REPOSITORY_ROOT / "tools" / "ui-harness" / "fixtures" / "tx16s", + ) + harden.add_argument( + "--runs", + type=Path, + default=REPOSITORY_ROOT / "build" / "ui-harness" / "hardening", + ) + harden.add_argument("--report", type=Path) + harden.add_argument( + "--force", + action="store_true", + help="replace an existing --report path instead of failing", + ) + harden.add_argument("--timeout", type=float, default=10.0) + harden.add_argument("--lifecycle-cycles", type=int, default=100) + harden.add_argument("--ping-count", type=int, default=10_000) + harden.add_argument("--lua-reloads", type=int, default=20) + harden.add_argument("--warm-restarts", type=int, default=20) + harden.add_argument("--captures", type=int, default=20) + harden.add_argument("simulator") + harden.add_argument( + "simulator_args", + nargs=argparse.REMAINDER, + help="arguments passed to the simulator after an optional -- separator", + ) + + for name, help_text in ( + ("run-flow", "validate and run a strict JSON UI automation flow"), + ("smoke", "run the checked-in TX16S smoke flow"), + ): + command = subcommands.add_parser(name, help=help_text) + if name == "run-flow": + command.add_argument("flow", type=Path) + command.add_argument("--fixture", type=Path) + command.add_argument( + "--runs", + type=Path, + default=REPOSITORY_ROOT / "build" / "ui-harness" / "runs", + ) + command.add_argument("--timeout", type=float, default=10.0) + if name == "smoke": + command.add_argument( + "--build-dir", + type=Path, + help="configure and incrementally build the TX16S simulator before running", + ) + command.add_argument("simulator", nargs="?") + else: + command.add_argument("simulator") + command.add_argument( + "simulator_args", + nargs=argparse.REMAINDER, + help="arguments passed to the simulator after an optional -- separator", + ) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_parser().parse_args(argv) + simulator_args = list(args.simulator_args) + if simulator_args[:1] == ["--"]: + simulator_args = simulator_args[1:] + + if args.command in ("run-flow", "smoke"): + flow_path = ( + args.flow + if args.command == "run-flow" + else REPOSITORY_ROOT / "tools" / "ui-harness" / "flows" / "tx16s-smoke.json" + ) + try: + flow = load_flow(flow_path) + simulator = args.simulator + if args.command == "smoke" and args.build_dir is not None: + if simulator is not None: + raise FlowValidationError( + "smoke accepts either --build-dir or a simulator path, not both" + ) + simulator = _build_tx16s_simulator(args.build_dir) + if simulator is None: + raise FlowValidationError( + "smoke requires --build-dir or an existing simulator path" + ) + fixture = args.fixture or ( + REPOSITORY_ROOT / "tools" / "ui-harness" / "fixtures" / flow.target + ) + result = FlowRunner( + flow, + fixture, + args.runs, + simulator, + simulator_args=simulator_args, + command_timeout=args.timeout, + ).run() + print( + json.dumps( + { + "ok": True, + "run": str(result.run_directory), + "manifest": str(result.manifest), + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + except FlowExecutionError as error: + print( + json.dumps( + { + "ok": False, + "error": str(error), + "run": str(error.result.run_directory), + "manifest": str(error.result.manifest), + }, + indent=2, + sort_keys=True, + ), + file=sys.stderr, + ) + return 2 + except (FlowValidationError, OSError, ValueError) as error: + print(str(error), file=sys.stderr) + return 2 + + if args.command == "harden": + try: + result = HardeningRunner( + args.fixture, + args.runs, + args.simulator, + report_path=args.report, + force_report=args.force, + simulator_args=simulator_args, + command_timeout=args.timeout, + lifecycle_cycles=args.lifecycle_cycles, + ping_count=args.ping_count, + lua_reloads=args.lua_reloads, + warm_restarts=args.warm_restarts, + capture_count=args.captures, + ).run() + print( + json.dumps( + { + "ok": True, + "run": str(result.run_directory), + "report": str(result.report), + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + except HardeningExecutionError as error: + print( + json.dumps( + { + "ok": False, + "error": str(error), + "run": str(error.result.run_directory), + "report": str(error.result.report), + }, + indent=2, + sort_keys=True, + ), + file=sys.stderr, + ) + return 2 + except (OSError, RuntimeError, ValueError) as error: + print(str(error), file=sys.stderr) + return 2 + + if args.command != "probe": + raise AssertionError("unhandled command") + + args.output.mkdir(parents=True, exist_ok=True) + + session = SimulatorSession( + args.simulator, + args.output, + simulator_args=simulator_args, + request_timeout=args.timeout, + stop_timeout=args.timeout, + terminate_timeout=args.timeout, + kill_timeout=args.timeout, + ) + try: + ready = session.start(timeout=args.timeout) + stop = session.stop(timeout=args.timeout) + assert session.startup_ping is not None + assert session.description_response is not None + payload = { + "ok": True, + "ping": session.startup_ping.raw, + "describe": session.description_response.raw, + "ready": ready.raw, + "stop": stop.raw if stop is not None else None, + "returncode": session.returncode, + "termination": session.termination_stage, + } + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + except (SessionError, ValueError) as error: + print(str(error), file=sys.stderr) + return 2 + finally: + session.close() + + +def _build_tx16s_simulator(build_dir: Path) -> str: + build = build_dir.resolve() + configure = [ + "cmake", + "-S", + str(REPOSITORY_ROOT), + "-B", + str(build), + "-G", + os.environ.get("CMAKE_GENERATOR", "Ninja"), + "-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/native.cmake", + "-DEdgeTX_SUPERBUILD=OFF", + "-DNATIVE_BUILD=ON", + "-DDISABLE_COMPANION=ON", + "-DPCB=X10", + "-DPCBREV=TX16S", + "-DDEFAULT_MODE=2", + ] + _run_build_command(configure, "configure TX16S simulator") + _run_build_command( + ["cmake", "--build", str(build), "--target", "simu", "--parallel"], + "build TX16S simulator", + ) + candidates = sorted( + path + for name in ("simu", "simu.exe") + for path in build.rglob(name) + if path.is_file() + ) + if not candidates: + raise FlowValidationError("built simulator executable was not found under " + str(build)) + return str(candidates[0]) + + +def _run_build_command(command: Sequence[str], label: str) -> None: + result = subprocess.run( + list(command), + cwd=REPOSITORY_ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if result.returncode != 0: + raise FlowValidationError( + label + " failed:\n" + result.stdout[-8000:] + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ui-harness/edgetx_ui/flow.py b/tools/ui-harness/edgetx_ui/flow.py new file mode 100644 index 00000000000..c6f1b837e37 --- /dev/null +++ b/tools/ui-harness/edgetx_ui/flow.py @@ -0,0 +1,825 @@ +"""Strict declarative scenarios for the EdgeTX simulator UI harness.""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import tempfile +import time +from dataclasses import asdict, dataclass, is_dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from .ppm import ArtifactDigest, digest_file +from .protocol import CAPABILITY_NAMES, Response +from .session import CaptureBundle, SimulatorSession + + +MAX_FLOW_STEPS = 1000 +MAX_TIMEOUT_MS = 60_000 +ARTIFACT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + + +@dataclass(frozen=True) +class TargetFlowSpec: + keys: frozenset[str] + switches: Mapping[str, Tuple[int, int]] + analogs: Mapping[str, Tuple[int, int]] + lcd: Tuple[int, int, int] + + +TX16S_SPEC = TargetFlowSpec( + keys=frozenset(("EXIT", "ENTER", "PAGEUP", "PAGEDN", "MODEL", "TELE", "SYS")), + switches={name: (-1, 1) for name in ("SA", "SB", "SC", "SD", "SE", "SF", "SG", "SH")}, + analogs={ + name: (0, 4096) + for name in ( + "Rud", "Ele", "Thr", "Ail", "P1", "P2", "P3", "SL1", "SL2", + "EXT1", "EXT2", "EXT3", "EXT4", + ) + }, + lcd=(480, 272, 16), +) +TARGET_SPECS = {"tx16s": TX16S_SPEC} + + +@dataclass(frozen=True) +class FlowDefinition: + source: Path + schema: int + target: str + requires: Tuple[str, ...] + steps: Tuple[Mapping[str, Any], ...] + sha256: str + + +@dataclass(frozen=True) +class FlowRunResult: + run_directory: Path + manifest: Path + success: bool + + +class FlowError(RuntimeError): + """Base class for flow validation and execution failures.""" + + +class FlowValidationError(FlowError): + pass + + +class FlowExecutionError(FlowError): + def __init__(self, message: str, result: FlowRunResult) -> None: + super().__init__(message) + self.result = result + + +def load_flow(path: Path) -> FlowDefinition: + """Load one strict JSON flow and validate every step before launch.""" + + source = path.resolve(strict=True) + try: + encoded = source.read_bytes() + payload = json.loads( + encoded.decode("utf-8"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error: + raise FlowValidationError("cannot load strict flow JSON: " + str(error)) from error + if not isinstance(payload, dict): + raise FlowValidationError("flow root must be an object") + _exact_keys(payload, {"schema", "target", "requires", "steps"}, "flow") + if payload["schema"] != 1 or isinstance(payload["schema"], bool): + raise FlowValidationError("flow schema must be integer 1") + target = payload["target"] + if not isinstance(target, str) or target not in TARGET_SPECS: + raise FlowValidationError("unsupported flow target: " + str(target)) + requires = _required_capabilities(payload["requires"]) + raw_steps = payload["steps"] + if not isinstance(raw_steps, list) or not 1 <= len(raw_steps) <= MAX_FLOW_STEPS: + raise FlowValidationError("flow steps must contain 1..1000 entries") + steps = tuple( + _validate_step(step, index, TARGET_SPECS[target]) + for index, step in enumerate(raw_steps) + ) + if steps[0]["action"] != "wait-ready": + raise FlowValidationError("step 0 must be the single wait-ready action") + if sum(step["action"] == "wait-ready" for step in steps) != 1: + raise FlowValidationError("wait-ready must appear exactly once") + action_capabilities = { + "rotate": "rotary", + "tap": "touch", + "drag": "touch", + "set-switch": "switches", + "set-analog": "analog", + "clear-analog": "analog", + "set-telemetry": "telemetry", + "reload-lua": "lua", + "restart": "warm_restart", + "capture": "capture", + } + undeclared = sorted( + { + capability + for step in steps + for capability in (action_capabilities.get(step["action"]),) + if capability is not None and capability not in requires + } + ) + if undeclared: + raise FlowValidationError( + "flow actions use undeclared capabilities: " + ", ".join(undeclared) + ) + return FlowDefinition( + source=source, + schema=1, + target=target, + requires=requires, + steps=steps, + sha256=hashlib.sha256(encoded).hexdigest(), + ) + + +class FlowRunner: + """Copy an immutable fixture and execute one validated scenario.""" + + def __init__( + self, + flow: FlowDefinition, + fixture_root: Path, + runs_root: Path, + executable: str, + *, + simulator_args: Sequence[str] = (), + command_timeout: float = 10.0, + session_factory: Callable[..., SimulatorSession] = SimulatorSession, + ) -> None: + self.flow = flow + self.fixture_root = fixture_root.resolve(strict=True) + self.runs_root = runs_root + self.executable = executable + self.simulator_args = tuple(simulator_args) + self.command_timeout = command_timeout + self.session_factory = session_factory + self._validate_fixture() + + def run(self) -> FlowRunResult: + source_hash = _tree_hash(self.fixture_root) + self.runs_root.mkdir(parents=True, exist_ok=True) + runs = self.runs_root.resolve(strict=True) + if _is_below(runs, self.fixture_root): + raise FlowValidationError("runs root must not be inside the fixture") + run_directory = Path( + tempfile.mkdtemp(prefix=self.flow.source.stem + "-", dir=str(runs)) + ).resolve(strict=True) + try: + settings = run_directory / "settings" + sdcard = run_directory / "sdcard" + artifacts = run_directory / "artifacts" + checkpoints = artifacts / "checkpoints" + shutil.copytree(self.fixture_root / "settings", settings, symlinks=False) + shutil.copytree(self.fixture_root / "sdcard", sdcard, symlinks=False) + checkpoints.mkdir(parents=True) + + args = _replace_simulator_path(self.simulator_args, "--settings", settings) + args = _replace_simulator_path(args, "--storage", sdcard) + first_protocol_sink = run_directory / "protocol-generation-001.jsonl" + session = self.session_factory( + self.executable, + artifacts, + simulator_args=args, + cwd=run_directory, + request_timeout=self.command_timeout, + stop_timeout=self.command_timeout, + terminate_timeout=self.command_timeout, + kill_timeout=self.command_timeout, + protocol_sink=first_protocol_sink, + required_capabilities=self.flow.requires, + expected_target=self.flow.target, + expected_lcd=TARGET_SPECS[self.flow.target].lcd, + ) + except BaseException as preparation_error: + try: + shutil.rmtree(run_directory) + except BaseException as cleanup_error: + raise FlowError( + "flow preparation failed and its incomplete run could not be removed: " + + str(cleanup_error) + ) from preparation_error + raise + started_at = _utc_now() + started_monotonic = time.monotonic() + step_records: List[Dict[str, Any]] = [] + protocol_generations: List[Dict[str, Any]] = [] + retired_stderr: List[str] = [] + failure: Optional[Dict[str, Any]] = None + caught: Optional[BaseException] = None + try: + startup_timeout = self.flow.steps[0]["timeout_ms"] / 1000.0 + session.start(timeout=startup_timeout) + for index, step in enumerate(self.flow.steps): + step_started = time.monotonic() + record: Dict[str, Any] = { + "index": index, + "action": step["action"], + "status": "running", + } + step_records.append(record) + try: + if step["action"] == "restart-process": + previous = session + session = previous.restart_process( + self.fixture_root, + run_directory / "restarts", + timeout=step["timeout_ms"] / 1000.0, + ) + protocol_generations.append( + _protocol_generation(previous, run_directory) + ) + if previous.recent_stderr: + retired_stderr.append(previous.recent_stderr) + result = { + "run_directory": _redact_path( + session.fixture_run_directory, + ((run_directory, ""),), + ) + } + else: + result = self._execute_step(session, step) + record["result"] = _json_value(result) + record["status"] = "passed" + except BaseException as error: + record["status"] = "failed" + record["error"] = str(error) + failure = { + "step": index, + "action": step["action"], + "error_type": type(error).__name__, + "message": str(error), + } + raise + finally: + record["elapsed_ms"] = round( + (time.monotonic() - step_started) * 1000.0, 3 + ) + except BaseException as error: + caught = error + finally: + try: + session.stop(timeout=self.command_timeout) + except BaseException as stop_error: + if caught is None: + caught = stop_error + failure = { + "step": None, + "action": "clean-stop", + "error_type": type(stop_error).__name__, + "message": str(stop_error), + } + else: + session.close() + + protocol_generations.append(_protocol_generation(session, run_directory)) + protocol_path = run_directory / "protocol.jsonl" + protocol_evidence = _consolidate_protocol( + protocol_generations, protocol_path, run_directory + ) + stderr_path = run_directory / "stderr.log" + current_stderr = getattr(session, "recent_stderr", "") + stderr_text = "\n".join( + item for item in (*retired_stderr, current_stderr) if item + ) + stderr_path.write_text( + stderr_text + ("\n" if stderr_text else ""), encoding="utf-8", newline="\n" + ) + + final_source_hash = _tree_hash(self.fixture_root) + if final_source_hash != source_hash and caught is None: + caught = FlowError("immutable fixture changed during the run") + failure = { + "step": None, + "action": "fixture-integrity", + "error_type": "FlowError", + "message": str(caught), + } + + artifact_digests = _artifact_digests(run_directory, artifacts) + description = getattr(session, "description", None) + manifest_payload: Dict[str, Any] = { + "schema_version": 1, + "success": caught is None, + "flow": { + "path": "/" + self.flow.source.name, + "sha256": self.flow.sha256, + "target": self.flow.target, + "requires": list(self.flow.requires), + }, + "fixture": { + "path": "", + "sha256": source_hash, + "unchanged": final_source_hash == source_hash, + }, + "environment": { + "edgetx_commit": _git_commit(self.flow.source.parent), + "platform": platform.platform(), + "python": platform.python_version(), + }, + "simulator": { + "command": _redact_command( + getattr(session, "command", (self.executable, *args)), + run_directory, + self.fixture_root, + ), + "returncode": getattr(session, "returncode", None), + "termination": getattr(session, "termination_stage", "unknown"), + "target": getattr(description, "target", self.flow.target), + "lcd": _json_value(getattr(description, "lcd", None)), + }, + "started_at": started_at, + "ended_at": _utc_now(), + "elapsed_ms": round((time.monotonic() - started_monotonic) * 1000.0, 3), + "steps": step_records, + "protocol": protocol_evidence, + "artifacts": artifact_digests, + "failure": failure, + } + manifest_payload = _redact_strings( + manifest_payload, + ((run_directory, ""), (self.fixture_root, "")), + ) + manifest_path = run_directory / "manifest.json" + manifest_path.write_text( + json.dumps(manifest_payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + newline="\n", + ) + result = FlowRunResult(run_directory, manifest_path, caught is None) + if caught is not None: + raise FlowExecutionError(str(caught), result) from caught + return result + + def _execute_step( + self, session: SimulatorSession, step: Mapping[str, Any] + ) -> Any: + action = step["action"] + if action == "wait-ready": + response = session.status_response + return response.raw if response is not None else {"ready": True} + if action == "press": + return session.press( + step["key"], duration=step["hold_ms"] / 1000.0, + timeout=self.command_timeout, + ) + if action == "long-press": + return session.long_press( + step["key"], duration=step["hold_ms"] / 1000.0, + timeout=self.command_timeout, + ) + if action == "rotate": + return session.rotate(step["steps"], timeout=self.command_timeout) + if action == "tap": + return session.tap( + step["x"], step["y"], duration=step["hold_ms"] / 1000.0, + timeout=self.command_timeout, + ) + if action == "drag": + points = tuple((point["x"], point["y"]) for point in step["points"]) + duration = step["step_ms"] * (len(points) - 1) / 1000.0 + return session.drag(points, duration=duration, timeout=self.command_timeout) + if action == "wait-ms": + time.sleep(step["ms"] / 1000.0) + return {"waited_ms": step["ms"]} + if action == "wait-next-frame": + return session.wait_next_frame(timeout=step["timeout_ms"] / 1000.0) + if action == "set-switch": + return session.set_switch(step["name"], step["position"], timeout=self.command_timeout) + if action == "set-analog": + return session.set_analog(step["name"], step["value"], timeout=self.command_timeout) + if action == "clear-analog": + return session.clear_analog(step["name"], timeout=self.command_timeout) + if action == "set-telemetry": + return session.set_telemetry( + step["id"], step["sub_id"], step["instance"], step["value"], + step["unit"], step["precision"], step.get("name"), + timeout=self.command_timeout, + ) + if action == "reload-lua": + return session.reload_lua(timeout=step["timeout_ms"] / 1000.0) + if action == "restart": + return session.restart(timeout=step["timeout_ms"] / 1000.0) + if action == "capture": + return session.capture_png( + "checkpoints/" + step["name"] + ".png", + timeout=step["timeout_ms"] / 1000.0, + ) + if action == "release-all": + return session.release_all(timeout=self.command_timeout) + raise AssertionError("validated flow action has no executor: " + str(action)) + + def _validate_fixture(self) -> None: + if not self.fixture_root.is_dir(): + raise FlowValidationError("fixture root must be a directory") + for child in (self.fixture_root / "settings", self.fixture_root / "sdcard"): + if not child.is_dir(): + raise FlowValidationError("fixture is missing directory: " + child.name) + for current, directories, files in os.walk(self.fixture_root, followlinks=False): + for name in (*directories, *files): + if (Path(current) / name).is_symlink(): + raise FlowValidationError("fixture must not contain symlinks") + + +def _validate_step( + value: object, index: int, target: TargetFlowSpec +) -> Mapping[str, Any]: + if not isinstance(value, dict): + raise FlowValidationError("step " + str(index) + " must be an object") + action = value.get("action") + if not isinstance(action, str): + raise FlowValidationError("step " + str(index) + " action must be a string") + label = "step " + str(index) + " (" + action + ")" + schemas = { + "wait-ready": {"action", "timeout_ms"}, + "press": {"action", "key", "hold_ms"}, + "long-press": {"action", "key", "hold_ms"}, + "rotate": {"action", "steps"}, + "tap": {"action", "x", "y", "hold_ms"}, + "drag": {"action", "points", "step_ms"}, + "wait-ms": {"action", "ms"}, + "wait-next-frame": {"action", "timeout_ms"}, + "set-switch": {"action", "name", "position"}, + "set-analog": {"action", "name", "value"}, + "clear-analog": {"action", "name"}, + "set-telemetry": {"action", "id", "sub_id", "instance", "value", "unit", "precision"}, + "reload-lua": {"action", "timeout_ms"}, + "restart": {"action", "timeout_ms"}, + "restart-process": {"action", "timeout_ms"}, + "capture": {"action", "name", "format", "timeout_ms"}, + "release-all": {"action"}, + } + optional = {"set-telemetry": {"name"}} + if action not in schemas: + raise FlowValidationError(label + " is unknown") + _exact_keys(value, schemas[action], label, optional.get(action, set())) + result = dict(value) + if action in ( + "wait-ready", "wait-next-frame", "reload-lua", "restart", + "restart-process", "capture", + ): + _integer(value["timeout_ms"], 1, MAX_TIMEOUT_MS, label + " timeout_ms") + if action in ("press", "long-press"): + if value["key"] not in target.keys: + raise FlowValidationError(label + " key is unsupported") + _integer(value["hold_ms"], 0, MAX_TIMEOUT_MS, label + " hold_ms") + elif action == "rotate": + steps = _integer(value["steps"], -128, 128, label + " steps") + if steps == 0: + raise FlowValidationError(label + " steps cannot be zero") + elif action == "tap": + _point(value["x"], value["y"], target, label) + _integer(value["hold_ms"], 0, MAX_TIMEOUT_MS, label + " hold_ms") + elif action == "drag": + points = value["points"] + if not isinstance(points, list) or not 2 <= len(points) <= 256: + raise FlowValidationError(label + " points must contain 2..256 entries") + for point_index, point in enumerate(points): + if not isinstance(point, dict): + raise FlowValidationError(label + " point must be an object") + _exact_keys(point, {"x", "y"}, label + " point " + str(point_index)) + _point(point["x"], point["y"], target, label) + _integer(value["step_ms"], 0, MAX_TIMEOUT_MS, label + " step_ms") + elif action == "wait-ms": + _integer(value["ms"], 0, MAX_TIMEOUT_MS, label + " ms") + elif action == "set-switch": + switch_range = target.switches.get(value["name"]) + if switch_range is None: + raise FlowValidationError(label + " switch is unsupported") + _integer(value["position"], *switch_range, label + " position") + elif action == "set-analog": + analog_range = target.analogs.get(value["name"]) + if analog_range is None: + raise FlowValidationError(label + " analog is unsupported") + _integer(value["value"], *analog_range, label + " value") + elif action == "clear-analog": + if value["name"] != "all" and value["name"] not in target.analogs: + raise FlowValidationError(label + " analog is unsupported") + elif action == "set-telemetry": + _integer(value["id"], 1, 65535, label + " id") + _integer(value["sub_id"], 0, 7, label + " sub_id") + _integer(value["instance"], 0, 255, label + " instance") + _integer(value["value"], -(1 << 31), (1 << 31) - 1, label + " value") + _integer(value["unit"], 0, 29, label + " unit") + _integer(value["precision"], 0, 2, label + " precision") + name = value.get("name") + if name is not None and ( + not isinstance(name, str) or re.fullmatch(r"[A-Za-z0-9_-]{1,4}", name) is None + ): + raise FlowValidationError(label + " telemetry name is invalid") + elif action == "capture": + if value["format"] != "png": + raise FlowValidationError(label + " format must be png") + if not isinstance(value["name"], str) or ARTIFACT_NAME.fullmatch(value["name"]) is None: + raise FlowValidationError(label + " artifact name is invalid") + return result + + +def _unique_object(pairs: Iterable[Tuple[str, Any]]) -> Dict[str, Any]: + result: Dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key: " + key) + result[key] = value + return result + + +def _reject_constant(value: str) -> None: + raise ValueError("non-standard JSON constant: " + value) + + +def _exact_keys( + value: Mapping[str, Any], required: set[str], label: str, optional: set[str] = set() +) -> None: + actual = set(value) + missing = required - actual + extra = actual - required - optional + if missing or extra: + details = [] + if missing: + details.append("missing " + ", ".join(sorted(missing))) + if extra: + details.append("unknown " + ", ".join(sorted(extra))) + raise FlowValidationError(label + " has invalid fields: " + "; ".join(details)) + + +def _required_capabilities(value: object) -> Tuple[str, ...]: + if not isinstance(value, list): + raise FlowValidationError("flow requires must be an array") + result = tuple(value) + if any(not isinstance(item, str) or item not in CAPABILITY_NAMES for item in result): + raise FlowValidationError("flow contains an unknown required capability") + if len(set(result)) != len(result): + raise FlowValidationError("flow required capabilities cannot repeat") + return result + + +def _integer(value: object, minimum: int, maximum: int, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum: + raise FlowValidationError( + label + " must be an integer in " + str(minimum) + ".." + str(maximum) + ) + return value + + +def _point(x: object, y: object, target: TargetFlowSpec, label: str) -> None: + _integer(x, 0, target.lcd[0] - 1, label + " x") + _integer(y, 0, target.lcd[1] - 1, label + " y") + + +def _replace_simulator_path( + arguments: Sequence[str], option: str, path: Path +) -> Tuple[str, ...]: + result = list(arguments) + positions = [index for index, value in enumerate(result) if value == option] + if len(positions) > 1: + raise FlowValidationError("simulator arguments repeat " + option) + if positions: + position = positions[0] + if position + 1 >= len(result) or result[position + 1].startswith("--"): + raise FlowValidationError("simulator argument has no value: " + option) + result[position + 1] = str(path) + else: + result.extend((option, str(path))) + return tuple(result) + + +def _tree_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()): + relative = path.relative_to(root).as_posix().encode("utf-8") + if path.is_symlink(): + raise FlowValidationError("fixture must not contain symlinks") + if path.is_dir(): + digest.update(b"D\0" + relative + b"\0") + elif path.is_file(): + digest.update(b"F\0" + relative + b"\0") + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(64 * 1024), b""): + digest.update(chunk) + else: + raise FlowValidationError("fixture contains an unsupported entry") + return digest.hexdigest() + + +def _protocol_generation(session: Any, run_directory: Path) -> Dict[str, Any]: + sink_value = getattr(session, "protocol_sink_path", None) + if sink_value is None: + raise FlowError("session did not expose its streaming protocol evidence") + sink = Path(sink_value).resolve(strict=True) + try: + relative = sink.relative_to(run_directory).as_posix() + except ValueError as error: + raise FlowError("protocol evidence escaped the flow run directory") from error + item = digest_file(sink) + expected_hash = getattr(session, "protocol_sha256", None) + expected_count = getattr(session, "protocol_record_count", None) + if expected_hash != item.sha256 or not isinstance(expected_count, int): + raise FlowError("session protocol evidence metadata is inconsistent") + return { + "_source": sink, + "path": relative, + "records": expected_count, + "sha256": expected_hash, + "diagnostic_records_dropped": int( + getattr(session, "protocol_records_dropped", 0) + ), + } + + +def _consolidate_protocol( + generations: Sequence[Mapping[str, Any]], destination: Path, run_directory: Path +) -> Dict[str, Any]: + digest = hashlib.sha256() + byte_count = 0 + record_count = 0 + public_generations: List[Dict[str, Any]] = [] + with destination.open("xb") as output: + for index, generation in enumerate(generations, start=1): + source = Path(generation["_source"]) + generation_digest = hashlib.sha256() + generation_records = 0 + with source.open("rb") as stream: + for chunk in iter(lambda: stream.read(64 * 1024), b""): + output.write(chunk) + digest.update(chunk) + generation_digest.update(chunk) + byte_count += len(chunk) + generation_records += chunk.count(b"\n") + if generation_records != generation["records"]: + raise FlowError("protocol evidence record count is inconsistent") + if generation_digest.hexdigest() != generation["sha256"]: + raise FlowError("protocol evidence digest is inconsistent") + record_count += generation_records + public_generations.append( + { + "generation": index, + "path": generation["path"], + "records": generation_records, + "sha256": generation["sha256"], + "diagnostic_records_dropped": generation[ + "diagnostic_records_dropped" + ], + } + ) + output.flush() + os.fsync(output.fileno()) + return { + "path": destination.relative_to(run_directory).as_posix(), + "bytes": byte_count, + "records": record_count, + "sha256": digest.hexdigest(), + "generations": public_generations, + } + + +def _artifact_digests(run_directory: Path, artifacts: Path) -> List[Dict[str, Any]]: + paths = [run_directory / "protocol.jsonl", run_directory / "stderr.log"] + paths.extend( + path + for path in run_directory.iterdir() + if path.is_file() + and path.name not in ("manifest.json", "protocol.jsonl", "stderr.log") + ) + paths.extend(path for path in artifacts.rglob("*") if path.is_file()) + restart_root = run_directory / "restarts" + if restart_root.is_dir(): + paths.extend( + path + for path in restart_root.rglob("*") + if path.is_file() and "artifacts" in path.relative_to(restart_root).parts + ) + result = [] + for path in sorted(paths, key=lambda item: item.relative_to(run_directory).as_posix()): + item = digest_file(path) + result.append( + { + "path": path.relative_to(run_directory).as_posix(), + "bytes": item.byte_count, + "sha256": item.sha256, + } + ) + return result + + +def _json_value(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, Response): + return value.raw + if isinstance(value, ArtifactDigest): + return {"path": value.path.as_posix(), "bytes": value.byte_count, "sha256": value.sha256} + if isinstance(value, CaptureBundle): + return { + "capture": _json_value(value.capture), + "ppm": _json_value(value.ppm), + "png": _json_value(value.png), + "manifest": _json_value(value.manifest), + } + if is_dataclass(value): + return _json_value(asdict(value)) + if isinstance(value, Mapping): + return {str(key): _json_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_value(item) for item in value] + return str(value) + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _redact_command( + command: Sequence[object], run_directory: Path, fixture_root: Path +) -> List[str]: + normalized: List[str] = [] + for index, value in enumerate(command): + text = str(value) + if index == 0: + path = Path(text) + normalized.append( + path.name + if path.is_absolute() or path.parent != Path(".") + else text + ) + continue + normalized.append( + _redact_path( + text, + ((run_directory, ""), (fixture_root, "")), + redact_other_absolute=True, + ) + ) + return normalized + + +def _redact_path( + value: object, + roots: Sequence[Tuple[Path, str]], + *, + redact_other_absolute: bool = False, +) -> str: + path = Path(value) + try: + candidate = path.resolve(strict=False) + except OSError: + return str(value) + for root, label in roots: + try: + relative = candidate.relative_to(root) + except ValueError: + continue + suffix = relative.as_posix() + return label + ("/" + suffix if suffix != "." else "") + if redact_other_absolute and path.is_absolute(): + return "/" + candidate.name + return str(value).replace("\\", "/") + + +def _redact_strings(value: Any, roots: Sequence[Tuple[Path, str]]) -> Any: + if isinstance(value, str): + result = value + for root, label in roots: + result = result.replace(str(root), label) + result = result.replace(root.as_posix(), label) + return result + if isinstance(value, Mapping): + return {key: _redact_strings(item, roots) for key, item in value.items()} + if isinstance(value, list): + return [_redact_strings(item, roots) for item in value] + return value + + +def _git_commit(start: Path) -> str: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=start, text=True, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, + ) + return result.stdout.strip() if result.returncode == 0 else "unknown" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def _is_below(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True diff --git a/tools/ui-harness/edgetx_ui/hardening.py b/tools/ui-harness/edgetx_ui/hardening.py new file mode 100644 index 00000000000..5c170c01aa9 --- /dev/null +++ b/tools/ui-harness/edgetx_ui/hardening.py @@ -0,0 +1,737 @@ +"""Reproducible lifecycle and visual hardening for a real TX16S simulator.""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple + +from .ppm import digest_file, read_ppm +from .session import SimulatorSession + + +DEFAULT_LIFECYCLE_CYCLES = 100 +DEFAULT_PING_COUNT = 10_000 +DEFAULT_LUA_RELOADS = 20 +DEFAULT_WARM_RESTARTS = 20 +DEFAULT_CAPTURE_COUNT = 20 +MAX_LIFECYCLE_CYCLES = 1_000 +MAX_PING_COUNT = 1_000_000 +MAX_LUA_RELOADS = 1_000 +MAX_WARM_RESTARTS = 1_000 +MAX_CAPTURE_COUNT = 1_000 +TX16S_LCD = (480, 272, 16) +REQUIRED_CAPABILITIES = ("rotary", "touch", "lua", "capture", "warm_restart") + + +@dataclass(frozen=True) +class HardeningResult: + run_directory: Path + report: Path + success: bool + + +class HardeningError(RuntimeError): + """The hardening run could not be prepared.""" + + +class HardeningExecutionError(HardeningError): + """A hardening gate failed after its evidence directory was created.""" + + def __init__(self, message: str, result: HardeningResult) -> None: + super().__init__(message) + self.result = result + + +class HardeningRunner: + """Run bounded lifecycle and capture stress through the public host API.""" + + def __init__( + self, + fixture_root: Path, + runs_root: Path, + executable: str, + *, + report_path: Optional[Path] = None, + force_report: bool = False, + simulator_args: Sequence[str] = (), + command_timeout: float = 10.0, + lifecycle_cycles: int = DEFAULT_LIFECYCLE_CYCLES, + ping_count: int = DEFAULT_PING_COUNT, + lua_reloads: int = DEFAULT_LUA_RELOADS, + warm_restarts: int = DEFAULT_WARM_RESTARTS, + capture_count: int = DEFAULT_CAPTURE_COUNT, + expected_target: str = "tx16s", + expected_lcd: Tuple[int, int, int] = TX16S_LCD, + session_factory: Any = SimulatorSession, + ) -> None: + self.fixture_root = fixture_root.resolve(strict=True) + self.runs_root = runs_root + self.executable = _executable_path(executable) + self.report_path = report_path + self.force_report = bool(force_report) + self.simulator_args = tuple(str(value) for value in simulator_args) + self.command_timeout = _positive_timeout(command_timeout) + self.lifecycle_cycles = _count( + lifecycle_cycles, "lifecycle cycles", MAX_LIFECYCLE_CYCLES + ) + self.ping_count = _count(ping_count, "ping count", MAX_PING_COUNT) + self.lua_reloads = _count(lua_reloads, "Lua reload count", MAX_LUA_RELOADS) + self.warm_restarts = _count( + warm_restarts, "warm restart count", MAX_WARM_RESTARTS + ) + self.capture_count = _count( + capture_count, "capture count", MAX_CAPTURE_COUNT + ) + if not expected_target: + raise ValueError("expected target must not be empty") + self.expected_target = expected_target + self.expected_lcd = expected_lcd + self.session_factory = session_factory + _validate_fixture(self.fixture_root) + + def run(self) -> HardeningResult: + source_hash = _tree_hash(self.fixture_root) + self.runs_root.mkdir(parents=True, exist_ok=True) + runs = self.runs_root.resolve(strict=True) + if _is_below(runs, self.fixture_root): + raise HardeningError("runs root must not be inside the fixture") + if self.report_path is not None: + requested_report = self.report_path.resolve() + if _is_below(requested_report, self.fixture_root): + raise HardeningError("hardening report must not be inside the fixture") + if requested_report.exists() and not requested_report.is_file(): + raise HardeningError("hardening report path is not a file") + if requested_report.exists() and not self.force_report: + raise HardeningError( + "hardening report already exists: " + str(requested_report) + ) + run_directory = Path( + tempfile.mkdtemp(prefix="tx16s-hardening-", dir=str(runs)) + ).resolve(strict=True) + report_path = ( + self.report_path.resolve() + if self.report_path is not None + else run_directory / "hardening-report.json" + ) + + report: Dict[str, Any] = { + "schema_version": 1, + "success": False, + "configuration": { + "lifecycle_cycles": self.lifecycle_cycles, + "ping_count": self.ping_count, + "lua_reloads": self.lua_reloads, + "warm_restarts": self.warm_restarts, + "captures": self.capture_count, + "target": self.expected_target, + "lcd": { + "width": self.expected_lcd[0], + "height": self.expected_lcd[1], + "depth": self.expected_lcd[2], + }, + }, + "environment": { + "edgetx_commit": _git_commit(self.fixture_root), + "platform": platform.platform(), + "python": platform.python_version(), + }, + "fixture": { + "path": "", + "sha256": source_hash, + "unchanged": False, + }, + "run_directory": "", + "simulator": { + "command": _redact_command( + (self.executable, *self.simulator_args), + run_directory, + self.fixture_root, + ) + }, + "lifecycle": {"completed": 0, "cycles": []}, + "stress": {}, + "cleanup": {"temporary_paths": [], "no_temporaries": False}, + "reap": {"all_reaped": False}, + "failure": None, + } + + failure: Optional[BaseException] = None + stage = "lifecycle" + + def set_stage(value: str) -> None: + nonlocal stage + stage = value + + try: + self._run_lifecycle(run_directory, report["lifecycle"]) + self._run_public_api_stress( + run_directory, report["stress"], set_stage=set_stage + ) + except BaseException as error: + failure = error + report["failure"] = { + "stage": stage, + "error_type": type(error).__name__, + "message": str(error), + } + + final_hash = source_hash + temporary_paths: List[Path] = [] + try: + final_hash = _tree_hash(self.fixture_root) + report["fixture"]["unchanged"] = final_hash == source_hash + temporary_paths = _temporary_paths(run_directory) + report["cleanup"] = { + "temporary_paths": [ + path.relative_to(run_directory).as_posix() + for path in temporary_paths + ], + "no_temporaries": not temporary_paths, + } + except BaseException as error: + if failure is None: + failure = error + report["failure"] = { + "stage": "cleanup", + "error_type": type(error).__name__, + "message": str(error), + } + report["cleanup"] = { + "temporary_paths": [], + "no_temporaries": False, + "collection_error": { + "error_type": type(error).__name__, + "message": str(error), + }, + } + cycles = report["lifecycle"]["cycles"] + stress_reap = report.get("stress", {}).get("reap", {}) + all_reaped = all( + item.get("returncode") is not None + and not item.get("reader_threads_alive") + and not item.get("writer_thread_alive") + for item in cycles + ) and ( + not stress_reap + or ( + stress_reap.get("returncode") is not None + and not stress_reap.get("reader_threads_alive") + and not stress_reap.get("writer_thread_alive") + ) + ) + report["reap"]["all_reaped"] = all_reaped + + if failure is None and final_hash != source_hash: + failure = HardeningError("immutable fixture changed during hardening") + if failure is None and temporary_paths: + failure = HardeningError("temporary artifacts remain after hardening") + if failure is None and not all_reaped: + failure = HardeningError("a simulator process or reader thread was not reaped") + if failure is not None and report["failure"] is None: + report["failure"] = { + "stage": "final-gates", + "error_type": type(failure).__name__, + "message": str(failure), + } + + report["success"] = failure is None + report = _redact_strings( + report, + ((run_directory, ""), (self.fixture_root, "")), + ) + try: + _write_report(report_path, report, force=self.force_report) + except BaseException as error: + prior_failure = report.get("failure") + report["success"] = False + report["failure"] = { + "stage": "report", + "error_type": type(error).__name__, + "message": str(error), + } + if prior_failure is not None: + report["failure"]["prior_failure"] = prior_failure + report = _redact_strings( + report, + ((run_directory, ""), (self.fixture_root, "")), + ) + fallback_path = run_directory / "hardening-report-publication-failure.json" + try: + _write_report(fallback_path, report, force=False) + except BaseException as fallback_error: + raise HardeningError( + "hardening report publication failed and fallback evidence " + "could not be written: " + str(fallback_error) + ) from error + result = HardeningResult(run_directory, fallback_path, False) + raise HardeningExecutionError( + "hardening report publication failed: " + str(error), result + ) from error + result = HardeningResult(run_directory, report_path, failure is None) + if failure is not None: + raise HardeningExecutionError(str(failure), result) from failure + return result + + def _run_lifecycle( + self, run_directory: Path, lifecycle: Dict[str, Any] + ) -> None: + root = run_directory / "lifecycle" + root.mkdir() + for index in range(self.lifecycle_cycles): + cycle_root, artifacts, arguments = self._copy_run( + root, "cycle-" + str(index + 1).zfill(3) + "-" + ) + session = self._session( + cycle_root, artifacts, arguments, required_capabilities=() + ) + try: + session.start(timeout=self.command_timeout) + session.stop(timeout=self.command_timeout) + except BaseException: + session.close() + raise + finally: + process = session.process + lifecycle["cycles"].append( + { + "index": index + 1, + "run": cycle_root.relative_to(run_directory).as_posix(), + "pid": process.pid if process is not None else None, + "returncode": session.returncode, + "termination": session.termination_stage, + "reader_threads_alive": session.reader_threads_alive, + "writer_thread_alive": getattr( + session, "writer_thread_alive", False + ), + "protocol": _session_protocol_metadata( + session, run_directory + ), + } + ) + lifecycle["completed"] = index + 1 + + def _run_public_api_stress( + self, + run_directory: Path, + payload: Dict[str, Any], + *, + set_stage: Callable[[str], None], + ) -> None: + root = run_directory / "stress" + root.mkdir() + session_root, artifacts, arguments = self._copy_run(root, "session-") + checkpoints = artifacts / "checkpoints" + checkpoints.mkdir() + session = self._session( + session_root, + artifacts, + arguments, + required_capabilities=REQUIRED_CAPABILITIES, + ) + payload.update({ + "run": session_root.relative_to(run_directory).as_posix(), + "ping": {"requested": self.ping_count, "completed": 0}, + "lua": {"requested": self.lua_reloads, "completed": 0, "generations": []}, + "warm_restart": { + "requested": self.warm_restarts, + "completed": 0, + "epochs": [], + "display_sequences": [], + "stale_completion_counts": [], + }, + "visual": { + "requested": self.capture_count, + "completed": 0, + "static": [], + "identical": True, + "changed": None, + "changed_differs": False, + }, + "reap": {}, + }) + first_ping_id: Optional[int] = None + last_ping_id: Optional[int] = None + try: + session.start(timeout=self.command_timeout) + set_stage("ping") + for index in range(self.ping_count): + response = session.ping(timeout=self.command_timeout) + if first_ping_id is None: + first_ping_id = response.id + last_ping_id = response.id + payload["ping"]["completed"] = index + 1 + payload["ping"]["first_id"] = first_ping_id + payload["ping"]["last_id"] = last_ping_id + + set_stage("lua") + for index in range(self.lua_reloads): + result = session.reload_lua(timeout=self.command_timeout) + expected = index + 1 + if result.generation != expected or result.state != "running": + raise HardeningError("Lua reload generation/state is not deterministic") + payload["lua"]["generations"].append(result.generation) + payload["lua"]["completed"] = expected + + set_stage("restart") + status = session.read_status(timeout=self.command_timeout) + previous_epoch = status.epoch + previous_sequence = status.display_sequence + for index in range(self.warm_restarts): + restarted = session.restart(timeout=self.command_timeout) + status = session.read_status(timeout=self.command_timeout) + if restarted.epoch != previous_epoch + 1: + raise HardeningError("warm restart did not advance exactly one epoch") + if restarted.display_sequence <= previous_sequence: + raise HardeningError("warm restart display sequence did not advance") + if status.stale_completion_count != 0: + raise HardeningError("stale completion observed after warm restart") + previous_epoch = restarted.epoch + previous_sequence = restarted.display_sequence + payload["warm_restart"]["epochs"].append(restarted.epoch) + payload["warm_restart"]["display_sequences"].append( + restarted.display_sequence + ) + payload["warm_restart"]["stale_completion_counts"].append( + status.stale_completion_count + ) + payload["warm_restart"]["completed"] = index + 1 + + set_stage("capture") + self._prepare_visual_state(session) + baseline: Optional[str] = None + for index in range(self.capture_count): + relative = "checkpoints/static-" + str(index + 1).zfill(2) + ".ppm" + artifact = session.capture_ppm(relative, timeout=self.command_timeout) + path = artifacts / Path(relative) + image = read_ppm(path) + digest = digest_file(path) + rgb_sha256 = hashlib.sha256(image.rgb).hexdigest() + if baseline is None: + baseline = rgb_sha256 + elif rgb_sha256 != baseline: + payload["visual"]["identical"] = False + payload["visual"]["static"].append( + { + "path": path.relative_to(run_directory).as_posix(), + "bytes": digest.byte_count, + "sha256": digest.sha256, + "rgb_sha256": rgb_sha256, + "display_sequence": artifact.display_sequence, + } + ) + payload["visual"]["completed"] = index + 1 + if not payload["visual"]["identical"]: + raise HardeningError("static TX16S captures are not identical") + + if self.capture_count: + session.rotate(1, timeout=self.command_timeout) + changed = session.capture_ppm( + "checkpoints/changed.ppm", timeout=self.command_timeout + ) + changed_path = artifacts / "checkpoints" / "changed.ppm" + changed_image = read_ppm(changed_path) + changed_digest = digest_file(changed_path) + changed_rgb = hashlib.sha256(changed_image.rgb).hexdigest() + payload["visual"]["changed"] = { + "path": changed_path.relative_to(run_directory).as_posix(), + "bytes": changed_digest.byte_count, + "sha256": changed_digest.sha256, + "rgb_sha256": changed_rgb, + "display_sequence": changed.display_sequence, + } + payload["visual"]["changed_differs"] = changed_rgb != baseline + if not payload["visual"]["changed_differs"]: + raise HardeningError("deliberate visible-state capture did not change") + + final_status = session.read_status(timeout=self.command_timeout) + if final_status.stale_completion_count != 0: + raise HardeningError("stale completion observed at end of stress run") + payload["final_status"] = { + "epoch": final_status.epoch, + "display_sequence": final_status.display_sequence, + "stale_completion_count": final_status.stale_completion_count, + } + set_stage("cleanup") + session.stop(timeout=self.command_timeout) + except BaseException: + session.close() + raise + finally: + process = session.process + payload["reap"] = { + "pid": process.pid if process is not None else None, + "returncode": session.returncode, + "termination": session.termination_stage, + "reader_threads_alive": session.reader_threads_alive, + "writer_thread_alive": getattr( + session, "writer_thread_alive", False + ), + } + payload["protocol"] = _session_protocol_metadata(session, run_directory) + + @staticmethod + def _prepare_visual_state(session: SimulatorSession) -> None: + """Enter the full-screen model manager, avoiding the home-screen RTC.""" + + session.press("ENTER", duration=0.12) + session.tap(40, 70, duration=0.08) + + def _copy_run( + self, parent: Path, prefix: str + ) -> Tuple[Path, Path, Tuple[str, ...]]: + run = Path(tempfile.mkdtemp(prefix=prefix, dir=str(parent))).resolve(strict=True) + settings = run / "settings" + storage = run / "sdcard" + artifacts = run / "artifacts" + try: + shutil.copytree(self.fixture_root / "settings", settings, symlinks=False) + shutil.copytree(self.fixture_root / "sdcard", storage, symlinks=False) + artifacts.mkdir() + except BaseException: + shutil.rmtree(run, ignore_errors=True) + raise + arguments = _replace_option(self.simulator_args, "--settings", str(settings)) + arguments = _replace_option(arguments, "--storage", str(storage)) + return run, artifacts, arguments + + def _session( + self, + run: Path, + artifacts: Path, + arguments: Sequence[str], + *, + required_capabilities: Sequence[str], + ) -> SimulatorSession: + return self.session_factory( + self.executable, + artifacts, + simulator_args=arguments, + cwd=run, + request_timeout=self.command_timeout, + stop_timeout=self.command_timeout, + terminate_timeout=self.command_timeout, + kill_timeout=self.command_timeout, + reader_join_timeout=self.command_timeout, + protocol_sink=run / "protocol.jsonl", + required_capabilities=required_capabilities, + expected_target=self.expected_target, + expected_lcd=self.expected_lcd, + ) + + +def _replace_option(arguments: Sequence[str], option: str, value: str) -> Tuple[str, ...]: + result = list(arguments) + positions = [index for index, item in enumerate(result) if item == option] + if len(positions) > 1: + raise ValueError("simulator arguments repeat " + option) + if positions: + index = positions[0] + if index + 1 >= len(result) or result[index + 1].startswith("--"): + raise ValueError("simulator argument has no value: " + option) + result[index + 1] = value + else: + result.extend((option, value)) + return tuple(result) + + +def _executable_path(value: str) -> str: + path = Path(value) + if path.is_absolute(): + return str(path) + if path.parent != Path("."): + return str(path.resolve(strict=True)) + discovered = shutil.which(value) + return discovered if discovered is not None else value + + +def _validate_fixture(root: Path) -> None: + for child in (root / "settings", root / "sdcard"): + if not child.is_dir(): + raise ValueError("fixture is missing directory: " + child.name) + for path in root.rglob("*"): + if path.is_symlink(): + raise ValueError("fixture must not contain symlinks") + + +def _tree_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted( + root.rglob("*"), key=lambda item: item.relative_to(root).as_posix() + ): + relative = path.relative_to(root).as_posix().encode("utf-8") + if path.is_symlink(): + raise ValueError("fixture must not contain symlinks") + if path.is_dir(): + digest.update(b"D\0" + relative + b"\0") + elif path.is_file(): + digest.update(b"F\0" + relative + b"\0") + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(64 * 1024), b""): + digest.update(chunk) + else: + raise ValueError("fixture contains an unsupported entry") + return digest.hexdigest() + + +def _session_protocol_metadata( + session: Any, run_directory: Path +) -> Dict[str, Any]: + sink_value = getattr(session, "protocol_sink_path", None) + if sink_value is None: + return {"available": False} + sink = Path(sink_value).resolve(strict=True) + try: + relative = sink.relative_to(run_directory).as_posix() + except ValueError as error: + raise HardeningError( + "protocol evidence escaped the hardening run directory" + ) from error + item = digest_file(sink) + expected_hash = getattr(session, "protocol_sha256", None) + expected_count = getattr(session, "protocol_record_count", None) + if expected_hash != item.sha256 or not isinstance(expected_count, int): + raise HardeningError("session protocol evidence metadata is inconsistent") + return { + "available": True, + "path": relative, + "bytes": item.byte_count, + "records": expected_count, + "sha256": expected_hash, + "diagnostic_records_dropped": int( + getattr(session, "protocol_records_dropped", 0) + ), + } + + +def _temporary_paths(root: Path) -> List[Path]: + return sorted( + ( + path + for path in root.rglob("*") + if path.is_file() + and (".tmp-v1-" in path.name or path.name.endswith(".tmp-ui-harness")) + ), + key=lambda path: path.relative_to(root).as_posix(), + ) + + +def _write_report( + path: Path, payload: Mapping[str, Any], *, force: bool = False +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + encoded = ( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + temporary = path.with_name("." + path.name + ".tmp-ui-harness") + try: + with temporary.open("wb") as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + if force: + os.replace(temporary, path) + else: + try: + os.link(temporary, path) + except FileExistsError as error: + raise HardeningError( + "hardening report already exists: " + str(path) + ) from error + temporary.unlink() + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def _git_commit(start: Path) -> str: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=start, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + return result.stdout.strip() if result.returncode == 0 else "unknown" + + +def _redact_command( + command: Sequence[object], run_directory: Path, fixture_root: Path +) -> List[str]: + normalized: List[str] = [] + for index, value in enumerate(command): + text = str(value) + path = Path(text) + if index == 0: + normalized.append( + path.name + if path.is_absolute() or path.parent != Path(".") + else text + ) + continue + try: + candidate = path.resolve(strict=False) + except OSError: + normalized.append(text) + continue + replacement: Optional[str] = None + for root, label in ((run_directory, ""), (fixture_root, "")): + try: + relative = candidate.relative_to(root) + except ValueError: + continue + suffix = relative.as_posix() + replacement = label + ("/" + suffix if suffix != "." else "") + break + if replacement is None and path.is_absolute(): + replacement = "/" + candidate.name + normalized.append( + replacement if replacement is not None else text.replace("\\", "/") + ) + return normalized + + +def _redact_strings(value: Any, roots: Sequence[Tuple[Path, str]]) -> Any: + if isinstance(value, str): + result = value + for root, label in roots: + result = result.replace(str(root), label) + result = result.replace(root.as_posix(), label) + return result + if isinstance(value, Mapping): + return {key: _redact_strings(item, roots) for key, item in value.items()} + if isinstance(value, list): + return [_redact_strings(item, roots) for item in value] + return value + + +def _positive_timeout(value: float) -> float: + if not isinstance(value, (int, float)) or value <= 0 or value > 60: + raise ValueError("command timeout must be in (0, 60]") + return float(value) + + +def _count(value: int, label: str, maximum: int) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(label + " must be a non-negative integer") + if value > maximum: + raise ValueError(label + " must not exceed " + str(maximum)) + return value + + +def _is_below(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True diff --git a/tools/ui-harness/edgetx_ui/ppm.py b/tools/ui-harness/edgetx_ui/ppm.py new file mode 100644 index 00000000000..c6849cec62f --- /dev/null +++ b/tools/ui-harness/edgetx_ui/ppm.py @@ -0,0 +1,258 @@ +"""Strict PPM handling and dependency-free deterministic PNG output.""" + +from __future__ import annotations + +import hashlib +import json +import os +import struct +import zlib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Tuple + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +@dataclass(frozen=True) +class RgbImage: + width: int + height: int + rgb: bytes + + +@dataclass(frozen=True) +class ArtifactDigest: + path: Path + byte_count: int + sha256: str + + +def read_ppm(path: Path) -> RgbImage: + """Read the exact one-image P6 subset emitted by the simulator.""" + + data = path.read_bytes() + if not data.startswith(b"P6\n"): + raise ValueError("PPM must start with the canonical P6 header") + third_newline = _nth_index(data, b"\n", 3) + if third_newline < 0: + raise ValueError("PPM header is incomplete") + header = data[: third_newline + 1] + lines = header.split(b"\n") + if len(lines) != 4 or lines[0] != b"P6" or lines[2] != b"255" or lines[3]: + raise ValueError("PPM header is not the canonical max-value-255 form") + dimensions = lines[1].split(b" ") + if len(dimensions) != 2 or not all( + value.isdigit() and str(int(value, 10)).encode("ascii") == value + for value in dimensions + ): + raise ValueError("PPM dimensions are invalid") + width, height = (int(value, 10) for value in dimensions) + if width <= 0 or height <= 0 or width > 65535 or height > 65535: + raise ValueError("PPM dimensions are outside the supported range") + rgb = data[len(header) :] + if len(rgb) != width * height * 3: + raise ValueError("PPM raster length does not match its dimensions") + return RgbImage(width=width, height=height, rgb=rgb) + + +def write_png(path: Path, image: RgbImage) -> ArtifactDigest: + """Write a minimal non-interlaced RGB PNG without replacing a file.""" + + _validate_image(image) + scanlines = bytearray() + stride = image.width * 3 + for start in range(0, len(image.rgb), stride): + scanlines.append(0) + scanlines.extend(image.rgb[start : start + stride]) + payload = b"".join( + ( + PNG_SIGNATURE, + _png_chunk( + b"IHDR", + struct.pack(">IIBBBBB", image.width, image.height, 8, 2, 0, 0, 0), + ), + _png_chunk(b"IDAT", zlib.compress(bytes(scanlines), level=9)), + _png_chunk(b"IEND", b""), + ) + ) + _write_new_bytes(path, payload) + try: + return digest_file(path) + except BaseException: + _remove_owned_file(path) + raise + + +def read_png(path: Path) -> RgbImage: + """Decode and CRC-check the narrow PNG subset produced by write_png.""" + + data = path.read_bytes() + if not data.startswith(PNG_SIGNATURE): + raise ValueError("PNG signature is invalid") + cursor = len(PNG_SIGNATURE) + chunks = [] + while cursor < len(data): + if len(data) - cursor < 12: + raise ValueError("PNG chunk is truncated") + length = struct.unpack(">I", data[cursor : cursor + 4])[0] + kind = data[cursor + 4 : cursor + 8] + end = cursor + 12 + length + if end > len(data): + raise ValueError("PNG chunk length exceeds the file") + payload = data[cursor + 8 : cursor + 8 + length] + expected_crc = struct.unpack(">I", data[cursor + 8 + length : end])[0] + if (zlib.crc32(kind + payload) & 0xFFFFFFFF) != expected_crc: + raise ValueError("PNG chunk CRC is invalid") + chunks.append((kind, payload)) + cursor = end + if kind == b"IEND": + break + if cursor != len(data): + raise ValueError("PNG contains trailing data") + if [kind for kind, _ in chunks] != [b"IHDR", b"IDAT", b"IEND"] or chunks[-1][1]: + raise ValueError("PNG critical chunk order is invalid") + + header = chunks[0][1] + if len(header) != 13: + raise ValueError("PNG IHDR length is invalid") + width, height, depth, color, compression, filtering, interlace = struct.unpack( + ">IIBBBBB", header + ) + if ( + width == 0 + or height == 0 + or width > 65535 + or height > 65535 + or (depth, color, compression, filtering, interlace) != (8, 2, 0, 0, 0) + ): + raise ValueError("PNG IHDR is outside the supported RGB subset") + idat = chunks[1][1] + if not idat: + raise ValueError("PNG has no IDAT payload") + try: + decompressor = zlib.decompressobj() + filtered = decompressor.decompress(idat) + decompressor.flush() + except zlib.error as error: + raise ValueError("PNG zlib stream is invalid") from error + if not decompressor.eof or decompressor.unused_data: + raise ValueError("PNG zlib stream has trailing or incomplete data") + stride = width * 3 + if len(filtered) != height * (stride + 1): + raise ValueError("PNG scanline length does not match its dimensions") + rgb = bytearray() + for y in range(height): + start = y * (stride + 1) + if filtered[start] != 0: + raise ValueError("PNG uses an unsupported scanline filter") + rgb.extend(filtered[start + 1 : start + stride + 1]) + return RgbImage(width=width, height=height, rgb=bytes(rgb)) + + +def convert_ppm_to_png( + ppm_path: Path, png_path: Path +) -> Tuple[RgbImage, ArtifactDigest]: + image = read_ppm(ppm_path) + png = write_png(png_path, image) + try: + decoded = read_png(png_path) + if decoded != image: + raise ValueError("PNG decode does not match the PPM raster") + except BaseException: + _remove_owned_file(png_path) + raise + return image, png + + +def digest_file(path: Path) -> ArtifactDigest: + digest = hashlib.sha256() + byte_count = 0 + with path.open("rb") as stream: + while True: + chunk = stream.read(64 * 1024) + if not chunk: + break + byte_count += len(chunk) + digest.update(chunk) + return ArtifactDigest(path=path, byte_count=byte_count, sha256=digest.hexdigest()) + + +def write_json_sidecar(path: Path, payload: Dict[str, Any]) -> ArtifactDigest: + encoded = ( + json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + "\n" + ).encode("utf-8") + _write_new_bytes(path, encoded) + try: + return digest_file(path) + except BaseException: + _remove_owned_file(path) + raise + + +def _validate_image(image: RgbImage) -> None: + if image.width <= 0 or image.height <= 0: + raise ValueError("image dimensions must be positive") + if image.width > 65535 or image.height > 65535: + raise ValueError("image dimensions exceed the supported range") + if len(image.rgb) != image.width * image.height * 3: + raise ValueError("RGB raster length does not match its dimensions") + + +def _png_chunk(kind: bytes, payload: bytes) -> bytes: + if len(kind) != 4: + raise ValueError("PNG chunk names must be four bytes") + return ( + struct.pack(">I", len(payload)) + + kind + + payload + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF) + ) + + +def _write_new_bytes(path: Path, payload: bytes) -> None: + if not path.parent.is_dir(): + raise ValueError("artifact parent directory does not exist") + temporary = path.with_name("." + path.name + ".tmp-ui-harness") + created = False + try: + with temporary.open("xb") as stream: + created = True + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + if os.name == "nt": + os.rename(temporary, path) + created = False + else: + os.link(temporary, path) + try: + temporary.unlink() + except OSError: + try: + path.unlink() + finally: + raise + created = False + finally: + if created: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def _remove_owned_file(path: Path) -> None: + try: + path.unlink() + except FileNotFoundError: + pass + + +def _nth_index(data: bytes, token: bytes, count: int) -> int: + position = -1 + for _ in range(count): + position = data.find(token, position + 1) + if position < 0: + return -1 + return position diff --git a/tools/ui-harness/edgetx_ui/protocol.py b/tools/ui-harness/edgetx_ui/protocol.py new file mode 100644 index 00000000000..94e3997ce1b --- /dev/null +++ b/tools/ui-harness/edgetx_ui/protocol.py @@ -0,0 +1,670 @@ +"""Encoding and strict validation for the EdgeTX simulator protocol.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Any, Dict, Mapping, Optional, Sequence, Tuple, Union + + +PROTOCOL_VERSION = 1 +MAX_RECORD_BYTES = 16 * 1024 +UINT64_MAX = (1 << 64) - 1 + +_COMMAND_PATTERN = re.compile(r"^[a-z][a-z0-9-]*$") +_TARGET_PATTERN = re.compile(r"^[A-Za-z0-9_.+-]+$") +_REMAINDER_ARGUMENT_COMMANDS = frozenset(("capture",)) + +CAPABILITY_NAMES = ( + "rotary", + "touch", + "switches", + "analog", + "telemetry", + "lua", + "capture", + "warm_restart", +) + +_STATUS_PHASES = frozenset(("starting", "ready", "restarting", "stopped")) +_ASYNC_OPERATIONS = frozenset( + ("none", "wait_frame", "capture", "firmware", "reload_lua", "restart") +) +_LUA_STATES = frozenset( + ("unavailable", "not_observed", "idle", "reloading", "running", "panic") +) + + +class ProtocolViolation(ValueError): + """Raised when bytes on the protocol channel violate protocol v1.""" + + +@dataclass(frozen=True) +class Response: + id: int + ok: bool + epoch: int + result: Optional[Dict[str, Any]] + error_code: Optional[str] + error_message: Optional[str] + raw: Dict[str, Any] + + +@dataclass(frozen=True) +class Event: + epoch: int + code: str + message: str + raw: Dict[str, Any] + + +@dataclass(frozen=True) +class LcdDescription: + width: int + height: int + depth: int + + +@dataclass(frozen=True) +class NamedRange: + name: str + minimum: int + maximum: int + + +@dataclass(frozen=True) +class Capabilities: + rotary: bool + touch: bool + switches: bool + analog: bool + telemetry: bool + lua: bool + capture: bool + warm_restart: bool + + def supports(self, name: str) -> bool: + if name not in CAPABILITY_NAMES: + raise ValueError("unknown capability: " + name) + return bool(getattr(self, name)) + + +@dataclass(frozen=True) +class Description: + target: str + lcd: LcdDescription + commands: Tuple[str, ...] + capabilities: Capabilities + keys: Tuple[str, ...] + switches: Tuple[NamedRange, ...] + analogs: Tuple[NamedRange, ...] + + +@dataclass(frozen=True) +class Status: + epoch: int + running: bool + phase: str + target: str + lcd: LcdDescription + display_sequence: int + async_operation: str + request_queue_depth: int + firmware_mailbox_depth: int + line_overflow_count: int + queue_overflow_count: int + stale_completion_count: int + active_key_count: int + touch_active: bool + analog_override_count: int + lua_state: str + capabilities: Capabilities + output_root: str + + +@dataclass(frozen=True) +class FrameBarrier: + epoch: int + display_sequence: int + + +@dataclass(frozen=True) +class LuaReload: + epoch: int + generation: int + state: str + + +@dataclass(frozen=True) +class CaptureArtifact: + epoch: int + display_sequence: int + path: str + width: int + height: int + depth: int + byte_count: int + + +Message = Union[Response, Event] + + +def encode_request( + request_id: int, command: str, arguments: Sequence[str] = () +) -> bytes: + """Serialize one request record without relying on shell quoting.""" + + if not _is_uint64(request_id) or request_id == 0: + raise ValueError("request id must be in 1..UINT64_MAX") + if not isinstance(command, str) or not _COMMAND_PATTERN.fullmatch(command): + raise ValueError("command must be a canonical lowercase ASCII token") + if command in _REMAINDER_ARGUMENT_COMMANDS and len(arguments) > 1: + raise ValueError("capture accepts at most one remainder argument") + + tokens = ["v1", str(request_id), command] + for argument in arguments: + if not isinstance(argument, str) or not argument: + raise ValueError("request arguments must be non-empty strings") + if any(character in argument for character in ("\0", "\r", "\n")): + raise ValueError("request arguments cannot contain NUL, CR, or LF") + if argument != argument.strip(" "): + raise ValueError("request arguments cannot start or end with spaces") + if " " in argument and command not in _REMAINDER_ARGUMENT_COMMANDS: + raise ValueError( + "only remainder arguments may contain internal spaces" + ) + tokens.append(argument) + + encoded = " ".join(tokens).encode("utf-8") + if len(encoded) + 1 > MAX_RECORD_BYTES: + raise ValueError("request record exceeds 16 KiB") + return encoded + b"\n" + + +def parse_message(record: bytes) -> Message: + """Parse one complete JSON record, excluding its newline delimiter.""" + + if not isinstance(record, bytes): + raise TypeError("protocol record must be bytes") + if not record: + raise ProtocolViolation("protocol stdout emitted an empty record") + if len(record) + 1 > MAX_RECORD_BYTES: + raise ProtocolViolation("protocol response exceeds 16 KiB") + if b"\r" in record or b"\n" in record: + raise ProtocolViolation("protocol response contains an embedded newline") + + try: + text = record.decode("utf-8", errors="strict") + except UnicodeDecodeError as error: + raise ProtocolViolation("protocol response is not valid UTF-8") from error + + try: + payload = json.loads( + text, + parse_constant=_reject_json_constant, + object_pairs_hook=_unique_json_object, + ) + except ProtocolViolation: + raise + except (ValueError, json.JSONDecodeError) as error: + raise ProtocolViolation("protocol response is not valid JSON") from error + + if not isinstance(payload, dict): + raise ProtocolViolation("protocol response must be a JSON object") + if payload.get("version") != PROTOCOL_VERSION or not _is_integer( + payload.get("version") + ): + raise ProtocolViolation("protocol response has an unsupported version") + + epoch = payload.get("epoch") + if not _is_uint64(epoch): + raise ProtocolViolation("protocol response has an invalid epoch") + + message_type = payload.get("type") + if message_type == "response": + return _parse_response(payload, epoch) + if message_type == "event": + return _parse_event(payload, epoch) + raise ProtocolViolation("protocol response has an invalid type") + + +def decode_description(response: Response) -> Description: + """Validate and decode the bounded result of a successful describe call.""" + + result = _successful_result(response, "describe") + _require_exact_keys( + result, + { + "protocol_version", + "target", + "lcd", + "commands", + "capabilities", + "keys", + "switches", + "analogs", + }, + "describe result", + ) + _require_protocol_version(result) + return Description( + target=_target(result.get("target"), "describe target"), + lcd=_lcd(result.get("lcd"), "describe lcd"), + commands=_string_tuple( + result.get("commands"), "describe commands", _COMMAND_PATTERN + ), + capabilities=_capabilities(result.get("capabilities")), + keys=_string_tuple(result.get("keys"), "describe keys", _TARGET_PATTERN), + switches=_named_ranges(result.get("switches"), "describe switches"), + analogs=_named_ranges(result.get("analogs"), "describe analogs"), + ) + + +def decode_status(response: Response) -> Status: + """Validate and decode one internally consistent status snapshot.""" + + result = _successful_result(response, "status") + _require_exact_keys( + result, + { + "protocol_version", + "running", + "phase", + "target", + "lcd", + "display_seq", + "async_operation", + "request_queue_depth", + "firmware_mailbox_depth", + "line_overflow_count", + "queue_overflow_count", + "stale_completion_count", + "active_key_count", + "touch_active", + "analog_override_count", + "lua_state", + "capabilities", + "output_root", + }, + "status result", + ) + _require_protocol_version(result) + + running = result.get("running") + touch_active = result.get("touch_active") + if not isinstance(running, bool) or not isinstance(touch_active, bool): + raise ProtocolViolation("status boolean fields are invalid") + + phase = result.get("phase") + if not isinstance(phase, str) or phase not in _STATUS_PHASES: + raise ProtocolViolation("status phase is invalid") + async_operation = result.get("async_operation") + if ( + not isinstance(async_operation, str) + or async_operation not in _ASYNC_OPERATIONS + ): + raise ProtocolViolation("status async operation is invalid") + lua_state = result.get("lua_state") + if not isinstance(lua_state, str) or lua_state not in _LUA_STATES: + raise ProtocolViolation("status Lua state is invalid") + output_root = result.get("output_root") + if not isinstance(output_root, str) or output_root not in ( + "ready", + "invalid", + ): + raise ProtocolViolation("status output-root state is invalid") + + return Status( + epoch=response.epoch, + running=running, + phase=phase, + target=_target(result.get("target"), "status target"), + lcd=_lcd(result.get("lcd"), "status lcd"), + display_sequence=_uint64(result.get("display_seq"), "display sequence"), + async_operation=async_operation, + request_queue_depth=_uint64( + result.get("request_queue_depth"), "request queue depth" + ), + firmware_mailbox_depth=_uint64( + result.get("firmware_mailbox_depth"), "firmware mailbox depth" + ), + line_overflow_count=_uint64( + result.get("line_overflow_count"), "line overflow count" + ), + queue_overflow_count=_uint64( + result.get("queue_overflow_count"), "queue overflow count" + ), + stale_completion_count=_uint64( + result.get("stale_completion_count"), "stale completion count" + ), + active_key_count=_uint64( + result.get("active_key_count"), "active key count" + ), + touch_active=touch_active, + analog_override_count=_uint64( + result.get("analog_override_count"), "analog override count" + ), + lua_state=lua_state, + capabilities=_capabilities(result.get("capabilities")), + output_root=output_root, + ) + + +def decode_frame(response: Response) -> FrameBarrier: + """Validate the terminal result of a wait-frame request.""" + + result = _successful_result(response, "wait-frame") + _require_exact_keys(result, {"display_seq"}, "wait-frame result") + return FrameBarrier( + epoch=response.epoch, + display_sequence=_uint64( + result.get("display_seq"), "wait-frame display sequence" + ), + ) + + +def decode_restart(response: Response) -> FrameBarrier: + """Validate the first-frame result of a successful warm restart.""" + + result = _successful_result(response, "restart") + _require_exact_keys(result, {"display_seq"}, "restart result") + return FrameBarrier( + epoch=response.epoch, + display_sequence=_uint64( + result.get("display_seq"), "restart display sequence" + ), + ) + + +def decode_lua_reload(response: Response) -> LuaReload: + """Validate a generation-correlated successful Lua reload.""" + + result = _successful_result(response, "reload-lua") + _require_exact_keys(result, {"generation", "state"}, "reload-lua result") + state = result.get("state") + if state != "running": + raise ProtocolViolation("reload-lua terminal state is invalid") + generation = _uint64(result.get("generation"), "Lua reload generation") + if generation == 0: + raise ProtocolViolation("Lua reload generation must be nonzero") + return LuaReload(epoch=response.epoch, generation=generation, state=state) + + +def decode_capture(response: Response) -> CaptureArtifact: + """Validate the terminal metadata of a native framebuffer capture.""" + + result = _successful_result(response, "capture") + _require_exact_keys( + result, + {"display_seq", "path", "width", "height", "depth", "bytes"}, + "capture result", + ) + path = result.get("path") + if not isinstance(path, str): + raise ProtocolViolation("capture result path is invalid") + try: + path_bytes = path.encode("utf-8") + except UnicodeEncodeError as error: + raise ProtocolViolation("capture result path is invalid") from error + path_parts = path.split("/") + if ( + not path + or path != path.strip(" ") + or any(character in path for character in ("\0", "\r", "\n", "\\")) + or len(path_bytes) > 1024 + or path.startswith("/") + or ( + len(path) >= 2 + and path[0].isascii() + and path[0].isalpha() + and path[1] == ":" + ) + or any(part in ("", ".", "..") for part in path_parts) + or not path.endswith(".ppm") + ): + raise ProtocolViolation("capture result path is invalid") + + width = _uint64(result.get("width"), "capture width") + height = _uint64(result.get("height"), "capture height") + depth = _uint64(result.get("depth"), "capture depth") + byte_count = _uint64(result.get("bytes"), "capture byte count") + if width == 0 or width > 65535 or height == 0 or height > 65535: + raise ProtocolViolation("capture dimensions are invalid") + if depth != 16: + raise ProtocolViolation("capture depth is not RGB565") + expected_bytes = len(f"P6\n{width} {height}\n255\n".encode("ascii")) + expected_bytes += width * height * 3 + if byte_count != expected_bytes: + raise ProtocolViolation("capture byte count does not match dimensions") + + display_sequence = _uint64( + result.get("display_seq"), "capture display sequence" + ) + if display_sequence == 0: + raise ProtocolViolation("capture display sequence is invalid") + + return CaptureArtifact( + epoch=response.epoch, + display_sequence=display_sequence, + path=path, + width=width, + height=height, + depth=depth, + byte_count=byte_count, + ) + + +def _parse_response(payload: Dict[str, Any], epoch: int) -> Response: + request_id = payload.get("id") + if not _is_uint64(request_id) or request_id == 0: + raise ProtocolViolation("protocol response has an invalid request id") + + ok = payload.get("ok") + if not isinstance(ok, bool): + raise ProtocolViolation("protocol response has an invalid ok field") + + base_keys = {"version", "type", "id", "ok", "epoch"} + allowed = (base_keys, base_keys | {"result"}) if ok else ( + base_keys | {"error"}, + ) + if set(payload) not in allowed: + expected = base_keys | ({"result"} if ok else {"error"}) + _require_exact_keys(payload, expected, "protocol response") + + result = payload.get("result") + if "result" in payload and not isinstance(result, dict): + raise ProtocolViolation("protocol response result must be an object") + + error_code: Optional[str] = None + error_message: Optional[str] = None + error = payload.get("error") + if ok: + if error is not None: + raise ProtocolViolation("successful protocol response contains an error") + else: + if not isinstance(error, dict): + raise ProtocolViolation("failed protocol response has no error object") + _require_exact_keys(error, {"code", "message"}, "protocol error") + error_code = error.get("code") + error_message = error.get("message") + if not isinstance(error_code, str) or not error_code: + raise ProtocolViolation("protocol error has an invalid code") + if not isinstance(error_message, str): + raise ProtocolViolation("protocol error has an invalid message") + + return Response( + id=request_id, + ok=ok, + epoch=epoch, + result=result, + error_code=error_code, + error_message=error_message, + raw=payload, + ) + + +def _parse_event(payload: Dict[str, Any], epoch: int) -> Event: + _require_exact_keys( + payload, {"version", "type", "id", "epoch", "event"}, "protocol event" + ) + if payload.get("id", object()) is not None: + raise ProtocolViolation("protocol event id must be null") + event = payload.get("event") + if not isinstance(event, dict): + raise ProtocolViolation("protocol event has no event object") + _require_exact_keys(event, {"code", "message"}, "protocol event payload") + code = event.get("code") + message = event.get("message") + if not isinstance(code, str) or not code: + raise ProtocolViolation("protocol event has an invalid code") + if not isinstance(message, str): + raise ProtocolViolation("protocol event has an invalid message") + return Event(epoch=epoch, code=code, message=message, raw=payload) + + +def _is_integer(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_uint64(value: object) -> bool: + return _is_integer(value) and 0 <= value <= UINT64_MAX + + +def _reject_json_constant(value: str) -> None: + raise ValueError("non-standard JSON constant: " + value) + + +def _unique_json_object(pairs: Sequence[Tuple[str, Any]]) -> Dict[str, Any]: + result: Dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProtocolViolation( + "protocol response contains duplicate JSON key: " + key + ) + result[key] = value + return result + + +def _successful_result(response: Response, command: str) -> Dict[str, Any]: + if not response.ok: + raise ProtocolViolation(command + " response is not successful") + if response.result is None: + raise ProtocolViolation(command + " response has no result") + return response.result + + +def _require_exact_keys( + value: Mapping[str, Any], expected: set[str], label: str +) -> None: + actual = set(value) + if actual != expected: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + detail = "" + if missing: + detail += "; missing " + ", ".join(missing) + if extra: + detail += "; extra " + ", ".join(extra) + raise ProtocolViolation(label + " has an invalid schema" + detail) + + +def _require_protocol_version(result: Mapping[str, Any]) -> None: + version = result.get("protocol_version") + if not _is_integer(version) or version != PROTOCOL_VERSION: + raise ProtocolViolation("result protocol version is invalid") + + +def _target(value: object, label: str) -> str: + if ( + not isinstance(value, str) + or not value + or len(value) > 64 + or _TARGET_PATTERN.fullmatch(value) is None + ): + raise ProtocolViolation(label + " is invalid") + return value + + +def _lcd(value: object, label: str) -> LcdDescription: + if not isinstance(value, dict): + raise ProtocolViolation(label + " must be an object") + _require_exact_keys(value, {"width", "height", "depth"}, label) + width = value.get("width") + height = value.get("height") + depth = value.get("depth") + if ( + not _is_integer(width) + or not _is_integer(height) + or width <= 0 + or height <= 0 + or width > 65535 + or height > 65535 + or not _is_integer(depth) + or depth not in (1, 4, 16) + ): + raise ProtocolViolation(label + " dimensions are invalid") + return LcdDescription(width=width, height=height, depth=depth) + + +def _capabilities(value: object) -> Capabilities: + if not isinstance(value, dict): + raise ProtocolViolation("capabilities must be an object") + _require_exact_keys(value, set(CAPABILITY_NAMES), "capabilities") + for name in CAPABILITY_NAMES: + if not isinstance(value.get(name), bool): + raise ProtocolViolation("capability " + name + " is not boolean") + return Capabilities(**{name: value[name] for name in CAPABILITY_NAMES}) + + +def _string_tuple( + value: object, label: str, pattern: re.Pattern[str] +) -> Tuple[str, ...]: + if not isinstance(value, list) or len(value) > 256: + raise ProtocolViolation(label + " must be a bounded array") + strings = [] + for item in value: + if ( + not isinstance(item, str) + or not item + or len(item) > 64 + or pattern.fullmatch(item) is None + ): + raise ProtocolViolation(label + " contains an invalid name") + strings.append(item) + if len(set(strings)) != len(strings): + raise ProtocolViolation(label + " contains duplicate names") + return tuple(strings) + + +def _named_ranges(value: object, label: str) -> Tuple[NamedRange, ...]: + if not isinstance(value, list) or len(value) > 256: + raise ProtocolViolation(label + " must be a bounded array") + ranges = [] + names = set() + for item in value: + if not isinstance(item, dict): + raise ProtocolViolation(label + " entries must be objects") + _require_exact_keys(item, {"name", "min", "max"}, label + " entry") + name = _target(item.get("name"), label + " name") + minimum = item.get("min") + maximum = item.get("max") + if ( + not _is_integer(minimum) + or not _is_integer(maximum) + or minimum < -(1 << 31) + or maximum > (1 << 31) - 1 + or minimum > maximum + ): + raise ProtocolViolation(label + " contains an invalid range") + if name in names: + raise ProtocolViolation(label + " contains duplicate names") + names.add(name) + ranges.append(NamedRange(name=name, minimum=minimum, maximum=maximum)) + return tuple(ranges) + + +def _uint64(value: object, label: str) -> int: + if not _is_uint64(value): + raise ProtocolViolation(label + " is invalid") + return value diff --git a/tools/ui-harness/edgetx_ui/session.py b/tools/ui-harness/edgetx_ui/session.py new file mode 100644 index 00000000000..9a27f2e0053 --- /dev/null +++ b/tools/ui-harness/edgetx_ui/session.py @@ -0,0 +1,1878 @@ +"""Cross-platform subprocess lifecycle for EdgeTX simulator automation.""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import queue +import re +import shutil +import subprocess +import tempfile +import threading +import time +from collections import deque +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, BinaryIO, Deque, Dict, Mapping, Optional, Sequence, Tuple, Union + +from .protocol import ( + CAPABILITY_NAMES, + CaptureArtifact, + Description, + Event, + FrameBarrier, + LuaReload, + NamedRange, + MAX_RECORD_BYTES, + ProtocolViolation, + PROTOCOL_VERSION, + Response, + Status, + UINT64_MAX, + decode_capture, + decode_description, + decode_frame, + decode_lua_reload, + decode_restart, + decode_status, + encode_request, + parse_message, +) +from .ppm import ( + ArtifactDigest, + convert_ppm_to_png, + digest_file, + read_ppm, + write_json_sidecar, +) + + +READ_CHUNK_BYTES = 4096 +MAX_STDERR_LINES = 200 +MAX_STDERR_BYTES = 256 * 1024 +MAX_EVENTS = 64 +MAX_PROTOCOL_RECORDS = 4096 +MAX_COMMAND_TIMEOUT = 60.0 +TELEMETRY_UNIT_MAX = 29 +TELEMETRY_LABEL_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,4}$") +REQUIRED_STARTUP_COMMANDS = frozenset(("ping", "status", "describe", "stop")) + + +@dataclass(frozen=True) +class CaptureBundle: + capture: CaptureArtifact + ppm: ArtifactDigest + png: ArtifactDigest + manifest: ArtifactDigest + + +@dataclass(frozen=True) +class _WriteWork: + record: bytes + completion: queue.Queue[Optional[SessionError]] + + +class SessionError(RuntimeError): + """Base exception for simulator session failures.""" + + +class ProtocolFailure(SessionError): + """The simulator emitted malformed or miscorrelated protocol data.""" + + +class _ProtocolClosed(ProtocolFailure): + pass + + +class ProcessExited(SessionError): + """The simulator exited before completing a request.""" + + +class RequestTimeout(SessionError): + """A request did not receive its terminal response before its deadline.""" + + +class StartupMismatch(ProtocolFailure): + """The simulator target or advertised capabilities do not match the run.""" + + +class CommandFailed(SessionError): + """The simulator returned a correlated error response.""" + + def __init__(self, response: Response, diagnostics: str) -> None: + message = ( + "simulator command failed for request " + + str(response.id) + + ": " + + str(response.error_code) + + ": " + + str(response.error_message) + ) + if diagnostics: + message += "\nRecent simulator stderr:\n" + diagnostics + super().__init__(message) + self.response = response + + +class _BoundedDiagnostics: + def __init__(self) -> None: + self._lines: Deque[Tuple[str, int]] = deque() + self._bytes = 0 + self._lock = threading.Lock() + + def append(self, raw_line: bytes) -> None: + if len(raw_line) > MAX_STDERR_BYTES: + raw_line = raw_line[-MAX_STDERR_BYTES:] + text = raw_line.decode("utf-8", errors="replace") + size = len(raw_line) + with self._lock: + self._lines.append((text, size)) + self._bytes += size + while ( + len(self._lines) > MAX_STDERR_LINES + or self._bytes > MAX_STDERR_BYTES + ): + _, removed_size = self._lines.popleft() + self._bytes -= removed_size + + def text(self) -> str: + with self._lock: + return "\n".join(line for line, _ in self._lines) + + def counts(self) -> Tuple[int, int]: + with self._lock: + return len(self._lines), self._bytes + + +_ResponseItem = Union[Response, SessionError] + + +class SimulatorSession: + """Own one simulator process and serialize correlated protocol requests.""" + + def __init__( + self, + executable: Union[str, os.PathLike[str]], + output_root: Union[str, os.PathLike[str]], + *, + simulator_args: Sequence[Union[str, os.PathLike[str]]] = (), + cwd: Optional[Union[str, os.PathLike[str]]] = None, + env: Optional[Mapping[str, str]] = None, + request_timeout: float = 5.0, + stop_timeout: float = 3.0, + terminate_timeout: float = 2.0, + kill_timeout: float = 2.0, + reader_join_timeout: float = 2.0, + protocol_sink: Optional[Union[str, os.PathLike[str]]] = None, + required_capabilities: Sequence[str] = (), + expected_target: Optional[str] = None, + expected_lcd: Optional[Tuple[int, int, int]] = None, + ) -> None: + self._executable = os.fspath(executable) + self._output_root = Path(output_root) + self._simulator_args = tuple(os.fspath(value) for value in simulator_args) + self._cwd = os.fspath(cwd) if cwd is not None else None + self._env = dict(env) if env is not None else None + self._protocol_sink_path = ( + Path(protocol_sink) if protocol_sink is not None else None + ) + self._request_timeout = _validated_timeout(request_timeout, "request") + self._stop_timeout = _validated_timeout(stop_timeout, "stop") + self._terminate_timeout = _validated_timeout( + terminate_timeout, "terminate" + ) + self._kill_timeout = _validated_timeout(kill_timeout, "kill") + self._reader_join_timeout = _validated_timeout( + reader_join_timeout, "reader join" + ) + self._required_capabilities = _validated_capabilities( + required_capabilities + ) + if expected_target is not None and ( + not isinstance(expected_target, str) or not expected_target + ): + raise ValueError("expected target must be a non-empty string") + self._expected_target = expected_target + self._expected_lcd = _validated_lcd(expected_lcd) + + self._process: Optional[subprocess.Popen[bytes]] = None + self._stdout_thread: Optional[threading.Thread] = None + self._stderr_thread: Optional[threading.Thread] = None + self._writer_thread: Optional[threading.Thread] = None + self._writer_queue: "queue.Queue[object]" = queue.Queue(maxsize=1) + self._writer_stop = threading.Event() + self._writer_timed_out = False + self._stdout_closed = threading.Event() + self._stderr_closed = threading.Event() + self._diagnostics = _BoundedDiagnostics() + self._events: Deque[Event] = deque(maxlen=MAX_EVENTS) + self._protocol_records: Deque[Dict[str, Any]] = deque( + maxlen=MAX_PROTOCOL_RECORDS + ) + self._protocol_record_count = 0 + self._protocol_sha256 = hashlib.sha256() + self._protocol_sink: Optional[BinaryIO] = None + + self._state_lock = threading.RLock() + self._request_lock = threading.Lock() + self._shutdown_lock = threading.Lock() + self._pending_id: Optional[int] = None + self._pending_queue: Optional[queue.Queue[_ResponseItem]] = None + self._next_request_id = 1 + self._failure: Optional[SessionError] = None + self._closing = False + self._closed = False + self._stop_response: Optional[Response] = None + self._startup_ping: Optional[Response] = None + self._description_response: Optional[Response] = None + self._description: Optional[Description] = None + self._status_response: Optional[Response] = None + self._status: Optional[Status] = None + self._termination_stage = "not-started" + self._fixture_run_directory: Optional[Path] = None + + @property + def process(self) -> Optional[subprocess.Popen[bytes]]: + return self._process + + @property + def returncode(self) -> Optional[int]: + return self._process.returncode if self._process is not None else None + + @property + def termination_stage(self) -> str: + return self._termination_stage + + @property + def recent_stderr(self) -> str: + return self._diagnostics.text() + + @property + def stderr_counts(self) -> Tuple[int, int]: + return self._diagnostics.counts() + + @property + def events(self) -> Tuple[Event, ...]: + with self._state_lock: + return tuple(self._events) + + @property + def protocol_records(self) -> Tuple[Dict[str, Any], ...]: + """Return the bounded diagnostic tail of protocol traffic.""" + + with self._state_lock: + return tuple(dict(record) for record in self._protocol_records) + + @property + def protocol_record_count(self) -> int: + with self._state_lock: + return self._protocol_record_count + + @property + def protocol_sha256(self) -> str: + with self._state_lock: + return self._protocol_sha256.hexdigest() + + @property + def protocol_records_dropped(self) -> int: + with self._state_lock: + return self._protocol_record_count - len(self._protocol_records) + + @property + def protocol_sink_path(self) -> Optional[Path]: + return self._protocol_sink_path + + @property + def startup_ping(self) -> Optional[Response]: + return self._startup_ping + + @property + def description_response(self) -> Optional[Response]: + return self._description_response + + @property + def description(self) -> Optional[Description]: + return self._description + + @property + def status_response(self) -> Optional[Response]: + return self._status_response + + @property + def status(self) -> Optional[Status]: + return self._status + + @property + def reader_threads_alive(self) -> bool: + return any( + thread is not None and thread.is_alive() + for thread in (self._stdout_thread, self._stderr_thread) + ) + + @property + def writer_thread_alive(self) -> bool: + return self._writer_thread is not None and self._writer_thread.is_alive() + + @property + def fixture_run_directory(self) -> Optional[Path]: + return self._fixture_run_directory + + @property + def command(self) -> Tuple[str, ...]: + return ( + self._executable, + *self._simulator_args, + "--automation-stdio", + "--automation-output", + str(self._output_root), + ) + + def start(self, *, timeout: Optional[float] = None) -> Response: + """Launch, validate discovery, and wait for the first display frame.""" + + with self._state_lock: + if self._process is not None: + raise SessionError("simulator session can only be started once") + + startup_timeout = ( + self._request_timeout + if timeout is None + else _validated_timeout(timeout, "startup") + ) + + try: + resolved_output = self._output_root.resolve(strict=True) + except OSError as error: + raise SessionError("automation output directory does not exist") from error + if not resolved_output.is_dir(): + raise SessionError("automation output path is not a directory") + self._output_root = resolved_output + + if self._protocol_sink_path is not None: + sink_path = self._protocol_sink_path.resolve() + if not sink_path.parent.is_dir(): + raise SessionError("protocol evidence parent directory does not exist") + try: + self._protocol_sink = sink_path.open("xb", buffering=64 * 1024) + except OSError as error: + raise SessionError( + "cannot create protocol evidence sink: " + str(error) + ) from error + self._protocol_sink_path = sink_path + + try: + process = subprocess.Popen( + list(self.command), + cwd=self._cwd, + env=self._env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + bufsize=0, + shell=False, + ) + except OSError as error: + self._close_protocol_sink() + raise SessionError("cannot launch simulator: " + str(error)) from error + + with self._state_lock: + self._process = process + self._termination_stage = "running" + self._stdout_thread = threading.Thread( + target=self._read_stdout, + name="edgetx-automation-stdout", + daemon=True, + ) + self._stderr_thread = threading.Thread( + target=self._read_stderr, + name="edgetx-automation-stderr", + daemon=True, + ) + self._writer_thread = threading.Thread( + target=self._write_stdin, + name="edgetx-automation-stdin", + daemon=True, + ) + try: + self._stdout_thread.start() + self._stderr_thread.start() + self._writer_thread.start() + except BaseException: + self._shutdown(send_stop=False, raise_errors=False) + raise + + deadline = time.monotonic() + startup_timeout + try: + self._startup_ping = self.ping( + timeout=self._startup_remaining(deadline) + ) + self._description_response = self.request( + "describe", timeout=self._startup_remaining(deadline) + ) + try: + self._description = decode_description( + self._description_response + ) + except ProtocolViolation as error: + raise ProtocolFailure(str(error)) from error + self._validate_description(self._description) + + while True: + try: + self._status_response = self.request( + "status", timeout=self._startup_remaining(deadline) + ) + except RequestTimeout as error: + raise RequestTimeout( + self._message_with_context( + "timed out waiting for first-frame readiness", None + ) + ) from error + try: + self._status = decode_status(self._status_response) + except ProtocolViolation as error: + raise ProtocolFailure(str(error)) from error + self._validate_status(self._status, self._description) + + if self._status.phase == "ready": + if ( + not self._status.running + or self._status.epoch == 0 + or self._status.display_sequence == 0 + ): + raise ProtocolFailure( + "ready status does not own a running first frame" + ) + return self._status_response + if self._status.phase == "stopped": + raise ProtocolFailure( + "simulator stopped before first-frame readiness" + ) + except BaseException: + self._shutdown(send_stop=False, raise_errors=False) + raise + + def ping(self, *, timeout: Optional[float] = None) -> Response: + return self.request("ping", timeout=timeout) + + def read_status(self, *, timeout: Optional[float] = None) -> Status: + """Read and validate a fresh status snapshot.""" + + description = self._require_command("status") + response = self.request("status", timeout=timeout) + try: + status = decode_status(response) + except ProtocolViolation as error: + raise ProtocolFailure(str(error)) from error + self._validate_status(status, description) + with self._state_lock: + self._status_response = response + self._status = status + return status + + def key_down( + self, key: str, *, timeout: Optional[float] = None + ) -> Response: + self._validate_key(key, "key-down") + return self.request("key-down", key, timeout=timeout) + + def key_up( + self, key: str, *, timeout: Optional[float] = None + ) -> Response: + self._validate_key(key, "key-up") + return self.request("key-up", key, timeout=timeout) + + def rotate( + self, steps: int, *, timeout: Optional[float] = None + ) -> Response: + self._require_command("rotate", capability="rotary") + _validated_integer(steps, -128, 128, "rotary steps", exclude_zero=True) + return self.request("rotate", str(steps), timeout=timeout) + + def touch_down( + self, x: int, y: int, *, timeout: Optional[float] = None + ) -> Response: + x, y = self._validate_touch_point(x, y, "touch-down") + return self.request("touch-down", str(x), str(y), timeout=timeout) + + def touch_move( + self, x: int, y: int, *, timeout: Optional[float] = None + ) -> Response: + x, y = self._validate_touch_point(x, y, "touch-move") + return self.request("touch-move", str(x), str(y), timeout=timeout) + + def touch_up(self, *, timeout: Optional[float] = None) -> Response: + self._require_command("touch-up", capability="touch") + return self.request("touch-up", timeout=timeout) + + def release_all(self, *, timeout: Optional[float] = None) -> Response: + self._require_command("release-all") + return self.request("release-all", timeout=timeout) + + def set_switch( + self, name: str, position: int, *, timeout: Optional[float] = None + ) -> Response: + description = self._require_command("set-switch", capability="switches") + _require_named_range(description.switches, name, "switch") + position = _validated_integer(position, -1, 1, "switch position") + return self.request("set-switch", name, str(position), timeout=timeout) + + def set_analog( + self, name: str, value: int, *, timeout: Optional[float] = None + ) -> Response: + description = self._require_command("set-analog", capability="analog") + analog = _require_named_range(description.analogs, name, "analog") + value = _validated_integer( + value, analog.minimum, analog.maximum, "analog value" + ) + return self.request("set-analog", name, str(value), timeout=timeout) + + def clear_analog( + self, name: str = "all", *, timeout: Optional[float] = None + ) -> Response: + description = self._require_command("clear-analog", capability="analog") + if name != "all": + _require_named_range(description.analogs, name, "analog") + return self.request("clear-analog", name, timeout=timeout) + + def set_telemetry( + self, + sensor_id: int, + sub_id: int, + instance: int, + value: int, + unit: int, + precision: int, + name: Optional[str] = None, + *, + timeout: Optional[float] = None, + ) -> Response: + self._require_command("set-telemetry", capability="telemetry") + sensor_id = _validated_integer(sensor_id, 1, 65535, "telemetry id") + sub_id = _validated_integer(sub_id, 0, 7, "telemetry sub-id") + instance = _validated_integer(instance, 0, 255, "telemetry instance") + value = _validated_integer( + value, -(1 << 31), (1 << 31) - 1, "telemetry value" + ) + unit = _validated_integer(unit, 0, TELEMETRY_UNIT_MAX, "telemetry unit") + precision = _validated_integer(precision, 0, 2, "telemetry precision") + arguments = [ + str(sensor_id), + str(sub_id), + str(instance), + str(value), + str(unit), + str(precision), + ] + if name is not None: + if not isinstance(name, str) or not TELEMETRY_LABEL_PATTERN.fullmatch( + name + ): + raise ValueError( + "telemetry name must match [A-Za-z0-9_-]{1,4}" + ) + arguments.append(name) + return self.request("set-telemetry", *arguments, timeout=timeout) + + def reload_lua(self, *, timeout: Optional[float] = None) -> LuaReload: + self._require_command("reload-lua", capability="lua") + response = self.request("reload-lua", timeout=timeout) + try: + return decode_lua_reload(response) + except ProtocolViolation as error: + raise ProtocolFailure(str(error)) from error + + def restart(self, *, timeout: Optional[float] = None) -> FrameBarrier: + self._require_command("restart", capability="warm_restart") + before = self.read_status(timeout=timeout) + response = self.request("restart", timeout=timeout) + try: + restarted = decode_restart(response) + except ProtocolViolation as error: + raise ProtocolFailure(str(error)) from error + if restarted.epoch <= before.epoch: + raise ProtocolFailure("warm restart did not advance the session epoch") + if restarted.display_sequence <= before.display_sequence: + raise ProtocolFailure( + "warm restart did not preserve the process display sequence" + ) + after = self.read_status(timeout=timeout) + if ( + after.phase != "ready" + or not after.running + or after.epoch != restarted.epoch + or after.display_sequence < restarted.display_sequence + ): + raise ProtocolFailure("warm restart result and ready status disagree") + return restarted + + def restart_process( + self, + fixture_root: Union[str, os.PathLike[str]], + runs_root: Union[str, os.PathLike[str]], + *, + timeout: Optional[float] = None, + ) -> "SimulatorSession": + """Cold-restart into a new process and fresh writable fixture copy.""" + + fixture = Path(fixture_root).resolve(strict=True) + settings_source = _validated_fixture_directory(fixture / "settings") + storage_source = _validated_fixture_directory(fixture / "sdcard") + runs = Path(runs_root) + runs.mkdir(parents=True, exist_ok=True) + runs = runs.resolve(strict=True) + if _path_is_below(runs, settings_source) or _path_is_below( + runs, storage_source + ): + raise ValueError("runs root must not be inside the fixture template") + + simulator_args = _replace_option( + self._simulator_args, "--settings", "{settings}" + ) + simulator_args = _replace_option( + simulator_args, "--storage", "{storage}" + ) + + self.stop(timeout=timeout) + + run_directory: Optional[Path] = None + replacement: Optional[SimulatorSession] = None + try: + run_directory = Path( + tempfile.mkdtemp(prefix="edgetx-ui-", dir=str(runs)) + ).resolve(strict=True) + settings_copy = run_directory / "settings" + storage_copy = run_directory / "sdcard" + artifacts = run_directory / "artifacts" + shutil.copytree(settings_source, settings_copy, symlinks=False) + shutil.copytree(storage_source, storage_copy, symlinks=False) + artifacts.mkdir() + + copied_args = tuple( + str(settings_copy) + if value == "{settings}" + else str(storage_copy) + if value == "{storage}" + else value + for value in simulator_args + ) + replacement = SimulatorSession( + self._executable, + artifacts, + simulator_args=copied_args, + cwd=self._cwd, + env=self._env, + request_timeout=self._request_timeout, + stop_timeout=self._stop_timeout, + terminate_timeout=self._terminate_timeout, + kill_timeout=self._kill_timeout, + reader_join_timeout=self._reader_join_timeout, + protocol_sink=( + run_directory / "protocol.jsonl" + if self._protocol_sink_path is not None + else None + ), + required_capabilities=self._required_capabilities, + expected_target=self._expected_target, + expected_lcd=self._expected_lcd, + ) + replacement._fixture_run_directory = run_directory + replacement.start(timeout=timeout) + return replacement + except BaseException: + if replacement is not None: + replacement.close() + if run_directory is not None: + shutil.rmtree(run_directory, ignore_errors=True) + raise + + def wait_frame( + self, minimum: int, *, timeout: Optional[float] = None + ) -> FrameBarrier: + self._require_command("wait-frame") + minimum = _validated_integer( + minimum, 0, UINT64_MAX, "minimum display sequence" + ) + response = self.request("wait-frame", str(minimum), timeout=timeout) + try: + barrier = decode_frame(response) + except ProtocolViolation as error: + raise ProtocolFailure(str(error)) from error + if barrier.display_sequence < minimum: + raise ProtocolFailure( + "wait-frame completed below its requested display sequence" + ) + return barrier + + def wait_next_frame( + self, *, timeout: Optional[float] = None + ) -> FrameBarrier: + total_timeout = ( + self._request_timeout + if timeout is None + else _validated_timeout(timeout, "wait-next-frame") + ) + deadline = time.monotonic() + total_timeout + + def remaining() -> float: + value = deadline - time.monotonic() + if value <= 0: + raise RequestTimeout( + self._message_with_context( + "timed out waiting for the next display frame", None + ) + ) + return value + + status = self.read_status(timeout=remaining()) + if status.display_sequence == UINT64_MAX: + raise SessionError("display sequence is saturated") + return self.wait_frame( + status.display_sequence + 1, timeout=remaining() + ) + + def capture_ppm( + self, relative_path: str, *, timeout: Optional[float] = None + ) -> CaptureArtifact: + """Capture a fresh RGB565 framebuffer as a validated native PPM.""" + + description = self._require_command("capture", capability="capture") + canonical, output_path = self._validate_artifact_path( + relative_path, ".ppm" + ) + total_timeout = ( + self._request_timeout + if timeout is None + else _validated_timeout(timeout, "capture") + ) + deadline = time.monotonic() + total_timeout + + def remaining() -> float: + value = deadline - time.monotonic() + if value <= 0: + raise RequestTimeout( + self._message_with_context( + "timed out capturing a fresh display frame", None + ) + ) + return value + + baseline = self.read_status(timeout=remaining()) + response = self.request("capture", canonical, timeout=remaining()) + try: + artifact = decode_capture(response) + except ProtocolViolation as error: + raise ProtocolFailure(str(error)) from error + if artifact.path != canonical: + raise ProtocolFailure("capture result path differs from the request") + if artifact.display_sequence <= baseline.display_sequence: + raise ProtocolFailure("capture did not use a newer display frame") + if (artifact.width, artifact.height, artifact.depth) != ( + description.lcd.width, + description.lcd.height, + description.lcd.depth, + ): + raise ProtocolFailure( + "capture metadata differs from target discovery" + ) + try: + image = read_ppm(output_path) + except (OSError, ValueError) as error: + raise ProtocolFailure( + "native capture artifact is invalid: " + str(error) + ) from error + if (image.width, image.height) != (artifact.width, artifact.height): + raise ProtocolFailure( + "PPM dimensions differ from capture metadata" + ) + try: + actual_bytes = output_path.stat().st_size + except OSError as error: + raise ProtocolFailure( + "cannot inspect native capture artifact" + ) from error + if actual_bytes != artifact.byte_count: + raise ProtocolFailure("PPM size differs from capture metadata") + return artifact + + def capture_png( + self, relative_path: str, *, timeout: Optional[float] = None + ) -> CaptureBundle: + """Capture PPM, convert and verify PNG, then write stable metadata.""" + + png_relative, png_path = self._validate_artifact_path( + relative_path, ".png" + ) + png_pure = PurePosixPath(png_relative) + ppm_relative = png_pure.with_suffix(".ppm").as_posix() + manifest_relative = png_pure.with_suffix(".capture.json").as_posix() + _, manifest_path = self._validate_artifact_path( + manifest_relative, ".json" + ) + + capture = self.capture_ppm(ppm_relative, timeout=timeout) + root = self._output_root.resolve(strict=True) + ppm_path = root.joinpath(*PurePosixPath(capture.path).parts) + try: + ppm_digest = digest_file(ppm_path) + image, png_digest = convert_ppm_to_png(ppm_path, png_path) + except (OSError, ValueError) as error: + raise ProtocolFailure( + "cannot convert native capture to PNG: " + str(error) + ) from error + if (image.width, image.height) != (capture.width, capture.height): + raise ProtocolFailure( + "converted PNG dimensions differ from capture" + ) + + description = self._description + if description is None: + raise SessionError("simulator discovery is unavailable") + manifest_payload = { + "artifacts": { + "png": { + "bytes": png_digest.byte_count, + "path": png_relative, + "sha256": png_digest.sha256, + }, + "ppm": { + "bytes": ppm_digest.byte_count, + "path": capture.path, + "sha256": ppm_digest.sha256, + }, + }, + "depth": capture.depth, + "display_seq": capture.display_sequence, + "epoch": capture.epoch, + "height": capture.height, + "schema_version": 1, + "target": description.target, + "width": capture.width, + } + try: + manifest_digest = write_json_sidecar( + manifest_path, manifest_payload + ) + except (OSError, ValueError) as error: + raise ProtocolFailure( + "cannot write capture metadata: " + str(error) + ) from error + return CaptureBundle( + capture=capture, + ppm=ppm_digest, + png=png_digest, + manifest=manifest_digest, + ) + + def press( + self, + key: str, + *, + duration: float = 0.05, + timeout: Optional[float] = None, + ) -> Response: + self._validate_key(key, "key-down") + duration = _validated_duration(duration, "press duration") + self.key_down(key, timeout=timeout) + try: + _sleep_for_duration(duration) + except BaseException: + self._best_effort_key_release(key, timeout) + raise + return self._key_release_with_fallback(key, timeout) + + def long_press( + self, + key: str, + *, + duration: float = 1.0, + timeout: Optional[float] = None, + ) -> Response: + return self.press(key, duration=duration, timeout=timeout) + + def tap( + self, + x: int, + y: int, + *, + duration: float = 0.05, + timeout: Optional[float] = None, + ) -> Response: + x, y = self._validate_touch_point(x, y, "touch-down") + duration = _validated_duration(duration, "tap duration") + self.touch_down(x, y, timeout=timeout) + try: + _sleep_for_duration(duration) + except BaseException: + self._best_effort_touch_release(timeout) + raise + return self._touch_release_with_fallback(timeout) + + def drag( + self, + points: Sequence[Tuple[int, int]], + *, + duration: float = 0.2, + timeout: Optional[float] = None, + ) -> Response: + if isinstance(points, (str, bytes)) or len(points) < 2: + raise ValueError("drag requires at least two touch points") + try: + validated = tuple( + self._validate_touch_point(x, y, "touch-move") + for x, y in points + ) + except (TypeError, ValueError) as error: + raise ValueError("drag points must be valid x/y pairs") from error + duration = _validated_duration(duration, "drag duration") + self.touch_down(*validated[0], timeout=timeout) + started = time.monotonic() + segments = len(validated) - 1 + try: + for index, point in enumerate(validated[1:], start=1): + deadline = started + duration * index / segments + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(remaining) + self.touch_move(*point, timeout=timeout) + except BaseException: + self._best_effort_touch_release(timeout) + raise + return self._touch_release_with_fallback(timeout) + + def _startup_remaining(self, deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RequestTimeout( + self._message_with_context( + "timed out waiting for simulator readiness", None + ) + ) + return remaining + + def _validate_description(self, description: Description) -> None: + commands = set(description.commands) + missing_commands = sorted(REQUIRED_STARTUP_COMMANDS - commands) + if missing_commands: + raise StartupMismatch( + "simulator discovery is missing required commands: " + + ", ".join(missing_commands) + ) + if self._expected_target is not None and ( + description.target != self._expected_target + ): + raise StartupMismatch( + "simulator target mismatch: expected " + + self._expected_target + + ", received " + + description.target + ) + if self._expected_lcd is not None: + actual_lcd = ( + description.lcd.width, + description.lcd.height, + description.lcd.depth, + ) + if actual_lcd != self._expected_lcd: + raise StartupMismatch( + "simulator LCD mismatch: expected " + + "x".join(str(value) for value in self._expected_lcd) + + ", received " + + "x".join(str(value) for value in actual_lcd) + ) + missing_capabilities = [ + name + for name in self._required_capabilities + if not description.capabilities.supports(name) + ] + if missing_capabilities: + raise StartupMismatch( + "simulator is missing required capabilities: " + + ", ".join(missing_capabilities) + ) + + def _require_command( + self, command: str, *, capability: Optional[str] = None + ) -> Description: + with self._state_lock: + description = self._description + if description is None: + raise SessionError("simulator discovery is not available") + if command not in description.commands: + raise SessionError("target does not advertise command: " + command) + if capability is not None and not description.capabilities.supports( + capability + ): + raise SessionError( + "target does not advertise capability: " + capability + ) + return description + + def _validate_key(self, key: str, command: str) -> None: + description = self._require_command(command) + if not isinstance(key, str) or key not in description.keys: + raise ValueError("key is not supported by target: " + str(key)) + + def _validate_touch_point( + self, x: int, y: int, command: str + ) -> Tuple[int, int]: + description = self._require_command(command, capability="touch") + return ( + _validated_integer(x, 0, description.lcd.width - 1, "touch x"), + _validated_integer(y, 0, description.lcd.height - 1, "touch y"), + ) + + def _validate_artifact_path( + self, value: str, extension: str + ) -> Tuple[str, Path]: + if not isinstance(value, str) or not value: + raise ValueError("artifact path must be a non-empty string") + if value != value.strip(" ") or any( + character in value for character in ("\0", "\r", "\n", "\\") + ): + raise ValueError("artifact path is not a canonical relative path") + if len(value.encode("utf-8")) > 1024: + raise ValueError("artifact path exceeds 1024 UTF-8 bytes") + relative = PurePosixPath(value) + canonical = relative.as_posix() + if ( + relative.is_absolute() + or ( + len(value) >= 2 + and value[0].isascii() + and value[0].isalpha() + and value[1] == ":" + ) + or canonical != value + or not relative.name + or any(part in (".", "..") for part in relative.parts) + or relative.suffix != extension + ): + raise ValueError( + "artifact path must be canonical, relative, and end in " + + extension + ) + if os.name == "nt" and _is_unsafe_win32_filename(relative.name): + raise ValueError("artifact filename is reserved by Windows") + + try: + root = self._output_root.resolve(strict=True) + parent = root.joinpath(*relative.parts[:-1]).resolve(strict=True) + parent.relative_to(root) + except (OSError, ValueError) as error: + raise ValueError( + "artifact parent must exist below the output root" + ) from error + output_path = parent / relative.name + if os.path.lexists(output_path): + raise ValueError("artifact path already exists: " + canonical) + return canonical, output_path + + def _key_release_with_fallback( + self, key: str, timeout: Optional[float] + ) -> Response: + try: + return self.key_up(key, timeout=timeout) + except BaseException: + self._best_effort_release_all(timeout) + raise + + def _touch_release_with_fallback( + self, timeout: Optional[float] + ) -> Response: + try: + return self.touch_up(timeout=timeout) + except BaseException: + self._best_effort_release_all(timeout) + raise + + def _best_effort_key_release( + self, key: str, timeout: Optional[float] + ) -> None: + try: + self._key_release_with_fallback(key, timeout) + except BaseException: + pass + + def _best_effort_touch_release(self, timeout: Optional[float]) -> None: + try: + self._touch_release_with_fallback(timeout) + except BaseException: + pass + + def _best_effort_release_all(self, timeout: Optional[float]) -> None: + try: + self.release_all(timeout=timeout) + except BaseException: + pass + + @staticmethod + def _validate_status(status: Status, description: Description) -> None: + if status.target != description.target: + raise ProtocolFailure("status target differs from describe") + if status.lcd != description.lcd: + raise ProtocolFailure("status LCD differs from describe") + if status.capabilities != description.capabilities: + raise ProtocolFailure("status capabilities differ from describe") + if status.output_root != "ready": + raise StartupMismatch("simulator output root is not ready") + + def request( + self, + command: str, + *arguments: str, + timeout: Optional[float] = None, + ) -> Response: + request_timeout = ( + self._request_timeout + if timeout is None + else _validated_timeout(timeout, "command") + ) + # One monotonic deadline covers serialization behind request_lock, + # queue admission, the complete pipe write/flush, and correlation of + # the terminal response. No stage receives a fresh timeout budget. + deadline = time.monotonic() + request_timeout + + with self._request_lock: + pending: queue.Queue[_ResponseItem] = queue.Queue(maxsize=1) + with self._state_lock: + process = self._require_requestable_process() + request_id = self._next_request_id + if request_id > (1 << 64) - 1: + raise SessionError("request id space is exhausted") + self._next_request_id += 1 + self._pending_id = request_id + self._pending_queue = pending + + try: + record = encode_request(request_id, command, arguments) + except (TypeError, ValueError): + self._clear_pending(pending) + raise + try: + self._record_protocol( + "request", + { + "version": PROTOCOL_VERSION, + "id": request_id, + "command": command, + "args": list(arguments), + }, + ) + self._submit_write(record, request_id, deadline) + except SessionError as error: + self._clear_pending(pending) + self._record_failure(error) + if self._writer_timed_out: + # This path runs while request_lock is owned. An abortive + # shutdown is safe because it never attempts a stop + # request, and it guarantees the blocked writer and child + # are gone before the timeout is exposed to the caller. + self._shutdown(send_stop=False, raise_errors=False) + raise + + try: + response = self._wait_for_response(pending, request_id, deadline) + except RequestTimeout as error: + # A late response cannot be safely correlated with a later + # request on this serialized v1 session. + self._record_failure(error) + raise + finally: + self._clear_pending(pending) + + if not response.ok: + raise CommandFailed(response, self.recent_stderr) + return response + + def stop(self, *, timeout: Optional[float] = None) -> Optional[Response]: + return self._shutdown( + send_stop=True, + raise_errors=True, + stop_request_timeout=timeout, + ) + + def close(self) -> None: + self._shutdown(send_stop=True, raise_errors=False) + + def __enter__(self) -> "SimulatorSession": + self.start() + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> bool: + if exc_type is None: + self.stop() + else: + self.close() + return False + + def _require_requestable_process(self) -> subprocess.Popen[bytes]: + process = self._process + if process is None: + raise SessionError("simulator session has not been started") + if self._closed or self._closing: + raise SessionError("simulator session is stopping") + if self._failure is not None: + raise self._with_context(self._failure, None) + returncode = process.poll() + if returncode is not None: + raise self._process_error("simulator is not running", None) + if self._stdout_closed.is_set(): + raise ProtocolFailure("protocol stdout is closed") + if process.stdin is None or process.stdout is None or process.stderr is None: + raise SessionError("simulator pipes are unavailable") + if self._pending_queue is not None: + raise SessionError("another request is already pending") + return process + + def _submit_write( + self, record: bytes, request_id: int, deadline: float + ) -> None: + completion: "queue.Queue[Optional[SessionError]]" = queue.Queue(maxsize=1) + work = _WriteWork(record, completion) + remaining = deadline - time.monotonic() + if remaining <= 0: + self._writer_timed_out = True + raise RequestTimeout( + self._message_with_context( + "timed out before request reached protocol stdin", request_id + ) + ) + try: + self._writer_queue.put(work, timeout=remaining) + except queue.Full as error: + self._writer_timed_out = True + raise RequestTimeout( + self._message_with_context( + "timed out enqueueing request to protocol stdin", request_id + ) + ) from error + + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + self._writer_timed_out = True + raise RequestTimeout( + self._message_with_context( + "timed out writing request to protocol stdin", request_id + ) + ) + try: + result = completion.get(timeout=min(remaining, 0.05)) + except queue.Empty: + process = self._process + if process is not None and process.poll() is not None: + raise self._process_error( + "simulator exited while writing", request_id + ) + continue + if result is not None: + raise self._with_context(result, request_id) + return + + def _write_stdin(self) -> None: + while not self._writer_stop.is_set(): + try: + item = self._writer_queue.get(timeout=0.05) + except queue.Empty: + continue + if not isinstance(item, _WriteWork): + return + + error: Optional[SessionError] = None + process = self._process + try: + if process is None or process.stdin is None: + raise OSError("protocol stdin is unavailable") + view = memoryview(item.record) + written = 0 + while written < len(view): + count = process.stdin.write(view[written:]) + if count is None or count <= 0: + raise OSError("protocol stdin accepted no bytes") + written += count + process.stdin.flush() + except (BrokenPipeError, OSError, ValueError) as caught: + error = ProcessExited("cannot write protocol stdin: " + str(caught)) + try: + item.completion.put_nowait(error) + except queue.Full: + pass + + def _record_protocol(self, direction: str, message: Mapping[str, Any]) -> None: + record = {"direction": direction, "message": dict(message)} + encoded = ( + json.dumps( + record, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + "\n" + ).encode("utf-8") + with self._state_lock: + self._protocol_records.append(record) + self._protocol_record_count += 1 + self._protocol_sha256.update(encoded) + if self._protocol_sink is not None: + try: + self._protocol_sink.write(encoded) + except OSError as error: + raise ProtocolFailure( + "cannot write protocol evidence sink: " + str(error) + ) from error + + def _wait_for_response( + self, + pending: queue.Queue[_ResponseItem], + request_id: int, + deadline: float, + ) -> Response: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RequestTimeout( + self._message_with_context( + "timed out waiting for request " + str(request_id), + request_id, + ) + ) + try: + item = pending.get(timeout=min(remaining, 0.05)) + except queue.Empty: + process = self._process + if process is not None and process.poll() is not None: + raise self._process_error( + "simulator exited while waiting", request_id + ) + continue + + if isinstance(item, Response): + with self._state_lock: + failure = self._failure + if failure is not None: + raise self._with_context(failure, request_id) + return item + + process = self._process + if ( + isinstance(item, _ProtocolClosed) + and self._stdout_closed.is_set() + and process is not None + ): + returncode = process.poll() + if returncode is None: + try: + returncode = process.wait(timeout=0.05) + except subprocess.TimeoutExpired: + pass + if returncode is not None: + raise self._process_error( + "simulator exited while waiting", request_id + ) + raise self._with_context(item, request_id) + + def _read_stdout(self) -> None: + process = self._process + if process is None or process.stdout is None: + self._record_failure(ProtocolFailure("protocol stdout is unavailable")) + self._stdout_closed.set() + return + + buffered = bytearray() + try: + while True: + chunk = _read_chunk(process.stdout) + if not chunk: + break + buffered.extend(chunk) + while True: + delimiter = buffered.find(b"\n") + if delimiter < 0: + break + record = bytes(buffered[:delimiter]) + del buffered[: delimiter + 1] + self._route_message(parse_message(record)) + if len(buffered) > MAX_RECORD_BYTES: + raise ProtocolFailure( + "protocol stdout record exceeds 16 KiB without newline" + ) + if buffered: + raise ProtocolFailure("protocol stdout closed with a partial record") + except SessionError as error: + self._record_failure(error) + except BaseException as error: + if not self._closing: + self._record_failure( + ProtocolFailure("cannot read protocol stdout: " + str(error)) + ) + finally: + self._stdout_closed.set() + with self._state_lock: + if self._pending_queue is not None: + self._record_failure( + _ProtocolClosed( + "protocol stdout closed while a request was pending" + ) + ) + + def _route_message(self, message: Union[Response, Event]) -> None: + self._record_protocol( + "event" if isinstance(message, Event) else "response", message.raw + ) + if isinstance(message, Event): + with self._state_lock: + self._events.append(message) + return + + with self._state_lock: + pending = self._pending_queue + expected_id = self._pending_id + if pending is None or expected_id is None: + self._record_failure( + ProtocolFailure( + "received unknown or repeated response id " + str(message.id) + ) + ) + return + if message.id != expected_id: + self._record_failure( + ProtocolFailure( + "response id " + + str(message.id) + + " does not match pending request " + + str(expected_id) + ) + ) + return + self._pending_id = None + self._pending_queue = None + pending.put_nowait(message) + + def _read_stderr(self) -> None: + process = self._process + if process is None or process.stderr is None: + self._stderr_closed.set() + return + + buffered = bytearray() + try: + while True: + chunk = _read_chunk(process.stderr) + if not chunk: + break + buffered.extend(chunk) + while True: + delimiter = buffered.find(b"\n") + if delimiter < 0: + break + line = bytes(buffered[:delimiter]) + del buffered[: delimiter + 1] + if line.endswith(b"\r"): + line = line[:-1] + self._diagnostics.append(line) + if len(buffered) > MAX_STDERR_BYTES: + self._diagnostics.append(bytes(buffered)) + buffered.clear() + if buffered: + self._diagnostics.append(bytes(buffered)) + except (OSError, ValueError) as error: + if not self._closing: + self._diagnostics.append( + ("stderr reader failed: " + str(error)).encode( + "utf-8", errors="replace" + ) + ) + finally: + self._stderr_closed.set() + + def _record_failure(self, error: SessionError) -> None: + with self._state_lock: + if self._failure is None: + self._failure = error + pending = self._pending_queue + self._pending_id = None + self._pending_queue = None + if pending is not None: + try: + pending.put_nowait(self._failure) + except queue.Full: + pass + + def _clear_pending(self, pending: queue.Queue[_ResponseItem]) -> None: + with self._state_lock: + if self._pending_queue is pending: + self._pending_id = None + self._pending_queue = None + + def _shutdown( + self, + *, + send_stop: bool, + raise_errors: bool, + stop_request_timeout: Optional[float] = None, + ) -> Optional[Response]: + with self._shutdown_lock: + if self._closed: + return self._stop_response + + process = self._process + if process is None: + self._closed = True + self._termination_stage = "not-started" + return None + + primary_error: Optional[SessionError] = None + with self._state_lock: + existing_failure = self._failure + if send_stop and existing_failure is not None: + primary_error = existing_failure + elif send_stop and process.poll() is not None: + primary_error = self._process_error( + "simulator exited before stop", None + ) + elif send_stop and self._stdout_closed.is_set(): + primary_error = ProtocolFailure( + "protocol stdout closed before stop" + ) + elif send_stop: + try: + self._stop_response = self.request( + "stop", timeout=stop_request_timeout + ) + except SessionError as error: + primary_error = error + + with self._state_lock: + self._closing = True + + # A timed-out pipe write can hold the stdin object's I/O lock. + # Terminate the child first so its read handle closes and releases + # the sole writer worker before this thread closes the Python pipe. + if self._writer_timed_out and process.poll() is None: + try: + process.terminate() + except OSError as error: + if process.poll() is None: + primary_error = primary_error or SessionError( + "cannot terminate simulator: " + str(error) + ) + try: + process.wait(timeout=self._terminate_timeout) + self._termination_stage = "terminated" + except subprocess.TimeoutExpired: + try: + process.kill() + except OSError as error: + if process.poll() is None: + primary_error = primary_error or SessionError( + "cannot kill simulator: " + str(error) + ) + try: + process.wait(timeout=self._kill_timeout) + self._termination_stage = "killed" + except subprocess.TimeoutExpired: + primary_error = primary_error or SessionError( + "simulator did not exit after kill" + ) + else: + if process.stdin is not None: + try: + process.stdin.close() + except OSError: + pass + + try: + process.wait(timeout=self._stop_timeout) + self._termination_stage = "graceful" + except subprocess.TimeoutExpired: + try: + process.terminate() + except OSError as error: + if process.poll() is None: + primary_error = primary_error or SessionError( + "cannot terminate simulator: " + str(error) + ) + try: + process.wait(timeout=self._terminate_timeout) + self._termination_stage = "terminated" + except subprocess.TimeoutExpired: + try: + process.kill() + except OSError as error: + if process.poll() is None: + primary_error = primary_error or SessionError( + "cannot kill simulator: " + str(error) + ) + try: + process.wait(timeout=self._kill_timeout) + self._termination_stage = "killed" + except subprocess.TimeoutExpired: + primary_error = primary_error or SessionError( + "simulator did not exit after kill" + ) + + self._writer_stop.set() + try: + self._writer_queue.put_nowait(None) + except queue.Full: + pass + self._close_process_pipes(process) + threads_alive = self._join_io_threads() + if threads_alive: + primary_error = primary_error or SessionError( + "simulator I/O threads did not stop" + ) + sink_error = self._close_protocol_sink() + if sink_error is not None: + primary_error = primary_error or sink_error + + with self._state_lock: + self._closed = True + self._pending_id = None + self._pending_queue = None + + if primary_error is not None and raise_errors: + raise self._with_context(primary_error, None) + return self._stop_response + + @staticmethod + def _close_process_pipes(process: subprocess.Popen[bytes]) -> None: + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None: + try: + stream.close() + except OSError: + pass + + def _join_io_threads(self) -> bool: + for thread in ( + self._writer_thread, + self._stdout_thread, + self._stderr_thread, + ): + if thread is not None and thread.ident is not None: + thread.join(timeout=self._reader_join_timeout) + return self.writer_thread_alive or self.reader_threads_alive + + def _close_protocol_sink(self) -> Optional[SessionError]: + sink = self._protocol_sink + self._protocol_sink = None + if sink is None: + return None + try: + sink.flush() + os.fsync(sink.fileno()) + sink.close() + except OSError as error: + try: + sink.close() + except OSError: + pass + return SessionError("cannot finalize protocol evidence sink: " + str(error)) + return None + + def _process_error( + self, prefix: str, request_id: Optional[int] + ) -> ProcessExited: + returncode = self.returncode + if returncode is not None: + self._stderr_closed.wait(timeout=0.1) + suffix = "" if returncode is None else " with code " + str(returncode) + return ProcessExited( + self._message_with_context(prefix + suffix, request_id) + ) + + def _with_context( + self, error: SessionError, request_id: Optional[int] + ) -> SessionError: + if isinstance(error, CommandFailed): + return error + return type(error)(self._message_with_context(str(error), request_id)) + + def _message_with_context( + self, message: str, request_id: Optional[int] + ) -> str: + if request_id is not None and "request " + str(request_id) not in message: + message += " (request " + str(request_id) + ")" + diagnostics = self.recent_stderr + if diagnostics and "Recent simulator stderr:" not in message: + message += "\nRecent simulator stderr:\n" + diagnostics + return message + + +def _read_chunk(stream: BinaryIO) -> bytes: + read1 = getattr(stream, "read1", None) + if read1 is not None: + return read1(READ_CHUNK_BYTES) + return stream.read(READ_CHUNK_BYTES) + + +def _require_named_range( + values: Sequence[NamedRange], name: str, label: str +) -> NamedRange: + if not isinstance(name, str) or not name: + raise ValueError(label + " name must be a non-empty string") + for value in values: + if value.name == name: + return value + raise ValueError(label + " is not supported by target: " + name) + + +def _replace_option( + arguments: Sequence[str], option: str, replacement: str +) -> Tuple[str, ...]: + result = list(arguments) + positions = [index for index, value in enumerate(result) if value == option] + if len(positions) > 1: + raise ValueError("simulator arguments repeat " + option) + if positions: + index = positions[0] + if index + 1 >= len(result) or result[index + 1].startswith("--"): + raise ValueError("simulator argument has no value: " + option) + result[index + 1] = replacement + else: + result.extend((option, replacement)) + return tuple(result) + + +def _validated_fixture_directory(path: Path) -> Path: + try: + resolved = path.resolve(strict=True) + except OSError as error: + raise ValueError("fixture directory does not exist: " + str(path)) from error + if not resolved.is_dir(): + raise ValueError("fixture path is not a directory: " + str(path)) + for current, directories, files in os.walk(resolved, followlinks=False): + for name in (*directories, *files): + if (Path(current) / name).is_symlink(): + raise ValueError("fixture templates must not contain symlinks") + return resolved + + +def _path_is_below(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + +def _is_unsafe_win32_filename(value: str) -> bool: + if not value or value[-1] in (" ", "."): + return True + if any( + ord(character) < 32 or character in '<>:"/\\|?*' + for character in value + ): + return True + base = value.split(".", 1)[0].upper() + if base in ("CON", "PRN", "AUX", "NUL"): + return True + return ( + len(base) == 4 + and base[:3] in ("COM", "LPT") + and base[3] in "123456789¹²³" + ) + + +def _validated_timeout(value: float, name: str) -> float: + try: + timeout = float(value) + except (TypeError, ValueError) as error: + raise ValueError(name + " timeout must be a number") from error + if not math.isfinite(timeout) or timeout <= 0 or timeout > MAX_COMMAND_TIMEOUT: + raise ValueError(name + " timeout must be in (0, 60] seconds") + return timeout + + +def _validated_integer( + value: int, + minimum: int, + maximum: int, + name: str, + *, + exclude_zero: bool = False, +) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < minimum + or value > maximum + or (exclude_zero and value == 0) + ): + zero_note = " and cannot be zero" if exclude_zero else "" + raise ValueError( + name + + " must be an integer in " + + str(minimum) + + ".." + + str(maximum) + + zero_note + ) + return value + + +def _validated_duration(value: float, name: str) -> float: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value < 0 + or value > MAX_COMMAND_TIMEOUT + ): + raise ValueError( + name + + " must be a finite number in 0.." + + str(MAX_COMMAND_TIMEOUT) + + " seconds" + ) + return float(value) + + +def _sleep_for_duration(duration: float) -> None: + deadline = time.monotonic() + duration + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(remaining) + + +def _validated_capabilities(values: Sequence[str]) -> Tuple[str, ...]: + if isinstance(values, (str, bytes)): + raise ValueError("required capabilities must be a sequence of names") + capabilities = tuple(values) + for name in capabilities: + if not isinstance(name, str) or name not in CAPABILITY_NAMES: + raise ValueError("unknown required capability: " + str(name)) + if len(set(capabilities)) != len(capabilities): + raise ValueError("required capabilities cannot contain duplicates") + return capabilities + + +def _validated_lcd( + value: Optional[Tuple[int, int, int]], +) -> Optional[Tuple[int, int, int]]: + if value is None: + return None + if not isinstance(value, tuple) or len(value) != 3: + raise ValueError("expected LCD must be a width/height/depth tuple") + width, height, depth = value + if ( + not isinstance(width, int) + or isinstance(width, bool) + or not isinstance(height, int) + or isinstance(height, bool) + or not isinstance(depth, int) + or isinstance(depth, bool) + or width <= 0 + or height <= 0 + or width > 65535 + or height > 65535 + or depth not in (1, 4, 16) + ): + raise ValueError("expected LCD dimensions are invalid") + return value diff --git a/tools/ui-harness/fixtures/.gitattributes b/tools/ui-harness/fixtures/.gitattributes new file mode 100644 index 00000000000..c0def32d0cd --- /dev/null +++ b/tools/ui-harness/fixtures/.gitattributes @@ -0,0 +1 @@ +tx16s/settings/**/*.yml text eol=crlf whitespace=-trailing-space diff --git a/tools/ui-harness/fixtures/README.md b/tools/ui-harness/fixtures/README.md new file mode 100644 index 00000000000..621212688b7 --- /dev/null +++ b/tools/ui-harness/fixtures/README.md @@ -0,0 +1,6 @@ +# Simulator UI fixtures + +The minimal TX16S settings template is derived from the fixture contributed by +Mateusz Urban (`onliner10`) in [EdgeTX pull request #7337](https://github.com/EdgeTX/edgetx/pull/7337). +It remains an immutable template: every flow copies `settings/` and `sdcard/` +into a unique run directory before launching the simulator. diff --git a/tools/ui-harness/fixtures/tx16s/sdcard/.gitkeep b/tools/ui-harness/fixtures/tx16s/sdcard/.gitkeep new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tools/ui-harness/fixtures/tx16s/sdcard/.gitkeep @@ -0,0 +1 @@ + diff --git a/tools/ui-harness/fixtures/tx16s/settings/MODELS/labels.yml b/tools/ui-harness/fixtures/tx16s/settings/MODELS/labels.yml new file mode 100644 index 00000000000..24d9be7fd16 --- /dev/null +++ b/tools/ui-harness/fixtures/tx16s/settings/MODELS/labels.yml @@ -0,0 +1,10 @@ +Labels: + "Favorites": +Sort: 1 +Models: + model1.yml: + hash: "64120000115d699f" + name: "MODEL01" + labels: "" + bitmap: "" + lastopen: 1786996758 diff --git a/tools/ui-harness/fixtures/tx16s/settings/MODELS/model1.yml b/tools/ui-harness/fixtures/tx16s/settings/MODELS/model1.yml new file mode 100644 index 00000000000..a103312f732 --- /dev/null +++ b/tools/ui-harness/fixtures/tx16s/settings/MODELS/model1.yml @@ -0,0 +1,264 @@ +semver: 3.0.0 +header: + name: "MODEL01" + bitmap: "" + labels: "" +telemetryProtocol: 0 +thrTrim: 0 +noGlobalFunctions: 0 +displayTrims: 0 +ignoreSensorIds: 0 +trimInc: 0 +disableThrottleWarning: 0 +displayChecklist: 0 +extendedLimits: 0 +extendedTrims: 0 +throttleReversed: 0 +enableCustomThrottleWarning: 0 +disableTelemetryWarning: 0 +showInstanceIds: 0 +checklistInteractive: 0 +customThrottleWarningPosition: 0 +beepANACenter: 0 +mixData: + - + destCh: 0 + srcRaw: "I0" + carryTrim: 0 + mixWarn: 0 + mltpx: ADD + delayPrec: 0 + speedPrec: 0 + flightModes: 000000000 + weight: 100 + offset: 0 + swtch: "NONE" + delayUp: 0 + delayDown: 0 + speedUp: 0 + speedDown: 0 + name: "" + - + destCh: 1 + srcRaw: "I1" + carryTrim: 0 + mixWarn: 0 + mltpx: ADD + delayPrec: 0 + speedPrec: 0 + flightModes: 000000000 + weight: 100 + offset: 0 + swtch: "NONE" + delayUp: 0 + delayDown: 0 + speedUp: 0 + speedDown: 0 + name: "" + - + destCh: 2 + srcRaw: "I2" + carryTrim: 0 + mixWarn: 0 + mltpx: ADD + delayPrec: 0 + speedPrec: 0 + flightModes: 000000000 + weight: 100 + offset: 0 + swtch: "NONE" + delayUp: 0 + delayDown: 0 + speedUp: 0 + speedDown: 0 + name: "" + - + destCh: 3 + srcRaw: "I3" + carryTrim: 0 + mixWarn: 0 + mltpx: ADD + delayPrec: 0 + speedPrec: 0 + flightModes: 000000000 + weight: 100 + offset: 0 + swtch: "NONE" + delayUp: 0 + delayDown: 0 + speedUp: 0 + speedDown: 0 + name: "" +expoData: + - + mode: 3 + scale: 0 + trimSource: 0 + srcRaw: "Ail" + weight: 100 + offset: 0 + swtch: "NONE" + curve: + type: 1 + value: 0 + chn: 0 + flightModes: 000000000 + name: "" + - + mode: 3 + scale: 0 + trimSource: 0 + srcRaw: "Ele" + weight: 100 + offset: 0 + swtch: "NONE" + curve: + type: 1 + value: 0 + chn: 1 + flightModes: 000000000 + name: "" + - + mode: 3 + scale: 0 + trimSource: 0 + srcRaw: "Thr" + weight: 100 + offset: 0 + swtch: "NONE" + curve: + type: 1 + value: 0 + chn: 2 + flightModes: 000000000 + name: "" + - + mode: 3 + scale: 0 + trimSource: 0 + srcRaw: "Rud" + weight: 100 + offset: 0 + swtch: "NONE" + curve: + type: 1 + value: 0 + chn: 3 + flightModes: 000000000 + name: "" +thrTraceSrc: Thr +switchWarning: + SA: + pos: up + SB: + pos: up + SC: + pos: up + SD: + pos: up + SE: + pos: up + SF: + pos: up + SG: + pos: up +rssiSource: none +rfAlarms: + warning: 45 + critical: 42 +thrTrimSw: 0 +potsWarnMode: WARN_OFF +jitterFilter: GLOBAL +inputNames: + 0: + val: "Ail" + 1: + val: "Ele" + 2: + val: "Thr" + 3: + val: "Rud" +potsWarnEnabled: 0 +screenData: + 0: + LayoutId: Layout2P1 + layoutData: + options: + 0: + type: Bool + value: + boolValue: 1 + 1: + type: Bool + value: + boolValue: 1 + 2: + type: Bool + value: + boolValue: 1 + 3: + type: Bool + value: + boolValue: 1 + 4: + type: Bool + value: + boolValue: 0 +topbarData: + zones: + 3: + widgetName: Internal GPS + widgetData: + 4: + widgetName: Radio Info + widgetData: + options: + 0: + type: Color + value: + color: 0xF04030 + 1: + type: Color + value: + color: 0xF8C000 + 2: + type: Color + value: + color: 0x48AC50 + 5: + widgetName: Date Time + widgetData: + options: + 0: + type: Color + value: + color: COLIDX1 +topbarWidgetWidth: + 0: + val: 1 + 1: + val: 1 + 2: + val: 1 + 3: + val: 1 + 4: + val: 1 + 5: + val: 1 +view: 0 +modelRegistrationID: "-eC!U*U*" +usbJoystickExtMode: 0 +usbJoystickIfMode: JOYSTICK +usbJoystickCircularCut: 0 +radioThemesDisabled: GLOBAL +radioGFDisabled: GLOBAL +radioTrainerDisabled: GLOBAL +modelHeliDisabled: GLOBAL +modelFMDisabled: GLOBAL +modelCurvesDisabled: GLOBAL +modelGVDisabled: GLOBAL +modelLSDisabled: GLOBAL +modelSFDisabled: GLOBAL +modelCustomScriptsDisabled: GLOBAL +modelTelemetryDisabled: GLOBAL diff --git a/tools/ui-harness/fixtures/tx16s/settings/RADIO/radio.yml b/tools/ui-harness/fixtures/tx16s/settings/RADIO/radio.yml new file mode 100644 index 00000000000..92dcaf0d19d --- /dev/null +++ b/tools/ui-harness/fixtures/tx16s/settings/RADIO/radio.yml @@ -0,0 +1,236 @@ +checksum: 50025 +manuallyEdited: 0 +timezoneMinutes: 0 +ppmunit: 0 +semver: 3.0.0 +board: tx16s +calib: + LH: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + LV: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + RV: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + RH: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + P1: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + P2: + mid: 1023 + spanNeg: 5388 + spanPos: 9758 + P3: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + SL1: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + SL2: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + EXT1: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + EXT2: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + EXT3: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 + EXT4: + mid: 1023 + spanNeg: 1008 + spanPos: 1008 +vBatWarn: 66 +txVoltageCalibration: 0 +backlightMode: backlight_mode_all +antennaMode: MODE_PER_MODEL +disableRtcWarning: 0 +keysBacklight: 0 +dontPlayHello: 0 +internalModule: TYPE_MULTIMODULE +trainer: + mix: + 0: + srcChn: 3 + mode: REPL + studWeight: 100 + 1: + srcChn: 1 + mode: REPL + studWeight: 100 + 2: + srcChn: 2 + mode: REPL + studWeight: 100 + 3: + srcChn: 0 + mode: REPL + studWeight: 100 +view: 0 +fai: 0 +beepMode: mode_nokeys +alarmsFlash: 0 +disableMemoryWarning: 0 +disableAlarmWarning: 0 +stickMode: 1 +timezone: 0 +adjustRTC: 0 +inactivityTimer: 10 +internalModuleBaudrate: 0 +splashMode: 0 +hapticMode: mode_nokeys +switchesDelay: 0 +lightAutoOff: 2 +templateSetup: 21 +PPM_Multiplier: 0 +hapticLength: 2 +beepLength: 2 +hapticStrength: 2 +gpsFormat: 0 +audioMuteEnable: 1 +speakerPitch: 0 +speakerVolume: 12 +vBatMin: 67 +vBatMax: 83 +backlightBright: 0 +globalTimer: 0 +bluetoothBaudrate: 0 +bluetoothMode: OFF +countryCode: 0 +pwrOnSpeed: 0 +pwrOffSpeed: 0 +noJitterFilter: 0 +imperial: 0 +disableRssiPoweroffAlarm: 0 +USBMode: 0 +jackMode: 0 +ttsLanguage: "en" +uiLanguage: "en" +beepVolume: 2 +wavVolume: 4 +varioVolume: 2 +backgroundVolume: 3 +varioPitch: 0 +varioRange: 0 +varioRepeat: 0 +potsConfig: + P1: + type: with_detent + inv: 0 + name: "" + P2: + type: multipos_switch + inv: 0 + name: "" + P3: + type: with_detent + inv: 0 + name: "" + SL1: + type: slider + inv: 0 + name: "" + SL2: + type: slider + inv: 0 + name: "" +switchConfig: + SA: + name: "" + type: 3POS + SB: + name: "" + type: 3POS + SC: + name: "" + type: 3POS + SD: + name: "" + type: 3POS + SE: + name: "" + type: 3POS + SF: + name: "" + type: 2POS + SG: + name: "" + type: 3POS + SH: + name: "" + type: TOGGLE + SI: + name: "" + type: NONE + SJ: + name: "" + type: NONE + FL1: + name: "" + type: NONE + FL2: + name: "" + type: NONE +currModelFilename: "model1.yml" +blOffBright: 20 +bluetoothName: "" +ownerRegistrationID: "-eC!U*U*" +rotEncMode: 0 +uartSampleMode: 0 +imuMax: 0 +imuOffset: 0 +imuInvert: 0 +selectedTheme: "EdgeTX Default" +backlightSrc: "NONE" +radioGFDisabled: 0 +radioTrainerDisabled: 0 +modelHeliDisabled: 0 +modelFMDisabled: 0 +modelCurvesDisabled: 0 +modelGVDisabled: 0 +volumeSrc: "NONE" +modelLSDisabled: 0 +modelSFDisabled: 0 +modelCustomScriptsDisabled: -1 +modelTelemetryDisabled: 0 +disableTrainerPoweroffAlarm: 0 +disablePwrOnOffHaptic: 0 +modelQuickSelect: 0 +oneLogPerDay: 0 +keyLockEnabled: 0 +labelSingleSelect: 0 +labelMultiMode: 0 +favMultiMode: 0 +modelSelectLayout: 0 +radioThemesDisabled: 0 +pwrOffIfInactive: 0 +keyShortcuts: + 0: + shortcut: MODEL_SETUP + 1: + shortcut: OPEN_QUICK_MENU + 2: + shortcut: UI_SCREEN1 + 3: + shortcut: MANAGE_MODELS + 4: + shortcut: TOOLS_APPS + 5: + shortcut: TOOLS_CHAN_MON diff --git a/tools/ui-harness/flows/tx16s-smoke.json b/tools/ui-harness/flows/tx16s-smoke.json new file mode 100644 index 00000000000..540bb3c8c91 --- /dev/null +++ b/tools/ui-harness/flows/tx16s-smoke.json @@ -0,0 +1,71 @@ +{ + "schema": 1, + "target": "tx16s", + "requires": [ + "rotary", + "touch", + "switches", + "analog", + "telemetry", + "lua", + "capture" + ], + "steps": [ + { + "action": "wait-ready", + "timeout_ms": 10000 + }, + { + "action": "press", + "key": "ENTER", + "hold_ms": 120 + }, + { + "action": "rotate", + "steps": 1 + }, + { + "action": "tap", + "x": 240, + "y": 136, + "hold_ms": 80 + }, + { + "action": "set-switch", + "name": "SA", + "position": 1 + }, + { + "action": "set-analog", + "name": "Ail", + "value": 2048 + }, + { + "action": "set-telemetry", + "id": 61696, + "sub_id": 0, + "instance": 1, + "value": 115, + "unit": 1, + "precision": 1, + "name": "RSSI" + }, + { + "action": "reload-lua", + "timeout_ms": 10000 + }, + { + "action": "wait-next-frame", + "timeout_ms": 3000 + }, + { + "action": "capture", + "name": "home", + "format": "png", + "timeout_ms": 5000 + }, + { + "action": "release-all" + } + ] +} diff --git a/tools/ui-harness/tests/fake_simulator.py b/tools/ui-harness/tests/fake_simulator.py new file mode 100644 index 00000000000..046bf1e936b --- /dev/null +++ b/tools/ui-harness/tests/fake_simulator.py @@ -0,0 +1,458 @@ +"""Small subprocess fixture for portable session lifecycle tests.""" + +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path, PurePosixPath +from typing import Any, Dict, List, Optional + + +def emit(payload: Dict[str, Any], *, fragmented: bool = False) -> None: + record = ( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + ).encode("utf-8") + if fragmented: + boundaries = (1, 4, 9, len(record)) + start = 0 + for boundary in boundaries: + sys.stdout.buffer.write(record[start:boundary]) + sys.stdout.buffer.flush() + start = boundary + return + sys.stdout.buffer.write(record) + sys.stdout.buffer.flush() + + +def is_phase4(mode: str) -> bool: + return ( + mode.startswith("phase4") + or mode.startswith("phase5") + or mode.startswith("phase6") + ) + + +def is_phase5(mode: str) -> bool: + return mode.startswith("phase5") or mode.startswith("phase6") + + +def is_phase6(mode: str) -> bool: + return mode.startswith("phase6") + + +def capabilities(mode: str) -> Dict[str, bool]: + return { + "rotary": is_phase4(mode), + "touch": is_phase4(mode), + "switches": is_phase6(mode), + "analog": is_phase6(mode), + "telemetry": is_phase6(mode), + "lua": is_phase6(mode), + "capture": is_phase5(mode), + "warm_restart": is_phase6(mode), + } + + +def description(mode: str) -> Dict[str, Any]: + commands = ["ping", "status", "describe", "stop"] + if is_phase4(mode): + commands = [ + "ping", + "status", + "describe", + "key-down", + "key-up", + "rotate", + "touch-down", + "touch-move", + "touch-up", + "wait-frame", + "release-all", + "stop", + ] + if is_phase5(mode): + commands.insert(-2, "capture") + if is_phase6(mode): + release_index = commands.index("release-all") + commands[release_index:release_index] = [ + "set-switch", + "set-analog", + "clear-analog", + "set-telemetry", + "reload-lua", + "restart", + ] + if mode == "missing-command": + commands.remove("status") + result: Dict[str, Any] = { + "protocol_version": 1, + "target": "test-target", + "lcd": {"width": 480, "height": 272, "depth": 16}, + "commands": commands, + "capabilities": capabilities(mode), + "keys": ["EXIT", "ENTER"] if is_phase4(mode) else [], + "switches": ( + [ + {"name": "SA", "min": -1, "max": 1}, + {"name": "SH", "min": -1, "max": 1}, + ] + if is_phase6(mode) + else [] + ), + "analogs": ( + [ + {"name": "AIL", "min": 0, "max": 4096}, + {"name": "P1", "min": 0, "max": 4096}, + ] + if is_phase6(mode) + else [] + ), + } + if mode == "bad-description": + result["capabilities"]["capture"] = "false" + return result + + +def status( + mode: str, poll: int, state: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + ready = mode != "never-ready" and not ( + mode == "starting-then-ready" and poll < 3 + ) + target = "other-target" if mode == "status-mismatch" else "test-target" + result: Dict[str, Any] = { + "protocol_version": 1, + "running": True, + "phase": "ready" if ready else "starting", + "target": target, + "lcd": {"width": 480, "height": 272, "depth": 16}, + "display_seq": ( + int(state["display_seq"]) + if state is not None and ready + else (1 if ready else 0) + ), + "async_operation": "none", + "request_queue_depth": 0, + "firmware_mailbox_depth": 0, + "line_overflow_count": 0, + "queue_overflow_count": 0, + "stale_completion_count": 0, + "active_key_count": len(state["keys"]) if state is not None else 0, + "touch_active": bool(state["touch"]) if state is not None else False, + "analog_override_count": ( + len(state["analogs"]) if state is not None else 0 + ), + "lua_state": ( + str(state["lua_state"]) + if state is not None and is_phase6(mode) + else "unavailable" + ), + "capabilities": capabilities(mode), + "output_root": "invalid" if mode == "output-invalid" else "ready", + } + if mode == "bad-ready": + result["phase"] = "ready" + result["display_seq"] = 0 + return result + + +def response( + request_id: int, + command: str, + mode: str, + status_poll: int, + state: Optional[Dict[str, Any]] = None, + *, + ok: bool = True, +) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "version": 1, + "type": "response", + "id": request_id, + "ok": ok, + "epoch": ( + int(state["epoch"]) + if state is not None + else (1 if command in ("status", "stop") else 0) + ), + } + if not ok: + payload["error"] = { + "code": "unsupported_command", + "message": "fixture rejection", + } + elif command == "describe": + payload["result"] = description(mode) + elif command == "status": + payload["result"] = status(mode, status_poll, state) + if payload["result"]["phase"] != "ready": + payload["epoch"] = 0 + else: + payload["result"] = {} + return payload + + +def phase4_response( + request_id: int, + command: str, + arguments: List[str], + mode: str, + status_poll: int, + state: Dict[str, Any], + output_root: Path, + settings_root: Optional[Path], +) -> Dict[str, Any]: + payload = response(request_id, command, mode, status_poll, state) + payload["epoch"] = int(state["epoch"]) + + def fail(code: str, message: str) -> Dict[str, Any]: + payload["ok"] = False + payload["error"] = {"code": code, "message": message} + payload.pop("result", None) + return payload + + if command == "key-down": + key = arguments[0] + if key not in ("EXIT", "ENTER"): + return fail("unsupported_target", "unsupported key") + if key in state["keys"]: + return fail("key_already_down", "key already down") + state["keys"].add(key) + elif command == "key-up": + key = arguments[0] + if key not in state["keys"]: + return fail("key_not_down", "key not down") + if mode == "phase4-release-error": + return fail("internal_error", "injected key release failure") + state["keys"].remove(key) + elif command == "touch-down": + if state["touch"]: + return fail("touch_already_down", "touch already down") + state["touch"] = True + elif command == "touch-move": + if not state["touch"]: + return fail("touch_not_down", "touch not down") + elif command == "touch-up": + if not state["touch"]: + return fail("touch_not_down", "touch not down") + if mode == "phase4-release-error": + return fail("internal_error", "injected touch release failure") + state["touch"] = False + elif command == "rotate": + state["visual_generation"] = int(state["visual_generation"]) + 1 + elif command == "release-all": + state["keys"].clear() + state["touch"] = False + state["analogs"].clear() + elif command == "set-switch": + name = arguments[0] + position = int(arguments[1]) + if name not in state["switches"]: + return fail("unsupported_target", "unsupported switch") + if position not in (-1, 0, 1) or (name == "SH" and position == 0): + return fail("out_of_range", "unsupported switch position") + state["switches"][name] = position + elif command == "set-analog": + name = arguments[0] + if name not in ("AIL", "P1"): + return fail("unsupported_target", "unsupported analog") + state["analogs"][name] = int(arguments[1]) + elif command == "clear-analog": + name = arguments[0] + if name == "all": + state["analogs"].clear() + elif name in ("AIL", "P1"): + state["analogs"].pop(name, None) + else: + return fail("unsupported_target", "unsupported analog") + elif command == "set-telemetry": + state["telemetry"].append(tuple(arguments)) + if settings_root is not None: + (settings_root / "telemetry.marker").write_text( + " ".join(arguments), encoding="utf-8" + ) + elif command == "reload-lua": + state["lua_generation"] = int(state["lua_generation"]) + 1 + if mode == "phase6-lua-panic": + state["lua_state"] = "panic" + return fail("lua_panic", "fixture Lua panic") + state["lua_state"] = "running" + payload["result"] = { + "generation": ( + 0 + if mode == "phase6-bad-lua" + else state["lua_generation"] + ), + "state": "running", + } + elif command == "restart": + state["keys"].clear() + state["touch"] = False + state["analogs"].clear() + state["switches"] = {"SA": -1, "SH": -1} + if mode != "phase6-bad-restart": + state["epoch"] = int(state["epoch"]) + 1 + state["display_seq"] = int(state["display_seq"]) + 1 + payload["epoch"] = int(state["epoch"]) + payload["result"] = {"display_seq": int(state["display_seq"])} + elif command == "wait-frame": + minimum = int(arguments[0]) + state["display_seq"] = max(int(state["display_seq"]), minimum) + completed = state["display_seq"] + if mode == "phase4-bad-frame" and minimum > 0: + completed = minimum - 1 + payload["result"] = {"display_seq": completed} + elif command == "capture": + relative_path = " ".join(arguments) + target = output_root.joinpath(*PurePosixPath(relative_path).parts) + state["display_seq"] = int(state["display_seq"]) + 1 + rgb = b"\x20\x40\x60" + if "ENTER" in state["keys"] or state["visual_generation"]: + rgb = b"\xe0\x30\x10" + header = b"P6\n480 272\n255\n" + with target.open("xb") as stream: + stream.write(header) + stream.write(rgb * (480 * 272)) + payload["result"] = { + "display_seq": state["display_seq"], + "path": relative_path, + "width": 480, + "height": 272, + "depth": 16, + "bytes": len(header) + 480 * 272 * 3, + } + if mode == "phase5-bad-capture": + payload["result"]["display_seq"] -= 1 + elif command not in ( + "ping", + "status", + "describe", + "rotate", + "stop", + ): + return fail("unsupported_command", "unsupported fixture command") + return payload + + +def main() -> int: + mode = sys.argv[1] if len(sys.argv) > 1 else "normal" + print("fake simulator started: " + mode, file=sys.stderr, flush=True) + status_poll = 0 + state: Dict[str, Any] = { + "keys": set(), + "touch": False, + "display_seq": 1, + "epoch": 1, + "switches": {"SA": -1, "SH": -1}, + "analogs": {}, + "telemetry": [], + "lua_generation": 0, + "lua_state": "running", + "visual_generation": 0, + } + output_root = Path.cwd() + if "--automation-output" in sys.argv: + output_index = sys.argv.index("--automation-output") + 1 + output_root = Path(sys.argv[output_index]).resolve(strict=True) + settings_root: Optional[Path] = None + if "--settings" in sys.argv: + settings_index = sys.argv.index("--settings") + 1 + settings_root = Path(sys.argv[settings_index]).resolve(strict=True) + if (settings_root / "startup-fail").exists(): + print("fixture startup failure", file=sys.stderr, flush=True) + return 23 + + for raw_line in sys.stdin.buffer: + fields = raw_line.decode("utf-8").rstrip("\n").split(" ") + request_id = int(fields[1]) + command = fields[2] + arguments = fields[3:] + + if mode == "malformed": + sys.stdout.buffer.write(b"not-json\n") + sys.stdout.buffer.flush() + continue + if mode == "wrong-id": + emit(response(request_id + 1, command, mode, status_poll)) + continue + if mode == "duplicate": + emit(response(request_id, command, mode, status_poll)) + emit(response(request_id, command, mode, status_poll)) + continue + if mode == "command-error": + emit(response(request_id, command, mode, status_poll, ok=False)) + continue + if mode == "crash": + print("fixture crash", file=sys.stderr, flush=True) + return 17 + if mode == "partial-eof": + sys.stdout.buffer.write(b'{"version":1') + sys.stdout.buffer.flush() + return 18 + if mode == "hang": + time.sleep(60) + continue + if mode == "stderr-flood": + for index in range(300): + print( + "diagnostic-%03d-" % index + ("x" * 1024), + file=sys.stderr, + ) + sys.stderr.flush() + if mode == "event": + emit( + { + "version": 1, + "type": "event", + "id": None, + "epoch": 0, + "event": {"code": "queue_full", "message": "fixture event"}, + } + ) + + if command == "status": + status_poll += 1 + if is_phase4(mode): + if mode == "phase4-wait-hang" and command == "wait-frame": + time.sleep(60) + continue + emit( + phase4_response( + request_id, + command, + arguments, + mode, + status_poll, + state, + output_root, + settings_root, + ) + ) + if command == "stop": + return 0 + continue + emit( + response(request_id, command, mode, status_poll), + fragmented=mode == "fragmented", + ) + if mode == "ready-no-read" and command == "status": + try: + import fcntl + + fcntl.fcntl(0, fcntl.F_SETPIPE_SZ, 4096) + except (ImportError, AttributeError, OSError): + pass + time.sleep(60) + continue + if mode == "exit-after-ping" and command == "ping": + return 0 + if command == "stop": + if mode == "ignore-stop": + time.sleep(60) + return 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ui-harness/tests/test_flow.py b/tools/ui-harness/tests/test_flow.py new file mode 100644 index 00000000000..1a2db6ed16b --- /dev/null +++ b/tools/ui-harness/tests/test_flow.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from typing import Any + + +HARNESS_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = HARNESS_ROOT.parents[1] +sys.path.insert(0, str(HARNESS_ROOT)) + +from edgetx_ui.cli import main # noqa: E402 +from edgetx_ui.flow import ( # noqa: E402 + FlowExecutionError, + FlowRunner, + FlowValidationError, + load_flow, +) +from edgetx_ui.protocol import Response # noqa: E402 +from edgetx_ui.session import MAX_PROTOCOL_RECORDS # noqa: E402 + + +def _response(request_id: int, command: str) -> Response: + raw = { + "version": 1, + "type": "response", + "id": request_id, + "epoch": 1, + "ok": True, + "result": {"command": command}, + } + return Response(request_id, True, 1, raw["result"], None, None, raw) + + +class _FlowSession: + instances: list["_FlowSession"] = [] + + def __init__(self, executable: str, output_root: Path, **options: Any) -> None: + self.executable = executable + self.output_root = Path(output_root) + self.options = options + self.protocol_records: list[dict[str, Any]] = [] + self.protocol_sink_path = Path(options["protocol_sink"]) + self._protocol_stream = self.protocol_sink_path.open("xb") + self._protocol_hash = hashlib.sha256() + self.protocol_record_count = 0 + self.protocol_records_dropped = 0 + self.recent_stderr = "bounded fixture diagnostic" + self.returncode = None + self.termination_stage = "not-started" + self.status_response = None + self.description = SimpleNamespace( + target="tx16s", + lcd=SimpleNamespace(width=480, height=272, depth=16), + ) + self._next_id = 1 + type(self).instances.append(self) + + @property + def protocol_sha256(self) -> str: + return self._protocol_hash.hexdigest() + + def _record_protocol(self, record: dict[str, Any]) -> None: + encoded = ( + json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + "\n" + ).encode("utf-8") + if len(self.protocol_records) == MAX_PROTOCOL_RECORDS: + self.protocol_records.pop(0) + self.protocol_records_dropped += 1 + self.protocol_records.append(record) + self._protocol_stream.write(encoded) + self._protocol_hash.update(encoded) + self.protocol_record_count += 1 + + def _reply(self, command: str) -> Response: + request_id = self._next_id + self._next_id += 1 + request = { + "version": 1, + "id": request_id, + "command": command, + "args": [], + } + response = _response(request_id, command) + self._record_protocol({"direction": "request", "message": request}) + self._record_protocol({"direction": "response", "message": response.raw}) + return response + + def start(self, **options: Any) -> Response: + self.termination_stage = "running" + self.status_response = self._reply("wait-ready") + return self.status_response + + def release_all(self, **options: Any) -> Response: + return self._reply("release-all") + + def capture_png(self, path: str, **options: Any) -> dict[str, str]: + target = self.output_root / Path(path) + target.with_suffix(".ppm").write_bytes(b"P6\n1 1\n255\n\x00\x00\x00") + target.write_bytes(b"test-png") + target.with_suffix(".capture.json").write_text("{}\n", encoding="utf-8") + self._reply("capture") + return {"path": path} + + def stop(self, **options: Any) -> Response: + if self.termination_stage != "graceful": + response = self._reply("stop") + self.returncode = 0 + self.termination_stage = "graceful" + self._protocol_stream.close() + return response + return self._reply("stop") + + def close(self) -> None: + self.returncode = 0 + self.termination_stage = "closed" + if not self._protocol_stream.closed: + self._protocol_stream.close() + + +class _FailingFlowSession(_FlowSession): + def release_all(self, **options: Any) -> Response: + raise RuntimeError("deliberate step failure at " + str(self.output_root)) + + +class _PreparationFailingFlowSession(_FlowSession): + def __init__(self, executable: str, output_root: Path, **options: Any) -> None: + raise RuntimeError("deliberate preparation failure") + + +class _ChattyFlowSession(_FlowSession): + def start(self, **options: Any) -> Response: + for _ in range(MAX_PROTOCOL_RECORDS): + self._reply("ping") + return super().start(**options) + + +class FlowScenarioTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary_directory.cleanup) + self.root = Path(self.temporary_directory.name) + self.fixture = self.root / "fixture" + (self.fixture / "settings").mkdir(parents=True) + (self.fixture / "sdcard").mkdir() + (self.fixture / "settings" / "radio.yml").write_text( + "fixture: immutable\n", encoding="utf-8" + ) + self.runs = self.root / "runs" + _FlowSession.instances.clear() + + def write_flow(self, payload: dict[str, Any], name: str = "flow.json") -> Path: + path = self.root / name + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + def minimal_payload(self) -> dict[str, Any]: + return { + "schema": 1, + "target": "tx16s", + "requires": ["capture"], + "steps": [ + {"action": "wait-ready", "timeout_ms": 1000}, + {"action": "release-all"}, + ], + } + + def runner(self, flow_path: Path, session_factory: Any = _FlowSession) -> FlowRunner: + return FlowRunner( + load_flow(flow_path), + self.fixture, + self.runs, + "fixture-simulator", + session_factory=session_factory, + ) + + def test_q01_strict_schema_accepts_only_the_canonical_shape(self) -> None: + flow = load_flow(self.write_flow(self.minimal_payload())) + self.assertEqual(flow.schema, 1) + self.assertEqual(flow.target, "tx16s") + duplicate = self.root / "duplicate.json" + duplicate.write_text( + '{"schema":1,"schema":1,"target":"tx16s","requires":[],"steps":[]}', + encoding="utf-8", + ) + with self.assertRaisesRegex(FlowValidationError, "duplicate JSON key"): + load_flow(duplicate) + + def test_q02_unknown_top_level_and_step_fields_are_rejected(self) -> None: + top = self.minimal_payload() | {"output": "somewhere"} + with self.assertRaisesRegex(FlowValidationError, "unknown output"): + load_flow(self.write_flow(top, "unknown-top.json")) + step = self.minimal_payload() + step["steps"][1]["surprise"] = True + with self.assertRaisesRegex(FlowValidationError, "unknown surprise"): + load_flow(self.write_flow(step, "unknown-step.json")) + + def test_q03_target_ranges_are_rejected_before_session_creation(self) -> None: + payload = self.minimal_payload() + payload["steps"][1] = {"action": "tap", "x": 480, "y": 0, "hold_ms": 1} + with self.assertRaisesRegex(FlowValidationError, "x must be"): + load_flow(self.write_flow(payload)) + self.assertEqual(_FlowSession.instances, []) + + def test_q04_required_capabilities_and_target_are_startup_contracts(self) -> None: + path = self.write_flow(self.minimal_payload()) + self.runner(path).run() + options = _FlowSession.instances[-1].options + self.assertEqual(options["required_capabilities"], ("capture",)) + self.assertEqual(options["expected_target"], "tx16s") + self.assertEqual(options["expected_lcd"], (480, 272, 16)) + + def test_q05_fixture_template_is_never_used_as_writable_state(self) -> None: + before = hashlib.sha256((self.fixture / "settings" / "radio.yml").read_bytes()).hexdigest() + result = self.runner(self.write_flow(self.minimal_payload())).run() + after = hashlib.sha256((self.fixture / "settings" / "radio.yml").read_bytes()).hexdigest() + self.assertEqual(before, after) + self.assertTrue((result.run_directory / "settings" / "radio.yml").is_file()) + manifest = json.loads(result.manifest.read_text(encoding="utf-8")) + self.assertTrue(manifest["fixture"]["unchanged"]) + + def test_q06_each_execution_owns_a_unique_run_directory(self) -> None: + path = self.write_flow(self.minimal_payload()) + first = self.runner(path).run() + second = self.runner(path).run() + self.assertNotEqual(first.run_directory, second.run_directory) + self.assertTrue(first.run_directory.is_dir()) + self.assertTrue(second.run_directory.is_dir()) + + def test_q07_manifest_protocol_and_artifact_hashes_are_verified(self) -> None: + payload = self.minimal_payload() + payload["steps"].insert( + 1, + {"action": "capture", "name": "home", "format": "png", "timeout_ms": 1000}, + ) + result = self.runner(self.write_flow(payload)).run() + manifest = json.loads(result.manifest.read_text(encoding="utf-8")) + self.assertTrue(manifest["success"]) + self.assertGreater(manifest["protocol"]["records"], 0) + self.assertEqual(manifest["protocol"]["path"], "protocol.jsonl") + paths = {item["path"]: item for item in manifest["artifacts"]} + for required in ( + "protocol.jsonl", + "stderr.log", + "artifacts/checkpoints/home.ppm", + "artifacts/checkpoints/home.png", + "artifacts/checkpoints/home.capture.json", + ): + artifact = result.run_directory / required + self.assertEqual( + hashlib.sha256(artifact.read_bytes()).hexdigest(), + paths[required]["sha256"], + ) + + def test_protocol_evidence_streams_without_embedding_records(self) -> None: + result = self.runner( + self.write_flow(self.minimal_payload()), _ChattyFlowSession + ).run() + manifest_bytes = result.manifest.read_bytes() + manifest = json.loads(manifest_bytes) + protocol = manifest["protocol"] + evidence = result.run_directory / protocol["path"] + + self.assertEqual(protocol["records"], MAX_PROTOCOL_RECORDS * 2 + 6) + self.assertEqual(protocol["bytes"], evidence.stat().st_size) + self.assertEqual( + protocol["sha256"], hashlib.sha256(evidence.read_bytes()).hexdigest() + ) + self.assertEqual(len(protocol["generations"]), 1) + self.assertGreater( + protocol["generations"][0]["diagnostic_records_dropped"], 0 + ) + self.assertNotIn(b'"direction": "request"', manifest_bytes) + self.assertLess(len(manifest_bytes), 64 * 1024) + + def test_q08_failed_step_and_bounded_stderr_survive_in_manifest(self) -> None: + runner = self.runner(self.write_flow(self.minimal_payload()), _FailingFlowSession) + with self.assertRaises(FlowExecutionError) as caught: + runner.run() + manifest = json.loads(caught.exception.result.manifest.read_text(encoding="utf-8")) + self.assertFalse(manifest["success"]) + self.assertEqual(manifest["failure"]["step"], 1) + self.assertEqual(manifest["failure"]["action"], "release-all") + self.assertNotIn(self.root.as_posix(), json.dumps(manifest).replace("\\", "/")) + self.assertIn("bounded fixture diagnostic", (caught.exception.result.run_directory / "stderr.log").read_text()) + + def test_q09_checked_in_smoke_is_the_documented_phase7_contract(self) -> None: + flow = load_flow(HARNESS_ROOT / "flows" / "tx16s-smoke.json") + actions = {step["action"] for step in flow.steps} + self.assertTrue( + { + "wait-ready", "press", "rotate", "tap", "set-switch", "set-analog", + "set-telemetry", "reload-lua", "wait-next-frame", "capture", "release-all", + }.issubset(actions) + ) + readme = (HARNESS_ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn("edgetx-ui smoke", readme) + + def test_q10_cli_returns_nonzero_and_preserves_manifest_on_failure(self) -> None: + path = self.write_flow(self.minimal_payload()) + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + exit_code = main( + [ + "run-flow", str(path), "--fixture", str(self.fixture), + "--runs", str(self.runs), str(self.root / "missing-simulator"), + ] + ) + self.assertEqual(exit_code, 2) + payload = json.loads(stderr.getvalue()) + self.assertFalse(payload["ok"]) + self.assertTrue(Path(payload["manifest"]).is_file()) + + def test_preparation_failure_removes_the_incomplete_run_directory(self) -> None: + runner = self.runner( + self.write_flow(self.minimal_payload()), _PreparationFailingFlowSession + ) + with self.assertRaisesRegex(RuntimeError, "preparation failure"): + runner.run() + self.assertEqual(list(self.runs.iterdir()), []) + + def test_manifest_redacts_host_paths_and_normalizes_the_command(self) -> None: + result = FlowRunner( + load_flow(self.write_flow(self.minimal_payload())), + self.fixture, + self.runs, + "fixture-simulator", + simulator_args=("--flag", "phase6"), + session_factory=_FlowSession, + ).run() + text = result.manifest.read_text(encoding="utf-8") + manifest = json.loads(text) + + self.assertNotIn(self.root.as_posix(), text.replace("\\", "/")) + self.assertEqual(manifest["flow"]["path"], "/flow.json") + self.assertEqual(manifest["fixture"]["path"], "") + self.assertEqual(manifest["simulator"]["command"][0], "fixture-simulator") + self.assertIn("--flag", manifest["simulator"]["command"]) + self.assertIn("phase6", manifest["simulator"]["command"]) + self.assertIn("/settings", manifest["simulator"]["command"]) + self.assertIn("/sdcard", manifest["simulator"]["command"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ui-harness/tests/test_hardening.py b/tools/ui-harness/tests/test_hardening.py new file mode 100644 index 00000000000..c35125dd171 --- /dev/null +++ b/tools/ui-harness/tests/test_hardening.py @@ -0,0 +1,491 @@ +from __future__ import annotations + +import hashlib +import json +import sys +import tempfile +import unittest +from unittest import mock +from pathlib import Path +from typing import Any + + +HARNESS_ROOT = Path(__file__).resolve().parents[1] +FAKE_SIMULATOR = Path(__file__).with_name("fake_simulator.py") +sys.path.insert(0, str(HARNESS_ROOT)) + +from edgetx_ui.hardening import ( # noqa: E402 + HardeningError, + HardeningExecutionError, + HardeningRunner, + MAX_CAPTURE_COUNT, + MAX_LIFECYCLE_CYCLES, + MAX_LUA_RELOADS, + MAX_PING_COUNT, + MAX_WARM_RESTARTS, +) +import edgetx_ui.hardening as hardening_module # noqa: E402 +from edgetx_ui.cli import build_parser # noqa: E402 +from edgetx_ui.session import SimulatorSession # noqa: E402 + + +class Phase8HardeningTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary_directory.cleanup) + self.output_root = Path(self.temporary_directory.name) + + def session(self, mode: str) -> SimulatorSession: + return SimulatorSession( + sys.executable, + self.output_root, + simulator_args=(str(FAKE_SIMULATOR), mode), + request_timeout=3.0, + stop_timeout=0.5, + terminate_timeout=0.5, + kill_timeout=0.5, + reader_join_timeout=0.5, + ) + + def assert_reaped(self, session: SimulatorSession) -> None: + self.assertEqual(session.returncode, 0) + self.assertFalse(session.reader_threads_alive) + + def test_r01_ten_thousand_requests_remain_correlated(self) -> None: + session = self.session("normal") + try: + session.start() + first = session.ping() + previous_id = first.id + for _ in range(9_999): + response = session.ping() + self.assertEqual(response.id, previous_id + 1) + self.assertTrue(response.ok) + previous_id = response.id + self.assertEqual(first.id, 4) + self.assertEqual(previous_id, 10_003) + stop = session.stop() + assert stop is not None + self.assertEqual(stop.id, 10_004) + finally: + session.close() + self.assert_reaped(session) + + def test_r02_twenty_lua_reloads_and_warm_restarts_are_monotonic(self) -> None: + session = self.session("phase6") + try: + session.start() + generations = [session.reload_lua().generation for _ in range(20)] + self.assertEqual(generations, list(range(1, 21))) + + before = session.read_status() + epochs = [] + sequences = [] + for _ in range(20): + restarted = session.restart() + epochs.append(restarted.epoch) + sequences.append(restarted.display_sequence) + self.assertEqual( + epochs, list(range(before.epoch + 1, before.epoch + 21)) + ) + self.assertEqual(sequences, sorted(set(sequences))) + final = session.read_status() + self.assertEqual(final.epoch, epochs[-1]) + self.assertEqual(final.active_key_count, 0) + self.assertFalse(final.touch_active) + self.assertEqual(final.analog_override_count, 0) + finally: + session.close() + self.assert_reaped(session) + + def test_r03_twenty_static_captures_are_identical_and_isolated(self) -> None: + session = self.session("phase5") + try: + session.start() + hashes = [] + sequences = [] + for index in range(20): + artifact = session.capture_ppm(f"static-{index:02d}.ppm") + capture_path = self.output_root / artifact.path + hashes.append(hashlib.sha256(capture_path.read_bytes()).hexdigest()) + sequences.append(artifact.display_sequence) + self.assertEqual(len(set(hashes)), 1) + self.assertEqual(sequences, sorted(set(sequences))) + + session.key_down("ENTER") + changed = session.capture_ppm("changed.ppm") + changed_hash = hashlib.sha256( + (self.output_root / changed.path).read_bytes() + ).hexdigest() + self.assertNotEqual(changed_hash, hashes[0]) + session.key_up("ENTER") + + self.assertEqual(len(list(self.output_root.glob("static-*.ppm"))), 20) + self.assertFalse(list(self.output_root.rglob("*.tmp-ui-harness"))) + finally: + session.close() + self.assert_reaped(session) + + def test_r04_runner_preserves_a_complete_machine_readable_report(self) -> None: + fixture = self.output_root / "fixture" + (fixture / "settings").mkdir(parents=True) + (fixture / "sdcard").mkdir() + (fixture / "settings" / "radio.yml").write_text( + "fixture\n", encoding="utf-8" + ) + fixture_before = hashlib.sha256( + (fixture / "settings" / "radio.yml").read_bytes() + ).hexdigest() + + result = HardeningRunner( + fixture, + self.output_root / "runs", + sys.executable, + simulator_args=(str(FAKE_SIMULATOR), "phase6"), + lifecycle_cycles=2, + ping_count=25, + lua_reloads=2, + warm_restarts=2, + capture_count=2, + expected_target="test-target", + ).run() + + report = json.loads(result.report.read_text(encoding="utf-8")) + self.assertTrue(result.success) + self.assertTrue(report["success"]) + self.assertEqual(report["lifecycle"]["completed"], 2) + self.assertEqual(report["stress"]["ping"]["completed"], 25) + self.assertEqual(report["stress"]["lua"]["generations"], [1, 2]) + self.assertEqual(report["stress"]["warm_restart"]["completed"], 2) + self.assertEqual(report["stress"]["visual"]["completed"], 2) + self.assertTrue(report["stress"]["visual"]["identical"]) + self.assertTrue(report["stress"]["visual"]["changed_differs"]) + self.assertTrue(report["cleanup"]["no_temporaries"]) + self.assertTrue(report["reap"]["all_reaped"]) + for cycle in report["lifecycle"]["cycles"]: + self.assertFalse(cycle["writer_thread_alive"]) + evidence = cycle["protocol"] + self.assertTrue(evidence["available"]) + self.assertGreater(evidence["records"], 0) + protocol = result.run_directory / evidence["path"] + self.assertEqual( + hashlib.sha256(protocol.read_bytes()).hexdigest(), + evidence["sha256"], + ) + stress_evidence = report["stress"]["protocol"] + self.assertTrue(stress_evidence["available"]) + self.assertGreater(stress_evidence["records"], 0) + self.assertNotIn("direction", stress_evidence) + self.assertFalse(report["stress"]["reap"]["writer_thread_alive"]) + self.assertTrue(report["fixture"]["unchanged"]) + self.assertEqual( + hashlib.sha256( + (fixture / "settings" / "radio.yml").read_bytes() + ).hexdigest(), + fixture_before, + ) + + def fixture(self) -> Path: + fixture = self.output_root / "failure-fixture" + (fixture / "settings").mkdir(parents=True) + (fixture / "sdcard").mkdir() + return fixture + + def test_failure_contract_preserves_report_and_cleanup_evidence(self) -> None: + fixture = self.fixture() + report_path = self.output_root / "failure-report.json" + runner = HardeningRunner( + fixture, + self.output_root / "failure-runs", + sys.executable, + report_path=report_path, + simulator_args=(str(FAKE_SIMULATOR), "command-error"), + lifecycle_cycles=1, + ping_count=0, + lua_reloads=0, + warm_restarts=0, + capture_count=0, + expected_target="test-target", + ) + + with self.assertRaises(HardeningExecutionError) as caught: + runner.run() + payload = json.loads(caught.exception.result.report.read_text(encoding="utf-8")) + self.assertFalse(payload["success"]) + self.assertEqual(payload["failure"]["stage"], "lifecycle") + self.assertEqual(payload["lifecycle"]["completed"], 0) + self.assertEqual(len(payload["lifecycle"]["cycles"]), 1) + self.assertTrue(payload["reap"]["all_reaped"]) + self.assertTrue(payload["fixture"]["unchanged"]) + self.assertTrue(payload["cleanup"]["no_temporaries"]) + + def test_failure_contract_captures_base_exception_and_reap_state(self) -> None: + class DeliberateAbort(BaseException): + pass + + class AbortingSession: + def __init__(self, *args: object, **kwargs: object) -> None: + self.process = None + self.returncode = None + self.termination_stage = "not-started" + self.reader_threads_alive = False + self.cwd = Path(kwargs["cwd"]) + + def start(self, **kwargs: object) -> None: + raise DeliberateAbort("deliberate base exception at " + str(self.cwd)) + + def close(self) -> None: + self.returncode = -1 + self.termination_stage = "closed" + + fixture = self.fixture() + runner = HardeningRunner( + fixture, + self.output_root / "base-exception-runs", + "fixture-simulator", + lifecycle_cycles=1, + ping_count=0, + lua_reloads=0, + warm_restarts=0, + capture_count=0, + expected_target="test-target", + session_factory=AbortingSession, + ) + with self.assertRaises(HardeningExecutionError) as caught: + runner.run() + payload = json.loads(caught.exception.result.report.read_text(encoding="utf-8")) + self.assertEqual(payload["failure"]["error_type"], "DeliberateAbort") + self.assertEqual(payload["failure"]["stage"], "lifecycle") + self.assertEqual(payload["lifecycle"]["cycles"][0]["returncode"], -1) + self.assertTrue(payload["reap"]["all_reaped"]) + self.assertNotIn( + self.output_root.as_posix(), + json.dumps(payload).replace("\\", "/"), + ) + + def test_failure_contract_identifies_each_public_api_stage(self) -> None: + fixture = self.fixture() + + class InjectedFailureSession: + def __init__( + self, failure_method: str, *args: object, **kwargs: object + ) -> None: + self._failure_method = failure_method + self._session = SimulatorSession(*args, **kwargs) + + def __getattr__(self, name: str) -> Any: + value = getattr(self._session, name) + if name != self._failure_method: + return value + + def fail(*args: object, **kwargs: object) -> None: + raise RuntimeError("injected failure in " + name) + + return fail + + stages = ( + ("ping", "ping"), + ("reload_lua", "lua"), + ("restart", "restart"), + ("capture_ppm", "capture"), + ("stop", "cleanup"), + ) + for failure_method, expected_stage in stages: + with self.subTest(stage=expected_stage): + def factory( + *args: object, + _method: str = failure_method, + **kwargs: object, + ) -> InjectedFailureSession: + return InjectedFailureSession(_method, *args, **kwargs) + + runner = HardeningRunner( + fixture, + self.output_root / (expected_stage + "-failure-runs"), + sys.executable, + simulator_args=(str(FAKE_SIMULATOR), "phase6"), + lifecycle_cycles=0, + ping_count=1, + lua_reloads=1, + warm_restarts=1, + capture_count=1, + expected_target="test-target", + session_factory=factory, + ) + with self.assertRaises(HardeningExecutionError) as caught: + runner.run() + payload = json.loads( + caught.exception.result.report.read_text(encoding="utf-8") + ) + self.assertEqual(payload["failure"]["stage"], expected_stage) + self.assertTrue(payload["fixture"]["unchanged"]) + self.assertTrue(payload["cleanup"]["no_temporaries"]) + self.assertTrue(payload["reap"]["all_reaped"]) + self.assertIsNotNone(payload["stress"]["reap"]["returncode"]) + + def test_cleanup_evidence_collection_failure_is_reported(self) -> None: + fixture = self.fixture() + runner = HardeningRunner( + fixture, + self.output_root / "cleanup-collection-runs", + sys.executable, + simulator_args=(str(FAKE_SIMULATOR), "phase6"), + lifecycle_cycles=0, + ping_count=0, + lua_reloads=0, + warm_restarts=0, + capture_count=0, + expected_target="test-target", + ) + with mock.patch.object( + hardening_module, + "_temporary_paths", + side_effect=OSError("cleanup evidence unavailable"), + ): + with self.assertRaises(HardeningExecutionError) as caught: + runner.run() + payload = json.loads(caught.exception.result.report.read_text(encoding="utf-8")) + self.assertEqual(payload["failure"]["stage"], "cleanup") + self.assertFalse(payload["cleanup"]["no_temporaries"]) + self.assertEqual( + payload["cleanup"]["collection_error"]["error_type"], "OSError" + ) + self.assertTrue(payload["fixture"]["unchanged"]) + self.assertTrue(payload["reap"]["all_reaped"]) + + def test_report_publication_failure_preserves_fallback_evidence(self) -> None: + fixture = self.fixture() + requested = self.output_root / "unpublished-report.json" + runner = HardeningRunner( + fixture, + self.output_root / "publication-failure-runs", + sys.executable, + report_path=requested, + simulator_args=(str(FAKE_SIMULATOR), "phase6"), + lifecycle_cycles=0, + ping_count=0, + lua_reloads=0, + warm_restarts=0, + capture_count=0, + expected_target="test-target", + ) + original_write = hardening_module._write_report + + def fail_requested( + path: Path, payload: object, *, force: bool = False + ) -> None: + if path == requested.resolve(): + raise OSError("publication deliberately unavailable") + original_write(path, payload, force=force) + + with mock.patch.object( + hardening_module, "_write_report", side_effect=fail_requested + ): + with self.assertRaises(HardeningExecutionError) as caught: + runner.run() + self.assertFalse(requested.exists()) + self.assertNotEqual(caught.exception.result.report, requested) + payload = json.loads(caught.exception.result.report.read_text(encoding="utf-8")) + self.assertEqual(payload["failure"]["stage"], "report") + self.assertTrue(payload["fixture"]["unchanged"]) + self.assertTrue(payload["cleanup"]["no_temporaries"]) + self.assertTrue(payload["reap"]["all_reaped"]) + + def test_report_is_exclusive_by_default_and_force_is_explicit(self) -> None: + fixture = self.fixture() + report_path = self.output_root / "exclusive.json" + report_path.write_text("keep\n", encoding="utf-8") + + options: dict[str, Any] = { + "report_path": report_path, + "simulator_args": (str(FAKE_SIMULATOR), "phase6"), + "lifecycle_cycles": 0, + "ping_count": 0, + "lua_reloads": 0, + "warm_restarts": 0, + "capture_count": 0, + "expected_target": "test-target", + } + with self.assertRaisesRegex(HardeningError, "already exists"): + HardeningRunner( + fixture, self.output_root / "exclusive-runs", sys.executable, **options + ).run() + self.assertEqual(report_path.read_text(encoding="utf-8"), "keep\n") + + result = HardeningRunner( + fixture, + self.output_root / "exclusive-runs", + sys.executable, + force_report=True, + **options, + ).run() + self.assertTrue(result.success) + self.assertTrue(json.loads(report_path.read_text(encoding="utf-8"))["success"]) + + with self.assertRaisesRegex(HardeningError, "inside the fixture"): + HardeningRunner( + fixture, + self.output_root / "fixture-report-runs", + sys.executable, + report_path=fixture / "report.json", + force_report=True, + lifecycle_cycles=0, + ping_count=0, + lua_reloads=0, + warm_restarts=0, + capture_count=0, + expected_target="test-target", + ).run() + + def test_cli_force_flag_is_explicit_and_disabled_by_default(self) -> None: + parser = build_parser() + normal = parser.parse_args(["harden", "fixture-simulator"]) + forced = parser.parse_args(["harden", "--force", "fixture-simulator"]) + self.assertFalse(normal.force) + self.assertTrue(forced.force) + + def test_runner_rejects_each_count_one_above_its_documented_limit(self) -> None: + fixture = self.fixture() + limits = ( + ("lifecycle_cycles", MAX_LIFECYCLE_CYCLES), + ("ping_count", MAX_PING_COUNT), + ("lua_reloads", MAX_LUA_RELOADS), + ("warm_restarts", MAX_WARM_RESTARTS), + ("capture_count", MAX_CAPTURE_COUNT), + ) + for argument, maximum in limits: + with self.subTest(argument=argument): + with self.assertRaisesRegex(ValueError, "must not exceed"): + HardeningRunner( + fixture, + self.output_root / (argument + "-runs"), + sys.executable, + **{argument: maximum + 1}, + ) + + def test_report_redacts_machine_specific_paths_and_command(self) -> None: + fixture = self.fixture() + result = HardeningRunner( + fixture, + self.output_root / "redaction-runs", + sys.executable, + simulator_args=(str(FAKE_SIMULATOR), "phase6"), + lifecycle_cycles=0, + ping_count=0, + lua_reloads=0, + warm_restarts=0, + capture_count=0, + expected_target="test-target", + ).run() + text = result.report.read_text(encoding="utf-8") + payload = json.loads(text) + + self.assertNotIn(self.output_root.as_posix(), text.replace("\\", "/")) + self.assertEqual(payload["fixture"]["path"], "") + self.assertEqual(payload["run_directory"], "") + self.assertEqual(payload["simulator"]["command"][0], Path(sys.executable).name) + self.assertIn("phase6", payload["simulator"]["command"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ui-harness/tests/test_ppm.py b/tools/ui-harness/tests/test_ppm.py new file mode 100644 index 00000000000..0b65297b217 --- /dev/null +++ b/tools/ui-harness/tests/test_ppm.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import hashlib +import json +import sys +import tempfile +import unittest +from pathlib import Path + +HARNESS_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(HARNESS_ROOT)) + +from edgetx_ui.ppm import ( # noqa: E402 + PNG_SIGNATURE, + RgbImage, + convert_ppm_to_png, + digest_file, + read_png, + read_ppm, + write_json_sidecar, + write_png, +) + + +class CaptureImageFormatTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary_directory.cleanup) + self.root = Path(self.temporary_directory.name) + + def write(self, name: str, payload: bytes) -> Path: + path = self.root / name + path.write_bytes(payload) + return path + + def test_reads_only_the_canonical_ppm_subset(self) -> None: + payload = b"P6\n2 1\n255\n" + bytes((0, 1, 2, 253, 254, 255)) + image = read_ppm(self.write("valid.ppm", payload)) + + self.assertEqual((image.width, image.height), (2, 1)) + self.assertEqual(image.rgb, payload[-6:]) + + invalid_payloads = ( + b"P3\n2 1\n255\n" + payload[-6:], + b"P6\r\n2 1\r\n255\r\n" + payload[-6:], + b"P6\n02 1\n255\n" + payload[-6:], + b"P6\n2 1\n254\n" + payload[-6:], + b"P6\n2 1\n255\n" + payload[-5:], + payload + b"trailing", + ) + for index, invalid in enumerate(invalid_payloads): + with self.subTest(index=index): + with self.assertRaises(ValueError): + read_ppm(self.write(f"invalid-{index}.ppm", invalid)) + + def test_png_is_deterministic_and_independently_decoded(self) -> None: + image = RgbImage( + width=2, + height=2, + rgb=bytes( + ( + 0, + 0, + 0, + 255, + 255, + 255, + 255, + 0, + 0, + 0, + 255, + 0, + ) + ), + ) + first = self.root / "first.png" + second = self.root / "second.png" + + first_digest = write_png(first, image) + second_digest = write_png(second, image) + + self.assertTrue(first.read_bytes().startswith(PNG_SIGNATURE)) + self.assertEqual(first.read_bytes(), second.read_bytes()) + self.assertEqual(first_digest.sha256, second_digest.sha256) + self.assertEqual(read_png(first), image) + + corrupted = bytearray(first.read_bytes()) + corrupted[16] ^= 0x01 + with self.assertRaisesRegex(ValueError, "CRC"): + read_png(self.write("corrupted.png", bytes(corrupted))) + + def test_ppm_to_png_round_trip_preserves_every_rgb_byte(self) -> None: + ppm = self.write( + "source.ppm", + b"P6\n3 1\n255\n" + bytes((1, 2, 3, 4, 5, 6, 7, 8, 9)), + ) + png = self.root / "converted.png" + + image, digest = convert_ppm_to_png(ppm, png) + + self.assertEqual(read_png(png), image) + self.assertEqual(digest, digest_file(png)) + + def test_artifact_writers_never_replace_an_existing_name(self) -> None: + final = self.write("existing.png", b"keep-me") + image = RgbImage(width=1, height=1, rgb=b"\x01\x02\x03") + + with self.assertRaises(FileExistsError): + write_png(final, image) + + self.assertEqual(final.read_bytes(), b"keep-me") + self.assertFalse((self.root / ".existing.png.tmp-ui-harness").exists()) + + def test_sidecar_is_utf8_sorted_stable_and_hashed(self) -> None: + payload = { + "target": "écran", + "schema_version": 1, + "artifacts": {"ppm": {"sha256": "abc", "bytes": 7}}, + } + first = self.root / "first.capture.json" + second = self.root / "second.capture.json" + + first_digest = write_json_sidecar(first, payload) + second_digest = write_json_sidecar(second, payload) + + expected = ( + json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + "\n" + ).encode("utf-8") + self.assertEqual(first.read_bytes(), expected) + self.assertEqual(first.read_bytes(), second.read_bytes()) + self.assertEqual(first_digest.sha256, hashlib.sha256(expected).hexdigest()) + self.assertEqual(first_digest.sha256, second_digest.sha256) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ui-harness/tests/test_protocol.py b/tools/ui-harness/tests/test_protocol.py new file mode 100644 index 00000000000..f4e8400f9fc --- /dev/null +++ b/tools/ui-harness/tests/test_protocol.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + + +HARNESS_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(HARNESS_ROOT)) + +from edgetx_ui.protocol import ( # noqa: E402 + MAX_RECORD_BYTES, + Event, + ProtocolViolation, + Response, + decode_capture, + decode_description, + decode_frame, + decode_lua_reload, + decode_restart, + decode_status, + encode_request, + parse_message, +) + + +def encoded(payload: object) -> bytes: + return json.dumps(payload, separators=(",", ":")).encode("utf-8") + + +def capability_payload() -> dict[str, bool]: + return { + "rotary": False, + "touch": False, + "switches": False, + "analog": False, + "telemetry": False, + "lua": False, + "capture": False, + "warm_restart": False, + } + + +def description_payload() -> dict[str, object]: + return { + "protocol_version": 1, + "target": "tx16s", + "lcd": {"width": 480, "height": 272, "depth": 16}, + "commands": ["ping", "status", "describe", "stop"], + "capabilities": capability_payload(), + "keys": [], + "switches": [], + "analogs": [], + } + + +def status_payload() -> dict[str, object]: + return { + "protocol_version": 1, + "running": True, + "phase": "ready", + "target": "tx16s", + "lcd": {"width": 480, "height": 272, "depth": 16}, + "display_seq": 8, + "async_operation": "none", + "request_queue_depth": 0, + "firmware_mailbox_depth": 0, + "line_overflow_count": 0, + "queue_overflow_count": 0, + "stale_completion_count": 0, + "active_key_count": 0, + "touch_active": False, + "analog_override_count": 0, + "lua_state": "unavailable", + "capabilities": capability_payload(), + "output_root": "ready", + } + + +class EncodeRequestTests(unittest.TestCase): + def test_encodes_exact_ping_record(self) -> None: + self.assertEqual(encode_request(1, "ping"), b"v1 1 ping\n") + + def test_preserves_internal_spaces_in_capture_remainder(self) -> None: + self.assertEqual(encode_request(8, "capture"), b"v1 8 capture\n") + self.assertEqual( + encode_request(9, "capture", ("checkpoints/home screen.ppm",)), + b"v1 9 capture checkpoints/home screen.ppm\n", + ) + + def test_rejects_ambiguous_argument_boundaries(self) -> None: + invalid_calls = ( + lambda: encode_request(1, "key-down", ("ENTER extra",)), + lambda: encode_request(1, "set-switch", ("SA extra", "1")), + lambda: encode_request( + 1, "set-telemetry", ("1", "0", "0", "1", "0", "0 RSSI") + ), + lambda: encode_request( + 1, "capture", ("checkpoints/home", "screen.ppm") + ), + ) + for invalid_call in invalid_calls: + with self.subTest(call=invalid_call): + with self.assertRaises(ValueError): + invalid_call() + + def test_rejects_ambiguous_or_oversized_fields(self) -> None: + invalid_calls = ( + lambda: encode_request(0, "ping"), + lambda: encode_request(True, "ping"), + lambda: encode_request(1, "Ping"), + lambda: encode_request(1, "ping", ("",)), + lambda: encode_request(1, "capture", ("bad\npath",)), + lambda: encode_request(1, "capture", (" leading",)), + lambda: encode_request(1, "capture", ("x" * MAX_RECORD_BYTES,)), + ) + for invalid_call in invalid_calls: + with self.subTest(call=invalid_call): + with self.assertRaises(ValueError): + invalid_call() + + def test_wire_limit_includes_the_newline_delimiter(self) -> None: + prefix = b"v1 1 capture " + exact_argument = "x" * (MAX_RECORD_BYTES - len(prefix) - 1) + encoded_request = encode_request(1, "capture", (exact_argument,)) + self.assertEqual(len(encoded_request), MAX_RECORD_BYTES) + + with self.assertRaisesRegex(ValueError, "16 KiB"): + encode_request(1, "capture", (exact_argument + "x",)) + + +class ParseMessageTests(unittest.TestCase): + def test_parses_success_and_failure_responses(self) -> None: + success = parse_message( + encoded( + { + "version": 1, + "type": "response", + "id": 3, + "ok": True, + "epoch": 2, + "result": {"display_seq": 4}, + } + ) + ) + self.assertIsInstance(success, Response) + self.assertEqual(success.id, 3) + self.assertEqual(success.result, {"display_seq": 4}) + + failure = parse_message( + encoded( + { + "version": 1, + "type": "response", + "id": 4, + "ok": False, + "epoch": 2, + "error": {"code": "out_of_range", "message": "bad value"}, + } + ) + ) + self.assertIsInstance(failure, Response) + self.assertFalse(failure.ok) + self.assertEqual(failure.error_code, "out_of_range") + + def test_parses_uncorrelated_event(self) -> None: + event = parse_message( + encoded( + { + "version": 1, + "type": "event", + "id": None, + "epoch": 0, + "event": {"code": "queue_full", "message": "bounded"}, + } + ) + ) + self.assertIsInstance(event, Event) + self.assertEqual(event.code, "queue_full") + + def test_rejects_null_result_when_result_member_is_present(self) -> None: + with self.assertRaisesRegex(ProtocolViolation, "result must be an object"): + parse_message( + encoded( + { + "version": 1, + "type": "response", + "id": 3, + "ok": True, + "epoch": 2, + "result": None, + } + ) + ) + + def test_rejects_malformed_or_ambiguous_messages(self) -> None: + invalid_records = ( + b"\xff", + b"not-json", + b"[]", + encoded({"version": True, "type": "response", "epoch": 0}), + encoded( + { + "version": 1, + "type": "response", + "id": True, + "ok": True, + "epoch": 0, + } + ), + encoded( + { + "version": 1, + "type": "response", + "id": 1, + "ok": False, + "epoch": 0, + } + ), + encoded( + { + "version": 1, + "type": "event", + "id": 1, + "epoch": 0, + "event": {"code": "bad", "message": "bad"}, + } + ), + b'{"version":1,"type":"response","id":1,"ok":true,"epoch":NaN}', + ) + for record in invalid_records: + with self.subTest(record=record): + with self.assertRaises(ProtocolViolation): + parse_message(record) + + def test_rejects_duplicate_keys_at_every_json_depth(self) -> None: + records = ( + b'{"version":1,"version":1,"type":"response","id":1,"ok":true,"epoch":0,"result":{}}', + b'{"version":1,"type":"response","id":1,"ok":true,"epoch":0,"result":{"value":1,"value":2}}', + b'{"version":1,"type":"response","id":1,"ok":false,"epoch":0,"error":{"code":"bad","code":"worse","message":"x"}}', + b'{"version":1,"type":"event","id":null,"epoch":0,"event":{"code":"bad","message":"x","message":"y"}}', + ) + for record in records: + with self.subTest(record=record), self.assertRaises(ProtocolViolation): + parse_message(record) + with self.assertRaisesRegex(ProtocolViolation, "duplicate JSON key: version"): + parse_message(records[0]) + + def test_requires_exact_success_failure_and_event_schemas(self) -> None: + valid_success = { + "version": 1, + "type": "response", + "id": 1, + "ok": True, + "epoch": 0, + "result": {}, + } + valid_failure = { + "version": 1, + "type": "response", + "id": 1, + "ok": False, + "epoch": 0, + "error": {"code": "bad", "message": "x"}, + } + valid_event = { + "version": 1, + "type": "event", + "id": None, + "epoch": 0, + "event": {"code": "queue_full", "message": "x"}, + } + invalid = ( + valid_success | {"extra": 1}, + valid_success | {"error": {"code": "bad", "message": "x"}}, + valid_failure | {"result": {}}, + valid_failure | {"error": {"code": "bad", "message": "x", "extra": 1}}, + valid_event | {"ok": False}, + valid_event | {"event": {"code": "queue_full", "message": "x", "extra": 1}}, + ) + for payload in invalid: + with self.subTest(payload=payload), self.assertRaises(ProtocolViolation): + parse_message(encoded(payload)) + + def test_response_wire_limit_reserves_the_newline_delimiter(self) -> None: + prefix = b'{"version":1,"type":"event","id":null,"epoch":0,"event":{"code":"x","message":"' + suffix = b'"}}' + exact = prefix + b"x" * (MAX_RECORD_BYTES - 1 - len(prefix) - len(suffix)) + suffix + self.assertEqual(len(exact) + 1, MAX_RECORD_BYTES) + self.assertIsInstance(parse_message(exact), Event) + + with self.assertRaisesRegex(ProtocolViolation, "16 KiB"): + parse_message(exact[:-len(suffix)] + b"x" + suffix) + + +class DiscoveryResultTests(unittest.TestCase): + @staticmethod + def response(result: dict[str, object], *, epoch: int = 1) -> Response: + message = parse_message( + encoded( + { + "version": 1, + "type": "response", + "id": 1, + "ok": True, + "epoch": epoch, + "result": result, + } + ) + ) + assert isinstance(message, Response) + return message + + def test_decodes_description_and_ready_status(self) -> None: + description = decode_description(self.response(description_payload())) + status = decode_status(self.response(status_payload())) + + self.assertEqual(description.target, "tx16s") + self.assertEqual(description.lcd.width, 480) + self.assertEqual(description.commands[-1], "stop") + self.assertFalse(description.capabilities.capture) + self.assertEqual(status.phase, "ready") + self.assertEqual(status.display_sequence, 8) + self.assertEqual(status.epoch, 1) + + def test_rejects_unbounded_or_ambiguous_discovery_shapes(self) -> None: + missing = description_payload() + del missing["commands"] + + duplicate = description_payload() + duplicate["commands"] = ["ping", "ping"] + + non_boolean = description_payload() + non_boolean_capabilities = capability_payload() + non_boolean_capabilities["capture"] = 1 # type: ignore[assignment] + non_boolean["capabilities"] = non_boolean_capabilities + + bad_ready = status_payload() + bad_ready["display_seq"] = True + + for result, decoder in ( + (missing, decode_description), + (duplicate, decode_description), + (non_boolean, decode_description), + (bad_ready, decode_status), + ): + with self.subTest(result=result): + with self.assertRaises(ProtocolViolation): + decoder(self.response(result)) + + +class FrameResultTests(unittest.TestCase): + def response(self, result: object) -> Response: + message = parse_message( + encoded( + { + "version": 1, + "type": "response", + "id": 7, + "ok": True, + "epoch": 3, + "result": result, + } + ) + ) + assert isinstance(message, Response) + return message + + def test_decodes_exact_wait_frame_result(self) -> None: + barrier = decode_frame(self.response({"display_seq": 42})) + self.assertEqual(barrier.epoch, 3) + self.assertEqual(barrier.display_sequence, 42) + + def test_rejects_ambiguous_wait_frame_result(self) -> None: + for result in ( + {}, + {"display_seq": True}, + {"display_seq": -1}, + {"display_seq": 4, "extra": 1}, + ): + with self.subTest(result=result): + with self.assertRaises(ProtocolViolation): + decode_frame(self.response(result)) + + def test_decodes_restart_and_generation_observed_lua_results(self) -> None: + restart = decode_restart(self.response({"display_seq": 43})) + lua = decode_lua_reload( + self.response({"generation": 7, "state": "running"}) + ) + + self.assertEqual((restart.epoch, restart.display_sequence), (3, 43)) + self.assertEqual((lua.epoch, lua.generation, lua.state), (3, 7, "running")) + + def test_rejects_ambiguous_restart_and_lua_results(self) -> None: + for decoder, result in ( + (decode_restart, {"display_seq": 4, "extra": 1}), + (decode_lua_reload, {"generation": 0, "state": "running"}), + (decode_lua_reload, {"generation": 1, "state": "panic"}), + (decode_lua_reload, {"generation": 1}), + ): + with self.subTest(decoder=decoder, result=result): + with self.assertRaises(ProtocolViolation): + decoder(self.response(result)) + + +class CaptureResultTests(unittest.TestCase): + @staticmethod + def response(result: object) -> Response: + message = parse_message( + encoded( + { + "version": 1, + "type": "response", + "id": 9, + "ok": True, + "epoch": 4, + "result": result, + } + ) + ) + assert isinstance(message, Response) + return message + + @staticmethod + def valid_result() -> dict[str, object]: + return { + "display_seq": 42, + "path": "checkpoints/home screen.ppm", + "width": 480, + "height": 272, + "depth": 16, + "bytes": 391695, + } + + def test_decodes_exact_capture_metadata(self) -> None: + artifact = decode_capture(self.response(self.valid_result())) + + self.assertEqual(artifact.epoch, 4) + self.assertEqual(artifact.display_sequence, 42) + self.assertEqual(artifact.path, "checkpoints/home screen.ppm") + self.assertEqual((artifact.width, artifact.height), (480, 272)) + self.assertEqual(artifact.depth, 16) + self.assertEqual(artifact.byte_count, 391695) + + def test_rejects_noncanonical_or_inconsistent_capture_metadata(self) -> None: + mutations = ( + ("missing", None), + ("extra", 1), + ("display_seq", 0), + ("display_seq", True), + ("path", "../escape.ppm"), + ("path", "/absolute.ppm"), + ("path", "C:/rooted.ppm"), + ("path", "wrong.PNG"), + ("depth", 1), + ("width", 0), + ("bytes", 391694), + ) + for field, value in mutations: + result = self.valid_result() + if field == "missing": + del result["path"] + else: + result[field] = value + with self.subTest(field=field, value=value): + with self.assertRaises(ProtocolViolation): + decode_capture(self.response(result)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ui-harness/tests/test_session.py b/tools/ui-harness/tests/test_session.py new file mode 100644 index 00000000000..8dfdcf5f19f --- /dev/null +++ b/tools/ui-harness/tests/test_session.py @@ -0,0 +1,929 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + + +HARNESS_ROOT = Path(__file__).resolve().parents[1] +FAKE_SIMULATOR = Path(__file__).with_name("fake_simulator.py") +LAUNCHER = HARNESS_ROOT / "edgetx-ui" +sys.path.insert(0, str(HARNESS_ROOT)) + +from edgetx_ui.ppm import read_png # noqa: E402 +from edgetx_ui.session import ( # noqa: E402 + MAX_PROTOCOL_RECORDS, + MAX_STDERR_BYTES, + MAX_STDERR_LINES, + CommandFailed, + ProcessExited, + ProtocolFailure, + RequestTimeout, + SessionError, + SimulatorSession, + StartupMismatch, +) + + +class _FailsSecondWrite: + def __init__(self, path: Path) -> None: + self.stream = path.open("wb") + self.calls = 0 + + def write(self, payload: bytes) -> int: + self.calls += 1 + if self.calls == 2: + raise OSError("injected evidence sink failure") + return self.stream.write(payload) + + def flush(self) -> None: + self.stream.flush() + + def fileno(self) -> int: + return self.stream.fileno() + + def close(self) -> None: + self.stream.close() + + +class SimulatorSessionTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary_directory.cleanup) + self.output_root = Path(self.temporary_directory.name) + + def session(self, mode: str, **overrides: object) -> SimulatorSession: + options = { + "request_timeout": 1.0, + "stop_timeout": 0.5, + "terminate_timeout": 0.5, + "kill_timeout": 0.5, + "reader_join_timeout": 0.5, + } + options.update(overrides) + return SimulatorSession( + sys.executable, + self.output_root, + simulator_args=(str(FAKE_SIMULATOR), mode), + **options, + ) + + def assert_reaped(self, session: SimulatorSession) -> None: + self.assertIsNotNone(session.returncode) + self.assertFalse(session.reader_threads_alive) + self.assertFalse(session.writer_thread_alive) + + def test_fragmented_start_ping_stop_is_correlated_and_reaped(self) -> None: + session = self.session("fragmented") + ready = session.start() + stop = session.stop() + + self.assertEqual(session.startup_ping.id, 1) + self.assertEqual(session.description_response.id, 2) + self.assertEqual(ready.id, 3) + self.assertEqual(ready.result["phase"], "ready") + self.assertEqual(stop.id, 4) + self.assertEqual(session.returncode, 0) + self.assertEqual(session.termination_stage, "graceful") + self.assert_reaped(session) + + def test_event_does_not_steal_correlated_response(self) -> None: + session = self.session("event") + try: + ready = session.start() + self.assertEqual(ready.id, 3) + self.assertEqual(session.events[-1].code, "queue_full") + finally: + session.close() + self.assert_reaped(session) + + def test_stderr_is_separate_and_bounded(self) -> None: + session = self.session("stderr-flood", request_timeout=3.0) + session.start() + session.stop() + + line_count, byte_count = session.stderr_counts + self.assertLessEqual(line_count, MAX_STDERR_LINES) + self.assertLessEqual(byte_count, MAX_STDERR_BYTES) + self.assertIn("diagnostic-299", session.recent_stderr) + self.assert_reaped(session) + + def test_malformed_stdout_fails_and_cleans_up(self) -> None: + session = self.session("malformed") + with self.assertRaisesRegex(ProtocolFailure, "valid JSON"): + session.start() + self.assert_reaped(session) + + def test_mismatched_response_id_fails_and_cleans_up(self) -> None: + session = self.session("wrong-id") + with self.assertRaisesRegex(ProtocolFailure, "does not match"): + session.start() + self.assert_reaped(session) + + def test_repeated_response_id_poisoning_is_detected(self) -> None: + session = self.session("duplicate") + try: + try: + session.start() + except ProtocolFailure: + pass + else: + with self.assertRaisesRegex( + ProtocolFailure, "repeated|does not match" + ): + session.ping() + finally: + session.close() + self.assert_reaped(session) + + def test_command_error_keeps_code_and_diagnostics(self) -> None: + session = self.session("command-error") + with self.assertRaises(CommandFailed) as raised: + session.start() + self.assertEqual(raised.exception.response.error_code, "unsupported_command") + self.assertIn("fake simulator started", str(raised.exception)) + self.assert_reaped(session) + + def test_timeout_terminates_and_reaps_child(self) -> None: + session = self.session( + "hang", + request_timeout=0.1, + stop_timeout=0.1, + terminate_timeout=0.5, + ) + started = time.monotonic() + with self.assertRaises(RequestTimeout): + session.start() + self.assertLess(time.monotonic() - started, 2.0) + self.assertIn(session.termination_stage, ("terminated", "killed")) + self.assert_reaped(session) + + def test_stdin_backpressure_is_bounded_by_the_request_deadline(self) -> None: + session = self.session( + "ready-no-read", + request_timeout=0.2, + stop_timeout=0.2, + terminate_timeout=0.5, + kill_timeout=0.5, + ) + try: + session.start(timeout=1.0) + started = time.monotonic() + with self.assertRaisesRegex(RequestTimeout, "writing request"): + session.request("ping", "x" * 12_000, timeout=0.2) + self.assertLess(time.monotonic() - started, 1.0) + self.assertIsNotNone(session.returncode) + self.assertFalse(session.writer_thread_alive) + self.assertFalse(session.reader_threads_alive) + finally: + session.close() + self.assert_reaped(session) + + def test_protocol_transcript_ring_is_bounded_and_sink_is_verifiable( + self, + ) -> None: + sink = self.output_root / "protocol-evidence.jsonl" + session = self.session("normal", protocol_sink=sink) + request_count = MAX_PROTOCOL_RECORDS // 2 + 32 + try: + session.start() + for _ in range(request_count): + session.ping() + session.stop() + finally: + session.close() + + evidence = sink.read_bytes() + lines = evidence.splitlines(keepends=True) + self.assertEqual(len(lines), session.protocol_record_count) + self.assertEqual(hashlib.sha256(evidence).hexdigest(), session.protocol_sha256) + self.assertEqual(len(session.protocol_records), MAX_PROTOCOL_RECORDS) + self.assertEqual( + session.protocol_records_dropped, + session.protocol_record_count - MAX_PROTOCOL_RECORDS, + ) + for line in lines: + record = json.loads(line) + self.assertIn(record["direction"], ("request", "response", "event")) + self.assert_reaped(session) + + def test_protocol_evidence_bounds_one_hundred_thousand_records(self) -> None: + sink = self.output_root / "protocol-100k.jsonl" + session = self.session("normal") + session._protocol_sink = sink.open("wb", buffering=64 * 1024) + for index in range(100_000): + session._record_protocol("event", {"index": index}) + self.assertIsNone(session._close_protocol_sink()) + + digest = hashlib.sha256() + line_count = 0 + with sink.open("rb") as stream: + for index, line in enumerate(stream): + digest.update(line) + record = json.loads(line) + self.assertEqual(record, { + "direction": "event", + "message": {"index": index}, + }) + line_count += 1 + self.assertEqual(line_count, 100_000) + self.assertEqual(session.protocol_record_count, 100_000) + self.assertEqual(len(session.protocol_records), MAX_PROTOCOL_RECORDS) + self.assertEqual( + session.protocol_records_dropped, 100_000 - MAX_PROTOCOL_RECORDS + ) + self.assertEqual(session.protocol_sha256, digest.hexdigest()) + + def test_protocol_sink_failure_wakes_the_pending_request(self) -> None: + session = self.session("normal") + try: + session.start() + session._protocol_sink = _FailsSecondWrite( + self.output_root / "failing-protocol.jsonl" + ) + with self.assertRaisesRegex( + ProtocolFailure, "injected evidence sink failure" + ): + session.ping(timeout=0.5) + finally: + session.close() + self.assert_reaped(session) + + def test_child_crash_reports_exit_and_recent_stderr(self) -> None: + session = self.session("crash") + with self.assertRaises(ProcessExited) as raised: + session.start() + self.assertIn("code 17", str(raised.exception)) + self.assertIn("fixture crash", str(raised.exception)) + self.assert_reaped(session) + + def test_partial_record_at_eof_is_rejected(self) -> None: + session = self.session("partial-eof") + with self.assertRaisesRegex(ProtocolFailure, "partial record"): + session.start() + self.assert_reaped(session) + + def test_local_validation_error_does_not_poison_next_request(self) -> None: + session = self.session("normal") + try: + session.start() + with self.assertRaises(ValueError): + session.request("key-down", "ENTER extra") + ping = session.ping() + self.assertEqual(ping.id, 5) + finally: + session.close() + self.assert_reaped(session) + + def test_exit_after_ping_is_not_reported_as_a_clean_stop(self) -> None: + session = self.session("exit-after-ping") + with self.assertRaises(ProcessExited): + session.start() + self.assert_reaped(session) + + def test_stop_escalates_when_child_acknowledges_but_does_not_exit(self) -> None: + session = self.session( + "ignore-stop", stop_timeout=0.1, terminate_timeout=0.5 + ) + session.start() + stop = session.stop() + + self.assertEqual(stop.id, 4) + self.assertEqual(session.termination_stage, "terminated") + self.assert_reaped(session) + + def test_stop_kills_and_waits_when_terminate_does_not_reap(self) -> None: + session = self.session( + "ignore-stop", + stop_timeout=0.1, + terminate_timeout=0.1, + kill_timeout=0.5, + ) + session.start() + process = session.process + assert process is not None + process.terminate = lambda: None # type: ignore[method-assign] + + stop = session.stop() + + self.assertEqual(stop.id, 4) + self.assertEqual(session.termination_stage, "killed") + self.assert_reaped(session) + + def test_start_polls_status_until_a_real_first_frame(self) -> None: + session = self.session("starting-then-ready") + try: + ready = session.start() + self.assertEqual(ready.id, 5) + self.assertEqual(session.status.phase, "ready") + self.assertEqual(session.status.epoch, 1) + self.assertEqual(session.status.display_sequence, 1) + finally: + session.close() + self.assert_reaped(session) + + def test_startup_deadline_bounds_a_never_ready_simulator(self) -> None: + session = self.session("never-ready") + with self.assertRaisesRegex(RequestTimeout, "readiness"): + session.start(timeout=1.0) + self.assert_reaped(session) + + def test_discovery_schema_and_status_identity_are_strict(self) -> None: + bad_description = self.session("bad-description") + with self.assertRaisesRegex(ProtocolFailure, "capture.*boolean"): + bad_description.start() + self.assert_reaped(bad_description) + + mismatched_status = self.session("status-mismatch") + with self.assertRaisesRegex(ProtocolFailure, "target differs"): + mismatched_status.start() + self.assert_reaped(mismatched_status) + + def test_target_lcd_and_required_capabilities_are_validated(self) -> None: + matching = self.session( + "normal", + expected_target="test-target", + expected_lcd=(480, 272, 16), + ) + matching.start() + matching.stop() + self.assert_reaped(matching) + + missing = self.session("normal", required_capabilities=("capture",)) + with self.assertRaisesRegex(StartupMismatch, "capture"): + missing.start() + self.assert_reaped(missing) + + def test_phase4_discovery_and_primitives_are_validated(self) -> None: + session = self.session("phase4") + try: + session.start() + assert session.description is not None + self.assertTrue(session.description.capabilities.rotary) + self.assertTrue(session.description.capabilities.touch) + self.assertEqual(session.description.keys, ("EXIT", "ENTER")) + + session.key_down("ENTER") + self.assertEqual(session.read_status().active_key_count, 1) + with self.assertRaises(CommandFailed) as duplicate: + session.key_down("ENTER") + self.assertEqual( + duplicate.exception.response.error_code, "key_already_down" + ) + session.key_up("ENTER") + + session.rotate(-128) + session.rotate(128) + session.touch_down(0, 0) + session.touch_move(479, 271) + session.touch_up() + session.release_all() + status = session.read_status() + self.assertEqual(status.active_key_count, 0) + self.assertFalse(status.touch_active) + finally: + session.close() + self.assert_reaped(session) + + def test_phase4_host_composites_release_owned_inputs(self) -> None: + session = self.session("phase4") + try: + session.start() + session.press("ENTER", duration=0) + session.long_press("EXIT", duration=0) + session.tap(0, 0, duration=0) + session.drag(((0, 0), (240, 136), (479, 271)), duration=0) + status = session.read_status() + self.assertEqual(status.active_key_count, 0) + self.assertFalse(status.touch_active) + finally: + session.close() + self.assert_reaped(session) + + def test_phase4_composite_release_failure_falls_back_to_release_all( + self, + ) -> None: + session = self.session("phase4-release-error") + try: + session.start() + with self.assertRaises(CommandFailed): + session.press("ENTER", duration=0) + self.assertEqual(session.read_status().active_key_count, 0) + + with self.assertRaises(CommandFailed): + session.tap(1, 1, duration=0) + self.assertFalse(session.read_status().touch_active) + finally: + session.close() + self.assert_reaped(session) + + def test_phase4_local_validation_does_not_consume_request_ids(self) -> None: + session = self.session("phase4") + try: + session.start() + for invalid_call in ( + lambda: session.key_down("UNKNOWN"), + lambda: session.rotate(0), + lambda: session.rotate(129), + lambda: session.touch_down(-1, 0), + lambda: session.touch_move(480, 0), + lambda: session.drag(((0, 0),), duration=0), + ): + with self.subTest(call=invalid_call): + with self.assertRaises(ValueError): + invalid_call() + self.assertEqual(session.ping().id, 4) + finally: + session.close() + self.assert_reaped(session) + + def test_phase4_wait_frame_uses_fresh_status_and_strict_result(self) -> None: + session = self.session("phase4") + try: + session.start() + current = session.wait_frame(1) + self.assertEqual(current.display_sequence, 1) + next_frame = session.wait_next_frame() + self.assertEqual(next_frame.display_sequence, 2) + self.assertEqual(next_frame.epoch, 1) + finally: + session.close() + self.assert_reaped(session) + + malformed = self.session("phase4-bad-frame") + try: + malformed.start() + with self.assertRaisesRegex(ProtocolFailure, "below"): + malformed.wait_frame(2) + finally: + malformed.close() + self.assert_reaped(malformed) + + def test_phase4_wait_timeout_poisons_and_reaps_session(self) -> None: + session = self.session( + "phase4-wait-hang", + request_timeout=0.1, + stop_timeout=0.1, + terminate_timeout=0.5, + ) + session.start(timeout=1.0) + with self.assertRaises(RequestTimeout): + session.wait_frame(2) + with self.assertRaises(SessionError): + session.ping() + session.close() + self.assertIn(session.termination_stage, ("terminated", "killed")) + self.assert_reaped(session) + + def test_phase5_capture_ppm_is_fresh_and_preserves_safe_spaces(self) -> None: + capture_dir = self.output_root / "check points" + capture_dir.mkdir() + session = self.session("phase5") + try: + session.start() + artifact = session.capture_ppm("check points/home screen.ppm") + + self.assertEqual(artifact.path, "check points/home screen.ppm") + self.assertEqual(artifact.display_sequence, 2) + self.assertEqual((artifact.width, artifact.height), (480, 272)) + self.assertEqual(artifact.depth, 16) + capture_path = self.output_root / "check points" / "home screen.ppm" + self.assertEqual(capture_path.stat().st_size, artifact.byte_count) + self.assertTrue( + capture_path.read_bytes().startswith(b"P6\n480 272\n255\n") + ) + finally: + session.close() + self.assert_reaped(session) + + def test_phase5_local_path_rejection_does_not_consume_request_ids(self) -> None: + existing = self.output_root / "existing.ppm" + existing.write_bytes(b"keep") + session = self.session("phase5") + try: + session.start() + invalid_paths = [ + "../escape.ppm", + "/absolute.ppm", + "C:/rooted.ppm", + "missing/parent.ppm", + "existing.ppm", + "wrong.PNG", + "double//separator.ppm", + "back\\slash.ppm", + "a" * 1021 + ".ppm", + ] + if sys.platform == "win32": + invalid_paths.append("CON.ppm") + for path in invalid_paths: + with self.subTest(path=path), self.assertRaises(ValueError): + session.capture_ppm(path) + self.assertEqual(session.ping().id, 4) + self.assertEqual(existing.read_bytes(), b"keep") + finally: + session.close() + self.assert_reaped(session) + + def test_phase5_png_bundle_has_verified_hashes_and_stable_metadata(self) -> None: + (self.output_root / "captures").mkdir() + session = self.session("phase5") + try: + session.start() + bundle = session.capture_png("captures/home screen.png") + + manifest_path = ( + self.output_root / "captures" / "home screen.capture.json" + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + png_path = self.output_root / "captures" / "home screen.png" + ppm_path = self.output_root / "captures" / "home screen.ppm" + self.assertEqual( + bundle.png.sha256, + hashlib.sha256(png_path.read_bytes()).hexdigest(), + ) + self.assertEqual( + bundle.ppm.sha256, + hashlib.sha256(ppm_path.read_bytes()).hexdigest(), + ) + self.assertEqual(manifest["schema_version"], 1) + self.assertEqual( + manifest["display_seq"], bundle.capture.display_sequence + ) + self.assertEqual( + manifest["artifacts"]["png"]["sha256"], bundle.png.sha256 + ) + self.assertEqual( + manifest["artifacts"]["ppm"]["sha256"], bundle.ppm.sha256 + ) + self.assertEqual( + (read_png(png_path).width, read_png(png_path).height), + (480, 272), + ) + self.assertFalse(list(self.output_root.rglob("*.tmp-ui-harness"))) + finally: + session.close() + self.assert_reaped(session) + + def test_phase5_static_hashes_match_and_visible_state_changes_hash(self) -> None: + session = self.session("phase5") + try: + session.start() + static_hashes = [] + for index in range(3): + artifact = session.capture_ppm(f"static-{index}.ppm") + path = self.output_root / artifact.path + static_hashes.append(hashlib.sha256(path.read_bytes()).hexdigest()) + self.assertEqual(len(set(static_hashes)), 1) + + session.key_down("ENTER") + changed = session.capture_ppm("changed.ppm") + changed_hash = hashlib.sha256( + (self.output_root / changed.path).read_bytes() + ).hexdigest() + self.assertNotEqual(changed_hash, static_hashes[0]) + session.key_up("ENTER") + finally: + session.close() + self.assert_reaped(session) + + def test_phase5_rejects_a_stale_capture_result(self) -> None: + session = self.session("phase5-bad-capture") + try: + session.start() + with self.assertRaisesRegex(ProtocolFailure, "newer"): + session.capture_ppm("stale.ppm") + finally: + session.close() + self.assert_reaped(session) + + def test_phase6_state_injection_and_lua_generation_are_strict(self) -> None: + session = self.session("phase6") + try: + session.start() + assert session.description is not None + self.assertTrue(session.description.capabilities.switches) + self.assertTrue(session.description.capabilities.analog) + self.assertTrue(session.description.capabilities.telemetry) + self.assertTrue(session.description.capabilities.lua) + self.assertTrue(session.description.capabilities.warm_restart) + self.assertEqual( + tuple(item.name for item in session.description.switches), + ("SA", "SH"), + ) + self.assertEqual( + tuple(item.name for item in session.description.analogs), + ("AIL", "P1"), + ) + + for position in (-1, 0, 1): + session.set_switch("SA", position) + session.set_switch("SH", -1) + session.set_switch("SH", 1) + with self.assertRaises(CommandFailed) as neutral: + session.set_switch("SH", 0) + self.assertEqual(neutral.exception.response.error_code, "out_of_range") + + session.set_analog("AIL", 1) + session.set_analog("AIL", 4096) + self.assertEqual(session.read_status().analog_override_count, 1) + session.set_analog("P1", 2048) + self.assertEqual(session.read_status().analog_override_count, 2) + session.clear_analog("AIL") + self.assertEqual(session.read_status().analog_override_count, 1) + session.clear_analog() + self.assertEqual(session.read_status().analog_override_count, 0) + + session.set_telemetry(61696, 0, 1, -115, 1, 1, "RSSI") + first = session.reload_lua() + second = session.reload_lua() + self.assertEqual((first.generation, second.generation), (1, 2)) + self.assertEqual(session.read_status().lua_state, "running") + finally: + session.close() + self.assert_reaped(session) + + def test_phase6_local_validation_preserves_the_next_request_id(self) -> None: + session = self.session("phase6") + try: + session.start() + for invalid_call in ( + lambda: session.set_switch("UNKNOWN", 1), + lambda: session.set_switch("SA", 2), + lambda: session.set_analog("UNKNOWN", 1), + lambda: session.set_analog("AIL", 4097), + lambda: session.clear_analog("UNKNOWN"), + lambda: session.set_telemetry(0, 0, 0, 0, 0, 0), + lambda: session.set_telemetry(1, 8, 0, 0, 0, 0), + lambda: session.set_telemetry(1, 0, 0, 0, 30, 0), + lambda: session.set_telemetry(1, 0, 0, 0, 0, 3), + lambda: session.set_telemetry(1, 0, 0, 0, 0, 0, "bad name"), + ): + with self.subTest(call=invalid_call), self.assertRaises(ValueError): + invalid_call() + self.assertEqual(session.ping().id, 4) + finally: + session.close() + self.assert_reaped(session) + + def test_phase6_warm_restart_advances_epoch_and_cleans_inputs(self) -> None: + session = self.session("phase6") + try: + session.start() + process = session.process + assert process is not None + original_pid = process.pid + session.key_down("ENTER") + session.touch_down(10, 20) + session.set_analog("AIL", 2048) + before = session.read_status() + + restarted = session.restart() + after = session.read_status() + + assert session.process is not None + self.assertEqual(session.process.pid, original_pid) + self.assertEqual(restarted.epoch, before.epoch + 1) + self.assertGreater(restarted.display_sequence, before.display_sequence) + self.assertEqual(after.epoch, restarted.epoch) + self.assertEqual(after.active_key_count, 0) + self.assertFalse(after.touch_active) + self.assertEqual(after.analog_override_count, 0) + self.assertTrue(session.ping().ok) + finally: + session.close() + self.assert_reaped(session) + + def test_phase6_rejects_unproven_restart_and_lua_completion(self) -> None: + bad_restart = self.session("phase6-bad-restart") + try: + bad_restart.start() + with self.assertRaisesRegex(ProtocolFailure, "advance the session epoch"): + bad_restart.restart() + finally: + bad_restart.close() + self.assert_reaped(bad_restart) + + bad_lua = self.session("phase6-bad-lua") + try: + bad_lua.start() + with self.assertRaisesRegex(ProtocolFailure, "nonzero"): + bad_lua.reload_lua() + finally: + bad_lua.close() + self.assert_reaped(bad_lua) + + lua_panic = self.session("phase6-lua-panic") + try: + lua_panic.start() + with self.assertRaises(CommandFailed) as panic: + lua_panic.reload_lua() + self.assertEqual(panic.exception.response.error_code, "lua_panic") + finally: + lua_panic.close() + self.assert_reaped(lua_panic) + + def test_phase6_cold_restart_uses_new_process_and_fixture_copy(self) -> None: + fixture = self.output_root / "fixture" + template_settings = fixture / "settings" + template_storage = fixture / "sdcard" + template_settings.mkdir(parents=True) + template_storage.mkdir() + (template_settings / "radio.yml").write_text("template\n", encoding="utf-8") + (template_storage / "README.txt").write_text("template\n", encoding="utf-8") + + old_run = self.output_root / "old-run" + old_settings = old_run / "settings" + old_storage = old_run / "sdcard" + old_artifacts = old_run / "artifacts" + old_settings.mkdir(parents=True) + old_storage.mkdir() + old_artifacts.mkdir() + (old_settings / "radio.yml").write_text("old\n", encoding="utf-8") + + session = SimulatorSession( + sys.executable, + old_artifacts, + simulator_args=( + str(FAKE_SIMULATOR), + "phase6", + "--settings", + str(old_settings), + "--storage", + str(old_storage), + ), + request_timeout=1.0, + stop_timeout=0.5, + terminate_timeout=0.5, + kill_timeout=0.5, + reader_join_timeout=0.5, + ) + replacement = None + try: + session.start() + assert session.process is not None + old_pid = session.process.pid + session.set_telemetry(61696, 0, 1, 115, 1, 1, "RSSI") + self.assertTrue((old_settings / "telemetry.marker").exists()) + self.assertFalse((template_settings / "telemetry.marker").exists()) + + runs = self.output_root / "runs" + replacement = session.restart_process(fixture, runs) + assert replacement.process is not None + run_directory = replacement.fixture_run_directory + assert run_directory is not None + + self.assertNotEqual(replacement.process.pid, old_pid) + self.assert_reaped(session) + self.assertEqual(run_directory.parent, runs.resolve()) + self.assertEqual( + (run_directory / "settings" / "radio.yml").read_text( + encoding="utf-8" + ), + "template\n", + ) + self.assertFalse( + (run_directory / "settings" / "telemetry.marker").exists() + ) + self.assertEqual(replacement.read_status().analog_override_count, 0) + finally: + if replacement is not None: + replacement.close() + else: + session.close() + if replacement is not None: + self.assert_reaped(replacement) + + def test_phase6_cold_restart_preflight_and_failure_cleanup(self) -> None: + incomplete_fixture = self.output_root / "incomplete-fixture" + (incomplete_fixture / "settings").mkdir(parents=True) + session = self.session("phase6") + try: + session.start() + with self.assertRaisesRegex(ValueError, "fixture directory"): + session.restart_process( + incomplete_fixture, self.output_root / "unused-runs" + ) + self.assertTrue(session.ping().ok) + finally: + session.close() + self.assert_reaped(session) + + failing_fixture = self.output_root / "failing-fixture" + failing_settings = failing_fixture / "settings" + failing_storage = failing_fixture / "sdcard" + failing_settings.mkdir(parents=True) + failing_storage.mkdir() + (failing_settings / "startup-fail").write_text("fail\n", encoding="utf-8") + runs = self.output_root / "failed-runs" + + failing_session = self.session("phase6") + failing_session.start() + with self.assertRaises(ProcessExited): + failing_session.restart_process(failing_fixture, runs) + self.assert_reaped(failing_session) + self.assertEqual(list(runs.iterdir()), []) + + def test_one_hundred_lifecycle_cycles_leave_no_reader_or_child(self) -> None: + for cycle in range(100): + with self.subTest(cycle=cycle): + session = self.session("normal") + session.start() + session.stop() + self.assert_reaped(session) + + def test_cli_probe_uses_the_same_session_lifecycle(self) -> None: + result = subprocess.run( + [ + sys.executable, + str(LAUNCHER), + "probe", + "--output", + str(self.output_root), + "--timeout", + "1", + sys.executable, + "--", + str(FAKE_SIMULATOR), + "normal", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=5, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["ping"]["id"], 1) + self.assertEqual(payload["describe"]["id"], 2) + self.assertEqual(payload["ready"]["id"], 3) + self.assertEqual(payload["stop"]["id"], 4) + self.assertEqual(payload["returncode"], 0) + + def test_cli_returns_nonzero_for_a_protocol_command_failure(self) -> None: + result = subprocess.run( + [ + sys.executable, + str(LAUNCHER), + "probe", + "--output", + str(self.output_root), + "--timeout", + "1", + sys.executable, + "--", + str(FAKE_SIMULATOR), + "command-error", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=5, + check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertEqual(result.stdout, "") + self.assertIn("unsupported_command", result.stderr) + + def test_cli_probe_closes_session_when_start_raises_base_exception(self) -> None: + from edgetx_ui import cli + + class ProbeAbort(BaseException): + pass + + class AbortingSession: + instance = None + + def __init__(self, *args: object, **kwargs: object) -> None: + self.closed = False + type(self).instance = self + + def start(self, **kwargs: object) -> object: + raise ProbeAbort("abort probe") + + def close(self) -> None: + self.closed = True + + with mock.patch.object(cli, "SimulatorSession", AbortingSession): + with self.assertRaises(ProbeAbort): + cli.main( + [ + "probe", + "--output", + str(self.output_root), + "fake-simulator", + ] + ) + assert AbortingSession.instance is not None + self.assertTrue(AbortingSession.instance.closed) + + +if __name__ == "__main__": + unittest.main()