diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml new file mode 100644 index 0000000..92e700f --- /dev/null +++ b/.github/workflows/firmware.yml @@ -0,0 +1,45 @@ +name: firmware + +on: + push: + branches: [main, v6-idf-rewrite] + tags: ['v6.*'] + paths: + - 'firmware/**' + - '.github/workflows/firmware.yml' + pull_request: + paths: + - 'firmware/**' + - '.github/workflows/firmware.yml' + +jobs: + build: + name: idf-build (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # esp32c3 is the validated target. Others build to keep them green + # at the compile level until hardware arrives. + target: [esp32c3, esp32, esp32s3, esp32c6] + steps: + - uses: actions/checkout@v4 + + - name: ESP-IDF build + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v5.3 + target: ${{ matrix.target }} + path: firmware + + - name: Upload firmware artifact + if: github.ref_type == 'tag' || github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 + with: + name: ambisense-${{ matrix.target }}-${{ github.sha }} + path: | + firmware/build/ambisense.bin + firmware/build/bootloader/bootloader.bin + firmware/build/partition_table/partition-table.bin + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index 259148f..e844657 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,15 @@ *.exe *.out *.app + +# IDE / agent state +.claude/ +.vscode/ +.cache/ +.DS_Store + +# ESP-IDF build output (firmware also has its own .gitignore) +build/ +sdkconfig.old +managed_components/ +dependencies.lock diff --git a/README.md b/README.md index cf5fda9..1f7e15d 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,115 @@ AmbiSense Logo

+> ### πŸš€ v6.0.0 shipped β€” full ESP-IDF + FreeRTOS rewrite +> +> v6 is a ground-up rewrite from Arduino onto **ESP-IDF + FreeRTOS**: independent +> FreeRTOS tasks for radar read, motion smoothing, LED rendering, web serving, +> and ESP-NOW peer-mesh β€” no more cooperative `loop()` starving the LED +> render under HTTP load. v6 ships with **LD2450 multi-target tracking**, a +> **modular radar driver layer** (LD2410 / LD2412 / LD2420 / LD2450 / sim β€” +> swap via web UI without reflashing), a **board picker with editable pin map** +> (ESP32-C3 / ESP32 / S3 / C6), a **peer mesh** for U/L/asymmetric stairs +> (every device runs identical code; lowest-MAC wins coordinator), +> **asymmetric pairing** (click Pair on either device β†’ both join), a +> **Kalman-based motion filter** (3 user knobs instead of v5's 5 cryptic +> gains), **OTA with rollback**, **captive portal**, **PBKDF2-SHA256 auth**, +> and a **fully responsive Preact web UI** (sidebar on desktop, bottom-tab +> nav on mobile). +> +> **Recommended hardware: ESP32-S3 (DevKitC-1 or S3-Zero).** ESP32-C3 SuperMini +> is supported for single-strip installs. See +> [docs/HARDWARE.md](docs/HARDWARE.md) for the full board recommendation +> table and known issues. +> +> | Branch / tag | Purpose | +> |---|---| +> | `main` | v6.0.0 β€” current canonical firmware (this branch) | +> | `v6-idf-rewrite` | active dev branch for v6.x work (encrypted ESP-NOW, S3 dual-core, auto-topology) | +> | `legacy/v5-arduino` | frozen archive of the v5.x Arduino line β€” reference only | +> | tag `v6.0.0` | shipped release with prebuilt C3 binaries on the release page | +> | tag `v5.1.1` | last Arduino-era release | + +If you cloned this repo before v6.0.0 landed and your local `main` still +reflects the old Arduino tree, run `git fetch && git reset --hard origin/main` +once after the merge to come up to speed. The Arduino code is preserved on +`legacy/v5-arduino` and under `legacy/AmbiSense/` if you want to keep +running v5. + +## v6 quickstart (ESP-IDF v5.5.2) + +**Easiest path** β€” flash the pre-built C3 binary from the +[v6.0.0 release page](https://github.com/Techposts/AmbiSense/releases/tag/v6.0.0): + +```sh +pip install esptool +python -m esptool --chip esp32c3 -p /dev/ttyUSB0 -b 460800 \ + --before default_reset --after hard_reset write_flash \ + --flash_mode dio --flash_size 4MB --flash_freq 80m \ + 0x0 bootloader-c3-v6.0.0.bin \ + 0x8000 partition-table-c3-v6.0.0.bin \ + 0x10000 ambisense-c3-v6.0.0.bin +``` + +**Build from source** β€” you need ESP-IDF v5.5.2 installed (`~/esp/esp-idf-v5.5.2/`): + +```sh +git clone https://github.com/Techposts/AmbiSense.git +cd AmbiSense/firmware +. ~/esp/esp-idf-v5.5.2/export.sh + +idf.py set-target esp32c3 # or: esp32, esp32s3, esp32c6 +idf.py build flash monitor +``` + +After flash the device starts a Wi-Fi AP `AmbiSense-XXXX`. Connect from +your phone β€” the captive portal pops at `http://192.168.4.1/`. Enter +home Wi-Fi creds; the device joins and is reachable as +`http://ambisense-XXXX.local/`. + +**Recommended hardware**: ESP32-S3 DevKitC-1 or S3-Zero (dual-core, +native USB). **Supported**: ESP32-C3 SuperMini for single-strip installs. +See [docs/HARDWARE.md](docs/HARDWARE.md) for the full table and known +issues. + +VSCode users: install Espressif's ESP-IDF extension to get IntelliSense +for IDF headers β€” without it, clangd will report +`'esp_err.h' file not found` and similar; the code still builds +correctly via `idf.py`. + +## v6 documentation + +| Document | Read when | +|---|---| +| [docs/V6-ARCHITECTURE.md](docs/V6-ARCHITECTURE.md) | Picking up the v6 rewrite cold β€” this captures the locked architectural decisions (peer mesh, modular radar drivers, NVS schema, board profiles). Read first before proposing any structural change. | +| [docs/V6-ROADMAP.md](docs/V6-ROADMAP.md) | Planning what to build next β€” the 5-PR plan with status per PR and the tag/release cadence. | +| [docs/HARDWARE.md](docs/HARDWARE.md) | Building or debugging hardware β€” reference wiring for C3 SuperMini, board profiles, sensor pinouts, and a flash-fails-to-connect troubleshooting ladder. | + +## Repo layout + +``` +firmware/ β€” ESP-IDF v6 source (this is where new work goes) +frontend/design-source/ β€” Claude-Design handoff bundle for the v6 UI (read frontend/design-source/README.md) +legacy/AmbiSense/ β€” v5.x Arduino source, preserved for reference +Assets/, STL Files/ β€” design assets, enclosures (unchanged) +``` + +--- + +## v5 (Arduino) docs β€” reference only + +The text below is the original v5.1.1 Arduino README, kept here for +people running v5 hardware. v5 is **frozen** β€” no further bug fixes or +features will be backported. Running v5? Track the +[`legacy/v5-arduino`](https://github.com/Techposts/AmbiSense/tree/legacy/v5-arduino) +branch instead of `main`, and the source is at `legacy/AmbiSense/` +in this tree. + +For new installs we strongly recommend going to v6.0.0 β€” see the +quickstart above. + +--- + AmbiSense is an innovative smart lighting solution that uses radar sensing technology to create responsive ambient lighting experiences. The system detects movement and distance using an LD2410 radar sensor and dynamically controls NeoPixel LED strips in real-time, creating an interactive lighting environment. The core of AmbiSense is built around an ESP32 microcontroller that interfaces with an LD2410 radar module and NeoPixel LED strips. The system creates a moving light pattern that responds to a person's proximity, with the illuminated section of the LED strip changing based on detected distance. diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md new file mode 100644 index 0000000..cfc24e7 --- /dev/null +++ b/docs/HARDWARE.md @@ -0,0 +1,215 @@ +# AmbiSense v6 β€” Hardware Setup & Troubleshooting + +For locked architectural decisions about which boards/sensors are +supported, see [`V6-ARCHITECTURE.md`](V6-ARCHITECTURE.md). This file +covers the practical "I have hardware in front of me" stuff. + +## Board recommendation (read this first) + +| Tier | Board | Why | +| ---- | ----- | --- | +| **Recommended** | **ESP32-S3 DevKitC-1** *or* **ESP32-S3-Zero** | Dual-core LX7 @ 240 MHz, native USB-OTG (no CH340 driver fights), 512 KB SRAM. Wi-Fi/HTTP can be pinned to core 0 and radar+LED+motion to core 1 β€” slider floods can't starve the render loop. Headroom for v6.1 features (auto-topology, encrypted ESP-NOW, larger strips). Best choice for a stairwell with mesh peers. | +| **Supported** | **ESP32-C3 SuperMini** | Single-core RISC-V @ 160 MHz. The validated v6.0 reference build. Fine for **single-strip, single-device** installs. The 300 ms client-side debounced-save shipped with v6.0 prevents HTTP saturation under slider drag. Stops being the right choice the moment you want >2 mesh peers or >300 LEDs. | +| **Deprecated** | ESP32 classic (WROOM-32) | Older silicon, no native USB, no advantage over S3. Profile still builds; not recommended for new installs. | +| **Avoid** | ESP32-C6 | Single-core, *less* SRAM than C3, and Wi-Fi 6 / Thread don't help AmbiSense. | + +If you're starting from scratch and asked us "which board do I buy" β†’ **S3-Zero**. If you already have a C3 SuperMini on the bench β†’ it works. + +## Reference wiring (ESP32-C3 SuperMini) + +This is the validated reference build for v6.0. + +| Function | C3 GPIO | Note | +| -------------- | ------- | ----------------------------------- | +| LED data (DIN) | GPIO 10 | WS2812 / NeoPixel | +| Radar RX (MCU) | GPIO 20 | from radar's TX | +| Radar TX (MCU) | GPIO 21 | to radar's RX | +| Reset / mode | GPIO 4 | momentary button to GND | +| Status LED | GPIO 8 | onboard (active-low, on most clones)| +| 5 V power | 5V pin | for radar VCC | +| Ground | GND | radar, LED logic ground | + +**Pin defaults match the firmware out of the box** β€” flash the C3 +SuperMini build, wire to spec, no NVS pin override needed. If you +remap pins from the web UI later, the unsafe-pin guard refuses +strapping/USB-JTAG/flash pins (GPIOs 9, 11–19 on C3). + +### Power supply + +- **Logic**: USB power is fine for radar + MCU + onboard status LED. +- **LED strip**: drive from a separate 5 V PSU sized to the strip + (60 mA per WS2812 LED at full white). **Inject power on both ends** + for runs above ~50 LEDs to prevent voltage droop and rainbow + desaturation. The C3's 3.3 V LDO cannot power more than ~30 LEDs at + full brightness β€” don't try. +- **Common ground**: tie the LED PSU's GND to the C3's GND so the + data signal references correctly. + +## Sensor reference wiring + +Both LD2410 and LD2450 use 256000 baud UART, 5 V VCC, identical pinout: + +``` +Sensor MCU (C3 SuperMini) +------ ------------------ +TX β†’ GPIO 20 (radar_rx) +RX ← GPIO 21 (radar_tx) +VCC ← 5V +GND ↔ GND +``` + +LD2450 adds OUT pin (digital presence indicator) β€” leave unconnected +for v6, the firmware reads everything via UART. + +## Supported boards (v6.0) + +| Profile | Status | LED pin | Radar RX | Radar TX | Button | Status LED | +| --------------------- | ------------- | ------- | -------- | -------- | ------ | ------------- | +| `esp32s3-zero` | recommended | 21 | 4 | 5 | 9 | 21 | +| `esp32c3-supermini` | βœ… validated | 10 | 20 | 21 | 4 | 8 (active-low)| +| `esp32-devkit` | deprecated | 5 | 16 | 17 | 4 | 2 | +| `esp32c6-devkit` | builds only | 8 | 4 | 5 | 9 | 15 | + +The `esp32s3-zero` profile is structurally validated (the firmware +builds and the pinmap is correct for the AliExpress S3-Zero / S3-Mini +clones) but Ravi's bench currently only has C3s β€” full hardware +validation pending arrival of S3 units. + +Profiles defined in +[`firmware/components/board/board.c`](../firmware/components/board/board.c). +Adding a new board = +1 entry there + a build target in +`.github/workflows/firmware.yml`. + +## Supported sensors (v6.0) + +| Driver id | Sensor | Targets | x/y? | +| ---------- | --------------- | ------- | ---- | +| `ld2410` | HiLink LD2410(B/C) | 1 | no | +| `ld2412` | HiLink LD2412 | 1 | no | +| `ld2420` | HiLink LD2420 | 1 (presence only) | no | +| `ld2450` | HiLink LD2450 | up to 3 | yes (LD2450 only) | +| `sim` | Synthetic | scripted | optional | + +Switch sensors at runtime via the web UI without reflashing β€” the +driver registry compiles every driver in and selects one from NVS at +boot. + +## Troubleshooting + +### Flash fails with "Failed to connect to ESP32-C3: No serial data received" + +**Symptom**: `idf.py flash` fails repeatedly. The chip enumerates as +`/dev/cu.usbmodem*` (so USB-CDC is up) but esptool's SYNC packets +go unanswered. Sometimes the chip feels warm. + +**Diagnosis ladder** β€” try in order: + +1. **Check the port isn't being held by another process.** + ```sh + lsof /dev/cu.usbmodem* + ``` + Common culprits on macOS: `LG Calibration` (LG monitor calibration + daemon), Arduino IDE serial monitor, VSCode serial monitor. Kill + any holders before retrying. + +2. **Force the C3 into ROM download mode.** On most C3 SuperMini + clones with two surface-mount buttons (RST + BOOT): + - Hold `BOOT`. + - While holding `BOOT`, tap `RST`. + - Wait 1 s. Release `BOOT`. + - Retry `idf.py flash`. + +3. **Hold BOOT throughout the flash.** Some clones need `BOOT` held + continuously, not just sampled at reset. Run `idf.py flash` with + `BOOT` still pressed; release only after flash completes. + +4. **For single-button boards**: `BOOT` is often a tiny solder pad on + the back, or `BOOT = GPIO 9`. Bridge GPIO 9 to GND with tweezers + while pressing `RST`. + +5. **`--before usb_reset`** (the C3's USB-Serial-JTAG-specific reset). + ```sh + python -m esptool --chip esp32c3 -p /dev/cu.usbmodem... \ + -b 460800 --before usb_reset --after hard_reset \ + write_flash @flash_args + ``` + +6. **macOS USB-CDC stuck state β€” restart the Mac.** This is the known + final-resort fix when the chip enumerates but esptool can't sync + despite all of the above. macOS sometimes caches a stale USB-CDC + endpoint state for the C3's USB-Serial-JTAG and won't release it + until reboot. Confirmed by Ravi as the working recovery on + 2026-05-05. + +### Onboard LED stays solid after boot + +Expected during PR #1 β€” `app_main` finishes setup and switches to +`STATUS_LED_AP_MODE` (slow 1 Hz blink). If it stays solid, you're on +PR #1's `BOOT` pattern still, which means `app_main` crashed before +reaching the `set_pattern(AP_MODE)` call. Check the serial log over +USB-Serial-JTAG (the C3's onboard USB IS the serial console). + +### Where do I see logs? + +The C3 SuperMini routes ESP-IDF console over its built-in USB-Serial- +JTAG peripheral. Connect via: + +```sh +. $IDF_PATH/export.sh +idf.py -p /dev/cu.usbmodem... monitor +``` + +or any serial terminal at 115200 baud. Logs are also mirrored to a +16 KB in-RAM ring buffer accessible at `GET /api/logs` once PR #2's +web server is up. + +### Device boots but second one isn't visible (no AP, no STA) + +**Symptom**: After flashing, the device serial output shows boot logs +but no AP `AmbiSense-XXXX` shows up on a phone scan and the device +never joins Wi-Fi. First device works fine. + +**Most common cause**: stale NVS holding a board profile for a +different MCU (e.g., `esp32-devkit` saved on a C3 from a prior test +flash). v6.0 ships an MCU-mismatch boot guard that catches this on +new boots, but if you're upgrading from an alpha build the safest +recovery is: + +```sh +idf.py -p /dev/cu.usbmodemXXXXXXX erase-flash +idf.py -p /dev/cu.usbmodemXXXXXXX flash +``` + +The erase-flash wipes the NVS partition so the device boots with +factory defaults. Confirm with `idf.py monitor` β€” you should see +`Board profile (default): esp32c3-supermini (ESP32-C3 SuperMini)` +followed by netmgr starting AP. + +### Slider in web UI throws ERR_CONNECTION_RESET + +**Symptom**: Dragging a slider in the LED or Motion screen flood-fires +`POST /api/settings` and the browser console fills with +`ERR_CONNECTION_RESET` / `ERR_EMPTY_RESPONSE`. + +**Cause**: The C3 is single-core. The HTTP server, ESP-NOW broadcast, +radar UART, motion smoother, and LED render task all run on one +RISC-V core. Sliders fire `onChange` ~30 times/second; each one was +posting + reloading at full rate, saturating the httpd's +`max_open_sockets = 7`. + +**Fix**: v6.0 adds a 300 ms debounced-save on the client side +(`useDebouncedSave` in `frontend/src/screens.tsx`). The slider only +POSTs once you stop dragging, and multiple slider changes within +300 ms get coalesced into a single batched request body. If you +build the UI yourself, make sure you're on commit `0b63b6e` or later. + +### Brownout reset loop + +The C3's brownout detector is configured at threshold level 7 (~2.7 V). +Brownouts on a powered-only-by-USB C3 driving a long LED strip from +the same rail are common. v6 expects the **LED strip on its own PSU**; +running them off the C3's 5 V/3.3 V rails will trigger the brownout +detector and reboot you in a loop. + +If you must run a few LEDs from USB power for testing, keep `count ≀ 30` +and `brightness ≀ 100/255`. diff --git a/docs/V6-ARCHITECTURE.md b/docs/V6-ARCHITECTURE.md new file mode 100644 index 0000000..7ff9e3c --- /dev/null +++ b/docs/V6-ARCHITECTURE.md @@ -0,0 +1,246 @@ +# AmbiSense v6 β€” Architecture (locked decisions) + +Status: **active** as of 2026-05-05. This document captures decisions that +must not be relitigated without an explicit conversation. If you're picking +up the v6 rewrite cold, read this first. + +## Why v6 exists + +v5.1.1 (Arduino) works but is structurally constrained: + +- Every subsystem runs cooperatively in `loop()` β€” radar reads, LED + rendering, HTTP serving, ESP-NOW. A slow web request stalls the LEDs; + a heavy Fire-mode frame stalls radar reads. +- EEPROM byte-offset layout (320 bytes across 6 sections) β€” fragile, no + versioning, no atomicity. +- One radar driver hard-coded; no path to LD2450 multi-target tracking. +- Master/slave ESP-NOW with implicit topology β€” equal LED-segment splits + break asymmetric stairs (3-step landing + 12-step main flight). +- WiFi channel pinned to 6 even when the master joins a router on + channel 11 β€” silent failure. +- No OTA, no captive portal, no auth, plaintext WiFi creds in EEPROM. + +v6 is an ESP-IDF + FreeRTOS rewrite that fixes all of the above while +preserving the v5 visual modes, the user-facing API surface (most +endpoints kept compatible), and the master-slave-style coordination +behaviour for U/L stair installs. + +--- + +## Target hardware + +| Profile | Validated | Default LED pin | Default radar pins | +| --------------------- | --------- | --------------- | ------------------ | +| `esp32c3-supermini` | βœ… yes | GPIO 10 | RX 20 / TX 21 | +| `esp32-devkit` | builds | GPIO 5 | RX 16 / TX 17 | +| `esp32s3-zero` | builds | GPIO 21 | RX 4 / TX 5 | +| `esp32c6-devkit` | builds | GPIO 8 | RX 4 / TX 5 | + +Profiles ship in `firmware/components/board/board.c`. Each declares an +`unsafe_pin_mask` covering strapping pins, USB-Serial-JTAG D-/D+, and +internal SPI flash. The web UI (PR #5) will refuse pin remaps that fall +on unsafe pins. + +The C3 SuperMini is the primary target for v6.0. Other boards are +"compile-clean" until the user gets hardware in hand. + +## Target sensors (radar abstraction layer) + +A driver registry compiled into `components/radar/`. All drivers linked +in; one selected at runtime via the `board.radar_kind` NVS key. Adding a +new sensor = +1 file + 1 dropdown entry, no firmware reflash needed. + +| Driver id | Sensor | Tier | Notes | +| ---------- | ---------------- | ----- | ----------------------------------- | +| `ld2410` | HiLink LD2410(B/C)| v1 | Single target; existing v5 hardware | +| `ld2412` | HiLink LD2412 | v1 | Per-gate sensitivity | +| `ld2420` | HiLink LD2420 | v1 | Presence only (no distance) | +| `ld2450` | HiLink LD2450 | v1 | Up to 3 targets, x/y/speed | +| `sim` | Simulator | v1 | Synthetic traces for desk testing | + +**One radar per device.** The C3 has only one usable UART beyond the +console, and the user explicitly confirmed this constraint. Multi-radar +fusion happens at the mesh level (each device contributes its sensor). + +## Mesh model: peer mesh, NOT master/slave + +Every device: + +1. Has the **full topology config** in NVS (which segments exist, which + device owns each segment, sensor positions in cm along the strip). +2. **Broadcasts its radar reading at 5 Hz** to all peers via ESP-NOW. +3. Runs the **same fusion algorithm locally** on the merged peer stream + and arrives at the same global "active person position". +4. Renders **only its own LED segment** on its **own local strip**. + +Properties: + +- **No leader required for control.** Any device can drop and the others + keep working at degraded fusion accuracy. +- **Web UI host = elected coordinator** (lowest-MAC peer). User browses + to `ambisense.local`, mDNS resolves to whoever is elected. No "which + IP do I open?" confusion. +- **Config gossip.** Any device accepts a config write; gossip propagates + with a `config_version` stamp. Lex tiebreaker on MAC for simultaneous + writes. + +### Critical physical fact: each device has its OWN local strip + +LED strips need power injection on both ends for long runs and the data +signal degrades over distance. We do **not** chain a single strip across +multiple devices. Each device drives its own physical strip; the "global +LED address space" is purely virtual: + +``` +device A (segment 0): physical pixels 0..99 == virtual 0..99 +device B (segment 1): physical pixels 0..99 == virtual 100..199 +device C (segment 2): physical pixels 0..99 == virtual 200..299 +``` + +When the active person is at virtual position 250: + +- A renders background only (250 not in 0..99). +- B renders background only (250 not in 100..199). +- C renders the moving light at *local* pixel 50 (250 βˆ’ 200). + +This shrinks ESP-NOW bandwidth dramatically β€” peers exchange ~24-byte +`global_state_t` frames (active position + mode + color + effect tick), +not pixel-level commands. ~600 B/s mesh chatter for a 5-device install. + +### Wire protocol (PR #4 will implement) + +```c +typedef struct { // ESP-NOW, broadcast every 200 ms + uint8_t msg_type; // 1=TARGET, 2=HEARTBEAT, 3=CONFIG_GOSSIP, 4=CHAN_ANNOUNCE + uint8_t device_idx; + uint16_t config_version; + uint16_t distance_cm; + int16_t x_cm, y_cm; // valid for LD2450, else 0 + int16_t velocity_cms; + uint8_t energy; + uint8_t flags; // bit0=primary_target_present, bit1=is_coordinator + uint64_t ts_us; +} peer_msg_t; +``` + +Channel-follow-STA: when any device joins a router, it broadcasts +`CHAN_ANNOUNCE`; peers update via `esp_wifi_set_channel()`. Solves the +v5 "channel 6 forced, router on 11, mesh dies" silent failure. + +Encrypted ESP-NOW: PMK/LMK pairing during a 3-second-button-hold +pairing window. Stops a neighbour with the same firmware from joining +your mesh accidentally. + +--- + +## Software structure + +``` +firmware/ +β”œβ”€β”€ CMakeLists.txt # IDF project root +β”œβ”€β”€ partitions.csv # NVS + 2x OTA + LittleFS + coredump +β”œβ”€β”€ sdkconfig.defaults # Common knobs (WDT, brownout, coredump, …) +β”œβ”€β”€ sdkconfig.defaults.esp32c3 # C3-specific (USB-Serial-JTAG console, brownout) +β”œβ”€β”€ main/main.c # app_main: bring up tasks; no business logic +└── components/ + β”œβ”€β”€ board/ # PR #1 βœ“ β€” board profile struct + 4 profiles + β”œβ”€β”€ settings/ # PR #1 βœ“ β€” NVS facade replacing v5 EEPROM + β”œβ”€β”€ status_led/ # PR #1 βœ“ β€” pattern-driven LED in own task + β”œβ”€β”€ netmgr/ # PR #2 β€” Wi-Fi STA/AP + mDNS + captive DNS + β”œβ”€β”€ webui/ # PR #2 β€” esp_http_server + LittleFS + auth + β”œβ”€β”€ ota/ # PR #2 β€” esp_https_ota wrapper + β”œβ”€β”€ radar/ # PR #3 β€” driver registry (LD2410/2412/2420/2450/sim) + β”œβ”€β”€ motion/ # PR #3 β€” PI smoother (port of v5 algorithm) + β”œβ”€β”€ led_engine/ # PR #3 β€” 11 modes via led_strip RMT + β”œβ”€β”€ mesh/ # PR #4 β€” ESP-NOW peer mesh + └── topology/ # PR #4 β€” explicit L/U/asymmetric stair model +``` + +### FreeRTOS task model + +| Task | Pri | Stack | Period | Responsibility | +| ----------------- | --- | ----- | ----------- | ----------------------------------- | +| `radar_task` | 6 | 3 KB | UART event | Read radar bytes β†’ `radar_frame_t` | +| `motion_task` | 5 | 4 KB | 50 Hz | PI smoother β†’ publishes `target_t` | +| `mesh_rx_task` | 5 | 4 KB | event | ESP-NOW callbacks β†’ `target_q` | +| `mesh_tx_task` | 4 | 3 KB | 5 Hz | Broadcast our reading; topology gossip | +| `led_render_task` | 4 | 6 KB | 60 Hz | Read fused target β†’ framebuffer β†’ `led_strip_refresh()` | +| `web_task` | 3 | 6 KB | event | HTTPD handler thread | +| `health_task` | 2 | 2 KB | 1 Hz | Heap watch, peer timeout, status LED | + +The single integration point is `target_q` β€” radar local + ESP-NOW +remote both push into it; motion fusion + LED render both consume it. +This collapses v5's four ad-hoc paths in `processRadarReading()` +(master / slave / standalone / no-slaves) into one uniform pipeline. + +--- + +## NVS schema (replacing v5's 320-byte EEPROM map) + +| Namespace | Keys (representative) | +| --------- | --------------------- | +| `sys` | `device_name` | +| `board` | `id`, `led_pin`, `radar_rx`, `radar_tx`, `button`, `status_led`, `radar_kind` | +| `led` | `count`, `brightness`, `r/g/b`, `mode`, `span`, `center_shift`, `trail`, `dir_light`, `bg_mode`, `effect_speed`, `effect_intensity` | +| `dist` | `min_cm`, `max_cm` | +| `motion` | `enabled`, `pos_smooth`, `vel_smooth`, `predict`, `p_gain`, `i_gain` | +| `mesh` | `peers` (blob: count + 6Γ—N MAC array), `pmk`, `channel`, `priority_mode` | +| `topo` | `kind` (straight/L/U/custom), `total_leds`, `segments` (blob) | +| `wifi` | `ssid`, `pass` (encrypted w/ chip-derived key), `static_ip` (optional), `mdns_name` | +| `auth` | `admin_pass_hash` (PBKDF2-SHA256, 250k rounds) | + +NVS is journaled (atomic per-key writes), wear-levelled, typed (no +manual hi/lo byte unpacking), and versionable. Replaces v5's manual +XOR-CRC sectioned layout entirely. + +No migration shim from v5 EEPROM β€” the user explicitly confirmed v6 is +a clean cutover (no backwards compatibility with Arduino fleet). + +--- + +## HTTP API + +The PR #1 design preserves every existing v5 endpoint (compatibility for +external integrations like Home Assistant), and adds the missing v6 +surface. See `docs/V6-ROADMAP.md` for the per-PR endpoint deliveries. + +New endpoints summary: + +``` +POST /api/auth/login β†’ cookie session +WS /api/live β†’ distance + RSSI + heap @ 5 Hz, replaces XHR polling +GET /api/board/profiles β†’ board dropdown +POST /api/board β†’ save board id + pin overrides; reboot +GET /api/radar/kinds β†’ ld2410 | ld2412 | ld2420 | ld2450 | sim +POST /api/topology β†’ persist L/U/custom + per-segment LED ranges +POST /api/ota β†’ multipart firmware upload +POST /api/sim/trace β†’ simulator-driver: replay a recorded distance trace +GET /api/version β†’ app version + git sha + idf version + free heap +GET /api/logs β†’ ring buffer of recent ESP_LOG output +``` + +--- + +## Open decisions (locked for v6.0) + +These were resolved by the user with "decide for me": + +| Decision | Choice | +| ------------ | ------------------------------------------------------------ | +| Boards in v1 | All four profiles ship; only C3 validated, others build-clean | +| Auth default | OFF; banner until configured; PBKDF2-SHA256 hash | +| OTA signing | Unsigned for v6.0; signed-OTA on the v6.x roadmap | +| UI framework | Preact + Tailwind + Vite + TypeScript; bundle target <80 KB gz | +| Mesh model | Peer mesh with elected coordinator (lowest-MAC) | +| MQTT | Off by default; HA auto-discovery format when enabled | +| Repo strategy | Same repo; `legacy/v5-arduino` archive; `v6-idf-rewrite` working branch; tagged releases `v6.0.0-alpha.N` | + +## What's NOT in v6.0 (deferred) + +- BLE Mesh / Thread / Matter (would require C6/S3 only β€” rules out C3) +- Simultaneous LD2410 + LD2450 on one board (C3 has only one usable UART) +- Signed OTA (defer until update flow is exercised) +- Multi-room / cross-house mesh (≀5 nodes per install is the design point) +- Anonymous telemetry / phone-home (never) + +These can be added later without breaking the architecture. diff --git a/docs/V6-ROADMAP.md b/docs/V6-ROADMAP.md new file mode 100644 index 0000000..48f7276 --- /dev/null +++ b/docs/V6-ROADMAP.md @@ -0,0 +1,235 @@ +# AmbiSense v6 β€” PR-by-PR roadmap + +> **Status (2026-05-05): v6.0.0 RELEASED.** All five planned PRs landed +> plus a post-PR-5 polish pass (Kalman motion, asymmetric pairing, +> debounced UI saves, mobile CSS, board MCU-mismatch boot guard). See +> the [v6.0.0 release page](https://github.com/Techposts/AmbiSense/releases/tag/v6.0.0) +> for the user-facing summary. +> +> v6.x roadmap (post-6.0.0 work) is at the bottom of this file. + +Five PRs. Each is independently flashable and validated on real C3 +hardware before the next starts. Releases tagged `v6.0.0-alpha.N` from +PR #2 onwards; final `v6.0.0` ships with PR #5. + +For locked architectural decisions see [`V6-ARCHITECTURE.md`](V6-ARCHITECTURE.md). + +--- + +## PR #1 β€” IDF skeleton + +**Status: βœ… MERGED on `v6-idf-rewrite`, pushed to origin (2026-05-05).** + +Delivered: +- Repo housekeeping: legacy v5 moved under `legacy/AmbiSense/`, + branches `legacy/v5-arduino` and `v6-idf-rewrite` created. +- ESP-IDF v5.3-compatible project scaffold under `firmware/`. +- Custom partition table: 16 KB NVS + 8 KB OTA data + 1408 KB Γ— 2 OTA + app slots + 960 KB LittleFS + 64 KB coredump (4 MB total). +- `sdkconfig.defaults` with task watchdog, brownout detector, + bootloader rollback, coredump-to-flash, esp_http_server WS support. +- `components/board/` β€” profile struct + 4 ship-ready profiles + (`esp32c3-supermini` validated; others build-clean, untested). + `unsafe_pin_mask` blocks UI pin remap to strapping/USB/flash GPIOs. +- `components/settings/` β€” NVS facade replacing v5's 320-byte EEPROM + layout. PR #1 only exposes the `board` namespace; other namespaces + populated as their owners come online. +- `components/status_led/` β€” pattern-driven LED in its own FreeRTOS task. + Six patterns (BOOT, AP_MODE, STA_MODE, OTA, ERROR, PANIC) cover every + user-visible state. +- `main/main.c` β€” resolves board profile (NVS override β†’ compile-time + default), applies per-pin NVS overrides while rejecting unsafe pins, + spawns status_led task. +- `frontend/design-source/` β€” full Claude Design handoff bundle + preserved for PR #5 to build against. +- `.github/workflows/firmware.yml` β€” IDF build matrix across all four + targets on every push/PR; uploads tagged firmware artifacts. + +**Build numbers** (C3 target): +- `ambisense.bin`: 0x30CF0 bytes (~195 KB) +- App slot free: 0x12F310 bytes (86%) +- Bootloader free: 35% + +**Hardware validation status**: blocked on a one-off macOS USB-CDC +enumeration issue (the chip enumerates but esptool can't sync). Not a +firmware bug β€” see [`HARDWARE.md`](HARDWARE.md). Will be flashed once +the user retries after a Mac restart. + +--- + +## PR #2 β€” Wi-Fi, web shell, OTA, auth scaffold + +**Status: not started.** + +Scope: +- `components/netmgr/` β€” Wi-Fi STA with fallback to AP, mDNS hostname, + captive-portal DNS responder for AP mode (auto-pop on iOS/Android/ + Win11), Wi-Fi event loop integration. +- `components/webui/` β€” esp_http_server, LittleFS-served static UI + (placeholder bundle), JSON settings round-trip for every NVS key, + cookie-based auth (off by default, PBKDF2-SHA256 password hash), + WebSocket endpoint stub at `/api/live`. +- `components/ota/` β€” `esp_https_ota` wrapper, `POST /api/ota` multipart + upload, two-stage commit (write β†’ verify β†’ mark valid β†’ reboot). +- Status LED transitions: BOOT β†’ AP_MODE while waiting for credentials, + STA_MODE heartbeat once connected. +- `GET /api/version`, `GET /api/logs` (ring buffer mirror of `ESP_LOG*`). + +**Done criterion**: from a stock C3, connect phone to `AmbiSense-XXXX` +AP, captive portal pops, configure home Wi-Fi, device reboots into STA +mode, browse to `http://ambisense.local`, see placeholder dashboard, +upload a `.bin` firmware via OTA, device reboots into new firmware. + +**Tag on completion**: `v6.0.0-alpha.1`. + +--- + +## PR #3 β€” Radar driver registry, motion, LED engine + +**Status: not started.** + +Scope: +- `components/radar/` β€” driver registry pattern with one driver per + sensor: `ld2410.c`, `ld2412.c`, `ld2420.c`, `ld2450.c`, `sim.c`. + Selected at runtime via `board.radar_kind` NVS key. Common + `radar_frame_t` API; LD2450's x/y/multi-target fields ignored by + drivers that don't provide them. +- `components/motion/` β€” port of v5's PI smoother + (`legacy/AmbiSense/radar_manager.cpp:38-198`). Same algorithm, runs + in its own task at 50 Hz, output published to `target_q`. +- `components/led_engine/` β€” port of all 11 visual modes + (`legacy/AmbiSense/led_controller.cpp` lines 273–650). RMT-backed + `led_strip` driver replaces `Adafruit_NeoPixel`. Render task at + 60 Hz consumes `target_q`, writes framebuffer, refreshes strip + asynchronously (non-blocking, unlike v5's `strip.show()`). +- `POST /api/sim/trace` β€” accept JSON-encoded distance traces for the + simulator driver. Lets you debug LED modes without standing in front + of a sensor. +- UI placeholder gets the LED preview canvas wired to live state. + +**Done criterion**: standalone C3 + LD2450 + 30 LED test strip mimics +v5 visual behaviour at parity for all 11 modes; web request load no +longer glitches LED rendering; simulator replays a recorded trace +identically to a live one. + +**Tag on completion**: `v6.0.0-alpha.2`. + +--- + +## PR #4 β€” Peer mesh, topology, ESP-NOW + +**Status: not started.** + +Scope: +- `components/mesh/` β€” ESP-NOW peer mesh per the wire protocol in + [`V6-ARCHITECTURE.md`](V6-ARCHITECTURE.md#wire-protocol). + Channel-follow-STA, encrypted ESP-NOW (PMK/LMK), 3-second-button- + hold pairing window, active heartbeat, 10 s health timeout. +- `components/topology/` β€” explicit topology config in NVS (straight / + L / U / asymmetric custom) with per-segment device + LED-range + assignments. Replaces v5's implicit-from-count model that broke + asymmetric stairs. +- All 4 sensor priority modes preserved as fusion algorithms (MOST_RECENT, + SLAVE_FIRST, MASTER_FIRST, ZONE_BASED) β€” but reframed as peer-fusion + policies rather than master-decides logic. +- Coordinator election (lowest-MAC peer serves the web UI; mDNS and + captive portal point at coordinator's IP). +- Config gossip with version-stamped `CONFIG_GOSSIP` packets; lex + tiebreaker on MAC for simultaneous edits. + +**Done criterion**: 2 devices on a U-stair, kill any one β€” the others +keep tracking; configure asymmetric segments and verify visually; +coordinator survives; rebooting the coordinator triggers a new election +within 5 s. + +**Tag on completion**: `v6.0.0-alpha.3`. + +--- + +## PR #5 β€” Real UI, MQTT, polish + +**Status: not started.** Design source already in repo at +`frontend/design-source/` (Claude Design handoff: tokens.css + 7 +screen JSXs). + +Scope: +- `frontend/` β€” Vite + Preact + Tailwind + TypeScript scaffold. Lift + the design tokens from `frontend/design-source/project/tokens.css` + into `tailwind.config.ts`. Port each of the 7 screens from JSX into + Preact components. Bundle target <80 KB gzipped. +- WebSocket live data wired to `/api/live` (replace polling-based + placeholder from PR #2). +- OTA UI: drag-drop `.bin`, progress bar, 30 s reboot overlay polling + `/api/version` to know when to dismiss. +- Pin remap UI with guard rails β€” unsafe pins shown disabled with + tooltip, "reboot to apply" affordance. +- LD2450 2-D zone editor (only visible when `radar_kind=ld2450`) β€” + drag-define exclusion zones in x/y space. +- Factory reset flow with type-device-name confirmation. +- MQTT publisher: off by default, system-tab toggle, Home Assistant + auto-discovery payload format. Publishes `{distance, mode, state}`, + subscribes to `cmd/*`. +- README rewrite for v6, hardware photos, install guide, HA integration + re-validation against v6 endpoints. + +**Done criterion**: full UI works on phone + desktop; all flows from +the design covered; MQTT integration tested with a real Home Assistant; +clean release notes. + +**Tag on completion**: `v6.0.0`. Promote `v6-idf-rewrite` β†’ `main`. +Move v5 README content fully behind a "v5 legacy" link. + +--- + +## Tag/release cadence + +| Tag | Trigger | Contents | +| ------------------ | -------------- | --------------------- | +| `v5.1.1` | (already exists) | last Arduino release | +| `v6.0.0-alpha.0` | PR #1 merge | skeleton boots only | +| `v6.0.0-alpha.1` | PR #2 merge | + Wi-Fi, web, OTA | +| `v6.0.0-alpha.2` | PR #3 merge | + radar + LED engine | +| `v6.0.0-alpha.3` | PR #4 merge | + peer mesh | +| `v6.0.0` | PR #5 merge | feature complete | + +CI auto-attaches per-board firmware artifacts to tag pushes. + +--- + +## v6.x roadmap (post-6.0.0) + +These are tracked but did NOT ship in 6.0.0. See the v6.0.0 release +notes for the user-facing version of the same list. + +### Hardening +- **Encrypted ESP-NOW** β€” derive a PMK from a user mesh password, + enable per-peer LMK, store in NVS namespace `mesh.pmk`. Targets are + defined in `mesh.h` (the `mesh_event_cb_t` plumbing is already in + place); the actual `esp_now_set_pmk` call and UI password field are + v6.1 work. +- **Signed OTA** β€” `esp_secure_boot` + signed `.bin` with rollback. The + bootloader rollback path is already armed; signing keys + UI + workflow are pending. +- **Persistent sessions** β€” auth tokens currently live in RAM, lost on + reboot. Move to NVS with explicit revocation list. + +### UX / scale +- **Auto-topology learning** β€” "walk through your stairs" mode where + the device records distance histograms over a 60 s window and + proposes segment boundaries automatically. +- **Dual-core pinning (S3 / classic)** β€” `xTaskCreatePinnedToCore` for + Wi-Fi/HTTP on core 0 and radar+LED+motion on core 1. Lifts the + HTTP-saturates-render limitation we currently work around with the + client-side debounce. +- **LED count beyond 300** β€” verified; lift the documented soft cap to + the theoretical 1500-pixel limit per device. +- **Realistic `sim` radar** β€” replay captured LD2410 frame logs + instead of emitting a slow sine. + +### Misc +- **OTA preserves paired peers** β€” currently a factory reset wipes the + topology blob too; needs a "keep mesh config" toggle. +- **`esp32s3-zero` profile validation** β€” pinmap is correct in the + profile; needs a hardware bring-up + photo for HARDWARE.md once an + S3-Zero arrives on bench. +- **Factory reset preserves Wi-Fi** option. diff --git a/firmware/.gitignore b/firmware/.gitignore new file mode 100644 index 0000000..5e4dce3 --- /dev/null +++ b/firmware/.gitignore @@ -0,0 +1,12 @@ +# IDF build artifacts +build/ +sdkconfig +sdkconfig.old +managed_components/ +dependencies.lock + +# Editor / OS noise +.vscode/ +.cache/ +*.swp +.DS_Store diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt new file mode 100644 index 0000000..f6cdae5 --- /dev/null +++ b/firmware/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.16) + +# AmbiSense v6 β€” ESP-IDF firmware +# +# Build with: +# . $IDF_PATH/export.sh +# idf.py set-target esp32c3 # or esp32, esp32s3, esp32c6 +# idf.py build flash monitor + +set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/components") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(ambisense) diff --git a/firmware/components/auth/CMakeLists.txt b/firmware/components/auth/CMakeLists.txt new file mode 100644 index 0000000..4f987d9 --- /dev/null +++ b/firmware/components/auth/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "auth.c" + INCLUDE_DIRS "include" + REQUIRES settings mbedtls log esp_system freertos +) diff --git a/firmware/components/auth/auth.c b/firmware/components/auth/auth.c new file mode 100644 index 0000000..1c2bdc4 --- /dev/null +++ b/firmware/components/auth/auth.c @@ -0,0 +1,210 @@ +#include "auth.h" + +#include +#include +#include + +#include "esp_log.h" +#include "esp_random.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "mbedtls/pkcs5.h" +#include "mbedtls/md.h" + +#include "settings.h" + +static const char *TAG = "auth"; + +#define PBKDF2_ROUNDS 250000 +#define SALT_LEN 16 +#define HASH_LEN 32 +#define MAX_SESSIONS 8 +#define SESSION_TTL_MS (24ULL * 60ULL * 60ULL * 1000ULL) /* 24h */ + +typedef struct { + bool active; + uint8_t token[AUTH_TOKEN_LEN]; + uint64_t expires_ms; +} session_t; + +static struct { + bool inited; + bool enabled; + uint8_t hash[HASH_LEN]; + uint8_t salt[SALT_LEN]; + SemaphoreHandle_t lock; + session_t sessions[MAX_SESSIONS]; +} s_auth; + +static uint64_t now_ms(void) { + return (uint64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; +} + +static void hex_encode(const uint8_t *in, size_t n, char *out) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < n; ++i) { + out[i*2] = hex[in[i] >> 4]; + out[i*2+1] = hex[in[i] & 0x0f]; + } + out[n*2] = '\0'; +} + +static int hex_decode(const char *in, uint8_t *out, size_t n) { + for (size_t i = 0; i < n; ++i) { + char hi = in[i*2], lo = in[i*2+1]; + if (!hi || !lo) return -1; + int v = 0; + for (int k = 0; k < 2; ++k) { + char c = (k == 0) ? hi : lo; + int d; + if (c >= '0' && c <= '9') d = c - '0'; + else if (c >= 'a' && c <= 'f') d = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') d = c - 'A' + 10; + else return -1; + v = (v << 4) | d; + } + out[i] = (uint8_t)v; + } + return 0; +} + +static int pbkdf2(const char *plaintext, const uint8_t salt[SALT_LEN], uint8_t out[HASH_LEN]) { + const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + if (!info) return -1; +#if defined(MBEDTLS_VERSION_MAJOR) && MBEDTLS_VERSION_MAJOR >= 3 + return mbedtls_pkcs5_pbkdf2_hmac_ext(MBEDTLS_MD_SHA256, + (const unsigned char *)plaintext, strlen(plaintext), + salt, SALT_LEN, PBKDF2_ROUNDS, HASH_LEN, out); +#else + mbedtls_md_context_t ctx; + mbedtls_md_init(&ctx); + int rc = mbedtls_md_setup(&ctx, info, 1); + if (rc == 0) { + rc = mbedtls_pkcs5_pbkdf2_hmac(&ctx, + (const unsigned char *)plaintext, strlen(plaintext), + salt, SALT_LEN, PBKDF2_ROUNDS, HASH_LEN, out); + } + mbedtls_md_free(&ctx); + return rc; +#endif +} + +esp_err_t auth_init(void) { + if (s_auth.inited) return ESP_OK; + s_auth.lock = xSemaphoreCreateMutex(); + s_auth.inited = true; + + if (settings_get_auth_hash(s_auth.hash, s_auth.salt) == ESP_OK) { + s_auth.enabled = true; + ESP_LOGI(TAG, "Auth enabled (password configured)"); + } else { + s_auth.enabled = false; + ESP_LOGI(TAG, "Auth DISABLED (no password set β€” open access on local network)"); + } + return ESP_OK; +} + +bool auth_is_enabled(void) { + return s_auth.enabled; +} + +esp_err_t auth_set_password(const char *plaintext) { + if (!plaintext || !plaintext[0]) { + settings_clear_auth(); + s_auth.enabled = false; + memset(s_auth.hash, 0, HASH_LEN); + memset(s_auth.salt, 0, SALT_LEN); + auth_revoke_all(); + ESP_LOGI(TAG, "Password cleared; auth disabled"); + return ESP_OK; + } + if (strlen(plaintext) < 8) return ESP_ERR_INVALID_ARG; + + uint8_t salt[SALT_LEN]; + esp_fill_random(salt, SALT_LEN); + uint8_t hash[HASH_LEN]; + if (pbkdf2(plaintext, salt, hash) != 0) return ESP_FAIL; + + esp_err_t err = settings_set_auth_hash(hash, salt); + if (err != ESP_OK) return err; + memcpy(s_auth.hash, hash, HASH_LEN); + memcpy(s_auth.salt, salt, SALT_LEN); + s_auth.enabled = true; + auth_revoke_all(); /* invalidate any pre-existing sessions */ + ESP_LOGI(TAG, "Password set; auth enabled"); + return ESP_OK; +} + +bool auth_check_password(const char *plaintext) { + if (!s_auth.enabled || !plaintext) return false; + uint8_t cand[HASH_LEN]; + if (pbkdf2(plaintext, s_auth.salt, cand) != 0) return false; + /* Constant-time compare */ + uint8_t diff = 0; + for (size_t i = 0; i < HASH_LEN; ++i) diff |= cand[i] ^ s_auth.hash[i]; + return diff == 0; +} + +esp_err_t auth_issue_session(char token_hex_out[AUTH_TOKEN_HEX_LEN]) { + xSemaphoreTake(s_auth.lock, portMAX_DELAY); + int slot = -1; + uint64_t now = now_ms(); + for (int i = 0; i < MAX_SESSIONS; ++i) { + if (!s_auth.sessions[i].active || s_auth.sessions[i].expires_ms < now) { + slot = i; break; + } + } + if (slot < 0) { + /* All slots occupied & valid β€” evict oldest. */ + slot = 0; + for (int i = 1; i < MAX_SESSIONS; ++i) { + if (s_auth.sessions[i].expires_ms < s_auth.sessions[slot].expires_ms) slot = i; + } + } + esp_fill_random(s_auth.sessions[slot].token, AUTH_TOKEN_LEN); + s_auth.sessions[slot].expires_ms = now + SESSION_TTL_MS; + s_auth.sessions[slot].active = true; + hex_encode(s_auth.sessions[slot].token, AUTH_TOKEN_LEN, token_hex_out); + xSemaphoreGive(s_auth.lock); + return ESP_OK; +} + +bool auth_check_session(const char *token_hex) { + if (!token_hex || strlen(token_hex) < AUTH_TOKEN_LEN * 2) return false; + uint8_t tok[AUTH_TOKEN_LEN]; + if (hex_decode(token_hex, tok, AUTH_TOKEN_LEN) < 0) return false; + + xSemaphoreTake(s_auth.lock, portMAX_DELAY); + uint64_t now = now_ms(); + bool ok = false; + for (int i = 0; i < MAX_SESSIONS; ++i) { + if (!s_auth.sessions[i].active) continue; + if (s_auth.sessions[i].expires_ms < now) { s_auth.sessions[i].active = false; continue; } + uint8_t diff = 0; + for (size_t k = 0; k < AUTH_TOKEN_LEN; ++k) diff |= tok[k] ^ s_auth.sessions[i].token[k]; + if (diff == 0) { ok = true; break; } + } + xSemaphoreGive(s_auth.lock); + return ok; +} + +void auth_revoke(const char *token_hex) { + if (!token_hex) return; + uint8_t tok[AUTH_TOKEN_LEN]; + if (hex_decode(token_hex, tok, AUTH_TOKEN_LEN) < 0) return; + xSemaphoreTake(s_auth.lock, portMAX_DELAY); + for (int i = 0; i < MAX_SESSIONS; ++i) { + if (s_auth.sessions[i].active && + memcmp(tok, s_auth.sessions[i].token, AUTH_TOKEN_LEN) == 0) { + s_auth.sessions[i].active = false; + } + } + xSemaphoreGive(s_auth.lock); +} + +void auth_revoke_all(void) { + if (!s_auth.lock) return; + xSemaphoreTake(s_auth.lock, portMAX_DELAY); + for (int i = 0; i < MAX_SESSIONS; ++i) s_auth.sessions[i].active = false; + xSemaphoreGive(s_auth.lock); +} diff --git a/firmware/components/auth/include/auth.h b/firmware/components/auth/include/auth.h new file mode 100644 index 0000000..1deb9b2 --- /dev/null +++ b/firmware/components/auth/include/auth.h @@ -0,0 +1,50 @@ +#pragma once + +/* + * AmbiSense v6 β€” auth. + * + * Off by default. When a password is set, every /api/... endpoint requires + * a valid session cookie. Login takes a password, hashes it with PBKDF2- + * SHA256 (250k rounds), compares against the stored hash, and issues a + * 32-byte random session token returned as `Set-Cookie: ambisense=...`. + * + * Session storage is in-RAM (8 slots, 24 h TTL). Restart wipes sessions β€” + * acceptable for a hobbyist device. + */ + +#include +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define AUTH_TOKEN_LEN 32 +#define AUTH_TOKEN_HEX_LEN (AUTH_TOKEN_LEN * 2 + 1) + +esp_err_t auth_init(void); + +/* True if a password is configured (auth required for /api/...). */ +bool auth_is_enabled(void); + +/* Set the admin password. Empty/NULL clears it (disables auth). */ +esp_err_t auth_set_password(const char *plaintext); + +/* Verify a plaintext password against the stored hash. */ +bool auth_check_password(const char *plaintext); + +/* Issue a session token (returned in token_hex_out, NUL-terminated). */ +esp_err_t auth_issue_session(char token_hex_out[AUTH_TOKEN_HEX_LEN]); + +/* Validate a token string. Returns true if active session exists. */ +bool auth_check_session(const char *token_hex); + +/* Revoke a single session (logout) or all (factory reset). */ +void auth_revoke(const char *token_hex); +void auth_revoke_all(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/board/CMakeLists.txt b/firmware/components/board/CMakeLists.txt new file mode 100644 index 0000000..9298597 --- /dev/null +++ b/firmware/components/board/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "board.c" + INCLUDE_DIRS "include" + REQUIRES +) diff --git a/firmware/components/board/board.c b/firmware/components/board/board.c new file mode 100644 index 0000000..0144084 --- /dev/null +++ b/firmware/components/board/board.c @@ -0,0 +1,177 @@ +#include "board.h" +#include "sdkconfig.h" +#include + +/* + * Pin safety masks were derived from the ESP32-{C3,C6,S3,base} hardware + * design guidelines. When in doubt, prefer marking a pin unsafe β€” users + * can edit board.c if they know what they're doing. + * + * UNSAFE(n) is bit n in a uint64_t. Helper here for readability. + */ +#define U(n) (1ULL << (n)) + +/* ESP32-C3 SuperMini + * - GPIO 8: boot strap, also onboard LED (active-low) on most clones + * - GPIO 9: boot strap (download mode), pulled up β€” driving low at boot bricks boot + * - GPIO 11: SPI VDD strapping + * - GPIO 12-17: SPI flash on internal-flash variants + * - GPIO 18, 19: USB-Serial-JTAG D-/D+ + * Status LED on this board is GPIO 8 (active-low). We *do* use it for status, + * which is fine β€” we're not remapping it to anything else. + * Radar UART on GPIO 20 (RX) and GPIO 21 (TX) keeps us off USB and strapping. + * LED data on GPIO 10 β€” middle of the safe block. + * Button on GPIO 4. + */ +static const board_profile_t profile_c3_supermini = { + .id = "esp32c3-supermini", + .display = "ESP32-C3 SuperMini", + .mcu = "esp32c3", + .validated = true, + .led_pin = 10, + .radar_rx_pin = 20, + .radar_tx_pin = 21, + .button_pin = 4, + .status_led_pin = 8, + .status_led_active_low = true, + .uart_num = 1, + .rmt_channel = 0, + .unsafe_pin_mask = + U(9) | U(11) | /* strapping */ + U(12) | U(13) | U(14) | U(15) | U(16) | U(17) | /* internal flash */ + U(18) | U(19), /* USB-Serial-JTAG */ + .max_gpio = 21, +}; + +/* Classic ESP32 DevKit (WROOM-32, 30 or 38 pin) + * - GPIO 0, 2, 5, 12, 15: strapping pins + * - GPIO 6-11: internal SPI flash β€” NEVER touch + * - GPIO 1, 3: UART0 console (we keep it for log output by default) + * - GPIO 34-39: input-only (not usable for LED data or radar TX) + * Defaults match the original AmbiSense Arduino mapping where they don't + * conflict. + */ +static const board_profile_t profile_esp32_devkit = { + .id = "esp32-devkit", + .display = "ESP32 DevKit (WROOM-32)", + .mcu = "esp32", + .validated = false, + .led_pin = 5, + .radar_rx_pin = 16, + .radar_tx_pin = 17, + .button_pin = 4, + .status_led_pin = 2, + .status_led_active_low = false, + .uart_num = 2, + .rmt_channel = 0, + .unsafe_pin_mask = + U(0) | U(2) | /* strapping (5/12/15 are usable with care) */ + U(1) | U(3) | /* UART0 console */ + U(6) | U(7) | U(8) | U(9) | U(10) | U(11) | /* SPI flash */ + U(20) | U(24) | U(28) | U(29) | U(30) | U(31), /* not bonded */ + .max_gpio = 39, +}; + +/* ESP32-S3 (Zero / SuperMini class) + * - GPIO 0, 3, 45, 46: strapping + * - GPIO 19, 20: USB-Serial-JTAG + * - GPIO 26-32: SPI flash on the WROOM module variants + * - GPIO 33-37: octal PSRAM/flash β€” depends on module + * Defaults pick visible header pins on common Zero-class boards. + */ +static const board_profile_t profile_esp32s3_zero = { + .id = "esp32s3-zero", + .display = "ESP32-S3 Zero/SuperMini", + .mcu = "esp32s3", + .validated = false, + .led_pin = 21, /* common WS2812 default on S3 Zero */ + .radar_rx_pin = 4, + .radar_tx_pin = 5, + .button_pin = 9, + .status_led_pin = 21, /* same pin as data on some Zero clones; user should remap */ + .status_led_active_low = false, + .uart_num = 1, + .rmt_channel = 0, + .unsafe_pin_mask = + U(0) | U(3) | U(45) | U(46) | /* strapping */ + U(19) | U(20) | /* USB-Serial-JTAG */ + U(26) | U(27) | U(28) | U(29) | U(30) | U(31) | U(32) | /* flash */ + U(33) | U(34) | U(35) | U(36) | U(37), /* octal flash/PSRAM (WROOM-1) */ + .max_gpio = 48, +}; + +/* ESP32-C6 DevKit + * - GPIO 8, 9: strapping + * - GPIO 12, 13: USB-Serial-JTAG + * - GPIO 24-30: internal SPI flash on most modules + * C6 also has 802.15.4 (Thread/Zigbee/Matter) β€” future-relevant but unused + * for v6. + */ +static const board_profile_t profile_esp32c6_devkit = { + .id = "esp32c6-devkit", + .display = "ESP32-C6 DevKit", + .mcu = "esp32c6", + .validated = false, + .led_pin = 8, /* onboard WS2812 on most C6 devkits */ + .radar_rx_pin = 4, + .radar_tx_pin = 5, + .button_pin = 9, + .status_led_pin = 15, + .status_led_active_low = false, + .uart_num = 1, + .rmt_channel = 0, + .unsafe_pin_mask = + U(9) | /* strap */ + U(12) | U(13) | /* USB-Serial-JTAG */ + U(24) | U(25) | U(26) | U(27) | U(28) | U(29) | U(30), /* flash */ + .max_gpio = 30, +}; + +static const board_profile_t *const k_all_profiles[] = { + &profile_c3_supermini, + &profile_esp32_devkit, + &profile_esp32s3_zero, + &profile_esp32c6_devkit, +}; + +/* The order in which we present profiles to the UI. C3 first because it's + * the validated default for v6.0. */ +const board_profile_t *board_profiles(size_t *out_count) { + if (out_count) { + *out_count = sizeof(k_all_profiles) / sizeof(k_all_profiles[0]); + } + /* The pointer-array layout is what callers iterate; we expose the + * underlying first-profile address as the base. */ + return *k_all_profiles; +} + +const board_profile_t *board_profile_by_id(const char *id) { + if (!id) return NULL; + for (size_t i = 0; i < sizeof(k_all_profiles) / sizeof(k_all_profiles[0]); ++i) { + if (strcmp(k_all_profiles[i]->id, id) == 0) { + return k_all_profiles[i]; + } + } + return NULL; +} + +const board_profile_t *board_default_profile(void) { + /* Match the build's CONFIG_IDF_TARGET to a profile id. */ +#if CONFIG_IDF_TARGET_ESP32C3 + return &profile_c3_supermini; +#elif CONFIG_IDF_TARGET_ESP32 + return &profile_esp32_devkit; +#elif CONFIG_IDF_TARGET_ESP32S3 + return &profile_esp32s3_zero; +#elif CONFIG_IDF_TARGET_ESP32C6 + return &profile_esp32c6_devkit; +#else + return &profile_c3_supermini; +#endif +} + +bool board_pin_is_unsafe(const board_profile_t *profile, uint8_t pin) { + if (!profile) return true; + if (pin > profile->max_gpio) return true; + return (profile->unsafe_pin_mask & U(pin)) != 0; +} diff --git a/firmware/components/board/include/board.h b/firmware/components/board/include/board.h new file mode 100644 index 0000000..94ac755 --- /dev/null +++ b/firmware/components/board/include/board.h @@ -0,0 +1,68 @@ +#pragma once + +/* + * AmbiSense v6 β€” board abstraction layer. + * + * Each supported ESP32 variant ships a static board_profile_t describing its + * sane defaults and the pins that are unsafe to use as user-configurable I/O + * (strapping pins, USB-JTAG pins, flash pins, etc.). At runtime the active + * profile is selected from NVS by id; pins on top can be individually + * overridden by the user from the web UI, but writes to unsafe_pin_mask pins + * are rejected before the override is persisted. + * + * Adding a board = +1 board_profile_t entry in board.c. + */ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Sentinel for "no pin assigned". GPIO_NUM_NC (-1) cast to unsigned. */ +#define BOARD_PIN_NONE 0xFF + +typedef struct { + const char *id; /* stable id, used as NVS key value */ + const char *display; /* human label shown in UI */ + const char *mcu; /* "esp32c3", "esp32", "esp32s3", "esp32c6" */ + bool validated; /* true = hardware-tested, false = ships untested */ + + /* Sane defaults β€” the board ships with these unless the user remaps. */ + uint8_t led_pin; + uint8_t radar_rx_pin; /* MCU side: receives from radar TX */ + uint8_t radar_tx_pin; /* MCU side: transmits to radar RX */ + uint8_t button_pin; + uint8_t status_led_pin; /* on-board LED, often inverted-active-low */ + bool status_led_active_low; + + /* Peripheral defaults */ + uint8_t uart_num; /* UART number for radar β€” 1 on most boards */ + uint8_t rmt_channel; /* RMT channel for led_strip */ + + /* Bitmask of GPIOs that must NOT be remapped to user I/O. + * Bit N = 1 β†’ GPIO N is unsafe (strapping, flash, USB, reserved). */ + uint64_t unsafe_pin_mask; + + /* Highest legal GPIO number on this part β€” UI clamps dropdowns to this. */ + uint8_t max_gpio; +} board_profile_t; + +/* Return the array of all known profiles and its length. */ +const board_profile_t *board_profiles(size_t *out_count); + +/* Look up a profile by id; returns NULL if id is unknown. */ +const board_profile_t *board_profile_by_id(const char *id); + +/* The compile-time default profile for this build's CONFIG_IDF_TARGET. + * Used on first boot when NVS has no board.id key yet. */ +const board_profile_t *board_default_profile(void); + +/* True if `pin` is in `profile`'s unsafe_pin_mask. */ +bool board_pin_is_unsafe(const board_profile_t *profile, uint8_t pin); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/button/CMakeLists.txt b/firmware/components/button/CMakeLists.txt new file mode 100644 index 0000000..31c1ecc --- /dev/null +++ b/firmware/components/button/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "button.c" + INCLUDE_DIRS "include" + REQUIRES driver log freertos esp_timer +) diff --git a/firmware/components/button/button.c b/firmware/components/button/button.c new file mode 100644 index 0000000..57bfcca --- /dev/null +++ b/firmware/components/button/button.c @@ -0,0 +1,108 @@ +#include "button.h" + +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "button"; + +#define POLL_MS 20 +#define DEBOUNCE_MS 40 +#define LONG_MS 3000 +#define VERYLONG_MS 10000 + +static struct { + uint8_t gpio; + bool active_low; + button_event_cb_t cb; +} s_b; + +static inline bool read_pressed(void) { + int level = gpio_get_level(s_b.gpio); + return s_b.active_low ? (level == 0) : (level != 0); +} + +static void button_task(void *arg) { + (void)arg; + bool pressed = false; + uint64_t press_started_us = 0; + int debounce_ticks = 0; + bool fired_long = false; + bool fired_verylong = false; + + while (1) { + bool now_pressed = read_pressed(); + + if (now_pressed != pressed) { + /* Edge detected β€” but don't trust it until DEBOUNCE_MS of + * matching reads have stacked up. Counter approach is simpler + * (and has identical effect to) a 40-ms blocking sleep. */ + debounce_ticks += POLL_MS; + if (debounce_ticks >= DEBOUNCE_MS) { + pressed = now_pressed; + debounce_ticks = 0; + if (pressed) { + press_started_us = (uint64_t)esp_timer_get_time(); + fired_long = false; + fired_verylong = false; + ESP_LOGD(TAG, "press start"); + } else { + /* Released β€” fire short if no long fired. */ + uint64_t held_ms = ((uint64_t)esp_timer_get_time() - press_started_us) / 1000ULL; + ESP_LOGI(TAG, "press end after %llu ms", (unsigned long long)held_ms); + if (!fired_long && !fired_verylong && held_ms < LONG_MS && held_ms >= 50) { + if (s_b.cb) s_b.cb(BUTTON_PRESS_SHORT); + } + } + } + } else { + debounce_ticks = 0; + /* While held, fire long/verylong on threshold crossing. */ + if (pressed && !fired_long) { + uint64_t held_ms = ((uint64_t)esp_timer_get_time() - press_started_us) / 1000ULL; + if (held_ms >= LONG_MS) { + fired_long = true; + ESP_LOGI(TAG, "long press @ %llu ms", (unsigned long long)held_ms); + if (s_b.cb) s_b.cb(BUTTON_PRESS_LONG); + } + } + if (pressed && fired_long && !fired_verylong) { + uint64_t held_ms = ((uint64_t)esp_timer_get_time() - press_started_us) / 1000ULL; + if (held_ms >= VERYLONG_MS) { + fired_verylong = true; + ESP_LOGI(TAG, "very-long press @ %llu ms", (unsigned long long)held_ms); + if (s_b.cb) s_b.cb(BUTTON_PRESS_VERYLONG); + } + } + } + + vTaskDelay(pdMS_TO_TICKS(POLL_MS)); + } +} + +esp_err_t button_init(uint8_t gpio_num, bool active_low, button_event_cb_t cb) { + s_b.gpio = gpio_num; + s_b.active_low = active_low; + s_b.cb = cb; + + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << gpio_num, + .mode = GPIO_MODE_INPUT, + .pull_up_en = active_low ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE, + .pull_down_en = active_low ? GPIO_PULLDOWN_DISABLE : GPIO_PULLDOWN_ENABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_config(%u) failed: 0x%x", gpio_num, err); + return err; + } + + BaseType_t ok = xTaskCreate(button_task, "button", 2048, NULL, 4, NULL); + if (ok != pdPASS) return ESP_ERR_NO_MEM; + + ESP_LOGI(TAG, "button on GPIO %u (active_%s)", gpio_num, active_low ? "low" : "high"); + return ESP_OK; +} diff --git a/firmware/components/button/include/button.h b/firmware/components/button/include/button.h new file mode 100644 index 0000000..3e68a8a --- /dev/null +++ b/firmware/components/button/include/button.h @@ -0,0 +1,43 @@ +#pragma once + +/* + * AmbiSense v6 β€” physical button driver. + * + * Single-button polling driver designed for the C3 SuperMini's BOOT + * button (GPIO 9, active-low, internal pull-up). Drives three callbacks: + * + * short β€” press < 1 s. Could be used for "toggle LEDs" later; unused + * in v6.0. + * long β€” press >= 3 s. Wired to mesh_open_pairing(): the standard + * "physically pair this device" gesture. + * verylong β€” press >= 10 s. Wired to factory reset (TODO; not in v6.0). + * + * Polled at 50 Hz from a tiny dedicated task. Polling beats GPIO ISRs for + * mechanical buttons because the state machine inherently debounces β€” a + * spurious 1 ms blip simply doesn't survive across two 20 ms samples. + */ + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + BUTTON_PRESS_SHORT = 1, + BUTTON_PRESS_LONG = 2, /* >= 3 s */ + BUTTON_PRESS_VERYLONG = 3, /* >= 10 s */ +} button_event_t; + +typedef void (*button_event_cb_t)(button_event_t evt); + +/* Initialize and start the polling task. `gpio_num` is the button pin; + * `active_low` true means the button reads 0 when pressed (default for + * BOOT-style pull-up wiring). */ +esp_err_t button_init(uint8_t gpio_num, bool active_low, button_event_cb_t cb); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/led_engine/CMakeLists.txt b/firmware/components/led_engine/CMakeLists.txt new file mode 100644 index 0000000..9f4be3b --- /dev/null +++ b/firmware/components/led_engine/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "led_engine.c" + INCLUDE_DIRS "include" + REQUIRES motion settings esp_timer log freertos +) diff --git a/firmware/components/led_engine/idf_component.yml b/firmware/components/led_engine/idf_component.yml new file mode 100644 index 0000000..d57b77d --- /dev/null +++ b/firmware/components/led_engine/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + espressif/led_strip: "^3.0.0" diff --git a/firmware/components/led_engine/include/led_engine.h b/firmware/components/led_engine/include/led_engine.h new file mode 100644 index 0000000..9dc7a10 --- /dev/null +++ b/firmware/components/led_engine/include/led_engine.h @@ -0,0 +1,44 @@ +#pragma once + +/* + * AmbiSense v6 β€” LED engine. + * + * Drives a WS2812(B) strip via the ESP-IDF managed-component `led_strip` + * (RMT-backed, non-blocking refresh). Renders one of 11 visual modes + * ported from v5 led_controller.cpp at 60 Hz, consuming the smoothed + * target_t from components/motion. + */ + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Mode IDs match v5 (config.h:57-67) so any external integration that + * spoke the v5 numeric mode keeps working. */ +typedef enum { + LED_MODE_STANDARD = 0, + LED_MODE_RAINBOW = 1, + LED_MODE_COLOR_WAVE = 2, + LED_MODE_BREATHING = 3, + LED_MODE_SOLID = 4, + LED_MODE_COMET = 5, + LED_MODE_PULSE = 6, + LED_MODE_FIRE = 7, + LED_MODE_THEATER_CHASE = 8, + LED_MODE_DUAL_SCAN = 9, + LED_MODE_MOTION_PARTICLES = 10, +} led_mode_t; + +esp_err_t led_engine_init(uint8_t data_gpio); + +/* Force a parameter reload from NVS (called when /api/settings POST changes + * any LED key). */ +void led_engine_reload(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/led_engine/led_engine.c b/firmware/components/led_engine/led_engine.c new file mode 100644 index 0000000..9c990bc --- /dev/null +++ b/firmware/components/led_engine/led_engine.c @@ -0,0 +1,400 @@ +#include "led_engine.h" + +#include +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "led_strip.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "motion.h" +#include "settings.h" + +static const char *TAG = "led_engine"; + +#define MAX_LEDS 1500 +#define DEFAULT_LEDS 30 +#define MAX_PARTICLES 50 + +typedef struct { uint8_t r, g, b; } rgb_t; +typedef struct { float pos; float vel; float bright; bool active; } particle_t; + +static struct { + led_strip_handle_t strip; + uint8_t data_gpio; + uint16_t count; + uint8_t brightness; + rgb_t base; + led_mode_t mode; + uint16_t span; + int16_t center_shift; + uint8_t trail; + bool dir_light; + bool bg_mode; + uint8_t effect_speed; + uint8_t effect_intensity; + uint16_t min_cm, max_cm; + + /* Mode-private state */ + uint32_t step; /* effect frame counter */ + uint8_t *fire_heat; + particle_t particles[MAX_PARTICLES]; + rgb_t *prev_frame; /* for COMET tail fade */ +} s_led; + +/* ------ utilities -------------------------------------------------- */ + +static rgb_t dim(rgb_t c, float k) { + return (rgb_t){(uint8_t)(c.r * k), (uint8_t)(c.g * k), (uint8_t)(c.b * k)}; +} + +static rgb_t wheel(uint8_t pos) { + /* 0..255 β†’ rainbow (port of v5 wheelColor) */ + if (pos < 85) return (rgb_t){ pos*3, 255 - pos*3, 0 }; + if (pos < 170) { pos -= 85; return (rgb_t){ 255 - pos*3, 0, pos*3 }; } + { pos -= 170; return (rgb_t){ 0, pos*3, 255 - pos*3 }; } +} + +static int distance_to_start_led(int distance_cm) { + if (s_led.max_cm <= s_led.min_cm) return 0; + int range = s_led.max_cm - s_led.min_cm; + int span_pixels = s_led.span > 0 ? s_led.span : 30; + int avail = (int)s_led.count - span_pixels; + if (avail < 0) avail = 0; + int rel = distance_cm - s_led.min_cm; + if (rel < 0) rel = 0; + if (rel > range) rel = range; + int p = (rel * avail) / range; + p += s_led.center_shift; + if (p < 0) p = 0; + if (p > avail) p = avail; + return p; +} + +static void clear_all(void) { + led_strip_clear(s_led.strip); +} + +static inline void set_pixel(int i, rgb_t c) { + if (i < 0 || i >= (int)s_led.count) return; + /* Apply global brightness scaling here so modes don't need to. */ + float k = s_led.brightness / 255.0f; + led_strip_set_pixel(s_led.strip, i, (uint8_t)(c.r * k), + (uint8_t)(c.g * k), + (uint8_t)(c.b * k)); +} + +/* ------ modes ------------------------------------------------------ */ + +static void mode_standard(int start) { + int span = s_led.span > 0 ? s_led.span : 30; + if (s_led.bg_mode) { + rgb_t bg = dim(s_led.base, 0.05f); + for (int i = 0; i < s_led.count; ++i) set_pixel(i, bg); + } else { + clear_all(); + } + for (int i = start; i < start + span && i < s_led.count; ++i) { + set_pixel(i, s_led.base); + } + if (s_led.dir_light && s_led.trail > 0) { + int trail = s_led.trail; + for (int i = 0; i < trail && (start - 1 - i) >= 0; ++i) { + float k = 1.0f - (float)(i + 1) / (trail + 1); + set_pixel(start - 1 - i, dim(s_led.base, k)); + } + } +} + +static void mode_rainbow(void) { + uint8_t step = (uint8_t)(s_led.step * (s_led.effect_speed > 0 ? s_led.effect_speed/12 : 1)); + for (int i = 0; i < s_led.count; ++i) { + set_pixel(i, wheel((uint8_t)((i * 256 / s_led.count + step) & 0xFF))); + } +} + +static void mode_color_wave(void) { + float t = s_led.step * 0.05f * (s_led.effect_speed/50.0f + 0.5f); + float intensity = s_led.effect_intensity / 100.0f; + for (int i = 0; i < s_led.count; ++i) { + float phase = (float)i / s_led.count * 6.28318f + t; + float k = (sinf(phase) + 1.0f) * 0.5f; + k = k * intensity + (1.0f - intensity) * 0.4f; + rgb_t c = wheel((uint8_t)((i * 256 / s_led.count + s_led.step) & 0xFF)); + set_pixel(i, dim(c, k)); + } +} + +static void mode_breathing(void) { + float speed = s_led.effect_speed / 50.0f; + float k = (sinf(s_led.step * 0.05f * speed) + 1.0f) * 0.5f; + k = 0.1f + 0.9f * k * (s_led.effect_intensity / 100.0f); + for (int i = 0; i < s_led.count; ++i) set_pixel(i, dim(s_led.base, k)); +} + +static void mode_solid(void) { + for (int i = 0; i < s_led.count; ++i) set_pixel(i, s_led.base); +} + +static void mode_comet(int start) { + if (!s_led.prev_frame) return; + /* Fade entire previous frame */ + float fade = 0.85f - (s_led.effect_speed / 1000.0f); + if (fade < 0.7f) fade = 0.7f; + for (int i = 0; i < s_led.count; ++i) { + s_led.prev_frame[i] = dim(s_led.prev_frame[i], fade); + set_pixel(i, s_led.prev_frame[i]); + } + /* Bright head at start */ + int head_w = 3; + for (int i = 0; i < head_w; ++i) { + int p = start + i; + if (p >= 0 && p < s_led.count) { + s_led.prev_frame[p] = s_led.base; + set_pixel(p, s_led.base); + } + } +} + +static void mode_pulse(int start) { + clear_all(); + float intensity = s_led.effect_intensity / 100.0f; + int max_radius = s_led.count / 4; + for (int p = 0; p < 3; ++p) { + float phase = (float)p * 2.0f; + float r = fmodf(s_led.step * 0.2f + phase, max_radius); + for (int off = -(int)r; off <= (int)r; ++off) { + float dist = fabsf(off / r); + float k = (1.0f - dist * dist) * intensity; + int idx = start + off; + if (idx >= 0 && idx < s_led.count) { + rgb_t c = dim(s_led.base, k); + set_pixel(idx, c); + } + } + } +} + +static uint8_t qadd8(uint8_t a, uint8_t b) { unsigned s = a + b; return s > 255 ? 255 : s; } +static uint8_t qsub8(uint8_t a, uint8_t b) { return a > b ? a - b : 0; } + +static void mode_fire(void) { + if (!s_led.fire_heat) return; + int n = s_led.count; + /* Cool */ + int cooling = 55; + for (int i = 0; i < n; ++i) { + s_led.fire_heat[i] = qsub8(s_led.fire_heat[i], (uint8_t)((rand() % cooling) + 2)); + } + /* Drift up */ + for (int k = n - 1; k >= 2; --k) { + s_led.fire_heat[k] = (uint8_t)((s_led.fire_heat[k-1] + s_led.fire_heat[k-2] + s_led.fire_heat[k-2]) / 3); + } + /* Sparks */ + int sparking = 120; + if ((rand() & 0xFF) < sparking) { + int y = rand() % 7; + s_led.fire_heat[y] = qadd8(s_led.fire_heat[y], (uint8_t)(160 + (rand() % 96))); + } + /* Render */ + for (int i = 0; i < n; ++i) { + uint8_t t = (uint8_t)((s_led.fire_heat[i] * 191) / 255); + rgb_t c; + if (t < 64) c = (rgb_t){ t * 4, 0, 0 }; + else if (t < 128) c = (rgb_t){ 255, (t - 64) * 4, 0 }; + else c = (rgb_t){ 255, 255, (t - 128) * 4 }; + set_pixel(i, c); + } +} + +static void mode_theater_chase(void) { + int gap = 3; + int phase = s_led.step % gap; + for (int i = 0; i < s_led.count; ++i) { + if ((i + phase) % gap == 0) set_pixel(i, s_led.base); + else set_pixel(i, (rgb_t){0,0,0}); + } +} + +static void mode_dual_scan(int start) { + clear_all(); + int scan_w = 4; + int p1 = (s_led.step) % s_led.count; + int p2 = (s_led.count - 1 - (s_led.step % s_led.count)); + for (int i = -scan_w; i <= scan_w; ++i) { + float k = 1.0f - (float)abs(i) / scan_w; + if (p1 + i >= 0 && p1 + i < s_led.count) set_pixel(p1 + i, dim(s_led.base, k)); + if (p2 + i >= 0 && p2 + i < s_led.count) set_pixel(p2 + i, dim((rgb_t){255 - s_led.base.r, 255 - s_led.base.g, 255 - s_led.base.b}, k)); + } + /* Brighter point at the active distance */ + if (start >= 0 && start < s_led.count) set_pixel(start, (rgb_t){255, 255, 255}); +} + +static void mode_motion_particles(int start) { + /* Decay all */ + for (int i = 0; i < MAX_PARTICLES; ++i) { + if (!s_led.particles[i].active) continue; + s_led.particles[i].pos += s_led.particles[i].vel; + s_led.particles[i].bright -= 0.02f; + if (s_led.particles[i].bright <= 0 || + s_led.particles[i].pos < 0 || + s_led.particles[i].pos >= s_led.count) { + s_led.particles[i].active = false; + } + } + /* Spawn new particles around `start`. */ + int to_spawn = 1 + (s_led.effect_intensity / 25); + for (int n = 0; n < to_spawn; ++n) { + for (int i = 0; i < MAX_PARTICLES; ++i) { + if (s_led.particles[i].active) continue; + s_led.particles[i].active = true; + s_led.particles[i].pos = (float)start + (rand() % 7) - 3; + s_led.particles[i].vel = ((rand() % 200) - 100) / 100.0f * (s_led.effect_speed/100.0f + 0.3f); + s_led.particles[i].bright = 1.0f; + break; + } + } + /* Render */ + clear_all(); + for (int i = 0; i < MAX_PARTICLES; ++i) { + if (!s_led.particles[i].active) continue; + int p = (int)s_led.particles[i].pos; + if (p >= 0 && p < s_led.count) { + set_pixel(p, dim(s_led.base, s_led.particles[i].bright)); + } + } +} + +/* ------ main render task ------------------------------------------- */ + +static void render_task(void *arg) { + (void)arg; + while (1) { + /* Honor the global "system enabled" flag β€” when off, paint black + * and refresh once per frame rather than skipping (so a disabled + * strip stays dark even if FreeRTOS switches in mid-frame). */ + uint8_t sys_en = 1; + settings_get_u8("sys", "enabled", &sys_en); + if (!sys_en) { + for (int i = 0; i < s_led.count; ++i) set_pixel(i, (rgb_t){0,0,0}); + led_strip_refresh(s_led.strip); + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + + target_t t = {0}; + motion_get(&t); + int start = distance_to_start_led(t.distance_cm); + + switch (s_led.mode) { + case LED_MODE_STANDARD: mode_standard(start); break; + case LED_MODE_RAINBOW: mode_rainbow(); break; + case LED_MODE_COLOR_WAVE: mode_color_wave(); break; + case LED_MODE_BREATHING: mode_breathing(); break; + case LED_MODE_SOLID: mode_solid(); break; + case LED_MODE_COMET: mode_comet(start); break; + case LED_MODE_PULSE: mode_pulse(start); break; + case LED_MODE_FIRE: mode_fire(); break; + case LED_MODE_THEATER_CHASE: mode_theater_chase(); break; + case LED_MODE_DUAL_SCAN: mode_dual_scan(start); break; + case LED_MODE_MOTION_PARTICLES: mode_motion_particles(start); break; + default: mode_standard(start); break; + } + + led_strip_refresh(s_led.strip); + s_led.step++; + vTaskDelay(pdMS_TO_TICKS(16)); /* ~60 Hz */ + } +} + +/* ------ init / reload ---------------------------------------------- */ + +static void load_settings_from_nvs(void) { + uint32_t v; + uint8_t b; + v = DEFAULT_LEDS; settings_get_u32("led", "count", &v); + if (v < 1) v = DEFAULT_LEDS; + if (v > MAX_LEDS) v = MAX_LEDS; + s_led.count = (uint16_t)v; + + b = 80; settings_get_u8("led", "br", &b); + s_led.brightness = b; + + b = 255; settings_get_u8("led", "r", &b); s_led.base.r = b; + b = 255; settings_get_u8("led", "g", &b); s_led.base.g = b; + b = 255; settings_get_u8("led", "b", &b); s_led.base.b = b; + + b = 0; settings_get_u8("led", "mode", &b); s_led.mode = b; + v = 30; settings_get_u32("led", "span", &v); s_led.span = v; + + int32_t i32 = 0; settings_get_i32("led", "ctr", &i32); s_led.center_shift = i32; + b = 0; settings_get_u8("led", "trail", &b); s_led.trail = b; + b = 1; settings_get_u8("led", "dirlt", &b); s_led.dir_light = b != 0; + b = 0; settings_get_u8("led", "bg", &b); s_led.bg_mode = b != 0; + b = 50; settings_get_u8("led", "espd", &b); s_led.effect_speed = b; + b = 50; settings_get_u8("led", "eint", &b); s_led.effect_intensity = b; + + v = 30; settings_get_u32("dist", "min", &v); s_led.min_cm = v; + v = 300; settings_get_u32("dist", "max", &v); s_led.max_cm = v; +} + +void led_engine_reload(void) { + uint16_t prev_count = s_led.count; + load_settings_from_nvs(); + if (s_led.count != prev_count) { + ESP_LOGI(TAG, "LED count changed %u β†’ %u; reallocating buffers", prev_count, s_led.count); + if (s_led.fire_heat) { free(s_led.fire_heat); s_led.fire_heat = NULL; } + if (s_led.prev_frame){ free(s_led.prev_frame); s_led.prev_frame = NULL; } + s_led.fire_heat = calloc(s_led.count, 1); + s_led.prev_frame = calloc(s_led.count, sizeof(rgb_t)); + /* Strip resize requires re-init via the led_strip API. */ + led_strip_del(s_led.strip); + led_strip_config_t scfg = { + .strip_gpio_num = s_led.data_gpio, + .max_leds = s_led.count, + .led_model = LED_MODEL_WS2812, + .color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB, + .flags.invert_out = false, + }; + led_strip_rmt_config_t rcfg = { .clk_src = RMT_CLK_SRC_DEFAULT, .resolution_hz = 10*1000*1000, .flags.with_dma = false }; + ESP_ERROR_CHECK(led_strip_new_rmt_device(&scfg, &rcfg, &s_led.strip)); + } +} + +esp_err_t led_engine_init(uint8_t data_gpio) { + s_led.data_gpio = data_gpio; + load_settings_from_nvs(); + + s_led.fire_heat = calloc(s_led.count, 1); + s_led.prev_frame = calloc(s_led.count, sizeof(rgb_t)); + + led_strip_config_t scfg = { + .strip_gpio_num = data_gpio, + .max_leds = s_led.count, + .led_model = LED_MODEL_WS2812, + .color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB, + .flags.invert_out = false, + }; + led_strip_rmt_config_t rcfg = { + .clk_src = RMT_CLK_SRC_DEFAULT, + .resolution_hz = 10 * 1000 * 1000, + .flags.with_dma = false, + }; + esp_err_t err = led_strip_new_rmt_device(&scfg, &rcfg, &s_led.strip); + if (err != ESP_OK) { + ESP_LOGE(TAG, "led_strip_new_rmt_device(GPIO%u): 0x%x", data_gpio, err); + return err; + } + led_strip_clear(s_led.strip); + led_strip_refresh(s_led.strip); + + ESP_LOGI(TAG, "LED engine: %u LEDs on GPIO %u, mode=%d, br=%u, base=(%u,%u,%u)", + s_led.count, data_gpio, s_led.mode, s_led.brightness, + s_led.base.r, s_led.base.g, s_led.base.b); + + xTaskCreate(render_task, "led_render", 6144, NULL, 4, NULL); + return ESP_OK; +} diff --git a/firmware/components/mesh/CMakeLists.txt b/firmware/components/mesh/CMakeLists.txt new file mode 100644 index 0000000..f464d04 --- /dev/null +++ b/firmware/components/mesh/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "mesh.c" + INCLUDE_DIRS "include" + REQUIRES settings motion topology esp_wifi esp_event esp_timer log freertos +) diff --git a/firmware/components/mesh/include/mesh.h b/firmware/components/mesh/include/mesh.h new file mode 100644 index 0000000..d837d2d --- /dev/null +++ b/firmware/components/mesh/include/mesh.h @@ -0,0 +1,98 @@ +#pragma once + +/* + * AmbiSense v6 β€” ESP-NOW peer mesh. + * + * Each device: + * 1. Broadcasts its smoothed reading at 5 Hz on the active Wi-Fi channel. + * 2. Listens for peer broadcasts and runs the same fusion algorithm on + * the merged stream β€” every device arrives at the same active position. + * 3. Renders only its own LED segment (per topology_my_segment()). + * + * Coordinator election: lowest-MAC peer claims the role and serves as the + * authority for topology gossip and the canonical web UI host advertised + * via mDNS. Re-runs whenever a peer is lost. + * + * Pairing window: 30 s after netmgr boot OR while user holds BOOT button. + * During the window, broadcast PAIR packets are accepted and added to the + * peer list. After the window closes, only known peers' packets are + * accepted (mitigates passive sniffing of neighbors' devices). + */ + +#include +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define MESH_MAX_PEERS 5 /* including self in some accounting */ +#define MESH_MAC_LEN 6 +#define MESH_TIMEOUT_MS 10000 + +typedef enum { + MESH_FUSE_MOST_RECENT = 0, + MESH_FUSE_SLAVE_FIRST = 1, /* prefer non-coordinator readings */ + MESH_FUSE_MASTER_FIRST = 2, /* prefer coordinator's reading */ + MESH_FUSE_ZONE_BASED = 3, /* per-segment dist_min/max windows */ +} mesh_fusion_t; + +typedef struct { + uint8_t mac[MESH_MAC_LEN]; + int16_t distance_cm; + int8_t direction; + uint8_t energy; + int8_t rssi; + uint64_t last_seen_us; + bool healthy; /* false if last_seen > MESH_TIMEOUT_MS */ +} mesh_peer_t; + +esp_err_t mesh_init(void); + +/* Open a 30 s pairing window during which new peers' broadcasts are + * accepted. While the window is open this device also broadcasts a + * MSG_PAIR beacon every second; any peer that receives it auto-opens + * its own window (asymmetric pairing). */ +esp_err_t mesh_open_pairing(void); + +/* Window introspection β€” used by the UI to render a live countdown. */ +bool mesh_in_pairing(void); +uint32_t mesh_pairing_remaining_ms(void); + +/* Send a unicast MSG_IDENTIFY to `mac`. The recipient blinks its status + * LED at 10 Hz for ~5 s so the user can physically locate it. */ +esp_err_t mesh_identify(const uint8_t mac[6]); + +/* Events the mesh layer emits to its consumer (main.c wires this so the + * status LED can react). Currently: peer joined, identify-me received. */ +typedef enum { + MESH_EVT_PEER_JOINED = 1, + MESH_EVT_IDENTIFY_REQUESTED = 2, + MESH_EVT_PAIRING_OPENED = 3, + MESH_EVT_PAIRING_CLOSED = 4, +} mesh_event_t; +typedef void (*mesh_event_cb_t)(mesh_event_t evt, const uint8_t mac[6]); +void mesh_set_event_cb(mesh_event_cb_t cb); + +/* Snapshot of currently-known peers (live + stale). Returns count. */ +size_t mesh_peers_snapshot(mesh_peer_t *out, size_t max); + +/* Fused active-target estimate from local + peer readings. */ +typedef struct { int16_t distance_cm; int8_t direction; bool present; uint8_t energy; } mesh_fused_t; +void mesh_get_fused(mesh_fused_t *out); + +/* Are we the coordinator (lowest MAC seen)? */ +bool mesh_is_coordinator(void); + +/* Set fusion policy. Persisted to NVS topo.fuse. */ +esp_err_t mesh_set_fusion(mesh_fusion_t mode); +mesh_fusion_t mesh_get_fusion(void); + +/* Force a topology gossip after a local change. */ +void mesh_gossip_topology(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/mesh/mesh.c b/firmware/components/mesh/mesh.c new file mode 100644 index 0000000..ac183b5 --- /dev/null +++ b/firmware/components/mesh/mesh.c @@ -0,0 +1,464 @@ +#include "mesh.h" + +#include +#include + +#include "esp_log.h" +#include "esp_now.h" +#include "esp_wifi.h" +#include "esp_mac.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" + +#include "settings.h" +#include "motion.h" +#include "topology.h" + +static const char *TAG = "mesh"; + +#define MSG_TARGET 1 +#define MSG_HEARTBEAT 2 +#define MSG_GOSSIP 3 +#define MSG_CHAN_ANN 4 +#define MSG_PAIR 5 /* sent every 1 s while pairing window open */ +#define MSG_IDENTIFY 6 /* unicast: payload extends with target_mac */ + +#define MESH_MAGIC 0xA61B + +#define PAIRING_WINDOW_MS 30000 +#define COORD_HYSTERESIS_US (5ULL * 1000ULL * 1000ULL) /* 5 s before role flip */ + +typedef struct __attribute__((packed)) { + uint16_t magic; + uint8_t msg_type; + uint8_t device_idx; + uint16_t config_version; + int16_t distance_cm; + int8_t direction; + uint8_t energy; + int16_t x_cm; + int16_t y_cm; + int16_t velocity_cms; + uint8_t flags; /* bit0=present, bit1=is_coordinator */ + uint8_t reserved; + uint64_t ts_us; +} peer_msg_t; + +static const uint8_t BCAST_MAC[6] = { 0xff,0xff,0xff,0xff,0xff,0xff }; + +static struct { + uint8_t my_mac[MESH_MAC_LEN]; + bool is_coordinator; + uint64_t pairing_until_us; + mesh_peer_t peers[MESH_MAX_PEERS]; + size_t peer_count; + SemaphoreHandle_t lock; + bool inited; + mesh_fusion_t fusion; + uint16_t last_gossip_version; + + /* Coordinator hysteresis: when the elected role disagrees with our + * current role, we wait COORD_HYSTERESIS_US of consistent disagreement + * before flipping. Prevents flap from a single dropped packet. */ + uint64_t coord_pending_since_us; + + /* Last time we emitted a MSG_PAIR beacon. */ + uint64_t last_pair_beacon_us; + + mesh_event_cb_t event_cb; +} s_m; + +/* ---- helpers ---- */ + +static int peer_idx_locked(const uint8_t mac[6]) { + for (size_t i = 0; i < s_m.peer_count; ++i) { + if (memcmp(s_m.peers[i].mac, mac, 6) == 0) return (int)i; + } + return -1; +} + +static bool mac_lt(const uint8_t a[6], const uint8_t b[6]) { + return memcmp(a, b, 6) < 0; +} + +static bool in_pairing_window(void) { + return (uint64_t)esp_timer_get_time() < s_m.pairing_until_us; +} + +static void recompute_coordinator_locked(void) { + /* Coordinator = lowest-MAC peer that is currently healthy (or self). + * Hysteresis: once an election outcome differs from our current role + * we record the time and only flip once the outcome has been stable + * for COORD_HYSTERESIS_US. This prevents a single dropped heartbeat + * (which marks a peer "stale" for one tick) from causing two devices + * to briefly both believe they are the coordinator. */ + uint8_t best[6]; + memcpy(best, s_m.my_mac, 6); + uint64_t now = (uint64_t)esp_timer_get_time(); + for (size_t i = 0; i < s_m.peer_count; ++i) { + if (!s_m.peers[i].healthy) continue; + if (now - s_m.peers[i].last_seen_us > (uint64_t)MESH_TIMEOUT_MS * 1000ULL) continue; + if (mac_lt(s_m.peers[i].mac, best)) memcpy(best, s_m.peers[i].mac, 6); + } + bool desired = (memcmp(best, s_m.my_mac, 6) == 0); + + if (desired == s_m.is_coordinator) { + s_m.coord_pending_since_us = 0; + return; + } + if (s_m.coord_pending_since_us == 0) { + s_m.coord_pending_since_us = now; + ESP_LOGI(TAG, "Coordinator change pending β†’ %s (5 s hysteresis)", + desired ? "this device" : "peer"); + return; + } + if (now - s_m.coord_pending_since_us >= COORD_HYSTERESIS_US) { + s_m.is_coordinator = desired; + s_m.coord_pending_since_us = 0; + ESP_LOGI(TAG, "Coordinator role: %s", s_m.is_coordinator ? "this device" : "peer"); + } +} + +static void add_peer_locked(const uint8_t mac[6]) { + if (peer_idx_locked(mac) >= 0) return; + if (s_m.peer_count >= MESH_MAX_PEERS) { + ESP_LOGW(TAG, "Peer table full; rejecting new peer"); + return; + } + /* Add to esp_now peer list as well so unicast works. */ + esp_now_peer_info_t info = {0}; + memcpy(info.peer_addr, mac, 6); + info.channel = 0; + info.ifidx = WIFI_IF_AP; /* mirror our broadcast channel via AP */ + info.encrypt = false; + esp_now_add_peer(&info); + + memcpy(s_m.peers[s_m.peer_count].mac, mac, 6); + s_m.peers[s_m.peer_count].last_seen_us = (uint64_t)esp_timer_get_time(); + s_m.peers[s_m.peer_count].healthy = true; + s_m.peer_count++; + ESP_LOGI(TAG, "Peer added: %02x:%02x:%02x:%02x:%02x:%02x (count=%u)", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], (unsigned)s_m.peer_count); + + /* Notify upper layer outside the lock would be cleaner, but our cb is + * documented as fast-callable and the lock is uncontended at this + * point β€” fire it inline. */ + if (s_m.event_cb) s_m.event_cb(MESH_EVT_PEER_JOINED, mac); +} + +/* ---- ESP-NOW recv callback ---- */ + +static void on_recv(const esp_now_recv_info_t *info, const uint8_t *data, int len) { + if (len < (int)sizeof(peer_msg_t)) return; + const peer_msg_t *m = (const peer_msg_t *)data; + if (m->magic != MESH_MAGIC) return; + if (memcmp(info->src_addr, s_m.my_mac, 6) == 0) return; /* our own */ + + /* IDENTIFY is special: handle outside the lock so we can fire the + * status-LED reaction without holding the mesh mutex while a status + * LED API runs. We still snapshot whether we're the target. */ + bool identify_for_me = false; + if (m->msg_type == MSG_IDENTIFY && len >= (int)sizeof(peer_msg_t) + 6) { + const uint8_t *target = data + sizeof(peer_msg_t); + identify_for_me = (memcmp(target, s_m.my_mac, 6) == 0); + } + + /* Asymmetric pairing: receiving a MSG_PAIR from anyone reopens our + * own window. So clicking "Pair" on either device pulls the other in + * β€” no need to tap both physical buttons. */ + bool opened_window = false; + if (m->msg_type == MSG_PAIR && !in_pairing_window()) { + s_m.pairing_until_us = (uint64_t)esp_timer_get_time() + (uint64_t)PAIRING_WINDOW_MS * 1000ULL; + opened_window = true; + ESP_LOGI(TAG, "Pairing auto-opened by peer beacon"); + } + + xSemaphoreTake(s_m.lock, portMAX_DELAY); + int idx = peer_idx_locked(info->src_addr); + if (idx < 0) { + if (in_pairing_window() || m->msg_type == MSG_PAIR) { + add_peer_locked(info->src_addr); + idx = peer_idx_locked(info->src_addr); + } else { + xSemaphoreGive(s_m.lock); + return; + } + } + if (idx >= 0) { + s_m.peers[idx].distance_cm = m->distance_cm; + s_m.peers[idx].direction = m->direction; + s_m.peers[idx].energy = m->energy; + s_m.peers[idx].rssi = info->rx_ctrl ? info->rx_ctrl->rssi : 0; + s_m.peers[idx].last_seen_us = (uint64_t)esp_timer_get_time(); + s_m.peers[idx].healthy = true; + } + + if (m->msg_type == MSG_GOSSIP) { + /* Topology gossip: payload extends past peer_msg_t with a topology_t. */ + if (len >= (int)(sizeof(peer_msg_t) + sizeof(topology_t))) { + const topology_t *remote = (const topology_t *)(data + sizeof(peer_msg_t)); + const topology_t *local = topology_get(); + if (remote->version > local->version) { + ESP_LOGI(TAG, "Accepting gossiped topology v%u (was v%u)", + remote->version, local->version); + topology_set(remote, false); /* don't re-gossip */ + } + } + } + + recompute_coordinator_locked(); + xSemaphoreGive(s_m.lock); + + /* Fire deferred events outside the lock. */ + if (opened_window && s_m.event_cb) s_m.event_cb(MESH_EVT_PAIRING_OPENED, s_m.my_mac); + if (identify_for_me && s_m.event_cb) s_m.event_cb(MESH_EVT_IDENTIFY_REQUESTED, info->src_addr); +} + +/* ---- broadcast task: 5 Hz target broadcast, 1 Hz pair beacon ---- */ + +static void make_msg(peer_msg_t *out, uint8_t type) { + target_t t; + motion_get(&t); + memset(out, 0, sizeof(*out)); + out->magic = MESH_MAGIC; + out->msg_type = type; + out->config_version = topology_get()->version; + out->distance_cm = t.distance_cm; + out->direction = t.direction; + out->energy = t.energy; + out->flags = (t.present ? 1 : 0) | (s_m.is_coordinator ? 2 : 0); + out->ts_us = (uint64_t)esp_timer_get_time(); +} + +static void broadcast_task(void *arg) { + (void)arg; + bool was_pairing = false; + while (1) { + peer_msg_t msg; + make_msg(&msg, MSG_TARGET); + esp_now_send(BCAST_MAC, (const uint8_t *)&msg, sizeof(msg)); + + /* MSG_PAIR beacon at 1 Hz while window open. Tracks state edges so + * the event callback is fired exactly once on open and on close. */ + bool pairing_now = in_pairing_window(); + if (pairing_now) { + uint64_t now = (uint64_t)esp_timer_get_time(); + if (now - s_m.last_pair_beacon_us >= 1000000ULL) { + peer_msg_t pm; + make_msg(&pm, MSG_PAIR); + esp_now_send(BCAST_MAC, (const uint8_t *)&pm, sizeof(pm)); + s_m.last_pair_beacon_us = now; + } + } + if (was_pairing && !pairing_now) { + ESP_LOGI(TAG, "Pairing window closed"); + if (s_m.event_cb) s_m.event_cb(MESH_EVT_PAIRING_CLOSED, s_m.my_mac); + } + was_pairing = pairing_now; + + /* Mark stale peers + recompute coordinator + step to next tick. */ + xSemaphoreTake(s_m.lock, portMAX_DELAY); + uint64_t now = (uint64_t)esp_timer_get_time(); + for (size_t i = 0; i < s_m.peer_count; ++i) { + uint64_t age = now - s_m.peers[i].last_seen_us; + bool was_h = s_m.peers[i].healthy; + s_m.peers[i].healthy = age < ((uint64_t)MESH_TIMEOUT_MS * 1000ULL); + if (was_h != s_m.peers[i].healthy) { + ESP_LOGW(TAG, "Peer %02x:%02x:%02x:%02x:%02x:%02x is now %s", + s_m.peers[i].mac[0], s_m.peers[i].mac[1], s_m.peers[i].mac[2], + s_m.peers[i].mac[3], s_m.peers[i].mac[4], s_m.peers[i].mac[5], + s_m.peers[i].healthy ? "healthy" : "STALE"); + } + } + recompute_coordinator_locked(); + xSemaphoreGive(s_m.lock); + + vTaskDelay(pdMS_TO_TICKS(200)); /* 5 Hz */ + } +} + +/* ---- public API ---- */ + +esp_err_t mesh_init(void) { + if (s_m.inited) return ESP_OK; + s_m.lock = xSemaphoreCreateMutex(); + esp_read_mac(s_m.my_mac, ESP_MAC_WIFI_STA); + + /* ESP-NOW must be initialized after Wi-Fi is started. */ + esp_err_t err = esp_now_init(); + if (err != ESP_OK) { ESP_LOGE(TAG, "esp_now_init: 0x%x", err); return err; } + + /* Broadcast peer is implicit on most IDF versions; explicit add for safety. */ + esp_now_peer_info_t bcast = {0}; + memcpy(bcast.peer_addr, BCAST_MAC, 6); + bcast.channel = 0; /* current */ + bcast.ifidx = WIFI_IF_AP; + bcast.encrypt = false; + esp_now_add_peer(&bcast); + + esp_now_register_recv_cb(on_recv); + + /* Restore fusion mode from NVS. */ + uint8_t f = MESH_FUSE_MOST_RECENT; + settings_get_u8("topo", "fuse", &f); + s_m.fusion = (mesh_fusion_t)f; + + /* Open a pairing window at boot β€” first 30 s lets uninitialized peers + * find each other without manual button presses. */ + s_m.pairing_until_us = (uint64_t)esp_timer_get_time() + (uint64_t)PAIRING_WINDOW_MS * 1000ULL; + s_m.is_coordinator = true; /* Until proven otherwise by a lower-MAC peer. */ + + xTaskCreate(broadcast_task, "mesh_bcast", 4096, NULL, 5, NULL); + + ESP_LOGI(TAG, "Mesh up. MAC %02x:%02x:%02x:%02x:%02x:%02x; pairing window 30 s", + s_m.my_mac[0], s_m.my_mac[1], s_m.my_mac[2], + s_m.my_mac[3], s_m.my_mac[4], s_m.my_mac[5]); + s_m.inited = true; + return ESP_OK; +} + +esp_err_t mesh_open_pairing(void) { + s_m.pairing_until_us = (uint64_t)esp_timer_get_time() + (uint64_t)PAIRING_WINDOW_MS * 1000ULL; + s_m.last_pair_beacon_us = 0; /* force immediate beacon on next tick */ + ESP_LOGI(TAG, "Pairing window opened (30 s)"); + if (s_m.event_cb) s_m.event_cb(MESH_EVT_PAIRING_OPENED, s_m.my_mac); + return ESP_OK; +} + +bool mesh_in_pairing(void) { + return in_pairing_window(); +} + +uint32_t mesh_pairing_remaining_ms(void) { + uint64_t now = (uint64_t)esp_timer_get_time(); + if (now >= s_m.pairing_until_us) return 0; + return (uint32_t)((s_m.pairing_until_us - now) / 1000ULL); +} + +esp_err_t mesh_identify(const uint8_t mac[6]) { + if (!mac) return ESP_ERR_INVALID_ARG; + /* Add the peer to esp-now if we don't have it yet β€” the user may be + * identifying a topology-listed device that isn't currently a peer. */ + xSemaphoreTake(s_m.lock, portMAX_DELAY); + if (peer_idx_locked(mac) < 0) add_peer_locked(mac); + xSemaphoreGive(s_m.lock); + + uint8_t buf[sizeof(peer_msg_t) + 6]; + peer_msg_t *m = (peer_msg_t *)buf; + make_msg(m, MSG_IDENTIFY); + memcpy(buf + sizeof(peer_msg_t), mac, 6); + esp_err_t err = esp_now_send(mac, buf, sizeof(buf)); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mesh_identify: esp_now_send failed 0x%x", err); + } else { + ESP_LOGI(TAG, "Identify request sent to %02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } + return err; +} + +void mesh_set_event_cb(mesh_event_cb_t cb) { + s_m.event_cb = cb; +} + +size_t mesh_peers_snapshot(mesh_peer_t *out, size_t max) { + xSemaphoreTake(s_m.lock, portMAX_DELAY); + size_t n = s_m.peer_count < max ? s_m.peer_count : max; + memcpy(out, s_m.peers, n * sizeof(mesh_peer_t)); + xSemaphoreGive(s_m.lock); + return n; +} + +void mesh_get_fused(mesh_fused_t *out) { + if (!out) return; + target_t local; + motion_get(&local); + + xSemaphoreTake(s_m.lock, portMAX_DELAY); + uint64_t now = (uint64_t)esp_timer_get_time(); + + /* Build a list of candidate readings: local + healthy peers. */ + struct { int16_t d; int8_t dir; uint8_t en; bool from_coord; uint64_t ts; } cand[MESH_MAX_PEERS + 1]; + size_t nc = 0; + cand[nc++] = (typeof(cand[0])){ local.distance_cm, local.direction, local.energy, s_m.is_coordinator, local.ts_us }; + for (size_t i = 0; i < s_m.peer_count; ++i) { + if (!s_m.peers[i].healthy) continue; + bool peer_is_coord = mac_lt(s_m.peers[i].mac, s_m.my_mac); /* lower-MAC than us */ + cand[nc++] = (typeof(cand[0])){ s_m.peers[i].distance_cm, s_m.peers[i].direction, + s_m.peers[i].energy, peer_is_coord, s_m.peers[i].last_seen_us }; + if (nc >= sizeof(cand)/sizeof(cand[0])) break; + } + + int chosen = 0; + switch (s_m.fusion) { + case MESH_FUSE_MOST_RECENT: { + uint64_t newest = 0; + for (size_t i = 0; i < nc; ++i) if (cand[i].ts > newest) { newest = cand[i].ts; chosen = (int)i; } + break; + } + case MESH_FUSE_SLAVE_FIRST: { + int slave_pick = -1; uint64_t slave_t = 0; + for (size_t i = 0; i < nc; ++i) if (!cand[i].from_coord && cand[i].en > 0 && cand[i].ts > slave_t) { slave_t = cand[i].ts; slave_pick = (int)i; } + if (slave_pick >= 0) chosen = slave_pick; + else { uint64_t newest = 0; for (size_t i = 0; i < nc; ++i) if (cand[i].ts > newest) { newest = cand[i].ts; chosen = (int)i; } } + break; + } + case MESH_FUSE_MASTER_FIRST: { + int master_pick = -1; uint64_t master_t = 0; + for (size_t i = 0; i < nc; ++i) if (cand[i].from_coord && cand[i].en > 0 && cand[i].ts > master_t) { master_t = cand[i].ts; master_pick = (int)i; } + if (master_pick >= 0) chosen = master_pick; + else { uint64_t newest = 0; for (size_t i = 0; i < nc; ++i) if (cand[i].ts > newest) { newest = cand[i].ts; chosen = (int)i; } } + break; + } + case MESH_FUSE_ZONE_BASED: { + /* Each candidate is rated by which segment's distance window it + * lands in; the candidate matching its segment's range wins. */ + int best = 0; int best_score = -1; + const topology_t *t = topology_get(); + for (size_t i = 0; i < nc; ++i) { + int score = (int)cand[i].en; /* baseline */ + for (uint8_t s = 0; s < t->segment_count; ++s) { + if (t->segments[s].dist_max_cm == 0) continue; /* no window */ + if (cand[i].d >= t->segments[s].dist_min_cm && + cand[i].d <= t->segments[s].dist_max_cm) { + score += 100; + } + } + if (score > best_score) { best_score = score; best = (int)i; } + } + chosen = best; + break; + } + } + + out->distance_cm = cand[chosen].d; + out->direction = cand[chosen].dir; + out->energy = cand[chosen].en; + out->present = cand[chosen].en > 0; + (void)now; + xSemaphoreGive(s_m.lock); +} + +bool mesh_is_coordinator(void) { return s_m.is_coordinator; } + +esp_err_t mesh_set_fusion(mesh_fusion_t mode) { + if (mode > MESH_FUSE_ZONE_BASED) return ESP_ERR_INVALID_ARG; + s_m.fusion = mode; + settings_set_u8("topo", "fuse", (uint8_t)mode); + ESP_LOGI(TAG, "Fusion mode set to %d", mode); + return ESP_OK; +} + +mesh_fusion_t mesh_get_fusion(void) { return s_m.fusion; } + +void mesh_gossip_topology(void) { + /* peer_msg_t header + topology_t blob */ + uint8_t buf[sizeof(peer_msg_t) + sizeof(topology_t)]; + peer_msg_t *m = (peer_msg_t *)buf; + make_msg(m, MSG_GOSSIP); + memcpy(buf + sizeof(peer_msg_t), topology_get(), sizeof(topology_t)); + esp_now_send(BCAST_MAC, buf, sizeof(buf)); + ESP_LOGI(TAG, "Gossiped topology v%u", topology_get()->version); +} diff --git a/firmware/components/motion/CMakeLists.txt b/firmware/components/motion/CMakeLists.txt new file mode 100644 index 0000000..7c09376 --- /dev/null +++ b/firmware/components/motion/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "motion.c" "motion_kalman.c" + INCLUDE_DIRS "include" + REQUIRES radar settings esp_timer log freertos +) diff --git a/firmware/components/motion/include/motion.h b/firmware/components/motion/include/motion.h new file mode 100644 index 0000000..5c25cd4 --- /dev/null +++ b/firmware/components/motion/include/motion.h @@ -0,0 +1,45 @@ +#pragma once + +/* + * AmbiSense v6 β€” motion smoother (port of v5 radar_manager.cpp:38-198). + * + * Low-pass distance + velocity estimator + PI controller, runs in its own + * task at ~50 Hz. Consumes radar_frame_t, publishes a smoothed target_t. + * + * Tunables come from NVS namespace `motion` (set via /api/settings). + * Defaults match v5: position_smooth=0.2, velocity_smooth=0.1, + * predict=0.5, p_gain=0.1, i_gain=0.01. + */ + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + bool present; + int16_t distance_cm; /* smoothed + predicted */ + int16_t raw_cm; /* spike-rejected (median) but un-smoothed β€” + * for the Motion screen's raw vs smoothed + * line chart so users can see the smoother + * actually working. */ + int8_t direction; + uint8_t energy; + uint64_t ts_us; +} target_t; + +esp_err_t motion_init(void); + +/* Get the latest smoothed target. Non-blocking. */ +void motion_get(target_t *out); + +/* Re-read all NVS-backed motion settings and reset filter state. Called + * by webui after /api/settings POST so changes apply without reboot. */ +void motion_reload(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/motion/motion.c b/firmware/components/motion/motion.c new file mode 100644 index 0000000..232c00f --- /dev/null +++ b/firmware/components/motion/motion.c @@ -0,0 +1,264 @@ +#include "motion.h" +#include "motion_kalman.h" + +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" + +#include "radar.h" +#include "settings.h" + +static const char *TAG = "motion"; + +/* Outlier (median) buffer: up to 7 samples for the "Strong" setting. */ +#define MEDIAN_MAX_W 7 + +typedef enum { MODE_KALMAN = 0, MODE_PI = 1 } motion_mode_t; + +static struct { + motion_mode_t mode; + + /* User-facing simplified knobs. */ + bool enabled; + uint8_t response; /* 0..100 β€” calm⇄snappy */ + uint16_t look_ahead_ms; /* 0..500 β€” predictive lead */ + uint8_t outlier_strength; /* 0=off, 1=soft (3), 2=strong (7) */ + + /* Legacy / advanced PI knobs (still respected if present in NVS). */ + float pos_smooth; + float vel_smooth; + float predict; + float p_gain; + float i_gain; + + /* PI smoother state. */ + float smoothed; + float predicted; + float velocity; + float err_integral; + + /* Kalman state. */ + kalman_t kf; + + /* Common. */ + uint64_t last_us; + int16_t med_buf[MEDIAN_MAX_W]; + uint8_t med_idx; + uint8_t med_filled; + + target_t latest; + SemaphoreHandle_t lock; + int min_cm, max_cm; +} s_m; + +static float clamp(float v, float lo, float hi) { + return v < lo ? lo : (v > hi ? hi : v); +} + +/* Median over `w` samples drawn (most recent w) from `src` ring buffer. + * `w` must be ≀ MEDIAN_MAX_W and odd. */ +static int16_t median_w(int16_t *src, uint8_t idx, uint8_t filled, uint8_t w) { + if (w <= 1 || filled < w) { + /* Not enough samples yet β€” fall back to most recent. */ + uint8_t last = (idx + MEDIAN_MAX_W - 1) % MEDIAN_MAX_W; + return src[last]; + } + int16_t a[MEDIAN_MAX_W]; + /* Copy last w samples, walking backwards from idx-1. */ + for (uint8_t i = 0; i < w; ++i) { + uint8_t k = (idx + MEDIAN_MAX_W - 1 - i) % MEDIAN_MAX_W; + a[i] = src[k]; + } + for (int i = 1; i < w; ++i) { + int16_t x = a[i]; int j = i - 1; + while (j >= 0 && a[j] > x) { a[j+1] = a[j]; j--; } + a[j+1] = x; + } + return a[w / 2]; +} + +static uint8_t outlier_window(uint8_t strength) { + return (strength == 2) ? 7 : (strength == 1) ? 3 : 1; +} + +/* Map the 0..100 user "Response" knob to Kalman process noise. The curve + * is exponential so the slider has perceptual linearity β€” at 50 the + * tracker feels balanced, at 0 it's heavy, at 100 it's nearly raw. */ +static void response_to_kalman_q(uint8_t response, float *Q_pos, float *Q_vel) { + float r = (float)response / 100.f; /* 0..1 */ + /* Q_pos: 0.1 .. 4 cmΒ²/s (exponential) + * Q_vel: 2 .. 80 cmΒ²/sΒ³ (exponential) */ + *Q_pos = 0.1f * powf(40.f, r); /* 0.1 β†’ 4 */ + *Q_vel = 2.f * powf(40.f, r); /* 2 β†’ 80 */ +} + +/* Same response slider drives the PI's pos_smooth alpha when mode == PI. */ +static float response_to_pi_alpha(uint8_t response) { + /* 0 β†’ 0.05 (very calm), 50 β†’ 0.20 (default), 100 β†’ 0.6 (very snappy) */ + float r = (float)response / 100.f; + return 0.05f + 0.55f * r; +} + +static void run_pi(float filtered_raw, float dt, target_t *t) { + if (s_m.smoothed <= 0) { + s_m.smoothed = filtered_raw; + s_m.predicted = filtered_raw; + } + /* Adaptive alpha: ease the filter when target is moving fast. The + * envelope is (pos_smooth .. min(0.9, pos_smooth*4)). Same logic v2 + * had β€” the PI path is the "Legacy" mode users select for parity. */ + float delta = fabsf(filtered_raw - s_m.smoothed); + float scale = clamp(delta / 30.f, 0.f, 1.f); + float alpha_max = clamp(s_m.pos_smooth * 4.f, s_m.pos_smooth, 0.9f); + float alpha_eff = s_m.pos_smooth + (alpha_max - s_m.pos_smooth) * scale; + + s_m.smoothed = (1.f - alpha_eff) * s_m.smoothed + alpha_eff * filtered_raw; + float instant_v = (s_m.smoothed - s_m.predicted) / dt; + instant_v = clamp(instant_v, -200.f, 200.f); + s_m.velocity = (1.f - s_m.vel_smooth) * s_m.velocity + s_m.vel_smooth * instant_v; + /* User-facing look-ahead-ms overrides the legacy `predict` knob if set. */ + float predict_s = s_m.look_ahead_ms ? (s_m.look_ahead_ms / 1000.f) : s_m.predict; + s_m.predicted = s_m.smoothed + s_m.velocity * predict_s; + float perr = s_m.predicted - s_m.smoothed; + s_m.err_integral = clamp(s_m.err_integral + perr * dt, -100.f, 100.f); + float ctl = s_m.p_gain * perr + s_m.i_gain * s_m.err_integral; + int final_d = (int)(s_m.predicted + ctl); + if (final_d < s_m.min_cm) final_d = s_m.min_cm; + if (final_d > s_m.max_cm) final_d = s_m.max_cm; + t->distance_cm = (int16_t)final_d; + /* PI mode reports raw sensor direction; smoothed velocity sign is + * noisy near zero, so users get cleaner direction in Kalman mode. */ + if (s_m.velocity > 4.f) t->direction = 1; + else if (s_m.velocity < -4.f) t->direction = -1; +} + +static void run_kalman(float filtered_raw, uint8_t energy, float dt, target_t *t) { + float vel; + float pos = kalman_step(&s_m.kf, filtered_raw, energy, dt, &vel); + float predicted = s_m.look_ahead_ms ? kalman_predict_ahead(&s_m.kf, (float)s_m.look_ahead_ms) : pos; + if (predicted < s_m.min_cm) predicted = s_m.min_cm; + if (predicted > s_m.max_cm) predicted = s_m.max_cm; + t->distance_cm = (int16_t)predicted; + /* Direction with hysteresis. 4 cm/s threshold: below this we treat the + * target as stationary (returns 0). */ + t->direction = kalman_direction(&s_m.kf, vel, 4.f); +} + +static void motion_task(void *arg) { + (void)arg; + radar_frame_t f; + while (1) { + if (radar_read(&f, pdMS_TO_TICKS(1000)) != ESP_OK) { + xSemaphoreTake(s_m.lock, portMAX_DELAY); + s_m.latest.present = false; + xSemaphoreGive(s_m.lock); + continue; + } + + int raw = f.distance_cm; + if (raw < s_m.min_cm) raw = s_m.min_cm; + if (raw > s_m.max_cm) raw = s_m.max_cm; + + /* Outlier rejection (median over W=1/3/7 most recent samples). + * W=1 effectively bypasses median for users who want it off. */ + s_m.med_buf[s_m.med_idx] = (int16_t)raw; + s_m.med_idx = (s_m.med_idx + 1) % MEDIAN_MAX_W; + if (s_m.med_filled < MEDIAN_MAX_W) s_m.med_filled++; + uint8_t W = outlier_window(s_m.outlier_strength); + int16_t filtered_raw = median_w(s_m.med_buf, s_m.med_idx, s_m.med_filled, W); + + target_t t = { .present = f.present, .energy = f.energy, + .direction = f.direction, .ts_us = f.ts_us }; + t.raw_cm = filtered_raw; + + if (!s_m.enabled) { + t.distance_cm = filtered_raw; + } else { + uint64_t now = f.ts_us; + float dt = s_m.last_us ? (float)(now - s_m.last_us) / 1e6f : 0.02f; + s_m.last_us = now; + dt = clamp(dt, 0.001f, 1.0f); + + if (s_m.mode == MODE_KALMAN) run_kalman((float)filtered_raw, f.energy, dt, &t); + else run_pi ((float)filtered_raw, dt, &t); + } + + xSemaphoreTake(s_m.lock, portMAX_DELAY); + s_m.latest = t; + xSemaphoreGive(s_m.lock); + } +} + +static void load_config(void) { + /* Mode: string "kalman" (default) or "pi". */ + char mode[16] = "kalman"; + settings_get_str("motion", "mode", mode, sizeof(mode)); + s_m.mode = (strcmp(mode, "pi") == 0) ? MODE_PI : MODE_KALMAN; + + uint8_t en = 1; settings_get_u8("motion", "en", &en); s_m.enabled = en != 0; + + /* New simplified knobs. */ + uint8_t resp = 50; settings_get_u8("motion", "resp", &resp); + s_m.response = resp > 100 ? 100 : resp; + uint32_t la = 0; settings_get_u32("motion", "la_ms", &la); + s_m.look_ahead_ms = la > 500 ? 500 : (uint16_t)la; + uint8_t outl = 1; settings_get_u8("motion", "outl", &outl); + s_m.outlier_strength = outl > 2 ? 2 : outl; + + /* Legacy PI knobs. */ + uint32_t v; + v = (uint32_t)(response_to_pi_alpha(s_m.response) * 1000.f); + settings_get_u32("motion", "ps", &v); s_m.pos_smooth = v / 1000.f; + v = 100; settings_get_u32("motion", "vs", &v); s_m.vel_smooth = v / 1000.f; + v = (uint32_t)(s_m.look_ahead_ms); + settings_get_u32("motion", "pf", &v); s_m.predict = (v >= 1000) ? (v / 1000.f) : (v / 1000.f); + v = 100; settings_get_u32("motion", "pg", &v); s_m.p_gain = v / 1000.f; + v = 10; settings_get_u32("motion", "ig", &v); s_m.i_gain = v / 1000.f; + + /* Kalman tunables derived from response. R_base 16 cmΒ² (~Β±4 cm). */ + float Qp, Qv; + response_to_kalman_q(s_m.response, &Qp, &Qv); + kalman_set_tunables(&s_m.kf, Qp, Qv, 16.f); + + uint32_t mn = 30, mx = 300; + settings_get_u32("dist", "min", &mn); + settings_get_u32("dist", "max", &mx); + s_m.min_cm = (int)mn; s_m.max_cm = (int)mx; + if (s_m.max_cm <= s_m.min_cm) { s_m.min_cm = 30; s_m.max_cm = 300; } + + ESP_LOGI(TAG, "Motion: mode=%s en=%d resp=%u la_ms=%u outl=%u range=%d..%d cm", + s_m.mode == MODE_KALMAN ? "kalman" : "pi", + s_m.enabled, s_m.response, s_m.look_ahead_ms, s_m.outlier_strength, + s_m.min_cm, s_m.max_cm); + ESP_LOGI(TAG, "Motion (advanced): ps=%.3f vs=%.3f pf=%.3f pg=%.3f ig=%.3f Qp=%.3f Qv=%.3f", + s_m.pos_smooth, s_m.vel_smooth, s_m.predict, s_m.p_gain, s_m.i_gain, Qp, Qv); +} + +esp_err_t motion_init(void) { + s_m.lock = xSemaphoreCreateMutex(); + /* Initialize Kalman struct to zero before load_config sets tunables. */ + memset(&s_m.kf, 0, sizeof(s_m.kf)); + load_config(); + xTaskCreate(motion_task, "motion", 4096, NULL, 5, NULL); + return ESP_OK; +} + +void motion_get(target_t *out) { + if (!out) return; + xSemaphoreTake(s_m.lock, portMAX_DELAY); + *out = s_m.latest; + xSemaphoreGive(s_m.lock); +} + +void motion_reload(void) { + load_config(); + /* Reset filter state so the new mode/tunables take effect cleanly. */ + s_m.kf.initialized = false; + s_m.smoothed = 0; s_m.predicted = 0; s_m.velocity = 0; s_m.err_integral = 0; + ESP_LOGI(TAG, "Motion config reloaded"); +} diff --git a/firmware/components/motion/motion_kalman.c b/firmware/components/motion/motion_kalman.c new file mode 100644 index 0000000..830f628 --- /dev/null +++ b/firmware/components/motion/motion_kalman.c @@ -0,0 +1,110 @@ +#include "motion_kalman.h" + +#include +#include + +static float clampf(float v, float lo, float hi) { + return v < lo ? lo : (v > hi ? hi : v); +} + +void kalman_set_tunables(kalman_t *k, float Q_pos, float Q_vel, float R_base) { + if (Q_pos > 0) k->Q_pos = Q_pos; + if (Q_vel > 0) k->Q_vel = Q_vel; + if (R_base > 0) k->R_base = R_base; +} + +void kalman_reset(kalman_t *k, float z) { + k->x_pos = z; + k->x_vel = 0.f; + /* Initial covariance: high uncertainty in velocity (we don't know + * which direction the target is moving), moderate in position + * (radar gave us a measurement). 100 cmΒ²/sΒ² lets the filter learn + * the velocity within ~5 samples; tighter than that overconstrains + * the early estimates. */ + k->P[0][0] = (k->R_base > 0 ? k->R_base : 4.f); + k->P[0][1] = 0.f; + k->P[1][0] = 0.f; + k->P[1][1] = 100.f; + + k->dir_committed = 0; + k->dir_pending = 0; + k->dir_agree = 0; + k->initialized = true; +} + +float kalman_step(kalman_t *k, float z, uint8_t energy, float dt, float *vel_out) { + if (!k->initialized) kalman_reset(k, z); + dt = clampf(dt, 0.001f, 1.0f); + + /* ---- Predict ---- */ + /* x = F x ; F = [[1, dt], [0, 1]] */ + float x0 = k->x_pos + k->x_vel * dt; + float x1 = k->x_vel; + + /* P = F P F^T + Q; Q = diag(Q_pos*dt, Q_vel*dt). The Q_pos*dt and + * Q_vel*dt scaling makes the noise integral over the timestep, which + * is what discrete KF theory requires. */ + float P00 = k->P[0][0] + dt * (k->P[1][0] + k->P[0][1]) + dt * dt * k->P[1][1] + k->Q_pos * dt; + float P01 = k->P[0][1] + dt * k->P[1][1]; + float P10 = k->P[1][0] + dt * k->P[1][1]; + float P11 = k->P[1][1] + k->Q_vel * dt; + + /* ---- Update ---- */ + /* H = [1, 0] β€” we observe position only. + * y = z - H x = z - x0 + * S = H P H^T + R = P00 + R + * K = P H^T / S = [P00 / S; P10 / S] + * x = x + K y + * P = (I - K H) P + */ + float R = k->R_base; + if (energy < 30) R *= 4.f; /* deweight low-confidence observations */ + if (R < 0.5f) R = 0.5f; + + float S = P00 + R; + float K0 = P00 / S; + float K1 = P10 / S; + float y = z - x0; + + k->x_pos = x0 + K0 * y; + k->x_vel = x1 + K1 * y; + + k->P[0][0] = (1.f - K0) * P00; + k->P[0][1] = (1.f - K0) * P01; + k->P[1][0] = P10 - K1 * P00; + k->P[1][1] = P11 - K1 * P01; + + /* ---- Bound velocity (humans walking on stairs are <200 cm/s) ---- */ + k->x_vel = clampf(k->x_vel, -200.f, 200.f); + + if (vel_out) *vel_out = k->x_vel; + return k->x_pos; +} + +float kalman_predict_ahead(const kalman_t *k, float predict_ms) { + return k->x_pos + k->x_vel * (predict_ms / 1000.f); +} + +int8_t kalman_direction(kalman_t *k, float vel, float vel_threshold_cm_s) { + int8_t sign = (vel > vel_threshold_cm_s) ? 1 : + (vel < -vel_threshold_cm_s) ? -1 : 0; + if (sign == k->dir_committed) { + k->dir_pending = 0; + k->dir_agree = 0; + return k->dir_committed; + } + /* Different from committed β†’ require 3 in a row to flip. This kills + * the per-tick direction jitter that radar produces near zero + * crossings (where the smoothed velocity oscillates around 0). */ + if (sign == k->dir_pending) { + if (k->dir_agree < 255) k->dir_agree++; + } else { + k->dir_pending = sign; + k->dir_agree = 1; + } + if (k->dir_agree >= 3) { + k->dir_committed = k->dir_pending; + k->dir_agree = 0; + } + return k->dir_committed; +} diff --git a/firmware/components/motion/motion_kalman.h b/firmware/components/motion/motion_kalman.h new file mode 100644 index 0000000..cdd0633 --- /dev/null +++ b/firmware/components/motion/motion_kalman.h @@ -0,0 +1,80 @@ +#pragma once + +/* + * AmbiSense v6 β€” 1-D Kalman filter for radar distance smoothing. + * + * State: x = [position_cm, velocity_cm_per_s] + * Process model: position_{k+1} = position_k + velocity_k * dt + * velocity_{k+1} = velocity_k (constant velocity) + * Observation: z_k = position_k + R noise + * + * Process noise: + * Q_pos drives "how much can position spontaneously wander between + * ticks" β€” small (~0.5 cmΒ²/s) for steady targets. + * Q_vel drives "how aggressively the velocity can change". Big values + * make the filter snappier; small values make it calmer. + * + * Observation noise R: + * Trust the radar more when it reports high energy (target lock is + * strong) and less when energy is low. We multiply the base R by 4Γ— + * when energy < 30 β€” empirically the radar's reported distance jumps + * most when it's struggling to lock. + * + * Direction hysteresis (3-sample agreement) and Β±200 cm/s velocity + * clamp are applied at the API boundary, not inside the filter math β€” + * the filter itself stays a clean linear KF. + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + /* State + covariance. */ + float x_pos; + float x_vel; + float P[2][2]; + + /* Tunables. */ + float Q_pos; + float Q_vel; + float R_base; + + /* Derived: direction hysteresis state. dir_pending holds the + * candidate direction sign; dir_agree counts consecutive samples + * agreeing with the candidate. */ + int8_t dir_committed; + int8_t dir_pending; + uint8_t dir_agree; + + bool initialized; +} kalman_t; + +/* Initialize at first observation; sets x_pos = z, x_vel = 0, + * P = diag(R_base, 100). Tunables left untouched if non-zero. */ +void kalman_reset(kalman_t *k, float z); + +/* Set tunables. Pass 0 for any to keep current. */ +void kalman_set_tunables(kalman_t *k, float Q_pos, float Q_vel, float R_base); + +/* Run one predict+update step. Returns the smoothed position and writes + * the velocity through *vel_out. `energy` 0-255 from radar; <30 widens + * R to deweight low-confidence observations. dt in seconds, clamped + * internally to (0.001, 1.0). */ +float kalman_step(kalman_t *k, float z, uint8_t energy, float dt, float *vel_out); + +/* Look ahead `predict_ms` from the latest filtered state; useful for the + * UI's "look-ahead-ms" predict slider. Bounded to Β±max_dist_cm shift. */ +float kalman_predict_ahead(const kalman_t *k, float predict_ms); + +/* Run direction hysteresis on the latest velocity estimate. Returns the + * committed direction (-1, 0, +1). Three consecutive same-sign samples + * are required to flip; below `vel_threshold_cm_s` direction is 0. */ +int8_t kalman_direction(kalman_t *k, float vel, float vel_threshold_cm_s); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/netmgr/CMakeLists.txt b/firmware/components/netmgr/CMakeLists.txt new file mode 100644 index 0000000..652fd5a --- /dev/null +++ b/firmware/components/netmgr/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "netmgr.c" + INCLUDE_DIRS "include" + REQUIRES settings esp_wifi esp_netif esp_event nvs_flash mdns lwip log +) diff --git a/firmware/components/netmgr/idf_component.yml b/firmware/components/netmgr/idf_component.yml new file mode 100644 index 0000000..f964bd7 --- /dev/null +++ b/firmware/components/netmgr/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + espressif/mdns: "^1.5.0" diff --git a/firmware/components/netmgr/include/netmgr.h b/firmware/components/netmgr/include/netmgr.h new file mode 100644 index 0000000..6e2f8de --- /dev/null +++ b/firmware/components/netmgr/include/netmgr.h @@ -0,0 +1,85 @@ +#pragma once + +/* + * AmbiSense v6 β€” network manager. + * + * Owns the Wi-Fi state machine: tries STA with stored credentials, falls + * back to AP "AmbiSense-XXXX" if STA fails or no credentials are saved. + * Brings up mDNS (`.local`), and runs a captive-portal DNS responder + * while in AP mode so phones auto-pop the setup page. + * + * State changes are exposed via callback so the status_led component can + * mirror them as blink patterns. + */ + +#include +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + NETMGR_STATE_BOOT = 0, + NETMGR_STATE_STA_CONNECTING, + NETMGR_STATE_STA_CONNECTED, + NETMGR_STATE_AP_FALLBACK, /* AP because STA failed or no creds */ + NETMGR_STATE_OTA, + NETMGR_STATE_ERROR, +} netmgr_state_t; + +typedef void (*netmgr_state_cb_t)(netmgr_state_t state, void *ctx); + +/* Bring up the Wi-Fi stack, mDNS, and captive portal as needed. Reads + * stored credentials from NVS (`wifi.ssid` / `wifi.pass`). Non-blocking; + * connection happens on the IDF event loop. */ +esp_err_t netmgr_init(void); + +/* Connect with new credentials and persist them on success. Pass NULL ssid + * to clear creds and force AP fallback. */ +esp_err_t netmgr_set_credentials(const char *ssid, const char *pass); + +/* Currently in STA mode? */ +bool netmgr_is_sta_connected(void); + +/* IP/hostname accessors β€” caller-provided buffers. */ +esp_err_t netmgr_get_ip(char *out, size_t max); +esp_err_t netmgr_get_hostname(char *out, size_t max); + +/* RSSI in STA mode, 0 in AP mode. */ +int8_t netmgr_get_rssi(void); + +/* Register a callback fired on every state transition. */ +void netmgr_on_state_change(netmgr_state_cb_t cb, void *ctx); + +/* Set device hostname (lower-case, alnum/hyphen). Persists to NVS. */ +esp_err_t netmgr_set_hostname(const char *name); + +/* AP visibility policy. + * AUTO β€” AP up only while STA is not connected (default). + * ALWAYS β€” AP up at all times (use if you want a guaranteed local + * access path even when joined to a router). + * STA_ONLY β€” Once STA connects, AP comes down and stays down until + * STA loses connection. Same as AUTO but spelled-out for clarity. + */ +typedef enum { + NETMGR_AP_AUTO = 0, + NETMGR_AP_ALWAYS = 1, + NETMGR_AP_STA_ONLY = 2, +} netmgr_ap_mode_t; + +netmgr_ap_mode_t netmgr_get_ap_mode(void); +esp_err_t netmgr_set_ap_mode(netmgr_ap_mode_t mode); + +/* Whether the AP interface is currently broadcasting. */ +bool netmgr_is_ap_active(void); + +/* Set AP password (>= 8 chars enables WPA2; empty/short = open). + * Persists to NVS; takes effect after next netmgr_init or restart. */ +esp_err_t netmgr_set_ap_password(const char *pass); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/netmgr/netmgr.c b/firmware/components/netmgr/netmgr.c new file mode 100644 index 0000000..ba08e13 --- /dev/null +++ b/firmware/components/netmgr/netmgr.c @@ -0,0 +1,469 @@ +#include "netmgr.h" + +#include +#include +#include +#include +#include +#include + +#include "esp_log.h" +#include "esp_event.h" +#include "esp_wifi.h" +#include "esp_netif.h" +#include "esp_mac.h" +#include "esp_random.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/event_groups.h" +#include "lwip/err.h" +#include "lwip/sys.h" +#include "mdns.h" + +#include "settings.h" + +static const char *TAG = "netmgr"; + +#define STA_RETRY_MAX 3 +#define STA_RETRY_BACKOFF_MS 3000 + +static struct { + netmgr_state_t state; + netmgr_state_cb_t cb; + void *cb_ctx; + EventGroupHandle_t evt; + int sta_retry; + esp_netif_t *sta_netif; + esp_netif_t *ap_netif; + char hostname[33]; + bool inited; + bool dns_running; + bool ap_active; /* true while we are broadcasting an SSID */ + bool sta_configured; /* true if NVS has stored creds */ + netmgr_ap_mode_t ap_mode; + TaskHandle_t dns_task; +} s_net; + +/* Decide whether the AP interface should be on right now. + * AUTO / STA_ONLY: AP up unless STA is currently connected. + * ALWAYS: AP up unconditionally. + * No STA configured at all: AP up regardless of mode (otherwise the + * user has no way to reach the device). */ +static bool ap_should_be_on(void) { + if (!s_net.sta_configured) return true; + if (s_net.ap_mode == NETMGR_AP_ALWAYS) return true; + return s_net.state != NETMGR_STATE_STA_CONNECTED; +} + +/* Forward decls used in transition helpers below */ +static esp_err_t configure_ap(void); +static void start_captive_dns(void); +static void stop_captive_dns_now(void); + +/* Switch the radio to APSTA / STA_ONLY without restarting Wi-Fi. + * `target_ap_on` is what we want; we pick the IDF mode accordingly. */ +static void apply_ap_state(bool target_ap_on) { + if (target_ap_on == s_net.ap_active) return; + wifi_mode_t want = target_ap_on ? WIFI_MODE_APSTA : WIFI_MODE_STA; + esp_err_t err = esp_wifi_set_mode(want); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_set_mode(%d) failed: 0x%x", want, err); + return; + } + if (target_ap_on) { + configure_ap(); + start_captive_dns(); + ESP_LOGI(TAG, "AP brought up (mode=%d)", s_net.ap_mode); + } else { + stop_captive_dns_now(); + ESP_LOGI(TAG, "AP brought down (STA owns the radio)"); + } + s_net.ap_active = target_ap_on; +} + +#define EVT_GOT_IP BIT0 +#define EVT_FAIL BIT1 + +static void notify_state(netmgr_state_t st) { + s_net.state = st; + if (s_net.cb) s_net.cb(st, s_net.cb_ctx); +} + +/* Sanitize a free-form name into an mDNS-safe hostname: + * lowercase, alnum + hyphen, leading "ambisense-" if not present. + * Falls back to "ambisense-XXXX" using the last 16 bits of MAC. */ +static void sanitize_hostname(const char *in, char *out, size_t max) { + char buf[33] = {0}; + size_t bi = 0; + if (in) { + for (size_t i = 0; in[i] && bi < sizeof(buf) - 1; ++i) { + char c = in[i]; + if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { + buf[bi++] = c; + } else if (c == ' ' || c == '.' || c == '_') { + buf[bi++] = '-'; + } + } + } + if (bi == 0 || strncmp(buf, "ambisense", 9) != 0) { + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + snprintf(out, max, "ambisense-%02x%02x", mac[4], mac[5]); + } else { + snprintf(out, max, "%s", buf); + } +} + +static esp_err_t bring_up_mdns(void) { + esp_err_t err = mdns_init(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mdns_init: 0x%x", err); + return err; + } + mdns_hostname_set(s_net.hostname); + mdns_instance_name_set("AmbiSense"); + mdns_service_add("_ambisense", "_http", "_tcp", 80, NULL, 0); + ESP_LOGI(TAG, "mDNS up: %s.local", s_net.hostname); + return ESP_OK; +} + +/* Captive-portal DNS responder. Resolves every query to our AP IP so any + * hostname a phone tries (apple.com/library/test/success.html, msftconnecttest, + * captive.apple.com, ...) gets steered at the device. iOS / Android / Win11 + * detect this and pop the setup page automatically. */ +static void dns_task(void *arg) { + (void)arg; + int sock = socket(AF_INET, SOCK_DGRAM, 0); + if (sock < 0) { ESP_LOGE(TAG, "dns: socket"); vTaskDelete(NULL); } + + struct sockaddr_in srv = { + .sin_family = AF_INET, + .sin_port = htons(53), + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + if (bind(sock, (struct sockaddr *)&srv, sizeof(srv)) < 0) { + ESP_LOGE(TAG, "dns: bind 53"); close(sock); vTaskDelete(NULL); + } + + /* Our AP IP. Default IDF AP is 192.168.4.1. We'll fetch live. */ + esp_netif_ip_info_t ip; + esp_netif_get_ip_info(s_net.ap_netif, &ip); + uint32_t ap_ip = ip.ip.addr; /* network-order */ + + uint8_t buf[512]; + while (s_net.dns_running) { + struct sockaddr_in src; + socklen_t slen = sizeof(src); + int n = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr *)&src, &slen); + if (n < 12) continue; + /* Build a minimal answer: copy the question, set QR=1, AA=1, ANCOUNT=1, + * append an A-record pointing to ap_ip, TTL 60. */ + buf[2] |= 0x80; /* QR */ + buf[2] |= 0x04; /* AA */ + buf[3] = 0x80; /* RA + RCODE=0 */ + buf[6] = 0; buf[7] = 1; /* ANCOUNT = 1 */ + buf[8] = 0; buf[9] = 0; /* NSCOUNT */ + buf[10] = 0; buf[11] = 0; /* ARCOUNT */ + + /* Find end of question (NUL-terminated label sequence + 4 bytes type/class). */ + int p = 12; + while (p < n && buf[p] != 0) p += buf[p] + 1; + p += 1 + 4; /* skip null label + qtype + qclass */ + if (p + 16 > (int)sizeof(buf)) continue; + + /* Answer: pointer to question name (0xC00C), TYPE=A, CLASS=IN, TTL=60, RDLEN=4, IP. */ + buf[p++] = 0xC0; buf[p++] = 0x0C; + buf[p++] = 0x00; buf[p++] = 0x01; + buf[p++] = 0x00; buf[p++] = 0x01; + buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x3C; + buf[p++] = 0x00; buf[p++] = 0x04; + buf[p++] = (ap_ip >> 0) & 0xFF; + buf[p++] = (ap_ip >> 8) & 0xFF; + buf[p++] = (ap_ip >> 16) & 0xFF; + buf[p++] = (ap_ip >> 24) & 0xFF; + + sendto(sock, buf, p, 0, (struct sockaddr *)&src, slen); + } + close(sock); + vTaskDelete(NULL); +} + +static void start_captive_dns(void) { + if (s_net.dns_running) return; + s_net.dns_running = true; + xTaskCreate(dns_task, "captive_dns", 3072, NULL, 3, &s_net.dns_task); +} + +static void stop_captive_dns_now(void) { + /* The dns_task observes s_net.dns_running and exits at next packet/recv + * timeout. We don't force-kill the task; it self-terminates. */ + s_net.dns_running = false; +} + +static void on_wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data) { + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + bool was_connected = (s_net.state == NETMGR_STATE_STA_CONNECTED); + s_net.state = NETMGR_STATE_STA_CONNECTING; + if (was_connected) { + ESP_LOGW(TAG, "STA dropped after being connected β€” bringing AP back up while we retry"); + apply_ap_state(ap_should_be_on()); + } + if (s_net.sta_retry < STA_RETRY_MAX) { + s_net.sta_retry++; + ESP_LOGW(TAG, "STA disconnected; retry %d/%d", s_net.sta_retry, STA_RETRY_MAX); + vTaskDelay(pdMS_TO_TICKS(STA_RETRY_BACKOFF_MS)); + esp_wifi_connect(); + } else { + ESP_LOGW(TAG, "STA failed after %d retries; AP fallback active", STA_RETRY_MAX); + xEventGroupSetBits(s_net.evt, EVT_FAIL); + s_net.state = NETMGR_STATE_AP_FALLBACK; + apply_ap_state(true); /* No matter the mode, fail-soft to AP. */ + } + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *e = (ip_event_got_ip_t *)data; + ESP_LOGI(TAG, "STA got IP: " IPSTR, IP2STR(&e->ip_info.ip)); + s_net.sta_retry = 0; + s_net.state = NETMGR_STATE_STA_CONNECTED; + xEventGroupSetBits(s_net.evt, EVT_GOT_IP); + /* AUTO/STA_ONLY: power down the AP now that STA is up. ALWAYS: keep it. */ + apply_ap_state(ap_should_be_on()); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) { + wifi_event_ap_staconnected_t *e = (wifi_event_ap_staconnected_t *)data; + ESP_LOGI(TAG, "AP client joined: " MACSTR, MAC2STR(e->mac)); + } +} + +/* Configure both AP and STA interfaces. The AP stays up for the + * entire device lifetime β€” many installs have no router at all, and + * peer-mesh devices need a stable channel to find each other. */ +static esp_err_t configure_ap(void) { + char ap_ssid[32]; + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + snprintf(ap_ssid, sizeof(ap_ssid), "AmbiSense-%02X%02X", mac[4], mac[5]); + + /* Optional WPA2 password from NVS; default is open for first-setup. */ + char ap_pass[64] = {0}; + settings_get_str("wifi", "ap_pass", ap_pass, sizeof(ap_pass)); + + /* Channel: prefer NVS pin (so peers can be co-channeled even off-router); + * default 6. PR #4's mesh uses this same channel. */ + uint8_t channel = 6; + uint8_t saved_ch = 0; + if (settings_get_u8("wifi", "ap_ch", &saved_ch) == ESP_OK && saved_ch >= 1 && saved_ch <= 13) { + channel = saved_ch; + } + + wifi_config_t cfg = {0}; + snprintf((char *)cfg.ap.ssid, sizeof(cfg.ap.ssid), "%s", ap_ssid); + cfg.ap.ssid_len = strlen(ap_ssid); + cfg.ap.channel = channel; + cfg.ap.max_connection = 6; /* up to 5 mesh peers + 1 phone */ + if (ap_pass[0] && strlen(ap_pass) >= 8) { + snprintf((char *)cfg.ap.password, sizeof(cfg.ap.password), "%s", ap_pass); + cfg.ap.authmode = WIFI_AUTH_WPA2_PSK; + } else { + cfg.ap.authmode = WIFI_AUTH_OPEN; + } + cfg.ap.pmf_cfg.required = false; + + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &cfg)); + ESP_LOGI(TAG, "AP configured: SSID=%s%s channel=%u", ap_ssid, + cfg.ap.authmode == WIFI_AUTH_OPEN ? " (open)" : " (wpa2)", channel); + return ESP_OK; +} + +static esp_err_t configure_sta(const char *ssid, const char *pass) { + wifi_config_t cfg = {0}; + snprintf((char *)cfg.sta.ssid, sizeof(cfg.sta.ssid), "%s", ssid); + if (pass && pass[0]) snprintf((char *)cfg.sta.password, sizeof(cfg.sta.password), "%s", pass); + cfg.sta.threshold.authmode = WIFI_AUTH_OPEN; + cfg.sta.scan_method = WIFI_FAST_SCAN; + cfg.sta.pmf_cfg.capable = true; + return esp_wifi_set_config(WIFI_IF_STA, &cfg); +} + +esp_err_t netmgr_init(void) { + if (s_net.inited) return ESP_OK; + + s_net.evt = xEventGroupCreate(); + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + s_net.sta_netif = esp_netif_create_default_wifi_sta(); + s_net.ap_netif = esp_netif_create_default_wifi_ap(); + + /* Hostname: NVS wifi.host > derived from MAC. */ + char raw[33] = {0}; + settings_get_hostname(raw, sizeof(raw)); + sanitize_hostname(raw, s_net.hostname, sizeof(s_net.hostname)); + esp_netif_set_hostname(s_net.sta_netif, s_net.hostname); + + wifi_init_config_t wcfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&wcfg)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi_event, NULL)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &on_wifi_event, NULL)); + + /* Read AP-mode policy from NVS; default AUTO (AP only when STA is + * down, or always when no STA configured). */ + uint8_t apmode = NETMGR_AP_AUTO; + settings_get_u8("wifi", "ap_mode", &apmode); + if (apmode > NETMGR_AP_STA_ONLY) apmode = NETMGR_AP_AUTO; + s_net.ap_mode = (netmgr_ap_mode_t)apmode; + + char ssid[33] = {0}, pass[65] = {0}; + settings_get_wifi_ssid(ssid, sizeof(ssid)); + settings_get_wifi_pass(pass, sizeof(pass)); + s_net.sta_configured = ssid[0] != 0; + + /* Decide initial mode. If we have STA creds, start in APSTA so STA + * can come up while AP is reachable; the AP will be torn down by + * the IP_EVENT_STA_GOT_IP handler if policy allows. If no creds, + * AP-only is the right answer. */ + bool ap_at_boot = ap_should_be_on(); + ESP_ERROR_CHECK(esp_wifi_set_mode(ap_at_boot ? WIFI_MODE_APSTA : WIFI_MODE_STA)); + if (ap_at_boot) configure_ap(); + + if (s_net.sta_configured) { + configure_sta(ssid, pass); + s_net.sta_retry = 0; + xEventGroupClearBits(s_net.evt, EVT_GOT_IP | EVT_FAIL); + notify_state(NETMGR_STATE_STA_CONNECTING); + } else { + notify_state(NETMGR_STATE_AP_FALLBACK); + } + + ESP_ERROR_CHECK(esp_wifi_start()); + s_net.ap_active = ap_at_boot; + ESP_LOGI(TAG, "Wi-Fi up: ap=%s sta=%s host=%s mode=%s", + ap_at_boot ? "yes" : "no", + s_net.sta_configured ? ssid : "(none)", + s_net.hostname, + s_net.ap_mode == NETMGR_AP_ALWAYS ? "always" : + s_net.ap_mode == NETMGR_AP_STA_ONLY ? "sta_only" : "auto"); + + if (ap_at_boot) start_captive_dns(); + bring_up_mdns(); + + /* If we tried STA, wait briefly so callers see a settled state. */ + if (s_net.sta_configured) { + EventBits_t bits = xEventGroupWaitBits( + s_net.evt, EVT_GOT_IP | EVT_FAIL, pdFALSE, pdFALSE, + pdMS_TO_TICKS(15000)); + if (bits & EVT_GOT_IP) { + /* event handler already adjusted AP state per policy */ + } else { + notify_state(NETMGR_STATE_AP_FALLBACK); + apply_ap_state(true); /* AP is the user's only way back in */ + } + } + + s_net.inited = true; + return ESP_OK; +} + +netmgr_ap_mode_t netmgr_get_ap_mode(void) { + return s_net.ap_mode; +} + +esp_err_t netmgr_set_ap_mode(netmgr_ap_mode_t mode) { + if (mode > NETMGR_AP_STA_ONLY) return ESP_ERR_INVALID_ARG; + s_net.ap_mode = mode; + settings_set_u8("wifi", "ap_mode", (uint8_t)mode); + /* Apply immediately so the user sees the effect without a reboot. */ + apply_ap_state(ap_should_be_on()); + ESP_LOGI(TAG, "AP mode set to %s", mode == NETMGR_AP_ALWAYS ? "always" : + mode == NETMGR_AP_STA_ONLY ? "sta_only" : "auto"); + return ESP_OK; +} + +bool netmgr_is_ap_active(void) { + return s_net.ap_active; +} + +esp_err_t netmgr_set_ap_password(const char *pass) { + if (!pass) pass = ""; + settings_set_str("wifi", "ap_pass", pass); + /* If AP is currently up, re-apply config so new password takes effect. */ + if (s_net.ap_active) { + configure_ap(); + ESP_LOGI(TAG, "AP password updated; re-applied to running AP"); + } + return ESP_OK; +} + +esp_err_t netmgr_set_credentials(const char *ssid, const char *pass) { + if (!ssid || !ssid[0]) { + settings_set_wifi_ssid(""); + settings_set_wifi_pass(""); + esp_wifi_disconnect(); + notify_state(NETMGR_STATE_AP_FALLBACK); + ESP_LOGI(TAG, "Cleared STA creds; AP remains up"); + return ESP_OK; + } + settings_set_wifi_ssid(ssid); + settings_set_wifi_pass(pass ? pass : ""); + + /* AP keeps running throughout. Just retarget STA. */ + esp_wifi_disconnect(); + s_net.sta_retry = 0; + xEventGroupClearBits(s_net.evt, EVT_GOT_IP | EVT_FAIL); + configure_sta(ssid, pass); + notify_state(NETMGR_STATE_STA_CONNECTING); + esp_wifi_connect(); + + EventBits_t bits = xEventGroupWaitBits( + s_net.evt, EVT_GOT_IP | EVT_FAIL, pdFALSE, pdFALSE, + pdMS_TO_TICKS(15000)); + if (bits & EVT_GOT_IP) { + notify_state(NETMGR_STATE_STA_CONNECTED); + return ESP_OK; + } + notify_state(NETMGR_STATE_AP_FALLBACK); + return ESP_FAIL; +} + +bool netmgr_is_sta_connected(void) { + return s_net.state == NETMGR_STATE_STA_CONNECTED; +} + +esp_err_t netmgr_get_ip(char *out, size_t max) { + if (!out || max == 0) return ESP_ERR_INVALID_ARG; + esp_netif_ip_info_t ip; + esp_netif_t *if_ = (s_net.state == NETMGR_STATE_STA_CONNECTED) ? s_net.sta_netif : s_net.ap_netif; + esp_err_t err = esp_netif_get_ip_info(if_, &ip); + if (err != ESP_OK) return err; + snprintf(out, max, IPSTR, IP2STR(&ip.ip)); + return ESP_OK; +} + +esp_err_t netmgr_get_hostname(char *out, size_t max) { + if (!out || max == 0) return ESP_ERR_INVALID_ARG; + snprintf(out, max, "%s", s_net.hostname); + return ESP_OK; +} + +int8_t netmgr_get_rssi(void) { + if (!netmgr_is_sta_connected()) return 0; + wifi_ap_record_t info = {0}; + if (esp_wifi_sta_get_ap_info(&info) != ESP_OK) return 0; + return info.rssi; +} + +void netmgr_on_state_change(netmgr_state_cb_t cb, void *ctx) { + s_net.cb = cb; + s_net.cb_ctx = ctx; +} + +esp_err_t netmgr_set_hostname(const char *name) { + if (!name) return ESP_ERR_INVALID_ARG; + sanitize_hostname(name, s_net.hostname, sizeof(s_net.hostname)); + settings_set_hostname(s_net.hostname); + if (s_net.sta_netif) esp_netif_set_hostname(s_net.sta_netif, s_net.hostname); + mdns_hostname_set(s_net.hostname); + return ESP_OK; +} diff --git a/firmware/components/ota/CMakeLists.txt b/firmware/components/ota/CMakeLists.txt new file mode 100644 index 0000000..28cce9d --- /dev/null +++ b/firmware/components/ota/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "ota.c" + INCLUDE_DIRS "include" + REQUIRES app_update esp_app_format log freertos +) diff --git a/firmware/components/ota/include/ota.h b/firmware/components/ota/include/ota.h new file mode 100644 index 0000000..f9f3958 --- /dev/null +++ b/firmware/components/ota/include/ota.h @@ -0,0 +1,44 @@ +#pragma once + +/* + * AmbiSense v6 β€” OTA firmware update. + * + * Streams a multipart/form-data firmware upload into the inactive OTA + * partition. On valid hash, marks the partition for boot and reboots. + * Bootloader rollback is enabled (sdkconfig.defaults), so a failed boot + * automatically reverts to the previous slot. + * + * Unsigned for v6.0 (per architecture decision); signed-OTA on roadmap. + */ + +#include +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct ota_session_s ota_session_t; + +/* Begin a new OTA session. Returns a handle the caller streams data into. */ +ota_session_t *ota_begin(size_t expected_size); + +/* Append bytes to the in-progress session. Returns ESP_OK or an error; + * on error the session is automatically aborted and freed. */ +esp_err_t ota_write(ota_session_t *s, const void *data, size_t len); + +/* Finish: validate, mark next-boot, schedule reboot. Frees the session. */ +esp_err_t ota_finish(ota_session_t *s); + +/* Abort and free without committing. Safe to call on any state. */ +void ota_abort(ota_session_t *s); + +/* Mark the running firmware valid (call once at boot if everything's OK, + * defeats the rollback timer). */ +esp_err_t ota_mark_valid(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/ota/ota.c b/firmware/components/ota/ota.c new file mode 100644 index 0000000..36951f0 --- /dev/null +++ b/firmware/components/ota/ota.c @@ -0,0 +1,99 @@ +#include "ota.h" + +#include +#include + +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_app_format.h" +#include "esp_app_desc.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "ota"; + +struct ota_session_s { + esp_ota_handle_t handle; + const esp_partition_t *target; + size_t expected; + size_t written; +}; + +ota_session_t *ota_begin(size_t expected_size) { + const esp_partition_t *next = esp_ota_get_next_update_partition(NULL); + if (!next) { ESP_LOGE(TAG, "no OTA partition"); return NULL; } + + ota_session_t *s = calloc(1, sizeof(*s)); + if (!s) return NULL; + s->target = next; + s->expected = expected_size; + + esp_err_t err = esp_ota_begin(next, OTA_WITH_SEQUENTIAL_WRITES, &s->handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_begin: 0x%x", err); + free(s); + return NULL; + } + ESP_LOGI(TAG, "OTA begin: partition '%s' offset 0x%lx, expected %u bytes", + next->label, (unsigned long)next->address, (unsigned)expected_size); + return s; +} + +esp_err_t ota_write(ota_session_t *s, const void *data, size_t len) { + if (!s) return ESP_ERR_INVALID_STATE; + esp_err_t err = esp_ota_write(s->handle, data, len); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_write @ %u: 0x%x", (unsigned)s->written, err); + ota_abort(s); + return err; + } + s->written += len; + return ESP_OK; +} + +esp_err_t ota_finish(ota_session_t *s) { + if (!s) return ESP_ERR_INVALID_STATE; + esp_err_t err = esp_ota_end(s->handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_end: 0x%x (validation failed)", err); + free(s); + return err; + } + err = esp_ota_set_boot_partition(s->target); + if (err != ESP_OK) { + ESP_LOGE(TAG, "set_boot_partition: 0x%x", err); + free(s); + return err; + } + ESP_LOGI(TAG, "OTA committed: %u bytes to '%s'. Rebooting in 1 s.", + (unsigned)s->written, s->target->label); + free(s); + /* Defer reboot so the HTTP response can flush. */ + extern void _ota_reboot_task(void *); + xTaskCreate(_ota_reboot_task, "ota_reboot", 2048, NULL, 5, NULL); + return ESP_OK; +} + +void _ota_reboot_task(void *arg) { + (void)arg; + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); +} + +void ota_abort(ota_session_t *s) { + if (!s) return; + esp_ota_abort(s->handle); + free(s); +} + +esp_err_t ota_mark_valid(void) { + const esp_partition_t *running = esp_ota_get_running_partition(); + esp_ota_img_states_t state; + if (esp_ota_get_state_partition(running, &state) == ESP_OK) { + if (state == ESP_OTA_IMG_PENDING_VERIFY) { + ESP_LOGI(TAG, "Marking running image as valid (rollback armed β†’ defused)"); + return esp_ota_mark_app_valid_cancel_rollback(); + } + } + return ESP_OK; +} diff --git a/firmware/components/radar/CMakeLists.txt b/firmware/components/radar/CMakeLists.txt new file mode 100644 index 0000000..d876f84 --- /dev/null +++ b/firmware/components/radar/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "radar.c" "radar_ld2410.c" "radar_ld2450.c" "radar_sim.c" + INCLUDE_DIRS "include" + REQUIRES settings driver esp_timer log freertos +) diff --git a/firmware/components/radar/include/radar.h b/firmware/components/radar/include/radar.h new file mode 100644 index 0000000..967d180 --- /dev/null +++ b/firmware/components/radar/include/radar.h @@ -0,0 +1,77 @@ +#pragma once + +/* + * AmbiSense v6 β€” radar abstraction. + * + * One unified frame type, one registry, one driver instantiated at boot + * based on the NVS `board.radar_kind` key. Drivers are linked into the + * binary unconditionally; selection is runtime so users can swap sensors + * via the web UI without reflashing. + * + * v6.0 drivers: + * ld2410 β€” HiLink LD2410(B/C), 1 target, distance + energy + * ld2412 β€” HiLink LD2412 (alias of ld2410 for now; same protocol family) + * ld2420 β€” HiLink LD2420 presence (alias of ld2410) + * ld2450 β€” HiLink LD2450, up to 3 targets with x/y/speed + * sim β€” synthetic trace generator for desk testing + */ + +#include +#include +#include "esp_err.h" +#include "freertos/FreeRTOS.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define RADAR_MAX_TARGETS 3 + +typedef struct { + bool present; /* primary target present */ + int16_t distance_cm; /* primary target distance, 0 if absent */ + int8_t direction; /* -1 closer, 0 still, +1 away */ + uint8_t energy; /* signal strength 0..100 (where exposed) */ + uint8_t target_count; /* 1 for ld2410-family, 0..3 for ld2450 */ + struct { + int16_t x_cm; /* lateral; left negative, right positive */ + int16_t y_cm; /* radial */ + int16_t v_cms; /* speed cm/s; +ve = away from sensor */ + uint16_t resolution_mm; /* LD2450 reports per-target resolution */ + } targets[RADAR_MAX_TARGETS]; + uint64_t ts_us; +} radar_frame_t; + +typedef struct { + uint8_t uart_num; + uint8_t rx_pin; + uint8_t tx_pin; + uint32_t baud; /* 256000 for LD-family */ +} radar_config_t; + +/* Set up the active driver from NVS-selected kind. Spawns a task that + * continuously parses radar frames and pushes them to an internal queue. */ +esp_err_t radar_init(const radar_config_t *cfg); + +/* Block until a frame is available or the timeout expires. */ +esp_err_t radar_read(radar_frame_t *out, TickType_t timeout); + +/* For the simulator driver β€” replay a scripted trace. */ +esp_err_t radar_sim_push_trace(const int16_t *distances_cm, size_t n, uint32_t period_ms); + +/* Diagnostics: helps debug "distance is always 0" β€” tells you whether + * UART bytes are even arriving from the radar. */ +typedef struct { + char driver_id[16]; + uint32_t total_bytes_rx; /* bytes read from UART since boot */ + uint32_t total_frames_parsed; /* successfully parsed radar frames */ + uint32_t last_frame_age_ms; /* 0 if never; UINT32_MAX if stale */ + uint8_t last_bytes[64]; /* most recent raw bytes for hex dump */ + size_t last_bytes_len; +} radar_diag_t; + +void radar_get_diag(radar_diag_t *out); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/radar/radar.c b/firmware/components/radar/radar.c new file mode 100644 index 0000000..f39b59a --- /dev/null +++ b/firmware/components/radar/radar.c @@ -0,0 +1,163 @@ +#include "radar.h" + +#include + +#include "esp_log.h" +#include "driver/uart.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/queue.h" + +#include "settings.h" + +static const char *TAG = "radar"; + +/* Driver function table β€” each driver implements parse(buf, len) β†’ frame. */ +typedef struct { + const char *id; + /* Read up to len bytes from UART; on a complete frame, fill out and + * return number of bytes consumed (>= 1). On partial frame return 0. */ + size_t (*parse)(const uint8_t *buf, size_t len, radar_frame_t *out); +} radar_driver_t; + +/* Forward decls β€” drivers live in radar_.c */ +extern size_t radar_ld2410_parse(const uint8_t *buf, size_t len, radar_frame_t *out); +extern size_t radar_ld2450_parse(const uint8_t *buf, size_t len, radar_frame_t *out); +extern size_t radar_sim_parse (const uint8_t *buf, size_t len, radar_frame_t *out); + +static const radar_driver_t k_drivers[] = { + { "ld2410", radar_ld2410_parse }, + { "ld2412", radar_ld2410_parse }, /* same family/protocol */ + { "ld2420", radar_ld2410_parse }, /* presence subset */ + { "ld2450", radar_ld2450_parse }, + { "sim", radar_sim_parse }, +}; + +static struct { + const radar_driver_t *drv; + QueueHandle_t q; + radar_config_t cfg; + bool inited; + /* Diagnostics β€” let users debug "distance always 0" by seeing whether + * UART bytes are arriving and frames are parsing. */ + uint32_t diag_bytes; + uint32_t diag_frames; + uint64_t diag_last_frame_us; + uint8_t diag_last[64]; + size_t diag_last_len; +} s_radar; + +void radar_get_diag(radar_diag_t *out) { + if (!out) return; + memset(out, 0, sizeof(*out)); + if (s_radar.drv) snprintf(out->driver_id, sizeof(out->driver_id), "%s", s_radar.drv->id); + out->total_bytes_rx = s_radar.diag_bytes; + out->total_frames_parsed = s_radar.diag_frames; + if (s_radar.diag_last_frame_us == 0) { + out->last_frame_age_ms = 0xFFFFFFFFu; + } else { + uint64_t now = (uint64_t)esp_timer_get_time(); + out->last_frame_age_ms = (uint32_t)((now - s_radar.diag_last_frame_us) / 1000ULL); + } + out->last_bytes_len = s_radar.diag_last_len; + memcpy(out->last_bytes, s_radar.diag_last, s_radar.diag_last_len); +} + +static const radar_driver_t *find_driver(const char *id) { + for (size_t i = 0; i < sizeof(k_drivers)/sizeof(k_drivers[0]); ++i) { + if (strcmp(k_drivers[i].id, id) == 0) return &k_drivers[i]; + } + return NULL; +} + +static void radar_task(void *arg) { + (void)arg; + static uint8_t rx[512]; + size_t held = 0; + while (1) { + if (held >= sizeof(rx)) { + /* Buffer wedged β€” discard half to keep parsing forward-progress. */ + memmove(rx, rx + sizeof(rx)/2, sizeof(rx)/2); + held = sizeof(rx)/2; + } + int n = uart_read_bytes(s_radar.cfg.uart_num, rx + held, + sizeof(rx) - held, pdMS_TO_TICKS(50)); + if (n > 0) { + held += n; + s_radar.diag_bytes += (uint32_t)n; + /* Keep a rolling window of the last 64 bytes for the hex dump. */ + size_t copy = (size_t)n > sizeof(s_radar.diag_last) ? sizeof(s_radar.diag_last) : (size_t)n; + if (copy < sizeof(s_radar.diag_last) && s_radar.diag_last_len > 0) { + size_t shift = sizeof(s_radar.diag_last) - copy; + size_t keep = s_radar.diag_last_len < shift ? s_radar.diag_last_len : shift; + memmove(s_radar.diag_last, s_radar.diag_last + (s_radar.diag_last_len - keep), keep); + s_radar.diag_last_len = keep; + } + if (s_radar.diag_last_len + copy > sizeof(s_radar.diag_last)) { + s_radar.diag_last_len = sizeof(s_radar.diag_last) - copy; + } + memcpy(s_radar.diag_last + s_radar.diag_last_len, rx + held - copy, copy); + s_radar.diag_last_len += copy; + if (s_radar.diag_last_len > sizeof(s_radar.diag_last)) + s_radar.diag_last_len = sizeof(s_radar.diag_last); + } + + radar_frame_t frame = {0}; + size_t consumed = s_radar.drv->parse(rx, held, &frame); + if (consumed > 0) { + xQueueOverwrite(s_radar.q, &frame); + if (frame.ts_us != 0) { + s_radar.diag_frames++; + s_radar.diag_last_frame_us = frame.ts_us; + } + if (consumed < held) memmove(rx, rx + consumed, held - consumed); + held -= consumed; + } + } +} + +esp_err_t radar_init(const radar_config_t *cfg) { + if (s_radar.inited) return ESP_OK; + + char kind[16] = {0}; + if (settings_get_radar_kind(kind, sizeof(kind)) != ESP_OK || !kind[0]) { + snprintf(kind, sizeof(kind), "ld2450"); /* user has this on bench */ + } + s_radar.drv = find_driver(kind); + if (!s_radar.drv) { + ESP_LOGE(TAG, "Unknown radar kind '%s'", kind); + return ESP_ERR_INVALID_ARG; + } + ESP_LOGI(TAG, "Radar driver: %s (uart%u, rx=%u, tx=%u, baud=%lu)", + s_radar.drv->id, cfg->uart_num, cfg->rx_pin, cfg->tx_pin, + (unsigned long)cfg->baud); + + s_radar.cfg = *cfg; + s_radar.q = xQueueCreate(1, sizeof(radar_frame_t)); + + /* The simulator driver doesn't need UART at all. */ + if (strcmp(s_radar.drv->id, "sim") != 0) { + const uart_config_t uc = { + .baud_rate = cfg->baud ? cfg->baud : 256000, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .source_clk = UART_SCLK_DEFAULT, + }; + ESP_ERROR_CHECK(uart_driver_install(cfg->uart_num, 1024, 0, 0, NULL, 0)); + ESP_ERROR_CHECK(uart_param_config(cfg->uart_num, &uc)); + ESP_ERROR_CHECK(uart_set_pin(cfg->uart_num, cfg->tx_pin, cfg->rx_pin, + UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE)); + } + + xTaskCreate(radar_task, "radar", 4096, NULL, 6, NULL); + s_radar.inited = true; + return ESP_OK; +} + +esp_err_t radar_read(radar_frame_t *out, TickType_t timeout) { + if (!s_radar.inited) return ESP_ERR_INVALID_STATE; + if (xQueueReceive(s_radar.q, out, timeout) != pdTRUE) return ESP_ERR_TIMEOUT; + return ESP_OK; +} diff --git a/firmware/components/radar/radar_ld2410.c b/firmware/components/radar/radar_ld2410.c new file mode 100644 index 0000000..a6ff815 --- /dev/null +++ b/firmware/components/radar/radar_ld2410.c @@ -0,0 +1,81 @@ +/* + * LD2410 / LD2412 / LD2420 frame parser. + * + * Frame layout (data report, "basic" mode, 23 bytes): + * F4 F3 F2 F1 header + * payload length (always 0x000D for basic) + * 02 data type: target data + * AA head + * 0=none, 1=moving, 2=stationary, 3=both + * cm + * 0..100 + * cm + * 0..100 + * cm + * 55 tail + * 00 checksum-ish + * F8 F7 F6 F5 trailer + * + * The LD2412 / LD2420 stream identical layout for the basic data report. + * If "engineering mode" is enabled the frame is longer; we just skip it. + */ + +#include +#include "esp_timer.h" + +#include "radar.h" + +static const uint8_t HEAD[4] = { 0xF4, 0xF3, 0xF2, 0xF1 }; +static const uint8_t TAIL[4] = { 0xF8, 0xF7, 0xF6, 0xF5 }; + +size_t radar_ld2410_parse(const uint8_t *buf, size_t len, radar_frame_t *out) { + /* Find header */ + for (size_t start = 0; start + 8 < len; ++start) { + if (memcmp(buf + start, HEAD, 4) != 0) continue; + if (start + 6 > len) return 0; /* need length bytes */ + uint16_t plen = buf[start + 4] | (buf[start + 5] << 8); + size_t total = 4 /*head*/ + 2 /*len*/ + plen + 4 /*tail*/; + if (start + total > len) return 0; /* incomplete */ + const uint8_t *p = buf + start + 6; + const uint8_t *tail = buf + start + 6 + plen; + if (memcmp(tail, TAIL, 4) != 0) { + /* Header without matching tail in expected position; advance one byte + * past header and try again on next loop iteration. */ + return start + 1; + } + /* Validate the basic-mode payload (type 02, head AA, body len 13). */ + if (plen >= 13 && p[0] == 0x02 && p[1] == 0xAA) { + uint8_t state = p[2]; + int16_t mov_dist = (int16_t)(p[3] | (p[4] << 8)); + uint8_t mov_e = p[5]; + int16_t sta_dist = (int16_t)(p[6] | (p[7] << 8)); + uint8_t sta_e = p[8]; + + int16_t dist = 0; + uint8_t energy = 0; + if (state & 0x01) { /* moving target */ + dist = mov_dist; + energy = mov_e; + } else if (state & 0x02) { /* stationary fallback */ + dist = sta_dist; + energy = sta_e; + } + + out->present = (state != 0) && dist > 0; + out->distance_cm = dist; + out->energy = energy; + out->target_count = out->present ? 1 : 0; + + /* Naive direction = sign of distance change vs last frame. */ + static int16_t s_last = 0; + int16_t delta = dist - s_last; + out->direction = delta < -3 ? -1 : (delta > 3 ? 1 : 0); + s_last = dist; + + out->ts_us = (uint64_t)esp_timer_get_time(); + } + return start + total; + } + /* No header in buffer (or only at very end) β€” keep last 3 bytes. */ + return len > 3 ? len - 3 : 0; +} diff --git a/firmware/components/radar/radar_ld2450.c b/firmware/components/radar/radar_ld2450.c new file mode 100644 index 0000000..ed94915 --- /dev/null +++ b/firmware/components/radar/radar_ld2450.c @@ -0,0 +1,93 @@ +/* + * LD2450 frame parser. 30-byte fixed frames at 256000 baud: + * + * AA FF 03 00 header + * T1: int16 x_mm, int16 y_mm, int16 v_cms, uint16 res_mm (8 B) + * T2: same (8 B) + * T3: same (8 B) + * 55 CC tail + * + * Sign convention is unusual: bit 15 indicates sign, but with the + * inverse meaning of two's complement. For each 16-bit field: + * if bit15 == 0 β†’ value = -(raw & 0x7FFF) + * if bit15 == 1 β†’ value = +(raw & 0x7FFF) + * So the top bit is "1 for positive, 0 for negative" β€” opposite of usual. + * + * Targets with all fields == 0 are absent. Up to 3 targets per frame. + */ + +#include +#include "esp_timer.h" + +#include "radar.h" + +static const uint8_t HEAD[4] = { 0xAA, 0xFF, 0x03, 0x00 }; +static const uint8_t TAIL[2] = { 0x55, 0xCC }; +#define LD2450_FRAME_LEN 30 + +static int16_t decode_signed(uint16_t raw) { + int16_t v = (int16_t)(raw & 0x7FFF); + return (raw & 0x8000) ? v : -v; +} + +size_t radar_ld2450_parse(const uint8_t *buf, size_t len, radar_frame_t *out) { + for (size_t start = 0; start + LD2450_FRAME_LEN <= len; ++start) { + if (memcmp(buf + start, HEAD, 4) != 0) continue; + const uint8_t *t = buf + start + LD2450_FRAME_LEN - 2; + if (t[0] != TAIL[0] || t[1] != TAIL[1]) { + return start + 1; /* false positive on header β€” skip 1 */ + } + const uint8_t *p = buf + start + 4; + uint8_t tcount = 0; + int16_t primary_distance = 0; + for (int i = 0; i < RADAR_MAX_TARGETS; ++i) { + uint16_t xr = p[0] | (p[1] << 8); + uint16_t yr = p[2] | (p[3] << 8); + uint16_t vr = p[4] | (p[5] << 8); + uint16_t rr = p[6] | (p[7] << 8); + p += 8; + if (xr == 0 && yr == 0 && vr == 0 && rr == 0) { + out->targets[i].x_cm = 0; + out->targets[i].y_cm = 0; + out->targets[i].v_cms = 0; + out->targets[i].resolution_mm = 0; + continue; + } + int16_t x_mm = decode_signed(xr); + int16_t y_mm = decode_signed(yr); + int16_t v_cs = decode_signed(vr); + out->targets[i].x_cm = x_mm / 10; + out->targets[i].y_cm = y_mm / 10; + out->targets[i].v_cms = v_cs; + out->targets[i].resolution_mm = rr; + tcount++; + if (tcount == 1) { + /* Primary distance = euclidean from origin in cm. */ + int32_t dx = (int32_t)(x_mm / 10); + int32_t dy = (int32_t)(y_mm / 10); + int32_t d2 = dx*dx + dy*dy; + /* Integer sqrt β€” fine for radar distance precision. */ + int32_t r = 0; int32_t b = 1L << 14; + while (b > d2) b >>= 2; + while (b > 0) { + if (d2 >= r + b) { d2 -= r + b; r = (r >> 1) + b; } + else r >>= 1; + b >>= 2; + } + primary_distance = (int16_t)r; + } + } + out->present = tcount > 0; + out->distance_cm = primary_distance; + out->target_count = tcount; + out->energy = 0; /* LD2450 doesn't report energy; left zero. */ + + static int16_t s_last = 0; + int16_t delta = primary_distance - s_last; + out->direction = delta < -3 ? -1 : (delta > 3 ? 1 : 0); + s_last = primary_distance; + out->ts_us = (uint64_t)esp_timer_get_time(); + return start + LD2450_FRAME_LEN; + } + return len > 3 ? len - 3 : 0; +} diff --git a/firmware/components/radar/radar_sim.c b/firmware/components/radar/radar_sim.c new file mode 100644 index 0000000..da77f31 --- /dev/null +++ b/firmware/components/radar/radar_sim.c @@ -0,0 +1,77 @@ +/* + * Synthetic radar driver β€” emits scripted distance traces or a default + * walk-up/walk-away sine wave. Lets us iterate on LED modes and mesh + * fusion without standing in front of a real radar all day. + * + * Configure via POST /api/sim/trace with a JSON array of cm distances. + * Until configured, uses a 4-second cycle 30β†’200 cm sine wave. + */ + +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "radar.h" + +static const char *TAG = "radar_sim"; + +#define MAX_TRACE 256 + +static struct { + int16_t trace[MAX_TRACE]; + size_t trace_len; + uint32_t period_ms; + int idx; + int16_t last; +} s_sim; + +esp_err_t radar_sim_push_trace(const int16_t *distances_cm, size_t n, uint32_t period_ms) { + if (n > MAX_TRACE) n = MAX_TRACE; + s_sim.trace_len = n; + if (n) memcpy(s_sim.trace, distances_cm, n * sizeof(int16_t)); + s_sim.period_ms = period_ms ? period_ms : 100; + s_sim.idx = 0; + ESP_LOGI(TAG, "Trace loaded: %u points @ %lu ms", (unsigned)n, (unsigned long)s_sim.period_ms); + return ESP_OK; +} + +/* The simulator never reads from UART; instead, the parser fakes a frame + * every time it's called. radar_task busy-loops on uart_read_bytes which + * returns 0 for sim β€” we add a small delay on each call. */ +size_t radar_sim_parse(const uint8_t *buf, size_t len, radar_frame_t *out) { + (void)buf; (void)len; + static uint64_t last_us = 0; + uint64_t now = (uint64_t)esp_timer_get_time(); + uint32_t step_ms = s_sim.period_ms ? s_sim.period_ms : 100; + if (last_us != 0 && (now - last_us) < (step_ms * 1000ULL)) { + return 0; + } + last_us = now; + + int16_t d; + if (s_sim.trace_len > 0) { + d = s_sim.trace[s_sim.idx]; + s_sim.idx = (s_sim.idx + 1) % s_sim.trace_len; + } else { + /* Default: 4 s cycle, 30..200 cm. */ + double t = (double)(now / 1000ULL) / 4000.0 * 2.0 * 3.14159265; + d = (int16_t)(115.0 + 85.0 * sin(t)); + } + + out->present = true; + out->distance_cm = d; + out->target_count = 1; + out->energy = 80; + out->direction = d < s_sim.last - 2 ? -1 : (d > s_sim.last + 2 ? 1 : 0); + s_sim.last = d; + out->targets[0].x_cm = 0; + out->targets[0].y_cm = d; + out->targets[0].v_cms = 0; + out->targets[0].resolution_mm = 100; + out->ts_us = now; + return 1; /* "consumed" any leftover bytes; radar_task moves on. */ +} diff --git a/firmware/components/settings/CMakeLists.txt b/firmware/components/settings/CMakeLists.txt new file mode 100644 index 0000000..f9a7418 --- /dev/null +++ b/firmware/components/settings/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "settings.c" + INCLUDE_DIRS "include" + REQUIRES nvs_flash log +) diff --git a/firmware/components/settings/include/settings.h b/firmware/components/settings/include/settings.h new file mode 100644 index 0000000..72be273 --- /dev/null +++ b/firmware/components/settings/include/settings.h @@ -0,0 +1,98 @@ +#pragma once + +/* + * AmbiSense v6 β€” settings facade over NVS. + * + * Replaces the v5 EEPROM byte-layout (config.h:150-217) with one NVS namespace + * per concern. Each module owns its namespace; this header only exposes the + * cross-cutting init + the board namespace (used by main during boot). + * + * Namespaces (one per concern, populated across PRs): + * sys β€” device_name, role-related keys + * board β€” board.id, pin overrides, radar_kind ← PR #1 (this file) + * led β€” count, brightness, rgb, mode, span, ... ← PR #3 + * dist β€” min_cm, max_cm ← PR #3 + * motion β€” pi smoother gains ← PR #3 + * mesh β€” peers blob, channel, encryption keys ← PR #4 + * topo β€” topology kind + segments blob ← PR #4 + * wifi β€” ssid, encrypted password, static ip ← PR #2 + * auth β€” pbkdf2 hash of admin password ← PR #2 + */ + +#include +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Initialize NVS partition. Erases and re-initializes if the partition + * is corrupted or its layout has changed (handled internally by IDF). */ +esp_err_t settings_init(void); + +/* ---- board namespace ---------------------------------------------------- + * Stores: board.id (string), board.led_pin (u8), board.radar_rx (u8), + * board.radar_tx (u8), board.button (u8), board.status (u8), + * board.radar_kind (string: "ld2410" | "ld2412" | "ld2420" | + * "ld2450" | "sim") + * + * On first boot every key is missing; callers fall back to the board + * profile defaults (see components/board). + */ + +/* Read the saved board id; copies up to `max` bytes into `out` (NUL-terminated). + * Returns ESP_ERR_NVS_NOT_FOUND if no value has ever been written. */ +esp_err_t settings_get_board_id(char *out, size_t max); +esp_err_t settings_set_board_id(const char *id); + +/* Pin overrides β€” a value of BOARD_PIN_NONE means "use board profile default". */ +esp_err_t settings_get_pin_override(const char *key, uint8_t *out); +esp_err_t settings_set_pin_override(const char *key, uint8_t pin); + +esp_err_t settings_get_radar_kind(char *out, size_t max); +esp_err_t settings_set_radar_kind(const char *kind); + +/* ---- wifi namespace ----------------------------------------------------- + * Stores: wifi.ssid (string), wifi.pass (string), wifi.hostname (string). + * Note: v6.0 stores creds plaintext in NVS. v6.x will move to chip-key AES. + */ +esp_err_t settings_get_wifi_ssid(char *out, size_t max); +esp_err_t settings_set_wifi_ssid(const char *ssid); +esp_err_t settings_get_wifi_pass(char *out, size_t max); +esp_err_t settings_set_wifi_pass(const char *pass); +esp_err_t settings_get_hostname(char *out, size_t max); +esp_err_t settings_set_hostname(const char *name); + +/* ---- sys namespace ------------------------------------------------------ + * Stores: sys.device_name (string). + */ +esp_err_t settings_get_device_name(char *out, size_t max); +esp_err_t settings_set_device_name(const char *name); + +/* ---- auth namespace ----------------------------------------------------- + * Stores: auth.pw_hash (32-byte PBKDF2-SHA256 hash), auth.pw_salt (16 bytes). + * Empty/missing means auth disabled. + */ +esp_err_t settings_get_auth_hash(uint8_t out_hash[32], uint8_t out_salt[16]); +esp_err_t settings_set_auth_hash(const uint8_t hash[32], const uint8_t salt[16]); +esp_err_t settings_clear_auth(void); + +/* ---- led namespace ------------------------------------------------------ + * All LED-engine settings as typed accessors. PR #3 owns these. + */ +esp_err_t settings_get_u32(const char *ns, const char *key, uint32_t *out); +esp_err_t settings_set_u32(const char *ns, const char *key, uint32_t v); +esp_err_t settings_get_i32(const char *ns, const char *key, int32_t *out); +esp_err_t settings_set_i32(const char *ns, const char *key, int32_t v); +esp_err_t settings_get_u8(const char *ns, const char *key, uint8_t *out); +esp_err_t settings_set_u8(const char *ns, const char *key, uint8_t v); +esp_err_t settings_get_blob(const char *ns, const char *key, void *out, size_t *len); +esp_err_t settings_set_blob(const char *ns, const char *key, const void *data, size_t len); +esp_err_t settings_get_str(const char *ns, const char *key, char *out, size_t max); +esp_err_t settings_set_str(const char *ns, const char *key, const char *val); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/settings/settings.c b/firmware/components/settings/settings.c new file mode 100644 index 0000000..82392c5 --- /dev/null +++ b/firmware/components/settings/settings.c @@ -0,0 +1,256 @@ +#include "settings.h" + +#include + +#include "esp_log.h" +#include "nvs_flash.h" +#include "nvs.h" + +static const char *TAG = "settings"; +static const char *NS_BOARD = "board"; + +esp_err_t settings_init(void) { + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_LOGW(TAG, "NVS partition needs reformat (err=0x%x); erasing", err); + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + if (err != ESP_OK) { + ESP_LOGE(TAG, "nvs_flash_init failed: 0x%x", err); + } + return err; +} + +/* Open helper that hides the open-mode boilerplate. */ +static esp_err_t open_ns(const char *ns, nvs_open_mode_t mode, nvs_handle_t *out) { + esp_err_t err = nvs_open(ns, mode, out); + if (err == ESP_ERR_NVS_NOT_FOUND && mode == NVS_READONLY) { + /* Namespace doesn't exist yet β€” that's fine for first-boot reads. */ + return ESP_ERR_NVS_NOT_FOUND; + } + if (err != ESP_OK) { + ESP_LOGW(TAG, "nvs_open(%s) failed: 0x%x", ns, err); + } + return err; +} + +esp_err_t settings_get_board_id(char *out, size_t max) { + if (!out || max == 0) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(NS_BOARD, NVS_READONLY, &h); + if (err != ESP_OK) return err; + size_t len = max; + err = nvs_get_str(h, "id", out, &len); + nvs_close(h); + return err; +} + +esp_err_t settings_set_board_id(const char *id) { + if (!id) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(NS_BOARD, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + err = nvs_set_str(h, "id", id); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + return err; +} + +esp_err_t settings_get_pin_override(const char *key, uint8_t *out) { + if (!key || !out) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(NS_BOARD, NVS_READONLY, &h); + if (err != ESP_OK) return err; + err = nvs_get_u8(h, key, out); + nvs_close(h); + return err; +} + +esp_err_t settings_set_pin_override(const char *key, uint8_t pin) { + if (!key) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(NS_BOARD, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + err = nvs_set_u8(h, key, pin); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + return err; +} + +esp_err_t settings_get_radar_kind(char *out, size_t max) { + if (!out || max == 0) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(NS_BOARD, NVS_READONLY, &h); + if (err != ESP_OK) return err; + size_t len = max; + err = nvs_get_str(h, "radar_kind", out, &len); + nvs_close(h); + return err; +} + +esp_err_t settings_set_radar_kind(const char *kind) { + if (!kind) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(NS_BOARD, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + err = nvs_set_str(h, "radar_kind", kind); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + return err; +} + +/* ---- generic typed accessors -------------------------------------------- */ + +esp_err_t settings_get_str(const char *ns, const char *key, char *out, size_t max) { + if (!ns || !key || !out || max == 0) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READONLY, &h); + if (err != ESP_OK) return err; + size_t len = max; + err = nvs_get_str(h, key, out, &len); + nvs_close(h); + return err; +} + +esp_err_t settings_set_str(const char *ns, const char *key, const char *val) { + if (!ns || !key || !val) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + err = nvs_set_str(h, key, val); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + return err; +} + +esp_err_t settings_get_u32(const char *ns, const char *key, uint32_t *out) { + if (!ns || !key || !out) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READONLY, &h); + if (err != ESP_OK) return err; + err = nvs_get_u32(h, key, out); + nvs_close(h); + return err; +} + +esp_err_t settings_set_u32(const char *ns, const char *key, uint32_t v) { + if (!ns || !key) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + err = nvs_set_u32(h, key, v); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + return err; +} + +esp_err_t settings_get_i32(const char *ns, const char *key, int32_t *out) { + if (!ns || !key || !out) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READONLY, &h); + if (err != ESP_OK) return err; + err = nvs_get_i32(h, key, out); + nvs_close(h); + return err; +} + +esp_err_t settings_set_i32(const char *ns, const char *key, int32_t v) { + if (!ns || !key) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + err = nvs_set_i32(h, key, v); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + return err; +} + +esp_err_t settings_get_u8(const char *ns, const char *key, uint8_t *out) { + if (!ns || !key || !out) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READONLY, &h); + if (err != ESP_OK) return err; + err = nvs_get_u8(h, key, out); + nvs_close(h); + return err; +} + +esp_err_t settings_set_u8(const char *ns, const char *key, uint8_t v) { + if (!ns || !key) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + err = nvs_set_u8(h, key, v); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + return err; +} + +esp_err_t settings_get_blob(const char *ns, const char *key, void *out, size_t *len) { + if (!ns || !key || !out || !len) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READONLY, &h); + if (err != ESP_OK) return err; + err = nvs_get_blob(h, key, out, len); + nvs_close(h); + return err; +} + +esp_err_t settings_set_blob(const char *ns, const char *key, const void *data, size_t len) { + if (!ns || !key || !data) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t err = open_ns(ns, NVS_READWRITE, &h); + if (err != ESP_OK) return err; + err = nvs_set_blob(h, key, data, len); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + return err; +} + +/* ---- wifi namespace shortcuts ------------------------------------------- */ +esp_err_t settings_get_wifi_ssid(char *o, size_t m) { return settings_get_str("wifi", "ssid", o, m); } +esp_err_t settings_set_wifi_ssid(const char *s) { return settings_set_str("wifi", "ssid", s); } +esp_err_t settings_get_wifi_pass(char *o, size_t m) { return settings_get_str("wifi", "pass", o, m); } +esp_err_t settings_set_wifi_pass(const char *p) { return settings_set_str("wifi", "pass", p); } +esp_err_t settings_get_hostname (char *o, size_t m) { return settings_get_str("wifi", "host", o, m); } +esp_err_t settings_set_hostname (const char *n) { return settings_set_str("wifi", "host", n); } + +/* ---- sys namespace shortcuts -------------------------------------------- */ +esp_err_t settings_get_device_name(char *o, size_t m) { return settings_get_str("sys", "name", o, m); } +esp_err_t settings_set_device_name(const char *n) { return settings_set_str("sys", "name", n); } + +/* ---- auth namespace ----------------------------------------------------- */ +esp_err_t settings_get_auth_hash(uint8_t hash[32], uint8_t salt[16]) { + nvs_handle_t h; + esp_err_t err = open_ns("auth", NVS_READONLY, &h); + if (err != ESP_OK) return err; + size_t hlen = 32, slen = 16; + err = nvs_get_blob(h, "pw_hash", hash, &hlen); + if (err == ESP_OK) err = nvs_get_blob(h, "pw_salt", salt, &slen); + nvs_close(h); + if (err == ESP_OK && (hlen != 32 || slen != 16)) return ESP_ERR_INVALID_SIZE; + return err; +} + +esp_err_t settings_set_auth_hash(const uint8_t hash[32], const uint8_t salt[16]) { + nvs_handle_t h; + esp_err_t err = open_ns("auth", NVS_READWRITE, &h); + if (err != ESP_OK) return err; + err = nvs_set_blob(h, "pw_hash", hash, 32); + if (err == ESP_OK) err = nvs_set_blob(h, "pw_salt", salt, 16); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + return err; +} + +esp_err_t settings_clear_auth(void) { + nvs_handle_t h; + esp_err_t err = open_ns("auth", NVS_READWRITE, &h); + if (err != ESP_OK) return err; + nvs_erase_key(h, "pw_hash"); + nvs_erase_key(h, "pw_salt"); + err = nvs_commit(h); + nvs_close(h); + return err; +} diff --git a/firmware/components/status_led/CMakeLists.txt b/firmware/components/status_led/CMakeLists.txt new file mode 100644 index 0000000..1c0f4b9 --- /dev/null +++ b/firmware/components/status_led/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "status_led.c" + INCLUDE_DIRS "include" + REQUIRES driver log freertos +) diff --git a/firmware/components/status_led/include/status_led.h b/firmware/components/status_led/include/status_led.h new file mode 100644 index 0000000..36ab39d --- /dev/null +++ b/firmware/components/status_led/include/status_led.h @@ -0,0 +1,54 @@ +#pragma once + +/* + * AmbiSense v6 β€” onboard status LED driver. + * + * Encodes system state as blink patterns on the board's onboard LED. A + * dedicated FreeRTOS task owns the GPIO; any subsystem can change pattern + * without coordinating with the LED loop. + * + * Patterns: + * OFF β€” fully dark. + * BOOT β€” solid on while bringing up subsystems. + * AP_MODE β€” slow blink (1 Hz) while waiting for Wi-Fi config. + * STA_MODE β€” heartbeat (two short pulses then pause) when connected. + * OTA β€” fast blink (5 Hz) during firmware update. + * ERROR β€” long-short-long pattern (recoverable error). + * PANIC β€” SOS pattern (Β· Β· Β· β€” β€” β€” Β· Β· Β·) before reset. + */ + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + STATUS_LED_OFF = 0, + STATUS_LED_BOOT, + STATUS_LED_AP_MODE, + STATUS_LED_STA_MODE, + STATUS_LED_OTA, + STATUS_LED_ERROR, + STATUS_LED_PANIC, + STATUS_LED_PAIRING, /* very fast blink β€” pairing window open */ + STATUS_LED_IDENTIFY, /* hard fast blink β€” short physical-locate burst */ +} status_led_pattern_t; + +/* gpio_num: BCM-style GPIO number; active_low: true if onboard LED sinks + * current (typical on C3 SuperMini onboard LED on GPIO 8). */ +esp_err_t status_led_init(uint8_t gpio_num, bool active_low); + +/* Switch pattern. Thread-safe; safe to call from any task. */ +void status_led_set_pattern(status_led_pattern_t pattern); + +/* Run `pattern` for `duration_ms`, then automatically revert to whatever + * the last stable pattern was. Used by mesh pairing/identify so callers + * don't have to track state and revert manually. */ +void status_led_oneshot(status_led_pattern_t pattern, uint32_t duration_ms); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/status_led/status_led.c b/firmware/components/status_led/status_led.c new file mode 100644 index 0000000..048f4bc --- /dev/null +++ b/firmware/components/status_led/status_led.c @@ -0,0 +1,167 @@ +#include "status_led.h" + +#include + +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "status_led"; + +static struct { + uint8_t gpio; + bool active_low; + bool inited; + /* `pattern` is the stable pattern. `oneshot` overrides it until + * `oneshot_until_us` passes; on expiry we revert to `pattern`. The LED + * task re-reads both atomics on every step, so callers don't need to + * coordinate. */ + _Atomic uint32_t pattern; + _Atomic uint32_t oneshot; + _Atomic uint64_t oneshot_until_us; +} s_led; + +static inline void led_set(bool on) { + int level = on ? (s_led.active_low ? 0 : 1) + : (s_led.active_low ? 1 : 0); + gpio_set_level(s_led.gpio, level); +} + +/* Each pattern is a sequence of (level, duration_ms) steps, repeated. We + * encode them inline rather than table-driven so the patterns are easy to + * read and tweak. */ +static void run_pattern(status_led_pattern_t p) { + switch (p) { + case STATUS_LED_OFF: + led_set(false); + vTaskDelay(pdMS_TO_TICKS(200)); + return; + + case STATUS_LED_BOOT: + led_set(true); + vTaskDelay(pdMS_TO_TICKS(200)); + return; + + case STATUS_LED_AP_MODE: + led_set(true); vTaskDelay(pdMS_TO_TICKS(500)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(500)); + return; + + case STATUS_LED_STA_MODE: { + /* Heartbeat: two short pulses then a longer dark pause. */ + led_set(true); vTaskDelay(pdMS_TO_TICKS(80)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(120)); + led_set(true); vTaskDelay(pdMS_TO_TICKS(80)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(1720)); + return; + } + + case STATUS_LED_OTA: + led_set(true); vTaskDelay(pdMS_TO_TICKS(100)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(100)); + return; + + case STATUS_LED_PAIRING: + /* 5 Hz fast blink β€” clearly distinguishable from STA/AP. */ + led_set(true); vTaskDelay(pdMS_TO_TICKS(100)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(100)); + return; + + case STATUS_LED_IDENTIFY: + /* 10 Hz hammer blink β€” unmistakable for "this is me". */ + led_set(true); vTaskDelay(pdMS_TO_TICKS(50)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(50)); + return; + + case STATUS_LED_ERROR: + led_set(true); vTaskDelay(pdMS_TO_TICKS(600)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(150)); + led_set(true); vTaskDelay(pdMS_TO_TICKS(150)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(150)); + led_set(true); vTaskDelay(pdMS_TO_TICKS(600)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(800)); + return; + + case STATUS_LED_PANIC: { + /* SOS Β· Β· Β· β€” β€” β€” Β· Β· Β· */ + for (int i = 0; i < 3; ++i) { + led_set(true); vTaskDelay(pdMS_TO_TICKS(120)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(180)); + } + vTaskDelay(pdMS_TO_TICKS(200)); + for (int i = 0; i < 3; ++i) { + led_set(true); vTaskDelay(pdMS_TO_TICKS(360)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(180)); + } + vTaskDelay(pdMS_TO_TICKS(200)); + for (int i = 0; i < 3; ++i) { + led_set(true); vTaskDelay(pdMS_TO_TICKS(120)); + led_set(false); vTaskDelay(pdMS_TO_TICKS(180)); + } + vTaskDelay(pdMS_TO_TICKS(800)); + return; + } + } +} + +static void status_led_task(void *arg) { + (void)arg; + while (1) { + uint64_t now = (uint64_t)esp_timer_get_time(); + uint64_t until = atomic_load(&s_led.oneshot_until_us); + status_led_pattern_t p; + if (until && now < until) { + p = (status_led_pattern_t)atomic_load(&s_led.oneshot); + } else { + if (until) atomic_store(&s_led.oneshot_until_us, 0ULL); + p = (status_led_pattern_t)atomic_load(&s_led.pattern); + } + run_pattern(p); + } +} + +esp_err_t status_led_init(uint8_t gpio_num, bool active_low) { + if (s_led.inited) return ESP_OK; + + s_led.gpio = gpio_num; + s_led.active_low = active_low; + atomic_store(&s_led.pattern, (uint32_t)STATUS_LED_BOOT); + atomic_store(&s_led.oneshot, (uint32_t)STATUS_LED_OFF); + atomic_store(&s_led.oneshot_until_us, 0ULL); + + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << gpio_num, + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_config(%u) failed: 0x%x", gpio_num, err); + return err; + } + led_set(false); + + BaseType_t ok = xTaskCreate(status_led_task, "status_led", 2048, NULL, 2, NULL); + if (ok != pdPASS) { + ESP_LOGE(TAG, "task create failed"); + return ESP_ERR_NO_MEM; + } + + s_led.inited = true; + ESP_LOGI(TAG, "status LED on GPIO %u (active_%s)", gpio_num, active_low ? "low" : "high"); + return ESP_OK; +} + +void status_led_set_pattern(status_led_pattern_t pattern) { + atomic_store(&s_led.pattern, (uint32_t)pattern); +} + +void status_led_oneshot(status_led_pattern_t pattern, uint32_t duration_ms) { + atomic_store(&s_led.oneshot, (uint32_t)pattern); + uint64_t until = (uint64_t)esp_timer_get_time() + (uint64_t)duration_ms * 1000ULL; + atomic_store(&s_led.oneshot_until_us, until); +} diff --git a/firmware/components/topology/CMakeLists.txt b/firmware/components/topology/CMakeLists.txt new file mode 100644 index 0000000..1957b1d --- /dev/null +++ b/firmware/components/topology/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "topology.c" + INCLUDE_DIRS "include" + REQUIRES settings esp_system log freertos +) diff --git a/firmware/components/topology/include/topology.h b/firmware/components/topology/include/topology.h new file mode 100644 index 0000000..e44cf77 --- /dev/null +++ b/firmware/components/topology/include/topology.h @@ -0,0 +1,72 @@ +#pragma once + +/* + * AmbiSense v6 β€” explicit topology model. + * + * Replaces v5's implicit-from-count model that broke asymmetric stairs. + * Each device knows the full topology: which devices exist, which LED + * range each owns, and (optionally) which radar distance window each + * sensor primarily covers. + * + * Stored in NVS namespace `topo` as a versioned blob; gossiped between + * peers via mesh CONFIG packets. The lowest-MAC-numbered device's + * config wins on ties. + */ + +#include +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define TOPO_MAX_SEGMENTS 8 +#define TOPO_MAC_LEN 6 + +typedef enum { + TOPO_STRAIGHT = 0, + TOPO_L_SHAPE = 1, + TOPO_U_SHAPE = 2, + TOPO_CUSTOM = 3, +} topology_kind_t; + +typedef struct __attribute__((packed)) { + uint8_t mac[TOPO_MAC_LEN]; /* device that owns this segment */ + uint16_t led_start; /* virtual LED address range, inclusive */ + uint16_t led_end; /* inclusive */ + uint16_t dist_min_cm; /* distance window this segment covers */ + uint16_t dist_max_cm; /* (0 = no window, fall through to global) */ + uint8_t reverse; /* 1 if strip is physically wired backwards */ + uint8_t reserved; +} topology_segment_t; + +typedef struct __attribute__((packed)) { + uint16_t version; /* monotonic; gossip wins higher version */ + uint8_t kind; /* topology_kind_t */ + uint8_t segment_count; + uint16_t total_leds; /* virtual address space size */ + uint16_t reserved; + topology_segment_t segments[TOPO_MAX_SEGMENTS]; +} topology_t; + +esp_err_t topology_init(void); + +/* Return a pointer to the in-RAM topology. Caller must not modify; use + * topology_set() to update. */ +const topology_t *topology_get(void); + +/* Replace the topology atomically. If `gossip` is true the mesh layer + * will broadcast the new version to peers. Persists to NVS. */ +esp_err_t topology_set(const topology_t *t, bool gossip); + +/* Helper: which segment does the given device own (by MAC), if any? */ +const topology_segment_t *topology_segment_for_mac(const uint8_t mac[TOPO_MAC_LEN]); + +/* Helper: which segment does this current device own? */ +const topology_segment_t *topology_my_segment(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/topology/topology.c b/firmware/components/topology/topology.c new file mode 100644 index 0000000..fc16601 --- /dev/null +++ b/firmware/components/topology/topology.c @@ -0,0 +1,92 @@ +#include "topology.h" + +#include + +#include "esp_log.h" +#include "esp_mac.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "settings.h" + +static const char *TAG = "topology"; + +static struct { + topology_t topo; + uint8_t my_mac[TOPO_MAC_LEN]; + SemaphoreHandle_t lock; + bool inited; +} s_t; + +/* Build a sane default topology for a single-device install: one segment, + * owned by us, covering the full distance range. */ +static void make_default(topology_t *out, const uint8_t mac[TOPO_MAC_LEN]) { + memset(out, 0, sizeof(*out)); + out->version = 1; + out->kind = TOPO_STRAIGHT; + out->segment_count = 1; + out->total_leds = 30; + memcpy(out->segments[0].mac, mac, TOPO_MAC_LEN); + out->segments[0].led_start = 0; + out->segments[0].led_end = 29; + out->segments[0].dist_min_cm = 0; + out->segments[0].dist_max_cm = 0; /* 0 = no window */ +} + +esp_err_t topology_init(void) { + if (s_t.inited) return ESP_OK; + s_t.lock = xSemaphoreCreateMutex(); + esp_read_mac(s_t.my_mac, ESP_MAC_WIFI_STA); + + size_t len = sizeof(s_t.topo); + if (settings_get_blob("topo", "blob", &s_t.topo, &len) != ESP_OK || + len != sizeof(s_t.topo) || + s_t.topo.segment_count == 0 || + s_t.topo.segment_count > TOPO_MAX_SEGMENTS) { + ESP_LOGI(TAG, "No saved topology; creating default (1 segment, this device)"); + make_default(&s_t.topo, s_t.my_mac); + settings_set_blob("topo", "blob", &s_t.topo, sizeof(s_t.topo)); + } else { + ESP_LOGI(TAG, "Loaded topology v%u: kind=%u segments=%u total=%u", + s_t.topo.version, s_t.topo.kind, s_t.topo.segment_count, s_t.topo.total_leds); + } + s_t.inited = true; + return ESP_OK; +} + +const topology_t *topology_get(void) { + return &s_t.topo; +} + +esp_err_t topology_set(const topology_t *t, bool gossip) { + if (!t || t->segment_count == 0 || t->segment_count > TOPO_MAX_SEGMENTS) { + return ESP_ERR_INVALID_ARG; + } + xSemaphoreTake(s_t.lock, portMAX_DELAY); + memcpy(&s_t.topo, t, sizeof(s_t.topo)); + s_t.topo.version++; + settings_set_blob("topo", "blob", &s_t.topo, sizeof(s_t.topo)); + xSemaphoreGive(s_t.lock); + + ESP_LOGI(TAG, "Topology set: v%u kind=%u segments=%u total=%u (gossip=%d)", + s_t.topo.version, s_t.topo.kind, s_t.topo.segment_count, + s_t.topo.total_leds, gossip); + /* mesh component listens for topology updates and gossips on its own; the + * `gossip` flag is exposed for callers like mesh_rx that should *not* + * trigger another gossip when applying a remote update. */ + (void)gossip; + return ESP_OK; +} + +const topology_segment_t *topology_segment_for_mac(const uint8_t mac[TOPO_MAC_LEN]) { + for (uint8_t i = 0; i < s_t.topo.segment_count; ++i) { + if (memcmp(s_t.topo.segments[i].mac, mac, TOPO_MAC_LEN) == 0) { + return &s_t.topo.segments[i]; + } + } + return NULL; +} + +const topology_segment_t *topology_my_segment(void) { + return topology_segment_for_mac(s_t.my_mac); +} diff --git a/firmware/components/webui/CMakeLists.txt b/firmware/components/webui/CMakeLists.txt new file mode 100644 index 0000000..b79e79e --- /dev/null +++ b/firmware/components/webui/CMakeLists.txt @@ -0,0 +1,9 @@ +idf_component_register( + SRCS "webui.c" + INCLUDE_DIRS "include" + REQUIRES settings auth netmgr ota board led_engine radar motion + topology mesh + esp_http_server esp_wifi esp_app_format esp_timer + json log freertos esp_system + EMBED_FILES "ui.html.gz" "ui.html" +) diff --git a/firmware/components/webui/include/webui.h b/firmware/components/webui/include/webui.h new file mode 100644 index 0000000..729bce3 --- /dev/null +++ b/firmware/components/webui/include/webui.h @@ -0,0 +1,46 @@ +#pragma once + +/* + * AmbiSense v6 β€” embedded web server. + * + * One esp_http_server instance with all /api/... routes plus root + captive- + * portal redirect endpoints (so iOS/Android pop the setup page in AP + * mode). PR #5 replaces the inline placeholder HTML with the full Preact + * UI served from LittleFS. + * + * Stub state hooks let PR #3/#4 push live data (distance, RSSI, mesh + * health) without webui having to depend on radar/mesh components. + */ + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t webui_init(void); + +/* Live state hooks. PR #2 publishes Wi-Fi telemetry; PR #3 publishes + * distance + radar; PR #4 publishes peer health. + * Updates are coalesced and emitted to all connected /api/live WS + * clients at ~5 Hz. */ +typedef struct { + int16_t distance_cm; /* smoothed + predicted */ + int16_t raw_cm; /* median-filtered but un-smoothed; lets the + * Motion screen draw raw vs smoothed without + * a second NVS round-trip */ + int8_t direction; /* -1, 0, +1 */ + int8_t rssi; + uint32_t free_heap; + uint32_t uptime_s; + uint8_t peer_count; + uint8_t peer_healthy; +} webui_live_t; + +void webui_publish_live(const webui_live_t *snap); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/webui/ui.html b/firmware/components/webui/ui.html new file mode 100644 index 0000000..85c1acb --- /dev/null +++ b/firmware/components/webui/ui.html @@ -0,0 +1,14 @@ + + + + + + + AmbiSense v6 + + + + +
+ + diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz new file mode 100644 index 0000000..4c69bf2 Binary files /dev/null and b/firmware/components/webui/ui.html.gz differ diff --git a/firmware/components/webui/webui.c b/firmware/components/webui/webui.c new file mode 100644 index 0000000..1969fa8 --- /dev/null +++ b/firmware/components/webui/webui.c @@ -0,0 +1,1158 @@ +#include "webui.h" + +#include +#include +#include +#include + +#include "esp_log.h" +#include "esp_http_server.h" +#include "esp_wifi.h" +#include "nvs_flash.h" +#include "esp_app_desc.h" +#include "esp_system.h" +#include "esp_idf_version.h" +#include "esp_mac.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include "cJSON.h" + +#include "settings.h" +#include "auth.h" +#include "netmgr.h" +#include "ota.h" +#include "board.h" +#include "led_engine.h" +#include "radar.h" +#include "topology.h" +#include "mesh.h" +#include "motion.h" + +static const char *TAG = "webui"; + +#define MAX_WS_CLIENTS 4 + +static struct { + httpd_handle_t srv; + int ws_fds[MAX_WS_CLIENTS]; + SemaphoreHandle_t lock; + webui_live_t latest; +} s_web; + +/* ============================================================ + * helpers + * ============================================================ */ + +static esp_err_t send_json(httpd_req_t *req, cJSON *root) { + char *out = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!out) return ESP_ERR_NO_MEM; + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + esp_err_t err = httpd_resp_send(req, out, strlen(out)); + free(out); + return err; +} + +static esp_err_t send_err(httpd_req_t *req, int code, const char *msg) { + char body[128]; + snprintf(body, sizeof(body), "{\"error\":\"%s\"}", msg); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_status(req, + code == 400 ? "400 Bad Request" : + code == 401 ? "401 Unauthorized" : + code == 404 ? "404 Not Found" : + code == 500 ? "500 Internal Server Error" : + "500 Internal Server Error"); + return httpd_resp_send(req, body, strlen(body)); +} + +static cJSON *read_body_json(httpd_req_t *req) { + if (req->content_len == 0 || req->content_len > 4096) return NULL; + char *buf = malloc(req->content_len + 1); + if (!buf) return NULL; + int got = 0; + while (got < (int)req->content_len) { + int n = httpd_req_recv(req, buf + got, req->content_len - got); + if (n <= 0) { free(buf); return NULL; } + got += n; + } + buf[got] = 0; + cJSON *j = cJSON_Parse(buf); + free(buf); + return j; +} + +static bool extract_session_token(httpd_req_t *req, char *out, size_t max) { + char hdr[256]; + if (httpd_req_get_hdr_value_str(req, "Cookie", hdr, sizeof(hdr)) != ESP_OK) return false; + /* Find ambisense= */ + const char *p = strstr(hdr, "ambisense="); + if (!p) return false; + p += strlen("ambisense="); + size_t i = 0; + while (*p && *p != ';' && *p != ' ' && i < max - 1) out[i++] = *p++; + out[i] = 0; + return i > 0; +} + +static bool gate_auth(httpd_req_t *req) { + if (!auth_is_enabled()) return true; + char tok[AUTH_TOKEN_HEX_LEN + 1]; + if (!extract_session_token(req, tok, sizeof(tok))) { + send_err(req, 401, "auth required"); + return false; + } + if (!auth_check_session(tok)) { + send_err(req, 401, "invalid session"); + return false; + } + return true; +} + +/* ============================================================ + * Captive-portal redirect endpoints + * + * iOS hits captive.apple.com / hotspot-detect.html / library/test/... + * Android hits connectivitycheck.gstatic.com / generate_204 + * Win 11 hits msftconnecttest.com/connecttest.txt + * All return a 302 redirect to our root so the OS pops the setup page. + * ============================================================ */ + +static esp_err_t handle_captive_redirect(httpd_req_t *req) { + char ip[32] = "192.168.4.1"; + netmgr_get_ip(ip, sizeof(ip)); + char loc[64]; + snprintf(loc, sizeof(loc), "http://%s/", ip); + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", loc); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; +} + +/* ============================================================ + * Root β€” serves the embedded gzipped Preact UI built from + * frontend/. The blob lives in main/ui.html.gz, embedded via the + * EMBED_FILES directive in main/CMakeLists.txt. Symbols below are + * generated by the IDF build. + * ============================================================ */ + +extern const uint8_t _binary_ui_html_gz_start[] asm("_binary_ui_html_gz_start"); +extern const uint8_t _binary_ui_html_gz_end[] asm("_binary_ui_html_gz_end"); +extern const uint8_t _binary_ui_html_start[] asm("_binary_ui_html_start"); +extern const uint8_t _binary_ui_html_end[] asm("_binary_ui_html_end"); + +/* Serve the embedded UI, sniffing Accept-Encoding so clients that don't + * advertise gzip support get the raw HTML. Some captive-portal detects and + * a small handful of mobile browser edge cases strip Accept-Encoding for + * private-network LAN requests; serving raw to those keeps the UI working. */ +static esp_err_t handle_root_real(httpd_req_t *req) { + char ae[80] = {0}; + bool accepts_gzip = false; + if (httpd_req_get_hdr_value_str(req, "Accept-Encoding", ae, sizeof(ae)) == ESP_OK) { + accepts_gzip = (strstr(ae, "gzip") != NULL); + } + + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "max-age=300, public"); + + /* esp_http_server's single-call httpd_resp_send truncates ~16KB responses + * on the C3 because the underlying tx buffer can't accept it all at once. + * Break large bodies into chunks. Tested: 50 KB raw HTML now delivers + * intact instead of cutting off at ~15.8 KB. */ + const uint8_t *body; + size_t total; + if (accepts_gzip) { + httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + body = _binary_ui_html_gz_start; + total = _binary_ui_html_gz_end - _binary_ui_html_gz_start; + } else { + body = _binary_ui_html_start; + total = _binary_ui_html_end - _binary_ui_html_start; + } + + const size_t CHUNK = 4096; + size_t sent = 0; + while (sent < total) { + size_t n = total - sent; + if (n > CHUNK) n = CHUNK; + esp_err_t err = httpd_resp_send_chunk(req, (const char *)(body + sent), n); + if (err != ESP_OK) return err; + sent += n; + } + return httpd_resp_send_chunk(req, NULL, 0); /* terminator */ +} + +#define handle_root handle_root_real + +#if 0 /* legacy inline placeholder retained for reference */ +static const char k_placeholder_html[] = +"\n" +"\n" +"\n" +" \n" +" \n" +" AmbiSense v6\n" +" \n" +"\n" +"\n" +"
\n" +"

AmbiSense v6

\n" +"
Setup & status. The full UI ships in the next firmware update.
\n" +"
Authentication is …. Set a password under System > Security after Wi-Fi setup.
\n" +"\n" +"
\n" +"

Wi-Fi

\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
\n" +"
\n" +"\n" +"
\n" +"

Device

\n" +"
Firmware…
\n" +"
Board…
\n" +"
IP…
\n" +"
Hostname…
\n" +"
Free heap…
\n" +"
Uptime…
\n" +"
\n" +"\n" +"
\n" +"

Firmware update (OTA)

\n" +" \n" +" \n" +"
\n" +"
\n" +"
\n" +"\n" +"\n"; +#endif + +/* ============================================================ + * /api/ping β€” tiny health-check; lets a stuck client confirm the + * server is responsive even if HTML serving has gone wrong. + * ============================================================ */ +static esp_err_t handle_ping(httpd_req_t *req) { + httpd_resp_set_type(req, "text/plain"); + return httpd_resp_send(req, "pong", 4); +} + +/* /api/system β€” global enable/disable. When disabled, LED engine renders + * black, mesh keeps running but no light comes out. NVS sys.enabled. */ +static esp_err_t handle_system_get(httpd_req_t *req) { + uint8_t en = 1; + settings_get_u8("sys", "enabled", &en); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "enabled", en != 0); + return send_json(req, r); +} +static esp_err_t handle_system_post(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + cJSON *j = read_body_json(req); + if (!j) return send_err(req, 400, "bad json"); + cJSON *e = cJSON_GetObjectItem(j, "enabled"); + if (e) settings_set_u8("sys", "enabled", cJSON_IsTrue(e) ? 1 : 0); + cJSON_Delete(j); + return handle_system_get(req); +} + +/* /api/factory_reset β€” erase NVS, then reboot. Used by System tab's + * "type-the-hostname" confirmation. */ +static void _factory_task(void *arg) { + (void)arg; + vTaskDelay(pdMS_TO_TICKS(500)); + nvs_flash_erase(); + esp_restart(); +} +static esp_err_t handle_factory_reset(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddStringToObject(r, "note", "erasing NVS and rebooting"); + send_json(req, r); + xTaskCreate(_factory_task, "factory", 2048, NULL, 5, NULL); + return ESP_OK; +} + +/* /api/reboot β€” schedule restart so the response can flush first. */ +static void _reboot_task(void *arg) { (void)arg; vTaskDelay(pdMS_TO_TICKS(500)); esp_restart(); } +static esp_err_t handle_reboot(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddStringToObject(r, "note", "rebooting in 500 ms"); + send_json(req, r); + xTaskCreate(_reboot_task, "reboot", 2048, NULL, 5, NULL); + return ESP_OK; +} + +/* ============================================================ + * /api/version + * ============================================================ */ +static esp_err_t handle_version(httpd_req_t *req) { + const esp_app_desc_t *app = esp_app_get_description(); + cJSON *r = cJSON_CreateObject(); + + cJSON_AddStringToObject(r, "version", app->version); + cJSON_AddStringToObject(r, "idf_version", app->idf_ver); + cJSON_AddStringToObject(r, "build_date", app->date); + cJSON_AddStringToObject(r, "build_time", app->time); + cJSON_AddStringToObject(r, "target", CONFIG_IDF_TARGET); + + cJSON_AddNumberToObject(r, "free_heap", esp_get_free_heap_size()); + cJSON_AddNumberToObject(r, "min_free_heap", esp_get_minimum_free_heap_size()); + cJSON_AddNumberToObject(r, "uptime_s", (uint32_t)(esp_timer_get_time() / 1000000)); + + char buf[64]; + if (netmgr_get_ip(buf, sizeof(buf)) == ESP_OK) cJSON_AddStringToObject(r, "ip", buf); + if (netmgr_get_hostname(buf, sizeof(buf)) == ESP_OK) cJSON_AddStringToObject(r, "hostname", buf); + cJSON_AddNumberToObject(r, "rssi", netmgr_get_rssi()); + cJSON_AddBoolToObject(r, "sta_connected", netmgr_is_sta_connected()); + cJSON_AddBoolToObject(r, "auth_enabled", auth_is_enabled()); + + char board_id[32] = {0}; + if (settings_get_board_id(board_id, sizeof(board_id)) != ESP_OK) { + const board_profile_t *def = board_default_profile(); + if (def) snprintf(board_id, sizeof(board_id), "%s", def->id); + } + cJSON_AddStringToObject(r, "board", board_id); + + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + char macstr[18]; + snprintf(macstr, sizeof(macstr), "%02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + cJSON_AddStringToObject(r, "mac", macstr); + + return send_json(req, r); +} + +/* ============================================================ + * /api/wifi/scan, /api/wifi + * ============================================================ */ +static esp_err_t handle_wifi_scan(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + + wifi_scan_config_t cfg = {0}; + cfg.show_hidden = false; + esp_err_t err = esp_wifi_scan_start(&cfg, true); + if (err != ESP_OK) return send_err(req, 500, "scan failed"); + + uint16_t n = 0; + esp_wifi_scan_get_ap_num(&n); + if (n > 30) n = 30; + wifi_ap_record_t *aps = calloc(n, sizeof(*aps)); + if (!aps) return send_err(req, 500, "oom"); + esp_wifi_scan_get_ap_records(&n, aps); + + cJSON *r = cJSON_CreateObject(); + cJSON *arr = cJSON_AddArrayToObject(r, "networks"); + for (uint16_t i = 0; i < n; ++i) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "ssid", (const char *)aps[i].ssid); + cJSON_AddNumberToObject(o, "rssi", aps[i].rssi); + cJSON_AddNumberToObject(o, "channel", aps[i].primary); + cJSON_AddBoolToObject(o, "secure", aps[i].authmode != WIFI_AUTH_OPEN); + cJSON_AddItemToArray(arr, o); + } + free(aps); + return send_json(req, r); +} + +/* Apply Wi-Fi creds on a separate task so the HTTP response can flush before + * STA disconnects. The struct + task pair below is the deferred-apply path. */ +struct wifi_apply_args { char ssid[33]; char pass[65]; }; +static void wifi_apply_task(void *arg) { + struct wifi_apply_args *a = arg; + vTaskDelay(pdMS_TO_TICKS(500)); + netmgr_set_credentials(a->ssid, a->pass); + free(a); + vTaskDelete(NULL); +} + +static esp_err_t handle_wifi_get(httpd_req_t *req) { + cJSON *r = cJSON_CreateObject(); + char ssid[33] = {0}; + settings_get_wifi_ssid(ssid, sizeof(ssid)); + cJSON_AddStringToObject(r, "ssid", ssid); + cJSON_AddBoolToObject (r, "sta_configured", ssid[0] != 0); + cJSON_AddBoolToObject (r, "sta_connected", netmgr_is_sta_connected()); + cJSON_AddBoolToObject (r, "ap_active", netmgr_is_ap_active()); + netmgr_ap_mode_t m = netmgr_get_ap_mode(); + cJSON_AddStringToObject(r, "ap_mode", + m == NETMGR_AP_ALWAYS ? "always" : + m == NETMGR_AP_STA_ONLY ? "sta_only" : "auto"); + cJSON_AddNumberToObject(r, "rssi", netmgr_get_rssi()); + char ip[32] = {0}, host[33] = {0}; + netmgr_get_ip(ip, sizeof(ip)); + netmgr_get_hostname(host, sizeof(host)); + cJSON_AddStringToObject(r, "ip", ip); + cJSON_AddStringToObject(r, "hostname", host); + return send_json(req, r); +} + +static esp_err_t handle_wifi_post(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + cJSON *j = read_body_json(req); + if (!j) return send_err(req, 400, "bad json"); + + /* Optional sub-settings β€” applied independently of ssid change. */ + cJSON *host = cJSON_GetObjectItem(j, "hostname"); + if (host && cJSON_IsString(host) && host->valuestring[0]) { + netmgr_set_hostname(host->valuestring); + } + cJSON *apmode = cJSON_GetObjectItem(j, "ap_mode"); + if (apmode && cJSON_IsString(apmode)) { + netmgr_ap_mode_t m = NETMGR_AP_AUTO; + if (strcmp(apmode->valuestring, "always") == 0) m = NETMGR_AP_ALWAYS; + else if (strcmp(apmode->valuestring, "sta_only") == 0) m = NETMGR_AP_STA_ONLY; + netmgr_set_ap_mode(m); + } + cJSON *appass = cJSON_GetObjectItem(j, "ap_password"); + if (appass && cJSON_IsString(appass)) { + netmgr_set_ap_password(appass->valuestring); + } + cJSON *clear = cJSON_GetObjectItem(j, "forget_sta"); + if (clear && cJSON_IsTrue(clear)) { + netmgr_set_credentials(NULL, NULL); + cJSON_Delete(j); + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "status", "STA cleared; AP up for re-setup"); + return send_json(req, r); + } + + cJSON *ssid = cJSON_GetObjectItem(j, "ssid"); + cJSON *pass = cJSON_GetObjectItem(j, "pass"); + if (ssid && cJSON_IsString(ssid) && ssid->valuestring[0]) { + const char *p = (pass && cJSON_IsString(pass)) ? pass->valuestring : ""; + + struct wifi_apply_args *a = calloc(1, sizeof(*a)); + if (!a) { cJSON_Delete(j); return send_err(req, 500, "oom"); } + snprintf(a->ssid, sizeof(a->ssid), "%s", ssid->valuestring); + snprintf(a->pass, sizeof(a->pass), "%s", p); + + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "status", "saved; reconnecting"); + cJSON_AddStringToObject(r, "note", "AP remains available during STA join"); + send_json(req, r); + cJSON_Delete(j); + xTaskCreate(wifi_apply_task, "wifi_apply", 4096, a, 4, NULL); + return ESP_OK; + } + /* No ssid in request β€” caller is just updating sub-settings (ap_mode, + * ap_password, hostname). That's a valid no-op for the STA side. */ + cJSON_Delete(j); + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "status", "ok"); + return send_json(req, r); +} + +/* ============================================================ + * /api/auth/login, /api/auth/logout, /api/auth/password + * ============================================================ */ +static esp_err_t handle_login(httpd_req_t *req) { + cJSON *j = read_body_json(req); + if (!j) return send_err(req, 400, "bad json"); + cJSON *p = cJSON_GetObjectItem(j, "password"); + if (!p || !cJSON_IsString(p)) { cJSON_Delete(j); return send_err(req, 400, "password required"); } + bool ok = auth_check_password(p->valuestring); + cJSON_Delete(j); + if (!ok) return send_err(req, 401, "wrong password"); + + char tok[AUTH_TOKEN_HEX_LEN]; + auth_issue_session(tok); + char cookie[160]; + snprintf(cookie, sizeof(cookie), + "ambisense=%s; Path=/; Max-Age=86400; SameSite=Strict; HttpOnly", tok); + httpd_resp_set_hdr(req, "Set-Cookie", cookie); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + return send_json(req, r); +} + +static esp_err_t handle_logout(httpd_req_t *req) { + char tok[AUTH_TOKEN_HEX_LEN + 1]; + if (extract_session_token(req, tok, sizeof(tok))) auth_revoke(tok); + httpd_resp_set_hdr(req, "Set-Cookie", "ambisense=; Path=/; Max-Age=0"); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + return send_json(req, r); +} + +static esp_err_t handle_set_password(httpd_req_t *req) { + if (auth_is_enabled() && !gate_auth(req)) return ESP_OK; + cJSON *j = read_body_json(req); + if (!j) return send_err(req, 400, "bad json"); + cJSON *p = cJSON_GetObjectItem(j, "password"); + const char *pw = (p && cJSON_IsString(p)) ? p->valuestring : NULL; + esp_err_t err = auth_set_password(pw); + cJSON_Delete(j); + if (err == ESP_ERR_INVALID_ARG) return send_err(req, 400, "min 8 chars"); + if (err != ESP_OK) return send_err(req, 500, "save failed"); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddBoolToObject(r, "auth_enabled", auth_is_enabled()); + return send_json(req, r); +} + +/* ============================================================ + * /api/board/profiles, /api/board, /api/radar/kinds + * ============================================================ */ +static esp_err_t handle_board_profiles(httpd_req_t *req) { + size_t n = 0; + const board_profile_t *first = board_profiles(&n); + /* board_profiles returns the first profile pointer; we iterate via the + * static array in board.c β€” but its layout isn't exposed. Fall back to + * iterating known ids: we re-look-up each via board_profile_by_id. */ + static const char *ids[] = { + "esp32c3-supermini", "esp32-devkit", "esp32s3-zero", "esp32c6-devkit" + }; + cJSON *r = cJSON_CreateObject(); + cJSON *arr = cJSON_AddArrayToObject(r, "profiles"); + (void)first; + for (size_t i = 0; i < sizeof(ids)/sizeof(ids[0]); ++i) { + const board_profile_t *p = board_profile_by_id(ids[i]); + if (!p) continue; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "id", p->id); + cJSON_AddStringToObject(o, "display", p->display); + cJSON_AddStringToObject(o, "mcu", p->mcu); + cJSON_AddBoolToObject (o, "validated", p->validated); + cJSON_AddNumberToObject(o, "led_pin", p->led_pin); + cJSON_AddNumberToObject(o, "radar_rx", p->radar_rx_pin); + cJSON_AddNumberToObject(o, "radar_tx", p->radar_tx_pin); + cJSON_AddNumberToObject(o, "button", p->button_pin); + cJSON_AddNumberToObject(o, "status_led", p->status_led_pin); + cJSON_AddNumberToObject(o, "max_gpio", p->max_gpio); + /* Encode unsafe pins as an array of pin numbers up to max_gpio. */ + cJSON *unsafe = cJSON_AddArrayToObject(o, "unsafe"); + for (uint8_t pin = 0; pin <= p->max_gpio; ++pin) { + if (board_pin_is_unsafe(p, pin)) cJSON_AddItemToArray(unsafe, cJSON_CreateNumber(pin)); + } + cJSON_AddItemToArray(arr, o); + } + /* Active profile id */ + char active[32] = {0}; + if (settings_get_board_id(active, sizeof(active)) != ESP_OK) { + const board_profile_t *def = board_default_profile(); + if (def) snprintf(active, sizeof(active), "%s", def->id); + } + cJSON_AddStringToObject(r, "active", active); + return send_json(req, r); +} + +static esp_err_t handle_board_post(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + cJSON *j = read_body_json(req); + if (!j) return send_err(req, 400, "bad json"); + cJSON *id = cJSON_GetObjectItem(j, "id"); + if (id && cJSON_IsString(id)) { + const board_profile_t *p = board_profile_by_id(id->valuestring); + if (!p) { cJSON_Delete(j); return send_err(req, 400, "unknown board id"); } + settings_set_board_id(p->id); + } + /* Both JSON-key forms accepted (legacy + canonical), all map to the + * same NVS keys below. JSON uses "button_pin"/"status_led_pin" to be + * consistent with /api/settings GET; older clients sending + * "button"/"status_led" still work. */ + static const struct { const char *json_a; const char *json_b; const char *nvs; } M[] = { + { "led_pin", NULL, "led_pin" }, + { "radar_rx", NULL, "radar_rx" }, + { "radar_tx", NULL, "radar_tx" }, + { "button_pin", "button", "button" }, + { "status_led_pin", "status_led", "status_led" }, + }; + for (size_t i = 0; i < sizeof(M)/sizeof(M[0]); ++i) { + cJSON *v = cJSON_GetObjectItem(j, M[i].json_a); + if (!v && M[i].json_b) v = cJSON_GetObjectItem(j, M[i].json_b); + if (v && cJSON_IsNumber(v)) { + uint8_t pin = (uint8_t)v->valueint; + settings_set_pin_override(M[i].nvs, pin); + } + } + cJSON *rk = cJSON_GetObjectItem(j, "radar_kind"); + if (rk && cJSON_IsString(rk)) settings_set_radar_kind(rk->valuestring); + + cJSON_Delete(j); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddStringToObject(r, "note", "reboot to apply"); + return send_json(req, r); +} + +static esp_err_t handle_radar_kinds(httpd_req_t *req) { + cJSON *r = cJSON_CreateObject(); + cJSON *arr = cJSON_AddArrayToObject(r, "kinds"); + static const struct { const char *id; const char *display; bool xy; const char *note; } K[] = { + { "ld2410", "HiLink LD2410(B/C)", false, "single-target distance + presence (24 GHz)" }, + { "ld2412", "HiLink LD2412", false, "per-gate sensitivity tunable (24 GHz)" }, + { "ld2420", "HiLink LD2420", false, "presence only (24 GHz)" }, + { "ld2450", "HiLink LD2450", true, "up to 3 targets, x/y/speed (24 GHz)" }, + { "sim", "Simulator", true, "synthetic distance traces for testing" }, + }; + for (size_t i = 0; i < sizeof(K)/sizeof(K[0]); ++i) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "id", K[i].id); + cJSON_AddStringToObject(o, "display", K[i].display); + cJSON_AddBoolToObject (o, "provides_xy", K[i].xy); + cJSON_AddStringToObject(o, "note", K[i].note); + cJSON_AddItemToArray(arr, o); + } + char active[16] = {0}; + settings_get_radar_kind(active, sizeof(active)); + cJSON_AddStringToObject(r, "active", active[0] ? active : "ld2410"); + return send_json(req, r); +} + +/* ============================================================ + * /api/settings β€” flat read of every NVS namespace; PR #3/#4 expand the writer + * ============================================================ */ + +static void add_str_if(cJSON *j, const char *ns, const char *key, const char *json_key) { + char buf[80]; + if (settings_get_str(ns, key, buf, sizeof(buf)) == ESP_OK) { + cJSON_AddStringToObject(j, json_key, buf); + } +} +static void add_u32_if(cJSON *j, const char *ns, const char *key, const char *json_key) { + uint32_t v; + if (settings_get_u32(ns, key, &v) == ESP_OK) cJSON_AddNumberToObject(j, json_key, v); +} +static void add_i32_if(cJSON *j, const char *ns, const char *key, const char *json_key) { + int32_t v; + if (settings_get_i32(ns, key, &v) == ESP_OK) cJSON_AddNumberToObject(j, json_key, v); +} +static void add_u8_if(cJSON *j, const char *ns, const char *key, const char *json_key) { + uint8_t v; + if (settings_get_u8(ns, key, &v) == ESP_OK) cJSON_AddNumberToObject(j, json_key, v); +} + +static esp_err_t handle_settings_get(httpd_req_t *req) { + cJSON *r = cJSON_CreateObject(); + + add_str_if(r, "sys", "name", "device_name"); + add_str_if(r, "wifi", "host", "hostname"); + add_str_if(r, "wifi", "ssid", "wifi_ssid"); + + add_str_if(r, "board", "id", "board_id"); + add_str_if(r, "board", "radar_kind", "radar_kind"); + add_u8_if (r, "board", "led_pin", "led_pin"); + add_u8_if (r, "board", "radar_rx", "radar_rx"); + add_u8_if (r, "board", "radar_tx", "radar_tx"); + add_u8_if (r, "board", "button", "button_pin"); + add_u8_if (r, "board", "status_led", "status_led_pin"); + + /* LED settings (PR #3 will start writing these) */ + add_u32_if(r, "led", "count", "led_count"); + add_u8_if (r, "led", "br", "brightness"); + add_u8_if (r, "led", "r", "r"); + add_u8_if (r, "led", "g", "g"); + add_u8_if (r, "led", "b", "b"); + add_u8_if (r, "led", "mode", "light_mode"); + add_u32_if(r, "led", "span", "span"); + add_i32_if(r, "led", "ctr", "center_shift"); + add_u8_if (r, "led", "trail", "trail"); + add_u8_if (r, "led", "dirlt", "dir_light"); + add_u8_if (r, "led", "bg", "bg_mode"); + add_u8_if (r, "led", "espd", "effect_speed"); + add_u8_if (r, "led", "eint", "effect_intensity"); + + /* Distance window */ + add_u32_if(r, "dist", "min", "min_distance"); + add_u32_if(r, "dist", "max", "max_distance"); + + /* Motion smoothing β€” v3 simplified knobs (mode/response/look-ahead/outlier) + * plus the legacy PI gains kept as advanced overrides. */ + add_str_if(r, "motion", "mode", "motion_mode"); + add_u8_if (r, "motion", "en", "motion_enabled"); + add_u8_if (r, "motion", "resp", "response"); + add_u32_if(r, "motion", "la_ms", "look_ahead_ms"); + add_u8_if (r, "motion", "outl", "outlier_strength"); + add_u32_if(r, "motion", "ps", "pos_smooth_x1k"); + add_u32_if(r, "motion", "vs", "vel_smooth_x1k"); + add_u32_if(r, "motion", "pf", "predict_x1k"); + add_u32_if(r, "motion", "pg", "p_gain_x1k"); + add_u32_if(r, "motion", "ig", "i_gain_x1k"); + + /* Topology */ + add_u8_if (r, "topo", "kind", "topology"); + add_u32_if(r, "topo", "tot", "total_leds"); + + cJSON_AddBoolToObject(r, "auth_enabled", auth_is_enabled()); + return send_json(req, r); +} + +static const struct setting_map { + const char *json_key; + const char *ns; + const char *nvs_key; + char type; /* 's' string, '8', '4' u32/i32, 'i' int8 */ +} SETTINGS[] = { + { "device_name", "sys", "name", 's' }, + { "hostname", "wifi", "host", 's' }, + { "led_count", "led", "count", '4' }, + { "brightness", "led", "br", '8' }, + { "r", "led", "r", '8' }, + { "g", "led", "g", '8' }, + { "b", "led", "b", '8' }, + { "light_mode", "led", "mode", '8' }, + { "span", "led", "span", '4' }, + { "center_shift", "led", "ctr", 'i' }, + { "trail", "led", "trail", '8' }, + { "dir_light", "led", "dirlt", '8' }, + { "bg_mode", "led", "bg", '8' }, + { "effect_speed", "led", "espd", '8' }, + { "effect_intensity", "led", "eint", '8' }, + { "min_distance", "dist", "min", '4' }, + { "max_distance", "dist", "max", '4' }, + { "motion_mode", "motion", "mode", 's' }, + { "motion_enabled", "motion", "en", '8' }, + { "response", "motion", "resp", '8' }, + { "look_ahead_ms", "motion", "la_ms", '4' }, + { "outlier_strength", "motion", "outl", '8' }, + { "pos_smooth_x1k", "motion", "ps", '4' }, + { "vel_smooth_x1k", "motion", "vs", '4' }, + { "predict_x1k", "motion", "pf", '4' }, + { "p_gain_x1k", "motion", "pg", '4' }, + { "i_gain_x1k", "motion", "ig", '4' }, + { "topology", "topo", "kind", '8' }, + { "total_leds", "topo", "tot", '4' }, +}; + +static esp_err_t handle_settings_post(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + cJSON *j = read_body_json(req); + if (!j) return send_err(req, 400, "bad json"); + + int updated = 0; + for (size_t i = 0; i < sizeof(SETTINGS)/sizeof(SETTINGS[0]); ++i) { + cJSON *v = cJSON_GetObjectItem(j, SETTINGS[i].json_key); + if (!v) continue; + switch (SETTINGS[i].type) { + case 's': + if (cJSON_IsString(v)) { settings_set_str(SETTINGS[i].ns, SETTINGS[i].nvs_key, v->valuestring); updated++; } + break; + case '4': + if (cJSON_IsNumber(v)) { settings_set_u32(SETTINGS[i].ns, SETTINGS[i].nvs_key, (uint32_t)v->valuedouble); updated++; } + break; + case 'i': + if (cJSON_IsNumber(v)) { settings_set_i32(SETTINGS[i].ns, SETTINGS[i].nvs_key, (int32_t)v->valuedouble); updated++; } + break; + case '8': + if (cJSON_IsNumber(v)) { settings_set_u8(SETTINGS[i].ns, SETTINGS[i].nvs_key, (uint8_t)v->valueint); updated++; } + else if (cJSON_IsBool(v)) { settings_set_u8(SETTINGS[i].ns, SETTINGS[i].nvs_key, cJSON_IsTrue(v) ? 1 : 0); updated++; } + break; + } + } + cJSON_Delete(j); + /* Push LED-engine + motion settings live without reboot. Both modules + * are designed to re-read all NVS keys on reload; expensive but called + * at most once per save click. */ + led_engine_reload(); + motion_reload(); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddNumberToObject(r, "updated", updated); + return send_json(req, r); +} + +/* ============================================================ + * /api/radar/diag β€” counts of bytes received + frames parsed + + * hex-dump of the last 64 bytes; lets the user immediately tell + * whether "distance is always 0" is a wiring/protocol issue. + * ============================================================ */ +static esp_err_t handle_radar_diag(httpd_req_t *req) { + radar_diag_t d; + radar_get_diag(&d); + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "driver", d.driver_id); + cJSON_AddNumberToObject(r, "total_bytes_rx", d.total_bytes_rx); + cJSON_AddNumberToObject(r, "total_frames_parsed", d.total_frames_parsed); + if (d.last_frame_age_ms == 0xFFFFFFFFu) { + cJSON_AddNullToObject(r, "last_frame_age_ms"); + } else { + cJSON_AddNumberToObject(r, "last_frame_age_ms", d.last_frame_age_ms); + } + char hex[3 * 64 + 1]; + size_t off = 0; + for (size_t i = 0; i < d.last_bytes_len && off + 3 < sizeof(hex); ++i) { + off += snprintf(hex + off, sizeof(hex) - off, "%02x ", d.last_bytes[i]); + } + if (off > 0) hex[off - 1] = 0; else hex[0] = 0; + cJSON_AddStringToObject(r, "last_bytes_hex", hex); + cJSON_AddNumberToObject(r, "last_bytes_len", d.last_bytes_len); + /* Helper hint for the user. */ + const char *hint; + if (d.total_bytes_rx == 0) hint = "No UART bytes from radar β€” check wiring (TX/RX swap), 5V power, common GND."; + else if (d.total_frames_parsed == 0) hint = "Bytes arriving but no frames parsed β€” likely wrong driver picked. Verify radar_kind matches your sensor."; + else if (d.last_frame_age_ms > 2000) hint = "Frames stalled β€” check power, possibly disconnect."; + else hint = "OK β€” radar streaming valid frames."; + cJSON_AddStringToObject(r, "hint", hint); + return send_json(req, r); +} + +/* ============================================================ + * /api/mesh β€” peers, fusion, pairing + * ============================================================ */ +static esp_err_t handle_mesh_get(httpd_req_t *req) { + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "coordinator", mesh_is_coordinator()); + cJSON_AddBoolToObject(r, "pairing", mesh_in_pairing()); + cJSON_AddNumberToObject(r, "pairing_ms_left", mesh_pairing_remaining_ms()); + static const char *FUSE_NAMES[] = {"most_recent","slave_first","master_first","zone_based"}; + cJSON_AddStringToObject(r, "fusion", FUSE_NAMES[mesh_get_fusion()]); + + /* Our own MAC β€” UI uses it to highlight "this device" in the peers + * card and to filter ourselves out of identify-target dropdowns. */ + uint8_t mymac[6]; + esp_read_mac(mymac, ESP_MAC_WIFI_STA); + char myms[18]; + snprintf(myms, sizeof(myms), "%02x:%02x:%02x:%02x:%02x:%02x", + mymac[0], mymac[1], mymac[2], mymac[3], mymac[4], mymac[5]); + cJSON_AddStringToObject(r, "my_mac", myms); + + mesh_peer_t peers[MESH_MAX_PEERS]; + size_t n = mesh_peers_snapshot(peers, MESH_MAX_PEERS); + cJSON *arr = cJSON_AddArrayToObject(r, "peers"); + for (size_t i = 0; i < n; ++i) { + cJSON *o = cJSON_CreateObject(); + char macstr[18]; + snprintf(macstr, sizeof(macstr), "%02x:%02x:%02x:%02x:%02x:%02x", + peers[i].mac[0], peers[i].mac[1], peers[i].mac[2], + peers[i].mac[3], peers[i].mac[4], peers[i].mac[5]); + cJSON_AddStringToObject(o, "mac", macstr); + cJSON_AddNumberToObject(o, "distance_cm", peers[i].distance_cm); + cJSON_AddNumberToObject(o, "direction", peers[i].direction); + cJSON_AddNumberToObject(o, "rssi", peers[i].rssi); + cJSON_AddBoolToObject (o, "healthy", peers[i].healthy); + cJSON_AddItemToArray(arr, o); + } + return send_json(req, r); +} + +static esp_err_t handle_mesh_post(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + cJSON *j = read_body_json(req); + if (!j) return send_err(req, 400, "bad json"); + cJSON *fuse = cJSON_GetObjectItem(j, "fusion"); + if (fuse && cJSON_IsString(fuse)) { + mesh_fusion_t mode = MESH_FUSE_MOST_RECENT; + if (strcmp(fuse->valuestring, "slave_first") == 0) mode = MESH_FUSE_SLAVE_FIRST; + else if (strcmp(fuse->valuestring, "master_first") == 0) mode = MESH_FUSE_MASTER_FIRST; + else if (strcmp(fuse->valuestring, "zone_based") == 0) mode = MESH_FUSE_ZONE_BASED; + mesh_set_fusion(mode); + } + cJSON *pair = cJSON_GetObjectItem(j, "pair"); + if (pair && cJSON_IsTrue(pair)) mesh_open_pairing(); + cJSON_Delete(j); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddBoolToObject(r, "pairing", mesh_in_pairing()); + cJSON_AddNumberToObject(r, "pairing_ms_left", mesh_pairing_remaining_ms()); + return send_json(req, r); +} + +/* /api/mesh/identify {mac:"aa:bb:..."} β€” unicast IDENTIFY so the named + * peer blinks its LED for 5 s. Lets the user physically map MACs to + * actual stair locations during topology setup. */ +static esp_err_t handle_mesh_identify(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + cJSON *j = read_body_json(req); + if (!j) return send_err(req, 400, "bad json"); + cJSON *mac = cJSON_GetObjectItem(j, "mac"); + if (!mac || !cJSON_IsString(mac)) { cJSON_Delete(j); return send_err(req, 400, "mac required"); } + unsigned m[6]; + if (sscanf(mac->valuestring, "%x:%x:%x:%x:%x:%x", &m[0],&m[1],&m[2],&m[3],&m[4],&m[5]) != 6) { + cJSON_Delete(j); + return send_err(req, 400, "bad mac format"); + } + cJSON_Delete(j); + uint8_t target[6] = { (uint8_t)m[0], (uint8_t)m[1], (uint8_t)m[2], + (uint8_t)m[3], (uint8_t)m[4], (uint8_t)m[5] }; + esp_err_t err = mesh_identify(target); + if (err != ESP_OK) return send_err(req, 500, "send failed"); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddStringToObject(r, "note", "peer should blink for 5 s"); + return send_json(req, r); +} + +/* ============================================================ + * /api/topology + * ============================================================ */ +static esp_err_t handle_topology_get(httpd_req_t *req) { + const topology_t *t = topology_get(); + cJSON *r = cJSON_CreateObject(); + static const char *KIND_NAMES[] = {"straight","l_shape","u_shape","custom"}; + cJSON_AddStringToObject(r, "kind", KIND_NAMES[t->kind <= TOPO_CUSTOM ? t->kind : 0]); + cJSON_AddNumberToObject(r, "version", t->version); + cJSON_AddNumberToObject(r, "total_leds", t->total_leds); + cJSON *arr = cJSON_AddArrayToObject(r, "segments"); + for (uint8_t i = 0; i < t->segment_count; ++i) { + cJSON *o = cJSON_CreateObject(); + char macstr[18]; + snprintf(macstr, sizeof(macstr), "%02x:%02x:%02x:%02x:%02x:%02x", + t->segments[i].mac[0], t->segments[i].mac[1], t->segments[i].mac[2], + t->segments[i].mac[3], t->segments[i].mac[4], t->segments[i].mac[5]); + cJSON_AddStringToObject(o, "mac", macstr); + cJSON_AddNumberToObject(o, "led_start", t->segments[i].led_start); + cJSON_AddNumberToObject(o, "led_end", t->segments[i].led_end); + cJSON_AddNumberToObject(o, "dist_min_cm", t->segments[i].dist_min_cm); + cJSON_AddNumberToObject(o, "dist_max_cm", t->segments[i].dist_max_cm); + cJSON_AddBoolToObject (o, "reverse", t->segments[i].reverse); + cJSON_AddItemToArray(arr, o); + } + return send_json(req, r); +} + +static esp_err_t handle_topology_post(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + cJSON *j = read_body_json(req); + if (!j) return send_err(req, 400, "bad json"); + + topology_t t = *topology_get(); + cJSON *kind = cJSON_GetObjectItem(j, "kind"); + if (kind && cJSON_IsString(kind)) { + if (strcmp(kind->valuestring, "straight") == 0) t.kind = TOPO_STRAIGHT; + else if (strcmp(kind->valuestring, "l_shape") == 0) t.kind = TOPO_L_SHAPE; + else if (strcmp(kind->valuestring, "u_shape") == 0) t.kind = TOPO_U_SHAPE; + else if (strcmp(kind->valuestring, "custom") == 0) t.kind = TOPO_CUSTOM; + } + cJSON *total = cJSON_GetObjectItem(j, "total_leds"); + if (total && cJSON_IsNumber(total)) t.total_leds = (uint16_t)total->valueint; + cJSON *segs = cJSON_GetObjectItem(j, "segments"); + if (segs && cJSON_IsArray(segs)) { + int n = cJSON_GetArraySize(segs); + if (n > TOPO_MAX_SEGMENTS) n = TOPO_MAX_SEGMENTS; + t.segment_count = (uint8_t)n; + for (int i = 0; i < n; ++i) { + cJSON *s = cJSON_GetArrayItem(segs, i); + if (!s) continue; + cJSON *mac = cJSON_GetObjectItem(s, "mac"); + if (mac && cJSON_IsString(mac)) { + unsigned m[6]; + if (sscanf(mac->valuestring, "%x:%x:%x:%x:%x:%x", &m[0],&m[1],&m[2],&m[3],&m[4],&m[5]) == 6) { + for (int k = 0; k < 6; ++k) t.segments[i].mac[k] = (uint8_t)m[k]; + } + } + cJSON *v; + v = cJSON_GetObjectItem(s, "led_start"); if (v && cJSON_IsNumber(v)) t.segments[i].led_start = v->valueint; + v = cJSON_GetObjectItem(s, "led_end"); if (v && cJSON_IsNumber(v)) t.segments[i].led_end = v->valueint; + v = cJSON_GetObjectItem(s, "dist_min_cm"); if (v && cJSON_IsNumber(v)) t.segments[i].dist_min_cm = v->valueint; + v = cJSON_GetObjectItem(s, "dist_max_cm"); if (v && cJSON_IsNumber(v)) t.segments[i].dist_max_cm = v->valueint; + v = cJSON_GetObjectItem(s, "reverse"); if (v) t.segments[i].reverse = cJSON_IsTrue(v) ? 1 : 0; + } + } + cJSON_Delete(j); + esp_err_t err = topology_set(&t, true); + if (err != ESP_OK) return send_err(req, 400, "invalid topology"); + mesh_gossip_topology(); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddNumberToObject(r, "version", topology_get()->version); + return send_json(req, r); +} + +/* ============================================================ + * /api/distance + /api/live (WebSocket) + * ============================================================ */ +static esp_err_t handle_distance(httpd_req_t *req) { + char buf[16]; + snprintf(buf, sizeof(buf), "%d", s_web.latest.distance_cm); + httpd_resp_set_type(req, "text/plain"); + return httpd_resp_send(req, buf, strlen(buf)); +} + +static esp_err_t handle_ws(httpd_req_t *req) { + if (req->method == HTTP_GET) { + /* Handshake. Save fd. */ + int fd = httpd_req_to_sockfd(req); + xSemaphoreTake(s_web.lock, portMAX_DELAY); + for (int i = 0; i < MAX_WS_CLIENTS; ++i) { + if (s_web.ws_fds[i] == 0) { s_web.ws_fds[i] = fd; break; } + } + xSemaphoreGive(s_web.lock); + ESP_LOGI(TAG, "WS client connected, fd=%d", fd); + return ESP_OK; + } + return ESP_OK; +} + +static void ws_broadcast_task(void *arg) { + (void)arg; + while (1) { + vTaskDelay(pdMS_TO_TICKS(50)); /* 20 Hz β€” smooth UI updates */ + + webui_live_t snap; + xSemaphoreTake(s_web.lock, portMAX_DELAY); + snap = s_web.latest; + snap.free_heap = esp_get_free_heap_size(); + snap.uptime_s = (uint32_t)(esp_timer_get_time() / 1000000); + snap.rssi = netmgr_get_rssi(); + xSemaphoreGive(s_web.lock); + + char json[200]; + int n = snprintf(json, sizeof(json), + "{\"distance\":%d,\"raw\":%d,\"direction\":%d,\"rssi\":%d,\"heap\":%" PRIu32 ",\"uptime\":%" PRIu32 ",\"peers\":%u,\"healthy\":%u}", + snap.distance_cm, snap.raw_cm, snap.direction, snap.rssi, + snap.free_heap, snap.uptime_s, snap.peer_count, snap.peer_healthy); + + httpd_ws_frame_t f = { + .final = true, .fragmented = false, + .type = HTTPD_WS_TYPE_TEXT, + .payload = (uint8_t *)json, .len = n, + }; + xSemaphoreTake(s_web.lock, portMAX_DELAY); + for (int i = 0; i < MAX_WS_CLIENTS; ++i) { + if (s_web.ws_fds[i] == 0) continue; + esp_err_t err = httpd_ws_send_frame_async(s_web.srv, s_web.ws_fds[i], &f); + if (err != ESP_OK) { + /* Client gone β€” drop fd. */ + s_web.ws_fds[i] = 0; + } + } + xSemaphoreGive(s_web.lock); + } +} + +void webui_publish_live(const webui_live_t *snap) { + if (!snap) return; + xSemaphoreTake(s_web.lock, portMAX_DELAY); + s_web.latest = *snap; + xSemaphoreGive(s_web.lock); +} + +/* ============================================================ + * /api/ota β€” POST application/octet-stream firmware upload + * ============================================================ */ +static esp_err_t handle_ota(httpd_req_t *req) { + if (!gate_auth(req)) return ESP_OK; + if (req->content_len == 0) return send_err(req, 400, "empty body"); + + ota_session_t *s = ota_begin(req->content_len); + if (!s) return send_err(req, 500, "ota_begin failed"); + + char buf[1024]; + int total = 0; + while (total < (int)req->content_len) { + int n = httpd_req_recv(req, buf, sizeof(buf)); + if (n <= 0) { ota_abort(s); return send_err(req, 500, "recv failed"); } + if (ota_write(s, buf, n) != ESP_OK) return send_err(req, 500, "write failed"); + total += n; + } + if (ota_finish(s) != ESP_OK) return send_err(req, 500, "validate failed"); + + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddNumberToObject(r, "bytes", total); + cJSON_AddStringToObject(r, "note", "rebooting in 1 s"); + return send_json(req, r); +} + +/* ============================================================ + * Server lifecycle + * ============================================================ */ + +static const httpd_uri_t k_routes[] = { + /* Root + captive-portal redirects */ + { "/", HTTP_GET, handle_root, NULL }, + { "/generate_204", HTTP_GET, handle_captive_redirect, NULL }, + { "/gen_204", HTTP_GET, handle_captive_redirect, NULL }, + { "/hotspot-detect.html", HTTP_GET, handle_captive_redirect, NULL }, + { "/library/test/success.html", HTTP_GET, handle_captive_redirect, NULL }, + { "/connecttest.txt", HTTP_GET, handle_captive_redirect, NULL }, + { "/redirect", HTTP_GET, handle_captive_redirect, NULL }, + { "/ncsi.txt", HTTP_GET, handle_captive_redirect, NULL }, + + /* API */ + { "/api/ping", HTTP_GET, handle_ping, NULL }, + { "/api/reboot", HTTP_POST, handle_reboot, NULL }, + { "/api/system", HTTP_GET, handle_system_get, NULL }, + { "/api/system", HTTP_POST, handle_system_post, NULL }, + { "/api/factory_reset", HTTP_POST, handle_factory_reset, NULL }, + { "/api/version", HTTP_GET, handle_version, NULL }, + { "/api/wifi/scan", HTTP_GET, handle_wifi_scan, NULL }, + { "/api/wifi", HTTP_GET, handle_wifi_get, NULL }, + { "/api/wifi", HTTP_POST, handle_wifi_post, NULL }, + { "/api/auth/login", HTTP_POST, handle_login, NULL }, + { "/api/auth/logout", HTTP_POST, handle_logout, NULL }, + { "/api/auth/password", HTTP_POST, handle_set_password, NULL }, + { "/api/board/profiles", HTTP_GET, handle_board_profiles, NULL }, + { "/api/board", HTTP_POST, handle_board_post, NULL }, + { "/api/radar/kinds", HTTP_GET, handle_radar_kinds, NULL }, + { "/api/radar/diag", HTTP_GET, handle_radar_diag, NULL }, + { "/api/settings", HTTP_GET, handle_settings_get, NULL }, + { "/api/settings", HTTP_POST, handle_settings_post, NULL }, + { "/api/distance", HTTP_GET, handle_distance, NULL }, + { "/api/mesh", HTTP_GET, handle_mesh_get, NULL }, + { "/api/mesh", HTTP_POST, handle_mesh_post, NULL }, + { "/api/mesh/identify", HTTP_POST, handle_mesh_identify, NULL }, + { "/api/topology", HTTP_GET, handle_topology_get, NULL }, + { "/api/topology", HTTP_POST, handle_topology_post, NULL }, + { "/api/ota", HTTP_POST, handle_ota, NULL }, +}; + +esp_err_t webui_init(void) { + if (s_web.srv) return ESP_OK; + s_web.lock = xSemaphoreCreateMutex(); + + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + cfg.lru_purge_enable = true; + cfg.max_uri_handlers = 48; /* was 32 β€” overflowed at 33 once mesh/topology/factory_reset/ping/system routes were added; the /api/live WS handler stopped registering and the dashboard could no longer get live data */ + cfg.max_open_sockets = 7; + cfg.stack_size = 8192; + cfg.recv_wait_timeout = 10; + cfg.send_wait_timeout = 10; + + esp_err_t err = httpd_start(&s_web.srv, &cfg); + if (err != ESP_OK) { ESP_LOGE(TAG, "httpd_start: 0x%x", err); return err; } + + for (size_t i = 0; i < sizeof(k_routes)/sizeof(k_routes[0]); ++i) { + httpd_register_uri_handler(s_web.srv, &k_routes[i]); + } + + /* Live-data WS */ + static const httpd_uri_t ws_route = { + .uri = "/api/live", .method = HTTP_GET, + .handler = handle_ws, .user_ctx = NULL, + .is_websocket = true, .handle_ws_control_frames = false, + }; + httpd_register_uri_handler(s_web.srv, &ws_route); + + xTaskCreate(ws_broadcast_task, "ws_bcast", 4096, NULL, 3, NULL); + + ESP_LOGI(TAG, "Web server listening on :80 (%zu routes + ws)", + sizeof(k_routes)/sizeof(k_routes[0]) + 1); + return ESP_OK; +} diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt new file mode 100644 index 0000000..053750a --- /dev/null +++ b/firmware/main/CMakeLists.txt @@ -0,0 +1,7 @@ +idf_component_register( + SRCS "main.c" + INCLUDE_DIRS "." + REQUIRES board settings status_led netmgr auth webui ota + radar motion led_engine topology mesh button + app_update esp_system log +) diff --git a/firmware/main/main.c b/firmware/main/main.c new file mode 100644 index 0000000..79e17c2 --- /dev/null +++ b/firmware/main/main.c @@ -0,0 +1,253 @@ +/* + * AmbiSense v6 β€” application entry. + * + * Boot order: + * 1. settings_init β€” bring up NVS (replaces v5 EEPROM) + * 2. resolve board β€” NVS-saved board id wins over compile-time default + * 3. status LED β€” boot pattern; flips later as Wi-Fi / OTA progresses + * + * Subsequent PRs add Wi-Fi (PR #2), web server (PR #2), radar (PR #3), + * LED engine (PR #3), peer mesh (PR #4), and the real UI (PR #5). + */ + +#include + +#include "esp_log.h" +#include "esp_system.h" +#include "esp_chip_info.h" +#include "esp_app_desc.h" +#include "esp_mac.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "board.h" +#include "settings.h" +#include "status_led.h" +#include "netmgr.h" +#include "auth.h" +#include "webui.h" +#include "ota.h" +#include "radar.h" +#include "motion.h" +#include "led_engine.h" +#include "topology.h" +#include "mesh.h" +#include "button.h" + +static const char *TAG = "ambisense"; + +/* Resolve which board_profile_t this device should use: + * 1. If NVS has board.id set, look it up by id. + * 2. Otherwise, fall back to the build's compile-time default. + * 3. If a corrupt/unknown id was somehow stored, log and fall back. + */ +static const board_profile_t *resolve_board_profile(void) { + char saved_id[32] = {0}; + esp_err_t err = settings_get_board_id(saved_id, sizeof(saved_id)); + if (err == ESP_OK) { + const board_profile_t *p = board_profile_by_id(saved_id); + if (p) { + /* MCU mismatch guard: if NVS has a profile from a different SoC + * (e.g., previous "esp32-devkit" saved on a C3 from a stale flash), + * the wrong pin map can drive USB-JTAG / flash pins as outputs and + * brick boot before Wi-Fi comes up. Fall back to the compile-time + * default whenever the saved profile's MCU doesn't match the + * IDF_TARGET we were built for. */ + if (strcmp(p->mcu, CONFIG_IDF_TARGET) != 0) { + ESP_LOGW(TAG, "NVS board.id='%s' is for MCU '%s' but we're running on '%s' β€” falling back", + p->id, p->mcu, CONFIG_IDF_TARGET); + } else { + ESP_LOGI(TAG, "Board profile from NVS: %s (%s)", p->id, p->display); + return p; + } + } else { + ESP_LOGW(TAG, "NVS board.id='%s' is unknown; falling back to default", saved_id); + } + } + const board_profile_t *def = board_default_profile(); + ESP_LOGI(TAG, "Board profile (default): %s (%s)", def->id, def->display); + return def; +} + +/* Apply pin overrides from NVS on top of the profile's defaults. + * Each override is silently ignored if it would land on an unsafe pin. */ +static void apply_pin_overrides(board_profile_t *runtime) { + static const struct { const char *key; size_t off; } pin_keys[] = { + { "led_pin", offsetof(board_profile_t, led_pin) }, + { "radar_rx", offsetof(board_profile_t, radar_rx_pin) }, + { "radar_tx", offsetof(board_profile_t, radar_tx_pin) }, + { "button", offsetof(board_profile_t, button_pin) }, + { "status_led", offsetof(board_profile_t, status_led_pin) }, + }; + for (size_t i = 0; i < sizeof(pin_keys) / sizeof(pin_keys[0]); ++i) { + uint8_t pin; + if (settings_get_pin_override(pin_keys[i].key, &pin) != ESP_OK) continue; + if (pin == BOARD_PIN_NONE) continue; + if (board_pin_is_unsafe(runtime, pin)) { + ESP_LOGW(TAG, "Ignoring NVS pin override %s=%u (unsafe on this board)", + pin_keys[i].key, pin); + continue; + } + *(uint8_t *)((char *)runtime + pin_keys[i].off) = pin; + ESP_LOGI(TAG, "Pin override: %s = GPIO %u", pin_keys[i].key, pin); + } +} + +/* Mesh layer event reactions: surface peer joins / pair window edges / + * incoming identify pings on the onboard status LED. Identify is the + * UX-critical one β€” when the user clicks "Identify" on this device's + * card in the web UI, the LED hammers at 10 Hz for 5 s so they can + * physically locate which board is which during stair installation. */ +static void on_mesh_event(mesh_event_t evt, const uint8_t mac[6]) { + (void)mac; + switch (evt) { + case MESH_EVT_PEER_JOINED: + ESP_LOGI(TAG, "EVT peer joined %02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + /* Brief 1 s "OK" pulse β€” visible but unobtrusive. */ + status_led_oneshot(STATUS_LED_OTA, 1000); + break; + case MESH_EVT_IDENTIFY_REQUESTED: + ESP_LOGI(TAG, "EVT identify requested by peer"); + status_led_oneshot(STATUS_LED_IDENTIFY, 5000); + break; + case MESH_EVT_PAIRING_OPENED: + ESP_LOGI(TAG, "EVT pairing window opened"); + status_led_oneshot(STATUS_LED_PAIRING, 30000); + break; + case MESH_EVT_PAIRING_CLOSED: + ESP_LOGI(TAG, "EVT pairing window closed"); + /* The oneshot expires automatically; nothing to do here. */ + break; + } +} + +/* Button events: long-press (3 s) opens the pairing window β€” the standard + * "physically pair this device" gesture. The mesh event callback above + * then drives the LED. Short / very-long are reserved for future use. */ +static void on_button(button_event_t evt) { + switch (evt) { + case BUTTON_PRESS_SHORT: + ESP_LOGI(TAG, "Button: short press (no-op in v6.0)"); + break; + case BUTTON_PRESS_LONG: + ESP_LOGI(TAG, "Button: long press β†’ opening pairing window"); + mesh_open_pairing(); + break; + case BUTTON_PRESS_VERYLONG: + ESP_LOGW(TAG, "Button: very-long press (factory reset reserved for v6.1)"); + break; + } +} + +/* Telemetry pump: 20 Hz publish mesh-fused target + raw + RSSI + peer + * health to webui WS clients. Was 5 Hz β€” bumped to kill the visible + * stair-step jitter on the live distance graph. 20 Hz Γ— ~120-byte JSON + * = 2.5 KB/s on the WiFi link, trivial. Pulls raw_cm from local + * motion_get() because mesh fused only carries the smoothed value. */ +static void telemetry_pump_task(void *arg) { + (void)arg; + while (1) { + mesh_fused_t f; + mesh_get_fused(&f); + target_t local; + motion_get(&local); + mesh_peer_t peers[MESH_MAX_PEERS]; + size_t pn = mesh_peers_snapshot(peers, MESH_MAX_PEERS); + size_t hn = 0; + for (size_t i = 0; i < pn; ++i) if (peers[i].healthy) hn++; + + webui_live_t live = { + .distance_cm = f.distance_cm, + .raw_cm = local.raw_cm, + .direction = f.direction, + .rssi = netmgr_get_rssi(), + .free_heap = 0, + .uptime_s = 0, + .peer_count = (uint8_t)pn, + .peer_healthy = (uint8_t)hn, + }; + webui_publish_live(&live); + vTaskDelay(pdMS_TO_TICKS(50)); /* 20 Hz β€” see comment above */ + } +} + +static void log_chip_info(void) { + esp_chip_info_t chip; + esp_chip_info(&chip); + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + const esp_app_desc_t *app = esp_app_get_description(); + ESP_LOGI(TAG, "AmbiSense v6 β€” %s, IDF %s, built %s %s", + app->version, app->idf_ver, app->date, app->time); + ESP_LOGI(TAG, "MCU: %s rev v%d.%d, %d core(s), MAC %02X:%02X:%02X:%02X:%02X:%02X", + CONFIG_IDF_TARGET, chip.revision / 100, chip.revision % 100, chip.cores, + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} + +void app_main(void) { + log_chip_info(); + + if (settings_init() != ESP_OK) { + ESP_LOGE(TAG, "NVS init failed β€” settings will not persist this boot"); + } + + /* Copy the const profile into a mutable runtime struct so pin overrides + * apply only to this boot's working copy. */ + board_profile_t runtime = *resolve_board_profile(); + apply_pin_overrides(&runtime); + + ESP_ERROR_CHECK(status_led_init(runtime.status_led_pin, runtime.status_led_active_low)); + status_led_set_pattern(STATUS_LED_BOOT); + + ESP_LOGI(TAG, "Pins: led=%u radar_rx=%u radar_tx=%u button=%u status=%u (uart%u, rmt%u)", + runtime.led_pin, runtime.radar_rx_pin, runtime.radar_tx_pin, + runtime.button_pin, runtime.status_led_pin, + runtime.uart_num, runtime.rmt_channel); + + /* Auth (off until password set), Wi-Fi (always-on AP + optional STA), + * web server (port 80, every API endpoint plus captive portal). */ + auth_init(); + netmgr_init(); + webui_init(); + + /* Radar + motion smoother + LED engine. The render task pulls smoothed + * targets from motion_q and drives the strip at 60 Hz; the radar task + * parses UART frames; the motion task runs the PI smoother in between. */ + radar_config_t rcfg = { + .uart_num = runtime.uart_num, + .rx_pin = runtime.radar_rx_pin, + .tx_pin = runtime.radar_tx_pin, + .baud = 256000, + }; + if (radar_init(&rcfg) != ESP_OK) ESP_LOGW(TAG, "radar_init failed (continuing)"); + motion_init(); + if (led_engine_init(runtime.led_pin) != ESP_OK) { + ESP_LOGE(TAG, "led_engine_init on GPIO %u failed", runtime.led_pin); + } + + /* Topology + ESP-NOW peer mesh. Comes after netmgr because esp_now_init + * requires Wi-Fi started. */ + topology_init(); + if (mesh_init() != ESP_OK) ESP_LOGW(TAG, "mesh_init failed (single-device fallback)"); + mesh_set_event_cb(on_mesh_event); + + /* Physical BOOT button β€” long-press (3 s) opens pairing window. */ + if (runtime.button_pin != BOARD_PIN_NONE) { + if (button_init(runtime.button_pin, true /* active_low */, on_button) != ESP_OK) { + ESP_LOGW(TAG, "button_init on GPIO %u failed", runtime.button_pin); + } + } + + xTaskCreate(telemetry_pump_task, "tele_pump", 3072, NULL, 3, NULL); + + /* If we're running on a freshly-flashed image with rollback armed, mark + * us valid so the bootloader doesn't revert on next reset. */ + ota_mark_valid(); + + /* Status LED follows Wi-Fi state from here. */ + status_led_set_pattern(netmgr_is_sta_connected() ? STATUS_LED_STA_MODE : STATUS_LED_AP_MODE); + + ESP_LOGI(TAG, "Boot complete. Web UI on http://%s.local/ (when STA up) or AP \"AmbiSense-XXXX\" β†’ 192.168.4.1.", "ambisense"); + /* app_main returns; FreeRTOS owns the device. */ +} diff --git a/firmware/partitions.csv b/firmware/partitions.csv new file mode 100644 index 0000000..33f003a --- /dev/null +++ b/firmware/partitions.csv @@ -0,0 +1,9 @@ +# AmbiSense v6 partition table β€” 4 MB minimum +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +app0, app, ota_0, 0x10000, 0x160000, +app1, app, ota_1, , 0x160000, +storage, data, littlefs, , 0xf0000, +coredump, data, coredump, , 0x10000, diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults new file mode 100644 index 0000000..16a38b2 --- /dev/null +++ b/firmware/sdkconfig.defaults @@ -0,0 +1,58 @@ +# AmbiSense v6 β€” common sdkconfig defaults applied to every board +# Per-board overrides live in sdkconfig.defaults. (e.g. sdkconfig.defaults.esp32c3) + +# --- Partition table ----------------------------------------------------- +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" + +# --- App / OTA ----------------------------------------------------------- +CONFIG_APP_COMPILE_TIME_DATE=y +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y +CONFIG_BOOTLOADER_WDT_ENABLE=y +CONFIG_BOOTLOADER_WDT_TIME_MS=9000 + +# --- Task watchdog (catch our own hangs) --------------------------------- +CONFIG_ESP_TASK_WDT_INIT=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=5 +CONFIG_ESP_TASK_WDT_PANIC=y + +# --- Logging ------------------------------------------------------------- +CONFIG_LOG_DEFAULT_LEVEL_INFO=y +CONFIG_LOG_TIMESTAMP_SOURCE_RTOS=y +CONFIG_LOG_COLORS=y + +# --- Coredump to flash partition (post-mortem via web UI later) --------- +CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y +CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y +CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y + +# --- FreeRTOS ------------------------------------------------------------ +CONFIG_FREERTOS_HZ=1000 +CONFIG_FREERTOS_USE_TRACE_FACILITY=y +CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y +CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y + +# --- WiFi ---------------------------------------------------------------- +CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=y +CONFIG_ESP_WIFI_SOFTAP_BEACON_MAX_LEN=752 +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10 +CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32 +CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32 + +# --- HTTP server --------------------------------------------------------- +CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024 +CONFIG_HTTPD_MAX_URI_LEN=512 +CONFIG_HTTPD_WS_SUPPORT=y + +# --- mDNS ---------------------------------------------------------------- +CONFIG_MDNS_MAX_SERVICES=10 + +# --- LWIP ---------------------------------------------------------------- +CONFIG_LWIP_LOCAL_HOSTNAME="ambisense" +CONFIG_LWIP_MAX_SOCKETS=10 + +# --- Compiler ------------------------------------------------------------ +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_COMPILER_WARN_WRITE_STRINGS=y +CONFIG_COMPILER_STACK_CHECK_MODE_NORM=y diff --git a/firmware/sdkconfig.defaults.esp32c3 b/firmware/sdkconfig.defaults.esp32c3 new file mode 100644 index 0000000..71df454 --- /dev/null +++ b/firmware/sdkconfig.defaults.esp32c3 @@ -0,0 +1,16 @@ +# C3 SuperMini specific overrides +# RISC-V single-core, 400 KB SRAM, 4 MB flash, 2 RMT TX channels, USB-Serial-JTAG console. + +CONFIG_IDF_TARGET="esp32c3" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESPTOOLPY_FLASHFREQ_80M=y + +# C3 has USB-Serial-JTAG built in β€” use it for console (no UART0 needed for logs) +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y + +# Brownout: leave detection on but threshold low β€” user has separate LED PSU, +# but USB power dips during heavy WiFi TX still happen. +CONFIG_ESP_BROWNOUT_DET=y +CONFIG_ESP_BROWNOUT_DET_LVL_SEL_7=y + +# Single core β€” no SMP toggles needed diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..cfd2e1c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.cache/ +.DS_Store diff --git a/frontend/design-source/README.md b/frontend/design-source/README.md new file mode 100644 index 0000000..18006e7 --- /dev/null +++ b/frontend/design-source/README.md @@ -0,0 +1,25 @@ +# CODING AGENTS: READ THIS FIRST + +This is a **handoff bundle** from Claude Design (claude.ai/design). + +A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real. + +## What you should do β€” IMPORTANT + +**Read the chat transcripts first.** There are 1 chat transcript(s) in `ambisense/chats/`. The transcripts show the full back-and-forth between the user and the design assistant β€” they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives. + +**Find the primary design file under `ambisense/project/` and read it top to bottom.** The chat transcripts will tell you which file the user was last iterating on. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing. + +**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing. + +## About the design files + +The design medium is **HTML/CSS/JS** β€” these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit. + +**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need β€” dimensions, colors, layout rules β€” is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't. + +## Bundle contents + +- `ambisense/README.md` β€” this file +- `ambisense/chats/` β€” conversation transcripts (read these!) +- `ambisense/project/` β€” the `AmbiSense` project files (HTML prototypes, assets, components) diff --git a/frontend/design-source/chats/chat1.md b/frontend/design-source/chats/chat1.md new file mode 100644 index 0000000..d0990d3 --- /dev/null +++ b/frontend/design-source/chats/chat1.md @@ -0,0 +1,158 @@ +# AmbISense Logo Redesign + +_Started 2026-05-05 12:27 UTC_ + +--- + +## User + + + +## Assistant + +I'll design AmbiSense v6 as an original interactive prototype. Let me start by understanding the scope and asking a few targeted questions before diving in. + +This is a substantial brief with clear specs, so I'll skip a question round and commit to a design direction. Let me lay out my approach: + +**Design system commitment:** +- **Aesthetic:** Premium hardware product feel β€” technical but warm. Dark-first with near-black `#0B0D10`, elevated cards `#15181C`, amber-to-pink gradient accents echoing LED hardware +- **Type:** I'll use **Geist** (modern, technical) for UI + **JetBrains Mono** for technical readouts (IPs, MACs, hex, GPIO numbers). Avoiding Inter as called out in my guidelines, but keeping the same spirit. +- **Layout:** Desktop sidebar + mobile bottom tab bar, switched by viewport +- **Hero moments:** Live LED strip canvas preview, real animated mode thumbnails, live distance meter with sparkline, raw-vs-smoothed chart, topology diagrams + +**Plan:** +1. Build the full interactive prototype with all 7 screens (A–G) +2. Real animated LED previews on canvas (11 modes) +3. Simulated WebSocket live data (distance, RSSI, heap) +4. Optimistic writes with saved toasts +5. Tweaks panel for theme/density variations + +_[tool: update_todos]_ + +_[tool: copy_starter_component]_ + +Let me build the prototype. I'll structure it as a main HTML file with separate JSX files for each screen and shared components. + +_[tool: write_file]_ + +Now the shared components and state: + +_[tool: write_file]_ + +Now the LED canvas previews and mode definitions: + +_[tool: write_file]_ + +Now the screens. Let me build them in groupings. + +_[tool: write_file]_ + +_[tool: snip]_ + +Now the LEDs screen β€” the most important page: + +_[tool: write_file]_ + +_[tool: snip]_ + +Now Motion, Mesh, Hardware, Network, System screens combined into fewer files: + +_[tool: write_file]_ + +_[tool: snip]_ + +_[tool: write_file]_ + +_[tool: snip]_ + +Now the app shell with navigation, header, reboot overlay, and tweaks: + +_[tool: write_file]_ + +_[tool: snip]_ + +Now the index.html that wires everything together: + +_[tool: write_file]_ + +_[tool: show_html]_ + +_[tool: snip]_ + +_[tool: done]_ + +Empty root β€” let me check the logs. + +_[tool: get_webview_logs]_ + +_[tool: str_replace_edit]_ + +_[tool: snip]_ + +_[tool: done]_ + +_[tool: get_webview_logs]_ + +Let me check the tweaks panel API: + +_[tool: snip]_ + +_[tool: grep]_ + +The hook returns `[t, setTweak]` array, not `{tweaks, setTweak}`. Let me fix: + +_[tool: str_replace_edit]_ + +_[tool: snip]_ + +_[tool: done]_ + +_[tool: fork_verifier_agent]_ + +**AmbiSense v6** β€” full interactive prototype with all 7 screens (Live, LEDs, Motion, Mesh, Hardware, Network, System). Dark-default with a light toggle, sidebar on desktop / bottom tabs on mobile, simulated WebSocket live data at 5 Hz, optimistic writes with toast confirms, real-time canvas LED previews for all 11 modes, an HSV color wheel + presets, dual-handle distance window, raw-vs-smoothed motion chart, topology diagrams, segment editor, GPIO pin selector with strapping-pin guards, OTA drag-drop, and a 30 s reboot overlay. Tweaks panel exposes theme, density, and accent hue. + +## User + +Do something for AmbISense logo, may be light it up or make it bigger or like rethink please... + +## Assistant + +I'll redesign the logo with more presence and animation that ties to the radar/LED hardware concept. + +_[tool: snip]_ + +_[tool: read_file]_ + +I'll design a custom logo mark β€” a stylized radar-pulse "A" with concentric rings that pulse live, plus a wordmark with proper typographic care. + +_[tool: str_replace_edit]_ + +_[tool: snip]_ + +_[tool: str_replace_edit]_ + +Now define the Logo component. I'll insert it before Header in app.jsx: + +_[tool: str_replace_edit]_ + +_[tool: snip]_ + +_[tool: done]_ + +_[tool: snip]_ + +_[tool: save_screenshot]_ + +_[tool: view_image]_ + +The new logo looks great β€” animated radar pulses emanating from the base of a triangular "A" mark, with a glowing core dot, gradient stroke chip body, and a refined wordmark where "Sense" picks up the amberβ†’pink gradient. The outer glow breathes in sync with live distance data, so the logo is literally responsive to the radar. + +_[tool: snip]_ + +The redesigned AmbiSense logo: + +- **Pulsing radar mark** β€” a triangular "A" emitter with three concentric rings expanding outward from a glowing core dot at its base, animated continuously to evoke the mmWave radar +- **Live-reactive glow** β€” the soft halo around the mark breathes harder when the radar sees something close +- **Wordmark** β€” "Ambi" in solid foreground, "Sense" picks up the amberβ†’pink accent gradient, with a versioned monospace tag underneath +- **Bigger presence** in the sidebar (42 px mark, 18 px wordmark) and a clean compact mark-only version in the mobile header + diff --git a/frontend/design-source/project/AmbiSense v6.html b/frontend/design-source/project/AmbiSense v6.html new file mode 100644 index 0000000..6c5b3c5 --- /dev/null +++ b/frontend/design-source/project/AmbiSense v6.html @@ -0,0 +1,26 @@ + + + + + + AmbiSense v6 + + + + + + + + + +
+ + + + + + + + + + diff --git a/frontend/design-source/project/app.jsx b/frontend/design-source/project/app.jsx new file mode 100644 index 0000000..2fe16f5 --- /dev/null +++ b/frontend/design-source/project/app.jsx @@ -0,0 +1,376 @@ +// AmbiSense β€” App shell + +const TABS = [ + { id: "live", name: "Live", icon: "dashboard" }, + { id: "leds", name: "LEDs", icon: "led" }, + { id: "motion", name: "Motion", icon: "motion" }, + { id: "mesh", name: "Mesh", icon: "mesh" }, + { id: "hardware", name: "Hardware", icon: "chip" }, + { id: "network", name: "Network", icon: "wifi" }, + { id: "system", name: "System", icon: "settings" }, +]; + +/* ============ LOGO ============ */ +function Logo({ size = "md", mono = false }) { + // size: sm (header mobile), md, lg (sidebar) + const dims = { sm: { mark: 30, font: 13, sub: 9, gap: 9, pad: 0 }, + md: { mark: 36, font: 16, sub: 10, gap: 10, pad: 0 }, + lg: { mark: 42, font: 18, sub: 10, gap: 12, pad: 4 } }[size]; + return ( +
+ + {!mono && ( +
+ + AmbiSense + + v6.2.1 Β· esp32 +
+ )} +
+ ); +} + +function LogoMark({ size = 36 }) { + const live = useLive(); + // animated pulse phase from live distance + const pulseStrength = clamp((250 - live.distance) / 200, 0.2, 1); + return ( +
+ {/* outer glow */} +
+ + + + + + + + + + + + + + + + + + + {/* Squircle/rounded-square chip body */} + + + {/* Concentric pulse rings, animated */} + + {[0, 1, 2].map(i => ( + + ))} + + + {/* Triangle "A" mark β€” pointing up, the radar emitter */} + + {/* inner cutout to form the "A" crossbar */} + + + {/* core dot at base of A */} + + + + {/* corner ticks β€” chip detail */} + {[[6,6],[42,6],[6,42],[42,42]].map(([x,y],i) => ( + + ))} + + +
+ ); +} + +function Header({ tab, onTabName, theme, onTheme }) { + const live = useLive(); + const { s } = useStore(); + return ( +
+
+ +
+
+
+ {onTabName} + {s.hostname}.local +
+
+
+ + + live Β· {Math.round(live.distance)} cm + + {live.rssi} dBm +
+ +
+ ); +} + +function Sidebar({ tab, setTab }) { + return ( + + ); +} + +function BottomTabs({ tab, setTab }) { + return ( + + ); +} + +function RebootOverlay({ open, onDone }) { + const [count, setCount] = React.useState(30); + React.useEffect(() => { + if (!open) return; + setCount(30); + const id = setInterval(() => setCount(c => { + if (c <= 1) { clearInterval(id); onDone(); return 0; } + return c - 1; + }), 1000); + return () => clearInterval(id); + }, [open]); + if (!open) return null; + return ( +
+
+
+ +
+
Rebooting…
+
+ Polling /api/version Β· {count}s remaining +
+
+
+
+
+
+ ); +} + +/* ============ TWEAKS ============ */ +const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ + "density": "comfortable", + "accentHue": 22, + "accentBlend": 100, + "showLiveSparkline": true, + "monoNumbers": true +}/*EDITMODE-END*/; + +function TweaksUI({ theme, setTheme }) { + const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS); + const [open, setOpen] = React.useState(false); + React.useEffect(() => { + const onActivate = (e) => { if (e.data?.type === "__activate_edit_mode") setOpen(true); }; + const onDeactivate = (e) => { if (e.data?.type === "__deactivate_edit_mode") setOpen(false); }; + window.addEventListener("message", onActivate); + window.addEventListener("message", onDeactivate); + window.parent.postMessage({ type: "__edit_mode_available" }, "*"); + return () => { + window.removeEventListener("message", onActivate); + window.removeEventListener("message", onDeactivate); + }; + }, []); + + // apply tweaks to root + React.useEffect(() => { + const root = document.documentElement; + const h = tweaks.accentHue; + const acc1 = `oklch(0.78 0.17 ${h + 60})`; + const acc2 = `oklch(0.68 0.22 ${h + 30})`; + const acc3 = `oklch(0.62 0.24 ${h})`; + root.style.setProperty("--acc-amber", acc1); + root.style.setProperty("--acc-orange", acc2); + root.style.setProperty("--acc-pink", acc3); + root.style.setProperty("--acc-grad", `linear-gradient(135deg, ${acc1} 0%, ${acc2} 45%, ${acc3} 100%)`); + root.dataset.density = tweaks.density; + }, [tweaks.accentHue, tweaks.density]); + + if (!open) return null; + return ( + setOpen(false)}> + + + setTweak("density", v)}/> + + + setTweak("accentHue", v)}/> +
+ + + setTweak("showLiveSparkline", v)}/> + setTweak("monoNumbers", v)}/> + + + ); +} + +/* ============ APP ============ */ +function App() { + const [tab, setTab] = React.useState("live"); + const [theme, setTheme] = React.useState("dark"); + const [reboot, setReboot] = React.useState(false); + + React.useEffect(() => { + document.documentElement.dataset.theme = theme; + }, [theme]); + + React.useEffect(() => { + const onReboot = () => setReboot(true); + window.addEventListener("ambisense:reboot", onReboot); + return () => window.removeEventListener("ambisense:reboot", onReboot); + }, []); + + const screen = { + live: , + leds: , + motion: , + mesh: , + hardware: , + network: , + system: , + }[tab]; + + const tabName = TABS.find(t => t.id === tab)?.name; + + return ( +
+ +
+
+
{screen}
+
+ + setReboot(false)}/> + +
+ ); +} + +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render( + + + + + + + +); diff --git a/frontend/design-source/project/core.jsx b/frontend/design-source/project/core.jsx new file mode 100644 index 0000000..e183b7d --- /dev/null +++ b/frontend/design-source/project/core.jsx @@ -0,0 +1,330 @@ +// AmbiSense β€” shared atoms, icons, store, and live data sim +const { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } = React; + +/* ============ ICONS ============ */ +const Icon = ({ name, size = 16, stroke = 1.6, className = "", style = {} }) => { + const s = size; + const common = { + width: s, height: s, viewBox: "0 0 24 24", + fill: "none", stroke: "currentColor", + strokeWidth: stroke, strokeLinecap: "round", strokeLinejoin: "round", + className, style, + }; + const paths = { + dashboard: <>, + led: <>, + motion: <>, + mesh: <>, + chip: <>, + wifi: <>, + settings: <>, + sun: <>, + moon: <>, + check: <>, + x: <>, + info: <>, + warn: <>, + chevron: <>, + chevronDown: <>, + plus: <>, + minus: <>, + refresh: <>, + upload: <>, + download: <>, + trash: <>, + eye: <>, + eyeOff: <>, + bolt: <>, + cpu: <>, + radar: <>, + pin: <>, + grid: <>, + palette: <>, + sliders: <>, + play: <>, + pause: <>, + copy: <>, + link: <>, + flash: <>, + box: <>, + arrowRight: <>, + }; + return {paths[name] || null}; +}; + +/* ============ TOAST ============ */ +const ToastCtx = createContext({ push: () => {} }); +const useToast = () => useContext(ToastCtx); + +function ToastHost({ children }) { + const [items, setItems] = useState([]); + const push = useCallback((msg, kind = "ok") => { + const id = Math.random().toString(36).slice(2); + setItems(s => [...s, { id, msg, kind }]); + setTimeout(() => setItems(s => s.filter(i => i.id !== id)), 1800); + }, []); + return ( + + {children} +
+ {items.map(t => ( +
+ + {t.msg} +
+ ))} +
+
+ ); +} + +/* ============ SETTINGS STORE (mocks /api/settings) ============ */ +const defaultSettings = { + systemEnabled: true, + deviceName: "ambisense-hall", + hostname: "ambisense-hall", + // LED + ledMode: "standard", + brightness: 180, + color: { h: 22, s: 0.95, v: 1 }, // amber + numLeds: 60, + minDistance: 40, + maxDistance: 220, + lightSpan: 18, + centerShift: 0, + backgroundMode: false, + trailLength: 4, + directionalLight: true, + effectSpeed: 60, + effectIntensity: 70, + // Motion + motionSmoothing: true, + positionSmoothing: 65, + velocitySmoothing: 50, + predictionFactor: 35, + pGain: 60, + iGain: 22, + // Mesh + role: "master", + topology: "u-shape", + sensorPriority: "most-recent", + // Hardware + boardProfile: "esp32-c3-supermini", + radar: "ld2410c", + pins: { ledData: 4, radarRx: 20, radarTx: 21, button: 9, statusLed: 8 }, + // Network + ssid: "Loft 5GHz", + staticIp: false, + ip: "10.0.0.42", + gateway: "10.0.0.1", + netmask: "255.255.255.0", + // System + authRequired: false, + authPassword: "", + presets: ["#FFB54A", "#FF7A3D", "#FF3D82", "#5BC7FF", "#4ADE80", "#9D5BFF", "#FF5470", "#FFFFFF"], +}; + +const StoreCtx = createContext(null); +const useStore = () => useContext(StoreCtx); + +function StoreProvider({ children }) { + const [s, setS] = useState(defaultSettings); + const toast = useToast(); + // optimistic write + const set = useCallback((patch, opts = {}) => { + const silent = opts.silent; + setS(prev => { + const next = typeof patch === "function" ? patch(prev) : { ...prev, ...patch }; + return next; + }); + if (!silent) { + // simulate /api/settings POST 200 OK + setTimeout(() => toast.push("Saved"), 80); + } + }, [toast]); + return {children}; +} + +/* ============ LIVE WS SIM ============ */ +const LiveCtx = createContext(null); +const useLive = () => useContext(LiveCtx); + +function LiveProvider({ children }) { + const [live, setLive] = useState({ + distance: 120, + rssi: -52, + heap: 142000, + minHeap: 118000, + fragmentation: 14, + uptime: 78921, + connected: true, + distanceHistory: Array.from({ length: 80 }, (_, i) => 100 + Math.sin(i / 6) * 20), + rawHistory: Array.from({ length: 80 }, () => 0), + smoothHistory: Array.from({ length: 80 }, () => 0), + slaveHealth: [ + { mac: "A8:42:E3:9C:11:F2", name: "stair-mid", rssi: -64, lost: 0.6, lastSeen: 1, online: true }, + { mac: "A8:42:E3:9C:14:88", name: "stair-top", rssi: -71, lost: 1.2, lastSeen: 2, online: true }, + ], + }); + const tRef = useRef(0); + useEffect(() => { + const id = setInterval(() => { + tRef.current += 1; + setLive(prev => { + const t = tRef.current; + // simulate person walking around + const target = 80 + Math.sin(t / 14) * 70 + Math.sin(t / 5) * 8; + const noisy = target + (Math.random() - 0.5) * 22; + const distance = Math.max(5, Math.min(300, noisy)); + const smoothPrev = prev.smoothHistory[prev.smoothHistory.length - 1] || target; + const smoothed = smoothPrev + (target - smoothPrev) * 0.18; + return { + ...prev, + distance, + rssi: -50 + Math.round((Math.random() - 0.5) * 6), + heap: 142000 + Math.round((Math.random() - 0.5) * 800), + uptime: prev.uptime + 1, + distanceHistory: [...prev.distanceHistory.slice(1), distance], + rawHistory: [...prev.rawHistory.slice(1), noisy], + smoothHistory: [...prev.smoothHistory.slice(1), smoothed], + }; + }); + }, 200); + return () => clearInterval(id); + }, []); + return {children}; +} + +/* ============ Helpers ============ */ +function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); } +function lerp(a, b, t) { return a + (b - a) * t; } +function hsv2rgb(h, s, v) { + h = ((h % 360) + 360) % 360; + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + let r = 0, g = 0, b = 0; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; +} +function rgb2hex(r, g, b) { + return "#" + [r, g, b].map(n => n.toString(16).padStart(2, "0")).join("").toUpperCase(); +} +function hsv2hex(h, s, v) { const [r, g, b] = hsv2rgb(h, s, v); return rgb2hex(r, g, b); } +function hex2rgb(hex) { + const h = hex.replace("#", ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} +function rgb2hsv(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const d = max - min; + let h; + if (d === 0) h = 0; + else if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + const s = max === 0 ? 0 : d / max; + return { h, s, v: max }; +} +function fmtUptime(s) { + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (d) return `${d}d ${h}h ${m}m`; + if (h) return `${h}h ${m}m ${sec}s`; + return `${m}m ${sec}s`; +} + +/* ============ Sub-components ============ */ +function Toggle({ on, onChange, large }) { + return ( + + )} +
+ +
+
+
Board profile
+
+
+ {Object.entries(BOARD_PROFILES).map(([id, p]) => ( + + ))} +
+
+
+ +
+
Radar
+
+ {[ + { id: "ld2410c", name: "LD2410C", desc: "1-D distance Β· low cost Β· 6 m range" }, + { id: "ld2450", name: "LD2450", desc: "2-D zone tracking Β· up to 3 targets" }, + ].map(r => ( + + ))} +
+
+
+ +
+
Pin map{profile.name}
+
+
+ {[ + ["ledData", "LED data", "led"], + ["radarRx", "Radar RX", "arrowRight"], + ["radarTx", "Radar TX", "arrowRight"], + ["button", "Button", "pin"], + ["statusLed", "Status LED", "bolt"], + ].map(([k, label, icon]) => ( +
+
+ + {label} +
+ { set({ pins: { ...s.pins, [k]: v } }); setNeedsReboot(true); }}/> +
+ ))} +
+
+ + Strapping pins are disabled β€” they affect boot mode and shouldn't drive an LED strip or radar UART. +
+
+
+ + +
+ ); +} + +/* ============ NETWORK ============ */ +function ScreenNetwork() { + const { s, set } = useStore(); + const live = useLive(); + const networks = [ + { ssid: "Loft 5GHz", rssi: -52, secured: true, current: true }, + { ssid: "Loft 2.4GHz", rssi: -58, secured: true }, + { ssid: "FRITZ!Box 7530", rssi: -71, secured: true }, + { ssid: "neighbor-iot", rssi: -78, secured: false }, + { ssid: "JOSE-WIFI-EXT", rssi: -82, secured: true }, + ]; + const [confirm, setConfirm] = React.useState(false); + const toast = useToast(); + + return ( +
+
+
+

Network

+
Wi-Fi, mDNS, and static IP
+
+
+ +
+
+
+ +
+
+
+ {s.ssid} + connected +
+
+ {s.ip} Β· gw {s.gateway} Β· {live.rssi} dBm +
+
+ +
+
+ +
+
Available networks + +
+
+ {networks.map(n => { + const bars = n.rssi > -60 ? 4 : n.rssi > -68 ? 3 : n.rssi > -76 ? 2 : 1; + return ( +
+
+ {[1,2,3,4].map(i => ( +
+ ))} +
+
+
{n.ssid}
+
+ {n.rssi} dBm Β· {n.secured ? "WPA2" : "open"} +
+
+ {n.current ? current + : } +
+ ); + })} +
+
+ +
+
+
Hostname
+
+ mDNS name +
+ set({ hostname: e.target.value.replace(/[^a-z0-9-]/g, "") }, { silent: true })} + onBlur={() => set({ hostname: s.hostname })}/> + .local +
+
+
+ +
+
+ Static IP + set({ staticIp: v })}/> +
+
+
+
+ Address + set({ ip: e.target.value }, { silent: true })}/> +
+
+ Gateway + set({ gateway: e.target.value }, { silent: true })}/> +
+
+ Netmask + set({ netmask: e.target.value }, { silent: true })}/> +
+
+
+
+
+ +
+
+ +
+
Reset Wi-Fi
+
Forgets credentials and reboots into setup AP
+
+ {confirm + ?
+ + +
+ : } +
+
+ + +
+ ); +} + +/* ============ SYSTEM ============ */ +function ScreenSystem() { + const { s, set } = useStore(); + const live = useLive(); + const toast = useToast(); + const [showPwd, setShowPwd] = React.useState(false); + const [confirmText, setConfirmText] = React.useState(""); + const [otaName, setOtaName] = React.useState(null); + const [checking, setChecking] = React.useState(false); + + const checkUpdates = () => { + setChecking(true); + setTimeout(() => { setChecking(false); toast.push("Up to date"); }, 1200); + }; + + return ( +
+
+
+

System

+
Firmware, auth, and diagnostics
+
+
+ +
+
+
Firmwarev6.2.1
+
+
+ +
+
v6.2.1 Β· build a8f3
+
ESP-IDF 5.1.4 Β· checked 2 min ago
+
+ +
+ + + {otaName && ( + + )} +
+
+ +
+
Diagnostics
+
+
+ {[ + ["Free heap", `${(live.heap/1024).toFixed(1)} kB`], + ["Min free heap", `${(118).toFixed(1)} kB`], + ["Fragmentation", "14%"], + ["Uptime", fmtUptime(live.uptime)], + ["Reset reason", "POWERON_RESET"], + ["CPU temp", "47.2 Β°C"], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+
+
+ +
+
+ Auth + set({ authRequired: v })}/> +
+
+ Password +
+ set({ authPassword: e.target.value }, { silent: true })}/> + +
+
+ Required only on this network. Local mDNS access is always password-protected. +
+
+
+ +
+
+
JSON config
+
+ + +
+
+ +
+
Factory reset
+
+
+ Type {s.deviceName} to confirm +
+
+ setConfirmText(e.target.value)}/> + +
+
+
+
+ + +
+ ); +} + +Object.assign(window, { ScreenHardware, ScreenNetwork, ScreenSystem }); diff --git a/frontend/design-source/project/screen-leds.jsx b/frontend/design-source/project/screen-leds.jsx new file mode 100644 index 0000000..b232e9e --- /dev/null +++ b/frontend/design-source/project/screen-leds.jsx @@ -0,0 +1,329 @@ +// AmbiSense β€” LEDs screen (Screen B) + +function ColorWheel({ color, onChange, size = 200 }) { + const cnvRef = React.useRef(null); + const draggingRef = React.useRef(false); + React.useEffect(() => { + const c = cnvRef.current; + if (!c) return; + const ctx = c.getContext("2d"); + const dpr = window.devicePixelRatio || 1; + c.width = size * dpr; c.height = size * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const r = size / 2; + const img = ctx.createImageData(size, size); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const dx = x - r, dy = y - r; + const d = Math.sqrt(dx*dx + dy*dy); + const idx = (y * size + x) * 4; + if (d > r) { img.data[idx+3] = 0; continue; } + const h = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360; + const s = Math.min(1, d / r); + const [rr, gg, bb] = hsv2rgb(h, s, 1); + img.data[idx] = rr; img.data[idx+1] = gg; img.data[idx+2] = bb; img.data[idx+3] = 255; + } + } + ctx.putImageData(img, 0, 0); + }, [size]); + const r = size / 2; + const px = r + Math.cos(color.h * Math.PI/180) * color.s * r; + const py = r + Math.sin(color.h * Math.PI/180) * color.s * r; + const handle = (e) => { + const rect = cnvRef.current.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + const dx = cx - r, dy = cy - r; + const d = Math.sqrt(dx*dx + dy*dy); + const h = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360; + const s = Math.min(1, d / r); + onChange({ ...color, h, s }); + }; + return ( +
{ draggingRef.current = true; handle(e); }} + onMouseMove={(e) => { if (draggingRef.current) handle(e); }} + onMouseUp={() => { draggingRef.current = false; }} + onMouseLeave={() => { draggingRef.current = false; }}> + +
+
+ ); +} + +function ColorPicker() { + const { s, set } = useStore(); + const hex = hsv2hex(s.color.h, s.color.s, s.color.v); + const [hexInput, setHexInput] = React.useState(hex); + React.useEffect(() => setHexInput(hex), [hex]); + const submitHex = (val) => { + const m = val.replace(/[^0-9a-f]/gi, "").slice(0, 6).padEnd(6, "0"); + const [r, g, b] = hex2rgb("#" + m); + const hsv = rgb2hsv(r, g, b); + set({ color: hsv }); + }; + return ( +
+ set({ color: c }, { silent: true })} size={180}/> +
+
+ Brightness + set({ color: { ...s.color, v: v/100 } }, { silent: true })} min={0} max={100} suffix="%"/> +
+
+ Hex +
+
+ setHexInput(e.target.value)} + onBlur={() => { submitHex(hexInput); }} + onKeyDown={e => { if (e.key === "Enter") e.target.blur(); }}/> +
+
+
+ Presets +
+ {s.presets.map((p, i) => ( +
+
+
+
+ ); +} + +function ModeCard({ mode, active, onClick }) { + const { s } = useStore(); + return ( + + ); +} + +function DualHandleRange({ minVal, maxVal, onChange, min = 0, max = 300 }) { + const ref = React.useRef(null); + const [drag, setDrag] = React.useState(null); + const handle = (e) => { + if (!drag || !ref.current) return; + const rect = ref.current.getBoundingClientRect(); + const t = clamp((e.clientX - rect.left) / rect.width, 0, 1); + const v = Math.round(min + t * (max - min)); + if (drag === "min") onChange({ minVal: Math.min(v, maxVal - 5), maxVal }); + else onChange({ minVal, maxVal: Math.max(v, minVal + 5) }); + }; + React.useEffect(() => { + if (!drag) return; + const up = () => setDrag(null); + window.addEventListener("mousemove", handle); + window.addEventListener("mouseup", up); + return () => { + window.removeEventListener("mousemove", handle); + window.removeEventListener("mouseup", up); + }; + }, [drag, minVal, maxVal]); + const tMin = (minVal - min) / (max - min); + const tMax = (maxVal - min) / (max - min); + return ( +
+
+
+ {[["min", tMin, minVal], ["max", tMax, maxVal]].map(([k, t, v]) => ( +
setDrag(k)} + style={{ + position: "absolute", top: "50%", left: `calc(${t * 100}% - 9px)`, transform: "translateY(-50%)", + width: 18, height: 18, borderRadius: "50%", + background: "var(--text-0)", border: "3px solid var(--acc-orange)", + cursor: "grab", boxShadow: "var(--shadow-1)", + display: "flex", alignItems: "center", justifyContent: "center", + }}> + {v} cm +
+ ))} +
+ ); +} + +function NumberAndSlider({ label, value, onChange, min, max, suffix = "" }) { + return ( +
+
+ {label} + onChange(clamp(Number(e.target.value), min, max))} + style={{ + width: 70, background: "var(--bg-1)", border: "1px solid var(--line)", + borderRadius: 6, padding: "2px 6px", fontFamily: "var(--font-mono)", fontSize: 12, + color: "var(--text-0)", textAlign: "right", outline: "none", + }}/> +
+ onChange(Number(e.target.value))}/> +
+ ); +} + +function ScreenLEDs() { + const { s, set } = useStore(); + const live = useLive(); + const dNorm = clamp((live.distance - s.minDistance) / (s.maxDistance - s.minDistance), 0, 1); + const currentMode = LED_MODES.find(m => m.id === s.ledMode); + const showColorPicker = ["standard", "solid", "color-wave", "comet", "pulse", "breathing", "dual-scan", "particles"].includes(s.ledMode); + const showSpeed = !["solid"].includes(s.ledMode); + const showTrail = ["standard", "comet"].includes(s.ledMode); + const showDirection = s.ledMode === "standard"; + + return ( +
+
+
+

LEDs

+
{currentMode?.name} Β· {s.numLeds} pixels Β· {s.minDistance}–{s.maxDistance} cm
+
+
+ +
+
+ +
+
+ Live preview Β· {currentMode?.name} + distance {Math.round(live.distance)} cm +
+ +
+ +
+
+
Mode
+
+
+ {LED_MODES.map(m => ( + set({ ledMode: m.id })}/> + ))} +
+
+
+ +
+ {showColorPicker && ( +
+
Color
+
+
+ )} + +
+
{currentMode?.name} parameters
+
+ set({ brightness: v }, { silent: true })} min={0} max={255}/> + {showSpeed && ( + set({ effectSpeed: v }, { silent: true })} min={0} max={100} suffix="%"/> + )} + {!["solid","standard"].includes(s.ledMode) && ( + set({ effectIntensity: v }, { silent: true })} min={0} max={100} suffix="%"/> + )} + {showTrail && ( + set({ trailLength: v }, { silent: true })} min={1} max={20} suffix=" px"/> + )} + {showDirection && ( +
+
+
Directional light
+
Brighter side leads movement
+
+ set({ directionalLight: v })}/> +
+ )} +
+
+
Background mode
+
Faint always-on color when idle
+
+ set({ backgroundMode: v })}/> +
+
+
+ +
+
Layout
+
+
+
+ Distance window + {s.minDistance}–{s.maxDistance} cm +
+ set({ minDistance: minVal, maxDistance: maxVal }, { silent: true })}/> +
+ set({ lightSpan: v }, { silent: true })} min={1} max={60} suffix=" px"/> + set({ centerShift: v }, { silent: true })} min={-30} max={30} suffix=" px"/> + set({ numLeds: v }, { silent: true })} min={10} max={300} suffix=" px"/> +
+
+
+
+ + +
+ ); +} + +Object.assign(window, { ScreenLEDs }); diff --git a/frontend/design-source/project/screen-live.jsx b/frontend/design-source/project/screen-live.jsx new file mode 100644 index 0000000..f4ba891 --- /dev/null +++ b/frontend/design-source/project/screen-live.jsx @@ -0,0 +1,231 @@ +// AmbiSense β€” Live Dashboard (Screen A) + +function DistanceMeter() { + const live = useLive(); + const { s } = useStore(); + const d = live.distance; + const inWindow = d >= s.minDistance && d <= s.maxDistance; + return ( +
+
+
+
Distance
+
+ {Math.round(d)} + cm +
+
+ + + {inWindow ? "in window" : "outside"} + + min {s.minDistance} + max {s.maxDistance} +
+
+
+
+ + {/* min/max guides */} +
+
+
+
+
+
+ last 16 s Β· 5 hz +
+
+
+
+ ); +} + +function StripPreviewCard() { + const { s } = useStore(); + const live = useLive(); + const dNorm = clamp((live.distance - s.minDistance) / (s.maxDistance - s.minDistance), 0, 1); + return ( +
+
+
Live LED preview
+ {s.numLeds} px Β· {LED_MODES.find(m => m.id === s.ledMode)?.name} +
+ +
+ px 0 + {Math.round(dNorm * 100)}% along + px {s.numLeds - 1} +
+
+ ); +} + +function DeviceCard() { + const { s } = useStore(); + const live = useLive(); + const fields = [ + ["Name", s.deviceName], + ["IP", "10.0.0.42"], + ["mDNS", `${s.hostname}.local`], + ["RSSI", `${live.rssi} dBm`], + ["Free heap", `${(live.heap / 1024).toFixed(1)} kB`], + ["Uptime", fmtUptime(live.uptime)], + ["Firmware", "v6.2.1"], + ["Board", "ESP32-C3"], + ]; + return ( +
+
Device
+
+
+ {fields.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+
+ ); +} + +function MeshCard() { + const { s } = useStore(); + const live = useLive(); + return ( +
+
+ Mesh + {s.role} +
+
+
+ {live.slaveHealth.length} slave{live.slaveHealth.length === 1 ? "" : "s"} Β· topology {s.topology} +
+
+ {live.slaveHealth.map(sl => ( +
+ +
+
{sl.name}
+
{sl.mac}
+
+ {sl.rssi} dBm +
+ ))} +
+
+
+ ); +} + +function SystemEnableCard() { + const { s, set } = useStore(); + return ( +
+
+ +
+
+
System {s.systemEnabled ? "active" : "paused"}
+
+ {s.systemEnabled ? "Radar, mesh, and LED output running" : "All output muted, mesh idle"} +
+
+ set({ systemEnabled: v })}/> +
+ ); +} + +function StatTile({ label, value, sub, accent }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function ScreenLive() { + const live = useLive(); + return ( +
+
+
+

Live

+
Real-time radar, mesh, and LED output
+
+
+ WS connected Β· 5 Hz +
+
+ +
+ +
+ +
+
+ + +
+ + + + +
+
+
+ + +
+
+ + +
+ ); +} + +Object.assign(window, { ScreenLive }); diff --git a/frontend/design-source/project/screen-motion-mesh.jsx b/frontend/design-source/project/screen-motion-mesh.jsx new file mode 100644 index 0000000..186a688 --- /dev/null +++ b/frontend/design-source/project/screen-motion-mesh.jsx @@ -0,0 +1,311 @@ +// AmbiSense β€” Motion (C) + Mesh (D) screens + +function LineChart({ raw, smooth, width = 600, height = 180 }) { + const lo = 0, hi = 300; + const range = hi - lo; + const toPath = (data) => data.map((v, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - ((v - lo) / range) * height; + return `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); + return ( + + {[0, 0.25, 0.5, 0.75, 1].map(t => ( + + ))} + + + + ); +} + +function ScreenMotion() { + const { s, set } = useStore(); + const live = useLive(); + return ( +
+
+
+

Motion

+
Smoothing, prediction, and PI gains
+
+
+ +
+
+
+
Motion smoothing
+
Filters jitter and predicts velocity
+
+ set({ motionSmoothing: v })}/> +
+ +
+
+ Raw vs smoothed Β· last 5 s + + raw + + + smoothed + + {Math.round(live.distance)} cm +
+ +
+
+ +
+
Filter
+
+ set({ positionSmoothing: v }, { silent: true })} min={0} max={100} suffix="%"/> + set({ velocitySmoothing: v }, { silent: true })} min={0} max={100} suffix="%"/> + set({ predictionFactor: v }, { silent: true })} min={0} max={100} suffix="%"/> +
+
+
PI gains
+
+ set({ pGain: v }, { silent: true })} min={0} max={100}/> + set({ iGain: v }, { silent: true })} min={0} max={100}/> +
+ + Higher P responds faster but overshoots. Higher I corrects steady-state offset over time. +
+
+
+
+
+ ); +} + +/* ============ MESH ============ */ +function TopologyDiagram({ kind, size = 96 }) { + const stroke = "var(--text-2)"; + const acc = "var(--acc-orange)"; + if (kind === "straight") { + return ( + + + {[10, 50, 90].map((x,i) => )} + + ); + } + if (kind === "l-shape") { + return ( + + + {[[20,20],[20,50],[20,80],[50,80],[80,80]].map(([x,y],i) => )} + + ); + } + if (kind === "u-shape") { + return ( + + + {[[15,20],[15,50],[15,80],[50,80],[85,80],[85,50],[85,20]].map(([x,y],i) => )} + + ); + } + if (kind === "custom") { + return ( + + + {[[15,30],[40,22],[55,40],[70,52],[85,70]].map(([x,y],i) => )} + + ); + } + return null; +} + +function SegmentEditor() { + const { s, set } = useStore(); + const segments = [ + { id: "master", label: "master Β· hall", start: 0, end: 22, color: "#FFB54A" }, + { id: "stair-mid", label: "stair-mid", start: 22, end: 42, color: "#FF7A3D" }, + { id: "stair-top", label: "stair-top", start: 42, end: 60, color: "#FF3D82" }, + ]; + const total = s.numLeds; + return ( +
+
+ {segments.map((seg, i) => { + const w = ((seg.end - seg.start) / total) * 100; + return ( +
+
{seg.label}
+
{seg.start}–{seg.end - 1}
+ {i < segments.length - 1 && ( +
+ )} +
+ ); + })} +
+
+ px 0px {total - 1} +
+
+ ); +} + +function ScreenMesh() { + const { s, set } = useStore(); + const live = useLive(); + const [pairing, setPairing] = React.useState(false); + const [pairTime, setPairTime] = React.useState(0); + const toast = useToast(); + React.useEffect(() => { + if (!pairing) return; + setPairTime(30); + const t = setInterval(() => setPairTime(x => { + if (x <= 1) { setPairing(false); return 0; } + return x - 1; + }), 1000); + return () => clearInterval(t); + }, [pairing]); + + const topologies = [ + { id: "straight", name: "Straight", desc: "Single hallway run" }, + { id: "l-shape", name: "L-shape", desc: "One corner, two flights" }, + { id: "u-shape", name: "U-shape", desc: "Two corners, three flights" }, + { id: "custom", name: "Custom", desc: "Position pixels manually" }, + ]; + + const priorities = [ + { id: "most-recent", name: "Most recent", desc: "Whichever device just saw motion" }, + { id: "slave-first", name: "Slave first", desc: "Slaves win unless silent for 2 s" }, + { id: "master-first", name: "Master first", desc: "Master wins unless silent for 2 s" }, + { id: "zone-based", name: "Zone based", desc: "Each device owns its segment range" }, + ]; + + return ( +
+
+
+

Mesh & Topology

+
{live.slaveHealth.length + 1} devices Β· ESP-NOW Β· {s.topology}
+
+ +
+ + {pairing && ( +
+
+
+ +
+
+
Pairing window open Β· {pairTime}s
+
Press the button on the new device until its status LED blinks twice
+
+ +
+
+ )} + +
+
Topology
+
+
+ {topologies.map(t => ( + + ))} +
+
+
+ +
+
LED segment editordrag handles
+
+
+ +
+
Devices
+
+ {[ + { name: s.deviceName, mac: "A8:42:E3:9C:0F:4A", role: "master", rssi: -42, lost: 0.0, online: true }, + ...live.slaveHealth.map(sl => ({ ...sl, role: "slave" })), + ].map(d => ( +
+ +
+
+ {d.name} + {d.role} +
+
{d.mac}
+
+
+
{d.rssi} dBm
+
5 ? "var(--err)" : "var(--text-3)" }}>{d.lost?.toFixed(1) ?? "0.0"}% lost
+
+ {d.role === "slave" && ( + + )} +
+ ))} +
+
+ +
+
Sensor priority
+
+
+ {priorities.map(p => ( + + ))} +
+
+
+
+ ); +} + +Object.assign(window, { ScreenMotion, ScreenMesh }); diff --git a/frontend/design-source/project/screenshots/logo.png b/frontend/design-source/project/screenshots/logo.png new file mode 100644 index 0000000..195b8b2 Binary files /dev/null and b/frontend/design-source/project/screenshots/logo.png differ diff --git a/frontend/design-source/project/tokens.css b/frontend/design-source/project/tokens.css new file mode 100644 index 0000000..5b6cbfc --- /dev/null +++ b/frontend/design-source/project/tokens.css @@ -0,0 +1,317 @@ +/* AmbiSense v6 β€” design tokens */ +:root { + /* Dark theme (default) */ + --bg-0: #0B0D10; + --bg-1: #101317; + --bg-2: #15181C; + --bg-3: #1B1F24; + --bg-4: #232830; + --line: #232830; + --line-soft: #1B1F24; + --text-0: #F2F4F7; + --text-1: #C7CDD6; + --text-2: #8A929E; + --text-3: #5C636E; + --text-4: #3D434C; + + /* Accent β€” amber β†’ pink, echoing LEDs */ + --acc-amber: #FFB54A; + --acc-orange: #FF7A3D; + --acc-pink: #FF3D82; + --acc-grad: linear-gradient(135deg, #FFB54A 0%, #FF7A3D 45%, #FF3D82 100%); + + /* Semantic */ + --ok: #4ADE80; + --warn: #FFB54A; + --err: #FF5470; + --info: #5BC7FF; + + /* Geometry */ + --r-xs: 6px; + --r-sm: 8px; + --r-md: 12px; + --r-lg: 16px; + --r-xl: 22px; + --r-pill: 999px; + + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + --s-7: 48px; + + /* Type */ + --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + + --shadow-1: 0 1px 2px rgba(0,0,0,.35); + --shadow-2: 0 6px 24px rgba(0,0,0,.4); + --shadow-glow: 0 0 32px rgba(255,122,61,.25); +} + +[data-theme="light"] { + --bg-0: #F6F5F2; + --bg-1: #EDECE7; + --bg-2: #FFFFFF; + --bg-3: #F2F1ED; + --bg-4: #E6E4DE; + --line: #DEDCD5; + --line-soft: #E8E6DF; + --text-0: #15181C; + --text-1: #2D333B; + --text-2: #5C636E; + --text-3: #8A929E; + --text-4: #B5BBC4; + --shadow-1: 0 1px 2px rgba(20,15,10,.06); + --shadow-2: 0 6px 24px rgba(20,15,10,.08); +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg-0); + color: var(--text-0); + font-family: var(--font-sans); + font-feature-settings: "ss01", "cv11"; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + font-size: 14px; + line-height: 1.5; + letter-spacing: -0.005em; + overscroll-behavior: none; +} +.mono { font-family: var(--font-mono); font-feature-settings: "zero", "ss01"; } +.smallcaps { + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 11px; + font-weight: 500; + color: var(--text-2); +} + +button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; } +input, select, textarea { font: inherit; color: inherit; } + +::selection { background: rgba(255,122,61,.35); color: var(--text-0); } + +/* Scrollbars */ +*::-webkit-scrollbar { width: 10px; height: 10px; } +*::-webkit-scrollbar-track { background: transparent; } +*::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 999px; border: 2px solid var(--bg-0); } +*::-webkit-scrollbar-thumb:hover { background: var(--text-3); } + +/* Reusable atoms */ +.card { + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: var(--r-lg); + box-shadow: var(--shadow-1); +} +.card-title { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px 0; +} +.card-body { padding: 14px 18px 18px; } +.divider { height: 1px; background: var(--line); } + +.dot { width: 8px; height: 8px; border-radius: 999px; display: inline-block; } +.dot-ok { background: var(--ok); box-shadow: 0 0 0 3px rgba(74,222,128,.15); } +.dot-warn { background: var(--warn); box-shadow: 0 0 0 3px rgba(255,181,74,.18); } +.dot-err { background: var(--err); box-shadow: 0 0 0 3px rgba(255,84,112,.18); } +.dot-off { background: var(--text-4); } + +.kbd { + font-family: var(--font-mono); + font-size: 11px; + background: var(--bg-3); + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 4px; + padding: 1px 5px; + color: var(--text-1); +} + +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 8px 14px; + border-radius: var(--r-sm); + background: var(--bg-3); + border: 1px solid var(--line); + color: var(--text-0); + transition: background .12s, border-color .12s, transform .08s; + white-space: nowrap; + font-weight: 500; + font-size: 13px; +} +.btn:hover { background: var(--bg-4); border-color: var(--text-4); } +.btn:active { transform: translateY(1px); } +.btn-primary { + background: var(--acc-grad); + border: 0; + color: #1A0F08; + font-weight: 600; +} +.btn-primary:hover { filter: brightness(1.06); background: var(--acc-grad); } +.btn-ghost { background: transparent; border: 1px solid transparent; } +.btn-ghost:hover { background: var(--bg-3); border-color: var(--line); } +.btn-danger { + background: rgba(255,84,112,.08); + border: 1px solid rgba(255,84,112,.35); + color: var(--err); +} +.btn-danger:hover { background: rgba(255,84,112,.14); border-color: rgba(255,84,112,.55); } +.btn-sm { padding: 5px 10px; font-size: 12px; } +.btn-icon { padding: 8px; } + +/* form */ +.field-label { font-size: 12px; color: var(--text-2); margin-bottom: 6px; display: block; } +.input { + width: 100%; + padding: 9px 12px; + background: var(--bg-1); + border: 1px solid var(--line); + border-radius: var(--r-sm); + color: var(--text-0); + font-size: 13px; + outline: none; + transition: border-color .12s, background .12s; +} +.input:focus { border-color: var(--acc-orange); background: var(--bg-0); } +.input.mono { font-family: var(--font-mono); } + +.select { + appearance: none; + width: 100%; + padding: 9px 32px 9px 12px; + background: var(--bg-1); + border: 1px solid var(--line); + border-radius: var(--r-sm); + color: var(--text-0); + font-size: 13px; + outline: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 10px center; +} +.select:focus { border-color: var(--acc-orange); } + +/* segmented control */ +.seg { + display: inline-flex; + background: var(--bg-1); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 3px; + gap: 2px; +} +.seg button { + padding: 6px 12px; + font-size: 12px; + border-radius: 5px; + color: var(--text-2); + font-weight: 500; +} +.seg button.on { background: var(--bg-3); color: var(--text-0); box-shadow: var(--shadow-1); } +.seg button:hover:not(.on) { color: var(--text-1); } + +/* slider */ +.range { + -webkit-appearance: none; + width: 100%; + height: 4px; + background: var(--bg-3); + border-radius: 999px; + outline: none; +} +.range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; height: 16px; + border-radius: 50%; + background: var(--text-0); + border: 3px solid var(--acc-orange); + cursor: pointer; + box-shadow: var(--shadow-1); + transition: transform .1s; +} +.range::-webkit-slider-thumb:hover { transform: scale(1.1); } +.range::-moz-range-thumb { + width: 16px; height: 16px; + border-radius: 50%; + background: var(--text-0); + border: 3px solid var(--acc-orange); + cursor: pointer; +} + +/* toggle */ +.toggle { + position: relative; + width: 36px; height: 20px; + background: var(--bg-3); + border-radius: 999px; + border: 1px solid var(--line); + transition: background .15s; + flex-shrink: 0; +} +.toggle::after { + content: ""; + position: absolute; + top: 2px; left: 2px; + width: 14px; height: 14px; + background: var(--text-1); + border-radius: 50%; + transition: left .15s, background .15s; +} +.toggle.on { background: var(--acc-grad); border-color: transparent; } +.toggle.on::after { left: 18px; background: white; } + +.toggle-lg { width: 52px; height: 30px; } +.toggle-lg::after { width: 24px; height: 24px; top: 2px; left: 2px; } +.toggle-lg.on::after { left: 26px; } + +/* chip */ +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border-radius: var(--r-pill); + background: var(--bg-3); + border: 1px solid var(--line); + font-size: 11px; + color: var(--text-1); + font-family: var(--font-mono); +} + +/* page container */ +.page { padding: 28px 32px 80px; max-width: 1280px; margin: 0 auto; } +.page-header { display: flex; align-items: end; justify-content: space-between; margin-bottom: 24px; gap: 16px; } +.page-header h1 { font-size: 22px; font-weight: 600; margin: 0; letter-spacing: -0.02em; } +.page-header .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; } + +@media (max-width: 760px) { + .page { padding: 16px 14px 96px; } + .page-header h1 { font-size: 19px; } +} + +/* utility */ +.hide-mobile { } +.show-mobile { display: none !important; } +@media (max-width: 760px) { + .hide-mobile { display: none !important; } + .show-mobile { display: revert !important; } +} + +@keyframes pulse-acc { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } } +@keyframes spin-slow { to { transform: rotate(360deg); } } +@keyframes fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} diff --git a/frontend/design-source/project/tweaks-panel.jsx b/frontend/design-source/project/tweaks-panel.jsx new file mode 100644 index 0000000..5f8f95a --- /dev/null +++ b/frontend/design-source/project/tweaks-panel.jsx @@ -0,0 +1,425 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys β†’ host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability β€” if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag β€” ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..856c6f5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AmbiSense v6 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c60e5f5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2168 @@ +{ + "name": "ambisense-ui", + "version": "6.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ambisense-ui", + "version": "6.0.0", + "dependencies": { + "preact": "^10.24.0" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.0", + "terser": "^5.46.2", + "vite": "^5.4.0", + "vite-plugin-singlefile": "^2.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz", + "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", + "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz", + "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "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/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0.tgz", + "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/terser": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", + "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", + "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ebfb280 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "ambisense-ui", + "version": "6.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "preact": "^10.24.0" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.0", + "terser": "^5.46.2", + "vite": "^5.4.0", + "vite-plugin-singlefile": "^2.0.0" + } +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..2f5e07e --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,67 @@ +/* Thin fetch wrapper. All API endpoints relative; works in dev (Vite proxies + * /api β†’ device IP) and in prod (UI is served from same origin as the API). */ + +export async function getJSON(url: string): Promise { + const r = await fetch(url, { credentials: 'same-origin' }); + if (!r.ok) throw new Error(`${r.status}: ${url}`); + return r.json(); +} + +export async function postJSON(url: string, body: any): Promise { + const r = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) { + let msg = String(r.status); + try { const j = await r.json(); if (j.error) msg = j.error; } catch {} + throw new Error(msg); + } + return r.json(); +} + +export async function postBinary(url: string, blob: Blob, onProgress?: (n: number) => void): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.upload.onprogress = (e) => onProgress && onProgress(e.loaded / e.total); + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); } + } else { + let msg = String(xhr.status); + try { const j = JSON.parse(xhr.responseText); if (j.error) msg = j.error; } catch {} + reject(new Error(msg)); + } + }; + xhr.onerror = () => reject(new Error('network')); + xhr.send(blob); + }); +} + +/* WebSocket live data β€” auto-reconnects with backoff. */ +export function liveSocket(onMsg: (data: any) => void): () => void { + let stop = false; + let ws: WebSocket | null = null; + let backoff = 500; + const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/api/live'; + function connect() { + if (stop) return; + ws = new WebSocket(url); + ws.onopen = () => { backoff = 500; }; + ws.onmessage = (e) => { + try { onMsg(JSON.parse(e.data)); } catch {} + }; + ws.onclose = () => { + if (stop) return; + setTimeout(connect, backoff); + backoff = Math.min(backoff * 2, 5000); + }; + ws.onerror = () => ws?.close(); + } + connect(); + return () => { stop = true; ws?.close(); }; +} diff --git a/frontend/src/atoms.tsx b/frontend/src/atoms.tsx new file mode 100644 index 0000000..45861c8 --- /dev/null +++ b/frontend/src/atoms.tsx @@ -0,0 +1,280 @@ +/* Faithful ports of the Sparkline, Icon, Toggle from the Claude Design + * handoff (frontend/design-source/project/core.jsx). Kept in their own + * file so they're easy to lift verbatim. */ +import { JSX } from 'preact'; +import { useState, useEffect, useRef } from 'preact/hooks'; + +export const Icon = ({ name, size = 16, stroke = 1.6, style }: { + name: string; size?: number; stroke?: number; style?: any; +}) => { + const s = size; + const common: any = { + width: s, height: s, viewBox: '0 0 24 24', + fill: 'none', stroke: 'currentColor', + 'stroke-width': stroke, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', + style, + }; + const paths: Record = { + dashboard: <>, + led: <>, + motion: <>, + mesh: <>, + chip: <>, + wifi: <>, + settings: <>, + sun: <>, + moon: <>, + check: <>, + x: <>, + info: <>, + warn: <>, + refresh: <>, + upload: <>, + bolt: <>, + cpu: <>, + radar: <>, + palette: <>, + play: <>, + pause: <>, + plus: <>, + link: <>, + search: <>, + }; + return {paths[name] || null}; +}; + +/* Sparkline β€” exact port from design source/core.jsx */ +export function Sparkline({ data, width = 200, height = 40, color = 'var(--acc-orange)', fill = true, min, max }: { + data: number[]; width?: number; height?: number; color?: string; fill?: boolean; min?: number; max?: number; +}) { + if (!data || data.length === 0) return ; + const padded = data.length > 1 ? data : [...data, ...data]; + const lo = min !== undefined ? min : Math.min(...padded); + const hi = max !== undefined ? max : Math.max(...padded); + const range = hi - lo || 1; + const pts = padded.map((v, i) => { + const x = (i / (padded.length - 1)) * width; + const y = height - ((v - lo) / range) * height; + return [x, y]; + }); + const pathD = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' '); + const fillD = pathD + ` L${width},${height} L0,${height} Z`; + const id = 'spark-' + Math.abs(color.split('').reduce((a, c) => a * 31 + c.charCodeAt(0), 0)); + return ( + + {fill && ( + <> + + + + + + + + + )} + + + ); +} + +export function fmtUptime(s: number): string { + if (!s) return 'β€”'; + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (d) return `${d}d ${h}h ${m}m`; + if (h) return `${h}h ${m}m ${sec}s`; + return `${m}m ${sec}s`; +} + +/* Generic ring buffer hook for client-side history charts. Avoids growing + * an array unbounded β€” caps at `size` samples, oldest pushed out. */ +export function useRing(size: number, seed: number = 0) { + const [buf, setBuf] = useState(() => Array(size).fill(seed)); + const push = (v: number) => setBuf(b => [...b.slice(1), v]); + return [buf, push] as const; +} + +/* Number+Slider field used across LEDs and Motion screens. */ +export function NumberAndSlider({ label, value, onChange, min, max, step = 1, suffix = '' }: { + label: string; value: number; onChange: (v: number) => void; min: number; max: number; step?: number; suffix?: string; +}) { + return ( +
+
+ {label} + onChange(Math.max(min, Math.min(max, Number((e.target as HTMLInputElement).value))))} + style="width: 80px; background: var(--bg-1); border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; font-family: var(--font-mono); font-size: 12px; color: var(--text-0); text-align: right; outline: none;"/> +
+ onChange(Number((e.target as HTMLInputElement).value))}/> + {suffix &&
{suffix}
} +
+ ); +} + +/* Dual-handle range slider for the distance window. */ +export function DualHandleRange({ minVal, maxVal, onChange, min = 0, max = 500 }: { + minVal: number; maxVal: number; onChange: (v: { minVal: number; maxVal: number }) => void; min?: number; max?: number; +}) { + const ref = useRef(null); + const [drag, setDrag] = useState<'min' | 'max' | null>(null); + const handle = (e: PointerEvent) => { + if (!drag || !ref.current) return; + const rect = ref.current.getBoundingClientRect(); + const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const v = Math.round(min + t * (max - min)); + if (drag === 'min') onChange({ minVal: Math.min(v, maxVal - 5), maxVal }); + else onChange({ minVal, maxVal: Math.max(v, minVal + 5) }); + }; + useEffect(() => { + if (!drag) return; + const up = () => setDrag(null); + window.addEventListener('pointermove', handle); + window.addEventListener('pointerup', up); + return () => { window.removeEventListener('pointermove', handle); window.removeEventListener('pointerup', up); }; + }, [drag, minVal, maxVal]); + const tMin = (minVal - min) / (max - min); + const tMax = (maxVal - min) / (max - min); + return ( +
+
+
+
setDrag('min')} style={`position: absolute; top: 50%; left: calc(${tMin*100}% - 9px); transform: translateY(-50%); width: 18px; height: 18px; border-radius: 50%; background: var(--text-0); border: 3px solid var(--acc-orange); cursor: grab; box-shadow: var(--shadow-1);`}/> +
setDrag('max')} style={`position: absolute; top: 50%; left: calc(${tMax*100}% - 9px); transform: translateY(-50%); width: 18px; height: 18px; border-radius: 50%; background: var(--text-0); border: 3px solid var(--acc-orange); cursor: grab; box-shadow: var(--shadow-1);`}/> +
+ ); +} + +/* SVG topology diagrams used by Mesh screen. */ +export function TopologyDiagram({ kind, size = 96 }: { kind: 'straight'|'l_shape'|'u_shape'|'custom'; size?: number }) { + const stroke = 'var(--text-2)'; + const acc = 'var(--acc-orange)'; + if (kind === 'straight') return ( + + + {[10, 50, 90].map((x, i) => )} + + ); + if (kind === 'l_shape') return ( + + + {[[20,20],[20,50],[20,80],[50,80],[80,80]].map(([x,y], i) => )} + + ); + if (kind === 'u_shape') return ( + + + {[[15,20],[15,50],[15,80],[50,80],[85,80],[85,50],[85,20]].map(([x,y], i) => )} + + ); + return ( + + + {[[15,30],[40,22],[55,40],[70,52],[85,70]].map(([x,y], i) => )} + + ); +} + +/* Two-line chart for Motion screen β€” raw vs smoothed distance. */ +export function LineChart({ raw, smooth, width = 600, height = 180 }: { + raw: number[]; smooth: number[]; width?: number; height?: number; +}) { + const lo = 0, hi = 300; + const range = hi - lo; + const toPath = (data: number[]) => data.map((v, i) => { + const x = (i / Math.max(1, data.length - 1)) * width; + const y = height - ((v - lo) / range) * height; + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ( + + {[0, 0.25, 0.5, 0.75, 1].map(t => ( + + ))} + + + + ); +} + +/* HSV ↔ RGB conversion for the color wheel. */ +export function hsv2rgb(h: number, s: number, v: number): [number, number, number] { + h = ((h % 360) + 360) % 360; + const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c; + let r = 0, g = 0, b = 0; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; +} +export function rgb2hex(r: number, g: number, b: number) { + return '#' + [r, g, b].map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('').toUpperCase(); +} +export function hex2rgb(hex: string): [number, number, number] { + const h = hex.replace('#', '').padEnd(6, '0').slice(0, 6); + return [parseInt(h.slice(0,2), 16), parseInt(h.slice(2,4), 16), parseInt(h.slice(4,6), 16)]; +} + +/* Animated logo mark β€” triangle "A" with concentric pulse rings. + * Pulse intensity scales with proximity (closer target = brighter). + * Faithful port of frontend/design-source/project/app.jsx LogoMark. */ +export function LogoMark({ size = 36, distance = 0 }: { size?: number; distance?: number }) { + const pulseStrength = Math.max(0.2, Math.min(1, (250 - distance) / 200)); + return ( +
+
+ + + + + + + + + + + + + + + + {[0, 1, 2].map(i => ( + + ))} + + + + + + {[[6,6],[42,6],[6,42],[42,42]].map(([x,y]) => ( + + ))} + +
+ ); +} + +/* Wordmark with gradient "Sense" β€” paired with LogoMark in sidebar/header. */ +export function Wordmark({ font = 16, sub = 10, mono = false, version, target }: { + font?: number; sub?: number; mono?: boolean; version?: string; target?: string; +}) { + if (mono) return null; + return ( +
+ + AmbiSense + + + {version || ''} Β· {target || ''} + +
+ ); +} diff --git a/frontend/src/components.tsx b/frontend/src/components.tsx new file mode 100644 index 0000000..a569c4e --- /dev/null +++ b/frontend/src/components.tsx @@ -0,0 +1,130 @@ +/** Shared UI atoms. */ +import { ComponentChildren, JSX } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; + +export function Card({ title, right, children }: { + title?: string; right?: ComponentChildren; children: ComponentChildren; +}) { + return ( +
+ {title && ( +
+

{title}

+ {right} +
+ )} +
{children}
+
+ ); +} + +export function Toggle({ value, onChange, large }: { + value: boolean; onChange: (v: boolean) => void; large?: boolean; +}) { + return ( +
onChange(!value)} + role="switch" + aria-checked={value} + /> + ); +} + +export function Field({ label, children }: { label: string; children: ComponentChildren }) { + return ( +
+ + {children} +
+ ); +} + +export function Slider({ value, min, max, onChange, suffix }: { + value: number; min: number; max: number; onChange: (v: number) => void; suffix?: string; +}) { + return ( +
+ onChange(parseInt((e.target as HTMLInputElement).value))} + /> + + {value}{suffix || ''} + +
+ ); +} + +export function Toast({ msg, kind, onDone }: { msg: string; kind: 'ok'|'err'; onDone: () => void }) { + useEffect(() => { + const t = setTimeout(onDone, kind === 'err' ? 4500 : 2200); + return () => clearTimeout(t); + }, []); + return
{msg}
; +} + +export function Row({ k, v }: { k: string; v: ComponentChildren }) { + return ( +
+ {k} + {v} +
+ ); +} + +export function Dot({ kind }: { kind: 'ok'|'warn'|'err'|'off' }) { + return ; +} + +/* Optimistic save helper: shows a toast based on a promise. */ +export function useToaster() { + const [t, setT] = useState<{ msg: string; kind: 'ok'|'err' } | null>(null); + return { + toast: t, + set: (msg: string, kind: 'ok'|'err' = 'ok') => setT({ msg, kind }), + clear: () => setT(null), + track: async (p: Promise, okMsg = 'Saved'): Promise => { + try { const r = await p; setT({ msg: okMsg, kind: 'ok' }); return r; } + catch (e: any) { setT({ msg: e.message || 'Failed', kind: 'err' }); return undefined; } + } + }; +} + +/* HSV color picker β€” simple wheel + lightness slider. Returns r/g/b. */ +export function ColorPicker({ rgb, onChange }: { rgb: [number,number,number]; onChange: (r:number,g:number,b:number)=>void }) { + const presets: [number,number,number][] = [ + [255,255,255], [255,170,80], [255,80,80], [255,80,180], + [180,80,255], [80,180,255], [80,255,180], [255,255,80], + ]; + return ( +
+ {presets.map(p => { + const on = p[0]===rgb[0] && p[1]===rgb[1] && p[2]===rgb[2]; + return ( +
onChange(p[0], p[1], p[2])} + /> + ); + })} + v.toString(16).padStart(2,'0')).join('')}`} + onInput={(e) => { + const hex = (e.target as HTMLInputElement).value; + const r = parseInt(hex.slice(1,3),16); + const g = parseInt(hex.slice(3,5),16); + const b = parseInt(hex.slice(5,7),16); + onChange(r,g,b); + }} + style="width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 8px; padding: 2px; background: var(--bg-1); cursor: pointer;" + /> +
+ ); +} diff --git a/frontend/src/led_preview.tsx b/frontend/src/led_preview.tsx new file mode 100644 index 0000000..ccdd872 --- /dev/null +++ b/frontend/src/led_preview.tsx @@ -0,0 +1,198 @@ +/** LED strip canvas preview. Mirrors the firmware's mode logic so the + on-screen animation matches what a real strip would show. */ +import { useEffect, useRef } from 'preact/hooks'; + +export const LED_MODE_NAMES = [ + 'Standard','Rainbow','Color Wave','Breathing','Solid', + 'Comet','Pulse','Fire','Theater Chase','Dual Scan','Motion Particles', +]; + +interface PreviewProps { + mode: number; + rgb: [number, number, number]; + count: number; + brightness: number; + span: number; + distance?: number; + minD?: number; + maxD?: number; + height?: number; + speed?: number; + intensity?: number; +} + +interface RGB { r: number; g: number; b: number; } + +const wheel = (p: number): RGB => { + if (p < 85) return { r: p*3, g: 255-p*3, b: 0 }; + if (p < 170) { p -= 85; return { r: 255-p*3, g: 0, b: p*3 }; } + p -= 170; return { r: 0, g: p*3, b: 255-p*3 }; +}; +const dim = (c: RGB, k: number): RGB => ({ r: (c.r*k)|0, g: (c.g*k)|0, b: (c.b*k)|0 }); + +export function LedPreview(p: PreviewProps) { + const ref = useRef(null); + const stateRef = useRef<{ step: number; particles: any[]; heat: number[]; prev: RGB[] }>({ + step: 0, particles: [], heat: [], prev: [], + }); + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let raf = 0; + let stop = false; + + const draw = () => { + if (stop) return; + /* Pause when tab is hidden β€” phones don't need to burn battery on + * an off-screen canvas. The next visibilitychange resumes us. */ + if (document.hidden) { + raf = requestAnimationFrame(draw); + return; + } + const w = canvas.width = canvas.clientWidth * window.devicePixelRatio; + const h = canvas.height = (p.height || 80) * window.devicePixelRatio; + const n = Math.max(1, Math.min(p.count, 300)); /* preview cap */ + const pxW = w / n; + ctx.clearRect(0, 0, w, h); + + const s = stateRef.current; + if (s.heat.length !== n) s.heat = new Array(n).fill(0); + if (s.prev.length !== n) s.prev = new Array(n).fill({r:0,g:0,b:0}); + + const base: RGB = { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] }; + const br = (p.brightness ?? 255) / 255; + const minD = p.minD ?? 30, maxD = p.maxD ?? 300; + const span = Math.max(1, p.span); + const avail = Math.max(0, n - span); + const dist = p.distance ?? minD + Math.abs(Math.sin(s.step * 0.01)) * (maxD - minD); + const start = ((dist - minD) / (maxD - minD) * avail) | 0; + const espd = (p.speed ?? 50) / 50; + const eint = (p.intensity ?? 50) / 100; + + const paint = (i: number, c: RGB) => { + if (i < 0 || i >= n) return; + ctx.fillStyle = `rgb(${(c.r*br)|0},${(c.g*br)|0},${(c.b*br)|0})`; + ctx.fillRect(i * pxW, 0, pxW + 1, h); + }; + + switch (p.mode) { + case 0: { /* standard */ + for (let i = 0; i < n; i++) paint(i, dim(base, 0.05)); + for (let i = start; i < Math.min(start + span, n); i++) paint(i, base); + break; + } + case 1: { /* rainbow */ + const off = (s.step * espd) & 0xFF; + for (let i = 0; i < n; i++) paint(i, wheel(((i*256/n + off) | 0) & 0xFF)); + break; + } + case 2: { /* color_wave */ + for (let i = 0; i < n; i++) { + const phase = i / n * 6.28 + s.step * 0.05 * espd; + let k = (Math.sin(phase) + 1) * 0.5; + k = k * eint + (1 - eint) * 0.4; + paint(i, dim(wheel(((i*256/n + s.step) | 0) & 0xFF), k)); + } + break; + } + case 3: { /* breathing */ + let k = (Math.sin(s.step * 0.05 * espd) + 1) * 0.5; + k = 0.1 + 0.9 * k * eint; + for (let i = 0; i < n; i++) paint(i, dim(base, k)); + break; + } + case 4: { /* solid */ + for (let i = 0; i < n; i++) paint(i, base); + break; + } + case 5: { /* comet */ + const fade = 0.85; + for (let i = 0; i < n; i++) { + s.prev[i] = dim(s.prev[i], fade); + paint(i, s.prev[i]); + } + for (let i = 0; i < 3; i++) { + const px = start + i; + if (px < n) { s.prev[px] = base; paint(px, base); } + } + break; + } + case 6: { /* pulse */ + for (let i = 0; i < n; i++) paint(i, {r:0,g:0,b:0}); + const max_r = n / 4; + for (let p2 = 0; p2 < 3; p2++) { + const phase = p2 * 2; + const r = ((s.step * 0.2 + phase) % max_r); + for (let off = -((r)|0); off <= ((r)|0); off++) { + const k = (1 - Math.abs(off / Math.max(0.1, r)) ** 2) * eint; + const idx = start + off; + if (idx >= 0 && idx < n && k > 0) paint(idx, dim(base, k)); + } + } + break; + } + case 7: { /* fire */ + for (let i = 0; i < n; i++) s.heat[i] = Math.max(0, s.heat[i] - (Math.random()*55 + 2)|0); + for (let i = n-1; i >= 2; i--) s.heat[i] = ((s.heat[i-1] + s.heat[i-2]*2) / 3)|0; + if (Math.random() < 0.5) { const y = (Math.random()*7)|0; s.heat[y] = Math.min(255, s.heat[y] + 160 + ((Math.random()*96)|0)); } + for (let i = 0; i < n; i++) { + const t = ((s.heat[i] * 191) / 255)|0; + let c: RGB; + if (t < 64) c = {r: t*4, g: 0, b: 0}; + else if (t < 128) c = {r: 255, g: (t-64)*4, b: 0}; + else c = {r: 255, g: 255, b: (t-128)*4}; + paint(i, c); + } + break; + } + case 8: { /* theater_chase */ + const gap = 3, ph = s.step % gap; + for (let i = 0; i < n; i++) paint(i, ((i+ph) % gap === 0) ? base : {r:0,g:0,b:0}); + break; + } + case 9: { /* dual_scan */ + for (let i = 0; i < n; i++) paint(i, {r:0,g:0,b:0}); + const sw = 4, p1 = (s.step|0) % n, p2 = n - 1 - ((s.step|0) % n); + const inv: RGB = { r: 255-base.r, g: 255-base.g, b: 255-base.b }; + for (let off = -sw; off <= sw; off++) { + const k = 1 - Math.abs(off/sw); + if (p1+off >= 0 && p1+off < n) paint(p1+off, dim(base, k)); + if (p2+off >= 0 && p2+off < n) paint(p2+off, dim(inv, k)); + } + if (start >= 0 && start < n) paint(start, {r:255,g:255,b:255}); + break; + } + case 10: { /* motion_particles */ + for (const part of s.particles) { + if (!part.active) continue; + part.pos += part.vel; part.bright -= 0.02; + if (part.bright <= 0 || part.pos < 0 || part.pos >= n) part.active = false; + } + const spawn = 1 + (eint * 4)|0; + for (let i = 0; i < spawn; i++) { + if (s.particles.length < 50) s.particles.push({ active: true, pos: start + (Math.random()*7-3), vel: (Math.random()*2-1)*espd*0.6, bright: 1 }); + else for (const part of s.particles) if (!part.active) { part.active = true; part.pos = start + (Math.random()*7-3); part.vel = (Math.random()*2-1)*espd*0.6; part.bright = 1; break; } + } + for (let i = 0; i < n; i++) paint(i, {r:0,g:0,b:0}); + for (const part of s.particles) { + if (!part.active) continue; + const px = part.pos|0; + if (px >= 0 && px < n) paint(px, dim(base, part.bright)); + } + break; + } + } + + s.step++; + raf = requestAnimationFrame(draw); + }; + raf = requestAnimationFrame(draw); + return () => { stop = true; cancelAnimationFrame(raf); }; + }, [p.mode, p.rgb[0], p.rgb[1], p.rgb[2], p.count, p.brightness, p.span, p.distance, p.speed, p.intensity]); + + return ; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c40b855 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,134 @@ +/** App shell β€” sidebar (logo + nav + IP/board footer) + sticky header + * (page name Β· hostname.local + live distance chip + RSSI + theme). + * Faithful port of frontend/design-source/project/app.jsx. */ +import { render } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import { getJSON, liveSocket } from './api'; +import { Toast, useToaster } from './components'; +import { Icon, LogoMark, Wordmark } from './atoms'; +import { ScreenLive, ScreenLeds, ScreenMotion, ScreenMesh, ScreenHardware, ScreenNetwork, ScreenSystem } from './screens'; +import './styles.css'; + +type Tab = 'live'|'leds'|'motion'|'mesh'|'hardware'|'network'|'system'; + +const TABS: { id: Tab; name: string; icon: string }[] = [ + { id: 'live', name: 'Live', icon: 'dashboard' }, + { id: 'leds', name: 'LEDs', icon: 'led' }, + { id: 'motion', name: 'Motion', icon: 'motion' }, + { id: 'mesh', name: 'Mesh', icon: 'mesh' }, + { id: 'hardware', name: 'Hardware', icon: 'chip' }, + { id: 'network', name: 'Network', icon: 'wifi' }, + { id: 'system', name: 'System', icon: 'settings' }, +]; + +function App() { + const [tab, setTab] = useState(localStorage.getItem('ambitab') as Tab || 'live'); + const [version, setVersion] = useState({}); + const [settings, setSettings] = useState({}); + const [live, setLive] = useState({ distance: 0, raw: 0, direction: 0, rssi: 0, heap: 0, uptime: 0, peers: 0, healthy: 0 }); + const [theme, setTheme] = useState<'dark'|'light'>(localStorage.getItem('ambitheme') as any || 'dark'); + const [wsConnected, setWsConnected] = useState(false); + const t = useToaster(); + + const reload = () => { + getJSON('/api/version').then(setVersion).catch(() => {}); + getJSON('/api/settings').then(setSettings).catch(() => {}); + }; + + useEffect(() => { reload(); }, []); + useEffect(() => { + const id = setInterval(() => { if (!document.hidden) getJSON('/api/version').then(setVersion).catch(() => {}); }, 30000); + return () => clearInterval(id); + }, []); + useEffect(() => { + const close = liveSocket((data) => { setLive(data); setWsConnected(true); }); + return () => { close(); setWsConnected(false); }; + }, []); + useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('ambitheme', theme); }, [theme]); + useEffect(() => { localStorage.setItem('ambitab', tab); }, [tab]); + + const state = { live, settings, version, toast: t.toast, setToast: t.set, reload }; + const switcher: Record = { live: ScreenLive, leds: ScreenLeds, motion: ScreenMotion, mesh: ScreenMesh, hardware: ScreenHardware, network: ScreenNetwork, system: ScreenSystem }; + const Screen = switcher[tab]; + const tabName = TABS.find(x => x.id === tab)?.name || ''; + const dist = Math.round(live.distance || 0); + const ipShort = version.ip || 'β€”'; + const radarKind = settings.radar_kind ? settings.radar_kind.toUpperCase() : ''; + const boardSub = `${(version.target || 'esp32').toUpperCase()}${radarKind ? ` Β· ${radarKind}` : ''}`; + const fwVer = version.version || 'v6.x'; + const fwTarget = (version.target || 'esp32').toUpperCase(); + + return ( +
+ {/* Sidebar β€” desktop only */} + + + {/* Main column */} +
+ {/* Sticky header */} +
+
+ +
+
+
+ {tabName} + + {version.hostname ? `${version.hostname}.local` : 'configuring…'} + +
+
+
+ + live Β· {dist} cm + + {live.rssi || 0} dBm +
+ +
+ + {/* Page content */} +
+ +
+ + {/* Mobile bottom-tabs */} + +
+ {t.toast && } +
+ ); +} + +render(, document.getElementById('root')!); diff --git a/frontend/src/screens.tsx b/frontend/src/screens.tsx new file mode 100644 index 0000000..ee077ba --- /dev/null +++ b/frontend/src/screens.tsx @@ -0,0 +1,1166 @@ +/** All seven screens: Live, LEDs, Motion, Mesh, Hardware, Network, System. + * Faithful port of frontend/design-source/. Every control wires to a real + * /api/* endpoint with optimistic updates + toast confirmation. */ +import { useEffect, useRef, useState } from 'preact/hooks'; +import { Card, Toggle, Field, Slider, Row, Dot, ColorPicker as PaletteColorPicker, useToaster } from './components'; +import { LedPreview, LED_MODE_NAMES } from './led_preview'; +import { Icon, Sparkline, fmtUptime, NumberAndSlider, DualHandleRange, TopologyDiagram, LineChart, hsv2rgb, rgb2hex, hex2rgb } from './atoms'; +import { getJSON, postJSON, postBinary } from './api'; + +interface Live { distance: number; direction: number; rssi: number; heap: number; uptime: number; peers: number; healthy: number; } +export interface AppState { + live: Live; + settings: any; + version: any; + toast: any; + setToast: (m: string, k?: 'ok'|'err') => void; + reload: () => void; +} + +const MODE_DESCRIPTIONS = [ + 'Distance-driven cluster with directional fade', + 'Full-strip hue cycle', + 'Sine wave of color across strip', + 'Strip gently inhales and exhales', + 'All pixels one color', + 'Tail chasing across the strip', + 'Center pulse expands outward', + 'Flickering ember simulation', + 'Marquee dot pattern', + 'Two scanners meet in the middle', + 'Particles spawn from your position', +]; + +function PageHead({ title, sub, right }: any) { + return ( +
+
+

{title}

+ {sub &&
{sub}
} +
+ {right} +
+ ); +} + +/* Shared debounced save β€” every screen with sliders uses this. Sliders fire + * onChange ~30Γ—/s while dragging; without debouncing each tick fires a POST + * /api/settings + 2Γ— GET (reload), which overwhelms the C3's single-core + * httpd (max_open_sockets=7) and triggers ERR_CONNECTION_RESET. With a + * 300 ms tail, only the *final* slider value POSTs once the user stops + * moving. Multiple keys touched within the window get coalesced into a + * single JSON body β€” the firmware /api/settings POST handler already + * iterates the whole object looking for known keys, so one batched call + * is identical in effect to N individual calls. */ +function useDebouncedSave(reload: () => void, setToast: (m: string, k?: 'ok'|'err') => void, delay = 300) { + const pending = useRef({}); + const timer = useRef(null); + return (patch: any) => { + pending.current = { ...pending.current, ...patch }; + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(async () => { + const body = pending.current; + pending.current = {}; + timer.current = null; + try { await postJSON('/api/settings', body); reload(); } + catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + }, delay); + }; +} + +/* ================================================================= */ +/* A. LIVE */ +/* ================================================================= */ +export function ScreenLive({ live, version, settings, setToast }: AppState) { + const dist = Math.round(live.distance || 0); + const minD = settings.min_distance ?? 30; + const maxD = settings.max_distance ?? 300; + const inWindow = dist >= minD && dist <= maxD; + /* Push a sample on EVERY live update (now 20 Hz) instead of only when + * the integer cm value changes. Otherwise the sparkline freezes during + * stationary-but-real-time periods, which looks broken. */ + const histRef = useRef(Array(80).fill(0)); + const [hist, setHist] = useState(histRef.current); + useEffect(() => { + histRef.current = [...histRef.current.slice(1), dist]; + setHist(histRef.current); + }, [live]); + + const [sysEn, setSysEn] = useState(true); + useEffect(() => { getJSON('/api/system').then(r => setSysEn(!!r.enabled)).catch(() => {}); }, []); + const toggleSys = async () => { + const next = !sysEn; + setSysEn(next); + try { await postJSON('/api/system', { enabled: next }); } + catch (e: any) { setSysEn(!next); setToast(e.message || 'Toggle failed', 'err'); } + }; + + return ( + <> + WS connected Β· 5 Hz}/> + +
+
+ +
+
+
System {sysEn ? 'active' : 'paused'}
+
{sysEn ? 'Radar, mesh, and LED output running' : 'All output muted, mesh idle'}
+
+ +
+ +
+
+
+
+
+
Distance
+
+ {dist}cm +
+
+ + {inWindow ? 'in window' : 'outside'} + + min {minD} + max {maxD} + {live.direction === 0 ? 'still' : live.direction < 0 ? 'closer β†’' : 'away β†’'} +
+
+
+
+ +
+
+
+
+
+
last 16 s Β· 5 Hz
+
+
+
+ +
+
+
Live LED preview Β· {LED_MODE_NAMES[settings.light_mode ?? 0]}
+ distance {dist} cm +
+ +
+ +
+ + -65 ? 'dBm Β· excellent' : (live.rssi||0) > -75 ? 'dBm Β· good' : 'dBm Β· weak'}/> + + +
+
+ +
+
+
Device
+
+
+ + + + + + + + +
+
+
+
+
Mesh{live.peers > 0 ? 'peer' : 'standalone'}
+
+
{live.peers || 0} peer{(live.peers||0) === 1 ? '' : 's'} Β· {live.healthy||0} healthy
+ {(live.peers || 0) === 0 && ( +
+ No peers discovered. Open Mesh tab to start a 30 s pairing window. +
+ )} +
+
+
+
+ + ); +} + +function StatTile({ label, value, sub, accent }: any) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function DevField({ k, v }: any) { + return ( +
+
{k}
+
{v}
+
+ ); +} + +/* ================================================================= */ +/* B. LEDs */ +/* ================================================================= */ +export function ScreenLeds({ settings, live, reload, setToast }: AppState) { + const [s, setS] = useState(settings); + useEffect(() => setS(settings), [JSON.stringify(settings)]); + const debouncedSave = useDebouncedSave(reload, setToast); + /* `save` updates local state immediately so the slider/preview feel + * instantaneous, then queues the network write under the debouncer. */ + const save = (patch: any) => { + setS((prev: any) => ({ ...prev, ...patch })); + debouncedSave(patch); + }; + const mode = s.light_mode ?? 0; + const showColor = [0,2,3,4,5,6,9,10].includes(mode); + const showSpeed = mode !== 4; + const showTrail = mode === 0 || mode === 5; + const showDirection = mode === 0; + const showIntensity = ![0,4].includes(mode); + + const hex = rgb2hex(s.r ?? 255, s.g ?? 255, s.b ?? 255); + const presets = ['#FFB54A', '#FF7A3D', '#FF3D82', '#5BC7FF', '#4ADE80', '#9D5BFF', '#FF5470', '#FFFFFF']; + + return ( + <> + + +
+
+ Live preview Β· {LED_MODE_NAMES[mode]} + distance {Math.round(live.distance||0)} cm +
+ +
+ +
+
+
Mode
+
+
+ {LED_MODE_NAMES.map((name, i) => ( + + ))} +
+
+
+ +
+ {showColor && ( +
+
Color
+
+
+
+ { + const h = (e.target as HTMLInputElement).value; + const [r, g, b] = hex2rgb(h); + save({ r, g, b }); + }}/> +
+
Presets
+
+ {presets.map(p => { + const [r, g, b] = hex2rgb(p); + const on = hex.toUpperCase() === p.toUpperCase(); + return ( +
+
+
+ )} + +
+
{LED_MODE_NAMES[mode]} parameters
+
+ save({ brightness: v })} min={0} max={255}/> + {showSpeed && save({ effect_speed: v })} min={0} max={100}/>} + {showIntensity && save({ effect_intensity: v })} min={0} max={100}/>} + {showTrail && save({ trail: v })} min={0} max={20}/>} + {showDirection && ( +
+
+
Directional light
+
Brighter side leads movement
+
+ save({ dir_light: v ? 1 : 0 })}/> +
+ )} +
+
+
Background mode
+
Faint always-on color when idle
+
+ save({ bg_mode: v ? 1 : 0 })}/> +
+
+
+ +
+
Layout
+
+
+
+ Distance window + {s.min_distance ?? 30}–{s.max_distance ?? 300} cm +
+ save({ min_distance: minVal, max_distance: maxVal })} min={0} max={500}/> +
+ save({ span: v })} min={1} max={150}/> + save({ center_shift: v })} min={-100} max={100}/> + save({ led_count: v })} min={1} max={1500}/> +
+
+
+
+ + ); +} + +/* ================================================================= */ +/* C. MOTION */ +/* ================================================================= */ +export function ScreenMotion({ settings, live, reload, setToast }: AppState) { + const [s, setS] = useState(settings); + const [showAdvanced, setShowAdvanced] = useState(false); + useEffect(() => setS(settings), [JSON.stringify(settings)]); + const debouncedSave = useDebouncedSave(reload, setToast); + const save = (patch: any) => { + setS((prev: any) => ({ ...prev, ...patch })); + debouncedSave(patch); + }; + + /* Both buffers come straight from the firmware (raw_cm + distance_cm over + * WS at 20 Hz). No client-side alpha simulation β€” the chart shows what + * the firmware actually feeds the LED engine. */ + const rawRef = useRef(Array(80).fill(0)); + const smoothRef = useRef(Array(80).fill(0)); + const [raw, setRaw] = useState(rawRef.current); + const [smooth, setSmooth] = useState(smoothRef.current); + useEffect(() => { + const r = (live as any).raw ?? live.distance; + const sm = live.distance; + rawRef.current = [...rawRef.current.slice(1), r]; + smoothRef.current = [...smoothRef.current.slice(1), sm]; + setRaw([...rawRef.current]); + setSmooth([...smoothRef.current]); + }, [live]); + + const enabled = !!s.motion_enabled; + const mode: 'kalman'|'pi' = (s.motion_mode === 'pi') ? 'pi' : 'kalman'; + const response = s.response ?? 50; + const lookAhead = s.look_ahead_ms ?? 0; + const outlier = s.outlier_strength ?? 1; + + /* Tooltip text under each main slider β€” explains *what* the knob does + * physically, not just its numeric value. Helps users without filtering + * theory background pick a setting that matches their installation. */ + const responseHint = + response < 25 ? 'Heavy filtering. Drift is invisible, but fast walk-throughs lag noticeably.' : + response < 65 ? 'Balanced. Walks render smoothly; jitter is suppressed.' : + 'Snappy. The strip tracks subtle motion but radar noise leaks through.'; + const lookHint = + lookAhead === 0 ? 'No predictive lead. The strip lights where the radar last saw you.' : + lookAhead < 200 ? 'Slight predictive lead β€” masks ~50 ms render latency.' : + 'Aggressive prediction. Great for fast stairs, may overshoot near corners.'; + const outlierLabel = ['Off', 'Soft (3-sample)', 'Strong (7-sample)'][outlier] || 'Soft'; + + return ( + <> + setShowAdvanced(!showAdvanced)}> + {showAdvanced ? 'Hide' : 'Show'} advanced + }/> + +
+
+
+
Motion smoothing
+
{enabled ? 'Filters jitter and predicts velocity' : 'Disabled β€” strip follows raw radar'}
+
+ save({ motion_enabled: v ? 1 : 0 })}/> +
+ +
+
+ Raw vs smoothed Β· last 16 s + + raw + + + smoothed + + {Math.round(live.distance||0)} cm +
+ +
+
+ + {/* Algorithm picker */} +
+
Algorithm
+
+
+ {[ + { id: 'kalman', name: 'Kalman', desc: 'Default. Estimates position + velocity together; energy-aware noise model. Best for stairs.' }, + { id: 'pi', name: 'Legacy PI', desc: 'EMA + PI controller from v5. Five tunables; familiar if you tuned the Arduino build.' }, + ].map(a => { + const active = mode === a.id; + return ( + + ); + })} +
+
+
+ + {/* Two main sliders: Response + Look-ahead. These map onto either + * Kalman process noise (Q_pos, Q_vel) or PI alpha+predict in the + * firmware β€” the user shouldn't need to know which. */} +
+
Tuning
+
+
+
+ + Calm ⇆ Snappy Β· {response} +
+ save({ response: +e.target.value })}/> +
{responseHint}
+
+
+
+ + {lookAhead} ms +
+ save({ look_ahead_ms: +e.target.value })}/> +
{lookHint}
+
+
+
+ + {outlierLabel} +
+
+ {[ + { v: 0, name: 'Off', note: 'Trust radar 1:1' }, + { v: 1, name: 'Soft', note: 'Median of last 3' }, + { v: 2, name: 'Strong', note: 'Median of last 7' }, + ].map(o => { + const active = outlier === o.v; + return ( + + ); + })} +
+
Stronger rejection masks single-sample radar glitches but adds 1-2 frames of lag.
+
+
+
+ + {/* Advanced (collapsed by default) β€” exposes the v5 PI knobs for power + * users. Has visible effect only when Algorithm = Legacy PI. */} + {showAdvanced && ( +
+
+ Advanced β€” Legacy PI gains + + {mode === 'pi' ? 'Active' : 'Inactive in Kalman mode'} + +
+
+ save({ pos_smooth_x1k: v })} min={10} max={800} suffix="Γ— 1/1000"/> + save({ vel_smooth_x1k: v })} min={10} max={500} suffix="Γ— 1/1000"/> + save({ predict_x1k: v })} min={0} max={2000} suffix="Γ— 1/1000"/> + save({ p_gain_x1k: v })} min={0} max={1000} suffix="Γ— 1/1000"/> + save({ i_gain_x1k: v })} min={0} max={200} suffix="Γ— 1/1000"/> +
+ + These are the v5 firmware knobs. In Kalman mode the Response slider above replaces them. Higher P responds faster but overshoots; higher I corrects steady-state drift over time. +
+
+
+ )} + + ); +} + +/* ================================================================= */ +/* D. MESH */ +/* ================================================================= */ +export function ScreenMesh({ live, settings, setToast, reload }: AppState) { + const [topology, setTopology] = useState({ kind: 'straight', segments: [], total_leds: 30 }); + const [mesh, setMesh] = useState({ peers: [], fusion: 'most_recent', coordinator: true, pairing: false, pairing_ms_left: 0, my_mac: '' }); + const [identifying, setIdentifying] = useState(null); + + const refresh = () => Promise.all([ + getJSON('/api/topology').then(setTopology), + getJSON('/api/mesh').then(setMesh), + ]).catch(() => {}); + + /* Two polling cadences: 4 s when idle (cheap), 500 ms while pairing + * window is open (so the countdown ring updates in real time and we + * spot a new peer joining within half a second). The /api/mesh + * response carries pairing_ms_left from firmware β€” we never run a + * client-side countdown, so cancellation, OTA reboots, and pair-on- + * other-device events are all reflected accurately. */ + useEffect(() => { + refresh(); + const fast = mesh.pairing; + const id = setInterval(refresh, fast ? 500 : 4000); + return () => clearInterval(id); + }, [mesh.pairing]); + + const startPair = async () => { + try { await postJSON('/api/mesh', { pair: true }); setToast('Pairing window open Β· 30 s'); refresh(); } + catch (e: any) { setToast(e.message || 'Pair failed', 'err'); } + }; + + const identifyPeer = async (mac: string) => { + setIdentifying(mac); + try { + await postJSON('/api/mesh/identify', { mac }); + setToast(`Identifying ${mac.slice(-5)}…`); + } catch (e: any) { + setToast(e.message || 'Identify failed', 'err'); + } + setTimeout(() => setIdentifying(null), 5000); + }; + + const setTopo = async (kind: string) => { + try { await postJSON('/api/topology', { kind }); setToast('Topology saved'); refresh(); } + catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + }; + + const setFusion = async (f: string) => { + try { await postJSON('/api/mesh', { fusion: f }); setToast('Priority saved'); refresh(); } + catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + }; + + const topologies = [ + { id: 'straight', name: 'Straight', desc: 'Single hallway run' }, + { id: 'l_shape', name: 'L-shape', desc: 'One corner, two flights' }, + { id: 'u_shape', name: 'U-shape', desc: 'Two corners, three flights' }, + { id: 'custom', name: 'Custom', desc: 'Position pixels manually' }, + ]; + const priorities = [ + { id: 'most_recent', name: 'Most recent', desc: 'Whichever device just saw motion' }, + { id: 'slave_first', name: 'Slave first', desc: 'Slaves win unless silent for 2 s' }, + { id: 'master_first', name: 'Master first', desc: 'Master wins unless silent for 2 s' }, + { id: 'zone_based', name: 'Zone based', desc: 'Each device owns its segment range' }, + ]; + + const myMac: string = mesh.my_mac || ''; + const pairSecsLeft = Math.ceil((mesh.pairing_ms_left || 0) / 1000); + const pairProgress = Math.max(0, Math.min(1, (mesh.pairing_ms_left || 0) / 30000)); + + /* Rendering "this device + all peers" as one unified list keeps the + * mental model "every device is equal" β€” coordinator is just whoever + * has the lowest MAC at any given moment. */ + const allDevices = [ + { mac: myMac || 'β€”', name: settings.device_name || 'this device', role: mesh.coordinator ? 'coordinator' : 'follower', rssi: live.rssi || -50, healthy: true, self: true }, + ...(mesh.peers || []).map((p: any) => ({ ...p, role: 'follower', name: p.mac.slice(-5).toUpperCase(), self: false })), + ]; + + return ( + <> + + {mesh.pairing ? <> Listening Β· {pairSecsLeft}s : <> Pair new device} + }/> + + {/* Pairing card with circular SVG countdown β€” visual anchor that + * communicates "the device is actively listening RIGHT NOW" much + * more clearly than a number. */} + {mesh.pairing && ( +
+
+ + + + + + + + + + + {pairSecsLeft} + +
+
Pairing window open
+
+ On the other device, either hold the BOOT button for 3 seconds or open its web UI and click Pair new device. They auto-connect β€” no need to click on both. +
+
+
+
+ )} + +
+
Topology
+
+
+ {topologies.map(t => { + const active = topology.kind === t.id; + return ( + + ); + })} +
+
+
+ +
+
+ Devices + Click Identify to make a device blink β€” useful while wiring topology +
+
+ {allDevices.map((d: any) => { + const isIdentifying = identifying === d.mac; + return ( +
+ +
+
+ {d.name} + {d.role} + {d.self && this device} +
+
{d.mac}
+
+
+
{d.rssi || 'β€”'} dBm
+
+ {!d.self && ( + + )} +
+ ); + })} + {allDevices.length <= 1 && ( +
+ No peers paired yet. Click Pair new device above to start a 30-second pairing window. +
+ )} +
+
+ +
+
Sensor priority
+
+
+ {priorities.map(p => { + const active = mesh.fusion === p.id; + return ( + + ); + })} +
+
+
+ + ); +} + +/* ================================================================= */ +/* E. HARDWARE */ +/* ================================================================= */ +export function ScreenHardware({ setToast, reload }: AppState) { + const [profiles, setProfiles] = useState(null); + const [kinds, setKinds] = useState(null); + const [activeBoard, setActiveBoard] = useState(''); + const [activeRadar, setActiveRadar] = useState(''); + const [pins, setPins] = useState({}); + const [busy, setBusy] = useState(false); + const [needsReboot, setNeedsReboot] = useState(false); + + useEffect(() => { + Promise.all([getJSON('/api/board/profiles'), getJSON('/api/radar/kinds'), getJSON('/api/settings')]) + .then(([p, k, st]) => { + setProfiles(p); setKinds(k); setActiveBoard(p.active); setActiveRadar(k.active); + const prof = p.profiles.find((x: any) => x.id === p.active) || p.profiles[0]; + setPins({ + led_pin: st.led_pin ?? prof.led_pin, + radar_rx: st.radar_rx ?? prof.radar_rx, + radar_tx: st.radar_tx ?? prof.radar_tx, + button_pin: st.button_pin ?? prof.button, + status_led_pin: st.status_led_pin ?? prof.status_led, + }); + }).catch((e) => setToast(e.message || 'Load failed', 'err')); + }, []); + + if (!profiles || !kinds) return <>
Loading…
; + const profile = profiles.profiles.find((p: any) => p.id === activeBoard) || profiles.profiles[0]; + const unsafe: number[] = profile.unsafe || []; + + const onBoardChange = (id: string) => { + setActiveBoard(id); + const np = profiles.profiles.find((p: any) => p.id === id); + if (np) setPins({ led_pin: np.led_pin, radar_rx: np.radar_rx, radar_tx: np.radar_tx, button_pin: np.button, status_led_pin: np.status_led }); + setNeedsReboot(true); + }; + + const onRadarChange = (id: string) => { setActiveRadar(id); setNeedsReboot(true); }; + const onPinChange = (k: string, v: number) => { setPins({ ...pins, [k]: v }); setNeedsReboot(true); }; + + const save = async () => { + setBusy(true); + try { + await postJSON('/api/board', { id: activeBoard, radar_kind: activeRadar, ...pins }); + setToast('Saved'); + } catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + finally { setBusy(false); } + }; + + const saveReboot = async () => { + if (!confirm('Save and reboot device now? You will lose connection for ~10 seconds.')) return; + setBusy(true); + try { + await postJSON('/api/board', { id: activeBoard, radar_kind: activeRadar, ...pins }); + await postJSON('/api/reboot', {}); + setToast('Rebooting β€” refresh in 10 s'); + } catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + finally { setBusy(false); setNeedsReboot(false); } + }; + + const PINS: [string, string, string, string][] = [ + ['led_pin', 'LED data', 'led', 'led_pin'], + ['radar_rx', 'Radar RX', 'radar', 'radar_rx'], + ['radar_tx', 'Radar TX', 'radar', 'radar_tx'], + ['button_pin', 'Button', 'pin', 'button'], + ['status_led_pin', 'Status LED', 'bolt', 'status_led'], + ]; + + return ( + <> + Save & reboot : }/> + +
+
+
Board profile
+
+
+ {profiles.profiles.map((p: any) => { + const on = activeBoard === p.id; + return ( + + ); + })} +
+
+
+ +
+
Radar
+
+ {kinds.kinds.map((r: any) => { + const on = activeRadar === r.id; + return ( + + ); + })} +
+
+
+ +
+
Pin map{profile.display}
+
+
+ {PINS.map(([key, label, icon, profKey]) => { + const cur = pins[key] ?? (profile as any)[profKey]; + return ( +
+
+ + {label} +
+ +
+ ); + })} +
+
+ + Strapping pins are disabled β€” they affect boot mode and shouldn't drive an LED strip or radar UART. {unsafe.length} hidden ({unsafe.join(', ')}). +
+
+
+ + ); +} + +/* ================================================================= */ +/* F. NETWORK */ +/* ================================================================= */ +export function ScreenNetwork({ setToast }: AppState) { + const [wifi, setWifi] = useState(null); + const [scan, setScan] = useState(null); + const [pwd, setPwd] = useState(''); + const [host, setHost] = useState(''); + const [apMode, setApMode] = useState('auto'); + const [confirm, setConfirm] = useState(false); + const [scanning, setScanning] = useState(false); + const [joinSsid, setJoinSsid] = useState(null); + + const refresh = () => getJSON('/api/wifi').then(w => { setWifi(w); setApMode(w.ap_mode); setHost(w.hostname || ''); }); + const doScan = async () => { + setScanning(true); + try { const r = await getJSON('/api/wifi/scan'); setScan(r.networks); } + catch (e: any) { setToast(e.message || 'Scan failed', 'err'); } + finally { setScanning(false); } + }; + useEffect(() => { refresh(); }, []); + + const join = async () => { + if (!joinSsid) return; + try { await postJSON('/api/wifi', { ssid: joinSsid, pass: pwd, hostname: host || undefined }); setToast('Saved Β· reconnecting'); setJoinSsid(null); setPwd(''); setTimeout(refresh, 4000); } + catch (e: any) { setToast(e.message || 'Join failed', 'err'); } + }; + const saveApMode = async (m: string) => { setApMode(m); try { await postJSON('/api/wifi', { ap_mode: m }); setToast('AP mode saved'); refresh(); } catch (e: any) { setToast(e.message, 'err'); } }; + const saveHost = async () => { try { await postJSON('/api/wifi', { hostname: host }); setToast('Hostname saved'); } catch (e: any) { setToast(e.message, 'err'); } }; + const forget = async () => { + if (!confirm) { setConfirm(true); return; } + try { await postJSON('/api/wifi', { forget_sta: true }); setToast('Reset Β· device returns to AP mode'); setConfirm(false); refresh(); } + catch (e: any) { setToast(e.message, 'err'); } + }; + + if (!wifi) return <>
Loading…
; + + return ( + <> + + + {/* connected card */} +
+
+
+ +
+
+
+ {wifi.sta_connected ? wifi.ssid : 'Not connected'} + {wifi.sta_connected && connected} + {wifi.ap_active && AP up} +
+
+ {wifi.ip || 'β€”'} Β· {wifi.hostname}.local Β· {wifi.rssi} dBm +
+
+ {wifi.sta_configured && } +
+
+ +
+
Available networks + +
+
+ {scan === null &&
Click Scan to discover networks.
} + {scan?.length === 0 &&
No networks found.
} + {scan?.map(n => { + const isCurrent = wifi.sta_connected && n.ssid === wifi.ssid; + const bars = n.rssi > -60 ? 4 : n.rssi > -68 ? 3 : n.rssi > -76 ? 2 : 1; + return ( +
+
+ {[1,2,3,4].map(i =>
)} +
+
+
{n.ssid}
+
{n.rssi} dBm Β· {n.secure ? 'WPA2' : 'open'}
+
+ {isCurrent ? current : } +
+ ); + })} +
+
+ + {joinSsid && ( +
+
Join "{joinSsid}"
+ setPwd((e.target as HTMLInputElement).value)} style="margin-bottom: 8px;"/> +
+ + +
+
+ )} + +
+
+
Hostname
+
+ mDNS name +
+ setHost((e.target as HTMLInputElement).value.replace(/[^a-z0-9-]/g, ''))}/> + .local + +
+
+
+ +
+
AP behaviour
+
+ {[ + { id: 'auto', name: 'Auto', desc: 'AP off when STA connected' }, + { id: 'always', name: 'Always on', desc: 'AP up at all times β€” local fallback' }, + { id: 'sta_only', name: 'STA only', desc: 'AP off, ESP-NOW uses STA channel' }, + ].map(m => { + const on = apMode === m.id; + return ( + + ); + })} +
+
+
+ + ); +} + +/* ================================================================= */ +/* G. SYSTEM */ +/* ================================================================= */ +export function ScreenSystem({ version, setToast }: AppState) { + const [pwd, setPwd] = useState(''); + const [showPwd, setShowPwd] = useState(false); + const [authReq, setAuthReq] = useState(false); + const [otaName, setOtaName] = useState(null); + const [otaFile, setOtaFile] = useState(null); + const [otaProg, setOtaProg] = useState(-1); + const [confirmText, setConfirmText] = useState(''); + const fileRef = useRef(null); + + useEffect(() => setAuthReq(!!version.auth_enabled), [version.auth_enabled]); + + const setPassword = async () => { + if (pwd && pwd.length < 8) { setToast('Password must be β‰₯ 8 characters', 'err'); return; } + try { + const r = await postJSON('/api/auth/password', { password: pwd }); + setToast(pwd ? 'Password set Β· auth enabled' : 'Password cleared Β· auth disabled'); + setPwd(''); + setAuthReq(!!r.auth_enabled); + } catch (e: any) { setToast(e.message || 'Failed', 'err'); } + }; + + const onFile = (f: File | null | undefined) => { + if (!f) return; + if (!f.name.endsWith('.bin')) { setToast('Pick a .bin file', 'err'); return; } + setOtaFile(f); setOtaName(f.name); setOtaProg(-1); + }; + + const flash = async () => { + if (!otaFile) return; + setOtaProg(0); + try { + await postBinary('/api/ota', otaFile, p => setOtaProg(p)); + setToast('Flashed Β· device rebooting'); + setOtaProg(1); + setTimeout(() => { setOtaName(null); setOtaFile(null); setOtaProg(-1); }, 3000); + } catch (e: any) { setToast(e.message || 'OTA failed', 'err'); setOtaProg(-1); } + }; + + const reboot = async () => { + if (!confirm('Reboot device? You will lose connection for ~10 seconds.')) return; + try { await postJSON('/api/reboot', {}); setToast('Rebooting'); } + catch (e: any) { setToast(e.message, 'err'); } + }; + + const exportConfig = async () => { + try { + const r = await getJSON('/api/settings'); + const blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `${version.hostname || 'ambisense'}-config.json`; + a.click(); + URL.revokeObjectURL(url); + setToast('Exported'); + } catch (e: any) { setToast(e.message, 'err'); } + }; + + return ( + <> + + +
+
+
Firmware{version.version || 'β€”'}
+
+
+ +
+
{version.version} Β· {version.target}
+
ESP-IDF {version.idf_version} Β· built {version.build_date}
+
+ +
+ + + + {otaProg >= 0 && otaProg < 1 && ( +
+
+
Uploading {(otaProg*100).toFixed(0)}%
+
+ )} + + {otaName && otaProg < 0 && ( + + )} +
+
+ +
+
Diagnostics
+
+
+ + + + + + +
+
+
+
+ +
+
Auth + + {authReq ? 'enabled' : 'disabled'} + +
+
+ Password (β‰₯ 8 chars Β· empty disables auth) +
+ setPwd((e.target as HTMLInputElement).value)}/> + + +
+
+ Required only on this network. Local mDNS access uses cookie sessions. +
+
+
+ +
+
+
JSON config
+
+ + +
+
+ +
+
Factory reset
+
+
+ Type {version.hostname} to confirm +
+
+ setConfirmText((e.target as HTMLInputElement).value)}/> + +
+
+
+
+ + ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..64c28b2 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,311 @@ +/* AmbiSense v6 β€” design tokens (lifted from frontend/design-source/project/tokens.css) */ +:root { + --bg-0: #0B0D10; + --bg-1: #101317; + --bg-2: #15181C; + --bg-3: #1B1F24; + --bg-4: #232830; + --line: #232830; + --line-soft: #1B1F24; + --text-0: #F2F4F7; + --text-1: #C7CDD6; + --text-2: #8A929E; + --text-3: #5C636E; + --text-4: #3D434C; + + --acc-amber: #FFB54A; + --acc-orange: #FF7A3D; + --acc-pink: #FF3D82; + --acc-grad: linear-gradient(135deg, #FFB54A 0%, #FF7A3D 45%, #FF3D82 100%); + + --ok: #4ADE80; + --warn: #FFB54A; + --err: #FF5470; + --info: #5BC7FF; + + --r-xs: 6px; --r-sm: 8px; --r-md: 12px; --r-lg: 16px; --r-xl: 22px; --r-pill: 999px; + --s-1: 4px; --s-2: 8px; --s-3: 12px; --s-4: 16px; --s-5: 24px; --s-6: 32px; --s-7: 48px; + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --shadow-1: 0 1px 2px rgba(0,0,0,.35); + --shadow-2: 0 6px 24px rgba(0,0,0,.4); +} + +[data-theme="light"] { + --bg-0: #F6F5F2; --bg-1: #EDECE7; --bg-2: #FFFFFF; --bg-3: #F2F1ED; --bg-4: #E6E4DE; + --line: #DEDCD5; --line-soft: #E8E6DF; + --text-0: #15181C; --text-1: #2D333B; --text-2: #5C636E; --text-3: #8A929E; --text-4: #B5BBC4; +} + +* { box-sizing: border-box; } +html, body, #root { margin: 0; padding: 0; height: 100%; } +body { + background: var(--bg-0); color: var(--text-0); + font-family: var(--font-sans); font-size: 14px; line-height: 1.5; letter-spacing: -0.005em; + -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; + overscroll-behavior: none; +} +.mono { font-family: var(--font-mono); } +.smallcaps { text-transform: uppercase; letter-spacing: .14em; font-size: 11px; font-weight: 500; color: var(--text-2); } +button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; } +input, select, textarea { font: inherit; color: inherit; } +::selection { background: rgba(255,122,61,.35); color: var(--text-0); } + +/* Layout */ +.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100%; } +.sidebar { + background: var(--bg-1); border-right: 1px solid var(--line); + padding: 24px 14px; display: flex; flex-direction: column; gap: 4px; + position: sticky; top: 0; height: 100vh; overflow-y: auto; +} +.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 24px; } +.brand-mark { + width: 28px; height: 28px; border-radius: 8px; background: var(--acc-grad); + display: grid; place-items: center; font-weight: 700; color: #1A0F08; font-size: 13px; +} +.brand-name { font-weight: 600; letter-spacing: -0.01em; font-size: 15px; } +.brand-ver { font-size: 11px; color: var(--text-3); margin-left: 4px; } + +.navlink { + display: flex; align-items: center; gap: 10px; + padding: 9px 12px; border-radius: 8px; font-size: 13px; + color: var(--text-1); text-decoration: none; transition: background .12s, color .12s; +} +.navlink:hover { background: var(--bg-2); color: var(--text-0); } +.navlink.on { background: var(--bg-3); color: var(--text-0); box-shadow: var(--shadow-1); } +.navlink-icon { width: 16px; height: 16px; opacity: .9; flex-shrink: 0; } + +.main { padding: 28px 32px 96px; max-width: 1200px; } +.page-head { display: flex; align-items: end; justify-content: space-between; margin-bottom: 24px; gap: 16px; flex-wrap: wrap; } +.page-head h1 { font-size: 22px; font-weight: 600; margin: 0; letter-spacing: -0.02em; } +.page-head .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; } + +/* Responsive helpers used inline by the layout. Without these the desktop + * sidebar and mobile bottom-tab nav both render at once on phones, which + * is how the UI looked "scrambled" in the v6.0 report. */ +.hide-mobile { /* visible on desktop, hidden via media query below */ } +.show-mobile { display: none; } + +@media (max-width: 760px) { + .hide-mobile { display: none !important; } + .show-mobile { display: flex !important; } + .app { grid-template-columns: 1fr; } + .sidebar { + position: fixed; bottom: 0; left: 0; right: 0; top: auto; height: auto; + flex-direction: row; justify-content: space-around; padding: 8px 6px; + border-right: none; border-top: 1px solid var(--line); z-index: 10; + overflow-x: auto; + } + .brand { display: none; } + .navlink { flex-direction: column; gap: 3px; padding: 6px 8px; font-size: 10px; } + .main { padding: 14px 12px 88px; max-width: 100%; } + .page-head { gap: 8px; margin-bottom: 16px; } + .page-head h1 { font-size: 19px; } + .app-header { padding: 10px 12px; } + /* Cards lose their inner indent on phones β€” saves ~14 px per card * 4 cards. */ + .card-body { padding: 12px 12px 14px; } + .card-head { padding: 12px 12px 0; } + /* Tame inline grids that hardcode min 280 px columns β€” they'd overflow + * on a 360 px viewport once card padding is subtracted. */ + .card .card-body > div[style*="grid-template-columns: repeat(auto-fit"] { + grid-template-columns: 1fr !important; + } + /* LED screen's primary 2-col grid (Mode list | Color/Layout column) β€” + * stacks vertically on phones. */ + .led-grid { grid-template-columns: 1fr !important; gap: 12px !important; } + /* Top-level grid wrappers used by Motion advanced + Hardware pin maps. */ + div[style*="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))"], + div[style*="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr))"], + div[style*="grid-template-columns: repeat(auto-fit, minmax(170px, 1fr))"] { + grid-template-columns: 1fr 1fr !important; + } + div[style*="grid-template-columns: repeat(auto-fit, minmax(155px, 1fr))"] { + grid-template-columns: 1fr 1fr !important; + } +} +@media (max-width: 480px) { + /* Single column on the smallest phones. */ + div[style*="grid-template-columns: repeat(auto-fit"] { grid-template-columns: 1fr !important; } +} + +/* Atoms */ +.card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow-1); } +.card-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 0; } +.card-body { padding: 14px 18px 18px; } +.card-title { font-size: 13px; color: var(--text-2); text-transform: uppercase; letter-spacing: .14em; font-weight: 500; margin: 0; } +.divider { height: 1px; background: var(--line); } + +.dot { width: 8px; height: 8px; border-radius: 999px; display: inline-block; } +.dot-ok { background: var(--ok); box-shadow: 0 0 0 3px rgba(74,222,128,.15); } +.dot-warn { background: var(--warn); box-shadow: 0 0 0 3px rgba(255,181,74,.18); } +.dot-err { background: var(--err); box-shadow: 0 0 0 3px rgba(255,84,112,.18); } +.dot-off { background: var(--text-4); } + +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 9px 14px; border-radius: 8px; + background: var(--bg-3); border: 1px solid var(--line); + color: var(--text-0); font-weight: 500; font-size: 13px; + transition: background .12s, border-color .12s, transform .08s; + white-space: nowrap; +} +.btn:hover { background: var(--bg-4); border-color: var(--text-4); } +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn-primary { background: var(--acc-grad); border: 0; color: #1A0F08; font-weight: 600; } +.btn-primary:hover { filter: brightness(1.06); } +.btn-ghost { background: transparent; border: 1px solid transparent; } +.btn-ghost:hover { background: var(--bg-3); border-color: var(--line); } +.btn-danger { background: rgba(255,84,112,.08); border: 1px solid rgba(255,84,112,.35); color: var(--err); } +.btn-danger:hover { background: rgba(255,84,112,.14); border-color: rgba(255,84,112,.55); } +.btn-sm { padding: 6px 10px; font-size: 12px; } + +.field-label { font-size: 12px; color: var(--text-2); margin-bottom: 6px; display: block; } +.input, .select { + width: 100%; padding: 9px 12px; + background: var(--bg-1); border: 1px solid var(--line); border-radius: 8px; + color: var(--text-0); font-size: 13px; outline: none; + transition: border-color .12s, background .12s; +} +.input:focus, .select:focus { border-color: var(--acc-orange); background: var(--bg-0); } +.input.mono { font-family: var(--font-mono); } +.select { appearance: none; padding-right: 32px; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; background-position: right 10px center; } + +.range { -webkit-appearance: none; width: 100%; height: 4px; background: var(--bg-3); border-radius: 999px; outline: none; } +.range::-webkit-slider-thumb { + -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; + background: var(--text-0); border: 3px solid var(--acc-orange); cursor: pointer; +} +.range::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; + background: var(--text-0); border: 3px solid var(--acc-orange); cursor: pointer; } + +.toggle { position: relative; width: 36px; height: 20px; background: var(--bg-3); + border-radius: 999px; border: 1px solid var(--line); transition: background .15s; flex-shrink: 0; } +.toggle::after { content: ""; position: absolute; top: 2px; left: 2px; + width: 14px; height: 14px; background: var(--text-1); border-radius: 50%; transition: left .15s, background .15s; } +.toggle.on { background: var(--acc-grad); border-color: transparent; } +.toggle.on::after { left: 18px; background: white; } + +.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px; + border-radius: 999px; background: var(--bg-3); border: 1px solid var(--line); + font-size: 11px; color: var(--text-1); font-family: var(--font-mono); } + +.row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 13px; } +.row .lbl { color: var(--text-2); font-size: 12px; } +.row .val { font-family: var(--font-mono); color: var(--text-0); } + +.grid-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } + +.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; font-size: 13px; } +.kv .k { color: var(--text-2); font-size: 12px; } +.kv .v { font-family: var(--font-mono); color: var(--text-0); } + +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + background: var(--bg-3); border: 1px solid var(--line); border-radius: 10px; + padding: 10px 16px; font-size: 13px; box-shadow: var(--shadow-2); + z-index: 100; animation: fade-up .25s ease-out; +} +.toast.ok { border-color: rgba(74,222,128,.4); color: var(--ok); } +.toast.err { border-color: rgba(255,84,112,.4); color: var(--err); } + +@keyframes fade-up { from { opacity: 0; transform: translate(-50%, 6px); } to { opacity: 1; transform: translate(-50%, 0); } } +@keyframes pulse-acc { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } } +@keyframes logo-pulse { + 0% { r: 4; opacity: 0.9; stroke-width: 1.4; } + 70% { r: 18; opacity: 0; stroke-width: 0.4; } + 100% { r: 18; opacity: 0; } +} +@keyframes logo-breath { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.08); } +} + +/* App shell β€” sticky header bar + sidebar with logo + bottom IP chip */ +.app-header { + position: sticky; top: 0; z-index: 50; + display: flex; align-items: center; gap: 14px; + padding: 12px 24px; + background: color-mix(in srgb, var(--bg-0) 88%, transparent); + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--line); +} +.brand-block { + display: flex; align-items: center; gap: 12px; + padding: 4px 6px 16px; +} +.sidebar-foot { + padding: 10px 12px; font-size: 10px; color: var(--text-3); line-height: 1.5; + border-top: 1px solid var(--line-soft); margin-top: 6px; +} +.btn-icon { padding: 8px; } +@media (max-width: 760px) { + .app-header { padding: 10px 14px; } +} +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } } + +.led-canvas { + width: 100%; height: 80px; border-radius: 12px; + background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 100%); + border: 1px solid var(--line); display: block; +} +.distance-meter { + background: var(--bg-1); border-radius: 12px; padding: 18px; + border: 1px solid var(--line); +} +.distance-num { font-size: 44px; font-weight: 600; font-family: var(--font-mono); letter-spacing: -0.02em; } +.distance-unit { font-size: 14px; color: var(--text-2); margin-left: 4px; font-family: var(--font-sans); } +.bar { height: 6px; background: var(--bg-3); border-radius: 3px; overflow: hidden; margin-top: 10px; } +.bar-fill { height: 100%; background: var(--acc-grad); transition: width .18s ease; } + +/* Live dashboard β€” design-faithful */ +.dist-big { + font-size: 64px; font-weight: 500; letter-spacing: -0.04em; + background: var(--acc-grad); -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; line-height: 1; +} +.distance-row { + display: flex; justify-content: space-between; align-items: start; gap: 24px; +} +.dist-spark { flex: 1; align-self: end; max-width: 420px; } +@media (max-width: 900px) { + .dash-grid { grid-template-columns: 1fr !important; } + .stat-row { grid-template-columns: repeat(2, 1fr) !important; } + .distance-row { flex-direction: column; align-items: stretch; } + .dist-spark { max-width: 100%; } + .dist-big { font-size: 48px; } +} + +.tab-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; } +.mode-card { + border: 1px solid var(--line); border-radius: 10px; padding: 10px; + background: var(--bg-1); cursor: pointer; transition: border-color .12s, background .12s, transform .08s; + display: flex; flex-direction: column; gap: 6px; +} +.mode-card:hover { background: var(--bg-3); border-color: var(--text-4); } +.mode-card:active { transform: translateY(1px); } +.mode-card.on { border-color: var(--acc-orange); background: rgba(255,122,61,.08); } +.mode-card .preview { height: 32px; border-radius: 6px; } +.mode-card .name { font-size: 12px; font-weight: 500; } + +/* Static mode-card thumbnails β€” one CSS gradient per mode. Cheap, beautiful, + * no canvas paint cost. Only the active mode gets the live canvas preview + * in the hero card above. */ +.thumb-standard { background: linear-gradient(90deg, #15181C 0%, #15181C 30%, var(--text-0) 32%, var(--text-0) 38%, #15181C 40%, #15181C 100%); } +.thumb-rainbow { background: linear-gradient(90deg, #FF3D82, #FFB54A, #4ADE80, #5BC7FF, #B57BFF, #FF3D82); } +.thumb-color-wave { background: linear-gradient(90deg, #FF7A3D 0%, #FFB54A 50%, #FF3D82 100%); position: relative; } +.thumb-color-wave::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(0,0,0,.4), transparent); } +.thumb-breathing { background: var(--acc-grad); position: relative; opacity: .85; } +.thumb-solid { background: var(--acc-grad); } +.thumb-comet { background: linear-gradient(90deg, #15181C 0%, #15181C 60%, rgba(255,122,61,.4) 75%, var(--text-0) 90%, var(--text-0) 100%); } +.thumb-pulse { background: radial-gradient(circle at 50% 50%, var(--acc-orange) 0%, var(--acc-pink) 30%, transparent 70%), var(--bg-3); } +.thumb-fire { background: linear-gradient(0deg, #FF3D82 0%, #FF7A3D 30%, #FFB54A 65%, #FFF7E0 95%); } +.thumb-theater-chase { background: repeating-linear-gradient(90deg, var(--acc-orange) 0 10%, transparent 10% 20%, var(--acc-orange) 20% 30%, transparent 30% 40%, var(--acc-orange) 40% 50%, transparent 50% 60%, var(--acc-orange) 60% 70%, transparent 70% 80%, var(--acc-orange) 80% 90%, transparent 90% 100%); } +.thumb-dual-scan { background: linear-gradient(90deg, var(--acc-orange) 0 10%, transparent 15% 85%, #5BC7FF 90% 100%); } +.thumb-particles { background: radial-gradient(circle at 30% 50%, var(--acc-pink) 0%, transparent 8%), radial-gradient(circle at 60% 50%, var(--acc-orange) 0%, transparent 8%), radial-gradient(circle at 78% 50%, var(--acc-amber) 0%, transparent 8%), var(--bg-3); } + +.color-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.swatch { width: 28px; height: 28px; border-radius: 8px; cursor: pointer; border: 2px solid transparent; } +.swatch.on { border-color: var(--text-0); box-shadow: 0 0 0 2px var(--bg-0), 0 0 0 4px var(--acc-orange); } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8923fe2 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5fd7018 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import preact from '@preact/preset-vite'; +import { viteSingleFile } from 'vite-plugin-singlefile'; + +export default defineConfig({ + plugins: [preact(), viteSingleFile()], + build: { + target: 'es2020', + minify: 'terser', + cssCodeSplit: false, + assetsInlineLimit: 100000000, + chunkSizeWarningLimit: 100000000, + outDir: 'dist', + rollupOptions: { + output: { inlineDynamicImports: true }, + }, + }, + server: { + port: 5173, + proxy: { + '/api': 'http://192.168.4.1', + }, + }, +}); diff --git a/AmbiSense/AmbiSense.ino b/legacy/AmbiSense/AmbiSense.ino similarity index 100% rename from AmbiSense/AmbiSense.ino rename to legacy/AmbiSense/AmbiSense.ino diff --git a/AmbiSense/compressed_html_full.h b/legacy/AmbiSense/compressed_html_full.h similarity index 100% rename from AmbiSense/compressed_html_full.h rename to legacy/AmbiSense/compressed_html_full.h diff --git a/AmbiSense/config.h b/legacy/AmbiSense/config.h similarity index 100% rename from AmbiSense/config.h rename to legacy/AmbiSense/config.h diff --git a/AmbiSense/eeprom_manager.cpp b/legacy/AmbiSense/eeprom_manager.cpp similarity index 100% rename from AmbiSense/eeprom_manager.cpp rename to legacy/AmbiSense/eeprom_manager.cpp diff --git a/AmbiSense/eeprom_manager.h b/legacy/AmbiSense/eeprom_manager.h similarity index 100% rename from AmbiSense/eeprom_manager.h rename to legacy/AmbiSense/eeprom_manager.h diff --git a/AmbiSense/espnow_manager.cpp b/legacy/AmbiSense/espnow_manager.cpp similarity index 100% rename from AmbiSense/espnow_manager.cpp rename to legacy/AmbiSense/espnow_manager.cpp diff --git a/AmbiSense/espnow_manager.h b/legacy/AmbiSense/espnow_manager.h similarity index 100% rename from AmbiSense/espnow_manager.h rename to legacy/AmbiSense/espnow_manager.h diff --git a/AmbiSense/led_controller.cpp b/legacy/AmbiSense/led_controller.cpp similarity index 100% rename from AmbiSense/led_controller.cpp rename to legacy/AmbiSense/led_controller.cpp diff --git a/AmbiSense/led_controller.h b/legacy/AmbiSense/led_controller.h similarity index 100% rename from AmbiSense/led_controller.h rename to legacy/AmbiSense/led_controller.h diff --git a/AmbiSense/memory_analysis.h b/legacy/AmbiSense/memory_analysis.h similarity index 100% rename from AmbiSense/memory_analysis.h rename to legacy/AmbiSense/memory_analysis.h diff --git a/AmbiSense/radar_manager.cpp b/legacy/AmbiSense/radar_manager.cpp similarity index 100% rename from AmbiSense/radar_manager.cpp rename to legacy/AmbiSense/radar_manager.cpp diff --git a/AmbiSense/radar_manager.h b/legacy/AmbiSense/radar_manager.h similarity index 100% rename from AmbiSense/radar_manager.h rename to legacy/AmbiSense/radar_manager.h diff --git a/AmbiSense/resources.h b/legacy/AmbiSense/resources.h similarity index 100% rename from AmbiSense/resources.h rename to legacy/AmbiSense/resources.h diff --git a/AmbiSense/stair_wizard_fixed.cpp b/legacy/AmbiSense/stair_wizard_fixed.cpp similarity index 100% rename from AmbiSense/stair_wizard_fixed.cpp rename to legacy/AmbiSense/stair_wizard_fixed.cpp diff --git a/AmbiSense/stair_wizard_fixed.h b/legacy/AmbiSense/stair_wizard_fixed.h similarity index 100% rename from AmbiSense/stair_wizard_fixed.h rename to legacy/AmbiSense/stair_wizard_fixed.h diff --git a/AmbiSense/web_interface.cpp b/legacy/AmbiSense/web_interface.cpp similarity index 100% rename from AmbiSense/web_interface.cpp rename to legacy/AmbiSense/web_interface.cpp diff --git a/AmbiSense/web_interface.h b/legacy/AmbiSense/web_interface.h similarity index 100% rename from AmbiSense/web_interface.h rename to legacy/AmbiSense/web_interface.h diff --git a/AmbiSense/wifi_manager.cpp b/legacy/AmbiSense/wifi_manager.cpp similarity index 100% rename from AmbiSense/wifi_manager.cpp rename to legacy/AmbiSense/wifi_manager.cpp diff --git a/AmbiSense/wifi_manager.h b/legacy/AmbiSense/wifi_manager.h similarity index 100% rename from AmbiSense/wifi_manager.h rename to legacy/AmbiSense/wifi_manager.h