diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eabb897..58a634b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,25 +20,21 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - run: npm ci + - run: npm test - run: npm run build firmware: runs-on: ubuntu-latest - env: - FQBN: "esp8266:esp8266:d1_mini:xtal=80,vt=flash,exception=disabled,ssl=all,eesz=4M2M,ip=lm2f,dbg=Disabled,lvl=None____,wipe=none,baud=921600" - ESP8266_INDEX: "https://arduino.esp8266.com/stable/package_esp8266com_index.json" steps: - uses: actions/checkout@v7 - - uses: arduino/setup-arduino-cli@v2 - - name: Install ESP8266 core - run: | - arduino-cli core update-index --additional-urls "$ESP8266_INDEX" - arduino-cli core install esp8266:esp8266 --additional-urls "$ESP8266_INDEX" - - name: Install libraries - run: | - arduino-cli lib install ArduinoJson - arduino-cli lib install WebSockets + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install PlatformIO + run: pip install -U platformio - name: Create secrets stub - run: cp firmware/z-duino/arduino_secrets.h.example firmware/z-duino/arduino_secrets.h + run: cp secrets.ini.example secrets.ini - name: Compile - run: arduino-cli compile --fqbn "$FQBN" firmware/z-duino + run: pio run + - name: Run native unit tests + run: pio test -e native diff --git a/.gitignore b/.gitignore index e05ba03..c6ffc9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ # Secrets +secrets.ini arduino_secrets.h # Build artifacts build/ +.pio/ # Temp / debug scripts tmp/ diff --git a/CLAUDE.md b/CLAUDE.md index 189c334..82977c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,11 +31,20 @@ npm run dev:all # Vite dev server + mock WebSocket server ./build.sh # Interactive menu — option 7 builds + uploads everything ``` +## Testing +```bash +pio test -e native # Firmware: hardware-free unit tests for lib/ (MotorLogic, LedTiming, Command) +cd frontend && npm test # Frontend: Vitest — useTrainController.ts + component smoke tests +``` +Both run in CI. Code touching real hardware I/O (pins, WiFi/sockets) isn't unit-tested this way — verify manually against real hardware. + ## Key Files +- `platformio.ini` — PlatformIO project config (board, ldscript, lib_deps, secrets, native test env) +- `secrets.ini` — WiFi creds & build-time config (not committed, copy from `secrets.ini.example`) +- `lib/MotorLogic/`, `lib/LedTiming/`, `lib/Command/` — hardware-independent logic, unit-tested via `pio test -e native` - `firmware/z-duino/z-duino.ino` — Main sketch - `firmware/z-duino/Motor.h/.cpp` — Motor abstraction - `firmware/z-duino/StatusLED.h/.cpp` — Status LED abstraction -- `firmware/z-duino/arduino_secrets.h` — WiFi creds (not committed, copy from .example) - `frontend/src/App.vue` — Root Vue component - `frontend/src/composables/useTrainController.ts` — Core controller logic (WebSocket, ramping, state) - `frontend/src/components/` — Vue SFCs (SpeedController, LedTestPanel, DebugModal, etc.) @@ -43,5 +52,5 @@ npm run dev:all # Vite dev server + mock WebSocket server - `docs/LITTLEFS.md` — LittleFS flash layout & mklittlefs parameters ## Dependencies -- Arduino: ESP8266 board package, ArduinoJson, WebSockets +- Firmware (PlatformIO, `espressif8266` platform): ArduinoJson, WebSockets — declared in `platformio.ini`, resolved automatically - Node: Vue 3, Vite, Nuxt UI, Tailwind CSS v4, TypeScript diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index deeea2c..151dde3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,13 +5,9 @@ ### Prerequisites - [Node.js](https://nodejs.org/) v18+ -- [Arduino CLI](https://arduino.github.io/arduino-cli/) (for firmware work) -- ESP8266 board package: `arduino-cli core install esp8266:esp8266` -- Arduino libraries: - ```bash - arduino-cli lib install ArduinoJson - arduino-cli lib install WebSockets - ``` +- [PlatformIO Core](https://docs.platformio.org/en/latest/core/installation/index.html) (for firmware work): `brew install platformio` + +Firmware dependencies (`espressif8266` platform, `ArduinoJson`, `WebSockets`) are declared in `platformio.ini` and resolved automatically by PlatformIO on first build — no manual install step needed. See [docs/BUILD.md](docs/BUILD.md) for the full setup walkthrough. ### Frontend Development @@ -33,9 +29,9 @@ Open `http://localhost:3000` in your browser. The throttle UI is fully functiona 1. Copy the secrets template: ```bash - cp firmware/z-duino/arduino_secrets.h.example firmware/z-duino/arduino_secrets.h + cp secrets.ini.example secrets.ini ``` -2. Edit `firmware/z-duino/arduino_secrets.h` with your WiFi credentials. +2. Edit `secrets.ini` with your WiFi credentials. These are PlatformIO `build_flags` merged in via `extra_configs` — gitignored, never committed. 3. Compile: ```bash @@ -47,15 +43,39 @@ Open `http://localhost:3000` in your browser. The throttle UI is fully functiona ./build.sh # select option 7 (Build + Upload All) ``` +### Testing + +**Firmware** — the hardware-independent logic (motor state decisions, LED timing math, WebSocket command parsing) lives in `lib/` and is unit-tested on PlatformIO's `native` platform, no device required: +```bash +pio test -e native +``` +Code that touches actual hardware I/O (`Motor`, `StatusLED`'s pin writes, WiFi/sockets) isn't unit-tested this way — verify those manually against real hardware. + +**Frontend** — `useTrainController.ts` (via a fake WebSocket) and a couple of component smoke tests, using Vitest: +```bash +cd frontend +npm test # single run +npm run test:watch # watch mode +``` + ### Project Structure ``` z-duino/ +├── platformio.ini # PlatformIO project config (board, ldscript, lib_deps, native test env) +├── secrets.ini.example # WiFi creds template — copy to secrets.ini (gitignored) +├── lib/ # Hardware-independent logic, shared by firmware + native tests +│ ├── MotorLogic/ # Motor forward/reverse/stop decision +│ ├── LedTiming/ # StatusLED breathing/blink timing math +│ └── Command/ # WebSocket JSON command parsing +├── test/ # PlatformIO native (Unity) tests for lib/ +│ ├── test_motor_logic/ +│ ├── test_led_timing/ +│ └── test_command/ ├── firmware/z-duino/ │ ├── z-duino.ino # Main sketch (WiFi, mDNS, HTTP, WebSocket) │ ├── Motor.h / Motor.cpp # TB6612FNG motor driver abstraction -│ ├── StatusLED.h / StatusLED.cpp # RGB status LED abstraction -│ └── arduino_secrets.h.example +│ └── StatusLED.h / StatusLED.cpp # RGB status LED abstraction ├── frontend/ │ ├── src/ │ │ ├── App.vue # Root component @@ -64,12 +84,17 @@ z-duino/ │ │ ├── components/ # Vue SFCs (SpeedController, LedTestPanel, etc.) │ │ └── composables/ │ │ └── useTrainController.ts # Core logic (WebSocket, ramping, state) +│ ├── test/ # Vitest suites (composable + component smoke tests) │ ├── index.html │ ├── mock-server.ts # Mock WebSocket server for local dev │ ├── vite.config.ts # Builds to ../build/data/ for LittleFS +│ ├── vitest.config.ts │ ├── tsconfig.json │ └── package.json ├── docs/ +│ ├── BUILD.md # Full software prerequisites & build script reference +│ ├── HARDWARE.md # Parts list & wiring diagram +│ ├── PROTOCOL.md # Full WebSocket protocol spec │ ├── LITTLEFS.md # LittleFS flash layout & mklittlefs parameters │ └── status-led-wiring.md # RGB LED wiring & resistor values └── build.sh # Build + deploy script @@ -84,33 +109,14 @@ z-duino/ ### WebSocket Protocol -The device runs a WebSocket server on port 81 with the `arduino` subprotocol. - -**Client → Device:** -| Command | Payload | -|---|---| -| Set speed | `{"cmd": "speed", "value": 0.0}` (0.0–1.0) | -| Set direction | `{"cmd": "direction", "value": true}` (true=fwd) | -| Stop | `{"cmd": "stop"}` | -| Keepalive | `{"cmd": "ping"}` | -| Direction invert | `{"cmd": "invert", "value": true}` | -| LED test mode | `{"cmd": "led", "r": 0, "g": 0, "b": 1000}` (0–1000) | -| Resume status LED | `{"cmd": "led_auto"}` | - -**Device → Client:** -| Message | Payload | -|---|---| -| Status update | `{"type": "status", "name": "...", "speed": 0.0, "direction": true, "connected": true}` | -| Pong | `{"type": "pong"}` | - -The mock server (`frontend/mock-server.ts`) implements the same protocol. +See **[docs/PROTOCOL.md](docs/PROTOCOL.md)** for the full spec. The mock server (`frontend/mock-server.ts`) implements the same protocol. ### Submitting Changes 1. Fork the repo 2. Create a feature branch (`git checkout -b my-feature`) 3. Make your changes -4. Test against the mock server (`npm run dev:all`) +4. Test against the mock server (`npm run dev:all`), and run `pio test -e native` / `npm test` if you touched anything covered above 5. Open a pull request Keep PRs focused — one feature or fix per PR. diff --git a/README.md b/README.md index ca0f1fe..0c490b2 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![CI](https://github.com/yamanote1138/z-duino/actions/workflows/ci.yml/badge.svg)](https://github.com/yamanote1138/z-duino/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](#license) ![ESP8266](https://img.shields.io/badge/ESP8266-Arduino-00979D?logo=arduino&logoColor=white) +![PlatformIO](https://img.shields.io/badge/PlatformIO-Core-FF7F00?logo=platformio&logoColor=white) ![Vue 3](https://img.shields.io/badge/Vue-3-4FC08D?logo=vuedotjs&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-6-646CFF?logo=vite&logoColor=white) @@ -32,138 +33,37 @@ Discoverable via mDNS at `http://ztrain.local`. Wemos D1 Mini (ESP8266) → TB6612FNG H-bridge → track, plus an RGB status LED. Full parts list, wiring diagram, and pinout live in **[docs/HARDWARE.md](docs/HARDWARE.md)**. -## Software Prerequisites +## Quickstart -### macOS Setup - -1. **Arduino CLI** (via Homebrew): - ```bash - brew install arduino-cli - ``` - -2. **ESP8266 board package** — add the board manager URL, then install: - ```bash - arduino-cli config init # creates ~/Library/Arduino15/arduino-cli.yaml - arduino-cli config add board_manager.additional_urls \ - http://arduino.esp8266.com/stable/package_esp8266com_index.json - arduino-cli core update-index - arduino-cli core install esp8266:esp8266 - ``` - This installs the board definitions, toolchain, `mklittlefs`, and `esptool` under: - ``` - ~/Library/Arduino15/packages/esp8266/ - ``` - -3. **Arduino libraries:** - ```bash - arduino-cli lib install ArduinoJson WebSockets - ``` - Libraries install to `~/Arduino/libraries/`. - -4. **Node.js** (v18+) — via [nvm](https://github.com/nvm-sh/nvm) or [nodejs.org](https://nodejs.org/): - ```bash - nvm install 20 - ``` - -### Verify Installation - -```bash -arduino-cli version # should print version info -arduino-cli core list # should show esp8266:esp8266 -arduino-cli lib list # should show ArduinoJson and WebSockets -node --version # v18+ required -``` - -`mklittlefs` and `esptool` don't need to be on your PATH — `build.sh` finds them automatically inside `~/Library/Arduino15/`. - -## Setup - -1. **Clone the repo:** +1. Install [PlatformIO Core](https://docs.platformio.org/en/latest/core/installation/index.html) (`brew install platformio`) and [Node.js](https://nodejs.org/) v18+. +2. Clone the repo and configure WiFi credentials: ```bash git clone https://github.com/yamanote1138/z-duino.git cd z-duino + cp secrets.ini.example secrets.ini + # edit secrets.ini with your WiFi SSID/password ``` - -2. **Configure WiFi credentials:** - ```bash - cp firmware/z-duino/arduino_secrets.h.example firmware/z-duino/arduino_secrets.h - ``` - Edit `arduino_secrets.h` with your WiFi SSID and password. Optionally change the mDNS hostname (default: `ztrain`) and `MAX_PWM` to tune top speed (default: `500` — about half voltage to the track, which is plenty for Z-scale). - -3. **Install frontend dependencies:** +3. Install frontend dependencies and build + flash everything: ```bash - cd frontend - npm install - cd .. + cd frontend && npm install && cd .. + ./build.sh # select option 7 (Build + Upload All) ``` -4. **Build and flash:** - ```bash - ./build.sh - ``` - Select option **7** (Build + Upload All). This will: - - Build the frontend with Vite - - Compile the firmware with arduino-cli - - Flash the firmware to the Wemos - - Pack and flash the frontend filesystem (LittleFS) +For the full prerequisites walkthrough and every `build.sh` option, see **[docs/BUILD.md](docs/BUILD.md)**. ## Usage -1. Power up the Wemos D1 Mini (USB or external 5V) -2. Connect your 12V supply to the TB6612FNG -3. Open a browser and navigate to **http://ztrain.local** - - If mDNS isn't working on your network, check the serial monitor (115200 baud) for the device's IP address -4. Use the throttle: - - **Speed segments 1–10** — click to ramp to that speed level. Click a lit segment to ramp back down. - - **FWD / REV** — toggle direction. If the train is moving, it will smoothly ramp down, switch, and ramp back up. - - **Brake** — smooth ramp to stop - - **E-Stop** — immediate stop - -> **Z-scale note:** These locomotives are tiny and light. Don't be surprised if nothing happens at low power — it's normal for a Z-scale train to sit still until 40–50% throttle, then take off suddenly once it overcomes static friction. This isn't a bug; it's just how small motors behave at low voltage. You may want to start around segment 4 and work up from there. - -## Development - -For frontend development without hardware: - -```bash -cd frontend -npm run dev:all -``` - -This starts the Vite dev server on `http://localhost:3000` and a mock WebSocket server on port 81 that simulates the ESP8266's responses. - -## Build Script Options - -| Option | Action | -|---|---| -| 1 | Build All (frontend + firmware) | -| 2 | Build Frontend only | -| 3 | Build Firmware only | -| 4 | Upload Firmware only | -| 5 | Upload LittleFS only | -| 6 | Upload All (firmware + filesystem) | -| 7 | Build + Upload All | - -## WebSocket Protocol - -The ESP8266 runs a WebSocket server on port 81 (subprotocol `arduino`). - -**Client → Device:** -```json -{"cmd": "speed", "value": 0.0} // 0.0 to 1.0 -{"cmd": "direction", "value": true} // true = forward -{"cmd": "stop"} // immediate stop -{"cmd": "ping"} // keepalive -{"cmd": "invert", "value": true} // motor direction invert -{"cmd": "led", "r": 0, "g": 0, "b": 1000} // LED test mode (0–1000 per channel) -{"cmd": "led_auto"} // resume status LED behaviour -``` - -**Device → Client:** -```json -{"type": "status", "name": "Z-Duino", "speed": 0.0, "direction": true, "connected": true} -{"type": "pong"} -``` +Power up the Wemos, connect your 12V track supply, and open **http://ztrain.local** in a browser (check the serial monitor at 115200 baud for the IP if mDNS doesn't resolve). Speed segments 1–10 ramp to that throttle level, FWD/REV toggles direction (ramping down/up automatically if moving), Brake ramps to a stop, and E-Stop halts immediately. + +> **Z-scale note:** These locomotives are tiny and light. Don't be surprised if nothing happens at low power — it's normal for a Z-scale train to sit still until 40–50% throttle, then take off suddenly once it overcomes static friction. Start around segment 4 and work up from there. + +## Documentation + +- **[docs/BUILD.md](docs/BUILD.md)** — full prerequisites, setup, and `build.sh` reference +- **[docs/HARDWARE.md](docs/HARDWARE.md)** — parts list & wiring diagram +- **[docs/PROTOCOL.md](docs/PROTOCOL.md)** — WebSocket protocol spec +- **[docs/LITTLEFS.md](docs/LITTLEFS.md)** — LittleFS flash layout & partition details +- **[CONTRIBUTING.md](CONTRIBUTING.md)** — local development setup & project structure ## License diff --git a/build.sh b/build.sh index 72527f9..1b11641 100755 --- a/build.sh +++ b/build.sh @@ -9,12 +9,9 @@ CYAN='\033[0;36m' NC='\033[0m' # Config -FQBN="esp8266:esp8266:d1_mini:xtal=80,vt=flash,exception=disabled,ssl=all,eesz=4M2M,ip=lm2f,dbg=Disabled,lvl=None____,wipe=none,baud=921600" -SKETCH_DIR="firmware/z-duino" FRONTEND_DIR="frontend" BUILD_DIR="build" DATA_DIR="$BUILD_DIR/data" -ARDUINO_DATA=$(arduino-cli config get directories.data 2>/dev/null) info() { echo -e "${CYAN}[INFO]${NC} $1"; } success() { echo -e "${GREEN}[OK]${NC} $1"; } @@ -22,16 +19,9 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; } check_deps() { - local missing=() - if ! command -v arduino-cli &>/dev/null; then - error "arduino-cli not found. See: https://arduino.github.io/arduino-cli/" - exit 1 - fi - arduino-cli lib list 2>/dev/null | grep -q "ArduinoJson" || missing+=("ArduinoJson") - arduino-cli lib list 2>/dev/null | grep -q "WebSockets" || missing+=("WebSockets") - if [ ${#missing[@]} -gt 0 ]; then - error "Missing Arduino libraries: ${missing[*]}" - info "Install with: arduino-cli lib install ${missing[*]}" + if ! command -v pio &>/dev/null; then + error "PlatformIO not found. Install with: brew install platformio" + info "See: https://docs.platformio.org/en/latest/core/installation/index.html" exit 1 fi } @@ -75,18 +65,33 @@ build_frontend() { success "Frontend built → $DATA_DIR/" } +frontend_needs_build() { + [ ! -f "$DATA_DIR/index.html" ] && return 0 + find "$FRONTEND_DIR/src" "$FRONTEND_DIR/public" "$FRONTEND_DIR/index.html" \ + "$FRONTEND_DIR/vite.config.ts" "$FRONTEND_DIR/package.json" "$FRONTEND_DIR/package-lock.json" \ + -type f -newer "$DATA_DIR/index.html" 2>/dev/null | grep -q . +} + +build_frontend_if_needed() { + if frontend_needs_build; then + build_frontend + else + info "Frontend unchanged, skipping build ($DATA_DIR/ is up to date)" + fi +} + build_firmware() { check_deps info "Compiling firmware..." - mkdir -p "$BUILD_DIR" - arduino-cli compile --fqbn "$FQBN" --output-dir "$BUILD_DIR" "$SKETCH_DIR" + pio run success "Firmware compiled" } upload_firmware() { + check_deps detect_port || return 1 info "Uploading firmware..." - if ! arduino-cli upload --fqbn "$FQBN" --port "$SELECTED_PORT" --input-dir "$BUILD_DIR" "$SKETCH_DIR"; then + if ! pio run -t upload --upload-port "$SELECTED_PORT"; then error "Firmware upload failed. Check the connection and try again." return 1 fi @@ -94,6 +99,7 @@ upload_firmware() { } upload_littlefs() { + check_deps detect_port || return 1 if [ ! -d "$DATA_DIR" ]; then @@ -101,34 +107,11 @@ upload_littlefs() { return 1 fi - info "Packing LittleFS image..." - - # Find mklittlefs via arduino-cli's data directory - MKLITTLEFS=$(find "$ARDUINO_DATA" -name "mklittlefs" -type f 2>/dev/null | head -1) - if [ -z "$MKLITTLEFS" ]; then - error "mklittlefs not found. Install the ESP8266 board package:" - info " arduino-cli core install esp8266:esp8266" - return 1 - fi - - # 4M2M layout: 2MB filesystem at offset 0x200000 - # Block size MUST be 8192 to match eagle.flash.4m2m.ld (_FS_block = 0x2000) - # Size = FS_PHYS_SIZE = _FS_end - _FS_start = 0x1FA000 = 2072576 = 253 blocks × 8192 - "$MKLITTLEFS" -c "$DATA_DIR" -b 8192 -p 256 -s 2072576 "$BUILD_DIR/littlefs.bin" - success "LittleFS image created" - - # Find esptool via arduino-cli's data directory - ESPTOOL=$(find "$ARDUINO_DATA" -name "esptool" -not -name "*.py" -type f 2>/dev/null | head -1) - if [ -z "$ESPTOOL" ]; then - ESPTOOL=$(which esptool 2>/dev/null || which esptool.py 2>/dev/null || true) - fi - if [ -z "$ESPTOOL" ]; then - error "esptool not found." + info "Packing and flashing LittleFS image..." + if ! pio run -t uploadfs --upload-port "$SELECTED_PORT"; then + error "LittleFS upload failed. Check the connection and try again." return 1 fi - - info "Flashing LittleFS to ESP8266..." - "$ESPTOOL" --chip esp8266 --port "$SELECTED_PORT" --baud 460800 write-flash 0x200000 "$BUILD_DIR/littlefs.bin" success "LittleFS uploaded" } @@ -150,13 +133,13 @@ read -rp "Select option [7]: " option option=${option:-7} case $option in - 1) build_frontend; build_firmware ;; + 1) build_frontend_if_needed; build_firmware ;; 2) build_frontend ;; 3) build_firmware ;; 4) upload_firmware ;; 5) upload_littlefs ;; 6) upload_firmware; upload_littlefs ;; - 7) build_frontend; build_firmware; upload_firmware; upload_littlefs ;; + 7) build_frontend_if_needed; build_firmware; upload_firmware; upload_littlefs ;; *) error "Invalid option" ;; esac diff --git a/docs/BUILD.md b/docs/BUILD.md new file mode 100644 index 0000000..ff9177a --- /dev/null +++ b/docs/BUILD.md @@ -0,0 +1,88 @@ +# Build & Development Setup + +Full software prerequisites and the `build.sh` reference. For the condensed quickstart, see the main [README](../README.md). + +## Software Prerequisites + +### macOS Setup + +1. **PlatformIO Core** (via Homebrew): + ```bash + brew install platformio + ``` + This installs `pio`, the CLI PlatformIO uses to compile, upload, and manage dependencies. + +2. **Node.js** (v18+) — via [nvm](https://github.com/nvm-sh/nvm) or [nodejs.org](https://nodejs.org/): + ```bash + nvm install 20 + ``` + +That's it — the ESP8266 platform, toolchain, `mklittlefs`, `esptool`, and the `ArduinoJson`/`WebSockets` libraries are all declared in `platformio.ini` and resolved automatically the first time you run `pio run` or `./build.sh`. No separate board-package or library-install step. + +### Verify Installation + +```bash +pio --version # should print version info +node --version # v18+ required +``` + +## Setup + +1. **Clone the repo:** + ```bash + git clone https://github.com/yamanote1138/z-duino.git + cd z-duino + ``` + +2. **Configure WiFi credentials:** + ```bash + cp secrets.ini.example secrets.ini + ``` + Edit `secrets.ini` with your WiFi SSID and password. Optionally change the mDNS hostname (default: `ztrain`) and `MAX_PWM` to tune top speed (default: `500` — about half voltage to the track, which is plenty for Z-scale). These are plain PlatformIO `build_flags` (`-D WIFI_SSID='"..."'` etc. — note the single-quote wrapping, required so values with spaces survive PlatformIO's flag tokenizer) merged in via `extra_configs` in `platformio.ini` — `secrets.ini` is gitignored, so nothing here ever gets committed. + +3. **Install frontend dependencies:** + ```bash + cd frontend + npm install + cd .. + ``` + +4. **Build and flash:** + ```bash + ./build.sh + ``` + Select option **7** (Build + Upload All). This will: + - Build the frontend with Vite + - Compile the firmware with PlatformIO + - Flash the firmware to the Wemos + - Pack and flash the frontend filesystem (LittleFS) + +## Build Script Options + +| Option | Action | +|---|---| +| 1 | Build All (frontend + firmware) | +| 2 | Build Frontend only | +| 3 | Build Firmware only | +| 4 | Upload Firmware only | +| 5 | Upload LittleFS only | +| 6 | Upload All (firmware + filesystem) | +| 7 | Build + Upload All | + +Under the hood, `build.sh` wraps: +- **Build Frontend** (option 2) → `npm run build`, always runs unconditionally +- **Build All / Build + Upload All** (options 1, 7) → skip the frontend build automatically if nothing under `frontend/` (source, `public/`, `package.json`/`package-lock.json`, `vite.config.ts`) is newer than the existing `build/data/`, since PlatformIO's own incremental firmware compile already makes re-running `pio run` on unchanged firmware near-instant — only the frontend build previously had no such skip +- **Build Firmware** → `pio run` +- **Upload Firmware** → `pio run -t upload` +- **Upload LittleFS** → `pio run -t uploadfs` (packs `build/data/` into a LittleFS image sized from `platformio.ini`'s `board_build.ldscript`, then flashes it — see [LITTLEFS.md](LITTLEFS.md) for the partition math) + +## Development + +For frontend development without hardware: + +```bash +cd frontend +npm run dev:all +``` + +This starts the Vite dev server on `http://localhost:3000` and a mock WebSocket server on port 81 that simulates the ESP8266's responses. diff --git a/docs/LITTLEFS.md b/docs/LITTLEFS.md index 0d683f3..4e8279b 100644 --- a/docs/LITTLEFS.md +++ b/docs/LITTLEFS.md @@ -4,7 +4,7 @@ How the ESP8266 filesystem works on the Wemos D1 Mini with the 4M2M flash layout ## Flash Partition Layout -The FQBN option `eesz=4M2M` selects `eagle.flash.4m2m.ld`, which defines: +`platformio.ini` sets `board_build.ldscript = eagle.flash.4m2m.ld` (the same linker script the Arduino IDE's `eesz=4M2M` board-menu option selects), which defines: | Symbol | Value | Meaning | |---|---|---| @@ -26,7 +26,11 @@ The runtime block count: block_count = 2,072,576 / 8,192 = 253 (exact — no remainder) ``` -## mklittlefs Parameters +## PlatformIO Handles This Automatically + +As of the PlatformIO migration, `pio run -t buildfs` / `-t uploadfs` parse `_FS_START`, `_FS_END`, `_FS_BLOCK`, and `_FS_PAGE` directly out of whichever `board_build.ldscript` is configured in `platformio.ini` (currently `eagle.flash.4m2m.ld`), and pass the derived values to `mklittlefs` internally. There's no separate size constant to keep in sync — the ldscript is the single source of truth. The manual invocation below is kept for reference (e.g. if you ever need to pack an image outside of PlatformIO), but day-to-day you shouldn't need to run it by hand. + +## mklittlefs Parameters (Reference) The `mklittlefs` tool packs a directory into a LittleFS image. The parameters **must** match the runtime configuration exactly: @@ -98,14 +102,14 @@ The `mklittlefs` binary bundled with the ESP8266 Arduino core (v0.2.3) bundles i ## Other Flash Layouts -If you change the flash size option (e.g. `4M1M`, `4M3M`), the linker script and therefore all the values above will change. Always derive the mklittlefs parameters from the linker script for your selected layout: +If you change `board_build.ldscript` in `platformio.ini` (e.g. to `eagle.flash.4m1m.ld` or `eagle.flash.4m3m.ld`), the values above will change accordingly. Always derive the mklittlefs parameters from the linker script for your selected layout: ```bash -# Find your linker script -arduino-cli compile --show-properties ... | grep flash_ld +# Find the linker script PlatformIO is actually using +find ~/.platformio/packages/framework-arduinoespressif8266/tools/sdk/ld -iname "**" # Then check the values grep '_FS_' ``` -Or just print `FS_PHYS_SIZE` and `FS_PHYS_BLOCK` from the firmware at runtime — the definitive source of truth. +Or just print `FS_PHYS_SIZE` and `FS_PHYS_BLOCK` from the firmware at runtime — the definitive source of truth. (PlatformIO itself does exactly this automatically for `pio run -t buildfs`/`uploadfs` — see above.) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..56c406f --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,22 @@ +# WebSocket Protocol + +The ESP8266 runs a WebSocket server on port 81 (subprotocol `arduino`). + +**Client → Device:** +```json +{"cmd": "speed", "value": 0.0} // 0.0 to 1.0 +{"cmd": "direction", "value": true} // true = forward +{"cmd": "stop"} // immediate stop +{"cmd": "ping"} // keepalive +{"cmd": "invert", "value": true} // motor direction invert +{"cmd": "led", "r": 0, "g": 0, "b": 1000} // LED test mode (0–1000 per channel) +{"cmd": "led_auto"} // resume status LED behaviour +``` + +**Device → Client:** +```json +{"type": "status", "name": "Z-Duino", "speed": 0.0, "direction": true, "connected": true} +{"type": "pong"} +``` + +The mock server (`frontend/mock-server.ts`) implements the same protocol for local development without hardware. diff --git a/firmware/z-duino/StatusLED.cpp b/firmware/z-duino/StatusLED.cpp index e2074f9..cf89fe4 100644 --- a/firmware/z-duino/StatusLED.cpp +++ b/firmware/z-duino/StatusLED.cpp @@ -1,8 +1,10 @@ #include "StatusLED.h" +#include StatusLED::StatusLED(int pinR, int pinG, int pinB) : _pinR(pinR), _pinG(pinG), _pinB(pinB), _state(WIFI_CONNECTING), - _previousState(IDLE), _blinkCount(0), _blinkStartTime(0) + _previousState(IDLE), _blinkCount(0), _blinkStartTime(0), + _testR(0), _testG(0), _testB(0) { pinMode(_pinR, OUTPUT); pinMode(_pinG, OUTPUT); @@ -36,14 +38,22 @@ void StatusLED::setState(LEDState state) { case ACTIVE_REVERSE: setColor(1000, 0, 0); // Red break; + case LED_TEST: + setColor(_testR, _testG, _testB); + break; case EMERGENCY_STOP_BLINK: // Handled in update() break; + default: + break; } } void StatusLED::setTestColor(int r, int g, int b) { _state = LED_TEST; + _testR = r; + _testG = g; + _testB = b; setColor(r, g, b); } @@ -57,9 +67,7 @@ void StatusLED::blinkEmergencyStop() { void StatusLED::update() { if (_state == WIFI_CONNECTING) { // Sine-based breathing on red channel, ~2s cycle - float phase = (millis() % 2000) / 2000.0f * 2.0f * PI; - int brightness = (int)((sin(phase - PI / 2.0f) + 1.0f) / 2.0f * 1000.0f); - setColor(brightness, 0, 0); + setColor(breathingBrightness(millis()), 0, 0); } else if (_state == EMERGENCY_STOP_BLINK) { // Blink magenta 4 times: 150ms on, 150ms off per blink @@ -69,11 +77,8 @@ void StatusLED::update() { const unsigned long BLINK_CYCLE = BLINK_ON + BLINK_OFF; const int TOTAL_BLINKS = 4; - unsigned long cycleTime = elapsed % BLINK_CYCLE; - int currentBlink = elapsed / BLINK_CYCLE; - - if (currentBlink < TOTAL_BLINKS) { - if (cycleTime < BLINK_ON) { + if (!isBlinkSequenceDone(elapsed, BLINK_CYCLE, TOTAL_BLINKS)) { + if (isBlinkFrameOn(elapsed, BLINK_ON, BLINK_OFF)) { setColor(1000, 0, 1000); // Magenta ON } else { setColor(0, 0, 0); // OFF diff --git a/firmware/z-duino/StatusLED.h b/firmware/z-duino/StatusLED.h index dfdf261..4a43a0a 100644 --- a/firmware/z-duino/StatusLED.h +++ b/firmware/z-duino/StatusLED.h @@ -29,6 +29,9 @@ class StatusLED { LEDState _previousState; int _blinkCount; unsigned long _blinkStartTime; + int _testR; + int _testG; + int _testB; }; #endif diff --git a/firmware/z-duino/arduino_secrets.h.example b/firmware/z-duino/arduino_secrets.h.example deleted file mode 100644 index 74d6356..0000000 --- a/firmware/z-duino/arduino_secrets.h.example +++ /dev/null @@ -1,16 +0,0 @@ -// WiFi credentials (required) -// The # is part of the syntax — do not remove it! -#define WIFI_SSID "YourSSIDHere" -#define WIFI_PASS "YourPasswordHere" - -// mDNS hostname — your device will be reachable at http://.local -#define MDNS_HOSTNAME "ztrain" - -// Display name shown in the throttle UI header and browser tab -#define RAILROAD_NAME "Z-Duino" - -// Maximum PWM output (0–1000). Controls the top speed of the train. -// 1000 = full 12V, 500 = ~6V, etc. Start low and increase to taste. -#define MAX_PWM 500 - -// WIFI_SSID and WIFI_PASS are required. The rest have sensible defaults. diff --git a/firmware/z-duino/z-duino.ino b/firmware/z-duino/z-duino.ino index ec7c12b..59c5b1a 100644 --- a/firmware/z-duino/z-duino.ino +++ b/firmware/z-duino/z-duino.ino @@ -6,13 +6,14 @@ #include #include -#include "arduino_secrets.h" #include "Motor.h" #include "StatusLED.h" +#include +#include -#define VERSION "2.0.0" +#define VERSION "2.3.0" -// WiFi credentials from arduino_secrets.h +// WiFi credentials from secrets.ini char wifi_ssid[] = WIFI_SSID; char wifi_pass[] = WIFI_PASS; char mdns_hostname[] = MDNS_HOSTNAME; @@ -56,18 +57,21 @@ void applyLedStatus() { } void applyMotorState() { - int pwmValue = (int)(currentSpeed * MAX_PWM); - bool motorDirection = invertDirection ? !currentDirection : currentDirection; - if (pwmValue == 0) { - motor.stop(); - } else if (motorDirection) { - motor.forward(pwmValue); - } else { - motor.reverse(pwmValue); + MotorDecision decision = decideMotorState(currentSpeed, MAX_PWM, currentDirection, invertDirection); + switch (decision.action) { + case MOTOR_STOP: + motor.stop(); + break; + case MOTOR_FORWARD: + motor.forward(decision.pwm); + break; + case MOTOR_REVERSE: + motor.reverse(decision.pwm); + break; } } -void broadcastStatus() { +String buildStatusJson() { JsonDocument doc; doc["type"] = "status"; doc["name"] = railroad_name; @@ -77,60 +81,69 @@ void broadcastStatus() { String json; serializeJson(doc, json); + return json; +} + +void broadcastStatus() { + String json = buildStatusJson(); socket.broadcastTXT(json); } void handleWebSocketMessage(uint8_t num, uint8_t *payload, size_t length) { lastMessageTime = millis(); - JsonDocument doc; - DeserializationError error = deserializeJson(doc, payload, length); - if (error) { - Serial.print("JSON parse error: "); - Serial.println(error.c_str()); - return; - } + Command cmd = parseCommand((const char*)payload, length); - const char* cmd = doc["cmd"]; - if (!cmd) return; + switch (cmd.type) { + case CMD_SPEED: + currentSpeed = cmd.speed; + applyMotorState(); + broadcastStatus(); + break; - if (strcmp(cmd, "speed") == 0) { - float value = doc["value"] | 0.0f; - currentSpeed = constrain(value, 0.0f, 1.0f); - applyMotorState(); - broadcastStatus(); - } - else if (strcmp(cmd, "direction") == 0) { - currentDirection = doc["value"] | true; - applyMotorState(); - broadcastStatus(); - applyLedStatus(); - } - else if (strcmp(cmd, "stop") == 0) { - currentSpeed = 0.0; - applyMotorState(); - broadcastStatus(); - led.blinkEmergencyStop(); - } - else if (strcmp(cmd, "invert") == 0) { - invertDirection = doc["value"] | false; - applyMotorState(); - Serial.printf("Direction invert: %s\n", invertDirection ? "on" : "off"); - } - else if (strcmp(cmd, "led") == 0) { - int r = doc["r"] | 0; - int g = doc["g"] | 0; - int b = doc["b"] | 0; - led.setTestColor(r, g, b); - Serial.printf("LED test: r=%d g=%d b=%d\n", r, g, b); - } - else if (strcmp(cmd, "led_auto") == 0) { - applyLedStatus(); - Serial.println("LED: auto status resumed"); - } - else if (strcmp(cmd, "ping") == 0) { - String pong = "{\"type\":\"pong\"}"; - socket.sendTXT(num, pong); + case CMD_DIRECTION: + currentDirection = cmd.boolValue; + applyMotorState(); + broadcastStatus(); + applyLedStatus(); + break; + + case CMD_STOP: + currentSpeed = 0.0; + applyMotorState(); + broadcastStatus(); + led.blinkEmergencyStop(); + break; + + case CMD_INVERT: + invertDirection = cmd.boolValue; + applyMotorState(); + Serial.printf("Direction invert: %s\n", invertDirection ? "on" : "off"); + break; + + case CMD_LED: + led.setTestColor(cmd.ledR, cmd.ledG, cmd.ledB); + Serial.printf("LED test: r=%d g=%d b=%d\n", cmd.ledR, cmd.ledG, cmd.ledB); + break; + + case CMD_LED_AUTO: + applyLedStatus(); + Serial.println("LED: auto status resumed"); + break; + + case CMD_PING: { + String pong = "{\"type\":\"pong\"}"; + socket.sendTXT(num, pong); + break; + } + + case CMD_INVALID_JSON: + Serial.println("JSON parse error"); + break; + + case CMD_UNKNOWN: + default: + break; } } @@ -151,15 +164,7 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) connectedClients++; applyLedStatus(); { - JsonDocument doc; - doc["type"] = "status"; - doc["name"] = railroad_name; - doc["speed"] = currentSpeed; - doc["direction"] = currentDirection; - doc["connected"] = true; - - String json; - serializeJson(doc, json); + String json = buildStatusJson(); socket.sendTXT(num, json); } break; @@ -167,6 +172,9 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) case WStype_TEXT: handleWebSocketMessage(num, payload, length); break; + + default: + break; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1bb4d5a..28ea4e0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "z-duino-frontend", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "z-duino-frontend", - "version": "2.2.0", + "version": "2.3.0", "dependencies": { "@nuxt/ui": "^4.5.1", "tailwindcss": "^4.2.1", @@ -16,10 +16,13 @@ "devDependencies": { "@iconify-json/mdi": "^1.2.3", "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.11", "concurrently": "^10.0.3", + "happy-dom": "^20.10.6", "tsx": "^4.21.0", "typescript": "^5.9.3", "vite": "^6.0.7", + "vitest": "^4.1.10", "ws": "^8.19.0" } }, @@ -653,6 +656,67 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -962,6 +1026,24 @@ "semver": "^7.8.1" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -2328,18 +2410,63 @@ "yjs": "^13.5.38" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@unhead/vue": { "version": "2.1.15", "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.15.tgz", @@ -2370,6 +2497,119 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.40", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", @@ -2486,6 +2726,27 @@ "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, "node_modules/@vueuse/core": { "version": "14.3.0", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", @@ -2590,6 +2851,16 @@ "vue": "^3.5.0" } }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2665,6 +2936,46 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/c12": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", @@ -2693,6 +3004,16 @@ } } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2745,12 +3066,42 @@ "node": ">=20" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/colortranslator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/colortranslator/-/colortranslator-5.0.0.tgz", "integrity": "sha512-Z3UPUKasUVDFCDYAjP2fmlVRf1jFHJv1izAmPjiOa0OCIw1W7iC8PZ2GsoDa8uZv+mKyWopxxStT9q05+27h7w==", "license": "Apache-2.0" }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/concurrently": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", @@ -2782,6 +3133,17 @@ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -2791,6 +3153,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie-es": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", @@ -2872,6 +3241,32 @@ "url": "https://dotenvx.com" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", @@ -2998,6 +3393,13 @@ "integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==", "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3093,6 +3495,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", @@ -3193,6 +3605,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/framer-motion": { "version": "12.42.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", @@ -3291,6 +3720,28 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3314,6 +3765,25 @@ "uncrypto": "^0.1.3" } }, + "node_modules/happy-dom": { + "version": "20.10.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.6.tgz", + "integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/hey-listen": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", @@ -3354,6 +3824,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -3363,6 +3840,16 @@ "url": "https://github.com/sponsors/brc-dd" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -3392,6 +3879,22 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3401,6 +3904,35 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -3812,6 +4344,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -3911,6 +4469,22 @@ "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", "license": "MIT" }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3998,6 +4572,13 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/package-manager-detector": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz", @@ -4013,6 +4594,30 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4221,6 +4826,13 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -4412,6 +5024,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -4447,6 +5066,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -4471,6 +5097,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -4487,6 +5159,30 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -4578,6 +5274,13 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -4603,6 +5306,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -5163,6 +5876,13 @@ "node": ">=18.12.0" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/unhead": { "version": "2.1.15", "resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.15.tgz", @@ -6126,6 +6846,96 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vue": { "version": "3.5.40", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", @@ -6180,6 +6990,16 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/wheel-gestures": { "version": "2.2.48", "resolved": "https://registry.npmjs.org/wheel-gestures/-/wheel-gestures-2.2.48.tgz", @@ -6204,6 +7024,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -6222,6 +7059,86 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4af0a01..2000aa0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,13 +1,15 @@ { "name": "z-duino-frontend", - "version": "2.2.0", + "version": "2.3.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", "mock": "tsx mock-server.ts", - "dev:all": "concurrently \"npm run mock\" \"npm run dev\"" + "dev:all": "concurrently \"npm run mock\" \"npm run dev\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@nuxt/ui": "^4.5.1", @@ -18,10 +20,13 @@ "devDependencies": { "@iconify-json/mdi": "^1.2.3", "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.11", "concurrently": "^10.0.3", + "happy-dom": "^20.10.6", "tsx": "^4.21.0", "typescript": "^5.9.3", "vite": "^6.0.7", + "vitest": "^4.1.10", "ws": "^8.19.0" } } diff --git a/frontend/src/components/SpeedController.vue b/frontend/src/components/SpeedController.vue index ad801d1..c66d92c 100644 --- a/frontend/src/components/SpeedController.vue +++ b/frontend/src/components/SpeedController.vue @@ -55,8 +55,7 @@