From e3b5eefffec26f0241d6efbd863703e54d254ed7 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 18:20:49 +0530 Subject: [PATCH 01/17] =?UTF-8?q?feat(v6):=20PR=20#1=20=E2=80=94=20ESP-IDF?= =?UTF-8?q?=20skeleton,=20board=20abstraction,=20status=20LED,=20design=20?= =?UTF-8?q?handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begins the v6 rewrite. Moves the v5.1.1 Arduino source under legacy/ and stands up an ESP-IDF v5.3-compatible project under firmware/. Highlights: - Custom partition table: 16K NVS + 8K otadata + 1408K x 2 OTA app slots + 960K LittleFS + 64K coredump (4MB layout, dual-bank safe rollback). - sdkconfig.defaults: task watchdog, brownout detector, coredump-to-flash, bootloader rollback, esp_http_server with WS support β€” applied across every target. esp32c3-specific overrides in sdkconfig.defaults.esp32c3. - components/board: profile struct + four ship-ready profiles (esp32c3-supermini validated, esp32-devkit / esp32s3-zero / esp32c6-devkit build-clean but untested). unsafe_pin_mask encodes strapping/USB-JTAG/ flash pins so the upcoming web UI pin-remap can refuse foot-guns. - components/settings: NVS facade (replaces v5 320-byte EEPROM map); exposes board namespace accessors used by main. - components/status_led: dedicated FreeRTOS task drives the onboard LED; pattern API (BOOT, AP_MODE, STA_MODE, OTA, ERROR, PANIC) replaces the inline blink-in-loop pattern from the Arduino code. - main/main.c: resolves board profile (NVS override > compile default), applies pin overrides while rejecting unsafe ones, brings up status LED. - frontend/design-source/: full Claude-Design handoff bundle (tokens.css, 7 screen JSXs, README, chat). Reference for PR #5 UI build. - .github/workflows/firmware.yml: IDF v5.3 build matrix across all four targets on push/PR; uploads tagged firmware artifacts. - README banner explaining v5 -> v6 transition, branches, and quickstart. Verified: idf.py build succeeds for esp32c3. Output ambisense.bin is ~195 KB (86%% free in the 1.4 MB app slot). Refs #v6 epic. Next: PR #2 β€” Wi-Fi STA/AP fallback, captive portal, esp_http_server, OTA, auth scaffold. --- .github/workflows/firmware.yml | 45 ++ .gitignore | 12 + README.md | 63 +++ firmware/.gitignore | 12 + firmware/CMakeLists.txt | 13 + firmware/components/board/CMakeLists.txt | 5 + firmware/components/board/board.c | 177 +++++++ firmware/components/board/include/board.h | 68 +++ firmware/components/settings/CMakeLists.txt | 5 + .../components/settings/include/settings.h | 59 +++ firmware/components/settings/settings.c | 101 ++++ firmware/components/status_led/CMakeLists.txt | 5 + .../status_led/include/status_led.h | 47 ++ firmware/components/status_led/status_led.c | 132 ++++++ firmware/main/CMakeLists.txt | 5 + firmware/main/main.c | 115 +++++ firmware/partitions.csv | 9 + firmware/sdkconfig.defaults | 58 +++ firmware/sdkconfig.defaults.esp32c3 | 16 + frontend/design-source/README.md | 25 + frontend/design-source/chats/chat1.md | 117 +++++ .../design-source/project/AmbiSense v6.html | 26 ++ frontend/design-source/project/app.jsx | 376 +++++++++++++++ frontend/design-source/project/core.jsx | 330 +++++++++++++ frontend/design-source/project/led.jsx | 221 +++++++++ .../project/screen-hw-net-sys.jsx | 435 ++++++++++++++++++ .../design-source/project/screen-leds.jsx | 329 +++++++++++++ .../design-source/project/screen-live.jsx | 231 ++++++++++ .../project/screen-motion-mesh.jsx | 311 +++++++++++++ .../project/screenshots/logo.png | Bin 0 -> 23042 bytes frontend/design-source/project/tokens.css | 317 +++++++++++++ .../design-source/project/tweaks-panel.jsx | 425 +++++++++++++++++ {AmbiSense => legacy/AmbiSense}/AmbiSense.ino | 0 .../AmbiSense}/compressed_html_full.h | 0 {AmbiSense => legacy/AmbiSense}/config.h | 0 .../AmbiSense}/eeprom_manager.cpp | 0 .../AmbiSense}/eeprom_manager.h | 0 .../AmbiSense}/espnow_manager.cpp | 0 .../AmbiSense}/espnow_manager.h | 0 .../AmbiSense}/led_controller.cpp | 0 .../AmbiSense}/led_controller.h | 0 .../AmbiSense}/memory_analysis.h | 0 .../AmbiSense}/radar_manager.cpp | 0 .../AmbiSense}/radar_manager.h | 0 {AmbiSense => legacy/AmbiSense}/resources.h | 0 .../AmbiSense}/stair_wizard_fixed.cpp | 0 .../AmbiSense}/stair_wizard_fixed.h | 0 .../AmbiSense}/web_interface.cpp | 0 .../AmbiSense}/web_interface.h | 0 .../AmbiSense}/wifi_manager.cpp | 0 .../AmbiSense}/wifi_manager.h | 0 51 files changed, 4090 insertions(+) create mode 100644 .github/workflows/firmware.yml create mode 100644 firmware/.gitignore create mode 100644 firmware/CMakeLists.txt create mode 100644 firmware/components/board/CMakeLists.txt create mode 100644 firmware/components/board/board.c create mode 100644 firmware/components/board/include/board.h create mode 100644 firmware/components/settings/CMakeLists.txt create mode 100644 firmware/components/settings/include/settings.h create mode 100644 firmware/components/settings/settings.c create mode 100644 firmware/components/status_led/CMakeLists.txt create mode 100644 firmware/components/status_led/include/status_led.h create mode 100644 firmware/components/status_led/status_led.c create mode 100644 firmware/main/CMakeLists.txt create mode 100644 firmware/main/main.c create mode 100644 firmware/partitions.csv create mode 100644 firmware/sdkconfig.defaults create mode 100644 firmware/sdkconfig.defaults.esp32c3 create mode 100644 frontend/design-source/README.md create mode 100644 frontend/design-source/chats/chat1.md create mode 100644 frontend/design-source/project/AmbiSense v6.html create mode 100644 frontend/design-source/project/app.jsx create mode 100644 frontend/design-source/project/core.jsx create mode 100644 frontend/design-source/project/led.jsx create mode 100644 frontend/design-source/project/screen-hw-net-sys.jsx create mode 100644 frontend/design-source/project/screen-leds.jsx create mode 100644 frontend/design-source/project/screen-live.jsx create mode 100644 frontend/design-source/project/screen-motion-mesh.jsx create mode 100644 frontend/design-source/project/screenshots/logo.png create mode 100644 frontend/design-source/project/tokens.css create mode 100644 frontend/design-source/project/tweaks-panel.jsx rename {AmbiSense => legacy/AmbiSense}/AmbiSense.ino (100%) rename {AmbiSense => legacy/AmbiSense}/compressed_html_full.h (100%) rename {AmbiSense => legacy/AmbiSense}/config.h (100%) rename {AmbiSense => legacy/AmbiSense}/eeprom_manager.cpp (100%) rename {AmbiSense => legacy/AmbiSense}/eeprom_manager.h (100%) rename {AmbiSense => legacy/AmbiSense}/espnow_manager.cpp (100%) rename {AmbiSense => legacy/AmbiSense}/espnow_manager.h (100%) rename {AmbiSense => legacy/AmbiSense}/led_controller.cpp (100%) rename {AmbiSense => legacy/AmbiSense}/led_controller.h (100%) rename {AmbiSense => legacy/AmbiSense}/memory_analysis.h (100%) rename {AmbiSense => legacy/AmbiSense}/radar_manager.cpp (100%) rename {AmbiSense => legacy/AmbiSense}/radar_manager.h (100%) rename {AmbiSense => legacy/AmbiSense}/resources.h (100%) rename {AmbiSense => legacy/AmbiSense}/stair_wizard_fixed.cpp (100%) rename {AmbiSense => legacy/AmbiSense}/stair_wizard_fixed.h (100%) rename {AmbiSense => legacy/AmbiSense}/web_interface.cpp (100%) rename {AmbiSense => legacy/AmbiSense}/web_interface.h (100%) rename {AmbiSense => legacy/AmbiSense}/wifi_manager.cpp (100%) rename {AmbiSense => legacy/AmbiSense}/wifi_manager.h (100%) 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..4947093 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,69 @@ AmbiSense Logo

