From c2c8809931fab6f5a17370a78ffddf4b2f47e427 Mon Sep 17 00:00:00 2001 From: archat-hash Date: Mon, 1 Jun 2026 23:27:41 +0200 Subject: [PATCH 1/4] feat(wizard): add interactive RC calibration v1.1.0 - Real-time MSP telemetry polling (MSP_RC) - Human-in-the-Loop visual feedback in stderr - Strict JSON output in stdout for AI agents - Refactored for DI and dependency-cruiser compliance - Reorganized documentation structure - Updated unit tests and version bump --- docs/ARCHITECTURE.md | 178 ------------- docs/architecture/01-scenarios-view.md | 45 ++++ docs/architecture/02-logical-view.md | 70 ++++++ docs/architecture/03-process-view.md | 48 ++++ docs/architecture/04-development-view.md | 43 ++++ docs/architecture/05-physical-view.md | 41 +++ .../adrs}/ADR-001-Context-System.md | 0 docs/business/OKR.md | 12 + docs/business/REQUIREMENTS.md | 18 ++ index.js | 10 + package.json | 2 +- .../wizards/rxCalibrationMachine.js | 216 ++++++++++++++++ src/core/msp.js | 1 + src/infrastructure/MspProtocol.js | 237 ++++++++++++++++++ src/interfaces/cli/wizard.js | 77 ++++++ src/interfaces/ui/terminalIndicator.js | 124 +++++++++ test/unit/ExecuteCliUseCase.test.js | 4 +- test/unit/MspProtocol.test.js | 98 ++++++++ test/unit/execute_empty_fix.test.js | 2 +- test/unit/execute_queue.test.js | 4 +- .../unit/execute_timeout_reproduction.test.js | 2 +- test/unit/rxCalibrationMachine.test.js | 105 ++++++++ test/unit/timeout_fix.test.js | 2 +- 23 files changed, 1153 insertions(+), 186 deletions(-) delete mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/architecture/01-scenarios-view.md create mode 100644 docs/architecture/02-logical-view.md create mode 100644 docs/architecture/03-process-view.md create mode 100644 docs/architecture/04-development-view.md create mode 100644 docs/architecture/05-physical-view.md rename docs/{ => architecture/adrs}/ADR-001-Context-System.md (100%) create mode 100644 docs/business/OKR.md create mode 100644 docs/business/REQUIREMENTS.md create mode 100644 src/application/wizards/rxCalibrationMachine.js create mode 100644 src/infrastructure/MspProtocol.js create mode 100644 src/interfaces/cli/wizard.js create mode 100644 src/interfaces/ui/terminalIndicator.js create mode 100644 test/unit/MspProtocol.test.js create mode 100644 test/unit/rxCalibrationMachine.test.js diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 86bec0b..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,178 +0,0 @@ -# FlyCLI Solution Architecture - -This document describes the architectural solution of FlyCLI using **C4 Model** and **4+1 Architectural View Model** practices. The project is designed as a High-Stability tool for automated diagnostics and configuration of Betaflight flight controllers. - ---- - -## 1. System Context (C4 Level 1) -FlyCLI acts as a mediator between the AI/Pilot and the hardware (Flight Controller). It leverages the **MSP (MultiWii Serial Protocol)** to ensure reliable interaction across various firmwares (Betaflight, iNav, etc.). - -```mermaid -graph TD - User([Pilot / AI Agent]) -- "CLI Commands" --> FlyCLI[FlyCLI Tool] - FlyCLI -- "Serial/MSP Protocol" --> FC[Flight Controller] - FC -- "Telemetry / CLI Data" --> FlyCLI -``` - ---- - -## 2. Logical View (Clean Architecture) -We use hexagonal architecture (Ports and Adapters) to ensure business logic independence. - -```mermaid -graph TD - subgraph Delivery [Delivery Layer / Composition Root] - CLI[src/interfaces/cli/*.js] - end - - subgraph Application [Application Layer] - UC[ExecuteCliUseCase] - end - - subgraph Infrastructure [Infrastructure Layer] - SFC[SerialFlightController] - end - - subgraph Domain [Domain Layer] - IFC((IFlightController)) - CP[CliParser] - end - - CLI -- "Injects" --> SFC - CLI -- "Initializes" --> UC - UC -- "Uses" --> IFC - SFC -- "Implements" --> IFC - UC -- "Uses" --> CP -``` - -### Layers: -- **Domain Layer**: Entities and interfaces (`IFlightController`, `CliParser`). -- **Application Layer**: Use Cases that implement specific business scenarios (`ExecuteCliUseCase`). -- **Infrastructure Layer**: Implementation of Serial communication and Port Scanning. -- **Delivery Layer (Composition Root)**: CLI interface in `src/interfaces/cli/`. This is the only place where infrastructure connects with the application (Dependency Injection). - ---- - -## 3. State Machine (Command Lifecycle) -The CLI command execution process passes through several states to guarantee stability and avoid port "hanging". - -```mermaid -stateDiagram-v2 - [*] --> Idle - Idle --> Connecting : execute() - Connecting --> MspHandshake : connected - MspHandshake --> CliMode : MSP ACK - CliMode --> WaitingPrompt : Send command - WaitingPrompt --> ProcessingOutput : Prompt found (RegEx) - ProcessingOutput --> Idle : Success / Fail - ProcessingOutput --> Rebooting : "save" command detected - Rebooting --> [*] : Port Disconnected - ProcessingOutput --> [*] : Global Timeout (15s) -``` - ---- - -## 4. Process View (Hardware Interaction) -FlyCLI implements resilient processing of asynchronous events and fragmented data. - -```mermaid -sequenceDiagram - participant CLI as interfaces/cli/execute.js - participant UC as ExecuteCliUseCase - participant SFC as SerialFlightController - participant HW as Flight Controller - - CLI->>SFC: new SerialFlightController(...) - CLI->>UC: new ExecuteCliUseCase(SFC, ...) - CLI->>UC: execute("status") - - UC->>SFC: connect() - SFC->>HW: MSP Handshake (API_VERSION) - HW-->>SFC: ACK (0x65) - - UC->>SFC: sendRaw("status\n") - HW-->>SFC: Data Chunks... - HW-->>SFC: Final Prompt "# " - - SFC-->>UC: Full Response String - UC-->>CLI: Parsed JSON/Text - CLI->>User: Display Status Output -``` - ---- - -## 5. Development View (Standards & Tools) -The project adheres to high code quality principles to ensure AI-Ready status. - -- **Linting**: Airbnb JavaScript Style Guide (Strict). -- **Module System**: ESM (ECMAScript Modules). -- **Testing Strategy**: - - **Unit (Jest)**: Covers all significant behavior branches, including timeouts and connection breaks. - - **Integration (Jest)**: Control of architectural layers through **dependency-cruiser**. - - **BDD (Cucumber)**: **34 scenarios** of full functional verification on real hardware (STM32F411). -- **Resilience**: Protected by timeouts and buffer flush mechanisms. - ---- - -## 6. Physical View (Deployment) -FlyCLI is deployed as a Node.js tool connected via USB. - -```mermaid -graph LR - subgraph Host [Host Machine] - Node[Node.js Runtime] - FlyCLI[FlyCLI App] - Serial[System Serial APIs] - end - - subgraph Device [Hardware] - STM32[STM32 Chip] - BF[Betaflight FW] - end - - FlyCLI --> Node - Node --> Serial - Serial -- "USB / Serial VCP" --> STM32 - STM32 --> BF -``` - ---- - -## 7. Implementation Reality (Bottom-Up Challenges) - -### 7.1. Data Fragmentation (Serial Chunks) -The reality of working with USB-VCP requires processing chunks of 64/128 bytes. `SerialFlightController` accumulates data in `#buffer` until the prompt pattern appears. - -### 7.2. Debounce (Fake Prompts) -A delay of **300ms** is added in `ExecuteCliUseCase` after prompt detection to collect the "tail" of data that might have been delayed in the buffer. - -### 7.3. Hardware Handshake -MSP Handshake at start forces the firmware to initialize the USB stack, which is critical for reliable entry into CLI mode on some boards (e.g., STM32F411 Black Pill). - ---- - -## 8. AI Tuning Layer (Experimental) -FlyCLI provides structured data that enables AI Agents (like Gemini CLI) to act as Tuning Experts. - -```mermaid -graph TD - User([Pilot]) -- "Asks for advice" --> AI[AI Agent / Gemini CLI] - AI -- "flycli execute --json" --> SFC[SerialFlightController] - SFC -- "Raw Config" --> AI - AI -- "Refers to" --> KB[docs/knowledge/tuning-base.md] - KB -- "Best Practices" --> AI - AI -- "Proposes Fix" --> User -``` - -### Components: -- **Expert Agents**: Specialized prompts in `.agents/TUNER.md`. -- **Knowledge Base**: Curated tuning data in `docs/knowledge/`. -- **Structured Feedback**: JSON output from CLI ensures the AI doesn't hallucinate parameter names. - ---- - -## Key Design Decisions (ADR Summary) -- **Prompt Detection**: Dynamic detection via RegEx. -- **Echo Suppression**: Command echo removal. -- **Strict ESM**: Pure JS without a transpilation stage. -- **AI-First Design**: JSON output and structured knowledge base for automated diagnostics. diff --git a/docs/architecture/01-scenarios-view.md b/docs/architecture/01-scenarios-view.md new file mode 100644 index 0000000..2ef3cbf --- /dev/null +++ b/docs/architecture/01-scenarios-view.md @@ -0,0 +1,45 @@ +# 1. Scenarios View (+1 View) + +## Context +This view illustrates how the FlyCLI system is used from the perspective of external actors (Human Pilots and AI Agents) interacting with the Flight Controller hardware. + +## 1.1 Core CLI Execution Scenario (Global) +This represents the primary use case: sending atomic text commands and retrieving structured results. + +```mermaid +graph TD + User([Pilot / AI Agent]) -- "CLI Commands (e.g., status, dump)" --> FlyCLI[FlyCLI Tool] + FlyCLI -- "Serial/MSP Protocol" --> FC[Flight Controller] + FC -- "Telemetry / CLI Data" --> FlyCLI + FlyCLI -- "Parsed JSON or Text" --> User +``` + +## 1.2 Interactive RC Calibration Scenario (New Feature) +This flow highlights orchestration: the AI Agent triggers an interactive command, but the Human provides the physical input. FlyCLI acts as the bridge, providing visual feedback to the Human while collecting structured data for the Agent. + +```mermaid +sequenceDiagram + actor Human as Pilot + participant Agent as AI Agent + participant CLI as FlyCLI Process + participant FC as Flight Controller + + Agent->>CLI: flycli wizard rx --json + CLI->>FC: Open Serial Port + CLI->>CLI: Initialize State Machine + + loop Every 50ms (Polling) + CLI->>FC: Request MSP_RC (ID: 105) + FC-->>CLI: Binary Channel Data + CLI->>Human: Render ANSI Progress Bars (via process.stderr) + Human->>Human: Observes live feedback + end + + Human->>FC: Moves Transmitter Sticks Physically + CLI->>CLI: State Machine detects min/max limits reached + + CLI->>FC: Close Serial Port + CLI->>CLI: Clear ANSI terminal lines + CLI-->>Agent: Print JSON Summary (via process.stdout) + Agent->>Human: Confirm successful connection via Chat +``` diff --git a/docs/architecture/02-logical-view.md b/docs/architecture/02-logical-view.md new file mode 100644 index 0000000..4f09cb0 --- /dev/null +++ b/docs/architecture/02-logical-view.md @@ -0,0 +1,70 @@ +# 2. Logical View + +## Context +This view outlines the primary abstractions, components, and the clean architecture that drives the business logic of FlyCLI. We use hexagonal architecture (Ports and Adapters) to ensure business logic independence. + +## 2.1 Hexagonal Architecture (Global System) + +```mermaid +graph TD + subgraph Delivery [Delivery Layer / Composition Root] + CLI[src/interfaces/cli/*.js] + end + + subgraph Application [Application Layer] + UC[ExecuteCliUseCase] + WIZ[RxCalibrationMachine] + end + + subgraph Infrastructure [Infrastructure Layer] + SFC[SerialFlightController] + MSP[MspProtocol] + end + + subgraph Domain [Domain Layer] + IFC((IFlightController)) + CP[CliParser] + end + + CLI -- "Injects" --> SFC + CLI -- "Initializes" --> UC + CLI -- "Initializes" --> WIZ + UC -- "Uses" --> IFC + WIZ -- "Uses" --> MSP + SFC -- "Implements" --> IFC + UC -- "Uses" --> CP +``` + +### Layers: +- **Domain Layer**: Entities and interfaces (`IFlightController`, `CliParser`). +- **Application Layer**: Use Cases that implement specific scenarios (`ExecuteCliUseCase`, `RxCalibrationMachine`). +- **Infrastructure Layer**: Implementation of Serial communication (`SerialFlightController`) and binary protocol parsing (`MspProtocol`). +- **Delivery Layer**: CLI interfaces in `src/interfaces/cli/`. This is the only place where infrastructure connects with the application. + +## 2.2 Interactive RC State Machine (Feature Logic) +The `RxCalibrationMachine` governs the RC wizard lifecycle. + +```mermaid +stateDiagram-v2 + [*] --> INIT: Execution Started + INIT --> CONNECTING: Open Port + CONNECTING --> POLLING: Port Opened Successfully + CONNECTING --> ERROR: Port Failure + + state POLLING { + [*] --> READ_MSP + READ_MSP --> RENDER_UI + RENDER_UI --> CHECK_LIMITS + CHECK_LIMITS --> READ_MSP: Sticks haven't reached min/max + } + + POLLING --> ANALYZING: All 4 axes hit edges (or Timeout) + ANALYZING --> SUCCESS: Data Valid + ANALYZING --> TIMEOUT: Data Invalid / Incomplete + + SUCCESS --> DONE: Prepare JSON + TIMEOUT --> DONE: Prepare Error JSON + ERROR --> DONE + + DONE --> [*]: process.exit(0/1) +``` diff --git a/docs/architecture/03-process-view.md b/docs/architecture/03-process-view.md new file mode 100644 index 0000000..acf78f9 --- /dev/null +++ b/docs/architecture/03-process-view.md @@ -0,0 +1,48 @@ +# 3. Process View + +## Context +This view explains the flow of data across asynchronous processes, focusing on hardware communication resilience and output stream separation. + +## 3.1 Hardware Interaction (Command Lifecycle) +FlyCLI implements resilient processing of asynchronous events and fragmented data. + +```mermaid +sequenceDiagram + participant CLI as interfaces/cli/execute.js + participant UC as ExecuteCliUseCase + participant SFC as SerialFlightController + participant HW as Flight Controller + + CLI->>SFC: new SerialFlightController(...) + CLI->>UC: new ExecuteCliUseCase(SFC, ...) + CLI->>UC: execute("status") + + UC->>SFC: connect() + SFC->>HW: MSP Handshake (API_VERSION) + HW-->>SFC: ACK (0x65) + + UC->>SFC: sendRaw("status\n") + HW-->>SFC: Data Chunks... + HW-->>SFC: Final Prompt "# " + + SFC-->>UC: Full Response String + UC-->>CLI: Parsed JSON/Text +``` + +### Implementation Realities: +- **Data Fragmentation:** USB-VCP requires processing chunks of 64/128 bytes. `SerialFlightController` accumulates data in `#buffer` until the prompt pattern appears. +- **Debounce:** A delay of **300ms** is added in `ExecuteCliUseCase` after prompt detection to collect the "tail" of data. + +## 3.2 Data Stream Separation (Interactive UI) +For interactive features (like the RC Wizard), visual elements must not corrupt machine-readable output formats. + +```mermaid +graph TD + A[FlyCLI Process] -->|Asynchronous Event Loop| B(MSP Polling Timer ~20Hz) + B --> C{Output Routing} + C -->|Visual Progress Bars| D[process.stderr / TTY] + C -->|Final JSON payload| E[process.stdout] +``` + +- `process.stderr.write` is used synchronously to draw ANSI bars. +- `console.log` (stdout) is strictly reserved for the final output string/JSON. diff --git a/docs/architecture/04-development-view.md b/docs/architecture/04-development-view.md new file mode 100644 index 0000000..1b50b76 --- /dev/null +++ b/docs/architecture/04-development-view.md @@ -0,0 +1,43 @@ +# 4. Development View + +## Context +This view outlines the project's development standards, directory structures, and quality assurance strategies. + +## 4.1 Standards & Tools +The project adheres to high code quality principles to ensure AI-Ready status. +- **Linting**: Airbnb JavaScript Style Guide (Strict). +- **Module System**: ESM (ECMAScript Modules) without transpilation. +- **Testing Strategy**: + - **Unit (Jest)**: Covers all significant behavior branches, including timeouts and connection breaks. + - **Integration (Jest)**: Control of architectural layers through **dependency-cruiser**. + - **BDD (Cucumber)**: **34 scenarios** of full functional verification on real hardware (STM32F411). + +## 4.2 Module Organization +```mermaid +graph LR + subgraph src/interfaces + CLI[cli/execute.js, wizard.js] + UI[ui/terminalIndicator.js] + end + + subgraph src/application + UC[useCases/ExecuteCliUseCase.js] + SM[wizards/rxCalibrationMachine.js] + end + + subgraph src/infrastructure + SFC[infrastructure/SerialFlightController.js] + MSP[infrastructure/MspProtocol.js] + end + + CLI -->|Initializes| UC + CLI -->|Initializes| SM + UC -->|Calls| SFC + SM -->|Calls| MSP + SM -->|Uses| UI +``` + +## 4.3 Key Design Decisions +- **Prompt Detection**: Dynamic detection via RegEx. +- **Echo Suppression**: Command echo removal during CLI parsing. +- **AI-First Design**: `--json` output support and strictly separated streams (stderr for UI, stdout for data) ensure LLM Agents don't hallucinate over visual artifacts. diff --git a/docs/architecture/05-physical-view.md b/docs/architecture/05-physical-view.md new file mode 100644 index 0000000..3620221 --- /dev/null +++ b/docs/architecture/05-physical-view.md @@ -0,0 +1,41 @@ +# 5. Physical View + +## Context +This view illustrates the deployment environment and the physical hardware connections required for FlyCLI and its interactive features to function. + +## 5.1 Deployment & Hardware Topology +FlyCLI is deployed as a Node.js CLI tool running on the Host machine, communicating over USB Serial protocols to embedded devices. + +```mermaid +graph TD + subgraph Agent Host PC + Agent[AI Agent Process (e.g. Antigravity)] + Node[Node.js Runtime] + FlyCLI[FlyCLI App] + Serial[System Serial APIs] + + Agent -- "Spawns via Shell" --> FlyCLI + FlyCLI --> Node + Node --> Serial + end + + subgraph Flight Controller Stack + STM32[STM32 Chip] + BF[Betaflight FW] + RX[Radio Receiver] + + Serial -- "USB VCP (Serial)" --> STM32 + STM32 --> BF + STM32 -- "CRSF / SBUS / FPort" --> RX + end + + subgraph Pilot + TX[Radio Transmitter] + + TX -- "2.4GHz / 868MHz / 915MHz" --> RX + end +``` + +## 5.2 Physical Constraints +1. **USB Connectivity:** The Host PC must maintain an uninterrupted USB Virtual COM Port connection. Standard OS buffering rules apply. +2. **Radio Link Verification:** For RC calibration wizards, the Transmitter (TX) must be bound to the Receiver (RX), and the Receiver correctly wired to a Flight Controller UART. FlyCLI acts as a digital bridge crossing the air gap to verify this physical link. diff --git a/docs/ADR-001-Context-System.md b/docs/architecture/adrs/ADR-001-Context-System.md similarity index 100% rename from docs/ADR-001-Context-System.md rename to docs/architecture/adrs/ADR-001-Context-System.md diff --git a/docs/business/OKR.md b/docs/business/OKR.md new file mode 100644 index 0000000..096ec04 --- /dev/null +++ b/docs/business/OKR.md @@ -0,0 +1,12 @@ +# 🎯 FlyCLI Strategic Objectives (OKRs) + +## Objective 1: Deliver a "Human-in-the-Loop" Setup Process +Ensure that every automated process involving hardware configuration can be safely supervised and interacted with by a human operator, without breaking the automation context. + +**Key Results:** +- **KR1.1:** Release the "Interactive RC Calibration" feature by `v1.2.0`. +- **KR1.2:** Maintain 100% JSON machine-readability on `process.stdout` while pushing visual interactive feedback to humans via `process.stderr` or TTY. +- **KR1.3:** Achieve 0 crashes or port locks during the interactive sequence. + +## Pivot Logic & Reasoning +If the JSON parsing fails for AI Agents due to visual artifacts, we must immediately pivot to a pure background headless mode for agents, separating human UI completely into a standalone tool. diff --git a/docs/business/REQUIREMENTS.md b/docs/business/REQUIREMENTS.md new file mode 100644 index 0000000..64454d6 --- /dev/null +++ b/docs/business/REQUIREMENTS.md @@ -0,0 +1,18 @@ +# 📊 Business Requirements: Interactive RC Calibration + +## 1. Context +Currently, AI Agents configuring Flight Controllers (FC) lack the ability to verify physical radio (RC) connections without explicitly dropping the user out of the automated flow and into Betaflight Configurator. We need an interactive wizard within FlyCLI that allows a "Human-in-the-Loop" verification. + +## 2. User Stories +- **As an AI Agent**, I want to execute `flycli wizard rx --json` so that the process blocks until the user physically verifies the sticks, and then returns a strictly typed JSON object containing the `min`, `max`, and `center` values for the axes. +- **As a Pilot (User)**, I want to see real-time visual progress bars in the terminal when the AI Agent initiates an RC check, so that I know my transmitter movements are being registered by the drone. + +## 3. Success Metrics (Acceptance Criteria) +- **AC1:** The command `flycli wizard rx` successfully connects to the FC using the MSP protocol (specifically `MSP_RC` ID 105). +- **AC2:** Standard output (`stdout`) MUST NOT contain any visual elements, ANSI escape codes, or progress bars. It must remain 100% strictly formatted JSON. +- **AC3:** Visual progress bars MUST be rendered to `stderr` or a direct TTY stream. +- **AC4:** The process must terminate successfully either when all 4 primary axes (Roll, Pitch, Yaw, Throttle) reach their extremums (<1100 and >1900), or via a 15-second timeout, returning the collected data. + +## 4. Constraints +- Must not break the existing textual CLI execution architecture (`src/interfaces/cli/execute.js`). +- Must operate over the same USB VCP connection without requiring external tools. diff --git a/index.js b/index.js index 9fe50d7..07af2ca 100755 --- a/index.js +++ b/index.js @@ -5,6 +5,7 @@ import scanCommand from './src/interfaces/cli/scan.js'; import executeCommand from './src/interfaces/cli/execute.js'; import healthCommand from './src/interfaces/cli/health.js'; import contextCommand from './src/interfaces/cli/context.js'; +import wizardCommand from './src/interfaces/cli/wizard.js'; const program = new Command(); @@ -44,4 +45,13 @@ program .option('--json', 'Output as JSON for AI Agents') .action(contextCommand); +program + .command('wizard') + .description('Run an interactive setup wizard (Human-in-the-Loop)') + .argument('', 'Wizard type: rx (RC calibration)') + .argument('', 'Serial port path (e.g. /dev/tty.usbmodem1 or COM3)') + .argument('[baud]', 'Baud rate', '115200') + .option('--json', 'Output final result as JSON for AI Agents') + .action(wizardCommand); + program.parse(); diff --git a/package.json b/package.json index ab1d69a..94388be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flycli", - "version": "1.0.0", + "version": "1.1.0", "description": "A reliable CLI tool for Betaflight flight controller interaction and automation.", "license": "MIT", "keywords": [ diff --git a/src/application/wizards/rxCalibrationMachine.js b/src/application/wizards/rxCalibrationMachine.js new file mode 100644 index 0000000..60e5d65 --- /dev/null +++ b/src/application/wizards/rxCalibrationMachine.js @@ -0,0 +1,216 @@ +/** + * RC axis definitions for calibration. + * Indexes correspond to the MSP_RC channel array. + */ +const AXES = [ + { index: 0, name: 'Roll' }, + { index: 1, name: 'Pitch' }, + { index: 2, name: 'Yaw' }, + { index: 3, name: 'Throttle' }, +]; + +const RC_EDGE_LOW = 1100; +const RC_EDGE_HIGH = 1900; +const POLL_INTERVAL_MS = 50; +const DEFAULT_TIMEOUT_MS = 15000; + +/** State Machine states */ +const State = Object.freeze({ + INIT: 'INIT', + CONNECTING: 'CONNECTING', + POLLING: 'POLLING', + ANALYZING: 'ANALYZING', + SUCCESS: 'SUCCESS', + TIMEOUT: 'TIMEOUT', + ERROR: 'ERROR', + DONE: 'DONE', +}); + +/** + * RxCalibrationMachine orchestrates the Interactive RC Calibration wizard. + * + * Flow: INIT → CONNECTING → POLLING → ANALYZING → SUCCESS/TIMEOUT → DONE + * + * - Renders live channel progress bars to process.stderr (human-readable). + * - Emits a structured JSON result to process.stdout only when DONE (Agent-readable). + */ +export default class RxCalibrationMachine { + #msp; + + #ui; + + #timeoutMs; + + #state; + + #stats; + + #pollTimer; + + #globalTimer; + + #lastError; + + /** + * @param {object} msp - MSP Protocol implementation + * @param {object} ui - Terminal UI implementation + * @param {number} [timeoutMs] - Global timeout in milliseconds + */ + constructor(msp, ui, timeoutMs = DEFAULT_TIMEOUT_MS) { + this.#msp = msp; + this.#ui = ui; + this.#timeoutMs = timeoutMs; + this.#state = State.INIT; + this.#stats = AXES.map(() => ({ min: 2000, max: 1000, center: 1500 })); + this.#pollTimer = null; + this.#globalTimer = null; + this.#lastError = null; + } + + /** + * Runs the wizard. Returns the JSON result object. + * @returns {Promise} + */ + run() { + return new Promise((resolve) => { + this.#transition(State.CONNECTING, resolve); + }); + } + + // ─── State Transitions ────────────────────────────────────────────────────── + + /** + * Dispatch table mapping each State to its handler. + * Replaces switch to keep cyclomatic complexity ≤ 5. + */ + #getHandler(nextState, resolve) { + const handlers = { + [State.CONNECTING]: () => this.#onConnecting(resolve), + [State.POLLING]: () => this.#onPolling(resolve), + [State.ANALYZING]: () => this.#onAnalyzing(resolve), + [State.SUCCESS]: () => this.#onDone('success', null, resolve), + [State.TIMEOUT]: () => this.#onDone('timeout', 'User did not calibrate all axes within the time limit.', resolve), + [State.ERROR]: () => this.#onDone('error', this.#lastError || 'Failed to connect to the flight controller.', resolve), + }; + return handlers[nextState] ?? null; + } + + /** + * Transitions to the next state and invokes its handler. + * @param {string} nextState + * @param {Function} resolve + */ + async #transition(nextState, resolve) { + this.#state = nextState; + const handler = this.#getHandler(nextState, resolve); + if (handler) await handler(); + } + + async #onConnecting(resolve) { + try { + await this.#msp.connect(); + this.#transition(State.POLLING, resolve); + } catch (err) { + this.#lastError = err.message; + this.#transition(State.ERROR, resolve); + } + } + + #onPolling(resolve) { + // Initial channel snapshot to know how many channels we have + this.#msp.requestRc().then((channels) => { + const count = Math.min(channels.length, 8); + this.#ui.start(count); + + // Start global timeout + this.#globalTimer = setTimeout(() => { + this.#stopPolling(); + this.#transition(State.TIMEOUT, resolve); + }, this.#timeoutMs); + this.#globalTimer.unref(); + + // Start poll loop + this.#pollLoop(resolve); + }).catch((err) => { + this.#lastError = `Initial telemetry request failed: ${err.message}`; + this.#transition(State.ERROR, resolve); + }); + } + + #pollLoop(resolve) { + this.#pollTimer = setTimeout(async () => { + try { + const channels = await this.#msp.requestRc(); + this.#updateStats(channels); + this.#ui.render(channels, this.#stats); + + if (this.#allAxesCalibrated()) { + this.#stopPolling(); + this.#transition(State.ANALYZING, resolve); + } else { + this.#pollLoop(resolve); + } + } catch (err) { + this.#stopPolling(); + this.#lastError = `Telemetry poll lost: ${err.message}`; + this.#transition(State.ERROR, resolve); + } + }, POLL_INTERVAL_MS); + this.#pollTimer.unref(); + } + + #onAnalyzing(resolve) { + this.#transition(State.SUCCESS, resolve); + } + + #onDone(status, errorMsg, resolve) { + this.#state = State.DONE; + this.#ui.clear(); + + const result = { + status, + channels: AXES.reduce((acc, axis) => { + acc[axis.name.toLowerCase()] = { ...this.#stats[axis.index] }; + return acc; + }, {}), + }; + + if (errorMsg) { + result.error = errorMsg; + } + + resolve(result); + } + + // ─── Helpers ───────────────────────────────────────────────────────────────── + + #stopPolling() { + if (this.#pollTimer) { + clearTimeout(this.#pollTimer); + this.#pollTimer = null; + } + if (this.#globalTimer) { + clearTimeout(this.#globalTimer); + this.#globalTimer = null; + } + this.#msp.disconnect().catch(() => {}); + } + + #updateStats(channels) { + AXES.forEach((axis) => { + const val = channels[axis.index]; + const stat = this.#stats[axis.index]; + if (val === undefined || !stat) return; + if (val < stat.min) stat.min = val; + if (val > stat.max) stat.max = val; + stat.center = val; + }); + } + + #allAxesCalibrated() { + return AXES.every((axis) => { + const stat = this.#stats[axis.index]; + return stat.min <= RC_EDGE_LOW && stat.max >= RC_EDGE_HIGH; + }); + } +} diff --git a/src/core/msp.js b/src/core/msp.js index e19bed3..4d58963 100644 --- a/src/core/msp.js +++ b/src/core/msp.js @@ -10,6 +10,7 @@ export default class MSP { BUILD_INFO: 5, FEATURE: 36, BOARD_ALIGNMENT_CONFIG: 38, + RC: 105, CLI: 216, }; diff --git a/src/infrastructure/MspProtocol.js b/src/infrastructure/MspProtocol.js new file mode 100644 index 0000000..2890292 --- /dev/null +++ b/src/infrastructure/MspProtocol.js @@ -0,0 +1,237 @@ +import { SerialPort } from 'serialport'; +import EventEmitter from 'events'; +import MSP from '../core/msp.js'; + +/** + * MspProtocol handles binary MSP (MultiWii Serial Protocol) communication + * directly over a raw serial port, independent of CLI text mode. + * + * Used by interactive wizards (e.g. RC Calibration) that need + * real-time telemetry polling rather than text command/response cycles. + */ +export default class MspProtocol { + #path; + + #baudRate; + + #port; + + #events; + + #rxBuffer; + + /** + * @param {string} path - Serial port path (e.g. COM3 or /dev/ttyUSB0) + * @param {number} baudRate - Baud rate (e.g. 115200) + */ + constructor(path, baudRate) { + this.#path = path; + this.#baudRate = baudRate; + this.#port = null; + this.#events = new EventEmitter(); + this.#rxBuffer = Buffer.alloc(0); + } + + /** + * Opens the serial port and starts listening for binary MSP frames. + * @returns {Promise} + */ + connect() { + return new Promise((resolve, reject) => { + try { + if (this.#port && this.#port.isOpen) { + resolve(); + return; + } + + this.#port = new SerialPort({ + path: this.#path, + baudRate: this.#baudRate, + autoOpen: false, + }); + + this.#port.on('data', (chunk) => this.#handleData(chunk)); + this.#port.on('error', (err) => { + this.#events.emit('error', err); + }); + + this.#port.open((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + } catch (err) { + reject(err); + } + }); + } + + /** + * Closes the serial port. + * @returns {Promise} + */ + disconnect() { + return new Promise((resolve) => { + if (this.#port && this.#port.isOpen) { + this.#port.close(() => resolve()); + } else { + resolve(); + } + }); + } + + /** + * Sends a raw MSP request frame for the given command. + * @param {number} cmd - MSP command ID (e.g. MSP.CMD.RC = 105) + * @returns {Promise} + */ + request(cmd) { + return new Promise((resolve, reject) => { + if (!this.#port || !this.#port.isOpen) { + reject(new Error('MspProtocol: port is not open')); + return; + } + const frame = MSP.encode(cmd); + this.#port.write(frame, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + /** + * Sends MSP_RC request and returns parsed channel values. + * Resolves with an array of up to 16 channel values (1000-2000 range). + * @param {number} [timeoutMs=200] - Timeout waiting for response. + * @returns {Promise} + */ + requestRc(timeoutMs = 200) { + return new Promise((resolve, reject) => { + let resolved = false; + + const onRc = (channels) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); // eslint-disable-line no-use-before-define + resolve(channels); + } + }; + + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + this.#events.removeListener('msp_rc', onRc); + reject(new Error('MspProtocol: timeout waiting for MSP_RC response')); + } + }, timeoutMs); + timer.unref(); + + this.#events.once('msp_rc', onRc); + this.request(MSP.CMD.RC).catch((err) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + this.#events.removeListener('msp_rc', onRc); + reject(err); + } + }); + }); + } + + /** + * Accumulates incoming binary data and tries to extract MSP frames. + * @param {Buffer} chunk + */ + #handleData(chunk) { + this.#rxBuffer = Buffer.concat([this.#rxBuffer, chunk]); + this.#processBuffer(); + } + + /** + * Finds the next frame start in the buffer, stripping any leading garbage bytes. + * @returns {number} Index of frame start, or -1 if not found. + */ + #findFrameStart() { + return this.#rxBuffer.indexOf(Buffer.from([0x24, 0x4D])); + } + + /** + * Parses a complete MSP frame and emits 'msp_rc' if it is a valid RC packet. + * @param {Buffer} frame + */ + #dispatchPacket(frame) { + const packet = MSP.parse(frame); + if (packet && !packet.crcError && packet.type === MSP.CMD.RC) { + this.#events.emit('msp_rc', MspProtocol.#parseRcPayload(packet.payload)); + } + } + + /** + * Attempts to extract and dispatch a single complete MSP frame. + * @returns {boolean} true if a frame was consumed, false if buffer is incomplete. + */ + #extractFrame() { + if (this.#rxBuffer.length < 6) return false; + + const size = this.#rxBuffer[3]; + const frameLength = 6 + size; + + if (this.#rxBuffer.length < frameLength) return false; + + const frame = this.#rxBuffer.slice(0, frameLength); + this.#rxBuffer = this.#rxBuffer.slice(frameLength); + this.#dispatchPacket(frame); + return true; + } + + /** + * Cleans the buffer by discarding bytes that cannot be part of a frame start. + */ + #cleanBuffer() { + const start = this.#findFrameStart(); + if (start === -1) { + // If 0x24 is at the end, keep it, otherwise clear. + if (this.#rxBuffer[this.#rxBuffer.length - 1] === 0x24) { + this.#rxBuffer = this.#rxBuffer.slice(this.#rxBuffer.length - 1); + } else { + this.#rxBuffer = Buffer.alloc(0); + } + return -1; + } + if (start > 0) { + this.#rxBuffer = this.#rxBuffer.slice(start); + } + return 0; + } + + /** + * Searches the rx buffer for valid MSP response frames and emits events. + * Frame format: $ M > [payload...] + */ + #processBuffer() { + let running = true; + while (running && this.#rxBuffer.length > 0) { + if (this.#cleanBuffer() === -1) { + running = false; + } else { + running = this.#extractFrame(); + } + } + } + + /** + * Parses the MSP_RC payload into an array of channel values. + * Each channel is a uint16 little-endian value. + * @param {Buffer} payload + * @returns {number[]} + */ + static #parseRcPayload(payload) { + const channels = []; + for (let i = 0; i + 1 < payload.length; i += 2) { + channels.push(payload.readUInt16LE(i)); + } + return channels; + } +} diff --git a/src/interfaces/cli/wizard.js b/src/interfaces/cli/wizard.js new file mode 100644 index 0000000..5ae2c3f --- /dev/null +++ b/src/interfaces/cli/wizard.js @@ -0,0 +1,77 @@ +import RxCalibrationMachine from '../../application/wizards/rxCalibrationMachine.js'; +import MspProtocol from '../../infrastructure/MspProtocol.js'; +import TerminalIndicator from '../ui/terminalIndicator.js'; + +/** + * Writes the wizard result to stdout (JSON) or stderr (human text). + * @param {object} result + * @param {boolean} isJson + */ +function outputResult(result, isJson) { + if (isJson) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + const icon = result.status === 'success' ? '✔' : '✘'; + process.stderr.write(`\n ${icon} RC Wizard finished with status: ${result.status}\n`); + if (result.error) { + process.stderr.write(` Error: ${result.error}\n`); + return; + } + Object.entries(result.channels).forEach(([axis, stats]) => { + process.stderr.write(` ${axis.padEnd(10)} min=${stats.min} max=${stats.max}\n`); + }); +} + +/** + * Writes a fatal error and exits. + * @param {Error} err + * @param {boolean} isJson + */ +function handleFatalError(err, isJson) { + const errResult = { status: 'error', error: err.message }; + if (isJson) { + process.stdout.write(`${JSON.stringify(errResult)}\n`); + } else { + process.stderr.write(`\n ✘ Fatal error: ${err.message}\n`); + } + process.exit(1); +} + +/** + * Handler for `flycli wizard [baud]` + * + * - Visual progress bars go to process.stderr (human-readable). + * - Final JSON result goes to process.stdout (machine-readable for AI Agents). + * + * @param {string} type - Wizard type (currently only 'rx') + * @param {string} port - Serial port path + * @param {string} baud - Baud rate as string + * @param {object} options - Commander options + */ +export default async function wizardCommand(type, port, baud, options) { + if (type !== 'rx') { + const err = { status: 'error', error: `Unknown wizard type: "${type}". Available: rx` }; + outputResult(err, options.json); + process.exit(1); + } + + const baudRate = parseInt(baud, 10); + + if (!options.json) { + process.stderr.write('\n ✦ FlyCLI Interactive RC Wizard\n'); + process.stderr.write(` Port: ${port} @ ${baudRate} baud\n`); + process.stderr.write(' Connecting to flight controller...\n\n'); + } + + try { + const msp = new MspProtocol(port, baudRate); + const ui = new TerminalIndicator(); + const machine = new RxCalibrationMachine(msp, ui); + const result = await machine.run(); + outputResult(result, options.json); + process.exit(result.status === 'success' ? 0 : 1); + } catch (err) { + handleFatalError(err, options.json); + } +} diff --git a/src/interfaces/ui/terminalIndicator.js b/src/interfaces/ui/terminalIndicator.js new file mode 100644 index 0000000..3f9a61f --- /dev/null +++ b/src/interfaces/ui/terminalIndicator.js @@ -0,0 +1,124 @@ +/** + * TerminalIndicator renders real-time ANSI progress bars for RC channel values + * to process.stderr (NOT stdout), ensuring stdout stays clean for JSON output. + * + * Channel value range: 1000-2000 (standard RC PWM). + */ + +const CHANNEL_NAMES = ['Roll', 'Pitch', 'Yaw ', 'Thro', 'CH5 ', 'CH6 ', 'CH7 ', 'CH8 ']; +const BAR_WIDTH = 30; +const RC_MIN = 1000; +const RC_MAX = 2000; + +const ANSI = { + clearLine: '\x1b[2K', + moveCursorUp: (n) => `\x1b[${n}A`, + reset: '\x1b[0m', + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + bold: '\x1b[1m', + dim: '\x1b[2m', +}; + +/** + * @typedef {object} ChannelStats + * @property {number} min + * @property {number} max + */ + +/** + * Renders a single bar segment string (filled + empty). + * @param {number} value - RC value + * @returns {string} + */ +function buildBar(value) { + const clamped = Math.max(RC_MIN, Math.min(RC_MAX, value)); + const fraction = (clamped - RC_MIN) / (RC_MAX - RC_MIN); + const filled = Math.round(fraction * BAR_WIDTH); + const empty = BAR_WIDTH - filled; + return `${ANSI.green}${'█'.repeat(filled)}${ANSI.dim}${'░'.repeat(empty)}${ANSI.reset}`; +} + +/** + * Renders a single progress bar line for a channel. + * @param {string} name - Channel label + * @param {number} value - RC value (1000-2000) + * @param {ChannelStats} stats - Recorded min/max + * @returns {string} + */ +function renderBar(name, value, stats) { + const bar = buildBar(value); + const valueStr = String(value).padStart(4); + if (!stats) { + return `${ANSI.cyan}${ANSI.bold}${name}${ANSI.reset} ${bar} ${ANSI.yellow}${valueStr}${ANSI.reset}`; + } + const minStr = String(stats.min).padStart(4); + const maxStr = String(stats.max).padStart(4); + const rangeStr = `${ANSI.dim}[${minStr}-${maxStr}]${ANSI.reset}`; + return `${ANSI.cyan}${ANSI.bold}${name}${ANSI.reset} ${bar} ${ANSI.yellow}${valueStr}${ANSI.reset} ${rangeStr}`; +} + +const HEADER = `${ANSI.bold}${ANSI.cyan} FlyCLI RC Wizard — Move all sticks to their edges${ANSI.reset}`; + +/** + * TerminalIndicator class draws and updates a multi-channel RC display in stderr. + */ +export default class TerminalIndicator { + #lineCount; + + #started; + + constructor() { + this.#lineCount = 0; + this.#started = false; + } + + /** + * Prints the initial header. Must be called before first render(). + * @param {number} channelCount - Number of channels to display + */ + start(channelCount) { + this.#lineCount = channelCount + 2; + process.stderr.write('\n'); + process.stderr.write(`${HEADER}\n`); + for (let i = 0; i < channelCount; i += 1) { + process.stderr.write('\n'); + } + process.stderr.write('\n'); + this.#started = true; + } + + /** + * Redraws all channel bars in-place using ANSI cursor movement. + * @param {number[]} channels - Array of RC values + * @param {ChannelStats[]} stats - Per-channel min/max stats + */ + render(channels, stats) { + if (!this.#started) return; + + process.stderr.write(ANSI.moveCursorUp(this.#lineCount)); + process.stderr.write(`${ANSI.clearLine}\r`); + process.stderr.write(`${HEADER}\n`); + + const count = Math.min(channels.length, CHANNEL_NAMES.length); + for (let i = 0; i < count; i += 1) { + const name = CHANNEL_NAMES[i] || `CH${i + 1}`; + const bar = renderBar(name, channels[i], stats[i]); + process.stderr.write(`${ANSI.clearLine}\r ${bar}\n`); + } + + process.stderr.write(`${ANSI.clearLine}\r`); + } + + /** + * Clears all drawn lines from stderr after the wizard completes. + */ + clear() { + if (!this.#started) return; + for (let i = 0; i < this.#lineCount; i += 1) { + process.stderr.write(`${ANSI.moveCursorUp(1)}${ANSI.clearLine}\r`); + } + this.#started = false; + } +} diff --git a/test/unit/ExecuteCliUseCase.test.js b/test/unit/ExecuteCliUseCase.test.js index ab6086c..9c1472d 100644 --- a/test/unit/ExecuteCliUseCase.test.js +++ b/test/unit/ExecuteCliUseCase.test.js @@ -47,8 +47,8 @@ describe('ExecuteCliUseCase — Success Paths', () => { mockPort.on.mockImplementation((event, cb) => { if (event === 'data') dataCallback = cb; }); const executePromise = useCase.execute('version'); - setTimeout(() => { if (dataCallback) dataCallback(Buffer.from('##CLI\r\n# ')); }, 10); - setTimeout(() => { if (dataCallback) dataCallback(Buffer.from('v\r\n# Betaflight / STM32F411\r\n# ')); }, 20); + setTimeout(() => { if (dataCallback) dataCallback(Buffer.from('##CLI\r\n# ')); }, 100); + setTimeout(() => { if (dataCallback) dataCallback(Buffer.from('version\r\n# Betaflight / STM32F411\r\n# ')); }, 200); const result = await executePromise; expect(result).toContain('Betaflight / STM32F411'); diff --git a/test/unit/MspProtocol.test.js b/test/unit/MspProtocol.test.js new file mode 100644 index 0000000..085dcfe --- /dev/null +++ b/test/unit/MspProtocol.test.js @@ -0,0 +1,98 @@ +/* eslint-disable no-bitwise */ +import { jest } from '@jest/globals'; + +const mockPort = { + open: jest.fn((cb) => cb(null)), + write: jest.fn((data, cb) => cb && cb(null)), + on: jest.fn(), + close: jest.fn((cb) => cb()), + isOpen: true, +}; + +jest.unstable_mockModule('serialport', () => ({ + SerialPort: jest.fn(() => mockPort), +})); + +const { default: MspProtocol } = await import('../../src/infrastructure/MspProtocol.js'); +const { default: MSP } = await import('../../src/core/msp.js'); + +describe('MspProtocol', () => { + let msp; + let dataCallback; + + beforeEach(() => { + jest.clearAllMocks(); + mockPort.on.mockImplementation((event, cb) => { + if (event === 'data') dataCallback = cb; + }); + msp = new MspProtocol('/dev/tty.test', 115200); + }); + + test('connect() should open serial port', async () => { + await msp.connect(); + expect(mockPort.open).toHaveBeenCalled(); + }); + + test('requestRc() should send MSP_RC command and parse response', async () => { + await msp.connect(); + + /* + * Mock response for 4 channels: 1100, 1500, 1900, 1000 + * Each channel is uint16LE (2 bytes) + */ + const payload = Buffer.from([ + 0x4C, 0x04, + 0xDC, 0x05, + 0x6C, 0x07, + 0xE8, 0x03, + ]); + const responseFrame = MSP.encode(MSP.CMD.RC, payload); + // Change $M< to $M> for response + responseFrame[2] = 62; + // Recalculate CRC because we changed direction byte + let crc = payload.length ^ MSP.CMD.RC; + for (let i = 0; i < payload.length; i += 1) { + crc ^= payload[i]; + } + responseFrame[responseFrame.length - 1] = crc; + + const rcPromise = msp.requestRc(); + + // Simulate data arriving in chunks + dataCallback(responseFrame.slice(0, 3)); + dataCallback(responseFrame.slice(3)); + + const channels = await rcPromise; + expect(channels).toEqual([1100, 1500, 1900, 1000]); + expect(mockPort.write).toHaveBeenCalledWith(MSP.encode(MSP.CMD.RC), expect.anything()); + }); + + test('requestRc() should timeout if no response', async () => { + await msp.connect(); + await expect(msp.requestRc(10)).rejects.toThrow('timeout waiting for MSP_RC response'); + }); + + test('should handle interleaved garbage data', async () => { + await msp.connect(); + + const payload = Buffer.from([0xDC, 0x05]); + const frame = MSP.encode(MSP.CMD.RC, payload); + frame[2] = 62; + let crc = payload.length ^ MSP.CMD.RC; + for (let i = 0; i < payload.length; i += 1) { + crc ^= payload[i]; + } + frame[frame.length - 1] = crc; + + const rcPromise = msp.requestRc(); + + // Garbage + half header + dataCallback(Buffer.from([0x00, 0xFF, 0x24])); + // Rest of header + dataCallback(Buffer.from([0x4D, 0x3E])); + dataCallback(frame.slice(3)); + + const channels = await rcPromise; + expect(channels).toEqual([1500]); + }); +}); diff --git a/test/unit/execute_empty_fix.test.js b/test/unit/execute_empty_fix.test.js index 1c90d1b..59774d6 100644 --- a/test/unit/execute_empty_fix.test.js +++ b/test/unit/execute_empty_fix.test.js @@ -45,7 +45,7 @@ describe('executeCommand — Data Fragmentation Fix', () => { { json: true }, ); - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + const logSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => { }); /* Initial prompt */ await new Promise((r) => { setTimeout(r, 50); }); diff --git a/test/unit/execute_queue.test.js b/test/unit/execute_queue.test.js index 1293c31..b726a0d 100644 --- a/test/unit/execute_queue.test.js +++ b/test/unit/execute_queue.test.js @@ -39,7 +39,7 @@ describe('executeCommand — Queue-based State Machine', () => { }); it('should correctly process command through states: ENTER -> DATA -> DONE', async () => { - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + const logSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => { }); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); const cmdPromise = executeCommand( @@ -69,7 +69,7 @@ describe('executeCommand — Queue-based State Machine', () => { }, 15000); it('should handle large fragmented data arriving after echo', async () => { - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + const logSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => { }); const cmdPromise = executeCommand( '/dev/tty.usbmodem1', '115200', diff --git a/test/unit/execute_timeout_reproduction.test.js b/test/unit/execute_timeout_reproduction.test.js index d48e0a7..ecdbb57 100644 --- a/test/unit/execute_timeout_reproduction.test.js +++ b/test/unit/execute_timeout_reproduction.test.js @@ -47,7 +47,7 @@ describe('executeCommand — Timeout Reproduction', () => { { json: true }, ); - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + const logSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => { }); await new Promise((r) => { setTimeout(r, 50); }); dataCallback(Buffer.from('CLI\r\n# ')); diff --git a/test/unit/rxCalibrationMachine.test.js b/test/unit/rxCalibrationMachine.test.js new file mode 100644 index 0000000..097ed85 --- /dev/null +++ b/test/unit/rxCalibrationMachine.test.js @@ -0,0 +1,105 @@ +import { jest } from '@jest/globals'; + +// Mock MSP and UI +const mockMsp = { + connect: jest.fn(), + disconnect: jest.fn(), + requestRc: jest.fn(), +}; + +const mockUi = { + start: jest.fn(), + render: jest.fn(), + clear: jest.fn(), +}; + +jest.unstable_mockModule('../../src/infrastructure/MspProtocol.js', () => ({ + default: jest.fn(() => mockMsp), +})); + +jest.unstable_mockModule('../../src/interfaces/ui/terminalIndicator.js', () => ({ + default: jest.fn(() => mockUi), +})); + +const { default: RxCalibrationMachine } = await import('../../src/application/wizards/rxCalibrationMachine.js'); + +describe('RxCalibrationMachine', () => { + let machine; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + // 1s timeout for testing + machine = new RxCalibrationMachine(mockMsp, mockUi, 1000); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('should succeed when all axes are calibrated', async () => { + mockMsp.connect.mockResolvedValue(); + mockMsp.disconnect.mockResolvedValue(); + + // Initial requestRc for channel count + mockMsp.requestRc.mockResolvedValueOnce([1500, 1500, 1500, 1500]); + + const runPromise = machine.run(); + + /* + * Advance to trigger polling + * CONNECTING -> POLLING + */ + await jest.advanceTimersByTimeAsync(0); + // Initial requestRc + await jest.advanceTimersByTimeAsync(0); + + /* + * Simulate stick movements + * 1. Move to edges + */ + // Lows + mockMsp.requestRc.mockResolvedValueOnce([1050, 1050, 1050, 1050]); + await jest.advanceTimersByTimeAsync(50); + + // Highs + mockMsp.requestRc.mockResolvedValueOnce([1950, 1950, 1950, 1950]); + await jest.advanceTimersByTimeAsync(50); + + const result = await runPromise; + + expect(result.status).toBe('success'); + expect(result.channels.roll.min).toBeLessThanOrEqual(1100); + expect(result.channels.roll.max).toBeGreaterThanOrEqual(1900); + expect(mockUi.render).toHaveBeenCalled(); + expect(mockUi.clear).toHaveBeenCalled(); + }); + + test('should timeout if edges are not reached', async () => { + mockMsp.connect.mockResolvedValue(); + mockMsp.disconnect.mockResolvedValue(); + mockMsp.requestRc.mockResolvedValue([1500, 1500, 1500, 1500]); + + const runPromise = machine.run(); + + // CONNECTING -> POLLING + await jest.advanceTimersByTimeAsync(0); + // Initial requestRc + await jest.advanceTimersByTimeAsync(0); + + // Wait for timeout (1000ms set in constructor) + await jest.advanceTimersByTimeAsync(1100); + + const result = await runPromise; + expect(result.status).toBe('timeout'); + expect(result.error).toContain('time limit'); + }); + + test('should handle connection errors', async () => { + mockMsp.connect.mockRejectedValue(new Error('Connection failed')); + + const result = await machine.run(); + expect(result.status).toBe('error'); + expect(result.error).toBe('Failed to connect to the flight controller.'); + }); +}); diff --git a/test/unit/timeout_fix.test.js b/test/unit/timeout_fix.test.js index 5caea2e..4f879e8 100644 --- a/test/unit/timeout_fix.test.js +++ b/test/unit/timeout_fix.test.js @@ -46,7 +46,7 @@ describe('executeCommand — Debounce Fix', () => { { json: true }, ); - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + const logSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => { }); // 1. Initial prompt await new Promise((r) => { setTimeout(r, 50); }); From 9240956cd1c361c97462049ed498c78feb7e5614 Mon Sep 17 00:00:00 2001 From: archat-hash Date: Wed, 3 Jun 2026 21:24:23 +0200 Subject: [PATCH 2/4] test(wizard): update connection error message Align expectation with simplified error string. --- test/unit/rxCalibrationMachine.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/rxCalibrationMachine.test.js b/test/unit/rxCalibrationMachine.test.js index 097ed85..d95a57f 100644 --- a/test/unit/rxCalibrationMachine.test.js +++ b/test/unit/rxCalibrationMachine.test.js @@ -100,6 +100,6 @@ describe('RxCalibrationMachine', () => { const result = await machine.run(); expect(result.status).toBe('error'); - expect(result.error).toBe('Failed to connect to the flight controller.'); + expect(result.error).toBe('Connection failed'); }); }); From 0d1b962d43315c9de9fb32d97f42e75647678eb3 Mon Sep 17 00:00:00 2001 From: archat-hash Date: Wed, 3 Jun 2026 21:32:39 +0200 Subject: [PATCH 3/4] ci: force github actions to use node 24 to fix deprecation warnings --- .github/workflows/pr-tests.yml | 2 ++ .github/workflows/release.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 318938e..736ade3 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -11,6 +11,8 @@ on: jobs: test: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true strategy: matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3116476..713ac01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ on: jobs: build: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true permissions: contents: write From c8f626ecf64b446e512670f5a70eccc619a10d7e Mon Sep 17 00:00:00 2001 From: archat-hash Date: Wed, 3 Jun 2026 21:37:08 +0200 Subject: [PATCH 4/4] chore: downgrade dependency-cruiser to v16 for Node 18 compatibility --- package-lock.json | 165 +++++++++++++++++++++++++++++++++++++--------- package.json | 4 +- 2 files changed, 135 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 44bef9d..c3f1dbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "flycli", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flycli", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "@yao-pkg/pkg": "^5.12.0", @@ -21,7 +21,7 @@ "devDependencies": { "@cucumber/cucumber": "^12.7.0", "@jest/globals": "^30.3.0", - "dependency-cruiser": "^17.3.10", + "dependency-cruiser": "^16.10.4", "eslint": "^8.57.1", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.32.0", @@ -4003,30 +4003,34 @@ } }, "node_modules/dependency-cruiser": { - "version": "17.3.10", - "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-17.3.10.tgz", - "integrity": "sha512-jF5WaIb+O+wLabXrQE7iBY2zYBEW8VlnuuL0+iZPvZHGhTaAYdLk31DI0zkwhcGE8CiHcDwGhMnn3PfOAYnVdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "8.16.0", - "acorn-jsx": "5.3.2", - "acorn-jsx-walk": "2.0.0", - "acorn-loose": "8.5.2", - "acorn-walk": "8.3.5", - "commander": "14.0.3", - "enhanced-resolve": "5.20.1", - "ignore": "7.0.5", - "interpret": "3.1.1", - "is-installed-globally": "1.0.0", - "json5": "2.2.3", - "picomatch": "4.0.4", - "prompts": "2.4.2", - "rechoir": "0.8.0", - "safe-regex": "2.1.1", - "semver": "7.7.4", - "tsconfig-paths-webpack-plugin": "4.2.0", - "watskeburt": "5.0.3" + "version": "16.10.4", + "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-16.10.4.tgz", + "integrity": "sha512-hrxVOjIm8idZ9ZVDGSyyG3SHiNcEUPhL6RTEmO/3wfQWLepH5pA3nuDMMrcJ1DkZztFA7xg3tk8OVO+MmwwH9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "acorn-jsx-walk": "^2.0.0", + "acorn-loose": "^8.5.2", + "acorn-walk": "^8.3.4", + "ajv": "^8.17.1", + "commander": "^13.1.0", + "enhanced-resolve": "^5.18.2", + "ignore": "^7.0.5", + "interpret": "^3.1.1", + "is-installed-globally": "^1.0.0", + "json5": "^2.2.3", + "memoize": "^10.1.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.2", + "prompts": "^2.4.2", + "rechoir": "^0.8.0", + "safe-regex": "^2.1.1", + "semver": "^7.7.2", + "teamcity-service-messages": "^0.1.14", + "tsconfig-paths-webpack-plugin": "^4.2.0", + "watskeburt": "^4.2.3" }, "bin": { "depcruise": "bin/dependency-cruise.mjs", @@ -4037,7 +4041,34 @@ "dependency-cruiser": "bin/dependency-cruise.mjs" }, "engines": { - "node": "^20.12||^22||>=24" + "node": "^18.17||>=20" + } + }, + "node_modules/dependency-cruiser/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/dependency-cruiser/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/dependency-cruiser/node_modules/is-installed-globally": { @@ -4070,6 +4101,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dependency-cruiser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/dependency-cruiser/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -4929,6 +4967,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7197,6 +7252,22 @@ "node": ">= 0.4" } }, + "node_modules/memoize": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz", + "integrity": "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/memoize?sponsor=1" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7241,6 +7312,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -8364,6 +8448,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -9187,6 +9281,13 @@ "node": ">= 6" } }, + "node_modules/teamcity-service-messages": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/teamcity-service-messages/-/teamcity-service-messages-0.1.14.tgz", + "integrity": "sha512-29aQwaHqm8RMX74u2o/h1KbMLP89FjNiMxD9wbF2BbWOnbM+q+d1sCEC+MqCc4QW3NJykn77OMpTFw/xTHIc0w==", + "dev": true, + "license": "MIT" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9686,16 +9787,16 @@ } }, "node_modules/watskeburt": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-5.0.3.tgz", - "integrity": "sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-4.2.3.tgz", + "integrity": "sha512-uG9qtQYoHqAsnT711nG5iZc/8M5inSmkGCOp7pFaytKG2aTfIca7p//CjiVzAE4P7hzaYuCozMjNNaLgmhbK5g==", "dev": true, "license": "MIT", "bin": { "watskeburt": "dist/run-cli.js" }, "engines": { - "node": "^20.12||^22.13||>=24.0" + "node": "^18||>=20" } }, "node_modules/webidl-conversions": { diff --git a/package.json b/package.json index 94388be..a09ac7b 100644 --- a/package.json +++ b/package.json @@ -59,16 +59,16 @@ "node": ">=18" }, "dependencies": { + "@yao-pkg/pkg": "^5.12.0", "commander": "^14.0.3", "esbuild": "^0.28.0", "fs-extra": "^11.3.5", - "@yao-pkg/pkg": "^5.12.0", "serialport": "^13.0.0" }, "devDependencies": { "@cucumber/cucumber": "^12.7.0", "@jest/globals": "^30.3.0", - "dependency-cruiser": "^17.3.10", + "dependency-cruiser": "^16.10.4", "eslint": "^8.57.1", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.32.0",