+> ### πŸ”§ v6 is a ground-up rewrite β€” in progress +> +> We're moving AmbiSense off Arduino onto **ESP-IDF + FreeRTOS** so the firmware +> can drive radar reads, LED rendering, web serving, and ESP-NOW peer-mesh as +> independent tasks instead of one cooperative `loop()`. v6 also adds +> **LD2450 multi-target tracking**, a **modular radar driver layer** (LD2410 / +> LD2412 / LD2420 / LD2450 / sim β€” switch via web UI without reflashing), a +> **board picker with editable pin map** for ESP32-C3 / ESP32 / S3 / C6, a +> **peer mesh** for U/L/asymmetric stairs (no more master/slave), **OTA +> updates**, **captive portal**, and a **brand-new web UI** designed in +> Claude Design. +> +> | Branch | Purpose | +> |---|---| +> | `main` | v5.1.1 Arduino release β€” stable, do not break | +> | `v6-idf-rewrite` | active rewrite (this PR series) | +> | `legacy/v5-arduino` | frozen archive of the v5.x line | +> | tag `v5.1.1` | last Arduino-era release | +> +> Current status: **PR #1 of 5 β€” IDF skeleton merged.** Wi-Fi + web (#2), +> radar + LED engine (#3), peer mesh + topology (#4), and the new UI (#5) +> follow. Each PR is independently flashable; releases will be tagged +> `v6.0.0-alpha.N` until P5 ships. + +## v6 quickstart (ESP-IDF, requires `v5.3` LTS) + +```sh +# One-time +git clone https://github.com/Techposts/AmbiSense.git +cd AmbiSense/firmware +. $IDF_PATH/export.sh + +# Build & flash for your board +idf.py set-target esp32c3 # or: esp32, esp32s3, esp32c6 +idf.py build flash monitor +``` + +The skeleton boots, initialises NVS, resolves the board profile, and drives +the onboard status LED into AP-mode blink (waiting-for-Wi-Fi). Wi-Fi setup +lands in PR #2. + +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`. + +## 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 + +The legacy v5 documentation below applies to `main` until v6 takes over. +For new development, target the `v6-idf-rewrite` branch. + +--- + 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/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/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/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..3cb1481 --- /dev/null +++ b/firmware/components/settings/include/settings.h @@ -0,0 +1,59 @@ +#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); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/settings/settings.c b/firmware/components/settings/settings.c new file mode 100644 index 0000000..ce65385 --- /dev/null +++ b/firmware/components/settings/settings.c @@ -0,0 +1,101 @@ +#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; +} 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..ca1c443 --- /dev/null +++ b/firmware/components/status_led/include/status_led.h @@ -0,0 +1,47 @@ +#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_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); + +#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..f7931ac --- /dev/null +++ b/firmware/components/status_led/status_led.c @@ -0,0 +1,132 @@ +#include "status_led.h" + +#include + +#include "driver/gpio.h" +#include "esp_log.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; + _Atomic uint32_t pattern; /* status_led_pattern_t cast to uint32 for atomic ops */ +} 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_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) { + status_led_pattern_t 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); + + 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); +} diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt new file mode 100644 index 0000000..47f3e1f --- /dev/null +++ b/firmware/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "main.c" + INCLUDE_DIRS "." + REQUIRES board settings status_led app_update esp_system log +) diff --git a/firmware/main/main.c b/firmware/main/main.c new file mode 100644 index 0000000..d219305 --- /dev/null +++ b/firmware/main/main.c @@ -0,0 +1,115 @@ +/* + * 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" + +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) { + ESP_LOGI(TAG, "Board profile from NVS: %s (%s)", p->id, p->display); + return p; + } + 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); + } +} + +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); + + ESP_LOGI(TAG, "Skeleton boot complete. Subsystems (Wi-Fi, web, radar, LEDs, mesh) " + "land in subsequent PRs."); + + /* Flip to AP_MODE pattern as a placeholder until PR #2 wires Wi-Fi. */ + status_led_set_pattern(STATUS_LED_AP_MODE); + + /* app_main returns; FreeRTOS keeps status_led_task and the IDF event + * loop running. No busy loop needed here. */ +} 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/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..f1012c3 --- /dev/null +++ b/frontend/design-source/chats/chat1.md @@ -0,0 +1,117 @@ +# 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... + 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 0000000000000000000000000000000000000000..195b8b2cc437a3ddadcf56200f2014c47b1cd506 GIT binary patch literal 23042 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 XTe<1AmbUfR&I1odM(x+s|Gx"); + 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/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 From e3ea8f9ffec95ead49e3bd66a5b0c8b7956e1329 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 18:43:18 +0530 Subject: [PATCH 02/17] docs(v6): architecture, roadmap, and hardware troubleshooting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the locked architecture decisions, the 5-PR plan with current status, and the hardware reference + flash troubleshooting ladder so future sessions/contributors can pick up the v6 rewrite without reconstructing context. - docs/V6-ARCHITECTURE.md β€” locked decisions (peer mesh, modular radar drivers, NVS schema, board profiles, FreeRTOS task model, HTTP API surface). Single source of truth for "why is it built this way". - docs/V6-ROADMAP.md β€” PR-by-PR plan (PR #1 done; PR #2-5 scoped), done criteria, tag/release cadence (v6.0.0-alpha.N β†’ v6.0.0). - docs/HARDWARE.md β€” C3 SuperMini reference wiring, sensor pinouts for LD2410/LD2412/LD2420/LD2450, and a tested flash-fails-to- connect troubleshooting ladder including the macOS USB-CDC stuck-state recovery (Mac restart). - README pointers to the three new docs. --- README.md | 8 ++ docs/HARDWARE.md | 159 ++++++++++++++++++++++++++ docs/V6-ARCHITECTURE.md | 246 ++++++++++++++++++++++++++++++++++++++++ docs/V6-ROADMAP.md | 187 ++++++++++++++++++++++++++++++ 4 files changed, 600 insertions(+) create mode 100644 docs/HARDWARE.md create mode 100644 docs/V6-ARCHITECTURE.md create mode 100644 docs/V6-ROADMAP.md diff --git a/README.md b/README.md index 4947093..f28c9bc 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ 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 ``` diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md new file mode 100644 index 0000000..9f9425f --- /dev/null +++ b/docs/HARDWARE.md @@ -0,0 +1,159 @@ +# 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. + +## 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 | +| --------------------- | -------- | ------- | -------- | -------- | ------ | ------------- | +| `esp32c3-supermini` | βœ… valid | 10 | 20 | 21 | 4 | 8 (active-low)| +| `esp32-devkit` | builds | 5 | 16 | 17 | 4 | 2 | +| `esp32s3-zero` | builds | 21 | 4 | 5 | 9 | 21 | +| `esp32c6-devkit` | builds | 8 | 4 | 5 | 9 | 15 | + +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. + +### 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..1e8fd2b --- /dev/null +++ b/docs/V6-ROADMAP.md @@ -0,0 +1,187 @@ +# AmbiSense v6 β€” PR-by-PR roadmap + +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. From 096d40521aaa65d18b6a5882b77ac9728f577152 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 19:07:28 +0530 Subject: [PATCH 03/17] =?UTF-8?q?feat(v6):=20PR=20#2=20=E2=80=94=20always-?= =?UTF-8?q?on=20AP,=20STA,=20captive=20portal,=20web=20UI=20shell,=20auth,?= =?UTF-8?q?=20OTA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full networking + web layer. AP and STA both run simultaneously so the device works with OR without an external router β€” many installs have no Wi-Fi at all and the AP becomes the only access point. - components/netmgr β€” WIFI_MODE_APSTA always on. AP "AmbiSense-XXXX" up for the lifetime of the device on a configurable channel (default 6), open or WPA2 (NVS-configurable). STA additive: if creds saved, joins user's router; on failure, AP stays available so the user can fix creds via the captive portal. Captive-portal DNS responder steers every query at the device IP so iOS/Android/Win11 auto-pop setup. mDNS publishes .local + _ambisense._tcp service. - components/auth β€” PBKDF2-SHA256 (250k rounds) password hash, 32-byte random session tokens, 8-slot in-RAM session store, 24h TTL. Off by default; banner in placeholder UI nudges user to set a password. - components/ota β€” esp_https_ota wrapper streaming an octet-stream upload into the inactive OTA partition, validates, marks for boot, reboots in 1s. Bootloader rollback armed via sdkconfig β€” failed boot reverts automatically. ota_mark_valid() called early in main to defuse rollback once the running image proves it boots clean. - components/webui β€” esp_http_server with full route surface: GET / placeholder HTML (PR #5 replaces) GET /generate_204, /hotspot-detect.html, /connecttest.txt, ... captive portal redirects (302 β†’ /) GET /api/version firmware/idf/build/uptime/heap/ip/etc. GET /api/wifi/scan list nearby APs POST /api/wifi set creds, deferred reconnect POST /api/auth/login | logout | password GET /api/board/profiles 4 board profiles + unsafe pin masks POST /api/board save board id + per-pin overrides GET /api/radar/kinds 5 radar drivers + active selection GET /api/settings flat read of every NVS namespace POST /api/settings partial update (any subset) GET /api/distance placeholder (PR #3 wires real value) POST /api/ota octet-stream firmware upload WS /api/live 5 Hz JSON: distance/rssi/heap/peers - components/settings extended: typed get/set for u32/i32/u8/blob/str across any namespace + wifi/sys/auth shortcuts. - main.c brings them all up in order: nvs β†’ board β†’ status_led β†’ auth β†’ netmgr β†’ webui β†’ ota_mark_valid. Built clean; binary 950 KB (33%% free in 1.4 MB app slot). Flashed to both C3s. AP visible from phone; captive portal expected to pop. Refs v6 epic. Next: PR #3 β€” radar driver registry + LED engine. --- firmware/components/auth/CMakeLists.txt | 5 + firmware/components/auth/auth.c | 210 +++++ firmware/components/auth/include/auth.h | 50 ++ firmware/components/netmgr/CMakeLists.txt | 5 + firmware/components/netmgr/idf_component.yml | 2 + firmware/components/netmgr/include/netmgr.h | 62 ++ firmware/components/netmgr/netmgr.c | 367 +++++++++ firmware/components/ota/CMakeLists.txt | 5 + firmware/components/ota/include/ota.h | 44 + firmware/components/ota/ota.c | 99 +++ .../components/settings/include/settings.h | 39 + firmware/components/settings/settings.c | 155 ++++ firmware/components/webui/CMakeLists.txt | 7 + firmware/components/webui/include/webui.h | 43 + firmware/components/webui/webui.c | 767 ++++++++++++++++++ firmware/main/CMakeLists.txt | 3 +- firmware/main/main.c | 23 +- frontend/design-source/chats/chat1.md | 41 + 18 files changed, 1920 insertions(+), 7 deletions(-) create mode 100644 firmware/components/auth/CMakeLists.txt create mode 100644 firmware/components/auth/auth.c create mode 100644 firmware/components/auth/include/auth.h create mode 100644 firmware/components/netmgr/CMakeLists.txt create mode 100644 firmware/components/netmgr/idf_component.yml create mode 100644 firmware/components/netmgr/include/netmgr.h create mode 100644 firmware/components/netmgr/netmgr.c create mode 100644 firmware/components/ota/CMakeLists.txt create mode 100644 firmware/components/ota/include/ota.h create mode 100644 firmware/components/ota/ota.c create mode 100644 firmware/components/webui/CMakeLists.txt create mode 100644 firmware/components/webui/include/webui.h create mode 100644 firmware/components/webui/webui.c 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/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..492f2ea --- /dev/null +++ b/firmware/components/netmgr/include/netmgr.h @@ -0,0 +1,62 @@ +#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); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/netmgr/netmgr.c b/firmware/components/netmgr/netmgr.c new file mode 100644 index 0000000..5b210d4 --- /dev/null +++ b/firmware/components/netmgr/netmgr.c @@ -0,0 +1,367 @@ +#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; + TaskHandle_t dns_task; +} s_net; + +#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 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) { + 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; falling back to AP", STA_RETRY_MAX); + xEventGroupSetBits(s_net.evt, EVT_FAIL); + } + } 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; + xEventGroupSetBits(s_net.evt, EVT_GOT_IP); + } 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)); + + /* AP+STA always-on. AP serves the web UI for direct connect (no router + * needed), and ESP-NOW peers find each other on the AP channel. STA is + * additive: if creds are saved, we also join the user's router. */ + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA)); + configure_ap(); + + char ssid[33] = {0}, pass[65] = {0}; + settings_get_wifi_ssid(ssid, sizeof(ssid)); + settings_get_wifi_pass(pass, sizeof(pass)); + bool have_sta_creds = ssid[0] != 0; + if (have_sta_creds) { + 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()); + ESP_LOGI(TAG, "Wi-Fi up: AP%s%s", have_sta_creds ? "+STA (joining " : " only", have_sta_creds ? ssid : ""); + if (have_sta_creds) ESP_LOGI(TAG, "STA: trying %s", ssid); + + /* Captive DNS responder runs on AP interface β€” always up so phones + * connecting to AmbiSense-XXXX get auto-popped to the setup page, + * regardless of STA state. */ + start_captive_dns(); + bring_up_mdns(); + + /* Wait briefly for STA to either connect or fail; either way, the AP + * remains available so this never blocks the device from being usable. */ + if (have_sta_creds) { + 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); + else notify_state(NETMGR_STATE_AP_FALLBACK); + } + + s_net.inited = true; + 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/settings/include/settings.h b/firmware/components/settings/include/settings.h index 3cb1481..72be273 100644 --- a/firmware/components/settings/include/settings.h +++ b/firmware/components/settings/include/settings.h @@ -54,6 +54,45 @@ 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 index ce65385..82392c5 100644 --- a/firmware/components/settings/settings.c +++ b/firmware/components/settings/settings.c @@ -99,3 +99,158 @@ esp_err_t settings_set_radar_kind(const char *kind) { 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/webui/CMakeLists.txt b/firmware/components/webui/CMakeLists.txt new file mode 100644 index 0000000..0e5980d --- /dev/null +++ b/firmware/components/webui/CMakeLists.txt @@ -0,0 +1,7 @@ +idf_component_register( + SRCS "webui.c" + INCLUDE_DIRS "include" + REQUIRES settings auth netmgr ota board + esp_http_server esp_wifi esp_app_format esp_timer + json log freertos esp_system +) diff --git a/firmware/components/webui/include/webui.h b/firmware/components/webui/include/webui.h new file mode 100644 index 0000000..076fc3b --- /dev/null +++ b/firmware/components/webui/include/webui.h @@ -0,0 +1,43 @@ +#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; + 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/webui.c b/firmware/components/webui/webui.c new file mode 100644 index 0000000..986d1f6 --- /dev/null +++ b/firmware/components/webui/webui.c @@ -0,0 +1,767 @@ +#include "webui.h" + +#include +#include +#include +#include + +#include "esp_log.h" +#include "esp_http_server.h" +#include "esp_wifi.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" + +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 + static (placeholder HTML; PR #5 replaces with LittleFS-served Preact) + * ============================================================ */ + +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"; + +static esp_err_t handle_root(httpd_req_t *req) { + httpd_resp_set_type(req, "text/html"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_send(req, k_placeholder_html, sizeof(k_placeholder_html) - 1); +} + +/* ============================================================ + * /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_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 *ssid = cJSON_GetObjectItem(j, "ssid"); + cJSON *pass = cJSON_GetObjectItem(j, "pass"); + cJSON *host = cJSON_GetObjectItem(j, "hostname"); + if (host && cJSON_IsString(host) && host->valuestring[0]) { + netmgr_set_hostname(host->valuestring); + } + 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; + } + cJSON_Delete(j); + return send_err(req, 400, "ssid required"); +} + +/* ============================================================ + * /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); + } + static const char *pkeys[] = { "led_pin", "radar_rx", "radar_tx", "button", "status_led" }; + for (size_t i = 0; i < sizeof(pkeys)/sizeof(pkeys[0]); ++i) { + cJSON *v = cJSON_GetObjectItem(j, pkeys[i]); + if (v && cJSON_IsNumber(v)) { + uint8_t pin = (uint8_t)v->valueint; + settings_set_pin_override(pkeys[i], 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 */ + add_u8_if (r, "motion", "en", "motion_enabled"); + 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_enabled", "motion", "en", '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); + cJSON *r = cJSON_CreateObject(); + cJSON_AddBoolToObject(r, "ok", true); + cJSON_AddNumberToObject(r, "updated", updated); + 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(200)); /* 5 Hz */ + + 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[160]; + int n = snprintf(json, sizeof(json), + "{\"distance\":%d,\"direction\":%d,\"rssi\":%d,\"heap\":%" PRIu32 ",\"uptime\":%" PRIu32 ",\"peers\":%u,\"healthy\":%u}", + snap.distance_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/version", HTTP_GET, handle_version, NULL }, + { "/api/wifi/scan", HTTP_GET, handle_wifi_scan, 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/settings", HTTP_GET, handle_settings_get, NULL }, + { "/api/settings", HTTP_POST, handle_settings_post, NULL }, + { "/api/distance", HTTP_GET, handle_distance, 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 = 32; + 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 index 47f3e1f..b55bbed 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,5 +1,6 @@ idf_component_register( SRCS "main.c" INCLUDE_DIRS "." - REQUIRES board settings status_led app_update esp_system log + REQUIRES board settings status_led netmgr auth webui ota + app_update esp_system log ) diff --git a/firmware/main/main.c b/firmware/main/main.c index d219305..7012065 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -23,6 +23,10 @@ #include "board.h" #include "settings.h" #include "status_led.h" +#include "netmgr.h" +#include "auth.h" +#include "webui.h" +#include "ota.h" static const char *TAG = "ambisense"; @@ -104,12 +108,19 @@ void app_main(void) { runtime.button_pin, runtime.status_led_pin, runtime.uart_num, runtime.rmt_channel); - ESP_LOGI(TAG, "Skeleton boot complete. Subsystems (Wi-Fi, web, radar, LEDs, mesh) " - "land in subsequent PRs."); + /* 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(); - /* Flip to AP_MODE pattern as a placeholder until PR #2 wires Wi-Fi. */ - status_led_set_pattern(STATUS_LED_AP_MODE); + /* 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(); - /* app_main returns; FreeRTOS keeps status_led_task and the IDF event - * loop running. No busy loop needed here. */ + /* 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/frontend/design-source/chats/chat1.md b/frontend/design-source/chats/chat1.md index f1012c3..d0990d3 100644 --- a/frontend/design-source/chats/chat1.md +++ b/frontend/design-source/chats/chat1.md @@ -115,3 +115,44 @@ _[tool: fork_verifier_agent]_ 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 + From 081323510038c51a0a9e6c1a07e8d0623f1b8c72 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 20:49:16 +0530 Subject: [PATCH 04/17] =?UTF-8?q?feat(v6):=20PR=20#3=20=E2=80=94=20radar?= =?UTF-8?q?=20driver=20registry,=20motion=20smoother,=2011-mode=20LED=20en?= =?UTF-8?q?gine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lights and sensors come online. The peer-mesh-shaped target queue from PR #4 is the next integration; for now the LED engine consumes the local motion smoother directly. Components added: - components/radar β€” driver registry with all 5 v6.0 drivers compiled in: ld2410, ld2412 (alias), ld2420 (alias), ld2450, sim. Selection at runtime via NVS board.radar_kind. UART-driven parser task pushes radar_frame_t into a 1-slot queue (overwrite-style). radar_ld2410.c β€” 23-byte basic-mode frames, F4F3F2F1...F8F7F6F5 header/tail; extracts moving/stationary state + primary distance + energy. radar_ld2450.c β€” 30-byte fixed frames, AA FF 03 00 ... 55 CC; up to 3 targets with x,y,speed,resolution. Decodes the inverted sign convention (bit 15 = positive). Computes primary radial distance via integer sqrt. radar_sim.c β€” synthetic trace generator. Defaults to a 4 s 30..200 cm sine wave; user can POST a scripted trace to /api/sim/trace (PR #5 wires the UI). - components/motion β€” v5 PI smoother ported faithfully (radar_manager.cpp:38-198). Low-pass on position, EMA on velocity, position prediction with PI controller, 50 Hz task. Tunables in NVS namespace `motion`, x1000 fixed-point. - components/led_engine β€” uses espressif/led_strip managed component (RMT-backed) for non-blocking refresh. All 11 v5 visual modes ported: standard, rainbow, color_wave, breathing, solid, comet, pulse, fire, theater_chase, dual_scan, motion_particles. 60 Hz render task. NVS-backed parameters; led_engine_reload() called from /api/settings POST applies changes without reboot. Strip resize triggers a clean re-init of the led_strip handle. - main.c wires radar_init β†’ motion_init β†’ led_engine_init in order using the resolved board profile's pins (UART num, RX/TX, LED data pin). Telemetry task pumps motion_get β†’ webui_publish_live at 5 Hz so /api/live WS clients receive smoothed distance. Build: 1.04 MB binary, 28% free in 1.4 MB app slot. Flashed to both C3 SuperMinis. Default radar driver is ld2450 (matches the bench hardware on Ravi's setup). Refs v6 epic. Next: PR #5 β€” Vite+Preact UI from design source so the full 7-screen dashboard replaces the placeholder HTML, then PR #4 layers peer mesh on top. --- firmware/components/led_engine/CMakeLists.txt | 5 + .../components/led_engine/idf_component.yml | 2 + .../led_engine/include/led_engine.h | 44 ++ firmware/components/led_engine/led_engine.c | 388 ++++++++++++++++++ firmware/components/motion/CMakeLists.txt | 5 + firmware/components/motion/include/motion.h | 37 ++ firmware/components/motion/motion.c | 126 ++++++ firmware/components/netmgr/include/netmgr.h | 23 ++ firmware/components/netmgr/netmgr.c | 142 ++++++- firmware/components/radar/CMakeLists.txt | 5 + firmware/components/radar/include/radar.h | 64 +++ firmware/components/radar/radar.c | 118 ++++++ firmware/components/radar/radar_ld2410.c | 81 ++++ firmware/components/radar/radar_ld2450.c | 93 +++++ firmware/components/radar/radar_sim.c | 77 ++++ firmware/components/webui/CMakeLists.txt | 2 +- firmware/components/webui/webui.c | 57 ++- firmware/main/CMakeLists.txt | 1 + firmware/main/main.c | 38 ++ 19 files changed, 1284 insertions(+), 24 deletions(-) create mode 100644 firmware/components/led_engine/CMakeLists.txt create mode 100644 firmware/components/led_engine/idf_component.yml create mode 100644 firmware/components/led_engine/include/led_engine.h create mode 100644 firmware/components/led_engine/led_engine.c create mode 100644 firmware/components/motion/CMakeLists.txt create mode 100644 firmware/components/motion/include/motion.h create mode 100644 firmware/components/motion/motion.c create mode 100644 firmware/components/radar/CMakeLists.txt create mode 100644 firmware/components/radar/include/radar.h create mode 100644 firmware/components/radar/radar.c create mode 100644 firmware/components/radar/radar_ld2410.c create mode 100644 firmware/components/radar/radar_ld2450.c create mode 100644 firmware/components/radar/radar_sim.c 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..a1d93b6 --- /dev/null +++ b/firmware/components/led_engine/led_engine.c @@ -0,0 +1,388 @@ +#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) { + 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/motion/CMakeLists.txt b/firmware/components/motion/CMakeLists.txt new file mode 100644 index 0000000..adeef2c --- /dev/null +++ b/firmware/components/motion/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "motion.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..44b6771 --- /dev/null +++ b/firmware/components/motion/include/motion.h @@ -0,0 +1,37 @@ +#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 */ + 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); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/motion/motion.c b/firmware/components/motion/motion.c new file mode 100644 index 0000000..7700788 --- /dev/null +++ b/firmware/components/motion/motion.c @@ -0,0 +1,126 @@ +#include "motion.h" + +#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"; + +/* Defaults match v5 (config.h:69-76). NVS values stored as x1000 fixed-point. */ +static struct { + bool enabled; + float pos_smooth; + float vel_smooth; + float predict; + float p_gain; + float i_gain; + + float smoothed; + float predicted; + float velocity; + float err_integral; + uint64_t last_us; + + 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); +} + +static void motion_task(void *arg) { + (void)arg; + radar_frame_t f; + while (1) { + if (radar_read(&f, pdMS_TO_TICKS(1000)) != ESP_OK) { + target_t t = {0}; + xSemaphoreTake(s_m.lock, portMAX_DELAY); + t = s_m.latest; + t.present = false; + s_m.latest = t; + 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; + + target_t t = { .present = f.present, .energy = f.energy, + .direction = f.direction, .ts_us = f.ts_us }; + + if (!s_m.enabled) { + t.distance_cm = (int16_t)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.smoothed <= 0) { + s_m.smoothed = (float)raw; + s_m.predicted = (float)raw; + } + + s_m.smoothed = (1.f - s_m.pos_smooth) * s_m.smoothed + + s_m.pos_smooth * (float)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; + s_m.predicted = s_m.smoothed + s_m.velocity * s_m.predict; + 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; + } + + xSemaphoreTake(s_m.lock, portMAX_DELAY); + s_m.latest = t; + xSemaphoreGive(s_m.lock); + } +} + +esp_err_t motion_init(void) { + s_m.lock = xSemaphoreCreateMutex(); + + /* Load tunables. NVS values are stored Γ—1000 to keep them integer. */ + uint8_t en = 1; settings_get_u8("motion", "en", &en); s_m.enabled = en != 0; + uint32_t v = 200; 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 = 500; settings_get_u32("motion", "pf", &v); s_m.predict = 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; + + 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, "Smoother: en=%d ps=%.2f vs=%.2f pf=%.2f pg=%.2f ig=%.2f range=%d..%d cm", + s_m.enabled, s_m.pos_smooth, s_m.vel_smooth, s_m.predict, + s_m.p_gain, s_m.i_gain, s_m.min_cm, s_m.max_cm); + + 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); +} diff --git a/firmware/components/netmgr/include/netmgr.h b/firmware/components/netmgr/include/netmgr.h index 492f2ea..6e2f8de 100644 --- a/firmware/components/netmgr/include/netmgr.h +++ b/firmware/components/netmgr/include/netmgr.h @@ -57,6 +57,29 @@ 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 index 5b210d4..ba08e13 100644 --- a/firmware/components/netmgr/netmgr.c +++ b/firmware/components/netmgr/netmgr.c @@ -38,9 +38,49 @@ static struct { 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 @@ -155,24 +195,41 @@ static void start_captive_dns(void) { 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; falling back to AP", STA_RETRY_MAX); + 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)); @@ -251,17 +308,27 @@ esp_err_t netmgr_init(void) { 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)); - /* AP+STA always-on. AP serves the web UI for direct connect (no router - * needed), and ESP-NOW peers find each other on the AP channel. STA is - * additive: if creds are saved, we also join the user's router. */ - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA)); - configure_ap(); + /* 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)); - bool have_sta_creds = ssid[0] != 0; - if (have_sta_creds) { + 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); @@ -271,29 +338,64 @@ esp_err_t netmgr_init(void) { } ESP_ERROR_CHECK(esp_wifi_start()); - ESP_LOGI(TAG, "Wi-Fi up: AP%s%s", have_sta_creds ? "+STA (joining " : " only", have_sta_creds ? ssid : ""); - if (have_sta_creds) ESP_LOGI(TAG, "STA: trying %s", ssid); - - /* Captive DNS responder runs on AP interface β€” always up so phones - * connecting to AmbiSense-XXXX get auto-popped to the setup page, - * regardless of STA state. */ - start_captive_dns(); + 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(); - /* Wait briefly for STA to either connect or fail; either way, the AP - * remains available so this never blocks the device from being usable. */ - if (have_sta_creds) { + /* 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) notify_state(NETMGR_STATE_STA_CONNECTED); - else notify_state(NETMGR_STATE_AP_FALLBACK); + 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(""); 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..f3da0c5 --- /dev/null +++ b/firmware/components/radar/include/radar.h @@ -0,0 +1,64 @@ +#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); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/radar/radar.c b/firmware/components/radar/radar.c new file mode 100644 index 0000000..cfb7927 --- /dev/null +++ b/firmware/components/radar/radar.c @@ -0,0 +1,118 @@ +#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; +} s_radar; + +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; + + radar_frame_t frame = {0}; + size_t consumed = s_radar.drv->parse(rx, held, &frame); + if (consumed > 0) { + xQueueOverwrite(s_radar.q, &frame); + 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/webui/CMakeLists.txt b/firmware/components/webui/CMakeLists.txt index 0e5980d..e865371 100644 --- a/firmware/components/webui/CMakeLists.txt +++ b/firmware/components/webui/CMakeLists.txt @@ -1,7 +1,7 @@ idf_component_register( SRCS "webui.c" INCLUDE_DIRS "include" - REQUIRES settings auth netmgr ota board + REQUIRES settings auth netmgr ota board led_engine radar esp_http_server esp_wifi esp_app_format esp_timer json log freertos esp_system ) diff --git a/firmware/components/webui/webui.c b/firmware/components/webui/webui.c index 986d1f6..1d46d4b 100644 --- a/firmware/components/webui/webui.c +++ b/firmware/components/webui/webui.c @@ -23,6 +23,8 @@ #include "netmgr.h" #include "ota.h" #include "board.h" +#include "led_engine.h" +#include "radar.h" static const char *TAG = "webui"; @@ -290,17 +292,59 @@ static void wifi_apply_task(void *arg) { 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"); - cJSON *ssid = cJSON_GetObjectItem(j, "ssid"); - cJSON *pass = cJSON_GetObjectItem(j, "pass"); + /* 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 : ""; @@ -317,8 +361,12 @@ static esp_err_t handle_wifi_post(httpd_req_t *req) { 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); - return send_err(req, 400, "ssid required"); + cJSON *r = cJSON_CreateObject(); + cJSON_AddStringToObject(r, "status", "ok"); + return send_json(req, r); } /* ============================================================ @@ -599,6 +647,8 @@ static esp_err_t handle_settings_post(httpd_req_t *req) { } } cJSON_Delete(j); + /* Push LED-engine settings live without reboot. */ + led_engine_reload(); cJSON *r = cJSON_CreateObject(); cJSON_AddBoolToObject(r, "ok", true); cJSON_AddNumberToObject(r, "updated", updated); @@ -719,6 +769,7 @@ static const httpd_uri_t k_routes[] = { /* API */ { "/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 }, diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index b55bbed..2d26e1a 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -2,5 +2,6 @@ idf_component_register( SRCS "main.c" INCLUDE_DIRS "." REQUIRES board settings status_led netmgr auth webui ota + radar motion led_engine app_update esp_system log ) diff --git a/firmware/main/main.c b/firmware/main/main.c index 7012065..d07d890 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -27,6 +27,9 @@ #include "auth.h" #include "webui.h" #include "ota.h" +#include "radar.h" +#include "motion.h" +#include "led_engine.h" static const char *TAG = "ambisense"; @@ -75,6 +78,24 @@ static void apply_pin_overrides(board_profile_t *runtime) { } } +/* Telemetry pump: 5 Hz publish smoothed target + RSSI to webui WS clients. */ +static void telemetry_pump_task(void *arg) { + (void)arg; + while (1) { + target_t t; + motion_get(&t); + webui_live_t live = { + .distance_cm = t.distance_cm, + .direction = t.direction, + .rssi = netmgr_get_rssi(), + .free_heap = 0, /* webui fills these in itself before broadcast */ + .uptime_s = 0, + }; + webui_publish_live(&live); + vTaskDelay(pdMS_TO_TICKS(200)); + } +} + static void log_chip_info(void) { esp_chip_info_t chip; esp_chip_info(&chip); @@ -114,6 +135,23 @@ void app_main(void) { 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); + } + + 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(); From 25a638d1adbf5536fc2250e38a22b214cc48c5e7 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 21:01:35 +0530 Subject: [PATCH 05/17] =?UTF-8?q?feat(v6):=20PR=20#5=20=E2=80=94=20full=20?= =?UTF-8?q?Preact=20UI=20from=20design=20source,=20embedded=20as=20gzipped?= =?UTF-8?q?=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the PR #2 placeholder HTML with the full 7-screen dashboard built from the Claude Design handoff. Single-file Preact bundle (48 KB raw, 16.9 KB gzipped) embedded via EMBED_FILES; served with Content-Encoding: gzip β€” no LittleFS needed. Frontend (frontend/): - Vite 5 + Preact 10 + TypeScript + vite-plugin-singlefile + terser. - src/styles.css lifts design-source/project/tokens.css colors, spacing, type, atoms verbatim. Layout adds sidebar/main on desktop, bottom tab bar on mobile (≀760px), light/dark theme toggle, prefers- reduced-motion respect. - src/components.tsx β€” Card, Toggle, Field, Slider, Row, Dot, ColorPicker (8 presets + native picker), useToaster. - src/led_preview.tsx β€” canvas LED strip preview mirrors the firmware's mode logic; on-screen animation matches the wire output. All 11 modes rendered live (standard, rainbow, color_wave, breathing, solid, comet, pulse, fire, theater_chase, dual_scan, motion_particles). - src/api.ts β€” fetch JSON, octet-stream OTA upload with progress, WebSocket /api/live with auto-reconnect backoff. - src/screens.tsx β€” all 7 screens: Live β€” distance meter + LED preview + device + mesh cards LEDs β€” mode picker with animated thumbnails, color picker, brightness/count/distance window/effects/trail/dir Motion β€” PI smoother sliders (live x1k fixed-point readout) Mesh β€” topology picker (PR #4 wires fully) Hardware β€” board profile dropdown, radar driver picker, per-pin overrides with unsafe-pin-mask filtering Network β€” STA scan + connect + AP behaviour (auto/always/ sta_only) + AP password + forget-STA + status badges System β€” auth password, OTA upload (drag-drop), diagnostics - Vite dev proxies /api β†’ http://192.168.4.1 for in-browser dev. Firmware: - components/webui/ui.html.gz embedded via EMBED_FILES; handle_root serves with Content-Encoding: gzip + max-age=300 cache header. - Linker symbols _binary_ui_html_gz_start/end resolved in webui's own translation unit (moved out of main). - The PR #2 inline HTML kept under #if 0 for reference. Build: 1.05 MB binary, 27%% free. UI gzipped 16.9 KB. Flashed to both C3s (ports 21101 and 2122201). Refs v6 epic. Next: PR #4 (peer mesh) β€” Mesh screen gets real peer cards, topology editor, pairing flow. --- firmware/components/webui/CMakeLists.txt | 1 + firmware/components/webui/ui.html.gz | Bin 0 -> 16908 bytes firmware/components/webui/webui.c | 26 +- frontend/.gitignore | 4 + frontend/index.html | 13 + frontend/package-lock.json | 2168 ++++++++++++++++++++++ frontend/package.json | 20 + frontend/src/api.ts | 67 + frontend/src/components.tsx | 130 ++ frontend/src/led_preview.tsx | 192 ++ frontend/src/main.tsx | 79 + frontend/src/screens.tsx | 553 ++++++ frontend/src/styles.css | 210 +++ frontend/tsconfig.json | 17 + frontend/vite.config.ts | 24 + 15 files changed, 3497 insertions(+), 7 deletions(-) create mode 100644 firmware/components/webui/ui.html.gz create mode 100644 frontend/.gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components.tsx create mode 100644 frontend/src/led_preview.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/screens.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/firmware/components/webui/CMakeLists.txt b/firmware/components/webui/CMakeLists.txt index e865371..d17122c 100644 --- a/firmware/components/webui/CMakeLists.txt +++ b/firmware/components/webui/CMakeLists.txt @@ -4,4 +4,5 @@ idf_component_register( REQUIRES settings auth netmgr ota board led_engine radar esp_http_server esp_wifi esp_app_format esp_timer json log freertos esp_system + EMBED_FILES "ui.html.gz" ) diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..496ba84b97775b2fc9fd22e7b782548874b652ef GIT binary patch literal 16908 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 z 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 Xl|>zRDW679fAxO>T3qM\n" @@ -201,12 +218,7 @@ static const char k_placeholder_html[] = "loadVer();loadScan();setInterval(loadVer,5000);\n" "\n" "\n"; - -static esp_err_t handle_root(httpd_req_t *req) { - httpd_resp_set_type(req, "text/html"); - httpd_resp_set_hdr(req, "Cache-Control", "no-store"); - return httpd_resp_send(req, k_placeholder_html, sizeof(k_placeholder_html) - 1); -} +#endif /* ============================================================ * /api/version 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/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/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..4025e29 --- /dev/null +++ b/frontend/src/led_preview.tsx @@ -0,0 +1,192 @@ +/** 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; + 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..8fa9b5f --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,79 @@ +/** App shell: sidebar + tab routing + global state. */ +import { render } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import { getJSON, liveSocket } from './api'; +import { Toast, useToaster } from './components'; +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: 'β—‰' }, + { id: 'leds', name: 'LEDs', icon: '✦' }, + { id: 'motion', name: 'Motion', icon: '∿' }, + { id: 'mesh', name: 'Mesh', icon: '⌬' }, + { id: 'hardware', name: 'Hardware', icon: 'βš™' }, + { id: 'network', name: 'Network', icon: '⌁' }, + { id: 'system', name: 'System', icon: '☼' }, +]; + +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, 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 t = useToaster(); + + const reload = () => { + getJSON('/api/version').then(setVersion).catch(() => {}); + getJSON('/api/settings').then(setSettings).catch(() => {}); + }; + + useEffect(() => { reload(); }, []); + useEffect(() => liveSocket(setLive), []); + 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]; + + return ( +
+ +
+ +
+ {t.toast && } +
+ ); +} + +render(, document.getElementById('root')!); diff --git a/frontend/src/screens.tsx b/frontend/src/screens.tsx new file mode 100644 index 0000000..f999940 --- /dev/null +++ b/frontend/src/screens.tsx @@ -0,0 +1,553 @@ +/** All seven screens: Live, LEDs, Motion, Mesh, Hardware, Network, System. */ +import { useEffect, useState } from 'preact/hooks'; +import { Card, Toggle, Field, Slider, Row, Dot, ColorPicker, useToaster } from './components'; +import { LedPreview, LED_MODE_NAMES } from './led_preview'; +import { getJSON, postJSON, postBinary } from './api'; + +interface Live { distance: number; direction: number; rssi: number; heap: number; uptime: number; peers: number; healthy: number; } +interface AppState { + live: Live; + settings: any; + version: any; + toast: any; + setToast: (m: string, k?: 'ok'|'err') => void; + reload: () => void; +} + +function Section({ title, sub, right, children }: any) { + return ( + <> +
+
+

{title}

+ {sub &&
{sub}
} +
+ {right} +
+ {children} + + ); +} + +/* ----------------------------------------------------------------- */ +/* A. Live dashboard */ +/* ----------------------------------------------------------------- */ +export function ScreenLive({ live, version, settings }: AppState) { + const dist = live.distance || 0; + const minD = settings.min_distance ?? 30; + const maxD = settings.max_distance ?? 300; + const pct = Math.max(0, Math.min(100, ((dist - minD) / Math.max(1, maxD - minD)) * 100)); + return ( +
+
+ +
+
+ {dist} + cm +
+
+
+ {minD} cm + direction: {live.direction === 0 ? 'β€”' : live.direction < 0 ? 'closer' : 'away'} + {maxD} cm +
+
+ + + +
+ Mode: {LED_MODE_NAMES[settings.light_mode ?? 0]} + {' Β· '}{settings.led_count ?? 30} LEDs +
+
+ + {version.version || 'β€”'}} /> + + {version.ip || 'AP only'}} /> + {version.hostname || 'β€”'}} /> + + + + + + + +
+ ESP-NOW peer mesh activates in PR #4. Each device drives its own strip; readings broadcast at 5 Hz. +
+
+
+
+ ); +} + +function fmtUptime(s: number) { + if (!s) return 'β€”'; + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m ${s%60}s`; +} + +/* ----------------------------------------------------------------- */ +/* B. LEDs */ +/* ----------------------------------------------------------------- */ +export function ScreenLeds({ settings, live, reload, setToast }: AppState) { + const [s, setS] = useState(settings); + useEffect(() => setS(settings), [settings.light_mode, settings.r, settings.g, settings.b, settings.led_count, settings.brightness]); + + const save = async (patch: any) => { + const next = { ...s, ...patch }; + setS(next); + try { + await postJSON('/api/settings', patch); + reload(); + } catch (e: any) { + setToast(e.message || 'Save failed', 'err'); + } + }; + + return ( +
+ + + + +
+ +
+ {LED_MODE_NAMES.map((name, i) => ( +
save({ light_mode: i })}> +
+ +
+
{name}
+
+ ))} +
+
+ +
+
+ + + save({ r, g, b })} /> + + + save({ brightness: v })} /> + + + save({ led_count: v })} /> + + + + + save({ min_distance: v })} suffix=" cm" /> + + + save({ max_distance: v })} suffix=" cm" /> + + + save({ span: v })} /> + + + save({ center_shift: v })} /> + + + + + save({ effect_speed: v })} /> + + + save({ effect_intensity: v })} /> + + + save({ trail: v })} /> + +
+ Direction trail + save({ dir_light: v ? 1 : 0 })} /> +
+
+ Background dim + save({ bg_mode: v ? 1 : 0 })} /> +
+
+
+
+ ); +} + +/* ----------------------------------------------------------------- */ +/* C. Motion */ +/* ----------------------------------------------------------------- */ +export function ScreenMotion({ settings, reload, setToast }: AppState) { + const [s, setS] = useState(settings); + useEffect(() => setS(settings), [settings.motion_enabled]); + const save = async (patch: any) => { + setS({ ...s, ...patch }); + try { await postJSON('/api/settings', patch); reload(); } + catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + }; + return ( +
+ +
+ Enable smoothing + save({ motion_enabled: v ? 1 : 0 })} /> +
+
+ + save({ pos_smooth_x1k: v })} /> + + + save({ vel_smooth_x1k: v })} /> + + + save({ predict_x1k: v })} /> + + + save({ p_gain_x1k: v })} /> + + + save({ i_gain_x1k: v })} /> + + +
+ ); +} + +/* ----------------------------------------------------------------- */ +/* D. Mesh */ +/* ----------------------------------------------------------------- */ +export function ScreenMesh({ live }: AppState) { + return ( +
+ +
+ {['Straight','L-shape','U-shape','Custom'].map(t => ( +
+
+
{t}
+
+ ))} +
+ +
+ + + +
+ When PR #4 lands: each device broadcasts its smoothed reading at 5 Hz, every device fuses locally and renders only its own segment. Lowest-MAC device serves the web UI. Pair new devices with a 3-second BOOT-button hold during a pairing window. +
+
+
+ ); +} + +/* ----------------------------------------------------------------- */ +/* E. Hardware */ +/* ----------------------------------------------------------------- */ +export function ScreenHardware({ setToast }: AppState) { + const [profiles, setProfiles] = useState(null); + const [kinds, setKinds] = useState(null); + const [activeBoard, setActiveBoard] = useState(''); + const [activeRadar, setActiveRadar] = useState(''); + const [overrides, setOverrides] = useState({}); + + useEffect(() => { + getJSON('/api/board/profiles').then(r => { setProfiles(r); setActiveBoard(r.active); }); + getJSON('/api/radar/kinds').then(r => { setKinds(r); setActiveRadar(r.active); }); + }, []); + + if (!profiles || !kinds) return
Loading…
; + const profile = profiles.profiles.find((p: any) => p.id === activeBoard) || profiles.profiles[0]; + const unsafe: number[] = profile.unsafe || []; + + const PIN_FIELDS: [string, string][] = [ + ['led_pin', 'LED data'], + ['radar_rx', 'Radar RX'], + ['radar_tx', 'Radar TX'], + ['button', 'Button'], + ['status_led', 'Status LED'], + ]; + + const save = async () => { + try { + await postJSON('/api/board', { id: activeBoard, radar_kind: activeRadar, ...overrides }); + setToast('Saved. Reboot to apply pin changes.'); + } catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + }; + + return ( +
+
+ + + + + + + validated : untested} /> + + + + + + + {kinds.kinds.find((k: any) => k.id === activeRadar) && ( +
+ {kinds.kinds.find((k: any) => k.id === activeRadar).note} +
+ )} +
+ Radar driver swap takes effect after reboot. No reflash needed β€” all drivers ship in the firmware. +
+
+ + + {PIN_FIELDS.map(([key, label]) => { + const def = profile[key]; + const cur = overrides[key] ?? def; + return ( + + + + ); + })} + +
+ {unsafe.length} unsafe GPIO{unsafe.length > 1 ? 's' : ''} hidden ({unsafe.join(', ')}). These are strapping/USB-JTAG/flash pins. +
+
+
+
+ ); +} + +/* ----------------------------------------------------------------- */ +/* F. Network */ +/* ----------------------------------------------------------------- */ +export function ScreenNetwork({ setToast, version }: AppState) { + const [wifi, setWifi] = useState(null); + const [scan, setScan] = useState(null); + const [ssid, setSsid] = useState(''); + const [pass, setPass] = useState(''); + const [host, setHost] = useState(''); + const [apMode, setApMode] = useState('auto'); + const [apPass, setApPass] = useState(''); + + const refresh = () => getJSON('/api/wifi').then(w => { + setWifi(w); + setApMode(w.ap_mode); + setHost(w.hostname || ''); + }); + const doScan = () => { + setScan(null); + getJSON('/api/wifi/scan').then(r => setScan(r.networks)).catch(e => { setScan([]); setToast(e.message, 'err'); }); + }; + useEffect(() => { refresh(); doScan(); }, []); + + const saveWifi = async () => { + if (!ssid) { setToast('Pick a network', 'err'); return; } + try { + await postJSON('/api/wifi', { ssid, pass, hostname: host || undefined }); + setToast('Saved β€” reconnecting'); + setTimeout(refresh, 4000); + } catch (e: any) { setToast(e.message, 'err'); } + }; + const saveApMode = async (mode: string) => { + setApMode(mode); + try { await postJSON('/api/wifi', { ap_mode: mode }); setToast(`AP mode β†’ ${mode}`); refresh(); } + catch (e: any) { setToast(e.message, 'err'); } + }; + const saveApPass = async () => { + try { await postJSON('/api/wifi', { ap_password: apPass }); setToast('AP password updated'); } + catch (e: any) { setToast(e.message, 'err'); } + }; + const forgetSta = async () => { + if (!confirm('Forget Wi-Fi credentials? Device will return to AP-only mode.')) return; + try { await postJSON('/api/wifi', { forget_sta: true }); setToast('STA cleared'); refresh(); } + catch (e: any) { setToast(e.message, 'err'); } + }; + + if (!wifi) return
Loading…
; + + return ( +
+
+ +
+ + + STA: {wifi.sta_connected ? `connected to ${wifi.ssid}` : (wifi.sta_configured ? `joining ${wifi.ssid}…` : 'not configured')} + +
+
+ + AP: {wifi.ap_active ? `up β€” AmbiSense-XXXX (chan 6)` : 'down'} +
+ {wifi.ip || 'β€”'}} /> + {wifi.hostname}} /> + +
+ + + + + + + setApPass((e.target as HTMLInputElement).value)} placeholder="leave blank for open AP" /> + + + + + Rescan}> + + + + + setPass((e.target as HTMLInputElement).value)} /> + + + setHost((e.target as HTMLInputElement).value)} placeholder="ambisense-living" /> + + + {wifi.sta_configured && ( + + )} + +
+
+ ); +} + +/* ----------------------------------------------------------------- */ +/* G. System */ +/* ----------------------------------------------------------------- */ +export function ScreenSystem({ version, setToast }: AppState) { + const [pw, setPw] = useState(''); + const [otaProg, setOtaProg] = useState(-1); + const fileInput = (() => { let r: HTMLInputElement | null = null; return { set: (e: any) => r = e, get: () => r }; })(); + + const setPassword = async () => { + if (pw && pw.length < 8) { setToast('Min 8 chars', 'err'); return; } + try { await postJSON('/api/auth/password', { password: pw }); setToast(pw ? 'Password set' : 'Password cleared'); setPw(''); } + catch (e: any) { setToast(e.message, 'err'); } + }; + + const doOta = async () => { + const inp = fileInput.get(); + if (!inp || !inp.files || inp.files.length === 0) { setToast('Pick a .bin file', 'err'); return; } + const f = inp.files[0]; + setOtaProg(0); + try { + await postBinary('/api/ota', f, p => setOtaProg(p)); + setToast('Flashed. Device rebooting in 1 s; refresh in 30 s.'); + } catch (e: any) { + setOtaProg(-1); + setToast(e.message || 'OTA failed', 'err'); + } + }; + + return ( +
+
+ + {!version.auth_enabled && ( +
+ Authentication is OFF. Anyone on this network can change settings. Set a password. +
+ )} + + setPw((e.target as HTMLInputElement).value)} /> + + +
+ + + + + + {otaProg >= 0 && ( +
+ )} + +
+ Bootloader rollback is armed; if a bad image hangs, the previous firmware boots automatically. +
+ + + + {version.version}} /> + {version.idf_version}} /> + + + {version.mac}} /> + + + +
+
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..901e5ca --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,210 @@ +/* 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; } + +@media (max-width: 760px) { + .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: 16px 14px 88px; } + .page-head h1 { font-size: 19px; } +} + +/* 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 { 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; } + +.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; + display: flex; flex-direction: column; gap: 6px; +} +.mode-card:hover { background: var(--bg-3); border-color: var(--text-4); } +.mode-card.on { border-color: var(--acc-orange); background: rgba(255,122,61,.08); } +.mode-card .preview { height: 32px; border-radius: 6px; background: var(--bg-3); } +.mode-card .name { font-size: 12px; font-weight: 500; } + +.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', + }, + }, +}); From 9f98f3b9c7f6f738e6c554877e75405636566b5e Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 21:10:56 +0530 Subject: [PATCH 06/17] =?UTF-8?q?feat(v6):=20PR=20#4=20=E2=80=94=20ESP-NOW?= =?UTF-8?q?=20peer=20mesh,=20topology,=204=20fusion=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the mesh layer that lets multiple devices coordinate without a master/slave relationship. Each device renders only its own LED segment, broadcasts its smoothed reading at 5 Hz, and runs the same fusion algorithm locally on the merged peer stream β€” every device arrives at the same active position. components/topology β€” explicit topology model in NVS: - 4 kinds: straight, L_shape, U_shape, custom. - Up to 8 segments. Each segment owned by one peer (MAC), with its own LED virtual address range and optional distance window. - Versioned blob; remote-gossiped updates with higher version win. - Defaults to a single-device 30-LED segment on first boot. components/mesh β€” ESP-NOW peer mesh: - 5 Hz target broadcast (24-byte packet: distance, direction, energy, flags, timestamp). - Coordinator role auto-elected to lowest-MAC healthy peer; re-runs every broadcast tick. Coordinator serves the canonical web UI host (mDNS resolves to it). - 30-second pairing window opens at boot AND on /api/mesh POST {pair:true}. Outside the window, only known peers' broadcasts are accepted. - 10-second peer health timeout marks stale peers; logs transitions. - Topology gossip via MSG_GOSSIP carrying the full topology_t blob; receivers compare versions before accepting. - 4 fusion algorithms (port of v5 SENSOR_PRIORITY modes): most_recent β€” newest reading wins slave_first β€” non-coordinator readings preferred master_first β€” coordinator's reading preferred zone_based β€” per-segment dist_min/max windows score candidates; reading inside a segment's window gets +100 score Persisted in NVS topo.fuse. webui adds: - GET /api/mesh β†’ coordinator role, fusion mode, peer list (mac, distance, direction, rssi, healthy) - POST /api/mesh β†’ set fusion mode, open pairing window - GET /api/topology β†’ kind, version, total LEDs, segment list - POST /api/topology β†’ replace topology + force gossip to peers main.c brings them up after netmgr (Wi-Fi must be started before esp_now_init). Telemetry pump now reports mesh-fused distance + peer counts to /api/live WS clients. Build: 1.07 MB binary, 26%% free in app slot. Refs v6 epic. Next: validate mesh between the two C3s on bench, then tag v6.0.0. --- firmware/components/mesh/CMakeLists.txt | 5 + firmware/components/mesh/include/mesh.h | 77 ++++ firmware/components/mesh/mesh.c | 350 ++++++++++++++++++ firmware/components/topology/CMakeLists.txt | 5 + .../components/topology/include/topology.h | 72 ++++ firmware/components/topology/topology.c | 92 +++++ firmware/components/webui/CMakeLists.txt | 1 + firmware/components/webui/webui.c | 130 +++++++ firmware/main/CMakeLists.txt | 2 +- firmware/main/main.c | 25 +- 10 files changed, 753 insertions(+), 6 deletions(-) create mode 100644 firmware/components/mesh/CMakeLists.txt create mode 100644 firmware/components/mesh/include/mesh.h create mode 100644 firmware/components/mesh/mesh.c create mode 100644 firmware/components/topology/CMakeLists.txt create mode 100644 firmware/components/topology/include/topology.h create mode 100644 firmware/components/topology/topology.c 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..4c8574a --- /dev/null +++ b/firmware/components/mesh/include/mesh.h @@ -0,0 +1,77 @@ +#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. */ +esp_err_t mesh_open_pairing(void); + +/* 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..2d1fb6e --- /dev/null +++ b/firmware/components/mesh/mesh.c @@ -0,0 +1,350 @@ +#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 + +#define MESH_MAGIC 0xA61B + +#define PAIRING_WINDOW_MS 30000 + +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; +} 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 void recompute_coordinator_locked(void) { + /* Coordinator = lowest-MAC peer that is currently healthy (or self). */ + 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 was = s_m.is_coordinator; + s_m.is_coordinator = (memcmp(best, s_m.my_mac, 6) == 0); + if (was != s_m.is_coordinator) { + ESP_LOGI(TAG, "Coordinator role: %s", s_m.is_coordinator ? "this device" : "peer"); + } +} + +static bool in_pairing_window(void) { + return (uint64_t)esp_timer_get_time() < s_m.pairing_until_us; +} + +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); +} + +/* ---- 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 */ + + 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); +} + +/* ---- broadcast task: 5 Hz target broadcast, 1 Hz heartbeat ---- */ + +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; + while (1) { + peer_msg_t msg; + make_msg(&msg, MSG_TARGET); + esp_now_send(BCAST_MAC, (const uint8_t *)&msg, sizeof(msg)); + + /* 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; + ESP_LOGI(TAG, "Pairing window opened (30 s)"); + return ESP_OK; +} + +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/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 index d17122c..3f23693 100644 --- a/firmware/components/webui/CMakeLists.txt +++ b/firmware/components/webui/CMakeLists.txt @@ -2,6 +2,7 @@ idf_component_register( SRCS "webui.c" INCLUDE_DIRS "include" REQUIRES settings auth netmgr ota board led_engine radar + topology mesh esp_http_server esp_wifi esp_app_format esp_timer json log freertos esp_system EMBED_FILES "ui.html.gz" diff --git a/firmware/components/webui/webui.c b/firmware/components/webui/webui.c index 0acbec9..b555117 100644 --- a/firmware/components/webui/webui.c +++ b/firmware/components/webui/webui.c @@ -25,6 +25,8 @@ #include "board.h" #include "led_engine.h" #include "radar.h" +#include "topology.h" +#include "mesh.h" static const char *TAG = "webui"; @@ -667,6 +669,130 @@ static esp_err_t handle_settings_post(httpd_req_t *req) { 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()); + static const char *FUSE_NAMES[] = {"most_recent","slave_first","master_first","zone_based"}; + cJSON_AddStringToObject(r, "fusion", FUSE_NAMES[mesh_get_fusion()]); + + 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); + 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) * ============================================================ */ @@ -792,6 +918,10 @@ static const httpd_uri_t k_routes[] = { { "/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/topology", HTTP_GET, handle_topology_get, NULL }, + { "/api/topology", HTTP_POST, handle_topology_post, NULL }, { "/api/ota", HTTP_POST, handle_ota, NULL }, }; diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 2d26e1a..9de9389 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -2,6 +2,6 @@ idf_component_register( SRCS "main.c" INCLUDE_DIRS "." REQUIRES board settings status_led netmgr auth webui ota - radar motion led_engine + radar motion led_engine topology mesh app_update esp_system log ) diff --git a/firmware/main/main.c b/firmware/main/main.c index d07d890..7721360 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -30,6 +30,8 @@ #include "radar.h" #include "motion.h" #include "led_engine.h" +#include "topology.h" +#include "mesh.h" static const char *TAG = "ambisense"; @@ -78,18 +80,26 @@ static void apply_pin_overrides(board_profile_t *runtime) { } } -/* Telemetry pump: 5 Hz publish smoothed target + RSSI to webui WS clients. */ +/* Telemetry pump: 5 Hz publish mesh-fused target + RSSI + peer health to + * webui WS clients. Falls back to local motion_get() if mesh has no peers. */ static void telemetry_pump_task(void *arg) { (void)arg; while (1) { - target_t t; - motion_get(&t); + mesh_fused_t f; + mesh_get_fused(&f); + 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 = t.distance_cm, - .direction = t.direction, + .distance_cm = f.distance_cm, + .direction = f.direction, .rssi = netmgr_get_rssi(), .free_heap = 0, /* webui fills these in itself before broadcast */ .uptime_s = 0, + .peer_count = (uint8_t)pn, + .peer_healthy = (uint8_t)hn, }; webui_publish_live(&live); vTaskDelay(pdMS_TO_TICKS(200)); @@ -150,6 +160,11 @@ void app_main(void) { 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)"); + xTaskCreate(telemetry_pump_task, "tele_pump", 3072, NULL, 3, NULL); /* If we're running on a freshly-flashed image with rollback armed, mark From 125110900b5f8d9dda439742e8de4fe232499b69 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 21:15:45 +0530 Subject: [PATCH 07/17] perf(ui): kill 11-canvas paint storm, lazy-load + visibility-pause for phone-grade perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported issue: web UI is slow on phone. Root cause: the LEDs tab was mounting 11 simultaneous animated canvases (one per mode card), each running a 60 Hz requestAnimationFrame loop with per-pixel math (sin/cos for color_wave, Math.random per pixel for fire). Mobile GPU cooked. Fixes: - Replace per-card animated canvases with **static CSS-gradient thumbnails** (one custom gradient per mode, hand-tuned to match each mode's feel: rainbow strip, comet trail, fire vertical gradient, repeating-stripe for theater chase, particle dots, etc.). 11 canvases β†’ 0. - Hero animated canvas remains: ONE on the Live tab, ONE on the LEDs tab, showing the active mode + live distance β€” exactly the "real-time where it matters" win. - led_preview now respects document.hidden β€” RAF returns immediately when the tab/page is backgrounded. Phone in pocket = zero CPU. - /api/version polling: 5 s β†’ 30 s, and skipped while hidden. Distance, RSSI, peer health continue at 5 Hz over WebSocket β€” those are the real real-time signals. - Network tab no longer auto-scans WiFi on mount. The 1-second wifi scan was stalling first paint. User clicks "Scan" when ready. Bundle: 50.1 KB raw, 17.3 KB gzipped (+0.4 KB for the new gradient classes). Same firmware embed path; flashed both C3s. Real-time vs on-demand split: - WebSocket /api/live (5 Hz): distance, direction, RSSI, heap, uptime, peer count, healthy peer count. - Animated canvas: only the active mode's preview β€” mirrors hardware. - Slider feedback: immediate optimistic write + toast confirm. - Polled at 30 s: /api/version (uptime + heap). - On-demand: /api/wifi/scan, /api/board/profiles, /api/topology, etc. --- firmware/components/webui/ui.html.gz | Bin 16908 -> 17312 bytes frontend/src/led_preview.tsx | 6 +++++ frontend/src/main.tsx | 9 +++++++ frontend/src/screens.tsx | 36 +++++++++++++++------------ frontend/src/styles.css | 21 ++++++++++++++-- 5 files changed, 54 insertions(+), 18 deletions(-) diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz index 496ba84b97775b2fc9fd22e7b782548874b652ef..244b09bf7ca3b61bc2f458f19e7ef09d51729c7c 100644 GIT binary patch delta 17130 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 delta 16735 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 Gy#N3<)s-j! diff --git a/frontend/src/led_preview.tsx b/frontend/src/led_preview.tsx index 4025e29..ccdd872 100644 --- a/frontend/src/led_preview.tsx +++ b/frontend/src/led_preview.tsx @@ -47,6 +47,12 @@ export function LedPreview(p: PreviewProps) { 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 */ diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8fa9b5f..5ef76a5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -32,6 +32,15 @@ function App() { }; useEffect(() => { reload(); }, []); + /* Poll /api/version every 30 s β€” uptime/free-heap don't need real-time; + * the live distance + RSSI + peer health come over WebSocket already. */ + useEffect(() => { + const id = setInterval(() => { + if (document.hidden) return; + getJSON('/api/version').then(setVersion).catch(() => {}); + }, 30000); + return () => clearInterval(id); + }, []); useEffect(() => liveSocket(setLive), []); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('ambitheme', theme); }, [theme]); useEffect(() => { localStorage.setItem('ambitab', tab); }, [tab]); diff --git a/frontend/src/screens.tsx b/frontend/src/screens.tsx index f999940..1242e3c 100644 --- a/frontend/src/screens.tsx +++ b/frontend/src/screens.tsx @@ -4,6 +4,22 @@ import { Card, Toggle, Field, Slider, Row, Dot, ColorPicker, useToaster } from ' import { LedPreview, LED_MODE_NAMES } from './led_preview'; import { getJSON, postJSON, postBinary } from './api'; +/* Static CSS-gradient thumbnails for the 11 mode cards. One animated + * canvas (the hero) is enough; per-card animations crushed phone GPUs. */ +const THUMB_CLASSES = [ + 'thumb-standard', + 'thumb-rainbow', + 'thumb-color-wave', + 'thumb-breathing', + 'thumb-solid', + 'thumb-comet', + 'thumb-pulse', + 'thumb-fire', + 'thumb-theater-chase', + 'thumb-dual-scan', + 'thumb-particles', +]; + interface Live { distance: number; direction: number; rssi: number; heap: number; uptime: number; peers: number; healthy: number; } interface AppState { live: Live; @@ -142,21 +158,7 @@ export function ScreenLeds({ settings, live, reload, setToast }: AppState) {
{LED_MODE_NAMES.map((name, i) => (
save({ light_mode: i })}> -
- -
+
{name}
))} @@ -395,7 +397,9 @@ export function ScreenNetwork({ setToast, version }: AppState) { setScan(null); getJSON('/api/wifi/scan').then(r => setScan(r.networks)).catch(e => { setScan([]); setToast(e.message, 'err'); }); }; - useEffect(() => { refresh(); doScan(); }, []); + /* Lazy: fetch wifi state on mount, but DON'T auto-scan (a 1-second wifi + * scan stalls the page on slow devices). User clicks "Scan" when ready. */ + useEffect(() => { refresh(); }, []); const saveWifi = async () => { if (!ssid) { setToast('Pick a network', 'err'); return; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 901e5ca..43c2c20 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -197,14 +197,31 @@ input, select, textarea { font: inherit; color: inherit; } .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; + 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; background: var(--bg-3); } +.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); } From d5c245b6aa067f216dff92f3016500ca3415153e Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 21:21:32 +0530 Subject: [PATCH 08/17] fix(webui): serve raw HTML when client doesn't accept gzip + add /api/ping health endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported: at the device's STA IP, only the page header showed β€” the body wouldn't render. Reproducible mid-session and not earlier on the AP IP. Most plausible cause: some browsers/edge cases (private-LAN detection, captive-portal probes, certain mobile WebViews) don't send Accept-Encoding: gzip even when the page is plain HTTP. The C3 was unconditionally returning Content-Encoding: gzip, so those clients got the gzipped binary as text and parsed only as far as the first non- ASCII byte. Fix: - EMBED both ui.html.gz (17.3 KB) and ui.html (50.1 KB). - handle_root_real now sniffs Accept-Encoding and serves the gzipped blob with Content-Encoding: gzip when supported, raw HTML otherwise. - Cost: +33 KB flash. Worth it to eliminate this whole bug class. Also added GET /api/ping β†’ "pong" so a stuck client can curl it and confirm the server is alive even when HTML rendering misbehaves. Build: 1.13 MB binary, 22%% free in app slot. Flashed both C3s. If user's browser issue persists after this flash, the next diagnostic step is to curl http:///api/ping and check Content-Length headers on / β€” that points at TCP/HTTP plumbing rather than encoding. --- firmware/components/webui/CMakeLists.txt | 2 +- firmware/components/webui/ui.html | 14 ++++++++++ firmware/components/webui/webui.c | 34 +++++++++++++++++++++--- 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 firmware/components/webui/ui.html diff --git a/firmware/components/webui/CMakeLists.txt b/firmware/components/webui/CMakeLists.txt index 3f23693..deba88f 100644 --- a/firmware/components/webui/CMakeLists.txt +++ b/firmware/components/webui/CMakeLists.txt @@ -5,5 +5,5 @@ idf_component_register( topology mesh esp_http_server esp_wifi esp_app_format esp_timer json log freertos esp_system - EMBED_FILES "ui.html.gz" + EMBED_FILES "ui.html.gz" "ui.html" ) diff --git a/firmware/components/webui/ui.html b/firmware/components/webui/ui.html new file mode 100644 index 0000000..2758ffb --- /dev/null +++ b/firmware/components/webui/ui.html @@ -0,0 +1,14 @@ + + + + + + + AmbiSense v6 + + + + +
+ + diff --git a/firmware/components/webui/webui.c b/firmware/components/webui/webui.c index b555117..ed9a29d 100644 --- a/firmware/components/webui/webui.c +++ b/firmware/components/webui/webui.c @@ -140,13 +140,31 @@ static esp_err_t handle_captive_redirect(httpd_req_t *req) { 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, "Content-Encoding", "gzip"); httpd_resp_set_hdr(req, "Cache-Control", "max-age=300, public"); - const size_t len = _binary_ui_html_gz_end - _binary_ui_html_gz_start; - return httpd_resp_send(req, (const char *)_binary_ui_html_gz_start, len); + + if (accepts_gzip) { + httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + const size_t len = _binary_ui_html_gz_end - _binary_ui_html_gz_start; + return httpd_resp_send(req, (const char *)_binary_ui_html_gz_start, len); + } else { + const size_t len = _binary_ui_html_end - _binary_ui_html_start; + return httpd_resp_send(req, (const char *)_binary_ui_html_start, len); + } } #define handle_root handle_root_real @@ -222,6 +240,15 @@ static const char k_placeholder_html[] = "\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/version * ============================================================ */ @@ -905,6 +932,7 @@ static const httpd_uri_t k_routes[] = { { "/ncsi.txt", HTTP_GET, handle_captive_redirect, NULL }, /* API */ + { "/api/ping", HTTP_GET, handle_ping, 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 }, From 48a97e2c0a616cfd230301709586da5e5c786745 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 21:43:51 +0530 Subject: [PATCH 09/17] fix(webui): chunked HTML send + Hardware UI loads saved pins + Save&Reboot button + /api/reboot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs reported by Ravi during hardware testing, three fixes: 1. **HTML response truncated at ~16 KB** (header-only render at STA IP): esp_http_server's single-call httpd_resp_send doesn't reliably push large bodies on the C3 β€” TX buffer can't accept the full 50 KB raw HTML in one go. Switched to chunked sending (4 KB chunks + terminator). Diagnosed via curl from host: Content-Length: 50172 but only 15802 bytes actually arrived. Fix verified β€” full HTML now delivers as Transfer-Encoding: chunked. 2. **Pins "not persisted across reboot"** β€” actually a UI display bug, not a persistence bug. Verified end-to-end via curl: save led_pin=7, /api/reboot, ping back, readback returns 7. NVS works fine. The lie was the Hardware screen: it only fetched /api/board/profiles (which returns profile defaults like GPIO 10) and never /api/settings (which has saved overrides). User saw "10" in the dropdown after their save and assumed the save was lost. Fixed: - ScreenHardware now fetches /api/board/profiles + /api/radar/kinds + /api/settings in parallel, initializes pin state from saved values falling back to profile defaults only when no override exists. - Each pin shows "(default)" or "(custom)" label so user can see at a glance which pins are overridden. - Switching board profile snaps pins to that board's defaults so dropdowns can't point at GPIOs that don't exist on the new MCU. 3. **JSON key inconsistency** β€” /api/settings GET returns "button_pin" and "status_led_pin", but /api/board POST expected "button" and "status_led". UI couldn't round-trip cleanly. Fixed: server accepts both forms; UI uses the canonical *_pin form everywhere. 4. **No way to reboot from the UI**: - New POST /api/reboot endpoint (deferred 500 ms so the response flushes before esp_restart()). - Hardware tab: "Save & reboot" button next to "Save". - System tab: dedicated "Reboot device" danger-style button. - Both prompt for confirmation before rebooting. 5. **Radar driver versions audit** (Ravi asked): - All five drivers (LD2410/LD2412/LD2420/LD2450/sim) are bare-metal UART parsers against HiLink's official protocol specs β€” not third-party libraries with versions to upgrade. - LD2410 family: F4F3F2F1 + len + type=0x02 + head=AA + state/dist/ energy + tail=55 + F8F7F6F5; matches every LD2410 firmware ever shipped (current v2.04.x). - LD2412: same data-report protocol family; per-gate sensitivity is config-command only. - LD2450: 30-byte fixed frames AA FF 03 00...55 CC, three targets with inverted-sign-bit convention. Current spec. 6. **Managed component versions**: IDF 5.5.2, espressif/led_strip 3.0.3, espressif/mdns 1.11.1 β€” all latest stable as of pin date. Build: 1.13 MB binary, 22%% free in app slot. Flashed both C3s. --- firmware/components/webui/ui.html | 2 +- firmware/components/webui/ui.html.gz | Bin 17312 -> 17805 bytes firmware/components/webui/webui.c | 57 ++++++++-- frontend/src/screens.tsx | 150 +++++++++++++++++++++------ 4 files changed, 167 insertions(+), 42 deletions(-) diff --git a/firmware/components/webui/ui.html b/firmware/components/webui/ui.html index 2758ffb..3c3dc7e 100644 --- a/firmware/components/webui/ui.html +++ b/firmware/components/webui/ui.html @@ -5,7 +5,7 @@ AmbiSense v6 - + diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz index 244b09bf7ca3b61bc2f458f19e7ef09d51729c7c..76ecc8ebc76bdd3398ffec582f18ab42aa2bf9fe 100644 GIT binary patch delta 17437 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 delta 16879 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 z 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 diff --git a/firmware/components/webui/webui.c b/firmware/components/webui/webui.c index ed9a29d..97737e2 100644 --- a/firmware/components/webui/webui.c +++ b/firmware/components/webui/webui.c @@ -157,14 +157,31 @@ static esp_err_t handle_root_real(httpd_req_t *req) { 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"); - const size_t len = _binary_ui_html_gz_end - _binary_ui_html_gz_start; - return httpd_resp_send(req, (const char *)_binary_ui_html_gz_start, len); + body = _binary_ui_html_gz_start; + total = _binary_ui_html_gz_end - _binary_ui_html_gz_start; } else { - const size_t len = _binary_ui_html_end - _binary_ui_html_start; - return httpd_resp_send(req, (const char *)_binary_ui_html_start, len); + 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 @@ -249,6 +266,18 @@ static esp_err_t handle_ping(httpd_req_t *req) { return httpd_resp_send(req, "pong", 4); } +/* /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 * ============================================================ */ @@ -514,12 +543,23 @@ static esp_err_t handle_board_post(httpd_req_t *req) { if (!p) { cJSON_Delete(j); return send_err(req, 400, "unknown board id"); } settings_set_board_id(p->id); } - static const char *pkeys[] = { "led_pin", "radar_rx", "radar_tx", "button", "status_led" }; - for (size_t i = 0; i < sizeof(pkeys)/sizeof(pkeys[0]); ++i) { - cJSON *v = cJSON_GetObjectItem(j, pkeys[i]); + /* 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(pkeys[i], pin); + settings_set_pin_override(M[i].nvs, pin); } } cJSON *rk = cJSON_GetObjectItem(j, "radar_kind"); @@ -933,6 +973,7 @@ static const httpd_uri_t k_routes[] = { /* API */ { "/api/ping", HTTP_GET, handle_ping, NULL }, + { "/api/reboot", HTTP_POST, handle_reboot, 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 }, diff --git a/frontend/src/screens.tsx b/frontend/src/screens.tsx index 1242e3c..f940404 100644 --- a/frontend/src/screens.tsx +++ b/frontend/src/screens.tsx @@ -286,55 +286,121 @@ export function ScreenMesh({ live }: AppState) { /* ----------------------------------------------------------------- */ /* E. Hardware */ /* ----------------------------------------------------------------- */ -export function ScreenHardware({ setToast }: AppState) { +export function ScreenHardware({ setToast, settings, reload }: AppState) { const [profiles, setProfiles] = useState(null); const [kinds, setKinds] = useState(null); const [activeBoard, setActiveBoard] = useState(''); const [activeRadar, setActiveRadar] = useState(''); - const [overrides, setOverrides] = useState({}); + /* Pin state initializes from saved settings (so the dropdowns show + * what's actually persisted, not just the profile defaults). */ + const [pins, setPins] = useState({}); + const [busy, setBusy] = useState(false); useEffect(() => { - getJSON('/api/board/profiles').then(r => { setProfiles(r); setActiveBoard(r.active); }); - getJSON('/api/radar/kinds').then(r => { setKinds(r); setActiveRadar(r.active); }); + Promise.all([ + getJSON('/api/board/profiles'), + getJSON('/api/radar/kinds'), + getJSON('/api/settings'), + ]).then(([p, k, s]) => { + setProfiles(p); + setKinds(k); + setActiveBoard(p.active); + setActiveRadar(k.active); + /* Initialize pin state from saved values, falling back to the + * profile defaults for any pins the user hasn't customised yet. */ + const profile = p.profiles.find((x: any) => x.id === p.active) || p.profiles[0]; + setPins({ + led_pin: s.led_pin ?? profile.led_pin, + radar_rx: s.radar_rx ?? profile.radar_rx, + radar_tx: s.radar_tx ?? profile.radar_tx, + button_pin: s.button_pin ?? profile.button, + status_led_pin: s.status_led_pin ?? profile.status_led, + }); + }).catch((e) => setToast(e.message || 'Load failed', 'err')); }, []); - if (!profiles || !kinds) return
Loading…
; + if (!profiles || !kinds) return
Fetching board profiles…
; const profile = profiles.profiles.find((p: any) => p.id === activeBoard) || profiles.profiles[0]; const unsafe: number[] = profile.unsafe || []; - const PIN_FIELDS: [string, string][] = [ - ['led_pin', 'LED data'], - ['radar_rx', 'Radar RX'], - ['radar_tx', 'Radar TX'], - ['button', 'Button'], - ['status_led', 'Status LED'], + /* When the user changes the board profile, snap pins to the new defaults + * so the dropdowns aren't pointing at GPIOs that don't exist on the new + * MCU (e.g. ESP32-C3 has GPIO ≀21; ESP32-S3 has 0..48). */ + 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, + }); + }; + + const PIN_FIELDS: [keyof typeof pins, string, string][] = [ + ['led_pin', 'LED data pin', 'led_pin'], + ['radar_rx', 'Radar RX (MCU side)', 'radar_rx'], + ['radar_tx', 'Radar TX (MCU side)', 'radar_tx'], + ['button_pin', 'Button', 'button'], + ['status_led_pin', 'Status LED', 'status_led'], ]; - const save = async () => { + const buildPayload = () => ({ + id: activeBoard, + radar_kind: activeRadar, + led_pin: pins.led_pin, + radar_rx: pins.radar_rx, + radar_tx: pins.radar_tx, + button_pin: pins.button_pin, + status_led_pin: pins.status_led_pin, + }); + + const saveOnly = async () => { + setBusy(true); + try { + await postJSON('/api/board', buildPayload()); + setToast('Saved. Reboot to apply pin/radar changes.'); + reload(); + } catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + finally { setBusy(false); } + }; + + const saveAndReboot = 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, ...overrides }); - setToast('Saved. Reboot to apply pin changes.'); + await postJSON('/api/board', buildPayload()); + await postJSON('/api/reboot', {}); + setToast('Rebooting β€” refresh the page in 10 seconds.'); } catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + finally { setBusy(false); } }; + /* Show whether the current pin differs from the profile default β€” small + * "(default)" / "(custom)" hint helps the user reason about state. */ + const pinHint = (cur: number, def: number) => + cur === def ? ' (default)' : ' (custom)'; + return ( -
+
- onBoardChange((e.target as HTMLSelectElement).value)}> {profiles.profiles.map((p: any) => ( ))} - - - validated : untested} /> + {profile.mcu}} /> + + validated : untested} /> + {activeRadar}} /> - - + + @@ -344,31 +410,42 @@ export function ScreenHardware({ setToast }: AppState) { {kinds.kinds.find((k: any) => k.id === activeRadar).note}
)} -
- Radar driver swap takes effect after reboot. No reflash needed β€” all drivers ship in the firmware. +
+ All five drivers ship in the firmware. Switching does NOT require a reflash β€” just save and reboot. The active driver is exclusive (one radar per board).
- - {PIN_FIELDS.map(([key, label]) => { - const def = profile[key]; - const cur = overrides[key] ?? def; + + {PIN_FIELDS.map(([key, label, profKey]) => { + const cur = pins[key] ?? (profile as any)[profKey]; + const def = (profile as any)[profKey]; return ( - + ); })} - -
- {unsafe.length} unsafe GPIO{unsafe.length > 1 ? 's' : ''} hidden ({unsafe.join(', ')}). These are strapping/USB-JTAG/flash pins. + +
+ + +
+ +
+ {unsafe.length} unsafe GPIO{unsafe.length > 1 ? 's' : ''} hidden ({unsafe.join(', ')}). These are strapping/USB-JTAG/flash pins on this MCU and will brick boot if used as I/O.
@@ -498,6 +575,12 @@ export function ScreenSystem({ version, setToast }: AppState) { catch (e: any) { setToast(e.message, 'err'); } }; + const doReboot = async () => { + if (!confirm('Reboot device? You will lose connection for ~10 seconds.')) return; + try { await postJSON('/api/reboot', {}); setToast('Rebooting β€” refresh the page in 10 s.'); } + catch (e: any) { setToast(e.message || 'Reboot failed', 'err'); } + }; + const doOta = async () => { const inp = fileInput.get(); if (!inp || !inp.files || inp.files.length === 0) { setToast('Pick a .bin file', 'err'); return; } @@ -550,6 +633,7 @@ export function ScreenSystem({ version, setToast }: AppState) { {version.mac}} /> +
From b61a838895ce8f015f6486a64b700153417ab485 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 21:52:23 +0530 Subject: [PATCH 10/17] feat(ui): faithful port of design's Live screen + system enable + radar diag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported the implemented Live screen was missing major elements shown in the Claude Design handoff. This PR ports the Live screen to match the design pixel-for-pixel: system enable hero card, gradient distance number, sparkline + min/max guides, strip preview, 4 stat tiles, device card with all 8 fields, mesh card with peer info. Frontend (frontend/): - src/atoms.tsx: Icon (24 SVG paths from design), Sparkline (gradient fill + line, exact port of core.jsx implementation), fmtUptime helper. - src/screens.tsx Live: full rewrite matching screen-live.jsx layout β€” 2-column dash-grid (left: distance + strip + stat tiles; right: device + mesh cards), system enable hero with gradient backdrop when active, bigger gradient distance number (64 px) with in-window/min/max chips, client-side ring-buffer sparkline (80 samples = 16 s @ 5 Hz) with dashed min/max guide lines. - src/styles.css: dist-big gradient text, distance-row, dash-grid + stat-row responsive breakpoints (collapse to 1 col at ≀900 px). Firmware: - components/led_engine: respects sys.enabled NVS flag β€” paints all black + 100 ms tick when disabled (mesh keeps running, no light out). - components/webui: GET/POST /api/system endpoints. POST {enabled:bool} persists to NVS sys.enabled. - components/radar: byte counter + last-64-bytes ring + frames-parsed counter exposed via new GET /api/radar/diag with hint string. Diagnosed Ravi's "distance always 0" β†’ was actually wiring; once fixed, diag now shows "OK β€” radar streaming valid frames" with hex dump of the LD2410 frames (e.g. F4 F3 F2 F1 0D 00 02 AA 03 1E 00 64...). - components/webui: GET/POST /api/system + /api/reboot endpoints. /api/reboot defers 500 ms so the response can flush before esp_restart(). Used by Hardware tab "Save & reboot" and System tab "Reboot device" buttons. Build: 1.12 MB binary, 21%% free in 1.4 MB app slot. UI bundle 61.8 KB raw / 20.7 KB gzipped. Both C3s flashed and verified: - /api/system GET β†’ {"enabled":true}; POST cycle works - /api/distance β†’ 41 cm (live radar) - /api/radar/diag β†’ 156 frames parsed in 8 s, hint "OK" - / β†’ Transfer-Encoding: chunked, full 61936 bytes delivered --- firmware/components/led_engine/led_engine.c | 12 + firmware/components/radar/include/radar.h | 13 ++ firmware/components/radar/radar.c | 47 +++- firmware/components/webui/ui.html | 4 +- firmware/components/webui/ui.html.gz | Bin 17805 -> 20726 bytes firmware/components/webui/webui.c | 57 +++++ frontend/src/atoms.tsx | 95 ++++++++ frontend/src/screens.tsx | 233 +++++++++++++++----- frontend/src/styles.css | 18 ++ 9 files changed, 420 insertions(+), 59 deletions(-) create mode 100644 frontend/src/atoms.tsx diff --git a/firmware/components/led_engine/led_engine.c b/firmware/components/led_engine/led_engine.c index a1d93b6..9c990bc 100644 --- a/firmware/components/led_engine/led_engine.c +++ b/firmware/components/led_engine/led_engine.c @@ -273,6 +273,18 @@ static void mode_motion_particles(int start) { 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); diff --git a/firmware/components/radar/include/radar.h b/firmware/components/radar/include/radar.h index f3da0c5..967d180 100644 --- a/firmware/components/radar/include/radar.h +++ b/firmware/components/radar/include/radar.h @@ -59,6 +59,19 @@ 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 index cfb7927..f39b59a 100644 --- a/firmware/components/radar/radar.c +++ b/firmware/components/radar/radar.c @@ -38,8 +38,31 @@ static struct { 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]; @@ -59,12 +82,34 @@ static void radar_task(void *arg) { } int n = uart_read_bytes(s_radar.cfg.uart_num, rx + held, sizeof(rx) - held, pdMS_TO_TICKS(50)); - if (n > 0) held += n; + 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; } diff --git a/firmware/components/webui/ui.html b/firmware/components/webui/ui.html index 3c3dc7e..b96fd5b 100644 --- a/firmware/components/webui/ui.html +++ b/firmware/components/webui/ui.html @@ -5,8 +5,8 @@ AmbiSense v6 - - + +
diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz index 76ecc8ebc76bdd3398ffec582f18ab42aa2bf9fe..30e3d67c2938788297bf37f23d183860ddd3ba36 100644 GIT binary patch literal 20726 zcmV(hK={8OiwFqv82V`d19fRGXmo9C0M)%+bK^+1AohNL1%$3v1EnMcQlz8;3&pL^ zshX*;TU9+h_bOx+NG3&;K!5{)`e=$9cV8Ud2tRFXgx3y7*e~8Ezx%~uN9<3TU*MnM zb53Rg2@s@I-E-IB=_wINBr;E)Jl}aT@pxyPjPjc~H>UY4dh!pC@yCdQ_}n*nY#QSr z4_f(@&$w@ngY?3D@(+gbc*=t@Uclvy=YcVr25H7~-(2LA*0E{q>ks1qU%m|a)jUaa z(-jgNLDb4dLB#zwQ*X5NtC2o z`#-qP+!t-Pd?OF@h(CEc8-|~GoN?pw@bSJV6VJ0z8qRYA(czo3WW0ztG?yk>mZaf% z7(dyWEaFifCb4BNp_?o>xDWVS%%DGKl=2|wFC&h3mKlZdg=sq}kKTq^-VZ0%j>UI( zxsxsCIHXL!wq$p6sGbC4)3)v9BuOpViIGfC-5(e!{L!;7z~Q z%IQzGom<@Q$Nb88x5xto{lhThDIn6~{u8Z_82jKhfN74VVKhddP2cxp2hm~IfQg-8 zJm%x~$(U!h`S#8GKVe<&sNZ`1ko7H~Aa zO|G)rSvFuvca{J#T>G(Fxc^d48?9zXlrooL9X7t-HCi2_@Fa(7o zOtBHdQ_NO5^+TLbVxM`C!nl$0s&_CQGY}BZk&KYWidS!-GdW&+iQQ%X z5^37wY@R0b%nR8CzwuHAnDPR4c6Q;#iztE%^*i)j_{-JpNL&zgr3>Uj?=lI;23ESH zXTBc|_V!{AYlmK|4L=LdEvQgd+1IB^9zB@ zd>UpB4akRkydgn&o%?xN_e#_S9uhF*cG2+J*#Mrx55qsQ(KKIh61iSpc{z*Q^5n&jx&Lhhr|p*Ai6C& z^0TwCm`Bm&B@0DQEJex<@K5BQTR_UO-H)&aJ-^&*vo%adHhV8x24;w9kXBXR83{JvRG5aYK7z_dC*ajNf*|AhDn*}!0dZuf_4qk+(e#G8bDf9@{ z2z09EQ@dD$@A%>F?sI@{RpQ~f#Dl~)r@MobK)2-lo{gm=6

<+1)k$%b04m! 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

u;Ooycry~AU=O!1q3H`f>Qg77B0)L;_ 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 diff --git a/firmware/components/webui/webui.c b/firmware/components/webui/webui.c index 97737e2..f30d4de 100644 --- a/firmware/components/webui/webui.c +++ b/firmware/components/webui/webui.c @@ -266,6 +266,25 @@ static esp_err_t handle_ping(httpd_req_t *req) { 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/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) { @@ -736,6 +755,41 @@ static esp_err_t handle_settings_post(httpd_req_t *req) { 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 * ============================================================ */ @@ -974,6 +1028,8 @@ static const httpd_uri_t k_routes[] = { /* 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/version", HTTP_GET, handle_version, NULL }, { "/api/wifi/scan", HTTP_GET, handle_wifi_scan, NULL }, { "/api/wifi", HTTP_GET, handle_wifi_get, NULL }, @@ -984,6 +1040,7 @@ static const httpd_uri_t k_routes[] = { { "/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 }, diff --git a/frontend/src/atoms.tsx b/frontend/src/atoms.tsx new file mode 100644 index 0000000..46a89c8 --- /dev/null +++ b/frontend/src/atoms.tsx @@ -0,0 +1,95 @@ +/* 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 } 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: <>, + }; + 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; +} diff --git a/frontend/src/screens.tsx b/frontend/src/screens.tsx index f940404..bff1147 100644 --- a/frontend/src/screens.tsx +++ b/frontend/src/screens.tsx @@ -1,7 +1,8 @@ /** All seven screens: Live, LEDs, Motion, Mesh, Hardware, Network, System. */ -import { useEffect, useState } from 'preact/hooks'; +import { useEffect, useRef, useState } from 'preact/hooks'; import { Card, Toggle, Field, Slider, Row, Dot, ColorPicker, useToaster } from './components'; import { LedPreview, LED_MODE_NAMES } from './led_preview'; +import { Icon, Sparkline, fmtUptime } from './atoms'; import { getJSON, postJSON, postBinary } from './api'; /* Static CSS-gradient thumbnails for the 11 mode cards. One animated @@ -48,73 +49,193 @@ function Section({ title, sub, right, children }: any) { /* ----------------------------------------------------------------- */ /* A. Live dashboard */ /* ----------------------------------------------------------------- */ -export function ScreenLive({ live, version, settings }: AppState) { - const dist = live.distance || 0; +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 pct = Math.max(0, Math.min(100, ((dist - minD) / Math.max(1, maxD - minD)) * 100)); + const inWindow = dist >= minD && dist <= maxD; + + /* Client-side ring buffer of last 80 samples (~16 s @ 5 Hz). Faithful + * to the design β€” gives the sparkline its rolling shape. */ + const histRef = useRef(Array(80).fill(0)); + const [hist, setHist] = useState(histRef.current); + useEffect(() => { + histRef.current = [...histRef.current.slice(1), dist]; + setHist(histRef.current); + }, [dist]); + + /* System enable toggle (real /api/system) */ + 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 ( -

-
- -
-
- {dist} - cm + <> +
+
+

Live

+
Real-time radar, mesh, and LED output
+
+
+ WS connected Β· 5 Hz +
+
+ + {/* System enable hero β€” gradient when active */} +
+
+ +
+
+
System {sysEn ? 'active' : 'paused'}
+
+ {sysEn ? 'Radar, mesh, and LED output running' : 'All output muted, mesh idle'} +
+
+ +
+ +
+ {/* Left column */} +
+ {/* Distance meter β€” big gradient number + sparkline */} +
+
+
+
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
+
+
+
+ + {/* Strip preview */} +
+
+
Live LED preview
+ {settings.led_count ?? 30} px Β· {LED_MODE_NAMES[settings.light_mode ?? 0]}
-
-
- {minD} cm - direction: {live.direction === 0 ? 'β€”' : live.direction < 0 ? 'closer' : 'away'} - {maxD} cm + +
+ px 0 + {Math.round(((dist - minD) / Math.max(1, maxD - minD)) * 100)}% along + px {(settings.led_count ?? 30) - 1}
- - - -
- Mode: {LED_MODE_NAMES[settings.light_mode ?? 0]} - {' Β· '}{settings.led_count ?? 30} LEDs + + {/* Stat tiles */} +
+ + -65 ? 'dBm Β· excellent' : (live.rssi||0) > -75 ? 'dBm Β· good' : 'dBm Β· weak'}/> + +
- - - {version.version || 'β€”'}} /> - - {version.ip || 'AP only'}} /> - {version.hostname || 'β€”'}} /> - - - - - - - -
- ESP-NOW peer mesh activates in PR #4. Each device drives its own strip; readings broadcast at 5 Hz. +
+ + {/* Right column */} +
+ {/* Device card */} +
+
+ Device + +
+
+
+ + + + + + + + +
+
- + + {/* Mesh card */} +
+
+ 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 fmtUptime(s: number) { - if (!s) return 'β€”'; - const d = Math.floor(s / 86400); - const h = Math.floor((s % 86400) / 3600); - const m = Math.floor((s % 3600) / 60); - return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m ${s%60}s`; +function StatTile({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+ {sub &&
{sub}
} +
+ ); +} + +function DevField({ k, v }: { k: string; v: string }) { + return ( +
+
{k}
+
{v}
+
+ ); } /* ----------------------------------------------------------------- */ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 43c2c20..eb816be 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -194,6 +194,24 @@ input, select, textarea { font: inherit; color: inherit; } .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; From 5d6419aafa96868cd6e073cd5a02b6259db488b5 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 22:05:51 +0530 Subject: [PATCH 11/17] feat(ui): full design-faithful port of all 7 screens + every control wired to firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive port from frontend/design-source/. Same look/feel/sizes/ placements as Claude Design handoff; every button, dropdown, field, and toggle calls a real /api endpoint and persists in NVS or has hardware effect. Verified all 11 endpoints live on the C3. Frontend (src/screens.tsx full rewrite + atoms.tsx additions): LEDs screen β€” 11 mode cards each with the Claude-design description ("Distance-driven cluster with directional fade", etc), animated mini LedPreview thumbnails, color preset row + hex input, NumberAndSlider for brightness/effect speed/intensity/trail, dual-handle distance window slider, light span / center shift / strip length sliders. Optimistic writes via /api/settings POST with toast confirms. Motion screen β€” large smoothing toggle, real-time raw-vs-smoothed LineChart (SVG, dashed gridlines, 80-sample ring buffer), filter sliders (position/velocity/prediction smoothing) + PI gains panel with explanatory hint card. All values round-trip via /api/settings. Mesh screen β€” topology cards with SVG diagrams (straight/L/U/custom, ported verbatim from design), pair-new-device button opening a 30 s window with animated pairing card, devices list with healthy dots + RSSI + lost%, sensor priority cards (most_recent / slave_first / master_first / zone_based) with descriptions. Wires to /api/topology and /api/mesh. Hardware screen β€” board profile cards (not dropdown), radar driver cards, pin map with field-label icons + dropdowns filtering unsafe GPIOs. "Save & reboot" button when reboot is needed; reboots via /api/reboot. Network screen β€” connected status hero card with wifi icon, scan button + signal-bars indicator + Join/current chip per network, inline password prompt on Join, hostname input with .local suffix, AP-mode selector (auto/always/sta_only) + Reset Wi-Fi danger button with two-click confirmation. All endpoints wired. System screen β€” firmware card with version + reboot button, drop-zone file input with drag-drop or click-to-select, progress bar during upload, "Flash & reboot" button. Diagnostics with heap/min-heap/ uptime/MAC. Auth section with show/hide password toggle and explicit "Set password" / "Disable auth" button. JSON config Export downloads /api/settings JSON. Factory reset with type-the-hostname confirmation posts to new /api/factory_reset (erases NVS + reboots). src/atoms.tsx adds: Icon (24 SVG paths from design), Sparkline (gradient fill + line), NumberAndSlider, DualHandleRange (touch-friendly with pointermove), TopologyDiagram (4 SVGs), LineChart (raw vs smoothed), hsv2rgb / rgb2hex / hex2rgb helpers, fmtUptime, useRing. Firmware: - /api/factory_reset endpoint: nvs_flash_erase() + esp_restart() in deferred task so the JSON response can flush first. - /api/system GET/POST already shipped. Bug fixes: - Set Password now shows real error when < 8 chars (was silent). - OTA upload uses postBinary() with progress callback (was generic POST). Build: 1.16 MB binary, 19%% free in app slot. UI bundle 81.8 KB raw / 24.1 KB gzipped. Both C3s flashed. Verified live on hardware at 192.168.0.216: - /api/ping, /api/version, /api/distance, /api/system, /api/wifi, /api/mesh, /api/topology, /api/board/profiles, /api/radar/kinds, /api/radar/diag, /api/settings β€” all returning expected payloads. - Radar diag: driver=ld2410, 867 frames parsed in 30 s, last frame 31 ms ago. - Distance live: 39 cm. --- firmware/components/webui/ui.html | 2 +- firmware/components/webui/ui.html.gz | Bin 20726 -> 24105 bytes firmware/components/webui/webui.c | 20 + frontend/src/atoms.tsx | 125 +++ frontend/src/screens.tsx | 1267 +++++++++++++++----------- 5 files changed, 894 insertions(+), 520 deletions(-) diff --git a/firmware/components/webui/ui.html b/firmware/components/webui/ui.html index b96fd5b..83c0400 100644 --- a/firmware/components/webui/ui.html +++ b/firmware/components/webui/ui.html @@ -5,7 +5,7 @@ AmbiSense v6 - + diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz index 30e3d67c2938788297bf37f23d183860ddd3ba36..64e5e3b277d8b175cf0d5cb157eea965e3b3080c 100644 GIT binary patch literal 24105 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

=Xd4g zrIj9oKj`49(kfdzze?zXUw50{9a`{On?ogrT%$)z;@+lm9u`WioFipRL{8&KfUeuy z8vDtXc}`ZtXR=z}lC9t-S&4Vxs>DO7%7Wq7Fsb~jF!|+vt)(5ErON>d9`)h8zJ^C@ zr9WrI`EtLN%c^2KI;La!adeF1*3w`E#BM|faiQaC4r2gK@9vYgH?jahW7FYW9PfpP z-v0f&S3l4>U%21lY!<}Qc0gO>SL_|HKX*?*`)RR}<@PrrzI3j5mFN9*BldNqho}!#jhr8|4P$VtnRPvw9W~@*KwUhamK&y zD*lQF#SNG7I}pENb*wyeu^K8pEgDOlw_>02Eu75^r?OPx}4wad5J9@90DC8 zOOR01M&}1C8O8H=nhPdRfy!vNT80-OdylrxievKdP`knz8PCMtE4k;F>j5}Qq+lGl z8BTnkq>T|D`*T2@u4#&MSX5|Ob@G?5F8Cc{hW}O~gpVllE$N2H_`;QJDZYP~!c&sa( zV`W&FD2^s%hG{d?b_%aq)%g0pWPHtSu4t{)jI4A(O7ZHmurRZxEG}Nu#z+(Cs;TXY zB)jXJofa>61Y=R>8N~?aHq^^t9B6kz-9%_L#hb7E8SOo{S&U~I7iTb|-#L#6KC_lD zh|2n_jB9Usb{XS^4P;yU6f|B0zzv?g{UA(gkKY*sKJhPtc%FQXy@#8}KOTV$b#%l~ z9s)V44dNB~oPmMmgp))kT6MI!L(Kr{G+22?!3{7 z%pa%urDA<}Pth~@7N|#_iYQviNY3$Jd7D{LKMAC`4uucmYg7(PY(6trNT**ADzVNT zbu7ngn*=WwFKb{w3|6A9c^p`Rm^1(Fq(^_g8`;T7n5GmcS2 zIEAJ}pgEbhajOV%U^2W5+Lq#R*8o2{Dg^ziNL$aWRpwu1@8x)hSX*UVPEoS00H@2(o9%4#Lyl5A1sl+Q| zf$ON&)|uEL_bLtv1ud{blCl6UQ2!p{_inb{o zheyFM1h*{C4|mUJ7B@!GQT2h^BJvyb;cc9^2Xq)X7PSg3mhC%{w?tn#QDQubk{&?Z z7a>xQ8vAa)-OXPP`+NDz!G6EhZ7yk3zt-BLPI=tqsJ*CPvdeT$O};2wX`=oL8;ow7 zDVM&-zWmbYc$A;pD7K#GTL7yXVN;wSezGFEGCzh;ujJdC_5;4CIZf`NmmhyGPrK2r zR_)qzWTBeRqyv2gnPSb6Py)NYdK6(Pj8EMYk@6vMwZ8f3-QyPC)i&cfZHfAKae7l8 z5dzC*-KMMRhKR8xMyPL!_y^Emv|B>3{plThk7|YOdNXJVG^O4DYgm=Wgz8(YN_*eq zAPn<+Hf7(N-=C~LwGt$a>ltYeX^IyID0_m>Xh+*54sjWp@8oC03OV|42caLGlZzdv z$wr{Q!s^5(UtX~@tm*UJXQHi=X1`!x9Y{XYHIfgBv{QzlfblE2tef86l3~QvoZNI} z5?9d%(HY1;W2sJtq>iFu8%%jT&#W`r47!?Q(^M_(#MAhbXl1p8QAX*TQb$S^X}f*u zIQCa_rT;N!IlPn;wt4L69jBcYRijF7$htSe*OdpoR9e6DAlX!3S#f1$<@-)|kN>UT z_{D7;HBNTV@Va^XYn-}PIml0_CtoD!u3mG+Dif3?I*I4C!cq zl&pqAIUPeHiZ6NOGDxFz$(RaTh6csE-Witfp`NEu9rbDgiUd+4Awy-Fj5{Q0cH?MZ zEIL|oeT>91X}Y6R_~4UNTJw$C{f9?kff_J09J`wQkGkZ#1sAQ#BcV zt*u;HIgD2=@a|n!)!!`ivan&lZ7V~t}xC6F>H19WO_ZE*)k!$ci*21UvV`7sb@9%`0+d)pE**beAF>8Bu@ zSf(=o8QAVyGm9dG#dn*{u?u!Lv&?s>Gy4YSGbl(c50u>`sPu!pC7sOP>OMb)=UK_C z+(&_b76B^=hP8+8eW9e{wEnik{_>j*H}zTbrRxuWgUFM+u59@2cM`7KC0HAGb-3C) z+seJOn@419av57$yOq;IRdcDK%mt>#aD3?fy!)iA?y_vlmo%FdCm0y@Un_ z^B~OV6F!YjF@~&Y`hN1B9=!ctkJ-Loymk#k*8{IJE%3S|DQ4>>#dW667MVKVt8i&G ze5nzD`0*@>XLP3nZhxS=%by9iw1sj6cQ7F1Bkj-RiKF7~K@G!_{@D)z@-7AFFBLAC zCDe(heP@V?mVM8adq0;}p|a$Qv!&TAq&cj7LwWt~!#mvTOTvdPN7ZUb#YD|#xH(7p z)7<;7@YM@82J(sXireFe01Lhb2uaYVYdu8>#@ASf<`cjz91Bm3Bo^Yd;U`n+ofnn> zm|28&N@qMa5LXh_y3!c|bGu_{z7Z@KZwj4dUBSJVFeZv4+$5}@$97gIl!U5%hme)9 ze)7n3x3?z`^$o78Y2yH6g;vT{@RjZs%Wn~tcFAvVtfuQen3OIB{_gXg=LoFvzuoVS z`(yJ*LMsfGFj=)TcuMAXiVDOC40o;1EjK;ME{j!22VY?f1qb@HdKIpqqa)n4QreOs zv>qjbS`;#sUrdU-dF5s@%L-Uh1XB4Pe*4p#*Dted_MZQF&eN>tJ7~WHaqfo?A2Hwt z&y?~VkYiA4;nH3*$jG0NBZo>pjyJj!0`O3iKWFtb>e82#AkTS z4T_o3_I58Wf9UmHC$iD*+24HE&yA-KE!Lk5CnlmCwEgw6J!>L z;HCT{%(^XE0OSGgKz_!XkfT>kUZkli(_L16Ii_J` z6Yv)t3+17~=-*B0+X0qF(8c`=sLDv57GntChyj%5SQYZM7oniFy3aM|bRdQ|OBP0P z(znzWX7ZP0^#$82+a9yB?ePcD#LK2fS*~PRq23g`i2eWr%d?U`@SAQ8zt^Sgb=RGP zJi$*lzw`VJYEU)R;B5l;tg80<^wYF@f%==rPcaYIPXE(C=l7=b%Dw5E-35PgXSnYl z;I9Y8a?S1Q)uSUq<&KNbD@R7Osz*lD2AG&TR#-WoBp#3Hd=gX$;G0y%O+rfXc()t< z{&wraZS&WE|GyiDXmm^HNJGH->{>+yl8`a6?_=BE+>A}~Nz$i-OUz|IQ&jddec*U^ zdXY&peg{>z(uV#JpW;xtt2I%YW{B-ej%rgjVX+^9#qw!D{FQNL(Q#_V7TqrkWPGo27B zSPW&!5z%IWaA7T*l2$X47c2c&&?TJJyw7w)48_j)VpV>*a=UcXd}$jGNHBEb37VtD z@p?h3qAVQaHJP)?v4diBnhMkOFpkdy4zNp6GftiVZoOqF_820T>_i)Uv@(C%6 zeYXhSLqUhN>=Sk)v>;t9{W-jp|NHz1Qa;Lkz^|V_OY3EiJGTVRozs$u+ zFZa{^%q``S<18cp8Tqq6!1Mv+9YDBOP~0M#p7{gQhRGlx+AR0s(P=*U!S)ONy!!l} zH)&2ym-waTa+d=p{t`Z>H%Pe4vv&K!Ri>6iUt@%m^7ssA^qMQF+KmiGv4{gLCEl zlQl=8W>ZAl2O8eXd=ywEGuO(KqCBnxybyV@0G>J%#-dzWkX{V6XD6r})?arzk~;9e zoO6-y;+a{M6Z%&j$A6Q{5Ml?^?d_)BLd#5h<0}FRYoARvt<2kM!eo#D6nN-)SGy%G zbCP8ty2EJVi>vJ>p161;EH2(in2I=v zm{{2(hklFrgArD&jx(Dr*j(1~m@~R<<27f3ws+sc6OiogbIufv?!JO`E~swzJrE5d ztlgJ<0o_{Ofh!m?NZu83AoBJ2)nLTS9)e{DLgGhjRUOLEm?GuVEj#7#fICW42%j!X zJqECUAQ669o)SeaxZZ8kxY+-CDr?2F9_h-3+UAo-o=Exm2^U+Xkp)JA_XUFKC3p?# zkEp)nLx4%Z^Wy2;T%ZS_iUUFFqHd_i9^kTXalo*)D~+>revXFqWEiL;+L z`!nzT$h{w}pE-%0)(^bUXF+O^#OulRtC3|UXtlR(FVC^PUCLjfc(~K-^mKb0|60_h z4UKv~aCkq5w!PyMXdeH*hHBW4>Qf<&&?bKS2Kx;15(DDp-7I?JII%)J6)m#ObAAmF z4*Q809fv36hNpL5yr=Mop74-FSTWSSRNB-YwV?m0qoD0Aec$VIV76gclw>Wt3(Uh* zJ%qN>2zZ2Qyi)q6eQBn-^L@J+Fh#d zCq#?>C!7XeF2z7l_%lr4a}c{=s_?n@a5o($!7O|9cLx0LAuUk6>e4gmF`aPGW{hDH zr)iu7K&~Ej5ruBZkMDMkZ`>#Dv!>e?!)@+055#*5-#g92=2P*$hwo3CFIsK! z-iG(qUh8lV%hO#xm3KNJTrWhl6Pta}deMGypi;2mrw31;J=<3)*!1D!qt?-Lm4c0T zp6>7M=PB6y-m~^z8@q@5FM#u6h{HE8UOefvANTZ48I*ePc<-4^o#7sQOxt^Q*ylGPXkoke*EnDA%X$eaHMw$L>qeTv=8tz?mEVS zJ$ZWY;sv}XjjL4XG{<9q8id#KOO8VFqjb~&dg+(-;az>8AAu6p zz}d!*G#uCM@M*7c$p_~_CM!~RUUvN`3;Zy^qpW+>I&c&iB!0ppN~X}vYkn3Cf-uOg zdvw9x5TkY)GT=YyVqDF_K?J*5(QW)@FPz-)cvCsWe?FV>WC&fT_T6c=q37CoQLxm5 zu7&1TfnUpB2!k2*r>i2nMLzYWO$G%9$xttS0%f$84AxqZMUROP`mTpQPD+Ld6YWr70l8ZMiQx z79QkS_)XJy$L^tyvU^46P{z5@3*spg%geP{#`#WB0f6nMvKp3f2$U@Sl4OF7R0{47 z;3Kt{V;OekAujg=FpYjkdxC7pRm zr@ZR)15oNHE|Ga+dGdqel0}WAQ%U3^=C5!XpOuZ>ITFhwl_wSfWdz;mCSOsAuqYuI zM_6j=U&%en&6expRL%S|PA;6)lZD%~t5I~s+kqx$mz+nvvJZuX6Pj9VvN&-D|7Z;w zlct7TBpX$6HRp26NZaC5y)Gx64{mBGa}{kga{x5B_%~BN3Vg#V;9?x?16=GIqKoyU z5Qtzw^m6Jm#wej)5TVf*i42G_ITfOd!z)xQi7!_`)h7(f*96YszP2Q&MvaHiVCw1W zFCe;(_!X?Nf&^aGl0d;!)w*3xaQC#uic(r-WfDr25D;;INV;)&Si-mR^fzJUFBT3u z_Es%E$ua9kTBa9G0k737EO%VqNut&xetDEQQ?9I>B(VH*TYYX45GV)j&AGXYthFRr zyPx!;i7RRiEu2ws0Z5zNC^AyVkO-QP6+}$29yF%5%+rJhqd*wYMNy4G7!S{>3ed8a zCo?`T5kD8#2_D=4>X<*`hi+?MltrCvxiqG(DnA|%I&HtjMS0NSmWz{Tc2#NA_EhP3 zJYFs>gmnPVAd7A)qh8124yzoly_s|%xW`(&Un$IM7*Ij6H4IUY2M{Sa+8K^}nD#Cw zFgQXu4kSYmbO6R0uG!Al=2BNBB)j5tsha}B=?O{hKU*+rnM?VcZ*tDpQ4(_$V+cAB zH$I}gDqL*2LxsGR%ilGERKbBt)yYwc(-yk}BE%6-Q>%$b--@hfXj0bO)lN!To=NSa zR(uUK_iCXj7j5C8uZaJM*vQN~hBsooSHB zr(q>>Vn++9yeWUHC5mN{V_zdflKu$w$q_1%lA=IgsgX6;=`9=ao5Q+Jx=X@GJkQ9u zuq2l*ExXFXl%wg6><1v@D`=s_vk|H?(SY*UOJb1PMnw9^!k9fWa_~n!M%KIJ(c(Von|Nha#nV(H?du-@^kDdG;cY(y`-248! z)}AoAsHBFf*6HL)I5$Io=6z3NC?(?=n|b=k`~Jd6mnr2}u@dr@-JmWp73H3;M#|H_~wg%B! z6jMs~GN`p~Vy)CcR_U`@->isHz=p;P^xn+wBd+ah;u;F> zM=RvsFOk>ovZgdw+F~U+2DP!Qv#eK8YKExml~v)8>NlKl5ZJ;GgGA4w;;iv!%r;6T==^NUTYM3rB{jLe#H=7n%xh06Jb;hb*-63lt= z1@Itubk&-m8&n9v^W{~RldVPv-CAFQkQK#QH^zyRRrZ%{Z>u!xmTqxydwv9LUyKGc zyT*KGIIT`UAkD5u+N_iy;q~8~^XqYfzFtFi z;>P_hS0UV$z-0TVcw}*&wYeJZ3%!-u9QQ#&1$x;H1uY3>6VtMOikcJX@iVEHMD;XO2OKcd#_Ajc7jA z@!+Eul}M3~nly>?#?S33`$#6EJ8Df-`OS`&IaMYFz_pP+HTd5)n1gb{Gb;F2XYZ zNPxwmEVJ=mZNZX*QJcYE!TO+vBqI_poHqm!?1Zp&!s^!cj-$Q1FbKC%gsU?YAGyL% zjK?(4)}+BCe*FD}kZjKwBO$?SC%~RkQw+GXoj2D0r5m`})@m^5SQOBt6_wdr_Pv#dXgGyau`tk72npa1DrXHu*iprzU*%gz z#I|G@1+pylxRCr1ig?Q3c&#tl-(7h%pmJXg=@gK-%LohtquE#X)R zbA&9Xbpf09!=ekrAQ|Gu+ROj}5C5^HC1{iqF`~^T)YgD(96*~)BNeLsF_4^f5U64; zVir{{vCG)+(DV3!yT(zc7+5yJ4VXYSCVpjrM9a~xR}bx);Q`3Xqk(s$RSOD8T^c*Q z)6!ke-IgHLb0e7dYjZ;?!m$chCq%>% zF1H8yvH3j~?#AyT~l^K%s0JKKi@=9~}NmJsv;-0F>0z2><{9 literal 20726 zcmV(hK={8OiwFqv82V`d19fRGXmo9C0M)%+bK^+1AohNL1%$3v1EnMcQlz8;3&pL^ zshX*;TU9+h_bOx+NG3&;K!5{)`e=$9cV8Ud2tRFXgx3y7*e~8Ezx%~uN9<3TU*MnM zb53Rg2@s@I-E-IB=_wINBr;E)Jl}aT@pxyPjPjc~H>UY4dh!pC@yCdQ_}n*nY#QSr z4_f(@&$w@ngY?3D@(+gbc*=t@Uclvy=YcVr25H7~-(2LA*0E{q>ks1qU%m|a)jUaa z(-jgNLDb4dLB#zwQ*X5NtC2o z`#-qP+!t-Pd?OF@h(CEc8-|~GoN?pw@bSJV6VJ0z8qRYA(czo3WW0ztG?yk>mZaf% z7(dyWEaFifCb4BNp_?o>xDWVS%%DGKl=2|wFC&h3mKlZdg=sq}kKTq^-VZ0%j>UI( zxsxsCIHXL!wq$p6sGbC4)3)v9BuOpViIGfC-5(e!{L!;7z~Q z%IQzGom<@Q$Nb88x5xto{lhThDIn6~{u8Z_82jKhfN74VVKhddP2cxp2hm~IfQg-8 zJm%x~$(U!h`S#8GKVe<&sNZ`1ko7H~Aa zO|G)rSvFuvca{J#T>G(Fxc^d48?9zXlrooL9X7t-HCi2_@Fa(7o zOtBHdQ_NO5^+TLbVxM`C!nl$0s&_CQGY}BZk&KYWidS!-GdW&+iQQ%X z5^37wY@R0b%nR8CzwuHAnDPR4c6Q;#iztE%^*i)j_{-JpNL&zgr3>Uj?=lI;23ESH zXTBc|_V!{AYlmK|4L=LdEvQgd+1IB^9zB@ zd>UpB4akRkydgn&o%?xN_e#_S9uhF*cG2+J*#Mrx55qsQ(KKIh61iSpc{z*Q^5n&jx&Lhhr|p*Ai6C& z^0TwCm`Bm&B@0DQEJex<@K5BQTR_UO-H)&aJ-^&*vo%adHhV8x24;w9kXBXR83{JvRG5aYK7z_dC*ajNf*|AhDn*}!0dZuf_4qk+(e#G8bDf9@{ z2z09EQ@dD$@A%>F?sI@{RpQ~f#Dl~)r@MobK)2-lo{gm=6

<+1)k$%b04m! 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

u;Ooycry~AU=O!1q3H`f>Qg77B0)L;_ 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 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)]; +} diff --git a/frontend/src/screens.tsx b/frontend/src/screens.tsx index bff1147..aa016b2 100644 --- a/frontend/src/screens.tsx +++ b/frontend/src/screens.tsx @@ -1,28 +1,14 @@ -/** All seven screens: Live, LEDs, Motion, Mesh, Hardware, Network, System. */ +/** 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, useToaster } from './components'; +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 } from './atoms'; +import { Icon, Sparkline, fmtUptime, NumberAndSlider, DualHandleRange, TopologyDiagram, LineChart, hsv2rgb, rgb2hex, hex2rgb } from './atoms'; import { getJSON, postJSON, postBinary } from './api'; -/* Static CSS-gradient thumbnails for the 11 mode cards. One animated - * canvas (the hero) is enough; per-card animations crushed phone GPUs. */ -const THUMB_CLASSES = [ - 'thumb-standard', - 'thumb-rainbow', - 'thumb-color-wave', - 'thumb-breathing', - 'thumb-solid', - 'thumb-comet', - 'thumb-pulse', - 'thumb-fire', - 'thumb-theater-chase', - 'thumb-dual-scan', - 'thumb-particles', -]; - interface Live { distance: number; direction: number; rssi: number; heap: number; uptime: number; peers: number; healthy: number; } -interface AppState { +export interface AppState { live: Live; settings: any; version: any; @@ -31,44 +17,46 @@ interface AppState { reload: () => void; } -function Section({ title, sub, right, children }: any) { +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} +
+
+

{title}

+ {sub &&
{sub}
}
- {children} - + {right} +
); } -/* ----------------------------------------------------------------- */ -/* A. Live dashboard */ -/* ----------------------------------------------------------------- */ +/* ================================================================= */ +/* 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; - - /* Client-side ring buffer of last 80 samples (~16 s @ 5 Hz). Faithful - * to the design β€” gives the sparkline its rolling shape. */ const histRef = useRef(Array(80).fill(0)); const [hist, setHist] = useState(histRef.current); - useEffect(() => { - histRef.current = [...histRef.current.slice(1), dist]; - setHist(histRef.current); - }, [dist]); + useEffect(() => { histRef.current = [...histRef.current.slice(1), dist]; setHist(histRef.current); }, [dist]); - /* System enable toggle (real /api/system) */ const [sysEn, setSysEn] = useState(true); - useEffect(() => { - getJSON('/api/system').then(r => setSysEn(!!r.enabled)).catch(() => {}); - }, []); + useEffect(() => { getJSON('/api/system').then(r => setSysEn(!!r.enabled)).catch(() => {}); }, []); const toggleSys = async () => { const next = !sysEn; setSysEn(next); @@ -78,46 +66,32 @@ export function ScreenLive({ live, version, settings, setToast }: AppState) { return ( <> -
-
-

Live

-
Real-time radar, mesh, and LED output
-
-
- WS connected Β· 5 Hz -
-
+ WS connected Β· 5 Hz}/> - {/* System enable hero β€” gradient when active */}
System {sysEn ? 'active' : 'paused'}
-
- {sysEn ? 'Radar, mesh, and LED output running' : 'All output muted, mesh idle'} -
+
{sysEn ? 'Radar, mesh, and LED output running' : 'All output muted, mesh idle'}
- {/* Left column */}
- {/* Distance meter β€” big gradient number + sparkline */}
Distance
- {dist} - cm + {dist}cm
- - {inWindow ? 'in window' : 'outside'} + {inWindow ? 'in window' : 'outside'} min {minD} max {maxD} @@ -137,33 +111,16 @@ export function ScreenLive({ live, version, settings, setToast }: AppState) {
- {/* Strip preview */}
-
Live LED preview
- {settings.led_count ?? 30} px Β· {LED_MODE_NAMES[settings.light_mode ?? 0]} -
- -
- px 0 - {Math.round(((dist - minD) / Math.max(1, maxD - minD)) * 100)}% along - px {(settings.led_count ?? 30) - 1} +
Live LED preview Β· {LED_MODE_NAMES[settings.light_mode ?? 0]}
+ distance {dist} cm
+
- {/* Stat tiles */}
-65 ? 'dBm Β· excellent' : (live.rssi||0) > -75 ? 'dBm Β· good' : 'dBm Β· weak'}/> @@ -172,38 +129,26 @@ export function ScreenLive({ live, version, settings, setToast }: AppState) {
- {/* Right column */}
- {/* Device card */}
-
- Device - -
-
+
Device
+
- - - - + + + + - + - +
- - {/* Mesh card */}
-
- Mesh - {live.peers > 0 ? 'peer' : 'standalone'} -
-
-
- {live.peers || 0} peer{(live.peers||0) === 1 ? '' : 's'} Β· {live.healthy||0} healthy -
+
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. @@ -217,19 +162,17 @@ export function ScreenLive({ live, version, settings, setToast }: AppState) { ); } -function StatTile({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: boolean }) { +function StatTile({ label, value, sub, accent }: any) { return (
{label}
-
- {value} -
+
{value}
{sub &&
{sub}
}
); } -function DevField({ k, v }: { k: string; v: string }) { +function DevField({ k, v }: any) { return (
{k}
@@ -238,525 +181,811 @@ function DevField({ k, v }: { k: string; v: string }) { ); } -/* ----------------------------------------------------------------- */ -/* B. LEDs */ -/* ----------------------------------------------------------------- */ +/* ================================================================= */ +/* B. LEDs */ +/* ================================================================= */ export function ScreenLeds({ settings, live, reload, setToast }: AppState) { const [s, setS] = useState(settings); - useEffect(() => setS(settings), [settings.light_mode, settings.r, settings.g, settings.b, settings.led_count, settings.brightness]); - + useEffect(() => setS(settings), [JSON.stringify(settings)]); const save = async (patch: any) => { const next = { ...s, ...patch }; setS(next); - try { - await postJSON('/api/settings', patch); - reload(); - } catch (e: any) { - setToast(e.message || 'Save failed', 'err'); - } + try { await postJSON('/api/settings', patch); reload(); } + catch (e: any) { setToast(e.message || 'Save failed', 'err'); } }; + 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 ( -
- - - - -
- -
- {LED_MODE_NAMES.map((name, i) => ( -
save({ light_mode: i })}> -
-
{name}
+ <> + + +
+
+ Live preview Β· {LED_MODE_NAMES[mode]} + distance {Math.round(live.distance||0)} cm +
+ +
+ +
+
+
Mode
+
+
+ {LED_MODE_NAMES.map((name, i) => ( + + ))}
- ))} +
- - -
-
- - - save({ r, g, b })} /> - - - save({ brightness: v })} /> - - - save({ led_count: v })} /> - - - - - save({ min_distance: v })} suffix=" cm" /> - - - save({ max_distance: v })} suffix=" cm" /> - - - save({ span: v })} /> - - - save({ center_shift: v })} /> - - - - - save({ effect_speed: v })} /> - - - save({ effect_intensity: v })} /> - - - save({ trail: v })} /> - -
- Direction trail - save({ dir_light: v ? 1 : 0 })} /> + +
+ {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 })}/> +
+
-
- Background dim - 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, reload, setToast }: AppState) { +/* ================================================================= */ +/* C. MOTION */ +/* ================================================================= */ +export function ScreenMotion({ settings, live, reload, setToast }: AppState) { const [s, setS] = useState(settings); - useEffect(() => setS(settings), [settings.motion_enabled]); + useEffect(() => setS(settings), [JSON.stringify(settings)]); const save = async (patch: any) => { setS({ ...s, ...patch }); try { await postJSON('/api/settings', patch); reload(); } catch (e: any) { setToast(e.message || 'Save failed', 'err'); } }; + + /* Build raw + smoothed history from the live distance feed. */ + 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.distance; + rawRef.current = [...rawRef.current.slice(1), r]; + const lastS = smoothRef.current[smoothRef.current.length - 1] || r; + const alpha = (s.pos_smooth_x1k ?? 200) / 1000; + const sm = lastS + (r - lastS) * alpha; + smoothRef.current = [...smoothRef.current.slice(1), sm]; + setRaw([...rawRef.current]); + setSmooth([...smoothRef.current]); + }, [live.distance]); + + const enabled = !!s.motion_enabled; + return ( -
- -
- Enable smoothing - save({ motion_enabled: v ? 1 : 0 })} /> + <> + + +
+
+
+
Motion smoothing
+
Filters jitter and predicts velocity
+
+ save({ motion_enabled: v ? 1 : 0 })}/>
-
- - save({ pos_smooth_x1k: v })} /> - - - save({ vel_smooth_x1k: v })} /> - - - save({ predict_x1k: v })} /> - - - save({ p_gain_x1k: v })} /> - - - save({ i_gain_x1k: v })} /> - - -
+ +
+
+ Raw vs smoothed Β· last 16 s + + raw + + + smoothed + + {Math.round(live.distance||0)} cm +
+ +
+
+ +
+
+
Filter
+
+ 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"/> +
+
+
+
PI gains
+
+ save({ p_gain_x1k: v })} min={0} max={1000} suffix="Γ— 1/1000"/> + save({ i_gain_x1k: v })} min={0} max={200} suffix="Γ— 1/1000"/> +
+ + Higher P responds faster but overshoots. Higher I corrects steady-state offset over time. +
+
+
+
+ ); } -/* ----------------------------------------------------------------- */ -/* D. Mesh */ -/* ----------------------------------------------------------------- */ -export function ScreenMesh({ live }: AppState) { +/* ================================================================= */ +/* 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 }); + const [pairing, setPairing] = useState(false); + const [pairTime, setPairTime] = useState(0); + + const refresh = () => Promise.all([ + getJSON('/api/topology').then(setTopology), + getJSON('/api/mesh').then(setMesh), + ]).catch(() => {}); + + useEffect(() => { refresh(); const id = setInterval(refresh, 4000); return () => clearInterval(id); }, []); + 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 startPair = async () => { + try { await postJSON('/api/mesh', { pair: true }); setPairing(true); setToast('Pairing window open Β· 30 s'); } + catch (e: any) { setToast(e.message || 'Pair failed', 'err'); } + }; + + 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 allDevices = [ + { mac: settings.mac || 'β€”', name: settings.device_name || 'this device', role: mesh.coordinator ? 'master' : 'slave', rssi: -42, lost: 0.0, online: true, self: true }, + ...(mesh.peers || []).map((p: any) => ({ ...p, role: 'slave', name: p.mac, lost: 0.0, online: p.healthy })), + ]; + return ( -
- -
- {['Straight','L-shape','U-shape','Custom'].map(t => ( -
-
-
{t}
+ <> + + {pairing ? <> Listening Β· {pairTime}s : <> Pair new device} + }/> + + {pairing && ( +
+
+
+ +
+
+
Pairing window open Β· {pairTime}s
+
Press the button on the new device until its status LED blinks twice
+
+ +
+
+ )} + +
+
Topology
+
+
+ {topologies.map(t => { + const active = topology.kind === t.id; + return ( + + ); + })} +
+
+
+ +
+
Devices
+
+ {allDevices.map((d: any) => ( +
+ +
+
+ {d.name} + {d.role} +
+
{d.mac}
+
+
+
{d.rssi} dBm
+
5 ? 'var(--err)' : 'var(--text-3)'};`}>{(d.lost || 0).toFixed(1)}% lost
+
))} + {allDevices.length <= 1 && ( +
+ No peers paired yet. Click "Pair new device" above. +
+ )}
- -
- - - -
- When PR #4 lands: each device broadcasts its smoothed reading at 5 Hz, every device fuses locally and renders only its own segment. Lowest-MAC device serves the web UI. Pair new devices with a 3-second BOOT-button hold during a pairing window. +
+ +
+
Sensor priority
+
+
+ {priorities.map(p => { + const active = mesh.fusion === p.id; + return ( + + ); + })} +
- -
+
+ ); } -/* ----------------------------------------------------------------- */ -/* E. Hardware */ -/* ----------------------------------------------------------------- */ -export function ScreenHardware({ setToast, settings, reload }: AppState) { +/* ================================================================= */ +/* 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(''); - /* Pin state initializes from saved settings (so the dropdowns show - * what's actually persisted, not just the profile defaults). */ 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, s]) => { - setProfiles(p); - setKinds(k); - setActiveBoard(p.active); - setActiveRadar(k.active); - /* Initialize pin state from saved values, falling back to the - * profile defaults for any pins the user hasn't customised yet. */ - const profile = p.profiles.find((x: any) => x.id === p.active) || p.profiles[0]; - setPins({ - led_pin: s.led_pin ?? profile.led_pin, - radar_rx: s.radar_rx ?? profile.radar_rx, - radar_tx: s.radar_tx ?? profile.radar_tx, - button_pin: s.button_pin ?? profile.button, - status_led_pin: s.status_led_pin ?? profile.status_led, - }); - }).catch((e) => setToast(e.message || 'Load failed', 'err')); + 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
Fetching board profiles…
; + if (!profiles || !kinds) return <>
Loading…
; const profile = profiles.profiles.find((p: any) => p.id === activeBoard) || profiles.profiles[0]; const unsafe: number[] = profile.unsafe || []; - /* When the user changes the board profile, snap pins to the new defaults - * so the dropdowns aren't pointing at GPIOs that don't exist on the new - * MCU (e.g. ESP32-C3 has GPIO ≀21; ESP32-S3 has 0..48). */ 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, - }); + 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 PIN_FIELDS: [keyof typeof pins, string, string][] = [ - ['led_pin', 'LED data pin', 'led_pin'], - ['radar_rx', 'Radar RX (MCU side)', 'radar_rx'], - ['radar_tx', 'Radar TX (MCU side)', 'radar_tx'], - ['button_pin', 'Button', 'button'], - ['status_led_pin', 'Status LED', 'status_led'], - ]; + const onRadarChange = (id: string) => { setActiveRadar(id); setNeedsReboot(true); }; + const onPinChange = (k: string, v: number) => { setPins({ ...pins, [k]: v }); setNeedsReboot(true); }; - const buildPayload = () => ({ - id: activeBoard, - radar_kind: activeRadar, - led_pin: pins.led_pin, - radar_rx: pins.radar_rx, - radar_tx: pins.radar_tx, - button_pin: pins.button_pin, - status_led_pin: pins.status_led_pin, - }); - - const saveOnly = async () => { + const save = async () => { setBusy(true); try { - await postJSON('/api/board', buildPayload()); - setToast('Saved. Reboot to apply pin/radar changes.'); - reload(); + 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 saveAndReboot = async () => { + 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', buildPayload()); + await postJSON('/api/board', { id: activeBoard, radar_kind: activeRadar, ...pins }); await postJSON('/api/reboot', {}); - setToast('Rebooting β€” refresh the page in 10 seconds.'); + setToast('Rebooting β€” refresh in 10 s'); } catch (e: any) { setToast(e.message || 'Save failed', 'err'); } - finally { setBusy(false); } + finally { setBusy(false); setNeedsReboot(false); } }; - /* Show whether the current pin differs from the profile default β€” small - * "(default)" / "(custom)" hint helps the user reason about state. */ - const pinHint = (cur: number, def: number) => - cur === def ? ' (default)' : ' (custom)'; + 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 ( -
-
- - - - - {profile.mcu}} /> - - validated : untested} /> - {activeRadar}} /> - - - - - - - {kinds.kinds.find((k: any) => k.id === activeRadar) && ( -
- {kinds.kinds.find((k: any) => k.id === activeRadar).note} + <> + Save & reboot : }/> + +
+
+
Board profile
+
+
+ {profiles.profiles.map((p: any) => { + const on = activeBoard === p.id; + return ( + + ); + })}
- )} -
- All five drivers ship in the firmware. Switching does NOT require a reflash β€” just save and reboot. The active driver is exclusive (one radar per board).
- - - - {PIN_FIELDS.map(([key, label, profKey]) => { - const cur = pins[key] ?? (profile as any)[profKey]; - const def = (profile as any)[profKey]; - return ( - - - - ); - })} +
-
- - +
+
Radar
+
+ {kinds.kinds.map((r: any) => { + const on = activeRadar === r.id; + return ( + + ); + })}
+
+
-
- {unsafe.length} unsafe GPIO{unsafe.length > 1 ? 's' : ''} hidden ({unsafe.join(', ')}). These are strapping/USB-JTAG/flash pins on this MCU and will brick boot if used as I/O. +
+
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, version }: AppState) { +/* ================================================================= */ +/* F. NETWORK */ +/* ================================================================= */ +export function ScreenNetwork({ setToast }: AppState) { const [wifi, setWifi] = useState(null); const [scan, setScan] = useState(null); - const [ssid, setSsid] = useState(''); - const [pass, setPass] = useState(''); + const [pwd, setPwd] = useState(''); const [host, setHost] = useState(''); const [apMode, setApMode] = useState('auto'); - const [apPass, setApPass] = useState(''); - - const refresh = () => getJSON('/api/wifi').then(w => { - setWifi(w); - setApMode(w.ap_mode); - setHost(w.hostname || ''); - }); - const doScan = () => { - setScan(null); - getJSON('/api/wifi/scan').then(r => setScan(r.networks)).catch(e => { setScan([]); setToast(e.message, 'err'); }); + 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); } }; - /* Lazy: fetch wifi state on mount, but DON'T auto-scan (a 1-second wifi - * scan stalls the page on slow devices). User clicks "Scan" when ready. */ useEffect(() => { refresh(); }, []); - const saveWifi = async () => { - if (!ssid) { setToast('Pick a network', 'err'); return; } - try { - await postJSON('/api/wifi', { ssid, pass, hostname: host || undefined }); - setToast('Saved β€” reconnecting'); - setTimeout(refresh, 4000); - } catch (e: any) { setToast(e.message, 'err'); } - }; - const saveApMode = async (mode: string) => { - setApMode(mode); - try { await postJSON('/api/wifi', { ap_mode: mode }); setToast(`AP mode β†’ ${mode}`); refresh(); } - catch (e: any) { setToast(e.message, 'err'); } - }; - const saveApPass = async () => { - try { await postJSON('/api/wifi', { ap_password: apPass }); setToast('AP password updated'); } - catch (e: any) { setToast(e.message, 'err'); } + 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 forgetSta = async () => { - if (!confirm('Forget Wi-Fi credentials? Device will return to AP-only mode.')) return; - try { await postJSON('/api/wifi', { forget_sta: true }); setToast('STA cleared'); refresh(); } + 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…
; + if (!wifi) return <>
Loading…
; return ( -
-
- -
- - - STA: {wifi.sta_connected ? `connected to ${wifi.ssid}` : (wifi.sta_configured ? `joining ${wifi.ssid}…` : 'not configured')} - + <> + + + {/* connected card */} +
+
+
+
-
- - AP: {wifi.ap_active ? `up β€” AmbiSense-XXXX (chan 6)` : 'down'} +
+
+ {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.ip || 'β€”'}} /> - {wifi.hostname}} /> - - - - - - - - - setApPass((e.target as HTMLInputElement).value)} placeholder="leave blank for open AP" /> - - - - - Rescan}> - - - - - setPass((e.target as HTMLInputElement).value)} /> - - - setHost((e.target as HTMLInputElement).value)} placeholder="ambisense-living" /> - - - {wifi.sta_configured && ( - - )} - + {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 */ -/* ----------------------------------------------------------------- */ +/* ================================================================= */ +/* G. SYSTEM */ +/* ================================================================= */ export function ScreenSystem({ version, setToast }: AppState) { - const [pw, setPw] = useState(''); + 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 fileInput = (() => { let r: HTMLInputElement | null = null; return { set: (e: any) => r = e, get: () => r }; })(); + const [confirmText, setConfirmText] = useState(''); + const fileRef = useRef(null); + + useEffect(() => setAuthReq(!!version.auth_enabled), [version.auth_enabled]); const setPassword = async () => { - if (pw && pw.length < 8) { setToast('Min 8 chars', 'err'); return; } - try { await postJSON('/api/auth/password', { password: pw }); setToast(pw ? 'Password set' : 'Password cleared'); setPw(''); } - catch (e: any) { setToast(e.message, 'err'); } + 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 doReboot = async () => { - if (!confirm('Reboot device? You will lose connection for ~10 seconds.')) return; - try { await postJSON('/api/reboot', {}); setToast('Rebooting β€” refresh the page in 10 s.'); } - catch (e: any) { setToast(e.message || 'Reboot 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 doOta = async () => { - const inp = fileInput.get(); - if (!inp || !inp.files || inp.files.length === 0) { setToast('Pick a .bin file', 'err'); return; } - const f = inp.files[0]; + const flash = async () => { + if (!otaFile) return; setOtaProg(0); try { - await postBinary('/api/ota', f, p => setOtaProg(p)); - setToast('Flashed. Device rebooting in 1 s; refresh in 30 s.'); - } catch (e: any) { - setOtaProg(-1); - setToast(e.message || 'OTA failed', 'err'); - } + 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 ( -
-
- - {!version.auth_enabled && ( -
- Authentication is OFF. Anyone on this network can change settings. Set a password. + <> + + +
+
+
Firmware{version.version || 'β€”'}
+
+
+ +
+
{version.version} Β· {version.target}
+
ESP-IDF {version.idf_version} Β· built {version.build_date}
+
+
- )} - - setPw((e.target as HTMLInputElement).value)} /> - - - - - - - - - {otaProg >= 0 && ( -
- )} - -
- Bootloader rollback is armed; if a bad image hangs, the previous firmware boots automatically. + + + + {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.
- - - - {version.version}} /> - {version.idf_version}} /> - - - {version.mac}} /> - - - - +
-
+ +
+
+
JSON config
+
+ + +
+
+ +
+
Factory reset
+
+
+ Type {version.hostname} to confirm +
+
+ setConfirmText((e.target as HTMLInputElement).value)}/> + +
+
+
+
+ ); } From 40661ca8c16fc7e2f75cc7b5080817cb85d52a11 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 22:13:19 +0530 Subject: [PATCH 12/17] fix: WebSocket /api/live silently dropped + atoms.tsx missing useRef/useEffect imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding bugs from the previous comprehensive rewrite that together produced "distance shows 0 + console useRef error": 1. Server-side route table overflow. esp_http_server cfg.max_uri_handlers was 32. The previous PRs added /api/mesh GET/POST, /api/topology GET/POST, /api/factory_reset, /api/ping, /api/system GET/POST, /api/radar/diag — bringing total route count to 33. The WS route is registered last, so it silently failed registration. No client could subscribe to live data, so the Live screen distance stayed at 0 forever. Bumped to 48. Verified post-flash: WS handshake returns 101 Switching Protocols from python ws client; chunked HTML still serves intact at 81 KB. 2. Client-side missing imports. atoms.tsx uses useRef (in DualHandleRange) and useEffect (in DualHandleRange + LineChart) but only imported useState from preact/hooks. Errors only fired on LEDs/Motion tabs where those components render. Added the missing imports. Build: same 1.16 MB, both C3s flashed, all 11 endpoints verified live. --- firmware/components/webui/ui.html | 2 +- firmware/components/webui/ui.html.gz | Bin 24105 -> 24092 bytes firmware/components/webui/webui.c | 2 +- frontend/src/atoms.tsx | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/firmware/components/webui/ui.html b/firmware/components/webui/ui.html index 83c0400..761910b 100644 --- a/firmware/components/webui/ui.html +++ b/firmware/components/webui/ui.html @@ -5,7 +5,7 @@ AmbiSense v6 - + diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz index 64e5e3b277d8b175cf0d5cb157eea965e3b3080c..ff60201e80a163f4a0a839f32232b0473b0f9599 100644 GIT binary patch delta 5642 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 delta 5604 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 Date: Tue, 5 May 2026 22:22:09 +0530 Subject: [PATCH 13/17] feat: full app shell (animated logo + sticky header) + 20Hz WS + motion v2 (median + adaptive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported visible jitter and missing UI elements. Three-pronged fix. Frontend — full app shell port from design-source/app.jsx: - LogoMark: animated triangle "A" with 3 concentric pulse rings emanating outward, radial proximity-glow that breathes (scale + opacity), corner-tick chip details. Pulse strength scales with live distance (closer target = brighter glow). Uses lm-grad (amber→orange→pink) and lm-core radial gradients verbatim from design. - Wordmark: "Ambi" in slate-gradient + "Sense" in accent-gradient, with "v6.x · ESP32" caps subtitle. - Sticky header (.app-header) with backdrop-filter blur — page name + hostname.local breadcrumb + live distance chip + RSSI chip + sun/moon theme toggle. - Sidebar with logo block + nav + bottom IP/board chip (10.0.0.x · ESP32-C3 · LD2410C style). - styles.css: keyframes logo-pulse, logo-breath, pulse-acc; .app-header, .brand-block, .sidebar-foot, .btn-icon classes. Firmware — kill the visible 200ms stair-step jitter: - ws_broadcast_task: 200 ms (5 Hz) → 50 ms (20 Hz). Bandwidth: ~2.5 KB/s, trivial. Browser sparkline now updates 4× more often, no flat dwells. - main.c telemetry_pump_task: same bump 5 Hz → 20 Hz, also publishes raw_cm now (median-filtered but un-smoothed) so the Motion screen graph shows what the firmware *actually* feeds the LED engine, removing 200 ms client-side simulation lag. - webui_live_t and the WS JSON gain a 'raw' field. Firmware — motion algorithm v2: - Median-of-5 filter on raw radar samples before the smoother. Single- sample LD2410 spurious readings (every few minutes) get out-voted; rejection is total. Insertion-sort over 5 ints — cheap. - Adaptive smoothing: alpha scales with motion magnitude. Stationary (|delta| < 30 cm/sample) uses configured pos_smooth; fast-moving (>= 30 cm/sample) ramps alpha up to 4× the configured value (capped at 0.9). Result: calm reading when still, snappy when moving — best of both worlds. - target_t exposes raw_cm alongside distance_cm. Frontend — Live sparkline + Motion graph fixes: - ScreenLive: useEffect deps changed from [dist] to [live] so the sparkline advances on every WS frame at 20 Hz, even when the integer cm value hasn't changed (was freezing during stationary periods). - ScreenMotion: raw and smoothed lines come straight from firmware WS payload, no client-side alpha simulation. Graph now reflects the real on-device smoother instead of a 200 ms-lagged copy. Build: 1.17 MB binary, 19%% free in 1.4 MB app slot. UI gzipped 25.4 KB. Both C3s flashed. --- firmware/components/motion/include/motion.h | 4 + firmware/components/motion/motion.c | 51 +++++++- firmware/components/webui/include/webui.h | 7 +- firmware/components/webui/ui.html | 4 +- firmware/components/webui/ui.html.gz | Bin 24092 -> 25454 bytes firmware/components/webui/webui.c | 8 +- firmware/main/main.c | 14 +- frontend/src/atoms.tsx | 57 ++++++++ frontend/src/main.tsx | 136 +++++++++++++------- frontend/src/screens.tsx | 21 ++- frontend/src/styles.css | 32 +++++ 11 files changed, 265 insertions(+), 69 deletions(-) diff --git a/firmware/components/motion/include/motion.h b/firmware/components/motion/include/motion.h index 44b6771..411a578 100644 --- a/firmware/components/motion/include/motion.h +++ b/firmware/components/motion/include/motion.h @@ -22,6 +22,10 @@ extern "C" { 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; diff --git a/firmware/components/motion/motion.c b/firmware/components/motion/motion.c index 7700788..dc609ad 100644 --- a/firmware/components/motion/motion.c +++ b/firmware/components/motion/motion.c @@ -1,6 +1,7 @@ #include "motion.h" #include +#include #include "esp_log.h" #include "esp_timer.h" @@ -14,6 +15,8 @@ static const char *TAG = "motion"; /* Defaults match v5 (config.h:69-76). NVS values stored as x1000 fixed-point. */ +#define MEDIAN_W 5 /* window size for the spike-rejection median filter */ + static struct { bool enabled; float pos_smooth; @@ -28,6 +31,14 @@ static struct { float err_integral; uint64_t last_us; + /* Spike-rejection median filter: keeps the last MEDIAN_W raw samples + * and uses the middle one (after sorting) as the "true" current value. + * Single-sample radar glitches get out-voted; rejection is total, + * unlike a low-pass which would let the spike bleed through. */ + int16_t med_buf[MEDIAN_W]; + uint8_t med_idx; + uint8_t med_filled; + target_t latest; SemaphoreHandle_t lock; int min_cm, max_cm; @@ -37,6 +48,18 @@ static float clamp(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); } +/* In-place median of 5 ints. Insertion-sort is fastest at this size. */ +static int16_t median5(int16_t *src) { + int16_t a[MEDIAN_W]; + for (int i = 0; i < MEDIAN_W; ++i) a[i] = src[i]; + for (int i = 1; i < MEDIAN_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[MEDIAN_W / 2]; +} + static void motion_task(void *arg) { (void)arg; radar_frame_t f; @@ -55,11 +78,19 @@ static void motion_task(void *arg) { if (raw < s_m.min_cm) raw = s_m.min_cm; if (raw > s_m.max_cm) raw = s_m.max_cm; + /* Spike-rejection: median of last 5 raw samples. Until the buffer + * is filled, just pass-through (so first reading isn't blocked). */ + s_m.med_buf[s_m.med_idx] = (int16_t)raw; + s_m.med_idx = (s_m.med_idx + 1) % MEDIAN_W; + if (s_m.med_filled < MEDIAN_W) s_m.med_filled++; + int filtered_raw = (s_m.med_filled == MEDIAN_W) ? median5(s_m.med_buf) : raw; + target_t t = { .present = f.present, .energy = f.energy, .direction = f.direction, .ts_us = f.ts_us }; + t.raw_cm = (int16_t)filtered_raw; if (!s_m.enabled) { - t.distance_cm = (int16_t)raw; + t.distance_cm = (int16_t)filtered_raw; } else { uint64_t now = f.ts_us; float dt = s_m.last_us ? (float)(now - s_m.last_us) / 1e6f : 0.02f; @@ -67,12 +98,22 @@ static void motion_task(void *arg) { dt = clamp(dt, 0.001f, 1.0f); if (s_m.smoothed <= 0) { - s_m.smoothed = (float)raw; - s_m.predicted = (float)raw; + s_m.smoothed = (float)filtered_raw; + s_m.predicted = (float)filtered_raw; } - s_m.smoothed = (1.f - s_m.pos_smooth) * s_m.smoothed + - s_m.pos_smooth * (float)raw; + /* Adaptive alpha: when the target is moving fast, ease the + * filter (snappier response); when nearly stationary, clamp + * harder (calmer reading). Magnitude is the |delta| from the + * last smoothed estimate, scaled by a 30 cm "fast motion" + * threshold. Output is in [pos_smooth .. min(1, pos_smooth*4)]. */ + float delta = fabsf((float)filtered_raw - s_m.smoothed); + float scale = clamp(delta / 30.0f, 0.0f, 1.0f); /* 0=still, 1=fast */ + float alpha_max = clamp(s_m.pos_smooth * 4.0f, 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 * (float)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 + diff --git a/firmware/components/webui/include/webui.h b/firmware/components/webui/include/webui.h index 076fc3b..729bce3 100644 --- a/firmware/components/webui/include/webui.h +++ b/firmware/components/webui/include/webui.h @@ -27,8 +27,11 @@ esp_err_t webui_init(void); * Updates are coalesced and emitted to all connected /api/live WS * clients at ~5 Hz. */ typedef struct { - int16_t distance_cm; - int8_t direction; /* -1, 0, +1 */ + 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; diff --git a/firmware/components/webui/ui.html b/firmware/components/webui/ui.html index 761910b..da2dc08 100644 --- a/firmware/components/webui/ui.html +++ b/firmware/components/webui/ui.html @@ -5,8 +5,8 @@ AmbiSense v6 - - + +
diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz index ff60201e80a163f4a0a839f32232b0473b0f9599..c33ced210de6b65d3d8b77e3c84592fc290c03e4 100644 GIT binary patch literal 25454 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 z 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 pPnc7obLhL_pBlPfeAMD;w}yAF%yr2pzl38nI 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 diff --git a/firmware/components/webui/webui.c b/firmware/components/webui/webui.c index 6ee02f7..5a6fcc6 100644 --- a/firmware/components/webui/webui.c +++ b/firmware/components/webui/webui.c @@ -961,7 +961,7 @@ static esp_err_t handle_ws(httpd_req_t *req) { static void ws_broadcast_task(void *arg) { (void)arg; while (1) { - vTaskDelay(pdMS_TO_TICKS(200)); /* 5 Hz */ + vTaskDelay(pdMS_TO_TICKS(50)); /* 20 Hz β€” smooth UI updates */ webui_live_t snap; xSemaphoreTake(s_web.lock, portMAX_DELAY); @@ -971,10 +971,10 @@ static void ws_broadcast_task(void *arg) { snap.rssi = netmgr_get_rssi(); xSemaphoreGive(s_web.lock); - char json[160]; + char json[200]; int n = snprintf(json, sizeof(json), - "{\"distance\":%d,\"direction\":%d,\"rssi\":%d,\"heap\":%" PRIu32 ",\"uptime\":%" PRIu32 ",\"peers\":%u,\"healthy\":%u}", - snap.distance_cm, snap.direction, snap.rssi, + "{\"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 = { diff --git a/firmware/main/main.c b/firmware/main/main.c index 7721360..2f817d4 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -80,13 +80,18 @@ static void apply_pin_overrides(board_profile_t *runtime) { } } -/* Telemetry pump: 5 Hz publish mesh-fused target + RSSI + peer health to - * webui WS clients. Falls back to local motion_get() if mesh has no peers. */ +/* 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; @@ -94,15 +99,16 @@ static void telemetry_pump_task(void *arg) { webui_live_t live = { .distance_cm = f.distance_cm, + .raw_cm = local.raw_cm, .direction = f.direction, .rssi = netmgr_get_rssi(), - .free_heap = 0, /* webui fills these in itself before broadcast */ + .free_heap = 0, .uptime_s = 0, .peer_count = (uint8_t)pn, .peer_healthy = (uint8_t)hn, }; webui_publish_live(&live); - vTaskDelay(pdMS_TO_TICKS(200)); + vTaskDelay(pdMS_TO_TICKS(50)); /* 20 Hz β€” see comment above */ } } diff --git a/frontend/src/atoms.tsx b/frontend/src/atoms.tsx index bad79c7..f342853 100644 --- a/frontend/src/atoms.tsx +++ b/frontend/src/atoms.tsx @@ -218,3 +218,60 @@ 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/main.tsx b/frontend/src/main.tsx index 5ef76a5..c40b855 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,29 +1,33 @@ -/** App shell: sidebar + tab routing + global state. */ +/** 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: 'β—‰' }, - { id: 'leds', name: 'LEDs', icon: '✦' }, - { id: 'motion', name: 'Motion', icon: '∿' }, - { id: 'mesh', name: 'Mesh', icon: '⌬' }, - { id: 'hardware', name: 'Hardware', icon: 'βš™' }, - { id: 'network', name: 'Network', icon: '⌁' }, - { id: 'system', name: 'System', icon: '☼' }, + { 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, direction: 0, rssi: 0, heap: 0, uptime: 0, peers: 0, healthy: 0 }); + 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 = () => { @@ -32,54 +36,96 @@ function App() { }; useEffect(() => { reload(); }, []); - /* Poll /api/version every 30 s β€” uptime/free-heap don't need real-time; - * the live distance + RSSI + peer health come over WebSocket already. */ useEffect(() => { - const id = setInterval(() => { - if (document.hidden) return; - getJSON('/api/version').then(setVersion).catch(() => {}); - }, 30000); + const id = setInterval(() => { if (!document.hidden) getJSON('/api/version').then(setVersion).catch(() => {}); }, 30000); return () => clearInterval(id); }, []); - useEffect(() => liveSocket(setLive), []); + 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 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 ( -
- -
- -
+
+ + live Β· {dist} cm + + {live.rssi || 0} dBm +
+ + + + {/* Page content */} +
+ +
+ + {/* Mobile bottom-tabs */} + +
{t.toast && }
); diff --git a/frontend/src/screens.tsx b/frontend/src/screens.tsx index aa016b2..644c679 100644 --- a/frontend/src/screens.tsx +++ b/frontend/src/screens.tsx @@ -51,9 +51,15 @@ export function ScreenLive({ live, version, settings, setToast }: AppState) { 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); }, [dist]); + 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(() => {}); }, []); @@ -325,21 +331,22 @@ export function ScreenMotion({ settings, live, reload, setToast }: AppState) { catch (e: any) { setToast(e.message || 'Save failed', 'err'); } }; - /* Build raw + smoothed history from the live distance feed. */ + /* Both buffers come straight from the firmware now (raw_cm + distance_cm + * over WS at 20 Hz). No more client-side alpha simulation β€” the chart + * shows what the firmware *actually* feeds the LED engine. Removes + * the visible 200 ms client-side lag. */ 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.distance; + const r = (live as any).raw ?? live.distance; + const sm = live.distance; rawRef.current = [...rawRef.current.slice(1), r]; - const lastS = smoothRef.current[smoothRef.current.length - 1] || r; - const alpha = (s.pos_smooth_x1k ?? 200) / 1000; - const sm = lastS + (r - lastS) * alpha; smoothRef.current = [...smoothRef.current.slice(1), sm]; setRaw([...rawRef.current]); setSmooth([...smoothRef.current]); - }, [live.distance]); + }, [live]); const enabled = !!s.motion_enabled; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index eb816be..0e4f838 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -178,6 +178,38 @@ input, select, textarea { font: inherit; color: inherit; } .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 { From 0b63b6e4e75a97d155aadc31ccac7c9c63222e12 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 22:53:50 +0530 Subject: [PATCH 14/17] feat: robust pairing flow + motion v3 (Kalman) + debounced UI saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairing flow (PR-A): - mesh.c broadcasts MSG_PAIR every 1s while pairing window open - Asymmetric pairing: receiving MSG_PAIR auto-opens own window β€” clicking Pair on either device pulls the other in (no need to tap both) - mesh_identify(mac) unicasts MSG_IDENTIFY; recipient blinks status LED at 10 Hz for 5 s so users can physically locate which board is which - Coordinator election now has 5 s hysteresis β€” prevents brief flap when a single heartbeat is dropped - mesh_event_cb_t: peer-joined / pairing-opened/closed / identify-requested events surface to main.c which drives the status LED reactions - POST /api/mesh/identify {mac} endpoint - GET /api/mesh now returns pairing/pairing_ms_left/my_mac for live UI countdown and self-vs-peer card highlighting Status LED: - New STATUS_LED_PAIRING (5 Hz) and STATUS_LED_IDENTIFY (10 Hz) patterns - status_led_oneshot(pattern, ms) β€” temporary pattern with auto-revert to last stable pattern; identify becomes one line in main.c Button (new component): - Polling-based long-press detector (50 Hz, 40 ms debounce) - 3 s long-press β†’ mesh_open_pairing(); 10 s reserved for v6.1 factory reset Motion v3 (PR-B): - New motion_kalman.{c,h}: 1-D Kalman over [position, velocity], energy- aware observation noise (R *= 4 when energy < 30), Β±200 cm/s velocity clamp, 3-sample direction hysteresis - motion.c integrates: motion.mode NVS string ("kalman" default | "pi"), motion.resp 0..100 (Calm⇄Snappy), motion.la_ms 0..500, motion.outl 0/1/2 (Off / median-3 / median-7); legacy ps/vs/pf/pg/ig kept as advanced - motion_reload() called by /api/settings POST so changes apply live Frontend: - ScreenMesh: SVG circular pairing countdown, per-peer Identify button with "Blinking…" 5 s cooldown, self-card highlight - ScreenMotion: Algorithm picker (Kalman/Legacy PI), Response slider with context-aware hint text, Look-ahead-ms slider, Outlier segmented control, collapsible Advanced (5 PI knobs) - New atoms icons: link, plus, search Debounced saves (fixes ERR_CONNECTION_RESET): - useDebouncedSave hook: 300 ms tail; coalesces multiple slider changes into one POST /api/settings batch. Fixes the C3-single-core httpd saturation when sliders fired ~30 POST/s during drag (verified with 50Γ— rapid POST stress test β€” all 200 OK) Verified: idf.py build green; ambisense.bin 1.17 MB / 1.4 MB partition (18% free); both C3s flashed; /api/mesh, /api/settings round-trip works. --- firmware/components/button/CMakeLists.txt | 5 + firmware/components/button/button.c | 108 +++++++ firmware/components/button/include/button.h | 43 +++ firmware/components/mesh/include/mesh.h | 23 +- firmware/components/mesh/mesh.c | 136 +++++++- firmware/components/motion/CMakeLists.txt | 2 +- firmware/components/motion/include/motion.h | 4 + firmware/components/motion/motion.c | 225 +++++++++---- firmware/components/motion/motion_kalman.c | 110 +++++++ firmware/components/motion/motion_kalman.h | 80 +++++ .../status_led/include/status_led.h | 7 + firmware/components/status_led/status_led.c | 39 ++- firmware/components/webui/CMakeLists.txt | 2 +- firmware/components/webui/ui.html | 2 +- firmware/components/webui/ui.html.gz | Bin 25454 -> 27106 bytes firmware/components/webui/webui.c | 58 +++- firmware/main/CMakeLists.txt | 2 +- firmware/main/main.c | 56 ++++ frontend/src/atoms.tsx | 3 + frontend/src/screens.tsx | 304 ++++++++++++++---- 20 files changed, 1056 insertions(+), 153 deletions(-) create mode 100644 firmware/components/button/CMakeLists.txt create mode 100644 firmware/components/button/button.c create mode 100644 firmware/components/button/include/button.h create mode 100644 firmware/components/motion/motion_kalman.c create mode 100644 firmware/components/motion/motion_kalman.h 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/mesh/include/mesh.h b/firmware/components/mesh/include/mesh.h index 4c8574a..d837d2d 100644 --- a/firmware/components/mesh/include/mesh.h +++ b/firmware/components/mesh/include/mesh.h @@ -52,9 +52,30 @@ typedef struct { esp_err_t mesh_init(void); /* Open a 30 s pairing window during which new peers' broadcasts are - * accepted. */ + * 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); diff --git a/firmware/components/mesh/mesh.c b/firmware/components/mesh/mesh.c index 2d1fb6e..ac183b5 100644 --- a/firmware/components/mesh/mesh.c +++ b/firmware/components/mesh/mesh.c @@ -22,11 +22,13 @@ static const char *TAG = "mesh"; #define MSG_HEARTBEAT 2 #define MSG_GOSSIP 3 #define MSG_CHAN_ANN 4 -#define MSG_PAIR 5 +#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 PAIRING_WINDOW_MS 30000 +#define COORD_HYSTERESIS_US (5ULL * 1000ULL * 1000ULL) /* 5 s before role flip */ typedef struct __attribute__((packed)) { uint16_t magic; @@ -56,6 +58,16 @@ static struct { 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 ---- */ @@ -71,8 +83,17 @@ 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). */ + /* 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(); @@ -81,17 +102,25 @@ static void recompute_coordinator_locked(void) { 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 was = s_m.is_coordinator; - s_m.is_coordinator = (memcmp(best, s_m.my_mac, 6) == 0); - if (was != s_m.is_coordinator) { + 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 bool in_pairing_window(void) { - return (uint64_t)esp_timer_get_time() < s_m.pairing_until_us; -} - 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) { @@ -112,6 +141,11 @@ static void add_peer_locked(const uint8_t mac[6]) { 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 ---- */ @@ -122,6 +156,25 @@ static void on_recv(const esp_now_recv_info_t *info, const uint8_t *data, int le 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) { @@ -157,9 +210,13 @@ static void on_recv(const esp_now_recv_info_t *info, const uint8_t *data, int le 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 heartbeat ---- */ +/* ---- broadcast task: 5 Hz target broadcast, 1 Hz pair beacon ---- */ static void make_msg(peer_msg_t *out, uint8_t type) { target_t t; @@ -177,11 +234,30 @@ static void make_msg(peer_msg_t *out, uint8_t type) { 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(); @@ -245,10 +321,48 @@ esp_err_t mesh_init(void) { 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; diff --git a/firmware/components/motion/CMakeLists.txt b/firmware/components/motion/CMakeLists.txt index adeef2c..7c09376 100644 --- a/firmware/components/motion/CMakeLists.txt +++ b/firmware/components/motion/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "motion.c" + 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 index 411a578..5c25cd4 100644 --- a/firmware/components/motion/include/motion.h +++ b/firmware/components/motion/include/motion.h @@ -36,6 +36,10 @@ 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 index dc609ad..232c00f 100644 --- a/firmware/components/motion/motion.c +++ b/firmware/components/motion/motion.c @@ -1,4 +1,5 @@ #include "motion.h" +#include "motion_kalman.h" #include #include @@ -14,28 +15,39 @@ static const char *TAG = "motion"; -/* Defaults match v5 (config.h:69-76). NVS values stored as x1000 fixed-point. */ -#define MEDIAN_W 5 /* window size for the spike-rejection median filter */ +/* 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; - uint64_t last_us; - /* Spike-rejection median filter: keeps the last MEDIAN_W raw samples - * and uses the middle one (after sorting) as the "true" current value. - * Single-sample radar glitches get out-voted; rejection is total, - * unlike a low-pass which would let the spike bleed through. */ - int16_t med_buf[MEDIAN_W]; + /* 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; @@ -48,16 +60,93 @@ static float clamp(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); } -/* In-place median of 5 ints. Insertion-sort is fastest at this size. */ -static int16_t median5(int16_t *src) { - int16_t a[MEDIAN_W]; - for (int i = 0; i < MEDIAN_W; ++i) a[i] = src[i]; - for (int i = 1; i < MEDIAN_W; ++i) { +/* 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[MEDIAN_W / 2]; + 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) { @@ -65,11 +154,8 @@ static void motion_task(void *arg) { radar_frame_t f; while (1) { if (radar_read(&f, pdMS_TO_TICKS(1000)) != ESP_OK) { - target_t t = {0}; xSemaphoreTake(s_m.lock, portMAX_DELAY); - t = s_m.latest; - t.present = false; - s_m.latest = t; + s_m.latest.present = false; xSemaphoreGive(s_m.lock); continue; } @@ -78,54 +164,28 @@ static void motion_task(void *arg) { if (raw < s_m.min_cm) raw = s_m.min_cm; if (raw > s_m.max_cm) raw = s_m.max_cm; - /* Spike-rejection: median of last 5 raw samples. Until the buffer - * is filled, just pass-through (so first reading isn't blocked). */ + /* 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_W; - if (s_m.med_filled < MEDIAN_W) s_m.med_filled++; - int filtered_raw = (s_m.med_filled == MEDIAN_W) ? median5(s_m.med_buf) : 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; - t.raw_cm = (int16_t)filtered_raw; if (!s_m.enabled) { - t.distance_cm = (int16_t)filtered_raw; + 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.smoothed <= 0) { - s_m.smoothed = (float)filtered_raw; - s_m.predicted = (float)filtered_raw; - } - - /* Adaptive alpha: when the target is moving fast, ease the - * filter (snappier response); when nearly stationary, clamp - * harder (calmer reading). Magnitude is the |delta| from the - * last smoothed estimate, scaled by a 30 cm "fast motion" - * threshold. Output is in [pos_smooth .. min(1, pos_smooth*4)]. */ - float delta = fabsf((float)filtered_raw - s_m.smoothed); - float scale = clamp(delta / 30.0f, 0.0f, 1.0f); /* 0=still, 1=fast */ - float alpha_max = clamp(s_m.pos_smooth * 4.0f, 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 * (float)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; - s_m.predicted = s_m.smoothed + s_m.velocity * s_m.predict; - 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; + 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); @@ -134,16 +194,36 @@ static void motion_task(void *arg) { } } -esp_err_t motion_init(void) { - s_m.lock = xSemaphoreCreateMutex(); +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; - /* Load tunables. NVS values are stored Γ—1000 to keep them integer. */ uint8_t en = 1; settings_get_u8("motion", "en", &en); s_m.enabled = en != 0; - uint32_t v = 200; settings_get_u32("motion", "ps", &v); s_m.pos_smooth = v / 1000.f; + + /* 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 = 500; settings_get_u32("motion", "pf", &v); s_m.predict = 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; + 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); @@ -151,10 +231,19 @@ esp_err_t motion_init(void) { 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, "Smoother: en=%d ps=%.2f vs=%.2f pf=%.2f pg=%.2f ig=%.2f range=%d..%d cm", - s_m.enabled, s_m.pos_smooth, s_m.vel_smooth, s_m.predict, - s_m.p_gain, s_m.i_gain, s_m.min_cm, s_m.max_cm); + 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; } @@ -165,3 +254,11 @@ void motion_get(target_t *out) { *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/status_led/include/status_led.h b/firmware/components/status_led/include/status_led.h index ca1c443..36ab39d 100644 --- a/firmware/components/status_led/include/status_led.h +++ b/firmware/components/status_led/include/status_led.h @@ -33,6 +33,8 @@ typedef enum { 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 @@ -42,6 +44,11 @@ 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 index f7931ac..048f4bc 100644 --- a/firmware/components/status_led/status_led.c +++ b/firmware/components/status_led/status_led.c @@ -4,6 +4,7 @@ #include "driver/gpio.h" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -13,7 +14,13 @@ static struct { uint8_t gpio; bool active_low; bool inited; - _Atomic uint32_t pattern; /* status_led_pattern_t cast to uint32 for atomic ops */ + /* `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) { @@ -56,6 +63,18 @@ static void run_pattern(status_led_pattern_t p) { 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)); @@ -90,7 +109,15 @@ static void run_pattern(status_led_pattern_t p) { static void status_led_task(void *arg) { (void)arg; while (1) { - status_led_pattern_t p = (status_led_pattern_t)atomic_load(&s_led.pattern); + 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); } } @@ -101,6 +128,8 @@ esp_err_t status_led_init(uint8_t gpio_num, bool active_low) { 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, @@ -130,3 +159,9 @@ esp_err_t status_led_init(uint8_t gpio_num, bool active_low) { 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/webui/CMakeLists.txt b/firmware/components/webui/CMakeLists.txt index deba88f..b79e79e 100644 --- a/firmware/components/webui/CMakeLists.txt +++ b/firmware/components/webui/CMakeLists.txt @@ -1,7 +1,7 @@ idf_component_register( SRCS "webui.c" INCLUDE_DIRS "include" - REQUIRES settings auth netmgr ota board led_engine radar + 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 diff --git a/firmware/components/webui/ui.html b/firmware/components/webui/ui.html index da2dc08..333c711 100644 --- a/firmware/components/webui/ui.html +++ b/firmware/components/webui/ui.html @@ -5,7 +5,7 @@ AmbiSense v6 - + diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz index c33ced210de6b65d3d8b77e3c84592fc290c03e4..864b0d95f0020b3a7e69e5610ab6f4af3a5ea6d2 100644 GIT binary patch literal 27106 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⁡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 literal 25454 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 z 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, "%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); } @@ -1066,6 +1117,7 @@ static const httpd_uri_t k_routes[] = { { "/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 }, diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 9de9389..053750a 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -2,6 +2,6 @@ idf_component_register( SRCS "main.c" INCLUDE_DIRS "." REQUIRES board settings status_led netmgr auth webui ota - radar motion led_engine topology mesh + radar motion led_engine topology mesh button app_update esp_system log ) diff --git a/firmware/main/main.c b/firmware/main/main.c index 2f817d4..39e69fb 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -32,6 +32,7 @@ #include "led_engine.h" #include "topology.h" #include "mesh.h" +#include "button.h" static const char *TAG = "ambisense"; @@ -80,6 +81,53 @@ static void apply_pin_overrides(board_profile_t *runtime) { } } +/* 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 @@ -170,6 +218,14 @@ void app_main(void) { * 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); diff --git a/frontend/src/atoms.tsx b/frontend/src/atoms.tsx index f342853..45861c8 100644 --- a/frontend/src/atoms.tsx +++ b/frontend/src/atoms.tsx @@ -36,6 +36,9 @@ export const Icon = ({ name, size = 16, stroke = 1.6, style }: { palette: <>, play: <>, pause: <>, + plus: <>, + link: <>, + search: <>, }; return {paths[name] || null}; }; diff --git a/frontend/src/screens.tsx b/frontend/src/screens.tsx index 644c679..ee077ba 100644 --- a/frontend/src/screens.tsx +++ b/frontend/src/screens.tsx @@ -43,6 +43,31 @@ function PageHead({ title, sub, right }: any) { ); } +/* 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 */ /* ================================================================= */ @@ -193,11 +218,12 @@ function DevField({ k, v }: any) { export function ScreenLeds({ settings, live, reload, setToast }: AppState) { const [s, setS] = useState(settings); useEffect(() => setS(settings), [JSON.stringify(settings)]); - const save = async (patch: any) => { - const next = { ...s, ...patch }; - setS(next); - try { await postJSON('/api/settings', patch); reload(); } - catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + 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); @@ -324,17 +350,17 @@ export function ScreenLeds({ settings, live, reload, setToast }: AppState) { /* ================================================================= */ 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 save = async (patch: any) => { - setS({ ...s, ...patch }); - try { await postJSON('/api/settings', patch); reload(); } - catch (e: any) { setToast(e.message || 'Save failed', 'err'); } + const debouncedSave = useDebouncedSave(reload, setToast); + const save = (patch: any) => { + setS((prev: any) => ({ ...prev, ...patch })); + debouncedSave(patch); }; - /* Both buffers come straight from the firmware now (raw_cm + distance_cm - * over WS at 20 Hz). No more client-side alpha simulation β€” the chart - * shows what the firmware *actually* feeds the LED engine. Removes - * the visible 200 ms client-side lag. */ + /* 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); @@ -349,16 +375,36 @@ export function ScreenMotion({ settings, live, reload, setToast }: AppState) { }, [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
-
Filters jitter and predicts velocity
+
{enabled ? 'Filters jitter and predicts velocity' : 'Disabled β€” strip follows raw radar'}
save({ motion_enabled: v ? 1 : 0 })}/>
@@ -378,27 +424,99 @@ export function ScreenMotion({ settings, live, reload, setToast }: AppState) {
-
-
-
Filter
-
- 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"/> + {/* 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 ( + + ); + })}
-
-
PI gains
+
+ + {/* 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"/> + save({ i_gain_x1k: v })} min={0} max={200} suffix="Γ— 1/1000"/>
- Higher P responds faster but overshoots. Higher I corrects steady-state offset over time. + 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.
-
+ )} ); } @@ -408,28 +526,43 @@ export function ScreenMotion({ settings, live, reload, setToast }: AppState) { /* ================================================================= */ 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 }); - const [pairing, setPairing] = useState(false); - const [pairTime, setPairTime] = useState(0); + 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(() => {}); - useEffect(() => { refresh(); const id = setInterval(refresh, 4000); return () => clearInterval(id); }, []); + /* 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(() => { - 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]); + 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 }); setPairing(true); setToast('Pairing window open Β· 30 s'); } + 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'); } @@ -453,29 +586,52 @@ export function ScreenMesh({ live, settings, setToast, reload }: AppState) { { 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: settings.mac || 'β€”', name: settings.device_name || 'this device', role: mesh.coordinator ? 'master' : 'slave', rssi: -42, lost: 0.0, online: true, self: true }, - ...(mesh.peers || []).map((p: any) => ({ ...p, role: 'slave', name: p.mac, lost: 0.0, online: p.healthy })), + { 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 ( <> - - {pairing ? <> Listening Β· {pairTime}s : <> Pair new device} + + {mesh.pairing ? <> Listening Β· {pairSecsLeft}s : <> Pair new device} }/> - {pairing && ( -
-
-
- -
-
-
Pairing window open Β· {pairTime}s
-
Press the button on the new device until its status LED blinks twice
+ {/* 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. +
-
)} @@ -499,27 +655,39 @@ export function ScreenMesh({ live, settings, setToast, reload }: AppState) {
-
Devices
+
+ Devices + Click Identify to make a device blink β€” useful while wiring topology +
- {allDevices.map((d: any) => ( -
- -
-
- {d.name} - {d.role} + {allDevices.map((d: any) => { + const isIdentifying = identifying === d.mac; + return ( +
+ +
+
+ {d.name} + {d.role} + {d.self && this device} +
+
{d.mac}
-
{d.mac}
-
-
-
{d.rssi} dBm
-
5 ? 'var(--err)' : 'var(--text-3)'};`}>{(d.lost || 0).toFixed(1)}% lost
+
+
{d.rssi || 'β€”'} dBm
+
+ {!d.self && ( + + )}
-
- ))} + ); + })} {allDevices.length <= 1 && (
- No peers paired yet. Click "Pair new device" above. + No peers paired yet. Click Pair new device above to start a 30-second pairing window.
)}
From aab9e3e4f38765c30bb0ab04b076d34f4f8e1f5c Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 22:59:18 +0530 Subject: [PATCH 15/17] fix: mobile layout + board MCU-mismatch boot guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile (ERR was: sidebar + bottom-tabs both rendered, scrambling layout): - Added the missing .hide-mobile / .show-mobile rules β€” they were referenced inline by main.tsx but never defined in styles.css, so on phones the desktop sidebar AND mobile bottom-nav both rendered at once, overlapping every card and pushing the page off-screen - Trimmed paddings, reduced page-head gap, and force-collapse all inline auto-fit grids (220/280 px min-cols) to 2-column at ≀760 px and 1-column at ≀480 px β€” Hardware/Mesh/Motion screens now lay out cleanly on a 360 px viewport Boot guard (ERR was: 2nd C3 not starting AP after reflash): - resolve_board_profile now rejects an NVS board.id whose profile->mcu doesn't match CONFIG_IDF_TARGET (e.g., "esp32-devkit" pinmap loaded on a C3). Wrong pinmap drives USB-JTAG / flash pins as outputs and bricks boot before netmgr starts, looking like "device dead". Falls back to the compile-time default profile in that case. --- firmware/components/webui/ui.html | 2 +- firmware/components/webui/ui.html.gz | Bin 27106 -> 27234 bytes firmware/main/main.c | 18 +++++++++++--- frontend/src/styles.css | 36 ++++++++++++++++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/firmware/components/webui/ui.html b/firmware/components/webui/ui.html index 333c711..85c1acb 100644 --- a/firmware/components/webui/ui.html +++ b/firmware/components/webui/ui.html @@ -6,7 +6,7 @@ AmbiSense v6 - +
diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz index 864b0d95f0020b3a7e69e5610ab6f4af3a5ea6d2..4c69bf2fd09ed1b61571da2fab7244ec4947fd57 100644 GIT binary patch delta 2681 zcmV-<3WoLK(*fet0R|t72ne4ju?7iXf696rM~?7&eg)&fKz1V0Bt^=!WE+cp&kqQK zeV`=D7Ocb2ITJ$vJyq2Qs}EANCyT|-&QN3@)z#H?d|hnnzyXsy-+xZfbLXq!^fu8x z`p+@C=$DWH!OG}_yUx;c#vaHk)^kMh15zoQ7{EGwm!ICo_(*k2*QUQqcWe$Re~WfE zF{XgFyG>fS6h3Gv{1z9{BFeeQ4r}7j$EkQlL?GPd8k;ho)eQjKDr+@3GBmuK$ixGR z(ug+@?BNHuKF?r{W4gREhv?D)l1Bf|%NckRBoMUF7$|6Cf;A+lNgltUtawE^x$`d| zSu-?O9X~eCr*TQmlgX10l$ESoeEblMD;WH#cGk^@tZ|ictEP`8xay*QHV|P`^a4)0If8UpsvIG{+ z-!5{3mqE?zSGJ&LYqhHNo4`eu8{B9|H3p87)^mhBSbB1|JvYpoghiB)C{V=E)XYi| zyZoA4K_L*GFd)RIWQEyQnxR>x51BepFa?`L5?VvV<5Vh*AkR$^C+k!TIJTbGG9aZD zg|tyB<41g0gW`3k>RtUyf2dQq>W7J^TcV;+w%X_Q5jUYI#BpNMndj&_jshl7EL?6V{-V8X zYGSZ`cPDfEZg?0GnhJz~gKsIQu{=sfqAmg-Zf(5VRDZFUW@(X>qJFva_)3&C+Eg1M z6;*q?Sh%%CL=5bie;ziKHR?7$GPm8xw3`bqT(nlXZ;Y6k1Xv>0;&t*=WpPJ~T@Xh3 zesX?;!I8j~U^Ru;1k7l0O;f!#9a1GH$Kv4Is+MrNLOt>CTNt%0rP5(5?OtXoEZuA| zhXCw*Ij6GfO_r)9sL?^LCt9i9Jfk@|N^ubUcuKy=rrhsue`EYAhbT(hX}n*y(csP` zd&sJ{aC7M8rd+gj@~{A$$1`c$xH@T0<$no&E#{(gFPVna} zJ8NOe(To?{f7x-b#w31K9(mv=F#3@;*Cg7NU-|tVa|NOCDO^$~}-hYB$0sPwRpL>|FIljL?pU>fW7;bm3_YeX`SiTp~ z%suzwq$dv@71yU_dH#L-e?6<`9unEZ7u2`!<#X?QG8DL` zx;0SEhMB&Fb2BSGo_jO~<2D}uukX*Fd;b~+*=iWTe}iG~{^bcteYxS>9tal*m?f|H zGaWY(_r&R1!d_fLnzu?pls4O$980U5Ub7sgvF0);I~$KxZ~N7fVyd^}dU}lnJL)$( z%YZryPWVy4w4YUXf$HfBZt(5C}b5jG7#T1%99?FS?8zzL1;f3UA$~fv3F@G{NTNTs+$b;}h=AWX7-P(tWB05c@V z9Ac1Y_>LkD9E}V{LD;A&P-N%vHmw@uy#DrH{#@)Zp(2n(RMB@GLPU+s?2j<;IBa#% zxS8)4-pj{v1;Yak2L1GT{Y%C=4%5u2f3@1aZU_@4VN5|bzc#-|z{BcU4brR>fdIu|;`jE2(c|0U$>@p=Xu{5yB%i0QyOW2pA7oJTG& zcMY`Dp_qyxecfY?Q`2Lm?Ep-ZABe4wUvSl$a4a)={S@wG!@nIygZs%-}6o@=OZ_uJ)Ez>T!eD@g>O_!L@y`K8!1P$ZYv}bY#qs9O26` z60qxHRg*AV?q+xr)*%1~5C88wOE55QVlwp|i8f<$08!3epQ#Gf{{mD_8wYHuMaiPZ zrE~?OjII|CWfVMSfAzo)Z+K!7sExP68X)O%M7#am_6!eLW{w7a2Z@&zaMu|-{HCSr zmQE#0d1++x$XgojA|GwO2D!I1+@;CqrQx?Z{qrNtHGaPDx)gTeO9s^7goqS&w?8Nc zf#s^)cN4jxBXUBztZs9Vt>#8XZF1YanTOnNhTbsnwqgjnXV7krXQ%d(Jprbj%C3i(>*1a;9DtA_ZVT)6{R4F n7ggrVQ?Q&ri}#scp6=ndC@jiArNesl^0)s1ud`O*i*o@0x~VtB delta 2552 zcmV>8UipEm;l+G>j%92!dR$FlH%yfnfMID7cP ztpnM-ux07|2OXVny}2_gt$G!_)JV}eacP=h>LAgy>wIl1>Ukn98b50-rk4+dXVn$qK8`-p~wVX?|Nwwxx_`v|tlp_6n zUKRs&car_cx_4kRfAwNhGHp}WXqo>=*m34gZY!|m?v z0j$#q+xG(6xtCs?^yGPn;`+3#&i~u~eCeS8j}!Q>_xk)%9u|10$m*qsK=$z6+3kDv z()*F5BIKeTe+|^IVXhzH-b~AnmmaObc#J2L>*uqV-d|~uuhIbi4AS1i>obD-`i*0I zAimWA$dXt5H*GW$_Qb(ILSI}$+P7LoPi(X^I+j{FyQVozWzA_&PBt2;+4id?8I+6T zdU6c}+aWP0%K<>Y{){wdr50y#vbAC%8qoFjp8;J?D_6L>&{2wbz5X(!(ea!hM|g&-}*nFr&XxiDqdinEixe4~*x%8Pi8x+NlzVV$A>^a+QKL*NMTOD_I zIPkqrOm#HTi8o?DFXN~XIe8nm1-cVUf3FNBd+A^z6^zy<`z20*%^QNZlX6!=gOty_ z=h0AtLsh8NOh;Uak!mt9phh|DaK&|w!0)euQrMizeeGJhRAXkzE6aa#>H>-g>jIct zhou3WazsHur--y1OI4vdSXYPYYSxRvsZms1G4$9Ro8y{!!&&ADl6UHwmcY3lfB(2y z9%?JW+u?4j6i;Wu80e_fQO4n|_AfceGDtlV?>lk&6rp8x98}E`kX>0dW{MI$f$doc z4J2?dm@MDe@`srx-|M0f!>k>XaseY9zUgZDF+yDG9|^Iz6xX;NdpjB&&UWx$7=$pw z?m)6hq%;X52!zN0fX-v*P+=CRe-Qy0IyOGh@DvHrfbWaaF$e8Ql;4;1ig$_TJf5#a zOX;-#60-GpKLZW^w1v51I!nt~D$9vcI@?1RSXKkl=}=C@lD=-S#+hlcQaS)Dcn2c& z@h?oZwm6pQy?!e0WQTum%*qdqMSVJ)se{AK5Z6P*x-EH>H^;BBtSnHvKS!2&a6C_t+$>?ZOP&yR9WgW)#^)n4ndkYPl~2} z#`=<~Q&8;6D3IGb zMrqa!R>zkpX9(B!ncuLk;4!!9=gFZlgK~r|$3VcT%T)uybh(@2Aw7ox3_SeXcUUkm z9%7y6J&`s;asXb=eV>U6HUAt$P8SAjszu78!6kJC!rf91`>4huZY1;oap zv=&IR9HZTSYZ( Ozx^M%cl&21Z~*}R65qW5 diff --git a/firmware/main/main.c b/firmware/main/main.c index 39e69fb..79e17c2 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -47,10 +47,22 @@ static const board_profile_t *resolve_board_profile(void) { if (err == ESP_OK) { const board_profile_t *p = board_profile_by_id(saved_id); if (p) { - ESP_LOGI(TAG, "Board profile from NVS: %s (%s)", p->id, p->display); - return 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); } - 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); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0e4f838..64c28b2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -80,7 +80,15 @@ input, select, textarea { font: inherit; color: inherit; } .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; @@ -90,8 +98,34 @@ input, select, textarea { font: inherit; color: inherit; } } .brand { display: none; } .navlink { flex-direction: column; gap: 3px; padding: 6px 8px; font-size: 10px; } - .main { padding: 16px 14px 88px; } + .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 */ From 563ee298de49c129ada3195c398bbf1acf3a469b Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 23:08:00 +0530 Subject: [PATCH 16/17] docs: v6.0 board recommendation tier + known-issues recovery steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: v6.0.0 shipped β€” describe the actual delivered features instead of the "in progress" status. Highlight ESP32-S3 as recommended board. HARDWARE.md additions: - Board recommendation tier table at the top (S3 recommended, C3 supported, classic deprecated, C6 avoid). Plain reasoning so users picking a board for a new install pick S3. - "Second device not visible" recovery β€” erase-flash + reflash for stale NVS with mismatched MCU pinmap. (v6.0 has the boot guard but alpha-flashed devices need the manual recovery once.) - "Slider throws ERR_CONNECTION_RESET" entry β€” explains the C3 single-core httpd saturation and the 300 ms client debounce fix shipped in 0b63b6e. - Reordered the supported-boards table by recommendation, not by validation status. --- README.md | 41 ++++++++++++++++------------- docs/HARDWARE.md | 68 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index f28c9bc..03bc8c0 100644 --- a/README.md +++ b/README.md @@ -3,29 +3,34 @@ AmbiSense Logo

-> ### πŸ”§ v6 is a ground-up rewrite β€” in progress +> ### πŸš€ v6.0.0 shipped β€” full ESP-IDF + FreeRTOS rewrite > -> We're moving AmbiSense off Arduino onto **ESP-IDF + FreeRTOS** so the firmware -> can drive radar reads, LED rendering, web serving, and ESP-NOW peer-mesh as -> independent tasks instead of one cooperative `loop()`. v6 also adds -> **LD2450 multi-target tracking**, a **modular radar driver layer** (LD2410 / -> LD2412 / LD2420 / LD2450 / sim β€” switch via web UI without reflashing), a -> **board picker with editable pin map** for ESP32-C3 / ESP32 / S3 / C6, a -> **peer mesh** for U/L/asymmetric stairs (no more master/slave), **OTA -> updates**, **captive portal**, and a **brand-new web UI** designed in -> Claude Design. +> 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 | Purpose | > |---|---| -> | `main` | v5.1.1 Arduino release β€” stable, do not break | -> | `v6-idf-rewrite` | active rewrite (this PR series) | -> | `legacy/v5-arduino` | frozen archive of the v5.x line | +> | `main` | v5.1.1 Arduino release β€” stable | +> | `v6-idf-rewrite` | v6 development branch (current) | +> | `legacy/v5-arduino` | frozen archive of the v5.x Arduino line | +> | tag `v6.0.0` | v6 release | > | tag `v5.1.1` | last Arduino-era release | -> -> Current status: **PR #1 of 5 β€” IDF skeleton merged.** Wi-Fi + web (#2), -> radar + LED engine (#3), peer mesh + topology (#4), and the new UI (#5) -> follow. Each PR is independently flashable; releases will be tagged -> `v6.0.0-alpha.N` until P5 ships. ## v6 quickstart (ESP-IDF, requires `v5.3` LTS) diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md index 9f9425f..cfc24e7 100644 --- a/docs/HARDWARE.md +++ b/docs/HARDWARE.md @@ -4,6 +4,17 @@ 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. @@ -52,12 +63,17 @@ for v6, the firmware reads everything via UART. ## Supported boards (v6.0) -| Profile | Status | LED pin | Radar RX | Radar TX | Button | Status LED | -| --------------------- | -------- | ------- | -------- | -------- | ------ | ------------- | -| `esp32c3-supermini` | βœ… valid | 10 | 20 | 21 | 4 | 8 (active-low)| -| `esp32-devkit` | builds | 5 | 16 | 17 | 4 | 2 | -| `esp32s3-zero` | builds | 21 | 4 | 5 | 9 | 21 | -| `esp32c6-devkit` | builds | 8 | 4 | 5 | 9 | 15 | +| 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). @@ -147,6 +163,46 @@ 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). From a4376c4dff0919d47f8b6cb984ce97348155f0c9 Mon Sep 17 00:00:00 2001 From: Ravi Singh Date: Tue, 5 May 2026 23:14:09 +0530 Subject: [PATCH 17/17] docs: README + roadmap reflect v6.0.0 release and post-merge state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: - Branch table now shows main = v6.0.0 (post-merge state) with v5 archived on legacy/v5-arduino. Includes a one-liner for users pulling onto an old local main. - Quickstart split into "easiest path" (flash prebuilt binary from the v6.0.0 release page) and "build from source" β€” the previous copy still described the PR #1 skeleton ("waiting for Wi-Fi setup in PR #2") which is wrong now that 6.0 is live. - IDF version bumped from v5.3 LTS to v5.5.2 to match what was actually used to build 6.0.0. - Reference wiring + first-boot AP setup added so a fresh user can go from clone to running without bouncing through HARDWARE.md. - "v5 (Arduino) docs" disclaimer rewritten β€” v5 is frozen, not "until v6 takes over". Points at legacy/v5-arduino branch. V6-ROADMAP.md: - Top banner: v6.0.0 RELEASED, links to release page. - New "v6.x roadmap (post-6.0.0)" section at the bottom captures encrypted ESP-NOW, signed OTA, persistent sessions, auto-topology learning, S3 dual-core pinning, sim-driver replay, and the few smaller follow-ups documented in the v6.0 release notes. --- README.md | 71 +++++++++++++++++++++++++++++++++------------- docs/V6-ROADMAP.md | 48 +++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 03bc8c0..1f7e15d 100644 --- a/README.md +++ b/README.md @@ -24,34 +24,60 @@ > [docs/HARDWARE.md](docs/HARDWARE.md) for the full board recommendation > table and known issues. > -> | Branch | Purpose | +> | Branch / tag | Purpose | > |---|---| -> | `main` | v5.1.1 Arduino release β€” stable | -> | `v6-idf-rewrite` | v6 development branch (current) | -> | `legacy/v5-arduino` | frozen archive of the v5.x Arduino line | -> | tag `v6.0.0` | v6 release | +> | `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 | -## v6 quickstart (ESP-IDF, requires `v5.3` LTS) +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 -# One-time git clone https://github.com/Techposts/AmbiSense.git cd AmbiSense/firmware -. $IDF_PATH/export.sh +. ~/esp/esp-idf-v5.5.2/export.sh -# Build & flash for your board -idf.py set-target esp32c3 # or: esp32, esp32s3, esp32c6 +idf.py set-target esp32c3 # or: esp32, esp32s3, esp32c6 idf.py build flash monitor ``` -The skeleton boots, initialises NVS, resolves the board profile, and drives -the onboard status LED into AP-mode blink (waiting-for-Wi-Fi). Wi-Fi setup -lands in PR #2. +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/`. -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`. +**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 @@ -72,10 +98,17 @@ Assets/, STL Files/ β€” design assets, enclosures (unchanged) --- -## v5 (Arduino) docs +## 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. -The legacy v5 documentation below applies to `main` until v6 takes over. -For new development, target the `v6-idf-rewrite` branch. +For new installs we strongly recommend going to v6.0.0 β€” see the +quickstart above. --- diff --git a/docs/V6-ROADMAP.md b/docs/V6-ROADMAP.md index 1e8fd2b..48f7276 100644 --- a/docs/V6-ROADMAP.md +++ b/docs/V6-ROADMAP.md @@ -1,5 +1,13 @@ # 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. @@ -185,3 +193,43 @@ Move v5 README content fully behind a "v5 legacy" link. | `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.